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

+61
View File
@@ -1,3 +1,4 @@
import { scheduleInput } from './schedules';
import { movementDeltas } from './movement';
import { pairedReasons } from './validation';
import {
@@ -109,6 +110,16 @@ const backupSchema = z
}),
)
.optional(),
schedules: z
.array(
scheduleInput.safeExtend({
id: z.string().uuid(),
importedFromId: z.string().uuid().nullable().optional(),
enabled: z.boolean(),
completed: z.boolean().default(false),
}),
)
.optional(),
positions: z.array(record),
links: z.array(z.object({ sourceId: z.string().uuid(), targetId: z.string().uuid() }).strict()),
rates: z.array(rateInput.safeExtend({ source: z.enum(['manual', 'frankfurter']) })),
@@ -132,6 +143,7 @@ export function validateBackup(raw: unknown) {
positionInput.parse({
name: p.name,
category: p.category,
groupName: p.groupName,
kind: p.kind,
side: p.side,
currency: p.currency,
@@ -150,6 +162,31 @@ export function validateBackup(raw: unknown) {
}
if (!b.currencies.includes(p.currency)) throw new BadRequestException('币种清单不完整');
}
for (const p of b.positions)
if (
(p.kind !== 'account' || p.side !== 'asset') &&
p.revisions.some((r) => r.amount.startsWith('-'))
)
throw new BadRequestException('仅资产账户支持负余额');
const planIds = new Set<string>();
for (const plan of b.schedules || []) {
const source = ids.get(plan.sourceId),
target = plan.targetId ? ids.get(plan.targetId) : null;
if (
planIds.has(plan.importedFromId || plan.id) ||
!source ||
source.kind !== 'account' ||
source.side !== 'asset' ||
(plan.operation === 'expense'
? !!plan.targetId
: !target ||
target.kind !== 'account' ||
target.side !== 'asset' ||
(source.currency === target.currency && !new Decimal(plan.amount).eq(plan.received)))
)
throw new BadRequestException('计划账户关联无效');
planIds.add(plan.importedFromId || plan.id);
}
const transferIds = new Set<string>(),
usedRevisions = new Set<string>();
for (const t of b.transfers || []) {
@@ -288,6 +325,7 @@ export class BackupController implements OnModuleDestroy, OnModuleInit {
kind: p.kind,
side: p.side,
category: p.category,
groupName: p.groupName,
currency: p.currency,
notes: p.notes,
archived: p.archived,
@@ -317,6 +355,7 @@ export class BackupController implements OnModuleDestroy, OnModuleInit {
},
});
const transfers = await client.transfer.findMany({ where: { userId } });
const schedules = await client.schedule.findMany({ where: { userId } });
return backupSchema.parse({
format: 'worthpath',
version: 2,
@@ -349,6 +388,12 @@ export class BackupController implements OnModuleDestroy, OnModuleInit {
date: businessTime(effectiveDate),
createdAt: t.createdAt.toISOString(),
})),
schedules: schedules.map(({ userId, ...v }) => ({
...v,
amount: v.amount.toString(),
received: v.received.toString(),
nextAt: businessTime(v.nextAt),
})),
positions,
links: ps.flatMap((p) =>
p.outgoing.map((l) => ({ sourceId: l.sourceId, targetId: l.targetId })),
@@ -467,6 +512,7 @@ export class BackupController implements OnModuleDestroy, OnModuleInit {
throw new BadRequestException('请先下载当前账号备份,再进入下一步(10 分钟内有效)');
if (this.fingerprint(await this.data(r.userId, tx)) !== s.backupDigest)
throw new ConflictException('数据已变化,请重新下载备份');
await tx.schedule.deleteMany({ where: { userId: r.userId } });
await tx.position.deleteMany({ where: { userId: r.userId } });
await tx.icon.deleteMany({ where: { ownerId: r.userId, shared: false } });
await tx.exchangeRate.deleteMany({ where: { userId: r.userId } });
@@ -490,6 +536,7 @@ export class BackupController implements OnModuleDestroy, OnModuleInit {
rates: b.rates.length,
icons: (b.icons || []).length,
transfers: (b.transfers || []).length,
schedules: (b.schedules || []).length,
baseCurrency: b.baseCurrency,
currentBaseCurrency: existing.baseCurrency,
message:
@@ -567,6 +614,7 @@ export class BackupController implements OnModuleDestroy, OnModuleInit {
kind: p.kind,
side: p.side,
category: p.category,
groupName: p.groupName,
currency: p.currency,
notes: p.notes,
archived: p.archived,
@@ -625,6 +673,19 @@ export class BackupController implements OnModuleDestroy, OnModuleInit {
createdAt: new Date(t.createdAt),
},
});
for (const plan of b.schedules || []) {
const { id, importedFromId, ...v } = plan;
await tx.schedule.create({
data: {
...v,
userId: r.userId,
importedFromId: importedFromId || id,
sourceId: mapping.get(plan.sourceId)!,
targetId: plan.targetId ? mapping.get(plan.targetId)! : null,
nextAt: new Date(plan.nextAt + ':00+08:00'),
},
});
}
for (const l of b.links)
await tx.positionLink.create({
data: { sourceId: mapping.get(l.sourceId)!, targetId: mapping.get(l.targetId)! },
+6
View File
@@ -40,6 +40,7 @@ export function history(p: Holding) {
positionId: p.id,
name: p.name,
kind: p.kind,
side: p.side,
currency: p.currency,
date: businessDay(r.effectiveDate),
time: businessTime(r.effectiveDate),
@@ -153,6 +154,11 @@ export function trend(
to: string,
grain: 'day' | 'week' | 'month' = 'day',
) {
const dates = positions
.flatMap((p) => p.revisions.map((r) => businessDay(r.effectiveDate)))
.sort();
if (!dates.length || dates[0] > to) return [];
from = from < dates[0] ? dates[0] : from;
const streams = positions.map((p) => ({
p,
rows: [...p.revisions].sort(compareRevisions),
+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 },
);
}
}
+4
View File
@@ -7,6 +7,8 @@ import cookieParser from 'cookie-parser';
import helmet from 'helmet';
import { json } from 'express';
import { AuthController, AuthGuard, AuthService } from './auth';
import { CalendarController } from './calendar';
import { SchedulesController } from './schedules';
import { TransfersController } from './transfers';
import { PortfolioController } from './portfolio';
import { BackupController } from './backup';
@@ -53,6 +55,8 @@ class SafeErrors implements ExceptionFilter {
],
controllers: [
TransfersController,
SchedulesController,
CalendarController,
IconsController,
AuthController,
PortfolioController,
+3
View File
@@ -1,5 +1,6 @@
import { INestApplication } from '@nestjs/common';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import { scheduleInput } from './schedules';
import { z } from 'zod';
import {
credentials,
@@ -35,6 +36,8 @@ export function setupOpenApi(app: INestApplication) {
.object({ targetIds: z.array(z.string().uuid()).max(20) })
.strict(),
'POST /api/transfers': transferInput,
'POST /api/schedules': scheduleInput,
'PATCH /api/schedules/{id}': z.object({ enabled: z.boolean() }).strict(),
'PATCH /api/settings': z
.object({
baseCurrency: currency.optional(),
+4
View File
@@ -156,6 +156,8 @@ export class PortfolioController {
});
if (!p) throw new NotFoundException('项目不存在');
if (p.archived) throw new ConflictException('请先恢复归档项目');
if (v.amount.startsWith('-') && (p.kind !== 'account' || p.side !== 'asset'))
throw new BadRequestException('仅资产账户支持负余额');
const lastTransfer = await tx.revision.findFirst({
where: { positionId: p.id, reason: { in: pairedReasons } },
orderBy: [{ effectiveDate: 'desc' }, { sequence: 'desc' }],
@@ -198,6 +200,8 @@ export class PortfolioController {
});
if (!p) throw new NotFoundException('项目不存在');
if (p.archived) throw new ConflictException('请先恢复归档项目');
if (v.amount.startsWith('-') && (p.kind !== 'account' || p.side !== 'asset'))
throw new BadRequestException('仅资产账户支持负余额');
const original = await tx.revision.findFirst({ where: { id: revisionId, positionId: p.id } });
if (!original) throw new NotFoundException('历史记录不存在');
const transfer = await tx.revision.findFirst({
+9 -3
View File
@@ -131,7 +131,7 @@ export async function historyPage(db: Reader, userId: string, revealed: boolean,
...(revealed ? {} : { hidden: false }),
...(q.positionId ? { id: q.positionId } : {}),
},
select: { id: true, name: true, kind: true, currency: true },
select: { id: true, name: true, kind: true, side: true, currency: true },
});
// Bound every account's index scan before merging. A global JOIN + ORDER BY can
// sort an entire user's history (and evaluate the predecessor for every row).
@@ -172,7 +172,13 @@ export async function historyPage(db: Reader, userId: string, revealed: boolean,
take: q.limit + 1,
})
.then((rows) =>
rows.map((r) => ({ ...r, name: p.name, kind: p.kind, currency: p.currency })),
rows.map((r) => ({
...r,
name: p.name,
kind: p.kind,
side: p.side,
currency: p.currency,
})),
),
),
)
@@ -203,7 +209,7 @@ export async function historyPage(db: Reader, userId: string, revealed: boolean,
id: r.positionId,
name: r.name,
kind: r.kind,
side: '',
side: r.side,
currency: r.currency,
revisions: [r],
})[0],
+266
View File
@@ -0,0 +1,266 @@
import {
Controller,
Get,
Post,
Patch,
Delete,
Body,
Param,
Req,
BadRequestException,
NotFoundException,
} from '@nestjs/common';
import { Prisma } from '@prisma/client';
import Decimal from 'decimal.js';
import { z } from 'zod';
import { createHash } from 'node:crypto';
import { Database } from './database';
import { UserRequest } from './auth';
import { amount, notes, transferInput } from './validation';
import { businessTime } from './calculation';
import { executeMovement } from './transfers';
export const scheduleInput = z
.object({
name: z.string().trim().min(1).max(100),
operation: z.enum(['expense', 'transfer']),
sourceId: z.string().uuid(),
targetId: z.string().uuid().nullable().default(null),
amount: amount.refine((v) => new Decimal(v).gt(0), '金额必须大于零'),
received: amount.default('0'),
nextAt: z
.string()
.regex(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/)
.refine((s) => {
const d = new Date(s + ':00+08:00');
return (
Number.isFinite(+d) && businessTime(d) === s && s >= '1900-01-01' && s < '9999-01-01'
);
}, '计划时间无效'),
intervalDays: z.coerce.number().int().min(0).max(3650),
notes,
})
.strict()
.refine(
(v) =>
v.operation !== 'transfer' ||
(!!v.targetId && v.sourceId !== v.targetId && new Decimal(v.received).gt(0)),
'转账计划须选择不同账户并填写到账金额',
);
export function occurrenceId(id: string, when: Date) {
const hex = createHash('sha256')
.update(id + ':' + when.toISOString())
.digest('hex');
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-4${hex.slice(13, 16)}-a${hex.slice(17, 20)}-${hex.slice(20, 32)}`;
}
export function nextOccurrence(when: Date, intervalDays: number) {
return new Date(+when + intervalDays * 86400000);
}
@Controller('api/schedules')
export class SchedulesController {
constructor(private db: Database) {}
private async visible(r: UserRequest, tx: Prisma.TransactionClient = this.db) {
return (
await tx.position.findMany({
where: {
userId: r.userId,
kind: 'account',
side: 'asset',
...(r.revealed ? {} : { hidden: false }),
},
select: { id: true },
})
).map((p) => p.id);
}
@Get() async list(@Req() r: UserRequest) {
const ids = await this.visible(r);
const rows = await this.db.schedule.findMany({
where: {
userId: r.userId,
sourceId: { in: ids },
OR: [{ targetId: null }, { targetId: { in: ids } }],
},
orderBy: [{ nextAt: 'asc' }, { id: 'asc' }],
});
return rows.map(({ userId, ...v }) => ({ ...v, nextAt: businessTime(v.nextAt) }));
}
@Post() async create(@Req() r: UserRequest, @Body() body: unknown) {
const v = scheduleInput.parse(body);
return this.db.serial(async (tx) => {
const ids = [v.sourceId, ...(v.targetId ? [v.targetId] : [])].sort();
await tx.$queryRaw(
Prisma.sql`SELECT id FROM Position WHERE userId = ${r.userId} AND id IN (${Prisma.join(ids)}) ORDER BY id FOR UPDATE`,
);
const accounts = await tx.position.findMany({
where: {
userId: r.userId,
id: { in: ids },
kind: 'account',
side: 'asset',
archived: false,
...(r.revealed ? {} : { hidden: false }),
},
});
if (accounts.length !== ids.length) throw new BadRequestException('请选择启用的资产账户');
if (v.operation === 'expense' && v.targetId)
throw new BadRequestException('支出计划无需转入账户');
if (
v.operation === 'transfer' &&
accounts[0].currency === accounts[1].currency &&
!new Decimal(v.amount).eq(v.received)
)
throw new BadRequestException('同币种转出与到账金额必须一致');
return tx.schedule.create({
data: { ...v, userId: r.userId, nextAt: new Date(v.nextAt + ':00+08:00') },
select: { id: true },
});
});
}
@Patch(':id') async toggle(
@Req() r: UserRequest,
@Param('id') id: string,
@Body() body: unknown,
) {
const v = z.object({ enabled: z.boolean() }).strict().parse(body);
const ids = await this.visible(r);
const row = await this.db.schedule.findFirst({
where: {
id,
userId: r.userId,
sourceId: { in: ids },
OR: [{ targetId: null }, { targetId: { in: ids } }],
},
});
if (row?.completed && v.enabled) throw new BadRequestException('一次性计划已完成,请新建计划');
const result = await this.db.schedule.updateMany({
where: {
id,
userId: r.userId,
sourceId: { in: ids },
OR: [{ targetId: null }, { targetId: { in: ids } }],
},
data: v,
});
if (!result.count) throw new NotFoundException('计划不存在');
return { ok: true };
}
@Delete(':id') async remove(@Req() r: UserRequest, @Param('id') id: string) {
const ids = await this.visible(r);
const result = await this.db.schedule.deleteMany({
where: {
id,
userId: r.userId,
sourceId: { in: ids },
OR: [{ targetId: null }, { targetId: { in: ids } }],
},
});
if (!result.count) throw new NotFoundException('计划不存在');
return { ok: true };
}
@Post('run') async run(@Req() r: UserRequest) {
const ids = await this.visible(r),
now = new Date();
const due = await this.db.schedule.findMany({
where: {
userId: r.userId,
enabled: true,
nextAt: { lte: now },
sourceId: { in: ids },
OR: [{ targetId: null }, { targetId: { in: ids } }],
},
orderBy: [{ nextAt: 'asc' }, { id: 'asc' }],
take: 20,
});
let executed = 0;
const errors: { id: string; message: string }[] = [];
for (const candidate of due) {
try {
const applied = await this.db.serial(async (tx) => {
await tx.$queryRaw(
Prisma.sql`SELECT id FROM Schedule WHERE id = ${candidate.id} AND userId = ${r.userId} FOR UPDATE`,
);
const plan = await tx.schedule.findFirst({
where: { id: candidate.id, userId: r.userId, enabled: true, nextAt: { lte: now } },
});
if (!plan || +plan.nextAt !== +candidate.nextAt) return false;
// Execute against current balances; retain the intended time in the note.
const date = businessTime(now),
requestId = occurrenceId(plan.id, plan.nextAt);
const memo =
`计划:${plan.name} · 应执行时间 ${businessTime(plan.nextAt)}\n${plan.notes}`.slice(
0,
2000,
);
if (plan.operation === 'transfer') {
await executeMovement(
tx,
r,
transferInput.parse({
sourceId: plan.sourceId,
targetId: plan.targetId,
amount: plan.amount.toString(),
received: plan.received.toString(),
fee: '0',
date,
notes: memo,
requestId,
}),
);
} else {
await tx.$queryRaw(
Prisma.sql`SELECT id FROM Position WHERE id = ${plan.sourceId} AND userId = ${r.userId} FOR UPDATE`,
);
const account = await tx.position.findFirst({
where: {
id: plan.sourceId,
userId: r.userId,
kind: 'account',
side: 'asset',
archived: false,
...(r.revealed ? {} : { hidden: false }),
},
include: {
revisions: { orderBy: [{ effectiveDate: 'desc' }, { sequence: 'desc' }], take: 1 },
},
});
if (!account?.revisions[0]) throw new BadRequestException('计划账户已归档或不可用');
const after = new Decimal(account.revisions[0].amount.toString()).minus(
plan.amount.toString(),
);
if (after.abs().gte('10000000000000000'))
throw new BadRequestException('变更后的金额超出支持范围');
await tx.revision.create({
data: {
id: requestId,
positionId: account.id,
amount: after.toFixed(),
effectiveDate: new Date(date + ':00+08:00'),
notes: memo,
reason: 'scheduled_expense',
},
});
}
const nextAt = nextOccurrence(plan.nextAt, plan.intervalDays);
await tx.schedule.update({
where: { id: plan.id },
data: plan.intervalDays ? { nextAt } : { enabled: false, completed: true },
});
return true;
});
if (applied) executed++;
} catch (e) {
errors.push({
id: candidate.id,
message:
e instanceof BadRequestException ? e.message : '计划未执行,请刷新或检查账户状态后重试',
});
}
}
return {
executed,
errors,
hasMore:
due.length === 20 ||
due.some((p) => p.intervalDays > 0 && +nextOccurrence(p.nextAt, p.intervalDays) <= +now),
};
}
}
+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 };
}
+11 -4
View File
@@ -51,14 +51,18 @@ export const rateValue = z
.regex(/^(0|[1-9]\d{0,11})(\.\d{1,12})?$/)
.refine((s) => /[1-9]/.test(s), '汇率必须大于零');
export const notes = z.string().max(2000).default('');
export const signedAmount = z
.string()
.regex(/^-?(0|[1-9]\d{0,15})(\.\d{1,8})?$/, '金额最多 16 位整数和 8 位小数');
export const revisionInput = z
.object({
amount,
amount: signedAmount,
date: businessDate,
notes,
reason: z
.enum([
'initial',
'scheduled_expense',
'balance',
'valuation',
'repayment',
@@ -75,6 +79,7 @@ export const revisionInput = z
.strict();
export const positionMeta = z
.object({
groupName: z.string().trim().max(60).optional(),
iconId: z.string().uuid().nullable().optional(),
name: z.string().trim().min(1).max(100),
category: z.string().trim().min(1).max(40),
@@ -88,11 +93,13 @@ export const positionInput = positionMeta
kind: z.enum(['account', 'asset', 'debt']),
side: z.enum(['asset', 'liability']),
currency,
amount,
amount: signedAmount,
date: businessDate,
})
.strict()
.superRefine((p, c) => {
if (p.amount.startsWith('-') && (p.kind !== 'account' || p.side !== 'asset'))
c.addIssue({ code: 'custom', message: '仅资产账户支持负余额' });
if (
(p.kind === 'asset' && p.side !== 'asset') ||
(p.kind === 'account' &&
@@ -121,10 +128,10 @@ export const credentials = z
})
.strict();
export const menuKey = z.enum(['overview', 'account', 'asset', 'debt', 'history']);
export const menuKey = z.enum(['overview', 'account', 'asset', 'debt', 'history', 'calendar']);
export const hiddenMenus = z
.array(menuKey)
.max(5)
.max(6)
.refine((v) => new Set(v).size === v.length, '菜单不可重复');
export const transferInput = z
.object({
+8 -4
View File
@@ -17,6 +17,7 @@ const files = [
'rates.json',
'icons.json',
'transfers.json',
'schedules.json',
] as const;
const sha = (s: Buffer | string) => createHash('sha256').update(s).digest('hex');
export function packBackup(b: Backup) {
@@ -34,6 +35,7 @@ export function packBackup(b: Backup) {
'rates.json': b.rates,
'icons.json': b.icons || [],
'transfers.json': b.transfers || [],
'schedules.json': b.schedules || [],
};
const contents = Object.fromEntries(
files.map((name) => [name, JSON.stringify(data[name], null, 2)]),
@@ -41,7 +43,7 @@ export function packBackup(b: Backup) {
contents['manifest.json'] = JSON.stringify(
{
format: 'worthpath',
version: 6,
version: 7,
exportedAt: b.exportedAt,
files: files.map((name) => ({ name, sha256: sha(contents[name]) })),
},
@@ -116,13 +118,13 @@ export async function readBackupZip(input: string | Buffer): Promise<unknown> {
const manifest = z
.object({
format: z.literal('worthpath'),
version: z.union([z.literal(3), z.literal(4), z.literal(5), z.literal(6)]),
version: z.union([z.literal(3), z.literal(4), z.literal(5), z.literal(6), z.literal(7)]),
exportedAt: z.iso.datetime(),
files: z
.array(
z.object({ name: z.enum(files), sha256: z.string().regex(/^[a-f0-9]{64}$/) }).strict(),
)
.min(files.length - 2)
.min(files.length - 3)
.max(files.length),
})
.strict()
@@ -130,7 +132,8 @@ export async function readBackupZip(input: string | Buffer): Promise<unknown> {
const expected = files.filter(
(f) =>
(manifest.version >= 4 || f !== 'icons.json') &&
(manifest.version >= 5 || f !== 'transfers.json'),
(manifest.version >= 5 || f !== 'transfers.json') &&
(manifest.version >= 7 || f !== 'schedules.json'),
);
if (
contents.size !== expected.length + 1 ||
@@ -181,6 +184,7 @@ export async function readBackupZip(input: string | Buffer): Promise<unknown> {
links: parse('links.json'),
rates: parse('rates.json'),
...(manifest.version >= 5 ? { transfers: parse('transfers.json') } : {}),
...(manifest.version >= 7 ? { schedules: parse('schedules.json') } : {}),
...(manifest.version >= 4 ? { icons: parse('icons.json') } : {}),
};
} catch {