216 lines
7.1 KiB
TypeScript
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();
|
|
}
|
|
});
|