import { beforeEach, describe, expect, it, vi } from 'vitest'; const state = { invoice: { invoice_id: 'invoice-1', tenant: 'tenant-1', status: 'draft', finalized_at: null, invoice_number: 'INV-1001', } as Record | null, duplicateInvoice: null as Record | null, updates: [] as Array<{ table: string; payload: Record }>, }; const recalculateInvoiceMock = vi.fn(async () => undefined); function normalizeWhereArg(arg1: unknown, arg2?: unknown) { if (typeof arg1 === 'string') { return { [arg1]: arg2 }; } return (arg1 ?? {}) as Record; } function createBuilder(table: string) { const whereClauses: Array> = []; const whereNotClauses: Array> = []; const builder: any = {}; builder.leftJoin = vi.fn(() => builder); builder.join = vi.fn(() => builder); builder.andWhere = vi.fn((arg1: unknown, arg2?: unknown) => { whereClauses.push(normalizeWhereArg(arg1, arg2)); return builder; }); builder.where = vi.fn((arg1: unknown, arg2?: unknown) => { whereClauses.push(normalizeWhereArg(arg1, arg2)); return builder; }); builder.whereIn = vi.fn(() => builder); builder.whereNot = vi.fn((arg1: unknown, arg2?: unknown) => { whereNotClauses.push(normalizeWhereArg(arg1, arg2)); return builder; }); builder.select = vi.fn(() => builder); builder.delete = vi.fn(async () => 1); builder.update = vi.fn(async (payload: Record) => { state.updates.push({ table, payload }); return 1; }); builder.first = vi.fn(async () => { if (table !== 'invoices') { return undefined; } const mergedWhere = Object.assign({}, ...whereClauses); const mergedWhereNot = Object.assign({}, ...whereNotClauses); if (mergedWhere.invoice_id && mergedWhere.tenant) { return state.invoice; } if (mergedWhere.invoice_number && mergedWhere.tenant && mergedWhereNot.invoice_id) { return state.duplicateInvoice; } return undefined; }); builder.then = vi.fn((onFulfilled?: any, onRejected?: any) => Promise.resolve([]).then(onFulfilled, onRejected)); return builder; } function createMockTrx() { return ((table: string) => 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('updateDraftInvoiceProperties', () => { beforeEach(() => { state.invoice = { invoice_id: 'invoice-1', tenant: 'tenant-1', status: 'draft', finalized_at: null, invoice_number: 'INV-1001', }; state.duplicateInvoice = null; state.updates = []; recalculateInvoiceMock.mockClear(); }); it('updates invoice number and dates for draft invoices', async () => { const { updateDraftInvoiceProperties } = await import('../src/actions/invoiceModification.ts'); await expect( updateDraftInvoiceProperties('invoice-1', { invoiceNumber: ' INV-2001 ', invoiceDate: '2026-04-20', dueDate: null, }) ).resolves.toEqual({ invoiceId: 'invoice-1', invoiceNumber: 'INV-2001', invoiceDate: '2026-04-20', dueDate: null, }); expect(state.updates).toEqual([ { table: 'invoices', payload: expect.objectContaining({ invoice_number: 'INV-2001', invoice_date: '2026-04-20', due_date: null, }), }, ]); expect(recalculateInvoiceMock).not.toHaveBeenCalled(); }); it('rejects duplicate invoice numbers before updating', async () => { state.duplicateInvoice = { invoice_id: 'invoice-2' }; const { updateDraftInvoiceProperties } = await import('../src/actions/invoiceModification.ts'); await expect( updateDraftInvoiceProperties('invoice-1', { invoiceNumber: 'INV-0001', invoiceDate: '2026-04-20', dueDate: '2026-05-20', }) ).rejects.toThrow('Invoice number already exists. Choose a different number.'); expect(state.updates).toHaveLength(0); }); it('rejects edits for finalized invoices', async () => { state.invoice = { ...state.invoice, status: 'sent', finalized_at: '2026-04-10', }; const { updateDraftInvoiceProperties } = await import('../src/actions/invoiceModification.ts'); await expect( updateDraftInvoiceProperties('invoice-1', { invoiceNumber: 'INV-2002', invoiceDate: '2026-04-21', dueDate: '2026-05-21', }) ).rejects.toThrow('Only draft invoices can be edited'); expect(state.updates).toHaveLength(0); }); });