@accounter/server
Version:
Accounter GraphQL server
1,127 lines (965 loc) • 38.3 kB
text/typescript
import { describe, expect, it, vi } from 'vitest';
import type { AggregatedTransaction, DocumentCharge, TransactionCharge } from '../types.js';
import {
scoreMatch,
selectTransactionDate,
} from '../providers/match-scorer.provider.js';
import { createMockTransaction, createMockDocument } from './test-helpers.js';
import { Injector } from 'graphql-modules';
import { DocumentType } from '../../../shared/enums.js';
// Mock DI system and ClientsProvider / IssuedDocumentsProvider
vi.mock('../../financial-entities/providers/clients.provider.js', () => ({
ClientsProvider: class {},
}));
vi.mock('../../documents/providers/issued-documents.provider.js', () => ({
IssuedDocumentsProvider: class {},
}));
// Test user ID
const USER_ID = 'user-123';
const BUSINESS_ID = 'business-abc';
// Create a mock injector for testing client matching
// 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);
},
},
};
if (token.name === 'IssuedDocumentsProvider')
return {
getIssuedDocumentsStatusByChargeIdLoader: {
load: async (_chargeId: string) => ({ charge_id: _chargeId, open_docs_flag: true }),
},
};
return null;
}),
}) as Injector;
// Spy-able injector to assert DataLoader usage
const createSpyInjector = (loaderImpl?: (businessId: string) => Promise<any>) => {
const load = vi.fn(loaderImpl ?? (() => Promise.resolve(null)));
const get = vi.fn((token: {name: string}) => {
if (token.name === 'ClientsProvider') {
return {
getClientByIdLoader: { load },
};
}
if (token.name === 'IssuedDocumentsProvider') {
return {
getIssuedDocumentsStatusByChargeIdLoader: {
load: vi.fn(async (chargeId: string) => ({ charge_id: chargeId, open_docs_flag: true })),
},
};
}
return null;
});
return { get, load } as unknown as Injector & { load: typeof load };
};
describe('Match Scorer', () => {
describe('selectTransactionDate', () => {
const transaction: AggregatedTransaction = {
amount: 100,
currency: 'USD' as const,
businessId: BUSINESS_ID,
date: new Date('2024-01-15'), // event_date
debitDate: new Date('2024-01-20'), // debit_date
description: 'Test',
};
it('should use event_date for INVOICE', () => {
const result = selectTransactionDate(transaction, DocumentType.Invoice);
expect(result).toEqual(new Date('2024-01-15'));
});
it('should use event_date for CREDIT_INVOICE', () => {
const result = selectTransactionDate(transaction, DocumentType.CreditInvoice);
expect(result).toEqual(new Date('2024-01-15'));
});
it('should use event_date for RECEIPT', () => {
const result = selectTransactionDate(transaction, DocumentType.Receipt);
expect(result).toEqual(new Date('2024-01-15'));
});
it('should use event_date for INVOICE_RECEIPT', () => {
const result = selectTransactionDate(transaction, DocumentType.InvoiceReceipt);
expect(result).toEqual(new Date('2024-01-15'));
});
it('should use event_date for RECEIPT when debitDate is null', () => {
const txWithoutDebitDate = { ...transaction, debitDate: null };
const result = selectTransactionDate(txWithoutDebitDate, DocumentType.Receipt);
expect(result).toEqual(new Date('2024-01-15'));
});
it('should use event_date for OTHER', () => {
const result = selectTransactionDate(transaction, DocumentType.Other);
expect(result).toEqual(new Date('2024-01-15'));
});
it('should use event_date for PROFORMA', () => {
const result = selectTransactionDate(transaction, DocumentType.Proforma);
expect(result).toEqual(new Date('2024-01-15'));
});
it('should use event_date for UNPROCESSED', () => {
const result = selectTransactionDate(transaction, DocumentType.Unprocessed);
expect(result).toEqual(new Date('2024-01-15'));
});
});
describe('scoreMatch', () => {
describe('Perfect Matches', () => {
it('should score perfect match close to 1.0', async () => {
const txCharge: TransactionCharge = {
chargeId: 'charge-tx-1',
transactions: [
createMockTransaction({
amount: '100.00',
currency: 'USD',
event_date: new Date('2024-01-15'),
business_id: BUSINESS_ID,
}),
],
};
const docCharge: DocumentCharge = {
chargeId: 'charge-doc-1',
documents: [
createMockDocument({
total_amount: 100,
currency_code: 'USD',
date: new Date('2024-01-15'),
creditor_id: BUSINESS_ID,
debtor_id: USER_ID,
type: 'INVOICE',
}),
],
};
const result = await scoreMatch(txCharge, docCharge, USER_ID, createMockInjector());
expect(result.chargeId).toBe('charge-doc-1');
expect(result.confidenceScore).toBeGreaterThan(0.95);
expect(result.components.amount).toBe(1.0);
expect(result.components.currency).toBe(1.0);
expect(result.components.business).toBe(1.0);
// Non-client same-business: uses standard date calculation, not flat 1.0
expect(result.components.date).toBe(1.0);
});
it('should handle receipt matching with event_date', async () => {
const txCharge: TransactionCharge = {
chargeId: 'charge-tx-2',
transactions: [
createMockTransaction({
amount: "200.00",
event_date: new Date('2024-01-10'),
debit_date: new Date('2024-01-15'),
debit_timestamp: null,
business_id: 'customer-receipt', // Different business for cross-business test
}),
],
};
const docCharge: DocumentCharge = {
chargeId: 'charge-doc-2',
documents: [
createMockDocument({
total_amount: 200,
date: new Date('2024-01-15'),
type: 'RECEIPT',
creditor_id: 'vendor-receipt', // Different business
}),
],
};
const result = await scoreMatch(txCharge, docCharge, USER_ID, createMockInjector());
expect(result.confidenceScore).toBeGreaterThan(0.70); // Amount + currency + date good, business mismatch
expect(result.components.date).toBeCloseTo(0.83, 1);
});
});
describe('Partial Matches', () => {
it('should handle amount mismatch', async () => {
const txCharge: TransactionCharge = {
chargeId: 'charge-tx-3',
transactions: [createMockTransaction({ amount: "100.00" })],
};
const docCharge: DocumentCharge = {
chargeId: 'charge-doc-3',
documents: [createMockDocument({ total_amount: 110 })], // 10% difference
};
const result = await scoreMatch(txCharge, docCharge, USER_ID, createMockInjector());
expect(result.components.amount).toBeLessThan(1.0);
expect(result.components.amount).toBeGreaterThan(0.0);
expect(result.confidenceScore).toBeLessThan(0.95);
});
it('should handle currency mismatch', async () => {
const txCharge: TransactionCharge = {
chargeId: 'charge-tx-4',
transactions: [createMockTransaction({ currency: 'USD' })],
};
const docCharge: DocumentCharge = {
chargeId: 'charge-doc-4',
documents: [createMockDocument({ currency_code: 'EUR' })],
};
const result = await scoreMatch(txCharge, docCharge, USER_ID, createMockInjector());
expect(result.components.currency).toBe(0.0); // Different currencies = 0.0
// Overall confidence can still be decent if other factors match
expect(result.confidenceScore).toBeLessThan(1.0);
});
it('should handle business mismatch', async () => {
const txCharge: TransactionCharge = {
chargeId: 'charge-tx-5',
transactions: [createMockTransaction({ business_id: 'business-1' })],
};
const docCharge: DocumentCharge = {
chargeId: 'charge-doc-5',
documents: [
createMockDocument({
creditor_id: 'business-2',
debtor_id: USER_ID,
}),
],
};
const result = await scoreMatch(txCharge, docCharge, USER_ID, createMockInjector());
expect(result.components.business).toBe(0.2);
});
it('should handle date difference', async () => {
const txCharge: TransactionCharge = {
chargeId: 'charge-tx-6',
transactions: [
createMockTransaction({
event_date: new Date('2024-01-01'),
business_id: 'customer-1', // Different business for cross-business test
}),
],
};
const docCharge: DocumentCharge = {
chargeId: 'charge-doc-6',
documents: [
createMockDocument({
date: new Date('2024-01-16'), // 15 days difference
creditor_id: 'vendor-1', // Different business
}),
],
};
const result = await scoreMatch(txCharge, docCharge, USER_ID, createMockInjector());
expect(result.components.date).toBe(0.5); // 15/30 = 0.5
});
});
describe('Client-aware date confidence', () => {
it('returns flat 1.0 for client same-business on same day', async () => {
const txCharge: TransactionCharge = {
chargeId: 'charge-tx-client-same-day',
transactions: [
createMockTransaction({
amount: "100.00",
currency: 'USD',
event_date: new Date('2024-01-15'),
business_id: 'client-abc',
}),
],
};
const docCharge: DocumentCharge = {
chargeId: 'charge-doc-client-same-day',
documents: [
createMockDocument({
total_amount: 100,
currency_code: 'USD',
date: new Date('2024-01-15'),
creditor_id: 'client-abc',
debtor_id: USER_ID,
type: 'INVOICE',
}),
],
};
const spyInjector = createSpyInjector(async () => ({ id: 'client-abc' }));
const result = await scoreMatch(txCharge, docCharge, USER_ID, spyInjector as any);
expect(result.components.date).toBe(1.0);
expect(spyInjector.load).toHaveBeenCalledTimes(1);
});
it('returns flat 1.0 for client same-business 30 days apart', async () => {
const txCharge: TransactionCharge = {
chargeId: 'charge-tx-client-30',
transactions: [
createMockTransaction({
event_date: new Date('2024-01-31'),
business_id: 'client-xyz',
}),
],
};
const docCharge: DocumentCharge = {
chargeId: 'charge-doc-client-30',
documents: [
createMockDocument({
date: new Date('2024-01-01'),
creditor_id: 'client-xyz',
debtor_id: USER_ID,
type: 'INVOICE',
}),
],
};
const spyInjector = createSpyInjector(async () => ({ id: 'client-xyz' }));
const result = await scoreMatch(txCharge, docCharge, USER_ID, spyInjector as any);
expect(result.components.date).toBe(1.0);
expect(spyInjector.load).toHaveBeenCalledTimes(1);
});
it('returns flat 1.0 for client same-business 365 days apart', async () => {
const txCharge: TransactionCharge = {
chargeId: 'charge-tx-client-365',
transactions: [
createMockTransaction({
event_date: new Date('2024-01-01'),
business_id: 'client-long',
}),
],
};
const docCharge: DocumentCharge = {
chargeId: 'charge-doc-client-365',
documents: [
createMockDocument({
date: new Date('2023-01-01'),
creditor_id: 'client-long',
debtor_id: USER_ID,
type: 'INVOICE',
}),
],
};
const spyInjector = createSpyInjector(async () => ({ id: 'client-long' }));
const result = await scoreMatch(txCharge, docCharge, USER_ID, spyInjector as any);
expect(result.components.date).toBe(1.0);
expect(spyInjector.load).toHaveBeenCalledTimes(1);
});
it('uses standard degradation for non-client same-business (15 days)', async () => {
const txCharge: TransactionCharge = {
chargeId: 'charge-tx-nonclient-15',
transactions: [
createMockTransaction({
event_date: new Date('2024-01-01'),
business_id: 'vendor-plain',
}),
],
};
const docCharge: DocumentCharge = {
chargeId: 'charge-doc-nonclient-15',
documents: [
createMockDocument({
date: new Date('2024-01-16'),
creditor_id: 'vendor-plain',
debtor_id: USER_ID,
type: 'INVOICE',
}),
],
};
const result = await scoreMatch(txCharge, docCharge, USER_ID, createMockInjector());
expect(result.components.date).toBe(0.5);
});
it('uses standard degradation for non-client same-business (45 days)', async () => {
const txCharge: TransactionCharge = {
chargeId: 'charge-tx-nonclient-45',
transactions: [
createMockTransaction({
event_date: new Date('2024-01-01'),
business_id: 'vendor-far',
}),
],
};
const docCharge: DocumentCharge = {
chargeId: 'charge-doc-nonclient-45',
documents: [
createMockDocument({
date: new Date('2024-02-15'),
creditor_id: 'vendor-far',
debtor_id: USER_ID,
type: 'INVOICE',
}),
],
};
const result = await scoreMatch(txCharge, docCharge, USER_ID, createMockInjector());
expect(result.components.date).toBe(0.0);
});
it('uses standard degradation for cross-business matches', async () => {
const txCharge: TransactionCharge = {
chargeId: 'charge-tx-cross',
transactions: [
createMockTransaction({
event_date: new Date('2024-01-01'),
business_id: 'customer-xyz',
}),
],
};
const docCharge: DocumentCharge = {
chargeId: 'charge-doc-cross',
documents: [
createMockDocument({
date: new Date('2024-01-06'),
creditor_id: 'vendor-xyz',
debtor_id: USER_ID,
type: 'INVOICE',
}),
],
};
const result = await scoreMatch(txCharge, docCharge, USER_ID, createMockInjector());
expect(result.components.date).toBeCloseTo(0.83, 1);
});
it('skips client lookup when businessId is null', async () => {
const txCharge: TransactionCharge = {
chargeId: 'charge-tx-null-business',
transactions: [
createMockTransaction({
event_date: new Date('2024-01-01'),
business_id: null,
}),
],
};
const docCharge: DocumentCharge = {
chargeId: 'charge-doc-null-business',
documents: [
createMockDocument({
date: new Date('2024-01-16'),
creditor_id: USER_ID,
debtor_id: null,
type: 'INVOICE',
}),
],
};
const spyInjector = createSpyInjector();
const result = await scoreMatch(txCharge, docCharge, USER_ID, spyInjector as any);
expect(result.components.date).toBe(0.5);
expect(spyInjector.load).not.toHaveBeenCalled();
});
it('uses ClientsProvider result when lookup succeeds', async () => {
const txCharge: TransactionCharge = {
chargeId: 'charge-tx-client-lookup',
transactions: [
createMockTransaction({
event_date: new Date('2024-01-01'),
business_id: 'client-lookup',
}),
],
};
const docCharge: DocumentCharge = {
chargeId: 'charge-doc-client-lookup',
documents: [
createMockDocument({
date: new Date('2023-12-01'),
creditor_id: 'client-lookup',
debtor_id: USER_ID,
type: 'INVOICE',
}),
],
};
const spyInjector = createSpyInjector(async () => ({ id: 'client-lookup' }));
const result = await scoreMatch(txCharge, docCharge, USER_ID, spyInjector as any);
expect(result.components.date).toBe(1.0);
expect(spyInjector.load).toHaveBeenCalledTimes(1);
});
it('uses standard degradation when ClientsProvider returns null', async () => {
const txCharge: TransactionCharge = {
chargeId: 'charge-tx-client-null',
transactions: [
createMockTransaction({
event_date: new Date('2024-01-01'),
business_id: 'client-missing',
}),
],
};
const docCharge: DocumentCharge = {
chargeId: 'charge-doc-client-null',
documents: [
createMockDocument({
date: new Date('2024-01-11'),
creditor_id: 'client-missing',
debtor_id: USER_ID,
type: 'INVOICE',
}),
],
};
const spyInjector = createSpyInjector(async () => null);
const result = await scoreMatch(txCharge, docCharge, USER_ID, spyInjector as any);
expect(result.components.date).toBeCloseTo(0.67, 1);
expect(spyInjector.load).toHaveBeenCalledTimes(1);
});
it('gives equal date scores for multiple client invoices regardless of recency', async () => {
const txCharge: TransactionCharge = {
chargeId: 'charge-tx-client-multi',
transactions: [
createMockTransaction({
event_date: new Date('2024-01-15'),
business_id: 'client-multi',
}),
],
};
const janDoc: DocumentCharge = {
chargeId: 'charge-doc-client-jan',
documents: [
createMockDocument({
date: new Date('2023-12-30'),
creditor_id: 'client-multi',
debtor_id: USER_ID,
type: 'INVOICE',
}),
],
};
const febDoc: DocumentCharge = {
chargeId: 'charge-doc-client-feb',
documents: [
createMockDocument({
date: new Date('2023-11-30'),
creditor_id: 'client-multi',
debtor_id: USER_ID,
type: 'INVOICE',
}),
],
};
const spyInjector = createSpyInjector(async () => ({ id: 'client-multi' }));
const janScore = await scoreMatch(txCharge, janDoc, USER_ID, spyInjector as any);
const febScore = await scoreMatch(txCharge, febDoc, USER_ID, spyInjector as any);
expect(janScore.components.date).toBe(1.0);
expect(febScore.components.date).toBe(1.0);
expect(spyInjector.load).toHaveBeenCalledTimes(2);
});
it('prefers closer date for non-client when amounts tie', async () => {
const txCharge: TransactionCharge = {
chargeId: 'charge-tx-nonclient-multi',
transactions: [
createMockTransaction({
event_date: new Date('2024-01-15'),
business_id: 'vendor-multi',
}),
],
};
const nearDoc: DocumentCharge = {
chargeId: 'charge-doc-nonclient-near',
documents: [
createMockDocument({
date: new Date('2024-01-17'),
creditor_id: 'vendor-multi',
debtor_id: USER_ID,
type: 'INVOICE',
}),
],
};
const farDoc: DocumentCharge = {
chargeId: 'charge-doc-nonclient-far',
documents: [
createMockDocument({
date: new Date('2024-02-20'),
creditor_id: 'vendor-multi',
debtor_id: USER_ID,
type: 'INVOICE',
}),
],
};
const nearScore = await scoreMatch(txCharge, nearDoc, USER_ID, createMockInjector());
const farScore = await scoreMatch(txCharge, farDoc, USER_ID, createMockInjector());
expect(nearScore.components.date).toBeGreaterThan(farScore.components.date);
});
});
describe('Date Type Selection', () => {
it('should use event_date for INVOICE matching', async () => {
const txCharge: TransactionCharge = {
chargeId: 'charge-tx-7',
transactions: [
createMockTransaction({
event_date: new Date('2024-01-15'),
debit_date: new Date('2024-01-20'), // Different date
}),
],
};
const docCharge: DocumentCharge = {
chargeId: 'charge-doc-7',
documents: [
createMockDocument({
date: new Date('2024-01-15'), // Matches event_date
type: 'INVOICE',
}),
],
};
const result = await scoreMatch(txCharge, docCharge, USER_ID, createMockInjector());
expect(result.components.date).toBe(1.0); // Should match event_date
});
it('should use event_date for RECEIPT matching', async () => {
const txCharge: TransactionCharge = {
chargeId: 'charge-tx-8',
transactions: [
createMockTransaction({
event_date: new Date('2024-01-10'),
debit_date: new Date('2024-01-15'),
debit_timestamp: null,
business_id: 'customer-2', // Different business for cross-business test
}),
],
};
const docCharge: DocumentCharge = {
chargeId: 'charge-doc-8',
documents: [
createMockDocument({
date: new Date('2024-01-15'), // Matches debit_date
type: 'RECEIPT',
creditor_id: 'vendor-2', // Different business
}),
],
};
const result = await scoreMatch(txCharge, docCharge, USER_ID, createMockInjector());
expect(result.components.date).toBeCloseTo(0.83, 1);
});
it('should fall back to event_date for RECEIPT when debit_date is null', async () => {
const txCharge: TransactionCharge = {
chargeId: 'charge-tx-9',
transactions: [
createMockTransaction({
event_date: new Date('2024-01-15'),
debit_date: null,
debit_timestamp: null,
}),
],
};
const docCharge: DocumentCharge = {
chargeId: 'charge-doc-9',
documents: [
createMockDocument({
date: new Date('2024-01-15'),
type: 'RECEIPT',
}),
],
};
const result = await scoreMatch(txCharge, docCharge, USER_ID, createMockInjector());
expect(result.components.date).toBe(1.0);
});
});
describe('Flexible Document Types (PROFORMA/OTHER/UNPROCESSED)', () => {
it('should use event_date for PROFORMA and use better score', async () => {
const txCharge: TransactionCharge = {
chargeId: 'charge-tx-10',
transactions: [
createMockTransaction({
event_date: new Date('2024-01-01'), // Far from document date
debit_date: new Date('2024-01-15'), // Matches document date
debit_timestamp: null,
business_id: 'customer-3', // Different business for cross-business test
}),
],
};
const docCharge: DocumentCharge = {
chargeId: 'charge-doc-10',
documents: [
createMockDocument({
date: new Date('2024-01-15'),
type: 'PROFORMA',
creditor_id: 'vendor-3', // Different business
}),
],
};
const result = await scoreMatch(txCharge, docCharge, USER_ID, createMockInjector());
// Should use event_date (2024-01-01) vs latest doc date (2024-01-15) = 14 days
// Date confidence: 1.0 - 14/30 = 0.53
expect(result.components.date).toBeCloseTo(0.53, 1);
});
it('should fail on non-accounting (OTHER / UNPROCESSED) types', async () => {
const txCharge: TransactionCharge = {
chargeId: 'charge-tx-11',
transactions: [
createMockTransaction({
event_date: new Date('2024-01-10'),
debit_date: new Date('2024-01-25'), // Far from document
}),
],
};
const docCharge: DocumentCharge = {
chargeId: 'charge-doc-11',
documents: [
createMockDocument({
date: new Date('2024-01-10'), // Matches event_date
type: 'OTHER',
}),
createMockDocument({
date: new Date('2024-01-15'),
type: 'UNPROCESSED',
}),
],
};
await expect(scoreMatch(txCharge, docCharge, USER_ID, createMockInjector())).rejects.toThrow(/Cannot aggregate documents: array is empty/);
});
it('should handle flexible type without debit_date', async () => {
const txCharge: TransactionCharge = {
chargeId: 'charge-tx-13',
transactions: [
createMockTransaction({
event_date: new Date('2024-01-15'),
debit_date: null,
debit_timestamp: null,
}),
],
};
const docCharge: DocumentCharge = {
chargeId: 'charge-doc-13',
documents: [
createMockDocument({
date: new Date('2024-01-15'),
type: 'PROFORMA',
}),
],
};
const result = await scoreMatch(txCharge, docCharge, USER_ID, createMockInjector());
// Should only use event_date
expect(result.components.date).toBe(1.0);
});
});
describe('Amount Variations', () => {
it('should handle small amount differences', async () => {
const txCharge: TransactionCharge = {
chargeId: 'charge-tx-14',
transactions: [createMockTransaction({ amount: "100.00" })],
};
const docCharge: DocumentCharge = {
chargeId: 'charge-doc-14',
documents: [createMockDocument({ total_amount: 100.5 })], // 0.5 difference
};
const result = await scoreMatch(txCharge, docCharge, USER_ID, createMockInjector());
expect(result.components.amount).toBeGreaterThanOrEqual(0.9); // Within 1 unit = exactly 0.9
});
it('should handle large amount differences', async () => {
const txCharge: TransactionCharge = {
chargeId: 'charge-tx-15',
transactions: [createMockTransaction({ amount: "100.00" })],
};
const docCharge: DocumentCharge = {
chargeId: 'charge-doc-15',
documents: [createMockDocument({ total_amount: 150 })], // 50% difference
};
const result = await scoreMatch(txCharge, docCharge, USER_ID, createMockInjector());
expect(result.components.amount).toBe(0.0);
});
});
describe('Integration Tests', () => {
it('should handle multiple transactions and documents', async () => {
const txCharge: TransactionCharge = {
chargeId: 'charge-tx-16',
transactions: [
createMockTransaction({ amount: "50.00", source_description: 'Part 1' }),
createMockTransaction({ amount: "50.00", source_description: 'Part 2' }),
],
};
const docCharge: DocumentCharge = {
chargeId: 'charge-doc-16',
documents: [
createMockDocument({ total_amount: 100, serial_number: 'INV-001' }),
],
};
const result = await scoreMatch(txCharge, docCharge, USER_ID, createMockInjector());
expect(result.components.amount).toBe(1.0); // 50 + 50 = 100
expect(result.confidenceScore).toBeGreaterThan(0.95);
});
it('should handle credit invoice (negative amounts)', async () => {
const txCharge: TransactionCharge = {
chargeId: 'charge-tx-17',
transactions: [createMockTransaction({ amount: "-100.00" })],
};
const docCharge: DocumentCharge = {
chargeId: 'charge-doc-17',
documents: [
createMockDocument({
total_amount: 100,
type: 'CREDIT_INVOICE',
creditor_id: USER_ID, // Business is debtor
debtor_id: BUSINESS_ID,
}),
],
};
const result = await scoreMatch(txCharge, docCharge, USER_ID, createMockInjector());
expect(result.components.amount).toBe(1.0);
});
it('should handle real-world scenario with slight variations', async () => {
const txCharge: TransactionCharge = {
chargeId: 'charge-tx-18',
transactions: [
createMockTransaction({
amount: "1234.56",
currency: 'USD',
event_date: new Date('2024-03-15'),
debit_date: new Date('2024-03-17'),
business_id: 'vendor-xyz',
}),
],
};
const docCharge: DocumentCharge = {
chargeId: 'charge-doc-18',
documents: [
createMockDocument({
total_amount: 1234.5,
currency_code: 'USD',
date: new Date('2024-03-16'), // 1 day difference from tx event_date
creditor_id: 'vendor-xyz',
debtor_id: USER_ID,
type: 'INVOICE',
}),
],
};
const result = await scoreMatch(txCharge, docCharge, USER_ID, createMockInjector());
expect(result.components.amount).toBeGreaterThanOrEqual(0.9); // Within 1 unit = 0.9
expect(result.components.currency).toBe(1.0);
expect(result.components.business).toBe(1.0);
expect(result.components.date).toBeGreaterThan(0.95); // 1 day = 0.97
expect(result.confidenceScore).toBeGreaterThanOrEqual(0.9);
});
it('should apply flat date confidence for CLIENT same-business matches', async () => {
const txCharge: TransactionCharge = {
chargeId: 'charge-tx-client-1',
transactions: [
createMockTransaction({
amount: "500.00",
currency: 'USD',
event_date: new Date('2024-02-01'),
business_id: 'client-acme', // Registered client
}),
],
};
const docCharge: DocumentCharge = {
chargeId: 'charge-doc-client-1',
documents: [
createMockDocument({
total_amount: 500,
currency_code: 'USD',
date: new Date('2024-01-04'), // 28 days earlier - gentle eligible
creditor_id: 'client-acme', // Same business, registered client
debtor_id: USER_ID,
type: 'INVOICE',
}),
],
};
const result = await scoreMatch(txCharge, docCharge, USER_ID, createMockInjector() as any);
// For client same-business: date confidence should be 1.0 (flat)
expect(result.components.date).toBe(1.0);
// Overall confidence should be very high
expect(result.confidenceScore).toBeGreaterThan(0.95);
});
it('should use standard date degradation for NON-CLIENT same-business matches', async () => {
const txCharge: TransactionCharge = {
chargeId: 'charge-tx-nonc-1',
transactions: [
createMockTransaction({
amount: "500.00",
currency: 'USD',
event_date: new Date('2024-02-01'),
business_id: 'vendor-bob', // NOT a client (no 'client-' prefix)
}),
],
};
const docCharge: DocumentCharge = {
chargeId: 'charge-doc-nonc-1',
documents: [
createMockDocument({
total_amount: 500,
currency_code: 'USD',
date: new Date('2024-03-01'), // 28 days later = 1.0 - 28/30 = 0.07
creditor_id: 'vendor-bob', // Same business, but NOT a client
debtor_id: USER_ID,
type: 'INVOICE',
}),
],
};
const result = await scoreMatch(txCharge, docCharge, USER_ID, createMockInjector());
// For non-client same-business: date confidence should use standard degradation
expect(result.components.date).toBeCloseTo(0.07, 1);
// Overall confidence lower due to date mismatch
// Calculation: (amount: 1.0 * 0.4) + (currency: 1.0 * 0.2) + (business: 1.0 * 0.3) + (date: 0.07 * 0.1)
// = 0.4 + 0.2 + 0.3 + 0.007 = 0.907 ≈ 0.91
expect(result.confidenceScore).toBeLessThanOrEqual(0.91);
});
});
describe('Error Propagation', () => {
it('should throw error for mixed currencies in transactions', async () => {
const txCharge: TransactionCharge = {
chargeId: 'charge-tx-19',
transactions: [
createMockTransaction({ currency: 'USD' }),
createMockTransaction({ currency: 'EUR' }),
],
};
const docCharge: DocumentCharge = {
chargeId: 'charge-doc-19',
documents: [createMockDocument()],
};
await expect(scoreMatch(txCharge, docCharge, USER_ID, createMockInjector())).rejects.toThrow(/multiple currencies/);
});
it('should throw error for mixed currencies in documents', async () => {
const txCharge: TransactionCharge = {
chargeId: 'charge-tx-20',
transactions: [createMockTransaction()],
};
const docCharge: DocumentCharge = {
chargeId: 'charge-doc-20',
documents: [
createMockDocument({ currency_code: 'USD' }),
createMockDocument({ currency_code: 'EUR' }),
],
};
await expect(scoreMatch(txCharge, docCharge, USER_ID, createMockInjector())).rejects.toThrow(/multiple currencies/);
});
it('should throw error for multiple business IDs in transactions', async () => {
const txCharge: TransactionCharge = {
chargeId: 'charge-tx-21',
transactions: [
createMockTransaction({ business_id: 'business-1' }),
createMockTransaction({ business_id: 'business-2' }),
],
};
const docCharge: DocumentCharge = {
chargeId: 'charge-doc-21',
documents: [createMockDocument()],
};
await expect(scoreMatch(txCharge, docCharge, USER_ID, createMockInjector())).rejects.toThrow(/multiple business/);
});
it('should throw error for invalid document business extraction', async () => {
const txCharge: TransactionCharge = {
chargeId: 'charge-tx-22',
transactions: [createMockTransaction()],
};
const docCharge: DocumentCharge = {
chargeId: 'charge-doc-22',
documents: [
createMockDocument({
creditor_id: 'other-user',
debtor_id: 'another-user',
}),
],
};
await expect(scoreMatch(txCharge, docCharge, USER_ID, createMockInjector())).rejects.toThrow();
});
});
describe('Edge Cases', () => {
it('should handle null business IDs', async () => {
const txCharge: TransactionCharge = {
chargeId: 'charge-tx-23',
transactions: [createMockTransaction({ business_id: null })],
};
const docCharge: DocumentCharge = {
chargeId: 'charge-doc-23',
documents: [
createMockDocument({
creditor_id: USER_ID,
debtor_id: null,
}),
],
};
const result = await scoreMatch(txCharge, docCharge, USER_ID, createMockInjector());
expect(result.components.business).toBe(0.5);
});
it('should handle zero amounts', async () => {
const txCharge: TransactionCharge = {
chargeId: 'charge-tx-24',
transactions: [createMockTransaction({ amount: '0.00' })],
};
const docCharge: DocumentCharge = {
chargeId: 'charge-doc-24',
documents: [createMockDocument({ total_amount: 0 })],
};
const result = await scoreMatch(txCharge, docCharge, USER_ID, createMockInjector());
expect(result.components.amount).toBe(1.0);
});
it('should handle very large amounts', async () => {
const txCharge: TransactionCharge = {
chargeId: 'charge-tx-25',
transactions: [createMockTransaction({ amount: '1000000.00' })],
};
const docCharge: DocumentCharge = {
chargeId: 'charge-doc-25',
documents: [createMockDocument({ total_amount: 1000000 })],
};
const result = await scoreMatch(txCharge, docCharge, USER_ID, createMockInjector());
expect(result.components.amount).toBe(1.0);
});
});
});
});