import { beforeEach, describe, expect, it, vi } from 'vitest'; const state = { invoice: { invoice_id: 'invoice-1', client_id: 'client-1', status: 'draft', invoice_number: 'INV-1001', } as Record | null, client: { client_id: 'client-1', tax_region: 'US-WA', } as Record | null, nonManualTargets: [] as Array>, queriedTables: [] as string[], }; const recalculateInvoiceMock = vi.fn(async () => undefined); function createBuilder(table: string) { const builder: any = {}; builder.leftJoin = vi.fn(() => builder); builder.where = vi.fn(() => builder); builder.andWhere = vi.fn(() => builder); builder.whereIn = vi.fn(() => builder); builder.whereNot = vi.fn(() => builder); builder.orWhereNull = vi.fn(() => builder); builder.select = vi.fn(() => builder); builder.update = vi.fn(async () => 1); builder.delete = vi.fn(async () => 1); builder.first = vi.fn(async () => { if (table === 'invoices') { return state.invoice; } if (table === 'clients') { return state.client; } return undefined; }); builder.then = vi.fn((onFulfilled?: any, onRejected?: any) => { const rows = table === 'invoice_charges as ic' ? state.nonManualTargets : []; return Promise.resolve(rows).then(onFulfilled, onRejected); }); return builder; } function createMockTrx() { return ((table: string) => { state.queriedTables.push(table); return createBuilder(table); }) as any; } vi.mock('@alga-psa/auth', () => ({ withAuth: (fn: any) => (...args: any[]) => fn({ user_id: 'user-1' }, { tenant: 'tenant-1' }, ...args), getSession: vi.fn(async () => ({ user: { id: 'user-1' } })), })); vi.mock('@alga-psa/auth/rbac', () => ({ hasPermission: vi.fn().mockResolvedValue(true), })); vi.mock('@alga-psa/db', async () => { const actual = await vi.importActual('@alga-psa/db'); return { ...actual, createTenantKnex: vi.fn(async () => ({ knex: {} })), withTransaction: vi.fn(async (_knex: any, callback: any) => callback(createMockTrx())), }; }); vi.mock('../src/lib/billing/billingEngine', () => ({ BillingEngine: class BillingEngine { recalculateInvoice = recalculateInvoiceMock; }, })); vi.mock('../src/services/invoiceService', () => ({ persistInvoiceCharges: vi.fn(), persistManualInvoiceCharges: vi.fn(), })); vi.mock('../src/models/clientContractLine', () => ({ default: { updateClientCredit: vi.fn(async () => undefined), }, })); vi.mock('../src/actions/creditActions', () => ({ applyCreditToInvoice: vi.fn(), })); vi.mock('@alga-psa/billing/models/invoice', () => ({ default: { getFullInvoiceById: vi.fn(async () => undefined), }, })); vi.mock('@alga-psa/event-bus/publishers', () => ({ publishWorkflowEvent: vi.fn(async () => undefined), })); vi.mock('../src/actions/taxSourceActions', () => ({ validateInvoiceFinalization: vi.fn(async () => ({ canFinalize: true })), })); describe('manual invoice edits preserve recurring provenance', () => { beforeEach(() => { state.invoice = { invoice_id: 'invoice-1', client_id: 'client-1', status: 'draft', invoice_number: 'INV-1001', }; state.client = { client_id: 'client-1', tax_region: 'US-WA', }; state.nonManualTargets = [ { item_id: 'recurring-1', description: 'Managed Services', item_detail_id: 'detail-1', }, ]; state.queriedTables = []; recalculateInvoiceMock.mockClear(); }); it('T207: rejects updates that would manually mutate recurring invoice charges backed by canonical detail periods', async () => { const { updateInvoiceManualItems } = await import('../src/actions/invoiceModification.ts'); await expect( updateInvoiceManualItems('invoice-1', { updatedItems: [ { item_id: 'recurring-1', description: 'Edited recurring line', rate: 15000, }, ], newItems: [], removedItemIds: [], } as any) ).rejects.toThrow( 'Cannot manually edit recurring invoice charges once canonical detail periods exist. Add an adjustment as a manual item or cancel and regenerate the invoice instead.' ); expect(state.queriedTables).toContain('invoice_charges as ic'); expect(recalculateInvoiceMock).not.toHaveBeenCalled(); }); });