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