UNPKG

@accounter/server

Version:
662 lines (587 loc) 22.3 kB
import { describe, expect, it, vi, beforeEach } from 'vitest'; import { createMockTransaction, createMockDocument } from './test-helpers.js'; // Mock the module imports vi.mock('graphql-modules', () => ({ Injectable: () => (target: any) => target, Inject: () => (target: any, propertyKey: string | symbol, parameterIndex: number) => {}, Injector: class {}, InjectionToken: class { constructor(public description?: string) {} }, Scope: { Operation: 'Operation' }, CONTEXT: Symbol('CONTEXT'), })); vi.mock('../../charges/providers/charges.provider.js', () => ({ ChargesProvider: class {}, })); vi.mock('../../documents/providers/documents.provider.js', () => ({ DocumentsProvider: class {}, })); vi.mock('../../transactions/providers/transactions.provider.js', () => ({ TransactionsProvider: class {}, })); vi.mock('../../financial-entities/providers/clients.provider.js', () => ({ ClientsProvider: class {}, })); vi.mock('../../admin-context/providers/admin-context.provider.js', () => ({ AdminContextProvider: class {}, })); vi.mock('../../charges/helpers/merge-charges.helper.js', () => ({ mergeChargesExecutor: vi.fn(), })); vi.mock('../../../shared/helpers/index.js', () => ({ dateToTimelessDateString: (date: Date) => date.toISOString().split('T')[0], })); const createMockProvider = (mockChargesProvider?: { getChargesByFilters: (filters: any) => Promise<any[]>; }, mockTransactionsProvider?: { transactionsByChargeIDLoader: { load: (id: string) => Promise<any[]>; }}, mockDocumentsProvider?: { getDocumentsByChargeIdLoader: { load: (id: string) => Promise<any[]>; }; } ) => { const mockAdminContextProvider = { getVerifiedAdminContext: vi.fn(() => Promise.resolve({ ownerId: ADMIN_BUSINESS_ID })), } as any; const mockCharges = mockChargesProvider ?? { getChargesByFilters: vi.fn(() => Promise.resolve([])), }; const mockTransactions = mockTransactionsProvider ?? { transactionsByChargeIDLoader: { load: vi.fn(() => Promise.resolve([])), }, }; const mockDocuments = mockDocumentsProvider ?? { getDocumentsByChargeIdLoader: { load: vi.fn(() => Promise.resolve([])), }, }; const mockInjector = { get: vi.fn((token: {name: string}) => { if (token.name === 'ChargesProvider') return mockCharges; if (token.name === 'TransactionsProvider') return mockTransactions; if (token.name === 'DocumentsProvider') return mockDocuments; if (token.name === 'ClientsProvider') return { getClientByIdLoader: { load: (businessId: string) => { const isRegisteredClient = businessId.startsWith('client-'); return Promise.resolve(isRegisteredClient ? { id: businessId } : null); }, }, }; return null; }), } as Injector; const mockContext = { injector: mockInjector, } as any; return new ChargesMatcherProvider( mockAdminContextProvider, mockCharges as any, mockTransactions as any, mockDocuments as any, mockContext, ); }; // Import after mocking const { ChargesMatcherProvider } = await import('../providers/charges-matcher.provider.js'); const { mergeChargesExecutor } = await import( '../../charges/helpers/merge-charges.helper.js' ); type Injector = { get: (token: any) => any; }; // Test constants const ADMIN_BUSINESS_ID = 'user-123'; // Helper to create charge function createCharge(id: string, ownerId: string) { return { id, owner_id: ownerId, }; } describe('ChargesMatcherProvider - Auto-Match Integration', () => { beforeEach(() => { vi.clearAllMocks(); }); describe('autoMatchCharges', () => { it('should return 0 matches when database is empty', async () => { const provider = createMockProvider(); const result = await provider.autoMatchCharges(); expect(result.totalMatches).toBe(0); expect(result.mergedCharges).toEqual([]); expect(result.skippedCharges).toEqual([]); expect(result.errors).toEqual([]); }); it('should return 0 matches when all charges are already matched', async () => { const matchedChargeId = 'matched-charge-1'; const mockChargesProvider = { getChargesByFilters: vi.fn(() => Promise.resolve([createCharge(matchedChargeId, ADMIN_BUSINESS_ID)]), ), }; const provider = createMockProvider(mockChargesProvider); const result = await provider.autoMatchCharges(); expect(result.totalMatches).toBe(0); expect(result.mergedCharges).toEqual([]); expect(result.skippedCharges).toEqual([]); }); it('should execute merge for single unmatched charge with good match', async () => { const txChargeId = 'tx-charge-1'; const docChargeId = 'doc-charge-1'; const mockChargesProvider = { getChargesByFilters: vi.fn(() => Promise.resolve([ createCharge(txChargeId, ADMIN_BUSINESS_ID), createCharge(docChargeId, ADMIN_BUSINESS_ID), ]), ), }; const mockTransactionsProvider = { transactionsByChargeIDLoader: { load: vi.fn((id: string) => { if (id === txChargeId) { return Promise.resolve([ createMockTransaction({ charge_id: txChargeId, amount: "100", currency: 'USD', event_date: new Date('2024-01-15'), }), ]); } return Promise.resolve([]); }), }, }; const mockDocumentsProvider = { getDocumentsByChargeIdLoader: { load: vi.fn((id: string) => { if (id === docChargeId) { return Promise.resolve([ createMockDocument({ charge_id: docChargeId, total_amount: 100, currency_code: 'USD', date: new Date('2024-01-15'), }), ]); } return Promise.resolve([]); }), }, }; const provider = createMockProvider(mockChargesProvider, mockTransactionsProvider, mockDocumentsProvider); const result = await provider.autoMatchCharges(); expect(result.totalMatches).toBe(1); expect(result.mergedCharges).toHaveLength(1); expect(result.mergedCharges[0].confidenceScore).toBeGreaterThanOrEqual(0.95); expect(result.skippedCharges).toEqual([]); expect(result.errors).toEqual([]); // Verify merge was called expect(mergeChargesExecutor).toHaveBeenCalledTimes(1); }); it('should skip charges with ambiguous matches', async () => { const txChargeId = 'tx-charge-1'; const docCharge1Id = 'doc-charge-1'; const docCharge2Id = 'doc-charge-2'; const mockChargesProvider = { getChargesByFilters: vi.fn(() => Promise.resolve([ createCharge(txChargeId, ADMIN_BUSINESS_ID), createCharge(docCharge1Id, ADMIN_BUSINESS_ID), createCharge(docCharge2Id, ADMIN_BUSINESS_ID), ]), ), }; const mockTransactionsProvider = { transactionsByChargeIDLoader: { load: vi.fn((id: string) => { if (id === txChargeId) { return Promise.resolve([ createMockTransaction({ charge_id: txChargeId, amount: "100", currency: 'USD', event_date: new Date('2024-01-15'), }), ]); } return Promise.resolve([]); }), }, }; const mockDocumentsProvider = { getDocumentsByChargeIdLoader: { load: vi.fn((id: string) => { if (id === docCharge1Id) { return Promise.resolve([ createMockDocument({ charge_id: docCharge1Id, total_amount: 100, currency_code: 'USD', date: new Date('2024-01-15'), }), ]); } if (id === docCharge2Id) { return Promise.resolve([ createMockDocument({ charge_id: docCharge2Id, total_amount: 100, currency_code: 'USD', date: new Date('2024-01-15'), }), ]); } return Promise.resolve([]); }), }, }; const provider = createMockProvider(mockChargesProvider, mockTransactionsProvider, mockDocumentsProvider); const result = await provider.autoMatchCharges(); // The transaction charge should find 2 identical doc matches and skip (ambiguous) // But the doc charges will also match each other with high confidence // Since processing is sequential, one doc will match the other doc // So we expect: 1 match (doc1-doc2), and tx skipped due to ambiguity expect(result.totalMatches).toBe(1); expect(result.skippedCharges).toHaveLength(1); expect(result.skippedCharges[0]).toBe(txChargeId); expect(mergeChargesExecutor).toHaveBeenCalledTimes(1); }); it('should process multiple unmatched charges correctly', async () => { const tx1Id = 'tx-charge-1'; const doc1Id = 'doc-charge-1'; const tx2Id = 'tx-charge-2'; const doc2Id = 'doc-charge-2'; const mockChargesProvider = { getChargesByFilters: vi.fn(() => Promise.resolve([ createCharge(tx1Id, ADMIN_BUSINESS_ID), createCharge(doc1Id, ADMIN_BUSINESS_ID), createCharge(tx2Id, ADMIN_BUSINESS_ID), createCharge(doc2Id, ADMIN_BUSINESS_ID), ]), ), }; const mockTransactionsProvider = { transactionsByChargeIDLoader: { load: vi.fn((id: string) => { if (id === tx1Id) { return Promise.resolve([ createMockTransaction({ charge_id: tx1Id, amount: "100", currency: 'USD' }), ]); } if (id === tx2Id) { return Promise.resolve([ createMockTransaction({ charge_id: tx2Id, amount: "200", currency: 'EUR' }), ]); } return Promise.resolve([]); }), }, }; const mockDocumentsProvider = { getDocumentsByChargeIdLoader: { load: vi.fn((id: string) => { if (id === doc1Id) { return Promise.resolve([ createMockDocument({ charge_id: doc1Id, total_amount: 100, currency_code: 'USD' }), ]); } if (id === doc2Id) { return Promise.resolve([ createMockDocument({ charge_id: doc2Id, total_amount: 200, currency_code: 'EUR' }), ]); } return Promise.resolve([]); }), }, }; const provider = createMockProvider(mockChargesProvider, mockTransactionsProvider, mockDocumentsProvider); const result = await provider.autoMatchCharges(); expect(result.totalMatches).toBe(2); expect(result.mergedCharges).toHaveLength(2); expect(mergeChargesExecutor).toHaveBeenCalledTimes(2); }); it('should capture errors during merge but continue processing', async () => { const tx1Id = 'tx-charge-1'; const doc1Id = 'doc-charge-1'; const tx2Id = 'tx-charge-2'; // This will have no match const mockChargesProvider = { getChargesByFilters: vi.fn(() => Promise.resolve([ createCharge(tx1Id, ADMIN_BUSINESS_ID), createCharge(doc1Id, ADMIN_BUSINESS_ID), createCharge(tx2Id, ADMIN_BUSINESS_ID), ]), ), }; const mockTransactionsProvider = { transactionsByChargeIDLoader: { load: vi.fn((id: string) => { if (id === tx1Id) { return Promise.resolve([ createMockTransaction({ charge_id: id, amount: "100", currency: 'USD', event_date: new Date('2024-01-15'), }), ]); } if (id === tx2Id) { return Promise.resolve([ createMockTransaction({ charge_id: id, amount: "999", currency: 'GBP', event_date: new Date('2024-03-15'), }), ]); } return Promise.resolve([]); }), }, }; const mockDocumentsProvider = { getDocumentsByChargeIdLoader: { load: vi.fn((id: string) => { if (id === doc1Id) { return Promise.resolve([ createMockDocument({ charge_id: id, total_amount: 100, currency_code: 'USD', date: new Date('2024-01-15'), }), ]); } return Promise.resolve([]); }), }, }; // Mock merge to fail on first call, succeed on second (mergeChargesExecutor as any).mockImplementationOnce(() => { throw new Error('Merge failed for test'); }); (mergeChargesExecutor as any).mockImplementationOnce(() => Promise.resolve()); const provider = createMockProvider(mockChargesProvider, mockTransactionsProvider, mockDocumentsProvider); const result = await provider.autoMatchCharges(); // tx1 finds doc1, merge fails → error captured, charges not marked as merged // doc1 finds tx1, merge succeeds → totalMatches++, charges marked as merged // tx2 has no match → silent expect(result.totalMatches).toBe(1); expect(result.errors).toHaveLength(1); expect(result.errors[0]).toContain('Failed to merge'); expect(mergeChargesExecutor).toHaveBeenCalledTimes(2); }); it('should verify merge direction keeps transaction charge', async () => { const txChargeId = 'tx-charge-1'; const docChargeId = 'doc-charge-1'; const mockChargesProvider = { getChargesByFilters: vi.fn(() => Promise.resolve([ createCharge(txChargeId, ADMIN_BUSINESS_ID), createCharge(docChargeId, ADMIN_BUSINESS_ID), ]), ), }; const mockTransactionsProvider = { transactionsByChargeIDLoader: { load: vi.fn((id: string) => { if (id === txChargeId) { return Promise.resolve([createMockTransaction({ charge_id: txChargeId })]); } return Promise.resolve([]); }), }, }; const mockDocumentsProvider = { getDocumentsByChargeIdLoader: { load: vi.fn((id: string) => { if (id === docChargeId) { return Promise.resolve([createMockDocument({ charge_id: docChargeId })]); } return Promise.resolve([]); }), }, }; const provider = createMockProvider(mockChargesProvider, mockTransactionsProvider, mockDocumentsProvider); await provider.autoMatchCharges(); // Verify merge was called with doc charge being merged into tx charge expect(mergeChargesExecutor).toHaveBeenCalledWith([docChargeId], txChargeId, expect.any(Object)); }); it('should exclude merged charges from further processing in same run', async () => { const tx1Id = 'tx-charge-1'; const doc1Id = 'doc-charge-1'; const tx2Id = 'tx-charge-2'; const mockChargesProvider = { getChargesByFilters: vi.fn(() => Promise.resolve([ createCharge(tx1Id, ADMIN_BUSINESS_ID), createCharge(doc1Id, ADMIN_BUSINESS_ID), createCharge(tx2Id, ADMIN_BUSINESS_ID), ]), ), }; const mockTransactionsProvider = { transactionsByChargeIDLoader: { load: vi.fn((id: string) => { if (id === tx1Id || id === tx2Id) { return Promise.resolve([createMockTransaction({ charge_id: id, amount: "100" })]); } return Promise.resolve([]); }), }, }; const mockDocumentsProvider = { getDocumentsByChargeIdLoader: { load: vi.fn((id: string) => { if (id === doc1Id) { return Promise.resolve([createMockDocument({ charge_id: doc1Id, total_amount: 100 })]); } return Promise.resolve([]); }), }, }; const provider = createMockProvider(mockChargesProvider, mockTransactionsProvider, mockDocumentsProvider); const result = await provider.autoMatchCharges(); // Should only merge tx1 with doc1, tx2 should have no match expect(result.totalMatches).toBe(1); expect(mergeChargesExecutor).toHaveBeenCalledTimes(1); }); it('should handle mixed scenarios: some match, some skip, some no-match', async () => { const perfectMatchTx = 'tx-perfect'; const perfectMatchDoc = 'doc-perfect'; const ambiguousTx = 'tx-ambiguous'; const ambiguousDoc1 = 'doc-ambiguous-1'; const ambiguousDoc2 = 'doc-ambiguous-2'; const noMatchTx = 'tx-no-match'; const mockChargesProvider = { getChargesByFilters: vi.fn(() => Promise.resolve([ createCharge(perfectMatchTx, ADMIN_BUSINESS_ID), createCharge(perfectMatchDoc, ADMIN_BUSINESS_ID), createCharge(ambiguousTx, ADMIN_BUSINESS_ID), createCharge(ambiguousDoc1, ADMIN_BUSINESS_ID), createCharge(ambiguousDoc2, ADMIN_BUSINESS_ID), createCharge(noMatchTx, ADMIN_BUSINESS_ID), ]), ), }; const mockTransactionsProvider = { transactionsByChargeIDLoader: { load: vi.fn((id: string) => { if (id === perfectMatchTx) { return Promise.resolve([ createMockTransaction({ charge_id: id, amount: "100", currency: 'USD', event_date: new Date('2024-01-15'), }), ]); } if (id === ambiguousTx) { return Promise.resolve([ createMockTransaction({ charge_id: id, amount: "200", currency: 'EUR', event_date: new Date('2024-02-15'), }), ]); } if (id === noMatchTx) { return Promise.resolve([ createMockTransaction({ charge_id: id, amount: "500", currency: 'GBP', event_date: new Date('2024-03-15'), }), ]); } return Promise.resolve([]); }), }, }; const mockDocumentsProvider = { getDocumentsByChargeIdLoader: { load: vi.fn((id: string) => { if (id === perfectMatchDoc) { return Promise.resolve([ createMockDocument({ charge_id: id, total_amount: 100, currency_code: 'USD', date: new Date('2024-01-15'), debtor_id: ADMIN_BUSINESS_ID, creditor_id: 'business-a', }), ]); } if (id === ambiguousDoc1) { return Promise.resolve([ createMockDocument({ charge_id: id, total_amount: 200, currency_code: 'EUR', date: new Date('2024-02-15'), vat_amount: 10, // Different VAT }), ]); } if (id === ambiguousDoc2) { return Promise.resolve([ createMockDocument({ charge_id: id, total_amount: 200, currency_code: 'EUR', date: new Date('2024-02-15'), vat_amount: 20, // Different VAT - but this won't affect matching score }), ]); } return Promise.resolve([]); }), }, }; (mergeChargesExecutor as any).mockImplementation(() => Promise.resolve()); const provider = createMockProvider(mockChargesProvider, mockTransactionsProvider, mockDocumentsProvider); const result = await provider.autoMatchCharges(); // Expected results: // - perfectMatchTx finds perfectMatchDoc → merge (1) // - perfectMatchDoc → already merged, skip processing // - ambiguousTx finds 2 docs (ambiguousDoc1, ambiguousDoc2) → skipped due to ambiguity // - ambiguousDoc1 finds ambiguousDoc2 (tx was skipped, not merged) → merge (2) // - ambiguousDoc2 → already merged, skip processing // - noMatchTx → no match, silent // Total: 2 successful matches, 1 skipped charge (tx-ambiguous) expect(result.totalMatches).toBe(2); expect(result.mergedCharges).toHaveLength(2); expect(result.skippedCharges).toHaveLength(1); expect(result.skippedCharges[0]).toBe(ambiguousTx); expect(mergeChargesExecutor).toHaveBeenCalledTimes(2); }); it('should throw error if admin business ID not found in context', async () => { const mockAdminContextProvider = { getVerifiedAdminContext: vi.fn(() => Promise.reject(new Error('Admin context not found for the authenticated user.'))), } as any; const provider = new ChargesMatcherProvider( mockAdminContextProvider, { getChargesByFilters: vi.fn() } as any, { transactionsByChargeIDLoader: { load: vi.fn() } } as any, { getDocumentsByChargeIdLoader: { load: vi.fn() } } as any, { injector: {} } as any, ); await expect( provider.autoMatchCharges(), ).rejects.toThrow(/Admin context not found/); }); }); });