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

+161
View File
@@ -0,0 +1,161 @@
import { Controller, Get, Query, Req } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import Decimal from 'decimal.js';
import { z } from 'zod';
import { Database } from './database';
import { UserRequest } from './auth';
import { date, today, toBusinessDate } from './validation';
import { businessDay, rateAt } from './calculation';
import { currentRates, latestRevisions, endOfDay } from './queries';
export function cashflowDelta(side: string, reason: string, before: string, after: string) {
if (!['balance', 'correction', 'scheduled_expense'].includes(reason)) return new Decimal(0);
const delta = new Decimal(after).minus(before);
return side === 'liability' ? delta.neg() : delta;
}
export function calendarMonth(value: unknown) {
const month = z
.string()
.regex(/^\d{4}-(0[1-9]|1[0-2])$/)
.refine((s) => s >= '1900-01' && s <= today().slice(0, 7))
.parse(value || today().slice(0, 7));
const from = month + '-01',
next = new Date(from + 'T00:00:00Z');
next.setUTCMonth(next.getUTCMonth() + 1);
const last = new Date(+next - 86400000).toISOString().slice(0, 10);
return { month, from, to: last > today() ? today() : last, days: Number(last.slice(-2)) };
}
@Controller('api/calendar')
export class CalendarController {
constructor(private db: Database) {}
private async replay(
tx: Prisma.TransactionClient,
r: UserRequest,
from: string,
to: string,
details: boolean,
) {
const accounts = await tx.position.findMany({
where: { userId: r.userId, kind: 'account', ...(r.revealed ? {} : { hidden: false }) },
select: { id: true, name: true, side: true, currency: true },
});
const ids = accounts.map((p) => p.id),
seeds = await latestRevisions(tx, ids, toBusinessDate(from));
const rows = ids.length
? await tx.revision.findMany({
where: {
positionId: { in: ids },
effectiveDate: { gte: toBusinessDate(from), lt: endOfDay(to) },
},
select: {
id: true,
positionId: true,
amount: true,
effectiveDate: true,
sequence: true,
reason: true,
...(details ? { notes: true } : {}),
},
orderBy: [{ effectiveDate: 'asc' }, { sequence: 'asc' }],
})
: [];
const prior = new Map(seeds.map((v) => [v.positionId, v.amount.toString()]));
const meta = new Map(accounts.map((v) => [v.id, v]));
const changes = rows
.map((v) => {
const p = meta.get(v.positionId)!;
const hasBefore = prior.has(v.positionId);
const before = prior.get(v.positionId) || '0';
prior.set(v.positionId, v.amount.toString());
return {
id: v.id,
name: p.name,
currency: p.currency,
date: businessDay(v.effectiveDate),
delta: hasBefore
? cashflowDelta(p.side, v.reason, before, v.amount.toString())
: new Decimal(0),
notes: details ? (v as typeof v & { notes?: string }).notes || '' : '',
};
})
.filter((v) => !v.delta.isZero());
const user = await tx.user.findUniqueOrThrow({
where: { id: r.userId },
select: { baseCurrency: true },
});
const currencies = [...new Set(accounts.map((p) => p.currency))];
const rates = await tx.exchangeRate.findMany({
where: {
userId: r.userId,
currency: { in: currencies },
baseCurrency: user.baseCurrency,
date: { gte: new Date(from), lte: new Date(to) },
},
});
const priorRates = await currentRates(
tx,
r.userId,
currencies,
user.baseCurrency,
new Date(+new Date(from) - 86400000).toISOString().slice(0, 10),
);
const allRates = [...priorRates, ...rates];
return {
baseCurrency: user.baseCurrency,
changes: changes.map((v) => {
const rate = rateAt(allRates, v.currency, user.baseCurrency, v.date);
return {
...v,
amount: v.delta.abs().toFixed(),
direction: v.delta.isNegative() ? 'expense' : 'income',
converted: rate ? v.delta.abs().mul(rate.value).toFixed() : null,
};
}),
};
}
@Get() async month(@Req() r: UserRequest, @Query('month') input?: string) {
const q = calendarMonth(input);
return this.db.$transaction(
async (tx) => {
const data = await this.replay(tx, r, q.from, q.to, false);
const items = Array.from({ length: q.days }, (_, i) => ({
date: q.month + '-' + String(i + 1).padStart(2, '0'),
income: new Decimal(0),
expense: new Decimal(0),
complete: true,
}));
for (const v of data.changes) {
const item = items[Number(v.date.slice(-2)) - 1];
if (v.converted === null) item.complete = false;
else if (v.direction === 'income') item.income = item.income.plus(v.converted);
else item.expense = item.expense.plus(v.converted);
}
return {
month: q.month,
baseCurrency: data.baseCurrency,
revealed: r.revealed,
items: items.map((v) => ({
...v,
income: v.income.toFixed(2),
expense: v.expense.toFixed(2),
})),
};
},
{ timeout: 30000 },
);
}
@Get('day') async day(@Req() r: UserRequest, @Query('date') input?: string) {
const day = date.parse(input || today());
return this.db.$transaction(
async (tx) => {
const data = await this.replay(tx, r, day, day, true);
return {
date: day,
baseCurrency: data.baseCurrency,
revealed: r.revealed,
items: data.changes.map(({ delta, ...v }) => v),
};
},
{ timeout: 30000 },
);
}
}