feat: support credit transfers and precious metal valuations
This commit is contained in:
1 parent
425b45c91a
commit
518aea1e59
39 files changed
+1572
-77
No files matched your search
@@ -86,3 +86,76 @@ Invoke-RestMethod http://localhost:5173/api/openapi.json
|
|||||||
本次需要新增数据库迁移并运行图库初始化,备份导出升级至 ZIP v7。操作步骤、31 个内置图标与验证边界见 [更新说明](docs/update-2026-10-02.md)。
|
本次需要新增数据库迁移并运行图库初始化,备份导出升级至 ZIP v7。操作步骤、31 个内置图标与验证边界见 [更新说明](docs/update-2026-10-02.md)。
|
||||||
|
|
||||||
2026-10-03 设置与交互更新:支持 1 小时至 30 天登录有效期、隐藏项目密码开关、自选总览卡片、计划编辑弹窗、右上角操作提示及账户卡片快速转账。详见 [更新说明](docs/update-settings-interaction-2026-10-03.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)。
|
||||||
@@ -5,11 +5,11 @@
|
|||||||
"dev": "node scripts/dev.cjs",
|
"dev": "node scripts/dev.cjs",
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"typecheck": "tsc --noEmit",
|
"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:generate": "prisma generate",
|
||||||
"db:migrate": "node scripts/database.cjs deploy",
|
"db:migrate": "node scripts/database.cjs deploy",
|
||||||
"db:status": "node scripts/database.cjs status",
|
"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",
|
"test:performance": "tsx scripts/performance.ts after",
|
||||||
"icons:seed": "node scripts/seed-icons.cjs"
|
"icons:seed": "node scripts/seed-icons.cjs"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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 '用户的贵金属每克参考价历史';
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE MetalPrice MODIFY COLUMN source VARCHAR(20) NOT NULL COMMENT '价格来源:manual 或 goldapi';
|
||||||
@@ -23,6 +23,8 @@ model User {
|
|||||||
requireHiddenPassword Boolean @default(true)
|
requireHiddenPassword Boolean @default(true)
|
||||||
/// 总览显示的卡片键列表,空值表示默认全部显示
|
/// 总览显示的卡片键列表,空值表示默认全部显示
|
||||||
overviewCards Json?
|
overviewCards Json?
|
||||||
|
/// 独立资产是否参与资产总览和净资产轨迹
|
||||||
|
includeIndependentAssets Boolean @default(true)
|
||||||
/// 账户分组显示顺序;空值表示沿用默认顺序
|
/// 账户分组显示顺序;空值表示沿用默认顺序
|
||||||
accountGroupOrder Json?
|
accountGroupOrder Json?
|
||||||
/// 是否显示备注
|
/// 是否显示备注
|
||||||
@@ -35,6 +37,7 @@ model User {
|
|||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
positions Position[]
|
positions Position[]
|
||||||
rates ExchangeRate[]
|
rates ExchangeRate[]
|
||||||
|
metalPrices MetalPrice[]
|
||||||
sessions Session[]
|
sessions Session[]
|
||||||
icons Icon[]
|
icons Icon[]
|
||||||
transfers Transfer[]
|
transfers Transfer[]
|
||||||
@@ -86,6 +89,16 @@ model Position {
|
|||||||
archived Boolean @default(false)
|
archived Boolean @default(false)
|
||||||
/// 是否隐藏;未解锁时不参与可见范围统计
|
/// 是否隐藏;未解锁时不参与可见范围统计
|
||||||
hidden 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
|
/// 记录创建时间,UTC
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
/// 记录更新时间,UTC
|
/// 记录更新时间,UTC
|
||||||
@@ -257,3 +270,25 @@ model Schedule {
|
|||||||
@@index([userId,enabled,nextAt])
|
@@index([userId,enabled,nextAt])
|
||||||
@@unique([userId,importedFromId])
|
@@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])
|
||||||
|
}
|
||||||
@@ -35,7 +35,7 @@ async function main() {
|
|||||||
sources.push(
|
sources.push(
|
||||||
{ name: '支付宝', source: 'https://raw.githubusercontent.com/simple-icons/simple-icons/develop/icons/alipay.svg' },
|
{ 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://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 = [];
|
const manifest = [];
|
||||||
for (const item of sources) {
|
for (const item of sources) {
|
||||||
|
|||||||
@@ -175,6 +175,7 @@ export class AuthController {
|
|||||||
sessionHours: true,
|
sessionHours: true,
|
||||||
requireHiddenPassword: true,
|
requireHiddenPassword: true,
|
||||||
overviewCards: true,
|
overviewCards: true,
|
||||||
|
includeIndependentAssets: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const session = await this.db.session.findUniqueOrThrow({ where: { id: req.sessionId } });
|
const session = await this.db.session.findUniqueOrThrow({ where: { id: req.sessionId } });
|
||||||
@@ -182,6 +183,7 @@ export class AuthController {
|
|||||||
...user,
|
...user,
|
||||||
accountGroupOrder: user.accountGroupOrder || [],
|
accountGroupOrder: user.accountGroupOrder || [],
|
||||||
overviewCards: user.overviewCards ?? [...defaultOverviewCards],
|
overviewCards: user.overviewCards ?? [...defaultOverviewCards],
|
||||||
|
includeIndependentAssets: user.includeIndependentAssets,
|
||||||
sessionExpiresAt: session.expiresAt,
|
sessionExpiresAt: session.expiresAt,
|
||||||
hiddenMenus: user.hiddenMenus.split(',').filter(Boolean),
|
hiddenMenus: user.hiddenMenus.split(',').filter(Boolean),
|
||||||
revealed: req.revealed,
|
revealed: req.revealed,
|
||||||
|
|||||||
+82
-5
@@ -1,3 +1,4 @@
|
|||||||
|
import { metalConfig, metalPriceInput } from './metals';
|
||||||
import { scheduleInput } from './schedules';
|
import { scheduleInput } from './schedules';
|
||||||
import { movementDeltas } from './movement';
|
import { movementDeltas } from './movement';
|
||||||
import { pairedReasons } from './validation';
|
import { pairedReasons } from './validation';
|
||||||
@@ -30,6 +31,7 @@ import { Database } from './database';
|
|||||||
import { UserRequest } from './auth';
|
import { UserRequest } from './auth';
|
||||||
import {
|
import {
|
||||||
positionInput,
|
positionInput,
|
||||||
|
amount,
|
||||||
positionMeta,
|
positionMeta,
|
||||||
currency,
|
currency,
|
||||||
revisionInput,
|
revisionInput,
|
||||||
@@ -53,6 +55,10 @@ const timestamp = z.iso
|
|||||||
);
|
);
|
||||||
const record = positionMeta
|
const record = positionMeta
|
||||||
.extend({
|
.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']),
|
kind: z.enum(['account', 'asset', 'debt']),
|
||||||
side: z.enum(['asset', 'liability']),
|
side: z.enum(['asset', 'liability']),
|
||||||
currency,
|
currency,
|
||||||
@@ -85,6 +91,7 @@ const backupSchema = z
|
|||||||
sessionHours: sessionHours.optional(),
|
sessionHours: sessionHours.optional(),
|
||||||
requireHiddenPassword: z.boolean().optional(),
|
requireHiddenPassword: z.boolean().optional(),
|
||||||
overviewCards: overviewCards.optional(),
|
overviewCards: overviewCards.optional(),
|
||||||
|
includeIndependentAssets: z.boolean().optional(),
|
||||||
showNotes: z.boolean().optional(),
|
showNotes: z.boolean().optional(),
|
||||||
idleMinutes: z.number().int().min(0).max(1440),
|
idleMinutes: z.number().int().min(0).max(1440),
|
||||||
})
|
})
|
||||||
@@ -126,6 +133,14 @@ const backupSchema = z
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.optional(),
|
.optional(),
|
||||||
|
metalPrices: z
|
||||||
|
.array(
|
||||||
|
metalPriceInput.extend({
|
||||||
|
source: z.enum(['manual', 'goldapi']),
|
||||||
|
quotedAt: timestamp,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.optional(),
|
||||||
positions: z.array(record),
|
positions: z.array(record),
|
||||||
links: z.array(z.object({ sourceId: z.string().uuid(), targetId: z.string().uuid() }).strict()),
|
links: z.array(z.object({ sourceId: z.string().uuid(), targetId: z.string().uuid() }).strict()),
|
||||||
rates: z.array(rateInput.safeExtend({ source: z.enum(['manual', 'frankfurter']) })),
|
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');
|
if (ids.size !== b.positions.length) throw new BadRequestException('重复项目 ID');
|
||||||
const origins = b.positions.map((p) => p.importedFromId || p.id);
|
const origins = b.positions.map((p) => p.importedFromId || p.id);
|
||||||
if (new Set(origins).size !== origins.length) throw new BadRequestException('备份内包含重复项目');
|
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));
|
const iconIds = new Set((b.icons || []).map((i) => i.id));
|
||||||
if (
|
if (
|
||||||
iconIds.size !== (b.icons || []).length ||
|
iconIds.size !== (b.icons || []).length ||
|
||||||
@@ -146,6 +163,16 @@ export function validateBackup(raw: unknown) {
|
|||||||
throw new BadRequestException('图标关联无效');
|
throw new BadRequestException('图标关联无效');
|
||||||
const revisionIds = new Set<string>();
|
const revisionIds = new Set<string>();
|
||||||
for (const p of b.positions) {
|
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({
|
positionInput.parse({
|
||||||
name: p.name,
|
name: p.name,
|
||||||
category: p.category,
|
category: p.category,
|
||||||
@@ -156,6 +183,7 @@ export function validateBackup(raw: unknown) {
|
|||||||
notes: p.notes,
|
notes: p.notes,
|
||||||
archived: p.archived,
|
archived: p.archived,
|
||||||
hidden: p.hidden,
|
hidden: p.hidden,
|
||||||
|
included: p.included,
|
||||||
amount: '0',
|
amount: '0',
|
||||||
date: p.revisions[0]?.date || '1900-01-01',
|
date: p.revisions[0]?.date || '1900-01-01',
|
||||||
});
|
});
|
||||||
@@ -179,12 +207,11 @@ export function validateBackup(raw: unknown) {
|
|||||||
planIds.has(plan.importedFromId || plan.id) ||
|
planIds.has(plan.importedFromId || plan.id) ||
|
||||||
!source ||
|
!source ||
|
||||||
source.kind !== 'account' ||
|
source.kind !== 'account' ||
|
||||||
source.side !== 'asset' ||
|
(plan.operation === 'expense' && source.side !== 'asset') ||
|
||||||
(plan.operation === 'expense'
|
(plan.operation === 'expense'
|
||||||
? !!plan.targetId
|
? !!plan.targetId
|
||||||
: !target ||
|
: !target ||
|
||||||
target.kind !== 'account' ||
|
target.kind !== 'account' ||
|
||||||
target.side !== 'asset' ||
|
|
||||||
(source.currency === target.currency && !new Decimal(plan.amount).eq(plan.received)))
|
(source.currency === target.currency && !new Decimal(plan.amount).eq(plan.received)))
|
||||||
)
|
)
|
||||||
throw new BadRequestException('计划账户关联无效');
|
throw new BadRequestException('计划账户关联无效');
|
||||||
@@ -201,9 +228,9 @@ export function validateBackup(raw: unknown) {
|
|||||||
!source ||
|
!source ||
|
||||||
!target ||
|
!target ||
|
||||||
source.kind !== 'account' ||
|
source.kind !== 'account' ||
|
||||||
source.side !== 'asset' ||
|
(t.operation !== 'transfer' && source.side !== 'asset') ||
|
||||||
(t.operation === 'transfer'
|
(t.operation === 'transfer'
|
||||||
? target.kind !== 'account' || target.side !== 'asset'
|
? target.kind !== 'account'
|
||||||
: target.kind !== 'debt' ||
|
: target.kind !== 'debt' ||
|
||||||
target.side !== (['borrow', 'repay'].includes(t.operation) ? 'liability' : 'asset')) ||
|
target.side !== (['borrow', 'repay'].includes(t.operation) ? 'liability' : 'asset')) ||
|
||||||
source.currency !== t.sourceCurrency ||
|
source.currency !== t.sourceCurrency ||
|
||||||
@@ -213,7 +240,14 @@ export function validateBackup(raw: unknown) {
|
|||||||
transferIds.add(origin);
|
transferIds.add(origin);
|
||||||
if (t.sourceCurrency === t.targetCurrency && !new Decimal(t.amount).eq(t.received))
|
if (t.sourceCurrency === t.targetCurrency && !new Decimal(t.amount).eq(t.received))
|
||||||
throw new BadRequestException('同币种转账金额不一致');
|
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 [
|
for (const [p, revId, reason, delta] of [
|
||||||
[source, t.sourceRevisionId, deltas.sourceReason, deltas.source],
|
[source, t.sourceRevisionId, deltas.sourceReason, deltas.source],
|
||||||
[target, t.targetRevisionId, deltas.targetReason, deltas.target],
|
[target, t.targetRevisionId, deltas.targetReason, deltas.target],
|
||||||
@@ -318,6 +352,7 @@ export class BackupController implements OnModuleDestroy, OnModuleInit {
|
|||||||
sessionHours: true,
|
sessionHours: true,
|
||||||
requireHiddenPassword: true,
|
requireHiddenPassword: true,
|
||||||
overviewCards: true,
|
overviewCards: true,
|
||||||
|
includeIndependentAssets: true,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
client.position.findMany({
|
client.position.findMany({
|
||||||
@@ -342,6 +377,11 @@ export class BackupController implements OnModuleDestroy, OnModuleInit {
|
|||||||
notes: p.notes,
|
notes: p.notes,
|
||||||
archived: p.archived,
|
archived: p.archived,
|
||||||
hidden: p.hidden,
|
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(),
|
createdAt: p.createdAt.toISOString(),
|
||||||
updatedAt: p.updatedAt.toISOString(),
|
updatedAt: p.updatedAt.toISOString(),
|
||||||
revisions: p.revisions.map((r) => ({
|
revisions: p.revisions.map((r) => ({
|
||||||
@@ -381,6 +421,7 @@ export class BackupController implements OnModuleDestroy, OnModuleInit {
|
|||||||
sessionHours: user.sessionHours,
|
sessionHours: user.sessionHours,
|
||||||
requireHiddenPassword: user.requireHiddenPassword,
|
requireHiddenPassword: user.requireHiddenPassword,
|
||||||
overviewCards: overviewCards.parse(user.overviewCards ?? [...defaultOverviewCards]),
|
overviewCards: overviewCards.parse(user.overviewCards ?? [...defaultOverviewCards]),
|
||||||
|
includeIndependentAssets: user.includeIndependentAssets,
|
||||||
},
|
},
|
||||||
currencies: [
|
currencies: [
|
||||||
...new Set([
|
...new Set([
|
||||||
@@ -410,6 +451,14 @@ export class BackupController implements OnModuleDestroy, OnModuleInit {
|
|||||||
received: v.received.toString(),
|
received: v.received.toString(),
|
||||||
nextAt: businessTime(v.nextAt),
|
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,
|
positions,
|
||||||
links: ps.flatMap((p) =>
|
links: ps.flatMap((p) =>
|
||||||
p.outgoing.map((l) => ({ sourceId: l.sourceId, targetId: l.targetId })),
|
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) =>
|
data.rates.sort((a, b) =>
|
||||||
(a.currency + a.baseCurrency + a.date).localeCompare(b.currency + b.baseCurrency + b.date),
|
(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.currencies.sort();
|
||||||
data.transfers?.sort((a, b) => a.id.localeCompare(b.id));
|
data.transfers?.sort((a, b) => a.id.localeCompare(b.id));
|
||||||
data.schedules?.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);
|
.parse(raw);
|
||||||
return this.db.$transaction(
|
return this.db.$transaction(
|
||||||
async (tx) => {
|
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 } });
|
const s = await tx.session.findUniqueOrThrow({ where: { id: r.sessionId } });
|
||||||
if (!s.backupDigest || !s.backupExpiresAt || +s.backupExpiresAt <= Date.now())
|
if (!s.backupDigest || !s.backupExpiresAt || +s.backupExpiresAt <= Date.now())
|
||||||
throw new BadRequestException('请先下载当前账号备份,再进入下一步(10 分钟内有效)');
|
throw new BadRequestException('请先下载当前账号备份,再进入下一步(10 分钟内有效)');
|
||||||
@@ -532,6 +585,7 @@ export class BackupController implements OnModuleDestroy, OnModuleInit {
|
|||||||
await tx.schedule.deleteMany({ where: { userId: r.userId } });
|
await tx.schedule.deleteMany({ where: { userId: r.userId } });
|
||||||
await tx.position.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.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.exchangeRate.deleteMany({ where: { userId: r.userId } });
|
||||||
await tx.session.updateMany({
|
await tx.session.updateMany({
|
||||||
where: { userId: r.userId },
|
where: { userId: r.userId },
|
||||||
@@ -636,6 +690,11 @@ export class BackupController implements OnModuleDestroy, OnModuleInit {
|
|||||||
notes: p.notes,
|
notes: p.notes,
|
||||||
archived: p.archived,
|
archived: p.archived,
|
||||||
hidden: p.hidden,
|
hidden: p.hidden,
|
||||||
|
included: p.included,
|
||||||
|
metalType: p.metalType,
|
||||||
|
metalGrams: p.metalGrams,
|
||||||
|
metalPurity: p.metalPurity,
|
||||||
|
autoValuation: p.autoValuation,
|
||||||
createdAt: new Date(p.createdAt),
|
createdAt: new Date(p.createdAt),
|
||||||
updatedAt: new Date(p.updatedAt),
|
updatedAt: new Date(p.updatedAt),
|
||||||
revisions: {
|
revisions: {
|
||||||
@@ -670,6 +729,23 @@ export class BackupController implements OnModuleDestroy, OnModuleInit {
|
|||||||
});
|
});
|
||||||
originals.forEach((v, i) => revisionMapping.set(v.id, restored[i].id));
|
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 || [])
|
for (const t of b.transfers || [])
|
||||||
await tx.transfer.create({
|
await tx.transfer.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -732,6 +808,7 @@ export class BackupController implements OnModuleDestroy, OnModuleInit {
|
|||||||
sessionHours: b.preferences?.sessionHours,
|
sessionHours: b.preferences?.sessionHours,
|
||||||
requireHiddenPassword: b.preferences?.requireHiddenPassword,
|
requireHiddenPassword: b.preferences?.requireHiddenPassword,
|
||||||
overviewCards: b.preferences?.overviewCards,
|
overviewCards: b.preferences?.overviewCards,
|
||||||
|
includeIndependentAssets: b.preferences?.includeIndependentAssets,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!ps.length && !rs.length && b.preferences?.requireHiddenPassword !== undefined)
|
if (!ps.length && !rs.length && b.preferences?.requireHiddenPassword !== undefined)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export type Holding = {
|
|||||||
kind: string;
|
kind: string;
|
||||||
side: string;
|
side: string;
|
||||||
currency: string;
|
currency: string;
|
||||||
|
included?: boolean;
|
||||||
revisions: {
|
revisions: {
|
||||||
id: string;
|
id: string;
|
||||||
sequence?: number;
|
sequence?: number;
|
||||||
@@ -71,15 +72,16 @@ export function totals(positions: Holding[], rates: Rate[], base: string, date:
|
|||||||
.sort((a, b) => compareRevisions(b, a))[0],
|
.sort((a, b) => compareRevisions(b, a))[0],
|
||||||
amount = new Decimal(rev?.amount.toString() || '0'),
|
amount = new Decimal(rev?.amount.toString() || '0'),
|
||||||
fx = rateAt(rates, p.currency, base, date);
|
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;
|
const converted = fx ? amount.mul(fx.value) : null;
|
||||||
if (converted) {
|
if (converted && p.included !== false) {
|
||||||
if (p.side === 'asset') assets = assets.plus(converted);
|
if (p.side === 'asset') assets = assets.plus(converted);
|
||||||
else if (p.kind === 'account' && converted.isNegative()) assets = assets.minus(converted);
|
else if (p.kind === 'account' && converted.isNegative()) assets = assets.minus(converted);
|
||||||
else liabilities = liabilities.plus(converted);
|
else liabilities = liabilities.plus(converted);
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
id: p.id,
|
id: p.id,
|
||||||
|
included: p.included !== false,
|
||||||
name: p.name,
|
name: p.name,
|
||||||
kind: p.kind,
|
kind: p.kind,
|
||||||
side: p.side,
|
side: p.side,
|
||||||
@@ -117,7 +119,7 @@ export function overview(positions: Holding[], rates: Rate[], base: string, date
|
|||||||
fxChange: string | null = null;
|
fxChange: string | null = null;
|
||||||
if (previous?.complete && value.complete) {
|
if (previous?.complete && value.complete) {
|
||||||
let revalued = new Decimal(0);
|
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);
|
const fx = rateAt(rates, item.currency, base, d);
|
||||||
if (!fx && !new Decimal(item.amount).isZero()) {
|
if (!fx && !new Decimal(item.amount).isZero()) {
|
||||||
revalued = new Decimal(NaN);
|
revalued = new Decimal(NaN);
|
||||||
@@ -155,6 +157,7 @@ export function trend(
|
|||||||
to: string,
|
to: string,
|
||||||
grain: 'day' | 'week' | 'month' = 'day',
|
grain: 'day' | 'week' | 'month' = 'day',
|
||||||
) {
|
) {
|
||||||
|
positions = positions.filter((p) => p.included !== false);
|
||||||
const dates = positions
|
const dates = positions
|
||||||
.flatMap((p) => p.revisions.map((r) => businessDay(r.effectiveDate)))
|
.flatMap((p) => p.revisions.map((r) => businessDay(r.effectiveDate)))
|
||||||
.sort();
|
.sort();
|
||||||
@@ -197,7 +200,7 @@ export function trend(
|
|||||||
value = advance(d);
|
value = advance(d);
|
||||||
if (previous.complete && value.complete) {
|
if (previous.complete && value.complete) {
|
||||||
let revalued = new Decimal(0);
|
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 rate = item.currency === base ? new Decimal(1) : fx.get(item.currency)?.rate;
|
||||||
const v = new Decimal(item.amount).mul(rate?.toString() || '0');
|
const v = new Decimal(item.amount).mul(rate?.toString() || '0');
|
||||||
revalued = revalued.plus(item.side === 'asset' ? v : v.neg());
|
revalued = revalued.plus(item.side === 'asset' ? v : v.neg());
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { TransfersController } from './transfers';
|
|||||||
import { PortfolioController } from './portfolio';
|
import { PortfolioController } from './portfolio';
|
||||||
import { BackupController } from './backup';
|
import { BackupController } from './backup';
|
||||||
import { IconsController, IconsService } from './icons';
|
import { IconsController, IconsService } from './icons';
|
||||||
|
import { MetalsService, MetalsController } from './metals';
|
||||||
import { Database } from './database';
|
import { Database } from './database';
|
||||||
import { RatesService, SettingsController } from './rates';
|
import { RatesService, SettingsController } from './rates';
|
||||||
import { ZodError } from 'zod';
|
import { ZodError } from 'zod';
|
||||||
@@ -50,6 +51,7 @@ class SafeErrors implements ExceptionFilter {
|
|||||||
Database,
|
Database,
|
||||||
AuthService,
|
AuthService,
|
||||||
RatesService,
|
RatesService,
|
||||||
|
MetalsService,
|
||||||
IconsService,
|
IconsService,
|
||||||
{ provide: APP_GUARD, useClass: AuthGuard },
|
{ provide: APP_GUARD, useClass: AuthGuard },
|
||||||
],
|
],
|
||||||
@@ -60,6 +62,7 @@ class SafeErrors implements ExceptionFilter {
|
|||||||
IconsController,
|
IconsController,
|
||||||
AuthController,
|
AuthController,
|
||||||
PortfolioController,
|
PortfolioController,
|
||||||
|
MetalsController,
|
||||||
SettingsController,
|
SettingsController,
|
||||||
BackupController,
|
BackupController,
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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<string, string>();
|
||||||
|
private running = new Set<string>();
|
||||||
|
private outcomes = new Map<string, { state: string; message: string; attemptedAt: string }>();
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,15 +1,22 @@
|
|||||||
import Decimal from 'decimal.js';
|
import Decimal from 'decimal.js';
|
||||||
import { BadRequestException } from '@nestjs/common';
|
import { BadRequestException } from '@nestjs/common';
|
||||||
export type Movement = 'transfer' | 'borrow' | 'lend' | 'collect' | 'repay';
|
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 incoming = operation === 'borrow' || operation === 'collect';
|
||||||
const principal = new Decimal(amount),
|
const principal = new Decimal(amount),
|
||||||
charge = new Decimal(fee),
|
charge = new Decimal(fee),
|
||||||
debt = new Decimal(received);
|
debt = new Decimal(received);
|
||||||
if (operation === 'transfer')
|
if (operation === 'transfer')
|
||||||
return {
|
return {
|
||||||
source: principal.plus(charge).neg(),
|
source: principal.plus(charge).mul(sourceSide === 'liability' ? 1 : -1),
|
||||||
target: debt,
|
target: debt.mul(targetSide === 'liability' ? -1 : 1),
|
||||||
sourceReason: 'transfer_out',
|
sourceReason: 'transfer_out',
|
||||||
targetReason: 'transfer_in',
|
targetReason: 'transfer_in',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { INestApplication } from '@nestjs/common';
|
import { INestApplication } from '@nestjs/common';
|
||||||
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
||||||
|
import { metalConfig, metalPriceInput } from './metals';
|
||||||
import { scheduleInput } from './schedules';
|
import { scheduleInput } from './schedules';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import {
|
import {
|
||||||
@@ -45,6 +46,8 @@ export function setupOpenApi(app: INestApplication) {
|
|||||||
'PUT /api/schedules/{id}': scheduleInput,
|
'PUT /api/schedules/{id}': scheduleInput,
|
||||||
'PATCH /api/schedules/{id}': z.object({ enabled: z.boolean() }).strict(),
|
'PATCH /api/schedules/{id}': z.object({ enabled: z.boolean() }).strict(),
|
||||||
'PATCH /api/settings': settingsInput,
|
'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/clear': z.object({ confirmation: z.literal('确定清空') }),
|
||||||
'POST /api/backup/import-file': z.object({
|
'POST /api/backup/import-file': z.object({
|
||||||
token: z.string().uuid(),
|
token: z.string().uuid(),
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import { currentPositions, currentRates, historyPage, trendData, trendInput } fr
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
import { IconsService } from './icons';
|
import { IconsService } from './icons';
|
||||||
|
import { MetalsService } from './metals';
|
||||||
import { RatesService } from './rates';
|
import { RatesService } from './rates';
|
||||||
import { captureReplay } from './replay';
|
import { captureReplay } from './replay';
|
||||||
import { changeMovement } from './transfers';
|
import { changeMovement } from './transfers';
|
||||||
@@ -37,6 +38,7 @@ export class PortfolioController {
|
|||||||
private db: Database,
|
private db: Database,
|
||||||
private fx: RatesService,
|
private fx: RatesService,
|
||||||
private icons: IconsService,
|
private icons: IconsService,
|
||||||
|
private metals: MetalsService,
|
||||||
) {}
|
) {}
|
||||||
private async own(userId: string, id: string, revealed = false) {
|
private async own(userId: string, id: string, revealed = false) {
|
||||||
const [p] = await this.db.$transaction((tx) => currentPositions(tx, userId, revealed, id));
|
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 rows = await currentPositions(tx, r.userId, r.revealed, undefined, kind);
|
||||||
const user = await tx.user.findUniqueOrThrow({
|
const user = await tx.user.findUniqueOrThrow({
|
||||||
where: { id: r.userId },
|
where: { id: r.userId },
|
||||||
select: { baseCurrency: true },
|
select: { baseCurrency: true, includeIndependentAssets: true },
|
||||||
});
|
});
|
||||||
const rates = await currentRates(
|
const rates = await currentRates(
|
||||||
tx,
|
tx,
|
||||||
@@ -140,7 +142,15 @@ export class PortfolioController {
|
|||||||
)
|
)
|
||||||
throw new BadRequestException('信用卡和贷款账户必须为负债');
|
throw new BadRequestException('信用卡和贷款账户必须为负债');
|
||||||
await this.icons.requireVisible(r.userId, v.iconId);
|
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 };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
@Post('positions/:id/revisions') async revise(
|
@Post('positions/:id/revisions') async revise(
|
||||||
@@ -283,10 +293,11 @@ export class PortfolioController {
|
|||||||
}
|
}
|
||||||
@Get('overview') async overview(@Req() r: UserRequest) {
|
@Get('overview') async overview(@Req() r: UserRequest) {
|
||||||
void this.fx.daily(r.userId);
|
void this.fx.daily(r.userId);
|
||||||
|
void this.metals.daily(r.userId);
|
||||||
return this.db.$transaction(async (tx) => {
|
return this.db.$transaction(async (tx) => {
|
||||||
const user = await tx.user.findUniqueOrThrow({
|
const user = await tx.user.findUniqueOrThrow({
|
||||||
where: { id: r.userId },
|
where: { id: r.userId },
|
||||||
select: { baseCurrency: true },
|
select: { baseCurrency: true, includeIndependentAssets: true },
|
||||||
});
|
});
|
||||||
const positions = await currentPositions(tx, r.userId, r.revealed);
|
const positions = await currentPositions(tx, r.userId, r.revealed);
|
||||||
const rates = await currentRates(
|
const rates = await currentRates(
|
||||||
@@ -298,7 +309,15 @@ export class PortfolioController {
|
|||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
baseCurrency: user.baseCurrency,
|
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,
|
revealed: r.revealed,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -274,7 +274,7 @@ export async function trendData(
|
|||||||
const currencies = [...new Set(positions.map((p) => p.currency))];
|
const currencies = [...new Set(positions.map((p) => p.currency))];
|
||||||
const user = await db.user.findUniqueOrThrow({
|
const user = await db.user.findUniqueOrThrow({
|
||||||
where: { id: userId },
|
where: { id: userId },
|
||||||
select: { baseCurrency: true },
|
select: { baseCurrency: true, includeIndependentAssets: true },
|
||||||
});
|
});
|
||||||
const previousDay = new Date(+new Date(from) - 86400000).toISOString().slice(0, 10);
|
const previousDay = new Date(+new Date(from) - 86400000).toISOString().slice(0, 10);
|
||||||
const rates = await db.exchangeRate.findMany({
|
const rates = await db.exchangeRate.findMany({
|
||||||
@@ -287,7 +287,11 @@ export async function trendData(
|
|||||||
});
|
});
|
||||||
const priorRates = await currentRates(db, userId, currencies, user.baseCurrency, previousDay);
|
const priorRates = await currentRates(db, userId, currencies, user.baseCurrency, previousDay);
|
||||||
return {
|
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],
|
rates: [...priorRates, ...rates],
|
||||||
base: user.baseCurrency,
|
base: user.baseCurrency,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -190,6 +190,7 @@ export class SettingsController {
|
|||||||
sessionHours: true,
|
sessionHours: true,
|
||||||
requireHiddenPassword: true,
|
requireHiddenPassword: true,
|
||||||
overviewCards: true,
|
overviewCards: true,
|
||||||
|
includeIndependentAssets: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export async function captureReplay(tx: Prisma.TransactionClient, userId: string
|
|||||||
}
|
}
|
||||||
return async () => {
|
return async () => {
|
||||||
const transfers = await tx.transfer.findMany({
|
const transfers = await tx.transfer.findMany({
|
||||||
|
include: { source: true, target: true },
|
||||||
where: { userId, OR: [{ sourceId: { in: ids } }, { targetId: { in: ids } }] },
|
where: { userId, OR: [{ sourceId: { in: ids } }, { targetId: { in: ids } }] },
|
||||||
});
|
});
|
||||||
for (const t of transfers) {
|
for (const t of transfers) {
|
||||||
@@ -33,6 +34,8 @@ export async function captureReplay(tx: Prisma.TransactionClient, userId: string
|
|||||||
t.amount.toString(),
|
t.amount.toString(),
|
||||||
t.received.toString(),
|
t.received.toString(),
|
||||||
t.fee.toString(),
|
t.fee.toString(),
|
||||||
|
t.source.side,
|
||||||
|
t.target.side,
|
||||||
);
|
);
|
||||||
deltas.set(t.sourceRevisionId, d.source);
|
deltas.set(t.sourceRevisionId, d.source);
|
||||||
deltas.set(t.targetRevisionId, d.target);
|
deltas.set(t.targetRevisionId, d.target);
|
||||||
|
|||||||
@@ -65,7 +65,6 @@ export class SchedulesController {
|
|||||||
where: {
|
where: {
|
||||||
userId: r.userId,
|
userId: r.userId,
|
||||||
kind: 'account',
|
kind: 'account',
|
||||||
side: 'asset',
|
|
||||||
...(r.revealed ? {} : { hidden: false }),
|
...(r.revealed ? {} : { hidden: false }),
|
||||||
},
|
},
|
||||||
select: { id: true },
|
select: { id: true },
|
||||||
@@ -108,12 +107,13 @@ export class SchedulesController {
|
|||||||
userId: r.userId,
|
userId: r.userId,
|
||||||
id: { in: ids },
|
id: { in: ids },
|
||||||
kind: 'account',
|
kind: 'account',
|
||||||
side: 'asset',
|
...(v.operation === 'expense' ? { side: 'asset' } : {}),
|
||||||
archived: false,
|
archived: false,
|
||||||
...(r.revealed ? {} : { hidden: 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)
|
if (v.operation === 'expense' && v.targetId)
|
||||||
throw new BadRequestException('支出计划无需转入账户');
|
throw new BadRequestException('支出计划无需转入账户');
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -176,14 +176,14 @@ export async function executeMovement(
|
|||||||
revisions: latest.filter((r) => r.positionId === p.id),
|
revisions: latest.filter((r) => r.positionId === p.id),
|
||||||
}));
|
}));
|
||||||
if (accounts.length !== 2)
|
if (accounts.length !== 2)
|
||||||
throw new BadRequestException('只能在自己的启用资产账户之间转账(隐藏账户须先解锁)');
|
throw new BadRequestException('只能在自己的启用账户之间转账(隐藏账户须先解锁)');
|
||||||
const source = accounts.find((p) => p.id === v.sourceId)!,
|
const source = accounts.find((p) => p.id === v.sourceId)!,
|
||||||
target = accounts.find((p) => p.id === v.targetId)!;
|
target = accounts.find((p) => p.id === v.targetId)!;
|
||||||
if (
|
if (
|
||||||
source.kind !== 'account' ||
|
source.kind !== 'account' ||
|
||||||
source.side !== 'asset' ||
|
(v.operation !== 'transfer' && source.side !== 'asset') ||
|
||||||
(v.operation === 'transfer'
|
(v.operation === 'transfer'
|
||||||
? target.kind !== 'account' || target.side !== 'asset'
|
? target.kind !== 'account'
|
||||||
: target.kind !== 'debt' ||
|
: target.kind !== 'debt' ||
|
||||||
target.side !== (['borrow', 'repay'].includes(v.operation) ? 'liability' : 'asset'))
|
target.side !== (['borrow', 'repay'].includes(v.operation) ? 'liability' : 'asset'))
|
||||||
)
|
)
|
||||||
@@ -192,7 +192,7 @@ export async function executeMovement(
|
|||||||
throw new ConflictException('转账时间不能早于任一账户的最新余额记录,请以当前余额转账');
|
throw new ConflictException('转账时间不能早于任一账户的最新余额记录,请以当前余额转账');
|
||||||
if (source.currency === target.currency && !new Decimal(v.amount).eq(v.received))
|
if (source.currency === target.currency && !new Decimal(v.amount).eq(v.received))
|
||||||
throw new BadRequestException('同币种转出与到账金额必须一致,手续费单独填写');
|
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 before = new Decimal(source.revisions[0]?.amount.toString() || '0');
|
||||||
const sourceAfter = before.plus(deltas.source);
|
const sourceAfter = before.plus(deltas.source);
|
||||||
const after = new Decimal(target.revisions[0]?.amount.toString() || '0').plus(deltas.target);
|
const after = new Decimal(target.revisions[0]?.amount.toString() || '0').plus(deltas.target);
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ export const revisionInput = z
|
|||||||
.strict();
|
.strict();
|
||||||
export const positionMeta = z
|
export const positionMeta = z
|
||||||
.object({
|
.object({
|
||||||
|
included: z.boolean().optional(),
|
||||||
groupName: z.string().trim().max(60).optional(),
|
groupName: z.string().trim().max(60).optional(),
|
||||||
iconId: z.string().uuid().nullable().optional(),
|
iconId: z.string().uuid().nullable().optional(),
|
||||||
name: z.string().trim().min(1).max(100),
|
name: z.string().trim().min(1).max(100),
|
||||||
@@ -212,6 +213,7 @@ export const settingsInput = z
|
|||||||
sessionHours: sessionHours.optional(),
|
sessionHours: sessionHours.optional(),
|
||||||
requireHiddenPassword: z.boolean().optional(),
|
requireHiddenPassword: z.boolean().optional(),
|
||||||
overviewCards: overviewCards.optional(),
|
overviewCards: overviewCards.optional(),
|
||||||
|
includeIndependentAssets: z.boolean().optional(),
|
||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
.refine((v) => Object.keys(v).length > 0);
|
.refine((v) => Object.keys(v).length > 0);
|
||||||
+16
-3
@@ -24,7 +24,11 @@ const sha = (s: Buffer | string) => createHash('sha256').update(s).digest('hex')
|
|||||||
export function packBackup(b: Backup) {
|
export function packBackup(b: Backup) {
|
||||||
const metadata = b.positions.map(({ revisions, ...p }) => p);
|
const metadata = b.positions.map(({ revisions, ...p }) => p);
|
||||||
const data: Record<string, unknown> = {
|
const data: Record<string, unknown> = {
|
||||||
'settings.json': { baseCurrency: b.baseCurrency, preferences: b.preferences },
|
'settings.json': {
|
||||||
|
baseCurrency: b.baseCurrency,
|
||||||
|
preferences: b.preferences,
|
||||||
|
metalPrices: b.metalPrices,
|
||||||
|
},
|
||||||
'currencies.json': b.currencies,
|
'currencies.json': b.currencies,
|
||||||
'accounts.json': metadata.filter((p) => p.kind === 'account'),
|
'accounts.json': metadata.filter((p) => p.kind === 'account'),
|
||||||
'assets.json': metadata.filter((p) => p.kind === 'asset'),
|
'assets.json': metadata.filter((p) => p.kind === 'asset'),
|
||||||
@@ -44,7 +48,7 @@ export function packBackup(b: Backup) {
|
|||||||
contents['manifest.json'] = JSON.stringify(
|
contents['manifest.json'] = JSON.stringify(
|
||||||
{
|
{
|
||||||
format: 'worthpath',
|
format: 'worthpath',
|
||||||
version: 7,
|
version: 8,
|
||||||
exportedAt: b.exportedAt,
|
exportedAt: b.exportedAt,
|
||||||
files: files.map((name) => ({ name, sha256: sha(contents[name]) })),
|
files: files.map((name) => ({ name, sha256: sha(contents[name]) })),
|
||||||
},
|
},
|
||||||
@@ -119,7 +123,14 @@ export async function readBackupZip(input: string | Buffer): Promise<unknown> {
|
|||||||
const manifest = z
|
const manifest = z
|
||||||
.object({
|
.object({
|
||||||
format: z.literal('worthpath'),
|
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(),
|
exportedAt: z.iso.datetime(),
|
||||||
files: z
|
files: z
|
||||||
.array(
|
.array(
|
||||||
@@ -146,6 +157,7 @@ export async function readBackupZip(input: string | Buffer): Promise<unknown> {
|
|||||||
const settings = z
|
const settings = z
|
||||||
.object({
|
.object({
|
||||||
baseCurrency: z.string(),
|
baseCurrency: z.string(),
|
||||||
|
metalPrices: z.array(z.record(z.string(), z.unknown())).optional(),
|
||||||
preferences: z
|
preferences: z
|
||||||
.object({
|
.object({
|
||||||
showSidebar: z.boolean().optional(),
|
showSidebar: z.boolean().optional(),
|
||||||
@@ -154,6 +166,7 @@ export async function readBackupZip(input: string | Buffer): Promise<unknown> {
|
|||||||
sessionHours: sessionHours.optional(),
|
sessionHours: sessionHours.optional(),
|
||||||
requireHiddenPassword: z.boolean().optional(),
|
requireHiddenPassword: z.boolean().optional(),
|
||||||
overviewCards: overviewCards.optional(),
|
overviewCards: overviewCards.optional(),
|
||||||
|
includeIndependentAssets: z.boolean().optional(),
|
||||||
showNotes: z.boolean().optional(),
|
showNotes: z.boolean().optional(),
|
||||||
idleMinutes: z.number().int().min(0).max(1440),
|
idleMinutes: z.number().int().min(0).max(1440),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -151,7 +151,7 @@ test('borrow/lend/collect/repay pair balances atomically and survive concurrency
|
|||||||
200,
|
200,
|
||||||
);
|
);
|
||||||
const backup = (await call(a.token, '/backup')).data;
|
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.equal(backup.transfers.length, 5);
|
||||||
assert.ok(backup.transfers.some((p: any) => p.operation === 'lend' && p.fee === '-2'));
|
assert.ok(backup.transfers.some((p: any) => p.operation === 'lend' && p.fee === '-2'));
|
||||||
assert.equal(
|
assert.equal(
|
||||||
|
|||||||
@@ -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<void>((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();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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);
|
assert.equal((await call('/settings', a.cookie, 'PATCH', { showNotes: 'false' })).status, 400);
|
||||||
const debt = await position(a.cookie, '信用卡', '10', 'CNY', 'liability');
|
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(
|
assert.equal(
|
||||||
(
|
(await call('/transfers/' + creditTransfer.data.id, a.cookie, 'DELETE')).status,
|
||||||
await call('/transfers', a.cookie, 'POST', {
|
200,
|
||||||
...request,
|
|
||||||
requestId: randomUUID(),
|
|
||||||
targetId: debt,
|
|
||||||
})
|
|
||||||
).status,
|
|
||||||
400,
|
|
||||||
);
|
);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
(
|
(
|
||||||
|
|||||||
+51
-21
@@ -54,6 +54,7 @@ import { orderedGroups, mergeGroupOrder } from './group-order';
|
|||||||
import { Calendar } from './Calendar';
|
import { Calendar } from './Calendar';
|
||||||
import { SchedulePanel } from './SchedulePanel';
|
import { SchedulePanel } from './SchedulePanel';
|
||||||
import { IconPicker } from './IconPicker';
|
import { IconPicker } from './IconPicker';
|
||||||
|
import { MetalPanel } from './MetalPanel';
|
||||||
import { QuickTransfer } from './QuickTransfer';
|
import { QuickTransfer } from './QuickTransfer';
|
||||||
import { TransferForm } from './TransferForm';
|
import { TransferForm } from './TransferForm';
|
||||||
import { DebtPaymentForm, operationLabel, type DebtOperation } from './DebtPaymentForm';
|
import { DebtPaymentForm, operationLabel, type DebtOperation } from './DebtPaymentForm';
|
||||||
@@ -259,11 +260,7 @@ export default function App() {
|
|||||||
setQuickTransfer((current) =>
|
setQuickTransfer((current) =>
|
||||||
current &&
|
current &&
|
||||||
[current.sourceId, current.targetId].some(
|
[current.sourceId, current.targetId].some(
|
||||||
(id) =>
|
(id) => id && !positions.some((p) => p.id === id && p.kind === 'account' && !p.archived),
|
||||||
id &&
|
|
||||||
!positions.some(
|
|
||||||
(p) => p.id === id && p.kind === 'account' && p.side === 'asset' && !p.archived,
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
? null
|
? null
|
||||||
: current,
|
: current,
|
||||||
@@ -271,8 +268,8 @@ export default function App() {
|
|||||||
}, [positions]);
|
}, [positions]);
|
||||||
function chooseQuickAccount(p: Position) {
|
function chooseQuickAccount(p: Position) {
|
||||||
if (busy || loading) return;
|
if (busy || loading) return;
|
||||||
if (p.kind !== 'account' || p.side !== 'asset' || p.archived) {
|
if (p.kind !== 'account' || p.archived) {
|
||||||
setError(tr('请选择启用的资产账户'));
|
setError(tr('请选择启用的账户'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setQuickTransfer((current) => {
|
setQuickTransfer((current) => {
|
||||||
@@ -1152,7 +1149,8 @@ export default function App() {
|
|||||||
<span>
|
<span>
|
||||||
{tr(labels[k])}
|
{tr(labels[k])}
|
||||||
<small>
|
<small>
|
||||||
{overview.items.filter((p) => p.kind === k).length} {tr('个项目')}
|
{overview.items.filter((p) => p.included && p.kind === k).length}{' '}
|
||||||
|
{tr('个项目')}
|
||||||
</small>
|
</small>
|
||||||
</span>
|
</span>
|
||||||
<ArrowUpRight size={18} />
|
<ArrowUpRight size={18} />
|
||||||
@@ -1274,10 +1272,14 @@ export default function App() {
|
|||||||
: tr('缺少汇率')}
|
: tr('缺少汇率')}
|
||||||
</p>
|
</p>
|
||||||
<p className="notes-display">{p.notes || tr('暂无备注')}</p>
|
<p className="notes-display">{p.notes || tr('暂无备注')}</p>
|
||||||
|
{(p.included === false ||
|
||||||
|
(p.kind === 'asset' && user.includeIndependentAssets === false)) && (
|
||||||
|
<span className="badge">{tr('不计入总览')}</span>
|
||||||
|
)}
|
||||||
<small>{tr('币种和资产负债属性创建后固定。归档项目仍计入总览。')}</small>
|
<small>{tr('币种和资产负债属性创建后固定。归档项目仍计入总览。')}</small>
|
||||||
</div>
|
</div>
|
||||||
<div className="actions">
|
<div className="actions">
|
||||||
{p.kind === 'account' && p.side === 'asset' && !p.archived && (
|
{p.kind === 'account' && !p.archived && (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
className="primary"
|
className="primary"
|
||||||
@@ -1286,6 +1288,7 @@ export default function App() {
|
|||||||
{tr('转账')}
|
{tr('转账')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
hidden={p.side !== 'asset'}
|
||||||
className="secondary"
|
className="secondary"
|
||||||
onClick={() => setModal({ kind: 'debt-payment', p })}
|
onClick={() => setModal({ kind: 'debt-payment', p })}
|
||||||
>
|
>
|
||||||
@@ -1342,6 +1345,9 @@ export default function App() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
{p.kind === 'asset' && p.category === 'gold' && (
|
||||||
|
<MetalPanel position={p} refresh={() => load(false, true)} />
|
||||||
|
)}
|
||||||
{p.kind === 'debt' && (
|
{p.kind === 'debt' && (
|
||||||
<section className="panel">
|
<section className="panel">
|
||||||
<div className="panel-title">
|
<div className="panel-title">
|
||||||
@@ -1479,9 +1485,7 @@ export default function App() {
|
|||||||
? quickTransfer.sourceId === p.id || quickTransfer.targetId === p.id
|
? quickTransfer.sourceId === p.id || quickTransfer.targetId === p.id
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
disabled={
|
disabled={busy || (!!quickTransfer && p.archived)}
|
||||||
busy || (!!quickTransfer && (p.side !== 'asset' || p.archived))
|
|
||||||
}
|
|
||||||
key={p.id}
|
key={p.id}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
quickTransfer !== null && page === 'account' && quickMode
|
quickTransfer !== null && page === 'account' && quickMode
|
||||||
@@ -1507,6 +1511,10 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
<h3>{p.name}</h3>
|
<h3>{p.name}</h3>
|
||||||
{p.groupName && <span className="badge">{p.groupName}</span>}
|
{p.groupName && <span className="badge">{p.groupName}</span>}
|
||||||
|
{(p.included === false ||
|
||||||
|
(p.kind === 'asset' && user.includeIndependentAssets === false)) && (
|
||||||
|
<span className="badge">{tr('不计入总览')}</span>
|
||||||
|
)}
|
||||||
<p>
|
<p>
|
||||||
{categoryLabel(p)} ·{' '}
|
{categoryLabel(p)} ·{' '}
|
||||||
{p.kind === 'debt'
|
{p.kind === 'debt'
|
||||||
@@ -1555,9 +1563,7 @@ export default function App() {
|
|||||||
disabled={
|
disabled={
|
||||||
busy ||
|
busy ||
|
||||||
loading ||
|
loading ||
|
||||||
positions.filter(
|
positions.filter((p) => p.kind === 'account' && !p.archived).length < 2
|
||||||
(p) => p.kind === 'account' && p.side === 'asset' && !p.archived,
|
|
||||||
).length < 2
|
|
||||||
}
|
}
|
||||||
onClick={() => setQuickTransfer({})}
|
onClick={() => setQuickTransfer({})}
|
||||||
>
|
>
|
||||||
@@ -1729,9 +1735,7 @@ export default function App() {
|
|||||||
{page === 'schedules' && (
|
{page === 'schedules' && (
|
||||||
<section className="panel">
|
<section className="panel">
|
||||||
<SchedulePanel
|
<SchedulePanel
|
||||||
accounts={positions.filter(
|
accounts={positions.filter((p) => p.kind === 'account' && !p.archived)}
|
||||||
(p) => p.kind === 'account' && p.side === 'asset' && !p.archived,
|
|
||||||
)}
|
|
||||||
refresh={() => load(false, true)}
|
refresh={() => load(false, true)}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
@@ -1898,12 +1902,26 @@ export default function App() {
|
|||||||
'schedules',
|
'schedules',
|
||||||
].filter((key) => !f.has('menu_' + key)),
|
].filter((key) => !f.has('menu_' + key)),
|
||||||
showNotes: f.has('showNotes'),
|
showNotes: f.has('showNotes'),
|
||||||
|
includeIndependentAssets: f.has('includeIndependentAssets'),
|
||||||
overviewCards: allOverviewCards.filter((key) => f.has('card_' + key)),
|
overviewCards: allOverviewCards.filter((key) => f.has('card_' + key)),
|
||||||
}),
|
}),
|
||||||
tr('设置已保存'),
|
tr('设置已保存'),
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<label className="switch-row">
|
||||||
|
<span>{tr('独立资产计入总览')}</span>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
role="switch"
|
||||||
|
name="includeIndependentAssets"
|
||||||
|
defaultChecked={user.includeIndependentAssets !== false}
|
||||||
|
key={String(user.includeIndependentAssets)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<p className="muted">
|
||||||
|
{tr('关闭后,独立资产不参与总额和净资产轨迹,资料与历史仍保留。')}
|
||||||
|
</p>
|
||||||
<h3>{tr('导航菜单')}</h3>
|
<h3>{tr('导航菜单')}</h3>
|
||||||
<p className="muted">
|
<p className="muted">
|
||||||
{tr('选择需要显示的菜单项。设置入口始终保留,隐藏仅影响导航,不删除数据。')}
|
{tr('选择需要显示的菜单项。设置入口始终保留,隐藏仅影响导航,不删除数据。')}
|
||||||
@@ -2136,6 +2154,7 @@ export default function App() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
{settingsSection === 'rates' && <MetalPanel refresh={() => load(false, true)} />}
|
||||||
<section className="panel" hidden={settingsSection !== 'clear'}>
|
<section className="panel" hidden={settingsSection !== 'clear'}>
|
||||||
<h2 className="danger-text">{tr('清空本账号数据')}</h2>
|
<h2 className="danger-text">{tr('清空本账号数据')}</h2>
|
||||||
<p className="muted">
|
<p className="muted">
|
||||||
@@ -2546,9 +2565,7 @@ export default function App() {
|
|||||||
<p>{tr('正在刷新数据…')}</p>
|
<p>{tr('正在刷新数据…')}</p>
|
||||||
) : modal.kind === 'transfer' ? (
|
) : modal.kind === 'transfer' ? (
|
||||||
<TransferForm
|
<TransferForm
|
||||||
accounts={movementPositions.filter(
|
accounts={movementPositions.filter((p) => p.kind === 'account' && !p.archived)}
|
||||||
(p) => p.kind === 'account' && p.side === 'asset' && !p.archived,
|
|
||||||
)}
|
|
||||||
initialSourceId={modal.p?.kind === 'account' ? modal.p.id : undefined}
|
initialSourceId={modal.p?.kind === 'account' ? modal.p.id : undefined}
|
||||||
busy={busy}
|
busy={busy}
|
||||||
submit={(v) =>
|
submit={(v) =>
|
||||||
@@ -2621,6 +2638,7 @@ export default function App() {
|
|||||||
...(f.has('iconId') ? { iconId: f.get('iconId') || null } : {}),
|
...(f.has('iconId') ? { iconId: f.get('iconId') || null } : {}),
|
||||||
archived: mp!.archived,
|
archived: mp!.archived,
|
||||||
hidden: f.get('hidden') === 'on',
|
hidden: f.get('hidden') === 'on',
|
||||||
|
included: f.get('included') === 'on',
|
||||||
}),
|
}),
|
||||||
tr('项目资料已保存'),
|
tr('项目资料已保存'),
|
||||||
);
|
);
|
||||||
@@ -2641,6 +2659,7 @@ export default function App() {
|
|||||||
side,
|
side,
|
||||||
amount: accountInputAmount({ kind, side }, String(v.amount)),
|
amount: accountInputAmount({ kind, side }, String(v.amount)),
|
||||||
hidden: f.get('hidden') === 'on',
|
hidden: f.get('hidden') === 'on',
|
||||||
|
included: f.get('included') === 'on',
|
||||||
}),
|
}),
|
||||||
tr('项目已添加'),
|
tr('项目已添加'),
|
||||||
);
|
);
|
||||||
@@ -2660,6 +2679,17 @@ export default function App() {
|
|||||||
)}
|
)}
|
||||||
{['account', 'asset', 'debt', 'edit'].includes(modal.kind) && (
|
{['account', 'asset', 'debt', 'edit'].includes(modal.kind) && (
|
||||||
<>
|
<>
|
||||||
|
<label className="check-line">
|
||||||
|
<input
|
||||||
|
name="included"
|
||||||
|
type="checkbox"
|
||||||
|
defaultChecked={modal.p?.included !== false}
|
||||||
|
/>
|
||||||
|
{tr('计入资产总览')}
|
||||||
|
</label>
|
||||||
|
<p className="muted">
|
||||||
|
{tr('取消后不参与资产、负债及净资产轨迹,仍可管理和转账。')}
|
||||||
|
</p>
|
||||||
<label className="check-line">
|
<label className="check-line">
|
||||||
<input name="hidden" type="checkbox" defaultChecked={modal.p?.hidden} />
|
<input name="hidden" type="checkbox" defaultChecked={modal.p?.hidden} />
|
||||||
{tr('隐藏此项目(密码验证后可查看和编辑)')}
|
{tr('隐藏此项目(密码验证后可查看和编辑)')}
|
||||||
|
|||||||
@@ -0,0 +1,256 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { api, money, type Position, currencies } from './api';
|
||||||
|
import { t as tr } from './i18n';
|
||||||
|
import { useToast } from './Toast';
|
||||||
|
type Quote = { metalType: string; currency: string; price: string; date: string; source: string };
|
||||||
|
type Data = { status: { state: string; message: string }; prices: Quote[] };
|
||||||
|
export function MetalPanel({
|
||||||
|
position,
|
||||||
|
refresh,
|
||||||
|
}: {
|
||||||
|
position?: Position;
|
||||||
|
refresh: () => Promise<void>;
|
||||||
|
}) {
|
||||||
|
const [data, setData] = useState<Data>(),
|
||||||
|
[busy, setBusy] = useState(false),
|
||||||
|
[error, setError] = useState(''),
|
||||||
|
[message, setMessage] = useState('');
|
||||||
|
useToast(error, 'error');
|
||||||
|
useToast(message, 'success');
|
||||||
|
async function load() {
|
||||||
|
setData(await api<Data>('/metals'));
|
||||||
|
}
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true;
|
||||||
|
void api<Data>('/metals')
|
||||||
|
.then((v) => {
|
||||||
|
if (active) setData(v);
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
if (active) setError(e.message);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
};
|
||||||
|
}, [position]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (data?.status.state !== 'updating') return;
|
||||||
|
let active = true;
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
void api<Data>('/metals')
|
||||||
|
.then((v) => {
|
||||||
|
if (active) setData(v);
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
if (active) {
|
||||||
|
setError(e.message);
|
||||||
|
clearInterval(timer);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, 2000);
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
clearInterval(timer);
|
||||||
|
};
|
||||||
|
}, [data?.status.state]);
|
||||||
|
async function act(work: () => Promise<unknown>, message: string) {
|
||||||
|
setBusy(true);
|
||||||
|
setError('');
|
||||||
|
setMessage('');
|
||||||
|
try {
|
||||||
|
await work();
|
||||||
|
await load();
|
||||||
|
await refresh();
|
||||||
|
setMessage(tr(message));
|
||||||
|
} catch (e) {
|
||||||
|
setError((e as Error).message);
|
||||||
|
await load().catch(() => {});
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const quote = data?.prices.find(
|
||||||
|
(q) => q.metalType === position?.metalType && q.currency === position?.currency,
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<section className="panel">
|
||||||
|
<h2>{tr('贵金属估价')}</h2>
|
||||||
|
<p className="muted">
|
||||||
|
{tr(
|
||||||
|
'黄金与白银按重量、纯度及每克参考价估值。每日尝试更新,失败保留旧价;参考价不含工费与回收折价。',
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
{position && (
|
||||||
|
<form
|
||||||
|
key={
|
||||||
|
position.id +
|
||||||
|
String(position.metalGrams) +
|
||||||
|
String(position.autoValuation) +
|
||||||
|
String(position.metalPurity) +
|
||||||
|
String(position.metalType)
|
||||||
|
}
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const f = new FormData(e.currentTarget);
|
||||||
|
void act(
|
||||||
|
() =>
|
||||||
|
api('/metals/' + position.id, 'PUT', {
|
||||||
|
metalType: f.get('metalType'),
|
||||||
|
metalGrams: f.get('metalGrams'),
|
||||||
|
metalPurity: f.get('metalPurity'),
|
||||||
|
autoValuation: f.has('autoValuation'),
|
||||||
|
}),
|
||||||
|
'贵金属估价设置已保存',
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<label>
|
||||||
|
{tr('贵金属品种')}
|
||||||
|
<select name="metalType" defaultValue={position.metalType || 'gold'}>
|
||||||
|
<option value="gold">{tr('黄金')}</option>
|
||||||
|
<option value="silver">{tr('白银')}</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
{tr('重量(克)')}
|
||||||
|
<input
|
||||||
|
name="metalGrams"
|
||||||
|
required
|
||||||
|
inputMode="decimal"
|
||||||
|
pattern="(0|[1-9][0-9]{0,15})([.][0-9]{1,8})?"
|
||||||
|
defaultValue={position.metalGrams || ''}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
{tr('纯度(0–1,例如 0.999)')}
|
||||||
|
<input
|
||||||
|
name="metalPurity"
|
||||||
|
required
|
||||||
|
type="number"
|
||||||
|
min="0.00000001"
|
||||||
|
max="1"
|
||||||
|
step="0.00000001"
|
||||||
|
defaultValue={position.metalPurity || '1'}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="check-line">
|
||||||
|
<input type="checkbox" name="autoValuation" defaultChecked={position.autoValuation} />
|
||||||
|
{tr('随参考价自动记录估值')}
|
||||||
|
</label>
|
||||||
|
<button className="primary" disabled={busy || position.archived}>
|
||||||
|
{tr('保存估价设置')}
|
||||||
|
</button>
|
||||||
|
<p className="muted">
|
||||||
|
{tr('开启后,每日价格更新会新增估值历史;已有历史不会改写。关闭后可手动应用估价。')}
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
<div className="actions">
|
||||||
|
<button
|
||||||
|
className="secondary"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => void act(() => api('/metals/refresh', 'POST', {}), '贵金属参考价已更新')}
|
||||||
|
>
|
||||||
|
{tr('刷新贵金属价格')}
|
||||||
|
</button>
|
||||||
|
{position && (
|
||||||
|
<button
|
||||||
|
className="primary"
|
||||||
|
disabled={busy || !quote || position.archived}
|
||||||
|
onClick={() =>
|
||||||
|
void act(() => api('/metals/' + position.id + '/value', 'POST', {}), '估价已记入历史')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{tr('应用估价')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{quote && (
|
||||||
|
<p>
|
||||||
|
{tr('参考价')}:{money(quote.price, quote.currency)}/{tr('克')} ·{' '}
|
||||||
|
{quote.date.slice(0, 10)} · {quote.source === 'manual' ? tr('手动') : 'Gold API'}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p className={data?.status.state === 'error' ? 'danger-text' : 'muted'}>
|
||||||
|
{tr(data?.status.message || '尚未尝试更新')}
|
||||||
|
</p>
|
||||||
|
<details>
|
||||||
|
<summary>{tr('手动录入贵金属价格')}</summary>
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const f = new FormData(e.currentTarget);
|
||||||
|
void act(
|
||||||
|
() => api('/metals/prices', 'POST', Object.fromEntries(f)),
|
||||||
|
'贵金属价格已保存',
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<label>
|
||||||
|
{tr('贵金属品种')}
|
||||||
|
<select name="metalType" defaultValue={position?.metalType || 'gold'}>
|
||||||
|
<option value="gold">{tr('黄金')}</option>
|
||||||
|
<option value="silver">{tr('白银')}</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
{tr('币种')}
|
||||||
|
<select name="currency" defaultValue={position?.currency || 'CNY'}>
|
||||||
|
{currencies.map((c) => (
|
||||||
|
<option key={c}>{c}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
{tr('每克纯金属价格')}
|
||||||
|
<input
|
||||||
|
name="price"
|
||||||
|
required
|
||||||
|
inputMode="decimal"
|
||||||
|
pattern="(0|[1-9][0-9]{0,11})([.][0-9]{1,12})?"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
{tr('报价日期')}
|
||||||
|
<input
|
||||||
|
name="date"
|
||||||
|
type="date"
|
||||||
|
required
|
||||||
|
max={new Date(Date.now() + 8 * 3600000).toISOString().slice(0, 10)}
|
||||||
|
defaultValue={new Date(Date.now() + 8 * 3600000).toISOString().slice(0, 10)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button className="primary" disabled={busy}>
|
||||||
|
{tr('保存参考价')}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</details>
|
||||||
|
{!position && (
|
||||||
|
<div className="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{tr('贵金属品种')}</th>
|
||||||
|
<th>{tr('参考价')}</th>
|
||||||
|
<th>{tr('报价日期')}</th>
|
||||||
|
<th>{tr('来源')}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{data?.prices.map((q) => (
|
||||||
|
<tr key={q.metalType + q.currency + q.date}>
|
||||||
|
<td>{tr(q.metalType === 'gold' ? '黄金' : '白银')}</td>
|
||||||
|
<td>
|
||||||
|
{money(q.price, q.currency)}/{tr('克')}
|
||||||
|
</td>
|
||||||
|
<td>{q.date.slice(0, 10)}</td>
|
||||||
|
<td>{q.source === 'manual' ? tr('手动') : 'Gold API'}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { money, nowMinute, requestUuid, type Position } from './api';
|
import { money, positionAmount, nowMinute, requestUuid, type Position } from './api';
|
||||||
import { t as tr } from './i18n';
|
import { t as tr } from './i18n';
|
||||||
export function QuickTransfer({
|
export function QuickTransfer({
|
||||||
source,
|
source,
|
||||||
@@ -40,7 +40,7 @@ export function QuickTransfer({
|
|||||||
<div>
|
<div>
|
||||||
<small>{tr('转出账户')}</small>
|
<small>{tr('转出账户')}</small>
|
||||||
<strong>{source.name}</strong>
|
<strong>{source.name}</strong>
|
||||||
<span>{money(source.amount, source.currency)}</span>
|
<span>{money(positionAmount(source), source.currency)}</span>
|
||||||
<button type="button" className="text" disabled={busy} onClick={changeSource}>
|
<button type="button" className="text" disabled={busy} onClick={changeSource}>
|
||||||
{tr('重选')}
|
{tr('重选')}
|
||||||
</button>
|
</button>
|
||||||
@@ -49,7 +49,7 @@ export function QuickTransfer({
|
|||||||
<div>
|
<div>
|
||||||
<small>{tr('转入账户')}</small>
|
<small>{tr('转入账户')}</small>
|
||||||
<strong>{target.name}</strong>
|
<strong>{target.name}</strong>
|
||||||
<span>{money(target.amount, target.currency)}</span>
|
<span>{money(positionAmount(target), target.currency)}</span>
|
||||||
<button type="button" className="text" disabled={busy} onClick={changeTarget}>
|
<button type="button" className="text" disabled={busy} onClick={changeTarget}>
|
||||||
{tr('重选')}
|
{tr('重选')}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -47,7 +47,11 @@ export function SchedulePanel({
|
|||||||
[source, setSource] = useState(''),
|
[source, setSource] = useState(''),
|
||||||
[target, setTarget] = useState(''),
|
[target, setTarget] = useState(''),
|
||||||
[amount, setAmount] = useState('');
|
[amount, setAmount] = useState('');
|
||||||
const sourceId = accounts.some((p) => p.id === source) ? source : accounts[0]?.id || '';
|
const sourceAccounts =
|
||||||
|
operation === 'expense' ? accounts.filter((p) => p.side === 'asset') : accounts;
|
||||||
|
const sourceId = sourceAccounts.some((p) => p.id === source)
|
||||||
|
? source
|
||||||
|
: sourceAccounts[0]?.id || '';
|
||||||
const sourceAccount = accounts.find((p) => p.id === sourceId),
|
const sourceAccount = accounts.find((p) => p.id === sourceId),
|
||||||
targetAccount = accounts.find((p) => p.id === target);
|
targetAccount = accounts.find((p) => p.id === target);
|
||||||
const same = sourceAccount?.currency === targetAccount?.currency;
|
const same = sourceAccount?.currency === targetAccount?.currency;
|
||||||
@@ -173,7 +177,7 @@ export function SchedulePanel({
|
|||||||
{tr('付款账户')}
|
{tr('付款账户')}
|
||||||
<AccountPicker
|
<AccountPicker
|
||||||
name="sourceId"
|
name="sourceId"
|
||||||
accounts={accounts}
|
accounts={sourceAccounts}
|
||||||
value={sourceId}
|
value={sourceId}
|
||||||
onChange={(id) => {
|
onChange={(id) => {
|
||||||
setSource(id);
|
setSource(id);
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export function TransferForm({
|
|||||||
submit(Object.fromEntries(new FormData(e.currentTarget)));
|
submit(Object.fromEntries(new FormData(e.currentTarget)));
|
||||||
}
|
}
|
||||||
if (accounts.length < 2)
|
if (accounts.length < 2)
|
||||||
return <p>{tr('请先创建至少两个启用中的资产账户。隐藏账户需验证密码后才能选择。')}</p>;
|
return <p>{tr('请先创建至少两个启用中的账户。隐藏账户需验证密码后才能选择。')}</p>;
|
||||||
return (
|
return (
|
||||||
<form onSubmit={save} className="transfer-form">
|
<form onSubmit={save} className="transfer-form">
|
||||||
<input type="hidden" name="requestId" value={requestId} />
|
<input type="hidden" name="requestId" value={requestId} />
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export type User = {
|
|||||||
sessionHours?: number;
|
sessionHours?: number;
|
||||||
requireHiddenPassword?: boolean;
|
requireHiddenPassword?: boolean;
|
||||||
overviewCards?: string[];
|
overviewCards?: string[];
|
||||||
|
includeIndependentAssets?: boolean;
|
||||||
sessionExpiresAt?: string;
|
sessionExpiresAt?: string;
|
||||||
revealed?: boolean;
|
revealed?: boolean;
|
||||||
revealUntil?: string | null;
|
revealUntil?: string | null;
|
||||||
@@ -50,6 +51,11 @@ export type History = {
|
|||||||
reason: string;
|
reason: string;
|
||||||
};
|
};
|
||||||
export type Position = {
|
export type Position = {
|
||||||
|
included?: boolean;
|
||||||
|
metalType?: string | null;
|
||||||
|
metalGrams?: string | null;
|
||||||
|
metalPurity?: string;
|
||||||
|
autoValuation?: boolean;
|
||||||
groupName?: string;
|
groupName?: string;
|
||||||
iconId?: string | null;
|
iconId?: string | null;
|
||||||
id: string;
|
id: string;
|
||||||
@@ -83,6 +89,7 @@ export type Overview = Total & {
|
|||||||
recent: History[];
|
recent: History[];
|
||||||
items: {
|
items: {
|
||||||
id: string;
|
id: string;
|
||||||
|
included: boolean;
|
||||||
kind: string;
|
kind: string;
|
||||||
converted: string | null;
|
converted: string | null;
|
||||||
rateDate: string | null;
|
rateDate: string | null;
|
||||||
|
|||||||
@@ -519,5 +519,37 @@
|
|||||||
"关闭后,已登录账号可直接显示隐藏资产。每次显示持续 5 分钟,也可手动锁定;保存此设置会锁定所有会话的隐藏资产。": "When disabled, a signed-in account can reveal hidden assets directly for 5 minutes. You can lock them sooner. Saving this setting locks hidden assets in all sessions.",
|
"关闭后,已登录账号可直接显示隐藏资产。每次显示持续 5 分钟,也可手动锁定;保存此设置会锁定所有会话的隐藏资产。": "When disabled, a signed-in account can reveal hidden assets directly for 5 minutes. You can lock them sooner. Saving this setting locks hidden assets in all sessions.",
|
||||||
"隐藏资产已显示": "hidden assets revealed",
|
"隐藏资产已显示": "hidden assets revealed",
|
||||||
"显示隐藏资产": "Reveal hidden assets",
|
"显示隐藏资产": "Reveal hidden assets",
|
||||||
"当前包含隐藏资产 · 5 分钟后自动锁定": "hidden assets included · Locks automatically in 5 minutes"
|
"当前包含隐藏资产 · 5 分钟后自动锁定": "hidden assets included · Locks automatically in 5 minutes",
|
||||||
|
"独立资产计入总览": "Include standalone assets in overview",
|
||||||
|
"关闭后,独立资产不参与总额和净资产轨迹,资料与历史仍保留。": "Exclude standalone assets from totals and net worth history while keeping their records.",
|
||||||
|
"计入资产总览": "Include in portfolio totals",
|
||||||
|
"取消后不参与资产、负债及净资产轨迹,仍可管理和转账。": "Exclude from assets, liabilities and net worth history; management and transfers remain available.",
|
||||||
|
"不计入总览": "Excluded from totals",
|
||||||
|
"贵金属估价": "Precious metal valuation",
|
||||||
|
"黄金与白银按重量、纯度及每克参考价估值。每日尝试更新,失败保留旧价;参考价不含工费与回收折价。": "Value gold and silver by weight, purity and reference price per gram. Daily updates keep previous prices on failure; workmanship and resale discounts are excluded.",
|
||||||
|
"贵金属品种": "Metal",
|
||||||
|
"黄金": "Gold",
|
||||||
|
"白银": "Silver",
|
||||||
|
"重量(克)": "Weight (grams)",
|
||||||
|
"纯度(0–1,例如 0.999)": "Purity (0–1, e.g. 0.999)",
|
||||||
|
"随参考价自动记录估值": "Record valuations automatically with price updates",
|
||||||
|
"保存估价设置": "Save valuation settings",
|
||||||
|
"开启后,每日价格更新会新增估值历史;已有历史不会改写。关闭后可手动应用估价。": "Automatic updates add valuation history and preserve earlier records. You can also apply valuations manually.",
|
||||||
|
"刷新贵金属价格": "Refresh metal prices",
|
||||||
|
"应用估价": "Apply valuation",
|
||||||
|
"参考价": "Reference price",
|
||||||
|
"克": "g",
|
||||||
|
"手动录入贵金属价格": "Enter a metal price manually",
|
||||||
|
"每克纯金属价格": "Pure metal price per gram",
|
||||||
|
"报价日期": "Quote date",
|
||||||
|
"保存参考价": "Save reference price",
|
||||||
|
"贵金属估价设置已保存": "Valuation settings saved",
|
||||||
|
"贵金属参考价已更新": "Metal prices updated",
|
||||||
|
"估价已记入历史": "Valuation recorded",
|
||||||
|
"贵金属价格已保存": "Metal price saved",
|
||||||
|
"当前没有已设置重量的贵金属资产": "No configured precious metal holdings",
|
||||||
|
"正在更新贵金属参考价": "Updating reference prices",
|
||||||
|
"贵金属价格更新失败,已有价格与估值已保留,可手动录价": "Price update failed. Previous prices and values are preserved; you can enter prices manually.",
|
||||||
|
"请先创建至少两个启用中的账户。隐藏账户需验证密码后才能选择。": "Create at least two active accounts. Unlock hidden accounts before selecting them.",
|
||||||
|
"请选择启用的账户": "Choose an active account"
|
||||||
}
|
}
|
||||||
@@ -519,5 +519,37 @@
|
|||||||
"关闭后,已登录账号可直接显示隐藏资产。每次显示持续 5 分钟,也可手动锁定;保存此设置会锁定所有会话的隐藏资产。": "關閉後,已登入帳號可直接顯示隱藏資產。每次顯示持續 5 分鐘,也可手動鎖定;儲存此設定會鎖定所有會話的隱藏資產。",
|
"关闭后,已登录账号可直接显示隐藏资产。每次显示持续 5 分钟,也可手动锁定;保存此设置会锁定所有会话的隐藏资产。": "關閉後,已登入帳號可直接顯示隱藏資產。每次顯示持續 5 分鐘,也可手動鎖定;儲存此設定會鎖定所有會話的隱藏資產。",
|
||||||
"隐藏资产已显示": "隱藏資產已顯示",
|
"隐藏资产已显示": "隱藏資產已顯示",
|
||||||
"显示隐藏资产": "顯示隱藏資產",
|
"显示隐藏资产": "顯示隱藏資產",
|
||||||
"当前包含隐藏资产 · 5 分钟后自动锁定": "目前包含隱藏資產 · 5 分鐘後自動鎖定"
|
"当前包含隐藏资产 · 5 分钟后自动锁定": "目前包含隱藏資產 · 5 分鐘後自動鎖定",
|
||||||
|
"独立资产计入总览": "獨立資產計入總覽",
|
||||||
|
"关闭后,独立资产不参与总额和净资产轨迹,资料与历史仍保留。": "關閉後,獨立資產不參與總額和淨資產軌跡,資料與歷史仍保留。",
|
||||||
|
"计入资产总览": "計入資產總覽",
|
||||||
|
"取消后不参与资产、负债及净资产轨迹,仍可管理和转账。": "取消後不參與資產、負債及淨資產軌跡,仍可管理和轉賬。",
|
||||||
|
"不计入总览": "不計入總覽",
|
||||||
|
"贵金属估价": "貴金屬估價",
|
||||||
|
"黄金与白银按重量、纯度及每克参考价估值。每日尝试更新,失败保留旧价;参考价不含工费与回收折价。": "黃金與白銀按重量、純度及每克參考價估值。每日嘗試更新,失敗保留舊價;參考價不含工費與回收折價。",
|
||||||
|
"贵金属品种": "貴金屬品種",
|
||||||
|
"黄金": "黃金",
|
||||||
|
"白银": "白銀",
|
||||||
|
"重量(克)": "重量(克)",
|
||||||
|
"纯度(0–1,例如 0.999)": "純度(0–1,例如 0.999)",
|
||||||
|
"随参考价自动记录估值": "隨參考價自動記錄估值",
|
||||||
|
"保存估价设置": "儲存估價設定",
|
||||||
|
"开启后,每日价格更新会新增估值历史;已有历史不会改写。关闭后可手动应用估价。": "開啟後,每日價格更新會新增估值歷史;已有歷史不會改寫。關閉後可手動應用估價。",
|
||||||
|
"刷新贵金属价格": "更新貴金屬價格",
|
||||||
|
"应用估价": "應用估價",
|
||||||
|
"参考价": "參考價",
|
||||||
|
"克": "克",
|
||||||
|
"手动录入贵金属价格": "手動輸入貴金屬價格",
|
||||||
|
"每克纯金属价格": "每克純金屬價格",
|
||||||
|
"报价日期": "報價日期",
|
||||||
|
"保存参考价": "儲存參考價",
|
||||||
|
"贵金属估价设置已保存": "貴金屬估價設定已儲存",
|
||||||
|
"贵金属参考价已更新": "貴金屬參考價已更新",
|
||||||
|
"估价已记入历史": "估價已記入歷史",
|
||||||
|
"贵金属价格已保存": "貴金屬價格已儲存",
|
||||||
|
"当前没有已设置重量的贵金属资产": "目前沒有已設定重量的貴金屬資產",
|
||||||
|
"正在更新贵金属参考价": "正在更新貴金屬參考價",
|
||||||
|
"贵金属价格更新失败,已有价格与估值已保留,可手动录价": "貴金屬價格更新失敗,已有價格與估值已保留,可手動輸入價格",
|
||||||
|
"请先创建至少两个启用中的账户。隐藏账户需验证密码后才能选择。": "請先建立至少兩個啟用中的賬戶。隱藏賬戶需驗證密碼後才能選擇。",
|
||||||
|
"请选择启用的账户": "請選擇啟用的賬戶"
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"tableCount": 9,
|
"tableCount": 10,
|
||||||
"columnCount": 92,
|
"columnCount": 106,
|
||||||
"tables": [
|
"tables": [
|
||||||
{
|
{
|
||||||
"TABLE_NAME": "exchangerate",
|
"TABLE_NAME": "exchangerate",
|
||||||
@@ -10,6 +10,10 @@
|
|||||||
"TABLE_NAME": "icon",
|
"TABLE_NAME": "icon",
|
||||||
"TABLE_COMMENT": "可复用账户图标"
|
"TABLE_COMMENT": "可复用账户图标"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"TABLE_NAME": "metalprice",
|
||||||
|
"TABLE_COMMENT": "用户的贵金属每克参考价历史"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"TABLE_NAME": "position",
|
"TABLE_NAME": "position",
|
||||||
"TABLE_COMMENT": "持有项目:账户、独立资产和借入借出债务"
|
"TABLE_COMMENT": "持有项目:账户、独立资产和借入借出债务"
|
||||||
@@ -115,6 +119,46 @@
|
|||||||
"COLUMN_NAME": "createdAt",
|
"COLUMN_NAME": "createdAt",
|
||||||
"COLUMN_COMMENT": "记录创建时间,UTC"
|
"COLUMN_COMMENT": "记录创建时间,UTC"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"TABLE_NAME": "metalprice",
|
||||||
|
"COLUMN_NAME": "id",
|
||||||
|
"COLUMN_COMMENT": "唯一标识"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"TABLE_NAME": "metalprice",
|
||||||
|
"COLUMN_NAME": "userId",
|
||||||
|
"COLUMN_COMMENT": "所属用户标识,用于数据隔离"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"TABLE_NAME": "metalprice",
|
||||||
|
"COLUMN_NAME": "metalType",
|
||||||
|
"COLUMN_COMMENT": "品种:gold 黄金或 silver 白银"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"TABLE_NAME": "metalprice",
|
||||||
|
"COLUMN_NAME": "currency",
|
||||||
|
"COLUMN_COMMENT": "价格币种"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"TABLE_NAME": "metalprice",
|
||||||
|
"COLUMN_NAME": "price",
|
||||||
|
"COLUMN_COMMENT": "纯金属每克价格"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"TABLE_NAME": "metalprice",
|
||||||
|
"COLUMN_NAME": "date",
|
||||||
|
"COLUMN_COMMENT": "报价日期,UTC+8 自然日"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"TABLE_NAME": "metalprice",
|
||||||
|
"COLUMN_NAME": "source",
|
||||||
|
"COLUMN_COMMENT": "价格来源:manual 或 goldapi"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"TABLE_NAME": "metalprice",
|
||||||
|
"COLUMN_NAME": "quotedAt",
|
||||||
|
"COLUMN_COMMENT": "报价对应时间,UTC"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"TABLE_NAME": "position",
|
"TABLE_NAME": "position",
|
||||||
"COLUMN_NAME": "id",
|
"COLUMN_NAME": "id",
|
||||||
@@ -190,6 +234,31 @@
|
|||||||
"COLUMN_NAME": "groupName",
|
"COLUMN_NAME": "groupName",
|
||||||
"COLUMN_COMMENT": "自定义账户分组"
|
"COLUMN_COMMENT": "自定义账户分组"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"TABLE_NAME": "position",
|
||||||
|
"COLUMN_NAME": "included",
|
||||||
|
"COLUMN_COMMENT": "是否参与资产负债总额及净资产轨迹"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"TABLE_NAME": "position",
|
||||||
|
"COLUMN_NAME": "metalType",
|
||||||
|
"COLUMN_COMMENT": "贵金属品种:gold 黄金或 silver 白银"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"TABLE_NAME": "position",
|
||||||
|
"COLUMN_NAME": "metalGrams",
|
||||||
|
"COLUMN_COMMENT": "贵金属总重量,单位克"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"TABLE_NAME": "position",
|
||||||
|
"COLUMN_NAME": "metalPurity",
|
||||||
|
"COLUMN_COMMENT": "贵金属纯度比例,0 至 1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"TABLE_NAME": "position",
|
||||||
|
"COLUMN_NAME": "autoValuation",
|
||||||
|
"COLUMN_COMMENT": "是否随每日价格更新自动记录估值"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"TABLE_NAME": "positionlink",
|
"TABLE_NAME": "positionlink",
|
||||||
"COLUMN_NAME": "id",
|
"COLUMN_NAME": "id",
|
||||||
@@ -499,6 +568,11 @@
|
|||||||
"TABLE_NAME": "user",
|
"TABLE_NAME": "user",
|
||||||
"COLUMN_NAME": "overviewCards",
|
"COLUMN_NAME": "overviewCards",
|
||||||
"COLUMN_COMMENT": "总览显示的卡片键列表,NULL 表示默认全部显示"
|
"COLUMN_COMMENT": "总览显示的卡片键列表,NULL 表示默认全部显示"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"TABLE_NAME": "user",
|
||||||
|
"COLUMN_NAME": "includeIndependentAssets",
|
||||||
|
"COLUMN_COMMENT": "独立资产是否参与资产总览和净资产轨迹"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 42 KiB |
@@ -0,0 +1,57 @@
|
|||||||
|
# 账户转账、计入总览与贵金属估价
|
||||||
|
|
||||||
|
完成与验收:2026-10-03 18:56(UTC+8)。
|
||||||
|
|
||||||
|
## 账户转账
|
||||||
|
|
||||||
|
普通转账、快速转账与计划转账均可选择银行、现金、投资、信用卡和贷款等启用账户。仍须属于当前用户、使用两个不同账户,隐藏账户先解锁。借入/借出本金操作与计划支出沿用原有校验。
|
||||||
|
|
||||||
|
信用卡等负债账户在数据库中以正数表示欠款,网页以负数显示;转出增加欠款,转入抵扣欠款,超过欠款后显示溢缴存款。手续费计入转出账户。双方余额在同一事务中更新,修改、删除、重放及备份校验使用相同方向。
|
||||||
|
|
||||||
|
## 是否计入资产总览
|
||||||
|
|
||||||
|
- 设置 → 显示与菜单 → 独立资产计入总览:全局控制独立资产是否参与总额。
|
||||||
|
- 新建或编辑账户、独立资产与债务 → 计入资产总览:逐项控制是否参与资产、负债、净资产及轨迹。默认开启;独立资产须同时开启全局与逐项开关。
|
||||||
|
- 取消计入不会删除项目、历史、关联或计划,也不限制转账。列表和详情显示“不计入总览”;变化记录、最近变化和收支日历继续保留实际记录。
|
||||||
|
- 当前开关作用于整个轨迹;它不是历史日期生效的配置。排除项目所缺少的汇率不会让总览变为不完整。
|
||||||
|
|
||||||
|
## 贵金属估价
|
||||||
|
|
||||||
|
独立资产选择“贵金属”,保存后进入详情,设置黄金或白银、克数、纯度比例(例如 0.999)。黄金与白银价格来自 [Gold API](https://gold-api.com/docs),美元报价按每金衡盎司 31.1034768 克换算,并通过现有 [Frankfurter](https://frankfurter.dev/) 公共日汇率转为资产原币。所有实际第三方接口及图标素材来源均已在 [README](../README.md#第三方接口与外部素材来源) 单独列明。
|
||||||
|
|
||||||
|
更新机制与汇率一致:进程运行时每小时检查、每日尝试一次,打开总览或估价区域也会按需触发;新配置会重新触发检查,支持手动刷新。固定请求不发送用户、账户、重量、金额或登录 Cookie。响应异常、超时或报价过期时保留已有报价与估值,并显示失败原因。休市日价格与日汇率日期可能不同。
|
||||||
|
|
||||||
|
默认关闭自动记录估值。点击“应用估价”记录当前时间的估值;开启“随参考价自动记录估值”后,更新价格自动新增估值历史,相同估值不重复记录,已有历史不改写。报价比最新余额观察日期更早时,自动更新不会覆盖该观察值。可手动录入原币每克纯金属价格;同日手动价格优先于公共报价。已开启自动估值且有可用报价时,保存估价设置或录价会同步更新余额、总览及历史;否则由“应用估价”记录。
|
||||||
|
|
||||||
|
估价使用 Decimal 精确计算,重量和金额最多 8 位小数、参考价最多 12 位小数。参考价不含工费、回收折价或买卖价差;其他金属仍可使用普通手动估值,不会被当作黄金自动计价。
|
||||||
|
|
||||||
|
## 数据库与备份
|
||||||
|
|
||||||
|
新增两项迁移:
|
||||||
|
|
||||||
|
- `20261003000300_inclusion_metals`:用户独立资产计入设置、逐项目计入设置、贵金属配置及 MetalPrice 表。
|
||||||
|
- `20261003000400_metal_source_comment`:将最终使用的价格源写入字段注释。
|
||||||
|
|
||||||
|
本机已应用,共 17 项迁移;10 张业务表、106 个字段注释均通过核验。已有项目默认计入总览,不改写原有余额符号。ZIP 导出为 v8,保留计入设置、重量、纯度、自动估值配置、参考价及估值历史,继续兼容旧 ZIP v3–v7 和 JSON v1/v2。价格与已有同日数据冲突时回滚导入。清空流程的备份摘要包含价格,价格发生变化后须重新下载备份。
|
||||||
|
|
||||||
|
其他环境更新:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
pnpm db:generate
|
||||||
|
pnpm db:migrate
|
||||||
|
pnpm build
|
||||||
|
```
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
|
||||||
|
35 项单元检查、17 项真实 MySQL 集成检查、前后端类型检查与生产构建通过。最终估值去重和首次配置触发更新另通过 4 项计算、失败保留及数据库专项回归。黄金、白银与固定币种公共汇率地址均实测返回 HTTP 200;集成测试验证同日手动价格不会被自动覆盖。
|
||||||
|
|
||||||
|
浏览器使用隔离模拟账号,验证信用卡快速转出 500 后显示欠款 -2500,日常账户变为 10500;贵金属应用估值生成历史;取消计入后总览净资产立即变为 8000;全局开关保存后状态持续保留。测试账号和模拟数据已清理,独立测试 API 已关闭。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
本轮只更新本地代码和数据库并创建本地 Git 提交,不 push;前端已有的 Vite 配置修改保留。
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 58 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 110 KiB |
@@ -1,10 +1,10 @@
|
|||||||
要求:
|
要求:
|
||||||
|
|
||||||
> 完成重要任务自行git commit,不需要push。
|
> * 完成重要任务自行git commit,不需要push。
|
||||||
> 完成一项任务后添加为这项目任务添加删除线,并写上完成时间
|
> * 完成一项任务后添加为这项目任务添加删除线,并写上完成时间
|
||||||
> 这份文档可能随时更新,在完成所有任务之后应判断文档是否加入新的更新要求
|
> * 这份文档可能随时更新,在完成所有任务之后应判断文档是否加入新的更新要求
|
||||||
> 同时需要继续更新docs文件夹或里面的文档
|
> * 同时需要继续更新docs文件夹或里面的文档
|
||||||
> 数据库的表和字段需要有注释
|
> * 数据库的表和字段需要有注释
|
||||||
|
|
||||||
更新要求:
|
更新要求:
|
||||||
|
|
||||||
@@ -46,7 +46,9 @@
|
|||||||
- ~~在快速记账时, 有一个转账按钮显示在右屏幕中间,点击后出现一个快速转账小卡片,点击后需要点击一个账号做为转出,任何在点击一个账号做为转入~~ — 已完成并验证:2026-10-03 17:54
|
- ~~在快速记账时, 有一个转账按钮显示在右屏幕中间,点击后出现一个快速转账小卡片,点击后需要点击一个账号做为转出,任何在点击一个账号做为转入~~ — 已完成并验证:2026-10-03 17:54
|
||||||
- ~~账号间转账两个字改成转账,隐藏项目改成隐藏资产~~ — 已完成并验证:2026-10-03 18:06
|
- ~~账号间转账两个字改成转账,隐藏项目改成隐藏资产~~ — 已完成并验证:2026-10-03 18:06
|
||||||
- ~~相同本位币不需要显示折合~~ — 已完成并验证:2026-10-03 18:06
|
- ~~相同本位币不需要显示折合~~ — 已完成并验证:2026-10-03 18:06
|
||||||
|
- ~~转账转入转出对账号选择没有要求,信用卡等也可以转账~~ — 已完成并验证:2026-10-03 18:56
|
||||||
|
- ~~设置中可以设置独立资产是否计入资产,每一个资产、账号、债务都可以设置是否计入资产~~ — 已完成并验证:2026-10-03 18:56
|
||||||
|
- ~~独立资产中贵金属支持估价,更新方式和汇率一致~~ — 已完成并验证:2026-10-03 18:56
|
||||||
|
|
||||||
验证与部署边界见 [更新说明](docs/update-2026-10-02.md)。
|
验证与部署边界见 [更新说明](docs/update-2026-10-02.md)。
|
||||||
|
|
||||||
@@ -65,3 +67,5 @@
|
|||||||
分组界面验收完成:2026-10-03 17:04。支持拖拽与键盘调整顺序,全部账户按分组顺序分行显示;33 项单元测试、前端类型检查、生产构建和真实数据库分组排序专项检查通过。详见 [更新说明](docs/update-group-drag-2026-10-03.md)。
|
分组界面验收完成:2026-10-03 17:04。支持拖拽与键盘调整顺序,全部账户按分组顺序分行显示;33 项单元测试、前端类型检查、生产构建和真实数据库分组排序专项检查通过。详见 [更新说明](docs/update-group-drag-2026-10-03.md)。
|
||||||
|
|
||||||
设置与交互验收完成:2026-10-03 17:54。33 项单元检查、15 项真实 MySQL 集成检查、前后端类型检查和生产构建通过;京东金融图标修正后另通过 4 项图标与转账专项回归。15 个迁移已应用,9 张表与 92 个字段注释齐全。登录与隐私、总览卡片、计划弹窗、右上角提示、京东金融 App 图标和快速转账详情见 [更新说明](docs/update-settings-interaction-2026-10-03.md)。复查未发现新增未完成要求。
|
设置与交互验收完成:2026-10-03 17:54。33 项单元检查、15 项真实 MySQL 集成检查、前后端类型检查和生产构建通过;京东金融图标修正后另通过 4 项图标与转账专项回归。15 个迁移已应用,9 张表与 92 个字段注释齐全。登录与隐私、总览卡片、计划弹窗、右上角提示、京东金融 App 图标和快速转账详情见 [更新说明](docs/update-settings-interaction-2026-10-03.md)。复查未发现新增未完成要求。
|
||||||
|
|
||||||
|
账户转账与估价验收完成:2026-10-03 18:56。35 项单元检查、17 项真实 MySQL 集成检查、类型检查和生产构建通过;最终估值去重另通过 4 项专项回归。17 个迁移已应用,10 张表与 106 个字段注释完整。计入开关、黄金/白银估价、ZIP v8 和第三方接口清单详见 [更新说明](docs/update-inclusion-metals-2026-10-03.md) 与 [README](README.md#第三方接口与外部素材来源)。复查未发现新增未完成要求。
|
||||||
Reference in new issue
Block a user