UNPKG

@accounter/server

Version:
272 lines (222 loc) 9.27 kB
import { describe, expect, it } from 'vitest'; import { matchExecutionsToTransactions, matchSecurityExecutions, type AccountTuple, type MatchableExecution, type MatchableTransaction, } from '../match-security-executions.helper.js'; const ACCOUNT_ID = 'account-1'; const OTHER_ACCOUNT_ID = 'account-2'; const ACCOUNT: AccountTuple = { bankNumber: 12, branchNumber: 615, accountNumber: 100000 }; const OTHER_ACCOUNT: AccountTuple = { bankNumber: 12, branchNumber: 615, accountNumber: 100001 }; const accountTuples = new Map([ [ACCOUNT_ID, ACCOUNT], [OTHER_ACCOUNT_ID, OTHER_ACCOUNT], ]); // Dates are constructed the way `DATE` columns come back from node-postgres: local midnight. const date = (value: string) => new Date(`${value}T00:00:00`); /** A buy: the account is debited on the execution's value date. */ function transaction(overrides: Partial<MatchableTransaction> = {}): MatchableTransaction { return { id: 't1', charge_id: 'c1', amount: '-1000.00', currency: 'USD', debit_date: date('2024-03-12'), debit_date_override: null, account_id: ACCOUNT_ID, ...overrides, }; } function execution(overrides: Partial<MatchableExecution> = {}): MatchableExecution { return { id: 'e1', security: '5129523', bank_number: ACCOUNT.bankNumber, branch_number: ACCOUNT.branchNumber, account_number: ACCOUNT.accountNumber, trade_date: date('2024-03-10'), value_date: date('2024-03-12'), trade_type: 'קניה', trade_currency: 'דולר ארה"ב', settlement_currency: null, net_value_trade_currency: '1000.00', net_value_settlement_currency: null, net_value_nis: '3700.00', ...overrides, }; } const match = ( transactions: MatchableTransaction[], executions: MatchableExecution[], ): Map<string, MatchableTransaction> => matchExecutionsToTransactions(transactions, executions, accountTuples); describe('matchExecutionsToTransactions', () => { it('pairs an execution with the cash movement on its value date', () => { const matched = match([transaction()], [execution()]); expect(matched.get('e1')?.id).toBe('t1'); }); it('requires the value date to be the effective debit date, not the trade date', () => { expect(match([transaction({ debit_date: date('2024-03-10') })], [execution()]).size).toBe(0); }); it('prefers a debit date override, as everywhere else', () => { const matched = match( [transaction({ debit_date: date('2024-03-31'), debit_date_override: date('2024-03-12') })], [execution()], ); expect(matched.get('e1')?.id).toBe('t1'); }); it('drops a transaction with no debit date', () => { expect(match([transaction({ debit_date: null })], [execution()]).size).toBe(0); }); it('drops an execution with no value date', () => { expect(match([transaction()], [execution({ value_date: null })]).size).toBe(0); }); it('compares the amount exactly — a near miss is a different event', () => { expect(match([transaction({ amount: '-1000.01' })], [execution()]).size).toBe(0); }); it('ignores trailing zeros, which the two sources spell differently', () => { const matched = match([transaction({ amount: '-1000' })], [execution()]); expect(matched.get('e1')?.id).toBe('t1'); }); it('compares decimals exactly, past what a double can hold', () => { // Both sides parse to the same double, so a Number-based compare would call these equal. expect( match( [transaction({ amount: '-12345678901234567.01' })], [execution({ net_value_trade_currency: '12345678901234567.02' })], ).size, ).toBe(0); const matched = match( [transaction({ amount: '-12345678901234567.01' })], [execution({ net_value_trade_currency: '12345678901234567.010' })], ); expect(matched.get('e1')?.id).toBe('t1'); }); it('reads a signed zero as a zero', () => { const matched = match( [transaction({ amount: '-0.00' })], [execution({ net_value_trade_currency: '0' })], ); expect(matched.get('e1')?.id).toBe('t1'); }); it('does not match an amount that is not a plain decimal', () => { expect(match([transaction({ amount: 'NaN' })], [execution()]).size).toBe(0); }); it('compares the net value quoted in the transaction currency', () => { // Same execution, booked in ILS: the NIS column is the comparable one. const matched = match( [transaction({ amount: '-3700.00', currency: 'ILS' })], [execution({ trade_currency: 'דולר ארה"ב' })], ); expect(matched.get('e1')?.id).toBe('t1'); }); it('reads the settlement currency column when that is what the transaction is in', () => { const matched = match( [transaction({ amount: '-920.00', currency: 'EUR' })], [execution({ settlement_currency: 'EUR', net_value_settlement_currency: '920.00' })], ); expect(matched.get('e1')?.id).toBe('t1'); }); it('will not compare across currencies', () => { expect( match( [transaction({ amount: '-1000.00', currency: 'EUR' })], [execution({ net_value_nis: null })], ).size, ).toBe(0); }); it('requires the direction the trade type implies', () => { // A buy takes money out; a credit of the same size is some other event. expect(match([transaction({ amount: '1000.00' })], [execution()]).size).toBe(0); }); it('matches a sale against a credit', () => { const matched = match( [transaction({ amount: '1000.00' })], [execution({ trade_type: 'מכירה' })], ); expect(matched.get('e1')?.id).toBe('t1'); }); it('leaves the sign unconstrained for actions with no cash direction', () => { const matched = match( [transaction({ amount: '1000.00' })], [execution({ trade_type: 'העברה לזכות הפקדון' })], ); expect(matched.get('e1')?.id).toBe('t1'); }); it('tolerates a trade type it does not know rather than failing the whole charge', () => { // The bank adding a word the translation has not learned must not take down a charge; the // resolver is where that drift is meant to surface, and only for rows that get served. const matched = match( [transaction({ amount: '1000.00' })], [execution({ trade_type: 'משהו חדש' })], ); expect(matched.get('e1')?.id).toBe('t1'); }); it('will not cross-match between two portfolios', () => { expect(match([transaction({ account_id: OTHER_ACCOUNT_ID })], [execution()]).size).toBe(0); }); it('skips a transaction whose account has no Poalim identity', () => { expect(match([transaction({ account_id: 'unmapped-account' })], [execution()]).size).toBe(0); }); it('pairs one-to-one when a security is executed twice the same day for the same amount', () => { const matched = match( [transaction({ id: 't1' }), transaction({ id: 't2' })], [execution({ id: 'e1' }), execution({ id: 'e2' })], ); expect([...matched.entries()].map(([executionId, t]) => [executionId, t.id])).toEqual([ ['e1', 't1'], ['e2', 't2'], ]); }); it('leaves a third execution unmatched when only two cash movements exist', () => { const matched = match( [transaction({ id: 't1' }), transaction({ id: 't2' })], [execution({ id: 'e1' }), execution({ id: 'e2' }), execution({ id: 'e3' })], ); expect(matched.size).toBe(2); expect(matched.has('e3')).toBe(false); }); it('consumes executions oldest first, whatever order they arrive in', () => { const older = execution({ id: 'e-late-id', trade_date: date('2024-03-08') }); const newer = execution({ id: 'e-early-id', trade_date: date('2024-03-10') }); const matched = match([transaction()], [newer, older]); expect(matched.has('e-late-id')).toBe(true); expect(matched.has('e-early-id')).toBe(false); }); }); describe('matchSecurityExecutions', () => { it('groups the matched executions by security key', () => { const matched = matchSecurityExecutions( [transaction({ id: 't1' }), transaction({ id: 't2', amount: '-2000.00' })], [ execution({ id: 'e1', security: '5129523' }), execution({ id: 'e2', security: '7654321', net_value_trade_currency: '2000.00' }), ], accountTuples, ); expect([...matched.keys()].sort()).toEqual(['5129523', '7654321']); expect(matched.get('5129523')?.map(e => e.id)).toEqual(['e1']); expect(matched.get('7654321')?.map(e => e.id)).toEqual(['e2']); }); it('omits a security whose executions match nothing', () => { const matched = matchSecurityExecutions( [transaction()], [execution({ id: 'e2', security: '7654321', net_value_trade_currency: '9999.00' })], accountTuples, ); expect(matched.size).toBe(0); }); it('orders a security group by trade date', () => { const matched = matchSecurityExecutions( [transaction({ id: 't1' }), transaction({ id: 't2' })], [ execution({ id: 'e-newer', trade_date: date('2024-03-11') }), execution({ id: 'e-older', trade_date: date('2024-03-09') }), ], accountTuples, ); expect(matched.get('5129523')?.map(e => e.id)).toEqual(['e-older', 'e-newer']); }); });