Files
WorthPath/apps/api/src/replay.ts
T

71 lines
3.1 KiB
TypeScript

import { BadRequestException, ConflictException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import Decimal from 'decimal.js';
import { movementDeltas, type Movement } from './movement';
import { pairedReasons } from './validation';
// Lock all affected positions in the same order as movement creation. Absolute
// balance observations remain anchors; transfers and expenses retain their delta.
export async function captureReplay(tx: Prisma.TransactionClient, userId: string, ids: string[]) {
await tx.$queryRaw(Prisma.sql`SELECT id FROM Position WHERE userId = ${userId}
AND id IN (${Prisma.join([...ids].sort())}) ORDER BY id FOR UPDATE`);
const rows = await tx.revision.findMany({
where: { positionId: { in: ids }, position: { userId } },
orderBy: [{ effectiveDate: 'asc' }, { sequence: 'asc' }],
});
const deltas = new Map<string, Decimal>();
const balances = new Map<string, Decimal>();
for (const row of rows) {
if (row.reason === 'scheduled_expense')
deltas.set(
row.id,
new Decimal(row.amount.toString()).minus(balances.get(row.positionId) || 0),
);
balances.set(row.positionId, new Decimal(row.amount.toString()));
}
return async () => {
const transfers = await tx.transfer.findMany({
include: { source: true, target: true },
where: { userId, OR: [{ sourceId: { in: ids } }, { targetId: { in: ids } }] },
});
for (const t of transfers) {
const d = movementDeltas(
t.operation as Movement,
t.amount.toString(),
t.received.toString(),
t.fee.toString(),
t.source.side,
t.target.side,
);
deltas.set(t.sourceRevisionId, d.source);
deltas.set(t.targetRevisionId, d.target);
}
const positions = await tx.position.findMany({ where: { userId, id: { in: ids } } });
const current = await tx.revision.findMany({
where: { positionId: { in: ids }, position: { userId } },
orderBy: [{ effectiveDate: 'asc' }, { sequence: 'asc' }],
});
balances.clear();
for (const row of current) {
const p = positions.find((v) => v.id === row.positionId)!;
const delta =
row.reason === 'scheduled_expense' || pairedReasons.includes(row.reason)
? deltas.get(row.id)
: undefined;
if (pairedReasons.includes(row.reason) && !delta)
throw new ConflictException('配对记录不完整,无法重算');
// With the initial observation deleted, remaining movements start at zero.
const amount = delta
? (balances.get(row.positionId) || new Decimal(0)).plus(delta)
: new Decimal(row.amount.toString());
if (amount.isNegative() && p.kind !== 'account')
throw new BadRequestException('修改后债务或资产金额不能为负数');
if (amount.abs().gte('10000000000000000'))
throw new BadRequestException('重算后的金额超出支持范围');
balances.set(row.positionId, amount);
if (!amount.eq(row.amount.toString()))
await tx.revision.update({ where: { id: row.id }, data: { amount: amount.toFixed() } });
}
};
}