Skip to content
218 changes: 218 additions & 0 deletions docs/features/LOAN_ACCOUNTS_PLAN_ZH.md

Large diffs are not rendered by default.

27 changes: 27 additions & 0 deletions docs/features/LOAN_ACCOUNTS_ZH.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# 贷款本金账户

> **WIP / 已冻结(2026-10-18)**:本文记录当前 PR 的手动本金账户实现,尚未包含完整房贷本息、还款计划与周期还款管理。App #566 / Cloud #130 保持 Draft,暂不合并或发布。后续方向、待决问题与恢复入口见[贷款完整改造方案](LOAN_ACCOUNTS_PLAN_ZH.md);该方案仍为讨论提案。

贷款与信用卡同属负债账户。贷款只记录本金借还,不提供信用额度、账单、分期计划或本息拆分表单。还本使用资金账户 → 贷款的同币种转账;借入使用贷款 → 资金账户。两者均不计普通收支,利息和手续费另记支出。当前仅支持个人账本,避免共享账本移动私人余额。

资产账户保留日常资金、房产等估值资产;估值资产继续手动更新估值,不能参与转账。新增贷款填写正数未还本金,内部保存负数债务余额。

借还复用现有转账页和金额面板,金额区不额外展示贷款说明或预计剩余本金。本金与利息的规则说明保留在贷款账户详情,错误通过原有保存校验提示。

## 旧账户与接续

旧贷款保持原 ID、币种、备注和隐藏状态。数据库升级时,无历史流水且余额不大于 0 的旧贷款自动接续;其他旧贷款先核对实际未还本金。核对不新增交易,不改历史流水,也不改变资金账户。

`loanBalanceVersion = 0` 表示待核对快照;`1` 表示本金账户。`initialBalance` 保存接续起点的负数本金;`loanBalanceSince` 保存接续时刻,当前余额只累加该时刻及之后的私人流水。接续之前的历史仍可查看,不再重算本金。校准欠款会建立新的接续起点,不能替代实际还款。净资产历史从最晚接续日期开始展示,避免把当前本金伪装成过去的精确余额。

接续前的财务编辑、删除、新增以及超额还本会被拒绝;历史备注可以修改。账户改名等元数据保存不能重置当前本金。转账修改必须在一个数据库事务中同时更新两端。

## 同步与恢复

App/Web/MCP 写入声明 `X-BeeCount-Capabilities: loan-flow-v1`;Cloud `/version` 返回 `loan_accounting_version: 1`。含本金账户的 App 向旧 Cloud 推送前要求升级;旧客户端不能覆盖已升级的贷款或修改其流水。跨仓上线须同时提供支持该契约的 App 与 Cloud。

Cloud 投影、增量合并、全量快照和原生 JSON/YAML 导出保存本金版本和起点。原生快照整体恢复后检查本金与账户关系。同步新增行可以通过 `loanHistoryImport` 恢复接续前的本地历史,历史对本金无影响;Web 写接口不能使用此例外,既有历史的财务修改与删除仍受保护。普通账户的历史资金变动照常保留。

## 隔离验收

使用 `isolated_app_cloud.py prepare --scenario loan-account` 与 `integration_test/loan_account_live_test.dart`,在独立包、专用模拟器和新 Cloud 数据库中验证生产界面、实际鉴权同步、Web 反向同步及正常入口重启。报告与运行数据保存在仓库外。
5 changes: 3 additions & 2 deletions integration_test/account_balance_trust_live_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -228,16 +228,17 @@ void main() {
if (!overpayment) {
final group = find.byKey(const ValueKey('account-type-group'));
final valuation = find.descendant(
of: group, matching: find.text('估值账户'));
of: group, matching: find.text('资产账户'));
await waitUi(() => valuation.evaluate().isNotEmpty);
await tester.ensureVisible(valuation);
await tester.tap(valuation);
await tester.pump(const Duration(milliseconds: 600));
expect(find.text('信用卡'), findsNothing);
await tester.tap(find.descendant(
of: group, matching: find.text('日常账户')));
of: group, matching: find.text('资产账户')));
await tester.pump(const Duration(milliseconds: 600));
}
await tap('负债账户');
await tap('信用卡');
await fill('account-name', name);
if (overpayment) {
Expand Down
Loading
Loading