diff --git a/apps/api/assets/icons/32.png b/apps/api/assets/icons/32.png
new file mode 100644
index 0000000..335efb1
Binary files /dev/null and b/apps/api/assets/icons/32.png differ
diff --git a/apps/api/assets/icons/33.png b/apps/api/assets/icons/33.png
new file mode 100644
index 0000000..2378b87
Binary files /dev/null and b/apps/api/assets/icons/33.png differ
diff --git a/apps/api/assets/icons/34.png b/apps/api/assets/icons/34.png
new file mode 100644
index 0000000..e21de53
Binary files /dev/null and b/apps/api/assets/icons/34.png differ
diff --git a/apps/api/assets/icons/sources.json b/apps/api/assets/icons/sources.json
index 7281fc3..ad7e43e 100644
--- a/apps/api/assets/icons/sources.json
+++ b/apps/api/assets/icons/sources.json
@@ -184,5 +184,23 @@
"source": "https://bin.bnbstatic.com/static/images/common/favicon.ico",
"file": "31.png",
"sha256": "8318ebbcb1cb4729eb0f78bb058dc618c3b63f9f9f0070a1a7a3265fdc79b833"
+ },
+ {
+ "name": "ZA BANK 众安银行",
+ "source": "https://cdn.za.group/if/za-group-portal-web/assets/images/share-logo.png",
+ "file": "32.png",
+ "sha256": "e67a4839b9f81bdd94a104f15ec8c1fc9fad1df1949b4e216662c2089c84246e"
+ },
+ {
+ "name": "微众银行 WeBank",
+ "source": "https://tctp.tctpwebankcdn.net/owb-res/owb-res/assets/mainGenerator.811f2078.js",
+ "file": "33.png",
+ "sha256": "ec3be0bf19bbb8c633ee7e13636f96d5c70584cefe0febfb8da294bcdaec6104"
+ },
+ {
+ "name": "大象银行 EleBank",
+ "file": "34.png",
+ "source": "https://is1-ssl.mzstatic.com/image/thumb/Purple211/v4/d4/bc/60/d4bc6004-3898-c9c3-3571-f3ffe4b46b48/Bank-Airstar-0-0-1x_U007epad-0-1-sRGB-85-220.png/512x512bb.jpg",
+ "sha256": "fa7306514308f0a8cc73fa3847002d768094cec25aceeca324da440fbbee2934"
}
]
diff --git a/apps/api/package.json b/apps/api/package.json
index fc367d8..4b6d66f 100644
--- a/apps/api/package.json
+++ b/apps/api/package.json
@@ -9,7 +9,7 @@
"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/update-integration.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/record-edit.test.ts",
"test:performance": "tsx scripts/performance.ts after",
"icons:seed": "node scripts/seed-icons.cjs"
},
diff --git a/apps/api/prisma/migrations/20261002000500_schedule_comments/migration.sql b/apps/api/prisma/migrations/20261002000500_schedule_comments/migration.sql
new file mode 100644
index 0000000..7cb068f
--- /dev/null
+++ b/apps/api/prisma/migrations/20261002000500_schedule_comments/migration.sql
@@ -0,0 +1,15 @@
+ALTER TABLE `Schedule` COMMENT = '按需执行的定时支出或转账计划',
+ MODIFY `id` CHAR(36) NOT NULL COMMENT '唯一标识',
+ MODIFY `userId` CHAR(36) NOT NULL COMMENT '所属用户标识,用于数据隔离',
+ MODIFY `importedFromId` CHAR(36) NULL COMMENT '备份来源计划标识,用于重复导入识别',
+ MODIFY `name` VARCHAR(100) NOT NULL COMMENT '计划显示名称',
+ MODIFY `operation` VARCHAR(16) NOT NULL COMMENT '操作类型:expense 支出或 transfer 转账',
+ MODIFY `sourceId` CHAR(36) NOT NULL COMMENT '支出或转出资产账户标识',
+ MODIFY `targetId` CHAR(36) NULL COMMENT '转入账户标识;支出计划为空',
+ MODIFY `amount` DECIMAL(24,8) NOT NULL COMMENT '原币支出或转出金额',
+ MODIFY `received` DECIMAL(24,8) NOT NULL COMMENT '原币到账金额;支出计划为零',
+ MODIFY `nextAt` DATETIME(3) NOT NULL COMMENT '下次到期业务时间对应的 UTC',
+ MODIFY `intervalDays` INTEGER NOT NULL COMMENT '重复间隔天数;零表示仅执行一次',
+ MODIFY `enabled` BOOLEAN NOT NULL DEFAULT true COMMENT '是否启用计划',
+ MODIFY `completed` BOOLEAN NOT NULL DEFAULT false COMMENT '一次性计划是否已执行完毕',
+ MODIFY `notes` TEXT NOT NULL COMMENT '用户备注,原文保留';
diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma
index 1739c55..5e4679e 100644
--- a/apps/api/prisma/schema.prisma
+++ b/apps/api/prisma/schema.prisma
@@ -216,20 +216,34 @@ model Transfer {
/// 按需执行的定时支出或转账计划
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)
+ /// 操作类型:expense 支出或 transfer 转账
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)
+ /// 下次到期业务时间对应的 UTC
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])
diff --git a/apps/api/scripts/check-comments.cjs b/apps/api/scripts/check-comments.cjs
new file mode 100644
index 0000000..c21fa0b
--- /dev/null
+++ b/apps/api/scripts/check-comments.cjs
@@ -0,0 +1,20 @@
+require('dotenv/config');
+const { PrismaClient } = require('@prisma/client');
+const { writeFileSync } = require('node:fs');
+const { resolve } = require('node:path');
+const db = new PrismaClient();
+(async () => {
+ const tables = await db.$queryRaw`SELECT TABLE_NAME, TABLE_COMMENT FROM information_schema.TABLES
+ WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME <> '_prisma_migrations' ORDER BY TABLE_NAME`;
+ const columns = await db.$queryRaw`SELECT TABLE_NAME, COLUMN_NAME, COLUMN_COMMENT FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME <> '_prisma_migrations' ORDER BY TABLE_NAME, ORDINAL_POSITION`;
+ if (tables.some((t) => !t.TABLE_COMMENT) || columns.some((c) => !c.COLUMN_COMMENT))
+ throw Error('Application tables or columns are missing comments.');
+ writeFileSync(resolve(__dirname, '../../../docs/database-comments.json'), JSON.stringify({
+ tableCount: tables.length, columnCount: columns.length, tables, columns,
+ }, null, 2) + '\n');
+ console.log(`Database comments verified: ${tables.length} tables, ${columns.length} columns.`);
+})().catch(() => {
+ console.error('Comment verification failed. Check database access and table/column comments.');
+ process.exitCode = 1;
+}).finally(() => db.$disconnect());
diff --git a/apps/api/scripts/expand-icons.py b/apps/api/scripts/expand-icons.py
index 498bffc..aa16d76 100644
--- a/apps/api/scripts/expand-icons.py
+++ b/apps/api/scripts/expand-icons.py
@@ -1,16 +1,26 @@
"""Fetch public brand icons from the listed official websites; keep existing catalog."""
-import hashlib, io, json, re, urllib.request, urllib.parse
+import hashlib, io, json, re, sys, 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')]
+brands += [('ZA BANK 众安银行', 'https://bank.za.group'), ('大象银行 EleBank', 'https://www.elebank.com'), ('微众银行 WeBank', 'https://www.webank.com')]
+if len(sys.argv) > 1:
+ brands = [item for item in brands if item[0] in sys.argv[1:]]
+
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, [])
+ 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'], '大象银行 EleBank': ['https://is1-ssl.mzstatic.com/image/thumb/Purple211/v4/d4/bc/60/d4bc6004-3898-c9c3-3571-f3ffe4b46b48/Bank-Airstar-0-0-1x_U007epad-0-1-sRGB-85-220.png/512x512bb.jpg']}.get(name, [])
+ if name == '微众银行 WeBank':
+ try:
+ script = get('https://tctp.tctpwebankcdn.net/owb-res/owb-res/assets/mainGenerator.811f2078.js').decode('utf-8')
+ logo = re.search(r'Tr="(data:image/png;base64,[^"]+)"', script)
+ if logo: urls.insert(0, logo.group(1))
+ except Exception: pass
try:
html = get(site).decode('utf-8', errors='replace')
for tag in re.findall(r']*>', html, re.I):
@@ -26,7 +36,7 @@ for name, site in brands:
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()})
+ catalog.append({'name':name,'source':site if url.startswith('data:') else url,'file':file,'sha256':hashlib.sha256(raw).hexdigest()})
print('Added:', name)
break
except Exception: continue
diff --git a/apps/api/src/openapi.ts b/apps/api/src/openapi.ts
index 4686960..926a7c6 100644
--- a/apps/api/src/openapi.ts
+++ b/apps/api/src/openapi.ts
@@ -36,6 +36,7 @@ export function setupOpenApi(app: INestApplication) {
.object({ targetIds: z.array(z.string().uuid()).max(20) })
.strict(),
'POST /api/transfers': transferInput,
+ 'PUT /api/transfers/{id}': transferInput,
'POST /api/schedules': scheduleInput,
'PATCH /api/schedules/{id}': z.object({ enabled: z.boolean() }).strict(),
'PATCH /api/settings': z
@@ -53,7 +54,7 @@ export function setupOpenApi(app: INestApplication) {
}),
};
for (const [path, entry] of Object.entries(document.paths)) {
- for (const method of ['get', 'post', 'patch', 'put'] as const) {
+ for (const method of ['get', 'post', 'patch', 'put', 'delete'] as const) {
const operation = entry[method];
if (!operation) continue;
operation.summary = `${method.toUpperCase()} ${path}`;
diff --git a/apps/api/src/portfolio.ts b/apps/api/src/portfolio.ts
index b935ba6..200c703 100644
--- a/apps/api/src/portfolio.ts
+++ b/apps/api/src/portfolio.ts
@@ -4,6 +4,7 @@ import {
Post,
Patch,
Put,
+ Delete,
Body,
Req,
Param,
@@ -28,6 +29,7 @@ import { z } from 'zod';
import { Prisma } from '@prisma/client';
import { IconsService } from './icons';
import { RatesService } from './rates';
+import { captureReplay } from './replay';
@Controller('api')
export class PortfolioController {
constructor(
@@ -204,23 +206,9 @@ export class PortfolioController {
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({
- where: {
- positionId: p.id,
- reason: { in: pairedReasons },
- OR: [
- { effectiveDate: { gt: original.effectiveDate } },
- { effectiveDate: original.effectiveDate, sequence: { gte: original.sequence } },
- { effectiveDate: { gt: toBusinessDate(v.date) } },
- { effectiveDate: toBusinessDate(v.date), sequence: { gte: original.sequence } },
- ],
- },
- select: { id: true },
- });
- if (transfer)
- throw new ConflictException(
- '转账及其之前的历史不可单独更正;请新增余额调整,保留转账双方一致',
- );
+ if (pairedReasons.includes(original.reason))
+ throw new ConflictException('请在资金往来中修改完整配对记录');
+ const replay = await captureReplay(tx, r.userId, [id]);
await tx.revision.update({
where: { id: revisionId },
data: {
@@ -230,6 +218,29 @@ export class PortfolioController {
reason: 'correction',
},
});
+ await replay();
+ return { ok: true };
+ });
+ }
+
+ @Delete('positions/:id/revisions/:revisionId') async deleteRevision(
+ @Req() r: UserRequest,
+ @Param('id') id: string,
+ @Param('revisionId') revisionId: string,
+ ) {
+ return this.db.serial(async (tx) => {
+ const replay = await captureReplay(tx, r.userId, [id]);
+ const p = await tx.position.findFirst({
+ where: { id, userId: r.userId, ...(r.revealed ? {} : { hidden: false }) },
+ });
+ if (!p) throw new NotFoundException('项目不存在');
+ if (p.archived) throw new ConflictException('请先恢复归档项目');
+ const row = await tx.revision.findFirst({ where: { id: revisionId, positionId: id } });
+ if (!row) throw new NotFoundException('历史记录不存在');
+ if (pairedReasons.includes(row.reason))
+ throw new ConflictException('请在资金往来中删除完整配对记录');
+ await tx.revision.delete({ where: { id: revisionId } });
+ await replay();
return { ok: true };
});
}
diff --git a/apps/api/src/replay.ts b/apps/api/src/replay.ts
new file mode 100644
index 0000000..a65481f
--- /dev/null
+++ b/apps/api/src/replay.ts
@@ -0,0 +1,68 @@
+import { BadRequestException, ConflictException } from '@nestjs/common';
+import { Prisma } from '@prisma/client';
+import Decimal from 'decimal.js';
+import { movementDeltas, type Movement } from './movement';
+import { pairedReasons } from './validation';
+
+// Lock all affected positions in the same order as movement creation. Absolute
+// balance observations remain anchors; transfers and expenses retain their delta.
+export async function captureReplay(tx: Prisma.TransactionClient, userId: string, ids: string[]) {
+ await tx.$queryRaw(Prisma.sql`SELECT id FROM Position WHERE userId = ${userId}
+ AND id IN (${Prisma.join([...ids].sort())}) ORDER BY id FOR UPDATE`);
+ const rows = await tx.revision.findMany({
+ where: { positionId: { in: ids }, position: { userId } },
+ orderBy: [{ effectiveDate: 'asc' }, { sequence: 'asc' }],
+ });
+ const deltas = new Map();
+ const balances = new Map();
+ for (const row of rows) {
+ if (row.reason === 'scheduled_expense')
+ deltas.set(
+ row.id,
+ new Decimal(row.amount.toString()).minus(balances.get(row.positionId) || 0),
+ );
+ balances.set(row.positionId, new Decimal(row.amount.toString()));
+ }
+ return async () => {
+ const transfers = await tx.transfer.findMany({
+ where: { userId, OR: [{ sourceId: { in: ids } }, { targetId: { in: ids } }] },
+ });
+ for (const t of transfers) {
+ const d = movementDeltas(
+ t.operation as Movement,
+ t.amount.toString(),
+ t.received.toString(),
+ t.fee.toString(),
+ );
+ deltas.set(t.sourceRevisionId, d.source);
+ deltas.set(t.targetRevisionId, d.target);
+ }
+ const positions = await tx.position.findMany({ where: { userId, id: { in: ids } } });
+ const current = await tx.revision.findMany({
+ where: { positionId: { in: ids }, position: { userId } },
+ orderBy: [{ effectiveDate: 'asc' }, { sequence: 'asc' }],
+ });
+ balances.clear();
+ for (const row of current) {
+ const p = positions.find((v) => v.id === row.positionId)!;
+ const delta =
+ row.reason === 'scheduled_expense' || pairedReasons.includes(row.reason)
+ ? deltas.get(row.id)
+ : undefined;
+ if (pairedReasons.includes(row.reason) && !delta)
+ throw new ConflictException('配对记录不完整,无法重算');
+ if (delta && !balances.has(row.positionId))
+ throw new ConflictException('资金操作之前必须保留一条余额记录');
+ const amount = delta
+ ? balances.get(row.positionId)!.plus(delta)
+ : new Decimal(row.amount.toString());
+ if (amount.isNegative() && (p.kind !== 'account' || p.side !== 'asset'))
+ throw new BadRequestException('修改后债务或资产金额不能为负数');
+ if (amount.abs().gte('10000000000000000'))
+ throw new BadRequestException('重算后的金额超出支持范围');
+ balances.set(row.positionId, amount);
+ if (!amount.eq(row.amount.toString()))
+ await tx.revision.update({ where: { id: row.id }, data: { amount: amount.toFixed() } });
+ }
+ };
+}
diff --git a/apps/api/src/transfers.ts b/apps/api/src/transfers.ts
index c146991..6afd3da 100644
--- a/apps/api/src/transfers.ts
+++ b/apps/api/src/transfers.ts
@@ -2,6 +2,10 @@ import {
Controller,
Get,
Post,
+ Put,
+ Delete,
+ Param,
+ NotFoundException,
Body,
Req,
Query,
@@ -15,6 +19,7 @@ import { UserRequest } from './auth';
import { transferInput, toBusinessDate } from './validation';
import { businessTime } from './calculation';
import { movementDeltas } from './movement';
+import { captureReplay } from './replay';
import { pageInput, encodeCursor, latestRevisions, transferPageIds } from './queries';
@Controller('api/transfers')
export class TransfersController {
@@ -43,6 +48,61 @@ export class TransfersController {
revealed: r.revealed,
};
}
+ @Put(':id') async edit(@Req() r: UserRequest, @Param('id') id: string, @Body() body: unknown) {
+ const v = transferInput.parse(body);
+ return this.change(r, id, v);
+ }
+ @Delete(':id') async remove(@Req() r: UserRequest, @Param('id') id: string) {
+ return this.change(r, id);
+ }
+ private async change(r: UserRequest, id: string, v?: ReturnType) {
+ return this.db.serial(async (tx) => {
+ const row = await tx.transfer.findFirst({
+ where: {
+ id,
+ userId: r.userId,
+ ...(r.revealed ? {} : { source: { hidden: false }, target: { hidden: false } }),
+ },
+ include: { source: true, target: true },
+ });
+ if (!row) throw new NotFoundException('资金往来记录不存在');
+ if (row.source.archived || row.target.archived)
+ throw new ConflictException('请先恢复归档项目');
+ const replay = await captureReplay(tx, r.userId, [row.sourceId, row.targetId]);
+ if (v) {
+ if (
+ v.sourceId !== row.sourceId ||
+ v.targetId !== row.targetId ||
+ v.operation !== row.operation
+ )
+ throw new BadRequestException('修改记录不能更换账户或操作类型,请删除后重新创建');
+ if (row.sourceCurrency === row.targetCurrency && !new Decimal(v.amount).eq(v.received))
+ throw new BadRequestException('同币种转出与到账金额必须一致,手续费单独填写');
+ const effectiveDate = toBusinessDate(v.date);
+ await tx.transfer.update({
+ where: { id },
+ data: {
+ amount: v.amount,
+ received: v.received,
+ fee: v.fee,
+ notes: v.notes,
+ effectiveDate,
+ },
+ });
+ await tx.revision.updateMany({
+ where: { id: { in: [row.sourceRevisionId, row.targetRevisionId] } },
+ data: { effectiveDate, notes: v.notes },
+ });
+ } else {
+ await tx.transfer.delete({ where: { id } });
+ await tx.revision.deleteMany({
+ where: { id: { in: [row.sourceRevisionId, row.targetRevisionId] } },
+ });
+ }
+ await replay();
+ return { ok: true };
+ });
+ }
@Post() async create(@Req() r: UserRequest, @Body() body: unknown) {
const v = transferInput.parse(body);
return this.db.serial((tx) => executeMovement(tx, r, v));
diff --git a/apps/api/src/validation.ts b/apps/api/src/validation.ts
index 4144d94..c0b770a 100644
--- a/apps/api/src/validation.ts
+++ b/apps/api/src/validation.ts
@@ -128,10 +128,18 @@ export const credentials = z
})
.strict();
-export const menuKey = z.enum(['overview', 'account', 'asset', 'debt', 'history', 'calendar']);
+export const menuKey = z.enum([
+ 'overview',
+ 'account',
+ 'asset',
+ 'debt',
+ 'history',
+ 'calendar',
+ 'schedules',
+]);
export const hiddenMenus = z
.array(menuKey)
- .max(6)
+ .max(7)
.refine((v) => new Set(v).size === v.length, '菜单不可重复');
export const transferInput = z
.object({
diff --git a/apps/api/test/record-edit.test.ts b/apps/api/test/record-edit.test.ts
new file mode 100644
index 0000000..0d8a832
--- /dev/null
+++ b/apps/api/test/record-edit.test.ts
@@ -0,0 +1,198 @@
+import 'reflect-metadata';
+import 'dotenv/config';
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { randomUUID } from 'node:crypto';
+import { Database } from '../src/database';
+import { TransfersController } from '../src/transfers';
+import { PortfolioController } from '../src/portfolio';
+import { CalendarController } from '../src/calendar';
+import { toBusinessDate } from '../src/validation';
+
+test('record edits replay paired movements, expense deltas, anchors and calendar atomically', async () => {
+ const db = new Database();
+ const users: string[] = [];
+ const movements = new TransfersController(db);
+ const portfolio = new PortfolioController(db, {} as any, {} as any);
+ const calendar = new CalendarController(db);
+ try {
+ const user = await db.user.create({
+ data: { username: 'wp_edit_' + randomUUID(), passwordHash: 'unused' },
+ });
+ users.push(user.id);
+ const r = { userId: user.id, revealed: false } as any;
+ const foreign = { userId: randomUUID(), revealed: false } as any;
+ const create = async (amount: string, kind = 'account', side = 'asset') =>
+ db.position.create({
+ data: {
+ userId: user.id,
+ name: randomUUID(),
+ kind,
+ side,
+ category: 'other',
+ currency: 'CNY',
+ notes: '',
+ revisions: {
+ create: {
+ amount,
+ effectiveDate: toBusinessDate('2026-09-01T00:00'),
+ notes: '',
+ reason: 'initial',
+ },
+ },
+ },
+ include: { revisions: true },
+ });
+ const a = await create('100'),
+ b = await create('0');
+ const input = {
+ sourceId: a.id,
+ targetId: b.id,
+ amount: '20',
+ received: '20',
+ fee: '2',
+ date: '2026-09-02T10:00',
+ operation: 'transfer',
+ notes: '',
+ };
+ const transfer = await movements.create(r, input);
+ const expense = await db.revision.create({
+ data: {
+ positionId: a.id,
+ amount: '73',
+ effectiveDate: toBusinessDate('2026-09-03T10:00'),
+ reason: 'scheduled_expense',
+ notes: '',
+ },
+ });
+ const later = await movements.create(r, {
+ ...input,
+ amount: '10',
+ received: '10',
+ fee: '0',
+ date: '2026-09-04T10:00',
+ });
+ const balance = async (id: string) => (await portfolio.detail(r, id)).amount;
+ assert.equal(await balance(a.id), '63');
+ await assert.rejects(movements.remove(foreign, transfer.id));
+ await assert.rejects(portfolio.deleteRevision(foreign, a.id, a.revisions[0].id));
+ await movements.edit(r, transfer.id, { ...input, amount: '30', received: '30', fee: '3' });
+ assert.equal(await balance(a.id), '52');
+ assert.equal(await balance(b.id), '40');
+ assert.equal(
+ (await db.revision.findUniqueOrThrow({ where: { id: expense.id } })).amount.toString(),
+ '62',
+ );
+ const month = await calendar.month(r, '2026-09');
+ assert.equal(month.items.find((v) => v.date === '2026-09-03')!.expense, '5.00');
+ await portfolio.correct(r, a.id, a.revisions[0].id, {
+ amount: '200',
+ date: '2026-09-01T00:00',
+ notes: '',
+ });
+ assert.equal(await balance(a.id), '152');
+ await movements.remove(r, transfer.id);
+ assert.equal(await balance(a.id), '185');
+ assert.equal(await balance(b.id), '10');
+ assert.equal(
+ await db.revision.count({
+ where: {
+ id: {
+ in: [
+ (await db.transfer.findUniqueOrThrow({ where: { id: later.id } })).sourceRevisionId,
+ ],
+ },
+ },
+ }),
+ 1,
+ );
+ await portfolio.correct(r, a.id, expense.id, {
+ amount: '193',
+ date: '2026-09-03T10:00',
+ notes: '',
+ });
+ assert.equal(await balance(a.id), '183');
+ assert.equal(
+ (await calendar.month(r, '2026-09')).items.find((v) => v.date === '2026-09-03')!.expense,
+ '7.00',
+ );
+ await portfolio.deleteRevision(r, a.id, expense.id);
+ assert.equal(await balance(a.id), '190');
+ assert.equal((await calendar.day(r, '2026-09-03')).items.length, 0);
+ await assert.rejects(portfolio.deleteRevision(r, a.id, a.revisions[0].id));
+ assert.equal(await balance(a.id), '190');
+ // An observed absolute balance is an anchor, including after a moved transfer.
+ await portfolio.revise(r, a.id, {
+ amount: '300',
+ date: '2026-09-05T00:00',
+ reason: 'balance',
+ notes: '',
+ });
+ await movements.edit(r, later.id, {
+ ...input,
+ amount: '15',
+ received: '15',
+ fee: '0',
+ date: '2026-09-02T00:00',
+ });
+ assert.equal(await balance(a.id), '300');
+ assert.equal(await balance(b.id), '15');
+ const initial = a.revisions[0].id;
+ await db.position.update({ where: { id: a.id }, data: { hidden: true } });
+ await assert.rejects(movements.remove(r, later.id));
+ await assert.rejects(
+ portfolio.correct(r, a.id, initial, { amount: '1', date: '2026-09-01T00:00', notes: '' }),
+ );
+ await db.position.update({ where: { id: a.id }, data: { hidden: false } });
+ const debt = await create('20', 'debt', 'liability');
+ const repayment = await movements.create(r, {
+ ...input,
+ targetId: debt.id,
+ amount: '10',
+ received: '10',
+ fee: '0',
+ operation: 'repay',
+ date: '2026-09-06T00:00',
+ });
+ await assert.rejects(
+ movements.edit(r, repayment.id, {
+ ...input,
+ targetId: debt.id,
+ amount: '25',
+ received: '25',
+ fee: '0',
+ operation: 'repay',
+ date: '2026-09-06T00:00',
+ }),
+ );
+ assert.equal(await balance(debt.id), '10');
+ assert.equal(await balance(a.id), '290');
+ await movements.remove(r, repayment.id);
+ assert.equal(await balance(debt.id), '20');
+ assert.equal(await balance(a.id), '300');
+ // Cross-currency values and signed fees retain exact decimal precision.
+ await db.position.update({ where: { id: b.id }, data: { currency: 'USD' } });
+ const fx = await movements.create(r, {
+ ...input,
+ amount: '0.12435678',
+ received: '0.01423567',
+ fee: '-0.00000001',
+ date: '2026-09-07T00:00',
+ });
+ await movements.edit(r, fx.id, {
+ ...input,
+ amount: '0.22345678',
+ received: '0.02234567',
+ fee: '-0.00000002',
+ date: '2026-09-07T00:00',
+ });
+ assert.equal(await balance(a.id), '299.77654324');
+ assert.equal(await balance(b.id), '15.02234567');
+ await movements.remove(r, fx.id);
+ assert.equal(await balance(a.id), '300');
+ assert.equal(await balance(b.id), '15');
+ } finally {
+ await db.user.deleteMany({ where: { id: { in: users } } });
+ await db.$disconnect();
+ }
+});
diff --git a/apps/web/src/AccountPicker.tsx b/apps/web/src/AccountPicker.tsx
index 90231c6..2bd59c1 100644
--- a/apps/web/src/AccountPicker.tsx
+++ b/apps/web/src/AccountPicker.tsx
@@ -51,11 +51,12 @@ export function AccountPicker({
onChange={(e) => setQuery(e.target.value)}
/>
-
-
{groups.map((group) => (
diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx
index 531d931..e402c7a 100644
--- a/apps/web/src/App.tsx
+++ b/apps/web/src/App.tsx
@@ -51,6 +51,7 @@ const labels: Record = {
debt: '债务',
history: '变化记录',
calendar: '收支日历',
+ schedules: '定时计划',
settings: '设置与备份',
};
const categories: Record = {
@@ -221,6 +222,7 @@ export default function App() {
kind: string;
p?: Position;
h?: History;
+ transfer?: Transfer;
operation?: DebtOperation;
} | null>(null),
[busy, setBusy] = useState(false),
@@ -340,6 +342,9 @@ export default function App() {
return;
}
setOverview({ ...o, trend: t.items, recent: recent.items });
+ } else if (page === 'schedules') {
+ const rows = await api('/positions?kind=account');
+ if (active()) setPositions(rows);
} else if (['account', 'asset', 'debt'].includes(page)) {
if (page === 'account' && !rangeOnly) {
const result = await api<{
@@ -741,6 +746,29 @@ export default function App() {
);
+ async function correctHistory(h: History) {
+ if (h.reason.startsWith('transfer_') || h.reason.startsWith('loan_')) {
+ setSelected(null);
+ setPage('history');
+ return;
+ }
+ const session = sessionGeneration.current,
+ view = viewRef.current;
+ try {
+ const p = await api('/positions/' + h.positionId);
+ if (session !== sessionGeneration.current || view !== viewRef.current) return;
+ setModal({ kind: 'correction', p, h });
+ } catch (e) {
+ if (session === sessionGeneration.current && view === viewRef.current) report(e);
+ }
+ }
+ function deleteHistory(h: History) {
+ if (!window.confirm(tr('删除这条余额记录并重算后续余额?'))) return;
+ void act(
+ () => api('/positions/' + h.positionId + '/revisions/' + h.id, 'DELETE'),
+ tr('记录已删除,余额和日历已更新'),
+ );
+ }
const nav = [
['overview', LayoutDashboard],
['account', Wallet],
@@ -748,6 +776,7 @@ export default function App() {
['debt', HandCoins],
['history', HistoryIcon],
['calendar', CalendarDays],
+ ['schedules', HistoryIcon],
['settings', Settings],
] as const;
const visibleNav = nav.filter(([key]) => !(user.hiddenMenus || []).includes(key));
@@ -882,42 +911,6 @@ export default function App() {
{tr('正在刷新数据…')}
)}
- {page === 'overview' && (
-
- )}
{page === 'overview' && overview && (
<>
{!overview.complete && (
@@ -967,6 +960,41 @@ export default function App() {
{tr('净资产轨迹')}
{tr('按业务日期')}
+
+
+
+
+
+
+ {tr(
+ '默认最近90天;周一至周日及自然月取期末余额,变化归因累计区间内每日变化,首尾不足周期按实际范围。',
+ )}
+
{tr('按当时可用汇率重放;更正历史后重新计算。归档项目仍计入。')}
@@ -1014,7 +1042,7 @@ export default function App() {
{
setSelected(id);
setPage(overview.recent.find((h) => h.positionId === id)?.kind || 'account');
@@ -1034,8 +1062,8 @@ export default function App() {
{tr('· 每日尝试更新,失败保留已有数据;旧日期汇率可能产生换算偏差。')}
{overview.trend.length > 1 && (
-
- {tr('变化归因')}
+
+ {tr('变化归因')}
@@ -1068,7 +1096,7 @@ export default function App() {
-
+
)}
>
)}
@@ -1211,7 +1239,8 @@ export default function App() {
setModal({ kind: 'correction', p, h })}
+ correct={p.archived ? undefined : (h) => void correctHistory(h)}
+ remove={p.archived ? undefined : deleteHistory}
/>
{historyCursor && (
void more('history')}>
@@ -1252,12 +1281,6 @@ export default function App() {
{quickMode && {tr('点击账户卡片,直接填写当前余额。')}
}
- p.kind === 'account' && p.side === 'asset' && !p.archived,
- )}
- refresh={() => load(false, true)}
- />
>
)}
{positions.some((p) => p.kind === page) ? (
@@ -1392,6 +1415,28 @@ export default function App() {
{money(t.fee, t.sourceCurrency)}
{t.notes && {t.notes}
}
+
+ setModal({ kind: 'transfer-edit', transfer: t })}
+ >
+ {tr('修改')}
+
+ {
+ if (window.confirm(tr('删除这笔资金往来并重算双方余额?')))
+ void act(
+ () => api('/transfers/' + t.id, 'DELETE'),
+ tr('记录已删除,余额和日历已更新'),
+ );
+ }}
+ >
+ {tr('删除')}
+
+
))}
@@ -1409,6 +1454,8 @@ export default function App() {
void correctHistory(h)}
+ remove={deleteHistory}
open={(id) => {
setSelected(id);
setPage(historyRows.find((h) => h.positionId === id)?.kind || 'account');
@@ -1422,6 +1469,16 @@ export default function App() {
>
)}
+ {page === 'schedules' && (
+
+ p.kind === 'account' && p.side === 'asset' && !p.archived,
+ )}
+ refresh={() => load(false, true)}
+ />
+
+ )}
{page === 'calendar' && (
)}
@@ -1471,9 +1528,15 @@ export default function App() {
() =>
api('/settings', 'PATCH', {
baseCurrency: f.get('baseCurrency'),
- hiddenMenus: ['overview', 'account', 'asset', 'debt', 'history'].filter(
- (key) => !f.has('menu_' + key),
- ),
+ hiddenMenus: [
+ 'overview',
+ 'account',
+ 'asset',
+ 'debt',
+ 'history',
+ 'calendar',
+ 'schedules',
+ ].filter((key) => !f.has('menu_' + key)),
idleMinutes: Number(f.get('idleMinutes')),
showNotes: f.has('showNotes'),
}),
@@ -1485,7 +1548,15 @@ export default function App() {
{tr('选择需要显示的菜单项。设置入口始终保留,隐藏仅影响导航,不删除数据。')}
- {['overview', 'account', 'asset', 'debt', 'history'].map((key) => (
+ {[
+ 'overview',
+ 'account',
+ 'asset',
+ 'debt',
+ 'history',
+ 'calendar',
+ 'schedules',
+ ].map((key) => (