diff --git a/README.md b/README.md index cb41db0..e273007 100644 --- a/README.md +++ b/README.md @@ -86,3 +86,76 @@ Invoke-RestMethod http://localhost:5173/api/openapi.json 本次需要新增数据库迁移并运行图库初始化,备份导出升级至 ZIP v7。操作步骤、31 个内置图标与验证边界见 [更新说明](docs/update-2026-10-02.md)。 2026-10-03 设置与交互更新:支持 1 小时至 30 天登录有效期、隐藏项目密码开关、自选总览卡片、计划编辑弹窗、右上角操作提示及账户卡片快速转账。详见 [更新说明](docs/update-settings-interaction-2026-10-03.md)。 + +## git仓库目录 +```shell +git remote add github https://github.com/chyuovo/WorthPath.git +git remote add wyh https://git.mashiroart.xyz/chyuovo/WorthPath.git + +# 将 github 设置为当前项目的默认推送仓库 +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 秒超时;格式、时间或汇率异常时保留之前的报价与估值;同日手动价格优先。 | + +上述接口由后端访问,浏览器业务请求使用本站 `/api`,第三方不会收到本应用登录 Cookie。Gold API 返回的参考价只用于持仓估值,不包含饰品工费、买卖价差或回收折价。外币价格使用最新可用公共日汇率,休市时两类报价时间可能不同;报价日期会显示在界面并随备份保存。自动估价为可选项,开启后新增估值历史,不改写历史记录。 + +### 图标获取接口与素材来源(仅维护脚本使用) + +- [GitHub Contents API:bank-icon-cn 图标目录](https://api.github.com/repos/cellier/bank-icon-cn/contents/png/72):`apps/api/scripts/download-icons.cjs` 查询公开目录,再下载 `raw.githubusercontent.com` 上的银行 PNG。公开访问无需令牌,受 GitHub 匿名访问限额约束。 +- [Simple Icons 支付宝 SVG](https://raw.githubusercontent.com/simple-icons/simple-icons/develop/icons/alipay.svg)、[微信官方图标](https://res.wx.qq.com/a/wx_fed/assets/res/NTI4MWU5.ico):静态素材地址,无需密钥。 +- `apps/api/scripts/expand-icons.py` 从下表品牌官网与官方 CDN 获取 favicon 或品牌图标;微众银行脚本还读取 [官网公开资源](https://tctp.tctpwebankcdn.net/owb-res/owb-res/assets/mainGenerator.811f2078.js)。京东金融和大象银行素材使用 Apple App Store 的 `is1-ssl.mzstatic.com` 图标 CDN。脚本的官网备用地址与尝试逻辑可在对应源文件查看。 +- 图标已随项目保存在本地并写入数据库;浏览器通过本站 `/api/icons/:id/image` 读取,正常使用不会向上述素材站点请求图片。维护脚本只请求公开素材,不发送财务数据。 + +以下为当前全部内置图标的来源;原始 URL、文件名和 SHA-256 同时保存在 [素材清单](apps/api/assets/icons/sources.json)。 + +| 图标 | 来源 | +| --- | --- | +| 中国工商银行 | [原始素材](https://raw.githubusercontent.com/cellier/bank-icon-cn/master/png/72/中国工商银行@3x.png) | +| 中国农业银行 | [原始素材](https://raw.githubusercontent.com/cellier/bank-icon-cn/master/png/72/中国农业银行@3x.png) | +| 中国建设银行 | [原始素材](https://raw.githubusercontent.com/cellier/bank-icon-cn/master/png/72/中国建设银行@3x.png) | +| 中国银行 | [原始素材](https://raw.githubusercontent.com/cellier/bank-icon-cn/master/png/72/中国银行@3x.png) | +| 交通银行 | [原始素材](https://raw.githubusercontent.com/cellier/bank-icon-cn/master/png/72/交通银行@3x.png) | +| 招商银行 | [原始素材](https://raw.githubusercontent.com/cellier/bank-icon-cn/master/png/72/招商银行@3x.png) | +| 中信银行 | [原始素材](https://raw.githubusercontent.com/cellier/bank-icon-cn/master/png/72/中信银行@3x.png) | +| 中国光大银行 | [原始素材](https://raw.githubusercontent.com/cellier/bank-icon-cn/master/png/72/中国光大银行@3x.png) | +| 中国民生银行 | [原始素材](https://raw.githubusercontent.com/cellier/bank-icon-cn/master/png/72/中国民生银行@3x.png) | +| 兴业银行 | [原始素材](https://raw.githubusercontent.com/cellier/bank-icon-cn/master/png/72/兴业银行@3x.png) | +| 平安银行 | [原始素材](https://raw.githubusercontent.com/cellier/bank-icon-cn/master/png/72/平安银行@3x.png) | +| 上海浦东发展银行 | [原始素材](https://raw.githubusercontent.com/cellier/bank-icon-cn/master/png/72/上海浦东发展银行@3x.png) | +| 广发银行 | [原始素材](https://raw.githubusercontent.com/cellier/bank-icon-cn/master/png/72/广发银行@3x.png) | +| 华夏银行 | [原始素材](https://raw.githubusercontent.com/cellier/bank-icon-cn/master/png/72/华夏银行@3x.png) | +| 北京银行 | [原始素材](https://raw.githubusercontent.com/cellier/bank-icon-cn/master/png/72/北京银行@3x.png) | +| 上海银行 | [原始素材](https://raw.githubusercontent.com/cellier/bank-icon-cn/master/png/72/上海银行@3x.png) | +| 浙商银行 | [原始素材](https://raw.githubusercontent.com/cellier/bank-icon-cn/master/png/72/浙商银行@3x.png) | +| 支付宝 | [原始素材](https://raw.githubusercontent.com/simple-icons/simple-icons/develop/icons/alipay.svg) | +| 微信 | [原始素材](https://res.wx.qq.com/a/wx_fed/assets/res/NTI4MWU5.ico) | +| 京东金融 | [原始素材](https://is1-ssl.mzstatic.com/image/thumb/Purple211/v4/7d/74/af/7d74af96-af89-ae14-cbf5-5bbd89859ab7/AppIcon-0-1x_U007epad-0-1-0-85-220-0.png/512x512bb.jpg) | +| 汇丰香港 | [原始素材](https://www.hsbc.com.hk/etc.clientlibs/dpws/clientlibs-public/clientlib-site/resources/favicons/apple-touch-icon.png) | +| 恒生银行 | [原始素材](https://www.hangseng.com/etc.clientlibs/hase/clientlibs/clientlib-header/resources/favicon.ico) | +| 渣打香港 | [原始素材](https://av.sc.com/hk/content/images/content/images/cropped-512x512-1-150x150.png) | +| 东亚银行 | [原始素材](https://www.hkbea.com/images/favicon.ico) | +| 星展香港 | [原始素材](https://www.dbs.com.hk/_next/public/favicon.ico?q=fa411ced-9fa6-4c3e-b208-d878769c84c9) | +| 花旗香港 | [原始素材](https://www.citibank.com.hk/views/images/favicon.ico) | +| Gate | [原始素材](https://www.gate.com/favicon.ico) | +| Bybit | [原始素材](https://www.bybit.com/favicon.ico) | +| Bitget | [原始素材](https://www.bitget.com/baseasset/favicon4.png) | +| 中银香港 | [原始素材](https://www.bochk.com/etc/designs/bochk_web/images/icon/boc-icon-32.ico) | +| 币安 Binance | [原始素材](https://bin.bnbstatic.com/static/images/common/favicon.ico) | +| ZA BANK 众安银行 | [原始素材](https://cdn.za.group/if/za-group-portal-web/assets/images/share-logo.png) | +| 微众银行 WeBank | [原始素材](https://tctp.tctpwebankcdn.net/owb-res/owb-res/assets/mainGenerator.811f2078.js) | +| 大象银行 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) | +| 抖音月付 | [原始素材](https://p26-infras.byteimg.com/static-assets/be18bb89d2ceeec6407aae38dc5e894c.png~tplv-49obo7mizy-png75.png) | +| 东方财富 | [原始素材](https://g1.dfcfw.com/g4/202307/20230731180250.png) | + +当前未接入银行账户 API、交易所交易 API、AI API、Apple 查询 API 或外部备份云服务。Git 远程仓库为代码托管地址;本应用业务运行不调用它们。 + +2026-10-03 补充更新:全部启用账户支持转账,可按全局独立资产设置和逐项目开关控制资产负债总额;贵金属支持黄金/白银参考价与自动估值历史。备份导出为 ZIP v8,旧格式仍可导入。详见 [更新说明](docs/update-inclusion-metals-2026-10-03.md)。 diff --git a/apps/api/package.json b/apps/api/package.json index ef29ab7..f8bba2a 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 ../web/test/i18n.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", "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: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:performance": "tsx scripts/performance.ts after", "icons:seed": "node scripts/seed-icons.cjs" }, diff --git a/apps/api/prisma/migrations/20261003000300_inclusion_metals/migration.sql b/apps/api/prisma/migrations/20261003000300_inclusion_metals/migration.sql new file mode 100644 index 0000000..32497d7 --- /dev/null +++ b/apps/api/prisma/migrations/20261003000300_inclusion_metals/migration.sql @@ -0,0 +1,20 @@ +ALTER TABLE User ADD COLUMN includeIndependentAssets BOOLEAN NOT NULL DEFAULT true COMMENT '独立资产是否参与资产总览和净资产轨迹'; +ALTER TABLE Position + ADD COLUMN included BOOLEAN NOT NULL DEFAULT true COMMENT '是否参与资产负债总额及净资产轨迹', + ADD COLUMN metalType VARCHAR(12) NULL COMMENT '贵金属品种:gold 黄金或 silver 白银', + ADD COLUMN metalGrams DECIMAL(24,8) NULL COMMENT '贵金属总重量,单位克', + ADD COLUMN metalPurity DECIMAL(12,8) NOT NULL DEFAULT 1 COMMENT '贵金属纯度比例,0 至 1', + ADD COLUMN autoValuation BOOLEAN NOT NULL DEFAULT false COMMENT '是否随每日价格更新自动记录估值'; +CREATE TABLE MetalPrice ( + id CHAR(36) NOT NULL COMMENT '唯一标识', + userId CHAR(36) NOT NULL COMMENT '所属用户标识,用于数据隔离', + metalType VARCHAR(12) NOT NULL COMMENT '品种:gold 黄金或 silver 白银', + currency CHAR(3) NOT NULL COMMENT '价格币种', + price DECIMAL(24,12) NOT NULL COMMENT '纯金属每克价格', + date DATE NOT NULL COMMENT '报价日期,UTC+8 自然日', + source VARCHAR(20) NOT NULL COMMENT '价格来源:manual 或 goldprice', + quotedAt DATETIME(3) NOT NULL COMMENT '报价对应时间,UTC', + PRIMARY KEY (id), + UNIQUE INDEX MetalPrice_userId_metalType_currency_date_key (userId, metalType, currency, date), + CONSTRAINT MetalPrice_userId_fkey FOREIGN KEY (userId) REFERENCES User(id) ON DELETE CASCADE ON UPDATE CASCADE +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci COMMENT '用户的贵金属每克参考价历史'; diff --git a/apps/api/prisma/migrations/20261003000400_metal_source_comment/migration.sql b/apps/api/prisma/migrations/20261003000400_metal_source_comment/migration.sql new file mode 100644 index 0000000..50ec304 --- /dev/null +++ b/apps/api/prisma/migrations/20261003000400_metal_source_comment/migration.sql @@ -0,0 +1 @@ +ALTER TABLE MetalPrice MODIFY COLUMN source VARCHAR(20) NOT NULL COMMENT '价格来源:manual 或 goldapi'; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index d4b7949..f9a525a 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -23,6 +23,8 @@ model User { requireHiddenPassword Boolean @default(true) /// 总览显示的卡片键列表,空值表示默认全部显示 overviewCards Json? + /// 独立资产是否参与资产总览和净资产轨迹 + includeIndependentAssets Boolean @default(true) /// 账户分组显示顺序;空值表示沿用默认顺序 accountGroupOrder Json? /// 是否显示备注 @@ -35,6 +37,7 @@ model User { updatedAt DateTime @updatedAt positions Position[] rates ExchangeRate[] + metalPrices MetalPrice[] sessions Session[] icons Icon[] transfers Transfer[] @@ -86,6 +89,16 @@ model Position { archived Boolean @default(false) /// 是否隐藏;未解锁时不参与可见范围统计 hidden Boolean @default(false) + /// 是否参与资产负债总额及净资产轨迹 + included Boolean @default(true) + /// 贵金属品种:gold 黄金或 silver 白银 + metalType String? @db.VarChar(12) + /// 贵金属总重量,单位克 + metalGrams Decimal? @db.Decimal(24,8) + /// 贵金属纯度比例,0 至 1 + metalPurity Decimal @default(1) @db.Decimal(12,8) + /// 是否随每日价格更新自动记录估值 + autoValuation Boolean @default(false) /// 记录创建时间,UTC createdAt DateTime @default(now()) /// 记录更新时间,UTC @@ -257,3 +270,25 @@ model Schedule { @@index([userId,enabled,nextAt]) @@unique([userId,importedFromId]) } + +/// 用户的贵金属每克参考价历史 +model MetalPrice { + /// 唯一标识 + id String @id @default(uuid()) @db.Char(36) + /// 所属用户标识,用于数据隔离 + userId String @db.Char(36) + user User @relation(fields:[userId],references:[id],onDelete:Cascade) + /// 品种:gold 黄金或 silver 白银 + metalType String @db.VarChar(12) + /// 价格币种 + currency String @db.Char(3) + /// 纯金属每克价格 + price Decimal @db.Decimal(24,12) + /// 报价日期,UTC+8 自然日 + date DateTime @db.Date + /// 价格来源:manual 或 goldapi + source String @db.VarChar(20) + /// 报价对应时间,UTC + quotedAt DateTime @db.DateTime(3) + @@unique([userId, metalType, currency, date]) +} diff --git a/apps/api/scripts/download-icons.cjs b/apps/api/scripts/download-icons.cjs index e36d051..cca7fee 100644 --- a/apps/api/scripts/download-icons.cjs +++ b/apps/api/scripts/download-icons.cjs @@ -35,7 +35,7 @@ async function main() { sources.push( { name: '支付宝', source: 'https://raw.githubusercontent.com/simple-icons/simple-icons/develop/icons/alipay.svg' }, { name: '微信', source: 'https://res.wx.qq.com/a/wx_fed/assets/res/NTI4MWU5.ico' }, - { name: '京东金融', source: 'https://jr.jd.com/logo.png' }, + { name: '京东金融', source: 'https://is1-ssl.mzstatic.com/image/thumb/Purple211/v4/7d/74/af/7d74af96-af89-ae14-cbf5-5bbd89859ab7/AppIcon-0-1x_U007epad-0-1-0-85-220-0.png/512x512bb.jpg', preserveWhite: true }, ); const manifest = []; for (const item of sources) { diff --git a/apps/api/src/auth.ts b/apps/api/src/auth.ts index ee454a1..165c70a 100644 --- a/apps/api/src/auth.ts +++ b/apps/api/src/auth.ts @@ -175,6 +175,7 @@ export class AuthController { sessionHours: true, requireHiddenPassword: true, overviewCards: true, + includeIndependentAssets: true, }, }); const session = await this.db.session.findUniqueOrThrow({ where: { id: req.sessionId } }); @@ -182,6 +183,7 @@ export class AuthController { ...user, accountGroupOrder: user.accountGroupOrder || [], overviewCards: user.overviewCards ?? [...defaultOverviewCards], + includeIndependentAssets: user.includeIndependentAssets, sessionExpiresAt: session.expiresAt, hiddenMenus: user.hiddenMenus.split(',').filter(Boolean), revealed: req.revealed, diff --git a/apps/api/src/backup.ts b/apps/api/src/backup.ts index 9aca1d2..2bd26bf 100644 --- a/apps/api/src/backup.ts +++ b/apps/api/src/backup.ts @@ -1,3 +1,4 @@ +import { metalConfig, metalPriceInput } from './metals'; import { scheduleInput } from './schedules'; import { movementDeltas } from './movement'; import { pairedReasons } from './validation'; @@ -30,6 +31,7 @@ import { Database } from './database'; import { UserRequest } from './auth'; import { positionInput, + amount, positionMeta, currency, revisionInput, @@ -53,6 +55,10 @@ const timestamp = z.iso ); const record = positionMeta .extend({ + metalType: z.enum(['gold', 'silver']).nullable().optional(), + metalGrams: amount.nullable().optional(), + metalPurity: metalConfig.shape.metalPurity.optional(), + autoValuation: z.boolean().optional(), kind: z.enum(['account', 'asset', 'debt']), side: z.enum(['asset', 'liability']), currency, @@ -85,6 +91,7 @@ const backupSchema = z sessionHours: sessionHours.optional(), requireHiddenPassword: z.boolean().optional(), overviewCards: overviewCards.optional(), + includeIndependentAssets: z.boolean().optional(), showNotes: z.boolean().optional(), idleMinutes: z.number().int().min(0).max(1440), }) @@ -126,6 +133,14 @@ const backupSchema = z }), ) .optional(), + metalPrices: z + .array( + metalPriceInput.extend({ + source: z.enum(['manual', 'goldapi']), + quotedAt: timestamp, + }), + ) + .optional(), positions: z.array(record), links: z.array(z.object({ sourceId: z.string().uuid(), targetId: z.string().uuid() }).strict()), rates: z.array(rateInput.safeExtend({ source: z.enum(['manual', 'frankfurter']) })), @@ -138,6 +153,8 @@ export function validateBackup(raw: unknown) { if (ids.size !== b.positions.length) throw new BadRequestException('重复项目 ID'); const origins = b.positions.map((p) => p.importedFromId || p.id); if (new Set(origins).size !== origins.length) throw new BadRequestException('备份内包含重复项目'); + const quoteKeys = (b.metalPrices || []).map((q) => q.metalType + q.currency + q.date); + if (new Set(quoteKeys).size !== quoteKeys.length) throw new BadRequestException('重复贵金属报价'); const iconIds = new Set((b.icons || []).map((i) => i.id)); if ( iconIds.size !== (b.icons || []).length || @@ -146,6 +163,16 @@ export function validateBackup(raw: unknown) { throw new BadRequestException('图标关联无效'); const revisionIds = new Set(); for (const p of b.positions) { + if (p.metalType || p.metalGrams || p.autoValuation) { + if (p.kind !== 'asset' || p.category !== 'gold') + throw new BadRequestException('贵金属估价关联无效'); + metalConfig.parse({ + metalType: p.metalType, + metalGrams: p.metalGrams, + metalPurity: p.metalPurity || '1', + autoValuation: p.autoValuation || false, + }); + } positionInput.parse({ name: p.name, category: p.category, @@ -156,6 +183,7 @@ export function validateBackup(raw: unknown) { notes: p.notes, archived: p.archived, hidden: p.hidden, + included: p.included, amount: '0', date: p.revisions[0]?.date || '1900-01-01', }); @@ -179,12 +207,11 @@ export function validateBackup(raw: unknown) { planIds.has(plan.importedFromId || plan.id) || !source || source.kind !== 'account' || - source.side !== 'asset' || + (plan.operation === 'expense' && source.side !== 'asset') || (plan.operation === 'expense' ? !!plan.targetId : !target || target.kind !== 'account' || - target.side !== 'asset' || (source.currency === target.currency && !new Decimal(plan.amount).eq(plan.received))) ) throw new BadRequestException('计划账户关联无效'); @@ -201,9 +228,9 @@ export function validateBackup(raw: unknown) { !source || !target || source.kind !== 'account' || - source.side !== 'asset' || + (t.operation !== 'transfer' && source.side !== 'asset') || (t.operation === 'transfer' - ? target.kind !== 'account' || target.side !== 'asset' + ? target.kind !== 'account' : target.kind !== 'debt' || target.side !== (['borrow', 'repay'].includes(t.operation) ? 'liability' : 'asset')) || source.currency !== t.sourceCurrency || @@ -213,7 +240,14 @@ export function validateBackup(raw: unknown) { transferIds.add(origin); if (t.sourceCurrency === t.targetCurrency && !new Decimal(t.amount).eq(t.received)) throw new BadRequestException('同币种转账金额不一致'); - const deltas = movementDeltas(t.operation, t.amount, t.received, t.fee); + const deltas = movementDeltas( + t.operation, + t.amount, + t.received, + t.fee, + source.side, + target.side, + ); for (const [p, revId, reason, delta] of [ [source, t.sourceRevisionId, deltas.sourceReason, deltas.source], [target, t.targetRevisionId, deltas.targetReason, deltas.target], @@ -318,6 +352,7 @@ export class BackupController implements OnModuleDestroy, OnModuleInit { sessionHours: true, requireHiddenPassword: true, overviewCards: true, + includeIndependentAssets: true, }, }), client.position.findMany({ @@ -342,6 +377,11 @@ export class BackupController implements OnModuleDestroy, OnModuleInit { notes: p.notes, archived: p.archived, hidden: p.hidden, + included: p.included, + metalType: p.metalType, + metalGrams: p.metalGrams?.toString() ?? null, + metalPurity: p.metalPurity.toString(), + autoValuation: p.autoValuation, createdAt: p.createdAt.toISOString(), updatedAt: p.updatedAt.toISOString(), revisions: p.revisions.map((r) => ({ @@ -381,6 +421,7 @@ export class BackupController implements OnModuleDestroy, OnModuleInit { sessionHours: user.sessionHours, requireHiddenPassword: user.requireHiddenPassword, overviewCards: overviewCards.parse(user.overviewCards ?? [...defaultOverviewCards]), + includeIndependentAssets: user.includeIndependentAssets, }, currencies: [ ...new Set([ @@ -410,6 +451,14 @@ export class BackupController implements OnModuleDestroy, OnModuleInit { received: v.received.toString(), nextAt: businessTime(v.nextAt), })), + metalPrices: (await client.metalPrice.findMany({ where: { userId } })).map( + ({ id, userId, date, quotedAt, price, ...v }) => ({ + ...v, + date: day(date), + quotedAt: quotedAt.toISOString(), + price: price.toString(), + }), + ), positions, links: ps.flatMap((p) => p.outgoing.map((l) => ({ sourceId: l.sourceId, targetId: l.targetId })), @@ -508,6 +557,9 @@ export class BackupController implements OnModuleDestroy, OnModuleInit { data.rates.sort((a, b) => (a.currency + a.baseCurrency + a.date).localeCompare(b.currency + b.baseCurrency + b.date), ); + data.metalPrices?.sort((a, b) => + (a.metalType + a.currency + a.date).localeCompare(b.metalType + b.currency + b.date), + ); data.currencies.sort(); data.transfers?.sort((a, b) => a.id.localeCompare(b.id)); data.schedules?.sort((a, b) => a.id.localeCompare(b.id)); @@ -524,6 +576,7 @@ export class BackupController implements OnModuleDestroy, OnModuleInit { .parse(raw); return this.db.$transaction( async (tx) => { + await tx.$queryRaw(Prisma.sql`SELECT id FROM User WHERE id=${r.userId} FOR UPDATE`); const s = await tx.session.findUniqueOrThrow({ where: { id: r.sessionId } }); if (!s.backupDigest || !s.backupExpiresAt || +s.backupExpiresAt <= Date.now()) throw new BadRequestException('请先下载当前账号备份,再进入下一步(10 分钟内有效)'); @@ -532,6 +585,7 @@ export class BackupController implements OnModuleDestroy, OnModuleInit { await tx.schedule.deleteMany({ where: { userId: r.userId } }); await tx.position.deleteMany({ where: { userId: r.userId } }); await tx.icon.deleteMany({ where: { ownerId: r.userId, shared: false } }); + await tx.metalPrice.deleteMany({ where: { userId: r.userId } }); await tx.exchangeRate.deleteMany({ where: { userId: r.userId } }); await tx.session.updateMany({ where: { userId: r.userId }, @@ -636,6 +690,11 @@ export class BackupController implements OnModuleDestroy, OnModuleInit { notes: p.notes, archived: p.archived, hidden: p.hidden, + included: p.included, + metalType: p.metalType, + metalGrams: p.metalGrams, + metalPurity: p.metalPurity, + autoValuation: p.autoValuation, createdAt: new Date(p.createdAt), updatedAt: new Date(p.updatedAt), revisions: { @@ -670,6 +729,23 @@ export class BackupController implements OnModuleDestroy, OnModuleInit { }); originals.forEach((v, i) => revisionMapping.set(v.id, restored[i].id)); } + for (const q of b.metalPrices || []) { + const key = { + userId: r.userId, + metalType: q.metalType, + currency: q.currency, + date: new Date(q.date), + }; + const existingQuote = await tx.metalPrice.findUnique({ + where: { userId_metalType_currency_date: key }, + }); + if (existingQuote && !new Decimal(existingQuote.price.toString()).eq(q.price)) + throw new ConflictException('已有同日贵金属价格与备份冲突'); + if (!existingQuote) + await tx.metalPrice.create({ + data: { ...key, price: q.price, source: q.source, quotedAt: new Date(q.quotedAt) }, + }); + } for (const t of b.transfers || []) await tx.transfer.create({ data: { @@ -732,6 +808,7 @@ export class BackupController implements OnModuleDestroy, OnModuleInit { sessionHours: b.preferences?.sessionHours, requireHiddenPassword: b.preferences?.requireHiddenPassword, overviewCards: b.preferences?.overviewCards, + includeIndependentAssets: b.preferences?.includeIndependentAssets, }, }); if (!ps.length && !rs.length && b.preferences?.requireHiddenPassword !== undefined) diff --git a/apps/api/src/calculation.ts b/apps/api/src/calculation.ts index 5df990a..01f7de4 100644 --- a/apps/api/src/calculation.ts +++ b/apps/api/src/calculation.ts @@ -6,6 +6,7 @@ export type Holding = { kind: string; side: string; currency: string; + included?: boolean; revisions: { id: string; sequence?: number; @@ -71,15 +72,16 @@ export function totals(positions: Holding[], rates: Rate[], base: string, date: .sort((a, b) => compareRevisions(b, a))[0], amount = new Decimal(rev?.amount.toString() || '0'), fx = rateAt(rates, p.currency, base, date); - if (!fx && !amount.isZero()) missing.add(p.currency); + if (p.included !== false && !fx && !amount.isZero()) missing.add(p.currency); const converted = fx ? amount.mul(fx.value) : null; - if (converted) { + if (converted && p.included !== false) { if (p.side === 'asset') assets = assets.plus(converted); else if (p.kind === 'account' && converted.isNegative()) assets = assets.minus(converted); else liabilities = liabilities.plus(converted); } return { id: p.id, + included: p.included !== false, name: p.name, kind: p.kind, side: p.side, @@ -117,7 +119,7 @@ export function overview(positions: Holding[], rates: Rate[], base: string, date fxChange: string | null = null; if (previous?.complete && value.complete) { let revalued = new Decimal(0); - for (const item of previous.items) { + for (const item of previous.items.filter((p) => p.included)) { const fx = rateAt(rates, item.currency, base, d); if (!fx && !new Decimal(item.amount).isZero()) { revalued = new Decimal(NaN); @@ -155,6 +157,7 @@ export function trend( to: string, grain: 'day' | 'week' | 'month' = 'day', ) { + positions = positions.filter((p) => p.included !== false); const dates = positions .flatMap((p) => p.revisions.map((r) => businessDay(r.effectiveDate))) .sort(); @@ -197,7 +200,7 @@ export function trend( value = advance(d); if (previous.complete && value.complete) { let revalued = new Decimal(0); - for (const item of previous.items) { + for (const item of previous.items.filter((p) => p.included)) { const rate = item.currency === base ? new Decimal(1) : fx.get(item.currency)?.rate; const v = new Decimal(item.amount).mul(rate?.toString() || '0'); revalued = revalued.plus(item.side === 'asset' ? v : v.neg()); diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index 3b634dc..25b6750 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -13,6 +13,7 @@ import { TransfersController } from './transfers'; import { PortfolioController } from './portfolio'; import { BackupController } from './backup'; import { IconsController, IconsService } from './icons'; +import { MetalsService, MetalsController } from './metals'; import { Database } from './database'; import { RatesService, SettingsController } from './rates'; import { ZodError } from 'zod'; @@ -50,6 +51,7 @@ class SafeErrors implements ExceptionFilter { Database, AuthService, RatesService, + MetalsService, IconsService, { provide: APP_GUARD, useClass: AuthGuard }, ], @@ -60,6 +62,7 @@ class SafeErrors implements ExceptionFilter { IconsController, AuthController, PortfolioController, + MetalsController, SettingsController, BackupController, ], diff --git a/apps/api/src/metals.ts b/apps/api/src/metals.ts new file mode 100644 index 0000000..a6ed633 --- /dev/null +++ b/apps/api/src/metals.ts @@ -0,0 +1,354 @@ +import { + Injectable, + Controller, + Get, + Post, + Put, + Req, + Body, + Param, + OnModuleInit, + OnModuleDestroy, + BadGatewayException, + BadRequestException, + NotFoundException, +} from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import Decimal from 'decimal.js'; +import { z } from 'zod'; +import { Database } from './database'; +import { UserRequest } from './auth'; +import { amount, currency, date, rateValue, today, toBusinessDate } from './validation'; +import { businessTime, businessDay } from './calculation'; +export const metalType = z.enum(['gold', 'silver']); +export const metalConfig = z + .object({ + metalType, + metalGrams: amount.refine((v) => new Decimal(v).gt(0), '重量必须大于零'), + metalPurity: z + .string() + .regex(/^(0|1)(\.\d{1,8})?$/) + .refine((v) => new Decimal(v).gt(0) && new Decimal(v).lte(1), '纯度应大于 0 且不超过 1'), + autoValuation: z.boolean(), + }) + .strict(); +export const metalPriceInput = z.object({ metalType, currency, price: rateValue, date }).strict(); +export function metalValue(grams: string, purity: string, price: string) { + const value = new Decimal(grams).mul(purity).mul(price); + if (value.gte('10000000000000000')) throw new BadRequestException('估价超出支持范围'); + return value.toFixed(8); +} +// Fixed public currencies; holdings, identifiers and quantities never leave the server. +const PUBLIC_PRICES = [ + 'https://api.gold-api.com/price/XAU', + 'https://api.gold-api.com/price/XAG', + 'https://api.frankfurter.dev/v2/rates?base=USD"es=CNY,HKD,EUR,GBP,JPY,AUD,CAD,CHF,SGD', +]; +@Injectable() +export class MetalsService implements OnModuleInit, OnModuleDestroy { + private timer?: NodeJS.Timeout; + private attempts = new Map(); + private running = new Set(); + private outcomes = new Map(); + constructor(private db: Database) {} + invalidate(userId: string) { + this.attempts.delete(userId); + } + status(userId: string) { + return ( + this.outcomes.get(userId) || { state: 'idle', message: '尚未尝试更新', attemptedAt: null } + ); + } + onModuleInit() { + this.timer = setInterval(() => { + void this.tick(); + }, 3600000); + this.timer.unref(); + } + onModuleDestroy() { + if (this.timer) clearInterval(this.timer); + } + private async tick() { + try { + for (const u of await this.db.user.findMany({ select: { id: true } })) await this.daily(u.id); + } catch { + /* Preserve previous quotes. */ + } + } + async daily(userId: string) { + if (this.attempts.get(userId) === today() || this.running.has(userId)) return; + this.attempts.set(userId, today()); + try { + await this.refresh(userId); + } catch { + /* Status explains failure. */ + } + } + async apply( + tx: Prisma.TransactionClient, + userId: string, + id: string, + revealed: boolean, + force = false, + ) { + await tx.$queryRaw( + Prisma.sql`SELECT id FROM Position WHERE id=${id} AND userId=${userId} FOR UPDATE`, + ); + const p = await tx.position.findFirst({ + where: { + id, + userId, + kind: 'asset', + category: 'gold', + archived: false, + ...(revealed ? {} : { hidden: false }), + }, + include: { + revisions: { orderBy: [{ effectiveDate: 'desc' }, { sequence: 'desc' }], take: 1 }, + }, + }); + if (!p) throw new NotFoundException('贵金属资产不存在或已归档'); + if (!p.metalType || !p.metalGrams) + throw new BadRequestException('请先设置贵金属品种、重量和纯度'); + const quote = await tx.metalPrice.findFirst({ + where: { + userId, + metalType: p.metalType, + currency: p.currency, + date: { lte: new Date(today()) }, + }, + orderBy: { date: 'desc' }, + }); + if (!quote) throw new BadRequestException('缺少该币种的贵金属价格,请刷新或手动录价'); + const value = metalValue( + p.metalGrams.toString(), + p.metalPurity.toString(), + quote.price.toString(), + ); + const last = p.revisions[0]; + // Old market quotes cannot overwrite a newer observation automatically. + if (last && new Decimal(last.amount.toString()).eq(value)) + return { amount: value, changed: false }; + if (!force && last && businessDay(last.effectiveDate) > businessDay(quote.date)) + return { amount: value, changed: false }; + const now = toBusinessDate(businessTime(new Date())); + if (last && last.effectiveDate > now) throw new BadRequestException('估价时间不能早于最新余额'); + await tx.revision.create({ + data: { + positionId: id, + amount: value, + effectiveDate: now, + reason: 'valuation', + notes: `贵金属估价:${p.metalGrams} 克 × 纯度 ${p.metalPurity} × ${quote.price} ${p.currency}/克;报价 ${quote.date.toISOString().slice(0, 10)}(${quote.source})`, + }, + }); + return { amount: value, changed: true }; + } + async refresh(userId: string) { + if (this.running.has(userId)) return { message: '贵金属价格更新正在进行' }; + this.running.add(userId); + this.outcomes.set(userId, { + state: 'updating', + message: '正在更新贵金属参考价', + attemptedAt: new Date().toISOString(), + }); + try { + const configured = await this.db.position.findMany({ + where: { + userId, + kind: 'asset', + category: 'gold', + archived: false, + metalType: { not: null }, + }, + select: { id: true, currency: true, metalType: true }, + }); + if (!configured.length) { + const message = '当前没有已设置重量的贵金属资产'; + this.outcomes.set(userId, { state: 'ok', message, attemptedAt: new Date().toISOString() }); + return { message }; + } + const raw = await Promise.all( + PUBLIC_PRICES.map(async (url) => { + const response = await fetch(url, { signal: AbortSignal.timeout(12000) }); + if (!response.ok) throw Error(); + return (await response.text()).replace( + /("(?:price|rate)"\s*:\s*)(\d+(?:\.\d+)?)/g, + '$1"$2"', + ); + }), + ); + const schema = z.object({ + currency: z.literal('USD'), + symbol: z.enum(['XAU', 'XAG']), + price: rateValue, + updatedAt: z.iso.datetime(), + }); + const gold = schema.parse(JSON.parse(raw[0])), + silver = schema.parse(JSON.parse(raw[1])); + if (gold.symbol !== 'XAU' || silver.symbol !== 'XAG') throw Error(); + for (const q of [gold, silver]) + if ( + +new Date(q.updatedAt) > Date.now() + 60000 || + +new Date(q.updatedAt) < Date.now() - 14 * 86400000 + ) + throw Error(); + const fx = z + .array(z.object({ base: z.literal('USD'), quote: currency, date, rate: rateValue })) + .parse(JSON.parse(raw[2])); + await this.db.serial(async (tx) => { + // Lock the owner to serialize with clear/import. Re-read positions after the network request. + await tx.$queryRaw(Prisma.sql`SELECT id FROM User WHERE id=${userId} FOR UPDATE`); + const ps = await tx.position.findMany({ + where: { + userId, + kind: 'asset', + category: 'gold', + archived: false, + metalType: { not: null }, + }, + }); + for (const p of ps) { + const row = p.metalType === 'gold' ? gold : silver; + const quoteDay = businessDay(new Date(row.updatedAt)); + const conversion = p.currency === 'USD' ? null : fx.find((v) => v.quote === p.currency); + if (p.currency !== 'USD' && !conversion) throw Error(); + if ( + conversion && + (conversion.date > today() || +new Date(conversion.date) < Date.now() - 14 * 86400000) + ) + throw Error(); + const price = rateValue.parse( + new Decimal(row.price) + .div('31.1034768') + .mul(conversion?.rate || '1') + .toFixed(12), + ); + const key = { + userId, + metalType: p.metalType!, + currency: p.currency, + date: new Date(quoteDay), + }; + const existing = await tx.metalPrice.findUnique({ + where: { userId_metalType_currency_date: key }, + }); + if (existing?.source !== 'manual') + await tx.metalPrice.upsert({ + where: { userId_metalType_currency_date: key }, + create: { ...key, price, source: 'goldapi', quotedAt: new Date(row.updatedAt) }, + update: { price, source: 'goldapi', quotedAt: new Date(row.updatedAt) }, + }); + if (p.autoValuation) await this.apply(tx, userId, p.id, true); + } + }); + const message = '贵金属参考价已更新;同日手动价格已保留,已开启的自动估价已记入历史'; + this.attempts.set(userId, today()); + this.outcomes.set(userId, { state: 'ok', message, attemptedAt: new Date().toISOString() }); + return { message }; + } catch { + this.outcomes.set(userId, { + state: 'error', + message: '贵金属价格更新失败,已有价格与估值已保留,可手动录价', + attemptedAt: new Date().toISOString(), + }); + throw new BadGatewayException('贵金属价格更新失败,已有价格与估值已保留,可手动录价'); + } finally { + this.running.delete(userId); + } + } +} +@Controller('api/metals') +export class MetalsController { + constructor( + private db: Database, + private metals: MetalsService, + ) {} + @Get() async list(@Req() r: UserRequest) { + void this.metals.daily(r.userId); + return { + status: this.metals.status(r.userId), + prices: await this.db.metalPrice.findMany({ + where: { userId: r.userId }, + orderBy: { date: 'desc' }, + take: 100, + }), + }; + } + @Post('refresh') refresh(@Req() r: UserRequest) { + return this.metals.refresh(r.userId); + } + @Post('prices') async manual(@Req() r: UserRequest, @Body() body: unknown) { + const v = metalPriceInput.parse(body); + const key = { + userId: r.userId, + metalType: v.metalType, + currency: v.currency, + date: new Date(v.date), + }; + return this.db.serial(async (tx) => { + await tx.$queryRaw(Prisma.sql`SELECT id FROM User WHERE id=${r.userId} FOR UPDATE`); + await tx.metalPrice.upsert({ + where: { userId_metalType_currency_date: key }, + create: { ...key, price: v.price, source: 'manual', quotedAt: new Date() }, + update: { price: v.price, source: 'manual', quotedAt: new Date() }, + }); + const ps = await tx.position.findMany({ + where: { + userId: r.userId, + kind: 'asset', + category: 'gold', + metalType: v.metalType, + currency: v.currency, + archived: false, + autoValuation: true, + ...(r.revealed ? {} : { hidden: false }), + }, + }); + for (const p of ps) await this.metals.apply(tx, r.userId, p.id, r.revealed); + return { message: '贵金属价格已保存' }; + }); + } + @Put(':id') async configure( + @Req() r: UserRequest, + @Param('id') id: string, + @Body() body: unknown, + ) { + const v = metalConfig.parse(body); + const result = await 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, + kind: 'asset', + category: 'gold', + archived: false, + ...(r.revealed ? {} : { hidden: false }), + }, + }); + if (!p) throw new NotFoundException('贵金属资产不存在或已归档'); + await tx.position.update({ where: { id }, data: v }); + if ( + v.autoValuation && + (await tx.metalPrice.count({ + where: { + userId: r.userId, + metalType: v.metalType, + currency: p.currency, + date: { lte: new Date(today()) }, + }, + })) + ) + await this.metals.apply(tx, r.userId, id, r.revealed, true); + return { message: '贵金属估价设置已保存' }; + }); + this.metals.invalidate(r.userId); + return result; + } + @Post(':id/value') value(@Req() r: UserRequest, @Param('id') id: string) { + return this.db.serial((tx) => this.metals.apply(tx, r.userId, id, r.revealed, true)); + } +} diff --git a/apps/api/src/movement.ts b/apps/api/src/movement.ts index 148cf99..0621473 100644 --- a/apps/api/src/movement.ts +++ b/apps/api/src/movement.ts @@ -1,15 +1,22 @@ import Decimal from 'decimal.js'; import { BadRequestException } from '@nestjs/common'; export type Movement = 'transfer' | 'borrow' | 'lend' | 'collect' | 'repay'; -export function movementDeltas(operation: Movement, amount: string, received: string, fee: string) { +export function movementDeltas( + operation: Movement, + amount: string, + received: string, + fee: string, + sourceSide = 'asset', + targetSide = 'asset', +) { const incoming = operation === 'borrow' || operation === 'collect'; const principal = new Decimal(amount), charge = new Decimal(fee), debt = new Decimal(received); if (operation === 'transfer') return { - source: principal.plus(charge).neg(), - target: debt, + source: principal.plus(charge).mul(sourceSide === 'liability' ? 1 : -1), + target: debt.mul(targetSide === 'liability' ? -1 : 1), sourceReason: 'transfer_out', targetReason: 'transfer_in', }; diff --git a/apps/api/src/openapi.ts b/apps/api/src/openapi.ts index 5d537ee..3ffabd6 100644 --- a/apps/api/src/openapi.ts +++ b/apps/api/src/openapi.ts @@ -1,5 +1,6 @@ import { INestApplication } from '@nestjs/common'; import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; +import { metalConfig, metalPriceInput } from './metals'; import { scheduleInput } from './schedules'; import { z } from 'zod'; import { @@ -45,6 +46,8 @@ export function setupOpenApi(app: INestApplication) { 'PUT /api/schedules/{id}': scheduleInput, 'PATCH /api/schedules/{id}': z.object({ enabled: z.boolean() }).strict(), 'PATCH /api/settings': settingsInput, + 'PUT /api/metals/{id}': metalConfig, + 'POST /api/metals/prices': metalPriceInput, 'POST /api/backup/clear': z.object({ confirmation: z.literal('确定清空') }), 'POST /api/backup/import-file': z.object({ token: z.string().uuid(), diff --git a/apps/api/src/portfolio.ts b/apps/api/src/portfolio.ts index 84ab6f8..b507228 100644 --- a/apps/api/src/portfolio.ts +++ b/apps/api/src/portfolio.ts @@ -28,6 +28,7 @@ import { currentPositions, currentRates, historyPage, trendData, trendInput } fr import { z } from 'zod'; import { Prisma } from '@prisma/client'; import { IconsService } from './icons'; +import { MetalsService } from './metals'; import { RatesService } from './rates'; import { captureReplay } from './replay'; import { changeMovement } from './transfers'; @@ -37,6 +38,7 @@ export class PortfolioController { private db: Database, private fx: RatesService, private icons: IconsService, + private metals: MetalsService, ) {} private async own(userId: string, id: string, revealed = false) { const [p] = await this.db.$transaction((tx) => currentPositions(tx, userId, revealed, id)); @@ -49,7 +51,7 @@ export class PortfolioController { const rows = await currentPositions(tx, r.userId, r.revealed, undefined, kind); const user = await tx.user.findUniqueOrThrow({ where: { id: r.userId }, - select: { baseCurrency: true }, + select: { baseCurrency: true, includeIndependentAssets: true }, }); const rates = await currentRates( tx, @@ -140,7 +142,15 @@ export class PortfolioController { ) throw new BadRequestException('信用卡和贷款账户必须为负债'); await this.icons.requireVisible(r.userId, v.iconId); - await this.db.position.update({ where: { id: p.id }, data: v }); + await this.db.position.update({ + where: { id: p.id }, + data: { + ...v, + ...(p.kind === 'asset' && v.category !== 'gold' + ? { metalType: null, metalGrams: null, metalPurity: '1', autoValuation: false } + : {}), + }, + }); return { ok: true }; } @Post('positions/:id/revisions') async revise( @@ -283,10 +293,11 @@ export class PortfolioController { } @Get('overview') async overview(@Req() r: UserRequest) { void this.fx.daily(r.userId); + void this.metals.daily(r.userId); return this.db.$transaction(async (tx) => { const user = await tx.user.findUniqueOrThrow({ where: { id: r.userId }, - select: { baseCurrency: true }, + select: { baseCurrency: true, includeIndependentAssets: true }, }); const positions = await currentPositions(tx, r.userId, r.revealed); const rates = await currentRates( @@ -298,7 +309,15 @@ export class PortfolioController { ); return { baseCurrency: user.baseCurrency, - ...totals(positions, rates, user.baseCurrency, today()), + ...totals( + positions.map((p) => ({ + ...p, + included: p.included && (p.kind !== 'asset' || user.includeIndependentAssets), + })), + rates, + user.baseCurrency, + today(), + ), revealed: r.revealed, }; }); diff --git a/apps/api/src/queries.ts b/apps/api/src/queries.ts index f42ab8b..0d06f80 100644 --- a/apps/api/src/queries.ts +++ b/apps/api/src/queries.ts @@ -274,7 +274,7 @@ export async function trendData( const currencies = [...new Set(positions.map((p) => p.currency))]; const user = await db.user.findUniqueOrThrow({ where: { id: userId }, - select: { baseCurrency: true }, + select: { baseCurrency: true, includeIndependentAssets: true }, }); const previousDay = new Date(+new Date(from) - 86400000).toISOString().slice(0, 10); const rates = await db.exchangeRate.findMany({ @@ -287,7 +287,11 @@ export async function trendData( }); const priorRates = await currentRates(db, userId, currencies, user.baseCurrency, previousDay); return { - positions: positions.map((p) => ({ ...p, revisions: byId.get(p.id) || [] })), + positions: positions.map((p) => ({ + ...p, + included: p.included && (p.kind !== 'asset' || user.includeIndependentAssets), + revisions: byId.get(p.id) || [], + })), rates: [...priorRates, ...rates], base: user.baseCurrency, }; diff --git a/apps/api/src/rates.ts b/apps/api/src/rates.ts index f601d91..1022474 100644 --- a/apps/api/src/rates.ts +++ b/apps/api/src/rates.ts @@ -190,6 +190,7 @@ export class SettingsController { sessionHours: true, requireHiddenPassword: true, overviewCards: true, + includeIndependentAssets: true, }, }); return { diff --git a/apps/api/src/replay.ts b/apps/api/src/replay.ts index 90c5cfb..584f82e 100644 --- a/apps/api/src/replay.ts +++ b/apps/api/src/replay.ts @@ -25,6 +25,7 @@ export async function captureReplay(tx: Prisma.TransactionClient, userId: string } return async () => { const transfers = await tx.transfer.findMany({ + include: { source: true, target: true }, where: { userId, OR: [{ sourceId: { in: ids } }, { targetId: { in: ids } }] }, }); for (const t of transfers) { @@ -33,6 +34,8 @@ export async function captureReplay(tx: Prisma.TransactionClient, userId: string t.amount.toString(), t.received.toString(), t.fee.toString(), + t.source.side, + t.target.side, ); deltas.set(t.sourceRevisionId, d.source); deltas.set(t.targetRevisionId, d.target); diff --git a/apps/api/src/schedules.ts b/apps/api/src/schedules.ts index 76e1c46..69697c1 100644 --- a/apps/api/src/schedules.ts +++ b/apps/api/src/schedules.ts @@ -65,7 +65,6 @@ export class SchedulesController { where: { userId: r.userId, kind: 'account', - side: 'asset', ...(r.revealed ? {} : { hidden: false }), }, select: { id: true }, @@ -108,12 +107,13 @@ export class SchedulesController { userId: r.userId, id: { in: ids }, kind: 'account', - side: 'asset', + ...(v.operation === 'expense' ? { side: 'asset' } : {}), archived: false, ...(r.revealed ? {} : { hidden: false }), }, }); - if (accounts.length !== ids.length) throw new BadRequestException('请选择启用的资产账户'); + if (accounts.length !== ids.length) + throw new BadRequestException('请选择启用的账户(支出计划使用资产账户)'); if (v.operation === 'expense' && v.targetId) throw new BadRequestException('支出计划无需转入账户'); if ( diff --git a/apps/api/src/transfers.ts b/apps/api/src/transfers.ts index 3cd18cd..cb58afa 100644 --- a/apps/api/src/transfers.ts +++ b/apps/api/src/transfers.ts @@ -176,14 +176,14 @@ export async function executeMovement( revisions: latest.filter((r) => r.positionId === p.id), })); if (accounts.length !== 2) - throw new BadRequestException('只能在自己的启用资产账户之间转账(隐藏账户须先解锁)'); + throw new BadRequestException('只能在自己的启用账户之间转账(隐藏账户须先解锁)'); const source = accounts.find((p) => p.id === v.sourceId)!, target = accounts.find((p) => p.id === v.targetId)!; if ( source.kind !== 'account' || - source.side !== 'asset' || + (v.operation !== 'transfer' && source.side !== 'asset') || (v.operation === 'transfer' - ? target.kind !== 'account' || target.side !== 'asset' + ? target.kind !== 'account' : target.kind !== 'debt' || target.side !== (['borrow', 'repay'].includes(v.operation) ? 'liability' : 'asset')) ) @@ -192,7 +192,7 @@ export async function executeMovement( throw new ConflictException('转账时间不能早于任一账户的最新余额记录,请以当前余额转账'); if (source.currency === target.currency && !new Decimal(v.amount).eq(v.received)) throw new BadRequestException('同币种转出与到账金额必须一致,手续费单独填写'); - const deltas = movementDeltas(v.operation, v.amount, v.received, v.fee); + const deltas = movementDeltas(v.operation, v.amount, v.received, v.fee, source.side, target.side); const before = new Decimal(source.revisions[0]?.amount.toString() || '0'); const sourceAfter = before.plus(deltas.source); const after = new Decimal(target.revisions[0]?.amount.toString() || '0').plus(deltas.target); diff --git a/apps/api/src/validation.ts b/apps/api/src/validation.ts index 7a0f89c..7f86fbd 100644 --- a/apps/api/src/validation.ts +++ b/apps/api/src/validation.ts @@ -79,6 +79,7 @@ export const revisionInput = z .strict(); export const positionMeta = z .object({ + included: z.boolean().optional(), groupName: z.string().trim().max(60).optional(), iconId: z.string().uuid().nullable().optional(), name: z.string().trim().min(1).max(100), @@ -212,6 +213,7 @@ export const settingsInput = z sessionHours: sessionHours.optional(), requireHiddenPassword: z.boolean().optional(), overviewCards: overviewCards.optional(), + includeIndependentAssets: z.boolean().optional(), }) .strict() .refine((v) => Object.keys(v).length > 0); diff --git a/apps/api/src/zip.ts b/apps/api/src/zip.ts index c3c141e..219bf3b 100644 --- a/apps/api/src/zip.ts +++ b/apps/api/src/zip.ts @@ -24,7 +24,11 @@ const sha = (s: Buffer | string) => createHash('sha256').update(s).digest('hex') export function packBackup(b: Backup) { const metadata = b.positions.map(({ revisions, ...p }) => p); const data: Record = { - 'settings.json': { baseCurrency: b.baseCurrency, preferences: b.preferences }, + 'settings.json': { + baseCurrency: b.baseCurrency, + preferences: b.preferences, + metalPrices: b.metalPrices, + }, 'currencies.json': b.currencies, 'accounts.json': metadata.filter((p) => p.kind === 'account'), 'assets.json': metadata.filter((p) => p.kind === 'asset'), @@ -44,7 +48,7 @@ export function packBackup(b: Backup) { contents['manifest.json'] = JSON.stringify( { format: 'worthpath', - version: 7, + version: 8, exportedAt: b.exportedAt, files: files.map((name) => ({ name, sha256: sha(contents[name]) })), }, @@ -119,7 +123,14 @@ export async function readBackupZip(input: string | Buffer): Promise { const manifest = z .object({ format: z.literal('worthpath'), - version: z.union([z.literal(3), z.literal(4), z.literal(5), z.literal(6), z.literal(7)]), + version: z.union([ + z.literal(3), + z.literal(4), + z.literal(5), + z.literal(6), + z.literal(7), + z.literal(8), + ]), exportedAt: z.iso.datetime(), files: z .array( @@ -146,6 +157,7 @@ export async function readBackupZip(input: string | Buffer): Promise { const settings = z .object({ baseCurrency: z.string(), + metalPrices: z.array(z.record(z.string(), z.unknown())).optional(), preferences: z .object({ showSidebar: z.boolean().optional(), @@ -154,6 +166,7 @@ export async function readBackupZip(input: string | Buffer): Promise { sessionHours: sessionHours.optional(), requireHiddenPassword: z.boolean().optional(), overviewCards: overviewCards.optional(), + includeIndependentAssets: z.boolean().optional(), showNotes: z.boolean().optional(), idleMinutes: z.number().int().min(0).max(1440), }) diff --git a/apps/api/test/debts.test.ts b/apps/api/test/debts.test.ts index 931fddd..0ba5218 100644 --- a/apps/api/test/debts.test.ts +++ b/apps/api/test/debts.test.ts @@ -151,7 +151,7 @@ test('borrow/lend/collect/repay pair balances atomically and survive concurrency 200, ); const backup = (await call(a.token, '/backup')).data; - assert.equal(JSON.parse(packBackup(backup)['manifest.json']).version, 7); + assert.equal(JSON.parse(packBackup(backup)['manifest.json']).version, 8); assert.equal(backup.transfers.length, 5); assert.ok(backup.transfers.some((p: any) => p.operation === 'lend' && p.fee === '-2')); assert.equal( diff --git a/apps/api/test/inclusion-metals.test.ts b/apps/api/test/inclusion-metals.test.ts new file mode 100644 index 0000000..7ae40ea --- /dev/null +++ b/apps/api/test/inclusion-metals.test.ts @@ -0,0 +1,296 @@ +import 'dotenv/config'; +import { test } from 'node:test'; +import { request } from 'node:http'; +import assert from 'node:assert/strict'; +import { randomUUID, randomBytes, createHash } from 'node:crypto'; +import { PrismaClient } from '@prisma/client'; +import { hash } from 'bcryptjs'; +import { readBackupZip } from '../src/zip'; +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!; +const password = 'Fixture-session-only-42!'; +const db = new PrismaClient(); +async function fixture() { + const u = await db.user.create({ + data: { + username: 'wp_settings_' + randomUUID(), + passwordHash: await hash(password, 4), + idleMinutes: 0, + }, + }); + const token = randomBytes(32).toString('hex'); + const id = createHash('sha256').update(token).digest('hex'); + await db.session.create({ + data: { id, userId: u.id, expiresAt: new Date(Date.now() + 86400000) }, + }); + return { ...u, sessionId: id, cookie: 'wp_session=' + token }; +} +// Give this fixture suite its own loopback source address so independent auth +// scenarios do not consume the existing suite's per-IP production rate limit. +async function call(path: string, cookie: string, method = 'GET', body?: unknown) { + const data = body === undefined ? undefined : JSON.stringify(body); + return new Promise<{ status: number; data: any; cookie: string | null }>((resolve, reject) => { + const req = request( + new URL(base + path), + { + method, + localAddress: '127.0.0.3', + headers: { + Cookie: cookie, + Origin: origin, + ...(data === undefined + ? {} + : { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) }), + }, + }, + (res) => { + const chunks: Buffer[] = []; + res.on('data', (chunk) => chunks.push(chunk)); + res.on('error', reject); + res.on('end', () => { + try { + resolve({ + status: res.statusCode!, + data: JSON.parse(Buffer.concat(chunks).toString()), + cookie: res.headers['set-cookie']?.[0] ?? null, + }); + } catch (e) { + reject(e); + } + }); + }, + ); + req.on('error', reject); + req.end(data); + }); +} + +async function position( + u: any, + name: string, + kind = 'account', + side = 'asset', + initial = '1000', + category = 'bank', +) { + const r = await call('/positions', u.cookie, 'POST', { + name, + kind, + side, + category, + currency: 'CNY', + amount: initial, + date: '2026-10-01T00:00', + notes: '', + }); + assert.equal(r.status, 201, JSON.stringify(r.data)); + return r.data.id; +} +test('all account transfer directions, replay, scheduled transfers and backup are consistent', async () => { + const a = await fixture(); + try { + const cash = await position(a, 'cash'), + card = await position(a, 'card', 'account', 'liability', '200', 'credit_card'), + other = await position(a, 'card2', 'account', 'liability', '300', 'loan'); + const payload = { + sourceId: card, + targetId: cash, + amount: '100', + received: '100', + fee: '2', + date: '2026-10-02T00:00', + notes: '', + }; + const t = await call('/transfers', a.cookie, 'POST', payload); + assert.equal(t.status, 201, JSON.stringify(t.data)); + assert.equal( + ( + await db.position.findUniqueOrThrow({ + where: { id: card }, + include: { revisions: { orderBy: { sequence: 'desc' }, take: 1 } }, + }) + ).revisions[0].amount.toString(), + '302', + ); + const t2 = await call('/transfers', a.cookie, 'POST', { + ...payload, + sourceId: cash, + targetId: card, + amount: '400', + received: '400', + fee: '0', + date: '2026-10-02T01:00', + }); + assert.equal(t2.status, 201); + assert.equal((await call('/positions/' + card, a.cookie)).data.amount, '-98'); + assert.equal( + ( + await call('/transfers/' + t.data.id, a.cookie, 'PUT', { + ...payload, + amount: '150', + received: '150', + }) + ).status, + 200, + ); + assert.equal((await call('/positions/' + card, a.cookie)).data.amount, '-48'); + assert.equal((await call('/transfers/' + t2.data.id, a.cookie, 'DELETE')).status, 200); + assert.equal((await call('/positions/' + card, a.cookie)).data.amount, '352'); + const plan = { + name: 'credit transfer', + operation: 'transfer', + sourceId: card, + targetId: other, + amount: '50', + received: '50', + nextAt: '2026-10-02T02:00', + intervalDays: 0, + notes: '', + }; + assert.equal((await call('/schedules', a.cookie, 'POST', plan)).status, 201); + assert.equal((await call('/schedules/run', a.cookie, 'POST', {})).data.executed, 1); + assert.equal((await call('/positions/' + other, a.cookie)).data.amount, '250'); + assert.equal( + ( + await call('/schedules', a.cookie, 'POST', { + ...plan, + operation: 'expense', + targetId: null, + }) + ).status, + 400, + ); + const backupController = (await import('../src/backup')).BackupController; + const controller = new backupController(db as any); + const backup = await (controller as any).data(a.id); + assert.equal((await import('../src/backup')).validateBackup(backup).transfers?.length, 2); + } finally { + await db.user.delete({ where: { id: a.id } }); + } +}); +test('inclusion preferences and precious metal settings, valuation and quotes restore exactly', async () => { + const a = await fixture(), + b = await fixture(); + try { + const cash = await position(a, 'cash'), + metal = await position(a, 'gold', 'asset', 'asset', '200', 'gold'), + debt = await position(a, 'debt', 'debt', 'liability', '100', 'loan'); + assert.equal((await call('/overview', a.cookie)).data.net, '1100.00'); + assert.equal( + (await call('/settings', a.cookie, 'PATCH', { includeIndependentAssets: false })).status, + 200, + ); + assert.equal((await call('/overview', a.cookie)).data.net, '900.00'); + assert.equal( + ( + await call('/positions/' + debt, a.cookie, 'PATCH', { + name: 'debt', + category: 'loan', + included: false, + notes: '', + }) + ).status, + 200, + ); + assert.equal((await call('/overview', a.cookie)).data.net, '1000.00'); + const tr = await call('/trend?from=2026-10-01&to=2026-10-03', a.cookie); + assert.ok(tr.data.items.every((i: any) => i.net === '1000.00')); + assert.equal( + ( + await call('/metals/' + cash, a.cookie, 'PUT', { + metalType: 'gold', + metalGrams: '10', + metalPurity: '0.999', + autoValuation: true, + }) + ).status, + 404, + ); + assert.equal( + ( + await call('/metals/' + metal, b.cookie, 'PUT', { + metalType: 'gold', + metalGrams: '10', + metalPurity: '0.999', + autoValuation: true, + }) + ).status, + 404, + ); + assert.equal( + ( + await call('/metals/' + metal, a.cookie, 'PUT', { + metalType: 'gold', + metalGrams: '10.86420978', + metalPurity: '0.999', + autoValuation: true, + }) + ).status, + 200, + ); + const d = new Date(Date.now() + 8 * 3600000).toISOString().slice(0, 10); + assert.equal( + ( + await call('/metals/prices', a.cookie, 'POST', { + metalType: 'gold', + currency: 'CNY', + price: '700.864209789012', + date: d, + }) + ).status, + 201, + ); + const expected = (await import('../src/metals')).metalValue( + '10.86420978', + '0.999', + '700.864209789012', + ); + assert.equal( + (await call('/positions/' + metal, a.cookie)).data.amount, + expected.replace(/0+$/, '').replace(/\.$/, ''), + ); + const count = await db.revision.count({ where: { positionId: metal } }); + await call('/metals/prices', a.cookie, 'POST', { + metalType: 'gold', + currency: 'CNY', + price: '700.864209789012', + date: d, + }); + assert.equal(await db.revision.count({ where: { positionId: metal } }), count); + const controller = new (await import('../src/backup')).BackupController(db as any); + const backup = await (controller as any).data(a.id); + const archive = (await import('../src/zip')).archiveBackup(backup); + const chunks: Buffer[] = []; + archive.on('data', (c: Buffer) => chunks.push(c)); + const done = new Promise((resolve) => archive.on('end', resolve)); + await archive.finalize(); + await done; + const restoredBackup = await readBackupZip(Buffer.concat(chunks)); + assert.equal( + (await call('/backup/import', b.cookie, 'POST', { confirmed: true, backup: restoredBackup })) + .status, + 201, + ); + assert.equal((await call('/auth/me', b.cookie)).data.includeIndependentAssets, false); + const ps = (await call('/positions', b.cookie)).data; + assert.equal(ps.find((p: any) => p.name === 'debt').included, false); + assert.equal(ps.find((p: any) => p.name === 'gold').metalGrams, '10.86420978'); + assert.equal( + (await db.metalPrice.findFirstOrThrow({ where: { userId: b.id } })).price.toString(), + '700.864209789012', + ); + const refreshed = await call('/metals/refresh', a.cookie, 'POST', {}); + assert.equal(refreshed.status, 201, JSON.stringify(refreshed.data)); + assert.equal( + ( + await db.metalPrice.findFirstOrThrow({ + where: { userId: a.id, metalType: 'gold', currency: 'CNY', date: new Date(d) }, + }) + ).price.toString(), + '700.864209789012', + ); + } finally { + await db.user.deleteMany({ where: { id: { in: [a.id, b.id] } } }); + await db.$disconnect(); + } +}); diff --git a/apps/api/test/metals.test.ts b/apps/api/test/metals.test.ts new file mode 100644 index 0000000..aa8195c --- /dev/null +++ b/apps/api/test/metals.test.ts @@ -0,0 +1,83 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { MetalsService, metalValue } from '../src/metals'; +import { totals, trend, type Holding } from '../src/calculation'; +import { today } from '../src/validation'; +test('excluded holdings do not require FX or contribute to totals, attribution or earliest trend date', () => { + const holding = (id: string, currency: string, included: boolean, day: string): Holding => ({ + id, + name: id, + kind: 'asset', + side: 'asset', + currency, + included, + revisions: [ + { id: id + 'r', amount: '100', effectiveDate: new Date(day), notes: '', reason: 'initial' }, + ], + }); + const ps = [ + holding('excluded', 'USD', false, '2026-01-01'), + holding('visible', 'CNY', true, '2026-10-01'), + ]; + const result = totals(ps, [], 'CNY', '2026-10-03'); + assert.equal(result.net, '100.00'); + assert.equal(result.complete, true); + assert.equal(result.items.length, 2); + const points = trend(ps, [], 'CNY', '2026-09-30', '2026-10-03'); + assert.equal(points[0].date, '2026-10-01'); + assert.equal(points[0].balanceChange, '100.00'); + assert.ok(points.every((p) => p.complete && p.fxChange === '0.00')); +}); +test('public metal update preserves decimal values and manual quotes, skips cleared positions and keeps old prices on failure', async () => { + const original = globalThis.fetch; + const calls: string[] = []; + const writes: any[] = []; + var cleared = false; + const db: any = { + position: { findMany: async () => [{ id: 'p', currency: 'CNY', metalType: 'gold' }] }, + serial: async (fn: any) => + fn({ + $queryRaw: async () => [], + position: { + findMany: async () => + cleared ? [] : [{ id: 'p', currency: 'CNY', metalType: 'gold', autoValuation: false }], + }, + metalPrice: { + findUnique: async () => ({ source: 'manual' }), + upsert: async (v: any) => writes.push(v), + }, + }), + }; + try { + globalThis.fetch = (async (url: any) => { + calls.push(String(url)); + return new Response( + String(url).includes('frankfurter') + ? JSON.stringify([{ base: 'USD', quote: 'CNY', date: today(), rate: 6.700400864209 }]) + : JSON.stringify({ + currency: 'USD', + symbol: String(url).endsWith('XAU') ? 'XAU' : 'XAG', + price: 4141.799805864209, + updatedAt: new Date().toISOString(), + }), + ); + }) as typeof fetch; + const service = new MetalsService(db); + await service.refresh('user'); + assert.equal(calls.length, 3); + assert.ok(calls.every((u) => !u.includes('user'))); + assert.equal(writes.length, 0); + cleared = true; + await service.refresh('user'); + assert.equal(writes.length, 0); + globalThis.fetch = (async () => { + throw Error('offline'); + }) as typeof fetch; + await assert.rejects(service.refresh('user')); + assert.equal(service.status('user').state, 'error'); + assert.equal(writes.length, 0); + assert.equal(metalValue('10.86420978', '0.999', '700.864209789012'), '7606.72146664'); + } finally { + globalThis.fetch = original; + } +}); diff --git a/apps/api/test/transfers.test.ts b/apps/api/test/transfers.test.ts index 4d1943c..7b7ce49 100644 --- a/apps/api/test/transfers.test.ts +++ b/apps/api/test/transfers.test.ts @@ -141,15 +141,15 @@ test('transfers are atomic, scoped, retry-safe, decimal exact and included in ba ); assert.equal((await call('/settings', a.cookie, 'PATCH', { showNotes: 'false' })).status, 400); const debt = await position(a.cookie, '信用卡', '10', 'CNY', 'liability'); + const creditTransfer = await call('/transfers', a.cookie, 'POST', { + ...request, + requestId: randomUUID(), + targetId: debt, + }); + assert.equal(creditTransfer.status, 201); assert.equal( - ( - await call('/transfers', a.cookie, 'POST', { - ...request, - requestId: randomUUID(), - targetId: debt, - }) - ).status, - 400, + (await call('/transfers/' + creditTransfer.data.id, a.cookie, 'DELETE')).status, + 200, ); assert.equal( ( diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 2122eca..eca2647 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -54,6 +54,7 @@ import { orderedGroups, mergeGroupOrder } from './group-order'; import { Calendar } from './Calendar'; import { SchedulePanel } from './SchedulePanel'; import { IconPicker } from './IconPicker'; +import { MetalPanel } from './MetalPanel'; import { QuickTransfer } from './QuickTransfer'; import { TransferForm } from './TransferForm'; import { DebtPaymentForm, operationLabel, type DebtOperation } from './DebtPaymentForm'; @@ -259,11 +260,7 @@ export default function App() { setQuickTransfer((current) => current && [current.sourceId, current.targetId].some( - (id) => - id && - !positions.some( - (p) => p.id === id && p.kind === 'account' && p.side === 'asset' && !p.archived, - ), + (id) => id && !positions.some((p) => p.id === id && p.kind === 'account' && !p.archived), ) ? null : current, @@ -271,8 +268,8 @@ export default function App() { }, [positions]); function chooseQuickAccount(p: Position) { if (busy || loading) return; - if (p.kind !== 'account' || p.side !== 'asset' || p.archived) { - setError(tr('请选择启用的资产账户')); + if (p.kind !== 'account' || p.archived) { + setError(tr('请选择启用的账户')); return; } setQuickTransfer((current) => { @@ -1152,7 +1149,8 @@ export default function App() { {tr(labels[k])} - {overview.items.filter((p) => p.kind === k).length} {tr('个项目')} + {overview.items.filter((p) => p.included && p.kind === k).length}{' '} + {tr('个项目')} @@ -1274,10 +1272,14 @@ export default function App() { : tr('缺少汇率')}

{p.notes || tr('暂无备注')}

+ {(p.included === false || + (p.kind === 'asset' && user.includeIndependentAssets === false)) && ( + {tr('不计入总览')} + )} {tr('币种和资产负债属性创建后固定。归档项目仍计入总览。')}
- {p.kind === 'account' && p.side === 'asset' && !p.archived && ( + {p.kind === 'account' && !p.archived && ( <>
+ {p.kind === 'asset' && p.category === 'gold' && ( + load(false, true)} /> + )} {p.kind === 'debt' && (
@@ -1479,9 +1485,7 @@ export default function App() { ? quickTransfer.sourceId === p.id || quickTransfer.targetId === p.id : undefined } - disabled={ - busy || (!!quickTransfer && (p.side !== 'asset' || p.archived)) - } + disabled={busy || (!!quickTransfer && p.archived)} key={p.id} onClick={() => quickTransfer !== null && page === 'account' && quickMode @@ -1507,6 +1511,10 @@ export default function App() {

{p.name}

{p.groupName && {p.groupName}} + {(p.included === false || + (p.kind === 'asset' && user.includeIndependentAssets === false)) && ( + {tr('不计入总览')} + )}

{categoryLabel(p)} ·{' '} {p.kind === 'debt' @@ -1555,9 +1563,7 @@ export default function App() { disabled={ busy || loading || - positions.filter( - (p) => p.kind === 'account' && p.side === 'asset' && !p.archived, - ).length < 2 + positions.filter((p) => p.kind === 'account' && !p.archived).length < 2 } onClick={() => setQuickTransfer({})} > @@ -1729,9 +1735,7 @@ export default function App() { {page === 'schedules' && (

p.kind === 'account' && p.side === 'asset' && !p.archived, - )} + accounts={positions.filter((p) => p.kind === 'account' && !p.archived)} refresh={() => load(false, true)} />
@@ -1898,12 +1902,26 @@ export default function App() { 'schedules', ].filter((key) => !f.has('menu_' + key)), showNotes: f.has('showNotes'), + includeIndependentAssets: f.has('includeIndependentAssets'), overviewCards: allOverviewCards.filter((key) => f.has('card_' + key)), }), tr('设置已保存'), ); }} > + +

+ {tr('关闭后,独立资产不参与总额和净资产轨迹,资料与历史仍保留。')} +

{tr('导航菜单')}

{tr('选择需要显示的菜单项。设置入口始终保留,隐藏仅影响导航,不删除数据。')} @@ -2136,6 +2154,7 @@ export default function App() { /> )}

+ {settingsSection === 'rates' && load(false, true)} />}