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 表示无优惠;只影响付款账户,优惠不能超过还款金额。跨币种请填写欠款减少金额。' + : '手续费只影响账户余额:付款时额外扣除,收款时从到账金额扣除;负数表示优惠。', + )}