diff --git a/README.md b/README.md index 5674c12..6d71d58 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ pnpm db:status pnpm check:staged # git add 后,提交前检查本地凭证和禁止提交的文件 ``` -当前功能:注册登录和退出、资产/负债账户、独立资产、独立债务和关联、分钟余额历史与更正、隐藏项目密码核验、本位币和自动日汇率、净资产趋势和变化归因、分文件 ZIP 备份与事务追加恢复、各菜单项显示开关、统一备注显示开关及无操作退出设置、安全清空本账号数据。 +当前功能:注册登录、资产与负债账户、独立资产及债务、账户分组、快速记账与撤回、双边还款及转账、月份还款和当天记账标记、单项统计开关、余额历史管理、收支日历、按需执行的定时计划、隐藏资产、净资产趋势、多币种换算、贵金属按克估价、ZIP v9 备份恢复,以及 OAuth/MCP 助手接入。 金额原币保留,金额/汇率使用十进制字符串和 MySQL Decimal。归档只停止金额编辑,仍参与统计;需要归零时先更新余额。每次金额更新新增独立历史,同日按记录顺序保留;每日趋势使用当日最后余额;已有错误记录可单独更正,转账及其之前的历史需通过新增余额调整修正,避免破坏双方一致性。币种与资产负债属性创建后固定。详情同时显示原币和本位币金额。 @@ -43,17 +43,17 @@ pnpm check:staged # git add 后,提交前检查本地凭证和禁止提交的 备份下载为 ZIP:manifest、settings、currencies、accounts、assets、debts、history、links、rates、icons、transfers 各一个 JSON 文件,包含隐藏项目及全部历史,不含密码或会话凭据。导入先上传并预览,再确认追加;仅接受当前 ZIP v9,所有 JSON 备份和旧 ZIP 均不支持。不限制项目、历史、关联或汇率条数;上传文件最大 512 MB,ZIP 解压总计最大 1 GB,用于保护服务器资源。 -内网穿透临时测试:本地 `apps/api/.env` 可设置 `WEB_ORIGIN=*` 并重启 API,允许来自任意 HTTP/HTTPS 站点的浏览器写入请求,仍需登录身份并校验数据归属。缺失或无效 Origin 仍拒绝;`NODE_ENV=production` 禁止此通配符。测试结束后恢复准确的站点地址。 +网络配置集中在 `apps/api/.env`,API 与 Vite 开发服务共同读取;[配置模板](apps/api/.env.example) 逐项用中文说明作用。当前本地配置支持 HTTP、所有监听网卡及有效 HTTP/HTTPS 来源;登录、权限与数据归属校验仍生效。上线需按域名收紧 Host/Origin、关闭开放开关、启用 HTTPS 与 Secure Cookie。显式配置优先于 NODE_ENV,仅改成 production 不会覆盖已设置的开放开关。修改后重启 API 和网页开发服务,详见 [网络配置](docs/network-settings.md)。 账户图标:新增或编辑账户时选择可复用图标;设置页面提供图标库及中文名称搜索。直接上传默认私有,仅当前用户能检索、读取和使用;勾选共享并明确确认公开后,所有登录用户均可搜索复用,名称必须包含中文。支持静态 PNG/JPG/WebP,单张最大 2 MB,转为最长边 256 像素的 PNG 并去除图片元数据。同一用户相同图片和可见范围会复用现有图标。账户图标通过外键关联,不复制图片。 预置银行、支付平台与交易所等共 36 个图标,资源及来源清单在 `apps/api/assets/icons`;银行来自公开银行标识库,支付平台来自官方网站资源及 Simple Icons。图标版权与商标归相应品牌所有,用于识别账户,不代表品牌合作或授权。运行 `pnpm --filter @worthpath/api icons:seed` 初始化共享库或更新固定 ID 的内置透明图标,不修改用户上传图标或财务数据。可离线使用已提交的 PNG,无需访问外部图标网站。 -ZIP 格式 v5 增加 transfers.json(转账双方、金额、手续费及配对历史),包含完整转账恢复关系。icons.json(图标名称、图片、内容校验值),包含自己的全部图标及账户引用的共享图标。导入会重建关联并将图标恢复为私有,相同图片复用,避免自动公开;旧 v3/v4 ZIP 和旧 JSON 仍可导入。清空个人数据会删除私有图标,已发布共享图标保留供其他用户使用。 +当前 ZIP v9 的 transfers.json 保存转账双方、金额、手续费及配对历史,恢复时重建关系;icons.json 保存图标名称、图片和内容校验值。恢复图标为私有,相同图片复用,避免自动公开;不支持旧 ZIP 或单文件 JSON。清空个人数据会删除私有图标,已发布共享图标保留供其他用户使用。 ## 账户转账与显示设置 -账户页面和账户详情提供“账户间转账”,详情自动选择当前账户。双方余额和历史在同一数据库事务中更新;正手续费额外扣除,负手续费表示优惠(绝对值不超过本金),同币种到账金额等于转出金额,跨币种填写实际到账金额。转账不调用银行或支付平台,不执行真实资金划转。重复提交使用请求 ID 防止重复记账,并发写冲突有限重试。转账时间不能早于双方最新余额;后续余额调整不能插入已有配对操作之前。 +账户页面和账户详情提供“转账”,详情自动选择当前账户。双方余额和历史在同一数据库事务中更新;正手续费额外扣除,负手续费表示优惠(绝对值不超过本金),同币种到账金额等于转出金额,跨币种填写实际到账金额。转账不调用银行或支付平台,不执行真实资金划转。重复提交使用请求 ID 防止重复记账,并发写冲突有限重试。转账时间不能早于双方最新余额;后续余额调整不能插入已有配对操作之前。 债务分为借入(应付负债)和借出(应收资产),支持借入到账、借出付款、收回应收和偿还应付。账户与债务在同一事务中记账并自动关联;双方详情和往来记录可互相导航。已有债务可录入剩余余额,新发生借贷可先建零余额债务再使用联动操作。当前 ZIP v9 保留操作类型,不保留任何旧备份兼容。 @@ -83,11 +83,28 @@ Invoke-RestMethod http://localhost:5173/api/openapi.json “定时计划”支持支出及转账,打开账户页时按需执行,每批最多 20 项;仅在本应用内记账。新增“收支日历”默认今天,月历与当日明细在同页上下展示,按账户余额变化估算,排除内部转账、借贷本金及初始余额。 -本次需要新增数据库迁移并运行图库初始化,备份导出升级至 ZIP v7。操作步骤、31 个内置图标与验证边界见 [更新说明](docs/update-2026-10-02.md)。 +2026-10-02 历史更新的迁移和验收见 [更新说明](docs/update-2026-10-02.md);该文档中的 ZIP v7 为当时版本,当前仅支持 ZIP v9。 2026-10-03 设置与交互更新:支持 1 小时至 30 天登录有效期、隐藏项目密码开关、自选总览卡片、计划编辑弹窗、右上角操作提示及账户卡片快速转账。详见 [更新说明](docs/update-settings-interaction-2026-10-03.md)。 +## 快速记账与独立资产 + +独立资产支持房产、车辆、贵金属、纪念币和纪念钞。普通独立资产手动录入估值;贵金属按克数自动估价,可选填每克买入成本。独立资产页可开启“快速设置计入总资产”,点击卡片切换统计状态;独立资产总开关仍以个人设置为准。 + +账户页开启“快速记账”后使用紧凑卡片直接更新余额;转账按钮紧邻模式入口。右侧显示本次保存成功的余额、转账、还款及标记,支持逐项撤回;窄屏记录面板显示在账户列表下方。列表为当前页面会话,刷新或重新进入后清空,已保存账目仍在变化记录中。 + +欠款账户弹窗提供“还款”,通过付款账户与欠款账户双边记账更新余额。可手动标记“本月已还款”和“当天已记账”,并筛选未还款或隐藏当天已记账的账户。数据库保留最后完成月份/日期,跨月、跨日只按当前 UTC+8 时间判断是否显示,无需定时删除;标记不会改变金额。 + +账户、独立资产和债务可在详情删除。存在配对往来或定时计划时需先处理依赖,避免破坏另一账户余额。详见 [快速记账更新与验收](docs/update-quick-entry-2026-10-04.md) 和 [项目删除说明](docs/update-position-deletion-2026-10-04.md)。 + +## 连接 Agent / MCP + +设置中的“连接助手”提供可复制的接入提示词、能力说明及权限选择。OAuth 授权和草稿确认使用独立页面;可选择 1、3、7、30 天、1 年或永久授权,并分别控制读取、修改隐藏账户。普通权限分为只读、草稿修改、直接写入,草稿需本人在网页审阅确认后才生效。 + +密码修改、清空数据、备份恢复、共享图标发布、汇率及贵金属报价修改在网站完成,MCP 不提供这些敏感操作。远程接入必须配置客户端可访问的 MCP_PUBLIC_URL 与 MCP_WEB_URL;localhost 仅代表客户端本机。详见 [连接与授权更新](docs/update-agent-accounts-2026-10-04.md)。 + ## git仓库目录 + ```shell git remote add github https://github.com/chyuovo/WorthPath.git git remote add wyh https://git.mashiroart.xyz/chyuovo/WorthPath.git @@ -98,7 +115,6 @@ git config remote.pushDefault github 'github','gitee','wyh' | ForEach-Object { git push $_ main } ``` - ## 文档维护 文档维护约定:`docs` 目录仅保存文字文档和结构化验收数据,不保存图片。界面验收结果以文字记录,Markdown 不引用已删除的截图;应用使用的图标素材仍保存在 `apps/api/assets/icons`。 @@ -107,9 +123,9 @@ git config remote.pushDefault github ### 运行时第三方接口 -| 服务 | 实际请求地址 | 用途与更新方式 | 密钥与发送数据 | 失败处理 | -| --- | --- | --- | --- | --- | -| [Frankfurter](https://frankfurter.dev/) | [USD 固定币种日汇率](https://api.frankfurter.dev/v2/rates?base=USD"es=CNY,HKD,EUR,GBP,JPY,AUD,CAD,CHF,SGD) | 外币换算;同时将贵金属美元报价换算为持仓原币。每小时检查、每日尝试一次,也支持手动刷新。 | 无需密钥;仅发送固定 USD 和九种公开币种,不发送用户身份、账户、金额或持仓重量。 | 12 秒超时;保留原币、已有汇率和估值,显示错误状态。 | -| [Gold API](https://gold-api.com/docs) | [黄金 XAU](https://api.gold-api.com/price/XAU)、[白银 XAG](https://api.gold-api.com/price/XAG) | 美元/金衡盎司参考价;以 31.1034768 克/金衡盎司换算为每克价格。已配置贵金属每日尝试更新,可手动刷新或录价。 | 无需密钥;请求固定 XAU/XAG 品种,不发送个人数据和持仓。 | 12 秒超时;格式、时间或汇率异常时保留之前的报价与估值;同日手动价格优先。 | +| 服务 | 实际请求地址 | 用途与更新方式 | 密钥与发送数据 | 失败处理 | +| --------------------------------------- | -------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- | +| [Frankfurter](https://frankfurter.dev/) | [USD 固定币种日汇率](https://api.frankfurter.dev/v2/rates?base=USD"es=CNY,HKD,EUR,GBP,JPY,AUD,CAD,CHF,SGD) | 外币换算;同时将贵金属美元报价换算为持仓原币。每小时检查、每日尝试一次,也支持手动刷新。 | 无需密钥;仅发送固定 USD 和九种公开币种,不发送用户身份、账户、金额或持仓重量。 | 12 秒超时;保留原币、已有汇率和估值,显示错误状态。 | +| [Gold API](https://gold-api.com/docs) | [黄金 XAU](https://api.gold-api.com/price/XAU)、[白银 XAG](https://api.gold-api.com/price/XAG) | 美元/金衡盎司参考价;以 31.1034768 克/金衡盎司换算为每克价格。已配置贵金属每日尝试更新,可在网站手动刷新。 | 无需密钥;请求固定 XAU/XAG 品种,不发送个人数据和持仓。 | 12 秒超时;格式、时间或汇率异常时保留之前的报价与估值;已有历史导入报价保留。 | -上述接口由后端访问,浏览器业务请求使用本站 `/api`,第三方不会收到本应用登录 Cookie。Gold API 返回的参考价只用于持仓估值,不包含饰品工费、买卖价差或回收折价。外币价格使用最新可用公共日汇率,休市时两类报价时间可能不同;报价日期会显示在界面并随备份保存。自动估价为可选项,开启后新增估值历史,不改写历史记录。 \ No newline at end of file +上述接口由后端访问,浏览器业务请求使用本站 `/api`,第三方不会收到本应用登录 Cookie。Gold API 返回的参考价只用于持仓估值,不包含饰品工费、买卖价差或回收折价。外币价格使用最新可用公共日汇率,休市时两类报价时间可能不同;报价日期会显示在界面并随备份保存。自动估价为可选项,开启后新增估值历史,不改写历史记录。 diff --git a/apps/api/package.json b/apps/api/package.json index e273c76..ec57d5a 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -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/update.test.ts test/zip.test.ts test/metals.test.ts ../web/test/i18n.test.ts test/account-deletion.test.ts test/mcp-hosts.test.ts test/mcp-http.test.ts test/network.test.ts", + "test": "tsx --test --test-concurrency=1 test/calculation.test.ts test/update.test.ts test/zip.test.ts test/metals.test.ts ../web/test/i18n.test.ts test/account-deletion.test.ts test/mcp-hosts.test.ts test/mcp-http.test.ts test/network.test.ts ../web/test/quick-entry.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/update-integration.test.ts test/record-edit.test.ts test/security-backup.test.ts test/credit-balance.test.ts test/group-order.test.ts test/settings-plans.test.ts test/inclusion-metals.test.ts test/account-deletion-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/security-backup.test.ts test/credit-balance.test.ts test/group-order.test.ts test/settings-plans.test.ts test/inclusion-metals.test.ts test/account-deletion-integration.test.ts test/quick-entry-integration.test.ts", "test:performance": "tsx scripts/performance.ts after", "test:mcp": "tsx --test --test-concurrency=1 test/mcp.test.ts test/oauth-duration.test.ts", "mcp:probe": "tsx scripts/mcp-probe.ts", diff --git a/apps/api/prisma/migrations/20261004154000_quick_repayment_month/migration.sql b/apps/api/prisma/migrations/20261004154000_quick_repayment_month/migration.sql new file mode 100644 index 0000000..1b0c643 --- /dev/null +++ b/apps/api/prisma/migrations/20261004154000_quick_repayment_month/migration.sql @@ -0,0 +1 @@ +ALTER TABLE `Position` ADD COLUMN `lastRepaymentMonth` CHAR(7) NULL COMMENT '最后手动标记已完成还款的业务月份,UTC+8 YYYY-MM;空表示未标记'; diff --git a/apps/api/prisma/migrations/20261004161000_quick_booked_date/migration.sql b/apps/api/prisma/migrations/20261004161000_quick_booked_date/migration.sql new file mode 100644 index 0000000..ea3a0b1 --- /dev/null +++ b/apps/api/prisma/migrations/20261004161000_quick_booked_date/migration.sql @@ -0,0 +1 @@ +ALTER TABLE `Position` ADD COLUMN `lastBookedDate` CHAR(10) NULL COMMENT '最后手动标记已完成记账的业务日期,UTC+8 YYYY-MM-DD;空表示未标记'; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 061c5d6..8c9cef0 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -218,6 +218,10 @@ model Position { hidden Boolean @default(false) /// 是否参与资产负债总额及净资产轨迹 included Boolean @default(true) + /// 最后手动标记已完成还款的业务月份,UTC+8 YYYY-MM;空表示未标记 + lastRepaymentMonth String? @db.Char(7) + /// 最后手动标记已完成记账的业务日期,UTC+8 YYYY-MM-DD;空表示未标记 + lastBookedDate String? @db.Char(10) /// 贵金属品种:gold 黄金或 silver 白银 metalType String? @db.VarChar(12) /// 贵金属总重量,单位克 diff --git a/apps/api/src/backup.ts b/apps/api/src/backup.ts index e5ccec6..4003fd6 100644 --- a/apps/api/src/backup.ts +++ b/apps/api/src/backup.ts @@ -1,3 +1,4 @@ +import { repaymentMonth, date } from './validation'; import { BACKUP_ZIP_VERSION } from './backup-format'; import { Injectable } from '@nestjs/common'; import { metalConfig, metalPriceInput, storedMetalPurity } from './metals'; @@ -63,6 +64,8 @@ const record = positionMeta notes: z.string().max(2000), archived: z.boolean(), hidden: z.boolean(), + lastRepaymentMonth: repaymentMonth.optional(), + lastBookedDate: date.nullable().optional(), metalType: z.enum(['gold', 'silver']).nullable(), metalGrams: amount.nullable(), metalCostPerGram: metalConfig.shape.metalCostPerGram.nonoptional(), @@ -172,6 +175,10 @@ export function validateBackup(raw: unknown) { throw new BadRequestException('图标关联无效'); const revisionIds = new Set(); for (const p of b.positions) { + if (p.lastBookedDate && p.kind !== 'account') + throw new BadRequestException('仅账户支持当天记账标记'); + if (p.lastRepaymentMonth && (p.kind !== 'account' || p.side !== 'liability')) + throw new BadRequestException('还款月份仅适用于欠款账户'); if (p.metalType || p.metalGrams || p.autoValuation || p.metalCostPerGram != null) { if (p.kind !== 'asset' || p.category !== 'gold') throw new BadRequestException('贵金属估价关联无效'); @@ -393,6 +400,8 @@ export class BackupBusinessService implements OnModuleDestroy, OnModuleInit { archived: p.archived, hidden: p.hidden, included: p.included, + lastRepaymentMonth: p.lastRepaymentMonth, + lastBookedDate: p.lastBookedDate, metalType: p.metalType, metalGrams: p.metalGrams?.toString() ?? null, metalCostPerGram: p.metalCostPerGram?.toString() ?? null, @@ -700,6 +709,8 @@ export class BackupBusinessService implements OnModuleDestroy, OnModuleInit { archived: p.archived, hidden: p.hidden, included: p.included, + lastRepaymentMonth: p.lastRepaymentMonth ?? null, + lastBookedDate: p.lastBookedDate ?? null, metalType: p.metalType, metalGrams: p.metalGrams, metalCostPerGram: p.metalCostPerGram, diff --git a/apps/api/src/openapi.ts b/apps/api/src/openapi.ts index 7cb0256..243afb0 100644 --- a/apps/api/src/openapi.ts +++ b/apps/api/src/openapi.ts @@ -7,6 +7,9 @@ import { credentials, credentialChange, positionInput, + repaymentMarkInput, + bookedMarkInput, + inclusionInput, positionMeta, revisionInput, transferInput, @@ -35,6 +38,9 @@ export function setupOpenApi(app: INestApplication) { 'POST /api/auth/reveal': z.object({ password: credentials.shape.password.optional() }).strict(), 'POST /api/positions': positionInput, 'PATCH /api/positions/{id}': positionMeta, + 'PATCH /api/positions/{id}/repayment-mark': repaymentMarkInput, + 'PATCH /api/positions/{id}/booked-mark': bookedMarkInput, + 'PATCH /api/positions/{id}/inclusion': inclusionInput, 'POST /api/positions/{id}/revisions': revisionInput, 'PUT /api/positions/{id}/revisions/{revisionId}': revisionInput, 'PUT /api/positions/{id}/links': z diff --git a/apps/api/src/portfolio.ts b/apps/api/src/portfolio.ts index 56b63be..2c69981 100644 --- a/apps/api/src/portfolio.ts +++ b/apps/api/src/portfolio.ts @@ -18,6 +18,9 @@ import { Database } from './database'; import { UserRequest } from './auth'; import { positionInput, + repaymentMarkInput, + bookedMarkInput, + inclusionInput, positionMeta, revisionInput, today, @@ -158,6 +161,68 @@ export class PortfolioBusinessService { }); return { ok: true }; } + + async quickMeta(r: UserRequest, id: string, raw: unknown, operation: 'booked' | 'included') { + const v = operation === 'booked' ? bookedMarkInput.parse(raw) : inclusionInput.parse(raw); + return this.db.serial(async (tx) => { + await tx.$queryRaw( + Prisma.sql`SELECT id FROM Position WHERE id = ${id} AND userId = ${r.userId} FOR UPDATE`, + ); + const p = await tx.position.findFirst({ + where: { id, userId: r.userId, ...(r.revealed ? {} : { hidden: false }) }, + }); + if (!p) throw new NotFoundException('项目不存在'); + if (operation === 'booked' && (p.kind !== 'account' || p.archived)) + throw new BadRequestException('仅启用账户支持当天记账标记'); + if (operation === 'included' && p.kind !== 'asset') + throw new BadRequestException('仅独立资产支持此快速统计开关'); + if (v.expectedUpdatedAt && p.updatedAt.toISOString() !== v.expectedUpdatedAt) + throw new ConflictException('项目已变化,请刷新后重试'); + if ( + ('expectedDate' in v && p.lastBookedDate !== v.expectedDate) || + ('expectedIncluded' in v && p.included !== v.expectedIncluded) + ) + throw new ConflictException('标记已变化,请刷新后重试'); + const updated = await tx.position.update({ + where: { id }, + data: 'date' in v ? { lastBookedDate: v.date } : { included: v.included }, + }); + return { + lastBookedDate: updated.lastBookedDate, + included: updated.included, + updatedAt: updated.updatedAt, + }; + }); + } + async repaymentMark(r: UserRequest, id: string, raw: unknown) { + const input = repaymentMarkInput.parse(raw); + return this.db.serial(async (tx) => { + await tx.$queryRaw( + Prisma.sql`SELECT id FROM Position WHERE id = ${id} AND userId = ${r.userId} FOR UPDATE`, + ); + const p = await tx.position.findFirst({ + where: { id, userId: r.userId, ...(r.revealed ? {} : { hidden: false }) }, + }); + if (!p) throw new NotFoundException('账户不存在'); + if (p.kind !== 'account' || p.side !== 'liability') + throw new BadRequestException('仅欠款账户支持还款月份标记'); + if (p.archived) throw new ConflictException('请先恢复归档账户'); + if ( + p.lastRepaymentMonth !== input.expectedMonth || + (input.expectedUpdatedAt && p.updatedAt.toISOString() !== input.expectedUpdatedAt) + ) + throw new ConflictException('还款标记已变化,请刷新后重试'); + const updated = await tx.position.update({ + where: { id }, + data: { lastRepaymentMonth: input.month }, + }); + return { + lastRepaymentMonth: input.month, + previousMonth: p.lastRepaymentMonth, + updatedAt: updated.updatedAt.toISOString(), + }; + }); + } async deletion(r: UserRequest, id: string) { const p = await this.own(r.userId, id, r.revealed); const [historyCount, movementCount, scheduleCount, linkCount] = await Promise.all([ @@ -408,6 +473,27 @@ export class PortfolioController { ) { return this.service.edit(r, id, b); } + @Patch('positions/:id/booked-mark') async bookedMark( + @Req() r: UserRequest, + @Param('id') id: string, + @Body() b: unknown, + ) { + return this.service.quickMeta(r, id, b, 'booked'); + } + @Patch('positions/:id/inclusion') async inclusion( + @Req() r: UserRequest, + @Param('id') id: string, + @Body() b: unknown, + ) { + return this.service.quickMeta(r, id, b, 'included'); + } + @Patch('positions/:id/repayment-mark') async repaymentMark( + @Req() r: UserRequest, + @Param('id') id: string, + @Body() b: unknown, + ) { + return this.service.repaymentMark(r, id, b); + } @Get('positions/:id/deletion') async deletion(@Req() r: UserRequest, @Param('id') id: string) { return this.service.deletion(r, id); } diff --git a/apps/api/src/validation.ts b/apps/api/src/validation.ts index 7f86fbd..c34b587 100644 --- a/apps/api/src/validation.ts +++ b/apps/api/src/validation.ts @@ -217,3 +217,31 @@ export const settingsInput = z }) .strict() .refine((v) => Object.keys(v).length > 0); + +export const repaymentMonth = z + .string() + .regex(/^(19|[2-9]\d)\d{2}-(0[1-9]|1[0-2])$/) + .refine((v) => v <= today().slice(0, 7), '不能标记未来还款月份') + .nullable(); +export const repaymentMarkInput = z + .object({ + month: repaymentMonth, + expectedMonth: repaymentMonth, + expectedUpdatedAt: z.iso.datetime().optional(), + }) + .strict(); + +export const bookedMarkInput = z + .object({ + date: date.nullable(), + expectedDate: date.nullable(), + expectedUpdatedAt: z.iso.datetime().optional(), + }) + .strict(); +export const inclusionInput = z + .object({ + included: z.boolean(), + expectedIncluded: z.boolean(), + expectedUpdatedAt: z.iso.datetime().optional(), + }) + .strict(); diff --git a/apps/api/test/quick-entry-integration.test.ts b/apps/api/test/quick-entry-integration.test.ts new file mode 100644 index 0000000..e5a2311 --- /dev/null +++ b/apps/api/test/quick-entry-integration.test.ts @@ -0,0 +1,269 @@ +import 'dotenv/config'; +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { randomUUID, randomBytes, createHash } from 'node:crypto'; +import { PrismaClient } from '@prisma/client'; +import { today } from '../src/validation'; +import { readBackupZip } from '../src/zip'; +import { fixtureFetch } from './backup-fixture'; +const base = process.env.TEST_API_URL || 'http://127.0.0.1:3100/api'; +const origin = process.env.WEB_ORIGIN === '*' ? 'http://localhost:5173' : process.env.WEB_ORIGIN!; +test('quick entries undo paired repayment and balance, persist monthly marks, protect privacy and survive ZIP restore', async () => { + const db = new PrismaClient(), + users: string[] = []; + async function user() { + const u = await db.user.create({ + data: { username: 'quick_' + randomUUID(), passwordHash: 'unused', idleMinutes: 0 }, + }); + users.push(u.id); + const token = randomBytes(32).toString('hex'), + sid = createHash('sha256').update(token).digest('hex'); + await db.session.create({ + data: { id: sid, userId: u.id, expiresAt: new Date(Date.now() + 3600000) }, + }); + return { id: u.id, cookie: 'wp_session=' + token, sid }; + } + async function call(u: any, path: string, method = 'GET', data?: unknown) { + const r = await fixtureFetch(base + path, { + method, + headers: { + Cookie: u.cookie, + Origin: origin, + ...(data ? { 'Content-Type': 'application/json' } : {}), + }, + body: data ? JSON.stringify(data) : undefined, + }); + return { + status: r.status, + data: r.headers.get('content-type')?.includes('application/zip') + ? await readBackupZip(Buffer.from(await r.arrayBuffer())) + : await r.json(), + }; + } + try { + const a = await user(), + b = await user(); + const create = async ( + name: string, + kind: string, + side: string, + category: string, + amount: string, + hidden = false, + ) => { + const r = await call(a, '/positions', 'POST', { + name, + kind, + side, + category, + amount, + hidden, + currency: 'CNY', + date: '2026-09-01T10:00', + }); + assert.equal(r.status, 201); + return r.data.id as string; + }; + const cash = await create('付款账户', 'account', 'asset', 'bank', '1000'); + const card = await create('欠款账户', 'account', 'liability', 'credit_card', '200'); + for (const category of ['commemorative_coin', 'commemorative_note']) { + const id = await create(category, 'asset', 'asset', category, '50'); + assert.equal((await call(a, '/positions/' + id)).data.category, category); + } + + const coin = (await db.position.findFirst({ + where: { userId: a.id, category: 'commemorative_coin' }, + }))!; + const inclusionPath = '/positions/' + coin.id + '/inclusion'; + assert.equal( + (await call(b, inclusionPath, 'PATCH', { included: false, expectedIncluded: true })).status, + 404, + ); + assert.equal( + ( + await call(a, inclusionPath, 'PATCH', { + included: false, + expectedIncluded: true, + expectedUpdatedAt: coin.updatedAt.toISOString(), + }) + ).status, + 200, + ); + assert.equal((await call(a, '/positions/' + coin.id)).data.included, false); + assert.equal( + (await call(a, inclusionPath, 'PATCH', { included: true, expectedIncluded: true })).status, + 409, + ); + assert.equal( + ( + await call(a, '/positions/' + cash + '/inclusion', 'PATCH', { + included: false, + expectedIncluded: true, + }) + ).status, + 400, + ); + const bookedPath = '/positions/' + cash + '/booked-mark', + day = today(); + const booked = await call(a, bookedPath, 'PATCH', { date: day, expectedDate: null }); + assert.equal(booked.status, 200); + assert.equal((await call(a, '/positions/' + cash)).data.lastBookedDate, day); + assert.equal( + (await call(a, bookedPath, 'PATCH', { date: null, expectedDate: null })).status, + 409, + ); + assert.equal( + (await call(b, bookedPath, 'PATCH', { date: null, expectedDate: day })).status, + 404, + ); + assert.equal( + (await call(a, bookedPath, 'PATCH', { date: '2099-01-01', expectedDate: day })).status, + 400, + ); + assert.equal( + ( + await call(a, bookedPath, 'PATCH', { + date: null, + expectedDate: day, + expectedUpdatedAt: booked.data.updatedAt, + }) + ).status, + 200, + ); + assert.equal( + (await call(a, bookedPath, 'PATCH', { date: day, expectedDate: null })).status, + 200, + ); + assert.equal( + ( + await call(a, '/positions/' + coin.id + '/booked-mark', 'PATCH', { + date: day, + expectedDate: null, + }) + ).status, + 400, + ); + const revision = await call(a, '/positions/' + cash + '/revisions', 'POST', { + amount: '1050.87654321', + date: '2026-09-02T10:00', + }); + assert.equal(revision.status, 201); + assert.equal( + (await call(a, '/positions/' + cash + '/revisions/' + revision.data.id, 'DELETE')).status, + 200, + ); + assert.equal((await call(a, '/positions/' + cash)).data.amount, '1000'); + const payment = await call(a, '/transfers', 'POST', { + sourceId: cash, + targetId: card, + amount: '30', + received: '30', + fee: '0', + date: '2026-09-03T10:00', + requestId: randomUUID(), + }); + assert.equal(payment.status, 201); + assert.equal((await call(a, '/positions/' + cash)).data.amount, '970'); + assert.equal((await call(a, '/positions/' + card)).data.amount, '170'); + assert.equal((await call(a, '/transfers/' + payment.data.id, 'DELETE')).status, 200); + assert.equal((await call(a, '/positions/' + cash)).data.amount, '1000'); + assert.equal((await call(a, '/positions/' + card)).data.amount, '200'); + const month = today().slice(0, 7), + path = '/positions/' + card + '/repayment-mark'; + assert.equal((await call(b, path, 'PATCH', { month, expectedMonth: null })).status, 404); + assert.equal( + ( + await call(a, '/positions/' + cash + '/repayment-mark', 'PATCH', { + month, + expectedMonth: null, + }) + ).status, + 400, + ); + assert.equal( + (await call(a, path, 'PATCH', { month: '2099-01', expectedMonth: null })).status, + 400, + ); + const before = await call(a, '/positions/' + card); + const marked = await call(a, path, 'PATCH', { + month, + expectedMonth: null, + expectedUpdatedAt: before.data.updatedAt, + }); + assert.equal(marked.status, 200); + assert.equal((await call(a, '/positions/' + card)).data.lastRepaymentMonth, month); + assert.equal((await call(a, '/positions/' + card)).data.amount, '200'); + assert.equal((await call(a, path, 'PATCH', { month: null, expectedMonth: null })).status, 409); + const restored = await call(a, path, 'PATCH', { + month: null, + expectedMonth: month, + expectedUpdatedAt: marked.data.updatedAt, + }); + assert.equal(restored.status, 200); + assert.equal((await call(a, '/positions/' + card)).data.lastRepaymentMonth, null); + await call(a, path, 'PATCH', { month, expectedMonth: null }); + assert.equal( + ( + await call(a, path, 'PATCH', { + month: null, + expectedMonth: month, + expectedUpdatedAt: marked.data.updatedAt, + }) + ).status, + 409, + ); + const old = '2026-09'; + const changed = await call(a, path, 'PATCH', { month: old, expectedMonth: month }); + assert.equal(changed.status, 200); + assert.equal((await call(a, '/positions/' + card)).data.lastRepaymentMonth, old); + const hidden = await create('隐藏欠款', 'account', 'liability', 'loan', '5', true); + assert.equal( + ( + await call(a, '/positions/' + hidden + '/repayment-mark', 'PATCH', { + month, + expectedMonth: null, + }) + ).status, + 404, + ); + await db.session.update({ + where: { id: a.sid }, + data: { revealUntil: new Date(Date.now() + 600000) }, + }); + assert.equal( + ( + await call(a, '/positions/' + hidden + '/repayment-mark', 'PATCH', { + month, + expectedMonth: null, + }) + ).status, + 200, + ); + await db.position.update({ where: { id: hidden }, data: { archived: true } }); + assert.equal( + ( + await call(a, '/positions/' + hidden + '/repayment-mark', 'PATCH', { + month: null, + expectedMonth: month, + }) + ).status, + 409, + ); + const backup = await call(a, '/backup'); + assert.equal(backup.status, 200); + assert.equal(backup.data.positions.find((p: any) => p.id === card).lastRepaymentMonth, old); + const imported = await call(b, '/backup/restore-fixture', 'POST', { + confirmed: true, + backup: backup.data, + }); + assert.equal(imported.status, 201); + const rows = await db.position.findMany({ where: { userId: b.id } }); + assert.equal(rows.find((p) => p.name === '欠款账户')?.lastRepaymentMonth, old); + assert.equal(rows.find((p) => p.name === '隐藏欠款')?.lastRepaymentMonth, month); + assert.equal(rows.find((p) => p.name === '付款账户')?.lastBookedDate, day); + assert.equal(rows.find((p) => p.category === 'commemorative_coin')?.included, false); + } finally { + await db.user.deleteMany({ where: { id: { in: users } } }); + await db.$disconnect(); + } +}); diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index fa96d2b..01ae418 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -59,6 +59,15 @@ import { PositionDeletion, deletionLabel } from './PositionDeletion'; import { IconPicker } from './IconPicker'; import { MetalPanel } from './MetalPanel'; import { QuickTransfer } from './QuickTransfer'; +import { + QuickJournal, + QuickRepayment, + isPaidForMonth, + isBookedToday, + quickAccountVisible, + canQuickRepay, + type QuickEntry, +} from './QuickJournal'; import { TransferForm } from './TransferForm'; import { DebtPaymentForm, operationLabel, type DebtOperation } from './DebtPaymentForm'; import type { Transfer } from './api'; @@ -86,6 +95,8 @@ const categories: Record = { ['property', '房产'], ['vehicle', '车辆'], ['gold', '贵金属'], + ['commemorative_coin', '纪念币'], + ['commemorative_note', '纪念钞'], ['other', '其他资产'], ], debt: [ @@ -248,6 +259,7 @@ export default function App() { h?: History; transfer?: Transfer; operation?: DebtOperation; + quick?: boolean; groups?: string[]; } | null>(null), [busy, setBusy] = useState(false), @@ -263,6 +275,36 @@ export default function App() { sourceId?: string; targetId?: string; } | null>(null); + const [quickEntries, setQuickEntries] = useState([]); + const [quickMarkMode, setQuickMarkMode] = useState(false); + const [quickBookedMode, setQuickBookedMode] = useState(false); + const [hideBooked, setHideBooked] = useState(false); + const [unpaidOnly, setUnpaidOnly] = useState(false); + const [assetQuickMode, setAssetQuickMode] = useState(false); + const [businessDay, setBusinessDay] = useState(today()); + const [repaymentMonth, setRepaymentMonth] = useState(today().slice(0, 7)); + const quickGeneration = useRef(0); + useEffect(() => { + const updateDay = () => { + setBusinessDay(today()); + setRepaymentMonth(today().slice(0, 7)); + }; + const timer = setInterval(updateDay, 60000); + window.addEventListener('focus', updateDay); + return () => { + clearInterval(timer); + window.removeEventListener('focus', updateDay); + }; + }, []); + useEffect(() => { + quickGeneration.current++; + setQuickEntries([]); + setQuickMarkMode(false); + setQuickBookedMode(false); + setHideBooked(false); + setUnpaidOnly(false); + setAssetQuickMode(false); + }, [user?.username, user?.revealed]); const [quickMode, setQuickMode] = useState(false), [accountGroup, setAccountGroup] = useState(null); useEffect(() => { @@ -278,6 +320,166 @@ export default function App() { : current, ); }, [positions]); + function toggleQuickMode() { + quickGeneration.current++; + if (!quickMode) setQuickEntries([]); + setQuickMode(!quickMode); + setQuickMarkMode(false); + setQuickBookedMode(false); + setHideBooked(false); + setUnpaidOnly(false); + setQuickTransfer(null); + } + function rememberQuick(entry: QuickEntry, generation: number, session: number) { + if (generation === quickGeneration.current && session === sessionGeneration.current) + setQuickEntries((rows) => [entry, ...rows]); + } + async function saveQuickBalance( + position: Position, + data: Record, + track: boolean, + ) { + const generation = quickGeneration.current, + session = sessionGeneration.current; + const result = await api<{ id: string }>( + '/positions/' + position.id + '/revisions', + 'POST', + data, + ); + if (track) + rememberQuick( + { + key: 'balance:' + result.id, + kind: 'balance', + id: result.id, + positionId: position.id, + title: position.name, + currency: position.currency, + before: positionAmount(position), + after: positionAmount(position, String(data.amount)), + date: String(data.date), + }, + generation, + session, + ); + return result; + } + async function saveQuickMovement(data: unknown, label: string) { + const value = data as Record, + generation = quickGeneration.current, + session = sessionGeneration.current; + const result = await api<{ id: string }>('/transfers', 'POST', data); + const source = positions.find((p) => p.id === value.sourceId), + target = positions.find((p) => p.id === value.targetId); + rememberQuick( + { + key: 'transfer:' + result.id, + kind: 'transfer', + id: result.id, + positionId: value.sourceId, + title: label + ' · ' + (source?.name || '') + ' → ' + (target?.name || ''), + currency: source?.currency || '', + amount: value.amount, + received: value.received, + targetCurrency: target?.currency, + date: value.date, + }, + generation, + session, + ); + return result; + } + async function markQuickRepayment(position: Position) { + const month = today().slice(0, 7), + previousMonth = position.lastRepaymentMonth ?? null; + const nextMonth = isPaidForMonth(position, month) ? null : month; + const generation = quickGeneration.current, + session = sessionGeneration.current; + const result = await api<{ updatedAt: string }>( + '/positions/' + position.id + '/repayment-mark', + 'PATCH', + { month: nextMonth, expectedMonth: previousMonth, expectedUpdatedAt: position.updatedAt }, + ); + rememberQuick( + { + key: 'mark:' + position.id + ':' + Date.now(), + kind: 'mark', + id: position.id, + positionId: position.id, + title: position.name, + currency: position.currency, + date: nowMinute(), + previousMonth, + month: nextMonth, + markUpdatedAt: result.updatedAt, + }, + generation, + session, + ); + return result; + } + async function markQuickBooked(position: Position) { + const previousDate = position.lastBookedDate ?? null, + day = today(); + const nextDate = isBookedToday(position, day) ? null : day; + const generation = quickGeneration.current, + session = sessionGeneration.current; + const result = await api<{ updatedAt: string }>( + '/positions/' + position.id + '/booked-mark', + 'PATCH', + { + date: nextDate, + expectedDate: previousDate, + expectedUpdatedAt: position.updatedAt, + }, + ); + rememberQuick( + { + key: 'booked:' + position.id + ':' + Date.now(), + kind: 'booked', + id: position.id, + positionId: position.id, + title: position.name, + currency: position.currency, + date: nowMinute(), + previousDate, + bookedDate: nextDate, + markUpdatedAt: result.updatedAt, + }, + generation, + session, + ); + return result; + } + async function toggleAssetInclusion(position: Position) { + return api('/positions/' + position.id + '/inclusion', 'PATCH', { + included: position.included === false, + expectedIncluded: position.included !== false, + expectedUpdatedAt: position.updatedAt, + }); + } + function undoQuick(entry: QuickEntry) { + void act(async () => { + if (entry.kind === 'balance') + await api('/positions/' + entry.positionId + '/revisions/' + entry.id, 'DELETE'); + else if (entry.kind === 'transfer') await api('/transfers/' + entry.id, 'DELETE'); + else if (entry.kind === 'booked') + await api('/positions/' + entry.positionId + '/booked-mark', 'PATCH', { + date: entry.previousDate ?? null, + expectedDate: entry.bookedDate ?? null, + expectedUpdatedAt: entry.markUpdatedAt, + }); + else + await api('/positions/' + entry.positionId + '/repayment-mark', 'PATCH', { + month: entry.previousMonth ?? null, + expectedMonth: entry.month ?? null, + expectedUpdatedAt: entry.markUpdatedAt, + }); + setQuickEntries((rows) => + rows.map((row) => (row.key === entry.key ? { ...row, undone: true } : row)), + ); + }, tr('记录已撤回,相关余额已更新')); + } function chooseQuickAccount(p: Position) { if (busy || loading) return; if (p.kind !== 'account' || p.archived) { @@ -335,6 +537,9 @@ export default function App() { const activityAt = useRef(0); function clearAccount() { setQuickMode(false); + quickGeneration.current++; + setQuickEntries([]); + setQuickMarkMode(false); setAccountGroup(''); setClearStep(0); setClearConfirmation(0); @@ -987,7 +1192,11 @@ export default function App() { -
+
{user.revealed @@ -1030,12 +1239,15 @@ export default function App() {

- {page === 'account' && !p && ( + {page === 'account' && !p && !quickMode && ( )} - {['overview', 'account', 'asset', 'debt'].includes(page) && !p && newAction} + {['overview', 'account', 'asset', 'debt'].includes(page) && + !p && + !(page === 'account' && quickMode) && + newAction}
@@ -1458,17 +1670,90 @@ export default function App() { ) : ( -
+
{page === 'account' && ( <>
+ {quickMode && ( + + )} + {quickMode && ( + <> + + + + + + )}