@accounter/server
Version:
Accounter GraphQL server
272 lines (222 loc) • 9.27 kB
text/typescript
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']);
});
});