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' && ( -
- {' '} -
- - - -
-

- {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) => (