refactor: organize backend by business responsibility
This commit is contained in:
@@ -0,0 +1,153 @@
|
||||
import {
|
||||
bankBalance,
|
||||
bankSummary,
|
||||
isCalendarDate,
|
||||
moscowToday,
|
||||
} from './bank-card.calculations';
|
||||
|
||||
describe('bank card calculations', () => {
|
||||
it('uses Moscow midnight for today and the current month', () => {
|
||||
expect(moscowToday(new Date('2026-08-31T20:59:59.999Z'))).toBe(
|
||||
'2026-08-31',
|
||||
);
|
||||
expect(moscowToday(new Date('2026-08-31T21:00:00.000Z'))).toBe(
|
||||
'2026-09-01',
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
'0001-01-01',
|
||||
'9999-12-31',
|
||||
'2024-02-29',
|
||||
'2000-02-29',
|
||||
'2026-09-12',
|
||||
])('accepts calendar date %s', (value) => {
|
||||
expect(isCalendarDate(value)).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
'0000-01-01',
|
||||
'10000-01-01',
|
||||
'2026-02-29',
|
||||
'1900-02-29',
|
||||
'2026-04-31',
|
||||
'2026-00-01',
|
||||
'2026-13-01',
|
||||
'2026-01-00',
|
||||
'2026-01-32',
|
||||
'2026-1-01',
|
||||
'2026-09-12\n',
|
||||
'2026-09-12T00:00:00Z',
|
||||
'',
|
||||
])('rejects invalid calendar date %s', (value) => {
|
||||
expect(isCalendarDate(value)).toBe(false);
|
||||
});
|
||||
|
||||
it('folds expenses, topups, refunds and both transfer sides exactly', () => {
|
||||
const operations = [
|
||||
{ kind: 'expense', cardId: 'a', targetCardId: null, amountKopecks: 10n },
|
||||
{ kind: 'expense', cardId: 'a', targetCardId: null, amountKopecks: 20n },
|
||||
{ kind: 'topup', cardId: 'a', targetCardId: null, amountKopecks: 15n },
|
||||
{ kind: 'refund', cardId: 'a', targetCardId: null, amountKopecks: 5n },
|
||||
{ kind: 'transfer', cardId: 'a', targetCardId: 'b', amountKopecks: 120n },
|
||||
{ kind: 'expense', cardId: 'c', targetCardId: null, amountKopecks: 300n },
|
||||
];
|
||||
expect(bankBalance('a', 100n, operations.slice(0, 2))).toBe(70n);
|
||||
expect(bankBalance('a', 100n, operations)).toBe(-30n);
|
||||
expect(bankBalance('b', 0n, operations)).toBe(120n);
|
||||
expect(bankBalance('unused', -1n, operations)).toBe(-1n);
|
||||
});
|
||||
|
||||
it('does not bound computed balances by the storage range', () => {
|
||||
expect(
|
||||
bankBalance('a', 2n ** 63n - 1n, [
|
||||
{ kind: 'topup', cardId: 'a', targetCardId: null, amountKopecks: 1n },
|
||||
]),
|
||||
).toBe(2n ** 63n);
|
||||
expect(
|
||||
bankBalance('a', -(2n ** 63n), [
|
||||
{ kind: 'expense', cardId: 'a', targetCardId: null, amountKopecks: 1n },
|
||||
]),
|
||||
).toBe(-(2n ** 63n) - 1n);
|
||||
});
|
||||
|
||||
it('counts refunds in their own month and inherited category, excluding transfers and topups', () => {
|
||||
const operations = [
|
||||
{
|
||||
kind: 'expense',
|
||||
date: '2026-08-31',
|
||||
categoryId: 'food',
|
||||
amountKopecks: 1000n,
|
||||
},
|
||||
{
|
||||
kind: 'refund',
|
||||
date: '2026-09-01',
|
||||
categoryId: 'food',
|
||||
amountKopecks: 500n,
|
||||
},
|
||||
{
|
||||
kind: 'expense',
|
||||
date: '2026-09-30',
|
||||
categoryId: 'food',
|
||||
amountKopecks: 100n,
|
||||
},
|
||||
{
|
||||
kind: 'expense',
|
||||
date: '2026-09-03',
|
||||
categoryId: 'travel',
|
||||
amountKopecks: 20n,
|
||||
},
|
||||
{
|
||||
kind: 'transfer',
|
||||
date: '2026-09-03',
|
||||
categoryId: null,
|
||||
amountKopecks: 2000n,
|
||||
},
|
||||
{
|
||||
kind: 'topup',
|
||||
date: '2026-09-03',
|
||||
categoryId: null,
|
||||
amountKopecks: 3000n,
|
||||
},
|
||||
{
|
||||
kind: 'expense',
|
||||
date: '2026-10-01',
|
||||
categoryId: 'travel',
|
||||
amountKopecks: 5000n,
|
||||
},
|
||||
];
|
||||
expect(bankSummary('2026-09', operations)).toEqual({
|
||||
expenses: 120n,
|
||||
refunds: 500n,
|
||||
net: -380n,
|
||||
categories: [
|
||||
{ categoryId: 'food', expenses: 100n, refunds: 500n, net: -400n },
|
||||
{ categoryId: 'travel', expenses: 20n, refunds: 0n, net: 20n },
|
||||
],
|
||||
});
|
||||
expect(bankSummary('2026-07', operations)).toEqual({
|
||||
expenses: 0n,
|
||||
refunds: 0n,
|
||||
net: 0n,
|
||||
categories: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps summary sums beyond signed 64-bit exact', () => {
|
||||
const operation = {
|
||||
kind: 'expense',
|
||||
date: '2026-09-01',
|
||||
categoryId: 'food',
|
||||
amountKopecks: 2n ** 63n - 1n,
|
||||
};
|
||||
const total = 2n * (2n ** 63n - 1n);
|
||||
expect(bankSummary('2026-09', [operation, operation])).toEqual({
|
||||
expenses: total,
|
||||
refunds: 0n,
|
||||
net: total,
|
||||
categories: [
|
||||
{ categoryId: 'food', expenses: total, refunds: 0n, net: total },
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
export function moscowToday(now = new Date()): string {
|
||||
return new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: 'Europe/Moscow',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
}).format(now);
|
||||
}
|
||||
|
||||
export function isCalendarDate(value: string): boolean {
|
||||
if (value.length !== 10 || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return false;
|
||||
if (value.startsWith('0000')) return false;
|
||||
const date = new Date(`${value}T00:00:00.000Z`);
|
||||
return (
|
||||
!Number.isNaN(date.getTime()) && date.toISOString().slice(0, 10) === value
|
||||
);
|
||||
}
|
||||
|
||||
export function bankBalance(
|
||||
cardId: string,
|
||||
opening: bigint,
|
||||
operations: Array<{
|
||||
kind: string;
|
||||
cardId: string;
|
||||
targetCardId: string | null;
|
||||
amountKopecks: bigint;
|
||||
}>,
|
||||
): bigint {
|
||||
// ponytail: O(N) history fold; optimize only after measuring growing histories.
|
||||
let balance = opening;
|
||||
for (const operation of operations) {
|
||||
if (operation.cardId === cardId) {
|
||||
if (operation.kind === 'expense' || operation.kind === 'transfer')
|
||||
balance -= operation.amountKopecks;
|
||||
else if (operation.kind === 'topup' || operation.kind === 'refund')
|
||||
balance += operation.amountKopecks;
|
||||
}
|
||||
if (operation.kind === 'transfer' && operation.targetCardId === cardId)
|
||||
balance += operation.amountKopecks;
|
||||
}
|
||||
return balance;
|
||||
}
|
||||
|
||||
export function bankSummary(
|
||||
period: string | { from: string; to: string },
|
||||
operations: Array<{
|
||||
kind: string;
|
||||
date: string;
|
||||
amountKopecks: bigint;
|
||||
categoryId: string | null;
|
||||
}>,
|
||||
) {
|
||||
let expenses = 0n;
|
||||
let refunds = 0n;
|
||||
const categories = new Map<
|
||||
string,
|
||||
{ categoryId: string; expenses: bigint; refunds: bigint; net: bigint }
|
||||
>();
|
||||
// ponytail: O(N) history fold; optimize only after measuring growing histories.
|
||||
for (const operation of operations) {
|
||||
if (
|
||||
typeof period === 'string'
|
||||
? operation.date.slice(0, 7) !== period
|
||||
: operation.date < period.from || operation.date > period.to
|
||||
)
|
||||
continue;
|
||||
if (operation.kind !== 'expense' && operation.kind !== 'refund') continue;
|
||||
const expense = operation.kind === 'expense' ? operation.amountKopecks : 0n;
|
||||
const refund = operation.kind === 'refund' ? operation.amountKopecks : 0n;
|
||||
expenses += expense;
|
||||
refunds += refund;
|
||||
if (operation.categoryId !== null) {
|
||||
const category = categories.get(operation.categoryId) ?? {
|
||||
categoryId: operation.categoryId,
|
||||
expenses: 0n,
|
||||
refunds: 0n,
|
||||
net: 0n,
|
||||
};
|
||||
category.expenses += expense;
|
||||
category.refunds += refund;
|
||||
category.net = category.expenses - category.refunds;
|
||||
categories.set(category.categoryId, category);
|
||||
}
|
||||
}
|
||||
return {
|
||||
expenses,
|
||||
refunds,
|
||||
net: expenses - refunds,
|
||||
categories: [...categories.values()],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { parseRubMoney, rubMoney } from './rub-money';
|
||||
|
||||
describe('RUB money', () => {
|
||||
it.each([
|
||||
['0', 0n],
|
||||
['-100', -10000n],
|
||||
['0.01', 1n],
|
||||
['123.4', 12340n],
|
||||
['90071992547409.93', 9007199254740993n],
|
||||
['92233720368547758.07', 2n ** 63n - 1n],
|
||||
['-92233720368547758.08', -(2n ** 63n)],
|
||||
])('roundtrips %s exactly', (value, kopecks) => {
|
||||
expect(parseRubMoney({ currency: 'RUB', value })).toBe(kopecks);
|
||||
expect(rubMoney(kopecks)).toEqual({ currency: 'RUB', value });
|
||||
});
|
||||
|
||||
it.each([
|
||||
'92233720368547758.08',
|
||||
'-92233720368547758.09',
|
||||
'1.001',
|
||||
'1.000',
|
||||
'NaN',
|
||||
'Infinity',
|
||||
'1e2',
|
||||
'1,23',
|
||||
'',
|
||||
' 1',
|
||||
'1\n',
|
||||
'01',
|
||||
])('rejects invalid input %s without rounding', (value) => {
|
||||
expect(() => parseRubMoney({ currency: 'RUB', value })).toThrow();
|
||||
});
|
||||
|
||||
it('rejects other currencies and non-string values', () => {
|
||||
expect(() => parseRubMoney({ currency: 'USD', value: '1' })).toThrow();
|
||||
expect(() =>
|
||||
parseRubMoney({ currency: 'RUB', value: 1 as unknown as string }),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it('requires positive operation amounts, including at the maximum', () => {
|
||||
for (const value of ['0', '-0.00', '-0.01'])
|
||||
expect(() => parseRubMoney({ currency: 'RUB', value }, true)).toThrow();
|
||||
expect(parseRubMoney({ currency: 'RUB', value: '0.01' }, true)).toBe(1n);
|
||||
expect(
|
||||
parseRubMoney({ currency: 'RUB', value: '92233720368547758.07' }, true),
|
||||
).toBe(2n ** 63n - 1n);
|
||||
});
|
||||
|
||||
it('formats computed sums beyond the storage range without losing precision', () => {
|
||||
expect(rubMoney(2n * (2n ** 63n - 1n))).toEqual({
|
||||
currency: 'RUB',
|
||||
value: '184467440737095516.14',
|
||||
});
|
||||
expect(rubMoney(-1n)).toEqual({ currency: 'RUB', value: '-0.01' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import { decimalFromString, formatDecimal } from '../../../../common/decimal';
|
||||
import type { Money } from '../../../../common/money.types';
|
||||
|
||||
export function parseRubMoney(money: Money, positive = false): bigint {
|
||||
if (
|
||||
money?.currency !== 'RUB' ||
|
||||
typeof money.value !== 'string' ||
|
||||
money.value !== money.value.trim()
|
||||
)
|
||||
throw new TypeError('Expected RUB money with a decimal string');
|
||||
const amount = decimalFromString(money.value);
|
||||
if (amount.scale > 2)
|
||||
throw new RangeError('RUB money supports at most two decimal places');
|
||||
const kopecks = amount.value * 10n ** BigInt(2 - amount.scale);
|
||||
if (kopecks < -(2n ** 63n) || kopecks > 2n ** 63n - 1n)
|
||||
throw new RangeError('RUB money is outside the signed 64-bit range');
|
||||
if (positive && kopecks <= 0n)
|
||||
throw new RangeError('Operation amount must be positive');
|
||||
return kopecks;
|
||||
}
|
||||
|
||||
export function rubMoney(kopecks: bigint): Money {
|
||||
return {
|
||||
currency: 'RUB',
|
||||
value: formatDecimal({ value: kopecks, scale: 2 }),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user