feat: show liability account credit balances as deposits
This commit is contained in:
1 parent
e77650f0c0
commit
20643b366f
19 files changed
+385
-68
No files matched your search
@@ -9,7 +9,7 @@
|
||||
"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: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:performance": "tsx scripts/performance.ts after",
|
||||
"icons:seed": "node scripts/seed-icons.cjs"
|
||||
},
|
||||
|
||||
@@ -103,6 +103,7 @@ model Revision {
|
||||
positionId String @db.Char(36)
|
||||
position Position @relation(fields:[positionId],references:[id],onDelete:Cascade)
|
||||
/// 变更后的原币绝对余额,不是收支增量,禁止对全部历史求和
|
||||
/// 负债账户正数为欠款,负数为溢缴存款;前端显示符号相反
|
||||
amount Decimal @db.Decimal(24,8)
|
||||
/// 北京时间业务时间对应的 UTC,精确到分钟
|
||||
effectiveDate DateTime @db.DateTime(3)
|
||||
|
||||
@@ -161,11 +161,8 @@ export function validateBackup(raw: unknown) {
|
||||
if (!b.currencies.includes(p.currency)) throw new BadRequestException('币种清单不完整');
|
||||
}
|
||||
for (const p of b.positions)
|
||||
if (
|
||||
(p.kind !== 'account' || p.side !== 'asset') &&
|
||||
p.revisions.some((r) => r.amount.startsWith('-'))
|
||||
)
|
||||
throw new BadRequestException('仅资产账户支持负余额');
|
||||
if (p.kind !== 'account' && p.revisions.some((r) => r.amount.startsWith('-')))
|
||||
throw new BadRequestException('仅账户支持负余额');
|
||||
const planIds = new Set<string>();
|
||||
for (const plan of b.schedules || []) {
|
||||
const source = ids.get(plan.sourceId),
|
||||
|
||||
@@ -75,6 +75,7 @@ export function totals(positions: Holding[], rates: Rate[], base: string, date:
|
||||
const converted = fx ? amount.mul(fx.value) : null;
|
||||
if (converted) {
|
||||
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 {
|
||||
|
||||
@@ -60,6 +60,12 @@ export function setupOpenApi(app: INestApplication) {
|
||||
const operation = entry[method];
|
||||
if (!operation) continue;
|
||||
operation.summary = `${method.toUpperCase()} ${path}`;
|
||||
if (
|
||||
(path === '/api/positions' && method === 'post') ||
|
||||
(path.startsWith('/api/positions/{id}/revisions') && ['post', 'put'].includes(method))
|
||||
)
|
||||
operation.description =
|
||||
'amount 为数据库原币余额:资产账户正数为存款、负数为透支;负债账户正数为欠款、负数为溢缴存款。网页负债账户输入和显示使用相反符号(负数欠款、正数存款)。独立资产和借入借出债务本金须非负。';
|
||||
if (
|
||||
method === 'get' &&
|
||||
['/api/history', '/api/positions/{id}/history', '/api/transfers'].includes(path)
|
||||
|
||||
@@ -159,8 +159,8 @@ export class PortfolioController {
|
||||
});
|
||||
if (!p) throw new NotFoundException('项目不存在');
|
||||
if (p.archived) throw new ConflictException('请先恢复归档项目');
|
||||
if (v.amount.startsWith('-') && (p.kind !== 'account' || p.side !== 'asset'))
|
||||
throw new BadRequestException('仅资产账户支持负余额');
|
||||
if (v.amount.startsWith('-') && p.kind !== 'account')
|
||||
throw new BadRequestException('仅账户支持负余额');
|
||||
const lastTransfer = await tx.revision.findFirst({
|
||||
where: { positionId: p.id, reason: { in: pairedReasons } },
|
||||
orderBy: [{ effectiveDate: 'desc' }, { sequence: 'desc' }],
|
||||
@@ -203,8 +203,8 @@ export class PortfolioController {
|
||||
});
|
||||
if (!p) throw new NotFoundException('项目不存在');
|
||||
if (p.archived) throw new ConflictException('请先恢复归档项目');
|
||||
if (v.amount.startsWith('-') && (p.kind !== 'account' || p.side !== 'asset'))
|
||||
throw new BadRequestException('仅资产账户支持负余额');
|
||||
if (v.amount.startsWith('-') && p.kind !== 'account')
|
||||
throw new BadRequestException('仅账户支持负余额');
|
||||
const original = await tx.revision.findFirst({ where: { id: revisionId, positionId: p.id } });
|
||||
if (!original) throw new NotFoundException('历史记录不存在');
|
||||
if (pairedReasons.includes(original.reason))
|
||||
|
||||
@@ -55,7 +55,7 @@ export async function captureReplay(tx: Prisma.TransactionClient, userId: string
|
||||
const amount = delta
|
||||
? (balances.get(row.positionId) || new Decimal(0)).plus(delta)
|
||||
: new Decimal(row.amount.toString());
|
||||
if (amount.isNegative() && (p.kind !== 'account' || p.side !== 'asset'))
|
||||
if (amount.isNegative() && p.kind !== 'account')
|
||||
throw new BadRequestException('修改后债务或资产金额不能为负数');
|
||||
if (amount.abs().gte('10000000000000000'))
|
||||
throw new BadRequestException('重算后的金额超出支持范围');
|
||||
|
||||
@@ -98,8 +98,8 @@ export const positionInput = positionMeta
|
||||
})
|
||||
.strict()
|
||||
.superRefine((p, c) => {
|
||||
if (p.amount.startsWith('-') && (p.kind !== 'account' || p.side !== 'asset'))
|
||||
c.addIssue({ code: 'custom', message: '仅资产账户支持负余额' });
|
||||
if (p.amount.startsWith('-') && p.kind !== 'account')
|
||||
c.addIssue({ code: 'custom', message: '仅账户支持负余额' });
|
||||
if (
|
||||
(p.kind === 'asset' && p.side !== 'asset') ||
|
||||
(p.kind === 'account' &&
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
import 'dotenv/config';
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { randomUUID, randomBytes, createHash } from 'node:crypto';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { readBackupZip } from '../src/zip';
|
||||
const base = process.env.TEST_API_URL || 'http://127.0.0.1:3100/api';
|
||||
test('liability account credit balances survive revisions, replay, calendar, privacy and ZIP restore', async () => {
|
||||
const db = new PrismaClient(),
|
||||
ids: string[] = [];
|
||||
const origin = process.env.WEB_ORIGIN === '*' ? 'http://localhost:5173' : process.env.WEB_ORIGIN!;
|
||||
async function user() {
|
||||
const u = await db.user.create({
|
||||
data: { username: 'wp_credit_' + randomUUID(), passwordHash: 'unused' },
|
||||
});
|
||||
ids.push(u.id);
|
||||
const token = randomBytes(32).toString('hex');
|
||||
await db.session.create({
|
||||
data: {
|
||||
id: createHash('sha256').update(token).digest('hex'),
|
||||
userId: u.id,
|
||||
expiresAt: new Date(Date.now() + 3600000),
|
||||
},
|
||||
});
|
||||
return { id: u.id, cookie: 'wp_session=' + token };
|
||||
}
|
||||
async function call(path: string, cookie: string, method = 'GET', body?: unknown) {
|
||||
const res = await fetch(base + path, {
|
||||
method,
|
||||
headers: {
|
||||
Cookie: cookie,
|
||||
Origin: origin,
|
||||
...(body ? { 'Content-Type': 'application/json' } : {}),
|
||||
},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
return { status: res.status, data: await res.json() };
|
||||
}
|
||||
try {
|
||||
const a = await user(),
|
||||
b = await user();
|
||||
const initial = {
|
||||
name: 'Card',
|
||||
kind: 'account',
|
||||
side: 'liability',
|
||||
category: 'credit_card',
|
||||
currency: 'CNY',
|
||||
amount: '100',
|
||||
date: '2026-09-01T10:00',
|
||||
};
|
||||
const create = await call('/positions', a.cookie, 'POST', initial);
|
||||
assert.equal(create.status, 201);
|
||||
const id = create.data.id;
|
||||
const balance = async (amount: string, date = '2026-09-02T10:00') =>
|
||||
call('/positions/' + id + '/revisions', a.cookie, 'POST', {
|
||||
amount,
|
||||
date,
|
||||
reason: 'balance',
|
||||
});
|
||||
const credit = await balance('-25');
|
||||
assert.equal(credit.status, 201);
|
||||
assert.equal((await call('/positions/' + id, a.cookie)).data.amount, '-25');
|
||||
let totals = (await call('/overview', a.cookie)).data;
|
||||
assert.equal(totals.assets, '25.00');
|
||||
assert.equal(totals.liabilities, '0.00');
|
||||
assert.equal(totals.net, '25.00');
|
||||
let day = (await call('/calendar/day?date=2026-09-02', a.cookie)).data;
|
||||
assert.equal(day.items[0].amount, '125');
|
||||
assert.equal(day.items[0].direction, 'income');
|
||||
assert.equal(
|
||||
(
|
||||
await call('/positions/' + id + '/revisions/' + credit.data.id, b.cookie, 'PUT', {
|
||||
amount: '-30',
|
||||
date: '2026-09-02T10:00',
|
||||
})
|
||||
).status,
|
||||
404,
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await call('/positions/' + id + '/revisions/' + credit.data.id, a.cookie, 'PUT', {
|
||||
amount: '-30',
|
||||
date: '2026-09-02T10:00',
|
||||
})
|
||||
).status,
|
||||
200,
|
||||
);
|
||||
assert.equal((await call('/overview', a.cookie)).data.assets, '30.00');
|
||||
assert.equal(
|
||||
(await call('/calendar/day?date=2026-09-02', a.cookie)).data.items[0].amount,
|
||||
'130',
|
||||
);
|
||||
assert.equal(
|
||||
(await call('/positions/' + id + '/revisions/' + credit.data.id, a.cookie, 'DELETE')).status,
|
||||
200,
|
||||
);
|
||||
assert.equal((await call('/overview', a.cookie)).data.liabilities, '100.00');
|
||||
await balance('-20');
|
||||
await balance('10', '2026-09-03T10:00');
|
||||
day = (await call('/calendar/day?date=2026-09-03', a.cookie)).data;
|
||||
assert.equal(day.items[0].direction, 'expense');
|
||||
assert.equal(day.items[0].amount, '30');
|
||||
const history = (await call('/positions/' + id + '/history', a.cookie)).data.items;
|
||||
await call('/positions/' + id + '/revisions/' + history[0].id, a.cookie, 'DELETE');
|
||||
assert.equal((await call('/positions/' + id, a.cookie)).data.amount, '-20');
|
||||
assert.equal(
|
||||
(
|
||||
await call('/positions', a.cookie, 'POST', {
|
||||
...initial,
|
||||
name: 'Initial credit',
|
||||
amount: '-5.01',
|
||||
})
|
||||
).status,
|
||||
201,
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await call('/positions', a.cookie, 'POST', {
|
||||
...initial,
|
||||
name: 'Debt',
|
||||
kind: 'debt',
|
||||
category: 'personal',
|
||||
amount: '10',
|
||||
})
|
||||
).status,
|
||||
201,
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await call('/positions', a.cookie, 'POST', {
|
||||
...initial,
|
||||
name: 'Invalid debt',
|
||||
kind: 'debt',
|
||||
category: 'personal',
|
||||
amount: '-1',
|
||||
})
|
||||
).status,
|
||||
400,
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await call('/positions', a.cookie, 'POST', {
|
||||
...initial,
|
||||
name: 'Invalid asset',
|
||||
kind: 'asset',
|
||||
side: 'asset',
|
||||
category: 'other',
|
||||
amount: '-1',
|
||||
})
|
||||
).status,
|
||||
400,
|
||||
);
|
||||
totals = (await call('/overview', a.cookie)).data;
|
||||
assert.equal(totals.assets, '25.01');
|
||||
assert.equal(totals.liabilities, '10.00');
|
||||
assert.equal(totals.net, '15.01');
|
||||
const trend = (await call('/trend?from=2026-09-01&to=2026-09-03', a.cookie)).data.items;
|
||||
assert.equal(trend[0].assets, '5.01');
|
||||
assert.equal(trend[0].liabilities, '110.00');
|
||||
assert.equal(trend[1].assets, '25.01');
|
||||
assert.equal(trend[1].liabilities, '10.00');
|
||||
const zip = await fetch(base + '/backup', { headers: { Cookie: a.cookie } });
|
||||
assert.equal(zip.status, 200);
|
||||
const backup: any = await readBackupZip(Buffer.from(await zip.arrayBuffer()));
|
||||
assert.equal(
|
||||
(await call('/backup/import', b.cookie, 'POST', { confirmed: true, backup })).status,
|
||||
201,
|
||||
);
|
||||
const restored = (await call('/overview', b.cookie)).data;
|
||||
assert.equal(restored.assets, '25.01');
|
||||
assert.equal(restored.liabilities, '10.00');
|
||||
assert.equal(restored.net, '15.01');
|
||||
assert.equal(
|
||||
(await call('/calendar/day?date=2026-09-02', b.cookie)).data.items[0].amount,
|
||||
'120',
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await call('/positions/' + id, a.cookie, 'PATCH', {
|
||||
name: 'Card',
|
||||
category: 'credit_card',
|
||||
hidden: true,
|
||||
})
|
||||
).status,
|
||||
200,
|
||||
);
|
||||
assert.equal((await call('/positions/' + id, a.cookie)).status, 404);
|
||||
assert.equal((await call('/overview', a.cookie)).data.assets, '5.01');
|
||||
} finally {
|
||||
await db.user.deleteMany({ where: { id: { in: ids } } });
|
||||
await db.$disconnect();
|
||||
}
|
||||
});
|
||||
@@ -7,7 +7,7 @@ import { executeMovement } from '../src/transfers';
|
||||
import { transferInput, positionInput, revisionInput } from '../src/validation';
|
||||
import { scheduleInput, occurrenceId, nextOccurrence, SchedulesController } from '../src/schedules';
|
||||
import { trend, totals, type Holding } from '../src/calculation';
|
||||
import { positionAmount, money, type Position } from '../../web/src/api';
|
||||
import { positionAmount, accountInputAmount, money, type Position } from '../../web/src/api';
|
||||
const now = new Date(Date.now() - 60000);
|
||||
const date = new Date(+now + 8 * 3600000).toISOString().slice(0, 16);
|
||||
function fixture(
|
||||
@@ -114,7 +114,7 @@ test('transfer into an overdrawn account is allowed; debt overpayment still fail
|
||||
assert.equal(debt.balances.get(debt.sourceId), '-11');
|
||||
assert.equal(debt.balances.get(debt.targetId), '0');
|
||||
});
|
||||
test('negative balances are confined to asset accounts; group names and principal remain validated', () => {
|
||||
test('negative balances are confined to accounts; group names and debt principal remain validated', () => {
|
||||
const data = {
|
||||
name: 'test',
|
||||
category: 'bank',
|
||||
@@ -127,7 +127,7 @@ test('negative balances are confined to asset accounts; group names and principa
|
||||
};
|
||||
assert.equal(positionInput.parse(data).groupName, '日常');
|
||||
assert.equal(positionInput.safeParse({ ...data, kind: 'debt' }).success, false);
|
||||
assert.equal(positionInput.safeParse({ ...data, side: 'liability' }).success, false);
|
||||
assert.equal(positionInput.safeParse({ ...data, side: 'liability' }).success, true);
|
||||
assert.equal(positionInput.safeParse({ ...data, groupName: 'a'.repeat(61) }).success, false);
|
||||
assert.equal(revisionInput.parse({ amount: '-0.00000001', date }).amount, '-0.00000001');
|
||||
assert.equal(transferInput.safeParse({ ...fixture().request, amount: '-1' }).success, false);
|
||||
@@ -343,3 +343,82 @@ test('calendar replays only the range, preserves fractional totals, and hides in
|
||||
assert.equal(month.items[2].complete, false);
|
||||
assert.equal(month.items.length, 30);
|
||||
});
|
||||
|
||||
test('liability account credit balances invert display and input without losing decimal precision', () => {
|
||||
const card = {
|
||||
kind: 'account',
|
||||
side: 'liability',
|
||||
amount: '-9999999999999999.12345678',
|
||||
} as Position;
|
||||
assert.equal(positionAmount(card), '9999999999999999.12345678');
|
||||
assert.equal(positionAmount(card, '12.12345678'), '-12.12345678');
|
||||
assert.equal(positionAmount(card, '-0.00000000'), '0.00000000');
|
||||
assert.equal(accountInputAmount(card, '12.12345678'), '-12.12345678');
|
||||
assert.equal(accountInputAmount(card, '-12.12345678'), '12.12345678');
|
||||
assert.equal(accountInputAmount({ ...card, kind: 'debt' }, '12.12345678'), '12.12345678');
|
||||
});
|
||||
test('credit balances count as assets across zero, retain FX attribution and do not offset other liabilities', () => {
|
||||
const card: Holding = {
|
||||
id: randomUUID(),
|
||||
name: 'Card',
|
||||
kind: 'account',
|
||||
side: 'liability',
|
||||
currency: 'USD',
|
||||
revisions: [
|
||||
{
|
||||
id: 'a',
|
||||
amount: '100',
|
||||
effectiveDate: new Date('2026-09-01'),
|
||||
reason: 'initial',
|
||||
notes: '',
|
||||
},
|
||||
{
|
||||
id: 'b',
|
||||
amount: '-25',
|
||||
effectiveDate: new Date('2026-09-02'),
|
||||
reason: 'balance',
|
||||
notes: '',
|
||||
},
|
||||
],
|
||||
};
|
||||
const rates = [
|
||||
{
|
||||
currency: 'USD',
|
||||
baseCurrency: 'CNY',
|
||||
date: new Date('2026-09-01'),
|
||||
rate: '7',
|
||||
source: 'manual',
|
||||
},
|
||||
{
|
||||
currency: 'USD',
|
||||
baseCurrency: 'CNY',
|
||||
date: new Date('2026-09-02'),
|
||||
rate: '8',
|
||||
source: 'manual',
|
||||
},
|
||||
];
|
||||
const owing = totals([card], rates, 'CNY', '2026-09-01');
|
||||
assert.equal(owing.assets, '0.00');
|
||||
assert.equal(owing.liabilities, '700.00');
|
||||
assert.equal(owing.net, '-700.00');
|
||||
const credit = totals([card], rates, 'CNY', '2026-09-02');
|
||||
assert.equal(credit.assets, '200.00');
|
||||
assert.equal(credit.liabilities, '0.00');
|
||||
assert.equal(credit.net, '200.00');
|
||||
const rows = trend([card], rates, 'CNY', '2026-09-01', '2026-09-02');
|
||||
assert.equal(rows[1].fxChange, '-100.00');
|
||||
assert.equal(rows[1].balanceChange, '1000.00');
|
||||
const debt = {
|
||||
...card,
|
||||
id: randomUUID(),
|
||||
kind: 'debt',
|
||||
currency: 'CNY',
|
||||
revisions: [{ ...card.revisions[0], amount: '20' }],
|
||||
};
|
||||
const mixed = totals([card, debt], rates, 'CNY', '2026-09-02');
|
||||
assert.equal(mixed.assets, '200.00');
|
||||
assert.equal(mixed.liabilities, '20.00');
|
||||
assert.equal(mixed.net, '180.00');
|
||||
assert.equal(cashflowDelta('liability', 'balance', '100', '-25').toFixed(), '125');
|
||||
assert.equal(cashflowDelta('liability', 'balance', '-25', '10').toFixed(), '-35');
|
||||
});
|
||||
Reference in new issue
Block a user