Files
WorthPath/apps/api/test/update.test.ts
T

451 lines
15 KiB
TypeScript

import 'reflect-metadata';
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { randomUUID } from 'node:crypto';
import { cashflowDelta, calendarMonth, CalendarBusinessService } from '../src/calendar';
import { executeMovement } from '../src/transfers';
import { transferInput, positionInput, revisionInput } from '../src/validation';
import {
scheduleInput,
occurrenceId,
nextOccurrence,
SchedulesBusinessService,
} from '../src/schedules';
import { trend, totals, type Holding } from '../src/calculation';
import { positionAmount, accountInputAmount, money, type Position } from '../../web/src/api';
const now = new Date(Date.now() - 60000);
const date = new Date(+now + 8 * 3600000).toISOString().slice(0, 16);
function fixture(
targetKind = 'account',
targetSide = 'asset',
sourceBalance = '10',
targetBalance = '0',
) {
const owner = randomUUID(),
sourceId = randomUUID(),
targetId = randomUUID();
const accounts = [
{ id: sourceId, kind: 'account', side: 'asset', currency: 'CNY', userId: owner },
{ id: targetId, kind: targetKind, side: targetSide, currency: 'CNY', userId: owner },
];
const balances = new Map([
[sourceId, sourceBalance],
[targetId, targetBalance],
]),
entries: any[] = [],
transfers: any[] = [];
const tx: any = {
$queryRaw: async (query: any) =>
query.sql.includes('SELECT r.*')
? accounts.map((p) => ({
positionId: p.id,
amount: balances.get(p.id),
effectiveDate: new Date('2026-09-01'),
id: randomUUID(),
}))
: [],
position: { findMany: async () => accounts },
revision: {
create: async ({ data }: any) => {
const row = { id: randomUUID(), ...data };
entries.push(row);
balances.set(data.positionId, data.amount);
return row;
},
},
transfer: {
findFirst: async ({ where }: any) => transfers.find((v) => v.id === where.id),
create: async ({ data }: any) => {
transfers.push(data);
return data;
},
},
positionLink: { upsert: async () => ({}) },
};
const request = transferInput.parse({
requestId: randomUUID(),
sourceId,
targetId,
amount: '25',
received: '25',
fee: '1',
date,
});
return {
tx,
r: { userId: owner, revealed: false },
request,
balances,
entries,
transfers,
sourceId,
targetId,
};
}
test('overdrafts preserve exact paired balances and idempotency', async () => {
const f = fixture();
await executeMovement(f.tx, f.r, f.request);
assert.equal(f.balances.get(f.sourceId), '-16');
assert.equal(f.balances.get(f.targetId), '25');
assert.equal(f.entries.length, 2);
await executeMovement(f.tx, f.r, f.request);
assert.equal(f.entries.length, 2);
await assert.rejects(() => executeMovement(f.tx, f.r, { ...f.request, amount: '26' }));
const p: Holding = {
id: f.sourceId,
name: '',
kind: 'account',
side: 'asset',
currency: 'CNY',
revisions: [{ id: '', amount: '-16', effectiveDate: now, reason: 'balance', notes: '' }],
};
assert.equal(totals([p], [], 'CNY', date.slice(0, 10)).net, '-16.00');
});
test('transfer into an overdrawn account is allowed; debt overpayment still fails before writing', async () => {
const f = fixture('account', 'asset', '10', '-100');
await executeMovement(f.tx, f.r, f.request);
assert.equal(f.balances.get(f.targetId), '-75');
const debt = fixture('debt', 'liability', '0', '10');
await assert.rejects(() =>
executeMovement(debt.tx, debt.r, { ...debt.request, operation: 'repay' }),
);
assert.equal(debt.entries.length, 0);
await executeMovement(debt.tx, debt.r, {
...debt.request,
operation: 'repay',
amount: '10',
received: '10',
});
assert.equal(debt.balances.get(debt.sourceId), '-11');
assert.equal(debt.balances.get(debt.targetId), '0');
});
test('negative balances are confined to accounts; group names and debt principal remain validated', () => {
const data = {
name: 'test',
category: 'bank',
kind: 'account',
side: 'asset',
amount: '-12.24681357',
currency: 'CNY',
date,
groupName: '日常',
};
assert.equal(positionInput.parse(data).groupName, '日常');
assert.equal(positionInput.safeParse({ ...data, kind: 'debt' }).success, false);
assert.equal(positionInput.safeParse({ ...data, side: 'liability' }).success, true);
assert.equal(positionInput.safeParse({ ...data, groupName: 'a'.repeat(61) }).success, false);
assert.equal(revisionInput.parse({ amount: '-0.00000001', date }).amount, '-0.00000001');
assert.equal(transferInput.safeParse({ ...fixture().request, amount: '-1' }).success, false);
});
test('trend starts at the first balance, retains valid zero days and handles an empty portfolio', () => {
const p: Holding = {
id: randomUUID(),
name: '',
kind: 'account',
side: 'asset',
currency: 'CNY',
revisions: [
{
id: '',
amount: '0',
effectiveDate: new Date('2026-09-15T02:00:00Z'),
notes: '',
reason: 'initial',
},
],
};
const rows = trend([p], [], 'CNY', '2026-09-01', '2026-09-17');
assert.deepEqual(
rows.map((r) => r.date),
['2026-09-15', '2026-09-16', '2026-09-17'],
);
assert.equal(rows[0].net, '0.00');
assert.deepEqual(trend([], [], 'CNY', '2026-09-01', '2026-09-17'), []);
assert.deepEqual(trend([p], [], 'CNY', '2026-09-01', '2026-09-14'), []);
});
test('debt signs and currency symbols preserve the original decimal strings', () => {
const p = { kind: 'debt', side: 'liability', amount: '9999999999999999.24681357' } as Position;
assert.equal(positionAmount(p), '-9999999999999999.24681357');
assert.equal(money(positionAmount(p), 'HKD'), 'HK$ -9,999,999,999,999,999.24681357');
assert.equal(positionAmount({ ...p, amount: '0.00000000' }), '0.00000000');
assert.equal(positionAmount({ ...p, side: 'asset' }), p.amount);
});
test('schedule validation rejects malformed times and creates stable occurrence identifiers', () => {
const data = {
name: '租金',
operation: 'expense',
sourceId: randomUUID(),
amount: '100',
nextAt: '2026-11-01T09:00',
intervalDays: 30,
};
assert.equal(scheduleInput.safeParse(data).success, true);
assert.equal(scheduleInput.safeParse({ ...data, nextAt: '2026-02-30T09:00' }).success, false);
assert.equal(scheduleInput.safeParse({ ...data, operation: 'transfer' }).success, false);
assert.equal(scheduleInput.safeParse({ ...data, intervalDays: -1 }).success, false);
assert.equal(occurrenceId('plan', now), occurrenceId('plan', now));
assert.notEqual(occurrenceId('plan', now), occurrenceId('plan', nextOccurrence(now, 1)));
assert.equal(+nextOccurrence(now, 30) - +now, 30 * 86400000);
});
test('due expense runs once, updates balance exactly and advances a recurring plan only after success', async () => {
const owner = randomUUID(),
sourceId = randomUUID();
const plan: any = {
id: randomUUID(),
userId: owner,
name: 'Rent',
operation: 'expense',
sourceId,
targetId: null,
amount: '12.00000001',
received: '0',
nextAt: new Date(Date.now() - 3600000),
intervalDays: 0,
enabled: true,
notes: '',
};
const entries: any[] = [];
let balance = '10';
const tx: any = {
$queryRaw: async () => [],
position: {
findMany: async () => [{ id: sourceId }],
findFirst: async () => ({ id: sourceId, revisions: [{ amount: balance }] }),
},
schedule: {
findMany: async () => (plan.enabled ? [{ ...plan }] : []),
findFirst: async () => (plan.enabled ? { ...plan } : null),
update: async ({ data }: any) => Object.assign(plan, data),
},
revision: {
create: async ({ data }: any) => {
entries.push(data);
balance = data.amount;
return data;
},
},
};
const db: any = { ...tx, serial: async (work: any) => work(tx) };
const c = new SchedulesBusinessService(db),
r: any = { userId: owner, revealed: false };
assert.equal((await c.run(r)).executed, 1);
assert.equal(balance, '-2.00000001');
assert.equal(plan.enabled, false);
assert.equal(plan.completed, true);
assert.equal((await c.run(r)).executed, 0);
assert.equal(entries.length, 1);
const before = new Date(Date.now() - 3600000);
Object.assign(plan, { enabled: true, nextAt: before, intervalDays: 30 });
assert.equal((await c.run(r)).executed, 1);
assert.equal(+plan.nextAt, +before + 30 * 86400000);
const next = plan.nextAt;
tx.position.findFirst = async () => null;
plan.nextAt = before;
const result = await c.run(r);
assert.equal(result.executed, 0);
assert.equal(result.errors.length, 1);
assert.equal(+plan.nextAt, +before);
assert.equal(entries.length, 2);
});
test('calendar excludes transfers, loans and initial balances and reverses liability changes', () => {
for (const reason of [
'transfer_out',
'transfer_in',
'loan_repay',
'loan_collect',
'loan_borrow',
'loan_lend',
'initial',
'valuation',
])
assert.equal(cashflowDelta('asset', reason, '100', '50').toFixed(), '0');
assert.equal(cashflowDelta('asset', 'balance', '100', '150').toFixed(), '50');
assert.equal(cashflowDelta('asset', 'scheduled_expense', '100', '50').toFixed(), '-50');
assert.equal(cashflowDelta('liability', 'balance', '100', '150').toFixed(), '-50');
assert.equal(calendarMonth('2024-02').days, 29);
assert.throws(() => calendarMonth('2026-13'));
});
test('calendar replays only the range, preserves fractional totals, and hides incomplete FX totals', async () => {
const accounts = [
{ id: 'cash', side: 'asset', name: '现金', currency: 'CNY' },
{ id: 'usd', side: 'asset', name: '外币', currency: 'USD' },
];
const rows = [
{
id: '1',
positionId: 'cash',
amount: '10',
effectiveDate: new Date('2026-09-02T01:00:00Z'),
reason: 'correction',
sequence: 1,
},
{
id: '2',
positionId: 'cash',
amount: '10.004',
effectiveDate: new Date('2026-09-02T02:00:00Z'),
reason: 'balance',
sequence: 2,
},
{
id: '3',
positionId: 'cash',
amount: '10.008',
effectiveDate: new Date('2026-09-02T03:00:00Z'),
reason: 'balance',
sequence: 3,
},
{
id: '4',
positionId: 'cash',
amount: '8.008',
effectiveDate: new Date('2026-09-02T04:00:00Z'),
reason: 'scheduled_expense',
sequence: 4,
},
{
id: '5',
positionId: 'usd',
amount: '10',
effectiveDate: new Date('2026-09-03T01:00:00Z'),
reason: 'initial',
sequence: 1,
},
{
id: '6',
positionId: 'usd',
amount: '11',
effectiveDate: new Date('2026-09-03T02:00:00Z'),
reason: 'balance',
sequence: 2,
},
];
const tx: any = {
position: {
findMany: async (input: any) => {
assert.equal(input.where.userId, 'owner');
assert.equal(input.where.hidden, false);
return accounts;
},
},
revision: {
findMany: async (input: any) => {
assert.equal(+input.where.effectiveDate.gte, +new Date('2026-08-31T16:00:00Z'));
return rows;
},
},
user: { findUniqueOrThrow: async () => ({ baseCurrency: 'CNY' }) },
exchangeRate: { findMany: async () => [], findFirst: async () => null },
$queryRaw: async () => [],
};
const db: any = { $transaction: async (work: any) => work(tx) };
const c = new CalendarBusinessService(db),
r: any = { userId: 'owner', revealed: false };
const month = await c.month(r, '2026-09');
assert.equal(month.items[1].income, '0.01');
assert.equal(month.items[1].expense, '2.00');
assert.equal(month.items[2].complete, false);
assert.equal(month.items.length, 30);
});
test('liability account credit balances invert display and input without losing decimal precision', () => {
const card = {
kind: 'account',
side: 'liability',
amount: '-9999999999999999.24681357',
} as Position;
assert.equal(positionAmount(card), '9999999999999999.24681357');
assert.equal(positionAmount(card, '12.24681357'), '-12.24681357');
assert.equal(positionAmount(card, '-0.00000000'), '0.00000000');
assert.equal(accountInputAmount(card, '12.24681357'), '-12.24681357');
assert.equal(accountInputAmount(card, '-12.24681357'), '12.24681357');
assert.equal(accountInputAmount({ ...card, kind: 'debt' }, '12.24681357'), '12.24681357');
});
test('credit balances count as assets across zero, retain FX attribution and do not offset other liabilities', () => {
const card: Holding = {
id: randomUUID(),
name: 'Card',
kind: 'account',
side: 'liability',
currency: 'USD',
revisions: [
{
id: 'a',
amount: '100',
effectiveDate: new Date('2026-09-01'),
reason: 'initial',
notes: '',
},
{
id: 'b',
amount: '-25',
effectiveDate: new Date('2026-09-02'),
reason: 'balance',
notes: '',
},
],
};
const rates = [
{
currency: 'USD',
baseCurrency: 'CNY',
date: new Date('2026-09-01'),
rate: '7',
source: 'manual',
},
{
currency: 'USD',
baseCurrency: 'CNY',
date: new Date('2026-09-02'),
rate: '8',
source: 'manual',
},
];
const owing = totals([card], rates, 'CNY', '2026-09-01');
assert.equal(owing.assets, '0.00');
assert.equal(owing.liabilities, '700.00');
assert.equal(owing.net, '-700.00');
const credit = totals([card], rates, 'CNY', '2026-09-02');
assert.equal(credit.assets, '200.00');
assert.equal(credit.liabilities, '0.00');
assert.equal(credit.net, '200.00');
const rows = trend([card], rates, 'CNY', '2026-09-01', '2026-09-02');
assert.equal(rows[1].fxChange, '-100.00');
assert.equal(rows[1].balanceChange, '1000.00');
const debt = {
...card,
id: randomUUID(),
kind: 'debt',
currency: 'CNY',
revisions: [{ ...card.revisions[0], amount: '20' }],
};
const mixed = totals([card, debt], rates, 'CNY', '2026-09-02');
assert.equal(mixed.assets, '200.00');
assert.equal(mixed.liabilities, '20.00');
assert.equal(mixed.net, '180.00');
assert.equal(cashflowDelta('liability', 'balance', '100', '-25').toFixed(), '125');
assert.equal(cashflowDelta('liability', 'balance', '-25', '10').toFixed(), '-35');
});
test('group order keeps visible subsets stable and distinguishes unnamed and named groups', async () => {
const { orderedGroups, mergeGroupOrder } = await import('../../web/src/group-order');
assert.deepEqual(
orderedGroups(['日常', '', '未分组', '理财', '日常'], ['理财', '', '日常', '已隐藏']),
['理财', '', '日常', '未分组'],
);
assert.deepEqual(orderedGroups(['日常'], ['理财', '', '日常']), ['日常']);
assert.deepEqual(mergeGroupOrder(['日常', '理财'], ['理财', '隐藏']), ['日常', '理财', '隐藏']);
});
test('drag insertion moves groups without swapping neighbours, mutating input or crossing bounds', async () => {
const { moveGroup } = await import('../../web/src/group-order');
const original = ['日常', '', '消费', '理财'];
assert.deepEqual(moveGroup(original, 3, 0), ['理财', '日常', '', '消费']);
assert.deepEqual(moveGroup(original, 0, 3), ['', '消费', '理财', '日常']);
assert.deepEqual(original, ['日常', '', '消费', '理财']);
assert.equal(moveGroup(original, 0, -1), original);
assert.equal(moveGroup(original, 3, 4), original);
assert.equal(moveGroup(original, 2, 2), original);
});