diff --git a/ENV_VARS.md b/ENV_VARS.md index f14c9061..40569b26 100644 --- a/ENV_VARS.md +++ b/ENV_VARS.md @@ -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 diff --git a/backend/src/index.ts b/backend/src/index.ts index 3b6f2689..72200379 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -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'; @@ -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()); @@ -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); diff --git a/backend/src/integrations/accounting/types.ts b/backend/src/integrations/accounting/types.ts new file mode 100644 index 00000000..92819690 --- /dev/null +++ b/backend/src/integrations/accounting/types.ts @@ -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; + +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; + +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; + +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; + +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); +} diff --git a/backend/src/integrations/quickbooks/__tests__/quickbooks-client.test.ts b/backend/src/integrations/quickbooks/__tests__/quickbooks-client.test.ts new file mode 100644 index 00000000..055a5fa6 --- /dev/null +++ b/backend/src/integrations/quickbooks/__tests__/quickbooks-client.test.ts @@ -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", + ); + }); +}); diff --git a/backend/src/integrations/quickbooks/__tests__/quickbooks-service.test.ts b/backend/src/integrations/quickbooks/__tests__/quickbooks-service.test.ts new file mode 100644 index 00000000..a112ac2c --- /dev/null +++ b/backend/src/integrations/quickbooks/__tests__/quickbooks-service.test.ts @@ -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/); + }); +}); diff --git a/backend/src/integrations/quickbooks/index.ts b/backend/src/integrations/quickbooks/index.ts new file mode 100644 index 00000000..67092e13 --- /dev/null +++ b/backend/src/integrations/quickbooks/index.ts @@ -0,0 +1,6 @@ +export { QuickBooksClient } from './quickbooks-client.js'; +export { + QuickBooksService, + quickBooksService, +} from './quickbooks-service.js'; +export * from './types.js'; diff --git a/backend/src/integrations/quickbooks/quickbooks-client.ts b/backend/src/integrations/quickbooks/quickbooks-client.ts new file mode 100644 index 00000000..2cfc45d0 --- /dev/null +++ b/backend/src/integrations/quickbooks/quickbooks-client.ts @@ -0,0 +1,103 @@ +import type { QuickBooksConfig } from './types.js'; + +interface QuickBooksQueryResponse { + QueryResponse: Record; +} + +/** + * QuickBooks Online Accounting API v3 client. + * + * Each request is scoped to the configured company (`realmId`). OAuth access + * tokens must be supplied by the caller. + */ +export class QuickBooksClient { + private readonly accessToken: string; + private readonly baseUrl: string; + private readonly minorVersion: string; + private readonly timeoutMs: number; + + constructor(config: QuickBooksConfig) { + if (!config.accessToken || !config.realmId) { + throw new Error('QuickBooks accessToken and realmId are required'); + } + + this.accessToken = config.accessToken; + this.baseUrl = ( + config.baseUrl ?? + `https://quickbooks.api.intuit.com/v3/company/${config.realmId}` + ).replace(/\/+$/, ''); + this.minorVersion = config.minorVersion ?? '65'; + this.timeoutMs = config.timeoutMs ?? 10_000; + } + + private async request( + method: string, + path: string, + body?: unknown, + ): Promise { + const separator = path.includes('?') ? '&' : '?'; + const response = await fetch( + `${this.baseUrl}${path}${separator}minorversion=${this.minorVersion}`, + { + method, + headers: { + Authorization: `Bearer ${this.accessToken}`, + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: body === undefined ? undefined : JSON.stringify(body), + signal: AbortSignal.timeout(this.timeoutMs), + }, + ); + + if (!response.ok) { + const errorText = await response.text().catch(() => ''); + throw new Error( + `QuickBooks API error ${response.status}: ${response.statusText} – ${errorText}`, + ); + } + + if (response.status === 204) { + return {} as T; + } + + return (await response.json()) as T; + } + + async query(query: string): Promise { + const result = await this.request>( + 'GET', + `/query?query=${encodeURIComponent(query)}`, + ); + const response = result.QueryResponse ?? {}; + const key = Object.keys(response).find((entry) => entry !== 'startPosition' && entry !== 'maxResults' && entry !== 'totalCount'); + if (!key) return []; + const value = response[key]; + return Array.isArray(value) ? value : []; + } + + async create(entity: string, payload: Record, requestId?: string): Promise { + const suffix = requestId ? `?requestid=${encodeURIComponent(requestId)}` : ''; + const result = await this.request>( + 'POST', + `/${entity}${suffix}`, + payload, + ); + const created = result[entity]; + if (!created) { + throw new Error(`QuickBooks did not return the created ${entity}`); + } + return created; + } + + async getInvoice(id: string): Promise> { + return this.request>('GET', `/invoice/${id}`); + } + + /** + * Escapes a value for safe interpolation into a QuickBooks query string. + */ + static escapeQuery(value: string): string { + return value.replace(/\\/g, '\\\\').replace(/'/g, "\\'"); + } +} diff --git a/backend/src/integrations/quickbooks/quickbooks-service.ts b/backend/src/integrations/quickbooks/quickbooks-service.ts new file mode 100644 index 00000000..1e1d5faf --- /dev/null +++ b/backend/src/integrations/quickbooks/quickbooks-service.ts @@ -0,0 +1,206 @@ +import { QuickBooksClient } from './quickbooks-client.js'; +import type { + QuickBooksCustomer, + QuickBooksConfig, + QuickBooksInvoice, + QuickBooksInvoiceInput, + QuickBooksPayment, + QuickBooksPaymentInput, + QuickBooksSyncOptions, +} from './types.js'; +import { + accountingInvoiceSchema, + accountingPaymentSchema, + toDateString, + type InvoiceSyncResult, + type PaymentSyncResult, +} from '../accounting/types.js'; + +const escape = QuickBooksClient.escapeQuery; + +/** + * Syncs AgenticPay invoices and payments into QuickBooks Online. + * + * Customers are matched by display name (falling back to email) and invoices + * by `DocNumber`. Payments use the payment external id as the QuickBooks + * `requestid`, so retried requests are idempotent. + */ +export class QuickBooksService { + private client: QuickBooksClient | null = null; + private readonly defaultItemId?: string; + + constructor(client?: QuickBooksClient, options: QuickBooksSyncOptions = {}) { + if (client) this.client = client; + this.defaultItemId = + options.defaultItemId ?? process.env.QUICKBOOKS_DEFAULT_ITEM_ID; + } + + getClient(): QuickBooksClient { + if (!this.client) { + const accessToken = process.env.QUICKBOOKS_ACCESS_TOKEN; + const realmId = process.env.QUICKBOOKS_REALM_ID; + + if (!accessToken || !realmId) { + throw new Error( + 'QUICKBOOKS_ACCESS_TOKEN and QUICKBOOKS_REALM_ID must be configured', + ); + } + + const config: QuickBooksConfig = { + accessToken, + realmId, + baseUrl: process.env.QUICKBOOKS_API_URL, + }; + this.client = new QuickBooksClient(config); + } + return this.client; + } + + isConfigured(): boolean { + return Boolean( + this.client || + (process.env.QUICKBOOKS_ACCESS_TOKEN && process.env.QUICKBOOKS_REALM_ID), + ); + } + + async ping(): Promise { + try { + await this.getClient().query('SELECT * FROM CompanyInfo'); + return true; + } catch { + return false; + } + } + + async syncInvoice( + input: QuickBooksInvoiceInput, + ): Promise { + const invoice = accountingInvoiceSchema.parse(input); + const client = this.getClient(); + const reference = invoice.reference ?? invoice.externalId; + + const customer = await this.ensureCustomer(invoice); + const existing = await client.query( + `SELECT * FROM Invoice WHERE DocNumber = '${escape(reference)}'`, + ); + + if (existing[0]) { + return { + externalId: invoice.externalId, + providerId: existing[0].Id, + action: 'skipped', + customerId: customer.Id, + }; + } + + const created = await client.create( + 'Invoice', + { + CustomerRef: { value: customer.Id }, + DocNumber: reference, + TxnDate: toDateString(invoice.invoiceDate), + DueDate: toDateString( + invoice.dueDate, + new Date(invoice.invoiceDate ?? Date.now()), + ), + CurrencyRef: { value: invoice.currency }, + Line: invoice.lines.map((line) => ({ + DetailType: 'SalesItemLineDetail', + Amount: line.quantity * line.unitAmount, + Description: line.description, + SalesItemLineDetail: { + Qty: line.quantity, + UnitPrice: line.unitAmount, + ...(this.defaultItemId + ? { ItemRef: { value: this.defaultItemId } } + : {}), + }, + })), + }, + invoice.externalId, + ); + + return { + externalId: invoice.externalId, + providerId: created.Id, + action: 'created', + customerId: customer.Id, + }; + } + + async recordPayment( + input: QuickBooksPaymentInput, + ): Promise { + const payment = accountingPaymentSchema.parse(input); + const client = this.getClient(); + + const invoices = await client.query( + `SELECT * FROM Invoice WHERE DocNumber = '${escape(payment.invoiceExternalId)}'`, + ); + const invoice = invoices[0]; + + if (!invoice) { + throw new Error( + `QuickBooks invoice ${payment.invoiceExternalId} was not found`, + ); + } + + const customerId = invoice.CustomerRef?.value; + if (!customerId) { + throw new Error( + `QuickBooks invoice ${invoice.Id} has no linked customer`, + ); + } + + const created = await client.create( + 'Payment', + { + CustomerRef: { value: customerId }, + TotalAmt: payment.amount, + CurrencyRef: { value: payment.currency }, + Line: [ + { + Amount: payment.amount, + LinkedTxn: [{ TxnId: invoice.Id, TxnType: 'Invoice' }], + }, + ], + PaymentRefNum: payment.reference ?? payment.externalId, + }, + payment.externalId, + ); + + return { + externalId: payment.externalId, + providerId: created.Id, + action: 'created', + }; + } + + private async ensureCustomer( + invoice: QuickBooksInvoiceInput, + ): Promise { + const client = this.getClient(); + const { customer } = invoice; + + const byName = await client.query( + `SELECT * FROM Customer WHERE DisplayName = '${escape(customer.name)}'`, + ); + if (byName[0]) return byName[0]; + + if (customer.email) { + const byEmail = await client.query( + `SELECT * FROM Customer WHERE PrimaryEmailAddr = '${escape(customer.email)}'`, + ); + if (byEmail[0]) return byEmail[0]; + } + + return client.create('Customer', { + DisplayName: customer.name, + ...(customer.email + ? { PrimaryEmailAddr: { Address: customer.email } } + : {}), + }); + } +} + +export const quickBooksService = new QuickBooksService(); diff --git a/backend/src/integrations/quickbooks/types.ts b/backend/src/integrations/quickbooks/types.ts new file mode 100644 index 00000000..8dc354f1 --- /dev/null +++ b/backend/src/integrations/quickbooks/types.ts @@ -0,0 +1,32 @@ +import type { AccountingInvoice, AccountingPayment } from '../accounting/types.js'; + +export interface QuickBooksConfig { + accessToken: string; + realmId: string; + baseUrl?: string; + minorVersion?: string; + timeoutMs?: number; +} + +export interface QuickBooksCustomer { + Id: string; + DisplayName: string; + PrimaryEmailAddr?: { Address: string }; +} + +export interface QuickBooksInvoice { + Id: string; + DocNumber?: string; + CustomerRef?: { value: string }; +} + +export interface QuickBooksPayment { + Id: string; +} + +export interface QuickBooksSyncOptions { + defaultItemId?: string; +} + +export type QuickBooksInvoiceInput = AccountingInvoice; +export type QuickBooksPaymentInput = AccountingPayment; diff --git a/backend/src/integrations/salesforce/__tests__/salesforce-billing-service.test.ts b/backend/src/integrations/salesforce/__tests__/salesforce-billing-service.test.ts new file mode 100644 index 00000000..861b250b --- /dev/null +++ b/backend/src/integrations/salesforce/__tests__/salesforce-billing-service.test.ts @@ -0,0 +1,180 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { SalesforceBillingService } from '../salesforce-billing-service.js'; +import type { SalesforceClient } from '../salesforce-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: 2, unitAmount: 50 }, + { description: 'Support', quantity: 1, unitAmount: 25.5 }, + ], +}; + +describe('SalesforceBillingService', () => { + let client: SalesforceClient; + let service: SalesforceBillingService; + + beforeEach(() => { + client = { + query: vi.fn().mockResolvedValue([]), + findOne: vi.fn().mockResolvedValue(null), + create: vi.fn().mockResolvedValue('created_id'), + update: vi.fn().mockResolvedValue(undefined), + } as unknown as SalesforceClient; + + service = new SalesforceBillingService(client, { + accountEmailField: 'PersonEmail', + }); + }); + + it('creates the account, invoice and lines on first sync', async () => { + (client.findOne as any) + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(null); + (client.create as any) + .mockResolvedValueOnce('account_1') + .mockResolvedValueOnce('invoice_1') + .mockResolvedValueOnce('line_1') + .mockResolvedValueOnce('line_2'); + + const result = await service.syncInvoice(invoice); + + expect(result).toEqual({ + externalId: 'inv_123', + providerId: 'invoice_1', + customerId: 'account_1', + action: 'created', + }); + expect(client.create).toHaveBeenNthCalledWith( + 1, + 'Account', + expect.objectContaining({ + External_Id__c: 'cus_123', + Name: 'Acme Corp', + PersonEmail: 'billing@acme.test', + }), + ); + expect(client.create).toHaveBeenNthCalledWith( + 2, + 'blng__Invoice__c', + expect.objectContaining({ + External_Id__c: 'inv_123', + blng__Account__c: 'account_1', + blng__TotalAmount__c: 125.5, + CurrencyIsoCode: 'USD', + }), + ); + expect(client.create).toHaveBeenNthCalledWith( + 3, + 'blng__InvoiceLine__c', + expect.objectContaining({ blng__Amount__c: 100 }), + ); + expect(client.create).toHaveBeenNthCalledWith( + 4, + 'blng__InvoiceLine__c', + expect.objectContaining({ blng__Amount__c: 25.5 }), + ); + }); + + it('updates an existing invoice and its existing lines', async () => { + (client.findOne as any) + .mockResolvedValueOnce({ Id: 'account_1' }) + .mockResolvedValueOnce({ Id: 'invoice_1' }); + (client.query as any).mockResolvedValueOnce([ + { Id: 'line_1' }, + { Id: 'line_2' }, + ]); + + const result = await service.syncInvoice(invoice); + + expect(result.action).toBe('updated'); + expect(client.update).toHaveBeenCalledWith( + 'Account', + 'account_1', + expect.objectContaining({ Name: 'Acme Corp' }), + ); + expect(client.update).toHaveBeenCalledWith( + 'blng__Invoice__c', + 'invoice_1', + expect.objectContaining({ External_Id__c: 'inv_123' }), + ); + expect(client.update).toHaveBeenCalledWith( + 'blng__InvoiceLine__c', + 'line_1', + expect.objectContaining({ blng__Description__c: 'Platform fee' }), + ); + expect(client.create).not.toHaveBeenCalled(); + }); + + it('records a payment against a synced invoice', async () => { + (client.findOne as any) + .mockResolvedValueOnce({ Id: 'invoice_1' }) + .mockResolvedValueOnce(null); + + const result = await service.recordPayment({ + externalId: 'pay_1', + invoiceExternalId: 'inv_123', + amount: 125.5, + currency: 'USD', + paidAt: '2026-10-15', + reference: 'tx_1', + }); + + expect(result).toEqual({ + externalId: 'pay_1', + providerId: 'created_id', + action: 'created', + }); + expect(client.create).toHaveBeenCalledWith( + 'blng__Payment__c', + expect.objectContaining({ + External_Id__c: 'pay_1', + blng__Invoice__c: 'invoice_1', + blng__Amount__c: 125.5, + blng__Reference__c: 'tx_1', + }), + ); + }); + + it('skips an already recorded payment', async () => { + (client.findOne as any) + .mockResolvedValueOnce({ Id: 'invoice_1' }) + .mockResolvedValueOnce({ Id: 'payment_1' }); + + const result = await service.recordPayment({ + externalId: 'pay_1', + invoiceExternalId: 'inv_123', + amount: 10, + currency: 'USD', + }); + + expect(result).toEqual({ + externalId: 'pay_1', + providerId: 'payment_1', + action: 'skipped', + }); + expect(client.create).not.toHaveBeenCalled(); + }); + + it('fails when the invoice for a payment is missing', async () => { + (client.findOne as any).mockResolvedValueOnce(null); + + await expect( + service.recordPayment({ + externalId: 'pay_1', + invoiceExternalId: 'missing', + amount: 10, + currency: 'USD', + }), + ).rejects.toThrow(/was not found/); + }); +}); diff --git a/backend/src/integrations/salesforce/__tests__/salesforce-client.test.ts b/backend/src/integrations/salesforce/__tests__/salesforce-client.test.ts new file mode 100644 index 00000000..d0a3288d --- /dev/null +++ b/backend/src/integrations/salesforce/__tests__/salesforce-client.test.ts @@ -0,0 +1,97 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { SalesforceClient } from '../salesforce-client.js'; + +describe('SalesforceClient', () => { + const originalFetch = global.fetch; + let client: SalesforceClient; + + beforeEach(() => { + client = new SalesforceClient({ + instanceUrl: 'https://acme.my.salesforce.com/', + accessToken: 'token-123', + apiVersion: '59.0', + timeoutMs: 1000, + }); + }); + + afterEach(() => { + global.fetch = originalFetch; + vi.restoreAllMocks(); + }); + + it('runs a SOQL query with bearer auth', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + statusText: 'OK', + json: async () => ({ + totalSize: 1, + done: true, + records: [{ Id: '001', Name: 'Acme' }], + }), + } as unknown as Response); + + const records = await client.query('SELECT Id, Name FROM Account'); + + expect(records).toEqual([{ Id: '001', Name: 'Acme' }]); + const [url, options] = (global.fetch as any).mock.calls[0]; + expect(url).toBe( + 'https://acme.my.salesforce.com/services/data/v59.0/query?q=SELECT%20Id%2C%20Name%20FROM%20Account', + ); + expect(options.headers.Authorization).toBe('Bearer token-123'); + }); + + it('creates a record and returns its id', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + status: 201, + statusText: 'Created', + json: async () => ({ id: 'a0101', success: true, errors: [] }), + } as unknown as Response); + + const id = await client.create('Account', { Name: 'Acme' }); + + expect(id).toBe('a0101'); + const [url, options] = (global.fetch as any).mock.calls[0]; + expect(url).toBe( + 'https://acme.my.salesforce.com/services/data/v59.0/sobjects/Account', + ); + expect(options.method).toBe('POST'); + expect(JSON.parse(options.body)).toEqual({ Name: 'Acme' }); + }); + + it('updates a record with PATCH', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + status: 204, + statusText: 'No Content', + } as unknown as Response); + + await client.update('Account', 'a0101', { Name: 'Acme 2' }); + + const [url, options] = (global.fetch as any).mock.calls[0]; + expect(url).toBe( + 'https://acme.my.salesforce.com/services/data/v59.0/sobjects/Account/a0101', + ); + expect(options.method).toBe('PATCH'); + }); + + it('throws on API errors', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: false, + status: 401, + statusText: 'Unauthorized', + text: async () => '[{"errorCode":"INVALID_SESSION_ID"}]', + } as unknown as Response); + + await expect(client.query('SELECT Id FROM Account')).rejects.toThrow( + /Salesforce API error 401/, + ); + }); + + it('escapes SOQL string literals', () => { + expect(SalesforceClient.escapeSoql("O'Brien\\Co")).toBe( + "O\\'Brien\\\\Co", + ); + }); +}); diff --git a/backend/src/integrations/salesforce/index.ts b/backend/src/integrations/salesforce/index.ts new file mode 100644 index 00000000..1278dc00 --- /dev/null +++ b/backend/src/integrations/salesforce/index.ts @@ -0,0 +1,6 @@ +export { SalesforceClient } from './salesforce-client.js'; +export { + SalesforceBillingService, + salesforceBillingService, +} from './salesforce-billing-service.js'; +export * from './types.js'; diff --git a/backend/src/integrations/salesforce/salesforce-billing-service.ts b/backend/src/integrations/salesforce/salesforce-billing-service.ts new file mode 100644 index 00000000..eba7d825 --- /dev/null +++ b/backend/src/integrations/salesforce/salesforce-billing-service.ts @@ -0,0 +1,229 @@ +import { SalesforceClient } from './salesforce-client.js'; +import { + DEFAULT_SALESFORCE_OBJECTS, + type SalesforceInvoiceInput, + type SalesforceObjectNames, + type SalesforcePaymentInput, + type SalesforceRecord, + type SalesforceSyncOptions, +} from './types.js'; +import { + accountingInvoiceSchema, + accountingPaymentSchema, + invoiceTotal, + toDateString, + type InvoiceSyncResult, + type PaymentSyncResult, +} from '../accounting/types.js'; + +const escape = SalesforceClient.escapeSoql; + +/** + * Syncs AgenticPay invoices and payments into Salesforce Billing. + * + * All writes are keyed by a configurable external id field so repeated syncs + * are idempotent. Account and invoice records are updated in place; invoice + * lines are reconciled positionally. + */ +export class SalesforceBillingService { + private client: SalesforceClient | null = null; + private readonly objects: SalesforceObjectNames; + private readonly invoiceStatus: string; + private readonly accountEmailField?: string; + + constructor( + client?: SalesforceClient, + options: SalesforceSyncOptions & { accountEmailField?: string } = {}, + ) { + if (client) this.client = client; + this.objects = { ...DEFAULT_SALESFORCE_OBJECTS, ...options.objects }; + this.invoiceStatus = + options.invoiceStatus ?? process.env.SALESFORCE_INVOICE_STATUS ?? 'Draft'; + this.accountEmailField = + options.accountEmailField ?? process.env.SALESFORCE_ACCOUNT_EMAIL_FIELD; + } + + getClient(): SalesforceClient { + if (!this.client) { + const instanceUrl = process.env.SALESFORCE_INSTANCE_URL; + const accessToken = process.env.SALESFORCE_ACCESS_TOKEN; + + if (!instanceUrl || !accessToken) { + throw new Error( + 'SALESFORCE_INSTANCE_URL and SALESFORCE_ACCESS_TOKEN must be configured', + ); + } + + this.client = new SalesforceClient({ + instanceUrl, + accessToken, + apiVersion: process.env.SALESFORCE_API_VERSION, + }); + } + return this.client; + } + + isConfigured(): boolean { + return Boolean( + this.client || + (process.env.SALESFORCE_INSTANCE_URL && + process.env.SALESFORCE_ACCESS_TOKEN), + ); + } + + async ping(): Promise { + try { + await this.getClient().query('SELECT Id FROM Account LIMIT 1'); + return true; + } catch { + return false; + } + } + + async syncInvoice(input: SalesforceInvoiceInput): Promise { + const invoice = accountingInvoiceSchema.parse(input); + const client = this.getClient(); + + const customerId = await this.upsertAccount(invoice); + + const existing = await client.findOne( + this.objects.invoice, + `${this.objects.externalIdField} = '${escape(invoice.externalId)}'`, + ); + + const record: Record = { + [this.objects.externalIdField]: invoice.externalId, + Name: invoice.reference ?? invoice.externalId, + blng__Account__c: customerId, + blng__InvoiceDate__c: toDateString(invoice.invoiceDate), + blng__DueDate__c: toDateString(invoice.dueDate, new Date(invoice.invoiceDate ?? Date.now())), + blng__InvoiceStatus__c: this.invoiceStatus, + blng__TotalAmount__c: invoiceTotal(invoice), + CurrencyIsoCode: invoice.currency, + }; + + let providerId: string; + let action: 'created' | 'updated'; + + if (existing) { + providerId = existing.Id; + await client.update(this.objects.invoice, providerId, record); + action = 'updated'; + } else { + providerId = await client.create(this.objects.invoice, record); + action = 'created'; + } + + await this.syncInvoiceLines(invoice, providerId); + + return { + externalId: invoice.externalId, + providerId, + action, + customerId, + }; + } + + async recordPayment( + input: SalesforcePaymentInput, + ): Promise { + const payment = accountingPaymentSchema.parse(input); + const client = this.getClient(); + + const invoice = await client.findOne( + this.objects.invoice, + `${this.objects.externalIdField} = '${escape(payment.invoiceExternalId)}'`, + ); + + if (!invoice) { + throw new Error( + `Salesforce invoice ${payment.invoiceExternalId} was not found`, + ); + } + + const existing = await client.findOne( + this.objects.payment, + `${this.objects.externalIdField} = '${escape(payment.externalId)}'`, + ); + + if (existing) { + return { + externalId: payment.externalId, + providerId: existing.Id, + action: 'skipped', + }; + } + + const providerId = await client.create(this.objects.payment, { + [this.objects.externalIdField]: payment.externalId, + blng__Invoice__c: invoice.Id, + blng__Amount__c: payment.amount, + blng__PaymentDate__c: toDateString(payment.paidAt), + blng__Reference__c: payment.reference ?? payment.externalId, + CurrencyIsoCode: payment.currency, + }); + + return { + externalId: payment.externalId, + providerId, + action: 'created', + }; + } + + private async upsertAccount( + invoice: SalesforceInvoiceInput & { currency: string }, + ): Promise { + const client = this.getClient(); + const customer = invoice.customer; + const existing = await client.findOne( + this.objects.account, + `${this.objects.externalIdField} = '${escape(customer.externalId)}'`, + ); + + const record: Record = { + [this.objects.externalIdField]: customer.externalId, + Name: customer.name, + }; + + if (customer.email && this.accountEmailField) { + record[this.accountEmailField] = customer.email; + } + + if (existing) { + await client.update(this.objects.account, existing.Id, record); + return existing.Id; + } + + return client.create(this.objects.account, record); + } + + private async syncInvoiceLines( + invoice: SalesforceInvoiceInput & { currency: string }, + invoiceId: string, + ): Promise { + const client = this.getClient(); + const existing = await client.query( + `SELECT Id FROM ${this.objects.invoiceLine} WHERE blng__Invoice__c = '${escape(invoiceId)}' ORDER BY CreatedDate ASC`, + ); + + for (let index = 0; index < invoice.lines.length; index += 1) { + const line = invoice.lines[index]; + const record = { + blng__Invoice__c: invoiceId, + blng__Description__c: line.description, + blng__Quantity__c: line.quantity, + blng__UnitPrice__c: line.unitAmount, + blng__Amount__c: line.quantity * line.unitAmount, + }; + + const existingLine = existing[index]; + if (existingLine) { + await client.update(this.objects.invoiceLine, existingLine.Id, record); + } else { + await client.create(this.objects.invoiceLine, record); + } + } + } +} + +export const salesforceBillingService = new SalesforceBillingService(); diff --git a/backend/src/integrations/salesforce/salesforce-client.ts b/backend/src/integrations/salesforce/salesforce-client.ts new file mode 100644 index 00000000..d9b04c07 --- /dev/null +++ b/backend/src/integrations/salesforce/salesforce-client.ts @@ -0,0 +1,120 @@ +import type { SalesforceConfig, SalesforceRecord } from './types.js'; + +interface SalesforceQueryResponse { + totalSize: number; + done: boolean; + records: T[]; +} + +interface SalesforceCreateResponse { + id: string; + success: boolean; + errors: unknown[]; +} + +/** + * Thin Salesforce REST API client. + * + * The caller is responsible for supplying a valid OAuth access token. Token + * refresh is intentionally left to the surrounding credential manager. + */ +export class SalesforceClient { + private readonly instanceUrl: string; + private readonly accessToken: string; + private readonly apiVersion: string; + private readonly timeoutMs: number; + + constructor(config: SalesforceConfig) { + if (!config.instanceUrl || !config.accessToken) { + throw new Error('Salesforce instanceUrl and accessToken are required'); + } + + this.instanceUrl = config.instanceUrl.replace(/\/+$/, ''); + this.accessToken = config.accessToken; + this.apiVersion = config.apiVersion ?? '59.0'; + this.timeoutMs = config.timeoutMs ?? 10_000; + } + + private get baseUrl(): string { + return `${this.instanceUrl}/services/data/v${this.apiVersion}`; + } + + private async request( + method: string, + path: string, + body?: unknown, + ): Promise { + const response = await fetch(`${this.baseUrl}${path}`, { + method, + headers: { + Authorization: `Bearer ${this.accessToken}`, + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: body === undefined ? undefined : JSON.stringify(body), + signal: AbortSignal.timeout(this.timeoutMs), + }); + + if (!response.ok) { + const errorText = await response.text().catch(() => ''); + throw new Error( + `Salesforce API error ${response.status}: ${response.statusText} – ${errorText}`, + ); + } + + if (response.status === 204) { + return {} as T; + } + + return (await response.json()) as T; + } + + async query(soql: string): Promise { + const result = await this.request>( + 'GET', + `/query?q=${encodeURIComponent(soql)}`, + ); + return result.records; + } + + async findOne( + sobject: string, + where: string, + fields = 'Id', + ): Promise { + const records = await this.query( + `SELECT ${fields} FROM ${sobject} WHERE ${where} LIMIT 1`, + ); + return records[0] ?? null; + } + + async create( + sobject: string, + record: Record, + ): Promise { + const result = await this.request( + 'POST', + `/sobjects/${sobject}`, + record, + ); + if (!result.success) { + throw new Error(`Salesforce create failed: ${JSON.stringify(result.errors)}`); + } + return result.id; + } + + async update( + sobject: string, + id: string, + record: Record, + ): Promise { + await this.request('PATCH', `/sobjects/${sobject}/${id}`, record); + } + + /** + * Escapes a value for safe interpolation into a SOQL string literal. + */ + static escapeSoql(value: string): string { + return value.replace(/\\/g, '\\\\').replace(/'/g, "\\'"); + } +} diff --git a/backend/src/integrations/salesforce/types.ts b/backend/src/integrations/salesforce/types.ts new file mode 100644 index 00000000..d0c14dd7 --- /dev/null +++ b/backend/src/integrations/salesforce/types.ts @@ -0,0 +1,37 @@ +import type { AccountingInvoice, AccountingPayment } from '../accounting/types.js'; + +export interface SalesforceConfig { + instanceUrl: string; + accessToken: string; + apiVersion?: string; + timeoutMs?: number; +} + +export interface SalesforceObjectNames { + account: string; + invoice: string; + invoiceLine: string; + payment: string; + externalIdField: string; +} + +export const DEFAULT_SALESFORCE_OBJECTS: SalesforceObjectNames = { + account: 'Account', + invoice: 'blng__Invoice__c', + invoiceLine: 'blng__InvoiceLine__c', + payment: 'blng__Payment__c', + externalIdField: 'External_Id__c', +}; + +export interface SalesforceSyncOptions { + invoiceStatus?: string; + objects?: Partial; +} + +export type SalesforceInvoiceInput = AccountingInvoice; +export type SalesforcePaymentInput = AccountingPayment; + +export interface SalesforceRecord { + Id: string; + [key: string]: unknown; +} diff --git a/backend/src/integrations/woocommerce/__tests__/woocommerce-client.test.ts b/backend/src/integrations/woocommerce/__tests__/woocommerce-client.test.ts new file mode 100644 index 00000000..a43d56fe --- /dev/null +++ b/backend/src/integrations/woocommerce/__tests__/woocommerce-client.test.ts @@ -0,0 +1,109 @@ +import { createHmac } from 'node:crypto'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { WooCommerceClient } from '../woocommerce-client.js'; + +describe('WooCommerceClient', () => { + let client: WooCommerceClient; + const originalFetch = global.fetch; + + beforeEach(() => { + client = new WooCommerceClient({ + storeUrl: 'https://store.example/', + consumerKey: 'ck_test', + consumerSecret: 'cs_test', + timeoutMs: 1000, + }); + }); + + afterEach(() => { + global.fetch = originalFetch; + vi.restoreAllMocks(); + }); + + it('fetches an order with basic auth from the v3 REST API', async () => { + const order = { id: 42, status: 'pending', currency: 'USD', total: '10.00' }; + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + statusText: 'OK', + json: async () => order, + } as unknown as Response); + + const result = await client.getOrder(42); + + expect(result).toEqual(order); + const [url, options] = (global.fetch as any).mock.calls[0]; + expect(url).toBe('https://store.example/wp-json/wc/v3/orders/42'); + expect(options.method).toBe('GET'); + expect(options.headers.Authorization).toBe( + `Basic ${Buffer.from('ck_test:cs_test').toString('base64')}`, + ); + }); + + it('updates an order status with a PUT request', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + statusText: 'OK', + json: async () => ({ id: 42, status: 'completed' }), + } as unknown as Response); + + await client.updateOrder(42, { status: 'completed' }); + + const [url, options] = (global.fetch as any).mock.calls[0]; + expect(url).toBe('https://store.example/wp-json/wc/v3/orders/42'); + expect(options.method).toBe('PUT'); + expect(JSON.parse(options.body)).toEqual({ status: 'completed' }); + }); + + it('creates an order note', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + status: 201, + statusText: 'Created', + json: async () => ({ id: 7 }), + } as unknown as Response); + + await client.createOrderNote(42, 'Payment received'); + + const [url, options] = (global.fetch as any).mock.calls[0]; + expect(url).toBe('https://store.example/wp-json/wc/v3/orders/42/notes'); + expect(options.method).toBe('POST'); + expect(JSON.parse(options.body)).toEqual({ + note: 'Payment received', + customer_note: false, + }); + }); + + it('throws when the API returns an error', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: false, + status: 404, + statusText: 'Not Found', + text: async () => '{"code":"woocommerce_rest_shop_order_invalid_id"}', + } as unknown as Response); + + await expect(client.getOrder(999)).rejects.toThrow(/WooCommerce API error 404/); + }); + + it('accepts a valid webhook signature', () => { + const body = JSON.stringify({ id: 42 }); + const secret = 'webhook-secret'; + const signature = createHmac('sha256', secret).update(body).digest('base64'); + + expect( + WooCommerceClient.verifyWebhookSignature(body, signature, secret), + ).toBe(true); + }); + + it('rejects an invalid webhook signature', () => { + const body = JSON.stringify({ id: 42 }); + + expect( + WooCommerceClient.verifyWebhookSignature(body, 'not-a-signature', 'secret'), + ).toBe(false); + expect(WooCommerceClient.verifyWebhookSignature(body, '', 'secret')).toBe( + false, + ); + }); +}); diff --git a/backend/src/integrations/woocommerce/__tests__/woocommerce-service.test.ts b/backend/src/integrations/woocommerce/__tests__/woocommerce-service.test.ts new file mode 100644 index 00000000..edf1f090 --- /dev/null +++ b/backend/src/integrations/woocommerce/__tests__/woocommerce-service.test.ts @@ -0,0 +1,129 @@ +import { createHmac } from 'node:crypto'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { WooCommerceService } from '../woocommerce-service.js'; +import type { WooCommerceClient } from '../woocommerce-client.js'; +import type { WooCommerceOrder } from '../types.js'; + +const order: WooCommerceOrder = { + id: 1001, + number: '1001', + status: 'pending', + currency: 'usd', + total: '49.50', + customer_id: 5, + billing: { + first_name: 'Ada', + last_name: 'Lovelace', + email: 'ada@example.com', + }, + line_items: [ + { name: 'Analytical Engine', quantity: 1, total: '49.50', price: 49.5 }, + ], +} as WooCommerceOrder; + +function sign(body: string, secret = 'whsec_test'): string { + return createHmac('sha256', secret).update(body).digest('base64'); +} + +describe('WooCommerceService', () => { + let client: WooCommerceClient; + let service: WooCommerceService; + + beforeEach(() => { + client = { + getOrder: vi.fn().mockResolvedValue(order), + updateOrder: vi + .fn() + .mockImplementation(async (_id: number, patch: { status: string }) => ({ + ...order, + ...patch, + })), + createOrderNote: vi.fn().mockResolvedValue({ id: 1 }), + } as unknown as WooCommerceClient; + + service = new WooCommerceService(client, 'whsec_test'); + }); + + it('builds a payment draft from an order', async () => { + const draft = await service.getPaymentDraft(1001); + + expect(client.getOrder).toHaveBeenCalledWith(1001); + expect(draft).toMatchObject({ + reference: 'wc_order_1001', + orderId: 1001, + amount: 49.5, + currency: 'USD', + description: 'WooCommerce order #1001', + customer: { + externalId: 'wc_customer_5', + name: 'Ada Lovelace', + email: 'ada@example.com', + }, + }); + expect(draft.lines).toEqual([ + { description: 'Analytical Engine', quantity: 1, unitAmount: 49.5 }, + ]); + }); + + it('verifies and parses a signed webhook', () => { + const body = JSON.stringify(order); + + const event = service.processWebhook({ + rawBody: body, + signature: sign(body), + topic: 'order.created', + deliveryId: 'delivery-1', + }); + + expect(event.topic).toBe('order.created'); + expect(event.deliveryId).toBe('delivery-1'); + expect(event.paymentDraft.reference).toBe('wc_order_1001'); + }); + + it('rejects a webhook with an invalid signature', () => { + const body = JSON.stringify(order); + + expect(() => + service.processWebhook({ + rawBody: body, + signature: sign(body, 'wrong-secret'), + topic: 'order.created', + }), + ).toThrow(/Invalid WooCommerce webhook signature/); + }); + + it('rejects an unsupported webhook topic', () => { + const body = JSON.stringify(order); + + expect(() => + service.processWebhook({ + rawBody: body, + signature: sign(body), + topic: 'product.created', + }), + ).toThrow(/Invalid enum value/); + }); + + it('maps paid payments to completed orders and records a note', async () => { + const updated = await service.syncPaymentStatus( + 1001, + 'paid', + 'tx_123', + ); + + expect(client.updateOrder).toHaveBeenCalledWith(1001, { + status: 'completed', + }); + expect(client.createOrderNote).toHaveBeenCalledWith( + 1001, + 'AgenticPay payment paid. Transaction: tx_123.', + ); + expect(updated.status).toBe('completed'); + }); + + it('maps failed payments to failed orders', async () => { + await service.syncPaymentStatus(1001, 'failed'); + + expect(client.updateOrder).toHaveBeenCalledWith(1001, { status: 'failed' }); + }); +}); diff --git a/backend/src/integrations/woocommerce/index.ts b/backend/src/integrations/woocommerce/index.ts new file mode 100644 index 00000000..055dd88c --- /dev/null +++ b/backend/src/integrations/woocommerce/index.ts @@ -0,0 +1,8 @@ +export { WooCommerceClient } from './woocommerce-client.js'; +export { + WooCommerceService, + wooCommerceService, + isWooCommerceTopic, + type ProcessWebhookInput, +} from './woocommerce-service.js'; +export * from './types.js'; diff --git a/backend/src/integrations/woocommerce/types.ts b/backend/src/integrations/woocommerce/types.ts new file mode 100644 index 00000000..2908dd02 --- /dev/null +++ b/backend/src/integrations/woocommerce/types.ts @@ -0,0 +1,152 @@ +import { z } from 'zod'; + +export interface WooCommerceConfig { + storeUrl: string; + consumerKey: string; + consumerSecret: string; + timeoutMs?: number; +} + +export const wooCommerceLineItemSchema = z + .object({ + id: z.number().optional(), + name: z.string(), + quantity: z.number(), + total: z.string(), + price: z.number().optional(), + sku: z.string().optional(), + }) + .passthrough(); + +export const wooCommerceOrderSchema = z + .object({ + id: z.number(), + number: z.string().optional(), + status: z.string(), + currency: z.string().min(1), + total: z.string(), + customer_id: z.number().optional(), + billing: z + .object({ + first_name: z.string().optional(), + last_name: z.string().optional(), + email: z.string().optional(), + company: z.string().optional(), + }) + .passthrough() + .optional(), + line_items: z.array(wooCommerceLineItemSchema).default([]), + date_created: z.string().optional(), + date_modified: z.string().optional(), + }) + .passthrough(); + +export type WooCommerceOrder = z.infer; + +export const wooCommerceWebhookTopicSchema = z.enum([ + 'order.created', + 'order.updated', + 'order.deleted', +]); + +export type WooCommerceWebhookTopic = z.infer< + typeof wooCommerceWebhookTopicSchema +>; + +export const wooCommerceOrderStatusSchema = z.enum([ + 'pending', + 'processing', + 'on-hold', + 'completed', + 'cancelled', + 'refunded', + 'failed', +]); + +export type WooCommerceOrderStatus = z.infer< + typeof wooCommerceOrderStatusSchema +>; + +export const wooCommercePaymentStatusSchema = z.enum([ + 'paid', + 'pending', + 'failed', + 'refunded', +]); + +export type WooCommercePaymentStatus = z.infer< + typeof wooCommercePaymentStatusSchema +>; + +export const wooCommercePaymentStatusUpdateSchema = z.object({ + status: wooCommercePaymentStatusSchema, + transactionId: z.string().max(200).optional(), +}); + +export type WooCommercePaymentStatusUpdateInput = z.infer< + typeof wooCommercePaymentStatusUpdateSchema +>; + +export interface WooCommercePaymentLine { + description: string; + quantity: number; + unitAmount: number; +} + +export interface WooCommercePaymentDraft { + reference: string; + orderId: number; + amount: number; + currency: string; + description: string; + customer: { + externalId: string; + name?: string; + email?: string; + }; + lines: WooCommercePaymentLine[]; + metadata: Record; +} + +export interface WooCommerceWebhookEvent { + topic: WooCommerceWebhookTopic; + deliveryId?: string; + order: WooCommerceOrder; + paymentDraft: WooCommercePaymentDraft; +} + +export function toPaymentDraft(order: WooCommerceOrder): WooCommercePaymentDraft { + const amount = Number(order.total); + if (!Number.isFinite(amount) || amount < 0) { + throw new Error(`WooCommerce order ${order.id} has an invalid total`); + } + + const name = [order.billing?.first_name, order.billing?.last_name] + .filter(Boolean) + .join(' ') + .trim(); + + return { + reference: `wc_order_${order.id}`, + orderId: order.id, + amount, + currency: order.currency.toUpperCase(), + description: `WooCommerce order #${order.number ?? order.id}`, + customer: { + externalId: `wc_customer_${order.customer_id ?? 'guest'}`, + name: name || order.billing?.company, + email: order.billing?.email, + }, + lines: order.line_items.map((line) => ({ + description: line.name, + quantity: line.quantity, + unitAmount: Number(line.price ?? 0), + })), + metadata: { + source: 'woocommerce', + orderId: order.id, + orderNumber: order.number ?? order.id, + status: order.status, + }, + }; +} diff --git a/backend/src/integrations/woocommerce/woocommerce-client.ts b/backend/src/integrations/woocommerce/woocommerce-client.ts new file mode 100644 index 00000000..e9b76d74 --- /dev/null +++ b/backend/src/integrations/woocommerce/woocommerce-client.ts @@ -0,0 +1,118 @@ +import { createHmac, timingSafeEqual } from 'node:crypto'; +import type { + WooCommerceConfig, + WooCommerceOrder, + WooCommerceOrderStatus, +} from './types.js'; + +interface WooCommerceOrderPatch { + status?: WooCommerceOrderStatus; + customer_note?: string; +} + +/** + * Minimal WooCommerce REST API v3 client. + * + * Authentication uses the store's consumer key/secret pair (HTTP Basic), and + * webhook payloads are verified with the store's webhook secret + * (`X-WC-Webhook-Signature`). + */ +export class WooCommerceClient { + private readonly baseUrl: string; + private readonly authHeader: string; + private readonly timeoutMs: number; + + constructor(config: WooCommerceConfig) { + if (!config.storeUrl) { + throw new Error('WooCommerce storeUrl is required'); + } + + this.baseUrl = `${config.storeUrl.replace(/\/+$/, '')}/wp-json/wc/v3`; + this.authHeader = `Basic ${Buffer.from( + `${config.consumerKey}:${config.consumerSecret}`, + ).toString('base64')}`; + this.timeoutMs = config.timeoutMs ?? 10_000; + } + + private async request( + method: string, + path: string, + body?: unknown, + ): Promise { + const response = await fetch(`${this.baseUrl}${path}`, { + method, + headers: { + Authorization: this.authHeader, + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: body === undefined ? undefined : JSON.stringify(body), + signal: AbortSignal.timeout(this.timeoutMs), + }); + + if (!response.ok) { + const errorText = await response.text().catch(() => ''); + throw new Error( + `WooCommerce API error ${response.status}: ${response.statusText} – ${errorText}`, + ); + } + + return (await response.json()) as T; + } + + async getOrder(orderId: number): Promise { + return this.request('GET', `/orders/${orderId}`); + } + + async listOrders(params: { + status?: string; + perPage?: number; + page?: number; + } = {}): Promise { + const query = new URLSearchParams(); + if (params.status) query.set('status', params.status); + if (params.perPage) query.set('per_page', String(params.perPage)); + if (params.page) query.set('page', String(params.page)); + const suffix = query.toString() ? `?${query.toString()}` : ''; + return this.request('GET', `/orders${suffix}`); + } + + async updateOrder( + orderId: number, + patch: WooCommerceOrderPatch, + ): Promise { + return this.request('PUT', `/orders/${orderId}`, patch); + } + + async createOrderNote( + orderId: number, + note: string, + customerNote = false, + ): Promise<{ id: number }> { + return this.request<{ id: number }>('POST', `/orders/${orderId}/notes`, { + note, + customer_note: customerNote, + }); + } + + /** + * Verifies an `X-WC-Webhook-Signature` header. WooCommerce signs the raw + * request body with HMAC-SHA256 and base64-encodes the digest. + */ + static verifyWebhookSignature( + rawBody: string | Buffer, + signature: string, + secret: string, + ): boolean { + if (!signature || !secret) return false; + + const expected = createHmac('sha256', secret) + .update(rawBody) + .digest('base64'); + const expectedBuffer = Buffer.from(expected); + const actualBuffer = Buffer.from(signature.trim()); + + if (expectedBuffer.length !== actualBuffer.length) return false; + return timingSafeEqual(expectedBuffer, actualBuffer); + } +} diff --git a/backend/src/integrations/woocommerce/woocommerce-service.ts b/backend/src/integrations/woocommerce/woocommerce-service.ts new file mode 100644 index 00000000..8ea8c5ec --- /dev/null +++ b/backend/src/integrations/woocommerce/woocommerce-service.ts @@ -0,0 +1,156 @@ +import { WooCommerceClient } from './woocommerce-client.js'; +import { + toPaymentDraft, + wooCommerceOrderSchema, + wooCommerceWebhookTopicSchema, + type WooCommerceOrder, + type WooCommerceOrderStatus, + type WooCommercePaymentDraft, + type WooCommercePaymentStatus, + type WooCommerceWebhookEvent, + type WooCommerceWebhookTopic, +} from './types.js'; + +const PAYMENT_STATUS_MAP: Record< + WooCommercePaymentStatus, + WooCommerceOrderStatus +> = { + paid: 'completed', + pending: 'processing', + failed: 'failed', + refunded: 'refunded', +}; + +export interface ProcessWebhookInput { + rawBody: string | Buffer; + signature: string; + topic: string; + deliveryId?: string; +} + +/** + * Bridges WooCommerce orders and AgenticPay payments. + * + * Inbound webhooks are verified before the order is normalised into a payment + * draft. Once a payment settles, the corresponding order status is written back + * to the store and an order note is added for merchant visibility. + */ +export class WooCommerceService { + private client: WooCommerceClient | null = null; + private webhookSecret?: string; + + constructor(client?: WooCommerceClient, webhookSecret?: string) { + if (client) this.client = client; + this.webhookSecret = + webhookSecret ?? process.env.WOOCOMMERCE_WEBHOOK_SECRET; + } + + getClient(): WooCommerceClient { + if (!this.client) { + const storeUrl = process.env.WOOCOMMERCE_STORE_URL; + const consumerKey = process.env.WOOCOMMERCE_CONSUMER_KEY; + const consumerSecret = process.env.WOOCOMMERCE_CONSUMER_SECRET; + + if (!storeUrl || !consumerKey || !consumerSecret) { + throw new Error( + 'WOOCOMMERCE_STORE_URL, WOOCOMMERCE_CONSUMER_KEY and WOOCOMMERCE_CONSUMER_SECRET must be configured', + ); + } + + this.client = new WooCommerceClient({ + storeUrl, + consumerKey, + consumerSecret, + }); + } + return this.client; + } + + isConfigured(): boolean { + return Boolean( + this.client || + (process.env.WOOCOMMERCE_STORE_URL && + process.env.WOOCOMMERCE_CONSUMER_KEY && + process.env.WOOCOMMERCE_CONSUMER_SECRET), + ); + } + + /** + * Returns the payment draft that should be sent to the AgenticPay checkout + * for an existing order. + */ + async getPaymentDraft(orderId: number): Promise { + const order = await this.getClient().getOrder(orderId); + return toPaymentDraft(order); + } + + /** + * Verifies and parses an inbound WooCommerce webhook. + */ + processWebhook(input: ProcessWebhookInput): WooCommerceWebhookEvent { + const secret = this.webhookSecret; + if (!secret) { + throw new Error('WOOCOMMERCE_WEBHOOK_SECRET is not configured'); + } + + if ( + !WooCommerceClient.verifyWebhookSignature( + input.rawBody, + input.signature, + secret, + ) + ) { + throw new Error('Invalid WooCommerce webhook signature'); + } + + const topic = wooCommerceWebhookTopicSchema.parse(input.topic); + const payload = + typeof input.rawBody === 'string' + ? input.rawBody + : input.rawBody.toString('utf8'); + const order = wooCommerceOrderSchema.parse(JSON.parse(payload)); + + return { + topic, + deliveryId: input.deliveryId, + order, + paymentDraft: toPaymentDraft(order), + }; + } + + /** + * Writes an AgenticPay payment status back to the WooCommerce order. + */ + async syncPaymentStatus( + orderId: number, + status: WooCommercePaymentStatus, + transactionId?: string, + ): Promise { + const client = this.getClient(); + const orderStatus = PAYMENT_STATUS_MAP[status]; + if (!orderStatus) { + throw new Error(`Unsupported payment status: ${status}`); + } + + const order = await client.updateOrder(orderId, { status: orderStatus }); + + const note = [ + `AgenticPay payment ${status}.`, + transactionId ? `Transaction: ${transactionId}.` : '', + ] + .filter(Boolean) + .join(' '); + + await client.createOrderNote(orderId, note); + + return order; + } +} + +export const wooCommerceService = new WooCommerceService(); + +export function isWooCommerceTopic( + value: string, +): value is WooCommerceWebhookTopic { + return wooCommerceWebhookTopicSchema.safeParse(value).success; +} diff --git a/backend/src/integrations/xero/__tests__/xero-client.test.ts b/backend/src/integrations/xero/__tests__/xero-client.test.ts new file mode 100644 index 00000000..faf4d785 --- /dev/null +++ b/backend/src/integrations/xero/__tests__/xero-client.test.ts @@ -0,0 +1,93 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { XeroClient } from '../xero-client.js'; + +describe('XeroClient', () => { + const originalFetch = global.fetch; + let client: XeroClient; + + beforeEach(() => { + client = new XeroClient({ + accessToken: 'xero-token', + tenantId: 'tenant-1', + timeoutMs: 1000, + }); + }); + + afterEach(() => { + global.fetch = originalFetch; + vi.restoreAllMocks(); + }); + + it('finds a contact by email with tenant scoping', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + statusText: 'OK', + json: async () => ({ + Contacts: [{ ContactID: 'c1', Name: 'Acme', EmailAddress: 'a@b.test' }], + }), + } as unknown as Response); + + const contact = await client.findContactByEmail('a@b.test'); + + expect(contact?.ContactID).toBe('c1'); + const [url, options] = (global.fetch as any).mock.calls[0]; + expect(url).toBe( + 'https://api.xero.com/api.xro/2.0/Contacts?where=EmailAddress%3D%3D%22a%40b.test%22', + ); + expect(options.headers['xero-tenant-id']).toBe('tenant-1'); + expect(options.headers.Authorization).toBe('Bearer xero-token'); + }); + + it('creates an invoice with an idempotency key', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + status: 201, + statusText: 'Created', + json: async () => ({ Invoices: [{ InvoiceID: 'inv-1' }] }), + } as unknown as Response); + + const invoice = await client.createInvoice( + { Type: 'ACCREC', Reference: 'INV-1' }, + 'idem-1', + ); + + expect(invoice.InvoiceID).toBe('inv-1'); + const [url, options] = (global.fetch as any).mock.calls[0]; + expect(url).toBe('https://api.xero.com/api.xro/2.0/Invoices'); + expect(options.method).toBe('POST'); + expect(options.headers['Idempotency-Key']).toBe('idem-1'); + expect(JSON.parse(options.body)).toEqual({ + Invoices: [{ Type: 'ACCREC', Reference: 'INV-1' }], + }); + }); + + it('creates a payment with an idempotency key', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + status: 201, + statusText: 'Created', + json: async () => ({ Payments: [{ PaymentID: 'pay-1' }] }), + } as unknown as Response); + + const payment = await client.createPayment( + { Invoice: { InvoiceID: 'inv-1' }, Amount: 10 }, + 'pay-idem', + ); + + expect(payment.PaymentID).toBe('pay-1'); + const [, options] = (global.fetch as any).mock.calls[0]; + expect(options.headers['Idempotency-Key']).toBe('pay-idem'); + }); + + it('throws on API errors', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: false, + status: 429, + statusText: 'Too Many Requests', + text: async () => 'Rate limit exceeded', + } as unknown as Response); + + await expect(client.getContacts()).rejects.toThrow(/Xero API error 429/); + }); +}); diff --git a/backend/src/integrations/xero/__tests__/xero-service.test.ts b/backend/src/integrations/xero/__tests__/xero-service.test.ts new file mode 100644 index 00000000..1a5e2508 --- /dev/null +++ b/backend/src/integrations/xero/__tests__/xero-service.test.ts @@ -0,0 +1,168 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { XeroService } from '../xero-service.js'; +import type { XeroClient } from '../xero-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('XeroService', () => { + let client: XeroClient; + let service: XeroService; + + beforeEach(() => { + client = { + findContactByEmail: vi.fn(), + findContactByName: vi.fn(), + createContact: vi.fn(), + findInvoiceByReference: vi.fn(), + createInvoice: vi.fn(), + createPayment: vi.fn(), + getContacts: vi.fn(), + } as unknown as XeroClient; + + service = new XeroService(client, { + defaultAccountCode: '200', + paymentAccountCode: '090', + }); + }); + + it('creates the contact and invoice on first sync', async () => { + (client.findContactByEmail as any).mockResolvedValue(null); + (client.createContact as any).mockResolvedValue({ + ContactID: 'contact_1', + Name: 'Acme Corp', + }); + (client.findInvoiceByReference as any).mockResolvedValue(null); + (client.createInvoice as any).mockResolvedValue({ InvoiceID: 'invoice_1' }); + + const result = await service.syncInvoice(invoice); + + expect(result).toEqual({ + externalId: 'inv_123', + providerId: 'invoice_1', + action: 'created', + customerId: 'contact_1', + }); + expect(client.createContact).toHaveBeenCalledWith({ + Name: 'Acme Corp', + EmailAddress: 'billing@acme.test', + }); + expect(client.createInvoice).toHaveBeenCalledWith( + expect.objectContaining({ + Type: 'ACCREC', + Contact: { ContactID: 'contact_1' }, + Reference: 'INV-123', + CurrencyCode: 'USD', + LineItems: [ + expect.objectContaining({ + Description: 'Platform fee', + AccountCode: '200', + }), + ], + }), + 'inv_123', + ); + }); + + it('skips an invoice that already exists', async () => { + (client.findContactByEmail as any).mockResolvedValue({ + ContactID: 'contact_1', + Name: 'Acme Corp', + }); + (client.findInvoiceByReference as any).mockResolvedValue({ + InvoiceID: 'invoice_1', + }); + + const result = await service.syncInvoice(invoice); + + expect(result.action).toBe('skipped'); + expect(result.providerId).toBe('invoice_1'); + expect(client.createInvoice).not.toHaveBeenCalled(); + }); + + it('falls back to contact name when no email is present', async () => { + (client.findContactByName as any).mockResolvedValue({ + ContactID: 'contact_2', + Name: 'Acme Corp', + }); + (client.findInvoiceByReference as any).mockResolvedValue(null); + (client.createInvoice as any).mockResolvedValue({ InvoiceID: 'invoice_1' }); + + await service.syncInvoice({ + ...invoice, + customer: { externalId: 'cus_123', name: 'Acme Corp' }, + }); + + expect(client.findContactByName).toHaveBeenCalledWith('Acme Corp'); + expect(client.createContact).not.toHaveBeenCalled(); + }); + + it('records a payment against the invoice reference', async () => { + (client.findInvoiceByReference as any).mockResolvedValue({ + InvoiceID: 'invoice_1', + }); + (client.createPayment as any).mockResolvedValue({ PaymentID: 'payment_1' }); + + const result = await service.recordPayment({ + externalId: 'pay_1', + invoiceExternalId: 'INV-123', + amount: 100, + currency: 'USD', + paidAt: '2026-10-15', + reference: 'tx_1', + }); + + expect(result).toEqual({ + externalId: 'pay_1', + providerId: 'payment_1', + action: 'created', + }); + expect(client.createPayment).toHaveBeenCalledWith( + expect.objectContaining({ + Invoice: { InvoiceID: 'invoice_1' }, + Account: { Code: '090' }, + Amount: 100, + Date: '2026-10-15', + Reference: 'tx_1', + }), + 'pay_1', + ); + }); + + it('fails when the invoice is missing', async () => { + (client.findInvoiceByReference as any).mockResolvedValue(null); + + await expect( + service.recordPayment({ + externalId: 'pay_1', + invoiceExternalId: 'missing', + amount: 10, + currency: 'USD', + }), + ).rejects.toThrow(/was not found/); + }); + + it('requires a payment account code', async () => { + const unconfigured = new XeroService(client); + + await expect( + unconfigured.recordPayment({ + externalId: 'pay_1', + invoiceExternalId: 'INV-123', + amount: 10, + currency: 'USD', + }), + ).rejects.toThrow(/PAYMENT_ACCOUNT_CODE/); + }); +}); diff --git a/backend/src/integrations/xero/index.ts b/backend/src/integrations/xero/index.ts new file mode 100644 index 00000000..e442847b --- /dev/null +++ b/backend/src/integrations/xero/index.ts @@ -0,0 +1,3 @@ +export { XeroClient } from './xero-client.js'; +export { XeroService, xeroService } from './xero-service.js'; +export * from './types.js'; diff --git a/backend/src/integrations/xero/types.ts b/backend/src/integrations/xero/types.ts new file mode 100644 index 00000000..21c4039a --- /dev/null +++ b/backend/src/integrations/xero/types.ts @@ -0,0 +1,38 @@ +import type { AccountingInvoice, AccountingPayment } from '../accounting/types.js'; + +export interface XeroConfig { + accessToken: string; + tenantId: string; + baseUrl?: string; + timeoutMs?: number; +} + +export interface XeroContact { + ContactID: string; + Name: string; + EmailAddress?: string; +} + +export interface XeroInvoice { + InvoiceID: string; + InvoiceNumber?: string; + Reference?: string; + Status?: string; +} + +export interface XeroPayment { + PaymentID: string; + Invoice?: { InvoiceID: string }; + Amount?: number; + Date?: string; + Reference?: string; +} + +export interface XeroSyncOptions { + defaultAccountCode?: string; + paymentAccountCode?: string; + invoiceStatus?: string; +} + +export type XeroInvoiceInput = AccountingInvoice; +export type XeroPaymentInput = AccountingPayment; diff --git a/backend/src/integrations/xero/xero-client.ts b/backend/src/integrations/xero/xero-client.ts new file mode 100644 index 00000000..3dada4b9 --- /dev/null +++ b/backend/src/integrations/xero/xero-client.ts @@ -0,0 +1,160 @@ +import type { + XeroConfig, + XeroContact, + XeroInvoice, + XeroPayment, +} from './types.js'; + +interface XeroCollection { + Contacts?: T[]; + Invoices?: T[]; + Payments?: T[]; +} + +/** + * Xero Accounting API client. + * + * Requests are scoped to a single tenant via the `xero-tenant-id` header, and + * OAuth access tokens must be supplied by the caller. + */ +export class XeroClient { + private readonly accessToken: string; + private readonly tenantId: string; + private readonly baseUrl: string; + private readonly timeoutMs: number; + + constructor(config: XeroConfig) { + if (!config.accessToken || !config.tenantId) { + throw new Error('Xero accessToken and tenantId are required'); + } + + this.accessToken = config.accessToken; + this.tenantId = config.tenantId; + this.baseUrl = ( + config.baseUrl ?? 'https://api.xero.com/api.xro/2.0' + ).replace(/\/+$/, ''); + this.timeoutMs = config.timeoutMs ?? 10_000; + } + + private async request( + method: string, + path: string, + body?: unknown, + extraHeaders: Record = {}, + ): Promise { + const response = await fetch(`${this.baseUrl}${path}`, { + method, + headers: { + Authorization: `Bearer ${this.accessToken}`, + 'xero-tenant-id': this.tenantId, + Accept: 'application/json', + 'Content-Type': 'application/json', + ...extraHeaders, + }, + body: body === undefined ? undefined : JSON.stringify(body), + signal: AbortSignal.timeout(this.timeoutMs), + }); + + if (!response.ok) { + const errorText = await response.text().catch(() => ''); + throw new Error( + `Xero API error ${response.status}: ${response.statusText} – ${errorText}`, + ); + } + + if (response.status === 204) { + return {} as T; + } + + return (await response.json()) as T; + } + + async getContacts(): Promise { + const result = await this.request>( + 'GET', + '/Contacts?page=1', + ); + return result.Contacts ?? []; + } + + async findContactByEmail(email: string): Promise { + const where = encodeURIComponent(`EmailAddress=="${escapeWhere(email)}"`); + const result = await this.request>( + 'GET', + `/Contacts?where=${where}`, + ); + return result.Contacts?.[0] ?? null; + } + + async findContactByName(name: string): Promise { + const where = encodeURIComponent(`Name=="${escapeWhere(name)}"`); + const result = await this.request>( + 'GET', + `/Contacts?where=${where}`, + ); + return result.Contacts?.[0] ?? null; + } + + async createContact(contact: { + Name: string; + EmailAddress?: string; + }): Promise { + const result = await this.request>( + 'POST', + '/Contacts', + { Contacts: [contact] }, + ); + const created = result.Contacts?.[0]; + if (!created) { + throw new Error('Xero did not return the created contact'); + } + return created; + } + + async findInvoiceByReference(reference: string): Promise { + const where = encodeURIComponent(`Reference=="${escapeWhere(reference)}"`); + const result = await this.request>( + 'GET', + `/Invoices?where=${where}`, + ); + return result.Invoices?.[0] ?? null; + } + + async createInvoice( + invoice: Record, + idempotencyKey: string, + ): Promise { + const result = await this.request>( + 'POST', + '/Invoices', + { Invoices: [invoice] }, + { 'Idempotency-Key': idempotencyKey }, + ); + const created = result.Invoices?.[0]; + if (!created) { + throw new Error('Xero did not return the created invoice'); + } + return created; + } + + async createPayment( + payment: Record, + idempotencyKey: string, + ): Promise { + const result = await this.request>( + 'POST', + '/Payments', + { Payments: [payment] }, + { 'Idempotency-Key': idempotencyKey }, + ); + const created = result.Payments?.[0]; + if (!created) { + throw new Error('Xero did not return the created payment'); + } + return created; + } +} + +function escapeWhere(value: string): string { + return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); +} diff --git a/backend/src/integrations/xero/xero-service.ts b/backend/src/integrations/xero/xero-service.ts new file mode 100644 index 00000000..20273428 --- /dev/null +++ b/backend/src/integrations/xero/xero-service.ts @@ -0,0 +1,178 @@ +import { XeroClient } from './xero-client.js'; +import type { + XeroConfig, + XeroInvoiceInput, + XeroPaymentInput, + XeroSyncOptions, +} from './types.js'; +import { + accountingInvoiceSchema, + accountingPaymentSchema, + toDateString, + type InvoiceSyncResult, + type PaymentSyncResult, +} from '../accounting/types.js'; + +/** + * Syncs AgenticPay invoices and payments into Xero. + * + * Invoices are matched by their `Reference` field and payments are submitted + * with the payment external id as the Xero idempotency key, making retries safe. + */ +export class XeroService { + private client: XeroClient | null = null; + private readonly defaultAccountCode?: string; + private readonly paymentAccountCode?: string; + private readonly invoiceStatus: string; + + constructor(client?: XeroClient, options: XeroSyncOptions = {}) { + if (client) this.client = client; + this.defaultAccountCode = + options.defaultAccountCode ?? process.env.XERO_DEFAULT_ACCOUNT_CODE; + this.paymentAccountCode = + options.paymentAccountCode ?? process.env.XERO_PAYMENT_ACCOUNT_CODE; + this.invoiceStatus = + options.invoiceStatus ?? process.env.XERO_INVOICE_STATUS ?? 'AUTHORISED'; + } + + getClient(): XeroClient { + if (!this.client) { + const accessToken = process.env.XERO_ACCESS_TOKEN; + const tenantId = process.env.XERO_TENANT_ID; + + if (!accessToken || !tenantId) { + throw new Error('XERO_ACCESS_TOKEN and XERO_TENANT_ID must be configured'); + } + + const config: XeroConfig = { + accessToken, + tenantId, + baseUrl: process.env.XERO_API_URL, + }; + this.client = new XeroClient(config); + } + return this.client; + } + + isConfigured(): boolean { + return Boolean( + this.client || + (process.env.XERO_ACCESS_TOKEN && process.env.XERO_TENANT_ID), + ); + } + + async ping(): Promise { + try { + await this.getClient().getContacts(); + return true; + } catch { + return false; + } + } + + async syncInvoice(input: XeroInvoiceInput): Promise { + const invoice = accountingInvoiceSchema.parse(input); + const client = this.getClient(); + const reference = invoice.reference ?? invoice.externalId; + + const contact = await this.ensureContact(invoice); + const existing = await client.findInvoiceByReference(reference); + + if (existing) { + return { + externalId: invoice.externalId, + providerId: existing.InvoiceID, + action: 'skipped', + customerId: contact.ContactID, + }; + } + + const created = await client.createInvoice( + { + Type: 'ACCREC', + Contact: { ContactID: contact.ContactID }, + Date: toDateString(invoice.invoiceDate), + DueDate: toDateString( + invoice.dueDate, + new Date(invoice.invoiceDate ?? Date.now()), + ), + LineAmountTypes: 'Exclusive', + Reference: reference, + CurrencyCode: invoice.currency, + Status: this.invoiceStatus, + LineItems: invoice.lines.map((line) => ({ + Description: line.description, + Quantity: line.quantity, + UnitAmount: line.unitAmount, + ...(line.accountCode || this.defaultAccountCode + ? { AccountCode: line.accountCode ?? this.defaultAccountCode } + : {}), + ...(line.taxType ? { TaxType: line.taxType } : {}), + })), + }, + invoice.externalId, + ); + + return { + externalId: invoice.externalId, + providerId: created.InvoiceID, + action: 'created', + customerId: contact.ContactID, + }; + } + + async recordPayment(input: XeroPaymentInput): Promise { + const payment = accountingPaymentSchema.parse(input); + const client = this.getClient(); + + if (!this.paymentAccountCode) { + throw new Error( + 'XERO_PAYMENT_ACCOUNT_CODE (or paymentAccountCode option) is required to record Xero payments', + ); + } + + const invoice = await client.findInvoiceByReference( + payment.invoiceExternalId, + ); + if (!invoice) { + throw new Error( + `Xero invoice ${payment.invoiceExternalId} was not found`, + ); + } + + const created = await client.createPayment( + { + Invoice: { InvoiceID: invoice.InvoiceID }, + Account: { Code: this.paymentAccountCode }, + Date: toDateString(payment.paidAt), + Amount: payment.amount, + Reference: payment.reference ?? payment.externalId, + }, + payment.externalId, + ); + + return { + externalId: payment.externalId, + providerId: created.PaymentID, + action: 'created', + }; + } + + private async ensureContact(invoice: XeroInvoiceInput) { + const client = this.getClient(); + const { customer } = invoice; + + const existing = customer.email + ? await client.findContactByEmail(customer.email) + : await client.findContactByName(customer.name); + + if (existing) return existing; + + return client.createContact({ + Name: customer.name, + ...(customer.email ? { EmailAddress: customer.email } : {}), + }); + } +} + +export const xeroService = new XeroService(); diff --git a/backend/src/routes/quickbooks.ts b/backend/src/routes/quickbooks.ts new file mode 100644 index 00000000..3baec9af --- /dev/null +++ b/backend/src/routes/quickbooks.ts @@ -0,0 +1,47 @@ +import { Router } from 'express'; +import { asyncHandler } from '../middleware/errorHandler.js'; +import { validate } from '../middleware/validate.js'; +import { + accountingInvoiceSchema, + accountingPaymentSchema, +} from '../integrations/accounting/types.js'; +import { quickBooksService } from '../integrations/quickbooks/index.js'; + +export const quickBooksRouter = Router(); + +quickBooksRouter.get( + '/status', + asyncHandler(async (_req, res) => { + const configured = quickBooksService.isConfigured(); + const reachable = configured ? await quickBooksService.ping() : false; + res.json({ configured, reachable }); + }), +); + +/** + * Create a QuickBooks invoice from an AgenticPay invoice. Invoices with the + * same DocNumber are left untouched. + */ +quickBooksRouter.post( + '/invoices/sync', + validate(accountingInvoiceSchema), + asyncHandler(async (req, res) => { + const result = await quickBooksService.syncInvoice(req.body); + res.status(result.action === 'created' ? 201 : 200).json({ + success: true, + result, + }); + }), +); + +/** + * Record a payment against a QuickBooks invoice. + */ +quickBooksRouter.post( + '/payments/sync', + validate(accountingPaymentSchema), + asyncHandler(async (req, res) => { + const result = await quickBooksService.recordPayment(req.body); + res.status(201).json({ success: true, result }); + }), +); diff --git a/backend/src/routes/salesforce.ts b/backend/src/routes/salesforce.ts new file mode 100644 index 00000000..b4028d4f --- /dev/null +++ b/backend/src/routes/salesforce.ts @@ -0,0 +1,51 @@ +import { Router } from 'express'; +import { asyncHandler } from '../middleware/errorHandler.js'; +import { validate } from '../middleware/validate.js'; +import { + accountingInvoiceSchema, + accountingPaymentSchema, +} from '../integrations/accounting/types.js'; +import { salesforceBillingService } from '../integrations/salesforce/index.js'; + +export const salesforceRouter = Router(); + +salesforceRouter.get( + '/status', + asyncHandler(async (_req, res) => { + const configured = salesforceBillingService.isConfigured(); + const reachable = configured + ? await salesforceBillingService.ping() + : false; + res.json({ configured, reachable }); + }), +); + +/** + * Create or update a Salesforce Billing invoice from an AgenticPay invoice. + */ +salesforceRouter.post( + '/invoices/sync', + validate(accountingInvoiceSchema), + asyncHandler(async (req, res) => { + const result = await salesforceBillingService.syncInvoice(req.body); + res.status(result.action === 'created' ? 201 : 200).json({ + success: true, + result, + }); + }), +); + +/** + * Record a payment against a previously synced Salesforce Billing invoice. + */ +salesforceRouter.post( + '/payments/sync', + validate(accountingPaymentSchema), + asyncHandler(async (req, res) => { + const result = await salesforceBillingService.recordPayment(req.body); + res.status(result.action === 'created' ? 201 : 200).json({ + success: true, + result, + }); + }), +); diff --git a/backend/src/routes/woocommerce.ts b/backend/src/routes/woocommerce.ts new file mode 100644 index 00000000..e6cbffd7 --- /dev/null +++ b/backend/src/routes/woocommerce.ts @@ -0,0 +1,111 @@ +import { Router } from 'express'; +import { asyncHandler, AppError } from '../middleware/errorHandler.js'; +import { validate } from '../middleware/validate.js'; +import { + wooCommercePaymentStatusUpdateSchema, + type WooCommercePaymentStatus, +} from '../integrations/woocommerce/index.js'; +import { wooCommerceService } from '../integrations/woocommerce/woocommerce-service.js'; + +export const wooCommerceRouter = Router(); + +const PAYMENT_STATUSES: WooCommercePaymentStatus[] = [ + 'paid', + 'pending', + 'failed', + 'refunded', +]; + +function parseOrderId(value: string | string[] | undefined): number { + const orderId = Number(Array.isArray(value) ? value[0] : value); + if (!Number.isInteger(orderId) || orderId <= 0) { + throw new AppError(400, 'orderId must be a positive integer', 'VALIDATION_ERROR'); + } + return orderId; +} + +wooCommerceRouter.get('/status', (_req, res) => { + res.json({ configured: wooCommerceService.isConfigured() }); +}); + +/** + * Build the payment draft used to open an AgenticPay checkout for an order. + */ +wooCommerceRouter.get( + '/orders/:orderId/payment-draft', + asyncHandler(async (req, res) => { + const orderId = parseOrderId(req.params.orderId); + const draft = await wooCommerceService.getPaymentDraft(orderId); + res.json({ success: true, paymentDraft: draft }); + }), +); + +/** + * Push an AgenticPay payment status back to WooCommerce. + */ +wooCommerceRouter.post( + '/orders/:orderId/status', + validate(wooCommercePaymentStatusUpdateSchema), + asyncHandler(async (req, res) => { + const orderId = parseOrderId(req.params.orderId); + const status = req.body.status as WooCommercePaymentStatus; + + if (!PAYMENT_STATUSES.includes(status)) { + throw new AppError( + 400, + `status must be one of: ${PAYMENT_STATUSES.join(', ')}`, + 'VALIDATION_ERROR', + ); + } + + const order = await wooCommerceService.syncPaymentStatus( + orderId, + status, + req.body.transactionId, + ); + res.json({ success: true, order: { id: order.id, status: order.status } }); + }), +); + +/** + * WooCommerce webhook receiver. The raw body is preserved by + * `webhookJsonParser` so the HMAC signature can be verified. + */ +wooCommerceRouter.post( + '/webhooks', + asyncHandler(async (req, res) => { + const topic = String(req.headers['x-wc-webhook-topic'] ?? ''); + const signature = String(req.headers['x-wc-webhook-signature'] ?? ''); + const deliveryId = req.headers['x-wc-webhook-delivery-id']; + const rawBody = req.rawBody; + + if (!topic || !signature || !rawBody) { + throw new AppError( + 400, + 'Missing WooCommerce webhook topic, signature or body', + 'VALIDATION_ERROR', + ); + } + + try { + const event = wooCommerceService.processWebhook({ + rawBody, + signature, + topic, + deliveryId: typeof deliveryId === 'string' ? deliveryId : undefined, + }); + + res.status(200).json({ + success: true, + topic: event.topic, + deliveryId: event.deliveryId, + orderId: event.order.id, + paymentDraft: event.paymentDraft, + }); + } catch (error) { + const message = + error instanceof Error ? error.message : 'Invalid WooCommerce webhook'; + throw new AppError(401, message, 'WEBHOOK_VERIFICATION_FAILED'); + } + }), +); diff --git a/backend/src/routes/xero.ts b/backend/src/routes/xero.ts new file mode 100644 index 00000000..42a53214 --- /dev/null +++ b/backend/src/routes/xero.ts @@ -0,0 +1,47 @@ +import { Router } from 'express'; +import { asyncHandler } from '../middleware/errorHandler.js'; +import { validate } from '../middleware/validate.js'; +import { + accountingInvoiceSchema, + accountingPaymentSchema, +} from '../integrations/accounting/types.js'; +import { xeroService } from '../integrations/xero/index.js'; + +export const xeroRouter = Router(); + +xeroRouter.get( + '/status', + asyncHandler(async (_req, res) => { + const configured = xeroService.isConfigured(); + const reachable = configured ? await xeroService.ping() : false; + res.json({ configured, reachable }); + }), +); + +/** + * Create a Xero invoice from an AgenticPay invoice. Existing invoices with the + * same reference are left untouched. + */ +xeroRouter.post( + '/invoices/sync', + validate(accountingInvoiceSchema), + asyncHandler(async (req, res) => { + const result = await xeroService.syncInvoice(req.body); + res.status(result.action === 'created' ? 201 : 200).json({ + success: true, + result, + }); + }), +); + +/** + * Record a payment against a Xero invoice reference. + */ +xeroRouter.post( + '/payments', + validate(accountingPaymentSchema), + asyncHandler(async (req, res) => { + const result = await xeroService.recordPayment(req.body); + res.status(201).json({ success: true, result }); + }), +); diff --git a/docs/ACCOUNTING_INTEGRATIONS.md b/docs/ACCOUNTING_INTEGRATIONS.md new file mode 100644 index 00000000..cf623409 --- /dev/null +++ b/docs/ACCOUNTING_INTEGRATIONS.md @@ -0,0 +1,198 @@ +# E-commerce & Accounting Integrations + +AgenticPay ships first-party integrations that connect Stellar and fiat payments +to the systems merchants already use: + +| Integration | Issue | Purpose | +| ----------- | ----- | ------- | +| WooCommerce | #954 | Turn WooCommerce orders into AgenticPay payments and write settlement status back to the store | +| Salesforce Billing | #955 | Sync AgenticPay invoices and payments into Salesforce Billing | +| Xero | #956 | Sync AgenticPay invoices and payments into Xero Accounting | +| QuickBooks Online | #957 | Sync AgenticPay invoices and payments into QuickBooks | + +All four follow the same layout as the existing Zapier/Intercom integrations: +a typed client, a service that owns the business logic, an `index.ts` barrel, a +thin Express router and Vitest unit tests. + +--- + +## WooCommerce + +``` +backend/src/integrations/woocommerce/ + types.ts # order schema (zod), payment draft mapping + woocommerce-client.ts # REST API v3 client + webhook signature verification + woocommerce-service.ts # payment drafts, webhook handling, status writeback + __tests__/ # client + service tests +backend/src/routes/woocommerce.ts +``` + +WooCommerce order → payment draft → AgenticPay payment → status writeback: + +``` +POST /api/v1/integrations/woocommerce/orders/:orderId/status +POST /api/v1/integrations/woocommerce/webhooks # X-WC-Webhook-Signature +GET /api/v1/integrations/woocommerce/orders/:orderId/payment-draft +GET /api/v1/integrations/woocommerce/status +``` + +The webhook route is mounted behind `webhookJsonParser` so the raw body is +preserved for HMAC-SHA256 verification (`X-WC-Webhook-Signature`, +base64-encoded digest). + +```ts +import { wooCommerceService } from './integrations/woocommerce/index.js'; + +const draft = await wooCommerceService.getPaymentDraft(1001); +// → { reference, orderId, amount, currency, customer, lines, metadata } + +const event = wooCommerceService.processWebhook({ + rawBody, // raw request body + signature: req.headers['x-wc-webhook-signature'], + topic: req.headers['x-wc-webhook-topic'], +}); + +await wooCommerceService.syncPaymentStatus(1001, 'paid', 'tx_abc'); +``` + +Environment: `WOOCOMMERCE_STORE_URL`, `WOOCOMMERCE_CONSUMER_KEY`, +`WOOCOMMERCE_CONSUMER_SECRET`, `WOOCOMMERCE_WEBHOOK_SECRET`. + +--- + +## Salesforce Billing + +``` +backend/src/integrations/salesforce/ + types.ts # client config + object name mapping + salesforce-client.ts # REST API client (SOQL + sObjects) + salesforce-billing-service.ts # idempotent account/invoice/payment sync + __tests__/ +backend/src/routes/salesforce.ts +``` + +``` +GET /api/v1/integrations/salesforce/status +POST /api/v1/integrations/salesforce/invoices/sync +POST /api/v1/integrations/salesforce/payments/sync +``` + +Records are keyed by an external id field (`External_Id__c` by default) so +repeated syncs update existing records instead of duplicating them. Object +names default to the Salesforce Billing package (`blng__Invoice__c`, +`blng__InvoiceLine__c`, `blng__Payment__c`) and can be overridden per call. + +```ts +await salesforceBillingService.syncInvoice({ + externalId: 'inv_123', + customer: { externalId: 'cus_123', name: 'Acme', email: 'ap@acme.test' }, + currency: 'USD', + lines: [{ description: 'Platform fee', quantity: 1, unitAmount: 100 }], +}); +``` + +Environment: `SALESFORCE_INSTANCE_URL`, `SALESFORCE_ACCESS_TOKEN`, +`SALESFORCE_API_VERSION`, `SALESFORCE_INVOICE_STATUS`, +`SALESFORCE_ACCOUNT_EMAIL_FIELD`. + +--- + +## Xero + +``` +backend/src/integrations/xero/ + types.ts # client config + API entity shapes + xero-client.ts # Accounting API client (tenant-scoped) + xero-service.ts # contact/invoice/payment sync + __tests__/ +backend/src/routes/xero.ts +``` + +``` +GET /api/v1/integrations/xero/status +POST /api/v1/integrations/xero/invoices/sync +POST /api/v1/integrations/xero/payments +``` + +Invoices are matched by their `Reference` (falling back to the external id) and +skipped when they already exist. Payments are submitted with the payment +external id as the Xero `Idempotency-Key`, so retries are safe. A payment +account code is required and validated up front. + +```ts +await xeroService.syncInvoice({ + externalId: 'inv_123', + customer: { externalId: 'cus_123', name: 'Acme', email: 'ap@acme.test' }, + currency: 'USD', + lines: [{ description: 'Platform fee', quantity: 1, unitAmount: 100 }], +}); +``` + +Environment: `XERO_ACCESS_TOKEN`, `XERO_TENANT_ID`, `XERO_API_URL`, +`XERO_DEFAULT_ACCOUNT_CODE`, `XERO_PAYMENT_ACCOUNT_CODE`, `XERO_INVOICE_STATUS`. + +--- + +## QuickBooks Online + +``` +backend/src/integrations/quickbooks/ + types.ts # client config + API entity shapes + quickbooks-client.ts # Accounting API client (realm-scoped) + quickbooks-service.ts # customer/invoice/payment sync + __tests__/ +backend/src/routes/quickbooks.ts +``` + +``` +GET /api/v1/integrations/quickbooks/status +POST /api/v1/integrations/quickbooks/invoices/sync +POST /api/v1/integrations/quickbooks/payments/sync +``` + +Customers are matched by `DisplayName` and then email. Invoices are matched by +`DocNumber` and skipped when present. Payments are created with the payment +external id as the QuickBooks `requestid`, making retries idempotent, and are +linked to the invoice through `LinkedTxn`. + +Environment: `QUICKBOOKS_ACCESS_TOKEN`, `QUICKBOOKS_REALM_ID`, +`QUICKBOOKS_API_URL`, `QUICKBOOKS_DEFAULT_ITEM_ID`. + +--- + +## Shared accounting primitives + +The Salesforce, Xero and QuickBooks integrations accept the same normalised +input, defined once in `backend/src/integrations/accounting/types.ts`: + +```ts +type AccountingInvoice = { + externalId: string; + reference?: string; + customer: { externalId: string; name: string; email?: string }; + currency: string; // ISO-4217, defaults to USD + invoiceDate?: string; + dueDate?: string; + lines: Array<{ + description: string; + quantity: number; + unitAmount: number; + taxType?: string; + accountCode?: string; + }>; +}; +``` + +This means the same payload can be fanned out to every provider without +per-provider translation at the call site. + +## Testing + +```bash +cd backend +npx vitest run src/integrations/woocommerce src/integrations/salesforce \ + src/integrations/xero src/integrations/quickbooks +``` + +Each integration covers success and failure paths, including invalid webhook +signatures, missing invoices, and provider API errors.