Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions ENV_VARS.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,25 @@
| PAYOUT_ANOMALY_BASELINE_SAMPLE_SIZE | Payouts kept per merchant for the rolling baseline | 50 | No |
| PAYOUT_BATCH_MAX_SIZE | Maximum payouts sent in one provider call | 100 | No |
| PAYOUT_PROVIDER_FEE_PER_CALL | Flat provider fee per call, used for the fees-saved estimate in batch reports | 0 | No |
| WOOCOMMERCE_STORE_URL | WooCommerce store base URL (e.g. `https://shop.example.com`) | - | No |
| WOOCOMMERCE_CONSUMER_KEY | WooCommerce REST API consumer key | - | No |
| WOOCOMMERCE_CONSUMER_SECRET | WooCommerce REST API consumer secret | - | No |
| WOOCOMMERCE_WEBHOOK_SECRET | Secret used to verify `X-WC-Webhook-Signature` | - | No |
| SALESFORCE_INSTANCE_URL | Salesforce instance URL (e.g. `https://acme.my.salesforce.com`) | - | No |
| SALESFORCE_ACCESS_TOKEN | Salesforce OAuth access token | - | No |
| SALESFORCE_API_VERSION | Salesforce REST API version | 59.0 | No |
| SALESFORCE_INVOICE_STATUS | Salesforce Billing invoice status to write | Draft | No |
| SALESFORCE_ACCOUNT_EMAIL_FIELD | Optional Account field to store the customer email | - | No |
| XERO_ACCESS_TOKEN | Xero OAuth access token | - | No |
| XERO_TENANT_ID | Xero tenant id the tokens are scoped to | - | No |
| XERO_API_URL | Xero Accounting API base URL | https://api.xero.com/api.xro/2.0 | No |
| XERO_DEFAULT_ACCOUNT_CODE | Default account code for invoice lines | - | No |
| XERO_PAYMENT_ACCOUNT_CODE | Account code bank payments are posted to (required to record payments) | - | No |
| XERO_INVOICE_STATUS | Xero invoice status to write | AUTHORISED | No |
| QUICKBOOKS_ACCESS_TOKEN | QuickBooks Online OAuth access token | - | No |
| QUICKBOOKS_REALM_ID | QuickBooks company (realm) id | - | No |
| QUICKBOOKS_API_URL | QuickBooks Accounting API base URL | https://quickbooks.api.intuit.com/v3/company/:realmId | No |
| QUICKBOOKS_DEFAULT_ITEM_ID | Default QuickBooks item used on invoice lines | - | No |

## Frontend

Expand Down
12 changes: 12 additions & 0 deletions backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,10 @@ import { cohortAnalyticsRouter } from './routes/cohort-analytics.js';
import { churnPredictionRouter } from './routes/churn-prediction.js';
import { slackRouter } from './routes/slack.js';
import { githubIntegrationRouter } from './routes/github-integration.js';
import { wooCommerceRouter } from './routes/woocommerce.js';
import { salesforceRouter } from './routes/salesforce.js';
import { xeroRouter } from './routes/xero.js';
import { quickBooksRouter } from './routes/quickbooks.js';
import { pluginsRouter } from './routes/plugins.js';
import { devWebhookRouter } from './routes/dev/webhook.js';
import { funnelTrackingRouter } from './routes/funnel-tracking.js';
Expand Down Expand Up @@ -244,6 +248,10 @@ app.use('/webhooks', webhookHandlersRouter);
// Mounted before the global JSON parser so `req.rawBody` is populated.
app.use('/api/v1/fiat-payments/webhooks', webhookJsonParser);

// WooCommerce webhooks are signed over the raw body (#954). Mounted before the
// global JSON parser so `req.rawBody` is populated for HMAC verification.
app.use('/api/v1/integrations/woocommerce/webhooks', webhookJsonParser);

// KYC document uploads carry base64 file contents (up to 10 MB decoded) (#921)
app.use('/api/v1/kyc', express.json({ limit: '15mb' }));
app.use(express.json());
Expand Down Expand Up @@ -351,6 +359,10 @@ apiV1Router.use('/analytics/cohorts', cohortAnalyticsRouter);
apiV1Router.use('/analytics/churn', churnPredictionRouter);
apiV1Router.use('/integrations/slack', slackRouter);
apiV1Router.use('/integrations/github', githubIntegrationRouter);
apiV1Router.use('/integrations/woocommerce', wooCommerceRouter);
apiV1Router.use('/integrations/salesforce', salesforceRouter);
apiV1Router.use('/integrations/xero', xeroRouter);
apiV1Router.use('/integrations/quickbooks', quickBooksRouter);
apiV1Router.use('/plugins', pluginsRouter);
apiV1Router.use('/funnels', funnelTrackingRouter);
apiV1Router.use('/forecast/ml', mlForecastRouter);
Expand Down
79 changes: 79 additions & 0 deletions backend/src/integrations/accounting/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { z } from 'zod';

/**
* Provider-agnostic accounting primitives shared by the Salesforce Billing,
* Xero and QuickBooks integrations. Keeping the shapes identical lets one
* caller sync the same invoice to any accounting provider.
*/

export const accountingCustomerSchema = z.object({
externalId: z.string().min(1),
name: z.string().min(1),
email: z.string().email().optional(),
});

export type AccountingCustomer = z.infer<typeof accountingCustomerSchema>;

export const accountingLineSchema = z.object({
description: z.string().min(1),
quantity: z.number().positive(),
unitAmount: z.number().nonnegative(),
taxType: z.string().optional(),
accountCode: z.string().optional(),
});

export type AccountingLine = z.infer<typeof accountingLineSchema>;

export const accountingInvoiceSchema = z.object({
externalId: z.string().min(1),
reference: z.string().min(1).optional(),
customer: accountingCustomerSchema,
currency: z.string().length(3).default('USD'),
invoiceDate: z.string().optional(),
dueDate: z.string().optional(),
lines: z.array(accountingLineSchema).min(1),
});

export type AccountingInvoice = z.infer<typeof accountingInvoiceSchema>;

export const accountingPaymentSchema = z.object({
externalId: z.string().min(1),
invoiceExternalId: z.string().min(1),
amount: z.number().positive(),
currency: z.string().length(3).default('USD'),
paidAt: z.string().optional(),
reference: z.string().optional(),
});

export type AccountingPayment = z.infer<typeof accountingPaymentSchema>;

export type SyncAction = 'created' | 'updated' | 'skipped';

export interface InvoiceSyncResult {
externalId: string;
providerId: string;
action: SyncAction;
customerId: string;
}

export interface PaymentSyncResult {
externalId: string;
providerId: string;
action: SyncAction;
}

export function invoiceTotal(invoice: AccountingInvoice): number {
return invoice.lines.reduce(
(total, line) => total + line.quantity * line.unitAmount,
0,
);
}

export function toDateString(value: string | undefined, fallback = new Date()): string {
if (!value) return fallback.toISOString().slice(0, 10);
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) {
throw new Error(`Invalid date: ${value}`);
}
return parsed.toISOString().slice(0, 10);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { QuickBooksClient } from '../quickbooks-client.js';

describe('QuickBooksClient', () => {
const originalFetch = global.fetch;
let client: QuickBooksClient;

beforeEach(() => {
client = new QuickBooksClient({
accessToken: 'qb-token',
realmId: 'realm-1',
timeoutMs: 1000,
});
});

afterEach(() => {
global.fetch = originalFetch;
vi.restoreAllMocks();
});

it('runs a query scoped to the company', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
statusText: 'OK',
json: async () => ({
QueryResponse: { Invoice: [{ Id: '1', DocNumber: 'INV-1' }] },
}),
} as unknown as Response);

const invoices = await client.query<{ Id: string }>(
'SELECT * FROM Invoice',
);

expect(invoices).toEqual([{ Id: '1', DocNumber: 'INV-1' }]);
const [url, options] = (global.fetch as any).mock.calls[0];
expect(url).toContain(
'https://quickbooks.api.intuit.com/v3/company/realm-1/query?query=SELECT%20*%20FROM%20Invoice&minorversion=65',
);
expect(options.headers.Authorization).toBe('Bearer qb-token');
});

it('returns an empty array for an empty query response', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
statusText: 'OK',
json: async () => ({ QueryResponse: {} }),
} as unknown as Response);

await expect(client.query('SELECT * FROM Customer')).resolves.toEqual([]);
});

it('creates an entity with a request id', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
status: 201,
statusText: 'Created',
json: async () => ({ Invoice: { Id: '99' } }),
} as unknown as Response);

const created = await client.create<{ Id: string }>(
'Invoice',
{ DocNumber: 'INV-1' },
'req-1',
);

expect(created.Id).toBe('99');
const [url, options] = (global.fetch as any).mock.calls[0];
expect(url).toContain('/Invoice?requestid=req-1&minorversion=65');
expect(options.method).toBe('POST');
});

it('throws on API errors', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 401,
statusText: 'Unauthorized',
text: async () => 'AuthenticationFailed',
} as unknown as Response);

await expect(client.query('SELECT * FROM Invoice')).rejects.toThrow(
/QuickBooks API error 401/,
);
});

it('escapes query string literals', () => {
expect(QuickBooksClient.escapeQuery("O'Brien\\Co")).toBe(
"O\\'Brien\\\\Co",
);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { QuickBooksService } from '../quickbooks-service.js';
import type { QuickBooksClient } from '../quickbooks-client.js';

const invoice = {
externalId: 'inv_123',
reference: 'INV-123',
customer: {
externalId: 'cus_123',
name: 'Acme Corp',
email: 'billing@acme.test',
},
currency: 'USD',
invoiceDate: '2026-10-01',
dueDate: '2026-11-01',
lines: [{ description: 'Platform fee', quantity: 1, unitAmount: 100 }],
};

describe('QuickBooksService', () => {
let client: QuickBooksClient;
let service: QuickBooksService;

beforeEach(() => {
client = {
query: vi.fn(),
create: vi.fn(),
getInvoice: vi.fn(),
} as unknown as QuickBooksClient;

service = new QuickBooksService(client, { defaultItemId: 'item-1' });
});

it('creates the customer and invoice on first sync', async () => {
(client.query as any)
.mockResolvedValueOnce([])
.mockResolvedValueOnce([])
.mockResolvedValueOnce([]);
(client.create as any)
.mockResolvedValueOnce({ Id: 'cust_1', DisplayName: 'Acme Corp' })
.mockResolvedValueOnce({ Id: 'inv_1', DocNumber: 'INV-123' });

const result = await service.syncInvoice(invoice);

expect(result).toEqual({
externalId: 'inv_123',
providerId: 'inv_1',
action: 'created',
customerId: 'cust_1',
});
expect(client.create).toHaveBeenNthCalledWith(1, 'Customer', {
DisplayName: 'Acme Corp',
PrimaryEmailAddr: { Address: 'billing@acme.test' },
});
expect(client.create).toHaveBeenNthCalledWith(
2,
'Invoice',
expect.objectContaining({
CustomerRef: { value: 'cust_1' },
DocNumber: 'INV-123',
Line: [
expect.objectContaining({
Amount: 100,
SalesItemLineDetail: expect.objectContaining({
Qty: 1,
UnitPrice: 100,
ItemRef: { value: 'item-1' },
}),
}),
],
}),
'inv_123',
);
});

it('reuses an existing customer matched by name', async () => {
(client.query as any)
.mockResolvedValueOnce([{ Id: 'cust_1', DisplayName: 'Acme Corp' }])
.mockResolvedValueOnce([]);
(client.create as any).mockResolvedValueOnce({ Id: 'inv_1' });

await service.syncInvoice(invoice);

expect(client.create).toHaveBeenCalledTimes(1);
expect(client.create).toHaveBeenCalledWith(
'Invoice',
expect.objectContaining({ CustomerRef: { value: 'cust_1' } }),
'inv_123',
);
});

it('skips an invoice that already exists', async () => {
(client.query as any)
.mockResolvedValueOnce([{ Id: 'cust_1', DisplayName: 'Acme Corp' }])
.mockResolvedValueOnce([{ Id: 'inv_1' }]);

const result = await service.syncInvoice(invoice);

expect(result.action).toBe('skipped');
expect(client.create).not.toHaveBeenCalled();
});

it('records a payment linked to the invoice', async () => {
(client.query as any).mockResolvedValueOnce([
{ Id: 'inv_1', CustomerRef: { value: 'cust_1' } },
]);
(client.create as any).mockResolvedValueOnce({ Id: 'pay_1' });

const result = await service.recordPayment({
externalId: 'pay_1',
invoiceExternalId: 'INV-123',
amount: 100,
currency: 'USD',
reference: 'tx_1',
});

expect(result).toEqual({
externalId: 'pay_1',
providerId: 'pay_1',
action: 'created',
});
expect(client.create).toHaveBeenCalledWith(
'Payment',
expect.objectContaining({
CustomerRef: { value: 'cust_1' },
TotalAmt: 100,
Line: [
{ Amount: 100, LinkedTxn: [{ TxnId: 'inv_1', TxnType: 'Invoice' }] },
],
}),
'pay_1',
);
});

it('fails when the invoice is missing', async () => {
(client.query as any).mockResolvedValueOnce([]);

await expect(
service.recordPayment({
externalId: 'pay_1',
invoiceExternalId: 'missing',
amount: 10,
currency: 'USD',
}),
).rejects.toThrow(/was not found/);
});
});
6 changes: 6 additions & 0 deletions backend/src/integrations/quickbooks/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export { QuickBooksClient } from './quickbooks-client.js';
export {
QuickBooksService,
quickBooksService,
} from './quickbooks-service.js';
export * from './types.js';
Loading
Loading