From 1487bdbb5145600438bd75fddc7b568575d0d3ff Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Sun, 26 Apr 2026 01:41:33 +0000 Subject: [PATCH] CodeRabbit Generated Unit Tests: Add unit tests --- .../__tests__/bookkeeping-workflows.test.ts | 492 ++++++++++++++++++ server/__tests__/routes-charges.test.ts | 213 ++++++++ server/__tests__/routes-wave-oauth.test.ts | 418 +++++++++++++++ server/__tests__/wave-bookkeeping.test.ts | 407 +++++++++++++++ 4 files changed, 1530 insertions(+) create mode 100644 server/__tests__/bookkeeping-workflows.test.ts create mode 100644 server/__tests__/routes-charges.test.ts create mode 100644 server/__tests__/routes-wave-oauth.test.ts create mode 100644 server/__tests__/wave-bookkeeping.test.ts diff --git a/server/__tests__/bookkeeping-workflows.test.ts b/server/__tests__/bookkeeping-workflows.test.ts new file mode 100644 index 0000000..014c548 --- /dev/null +++ b/server/__tests__/bookkeeping-workflows.test.ts @@ -0,0 +1,492 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// All vi.mock calls are hoisted to the top by Vitest's transformer +vi.mock('../storage', () => ({ + storage: { + getTransactions: vi.fn(), + getAccounts: vi.fn(), + listIntegrationsByService: vi.fn(), + getProperties: vi.fn(), + createTransaction: vi.fn(), + }, +})); + +vi.mock('../lib/chittychronicle-logging', () => ({ + logToChronicle: vi.fn().mockResolvedValue(undefined), +})); + +const mockReconcileAccount = vi.fn().mockResolvedValue({ matched: 0, unmatched: 0 }); +vi.mock('../lib/reconciliation', () => ({ + reconcileAccount: mockReconcileAccount, +})); + +vi.mock('../lib/ml-categorization', () => ({ + categorizeTransaction: vi.fn().mockResolvedValue({ category: 'other', confidence: 0.5 }), +})); + +vi.mock('../lib/wave-bookkeeping', () => ({ + WaveBookkeepingClient: vi.fn().mockImplementation(() => ({ + setAccessToken: vi.fn(), + syncToChittyFinance: vi.fn().mockResolvedValue({ invoices: 0, expenses: 0 }), + })), +})); + +vi.mock('../lib/chittyrental-integration', () => ({ + ChittyRentalClient: vi.fn().mockImplementation(() => ({ + syncProperty: vi.fn().mockResolvedValue({ rentPayments: 0, expenses: 0, errors: [] }), + })), +})); + +vi.mock('../lib/stripe-connect', () => ({ + StripeConnectClient: vi.fn().mockImplementation(() => ({ + syncAllConnectedAccounts: vi.fn().mockResolvedValue({ totalSynced: 0, accounts: 0 }), + })), +})); + +vi.mock('../lib/doorloop-integration', () => ({ + DoorLoopClient: vi.fn().mockImplementation(() => ({ + getProperties: vi.fn().mockResolvedValue([]), + syncProperty: vi.fn().mockResolvedValue({ rentPayments: 0, expenses: 0 }), + })), +})); + +import { storage } from '../storage'; +import { + runMonthlyClose, + runQuarterlyTaxPrep, + runYearEndClose, + runWeeklyReconciliation, + runDailyBookkeeping, + WorkflowScheduler, +} from '../lib/bookkeeping-workflows'; + +const mockedStorage = storage as { + getTransactions: ReturnType; + getAccounts: ReturnType; + listIntegrationsByService: ReturnType; + getProperties: ReturnType; + createTransaction: ReturnType; +}; + +beforeEach(() => { + vi.clearAllMocks(); + mockedStorage.getTransactions.mockResolvedValue([]); + mockedStorage.getAccounts.mockResolvedValue([]); + mockedStorage.listIntegrationsByService.mockResolvedValue([]); + mockedStorage.getProperties.mockResolvedValue([]); + mockedStorage.createTransaction.mockResolvedValue({}); +}); + +// ─── runMonthlyClose ───────────────────────────────────────────────────────── + +describe('runMonthlyClose', () => { + it('returns zero profit/loss when there are no transactions', async () => { + const result = await runMonthlyClose('tenant-1', 1, 2024); + expect(result.profitLoss.revenue).toBe(0); + expect(result.profitLoss.expenses).toBe(0); + expect(result.profitLoss.netIncome).toBe(0); + }); + + it('calculates revenue from income transactions within the month', async () => { + mockedStorage.getTransactions.mockResolvedValue([ + { id: 't1', type: 'income', amount: '1000.00', date: new Date('2024-03-15'), category: 'consulting' }, + { id: 't2', type: 'income', amount: '500.00', date: new Date('2024-03-20'), category: 'services' }, + // Outside month — should not be included + { id: 't3', type: 'income', amount: '999.00', date: new Date('2024-04-01'), category: 'consulting' }, + ]); + const result = await runMonthlyClose('tenant-1', 3, 2024); + expect(result.profitLoss.revenue).toBeCloseTo(1500, 2); + }); + + it('calculates expenses from expense transactions within the month', async () => { + mockedStorage.getTransactions.mockResolvedValue([ + { id: 't1', type: 'expense', amount: '-200.00', date: new Date('2024-03-10'), category: 'office' }, + { id: 't2', type: 'expense', amount: '-300.00', date: new Date('2024-03-25'), category: 'software' }, + ]); + const result = await runMonthlyClose('tenant-1', 3, 2024); + expect(result.profitLoss.expenses).toBeCloseTo(500, 2); + }); + + it('computes netIncome = revenue - expenses', async () => { + mockedStorage.getTransactions.mockResolvedValue([ + { id: 't1', type: 'income', amount: '3000.00', date: new Date('2024-06-15'), category: 'rent' }, + { id: 't2', type: 'expense', amount: '-1000.00', date: new Date('2024-06-10'), category: 'maintenance' }, + ]); + const result = await runMonthlyClose('tenant-1', 6, 2024); + expect(result.profitLoss.netIncome).toBeCloseTo(2000, 2); + }); + + it('excludes transactions outside the target month', async () => { + mockedStorage.getTransactions.mockResolvedValue([ + { id: 't1', type: 'income', amount: '5000.00', date: new Date('2024-02-28'), category: 'sales' }, // Feb + { id: 't2', type: 'income', amount: '1000.00', date: new Date('2024-04-01'), category: 'sales' }, // Apr + ]); + const result = await runMonthlyClose('tenant-1', 3, 2024); // March + expect(result.profitLoss.revenue).toBe(0); + }); + + it('calculates balance sheet equity = assets - liabilities', async () => { + mockedStorage.getAccounts.mockResolvedValue([ + { id: 'a1', name: 'Checking', type: 'checking', balance: '10000.00' }, + { id: 'a2', name: 'Savings', type: 'savings', balance: '5000.00' }, + { id: 'a3', name: 'Credit Card', type: 'credit', balance: '2000.00' }, + ]); + const result = await runMonthlyClose('tenant-1', 1, 2024); + expect(result.balanceSheet.assets).toBeCloseTo(15000, 2); + expect(result.balanceSheet.liabilities).toBeCloseTo(2000, 2); + expect(result.balanceSheet.equity).toBeCloseTo(13000, 2); + }); + + it('returns zero balance sheet values when no accounts', async () => { + mockedStorage.getAccounts.mockResolvedValue([]); + const result = await runMonthlyClose('tenant-1', 5, 2024); + expect(result.balanceSheet.assets).toBe(0); + expect(result.balanceSheet.liabilities).toBe(0); + expect(result.balanceSheet.equity).toBe(0); + }); + + it('tax summary deductions exclude personal expense category', async () => { + mockedStorage.getTransactions.mockResolvedValue([ + { id: 't1', type: 'expense', amount: '-500.00', date: new Date('2024-07-10'), category: 'office' }, + { id: 't2', type: 'expense', amount: '-200.00', date: new Date('2024-07-15'), category: 'personal' }, // excluded + ]); + const result = await runMonthlyClose('tenant-1', 7, 2024); + // Only non-personal expense should be in deductions + expect(result.taxSummary.deductions).toBeCloseTo(500, 2); + }); + + it('tax summary income excludes non_taxable category', async () => { + mockedStorage.getTransactions.mockResolvedValue([ + { id: 't1', type: 'income', amount: '1000.00', date: new Date('2024-08-10'), category: 'consulting' }, + { id: 't2', type: 'income', amount: '500.00', date: new Date('2024-08-15'), category: 'non_taxable_grant' }, + ]); + const result = await runMonthlyClose('tenant-1', 8, 2024); + // non_taxable_grant contains 'non_taxable', excluded from taxable income + expect(result.taxSummary.income).toBeCloseTo(1000, 2); + }); + + it('handles investment account type as assets', async () => { + mockedStorage.getAccounts.mockResolvedValue([ + { id: 'a1', name: 'Investment', type: 'investment', balance: '20000.00' }, + ]); + const result = await runMonthlyClose('tenant-1', 1, 2024); + expect(result.balanceSheet.assets).toBeCloseTo(20000, 2); + }); +}); + +// ─── runQuarterlyTaxPrep ────────────────────────────────────────────────────── + +describe('runQuarterlyTaxPrep', () => { + it('returns zeros when no transactions', async () => { + const result = await runQuarterlyTaxPrep('tenant-1', 1, 2024); + expect(result.income).toBe(0); + expect(result.expenses).toBe(0); + expect(result.netIncome).toBe(0); + expect(result.estimatedTax).toBe(0); + expect(result.deductions).toHaveLength(0); + }); + + it('Q1 covers January through March', async () => { + mockedStorage.getTransactions.mockResolvedValue([ + { id: 't1', type: 'income', amount: '3000.00', date: new Date('2024-01-15'), category: 'sales' }, + { id: 't2', type: 'income', amount: '2000.00', date: new Date('2024-03-31'), category: 'sales' }, + { id: 't3', type: 'income', amount: '999.00', date: new Date('2024-04-01'), category: 'sales' }, // Q2 + ]); + const result = await runQuarterlyTaxPrep('tenant-1', 1, 2024); + expect(result.income).toBeCloseTo(5000, 2); + }); + + it('Q2 covers April through June', async () => { + mockedStorage.getTransactions.mockResolvedValue([ + { id: 't1', type: 'income', amount: '1200.00', date: new Date('2024-04-01'), category: 'sales' }, + { id: 't2', type: 'income', amount: '800.00', date: new Date('2024-06-30'), category: 'sales' }, + { id: 't3', type: 'income', amount: '500.00', date: new Date('2024-07-01'), category: 'sales' }, // Q3 + ]); + const result = await runQuarterlyTaxPrep('tenant-1', 2, 2024); + expect(result.income).toBeCloseTo(2000, 2); + }); + + it('Q3 covers July through September', async () => { + mockedStorage.getTransactions.mockResolvedValue([ + { id: 't1', type: 'income', amount: '4000.00', date: new Date('2024-09-30'), category: 'sales' }, + ]); + const result = await runQuarterlyTaxPrep('tenant-1', 3, 2024); + expect(result.income).toBeCloseTo(4000, 2); + }); + + it('Q4 covers October through December', async () => { + mockedStorage.getTransactions.mockResolvedValue([ + { id: 't1', type: 'income', amount: '6000.00', date: new Date('2024-12-31'), category: 'sales' }, + { id: 't2', type: 'income', amount: '1000.00', date: new Date('2024-09-30'), category: 'sales' }, // Q3 + ]); + const result = await runQuarterlyTaxPrep('tenant-1', 4, 2024); + expect(result.income).toBeCloseTo(6000, 2); + }); + + it('calculates estimatedTax at 25% of net income', async () => { + mockedStorage.getTransactions.mockResolvedValue([ + { id: 't1', type: 'income', amount: '8000.00', date: new Date('2024-01-15'), category: 'sales' }, + { id: 't2', type: 'expense', amount: '-3000.00', date: new Date('2024-02-10'), category: 'office' }, + ]); + const result = await runQuarterlyTaxPrep('tenant-1', 1, 2024); + expect(result.netIncome).toBeCloseTo(5000, 2); + expect(result.estimatedTax).toBeCloseTo(1250, 2); // 5000 * 0.25 + }); + + it('estimatedTax is zero when netIncome is negative', async () => { + mockedStorage.getTransactions.mockResolvedValue([ + { id: 't1', type: 'expense', amount: '-5000.00', date: new Date('2024-01-10'), category: 'office' }, + ]); + const result = await runQuarterlyTaxPrep('tenant-1', 1, 2024); + expect(result.netIncome).toBeLessThan(0); + expect(result.estimatedTax).toBe(0); + }); + + it('groups deductions by expense category', async () => { + mockedStorage.getTransactions.mockResolvedValue([ + { id: 't1', type: 'expense', amount: '-300.00', date: new Date('2024-01-10'), category: 'office' }, + { id: 't2', type: 'expense', amount: '-200.00', date: new Date('2024-02-10'), category: 'office' }, + { id: 't3', type: 'expense', amount: '-500.00', date: new Date('2024-03-10'), category: 'software' }, + ]); + const result = await runQuarterlyTaxPrep('tenant-1', 1, 2024); + const officeDeduction = result.deductions.find(d => d.category === 'office'); + const softwareDeduction = result.deductions.find(d => d.category === 'software'); + expect(officeDeduction?.amount).toBeCloseTo(500, 2); + expect(softwareDeduction?.amount).toBeCloseTo(500, 2); + }); +}); + +// ─── runYearEndClose ────────────────────────────────────────────────────────── + +describe('runYearEndClose', () => { + it('returns zeros when no transactions', async () => { + const result = await runYearEndClose('tenant-1', 2024); + expect(result.annual.revenue).toBe(0); + expect(result.annual.expenses).toBe(0); + expect(result.annual.netIncome).toBe(0); + expect(result.metrics.profitMargin).toBe(0); + }); + + it('calculates annual revenue and expenses correctly', async () => { + mockedStorage.getTransactions.mockResolvedValue([ + { id: 't1', type: 'income', amount: '12000.00', date: new Date('2024-06-15'), category: 'sales' }, + { id: 't2', type: 'expense', amount: '-4000.00', date: new Date('2024-09-20'), category: 'office' }, + ]); + const result = await runYearEndClose('tenant-1', 2024); + expect(result.annual.revenue).toBeCloseTo(12000, 2); + expect(result.annual.expenses).toBeCloseTo(4000, 2); + expect(result.annual.netIncome).toBeCloseTo(8000, 2); + }); + + it('excludes transactions from other years', async () => { + mockedStorage.getTransactions.mockResolvedValue([ + { id: 't1', type: 'income', amount: '5000.00', date: new Date('2023-12-31'), category: 'sales' }, + { id: 't2', type: 'income', amount: '3000.00', date: new Date('2025-01-01'), category: 'sales' }, + ]); + const result = await runYearEndClose('tenant-1', 2024); + expect(result.annual.revenue).toBe(0); + }); + + it('calculates avgMonthlyRevenue = revenue / 12', async () => { + mockedStorage.getTransactions.mockResolvedValue([ + { id: 't1', type: 'income', amount: '24000.00', date: new Date('2024-06-01'), category: 'sales' }, + ]); + const result = await runYearEndClose('tenant-1', 2024); + expect(result.metrics.avgMonthlyRevenue).toBeCloseTo(2000, 2); // 24000 / 12 + }); + + it('calculates avgMonthlyExpenses = expenses / 12', async () => { + mockedStorage.getTransactions.mockResolvedValue([ + { id: 't1', type: 'expense', amount: '-6000.00', date: new Date('2024-03-01'), category: 'office' }, + ]); + const result = await runYearEndClose('tenant-1', 2024); + expect(result.metrics.avgMonthlyExpenses).toBeCloseTo(500, 2); // 6000 / 12 + }); + + it('calculates profitMargin = (netIncome / revenue) * 100', async () => { + mockedStorage.getTransactions.mockResolvedValue([ + { id: 't1', type: 'income', amount: '10000.00', date: new Date('2024-05-01'), category: 'sales' }, + { id: 't2', type: 'expense', amount: '-3000.00', date: new Date('2024-05-15'), category: 'office' }, + ]); + const result = await runYearEndClose('tenant-1', 2024); + // netIncome = 7000, revenue = 10000, margin = 70% + expect(result.metrics.profitMargin).toBeCloseTo(70, 1); + }); + + it('profitMargin is zero when revenue is zero', async () => { + const result = await runYearEndClose('tenant-1', 2024); + expect(result.metrics.profitMargin).toBe(0); + }); + + it('calculates estimatedTax as 25% of (taxableIncome - deductions)', async () => { + mockedStorage.getTransactions.mockResolvedValue([ + { id: 't1', type: 'income', amount: '20000.00', date: new Date('2024-06-01'), category: 'sales' }, + { id: 't2', type: 'expense', amount: '-8000.00', date: new Date('2024-06-15'), category: 'office' }, + ]); + const result = await runYearEndClose('tenant-1', 2024); + // taxableIncome=20000, deductions=8000, taxable=12000, estimatedTax=3000 + expect(result.tax.estimatedTax).toBeCloseTo(3000, 2); + }); + + it('estimatedTax is zero when expenses exceed revenue', async () => { + mockedStorage.getTransactions.mockResolvedValue([ + { id: 't1', type: 'income', amount: '1000.00', date: new Date('2024-01-01'), category: 'sales' }, + { id: 't2', type: 'expense', amount: '-5000.00', date: new Date('2024-01-15'), category: 'office' }, + ]); + const result = await runYearEndClose('tenant-1', 2024); + expect(result.tax.estimatedTax).toBe(0); + }); +}); + +// ─── runWeeklyReconciliation ────────────────────────────────────────────────── + +describe('runWeeklyReconciliation', () => { + it('returns zeros when no accounts', async () => { + mockedStorage.getAccounts.mockResolvedValue(null); + const result = await runWeeklyReconciliation('tenant-1'); + expect(result.accounts).toBe(0); + expect(result.reconciled).toBe(0); + expect(result.discrepancies).toBe(0); + }); + + it('returns zeros when accounts list is empty', async () => { + mockedStorage.getAccounts.mockResolvedValue([]); + const result = await runWeeklyReconciliation('tenant-1'); + expect(result.accounts).toBe(0); + }); + + it('counts accounts processed', async () => { + mockReconcileAccount.mockResolvedValue({ matched: 5, unmatched: 1 }); + + mockedStorage.getAccounts.mockResolvedValue([ + { id: 'a1', name: 'Checking', type: 'checking', balance: '10000.00' }, + { id: 'a2', name: 'Savings', type: 'savings', balance: '5000.00' }, + ]); + const result = await runWeeklyReconciliation('tenant-1'); + expect(result.accounts).toBe(2); + }); + + it('accumulates reconciled and discrepancy counts across accounts', async () => { + mockReconcileAccount.mockResolvedValue({ matched: 3, unmatched: 2 }); + + mockedStorage.getAccounts.mockResolvedValue([ + { id: 'a1', name: 'Checking', type: 'checking', balance: '1000.00' }, + { id: 'a2', name: 'Credit', type: 'credit', balance: '500.00' }, + ]); + const result = await runWeeklyReconciliation('tenant-1'); + expect(result.reconciled).toBe(6); // 3 + 3 + expect(result.discrepancies).toBe(4); // 2 + 2 + }); +}); + +// ─── WorkflowScheduler ─────────────────────────────────────────────────────── + +describe('WorkflowScheduler', () => { + it('registers a workflow and runDue skips disabled workflows', async () => { + const scheduler = new WorkflowScheduler(); + scheduler.register({ + id: 'wf-1', + name: 'Disabled Daily', + type: 'daily', + tenantId: 'tenant-1', + enabled: false, + config: {}, + }); + + // Should not throw even when storage has no integrations + mockedStorage.getTransactions.mockResolvedValue([]); + mockedStorage.listIntegrationsByService.mockResolvedValue([]); + await expect(scheduler.runDue()).resolves.toBeUndefined(); + }); + + it('skips workflows whose nextRun is in the future', async () => { + const scheduler = new WorkflowScheduler(); + const futureDate = new Date(Date.now() + 60 * 60 * 1000); // 1 hour from now + + scheduler.register({ + id: 'wf-2', + name: 'Future Workflow', + type: 'daily', + tenantId: 'tenant-1', + enabled: true, + nextRun: futureDate, + config: {}, + }); + + // runDue should not trigger the workflow since nextRun > now + await expect(scheduler.runDue()).resolves.toBeUndefined(); + // getTransactions should not be called since workflow was skipped + expect(mockedStorage.listIntegrationsByService).not.toHaveBeenCalled(); + }); + + it('allows multiple workflow registrations', () => { + const scheduler = new WorkflowScheduler(); + scheduler.register({ id: 'w1', name: 'W1', type: 'daily', tenantId: 't1', enabled: false, config: {} }); + scheduler.register({ id: 'w2', name: 'W2', type: 'weekly', tenantId: 't2', enabled: false, config: {} }); + // No error = registration successful + }); + + it('overwrites existing registration when same id is used', () => { + const scheduler = new WorkflowScheduler(); + scheduler.register({ id: 'w1', name: 'Old Name', type: 'daily', tenantId: 't1', enabled: false, config: {} }); + scheduler.register({ id: 'w1', name: 'New Name', type: 'weekly', tenantId: 't1', enabled: false, config: {} }); + // Should not throw — second registration replaces first + }); + + it('sets nextRun on successful daily workflow execution', async () => { + const scheduler = new WorkflowScheduler(); + const workflow = { + id: 'wf-daily', + name: 'Daily Bookkeeping', + type: 'daily' as const, + tenantId: 'tenant-1', + enabled: true, + nextRun: undefined, + lastRun: undefined, + config: {}, + }; + + mockedStorage.listIntegrationsByService.mockResolvedValue([]); + mockedStorage.getTransactions.mockResolvedValue([]); + mockedStorage.getProperties.mockResolvedValue(null); + + scheduler.register(workflow); + await scheduler.runDue(); + + // After running, lastRun and nextRun should be set + expect(workflow.lastRun).toBeDefined(); + expect(workflow.nextRun).toBeDefined(); + // nextRun should be ~24 hours in the future + const diffMs = (workflow.nextRun as Date).getTime() - Date.now(); + expect(diffMs).toBeGreaterThan(23 * 60 * 60 * 1000); + expect(diffMs).toBeLessThan(25 * 60 * 60 * 1000); + }); +}); + +// ─── runDailyBookkeeping ───────────────────────────────────────────────────── + +describe('runDailyBookkeeping', () => { + it('returns synced counts and categorized count', async () => { + mockedStorage.listIntegrationsByService.mockResolvedValue([]); + mockedStorage.getTransactions.mockResolvedValue([]); + mockedStorage.getProperties.mockResolvedValue(null); + + const result = await runDailyBookkeeping('tenant-1'); + expect(result.synced).toBeDefined(); + expect(result.synced.wave).toBeGreaterThanOrEqual(0); + expect(result.synced.rental).toBeGreaterThanOrEqual(0); + expect(result.synced.doorloop).toBeGreaterThanOrEqual(0); + expect(result.synced.stripe).toBeGreaterThanOrEqual(0); + expect(result.categorized).toBeGreaterThanOrEqual(0); + expect(result.anomalies).toBeGreaterThanOrEqual(0); + }); + + it('skips Wave sync when no Wave integrations', async () => { + mockedStorage.listIntegrationsByService.mockResolvedValue([]); + mockedStorage.getTransactions.mockResolvedValue([]); + mockedStorage.getProperties.mockResolvedValue(null); + + const result = await runDailyBookkeeping('tenant-1'); + expect(result.synced.wave).toBe(0); + }); +}); \ No newline at end of file diff --git a/server/__tests__/routes-charges.test.ts b/server/__tests__/routes-charges.test.ts new file mode 100644 index 0000000..6753935 --- /dev/null +++ b/server/__tests__/routes-charges.test.ts @@ -0,0 +1,213 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { Hono } from 'hono'; +import type { HonoEnv } from '../env'; +import { chargeRoutes } from '../routes/charges'; + +const baseEnv = { + CHITTY_AUTH_SERVICE_TOKEN: 'svc-token', + DATABASE_URL: 'fake', + FINANCE_KV: {} as any, + FINANCE_R2: {} as any, + ASSETS: {} as any, +}; + +function buildApp(mockStorage: Record) { + const app = new Hono(); + app.use('*', async (c, next) => { + c.set('tenantId', 'tenant-1'); + c.set('storage', mockStorage as any); + await next(); + }); + app.route('/', chargeRoutes); + return app; +} + +describe('GET /api/charges/recurring', () => { + it('returns empty array when there are no integrations', async () => { + const mockStorage = { getIntegrations: vi.fn().mockResolvedValue([]) }; + const app = buildApp(mockStorage); + const res = await app.request('/api/charges/recurring', {}, baseEnv); + expect(res.status).toBe(200); + const body = await res.json(); + expect(Array.isArray(body)).toBe(true); + expect(body).toHaveLength(0); + }); + + it('returns empty array when all integrations are disconnected', async () => { + const mockStorage = { + getIntegrations: vi.fn().mockResolvedValue([ + { id: 'i1', serviceType: 'wavapps', connected: false, credentials: {} }, + { id: 'i2', serviceType: 'stripe', connected: false, credentials: {} }, + ]), + }; + const app = buildApp(mockStorage); + const res = await app.request('/api/charges/recurring', {}, baseEnv); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body).toHaveLength(0); + }); + + it('returns empty array even with connected integrations (stub implementation)', async () => { + const mockStorage = { + getIntegrations: vi.fn().mockResolvedValue([ + { id: 'i1', serviceType: 'stripe', connected: true, credentials: { secret_key: 'sk_test_xxx' } }, + { id: 'i2', serviceType: 'wavapps', connected: true, credentials: { access_token: 'tok' } }, + ]), + }; + const app = buildApp(mockStorage); + const res = await app.request('/api/charges/recurring', {}, baseEnv); + expect(res.status).toBe(200); + const body = await res.json(); + // fetchChargesFromIntegration is a stub returning [] for all services + expect(Array.isArray(body)).toBe(true); + expect(body).toHaveLength(0); + }); + + it('calls storage.getIntegrations with the tenant id', async () => { + const getIntegrations = vi.fn().mockResolvedValue([]); + const app = buildApp({ getIntegrations }); + await app.request('/api/charges/recurring', {}, baseEnv); + expect(getIntegrations).toHaveBeenCalledWith('tenant-1'); + }); +}); + +describe('GET /api/charges/optimizations', () => { + it('returns empty array when there are no integrations', async () => { + const mockStorage = { getIntegrations: vi.fn().mockResolvedValue([]) }; + const app = buildApp(mockStorage); + const res = await app.request('/api/charges/optimizations', {}, baseEnv); + expect(res.status).toBe(200); + const body = await res.json(); + expect(Array.isArray(body)).toBe(true); + expect(body).toHaveLength(0); + }); + + it('returns empty array when integrations are disconnected', async () => { + const mockStorage = { + getIntegrations: vi.fn().mockResolvedValue([ + { id: 'i1', serviceType: 'stripe', connected: false, credentials: {} }, + ]), + }; + const app = buildApp(mockStorage); + const res = await app.request('/api/charges/optimizations', {}, baseEnv); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body).toHaveLength(0); + }); + + it('returns an array (stub charges = no recommendations)', async () => { + const mockStorage = { + getIntegrations: vi.fn().mockResolvedValue([ + { id: 'i1', serviceType: 'stripe', connected: true, credentials: {} }, + ]), + }; + const app = buildApp(mockStorage); + const res = await app.request('/api/charges/optimizations', {}, baseEnv); + expect(res.status).toBe(200); + const body = await res.json(); + // No real charges fetched because integration fetch is a stub + expect(Array.isArray(body)).toBe(true); + }); + + it('calls storage.getIntegrations with the tenant id', async () => { + const getIntegrations = vi.fn().mockResolvedValue([]); + const app = buildApp({ getIntegrations }); + await app.request('/api/charges/optimizations', {}, baseEnv); + expect(getIntegrations).toHaveBeenCalledWith('tenant-1'); + }); +}); + +describe('POST /api/charges/manage', () => { + it('returns 400 when chargeId is missing', async () => { + const app = buildApp({ getIntegrations: vi.fn().mockResolvedValue([]) }); + const res = await app.request('/api/charges/manage', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'cancel' }), + }, baseEnv); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toBeDefined(); + }); + + it('returns 400 when action is missing', async () => { + const app = buildApp({ getIntegrations: vi.fn().mockResolvedValue([]) }); + const res = await app.request('/api/charges/manage', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ chargeId: 'charge-123' }), + }, baseEnv); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toBeDefined(); + }); + + it('returns 400 when both chargeId and action are missing', async () => { + const app = buildApp({ getIntegrations: vi.fn().mockResolvedValue([]) }); + const res = await app.request('/api/charges/manage', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }, baseEnv); + expect(res.status).toBe(400); + }); + + it('returns 400 for invalid action value', async () => { + const app = buildApp({ getIntegrations: vi.fn().mockResolvedValue([]) }); + const res = await app.request('/api/charges/manage', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ chargeId: 'charge-123', action: 'delete' }), + }, baseEnv); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toContain("'cancel' or 'modify'"); + }); + + it('returns 400 for negotiate action (not a valid action)', async () => { + const app = buildApp({ getIntegrations: vi.fn().mockResolvedValue([]) }); + const res = await app.request('/api/charges/manage', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ chargeId: 'charge-123', action: 'negotiate' }), + }, baseEnv); + expect(res.status).toBe(400); + }); + + it('returns 200 with success:false for cancel action', async () => { + const app = buildApp({ getIntegrations: vi.fn().mockResolvedValue([]) }); + const res = await app.request('/api/charges/manage', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ chargeId: 'charge-123', action: 'cancel' }), + }, baseEnv); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.success).toBe(false); + expect(body.message).toContain('cancel'); + }); + + it('returns 200 with success:false for modify action', async () => { + const app = buildApp({ getIntegrations: vi.fn().mockResolvedValue([]) }); + const res = await app.request('/api/charges/manage', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ chargeId: 'charge-456', action: 'modify' }), + }, baseEnv); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.success).toBe(false); + expect(body.message).toContain('modify'); + }); + + it('indicates the feature is not yet implemented', async () => { + const app = buildApp({ getIntegrations: vi.fn().mockResolvedValue([]) }); + const res = await app.request('/api/charges/manage', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ chargeId: 'charge-789', action: 'cancel' }), + }, baseEnv); + const body = await res.json(); + expect(body.message.toLowerCase()).toContain('not yet implemented'); + }); +}); \ No newline at end of file diff --git a/server/__tests__/routes-wave-oauth.test.ts b/server/__tests__/routes-wave-oauth.test.ts new file mode 100644 index 0000000..abea146 --- /dev/null +++ b/server/__tests__/routes-wave-oauth.test.ts @@ -0,0 +1,418 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { Hono } from 'hono'; +import type { HonoEnv } from '../env'; + +// All vi.mock calls are hoisted to the top by Vitest's transformer + +const mockGetAuthorizationUrl = vi.fn().mockReturnValue('https://api.waveapps.com/oauth2/authorize/?mock=1'); +const mockExchangeCodeForToken = vi.fn(); +const mockGetBusinesses = vi.fn(); +const mockSetAccessToken = vi.fn(); +const mockRefreshAccessToken = vi.fn(); + +vi.mock('../lib/wave-api', () => ({ + WaveAPIClient: vi.fn().mockImplementation(() => ({ + getAuthorizationUrl: mockGetAuthorizationUrl, + exchangeCodeForToken: mockExchangeCodeForToken, + getBusinesses: mockGetBusinesses, + setAccessToken: mockSetAccessToken, + refreshAccessToken: mockRefreshAccessToken, + })), +})); + +const mockGenerateOAuthState = vi.fn().mockResolvedValue('mock-payload.mock-signature'); +const mockValidateOAuthState = vi.fn(); + +vi.mock('../lib/oauth-state-edge', () => ({ + generateOAuthState: mockGenerateOAuthState, + validateOAuthState: mockValidateOAuthState, +})); + +vi.mock('../db/connection', () => ({ + createDb: vi.fn().mockReturnValue({}), +})); + +const mockCreateIntegration = vi.fn().mockResolvedValue({}); +const mockUpdateIntegration = vi.fn().mockResolvedValue({}); +const mockGetIntegrationsStorage = vi.fn().mockResolvedValue([]); + +vi.mock('../storage/system', () => ({ + SystemStorage: vi.fn().mockImplementation(() => ({ + getIntegrations: mockGetIntegrationsStorage, + createIntegration: mockCreateIntegration, + updateIntegration: mockUpdateIntegration, + })), +})); + +import { waveRoutes, waveCallbackRoute } from '../routes/wave'; + +const baseEnv = { + CHITTY_AUTH_SERVICE_TOKEN: 'svc-token', + DATABASE_URL: 'postgres://fake/db', + FINANCE_KV: {} as any, + FINANCE_R2: {} as any, + ASSETS: {} as any, +}; + +const configuredEnv = { + ...baseEnv, + WAVE_CLIENT_ID: 'wave-client-id', + WAVE_CLIENT_SECRET: 'wave-client-secret', + OAUTH_STATE_SECRET: 'super-secret-32-chars-minimum-ok', +}; + +function buildAuthorizeApp() { + const app = new Hono(); + app.use('*', async (c, next) => { + c.set('tenantId', 'tenant-1'); + c.set('storage', {} as any); + await next(); + }); + app.route('/', waveRoutes); + return app; +} + +function buildRefreshApp(storageOverrides: Record = {}) { + const mockStorage = { + getIntegrations: vi.fn().mockResolvedValue([]), + updateIntegration: vi.fn().mockResolvedValue({}), + ...storageOverrides, + }; + const app = new Hono(); + app.use('*', async (c, next) => { + c.set('tenantId', 'tenant-1'); + c.set('storage', mockStorage as any); + await next(); + }); + app.route('/', waveRoutes); + return { app, mockStorage }; +} + +function buildCallbackApp() { + const app = new Hono(); + app.route('/', waveCallbackRoute); + return app; +} + +beforeEach(() => { + vi.clearAllMocks(); + // Restore defaults after clearAllMocks + mockGetAuthorizationUrl.mockReturnValue('https://api.waveapps.com/oauth2/authorize/?mock=1'); + mockGenerateOAuthState.mockResolvedValue('mock-payload.mock-signature'); + mockValidateOAuthState.mockResolvedValue(null); + mockGetIntegrationsStorage.mockResolvedValue([]); +}); + +// ─── GET /api/integrations/wave/authorize ──────────────────────────────────── + +describe('GET /api/integrations/wave/authorize', () => { + it('returns 503 when WAVE_CLIENT_ID is not configured', async () => { + const app = buildAuthorizeApp(); + const res = await app.request('/api/integrations/wave/authorize', {}, baseEnv); + expect(res.status).toBe(503); + const body = await res.json(); + expect(body.error).toBeDefined(); + expect(body.error).toContain('not configured'); + }); + + it('returns 503 when only WAVE_CLIENT_ID is set but WAVE_CLIENT_SECRET is missing', async () => { + const app = buildAuthorizeApp(); + const envWithoutSecret = { ...baseEnv, WAVE_CLIENT_ID: 'some-client-id' }; + const res = await app.request('/api/integrations/wave/authorize', {}, envWithoutSecret); + expect(res.status).toBe(503); + }); + + it('returns 200 with authUrl when Wave credentials are configured', async () => { + const app = buildAuthorizeApp(); + const res = await app.request('/api/integrations/wave/authorize', {}, configuredEnv); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.authUrl).toBeDefined(); + expect(typeof body.authUrl).toBe('string'); + expect(body.authUrl.length).toBeGreaterThan(0); + }); + + it('returns an authUrl pointing to waveapps OAuth', async () => { + const app = buildAuthorizeApp(); + const res = await app.request('/api/integrations/wave/authorize', {}, configuredEnv); + const body = await res.json(); + expect(body.authUrl).toContain('waveapps.com'); + }); + + it('does not include authUrl in a 503 error response', async () => { + const app = buildAuthorizeApp(); + const res = await app.request('/api/integrations/wave/authorize', {}, baseEnv); + const body = await res.json(); + expect(body.authUrl).toBeUndefined(); + }); + + it('calls generateOAuthState with the tenantId', async () => { + const app = buildAuthorizeApp(); + await app.request('/api/integrations/wave/authorize', {}, configuredEnv); + expect(mockGenerateOAuthState).toHaveBeenCalledWith('tenant-1', expect.any(String)); + }); +}); + +// ─── GET /api/integrations/wave/callback ───────────────────────────────────── + +describe('GET /api/integrations/wave/callback', () => { + it('redirects to error URL when error query param is present', async () => { + const app = buildCallbackApp(); + const res = await app.request( + '/api/integrations/wave/callback?error=access_denied', + {}, + baseEnv + ); + expect(res.status).toBe(302); + const location = res.headers.get('location'); + expect(location).toContain('wave=error'); + expect(location).toContain('access_denied'); + }); + + it('redirects with missing_params when code is absent', async () => { + const app = buildCallbackApp(); + const res = await app.request( + '/api/integrations/wave/callback?state=some-state', + {}, + baseEnv + ); + expect(res.status).toBe(302); + const location = res.headers.get('location'); + expect(location).toContain('wave=error'); + expect(location).toContain('missing_params'); + }); + + it('redirects with missing_params when state is absent', async () => { + const app = buildCallbackApp(); + const res = await app.request( + '/api/integrations/wave/callback?code=auth-code-123', + {}, + baseEnv + ); + expect(res.status).toBe(302); + const location = res.headers.get('location'); + expect(location).toContain('wave=error'); + expect(location).toContain('missing_params'); + }); + + it('redirects with invalid_state when state validation fails', async () => { + mockValidateOAuthState.mockResolvedValue(null); // Simulate invalid state + const app = buildCallbackApp(); + const res = await app.request( + '/api/integrations/wave/callback?code=auth-code&state=tampered-state', + {}, + baseEnv + ); + expect(res.status).toBe(302); + const location = res.headers.get('location'); + expect(location).toContain('wave=error'); + expect(location).toContain('invalid_state'); + }); + + it('uses PUBLIC_APP_BASE_URL for the redirect base URL', async () => { + const app = buildCallbackApp(); + const envWithBase = { ...baseEnv, PUBLIC_APP_BASE_URL: 'https://custom.example.com' }; + const res = await app.request( + '/api/integrations/wave/callback?error=denied', + {}, + envWithBase + ); + const location = res.headers.get('location'); + expect(location).toContain('custom.example.com'); + }); + + it('falls back to finance.chitty.cc when PUBLIC_APP_BASE_URL is not set', async () => { + const app = buildCallbackApp(); + const res = await app.request( + '/api/integrations/wave/callback?error=denied', + {}, + baseEnv + ); + const location = res.headers.get('location'); + expect(location).toContain('finance.chitty.cc'); + }); + + it('error redirect preserves the error reason from Wave', async () => { + const app = buildCallbackApp(); + const res = await app.request( + '/api/integrations/wave/callback?error=user_denied_access', + {}, + baseEnv + ); + const location = res.headers.get('location') ?? ''; + expect(location).toContain('user_denied_access'); + }); +}); + +// ─── POST /api/integrations/wave/refresh ───────────────────────────────────── + +describe('POST /api/integrations/wave/refresh', () => { + it('returns 404 when no integrations exist', async () => { + const { app } = buildRefreshApp({ getIntegrations: vi.fn().mockResolvedValue([]) }); + const res = await app.request('/api/integrations/wave/refresh', { method: 'POST' }, configuredEnv); + expect(res.status).toBe(404); + const body = await res.json(); + expect(body.error).toBeDefined(); + }); + + it('returns 404 when no Wave (wavapps) integration is found', async () => { + const { app } = buildRefreshApp({ + getIntegrations: vi.fn().mockResolvedValue([ + { id: 'i1', serviceType: 'stripe', connected: true, credentials: {} }, + { id: 'i2', serviceType: 'mercury', connected: true, credentials: {} }, + ]), + }); + const res = await app.request('/api/integrations/wave/refresh', { method: 'POST' }, configuredEnv); + expect(res.status).toBe(404); + }); + + it('returns 400 when Wave integration has no refresh_token in credentials', async () => { + const { app } = buildRefreshApp({ + getIntegrations: vi.fn().mockResolvedValue([ + { + id: 'i1', + serviceType: 'wavapps', + connected: true, + credentials: { access_token: 'tok', business_id: 'biz-1' }, // no refresh_token + }, + ]), + }); + const res = await app.request('/api/integrations/wave/refresh', { method: 'POST' }, configuredEnv); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toContain('refresh token'); + }); + + it('returns 400 when credentials are empty', async () => { + const { app } = buildRefreshApp({ + getIntegrations: vi.fn().mockResolvedValue([ + { id: 'i1', serviceType: 'wavapps', connected: true, credentials: {} }, + ]), + }); + const res = await app.request('/api/integrations/wave/refresh', { method: 'POST' }, configuredEnv); + expect(res.status).toBe(400); + }); + + it('returns 200 with success message when token refresh succeeds', async () => { + mockRefreshAccessToken.mockResolvedValue({ + access_token: 'new-access-token', + refresh_token: 'new-refresh-token', + expires_in: 3600, + }); + + const { app } = buildRefreshApp({ + getIntegrations: vi.fn().mockResolvedValue([ + { + id: 'i1', + serviceType: 'wavapps', + connected: true, + credentials: { + access_token: 'old-token', + refresh_token: 'old-refresh-token', + business_id: 'biz-1', + }, + }, + ]), + }); + const res = await app.request('/api/integrations/wave/refresh', { method: 'POST' }, configuredEnv); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.message).toBeDefined(); + expect(body.message.toLowerCase()).toContain('refresh'); + }); + + it('updates the integration record with new tokens on success', async () => { + mockRefreshAccessToken.mockResolvedValue({ + access_token: 'new-access-token', + refresh_token: 'new-refresh-token', + expires_in: 7200, + }); + + const updateIntegration = vi.fn().mockResolvedValue({}); + const { app } = buildRefreshApp({ + getIntegrations: vi.fn().mockResolvedValue([ + { + id: 'wave-integration-id', + serviceType: 'wavapps', + connected: true, + credentials: { + access_token: 'old-token', + refresh_token: 'old-refresh-token', + business_id: 'biz-1', + }, + }, + ]), + updateIntegration, + }); + await app.request('/api/integrations/wave/refresh', { method: 'POST' }, configuredEnv); + + expect(updateIntegration).toHaveBeenCalledWith( + 'wave-integration-id', + expect.objectContaining({ + credentials: expect.objectContaining({ + access_token: 'new-access-token', + refresh_token: 'new-refresh-token', + }), + }) + ); + }); + + it('preserves existing credential fields when updating tokens', async () => { + mockRefreshAccessToken.mockResolvedValue({ + access_token: 'new-access', + refresh_token: 'new-refresh', + expires_in: 3600, + }); + + const updateIntegration = vi.fn().mockResolvedValue({}); + const { app } = buildRefreshApp({ + getIntegrations: vi.fn().mockResolvedValue([ + { + id: 'i-abc', + serviceType: 'wavapps', + connected: true, + credentials: { + access_token: 'old-access', + refresh_token: 'old-refresh', + business_id: 'biz-123', + business_name: 'My Business', + }, + }, + ]), + updateIntegration, + }); + await app.request('/api/integrations/wave/refresh', { method: 'POST' }, configuredEnv); + + expect(updateIntegration).toHaveBeenCalledWith( + 'i-abc', + expect.objectContaining({ + credentials: expect.objectContaining({ + business_id: 'biz-123', + business_name: 'My Business', + }), + }) + ); + }); + + it('returns 500 when the Wave API throws during token refresh', async () => { + mockRefreshAccessToken.mockRejectedValue(new Error('Wave API unavailable')); + + const { app } = buildRefreshApp({ + getIntegrations: vi.fn().mockResolvedValue([ + { + id: 'i1', + serviceType: 'wavapps', + connected: true, + credentials: { + access_token: 'old-token', + refresh_token: 'old-refresh-token', + }, + }, + ]), + }); + const res = await app.request('/api/integrations/wave/refresh', { method: 'POST' }, configuredEnv); + expect(res.status).toBe(500); + const body = await res.json(); + expect(body.error).toBeDefined(); + }); +}); \ No newline at end of file diff --git a/server/__tests__/wave-bookkeeping.test.ts b/server/__tests__/wave-bookkeeping.test.ts new file mode 100644 index 0000000..997cba0 --- /dev/null +++ b/server/__tests__/wave-bookkeeping.test.ts @@ -0,0 +1,407 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Mock external dependencies that make network calls +vi.mock('../storage', () => ({ + storage: { + getTransactions: vi.fn().mockResolvedValue([]), + createTransaction: vi.fn().mockResolvedValue({}), + }, +})); + +vi.mock('../lib/chittychronicle-logging', () => ({ + logToChronicle: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('../lib/chittyschema-validation', () => ({ + validateTransaction: vi.fn().mockResolvedValue({ valid: true, errors: [] }), +})); + +import { WaveBookkeepingClient, createWaveBookkeepingClient } from '../lib/wave-bookkeeping'; +import { WaveAPIClient } from '../lib/wave-api'; + +// Build a minimal raw invoice node as Wave GraphQL would return it +function makeRawInvoice(overrides: Record = {}) { + return { + id: 'inv-1', + invoiceNumber: 'INV-001', + customer: { id: 'cust-1', name: 'Acme Corp' }, + invoiceDate: '2024-06-15', + dueDate: '2024-07-15', + status: 'PAID', + subTotal: { value: '1000.00', currency: { code: 'USD' } }, + total: { value: '1080.00', currency: { code: 'USD' } }, + amountDue: { value: '0.00' }, + items: [ + { + description: 'Consulting', + quantity: 10, + unitPrice: 100, + total: { value: '1000.00' }, + account: { id: 'acc-income' }, + }, + ], + taxes: [ + { + name: 'Sales Tax', + rate: 0.08, + amount: { value: '80.00' }, + }, + ], + ...overrides, + }; +} + +const defaultConfig = { + clientId: 'client-id', + clientSecret: 'client-secret', + redirectUri: 'https://example.com/callback', +}; + +describe('WaveBookkeepingClient - getInvoices', () => { + let client: WaveBookkeepingClient; + let graphqlSpy: ReturnType; + + beforeEach(() => { + client = new WaveBookkeepingClient(defaultConfig); + client.setAccessToken('test-token'); + }); + + function mockGraphQL(invoiceNodes: any[]) { + graphqlSpy = vi.spyOn(WaveAPIClient.prototype as any, 'graphql').mockResolvedValue({ + business: { + invoices: { + edges: invoiceNodes.map(node => ({ node })), + }, + }, + }); + } + + it('maps GraphQL response to WaveInvoice shape', async () => { + mockGraphQL([makeRawInvoice()]); + const invoices = await client.getInvoices('biz-1'); + expect(invoices).toHaveLength(1); + const inv = invoices[0]; + expect(inv.id).toBe('inv-1'); + expect(inv.invoiceNumber).toBe('INV-001'); + expect(inv.customerId).toBe('cust-1'); + expect(inv.customerName).toBe('Acme Corp'); + expect(inv.invoiceDate).toBe('2024-06-15'); + expect(inv.dueDate).toBe('2024-07-15'); + expect(inv.status).toBe('PAID'); + expect(inv.subtotal).toBe(1000); + expect(inv.total).toBe(1080); + expect(inv.amountDue).toBe(0); + expect(inv.currency).toBe('USD'); + }); + + it('maps items correctly including accountId', async () => { + mockGraphQL([makeRawInvoice()]); + const invoices = await client.getInvoices('biz-1'); + const item = invoices[0].items[0]; + expect(item.description).toBe('Consulting'); + expect(item.quantity).toBe(10); + expect(item.unitPrice).toBe(100); + expect(item.total).toBe(1000); + expect(item.accountId).toBe('acc-income'); + }); + + it('maps taxes correctly', async () => { + mockGraphQL([makeRawInvoice()]); + const invoices = await client.getInvoices('biz-1'); + const tax = invoices[0].taxes[0]; + expect(tax.name).toBe('Sales Tax'); + expect(tax.rate).toBe(0.08); + expect(tax.amount).toBe(80); + }); + + it('filters by status', async () => { + mockGraphQL([ + makeRawInvoice({ id: 'inv-1', status: 'PAID' }), + makeRawInvoice({ id: 'inv-2', status: 'DRAFT' }), + makeRawInvoice({ id: 'inv-3', status: 'PAID' }), + ]); + const invoices = await client.getInvoices('biz-1', { status: 'PAID' }); + expect(invoices).toHaveLength(2); + expect(invoices.every(i => i.status === 'PAID')).toBe(true); + }); + + it('filters by customerId', async () => { + mockGraphQL([ + makeRawInvoice({ id: 'inv-1', customer: { id: 'cust-A', name: 'Alpha Corp' } }), + makeRawInvoice({ id: 'inv-2', customer: { id: 'cust-B', name: 'Beta Inc' } }), + ]); + const invoices = await client.getInvoices('biz-1', { customerId: 'cust-A' }); + expect(invoices).toHaveLength(1); + expect(invoices[0].customerId).toBe('cust-A'); + }); + + it('filters by startDate (inclusive)', async () => { + mockGraphQL([ + makeRawInvoice({ id: 'inv-1', invoiceDate: '2024-03-01' }), + makeRawInvoice({ id: 'inv-2', invoiceDate: '2024-04-01' }), + makeRawInvoice({ id: 'inv-3', invoiceDate: '2024-04-15' }), + ]); + const invoices = await client.getInvoices('biz-1', { startDate: '2024-04-01' }); + expect(invoices).toHaveLength(2); + expect(invoices.map(i => i.invoiceDate)).toEqual(['2024-04-01', '2024-04-15']); + }); + + it('filters by endDate (inclusive)', async () => { + mockGraphQL([ + makeRawInvoice({ id: 'inv-1', invoiceDate: '2024-03-01' }), + makeRawInvoice({ id: 'inv-2', invoiceDate: '2024-04-01' }), + makeRawInvoice({ id: 'inv-3', invoiceDate: '2024-05-01' }), + ]); + const invoices = await client.getInvoices('biz-1', { endDate: '2024-04-01' }); + expect(invoices).toHaveLength(2); + expect(invoices.map(i => i.invoiceDate)).toEqual(['2024-03-01', '2024-04-01']); + }); + + it('applies all filters together (AND semantics)', async () => { + mockGraphQL([ + makeRawInvoice({ id: 'inv-1', status: 'PAID', invoiceDate: '2024-04-15', customer: { id: 'cust-A', name: 'A' } }), + makeRawInvoice({ id: 'inv-2', status: 'DRAFT', invoiceDate: '2024-04-15', customer: { id: 'cust-A', name: 'A' } }), + makeRawInvoice({ id: 'inv-3', status: 'PAID', invoiceDate: '2024-03-01', customer: { id: 'cust-A', name: 'A' } }), + makeRawInvoice({ id: 'inv-4', status: 'PAID', invoiceDate: '2024-04-15', customer: { id: 'cust-B', name: 'B' } }), + ]); + const invoices = await client.getInvoices('biz-1', { + status: 'PAID', + customerId: 'cust-A', + startDate: '2024-04-01', + endDate: '2024-04-30', + }); + expect(invoices).toHaveLength(1); + expect(invoices[0].id).toBe('inv-1'); + }); + + it('returns empty array when no invoices match filter', async () => { + mockGraphQL([makeRawInvoice({ status: 'DRAFT' })]); + const invoices = await client.getInvoices('biz-1', { status: 'PAID' }); + expect(invoices).toHaveLength(0); + }); + + it('returns all invoices when no filters specified', async () => { + mockGraphQL([ + makeRawInvoice({ id: 'inv-1', status: 'PAID' }), + makeRawInvoice({ id: 'inv-2', status: 'DRAFT' }), + ]); + const invoices = await client.getInvoices('biz-1'); + expect(invoices).toHaveLength(2); + }); + + it('uses product name as description fallback when description is missing', async () => { + const rawInv = makeRawInvoice(); + rawInv.items[0].description = null; + rawInv.items[0].product = { name: 'Product Alpha' }; + mockGraphQL([rawInv]); + const invoices = await client.getInvoices('biz-1'); + expect(invoices[0].items[0].description).toBe('Product Alpha'); + }); + + it('initializes payments as an empty array', async () => { + mockGraphQL([makeRawInvoice()]); + const invoices = await client.getInvoices('biz-1'); + expect(invoices[0].payments).toEqual([]); + }); +}); + +describe('WaveBookkeepingClient - recordInvoicePayment', () => { + let client: WaveBookkeepingClient; + + beforeEach(() => { + client = new WaveBookkeepingClient(defaultConfig); + client.setAccessToken('test-token'); + }); + + it('maps GraphQL response to WavePayment shape', async () => { + vi.spyOn(WaveAPIClient.prototype as any, 'graphql').mockResolvedValue({ + invoicePaymentRecord: { + payment: { + id: 'pay-1', + amount: { value: '1080.00' }, + date: '2024-07-01', + paymentMethod: 'ACH', + }, + }, + }); + + const payment = await client.recordInvoicePayment('inv-1', { + amount: 1080, + date: '2024-07-01', + paymentMethod: 'ACH', + memo: 'Full payment', + }); + + expect(payment.id).toBe('pay-1'); + expect(payment.amount).toBe(1080); + expect(payment.date).toBe('2024-07-01'); + expect(payment.paymentMethod).toBe('ACH'); + expect(payment.invoiceId).toBe('inv-1'); + expect(payment.memo).toBe('Full payment'); + }); + + it('parses amount as a float from GraphQL string value', async () => { + vi.spyOn(WaveAPIClient.prototype as any, 'graphql').mockResolvedValue({ + invoicePaymentRecord: { + payment: { + id: 'pay-2', + amount: { value: '250.50' }, + date: '2024-08-01', + paymentMethod: 'CHECK', + }, + }, + }); + + const payment = await client.recordInvoicePayment('inv-2', { + amount: 250.5, + date: '2024-08-01', + paymentMethod: 'CHECK', + }); + + expect(payment.amount).toBe(250.5); + }); +}); + +describe('WaveBookkeepingClient - getCustomers', () => { + let client: WaveBookkeepingClient; + + beforeEach(() => { + client = new WaveBookkeepingClient(defaultConfig); + client.setAccessToken('test-token'); + }); + + it('maps customer nodes to WaveCustomer shape', async () => { + vi.spyOn(WaveAPIClient.prototype as any, 'graphql').mockResolvedValue({ + business: { + customers: { + edges: [ + { + node: { + id: 'cust-1', + name: 'Acme Corp', + email: 'billing@acme.com', + phone: '555-0100', + address: { + addressLine1: '100 Main St', + addressLine2: 'Suite 200', + city: 'Springfield', + province: { name: 'Illinois' }, + postalCode: '62701', + country: { name: 'United States' }, + }, + currency: { code: 'USD' }, + }, + }, + ], + }, + }, + }); + + const customers = await client.getCustomers('biz-1'); + expect(customers).toHaveLength(1); + const c = customers[0]; + expect(c.id).toBe('cust-1'); + expect(c.name).toBe('Acme Corp'); + expect(c.email).toBe('billing@acme.com'); + expect(c.phone).toBe('555-0100'); + expect(c.address?.line1).toBe('100 Main St'); + expect(c.address?.line2).toBe('Suite 200'); + expect(c.address?.city).toBe('Springfield'); + expect(c.address?.state).toBe('Illinois'); + expect(c.address?.zip).toBe('62701'); + expect(c.address?.country).toBe('United States'); + expect(c.currency).toBe('USD'); + expect(c.balance).toBe(0); // Balance is always 0 (not calculated from API) + }); + + it('returns undefined address when customer has no address', async () => { + vi.spyOn(WaveAPIClient.prototype as any, 'graphql').mockResolvedValue({ + business: { + customers: { + edges: [ + { + node: { + id: 'cust-2', + name: 'Simple Customer', + email: null, + phone: null, + address: null, + currency: { code: 'CAD' }, + }, + }, + ], + }, + }, + }); + + const customers = await client.getCustomers('biz-1'); + expect(customers[0].address).toBeUndefined(); + expect(customers[0].currency).toBe('CAD'); + }); +}); + +describe('WaveBookkeepingClient - getAccounts', () => { + let client: WaveBookkeepingClient; + + beforeEach(() => { + client = new WaveBookkeepingClient(defaultConfig); + client.setAccessToken('test-token'); + }); + + it('maps account nodes to WaveAccount shape', async () => { + vi.spyOn(WaveAPIClient.prototype as any, 'graphql').mockResolvedValue({ + business: { + accounts: { + edges: [ + { + node: { + id: 'acc-1', + name: 'Business Checking', + type: { name: 'Asset' }, + subtype: { name: 'Checking' }, + currency: { code: 'USD' }, + }, + }, + ], + }, + }, + }); + + const accounts = await client.getAccounts('biz-1'); + expect(accounts).toHaveLength(1); + const a = accounts[0]; + expect(a.id).toBe('acc-1'); + expect(a.name).toBe('Business Checking'); + expect(a.type).toBe('Asset'); + expect(a.subtype).toBe('Checking'); + expect(a.currency).toBe('USD'); + expect(a.balance).toBe(0); // Always 0 (not fetched from API) + }); +}); + +describe('createWaveBookkeepingClient factory', () => { + it('creates a WaveBookkeepingClient instance', () => { + const client = createWaveBookkeepingClient({ + clientId: 'test-id', + clientSecret: 'test-secret', + redirectUri: 'https://example.com/callback', + }); + expect(client).toBeInstanceOf(WaveBookkeepingClient); + }); + + it('creates a WaveBookkeepingClient that is also a WaveAPIClient', () => { + const client = createWaveBookkeepingClient({ + clientId: 'test-id', + clientSecret: 'test-secret', + redirectUri: 'https://example.com/callback', + }); + expect(client).toBeInstanceOf(WaveAPIClient); + }); + + it('creates distinct client instances each time', () => { + const config = { clientId: 'id', clientSecret: 'secret', redirectUri: 'https://example.com' }; + const client1 = createWaveBookkeepingClient(config); + const client2 = createWaveBookkeepingClient(config); + expect(client1).not.toBe(client2); + }); +});