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 0000000..ca59bc5 Binary files /dev/null and b/docs/credit-balance-preview.png differ 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)。复查发现新增“分组可以排序”,继续处理。