feat: add account groups, scheduled payments and cash flow calendar
This commit is contained in:
1 parent
a7e0a0fe58
commit
86c6daf695
46 files changed
+2548
-290
No files matched your search
@@ -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)! },
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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
@@ -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 };
|
||||
}
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user