feat: add account groups, scheduled payments and cash flow calendar

This commit is contained in:
陈煜 committed 2026-10-02 17:42:57 +08:00
1 parent a7e0a0fe58
commit 86c6daf695
46 files changed
+2548 -290

No files matched your search

+116 -111
View File
@@ -44,116 +44,121 @@ export class TransfersController {
};
}
@Post() async create(@Req() r: UserRequest, @Body() body: unknown) {
const v = transferInput.parse(body),
when = toBusinessDate(v.date);
return this.db.serial(async (tx) => {
// Lock in a consistent order before reading balances or idempotency state.
await tx.$queryRaw(Prisma.sql`SELECT id FROM Position WHERE userId = ${r.userId}
AND id IN (${Prisma.join([v.sourceId, v.targetId].sort())}) ORDER BY id FOR UPDATE`);
if (v.requestId) {
const existing = await tx.transfer.findFirst({
where: { id: v.requestId, userId: r.userId },
});
if (existing) {
if (
existing.operation !== v.operation ||
existing.sourceId !== v.sourceId ||
existing.targetId !== v.targetId ||
!new Decimal(existing.amount.toString()).eq(v.amount) ||
!new Decimal(existing.received.toString()).eq(v.received) ||
!new Decimal(existing.fee.toString()).eq(v.fee) ||
+existing.effectiveDate !== +when ||
existing.notes !== v.notes
)
throw new ConflictException('转账请求标识已使用,请刷新后重试');
return { id: existing.id };
}
}
const metadata = await tx.position.findMany({
where: {
id: { in: [v.sourceId, v.targetId] },
userId: r.userId,
archived: false,
...(r.revealed ? {} : { hidden: false }),
},
});
const latest = await latestRevisions(
tx,
metadata.map((p) => p.id),
new Date('9999-01-01'),
);
const accounts = metadata.map((p) => ({
...p,
revisions: latest.filter((r) => r.positionId === p.id),
}));
if (accounts.length !== 2)
throw new BadRequestException('只能在自己的启用资产账户之间转账(隐藏账户须先解锁)');
const source = accounts.find((p) => p.id === v.sourceId)!,
target = accounts.find((p) => p.id === v.targetId)!;
if (
source.kind !== 'account' ||
source.side !== 'asset' ||
(v.operation === 'transfer'
? target.kind !== 'account' || target.side !== 'asset'
: target.kind !== 'debt' ||
target.side !== (['borrow', 'repay'].includes(v.operation) ? 'liability' : 'asset'))
)
throw new BadRequestException('请选择有效的资产账户和对应借入或借出债务');
if (accounts.some((p) => !p.revisions[0] || +p.revisions[0].effectiveDate > +when))
throw new ConflictException('转账时间不能早于任一账户的最新余额记录,请以当前余额转账');
if (source.currency === target.currency && !new Decimal(v.amount).eq(v.received))
throw new BadRequestException('同币种转出与到账金额必须一致,手续费单独填写');
const deltas = movementDeltas(v.operation, v.amount, v.received, v.fee);
const before = new Decimal(source.revisions[0].amount.toString());
const sourceAfter = before.plus(deltas.source);
const after = new Decimal(target.revisions[0].amount.toString()).plus(deltas.target);
if (sourceAfter.isNegative()) throw new BadRequestException('转出账户余额不足(含手续费)');
if (after.isNegative()) throw new BadRequestException('收款或还款不能超过剩余债务');
if (after.gte('10000000000000000') || sourceAfter.gte('10000000000000000'))
throw new BadRequestException('变更后的金额超出支持范围');
const outgoing = await tx.revision.create({
data: {
positionId: source.id,
amount: sourceAfter.toFixed(),
effectiveDate: when,
notes: v.notes,
reason: deltas.sourceReason,
},
});
const incoming = await tx.revision.create({
data: {
positionId: target.id,
amount: after.toFixed(),
effectiveDate: when,
notes: v.notes,
reason: deltas.targetReason,
},
});
const row = await tx.transfer.create({
data: {
id: v.requestId,
userId: r.userId,
operation: v.operation,
sourceId: source.id,
targetId: target.id,
sourceRevisionId: outgoing.id,
targetRevisionId: incoming.id,
sourceCurrency: source.currency,
targetCurrency: target.currency,
amount: v.amount,
received: v.received,
fee: v.fee,
effectiveDate: when,
notes: v.notes,
},
});
if (v.operation !== 'transfer')
await tx.positionLink.upsert({
where: { sourceId_targetId: { sourceId: target.id, targetId: source.id } },
create: { sourceId: target.id, targetId: source.id },
update: {},
});
return { id: row.id };
});
const v = transferInput.parse(body);
return this.db.serial((tx) => executeMovement(tx, r, v));
}
}
export async function executeMovement(
tx: Prisma.TransactionClient,
r: Pick<UserRequest, 'userId' | 'revealed'>,
v: ReturnType<typeof transferInput.parse>,
) {
const when = toBusinessDate(v.date);
// Lock in a consistent order before reading balances or idempotency state.
await tx.$queryRaw(Prisma.sql`SELECT id FROM Position WHERE userId = ${r.userId}
AND id IN (${Prisma.join([v.sourceId, v.targetId].sort())}) ORDER BY id FOR UPDATE`);
if (v.requestId) {
const existing = await tx.transfer.findFirst({
where: { id: v.requestId, userId: r.userId },
});
if (existing) {
if (
existing.operation !== v.operation ||
existing.sourceId !== v.sourceId ||
existing.targetId !== v.targetId ||
!new Decimal(existing.amount.toString()).eq(v.amount) ||
!new Decimal(existing.received.toString()).eq(v.received) ||
!new Decimal(existing.fee.toString()).eq(v.fee) ||
+existing.effectiveDate !== +when ||
existing.notes !== v.notes
)
throw new ConflictException('转账请求标识已使用,请刷新后重试');
return { id: existing.id };
}
}
const metadata = await tx.position.findMany({
where: {
id: { in: [v.sourceId, v.targetId] },
userId: r.userId,
archived: false,
...(r.revealed ? {} : { hidden: false }),
},
});
const latest = await latestRevisions(
tx,
metadata.map((p) => p.id),
new Date('9999-01-01'),
);
const accounts = metadata.map((p) => ({
...p,
revisions: latest.filter((r) => r.positionId === p.id),
}));
if (accounts.length !== 2)
throw new BadRequestException('只能在自己的启用资产账户之间转账(隐藏账户须先解锁)');
const source = accounts.find((p) => p.id === v.sourceId)!,
target = accounts.find((p) => p.id === v.targetId)!;
if (
source.kind !== 'account' ||
source.side !== 'asset' ||
(v.operation === 'transfer'
? target.kind !== 'account' || target.side !== 'asset'
: target.kind !== 'debt' ||
target.side !== (['borrow', 'repay'].includes(v.operation) ? 'liability' : 'asset'))
)
throw new BadRequestException('请选择有效的资产账户和对应借入或借出债务');
if (accounts.some((p) => !p.revisions[0] || +p.revisions[0].effectiveDate > +when))
throw new ConflictException('转账时间不能早于任一账户的最新余额记录,请以当前余额转账');
if (source.currency === target.currency && !new Decimal(v.amount).eq(v.received))
throw new BadRequestException('同币种转出与到账金额必须一致,手续费单独填写');
const deltas = movementDeltas(v.operation, v.amount, v.received, v.fee);
const before = new Decimal(source.revisions[0].amount.toString());
const sourceAfter = before.plus(deltas.source);
const after = new Decimal(target.revisions[0].amount.toString()).plus(deltas.target);
if (target.kind === 'debt' && after.isNegative())
throw new BadRequestException('收款或还款不能超过剩余债务');
if (after.abs().gte('10000000000000000') || sourceAfter.abs().gte('10000000000000000'))
throw new BadRequestException('变更后的金额超出支持范围');
const outgoing = await tx.revision.create({
data: {
positionId: source.id,
amount: sourceAfter.toFixed(),
effectiveDate: when,
notes: v.notes,
reason: deltas.sourceReason,
},
});
const incoming = await tx.revision.create({
data: {
positionId: target.id,
amount: after.toFixed(),
effectiveDate: when,
notes: v.notes,
reason: deltas.targetReason,
},
});
const row = await tx.transfer.create({
data: {
id: v.requestId,
userId: r.userId,
operation: v.operation,
sourceId: source.id,
targetId: target.id,
sourceRevisionId: outgoing.id,
targetRevisionId: incoming.id,
sourceCurrency: source.currency,
targetCurrency: target.currency,
amount: v.amount,
received: v.received,
fee: v.fee,
effectiveDate: when,
notes: v.notes,
},
});
if (v.operation !== 'transfer')
await tx.positionLink.upsert({
where: { sourceId_targetId: { sourceId: target.id, targetId: source.id } },
create: { sourceId: target.id, targetId: source.id },
update: {},
});
return { id: row.id };
}