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

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 580 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 702 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 645 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 511 B

+66
View File
@@ -118,5 +118,71 @@
"source": "https://jr.jd.com/logo.png",
"file": "20.png",
"sha256": "8dc9703f571e605df552dc7eb14ae074d9ddab6b27a9140b610e87b1c2a5f693"
},
{
"name": "汇丰香港",
"source": "https://www.hsbc.com.hk/etc.clientlibs/dpws/clientlibs-public/clientlib-site/resources/favicons/apple-touch-icon.png",
"file": "21.png",
"sha256": "d258f7e2ad662d73ec5c4a2c9e0b7d514b353df351bb8880b9b4731682788766"
},
{
"name": "恒生银行",
"source": "https://www.hangseng.com/etc.clientlibs/hase/clientlibs/clientlib-header/resources/favicon.ico",
"file": "22.png",
"sha256": "9d40bc7de25856427ba5ca9bcb6951652765cb0344ed14974d4ddc4ea17f9e65"
},
{
"name": "渣打香港",
"source": "https://av.sc.com/hk/content/images/content/images/cropped-512x512-1-150x150.png",
"file": "23.png",
"sha256": "1fdfd03637318e0ae31931a09a29bc5725342daab40e03ee715d635c0740c1a7"
},
{
"name": "东亚银行",
"source": "https://www.hkbea.com/images/favicon.ico",
"file": "24.png",
"sha256": "b99768b3931c08f69af569a9a662f490b5665d7203fbf1eeb49baa44ed511c1c"
},
{
"name": "星展香港",
"source": "https://www.dbs.com.hk/_next/public/favicon.ico?q=fa411ced-9fa6-4c3e-b208-d878769c84c9",
"file": "25.png",
"sha256": "3bcee651ede82d04774636cad1e14bbe448155ea17ac5f9a019bb7301b8811b2"
},
{
"name": "花旗香港",
"source": "https://www.citibank.com.hk/views/images/favicon.ico",
"file": "26.png",
"sha256": "a208032884b564421a9c5355c3806d4d33ef6616045444d58ec4595cb73abfa0"
},
{
"name": "Gate",
"source": "https://www.gate.com/favicon.ico",
"file": "27.png",
"sha256": "d8900cbf6d55a20ae77d5e83f224a10e051c06dc43a804960a86073c5c89d2a9"
},
{
"name": "Bybit",
"source": "https://www.bybit.com/favicon.ico",
"file": "28.png",
"sha256": "e6ca34b0ddfddd7fb2e32f4e137704f7e392463f817ac35fca47b30cd0885cc0"
},
{
"name": "Bitget",
"source": "https://www.bitget.com/baseasset/favicon4.png",
"file": "29.png",
"sha256": "96566dc4bc8606740f518de4126b24537e7d287ed7e8181eb9e641cfb80847a7"
},
{
"name": "中银香港",
"source": "https://www.bochk.com/etc/designs/bochk_web/images/icon/boc-icon-32.ico",
"file": "30.png",
"sha256": "8dd2451e41db10e438ae7708bae0b0090fa350da66e7a46571293427311268ef"
},
{
"name": "币安 Binance",
"source": "https://bin.bnbstatic.com/static/images/common/favicon.ico",
"file": "31.png",
"sha256": "8318ebbcb1cb4729eb0f78bb058dc618c3b63f9f9f0070a1a7a3265fdc79b833"
}
]
+2 -2
View File
@@ -5,11 +5,11 @@
"dev": "node scripts/dev.cjs",
"build": "tsc",
"typecheck": "tsc --noEmit",
"test": "tsx --test --test-concurrency=1 test/calculation.test.ts test/zip.test.ts ../web/test/i18n.test.ts",
"test": "tsx --test --test-concurrency=1 test/calculation.test.ts test/update.test.ts test/zip.test.ts ../web/test/i18n.test.ts",
"db:generate": "prisma generate",
"db:migrate": "node scripts/database.cjs deploy",
"db:status": "node scripts/database.cjs status",
"test:integration": "tsx --test --test-concurrency=1 test/integration.test.ts test/privacy.test.ts test/icons.test.ts test/transfers.test.ts test/queries.test.ts test/debts.test.ts",
"test:integration": "tsx --test --test-concurrency=1 test/integration.test.ts test/privacy.test.ts test/icons.test.ts test/transfers.test.ts test/queries.test.ts test/debts.test.ts test/update-integration.test.ts",
"test:performance": "tsx scripts/performance.ts after",
"icons:seed": "node scripts/seed-icons.cjs"
},
@@ -0,0 +1,12 @@
ALTER TABLE `Position` ADD COLUMN `groupName` VARCHAR(60) NOT NULL DEFAULT '' COMMENT '自定义账户分组';
CREATE TABLE `Schedule` (
`id` CHAR(36) NOT NULL, `userId` CHAR(36) NOT NULL, `importedFromId` CHAR(36) NULL,
`name` VARCHAR(100) NOT NULL, `operation` VARCHAR(16) NOT NULL,
`sourceId` CHAR(36) NOT NULL, `targetId` CHAR(36) NULL,
`amount` DECIMAL(24,8) NOT NULL, `received` DECIMAL(24,8) NOT NULL,
`nextAt` DATETIME(3) NOT NULL, `intervalDays` INTEGER NOT NULL,
`enabled` BOOLEAN NOT NULL DEFAULT true, `completed` BOOLEAN NOT NULL DEFAULT false, `notes` TEXT NOT NULL,
PRIMARY KEY (`id`), INDEX `Schedule_userId_enabled_nextAt_idx` (`userId`,`enabled`,`nextAt`),
UNIQUE INDEX `Schedule_userId_importedFromId_key` (`userId`,`importedFromId`),
CONSTRAINT `Schedule_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User` (`id`) ON DELETE CASCADE ON UPDATE CASCADE
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
+24
View File
@@ -30,6 +30,7 @@ model User {
sessions Session[]
icons Icon[]
transfers Transfer[]
schedules Schedule[]
}
/// 登录会话与隐藏项目查看授权
model Session {
@@ -67,6 +68,8 @@ model Position {
name String @db.VarChar(100)
/// 项目分类代码
category String @db.VarChar(40)
/// 自定义账户分组;空字符串表示未分组
groupName String @default("") @db.VarChar(60)
/// 原始币种代码
currency String @db.Char(3)
/// 用户备注,原文保留
@@ -210,3 +213,24 @@ model Transfer {
@@unique([userId,importedFromId])
@@index([userId,effectiveDate,id])
}
/// 按需执行的定时支出或转账计划
model Schedule {
id String @id @default(uuid()) @db.Char(36)
userId String @db.Char(36)
user User @relation(fields:[userId],references:[id],onDelete:Cascade)
importedFromId String? @db.Char(36)
name String @db.VarChar(100)
operation String @db.VarChar(16)
sourceId String @db.Char(36)
targetId String? @db.Char(36)
amount Decimal @db.Decimal(24,8)
received Decimal @db.Decimal(24,8)
nextAt DateTime @db.DateTime(3)
intervalDays Int
enabled Boolean @default(true)
completed Boolean @default(false)
notes String @db.Text
@@index([userId,enabled,nextAt])
@@unique([userId,importedFromId])
}
+35
View File
@@ -0,0 +1,35 @@
"""Fetch public brand icons from the listed official websites; keep existing catalog."""
import hashlib, io, json, re, urllib.request, urllib.parse
from pathlib import Path
from PIL import Image
folder = Path(__file__).resolve().parent.parent / 'assets' / 'icons'
catalog = json.loads((folder / 'sources.json').read_text(encoding='utf-8'))
brands = [('汇丰香港', 'https://www.hsbc.com.hk'), ('恒生银行', 'https://www.hangseng.com'), ('中银香港', 'https://www.bochk.com/en'), ('渣打香港', 'https://www.sc.com/hk'), ('东亚银行', 'https://www.hkbea.com'), ('星展香港', 'https://www.dbs.com.hk'), ('花旗香港', 'https://www.citibank.com.hk'), ('币安 Binance', 'https://www.binance.com'), ('Gate', 'https://www.gate.com'), ('Bybit', 'https://www.bybit.com'), ('Bitget', 'https://www.bitget.com')]
def get(url):
return urllib.request.urlopen(urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0'}), timeout=20).read()
failed = []
for name, site in brands:
if any(v['name'] == name for v in catalog): continue
urls = {'币安 Binance': ['https://bin.bnbstatic.com/static/images/common/favicon.ico', 'https://bin.bnbstatic.com/static/images/common/favicon.png'], '中银香港': ['https://www.bochk.com/dam/bochk/desktop/top/favicon.ico', 'https://www.bochk.com/favicon.ico']}.get(name, [])
try:
html = get(site).decode('utf-8', errors='replace')
for tag in re.findall(r'<link\b[^>]*>', html, re.I):
attrs = dict(re.findall(r'([\w-]+)=["\']([^"\']*)["\']', tag))
if 'icon' in attrs.get('rel', '').lower() and attrs.get('href'): urls.append(urllib.parse.urljoin(site + '/', attrs['href']))
except Exception: pass
urls += [urllib.parse.urljoin(site + '/', '/favicon.ico'), urllib.parse.urljoin(site + '/', '/favicon.png')]
for url in dict.fromkeys(urls):
try:
raw = get(url)
im = Image.open(io.BytesIO(raw)).convert('RGBA')
im.thumbnail((256,256))
out = io.BytesIO(); im.save(out, 'PNG'); png = out.getvalue()
file = str(len(catalog) + 1).zfill(2) + '.png'
(folder / file).write_bytes(png)
catalog.append({'name':name,'source':url,'file':file,'sha256':hashlib.sha256(raw).hexdigest()})
print('Added:', name)
break
except Exception: continue
else: failed.append(name)
(folder / 'sources.json').write_text(json.dumps(catalog, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
if failed: print('Unavailable:', ', '.join(failed)); raise SystemExit(1)
+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 {
+16 -7
View File
@@ -85,8 +85,13 @@ test('transfers are atomic, scoped, retry-safe, decimal exact and included in ba
400,
);
assert.equal(
(await call('/transfers', a.cookie, 'POST', { ...request, amount: '100', received: '100' }))
.status,
(
await call('/transfers', a.cookie, 'POST', {
...request,
amount: '10000000000000000',
received: '10000000000000000',
})
).status,
400,
);
assert.equal(await db.transfer.count({ where: { userId: a.id } }), 0);
@@ -190,10 +195,14 @@ test('transfers are atomic, scoped, retry-safe, decimal exact and included in ba
);
assert.equal(
race.filter((r) => r.status === 201).length,
1,
2,
JSON.stringify(race.map((r) => ({ status: r.status, data: r.data }))),
);
assert.equal(race.filter((r) => [400, 409].includes(r.status)).length, 1);
assert.equal(race.filter((r) => [400, 409].includes(r.status)).length, 0);
assert.equal(
(await call('/positions', a.cookie)).data.find((p: any) => p.id === usd).amount,
'-11.00000001',
);
assert.equal(
(
await call('/settings', a.cookie, 'PATCH', {
@@ -212,7 +221,7 @@ test('transfers are atomic, scoped, retry-safe, decimal exact and included in ba
);
assert.equal((await call('/settings', a.cookie, 'PATCH', { showSidebar: false })).status, 400);
const backup = (await call('/backup', a.cookie)).data;
assert.equal(backup.transfers.length, 3);
assert.equal(backup.transfers.length, 4);
assert.equal(backup.preferences.showNotes, false);
assert.deepEqual(backup.preferences.hiddenMenus, ['asset', 'debt']);
const broken = structuredClone(backup);
@@ -227,7 +236,7 @@ test('transfers are atomic, scoped, retry-safe, decimal exact and included in ba
201,
);
const restored = (await call('/backup', b.cookie)).data;
assert.equal(restored.transfers.length, 3);
assert.equal(restored.transfers.length, 4);
// Appending into an existing space preserves its display preferences.
assert.equal((await call('/settings', b.cookie)).data.showNotes, true);
const c = await account();
@@ -254,7 +263,7 @@ test('transfers are atomic, scoped, retry-safe, decimal exact and included in ba
hidden: true,
});
const visibleTransfers = (await call('/transfers', a.cookie)).data.items;
assert.equal(visibleTransfers.length, 2);
assert.equal(visibleTransfers.length, 3);
assert.ok(
visibleTransfers.every((t: any) => t.sourceId !== sourceId && t.targetId !== sourceId),
);
+157
View File
@@ -0,0 +1,157 @@
import 'dotenv/config';
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { randomUUID, randomBytes } from 'node:crypto';
import { PrismaClient } from '@prisma/client';
import { today } from '../src/validation';
import { readBackupZip } from '../src/zip';
const base = process.env.TEST_API_URL || 'http://127.0.0.1:3100/api';
test('real MySQL: groups, scheduled atomic execution, calendar, privacy and ZIP recovery', async () => {
const db = new PrismaClient(),
names: string[] = [];
const minute = (delta = 0) =>
new Date(Date.now() + delta + 8 * 3600000).toISOString().slice(0, 16);
async function call(path: string, cookie = '', method = 'GET', body?: unknown) {
const res = await fetch(base + path, {
method,
headers: {
Cookie: cookie,
Origin: process.env.WEB_ORIGIN === '*' ? 'http://localhost:5173' : process.env.WEB_ORIGIN!,
...(body ? { 'Content-Type': 'application/json' } : {}),
},
body: body ? JSON.stringify(body) : undefined,
});
return {
status: res.status,
data: res.headers.get('content-type')?.includes('application/zip')
? ((await readBackupZip(Buffer.from(await res.arrayBuffer()))) as any)
: await res.json(),
cookie: res.headers.get('set-cookie')?.split(';')[0] || '',
};
}
async function user() {
const username = 'wp_update_' + randomUUID(),
password = randomBytes(18).toString('hex');
names.push(username);
const result = await call('/auth/register', '', 'POST', { username, password });
assert.equal(result.status, 201);
return {
cookie: result.cookie,
password,
id: (await db.user.findUniqueOrThrow({ where: { username } })).id,
};
}
try {
const a = await user(),
b = await user();
const create = async (name: string, amount: string, groupName: string) => {
const result = await call('/positions', a.cookie, 'POST', {
name,
amount,
groupName,
kind: 'account',
side: 'asset',
category: 'bank',
currency: 'CNY',
date: '2026-09-01T10:00',
});
assert.equal(result.status, 201);
return result.data.id;
};
const sourceId = await create('Daily', '10', '日常'),
targetId = await create('Invest', '0', '理财');
assert.equal(
(await call('/positions', a.cookie)).data.find((p: any) => p.id === sourceId).groupName,
'日常',
);
const request = {
sourceId,
targetId,
amount: '25',
received: '25',
fee: '0',
date: minute(),
requestId: randomUUID(),
};
assert.equal((await call('/transfers', a.cookie, 'POST', request)).status, 201);
assert.equal(
(await call('/positions', a.cookie)).data.find((p: any) => p.id === sourceId).amount,
'-15',
);
const input = {
name: 'Rent',
operation: 'expense',
sourceId,
targetId: null,
amount: '2.5',
received: '0',
nextAt: minute(-60000),
intervalDays: 0,
notes: '',
};
const plan = await call('/schedules', a.cookie, 'POST', input);
assert.equal(plan.status, 201);
assert.equal((await call('/schedules', b.cookie)).data.length, 0);
assert.equal(
(await call('/schedules/' + plan.data.id, b.cookie, 'PATCH', { enabled: false })).status,
404,
);
const race = await Promise.all([
call('/schedules/run', a.cookie, 'POST', {}),
call('/schedules/run', a.cookie, 'POST', {}),
]);
assert.equal(
race.reduce((n, v) => n + v.data.executed, 0),
1,
);
assert.equal(
(await call('/positions', a.cookie)).data.find((p: any) => p.id === sourceId).amount,
'-17.5',
);
assert.equal(
(await call('/schedules/' + plan.data.id, a.cookie, 'PATCH', { enabled: true })).status,
400,
);
assert.equal((await call('/schedules/run', a.cookie, 'POST', {})).data.executed, 0);
const recurring = await call('/schedules', a.cookie, 'POST', {
...input,
name: 'Daily expense',
amount: '1',
intervalDays: 1,
nextAt: minute(-2 * 86400000),
});
assert.equal(recurring.status, 201);
for (let i = 0; i < 3; i++)
assert.equal((await call('/schedules/run', a.cookie, 'POST', {})).data.executed, 1);
assert.equal((await call('/schedules/run', a.cookie, 'POST', {})).data.executed, 0);
const calendar = await call('/calendar?month=' + today().slice(0, 7), a.cookie);
const day = calendar.data.items.find((v: any) => v.date === today());
assert.equal(day.expense, '5.50');
assert.equal(day.income, '0.00');
assert.equal((await call('/calendar/day?date=' + today(), a.cookie)).data.items.length, 4);
const backup = (await call('/backup', a.cookie)).data;
assert.equal(backup.schedules.length, 2);
assert.equal(
(await call('/backup/import', b.cookie, 'POST', { confirmed: true, backup })).status,
201,
);
assert.equal((await call('/schedules/run', b.cookie, 'POST', {})).data.executed, 0);
assert.deepEqual(
(await call('/positions', b.cookie)).data.map((v: any) => v.groupName).sort(),
['日常', '理财'].sort(),
);
const restored = (await call('/backup', b.cookie)).data;
assert.equal(restored.schedules.filter((p: any) => p.completed).length, 1);
await call('/positions/' + sourceId, a.cookie, 'PATCH', {
name: 'Daily',
category: 'bank',
groupName: '日常',
hidden: true,
});
assert.equal((await call('/schedules', a.cookie)).data.length, 0);
assert.equal((await call('/calendar/day?date=' + today(), a.cookie)).data.items.length, 0);
} finally {
await db.user.deleteMany({ where: { username: { in: names } } });
await db.$disconnect();
}
});
+345
View File
@@ -0,0 +1,345 @@
import 'reflect-metadata';
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { randomUUID } from 'node:crypto';
import { cashflowDelta, calendarMonth, CalendarController } from '../src/calendar';
import { executeMovement } from '../src/transfers';
import { transferInput, positionInput, revisionInput } from '../src/validation';
import { scheduleInput, occurrenceId, nextOccurrence, SchedulesController } from '../src/schedules';
import { trend, totals, type Holding } from '../src/calculation';
import { positionAmount, money, type Position } from '../../web/src/api';
const now = new Date(Date.now() - 60000);
const date = new Date(+now + 8 * 3600000).toISOString().slice(0, 16);
function fixture(
targetKind = 'account',
targetSide = 'asset',
sourceBalance = '10',
targetBalance = '0',
) {
const owner = randomUUID(),
sourceId = randomUUID(),
targetId = randomUUID();
const accounts = [
{ id: sourceId, kind: 'account', side: 'asset', currency: 'CNY', userId: owner },
{ id: targetId, kind: targetKind, side: targetSide, currency: 'CNY', userId: owner },
];
const balances = new Map([
[sourceId, sourceBalance],
[targetId, targetBalance],
]),
entries: any[] = [],
transfers: any[] = [];
const tx: any = {
$queryRaw: async (query: any) =>
query.sql.includes('SELECT r.*')
? accounts.map((p) => ({
positionId: p.id,
amount: balances.get(p.id),
effectiveDate: new Date('2026-09-01'),
id: randomUUID(),
}))
: [],
position: { findMany: async () => accounts },
revision: {
create: async ({ data }: any) => {
const row = { id: randomUUID(), ...data };
entries.push(row);
balances.set(data.positionId, data.amount);
return row;
},
},
transfer: {
findFirst: async ({ where }: any) => transfers.find((v) => v.id === where.id),
create: async ({ data }: any) => {
transfers.push(data);
return data;
},
},
positionLink: { upsert: async () => ({}) },
};
const request = transferInput.parse({
requestId: randomUUID(),
sourceId,
targetId,
amount: '25',
received: '25',
fee: '1',
date,
});
return {
tx,
r: { userId: owner, revealed: false },
request,
balances,
entries,
transfers,
sourceId,
targetId,
};
}
test('overdrafts preserve exact paired balances and idempotency', async () => {
const f = fixture();
await executeMovement(f.tx, f.r, f.request);
assert.equal(f.balances.get(f.sourceId), '-16');
assert.equal(f.balances.get(f.targetId), '25');
assert.equal(f.entries.length, 2);
await executeMovement(f.tx, f.r, f.request);
assert.equal(f.entries.length, 2);
await assert.rejects(() => executeMovement(f.tx, f.r, { ...f.request, amount: '26' }));
const p: Holding = {
id: f.sourceId,
name: '',
kind: 'account',
side: 'asset',
currency: 'CNY',
revisions: [{ id: '', amount: '-16', effectiveDate: now, reason: 'balance', notes: '' }],
};
assert.equal(totals([p], [], 'CNY', date.slice(0, 10)).net, '-16.00');
});
test('transfer into an overdrawn account is allowed; debt overpayment still fails before writing', async () => {
const f = fixture('account', 'asset', '10', '-100');
await executeMovement(f.tx, f.r, f.request);
assert.equal(f.balances.get(f.targetId), '-75');
const debt = fixture('debt', 'liability', '0', '10');
await assert.rejects(() =>
executeMovement(debt.tx, debt.r, { ...debt.request, operation: 'repay' }),
);
assert.equal(debt.entries.length, 0);
await executeMovement(debt.tx, debt.r, {
...debt.request,
operation: 'repay',
amount: '10',
received: '10',
});
assert.equal(debt.balances.get(debt.sourceId), '-11');
assert.equal(debt.balances.get(debt.targetId), '0');
});
test('negative balances are confined to asset accounts; group names and principal remain validated', () => {
const data = {
name: 'test',
category: 'bank',
kind: 'account',
side: 'asset',
amount: '-12.12345678',
currency: 'CNY',
date,
groupName: '日常',
};
assert.equal(positionInput.parse(data).groupName, '日常');
assert.equal(positionInput.safeParse({ ...data, kind: 'debt' }).success, false);
assert.equal(positionInput.safeParse({ ...data, side: 'liability' }).success, false);
assert.equal(positionInput.safeParse({ ...data, groupName: 'a'.repeat(61) }).success, false);
assert.equal(revisionInput.parse({ amount: '-0.00000001', date }).amount, '-0.00000001');
assert.equal(transferInput.safeParse({ ...fixture().request, amount: '-1' }).success, false);
});
test('trend starts at the first balance, retains valid zero days and handles an empty portfolio', () => {
const p: Holding = {
id: randomUUID(),
name: '',
kind: 'account',
side: 'asset',
currency: 'CNY',
revisions: [
{
id: '',
amount: '0',
effectiveDate: new Date('2026-09-15T02:00:00Z'),
notes: '',
reason: 'initial',
},
],
};
const rows = trend([p], [], 'CNY', '2026-09-01', '2026-09-17');
assert.deepEqual(
rows.map((r) => r.date),
['2026-09-15', '2026-09-16', '2026-09-17'],
);
assert.equal(rows[0].net, '0.00');
assert.deepEqual(trend([], [], 'CNY', '2026-09-01', '2026-09-17'), []);
assert.deepEqual(trend([p], [], 'CNY', '2026-09-01', '2026-09-14'), []);
});
test('debt signs and currency symbols preserve the original decimal strings', () => {
const p = { kind: 'debt', side: 'liability', amount: '9999999999999999.12345678' } as Position;
assert.equal(positionAmount(p), '-9999999999999999.12345678');
assert.equal(money(positionAmount(p), 'HKD'), 'HK$ -9,999,999,999,999,999.12345678');
assert.equal(positionAmount({ ...p, amount: '0.00000000' }), '0.00000000');
assert.equal(positionAmount({ ...p, side: 'asset' }), p.amount);
});
test('schedule validation rejects malformed times and creates stable occurrence identifiers', () => {
const data = {
name: '租金',
operation: 'expense',
sourceId: randomUUID(),
amount: '100',
nextAt: '2026-11-01T09:00',
intervalDays: 30,
};
assert.equal(scheduleInput.safeParse(data).success, true);
assert.equal(scheduleInput.safeParse({ ...data, nextAt: '2026-02-30T09:00' }).success, false);
assert.equal(scheduleInput.safeParse({ ...data, operation: 'transfer' }).success, false);
assert.equal(scheduleInput.safeParse({ ...data, intervalDays: -1 }).success, false);
assert.equal(occurrenceId('plan', now), occurrenceId('plan', now));
assert.notEqual(occurrenceId('plan', now), occurrenceId('plan', nextOccurrence(now, 1)));
assert.equal(+nextOccurrence(now, 30) - +now, 30 * 86400000);
});
test('due expense runs once, updates balance exactly and advances a recurring plan only after success', async () => {
const owner = randomUUID(),
sourceId = randomUUID();
const plan: any = {
id: randomUUID(),
userId: owner,
name: 'Rent',
operation: 'expense',
sourceId,
targetId: null,
amount: '12.00000001',
received: '0',
nextAt: new Date(Date.now() - 3600000),
intervalDays: 0,
enabled: true,
notes: '',
};
const entries: any[] = [];
let balance = '10';
const tx: any = {
$queryRaw: async () => [],
position: {
findMany: async () => [{ id: sourceId }],
findFirst: async () => ({ id: sourceId, revisions: [{ amount: balance }] }),
},
schedule: {
findMany: async () => (plan.enabled ? [{ ...plan }] : []),
findFirst: async () => (plan.enabled ? { ...plan } : null),
update: async ({ data }: any) => Object.assign(plan, data),
},
revision: {
create: async ({ data }: any) => {
entries.push(data);
balance = data.amount;
return data;
},
},
};
const db: any = { ...tx, serial: async (work: any) => work(tx) };
const c = new SchedulesController(db),
r: any = { userId: owner, revealed: false };
assert.equal((await c.run(r)).executed, 1);
assert.equal(balance, '-2.00000001');
assert.equal(plan.enabled, false);
assert.equal(plan.completed, true);
assert.equal((await c.run(r)).executed, 0);
assert.equal(entries.length, 1);
const before = new Date(Date.now() - 3600000);
Object.assign(plan, { enabled: true, nextAt: before, intervalDays: 30 });
assert.equal((await c.run(r)).executed, 1);
assert.equal(+plan.nextAt, +before + 30 * 86400000);
const next = plan.nextAt;
tx.position.findFirst = async () => null;
plan.nextAt = before;
const result = await c.run(r);
assert.equal(result.executed, 0);
assert.equal(result.errors.length, 1);
assert.equal(+plan.nextAt, +before);
assert.equal(entries.length, 2);
});
test('calendar excludes transfers, loans and initial balances and reverses liability changes', () => {
for (const reason of [
'transfer_out',
'transfer_in',
'loan_repay',
'loan_collect',
'loan_borrow',
'loan_lend',
'initial',
'valuation',
])
assert.equal(cashflowDelta('asset', reason, '100', '50').toFixed(), '0');
assert.equal(cashflowDelta('asset', 'balance', '100', '150').toFixed(), '50');
assert.equal(cashflowDelta('asset', 'scheduled_expense', '100', '50').toFixed(), '-50');
assert.equal(cashflowDelta('liability', 'balance', '100', '150').toFixed(), '-50');
assert.equal(calendarMonth('2024-02').days, 29);
assert.throws(() => calendarMonth('2026-13'));
});
test('calendar replays only the range, preserves fractional totals, and hides incomplete FX totals', async () => {
const accounts = [
{ id: 'cash', side: 'asset', name: '现金', currency: 'CNY' },
{ id: 'usd', side: 'asset', name: '外币', currency: 'USD' },
];
const rows = [
{
id: '1',
positionId: 'cash',
amount: '10',
effectiveDate: new Date('2026-09-02T01:00:00Z'),
reason: 'correction',
sequence: 1,
},
{
id: '2',
positionId: 'cash',
amount: '10.004',
effectiveDate: new Date('2026-09-02T02:00:00Z'),
reason: 'balance',
sequence: 2,
},
{
id: '3',
positionId: 'cash',
amount: '10.008',
effectiveDate: new Date('2026-09-02T03:00:00Z'),
reason: 'balance',
sequence: 3,
},
{
id: '4',
positionId: 'cash',
amount: '8.008',
effectiveDate: new Date('2026-09-02T04:00:00Z'),
reason: 'scheduled_expense',
sequence: 4,
},
{
id: '5',
positionId: 'usd',
amount: '10',
effectiveDate: new Date('2026-09-03T01:00:00Z'),
reason: 'initial',
sequence: 1,
},
{
id: '6',
positionId: 'usd',
amount: '11',
effectiveDate: new Date('2026-09-03T02:00:00Z'),
reason: 'balance',
sequence: 2,
},
];
const tx: any = {
position: {
findMany: async (input: any) => {
assert.equal(input.where.userId, 'owner');
assert.equal(input.where.hidden, false);
return accounts;
},
},
revision: {
findMany: async (input: any) => {
assert.equal(+input.where.effectiveDate.gte, +new Date('2026-08-31T16:00:00Z'));
return rows;
},
},
user: { findUniqueOrThrow: async () => ({ baseCurrency: 'CNY' }) },
exchangeRate: { findMany: async () => [], findFirst: async () => null },
$queryRaw: async () => [],
};
const db: any = { $transaction: async (work: any) => work(tx) };
const c = new CalendarController(db),
r: any = { userId: 'owner', revealed: false };
const month = await c.month(r, '2026-09');
assert.equal(month.items[1].income, '0.01');
assert.equal(month.items[1].expense, '2.00');
assert.equal(month.items[2].complete, false);
assert.equal(month.items.length, 30);
});
+66 -2
View File
@@ -14,6 +14,7 @@ const empty = () =>
currencies: ['CNY'],
icons: [],
transfers: [],
schedules: [],
positions: [],
rates: [],
links: [],
@@ -43,6 +44,7 @@ test('ZIP contains separate JSON files and restores settings without authenticat
'links.json',
'manifest.json',
'rates.json',
'schedules.json',
'settings.json',
'transfers.json',
]);
@@ -102,10 +104,11 @@ test('legacy v3 ZIP remains readable without icons', async () => {
const contents = packBackup(empty());
delete contents['icons.json'];
delete contents['transfers.json'];
delete contents['schedules.json'];
const manifest = JSON.parse(contents['manifest.json']);
manifest.version = 3;
manifest.files = manifest.files.filter(
(f: { name: string }) => !['icons.json', 'transfers.json'].includes(f.name),
(f: { name: string }) => !['icons.json', 'transfers.json', 'schedules.json'].includes(f.name),
);
contents['manifest.json'] = JSON.stringify(manifest);
const restored = validateBackup(await readBackupZip(await archive(contents)));
@@ -116,9 +119,12 @@ test('legacy v3 ZIP remains readable without icons', async () => {
test('legacy v4 ZIP remains readable without transfers', async () => {
const contents = packBackup(empty());
delete contents['transfers.json'];
delete contents['schedules.json'];
const manifest = JSON.parse(contents['manifest.json']);
manifest.version = 4;
manifest.files = manifest.files.filter((f: { name: string }) => f.name !== 'transfers.json');
manifest.files = manifest.files.filter(
(f: { name: string }) => !['transfers.json', 'schedules.json'].includes(f.name),
);
contents['manifest.json'] = JSON.stringify(manifest);
const restored = validateBackup(await readBackupZip(await archive(contents)));
assert.deepEqual(restored.icons, []);
@@ -126,3 +132,61 @@ test('legacy v4 ZIP remains readable without transfers', async () => {
assert.equal(restored.preferences?.showNotes, false);
assert.deepEqual(restored.preferences?.hiddenMenus, ['asset']);
});
test('ZIP v7 restores groups and schedules while v6 remains readable', async () => {
const id = randomUUID(),
stamp = new Date().toISOString();
const b = validateBackup({
...empty(),
positions: [
{
id,
name: '账户',
groupName: '日常',
kind: 'account',
side: 'asset',
category: 'bank',
currency: 'CNY',
notes: '',
archived: false,
hidden: false,
createdAt: stamp,
updatedAt: stamp,
revisions: [
{
id: randomUUID(),
amount: '-10',
date: '2026-09-01T10:00',
notes: '',
reason: 'balance',
createdAt: stamp,
updatedAt: stamp,
},
],
},
],
schedules: [
{
id: randomUUID(),
name: '租金',
operation: 'expense',
sourceId: id,
targetId: null,
amount: '100',
received: '0',
nextAt: '2026-11-01T10:00',
intervalDays: 30,
enabled: true,
notes: '',
},
],
});
const contents = packBackup(b);
assert.deepEqual(validateBackup(await readBackupZip(await archive(contents))), b);
delete contents['schedules.json'];
const manifest = JSON.parse(contents['manifest.json']);
manifest.version = 6;
manifest.files = manifest.files.filter((f: { name: string }) => f.name !== 'schedules.json');
contents['manifest.json'] = JSON.stringify(manifest);
assert.equal(validateBackup(await readBackupZip(await archive(contents))).schedules, undefined);
});