@accounter/server
Version:
Accounter GraphQL server
631 lines (523 loc) • 23.3 kB
text/typescript
import { describe, expect, it, vi } from 'vitest';
import type { Injector } from 'graphql-modules';
import type { Document, DocumentCharge, Transaction, TransactionCharge } from '../types.js';
import {
findMatches,
} from '../providers/single-match.provider.js';
import { createMockTransaction, createMockDocument } from './test-helpers.js';
// Mock DI system and ClientsProvider
vi.mock('../../financial-entities/providers/clients.provider.js', () => ({
ClientsProvider: class {},
}));
// Test user and business IDs
const USER_ID = 'user-123';
const BUSINESS_A = 'business-a';
const BUSINESS_B = 'business-b';
// Create a mock injector for testing
const createMockInjector = () => ({
get: vi.fn((token: {name: string}) => {
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;
// Helper to create transaction charge
function createTxCharge(
chargeId: string,
transactions: Transaction[],
): TransactionCharge {
return {
chargeId,
transactions,
};
}
// Helper to create document charge
function createDocCharge(
chargeId: string,
documents: Document[],
): DocumentCharge {
return {
chargeId,
documents,
};
}
describe('Single-Match Provider', () => {
describe('findMatches', () => {
describe('Valid Transaction Charge → Finds Document Matches', () => {
it('should find matching document charges for transaction charge', async () => {
const sourceCharge = createTxCharge('tx-charge-1', [
createMockTransaction({
amount: "100",
currency: 'USD',
event_date: new Date('2024-01-15'),
}),
]);
const candidateCharges = [
createDocCharge('doc-charge-1', [
createMockDocument({
total_amount: 100,
currency_code: 'USD',
date: new Date('2024-01-15'),
}),
]),
createDocCharge('doc-charge-2', [
createMockDocument({
total_amount: 110,
currency_code: 'USD',
date: new Date('2024-01-16'),
}),
]),
];
const results = await findMatches(sourceCharge, candidateCharges, USER_ID, createMockInjector());
expect(results).toHaveLength(2);
expect(results[0].chargeId).toBe('doc-charge-1'); // Perfect match
expect(results[0].confidenceScore).toBeGreaterThan(0.95);
expect(results[1].chargeId).toBe('doc-charge-2'); // Partial match
expect(results[1].confidenceScore).toBeLessThan(0.95);
});
it('should exclude transaction candidates when source is transaction', async () => {
const sourceCharge = createTxCharge('tx-charge-1', [createMockTransaction()]);
const candidateCharges = [
createTxCharge('tx-charge-2', [createMockTransaction()]), // Should be excluded
createDocCharge('doc-charge-1', [createMockDocument()]), // Should be included
];
const results = await findMatches(sourceCharge, candidateCharges, USER_ID, createMockInjector());
expect(results).toHaveLength(1);
expect(results[0].chargeId).toBe('doc-charge-1');
});
});
describe('Valid Document Charge → Finds Transaction Matches', () => {
it('should find matching transaction charges for document charge', async () => {
const sourceCharge = createDocCharge('doc-charge-1', [
createMockDocument({
total_amount: 100,
currency_code: 'USD',
date: new Date('2024-01-15'),
}),
]);
const candidateCharges = [
createTxCharge('tx-charge-1', [
createMockTransaction({
amount: "100",
currency: 'USD',
event_date: new Date('2024-01-15'),
}),
]),
createTxCharge('tx-charge-2', [
createMockTransaction({
amount: "90",
currency: 'USD',
event_date: new Date('2024-01-16'),
}),
]),
];
const results = await findMatches(sourceCharge, candidateCharges, USER_ID, createMockInjector());
expect(results).toHaveLength(2);
expect(results[0].chargeId).toBe('tx-charge-1'); // Perfect match
expect(results[1].chargeId).toBe('tx-charge-2'); // Partial match
});
it('should exclude document candidates when source is document', async () => {
const sourceCharge = createDocCharge('doc-charge-1', [createMockDocument()]);
const candidateCharges = [
createDocCharge('doc-charge-2', [createMockDocument()]), // Should be excluded
createTxCharge('tx-charge-1', [createMockTransaction()]), // Should be included
];
const results = await findMatches(sourceCharge, candidateCharges, USER_ID, createMockInjector());
expect(results).toHaveLength(1);
expect(results[0].chargeId).toBe('tx-charge-1');
});
});
describe('Matched Charge Input → Throws Error', () => {
it('should throw error if source has both transactions and documents', async () => {
const matchedCharge = {
chargeId: 'matched-charge',
transactions: [createMockTransaction()],
documents: [createMockDocument()],
} as any;
await expect(findMatches(matchedCharge, [], USER_ID, createMockInjector())).rejects.toThrow(
/already matched.*both transactions and documents/,
);
});
it('should throw error if source has neither transactions nor documents', async () => {
const emptyCharge = {
chargeId: 'empty-charge',
transactions: [],
documents: [],
} as any;
await expect(findMatches(emptyCharge, [], USER_ID, createMockInjector())).rejects.toThrow(
/no transactions or documents/,
);
});
});
describe('Multiple Currencies in Source → Error Propagates', () => {
it('should throw error for source with mixed currencies', async () => {
const mixedCurrencyCharge = createTxCharge('tx-charge-1', [
createMockTransaction({ currency: 'USD' }),
createMockTransaction({ currency: 'EUR' }),
]);
await expect(findMatches(mixedCurrencyCharge, [], USER_ID, createMockInjector())).rejects.toThrow(
/failed validation.*multiple currencies/,
);
});
it('should throw error for source with multiple business IDs', async () => {
const mixedBusinessCharge = createTxCharge('tx-charge-1', [
createMockTransaction({ business_id: BUSINESS_A }),
createMockTransaction({ business_id: BUSINESS_B }),
]);
await expect(findMatches(mixedBusinessCharge, [], USER_ID, createMockInjector())).rejects.toThrow(
/failed validation.*multiple business/,
);
});
it('should throw error for source document with invalid business extraction', async () => {
const invalidDocCharge = createDocCharge('doc-charge-1', [
createMockDocument({
creditor_id: 'other-user',
debtor_id: 'another-user',
}),
]);
await expect(findMatches(invalidDocCharge, [], USER_ID, createMockInjector())).rejects.toThrow(/failed validation/);
});
});
describe('No Candidates Found → Empty Array', () => {
it('should return empty array when no candidates provided', async () => {
const sourceCharge = createTxCharge('tx-charge-1', [createMockTransaction()]);
const results = await findMatches(sourceCharge, [], USER_ID, createMockInjector());
expect(results).toEqual([]);
});
it('should return empty array when all candidates are same type', async () => {
const sourceCharge = createTxCharge('tx-charge-1', [createMockTransaction()]);
const candidateCharges = [
createTxCharge('tx-charge-2', [createMockTransaction()]),
createTxCharge('tx-charge-3', [createMockTransaction()]),
];
const results = await findMatches(sourceCharge, candidateCharges, USER_ID, createMockInjector());
expect(results).toEqual([]);
});
it('should return empty array when all candidates outside date window', async () => {
const sourceCharge = createTxCharge('tx-charge-1', [
createMockTransaction({ event_date: new Date('2024-01-15') }),
]);
const candidateCharges = [
createDocCharge('doc-charge-1', [
createMockDocument({ date: new Date('2023-01-01') }), // 1 year before
]),
createDocCharge('doc-charge-2', [
createMockDocument({ date: new Date('2025-02-01') }), // 1 year after
]),
];
const results = await findMatches(sourceCharge, candidateCharges, USER_ID, createMockInjector());
expect(results).toEqual([]);
});
it('should return empty array when all candidates fail scoring', async () => {
const sourceCharge = createTxCharge('tx-charge-1', [
createMockTransaction({ currency: 'USD' }),
]);
// Candidates with mixed currencies will fail scoring
const candidateCharges = [
createDocCharge('doc-charge-1', [
createMockDocument({ currency_code: 'USD' }),
createMockDocument({ currency_code: 'EUR' }),
]),
];
const results = await findMatches(sourceCharge, candidateCharges, USER_ID, createMockInjector());
expect(results).toEqual([]);
});
});
describe('Candidate Count Handling', () => {
it('should return all candidates when fewer than 5', async () => {
const sourceCharge = createTxCharge('tx-charge-1', [createMockTransaction()]);
const candidateCharges = [
createDocCharge('doc-charge-1', [createMockDocument()]),
createDocCharge('doc-charge-2', [createMockDocument()]),
createDocCharge('doc-charge-3', [createMockDocument()]),
];
const results = await findMatches(sourceCharge, candidateCharges, USER_ID, createMockInjector());
expect(results).toHaveLength(3);
});
it('should return top 5 when more than 5 candidates', async () => {
const sourceCharge = createTxCharge('tx-charge-1', [
createMockTransaction({ amount: "100" }),
]);
const candidateCharges = [
createDocCharge('doc-charge-1', [createMockDocument({ total_amount: 100 })]), // Perfect
createDocCharge('doc-charge-2', [createMockDocument({ total_amount: 101 })]),
createDocCharge('doc-charge-3', [createMockDocument({ total_amount: 102 })]),
createDocCharge('doc-charge-4', [createMockDocument({ total_amount: 103 })]),
createDocCharge('doc-charge-5', [createMockDocument({ total_amount: 104 })]),
createDocCharge('doc-charge-6', [createMockDocument({ total_amount: 105 })]),
createDocCharge('doc-charge-7', [createMockDocument({ total_amount: 106 })]),
];
const results = await findMatches(sourceCharge, candidateCharges, USER_ID, createMockInjector());
expect(results).toHaveLength(5);
expect(results[0].chargeId).toBe('doc-charge-1'); // Best match
});
it('should respect custom maxMatches option', async () => {
const sourceCharge = createTxCharge('tx-charge-1', [createMockTransaction()]);
const candidateCharges = [
createDocCharge('doc-charge-1', [createMockDocument()]),
createDocCharge('doc-charge-2', [createMockDocument()]),
createDocCharge('doc-charge-3', [createMockDocument()]),
createDocCharge('doc-charge-4', [createMockDocument()]),
createDocCharge('doc-charge-5', [createMockDocument()]),
];
const results = await findMatches(sourceCharge, candidateCharges, USER_ID, createMockInjector(), { maxMatches: 3 });
expect(results).toHaveLength(3);
});
});
describe('Tie-Breaking by Date Proximity', () => {
it('should use date proximity as tie-breaker when confidence scores equal', async () => {
const sourceCharge = createTxCharge('tx-charge-1', [
createMockTransaction({
amount: "100",
event_date: new Date('2024-01-15'),
}),
]);
// All have same amounts (same confidence), different dates
const candidateCharges = [
createDocCharge('doc-charge-far', [
createMockDocument({
total_amount: 100,
date: new Date('2024-01-05'), // 10 days away
}),
]),
createDocCharge('doc-charge-close', [
createMockDocument({
total_amount: 100,
date: new Date('2024-01-14'), // 1 day away
}),
]),
createDocCharge('doc-charge-medium', [
createMockDocument({
total_amount: 100,
date: new Date('2024-01-10'), // 5 days away
}),
]),
];
const results = await findMatches(sourceCharge, candidateCharges, USER_ID, createMockInjector());
expect(results[0].chargeId).toBe('doc-charge-close'); // Closest date wins
expect(results[1].chargeId).toBe('doc-charge-medium');
expect(results[2].chargeId).toBe('doc-charge-far');
});
it('should include dateProximity in results', async () => {
const sourceCharge = createTxCharge('tx-charge-1', [
createMockTransaction({ event_date: new Date('2024-01-15') }),
]);
const candidateCharges = [
createDocCharge('doc-charge-1', [
createMockDocument({ date: new Date('2024-01-14') }), // 1 day
]),
];
const results = await findMatches(sourceCharge, candidateCharges, USER_ID, createMockInjector());
expect(results[0].dateProximity).toBe(1);
});
});
describe('Date Window Filtering', () => {
it('should filter candidates outside 12-month window by default', async () => {
const sourceCharge = createTxCharge('tx-charge-1', [
createMockTransaction({ event_date: new Date('2024-01-15') }),
]);
const candidateCharges = [
createDocCharge('doc-inside', [
createMockDocument({ date: new Date('2024-06-15') }), // 5 months
]),
createDocCharge('doc-outside', [
createMockDocument({ date: new Date('2025-02-15') }), // 13 months
]),
];
const results = await findMatches(sourceCharge, candidateCharges, USER_ID, createMockInjector());
expect(results).toHaveLength(1);
expect(results[0].chargeId).toBe('doc-inside');
});
it('should respect custom dateWindowMonths option', async () => {
const sourceCharge = createTxCharge('tx-charge-1', [
createMockTransaction({ event_date: new Date('2024-01-15') }),
]);
const candidateCharges = [
createDocCharge('doc-inside', [
createMockDocument({ date: new Date('2024-03-15') }), // 2 months
]),
createDocCharge('doc-outside', [
createMockDocument({ date: new Date('2024-05-15') }), // 4 months
]),
];
const results = await findMatches(sourceCharge, candidateCharges, USER_ID, createMockInjector(), {
dateWindowMonths: 3,
});
expect(results).toHaveLength(1);
expect(results[0].chargeId).toBe('doc-inside');
});
it('should include candidates on exact window boundary', async () => {
const sourceCharge = createTxCharge('tx-charge-1', [
createMockTransaction({ event_date: new Date('2024-01-15') }),
]);
const candidateCharges = [
createDocCharge('doc-boundary', [
createMockDocument({ date: new Date('2025-01-15') }), // Exactly 12 months
]),
];
const results = await findMatches(sourceCharge, candidateCharges, USER_ID, createMockInjector());
expect(results).toHaveLength(1);
});
});
describe('Fee Transactions Excluded', () => {
it('should handle source with fee transactions via aggregator', async () => {
const sourceCharge = createTxCharge('tx-charge-1', [
createMockTransaction({ amount: "100", is_fee: false }),
createMockTransaction({ amount: "5", is_fee: true }), // Should be excluded by aggregator
]);
const candidateCharges = [
createDocCharge('doc-charge-1', [createMockDocument({ total_amount: 100 })]),
];
const results = await findMatches(sourceCharge, candidateCharges, USER_ID, createMockInjector());
expect(results).toHaveLength(1);
expect(results[0].confidenceScore).toBeGreaterThan(0.95); // Matches 100, not 105
});
it('should throw error if all source transactions are fees', async () => {
const allFeesCharge = createTxCharge('tx-charge-1', [
createMockTransaction({ is_fee: true }),
createMockTransaction({ is_fee: true }),
]);
await expect(findMatches(allFeesCharge, [], USER_ID, createMockInjector())).rejects.toThrow(
/all transactions are marked as fees/,
);
});
});
describe('Same ChargeId → Throws Error', () => {
it('should throw error when candidate has same chargeId as source', async () => {
const sourceCharge = createTxCharge('same-charge-id', [createMockTransaction()]);
const candidateCharges = [
createDocCharge('same-charge-id', [createMockDocument()]), // Same ID!
];
await expect(findMatches(sourceCharge, candidateCharges, USER_ID, createMockInjector())).rejects.toThrow(
/same ID as source charge/,
);
});
});
describe('Various Confidence Levels', () => {
it('should rank matches by confidence level correctly', async () => {
const sourceCharge = createTxCharge('tx-charge-1', [
createMockTransaction({
amount: "100",
currency: 'USD',
event_date: new Date('2024-01-15'),
business_id: BUSINESS_A,
}),
]);
const candidateCharges = [
// Perfect match - all fields align
createDocCharge('perfect', [
createMockDocument({
total_amount: 100,
currency_code: 'USD',
date: new Date('2024-01-15'),
creditor_id: BUSINESS_A,
debtor_id: USER_ID,
}),
]),
// Good match - date difference
createDocCharge('good', [
createMockDocument({
total_amount: 100,
currency_code: 'USD',
date: new Date('2024-01-20'), // 5 days off
creditor_id: BUSINESS_A,
debtor_id: USER_ID,
}),
]),
// Medium match - slight amount difference
createDocCharge('medium', [
createMockDocument({
total_amount: 100.5,
currency_code: 'USD',
date: new Date('2024-01-15'),
creditor_id: BUSINESS_A,
debtor_id: USER_ID,
}),
]),
// Poor match - business mismatch
createDocCharge('poor', [
createMockDocument({
total_amount: 100,
currency_code: 'USD',
date: new Date('2024-01-15'),
creditor_id: BUSINESS_B, // Different business
debtor_id: USER_ID,
}),
]),
];
const results = await findMatches(sourceCharge, candidateCharges, USER_ID, createMockInjector());
expect(results).toHaveLength(4);
expect(results[0].chargeId).toBe('perfect');
expect(results[0].confidenceScore).toBeGreaterThan(0.95);
expect(results[1].chargeId).toBe('good');
expect(results[2].chargeId).toBe('medium');
expect(results[3].chargeId).toBe('poor');
});
it('should include component scores in results', async () => {
const sourceCharge = createTxCharge('tx-charge-1', [createMockTransaction()]);
const candidateCharges = [
createDocCharge('doc-charge-1', [createMockDocument()]),
];
const results = await findMatches(sourceCharge, candidateCharges, USER_ID, createMockInjector());
expect(results[0].components).toBeDefined();
expect(results[0].components.amount).toBeGreaterThanOrEqual(0);
expect(results[0].components.currency).toBeGreaterThanOrEqual(0);
expect(results[0].components.business).toBeGreaterThanOrEqual(0);
expect(results[0].components.date).toBeGreaterThanOrEqual(0);
});
});
describe('Edge Cases', () => {
it('should handle multiple transactions in source charge', async () => {
const sourceCharge = createTxCharge('tx-charge-1', [
createMockTransaction({ amount: "50" }),
createMockTransaction({ amount: "50" }),
]);
const candidateCharges = [
createDocCharge('doc-charge-1', [createMockDocument({ total_amount: 100 })]),
];
const results = await findMatches(sourceCharge, candidateCharges, USER_ID, createMockInjector());
expect(results).toHaveLength(1);
expect(results[0].confidenceScore).toBeGreaterThan(0.95); // 50+50 = 100
});
it('should handle multiple documents in candidate charge', async () => {
const sourceCharge = createTxCharge('tx-charge-1', [
createMockTransaction({ amount: "100" }),
]);
const candidateCharges = [
createDocCharge('doc-charge-1', [
createMockDocument({ total_amount: 60 }),
createMockDocument({ total_amount: 40 }),
]),
];
const results = await findMatches(sourceCharge, candidateCharges, USER_ID, createMockInjector());
expect(results).toHaveLength(1);
expect(results[0].confidenceScore).toBeGreaterThan(0.95); // 60+40 = 100
});
it('should handle negative amounts (credit transactions)', async () => {
const sourceCharge = createTxCharge('tx-charge-1', [
createMockTransaction({ amount: "-100" }),
]);
const candidateCharges = [
createDocCharge('doc-charge-1', [
createMockDocument({
total_amount: 100,
type: 'CREDIT_INVOICE',
creditor_id: USER_ID, // User is creditor for credit invoice
debtor_id: BUSINESS_A,
}),
]),
];
const results = await findMatches(sourceCharge, candidateCharges, USER_ID, createMockInjector());
expect(results).toHaveLength(1);
expect(results[0].confidenceScore).toBeGreaterThan(0.9);
});
});
});
});