@accounter/server
Version:
Accounter GraphQL server
662 lines (587 loc) • 22.3 kB
text/typescript
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/);
});
});
});