perf: optimize portfolio queries and workflows
This commit is contained in:
1 parent
da98d02661
commit
a7e0a0fe58
45 files changed
+4446
-637
No files matched your search
+11
-5
@@ -1,3 +1,5 @@
|
||||
import { movementDeltas } from './movement';
|
||||
import { pairedReasons } from './validation';
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
@@ -159,9 +161,11 @@ export function validateBackup(raw: unknown) {
|
||||
!source ||
|
||||
!target ||
|
||||
source.kind !== 'account' ||
|
||||
target.kind !== 'account' ||
|
||||
source.side !== 'asset' ||
|
||||
target.side !== 'asset' ||
|
||||
(t.operation === 'transfer'
|
||||
? target.kind !== 'account' || target.side !== 'asset'
|
||||
: target.kind !== 'debt' ||
|
||||
target.side !== (['borrow', 'repay'].includes(t.operation) ? 'liability' : 'asset')) ||
|
||||
source.currency !== t.sourceCurrency ||
|
||||
target.currency !== t.targetCurrency
|
||||
)
|
||||
@@ -169,9 +173,10 @@ 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);
|
||||
for (const [p, revId, reason, delta] of [
|
||||
[source, t.sourceRevisionId, 'transfer_out', new Decimal(t.amount).plus(t.fee).neg()],
|
||||
[target, t.targetRevisionId, 'transfer_in', new Decimal(t.received)],
|
||||
[source, t.sourceRevisionId, deltas.sourceReason, deltas.source],
|
||||
[target, t.targetRevisionId, deltas.targetReason, deltas.target],
|
||||
] as const) {
|
||||
const ordered = [...p.revisions].sort(
|
||||
(a, b) =>
|
||||
@@ -195,7 +200,7 @@ export function validateBackup(raw: unknown) {
|
||||
}
|
||||
if (
|
||||
b.positions.some((p) =>
|
||||
p.revisions.some((r) => r.reason.startsWith('transfer_') && !usedRevisions.has(r.id)),
|
||||
p.revisions.some((r) => pairedReasons.includes(r.reason) && !usedRevisions.has(r.id)),
|
||||
)
|
||||
)
|
||||
throw new BadRequestException('缺少配对转账记录');
|
||||
@@ -614,6 +619,7 @@ export class BackupController implements OnModuleDestroy, OnModuleInit {
|
||||
amount: t.amount,
|
||||
received: t.received,
|
||||
fee: t.fee,
|
||||
operation: t.operation,
|
||||
effectiveDate: toBusinessDate(t.date),
|
||||
notes: t.notes,
|
||||
createdAt: new Date(t.createdAt),
|
||||
|
||||
@@ -142,3 +142,79 @@ export function overview(positions: Holding[], rates: Rate[], base: string, date
|
||||
.slice(0, 20),
|
||||
};
|
||||
}
|
||||
|
||||
// Sort once, advance each revision/rate once, and retain only the running balances.
|
||||
// Sampling uses end-of-period balances; attribution sums daily changes in the bucket.
|
||||
export function trend(
|
||||
positions: Holding[],
|
||||
rates: Rate[],
|
||||
base: string,
|
||||
from: string,
|
||||
to: string,
|
||||
grain: 'day' | 'week' | 'month' = 'day',
|
||||
) {
|
||||
const streams = positions.map((p) => ({
|
||||
p,
|
||||
rows: [...p.revisions].sort(compareRevisions),
|
||||
index: 0,
|
||||
current: [] as Holding['revisions'],
|
||||
}));
|
||||
const fxRows = rates.filter((r) => r.baseCurrency === base).sort((a, b) => +a.date - +b.date);
|
||||
let fxIndex = 0;
|
||||
const fx = new Map<string, Rate>();
|
||||
function advance(d: string) {
|
||||
for (const s of streams)
|
||||
while (s.index < s.rows.length && businessDay(s.rows[s.index].effectiveDate) <= d)
|
||||
s.current = [s.rows[s.index++]];
|
||||
while (fxIndex < fxRows.length && day(fxRows[fxIndex].date) <= d) {
|
||||
const r = fxRows[fxIndex++];
|
||||
fx.set(r.currency, r);
|
||||
}
|
||||
return totals(
|
||||
streams.map((s) => ({ ...s.p, revisions: s.current })),
|
||||
[...fx.values()],
|
||||
base,
|
||||
d,
|
||||
);
|
||||
}
|
||||
let previous = advance(new Date(+new Date(from) - 86400000).toISOString().slice(0, 10));
|
||||
const output: (Omit<ReturnType<typeof totals>, 'items'> & {
|
||||
balanceChange: string | null;
|
||||
fxChange: string | null;
|
||||
})[] = [];
|
||||
let balance = new Decimal(0),
|
||||
exchange = new Decimal(0),
|
||||
complete = true;
|
||||
for (let time = +new Date(from); time <= +new Date(to); time += 86400000) {
|
||||
const d = new Date(time).toISOString().slice(0, 10),
|
||||
value = advance(d);
|
||||
if (previous.complete && value.complete) {
|
||||
let revalued = new Decimal(0);
|
||||
for (const item of previous.items) {
|
||||
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());
|
||||
}
|
||||
exchange = exchange.plus(revalued.minus(previous.net));
|
||||
balance = balance.plus(new Decimal(value.net).minus(revalued));
|
||||
} else complete = false;
|
||||
previous = value;
|
||||
const next = new Date(time + 86400000);
|
||||
if (
|
||||
grain === 'day' ||
|
||||
d === to ||
|
||||
(grain === 'week' ? next.getUTCDay() === 1 : next.getUTCDate() === 1)
|
||||
) {
|
||||
const { items, ...total } = value;
|
||||
output.push({
|
||||
...total,
|
||||
balanceChange: complete ? balance.toFixed(2) : null,
|
||||
fxChange: complete ? exchange.toFixed(2) : null,
|
||||
});
|
||||
balance = new Decimal(0);
|
||||
exchange = new Decimal(0);
|
||||
complete = true;
|
||||
}
|
||||
}
|
||||
return output;
|
||||
}
|
||||
@@ -1,7 +1,24 @@
|
||||
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { PrismaClient, Prisma } from '@prisma/client';
|
||||
@Injectable()
|
||||
export class Database extends PrismaClient implements OnModuleInit, OnModuleDestroy {
|
||||
async serial<T>(work: (tx: Prisma.TransactionClient) => Promise<T>): Promise<T> {
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
try {
|
||||
return await this.$transaction(work, {
|
||||
isolationLevel: Prisma.TransactionIsolationLevel.Serializable,
|
||||
});
|
||||
} catch (error) {
|
||||
// Serializable deadlock/write conflict; retry the entire atomic operation.
|
||||
const retryable =
|
||||
error instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
(error.code === 'P2034' ||
|
||||
(error.code === 'P2010' && ['1213', '1205'].includes(String(error.meta?.code))));
|
||||
if (!retryable || attempt >= 3) throw error;
|
||||
await new Promise((resolve) => setTimeout(resolve, 20 * 2 ** attempt + Math.random() * 20));
|
||||
}
|
||||
}
|
||||
}
|
||||
async onModuleInit() {
|
||||
await this.$connect();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
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) {
|
||||
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,
|
||||
sourceReason: 'transfer_out',
|
||||
targetReason: 'transfer_in',
|
||||
};
|
||||
if (incoming && charge.gt(principal)) throw new BadRequestException('收款手续费不能超过收款金额');
|
||||
return {
|
||||
source: incoming ? principal.minus(charge) : principal.plus(charge).neg(),
|
||||
target: operation === 'borrow' || operation === 'lend' ? debt : debt.neg(),
|
||||
sourceReason: 'loan_' + operation,
|
||||
targetReason: 'loan_' + operation,
|
||||
};
|
||||
}
|
||||
@@ -54,6 +54,49 @@ export function setupOpenApi(app: INestApplication) {
|
||||
const operation = entry[method];
|
||||
if (!operation) continue;
|
||||
operation.summary = `${method.toUpperCase()} ${path}`;
|
||||
if (
|
||||
method === 'get' &&
|
||||
['/api/history', '/api/positions/{id}/history', '/api/transfers'].includes(path)
|
||||
) {
|
||||
operation.parameters = [
|
||||
...(operation.parameters || []),
|
||||
{
|
||||
name: 'limit',
|
||||
in: 'query',
|
||||
schema: { type: 'integer', minimum: 1, maximum: 100, default: 50 },
|
||||
},
|
||||
{
|
||||
name: 'cursor',
|
||||
in: 'query',
|
||||
schema: { type: 'string' },
|
||||
description: '上页 nextCursor;业务时间及唯一顺序键降序,写入或更正后重新读取第一页',
|
||||
},
|
||||
...['from', 'to'].map((name) => ({
|
||||
name,
|
||||
in: 'query' as const,
|
||||
schema: { type: 'string', format: 'date' },
|
||||
})),
|
||||
];
|
||||
operation.responses['200'] = {
|
||||
description: '{ items, nextCursor, revealed };金额为绝对余额,before 为真实前序余额',
|
||||
};
|
||||
}
|
||||
if (method === 'get' && path === '/api/trend') {
|
||||
operation.parameters = [
|
||||
...['from', 'to'].map((name) => ({
|
||||
name,
|
||||
in: 'query' as const,
|
||||
schema: { type: 'string', format: 'date' },
|
||||
})),
|
||||
{
|
||||
name: 'grain',
|
||||
in: 'query',
|
||||
schema: { type: 'string', enum: ['day', 'week', 'month'], default: 'day' },
|
||||
},
|
||||
];
|
||||
operation.description =
|
||||
'默认90天;日最多366天,周最多1096天,月最多3653天。取北京时间期末余额,归因累计每日变化;不返回每天项目明细。';
|
||||
}
|
||||
if (path === '/api/health' || ['/api/auth/register', '/api/auth/login'].includes(path))
|
||||
operation.security = [];
|
||||
const schema = bodies[method.toUpperCase() + ' ' + path];
|
||||
|
||||
+173
-120
@@ -7,14 +7,23 @@ import {
|
||||
Body,
|
||||
Req,
|
||||
Param,
|
||||
Query,
|
||||
NotFoundException,
|
||||
ConflictException,
|
||||
BadRequestException,
|
||||
} from '@nestjs/common';
|
||||
import { Database } from './database';
|
||||
import { UserRequest } from './auth';
|
||||
import { positionInput, positionMeta, revisionInput, today, toBusinessDate } from './validation';
|
||||
import { history, overview } from './calculation';
|
||||
import {
|
||||
positionInput,
|
||||
positionMeta,
|
||||
revisionInput,
|
||||
today,
|
||||
toBusinessDate,
|
||||
pairedReasons,
|
||||
} from './validation';
|
||||
import { totals, trend } from './calculation';
|
||||
import { currentPositions, currentRates, historyPage, trendData, trendInput } from './queries';
|
||||
import { z } from 'zod';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { IconsService } from './icons';
|
||||
@@ -27,35 +36,70 @@ export class PortfolioController {
|
||||
private icons: IconsService,
|
||||
) {}
|
||||
private async own(userId: string, id: string, revealed = false) {
|
||||
const p = await this.db.position.findFirst({
|
||||
where: { id, userId, ...(revealed ? {} : { hidden: false }) },
|
||||
include: {
|
||||
revisions: { orderBy: [{ effectiveDate: 'asc' }, { sequence: 'asc' }] },
|
||||
outgoing: true,
|
||||
},
|
||||
});
|
||||
const [p] = await this.db.$transaction((tx) => currentPositions(tx, userId, revealed, id));
|
||||
if (!p) throw new NotFoundException('项目不存在');
|
||||
return p;
|
||||
}
|
||||
@Get('positions') async list(@Req() r: UserRequest) {
|
||||
const rows = await this.db.position.findMany({
|
||||
where: { userId: r.userId, ...(r.revealed ? {} : { hidden: false }) },
|
||||
include: {
|
||||
revisions: { orderBy: [{ effectiveDate: 'asc' }, { sequence: 'asc' }] },
|
||||
outgoing: true,
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
@Get('positions') async list(@Req() r: UserRequest, @Query('kind') inputKind?: string) {
|
||||
const kind = z.enum(['account', 'asset', 'debt']).optional().parse(inputKind);
|
||||
return this.db.$transaction(async (tx) => {
|
||||
const rows = await currentPositions(tx, r.userId, r.revealed, undefined, kind);
|
||||
const user = await tx.user.findUniqueOrThrow({
|
||||
where: { id: r.userId },
|
||||
select: { baseCurrency: true },
|
||||
});
|
||||
const rates = await currentRates(
|
||||
tx,
|
||||
r.userId,
|
||||
rows.map((p) => p.currency),
|
||||
user.baseCurrency,
|
||||
today(),
|
||||
);
|
||||
const amounts = new Map(
|
||||
totals(rows, rates, user.baseCurrency, today()).items.map((p) => [p.id, p]),
|
||||
);
|
||||
return rows.map(({ revisions, userId, ...p }) => ({
|
||||
...p,
|
||||
amount: revisions[0]?.amount.toString() || '0',
|
||||
converted: amounts.get(p.id)!.converted,
|
||||
rateDate: amounts.get(p.id)!.rateDate,
|
||||
}));
|
||||
});
|
||||
return rows.map((p) => ({
|
||||
...p,
|
||||
userId: undefined,
|
||||
amount: p.revisions.at(-1)?.amount.toString() || '0',
|
||||
history: history(p),
|
||||
}));
|
||||
}
|
||||
@Get('positions/:id') async detail(@Req() r: UserRequest, @Param('id') id: string) {
|
||||
const p = await this.own(r.userId, id, r.revealed);
|
||||
return { ...p, userId: undefined, history: history(p) };
|
||||
const { revisions, userId, ...p } = await this.own(r.userId, id, r.revealed);
|
||||
return { ...p, amount: revisions[0]?.amount.toString() || '0' };
|
||||
}
|
||||
@Get('history') async history(@Req() r: UserRequest, @Query() query: unknown) {
|
||||
return this.db.$transaction((tx) => historyPage(tx, r.userId, r.revealed, query));
|
||||
}
|
||||
@Get('positions/:id/history') async positionHistory(
|
||||
@Req() r: UserRequest,
|
||||
@Param('id') id: string,
|
||||
@Query() query: Record<string, string>,
|
||||
) {
|
||||
return this.db.$transaction(async (tx) => {
|
||||
const p = await tx.position.findFirst({
|
||||
where: { id, userId: r.userId, ...(r.revealed ? {} : { hidden: false }) },
|
||||
});
|
||||
if (!p) throw new NotFoundException('项目不存在');
|
||||
return historyPage(tx, r.userId, r.revealed, { ...query, positionId: id });
|
||||
});
|
||||
}
|
||||
@Get('trend') async trend(@Req() r: UserRequest, @Query() query: unknown) {
|
||||
const q = trendInput(query);
|
||||
return this.db.$transaction(
|
||||
async (tx) => {
|
||||
const data = await trendData(tx, r.userId, r.revealed, q.from, q.to);
|
||||
return {
|
||||
...q,
|
||||
baseCurrency: data.base,
|
||||
revealed: r.revealed,
|
||||
items: trend(data.positions, data.rates, data.base, q.from, q.to, q.grain),
|
||||
};
|
||||
},
|
||||
{ timeout: 30000 },
|
||||
);
|
||||
}
|
||||
@Post('positions') async create(@Req() r: UserRequest, @Body() b: unknown) {
|
||||
const v = positionInput.parse(b),
|
||||
@@ -102,41 +146,41 @@ export class PortfolioController {
|
||||
@Body() b: unknown,
|
||||
) {
|
||||
const v = revisionInput.parse(b);
|
||||
if (v.reason.startsWith('transfer_')) throw new BadRequestException('请使用账户转账接口');
|
||||
return this.db.$transaction(
|
||||
async (tx) => {
|
||||
const p = await tx.position.findFirst({
|
||||
where: { id, userId: r.userId, ...(r.revealed ? {} : { hidden: false }) },
|
||||
});
|
||||
if (!p) throw new NotFoundException('项目不存在');
|
||||
if (p.archived) throw new ConflictException('请先恢复归档项目');
|
||||
const lastTransfer = await tx.revision.findFirst({
|
||||
where: { positionId: p.id, reason: { in: ['transfer_out', 'transfer_in'] } },
|
||||
if (pairedReasons.includes(v.reason)) throw new BadRequestException('请使用账户转账接口');
|
||||
return 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, ...(r.revealed ? {} : { hidden: false }) },
|
||||
});
|
||||
if (!p) throw new NotFoundException('项目不存在');
|
||||
if (p.archived) throw new ConflictException('请先恢复归档项目');
|
||||
const lastTransfer = await tx.revision.findFirst({
|
||||
where: { positionId: p.id, reason: { in: pairedReasons } },
|
||||
orderBy: [{ effectiveDate: 'desc' }, { sequence: 'desc' }],
|
||||
});
|
||||
if (lastTransfer && toBusinessDate(v.date) < lastTransfer.effectiveDate)
|
||||
throw new ConflictException('余额调整时间不能早于已有转账;请使用当前时间调整');
|
||||
if (v.reason === 'repayment') {
|
||||
if (p.side !== 'liability') throw new BadRequestException('还款记录只能用于负债');
|
||||
const prior = await tx.revision.findFirst({
|
||||
where: { positionId: p.id, effectiveDate: { lte: toBusinessDate(v.date) } },
|
||||
orderBy: [{ effectiveDate: 'desc' }, { sequence: 'desc' }],
|
||||
});
|
||||
if (lastTransfer && toBusinessDate(v.date) < lastTransfer.effectiveDate)
|
||||
throw new ConflictException('余额调整时间不能早于已有转账;请使用当前时间调整');
|
||||
if (v.reason === 'repayment') {
|
||||
if (p.side !== 'liability') throw new BadRequestException('还款记录只能用于负债');
|
||||
const prior = await tx.revision.findFirst({
|
||||
where: { positionId: p.id, effectiveDate: { lte: toBusinessDate(v.date) } },
|
||||
orderBy: [{ effectiveDate: 'desc' }, { sequence: 'desc' }],
|
||||
});
|
||||
if (!prior || new Prisma.Decimal(v.amount).gt(prior.amount))
|
||||
throw new BadRequestException('还款后的欠款不能高于该业务日期的原欠款');
|
||||
}
|
||||
return tx.revision.create({
|
||||
data: {
|
||||
positionId: p.id,
|
||||
amount: v.amount,
|
||||
effectiveDate: toBusinessDate(v.date),
|
||||
notes: v.notes,
|
||||
reason: v.reason,
|
||||
},
|
||||
});
|
||||
},
|
||||
{ isolationLevel: Prisma.TransactionIsolationLevel.Serializable },
|
||||
);
|
||||
if (!prior || new Prisma.Decimal(v.amount).gt(prior.amount))
|
||||
throw new BadRequestException('还款后的欠款不能高于该业务日期的原欠款');
|
||||
}
|
||||
return tx.revision.create({
|
||||
data: {
|
||||
positionId: p.id,
|
||||
amount: v.amount,
|
||||
effectiveDate: toBusinessDate(v.date),
|
||||
notes: v.notes,
|
||||
reason: v.reason,
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
@Put('positions/:id/revisions/:revisionId') async correct(
|
||||
@Req() r: UserRequest,
|
||||
@@ -145,40 +189,45 @@ export class PortfolioController {
|
||||
@Body() b: unknown,
|
||||
) {
|
||||
const v = revisionInput.parse(b);
|
||||
return this.db.$transaction(
|
||||
async (tx) => {
|
||||
const p = await tx.position.findFirst({
|
||||
where: { id, userId: r.userId, ...(r.revealed ? {} : { hidden: false }) },
|
||||
include: { revisions: true },
|
||||
});
|
||||
if (!p) throw new NotFoundException('项目不存在');
|
||||
if (p.archived) throw new ConflictException('请先恢复归档项目');
|
||||
const original = p.revisions.find((h) => h.id === revisionId);
|
||||
if (!original) throw new NotFoundException('历史记录不存在');
|
||||
if (
|
||||
p.revisions.some(
|
||||
(h) =>
|
||||
h.reason.startsWith('transfer_') &&
|
||||
(+h.effectiveDate >= +original.effectiveDate ||
|
||||
toBusinessDate(v.date) < h.effectiveDate),
|
||||
)
|
||||
)
|
||||
throw new ConflictException(
|
||||
'转账及其之前的历史不可单独更正;请新增余额调整,保留转账双方一致',
|
||||
);
|
||||
await tx.revision.update({
|
||||
where: { id: revisionId },
|
||||
data: {
|
||||
amount: v.amount,
|
||||
effectiveDate: toBusinessDate(v.date),
|
||||
notes: v.notes,
|
||||
reason: 'correction',
|
||||
},
|
||||
});
|
||||
return { ok: true };
|
||||
},
|
||||
{ isolationLevel: Prisma.TransactionIsolationLevel.Serializable },
|
||||
);
|
||||
return 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, ...(r.revealed ? {} : { hidden: false }) },
|
||||
});
|
||||
if (!p) throw new NotFoundException('项目不存在');
|
||||
if (p.archived) throw new ConflictException('请先恢复归档项目');
|
||||
const original = await tx.revision.findFirst({ where: { id: revisionId, positionId: p.id } });
|
||||
if (!original) throw new NotFoundException('历史记录不存在');
|
||||
const transfer = await tx.revision.findFirst({
|
||||
where: {
|
||||
positionId: p.id,
|
||||
reason: { in: pairedReasons },
|
||||
OR: [
|
||||
{ effectiveDate: { gt: original.effectiveDate } },
|
||||
{ effectiveDate: original.effectiveDate, sequence: { gte: original.sequence } },
|
||||
{ effectiveDate: { gt: toBusinessDate(v.date) } },
|
||||
{ effectiveDate: toBusinessDate(v.date), sequence: { gte: original.sequence } },
|
||||
],
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
if (transfer)
|
||||
throw new ConflictException(
|
||||
'转账及其之前的历史不可单独更正;请新增余额调整,保留转账双方一致',
|
||||
);
|
||||
await tx.revision.update({
|
||||
where: { id: revisionId },
|
||||
data: {
|
||||
amount: v.amount,
|
||||
effectiveDate: toBusinessDate(v.date),
|
||||
notes: v.notes,
|
||||
reason: 'correction',
|
||||
},
|
||||
});
|
||||
return { ok: true };
|
||||
});
|
||||
}
|
||||
|
||||
@Put('positions/:id/links') async link(
|
||||
@@ -192,38 +241,42 @@ export class PortfolioController {
|
||||
.parse(b);
|
||||
if (new Set(targetIds).size !== targetIds.length || targetIds.includes(id))
|
||||
throw new BadRequestException('关联不能重复或指向自身');
|
||||
return this.db.$transaction(
|
||||
async (tx) => {
|
||||
const source = await tx.position.findFirst({
|
||||
where: { id, userId: r.userId, kind: 'debt', ...(r.revealed ? {} : { hidden: false }) },
|
||||
});
|
||||
if (!source) throw new NotFoundException('债务不存在');
|
||||
const count = await tx.position.count({
|
||||
where: { id: { in: targetIds }, userId: r.userId, kind: { in: ['account', 'asset'] } },
|
||||
});
|
||||
if (count !== targetIds.length) throw new BadRequestException('只能关联自己的账户或资产');
|
||||
await tx.positionLink.deleteMany({ where: { sourceId: id } });
|
||||
await tx.positionLink.createMany({
|
||||
data: targetIds.map((targetId) => ({ sourceId: id, targetId })),
|
||||
});
|
||||
return { ok: true };
|
||||
},
|
||||
{ isolationLevel: Prisma.TransactionIsolationLevel.Serializable },
|
||||
);
|
||||
return this.db.serial(async (tx) => {
|
||||
const source = await tx.position.findFirst({
|
||||
where: { id, userId: r.userId, kind: 'debt', ...(r.revealed ? {} : { hidden: false }) },
|
||||
});
|
||||
if (!source) throw new NotFoundException('债务不存在');
|
||||
const count = await tx.position.count({
|
||||
where: { id: { in: targetIds }, userId: r.userId, kind: { in: ['account', 'asset'] } },
|
||||
});
|
||||
if (count !== targetIds.length) throw new BadRequestException('只能关联自己的账户或资产');
|
||||
await tx.positionLink.deleteMany({ where: { sourceId: id } });
|
||||
await tx.positionLink.createMany({
|
||||
data: targetIds.map((targetId) => ({ sourceId: id, targetId })),
|
||||
});
|
||||
return { ok: true };
|
||||
});
|
||||
}
|
||||
@Get('overview') async overview(@Req() r: UserRequest) {
|
||||
void this.fx.daily(r.userId);
|
||||
const [user, positions, rates] = await this.db.$transaction([
|
||||
this.db.user.findUniqueOrThrow({
|
||||
return this.db.$transaction(async (tx) => {
|
||||
const user = await tx.user.findUniqueOrThrow({
|
||||
where: { id: r.userId },
|
||||
select: { baseCurrency: true },
|
||||
}),
|
||||
this.db.position.findMany({
|
||||
where: { userId: r.userId, ...(r.revealed ? {} : { hidden: false }) },
|
||||
include: { revisions: true },
|
||||
}),
|
||||
this.db.exchangeRate.findMany({ where: { userId: r.userId } }),
|
||||
]);
|
||||
return { ...overview(positions, rates, user.baseCurrency, today()), revealed: r.revealed };
|
||||
});
|
||||
const positions = await currentPositions(tx, r.userId, r.revealed);
|
||||
const rates = await currentRates(
|
||||
tx,
|
||||
r.userId,
|
||||
positions.map((p) => p.currency),
|
||||
user.baseCurrency,
|
||||
today(),
|
||||
);
|
||||
return {
|
||||
baseCurrency: user.baseCurrency,
|
||||
...totals(positions, rates, user.baseCurrency, today()),
|
||||
revealed: r.revealed,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { z } from 'zod';
|
||||
import { date, today, toBusinessDate } from './validation';
|
||||
import { Holding, Rate, businessDay, businessTime, history } from './calculation';
|
||||
import Decimal from 'decimal.js';
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
|
||||
type Reader = Prisma.TransactionClient;
|
||||
export const revisionOrder = [{ effectiveDate: 'desc' }, { sequence: 'desc' }] as const;
|
||||
const cursorSchema = z
|
||||
.object({ time: z.string().datetime(), key: z.string().min(1).max(64) })
|
||||
.strict();
|
||||
export function pageInput(query: unknown) {
|
||||
const q = z
|
||||
.object({
|
||||
limit: z.coerce.number().int().min(1).max(100).default(50),
|
||||
cursor: z.string().max(512).optional(),
|
||||
positionId: z.string().uuid().optional(),
|
||||
from: date.optional(),
|
||||
to: date.optional(),
|
||||
})
|
||||
.strict()
|
||||
.parse(query);
|
||||
const cursor = q.cursor
|
||||
? cursorSchema.parse(JSON.parse(Buffer.from(q.cursor, 'base64url').toString()))
|
||||
: null;
|
||||
if (q.from && q.to && q.from > q.to) throw new BadRequestException('开始日期不能晚于结束日期');
|
||||
return { ...q, cursor };
|
||||
}
|
||||
export const encodeCursor = (time: Date, key: string | number) =>
|
||||
Buffer.from(JSON.stringify({ time: time.toISOString(), key: String(key) })).toString('base64url');
|
||||
export const endOfDay = (d: string) => new Date(+toBusinessDate(d) + 86400000);
|
||||
|
||||
export async function transferPageIds(
|
||||
db: Reader,
|
||||
userId: string,
|
||||
revealed: boolean,
|
||||
q: ReturnType<typeof pageInput>,
|
||||
) {
|
||||
// Keep the ordered transfer index as the driving table. A visibility JOIN can
|
||||
// otherwise make MySQL scan source accounts first and sort all their transfers.
|
||||
return db.$queryRaw<{ id: string }[]>(Prisma.sql`
|
||||
SELECT t.id FROM Transfer t FORCE INDEX (Transfer_userId_effectiveDate_id_idx)
|
||||
STRAIGHT_JOIN Position s ON s.id = t.sourceId
|
||||
STRAIGHT_JOIN Position d ON d.id = t.targetId
|
||||
WHERE t.userId = ${userId} AND s.userId = ${userId} AND d.userId = ${userId}
|
||||
${revealed ? Prisma.empty : Prisma.sql`AND s.hidden = false AND d.hidden = false`}
|
||||
${q.positionId ? Prisma.sql`AND (t.sourceId = ${q.positionId} OR t.targetId = ${q.positionId})` : Prisma.empty}
|
||||
${q.from ? Prisma.sql`AND t.effectiveDate >= ${toBusinessDate(q.from)}` : Prisma.empty}
|
||||
${q.to ? Prisma.sql`AND t.effectiveDate < ${endOfDay(q.to)}` : Prisma.empty}
|
||||
${
|
||||
q.cursor
|
||||
? Prisma.sql`AND (t.effectiveDate < ${new Date(q.cursor.time)} OR
|
||||
(t.effectiveDate = ${new Date(q.cursor.time)} AND t.id < ${q.cursor.key}))`
|
||||
: Prisma.empty
|
||||
}
|
||||
ORDER BY t.effectiveDate DESC, t.id DESC LIMIT ${q.limit + 1}`);
|
||||
}
|
||||
|
||||
// Each branch has constant bounds so MySQL can use an index range even for a
|
||||
// historical seed. Prisma 6's nested take can fetch unbounded rows for multiple parents.
|
||||
export async function latestRevisions(db: Reader, ids: string[], before: Date) {
|
||||
if (!ids.length) return [];
|
||||
const result: (Holding['revisions'][number] & { positionId: string })[] = [];
|
||||
// Bound UNION size for users with many positions.
|
||||
for (let offset = 0; offset < ids.length; offset += 100) {
|
||||
const branches = ids.slice(offset, offset + 100).map(
|
||||
(id) => Prisma.sql`
|
||||
(SELECT r.* FROM Revision r FORCE INDEX (Revision_positionId_effectiveDate_sequence_idx)
|
||||
WHERE r.positionId = ${id} AND r.effectiveDate < ${before}
|
||||
ORDER BY r.effectiveDate DESC, r.sequence DESC LIMIT 1)`,
|
||||
);
|
||||
result.push(...(await db.$queryRaw<typeof result>(Prisma.join(branches, ' UNION ALL '))));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
export async function currentPositions(
|
||||
db: Reader,
|
||||
userId: string,
|
||||
revealed: boolean,
|
||||
id?: string,
|
||||
kind?: string,
|
||||
) {
|
||||
const positions = await db.position.findMany({
|
||||
where: {
|
||||
userId,
|
||||
...(revealed ? {} : { hidden: false }),
|
||||
...(id ? { id } : {}),
|
||||
...(kind ? { kind } : {}),
|
||||
},
|
||||
include: {
|
||||
outgoing: {
|
||||
where: { target: { userId, ...(revealed ? {} : { hidden: false }) } },
|
||||
include: { target: { select: { name: true, kind: true } } },
|
||||
},
|
||||
},
|
||||
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
|
||||
});
|
||||
const latest = await latestRevisions(
|
||||
db,
|
||||
positions.map((p) => p.id),
|
||||
endOfDay(today()),
|
||||
);
|
||||
const byId = new Map(latest.map((r) => [r.positionId, r]));
|
||||
return positions.map((p) => ({ ...p, revisions: byId.has(p.id) ? [byId.get(p.id)!] : [] }));
|
||||
}
|
||||
export async function currentRates(
|
||||
db: Reader,
|
||||
userId: string,
|
||||
currencies: string[],
|
||||
base: string,
|
||||
before: string,
|
||||
) {
|
||||
const rows = await Promise.all(
|
||||
[...new Set(currencies)]
|
||||
.filter((c) => c !== base)
|
||||
.map((currency) =>
|
||||
db.exchangeRate.findFirst({
|
||||
where: { userId, currency, baseCurrency: base, date: { lte: new Date(before) } },
|
||||
orderBy: { date: 'desc' },
|
||||
}),
|
||||
),
|
||||
);
|
||||
return rows.filter((r): r is NonNullable<typeof r> => r !== null);
|
||||
}
|
||||
export async function historyPage(db: Reader, userId: string, revealed: boolean, query: unknown) {
|
||||
const q = pageInput(query);
|
||||
const positions = await db.position.findMany({
|
||||
where: {
|
||||
userId,
|
||||
...(revealed ? {} : { hidden: false }),
|
||||
...(q.positionId ? { id: q.positionId } : {}),
|
||||
},
|
||||
select: { id: true, name: true, kind: true, currency: true },
|
||||
});
|
||||
// Bound every account's index scan before merging. A global JOIN + ORDER BY can
|
||||
// sort an entire user's history (and evaluate the predecessor for every row).
|
||||
const candidates = (
|
||||
await Promise.all(
|
||||
positions.map((p) =>
|
||||
db.revision
|
||||
.findMany({
|
||||
where: {
|
||||
positionId: p.id,
|
||||
...(q.from || q.to
|
||||
? {
|
||||
effectiveDate: {
|
||||
...(q.from ? { gte: toBusinessDate(q.from) } : {}),
|
||||
...(q.to ? { lt: endOfDay(q.to) } : {}),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(q.cursor
|
||||
? {
|
||||
AND: [
|
||||
{
|
||||
OR: [
|
||||
{ effectiveDate: { lt: new Date(q.cursor.time) } },
|
||||
{
|
||||
effectiveDate: new Date(q.cursor.time),
|
||||
sequence: {
|
||||
lt: z.coerce.number().int().positive().parse(q.cursor.key),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
orderBy: [{ effectiveDate: 'desc' }, { sequence: 'desc' }],
|
||||
take: q.limit + 1,
|
||||
})
|
||||
.then((rows) =>
|
||||
rows.map((r) => ({ ...r, name: p.name, kind: p.kind, currency: p.currency })),
|
||||
),
|
||||
),
|
||||
)
|
||||
)
|
||||
.flat()
|
||||
.sort((a, b) => +b.effectiveDate - +a.effectiveDate || b.sequence - a.sequence)
|
||||
.slice(0, q.limit + 1);
|
||||
const predecessors = candidates.length
|
||||
? await db.$queryRaw<{ id: string; beforeAmount: Prisma.Decimal | null }[]>(
|
||||
Prisma.join(
|
||||
candidates.map(
|
||||
(r) => Prisma.sql`SELECT ${r.id} AS id, (
|
||||
SELECT x.amount FROM Revision x FORCE INDEX (Revision_positionId_effectiveDate_sequence_idx)
|
||||
WHERE x.positionId = ${r.positionId} AND
|
||||
(x.effectiveDate < ${r.effectiveDate} OR (x.effectiveDate = ${r.effectiveDate} AND x.sequence < ${r.sequence}))
|
||||
ORDER BY x.effectiveDate DESC, x.sequence DESC LIMIT 1
|
||||
) AS beforeAmount`,
|
||||
),
|
||||
' UNION ALL ',
|
||||
),
|
||||
)
|
||||
: [];
|
||||
const prior = new Map(predecessors.map((r) => [r.id, r.beforeAmount]));
|
||||
const rows = candidates.map((r) => ({ ...r, beforeAmount: prior.get(r.id) || null }));
|
||||
const more = rows.length > q.limit;
|
||||
const items = rows.slice(0, q.limit).map((r) => ({
|
||||
...history({
|
||||
id: r.positionId,
|
||||
name: r.name,
|
||||
kind: r.kind,
|
||||
side: '',
|
||||
currency: r.currency,
|
||||
revisions: [r],
|
||||
})[0],
|
||||
before: r.beforeAmount?.toString() || '0',
|
||||
delta: new Decimal(r.amount.toString()).minus(r.beforeAmount?.toString() || '0').toFixed(),
|
||||
}));
|
||||
const last = rows[q.limit - 1];
|
||||
return {
|
||||
items,
|
||||
nextCursor: more ? encodeCursor(last.effectiveDate, last.sequence) : null,
|
||||
revealed,
|
||||
};
|
||||
}
|
||||
|
||||
export function trendInput(query: unknown) {
|
||||
const to = today();
|
||||
const from = new Date(+new Date(to) - 89 * 86400000).toISOString().slice(0, 10);
|
||||
const q = z
|
||||
.object({
|
||||
from: date.default(from),
|
||||
to: date.default(to),
|
||||
grain: z.enum(['day', 'week', 'month']).default('day'),
|
||||
})
|
||||
.strict()
|
||||
.parse(query);
|
||||
const span = (+new Date(q.to) - +new Date(q.from)) / 86400000;
|
||||
const max = q.grain === 'day' ? 365 : q.grain === 'week' ? 1095 : 3652;
|
||||
if (span < 0 || span > max) throw new BadRequestException(`日期范围超出限制(${max + 1} 天)`);
|
||||
return q;
|
||||
}
|
||||
export async function trendData(
|
||||
db: Reader,
|
||||
userId: string,
|
||||
revealed: boolean,
|
||||
from: string,
|
||||
to: string,
|
||||
) {
|
||||
const positions = await db.position.findMany({
|
||||
where: { userId, ...(revealed ? {} : { hidden: false }) },
|
||||
});
|
||||
const ids = positions.map((p) => p.id);
|
||||
const seeds = await latestRevisions(db, ids, toBusinessDate(from));
|
||||
// Only the final balance of each Beijing business day is needed. Rank in MySQL,
|
||||
// rather than shipping every intraday revision to Node or the browser.
|
||||
const daily = ids.length
|
||||
? await db.$queryRaw<(Holding['revisions'][number] & { positionId: string })[]>(Prisma.sql`
|
||||
SELECT id, sequence, positionId, amount, effectiveDate, '' AS notes, 'balance' AS reason FROM (
|
||||
SELECT r.id, r.sequence, r.positionId, r.amount, r.effectiveDate,
|
||||
ROW_NUMBER() OVER (PARTITION BY r.positionId, DATE(DATE_ADD(r.effectiveDate, INTERVAL 8 HOUR))
|
||||
ORDER BY r.effectiveDate DESC, r.sequence DESC) AS rankInDay
|
||||
FROM Revision r JOIN Position p ON p.id = r.positionId
|
||||
WHERE p.userId = ${userId} ${revealed ? Prisma.empty : Prisma.sql`AND p.hidden = false`}
|
||||
AND r.effectiveDate >= ${toBusinessDate(from)} AND r.effectiveDate < ${endOfDay(to)}
|
||||
) ranked WHERE rankInDay = 1 ORDER BY effectiveDate, sequence`)
|
||||
: [];
|
||||
const byId = new Map<string, Holding['revisions']>();
|
||||
for (const r of [...seeds, ...daily]) {
|
||||
const list = byId.get(r.positionId) || [];
|
||||
list.push(r);
|
||||
byId.set(r.positionId, list);
|
||||
}
|
||||
const currencies = [...new Set(positions.map((p) => p.currency))];
|
||||
const user = await db.user.findUniqueOrThrow({
|
||||
where: { id: userId },
|
||||
select: { baseCurrency: true },
|
||||
});
|
||||
const previousDay = new Date(+new Date(from) - 86400000).toISOString().slice(0, 10);
|
||||
const rates = await db.exchangeRate.findMany({
|
||||
where: {
|
||||
userId,
|
||||
currency: { in: currencies },
|
||||
baseCurrency: user.baseCurrency,
|
||||
date: { gte: new Date(from), lte: new Date(to) },
|
||||
},
|
||||
});
|
||||
const priorRates = await currentRates(db, userId, currencies, user.baseCurrency, previousDay);
|
||||
return {
|
||||
positions: positions.map((p) => ({ ...p, revisions: byId.get(p.id) || [] })),
|
||||
rates: [...priorRates, ...rates],
|
||||
base: user.baseCurrency,
|
||||
};
|
||||
}
|
||||
+11
-6
@@ -6,6 +6,7 @@ import {
|
||||
Post,
|
||||
Req,
|
||||
Body,
|
||||
Query,
|
||||
OnModuleInit,
|
||||
OnModuleDestroy,
|
||||
BadGatewayException,
|
||||
@@ -165,7 +166,8 @@ export class SettingsController {
|
||||
private db: Database,
|
||||
private fx: RatesService,
|
||||
) {}
|
||||
@Get('settings') async settings(@Req() r: UserRequest) {
|
||||
@Get('settings') async settings(@Req() r: UserRequest, @Query('rates') includeRates?: string) {
|
||||
const showRates = z.enum(['true', 'false']).optional().parse(includeRates) === 'true';
|
||||
const u = await this.db.user.findUniqueOrThrow({
|
||||
where: { id: r.userId },
|
||||
select: {
|
||||
@@ -186,11 +188,14 @@ export class SettingsController {
|
||||
? (await this.db.session.findUniqueOrThrow({ where: { id: r.sessionId } })).revealUntil
|
||||
: null,
|
||||
fxStatus: this.fx.status(r.userId),
|
||||
rates: await this.db.exchangeRate.findMany({
|
||||
where: { userId: r.userId },
|
||||
select: { currency: true, baseCurrency: true, date: true, rate: true, source: true },
|
||||
orderBy: { date: 'desc' },
|
||||
}),
|
||||
rates: showRates
|
||||
? await this.db.exchangeRate.findMany({
|
||||
where: { userId: r.userId },
|
||||
select: { currency: true, baseCurrency: true, date: true, rate: true, source: true },
|
||||
orderBy: [{ date: 'desc' }, { id: 'desc' }],
|
||||
take: 100,
|
||||
})
|
||||
: [],
|
||||
};
|
||||
}
|
||||
@Patch('settings') async update(@Req() r: UserRequest, @Body() b: unknown) {
|
||||
|
||||
+129
-91
@@ -4,6 +4,7 @@ import {
|
||||
Post,
|
||||
Body,
|
||||
Req,
|
||||
Query,
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
} from '@nestjs/common';
|
||||
@@ -13,109 +14,146 @@ import { Database } from './database';
|
||||
import { UserRequest } from './auth';
|
||||
import { transferInput, toBusinessDate } from './validation';
|
||||
import { businessTime } from './calculation';
|
||||
import { movementDeltas } from './movement';
|
||||
import { pageInput, encodeCursor, latestRevisions, transferPageIds } from './queries';
|
||||
@Controller('api/transfers')
|
||||
export class TransfersController {
|
||||
constructor(private db: Database) {}
|
||||
@Get() async list(@Req() r: UserRequest) {
|
||||
const visibility = { userId: r.userId, ...(r.revealed ? {} : { hidden: false }) };
|
||||
const rows = await this.db.transfer.findMany({
|
||||
where: { userId: r.userId, source: visibility, target: visibility },
|
||||
include: { source: { select: { name: true } }, target: { select: { name: true } } },
|
||||
orderBy: [{ effectiveDate: 'desc' }, { createdAt: 'desc' }],
|
||||
@Get() async list(@Req() r: UserRequest, @Query() query: unknown) {
|
||||
const q = pageInput(query);
|
||||
const rows = await this.db.$transaction(async (tx) => {
|
||||
const ids = await transferPageIds(tx, r.userId, r.revealed, q);
|
||||
return tx.transfer.findMany({
|
||||
where: { userId: r.userId, id: { in: ids.map((row) => row.id) } },
|
||||
include: {
|
||||
source: { select: { name: true, kind: true } },
|
||||
target: { select: { name: true, kind: true } },
|
||||
},
|
||||
orderBy: [{ effectiveDate: 'desc' }, { id: 'desc' }],
|
||||
});
|
||||
});
|
||||
return rows.map(({ userId, importedFromId, effectiveDate, ...v }) => ({
|
||||
const items = rows.slice(0, q.limit).map(({ userId, importedFromId, effectiveDate, ...v }) => ({
|
||||
...v,
|
||||
date: businessTime(effectiveDate),
|
||||
}));
|
||||
const last = rows[q.limit - 1];
|
||||
return {
|
||||
items,
|
||||
nextCursor: rows.length > q.limit ? encodeCursor(last.effectiveDate, last.id) : null,
|
||||
revealed: r.revealed,
|
||||
};
|
||||
}
|
||||
@Post() async create(@Req() r: UserRequest, @Body() body: unknown) {
|
||||
const v = transferInput.parse(body),
|
||||
when = toBusinessDate(v.date);
|
||||
return this.db.$transaction(
|
||||
async (tx) => {
|
||||
if (v.requestId) {
|
||||
const existing = await tx.transfer.findFirst({
|
||||
where: { id: v.requestId, userId: r.userId },
|
||||
});
|
||||
if (existing) {
|
||||
if (
|
||||
existing.sourceId !== v.sourceId ||
|
||||
existing.targetId !== v.targetId ||
|
||||
!new Decimal(existing.amount.toString()).eq(v.amount) ||
|
||||
!new Decimal(existing.received.toString()).eq(v.received) ||
|
||||
!new Decimal(existing.fee.toString()).eq(v.fee) ||
|
||||
+existing.effectiveDate !== +when ||
|
||||
existing.notes !== v.notes
|
||||
)
|
||||
throw new ConflictException('转账请求标识已使用,请刷新后重试');
|
||||
return { id: existing.id };
|
||||
}
|
||||
return this.db.serial(async (tx) => {
|
||||
// Lock in a consistent order before reading balances or idempotency state.
|
||||
await tx.$queryRaw(Prisma.sql`SELECT id FROM Position WHERE userId = ${r.userId}
|
||||
AND id IN (${Prisma.join([v.sourceId, v.targetId].sort())}) ORDER BY id FOR UPDATE`);
|
||||
if (v.requestId) {
|
||||
const existing = await tx.transfer.findFirst({
|
||||
where: { id: v.requestId, userId: r.userId },
|
||||
});
|
||||
if (existing) {
|
||||
if (
|
||||
existing.operation !== v.operation ||
|
||||
existing.sourceId !== v.sourceId ||
|
||||
existing.targetId !== v.targetId ||
|
||||
!new Decimal(existing.amount.toString()).eq(v.amount) ||
|
||||
!new Decimal(existing.received.toString()).eq(v.received) ||
|
||||
!new Decimal(existing.fee.toString()).eq(v.fee) ||
|
||||
+existing.effectiveDate !== +when ||
|
||||
existing.notes !== v.notes
|
||||
)
|
||||
throw new ConflictException('转账请求标识已使用,请刷新后重试');
|
||||
return { id: existing.id };
|
||||
}
|
||||
const accounts = await tx.position.findMany({
|
||||
where: {
|
||||
id: { in: [v.sourceId, v.targetId] },
|
||||
userId: r.userId,
|
||||
kind: 'account',
|
||||
side: 'asset',
|
||||
archived: false,
|
||||
...(r.revealed ? {} : { hidden: false }),
|
||||
},
|
||||
include: {
|
||||
revisions: { orderBy: [{ effectiveDate: 'desc' }, { sequence: 'desc' }], take: 1 },
|
||||
},
|
||||
}
|
||||
const metadata = await tx.position.findMany({
|
||||
where: {
|
||||
id: { in: [v.sourceId, v.targetId] },
|
||||
userId: r.userId,
|
||||
archived: false,
|
||||
...(r.revealed ? {} : { hidden: false }),
|
||||
},
|
||||
});
|
||||
const latest = await latestRevisions(
|
||||
tx,
|
||||
metadata.map((p) => p.id),
|
||||
new Date('9999-01-01'),
|
||||
);
|
||||
const accounts = metadata.map((p) => ({
|
||||
...p,
|
||||
revisions: latest.filter((r) => r.positionId === p.id),
|
||||
}));
|
||||
if (accounts.length !== 2)
|
||||
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'
|
||||
? target.kind !== 'account' || target.side !== 'asset'
|
||||
: target.kind !== 'debt' ||
|
||||
target.side !== (['borrow', 'repay'].includes(v.operation) ? 'liability' : 'asset'))
|
||||
)
|
||||
throw new BadRequestException('请选择有效的资产账户和对应借入或借出债务');
|
||||
if (accounts.some((p) => !p.revisions[0] || +p.revisions[0].effectiveDate > +when))
|
||||
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 before = new Decimal(source.revisions[0].amount.toString());
|
||||
const sourceAfter = before.plus(deltas.source);
|
||||
const after = new Decimal(target.revisions[0].amount.toString()).plus(deltas.target);
|
||||
if (sourceAfter.isNegative()) throw new BadRequestException('转出账户余额不足(含手续费)');
|
||||
if (after.isNegative()) throw new BadRequestException('收款或还款不能超过剩余债务');
|
||||
if (after.gte('10000000000000000') || sourceAfter.gte('10000000000000000'))
|
||||
throw new BadRequestException('变更后的金额超出支持范围');
|
||||
const outgoing = await tx.revision.create({
|
||||
data: {
|
||||
positionId: source.id,
|
||||
amount: sourceAfter.toFixed(),
|
||||
effectiveDate: when,
|
||||
notes: v.notes,
|
||||
reason: deltas.sourceReason,
|
||||
},
|
||||
});
|
||||
const incoming = await tx.revision.create({
|
||||
data: {
|
||||
positionId: target.id,
|
||||
amount: after.toFixed(),
|
||||
effectiveDate: when,
|
||||
notes: v.notes,
|
||||
reason: deltas.targetReason,
|
||||
},
|
||||
});
|
||||
const row = await tx.transfer.create({
|
||||
data: {
|
||||
id: v.requestId,
|
||||
userId: r.userId,
|
||||
operation: v.operation,
|
||||
sourceId: source.id,
|
||||
targetId: target.id,
|
||||
sourceRevisionId: outgoing.id,
|
||||
targetRevisionId: incoming.id,
|
||||
sourceCurrency: source.currency,
|
||||
targetCurrency: target.currency,
|
||||
amount: v.amount,
|
||||
received: v.received,
|
||||
fee: v.fee,
|
||||
effectiveDate: when,
|
||||
notes: v.notes,
|
||||
},
|
||||
});
|
||||
if (v.operation !== 'transfer')
|
||||
await tx.positionLink.upsert({
|
||||
where: { sourceId_targetId: { sourceId: target.id, targetId: source.id } },
|
||||
create: { sourceId: target.id, targetId: source.id },
|
||||
update: {},
|
||||
});
|
||||
if (accounts.length !== 2)
|
||||
throw new BadRequestException('只能在自己的启用资产账户之间转账(隐藏账户须先解锁)');
|
||||
const source = accounts.find((p) => p.id === v.sourceId)!,
|
||||
target = accounts.find((p) => p.id === v.targetId)!;
|
||||
if (accounts.some((p) => !p.revisions[0] || +p.revisions[0].effectiveDate > +when))
|
||||
throw new ConflictException('转账时间不能早于任一账户的最新余额记录,请以当前余额转账');
|
||||
if (source.currency === target.currency && !new Decimal(v.amount).eq(v.received))
|
||||
throw new BadRequestException('同币种转出与到账金额必须一致,手续费单独填写');
|
||||
const debit = new Decimal(v.amount).plus(v.fee),
|
||||
before = new Decimal(source.revisions[0].amount.toString());
|
||||
if (before.lt(debit)) throw new BadRequestException('转出账户余额不足(含手续费)');
|
||||
const after = new Decimal(target.revisions[0].amount.toString()).plus(v.received);
|
||||
if (after.gte('10000000000000000'))
|
||||
throw new BadRequestException('到账后的金额超出支持范围');
|
||||
const outgoing = await tx.revision.create({
|
||||
data: {
|
||||
positionId: source.id,
|
||||
amount: before.minus(debit).toFixed(),
|
||||
effectiveDate: when,
|
||||
notes: v.notes,
|
||||
reason: 'transfer_out',
|
||||
},
|
||||
});
|
||||
const incoming = await tx.revision.create({
|
||||
data: {
|
||||
positionId: target.id,
|
||||
amount: after.toFixed(),
|
||||
effectiveDate: when,
|
||||
notes: v.notes,
|
||||
reason: 'transfer_in',
|
||||
},
|
||||
});
|
||||
const row = await tx.transfer.create({
|
||||
data: {
|
||||
id: v.requestId,
|
||||
userId: r.userId,
|
||||
sourceId: source.id,
|
||||
targetId: target.id,
|
||||
sourceRevisionId: outgoing.id,
|
||||
targetRevisionId: incoming.id,
|
||||
sourceCurrency: source.currency,
|
||||
targetCurrency: target.currency,
|
||||
amount: v.amount,
|
||||
received: v.received,
|
||||
fee: v.fee,
|
||||
effectiveDate: when,
|
||||
notes: v.notes,
|
||||
},
|
||||
});
|
||||
return { id: row.id };
|
||||
},
|
||||
{ isolationLevel: Prisma.TransactionIsolationLevel.Serializable },
|
||||
);
|
||||
return { id: row.id };
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { z } from 'zod';
|
||||
import Decimal from 'decimal.js';
|
||||
export const currencies = [
|
||||
'CNY',
|
||||
'USD',
|
||||
@@ -64,6 +65,10 @@ export const revisionInput = z
|
||||
'correction',
|
||||
'transfer_out',
|
||||
'transfer_in',
|
||||
'loan_borrow',
|
||||
'loan_lend',
|
||||
'loan_collect',
|
||||
'loan_repay',
|
||||
])
|
||||
.default('balance'),
|
||||
})
|
||||
@@ -90,7 +95,6 @@ export const positionInput = positionMeta
|
||||
.superRefine((p, c) => {
|
||||
if (
|
||||
(p.kind === 'asset' && p.side !== 'asset') ||
|
||||
(p.kind === 'debt' && p.side !== 'liability') ||
|
||||
(p.kind === 'account' &&
|
||||
['credit_card', 'loan'].includes(p.category) &&
|
||||
p.side !== 'liability')
|
||||
@@ -124,14 +128,33 @@ export const hiddenMenus = z
|
||||
.refine((v) => new Set(v).size === v.length, '菜单不可重复');
|
||||
export const transferInput = z
|
||||
.object({
|
||||
operation: z.enum(['transfer', 'borrow', 'lend', 'collect', 'repay']).default('transfer'),
|
||||
requestId: z.string().uuid().optional(),
|
||||
sourceId: z.string().uuid(),
|
||||
targetId: z.string().uuid(),
|
||||
amount: amount.refine((v) => /[1-9]/.test(v), '转出金额必须大于零'),
|
||||
received: amount.refine((v) => /[1-9]/.test(v), '到账金额必须大于零'),
|
||||
fee: amount.default('0'),
|
||||
fee: z
|
||||
.string()
|
||||
.regex(
|
||||
/^-?(0|[1-9]\d{0,15})(\.\d{1,8})?$/,
|
||||
'手续费可为负数表示优惠,最多 16 位整数和 8 位小数',
|
||||
)
|
||||
.default('0'),
|
||||
date: businessDate,
|
||||
notes,
|
||||
})
|
||||
.strict()
|
||||
.refine((v) => v.sourceId !== v.targetId, '不能向同一账户转账');
|
||||
.refine((v) => v.sourceId !== v.targetId, '不能向同一账户转账')
|
||||
.refine((v) => new Decimal(v.fee).neg().lte(v.amount), {
|
||||
message: '手续费优惠不能超过转出金额',
|
||||
path: ['fee'],
|
||||
});
|
||||
export const pairedReasons = [
|
||||
'transfer_out',
|
||||
'transfer_in',
|
||||
'loan_borrow',
|
||||
'loan_lend',
|
||||
'loan_collect',
|
||||
'loan_repay',
|
||||
];
|
||||
+2
-2
@@ -41,7 +41,7 @@ export function packBackup(b: Backup) {
|
||||
contents['manifest.json'] = JSON.stringify(
|
||||
{
|
||||
format: 'worthpath',
|
||||
version: 5,
|
||||
version: 6,
|
||||
exportedAt: b.exportedAt,
|
||||
files: files.map((name) => ({ name, sha256: sha(contents[name]) })),
|
||||
},
|
||||
@@ -116,7 +116,7 @@ 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)]),
|
||||
version: z.union([z.literal(3), z.literal(4), z.literal(5), z.literal(6)]),
|
||||
exportedAt: z.iso.datetime(),
|
||||
files: z
|
||||
.array(
|
||||
|
||||
Reference in new issue
Block a user