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)} />}
{tr('清空本账号数据')}
@@ -2546,9 +2565,7 @@ export default function App() {
{tr('正在刷新数据…')}
) : modal.kind === 'transfer' ? (
p.kind === 'account' && p.side === 'asset' && !p.archived,
- )}
+ accounts={movementPositions.filter((p) => p.kind === 'account' && !p.archived)}
initialSourceId={modal.p?.kind === 'account' ? modal.p.id : undefined}
busy={busy}
submit={(v) =>
@@ -2621,6 +2638,7 @@ export default function App() {
...(f.has('iconId') ? { iconId: f.get('iconId') || null } : {}),
archived: mp!.archived,
hidden: f.get('hidden') === 'on',
+ included: f.get('included') === 'on',
}),
tr('项目资料已保存'),
);
@@ -2641,6 +2659,7 @@ export default function App() {
side,
amount: accountInputAmount({ kind, side }, String(v.amount)),
hidden: f.get('hidden') === 'on',
+ included: f.get('included') === 'on',
}),
tr('项目已添加'),
);
@@ -2660,6 +2679,17 @@ export default function App() {
)}
{['account', 'asset', 'debt', 'edit'].includes(modal.kind) && (
<>
+
+
+ {tr('取消后不参与资产、负债及净资产轨迹,仍可管理和转账。')}
+