From 9adf7fdbc564524abfa994c1c12c7530dfd22e02 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E7=85=9C?= <1871263099@qq.com> Date: Fri, 2 Oct 2026 18:46:25 +0800 Subject: [PATCH] feat: streamline portfolio UI and support atomic record edits --- apps/api/assets/icons/32.png | Bin 0 -> 10941 bytes apps/api/assets/icons/33.png | Bin 0 -> 10121 bytes apps/api/assets/icons/34.png | Bin 0 -> 20436 bytes apps/api/assets/icons/sources.json | 18 ++ apps/api/package.json | 2 +- .../migration.sql | 15 + apps/api/prisma/schema.prisma | 14 + apps/api/scripts/check-comments.cjs | 20 ++ apps/api/scripts/expand-icons.py | 16 +- apps/api/src/openapi.ts | 3 +- apps/api/src/portfolio.ts | 45 +-- apps/api/src/replay.ts | 68 +++++ apps/api/src/transfers.ts | 60 ++++ apps/api/src/validation.ts | 12 +- apps/api/test/record-edit.test.ts | 198 +++++++++++++ apps/web/src/AccountPicker.tsx | 11 +- apps/web/src/App.tsx | 263 ++++++++++++++---- apps/web/src/SchedulePanel.tsx | 10 +- apps/web/src/locales/en.json | 20 +- apps/web/src/locales/zh-Hant.json | 20 +- apps/web/src/style.css | 152 ++++++++++ docs/acceptance.md | 6 + docs/architecture.md | 2 + docs/database-comments.json | 83 +++++- docs/update-2026-10-02.md | 23 +- update.md | 39 ++- 26 files changed, 988 insertions(+), 112 deletions(-) create mode 100644 apps/api/assets/icons/32.png create mode 100644 apps/api/assets/icons/33.png create mode 100644 apps/api/assets/icons/34.png create mode 100644 apps/api/prisma/migrations/20261002000500_schedule_comments/migration.sql create mode 100644 apps/api/scripts/check-comments.cjs create mode 100644 apps/api/src/replay.ts create mode 100644 apps/api/test/record-edit.test.ts diff --git a/apps/api/assets/icons/32.png b/apps/api/assets/icons/32.png new file mode 100644 index 0000000000000000000000000000000000000000..335efb1e1d3527e51496fbe6300a0037ba55e35c GIT binary patch literal 10941 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 literal 0 HcmV?d00001 diff --git a/apps/api/assets/icons/33.png b/apps/api/assets/icons/33.png new file mode 100644 index 0000000000000000000000000000000000000000..2378b8765f0c46cd5750a61811103e21ee159754 GIT binary patch literal 10121 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 P00000NkvXXu0mjf>@;9- literal 0 HcmV?d00001 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' && ( -
- {' '} -
- - - -
-

- {tr( - '默认最近90天;周一至周日及自然月取期末余额,变化归因累计区间内每日变化,首尾不足周期按实际范围。', - )} -

-
- )} {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 && ( + + ))} @@ -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) => (