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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
|
||||
@@ -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 { 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',
|
||||
};
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -190,6 +190,7 @@ export class SettingsController {
|
||||
sessionHours: true,
|
||||
requireHiddenPassword: true,
|
||||
overviewCards: true,
|
||||
includeIndependentAssets: true,
|
||||
},
|
||||
});
|
||||
return {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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),
|
||||
})
|
||||
|
||||
Reference in new issue
Block a user