feat: support credit transfers and precious metal valuations

This commit is contained in:
陈煜 committed 2026-10-03 21:10:26 +08:00
1 parent 425b45c91a
commit 518aea1e59
39 files changed
+1572 -77

No files matched your search

+2 -2
View File
@@ -5,11 +5,11 @@
"dev": "node scripts/dev.cjs",
"build": "tsc",
"typecheck": "tsc --noEmit",
"test": "tsx --test --test-concurrency=1 test/calculation.test.ts test/update.test.ts test/zip.test.ts ../web/test/i18n.test.ts",
"test": "tsx --test --test-concurrency=1 test/calculation.test.ts test/update.test.ts test/zip.test.ts test/metals.test.ts ../web/test/i18n.test.ts",
"db:generate": "prisma generate",
"db:migrate": "node scripts/database.cjs deploy",
"db:status": "node scripts/database.cjs status",
"test:integration": "tsx --test --test-concurrency=1 test/integration.test.ts test/privacy.test.ts test/icons.test.ts test/transfers.test.ts test/queries.test.ts test/debts.test.ts test/update-integration.test.ts test/record-edit.test.ts test/security-backup.test.ts test/credit-balance.test.ts test/group-order.test.ts test/settings-plans.test.ts",
"test:integration": "tsx --test --test-concurrency=1 test/integration.test.ts test/privacy.test.ts test/icons.test.ts test/transfers.test.ts test/queries.test.ts test/debts.test.ts test/update-integration.test.ts test/record-edit.test.ts test/security-backup.test.ts test/credit-balance.test.ts test/group-order.test.ts test/settings-plans.test.ts test/inclusion-metals.test.ts",
"test:performance": "tsx scripts/performance.ts after",
"icons:seed": "node scripts/seed-icons.cjs"
},
@@ -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';
+35
View File
@@ -23,6 +23,8 @@ model User {
requireHiddenPassword Boolean @default(true)
/// 总览显示的卡片键列表,空值表示默认全部显示
overviewCards Json?
/// 独立资产是否参与资产总览和净资产轨迹
includeIndependentAssets Boolean @default(true)
/// 账户分组显示顺序;空值表示沿用默认顺序
accountGroupOrder Json?
/// 是否显示备注
@@ -35,6 +37,7 @@ model User {
updatedAt DateTime @updatedAt
positions Position[]
rates ExchangeRate[]
metalPrices MetalPrice[]
sessions Session[]
icons Icon[]
transfers Transfer[]
@@ -86,6 +89,16 @@ model Position {
archived Boolean @default(false)
/// 是否隐藏;未解锁时不参与可见范围统计
hidden Boolean @default(false)
/// 是否参与资产负债总额及净资产轨迹
included Boolean @default(true)
/// 贵金属品种:gold 黄金或 silver 白银
metalType String? @db.VarChar(12)
/// 贵金属总重量,单位克
metalGrams Decimal? @db.Decimal(24,8)
/// 贵金属纯度比例,0 至 1
metalPurity Decimal @default(1) @db.Decimal(12,8)
/// 是否随每日价格更新自动记录估值
autoValuation Boolean @default(false)
/// 记录创建时间,UTC
createdAt DateTime @default(now())
/// 记录更新时间,UTC
@@ -257,3 +270,25 @@ model Schedule {
@@index([userId,enabled,nextAt])
@@unique([userId,importedFromId])
}
/// 用户的贵金属每克参考价历史
model MetalPrice {
/// 唯一标识
id String @id @default(uuid()) @db.Char(36)
/// 所属用户标识,用于数据隔离
userId String @db.Char(36)
user User @relation(fields:[userId],references:[id],onDelete:Cascade)
/// 品种:gold 黄金或 silver 白银
metalType String @db.VarChar(12)
/// 价格币种
currency String @db.Char(3)
/// 纯金属每克价格
price Decimal @db.Decimal(24,12)
/// 报价日期,UTC+8 自然日
date DateTime @db.Date
/// 价格来源:manual 或 goldapi
source String @db.VarChar(20)
/// 报价对应时间,UTC
quotedAt DateTime @db.DateTime(3)
@@unique([userId, metalType, currency, date])
}
+1 -1
View File
@@ -35,7 +35,7 @@ async function main() {
sources.push(
{ name: '支付宝', source: 'https://raw.githubusercontent.com/simple-icons/simple-icons/develop/icons/alipay.svg' },
{ name: '微信', source: 'https://res.wx.qq.com/a/wx_fed/assets/res/NTI4MWU5.ico' },
{ name: '京东金融', source: 'https://jr.jd.com/logo.png' },
{ name: '京东金融', source: 'https://is1-ssl.mzstatic.com/image/thumb/Purple211/v4/7d/74/af/7d74af96-af89-ae14-cbf5-5bbd89859ab7/AppIcon-0-1x_U007epad-0-1-0-85-220-0.png/512x512bb.jpg', preserveWhite: true },
);
const manifest = [];
for (const item of sources) {
+2
View File
@@ -175,6 +175,7 @@ export class AuthController {
sessionHours: true,
requireHiddenPassword: true,
overviewCards: true,
includeIndependentAssets: true,
},
});
const session = await this.db.session.findUniqueOrThrow({ where: { id: req.sessionId } });
@@ -182,6 +183,7 @@ export class AuthController {
...user,
accountGroupOrder: user.accountGroupOrder || [],
overviewCards: user.overviewCards ?? [...defaultOverviewCards],
includeIndependentAssets: user.includeIndependentAssets,
sessionExpiresAt: session.expiresAt,
hiddenMenus: user.hiddenMenus.split(',').filter(Boolean),
revealed: req.revealed,
+82 -5
View File
@@ -1,3 +1,4 @@
import { metalConfig, metalPriceInput } from './metals';
import { scheduleInput } from './schedules';
import { movementDeltas } from './movement';
import { pairedReasons } from './validation';
@@ -30,6 +31,7 @@ import { Database } from './database';
import { UserRequest } from './auth';
import {
positionInput,
amount,
positionMeta,
currency,
revisionInput,
@@ -53,6 +55,10 @@ const timestamp = z.iso
);
const record = positionMeta
.extend({
metalType: z.enum(['gold', 'silver']).nullable().optional(),
metalGrams: amount.nullable().optional(),
metalPurity: metalConfig.shape.metalPurity.optional(),
autoValuation: z.boolean().optional(),
kind: z.enum(['account', 'asset', 'debt']),
side: z.enum(['asset', 'liability']),
currency,
@@ -85,6 +91,7 @@ const backupSchema = z
sessionHours: sessionHours.optional(),
requireHiddenPassword: z.boolean().optional(),
overviewCards: overviewCards.optional(),
includeIndependentAssets: z.boolean().optional(),
showNotes: z.boolean().optional(),
idleMinutes: z.number().int().min(0).max(1440),
})
@@ -126,6 +133,14 @@ const backupSchema = z
}),
)
.optional(),
metalPrices: z
.array(
metalPriceInput.extend({
source: z.enum(['manual', 'goldapi']),
quotedAt: timestamp,
}),
)
.optional(),
positions: z.array(record),
links: z.array(z.object({ sourceId: z.string().uuid(), targetId: z.string().uuid() }).strict()),
rates: z.array(rateInput.safeExtend({ source: z.enum(['manual', 'frankfurter']) })),
@@ -138,6 +153,8 @@ export function validateBackup(raw: unknown) {
if (ids.size !== b.positions.length) throw new BadRequestException('重复项目 ID');
const origins = b.positions.map((p) => p.importedFromId || p.id);
if (new Set(origins).size !== origins.length) throw new BadRequestException('备份内包含重复项目');
const quoteKeys = (b.metalPrices || []).map((q) => q.metalType + q.currency + q.date);
if (new Set(quoteKeys).size !== quoteKeys.length) throw new BadRequestException('重复贵金属报价');
const iconIds = new Set((b.icons || []).map((i) => i.id));
if (
iconIds.size !== (b.icons || []).length ||
@@ -146,6 +163,16 @@ export function validateBackup(raw: unknown) {
throw new BadRequestException('图标关联无效');
const revisionIds = new Set<string>();
for (const p of b.positions) {
if (p.metalType || p.metalGrams || p.autoValuation) {
if (p.kind !== 'asset' || p.category !== 'gold')
throw new BadRequestException('贵金属估价关联无效');
metalConfig.parse({
metalType: p.metalType,
metalGrams: p.metalGrams,
metalPurity: p.metalPurity || '1',
autoValuation: p.autoValuation || false,
});
}
positionInput.parse({
name: p.name,
category: p.category,
@@ -156,6 +183,7 @@ export function validateBackup(raw: unknown) {
notes: p.notes,
archived: p.archived,
hidden: p.hidden,
included: p.included,
amount: '0',
date: p.revisions[0]?.date || '1900-01-01',
});
@@ -179,12 +207,11 @@ export function validateBackup(raw: unknown) {
planIds.has(plan.importedFromId || plan.id) ||
!source ||
source.kind !== 'account' ||
source.side !== 'asset' ||
(plan.operation === 'expense' && source.side !== 'asset') ||
(plan.operation === 'expense'
? !!plan.targetId
: !target ||
target.kind !== 'account' ||
target.side !== 'asset' ||
(source.currency === target.currency && !new Decimal(plan.amount).eq(plan.received)))
)
throw new BadRequestException('计划账户关联无效');
@@ -201,9 +228,9 @@ export function validateBackup(raw: unknown) {
!source ||
!target ||
source.kind !== 'account' ||
source.side !== 'asset' ||
(t.operation !== 'transfer' && source.side !== 'asset') ||
(t.operation === 'transfer'
? target.kind !== 'account' || target.side !== 'asset'
? target.kind !== 'account'
: target.kind !== 'debt' ||
target.side !== (['borrow', 'repay'].includes(t.operation) ? 'liability' : 'asset')) ||
source.currency !== t.sourceCurrency ||
@@ -213,7 +240,14 @@ export function validateBackup(raw: unknown) {
transferIds.add(origin);
if (t.sourceCurrency === t.targetCurrency && !new Decimal(t.amount).eq(t.received))
throw new BadRequestException('同币种转账金额不一致');
const deltas = movementDeltas(t.operation, t.amount, t.received, t.fee);
const deltas = movementDeltas(
t.operation,
t.amount,
t.received,
t.fee,
source.side,
target.side,
);
for (const [p, revId, reason, delta] of [
[source, t.sourceRevisionId, deltas.sourceReason, deltas.source],
[target, t.targetRevisionId, deltas.targetReason, deltas.target],
@@ -318,6 +352,7 @@ export class BackupController implements OnModuleDestroy, OnModuleInit {
sessionHours: true,
requireHiddenPassword: true,
overviewCards: true,
includeIndependentAssets: true,
},
}),
client.position.findMany({
@@ -342,6 +377,11 @@ export class BackupController implements OnModuleDestroy, OnModuleInit {
notes: p.notes,
archived: p.archived,
hidden: p.hidden,
included: p.included,
metalType: p.metalType,
metalGrams: p.metalGrams?.toString() ?? null,
metalPurity: p.metalPurity.toString(),
autoValuation: p.autoValuation,
createdAt: p.createdAt.toISOString(),
updatedAt: p.updatedAt.toISOString(),
revisions: p.revisions.map((r) => ({
@@ -381,6 +421,7 @@ export class BackupController implements OnModuleDestroy, OnModuleInit {
sessionHours: user.sessionHours,
requireHiddenPassword: user.requireHiddenPassword,
overviewCards: overviewCards.parse(user.overviewCards ?? [...defaultOverviewCards]),
includeIndependentAssets: user.includeIndependentAssets,
},
currencies: [
...new Set([
@@ -410,6 +451,14 @@ export class BackupController implements OnModuleDestroy, OnModuleInit {
received: v.received.toString(),
nextAt: businessTime(v.nextAt),
})),
metalPrices: (await client.metalPrice.findMany({ where: { userId } })).map(
({ id, userId, date, quotedAt, price, ...v }) => ({
...v,
date: day(date),
quotedAt: quotedAt.toISOString(),
price: price.toString(),
}),
),
positions,
links: ps.flatMap((p) =>
p.outgoing.map((l) => ({ sourceId: l.sourceId, targetId: l.targetId })),
@@ -508,6 +557,9 @@ export class BackupController implements OnModuleDestroy, OnModuleInit {
data.rates.sort((a, b) =>
(a.currency + a.baseCurrency + a.date).localeCompare(b.currency + b.baseCurrency + b.date),
);
data.metalPrices?.sort((a, b) =>
(a.metalType + a.currency + a.date).localeCompare(b.metalType + b.currency + b.date),
);
data.currencies.sort();
data.transfers?.sort((a, b) => a.id.localeCompare(b.id));
data.schedules?.sort((a, b) => a.id.localeCompare(b.id));
@@ -524,6 +576,7 @@ export class BackupController implements OnModuleDestroy, OnModuleInit {
.parse(raw);
return this.db.$transaction(
async (tx) => {
await tx.$queryRaw(Prisma.sql`SELECT id FROM User WHERE id=${r.userId} FOR UPDATE`);
const s = await tx.session.findUniqueOrThrow({ where: { id: r.sessionId } });
if (!s.backupDigest || !s.backupExpiresAt || +s.backupExpiresAt <= Date.now())
throw new BadRequestException('请先下载当前账号备份,再进入下一步(10 分钟内有效)');
@@ -532,6 +585,7 @@ export class BackupController implements OnModuleDestroy, OnModuleInit {
await tx.schedule.deleteMany({ where: { userId: r.userId } });
await tx.position.deleteMany({ where: { userId: r.userId } });
await tx.icon.deleteMany({ where: { ownerId: r.userId, shared: false } });
await tx.metalPrice.deleteMany({ where: { userId: r.userId } });
await tx.exchangeRate.deleteMany({ where: { userId: r.userId } });
await tx.session.updateMany({
where: { userId: r.userId },
@@ -636,6 +690,11 @@ export class BackupController implements OnModuleDestroy, OnModuleInit {
notes: p.notes,
archived: p.archived,
hidden: p.hidden,
included: p.included,
metalType: p.metalType,
metalGrams: p.metalGrams,
metalPurity: p.metalPurity,
autoValuation: p.autoValuation,
createdAt: new Date(p.createdAt),
updatedAt: new Date(p.updatedAt),
revisions: {
@@ -670,6 +729,23 @@ export class BackupController implements OnModuleDestroy, OnModuleInit {
});
originals.forEach((v, i) => revisionMapping.set(v.id, restored[i].id));
}
for (const q of b.metalPrices || []) {
const key = {
userId: r.userId,
metalType: q.metalType,
currency: q.currency,
date: new Date(q.date),
};
const existingQuote = await tx.metalPrice.findUnique({
where: { userId_metalType_currency_date: key },
});
if (existingQuote && !new Decimal(existingQuote.price.toString()).eq(q.price))
throw new ConflictException('已有同日贵金属价格与备份冲突');
if (!existingQuote)
await tx.metalPrice.create({
data: { ...key, price: q.price, source: q.source, quotedAt: new Date(q.quotedAt) },
});
}
for (const t of b.transfers || [])
await tx.transfer.create({
data: {
@@ -732,6 +808,7 @@ export class BackupController implements OnModuleDestroy, OnModuleInit {
sessionHours: b.preferences?.sessionHours,
requireHiddenPassword: b.preferences?.requireHiddenPassword,
overviewCards: b.preferences?.overviewCards,
includeIndependentAssets: b.preferences?.includeIndependentAssets,
},
});
if (!ps.length && !rs.length && b.preferences?.requireHiddenPassword !== undefined)
+7 -4
View File
@@ -6,6 +6,7 @@ export type Holding = {
kind: string;
side: string;
currency: string;
included?: boolean;
revisions: {
id: string;
sequence?: number;
@@ -71,15 +72,16 @@ export function totals(positions: Holding[], rates: Rate[], base: string, date:
.sort((a, b) => compareRevisions(b, a))[0],
amount = new Decimal(rev?.amount.toString() || '0'),
fx = rateAt(rates, p.currency, base, date);
if (!fx && !amount.isZero()) missing.add(p.currency);
if (p.included !== false && !fx && !amount.isZero()) missing.add(p.currency);
const converted = fx ? amount.mul(fx.value) : null;
if (converted) {
if (converted && p.included !== false) {
if (p.side === 'asset') assets = assets.plus(converted);
else if (p.kind === 'account' && converted.isNegative()) assets = assets.minus(converted);
else liabilities = liabilities.plus(converted);
}
return {
id: p.id,
included: p.included !== false,
name: p.name,
kind: p.kind,
side: p.side,
@@ -117,7 +119,7 @@ export function overview(positions: Holding[], rates: Rate[], base: string, date
fxChange: string | null = null;
if (previous?.complete && value.complete) {
let revalued = new Decimal(0);
for (const item of previous.items) {
for (const item of previous.items.filter((p) => p.included)) {
const fx = rateAt(rates, item.currency, base, d);
if (!fx && !new Decimal(item.amount).isZero()) {
revalued = new Decimal(NaN);
@@ -155,6 +157,7 @@ export function trend(
to: string,
grain: 'day' | 'week' | 'month' = 'day',
) {
positions = positions.filter((p) => p.included !== false);
const dates = positions
.flatMap((p) => p.revisions.map((r) => businessDay(r.effectiveDate)))
.sort();
@@ -197,7 +200,7 @@ export function trend(
value = advance(d);
if (previous.complete && value.complete) {
let revalued = new Decimal(0);
for (const item of previous.items) {
for (const item of previous.items.filter((p) => p.included)) {
const rate = item.currency === base ? new Decimal(1) : fx.get(item.currency)?.rate;
const v = new Decimal(item.amount).mul(rate?.toString() || '0');
revalued = revalued.plus(item.side === 'asset' ? v : v.neg());
+3
View File
@@ -13,6 +13,7 @@ import { TransfersController } from './transfers';
import { PortfolioController } from './portfolio';
import { BackupController } from './backup';
import { IconsController, IconsService } from './icons';
import { MetalsService, MetalsController } from './metals';
import { Database } from './database';
import { RatesService, SettingsController } from './rates';
import { ZodError } from 'zod';
@@ -50,6 +51,7 @@ class SafeErrors implements ExceptionFilter {
Database,
AuthService,
RatesService,
MetalsService,
IconsService,
{ provide: APP_GUARD, useClass: AuthGuard },
],
@@ -60,6 +62,7 @@ class SafeErrors implements ExceptionFilter {
IconsController,
AuthController,
PortfolioController,
MetalsController,
SettingsController,
BackupController,
],
+354
View File
@@ -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&quotes=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));
}
}
+10 -3
View File
@@ -1,15 +1,22 @@
import Decimal from 'decimal.js';
import { BadRequestException } from '@nestjs/common';
export type Movement = 'transfer' | 'borrow' | 'lend' | 'collect' | 'repay';
export function movementDeltas(operation: Movement, amount: string, received: string, fee: string) {
export function movementDeltas(
operation: Movement,
amount: string,
received: string,
fee: string,
sourceSide = 'asset',
targetSide = 'asset',
) {
const incoming = operation === 'borrow' || operation === 'collect';
const principal = new Decimal(amount),
charge = new Decimal(fee),
debt = new Decimal(received);
if (operation === 'transfer')
return {
source: principal.plus(charge).neg(),
target: debt,
source: principal.plus(charge).mul(sourceSide === 'liability' ? 1 : -1),
target: debt.mul(targetSide === 'liability' ? -1 : 1),
sourceReason: 'transfer_out',
targetReason: 'transfer_in',
};
+3
View File
@@ -1,5 +1,6 @@
import { INestApplication } from '@nestjs/common';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import { metalConfig, metalPriceInput } from './metals';
import { scheduleInput } from './schedules';
import { z } from 'zod';
import {
@@ -45,6 +46,8 @@ export function setupOpenApi(app: INestApplication) {
'PUT /api/schedules/{id}': scheduleInput,
'PATCH /api/schedules/{id}': z.object({ enabled: z.boolean() }).strict(),
'PATCH /api/settings': settingsInput,
'PUT /api/metals/{id}': metalConfig,
'POST /api/metals/prices': metalPriceInput,
'POST /api/backup/clear': z.object({ confirmation: z.literal('确定清空') }),
'POST /api/backup/import-file': z.object({
token: z.string().uuid(),
+23 -4
View File
@@ -28,6 +28,7 @@ import { currentPositions, currentRates, historyPage, trendData, trendInput } fr
import { z } from 'zod';
import { Prisma } from '@prisma/client';
import { IconsService } from './icons';
import { MetalsService } from './metals';
import { RatesService } from './rates';
import { captureReplay } from './replay';
import { changeMovement } from './transfers';
@@ -37,6 +38,7 @@ export class PortfolioController {
private db: Database,
private fx: RatesService,
private icons: IconsService,
private metals: MetalsService,
) {}
private async own(userId: string, id: string, revealed = false) {
const [p] = await this.db.$transaction((tx) => currentPositions(tx, userId, revealed, id));
@@ -49,7 +51,7 @@ export class PortfolioController {
const rows = await currentPositions(tx, r.userId, r.revealed, undefined, kind);
const user = await tx.user.findUniqueOrThrow({
where: { id: r.userId },
select: { baseCurrency: true },
select: { baseCurrency: true, includeIndependentAssets: true },
});
const rates = await currentRates(
tx,
@@ -140,7 +142,15 @@ export class PortfolioController {
)
throw new BadRequestException('信用卡和贷款账户必须为负债');
await this.icons.requireVisible(r.userId, v.iconId);
await this.db.position.update({ where: { id: p.id }, data: v });
await this.db.position.update({
where: { id: p.id },
data: {
...v,
...(p.kind === 'asset' && v.category !== 'gold'
? { metalType: null, metalGrams: null, metalPurity: '1', autoValuation: false }
: {}),
},
});
return { ok: true };
}
@Post('positions/:id/revisions') async revise(
@@ -283,10 +293,11 @@ export class PortfolioController {
}
@Get('overview') async overview(@Req() r: UserRequest) {
void this.fx.daily(r.userId);
void this.metals.daily(r.userId);
return this.db.$transaction(async (tx) => {
const user = await tx.user.findUniqueOrThrow({
where: { id: r.userId },
select: { baseCurrency: true },
select: { baseCurrency: true, includeIndependentAssets: true },
});
const positions = await currentPositions(tx, r.userId, r.revealed);
const rates = await currentRates(
@@ -298,7 +309,15 @@ export class PortfolioController {
);
return {
baseCurrency: user.baseCurrency,
...totals(positions, rates, user.baseCurrency, today()),
...totals(
positions.map((p) => ({
...p,
included: p.included && (p.kind !== 'asset' || user.includeIndependentAssets),
})),
rates,
user.baseCurrency,
today(),
),
revealed: r.revealed,
};
});
+6 -2
View File
@@ -274,7 +274,7 @@ export async function trendData(
const currencies = [...new Set(positions.map((p) => p.currency))];
const user = await db.user.findUniqueOrThrow({
where: { id: userId },
select: { baseCurrency: true },
select: { baseCurrency: true, includeIndependentAssets: true },
});
const previousDay = new Date(+new Date(from) - 86400000).toISOString().slice(0, 10);
const rates = await db.exchangeRate.findMany({
@@ -287,7 +287,11 @@ export async function trendData(
});
const priorRates = await currentRates(db, userId, currencies, user.baseCurrency, previousDay);
return {
positions: positions.map((p) => ({ ...p, revisions: byId.get(p.id) || [] })),
positions: positions.map((p) => ({
...p,
included: p.included && (p.kind !== 'asset' || user.includeIndependentAssets),
revisions: byId.get(p.id) || [],
})),
rates: [...priorRates, ...rates],
base: user.baseCurrency,
};
+1
View File
@@ -190,6 +190,7 @@ export class SettingsController {
sessionHours: true,
requireHiddenPassword: true,
overviewCards: true,
includeIndependentAssets: true,
},
});
return {
+3
View File
@@ -25,6 +25,7 @@ export async function captureReplay(tx: Prisma.TransactionClient, userId: string
}
return async () => {
const transfers = await tx.transfer.findMany({
include: { source: true, target: true },
where: { userId, OR: [{ sourceId: { in: ids } }, { targetId: { in: ids } }] },
});
for (const t of transfers) {
@@ -33,6 +34,8 @@ export async function captureReplay(tx: Prisma.TransactionClient, userId: string
t.amount.toString(),
t.received.toString(),
t.fee.toString(),
t.source.side,
t.target.side,
);
deltas.set(t.sourceRevisionId, d.source);
deltas.set(t.targetRevisionId, d.target);
+3 -3
View File
@@ -65,7 +65,6 @@ export class SchedulesController {
where: {
userId: r.userId,
kind: 'account',
side: 'asset',
...(r.revealed ? {} : { hidden: false }),
},
select: { id: true },
@@ -108,12 +107,13 @@ export class SchedulesController {
userId: r.userId,
id: { in: ids },
kind: 'account',
side: 'asset',
...(v.operation === 'expense' ? { side: 'asset' } : {}),
archived: false,
...(r.revealed ? {} : { hidden: false }),
},
});
if (accounts.length !== ids.length) throw new BadRequestException('请选择启用的资产账户');
if (accounts.length !== ids.length)
throw new BadRequestException('请选择启用的账户(支出计划使用资产账户)');
if (v.operation === 'expense' && v.targetId)
throw new BadRequestException('支出计划无需转入账户');
if (
+4 -4
View File
@@ -176,14 +176,14 @@ export async function executeMovement(
revisions: latest.filter((r) => r.positionId === p.id),
}));
if (accounts.length !== 2)
throw new BadRequestException('只能在自己的启用资产账户之间转账(隐藏账户须先解锁)');
throw new BadRequestException('只能在自己的启用账户之间转账(隐藏账户须先解锁)');
const source = accounts.find((p) => p.id === v.sourceId)!,
target = accounts.find((p) => p.id === v.targetId)!;
if (
source.kind !== 'account' ||
source.side !== 'asset' ||
(v.operation !== 'transfer' && source.side !== 'asset') ||
(v.operation === 'transfer'
? target.kind !== 'account' || target.side !== 'asset'
? target.kind !== 'account'
: target.kind !== 'debt' ||
target.side !== (['borrow', 'repay'].includes(v.operation) ? 'liability' : 'asset'))
)
@@ -192,7 +192,7 @@ export async function executeMovement(
throw new ConflictException('转账时间不能早于任一账户的最新余额记录,请以当前余额转账');
if (source.currency === target.currency && !new Decimal(v.amount).eq(v.received))
throw new BadRequestException('同币种转出与到账金额必须一致,手续费单独填写');
const deltas = movementDeltas(v.operation, v.amount, v.received, v.fee);
const deltas = movementDeltas(v.operation, v.amount, v.received, v.fee, source.side, target.side);
const before = new Decimal(source.revisions[0]?.amount.toString() || '0');
const sourceAfter = before.plus(deltas.source);
const after = new Decimal(target.revisions[0]?.amount.toString() || '0').plus(deltas.target);
+2
View File
@@ -79,6 +79,7 @@ export const revisionInput = z
.strict();
export const positionMeta = z
.object({
included: z.boolean().optional(),
groupName: z.string().trim().max(60).optional(),
iconId: z.string().uuid().nullable().optional(),
name: z.string().trim().min(1).max(100),
@@ -212,6 +213,7 @@ export const settingsInput = z
sessionHours: sessionHours.optional(),
requireHiddenPassword: z.boolean().optional(),
overviewCards: overviewCards.optional(),
includeIndependentAssets: z.boolean().optional(),
})
.strict()
.refine((v) => Object.keys(v).length > 0);
+16 -3
View File
@@ -24,7 +24,11 @@ const sha = (s: Buffer | string) => createHash('sha256').update(s).digest('hex')
export function packBackup(b: Backup) {
const metadata = b.positions.map(({ revisions, ...p }) => p);
const data: Record<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,
'accounts.json': metadata.filter((p) => p.kind === 'account'),
'assets.json': metadata.filter((p) => p.kind === 'asset'),
@@ -44,7 +48,7 @@ export function packBackup(b: Backup) {
contents['manifest.json'] = JSON.stringify(
{
format: 'worthpath',
version: 7,
version: 8,
exportedAt: b.exportedAt,
files: files.map((name) => ({ name, sha256: sha(contents[name]) })),
},
@@ -119,7 +123,14 @@ export async function readBackupZip(input: string | Buffer): Promise<unknown> {
const manifest = z
.object({
format: z.literal('worthpath'),
version: z.union([z.literal(3), z.literal(4), z.literal(5), z.literal(6), z.literal(7)]),
version: z.union([
z.literal(3),
z.literal(4),
z.literal(5),
z.literal(6),
z.literal(7),
z.literal(8),
]),
exportedAt: z.iso.datetime(),
files: z
.array(
@@ -146,6 +157,7 @@ export async function readBackupZip(input: string | Buffer): Promise<unknown> {
const settings = z
.object({
baseCurrency: z.string(),
metalPrices: z.array(z.record(z.string(), z.unknown())).optional(),
preferences: z
.object({
showSidebar: z.boolean().optional(),
@@ -154,6 +166,7 @@ export async function readBackupZip(input: string | Buffer): Promise<unknown> {
sessionHours: sessionHours.optional(),
requireHiddenPassword: z.boolean().optional(),
overviewCards: overviewCards.optional(),
includeIndependentAssets: z.boolean().optional(),
showNotes: z.boolean().optional(),
idleMinutes: z.number().int().min(0).max(1440),
})
+1 -1
View File
@@ -151,7 +151,7 @@ test('borrow/lend/collect/repay pair balances atomically and survive concurrency
200,
);
const backup = (await call(a.token, '/backup')).data;
assert.equal(JSON.parse(packBackup(backup)['manifest.json']).version, 7);
assert.equal(JSON.parse(packBackup(backup)['manifest.json']).version, 8);
assert.equal(backup.transfers.length, 5);
assert.ok(backup.transfers.some((p: any) => p.operation === 'lend' && p.fee === '-2'));
assert.equal(
+296
View File
@@ -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();
}
});
+83
View File
@@ -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;
}
});
+8 -8
View File
@@ -141,15 +141,15 @@ test('transfers are atomic, scoped, retry-safe, decimal exact and included in ba
);
assert.equal((await call('/settings', a.cookie, 'PATCH', { showNotes: 'false' })).status, 400);
const debt = await position(a.cookie, '信用卡', '10', 'CNY', 'liability');
const creditTransfer = await call('/transfers', a.cookie, 'POST', {
...request,
requestId: randomUUID(),
targetId: debt,
});
assert.equal(creditTransfer.status, 201);
assert.equal(
(
await call('/transfers', a.cookie, 'POST', {
...request,
requestId: randomUUID(),
targetId: debt,
})
).status,
400,
(await call('/transfers/' + creditTransfer.data.id, a.cookie, 'DELETE')).status,
200,
);
assert.equal(
(