From b2c22ca0501b90af657292dec694834872e05a18 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E7=85=9C?= <1871263099@qq.com> Date: Sun, 4 Oct 2026 22:25:32 +0800 Subject: [PATCH] feat: add repayment discounts and interactive net worth trends Support positive repayment discounts and negative surcharges with exact decimal conversion. Add independent trend toggles, pointer and keyboard date inspection, tests and Chinese documentation. --- README.md | 4 +- apps/api/package.json | 2 +- apps/api/test/quick-entry-integration.test.ts | 52 +++++ apps/web/src/App.tsx | 74 +------ apps/web/src/DebtPaymentForm.tsx | 20 +- apps/web/src/NetWorthChart.tsx | 209 ++++++++++++++++++ apps/web/src/QuickJournal.tsx | 12 + apps/web/src/QuickTransfer.tsx | 30 ++- apps/web/src/locales/en.json | 12 +- apps/web/src/locales/zh-Hant.json | 12 +- apps/web/src/repayment.ts | 7 + apps/web/src/style.css | 70 ++++++ apps/web/test/net-worth-chart.test.ts | 45 ++++ apps/web/test/quick-entry.test.ts | 9 + docs/update-repayment-trend-2026-10-04.md | 27 +++ update.md | 4 +- 16 files changed, 505 insertions(+), 84 deletions(-) create mode 100644 apps/web/src/NetWorthChart.tsx create mode 100644 apps/web/src/repayment.ts create mode 100644 apps/web/test/net-worth-chart.test.ts create mode 100644 docs/update-repayment-trend-2026-10-04.md diff --git a/README.md b/README.md index 6d71d58..95c4da1 100644 --- a/README.md +++ b/README.md @@ -93,10 +93,12 @@ Invoke-RestMethod http://localhost:5173/api/openapi.json 账户页开启“快速记账”后使用紧凑卡片直接更新余额;转账按钮紧邻模式入口。右侧显示本次保存成功的余额、转账、还款及标记,支持逐项撤回;窄屏记录面板显示在账户列表下方。列表为当前页面会话,刷新或重新进入后清空,已保存账目仍在变化记录中。 -欠款账户弹窗提供“还款”,通过付款账户与欠款账户双边记账更新余额。可手动标记“本月已还款”和“当天已记账”,并筛选未还款或隐藏当天已记账的账户。数据库保留最后完成月份/日期,跨月、跨日只按当前 UTC+8 时间判断是否显示,无需定时删除;标记不会改变金额。 +欠款账户弹窗提供“还款”,通过付款账户与欠款账户双边记账更新余额。信用卡与独立债务还款支持优惠:正数减少扣款,负数表示手续费,欠款按本金减少。可手动标记“本月已还款”和“当天已记账”,并筛选未还款或隐藏当天已记账的账户。数据库保留最后完成月份/日期,跨月、跨日只按当前 UTC+8 时间判断是否显示,无需定时删除;标记不会改变金额。 账户、独立资产和债务可在详情删除。存在配对往来或定时计划时需先处理依赖,避免破坏另一账户余额。详见 [快速记账更新与验收](docs/update-quick-entry-2026-10-04.md) 和 [项目删除说明](docs/update-position-deletion-2026-10-04.md)。 +净资产轨迹支持分别隐藏总资产、总负债和净资产折线;悬停或点击图表查看对应日期的金额,方向键切换日期。缺失汇率或估值时保留断线并提示不完整。详见 [还款优惠与图表更新](docs/update-repayment-trend-2026-10-04.md)。 + ## 连接 Agent / MCP 设置中的“连接助手”提供可复制的接入提示词、能力说明及权限选择。OAuth 授权和草稿确认使用独立页面;可选择 1、3、7、30 天、1 年或永久授权,并分别控制读取、修改隐藏账户。普通权限分为只读、草稿修改、直接写入,草稿需本人在网页审阅确认后才生效。 diff --git a/apps/api/package.json b/apps/api/package.json index ec57d5a..98f29fb 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -5,7 +5,7 @@ "dev": "node scripts/dev.cjs", "build": "tsc", "typecheck": "tsc --noEmit", - "test": "tsx --test --test-concurrency=1 test/calculation.test.ts test/update.test.ts test/zip.test.ts test/metals.test.ts ../web/test/i18n.test.ts test/account-deletion.test.ts test/mcp-hosts.test.ts test/mcp-http.test.ts test/network.test.ts ../web/test/quick-entry.test.ts", + "test": "tsx --test --test-concurrency=1 test/calculation.test.ts test/update.test.ts test/zip.test.ts test/metals.test.ts ../web/test/i18n.test.ts test/account-deletion.test.ts test/mcp-hosts.test.ts test/mcp-http.test.ts test/network.test.ts ../web/test/quick-entry.test.ts ../web/test/net-worth-chart.test.ts", "db:generate": "prisma generate", "db:migrate": "node scripts/database.cjs deploy", "db:status": "node scripts/database.cjs status", diff --git a/apps/api/test/quick-entry-integration.test.ts b/apps/api/test/quick-entry-integration.test.ts index e5a2311..678f866 100644 --- a/apps/api/test/quick-entry-integration.test.ts +++ b/apps/api/test/quick-entry-integration.test.ts @@ -168,6 +168,58 @@ test('quick entries undo paired repayment and balance, persist monthly marks, pr assert.equal((await call(a, '/transfers/' + payment.data.id, 'DELETE')).status, 200); assert.equal((await call(a, '/positions/' + cash)).data.amount, '1000'); assert.equal((await call(a, '/positions/' + card)).data.amount, '200'); + + for (const [fee, expected] of [ + ['-5.87654321', '955.87654321'], + ['1.86420975', '948.13579025'], + ] as const) { + const movement = await call(a, '/transfers', 'POST', { + sourceId: cash, + targetId: card, + amount: '50', + received: '50', + fee, + date: '2026-09-03T11:00', + requestId: randomUUID(), + }); + assert.equal(movement.status, 201); + assert.equal((await call(a, '/positions/' + cash)).data.amount, expected); + assert.equal((await call(a, '/positions/' + card)).data.amount, '150'); + assert.equal((await call(a, '/transfers/' + movement.data.id, 'DELETE')).status, 200); + assert.equal((await call(a, '/positions/' + cash)).data.amount, '1000'); + assert.equal((await call(a, '/positions/' + card)).data.amount, '200'); + } + const invalidDiscount = await call(a, '/transfers', 'POST', { + sourceId: cash, + targetId: card, + amount: '30', + received: '30', + fee: '-31', + date: '2026-09-03T11:00', + requestId: randomUUID(), + }); + assert.equal(invalidDiscount.status, 400); + assert.equal((await call(a, '/positions/' + cash)).data.amount, '1000'); + const loan = await create('测试独立欠款', 'debt', 'liability', 'personal', '200'); + for (const [fee, expected] of [ + ['-5', '975'], + ['2', '968'], + ] as const) { + const movement = await call(a, '/transfers', 'POST', { + operation: 'repay', + sourceId: cash, + targetId: loan, + amount: '30', + received: '30', + fee, + date: '2026-09-03T12:00', + requestId: randomUUID(), + }); + assert.equal(movement.status, 201); + assert.equal((await call(a, '/positions/' + cash)).data.amount, expected); + assert.equal((await call(a, '/positions/' + loan)).data.amount, '170'); + assert.equal((await call(a, '/transfers/' + movement.data.id, 'DELETE')).status, 200); + } const month = today().slice(0, 7), path = '/positions/' + card + '/repayment-mark'; assert.equal((await call(b, path, 'PATCH', { month, expectedMonth: null })).status, 404); diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 01ae418..849c56b 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -46,6 +46,7 @@ import { type PageResult, } from './api'; import './style.css'; +import { NetWorthChart } from './NetWorthChart'; import { allOverviewCards, overviewCardLabels } from './overview-cards'; import { useToast } from './Toast'; import { GroupOrderContext } from './GroupOrderContext'; @@ -165,76 +166,6 @@ function Empty({ title, body, action }: { title: string; body: string; action?: ); } -function Chart({ rows, base }: { rows: Total[]; base: string }) { - const valid = rows.filter((r) => r.complete); - if (valid.length < 2) - return ( - - ); - const values = valid.flatMap((r) => [Number(r.assets), Number(r.liabilities), Number(r.net)]), - min = Math.min(0, ...values), - max = Math.max(1, ...values), - w = 720, - h = 210; - const x = (date: string) => - 35 + - ((new Date(date).getTime() - new Date(rows[0].date).getTime()) / - (new Date(rows.at(-1)!.date).getTime() - new Date(rows[0].date).getTime() || 1)) * - (w - 65); - const y = (v: string) => 15 + ((max - Number(v)) / (max - min)) * (h - 40); - const series = ['assets', 'liabilities', 'net'] as const; - return ( - <> -
- {tr('总资产')} - {tr('总负债')} - {tr('净资产')} - {base} -
- - - {series.map((s, i) => ( - - r.complete - ? `${n === 0 || !rows[n - 1].complete ? 'M' : 'L'}${x(r.date)},${y(r[s])}` - : '', - ) - .join(' ')} - fill="none" - stroke={['#199b76', '#c73542', '#457bca'][i]} - strokeWidth="3" - /> - ))} - {valid.map((r) => ( - - - {r.date} {tr('净资产')} - {money(r.net, base)} - - - ))} - -
- {rows[0].date} - {rows.at(-1)!.date} -
- {rows.some((r) => !r.complete) && ( -

{tr('缺失汇率的日期未绘制,曲线在该处断开。')}

- )} - - ); -} export default function App() { const locale = useLocale(); const sessionGeneration = useRef(0), @@ -381,6 +312,7 @@ export default function App() { currency: source?.currency || '', amount: value.amount, received: value.received, + fee: value.fee, targetCurrency: target?.currency, date: value.date, }, @@ -1390,7 +1322,7 @@ export default function App() { '默认最近90天;周一至周日及自然月取期末余额,变化归因累计区间内每日变化,首尾不足周期按实际范围。', )}

- +

{tr('按当时可用汇率重放;更正历史后重新计算。归档项目仍计入。')}

diff --git a/apps/web/src/DebtPaymentForm.tsx b/apps/web/src/DebtPaymentForm.tsx index 13370d9..ba2bcce 100644 --- a/apps/web/src/DebtPaymentForm.tsx +++ b/apps/web/src/DebtPaymentForm.tsx @@ -1,4 +1,5 @@ import { AccountPicker } from './AccountPicker'; +import { repaymentFee } from './repayment'; import { useState, type FormEvent } from 'react'; import { type Position, nowMinute, money, requestUuid, positionAmount } from './api'; import { t as tr } from './i18n'; @@ -45,7 +46,12 @@ export function DebtPaymentForm({ const same = debt?.currency === account?.currency; function save(event: FormEvent) { event.preventDefault(); - submit(Object.fromEntries(new FormData(event.currentTarget))); + const data = Object.fromEntries(new FormData(event.currentTarget)); + if (operation === 'repay') { + data.fee = repaymentFee(String(data.discount)); + delete data.discount; + } + submit(data); } if (!accounts.length || !debts.length) return

{tr('请先创建启用的资产账户和借入或借出债务。')}

; @@ -122,9 +128,11 @@ export function DebtPaymentForm({ )}

- {tr('手续费只影响账户余额:付款时额外扣除,收款时从到账金额扣除;负数表示优惠。')} + {tr( + operation === 'repay' + ? '正数为优惠,负数为手续费,0 表示无优惠;只影响付款账户,优惠不能超过还款金额。跨币种请填写欠款减少金额。' + : '手续费只影响账户余额:付款时额外扣除,收款时从到账金额扣除;负数表示优惠。', + )}