From 20643b366fbafce122849b580bf62ae47b4b69f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E7=85=9C?= <1871263099@qq.com> Date: Sat, 3 Oct 2026 12:06:36 +0800 Subject: [PATCH] feat: show liability account credit balances as deposits --- apps/api/package.json | 2 +- apps/api/prisma/schema.prisma | 1 + apps/api/src/backup.ts | 7 +- apps/api/src/calculation.ts | 1 + apps/api/src/openapi.ts | 6 + apps/api/src/portfolio.ts | 8 +- apps/api/src/replay.ts | 2 +- apps/api/src/validation.ts | 4 +- apps/api/test/credit-balance.test.ts | 193 +++++++++++++++++++++++ apps/api/test/update.test.ts | 85 +++++++++- apps/web/src/App.tsx | 81 ++++------ apps/web/src/api.ts | 15 +- apps/web/src/locales/en.json | 6 +- apps/web/src/locales/zh-Hant.json | 6 +- docs/acceptance.md | 4 + docs/architecture.md | 2 + docs/credit-balance-preview.png | Bin 0 -> 50780 bytes docs/update-credit-balance-2026-10-03.md | 26 +++ update.md | 4 + 19 files changed, 385 insertions(+), 68 deletions(-) create mode 100644 apps/api/test/credit-balance.test.ts create mode 100644 docs/credit-balance-preview.png create mode 100644 docs/update-credit-balance-2026-10-03.md diff --git a/apps/api/package.json b/apps/api/package.json index 079dd3d..fbfb05d 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -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" }, diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 5e4679e..a25e5e1 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -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) diff --git a/apps/api/src/backup.ts b/apps/api/src/backup.ts index 369cd87..1099823 100644 --- a/apps/api/src/backup.ts +++ b/apps/api/src/backup.ts @@ -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(); for (const plan of b.schedules || []) { const source = ids.get(plan.sourceId), diff --git a/apps/api/src/calculation.ts b/apps/api/src/calculation.ts index 8b2f4e2..5df990a 100644 --- a/apps/api/src/calculation.ts +++ b/apps/api/src/calculation.ts @@ -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 { diff --git a/apps/api/src/openapi.ts b/apps/api/src/openapi.ts index 4c2f0da..fb602e6 100644 --- a/apps/api/src/openapi.ts +++ b/apps/api/src/openapi.ts @@ -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) diff --git a/apps/api/src/portfolio.ts b/apps/api/src/portfolio.ts index 70ac284..84ab6f8 100644 --- a/apps/api/src/portfolio.ts +++ b/apps/api/src/portfolio.ts @@ -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)) diff --git a/apps/api/src/replay.ts b/apps/api/src/replay.ts index 4aa38f6..90c5cfb 100644 --- a/apps/api/src/replay.ts +++ b/apps/api/src/replay.ts @@ -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('重算后的金额超出支持范围'); diff --git a/apps/api/src/validation.ts b/apps/api/src/validation.ts index c0ae557..30316b8 100644 --- a/apps/api/src/validation.ts +++ b/apps/api/src/validation.ts @@ -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' && diff --git a/apps/api/test/credit-balance.test.ts b/apps/api/test/credit-balance.test.ts new file mode 100644 index 0000000..aab1f19 --- /dev/null +++ b/apps/api/test/credit-balance.test.ts @@ -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(); + } +}); diff --git a/apps/api/test/update.test.ts b/apps/api/test/update.test.ts index 7f66461..8a143a5 100644 --- a/apps/api/test/update.test.ts +++ b/apps/api/test/update.test.ts @@ -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'); +}); diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 1676651..6e25c60 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -25,6 +25,7 @@ import { currencies, money, positionAmount, + accountInputAmount, today, nowMinute, displayTime, @@ -1157,16 +1158,14 @@ export default function App() { ? tr(p.side === 'asset' ? '借款(待收)' : '欠款(待还)') : p.side === 'asset' ? tr('资产') - : tr('负债')} + : tr( + p.kind === 'account' && p.amount.startsWith('-') + ? '溢缴存款' + : '负债', + )} {p.archived ? tr(' · 已归档') : ''} -

+

{money(positionAmount(p), p.currency)}

{p.hidden && {tr('隐藏项目')}} @@ -1368,14 +1367,16 @@ export default function App() { ? tr(p.side === 'asset' ? '借款(待收)' : '欠款(待还)') : p.side === 'asset' ? tr('资产') - : tr('负债')} + : tr( + p.kind === 'account' && p.amount.startsWith('-') + ? '溢缴存款' + : '负债', + )} {p.archived ? tr(' · 已归档') : ''}

{money(positionAmount(p), p.currency)} @@ -2262,7 +2263,7 @@ export default function App() { api( '/positions/' + mp!.id + '/revisions' + (modal.h ? '/' + modal.h.id : ''), modal.h ? 'PUT' : 'POST', - v, + { ...v, amount: accountInputAmount(mp!, String(v.amount)) }, ), tr('金额历史已保存,总览已重新计算'), ); @@ -2292,6 +2293,7 @@ export default function App() { ...(f.has('iconId') ? { iconId: f.get('iconId') || null } : {}), kind, side, + amount: accountInputAmount({ kind, side }, String(v.amount)), hidden: f.get('hidden') === 'on', }), tr('项目已添加'), @@ -2360,7 +2362,7 @@ export default function App() { {modal.kind === 'account' && (

- {tr('信用卡、贷款账户自动计入负债,其余账户计入资产。')} + {tr('信用卡、贷款账户:负数为欠款,正数为溢缴存款;其余账户按实际余额填写。')}

)} @@ -2406,13 +2408,16 @@ export default function App() { inputMode="decimal" required pattern={ - (modal.p?.side === 'asset' && modal.p?.kind === 'account') || - modal.kind === 'account' + modal.p?.kind === 'account' || modal.kind === 'account' ? '-?(0|[1-9][0-9]{0,15})(\\.[0-9]{1,8})?' : '(0|[1-9][0-9]{0,15})(\\.[0-9]{1,8})?' } defaultValue={ - modal.h?.after || modal.p?.amount || (modal.kind === 'debt' ? '0' : '') + modal.p + ? accountInputAmount(modal.p, modal.h?.after || modal.p.amount) + : modal.kind === 'debt' + ? '0' + : '' } placeholder="0.00" /> @@ -2430,6 +2435,13 @@ export default function App() { /> )} + {['revision', 'correction'].includes(modal.kind) && + modal.p?.kind === 'account' && + modal.p.side === 'liability' && ( +

+ {tr('负数表示欠款,正数表示溢缴存款;溢缴部分计入总资产。')} +

+ )} {modal.kind === 'revision' && ( <> @@ -2575,42 +2587,19 @@ function HistoryTable({ - {money( - h.kind === 'debt' && h.side === 'liability' && Number(h.before) !== 0 - ? '-' + h.before - : h.before, - h.currency, - )} + {money(positionAmount(h, h.before), h.currency)} - {money( - h.kind === 'debt' && h.side === 'liability' && Number(h.after) !== 0 - ? '-' + h.after - : h.after, - h.currency, - )} + {money(positionAmount(h, h.after), h.currency)} - {money( - h.kind === 'debt' && h.side === 'liability' - ? h.delta.startsWith('-') - ? h.delta.slice(1) - : Number(h.delta) !== 0 - ? '-' + h.delta - : h.delta - : h.delta, - h.currency, - )} + {money(positionAmount(h, h.delta), h.currency)} {h.notes || '—'} {correct && ( diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index cb991f7..27feb08 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -140,8 +140,15 @@ export function requestUuid() { export type PageResult = { items: T[]; nextCursor: string | null; revealed: boolean }; -export function positionAmount(p: Position, value = p.amount) { - return p.kind === 'debt' && p.side === 'liability' && !/^[-]?0(?:\.0+)?$/.test(value) - ? '-' + value.replace(/^-/, '') - : value; +// Stored liability balances are amounts owed; negative account balances are credit. +export function positionAmount( + p: Pick & { amount?: string }, + value = p.amount || '0', +) { + if (p.side !== 'liability' || /^-?0(?:\.0+)?$/.test(value)) + return value.replace(/^-0(?=\.0+$|$)/, '0'); + return value.startsWith('-') ? value.slice(1) : '-' + value; +} +export function accountInputAmount(p: Pick, value: string) { + return p.kind === 'account' ? positionAmount(p, value) : value; } diff --git a/apps/web/src/locales/en.json b/apps/web/src/locales/en.json index 92a35d9..309b8f9 100644 --- a/apps/web/src/locales/en.json +++ b/apps/web/src/locales/en.json @@ -463,5 +463,9 @@ "备份下载失败": "Backup download failed.", "备份文件格式错误": "Invalid backup file format.", "备份文件为空": "The backup file is empty.", - "备份已生成,请确认文件已保存": "Backup generated. Please confirm the file has been saved." + "备份已生成,请确认文件已保存": "Backup generated. Please confirm the file has been saved.", + "溢缴存款": "Credit balance", + "信用卡、贷款账户:负数为欠款,正数为溢缴存款;其余账户按实际余额填写。": "Credit cards and loan accounts: negative means debt, positive means credit. Enter the actual balance for other accounts.", + "负数表示欠款,正数表示溢缴存款;溢缴部分计入总资产。": "Negative means debt, positive means credit. Credit balances count towards total assets.", + "仅账户支持负余额": "Only accounts support negative balances." } diff --git a/apps/web/src/locales/zh-Hant.json b/apps/web/src/locales/zh-Hant.json index d8f301a..df6b946 100644 --- a/apps/web/src/locales/zh-Hant.json +++ b/apps/web/src/locales/zh-Hant.json @@ -463,5 +463,9 @@ "备份下载失败": "備份下載失敗", "备份文件格式错误": "備份檔案格式錯誤", "备份文件为空": "備份檔案為空", - "备份已生成,请确认文件已保存": "備份已產生,請確認檔案已保存" + "备份已生成,请确认文件已保存": "備份已產生,請確認檔案已保存", + "溢缴存款": "溢繳存款", + "信用卡、贷款账户:负数为欠款,正数为溢缴存款;其余账户按实际余额填写。": "信用卡、貸款賬戶:負數為欠款,正數為溢繳存款;其餘賬戶按實際餘額填寫。", + "负数表示欠款,正数表示溢缴存款;溢缴部分计入总资产。": "負數表示欠款,正數表示溢繳存款;溢繳部分計入總資產。", + "仅账户支持负余额": "僅賬戶支援負餘額" } diff --git a/docs/acceptance.md b/docs/acceptance.md index 6af4887..f50144c 100644 --- a/docs/acceptance.md +++ b/docs/acceptance.md @@ -66,3 +66,7 @@ ## 2026-10-03 账号、删除与备份修复 28 项单元测试、11 项真实 MySQL 集成检查、前后端类型检查和生产构建通过。新增账号凭据变更与会话撤销、配对历史直接删除、零历史项目及 ZIP 上传恢复验证;浏览器确认删除弹窗和修改后的自动刷新。详情与验证边界见 [更新说明](update-2026-10-03.md)。无数据库结构变更。 + +## 2026-10-03 负债账户溢缴存款 + +30 项单元测试、12 项真实 MySQL 集成检查、类型检查及构建通过。浏览器验证信用卡从欠款转为正数溢缴存款并计入总资产,余额历史与总览自动刷新;补充金额精度、趋势、日历、权限和备份恢复断言。无数据库结构变更。详见 [更新说明](update-credit-balance-2026-10-03.md)。 diff --git a/docs/architecture.md b/docs/architecture.md index 81013bc..0eff0a7 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -45,3 +45,5 @@ User.hiddenMenus 保存受枚举校验的菜单键,settings 不可隐藏;Use 2026-10-02 记录编辑更新:允许更正转账之前的独立余额历史。`replay.ts` 重放涉及项目的历史,绝对余额保留为锚点,配对资金往来与定时支出按增量更新;整笔修改/删除与两侧余额在同一事务提交。单侧配对历史禁止独立更正;2026-10-03 更新后,从任一侧删除会原子删除完整配对记录。日历读取保存后的账户历史重算。完整边界见 [更新说明](update-2026-10-02.md)。 2026-10-03 修复:历史重放允许没有绝对余额锚点时从零计算增量,空历史项目金额为零;备份校验、转账和计划支出使用相同规则。账号凭据变更锁定用户行、验证当前密码、更新凭据并在同一事务撤销旧会话和创建新会话;无需新增字段。/auth/me 返回完整隐藏授权状态和会话时间。前端 mutation 更新数据版本,以当前页面重新读取数据,旧视图请求通过 generation 检查丢弃。见 [更新说明](update-2026-10-03.md)。 + +2026-10-03 负债账户溢缴更新:负债账户存储正数欠款、负数溢缴;前端显示和输入取相反符号。统计将负债账户的负余额按汇率转换为正资产,正欠款计入负债,两类分别汇总。独立资产和借入借出债务仍要求非负本金,旧数据和 API 金额含义保留。详见 [溢缴更新说明](update-credit-balance-2026-10-03.md)。 diff --git a/docs/credit-balance-preview.png b/docs/credit-balance-preview.png new file mode 100644 index 0000000000000000000000000000000000000000..ca59bc5e79b81fd9b22e6efa9b04ca7b06d9e735 GIT binary patch literal 50780 zcmeFZcUW6lb1*J<8@F|#x3%fr^bYF+EMUMC(S%@anne>uh#~~LwlPazg8);tm}dGS zKmtUu!SrUTkc5yybPyd;MEPmo*Tf#5mmLvPpqt+-?VPB81t}d{2TneQkJ^@WPFj8Cx442 z(wVat&Yru>lI(8;XKtOneeaz7!{;03Lw>*Hp1*rv;a%0|={pt|6d!G#k^t9ObZ2j! zy9Lk%92)_?|7WKEI#qrXgVcGviZ=TBU+4T+;}G~MC{XaAodoQeDmMv$W}d%xs2cmnvyIMNH zOX4;T4h3S9Ny-#7VIy$4Jm=c%fyFe+1?&0wDE*ShPFQxpkG8=?Pi`Xwu`$TfQ4k!@ z>8i<|p6jtYS8jB>(w$c-P7vFuc?4%$`xyW@53m~Bl{OJF!>c}PP@-AkQ0PNB&e;(C z+@+H5x=^s=dqg9oi1(z-$?SW-nd7{u&qrH~?}Lkuu`Z{8S=1?D{pKkE#dIp{ts^Kt zDL0_D{kC^<%TzbwTLf;{ar8z&Rd(eF-X>b}fkSC?d3}u?BX?oGaUf3H);+E?NhO<)L%)o8w2V3F%uln?+CAg2-afx)A}JP!C~>;Z zlWD!w7h`ObF69;|nyj*Tdw#*);VI)LM3=l$Tq%qsVPz!SNS03Oso>q9%Vfs!yIbYEz7HFLEi zl0dkr;}g)eY=Im{gHnmdnGVTC5zCWkSe;XV*%VfI3t9^Lg=8$(3RipX1d|Ff-q7O7;x{gQ$3IF5YfD~qy%>Sh%*%5iRqP% zoa!}>{l#b}k21}S4pPZve1^?Dw5nU%dT(0V~X%@Ll+(pp8V)~w9HGHZi zY#fxuOV+EPS2}LT2kg-a;oKpr1WJ8=DK-Q?mgu6?U1yuCZs#D4m6VmJTjx5iDD7?O zMeqn*G)0$B8)r;8qvyF=kWE1yz%E0F=QWJkIZ=bI)(H-jyExO{* zK=Max*Zq`p9NL7v;!llee^6~wDcis=(@*dYxrI@v0Qsc$$Ta6%o$^fc3}o96c`skv zK&W!mc?v>X4^K7Ila9IqU|P{HXy*Ft@BhfLuP0oQLgvJV^f)-kxit5#K|hT`4&g2k zlvhW8is5IVkaoMdIz&^NlfccAlfVnBBfuS)Y>$cyp3KHnL*(&0m6WL6kM z#a5%Yr#;806D1z0)iEcNRe+`I1<~gj)X9JY9_<6W>cL?6mPQY(*GE?Mdj3qt$^{H4 zsJR@0>opzmhaC&uvn<%?RX}}6N>nHnM)9OliAa$-g5OBP z)FcQTpYVEDMVRy^z|rw3Ty^@(v=_1KpmK)fNG@dK1AC)A^_mrEg^(+(d(1B_t>tB> zB`aX%dU39Y2%`kiG2{sTSMA#=+wXe_+nz;<0&!kZ+(r_`H5exb(9maZ6KO%*MXnSf zy!Jqqp=w=A14qR!&zF4W*HLpmdSP#u$pz+?4qzrCX=%!|RA%5}WlG8~ZU)7plkRO? z9XmJ>Vn4|bu~z3em}|pXqs}oc%N5;imwlUq80e+KSkxL061b z6S~MYKYmsf&RovN5@j+sRH=CnbWaR8PXYCbv(_)>^6)jOZT7a71q7~fdd|e2?JSP$ zy>Df)&N%GMh4awj{i@}7$Cgu-FwUwklBQ!!)~BaDF-*WuxMc`N>Ag z8_l~|=FVR8+MASbZcco*?mrQDirZ4WrPwnEiJpa1^4nr&niYEsZY`=ZbyX@y;|UkBqOl1gi`OdXAD*F+-YnwGi`gd+k=_D1WuhSA;TDqy3wpgYl>iW z;S1NAq22xEdQ1_|uS6@McCDClYa7nBgq5ZP+4b;_)j6g98_R()e6(a~`#cee*~dJR z-rlXW-k7vlMnWRbiZ@Ft6$$D%b8Q zz$yVGhfy3=mj2i?nH6U9xZFUH2os|Arn6C)7A6k-sDfmA$Y%95b8KfA2*_|8Qe z^0jcVd5SDM(Ct`yCTSYs(>$a|A`ISv(@cX?T4O|dFba4wYHCu8R}P|m+#W~qloOb|Tz198$t73Bfu@S7uvi)k zqyz^+*JZvf?psYekNF5lmqq%;p(W+uX-S3NnJh@5e}(*9KeUa# zI+YG%J}qvtcR+e-^g`1X7bHDXe0|?!Vs5AQ7t1uP=A%v5@b8xS3*x!CzbmZoQ67RN zTbV|T+sFzuYSIN&aeZTuQ0;x9T5|Wu!ZOgLH~nT)ga2ZI%N>NC4>J?V`6SJ-WOx^1 ztmDa1l7|d=5a(6vhY0Wo3pJbq9yp9ZWqd3~1i*KTANqdm@h%APtdtR+Y-|YJ^MRDo z1z-{#DM=+=tqwM1HYkeE-bb?=h*?i;nfIcErdli48eb<|00PGx4P=cJO*ZXTaSe_G zy%9lv7fo+CRP>Y_Dz0^6=3JWWt;8McoQEBt(SfmRoTuLZ|!AQJJ7EemcUO zuFTaSt)T6GQ9Ws{*|J%|#K=$ui?r^q8En{l*<^FObf?9p?{(F~p4)HOqe=L7}M^|rKdnqq3S~-Z%xrXFF4UzDr zV^*eS(|&*&O=K7MwOHqIPj|38$vTL*2HDVssbRmT_KRWG7XueAME$<3RLcWZ)i((TVsL9aUm187*w$6jQrsY@4r~S{Qp9($uTo^T~7hI>5->^0Qc3FowNG@ zsW$_36)e-K6k@A9Qx+yZOcP5e%cLc&2T83sJ?Td1lHyk5j)OSu@aJxGeOL0VE8C;4 zNa=*MVB+0O8P<=*@(8|Ez#B-H>ze&JJv|juBQBS|?6@KG$n?vk6K3B9M}?aZ)f6-VX?9`)F<=v23*|)V7YulVvR$ zEzRv=)7q2SHQ!;_VPPB%~a{dX)@-oe5{&X;1NVl zL}D;^I`Vij?gqU|#wvZb&o5{&M|SUJlMDVmW z+HppIE8P@WjTy#2FLQrcH7bMS;OM>278hE@m;Kex)X0OwPFyL6*@QJzZE#DwiqkI2 z!XPcBj7R17>m>WCZv%&<-=55<1l+H)3N2P1+gWLtdaBJUWVREL`Jwl!Mf<3>zXFCN zUtaHGXlVRWt+KimWtB6uy)HDcI)Ke}SA9|`Xq@V8Gv+AtX0_wP?I~bSop)#Z`gB0J z-pT@0i=vp~y`Zaw1Cj>5dhDma*U;B~&t+#HRT)95kGT}2k z2Ty2BC+aJScVdH&NOb=NBRO~RBKFl5!AYCdljs#>5uKx;WGO{IQ@T{}HYbp?$+sL~ zs53j+((67MXxk9ZAw0P$FTa=xQn#^sLE2b+69g+xpO11J6CY9vYch}vH`U@Jfwwk{ z`GaOC=QON3q1O3hikTU929>8n#Z1=D%wtDr2~xiAz3 z!rj;6d{j$Sz0wh;+Y;rRO?OK<1+Y&qHRPm3Yo*)q6kL^ze}=l9;ctnW&Bv}_VobF6 zT$bGfpCvw~r`AFtxKUWYsfMWw1^8@-oae^tt9n{2&E8A zOL|&YWJVyF+%x4MqyBJwcyW(9?*S9&^|VFi4VQJbje377Jn>F$=uwS*6#B|z>STMW$wdI~+m-Y&gg&S)|;h&J+cpFD&P?eB=%fI_3#Cwa(0q7Llwt6z_B=uG$AMCey}k%CyQfm_hE>2y$x&!FS(dv;Mc-ttp*i9D9u&JE zFVMdns?U+@Xy9J!{rJbiFMs$tY-VJM4Vx}lGYmVU>-Ol>d7lU+LRRCkU1N#XZr}lb z@mOBv@_rUwB@3x}Rz0=Db}Rc`W^9^Npof`DGS#*D6wrvSKF%AjuD#Y6(soIt&7?@< zeQWyH$<-OsZJz*eZ&PsAc=?3a!QrEj2_8KT2;8*TPO~C5mXzLV*p8EF$Y(g!5f@>P z&tYvmvwp~ zJ51TWjIKb-1I!?L2Zb3bQ6h_N=Y+0ejwT!Cfc5pNZd>`-z>aR*T|zw_XQo)LEJIU0 z0}@*=Jx-6Paw$*V8$0Q1nTVDCbAUE+n$Xu*K7QkJmxX*6dq-VcFzLOota?5zd~0nnG;!zGQ^1#(PE|pNpOU}Uu2`(1 zU&1t9Oa#5YroxJpbx0Zv%ISsei;^iugz1u}=2hSx{6VotV z?Kq`lIbDnH936DjyX3GtsL~Rggm6xOO(StFC)%^Lx+%Fe6u9DDuGGDPKk> zwFsD0O9MNk4puA`Hlhy&3(a~dzchsCyi+P&^&XgPR##p3#tx2(hRZ>ZptC(fDK$KT zUP>|nwu8epjcf4SSER0j5f?s{w5u!P`G@;H24>!P?H~_#V$UvDuia}1ZL4T1mG0-e zu@{m9ykbK%TM|rj9R|Me2u^OWKynOdnH_x_ICnOL%f2vriDyrokXb?Iy|V{wj37?5 z_{-iZH6zeq4EHQrgkS+^XUf1+$zIdkz}>r_Y}dfoTbfurw;7se zP9{&3iA7ZEG<``h*MWOFZr}8k$8-S&q{O%k@FCjoX22hIYRe63H8{lFY7PVZ0v#^9 z029S^JTV>+Sy|yE6PcrAwNrpGw{~5_K=)wH*wRFZ3RT2ZTOqCbMTXP3*Q5h{+SBcL zuDEW0NbASq_Xun`IbQK8JrzyNiowi%bk{Q~ZoL(lzqcL+VxsGo{3|Otc_w4$+Dx7W zWMQE%3-HLB1N?uWA%4el3?sYUB_cT2DnBn#!hjzqv9X&(5yn!B>a!iuUdrLb=OKqC$O!*Tco)PXQ#smw20rlW|CG_^%1!*@-Ue zJ%s^*;KVi&RcmsyEIo6HFzH{1E}AtiB8 z1b)q9!NQ&E*6{&)tz!>pE`D3f@nfa8Eey-YmsAORX@i4Y6dXO#xk-=#vJ4GRI|bbA zY1pJ)k}x_DZZO;v-xMa1BNTUTa6IwfSnPU5QX%l(-tr8v(|bDfHt1H^(gQ&6PpqMW z?JHAkWLBqp=OYXGZw0PNTnQwouKN26Uq%co=GjsP0?YGi^481>JHif50YQT8r+^>q zKS^yb98X4Y9$(Ulpmc6Ve3dCX+wzaefPct2;|Zu7MDO_>IqmTM2L(C)U_1JI_QQXq zzwqD9?1EPg?s1P?M9EQN#Q`&y{S+`xJ^u9&JDIbET>YM<-?JVktqSs$t3u!Z&ca@I z=;l8W>HE4+{mp0*x3C&+AiwBjQ)x^`-Ane8?CdECG_p*)W@#MlL+yo5A)r2*@`8(>rAi2@n4 zO@q@`VdA}0z|TzcQ$W((G3UIi(@&b_>L1H_MWE}c-4Vs2vJ#1nenrwnPu6#w7l&`7 zp-RB>WSe5$B(s3sd-GGVNP)rX)Fv})-_!!}I)a11GKzV~oEW`_|Qm3a(UY8zO?@`uKYADtZXJe>xQPU&kV^E zMqWAo@YcpnTf_tTuBgf`*dD0iq9&P>UuhOoXQ;fo-cldo8iYs;zF6oJMBj0H@M}y| zyz`;vm{N<8c2fVR&iTa*>+^nP-6_ABMeBc|-eB(fgL|jEk!o;7Te`M}th;-j2{FTa zZ9p&i8O_Xns5zU@24V2=?9%}%FZfnt=<22&+#Sn9$gk*yC)*5*e@T#5^B=SO4&_O* zB(v6bLq#LuTLZNdpA>@u)WdD1<$eunL4yp;@3~vjT1uxLBRxX z=B>)*oVR%G-=9C%@3*`)GEhLd{BfhzkZH1dSUWjrJ0xDsssE|7SxaT?$vx#}rt;l% z7>C<>^9t7&o0q=>^nYe`5cXV#Sy30ueEy7u{J(=U$`Q}Gxd@C~zx^r45C5(pXY5&4 z{;(*bP6`0P1$KcH!Uw~-^txAZi2DT@SUxcqaMN>xvZgeKmT|vmh9_(Q-aKf>19^?T z`D(4Q(w?LH@e`Sycb#?88(cO zvXw)dzc#doPvS27p!FGtqNZD(ajUPrjq0R?v1?`#-6>5LOKoQJ8mP^Mb{&C*RU>Vk zk8O%i6Rd%Z0W-*OY2x*ygnsp))uSPIj8CSJd0`~T59`pS$$6faB;`#$%##Y(T*mX% zn~}PhO{vVIBTd3+x!e1-965z<$>rQJkt<4bEjJHfF6xJR^(qvNEq>6WTZQt51+9Ca zwM=lB)|rOYUAAd7p8EUa7_UAV2fS zI~C@8Ym-i9a;_;p!|X}T17dyIn^@g(L*eAa&^2n_-dSj#mx12bW?3M1CZE0xVCqK;9+@Bq_2}38bHX#AN2Myo}R8!sWG){HZT`2rv z$xl?|CXNGGtl^%h?8X%Db^n3|+p{KxnP^F(zLB{_tf`Do#$HM;`pH)P)x_8!b}>QM z+NtDCh0+@(v_}r!BNr|+yg|0Gj#-D&H?9Kwx$0)I70f0=^%iI^^Sj>x;{R?R z`>)IR+g$u04&^bEY$R0*oivC}k5A_|zH>j5##tR`VkpLZRm7_8yMMn%YgSiB-(#2+ z{P6G7{14<@5?QifLRRyItR{i=w<-__T1^hsDnV>0k}*bF^_W)4g^6)@QiMN{Se}c7 z-eJOWv+91`Dd7Bo*ihw4&ZVBgxh^U4r$4=?0Dy-aL84^k_it%Z-jii9GZgxW_bFh| zeb!LM?Y%xu+@xN${ZrQH3;bN8N&*TZE?^^Qe&OVN%=B@izhVjWBA_17J4G?Anw%n~M2n8rxl z8gZ{2y?z1vbE3c$TpM114K9n1s$8Z*ad)UX28b5CIDDRSA?s_IJ49OO~bz?;6k80Y)=ff53v9$tj4&} zg=I{s3303}T2=)|W)lYnJ$DArK)uuNq&~86+#MmVA=#Whz{uKswAn_tT4o>`n3CX6 zP5VT>n!pUA6TH6!Y?nXscpr0ck|y`l*s<=nX^)WLi9@|~E1*{{c%#Xee3vDLX2vp< z$+;A3K&aYY8xy+DQx@bPq!pWNZR7!U9XZkWZy6T*@$=BJ03(9;98be@)2EV&Y%7=& zFfY<+-QDlqME}j2sYE5CS`R+Syc<~wl04=0k_fVKB|=d*Sz?FGgf$kaC4x@@MVA&W zhd~%R^hAk%_pW8>7}CQ-SCI3j8Bsgv61FL0ph$938+vhh({r98PQq3QNFN<6N)0jM z$yz2aj(xaqCupa%<^Z$!mrd4B^lr2+hK7S3fBR$074~3bv%tmGaX2F$UN3`NoEjqU zW%NQSsBLPliY;YLoS3F2`XvbddC3Zwbpu|gh#<3>Z644yZBlTZ5e98(DS85?%jM6N z9g2=A5A-?tT+E9_2AbYBM=eivRfa-}IH}tkT9&3!E(e+onm{CIcea7Xe|$0z181+M za=v8VBdsQM4gzN?lv=s$6kfx<#rxNW8E6b+ZDZLmzFA-?AwY4H0k;*%x);wf@AVA8Qap0~IWjp>$;SK6Yu!Hmzc*sd@D}xN0z|rz6+J1(XJ* zh^hC;qtLI+!N~c-!tn4V?x)UMjL@JDW%JLY6G<>O_vB| z4MSkeDS+SX6tJu?wNMkj!u`}LECLd0T1qZAFx$BmSq91!E1z!%f|(++Vq%S0vd=rS zN#st*QpA%|%tSAiKOeqw8&&XPS4umwrJ8@*>FOem+PASbx~x%fc**TSbybmdN_=c* z!pM}$rpcyRgg~bE8o6&L(KtJK)h%jq{i@a1ns!s4u##E36_vr4we4ax`Gg?T^$<6Y zg(k%WOXXX`0@FlO!b@aX>HUGhh0sZPQhZ{H#y)6`=?B_F{E8Iz;I_AUuG~o1Suh22 z4QIoJGh;VbzHVRVv}xNhn>c2(J-le@=u?@-D^w@wfPUBou7xwE3r1q>h){m)2>zz3$Ca3<|Vd7 z_)!=EBVum&hvhSnV5MbaG10K^;Fj$CN2Fve%OR>q%}M{u@fiv#No0cU^ym($sQF{ZX|>{PN(ouM7wQA6vuR0Io3>81%`cWMtS<4cpfhN zvQ>TH&&JsJGx88{F;>j9x4X9|3;b>-@w}LKrd$N@GR(7t2{w1^*Hl>)-kh!T%tDr> zajWb*(%YG!X4ynnETz-vilK>Ods;>e}CVqNE#$E z%;y(k5s+y{^3ig*8F76e>uzCllSZabb6=UgYhzNmDH}*nPmZ$wVDR$?uFbbPBAI^B zwz$&7F~g57)cR^bAuDDny}wsVP%gVot+;#mLmD{w8=@u1v@}3)=uV;3JwiV&M&Vn< zEt<(g10OMwk=(BU3qHv?Yku~AZ}HEfjSSI$wG2I5!!nc|u!8#}0f0+M?u<^&XFKgd zF830nQu6F9Dn&WQ8k4^Hr#Vq4_q4q>68agcSX*QvJ@KVjG?=GiQa$9#h zgvG&EW=!#0#qOHrP0wdT^+KC7fA`F2jxBeCj84xH5+(gAvV8_v9;8iAeAE;yr;^01 zZKOydtH%nuhfe{9MrM-Z1p*e;uYKgSaiWz&(0p5nT?5S5@7Pjd!U=|aQ9h@(UQG?p zUTqTi9QrJwAl$#bV>k1UV}1Vx%ki8G*Rj-T)C#5S(`X>CC;U+fN&@T6oa@NS zTd0!W5wA3pdbs=1_}orjulbCFL~WlZqoo@6Zrp`ojI*bDz!2_5FP3acA{M2b_Z8!k zr_EkDXky78Iy8{rqr+LI*x~b8vcNe0*gbW1dHi_Nap@G`88K;_O_MWQPV&ph@*F5@ z$+z*|@rH85Uoy#NxcxM*?j&I~eYvi~+>f$KrtEvqJ4Y`$nOFZ2njq_l=F_sTnV6m? zp>PcyC{>dkrj}XJFRL1IdB;~d?`x#y7CF0n!aEeYdz$&e;Hz~7TrF-gHPk1G>h#3C zsS!r!u!H2d^c#foBU(uh^1VLx@UsC8;YD5F(0?Jd5xTuIJ7r(#0GnN8`Kc4$X{!#c z`pdkI+g?vuiHLG#Khm8KiO6#+idr)Ig5O~>!c8VZZ{~a5{PBHgxh?Wl*2>pZX{kt{ z{`;+rDH$=5FAc(b)Tw8yH}jD0)lI+9gO(&mYrQRR1Oj6nP_x)KSV~1aPA=%@oTGNRAc%9S})I$|644KA|8G zKID*};egGr!>&=*8e`L((l$D7X@;Ew(sXl|n}5@MIMFA~FMsm(>w`tou|;>oejYzL z8BHShNG*Kr#A&p+%8kh{5&Qy}#^u0xp#MN?7tk`444X;2L#-Gr3UwRi5iIzXp?bn@ z@5nP!?^xM4Ki2taiV5vx;_(R?Q{C_u41dg5UrkOvhxkYJrgrp>ru^Cc)B%Kcnw7$W zLfw~91NUsW=flJ!1W8M_uUF2@cPM-RNTu1^A0?S~lhdGi>m}2%IJ;y~`z-+yc@RQU z@%qDG?f!zgp4z~5`YAv%X>u8%IWhQSC^F*|0CB0BCWTG0mAomk!o_AekWYF*dctwO zF!WO%xOCOds)^j~bd^azTD(1FuQ}%wARlNsF15$VFwvroA02{py4}I0p+GwB)+dXM1BemY?r&^@$)eB8LD>S&HWqBp9E#W98{ zv~&j(TFbTF(NSd3a?dXF*wqr1N)B3Y`MPt{IF!BKF71EGrtcih**Xn2f)I9d zej4^c2Rr#>Ex(g({LalK`gCiLdqD< zn8Q@>2cKFo&)yUXH^uMg<4#71zMXsg9Zo+n5v7cXYI}aTnV@0tI@+W%jW48F`yewg zb*T`4;rAy3n5+aSz=Qi{ThYW8d2c_Xp~rv83zAh8=RDGdcq3lI z{-~~1>${jZYf3jCu0Y+Gb7-n1Tq#@CCRXB65B1uIBTI0*(-Zp$2<)U+znL&cggfy9 zfzB2wz=~+~${K2f^`Vy+QuMjYvi2{82OE&=0~==}cESaG@`ir!yBhqoDo9tCp;(ji zn>=2K@0cyx!`5ofWY!LPJmb%>9)d89q*{V=-m$vqh-fnD+A^%){h~d*$fe%bLo09q zL~o;UZi7P7TJqMSi(i5hdpgD+RJ1kQS!6_35$iT8id>;CN>T`e#7b?A_sca!Z7`b~ z%yL{3n0Fg#GB85yz2-mS*WV1_hW{5&{Qn0#zp2y~;ATsE=f0?)PJD#JhK_ZFtbzBE z=dKg)kwe5Q_dAP(-NAEn?pw3$^lvPu#{a?LpC0^m%l%Ie{~si1O)}x7w20Wmji)n6 zo|F1-VPz){3FQCK6vA`ejjm4*;x!woejdf`JaAxYB?TgxgS<%l385!nmji(&b{g@v zLkVvB%Jt2q9cZ~#rW8KBkXn0-Htu&X)-hN57UR6;ZF~u zN|uP&tv|Lm6{N}GY!z`+Q<|ayl;3mo?8_OQNmD8sU2x(M!IAF!*=r)QtbWgFz{=6GG!Eod`Ph%$de8$5@(KJvnG+;R6tp$*~cN};6@v5+Qs7fU_X*W0C8 zN$gt+1j;bQ&QSsdHmYz{KGyHcn!HjdX-A~lLxs`iJq;~7Fla0 zAI${n+Y2r9yYg_ zhOT@aqFF8~F~wu6BCg84FSk{V3mpvOplWLGi_~!8KVW?x zb5@giX&iZN{5Ztgbnn}hrTWTpdyFJ}hQeRH64=uP#>Aq6!p9(iD`Pdie8Xv+{WJVrme^3UNmC zdZVKv-X!QVC`|^Ec4j=~+S@2As>))Aqzt`zC1`C!hJvbEET7erS{PQgkrk8hZXR01 z9R>v|sq^@Vgb*I_xT^Gy-ACQC(lEpkD!SU9b(EB_LLTL}+||a|=&+emlu815x5BvP zbLEs+60R;}7_0CWs1^57ug9v`4=2dg5_$>Rod9UL(E*BQ_-G9`eDX)?>f9D&a%!NJCeD;W$%=gAx98f^R(1I|GhJ^je$A7=1J? zVoYW^Ul`jf99f1H*m`1i)nD{X5Tjv*`FTc(Ku<_*^%{6E;@lO_?U5t#W6k@D=o?BHDU>Vh!|#(;sC{r|XHy0G9eY47@T1MIi(S@3?7an+ zUitfnOl3VEZ1|4xMAOpOHV}rOHc~i8)S%Bzq>>HVi<$>#LB}E&mM5*Kpyq`~)&`M< zd1Cl2hLe@wR%axa?f#l9{zEQT>rtr9-8rQKE(x1PEU0NDd~Mq``_*dOaB}BB#@om7 zs18Xjske}wm^e6PZ-=7$AWil)@3T_Ljw)xVV#8Aa_~sv^J+bw@J=h|I;JJ~VYQy7` zQd`RREsHPr69Z8p`{upU>~r5vRYF-3g5ctXnmJxpFsHiVab##k$E#yiAhu39N`!ap zD~K%NyX}^ z!wB1CL*}8<6q#qz;m1xvdGJ*avwKEGvA?M4bw3Q8tCh`UQq2^ky}a7Jo$?oZZ8zgm zDpSB&VP+AoHUi?ihU*0A_7!|VPN>~bM`W0V6~1f=q$WF?vAwb*KT>NH0K8@Vc10q7 z>lLH!&Kl(uaImH{cw^t=p!8|=tG>7&jjU(&El}25#xI*1R+EVIg}SeDl}|?`O3JRi zuN#u`FVb+o)XudB6``w6HvGDaRxuX_w!Jkt@NqBOj!i`MUV+nV8B~pKC8KQF$r~bZR|1`Itc`J`~eRVTo0L*dkJ*pE>{JaNC8s*YVL?tBZX*rirII9s6M z!*7AgO}svf_1>khESPr5qOKc4!lvI>B(cBH@l zh38)~;;`?i>R0P6{UNUW0YK@mCmt_#3gbyXATMh9?f*tg{PGI)y>q8v^bNJ6A33_X zHalk?C+Ob3fcbm&FJ8YS@`Vel(Kf&TU&8-Y&p))8|DaLmKm38!75Rb+D#ya0n|>!( zvdr?F2wFE&(7o@-q4vi%9j5?J!Efnb4wa7sJ{{N{)n7mavr4?ex`*k8I8>!mZ%)}< zGgGOwCH$cj>rtuz-}W|rR#D>al>uIVS;$y!$k-!mi^xm{^0(};+)M{-_9yAqEK9gf zW<_^_fmwrx?VeFFw68pc0lE%ZD_5o5bu}~s62x~vg@n*# z&1WlZy^it<=LVEdhUTZ?F?}y*sXWIJk+xPEmx$x$^tAVS$Km>#9Gnx>KjvH9-9PGy z_04nF3)J-hLEBySEzDY_3a9p34Da}PodPoXLpAWf`G<-e&e#4@w7fa$Ctrdol6X;O zAdY3?(iFop5m=Qb)F^#iNjhxuSDUL|0T#f*yww5oC4BgW!m1g4AO#1w_=%;&e}J=L zW{nZ||91Vps{bW`|F2OxP#2;(c51a&w@U8av!sq60C7M6z1;uU{rtoh^2HY`X?CgG1=quo^#n;$#! zWPvohc#U#=8U~doE6j+vK@Le#ET;>Ei@YZ`WsdlCR(WeIWH&?{i8-E&+gRIM*zj+9 z_0?+#GAafa(7B7Lq{ZJgi+?03A=19SfF16S-<9^yg-SF+%XPms z5SDs?wYym8T^1ZX-2*to^Uv?<%BIfd(XXuQWb;GkjIRI?km#En! zqz6gPaX)l7vy6AFezl?Qm&J-yH0pJAm*1tPHL#9YZcp~Fo7ycDEZSw~j#Md1w>K

nOtNSH>cE{EkIE64i?}nz#JmHg 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 aqa)j=vWR0lbZxXf|E5^=ve@z8>Hi7jV=TS^ literal 0 HcmV?d00001 diff --git a/docs/update-credit-balance-2026-10-03.md b/docs/update-credit-balance-2026-10-03.md new file mode 100644 index 0000000..a36eb02 --- /dev/null +++ b/docs/update-credit-balance-2026-10-03.md @@ -0,0 +1,26 @@ +# 2026-10-03 负债账户溢缴存款 + +## 使用方式 + +信用卡和贷款等负债账户的余额输入、列表、详情及历史统一使用有符号余额:负数表示欠款,正数表示溢缴存款。例如欠款 100 填写 -100,溢缴存款 100 填写 100。零表示无欠款或存款。快速记账、余额更新、历史更正和新建账户使用相同规则,表单说明已同步更新。 + +溢缴账户标记为“溢缴存款”,正数不显示为负债红色;欠款和负向变化显示红色。溢缴款按原币和可用历史汇率计入总资产,欠款计入总负债,不用溢缴款抵消其他账户的负债。净资产始终按总资产减总负债计算,趋势和汇率归因也按同一逻辑重放。 + +收支日历沿用负债方向计算。例如从欠款 100 到溢缴存款 25,余额净增加 125;从溢缴存款 25 到欠款 10,余额净减少 35。初始余额、估值和配对资金操作仍按原规则排除。 + +## 数据与兼容性 + +数据库和现有 API 保留原来的负债本金符号:正数为欠款,负数为溢缴存款;网页输入和显示进行无损字符串符号转换。已有正数欠款数据仍表示同一欠款,原币金额没有批量改写。资产账户仍按原币余额存储和显示;独立资产及借入借出债务仍要求非负本金。 + +创建、更新、更正、删除后的历史重放和备份校验均允许负债账户的负余额。ZIP 恢复保存负债账户类型、原币和历史,不把项目永久改为资产类型;同一账户可以再次从溢缴转为欠款。此次未扩大账户转账或计划支持的账户范围。 + +复用现有字段,无数据库结构变更和新迁移。数据库已有表、字段注释保留;Prisma 的余额字段注释补充了负债符号含义,OpenAPI 补充了网页与 API 的符号约定。 + +## 验证 + +- 30 项单元测试、12 项真实 MySQL 集成检查通过;前后端类型检查和生产构建通过。 +- 新增断言覆盖八位小数和大金额字符串无损转换、跨越零余额、溢缴款与其他债务分别汇总、历史趋势及 FX 归因、日历方向、更正和删除重算、跨用户和隐藏权限、ZIP 导出与恢复。 +- 浏览器使用临时模拟账号,将欠款 -100 更新为溢缴存款 120.12345678,详情、历史和总览自动刷新;总资产 120.12、总负债 0.00、净资产 120.12。未操作实际用户账户或进行真实资金转移。 +- 临时账号、模拟数据和预览配置在验收后清理。保留用户原有 Vite 配置修改,仅本地提交,不 push。 + +![溢缴存款显示](credit-balance-preview.png) diff --git a/update.md b/update.md index 3ba82b1..3dced8b 100644 --- a/update.md +++ b/update.md @@ -32,6 +32,8 @@ - ~~删除记录有问题,有时候无法删除,UI也不对~~ — 已完成并验证:2026-10-03 11:22 - ~~删除记录后部分UI需要手动刷新~~ — 已完成并验证:2026-10-03 11:22 - ~~备份功能出现问题~~ — 已完成并验证:2026-10-03 11:22 +- ~~负债(如信用卡)也可以显示为正数变成存款~~ — 已完成并验证:2026-10-03 12:05 +- 分组可以排序 验证与部署边界见 [更新说明](docs/update-2026-10-02.md)。 @@ -40,3 +42,5 @@ 本轮补充验收完成:2026-10-02 18:44。13 个迁移状态最新,34 个内置图标已初始化;28 项单元测试、10 项真实数据库集成检查通过,9 张表与 88 个字段注释已核验。重新检查文档,无新增未完成要求。 本轮验收完成:2026-10-03 11:22。28 项单元测试、11 项真实 MySQL 集成检查、前后端类型检查与生产构建通过;未新增数据库表或字段。账号、删除与备份更新详情见 [更新说明](docs/update-2026-10-03.md)。重新检查文档,无新增未完成要求。 + +负债账户溢缴验收完成:2026-10-03 12:05。30 项单元测试、12 项真实 MySQL 集成检查、类型检查和构建通过;支持信用卡等负债账户正数存款显示及总资产分类,已有数据符号保留。详见 [更新说明](docs/update-credit-balance-2026-10-03.md)。复查发现新增“分组可以排序”,继续处理。