Files
WorthPath/apps/api/test/record-edit.test.ts
T

216 lines
7.1 KiB
TypeScript

import 'reflect-metadata';
import 'dotenv/config';
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { randomUUID } from 'node:crypto';
import { Database } from '../src/database';
import { TransfersBusinessService } from '../src/transfers';
import { PortfolioBusinessService } from '../src/portfolio';
import { CalendarBusinessService } from '../src/calendar';
import { toBusinessDate } from '../src/validation';
test('record edits replay paired movements, expense deltas, anchors and calendar atomically', async () => {
const db = new Database();
const users: string[] = [];
const movements = new TransfersBusinessService(db);
const portfolio = new PortfolioBusinessService(db, {} as any, {} as any);
const calendar = new CalendarBusinessService(db);
try {
const user = await db.user.create({
data: { username: 'wp_edit_' + randomUUID(), passwordHash: 'unused' },
});
users.push(user.id);
const r = { userId: user.id, revealed: false } as any;
const foreign = { userId: randomUUID(), revealed: false } as any;
const create = async (amount: string, kind = 'account', side = 'asset') =>
db.position.create({
data: {
userId: user.id,
name: randomUUID(),
kind,
side,
category: 'other',
currency: 'CNY',
notes: '',
revisions: {
create: {
amount,
effectiveDate: toBusinessDate('2026-09-01T00:00'),
notes: '',
reason: 'initial',
},
},
},
include: { revisions: true },
});
const a = await create('100'),
b = await create('0');
const input = {
sourceId: a.id,
targetId: b.id,
amount: '20',
received: '20',
fee: '2',
date: '2026-09-02T10:00',
operation: 'transfer',
notes: '',
};
const transfer = await movements.create(r, input);
const expense = await db.revision.create({
data: {
positionId: a.id,
amount: '73',
effectiveDate: toBusinessDate('2026-09-03T10:00'),
reason: 'scheduled_expense',
notes: '',
},
});
const later = await movements.create(r, {
...input,
amount: '10',
received: '10',
fee: '0',
date: '2026-09-04T10:00',
});
const balance = async (id: string) => (await portfolio.detail(r, id)).amount;
assert.equal(await balance(a.id), '63');
await assert.rejects(movements.remove(foreign, transfer.id));
await assert.rejects(portfolio.deleteRevision(foreign, a.id, a.revisions[0].id));
await movements.edit(r, transfer.id, { ...input, amount: '30', received: '30', fee: '3' });
assert.equal(await balance(a.id), '52');
assert.equal(await balance(b.id), '40');
assert.equal(
(await db.revision.findUniqueOrThrow({ where: { id: expense.id } })).amount.toString(),
'62',
);
const month = await calendar.month(r, '2026-09');
assert.equal(month.items.find((v) => v.date === '2026-09-03')!.expense, '5.00');
await portfolio.correct(r, a.id, a.revisions[0].id, {
amount: '200',
date: '2026-09-01T00:00',
notes: '',
});
assert.equal(await balance(a.id), '152');
await movements.remove(r, transfer.id);
assert.equal(await balance(a.id), '185');
assert.equal(await balance(b.id), '10');
assert.equal(
await db.revision.count({
where: {
id: {
in: [
(await db.transfer.findUniqueOrThrow({ where: { id: later.id } })).sourceRevisionId,
],
},
},
}),
1,
);
await portfolio.correct(r, a.id, expense.id, {
amount: '193',
date: '2026-09-03T10:00',
notes: '',
});
assert.equal(await balance(a.id), '183');
assert.equal(
(await calendar.month(r, '2026-09')).items.find((v) => v.date === '2026-09-03')!.expense,
'7.00',
);
await portfolio.deleteRevision(r, a.id, expense.id);
assert.equal(await balance(a.id), '190');
assert.equal((await calendar.day(r, '2026-09-03')).items.length, 0);
await portfolio.deleteRevision(r, a.id, a.revisions[0].id);
assert.equal(await balance(a.id), '-10');
const restoredInitial = await db.revision.create({
data: {
positionId: a.id,
amount: '200',
effectiveDate: toBusinessDate('2026-09-01T00:00'),
reason: 'initial',
notes: '',
},
});
a.revisions[0].id = restoredInitial.id;
// Replay after restoring a baseline through the correction API.
await portfolio.correct(r, a.id, restoredInitial.id, {
amount: '200',
date: '2026-09-01T00:00',
notes: '',
});
assert.equal(await balance(a.id), '190');
// An observed absolute balance is an anchor, including after a moved transfer.
await portfolio.revise(r, a.id, {
amount: '300',
date: '2026-09-05T00:00',
reason: 'balance',
notes: '',
});
await movements.edit(r, later.id, {
...input,
amount: '15',
received: '15',
fee: '0',
date: '2026-09-02T00:00',
});
assert.equal(await balance(a.id), '300');
assert.equal(await balance(b.id), '15');
const initial = a.revisions[0].id;
await db.position.update({ where: { id: a.id }, data: { hidden: true } });
await assert.rejects(movements.remove(r, later.id));
await assert.rejects(
portfolio.correct(r, a.id, initial, { amount: '1', date: '2026-09-01T00:00', notes: '' }),
);
await db.position.update({ where: { id: a.id }, data: { hidden: false } });
const debt = await create('20', 'debt', 'liability');
const repayment = await movements.create(r, {
...input,
targetId: debt.id,
amount: '10',
received: '10',
fee: '0',
operation: 'repay',
date: '2026-09-06T00:00',
});
await assert.rejects(
movements.edit(r, repayment.id, {
...input,
targetId: debt.id,
amount: '25',
received: '25',
fee: '0',
operation: 'repay',
date: '2026-09-06T00:00',
}),
);
assert.equal(await balance(debt.id), '10');
assert.equal(await balance(a.id), '290');
await movements.remove(r, repayment.id);
assert.equal(await balance(debt.id), '20');
assert.equal(await balance(a.id), '300');
// Cross-currency values and signed fees retain exact decimal precision.
await db.position.update({ where: { id: b.id }, data: { currency: 'USD' } });
const fx = await movements.create(r, {
...input,
amount: '0.12435678',
received: '0.01423567',
fee: '-0.00000001',
date: '2026-09-07T00:00',
});
await movements.edit(r, fx.id, {
...input,
amount: '0.22345678',
received: '0.02234567',
fee: '-0.00000002',
date: '2026-09-07T00:00',
});
assert.equal(await balance(a.id), '299.77654324');
assert.equal(await balance(b.id), '15.02234567');
await movements.remove(r, fx.id);
assert.equal(await balance(a.id), '300');
assert.equal(await balance(b.id), '15');
} finally {
await db.user.deleteMany({ where: { id: { in: users } } });
await db.$disconnect();
}
});