diff --git a/.obsidian/workspace.json b/.obsidian/workspace.json index 0a77673..4e612e4 100644 --- a/.obsidian/workspace.json +++ b/.obsidian/workspace.json @@ -197,6 +197,9 @@ }, "active": "b06ed69835363258", "lastOpenFiles": [ + "prop-acc/scenarios/prepaid/audit-low-balance-and-overdue.md", + "prop-acc/scenarios/prepaid/exception-refund-on-frozen.md", + "prop-acc/scenarios/prepaid/exception-cross-community-consume.md", "prop-acc/scenarios/prepaid/close-with-zero-balance-decision.md", "prop-acc/scenarios/prepaid/close-resident-moveout.md", "prop-acc/scenarios/prepaid/unfreeze-after-verification.md", @@ -221,10 +224,7 @@ "prop-acc/concepts/prepaid/consume-via-bill-collection-type.md", "prop-acc/concepts/prepaid/transaction-types.md", "prop-acc/concepts/prepaid/one-account-per-resident.md", - "prop-acc/concepts/prepaid/prepaid-account-vs-transaction.md", "prop-acc/concepts/prepaid", - "prop-acc/concepts/deposit/deposit-vs-adhoc-vs-prepaid.md", - "prop-acc/scenarios/deposit/audit-long-pending-accounts.md", "prop-acc/scenarios/deposit", "prop-acc/concepts/deposit", "prop-acc/scenarios/adhoc", diff --git a/prop-acc/maps/prepaid-knowledge-map.md b/prop-acc/maps/prepaid-knowledge-map.md index b155a69..860dae4 100644 --- a/prop-acc/maps/prepaid-knowledge-map.md +++ b/prop-acc/maps/prepaid-knowledge-map.md @@ -53,43 +53,41 @@ code_version: 2026-05-22 | [Consume 走 CollectionType=Bill 的设计](../concepts/prepaid/consume-via-bill-collection-type.md) | 独特设计:抵账单用 Bill 视角,资金来源标 meta.fund_source=prepaid | | [月初批量自动抵扣设计](../concepts/prepaid/auto-deduction-design.md) | 待补 scheduled job,产品核心卖点 | -## 场景手册(16 篇,**待补充 ✋**) - -> 🚧 概念骨架已就位,场景文档将在下一轮(轮 2)产出。预定结构如下。 +## 场景手册(16 篇,**全部完成 ✅**) ### 📥 充值(Deposit)— 3 篇 -- 🚧 [首次开户充值 5000](../scenarios/prepaid/deposit-first-time.md) -- 🚧 [已有账户追加充值](../scenarios/prepaid/deposit-additional-topup.md) -- 🚧 [小程序在线充值(待补设计意图)](../scenarios/prepaid/deposit-via-miniapp-pending.md) +- ✅ [首次开户充值 5000](../scenarios/prepaid/deposit-first-time.md) +- ✅ [已有账户追加充值](../scenarios/prepaid/deposit-additional-topup.md) +- ✅ [小程序在线充值(待补设计意图)](../scenarios/prepaid/deposit-via-miniapp-pending.md) ### 🧹 消费 Consume — 4 篇(最核心) -- 🚧 [手动抵扣月度物业费](../scenarios/prepaid/consume-monthly-property-bill.md) -- 🚧 [多个未付账单按 due_at 优先级抵扣](../scenarios/prepaid/consume-multiple-bills-priority.md) -- 🚧 [抵扣计量账单(水电费)](../scenarios/prepaid/consume-meter-bill.md) -- 🚧 [月初批量自动抵扣 job(设计意图 + 业务流程)](../scenarios/prepaid/consume-batch-auto-monthly.md) +- ✅ [手动抵扣月度物业费](../scenarios/prepaid/consume-monthly-property-bill.md) +- ✅ [多个未付账单按 due_at 优先级抵扣](../scenarios/prepaid/consume-multiple-bills-priority.md) +- ✅ [抵扣计量账单(水电费)](../scenarios/prepaid/consume-meter-bill.md) +- ✅ [月初批量自动抵扣 job(设计意图 + 业务流程)](../scenarios/prepaid/consume-batch-auto-monthly.md) ### 💰 退款(Refund)— 2 篇 -- 🚧 [业户搬走全额退余](../scenarios/prepaid/refund-full-resident-moveout.md) -- 🚧 [部分消费后退余(不自动关账)](../scenarios/prepaid/refund-partial-after-consume.md) +- ✅ [业户搬走全额退余](../scenarios/prepaid/refund-full-resident-moveout.md) +- ✅ [部分消费后退余(不自动关账)](../scenarios/prepaid/refund-partial-after-consume.md) ### 🧊 冻结 / 解冻(Freeze / Unfreeze)— 2 篇 -- 🚧 [疑似欺诈 / 风控冻结](../scenarios/prepaid/freeze-suspected-fraud.md) -- 🚧 [核实后解冻](../scenarios/prepaid/unfreeze-after-verification.md) +- ✅ [疑似欺诈 / 风控冻结](../scenarios/prepaid/freeze-suspected-fraud.md) +- ✅ [核实后解冻](../scenarios/prepaid/unfreeze-after-verification.md) ### 🔒 结清(Close)— 2 篇 -- 🚧 [业户搬走主动关账](../scenarios/prepaid/close-resident-moveout.md) -- 🚧 [余额清零后不自动关,业户决定](../scenarios/prepaid/close-with-zero-balance-decision.md) +- ✅ [业户搬走主动关账](../scenarios/prepaid/close-resident-moveout.md) +- ✅ [余额清零后不自动关,业户决定](../scenarios/prepaid/close-with-zero-balance-decision.md) ### 🛡️ 异常 / 审计(3 篇) -- 🚧 [跨社区消费防御](../scenarios/prepaid/exception-cross-community-consume.md) -- 🚧 [冻结状态退款被三层守护拦截](../scenarios/prepaid/exception-refund-on-frozen.md) -- 🚧 [低余额业户预警 + 逾期账单排查](../scenarios/prepaid/audit-low-balance-and-overdue.md) +- ✅ [跨社区消费防御](../scenarios/prepaid/exception-cross-community-consume.md) +- ✅ [冻结状态退款被三层守护拦截](../scenarios/prepaid/exception-refund-on-frozen.md) +- ✅ [低余额业户预警 + 逾期账单排查](../scenarios/prepaid/audit-low-balance-and-overdue.md) ## 跨域引用 @@ -125,6 +123,9 @@ code_version: 2026-05-22 --- -> [!info] 概念已完成,场景待补 -> 本轮(轮 1)产出:6 个概念 + 本子模块地图 + 域总图更新。 -> 下一轮(轮 2)产出:16 个场景文档,基于本知识地图骨架填充。 +> [!success] prepaid 子模块:6 概念 + 16 场景 + 1 知识地图 = **23 篇完成** +> +> 写作日期:2026-05-25 +> 对应代码版本:2026-05-22(详见 `packages/prop-acc/issue.md` Q4 段) +> +> 如果发现遗漏的场景或需要补充的细节,告诉我,可以单独补充新文档。 diff --git a/prop-acc/scenarios/prepaid/audit-low-balance-and-overdue.md b/prop-acc/scenarios/prepaid/audit-low-balance-and-overdue.md new file mode 100644 index 0000000..a07bce3 --- /dev/null +++ b/prop-acc/scenarios/prepaid/audit-low-balance-and-overdue.md @@ -0,0 +1,247 @@ +--- +title: prop-acc · prepaid · 场景 - 低余额业户预警 + 逾期账单排查 +aliases: + - 低余额预警 + - 预存款余额告警 + - audit-low-balance-and-overdue + - 场景-预存款低余额预警 +tags: + - 场景 + - prop-acc + - 预存款 + - 审计 +audience: + - 业务人员 + - 财务 + - 产品 +status: 已发布 +sub_feature: prepaid +last_review: 2026-05-25 +code_version: 2026-05-22 +--- + +# 场景:低余额业户预警 + 逾期账单排查 + +物业业务人员**每周** / 每月扫描: + +1. **低余额预存款业户**(下月预计账单 > 当前余额) → 主动提醒充值 +2. **预存款余额不够付未付账单的业户** → 跨核对 + +`LowBalancePrepaidListWidget` 是后台 Dashboard 上专门的 Widget,展示低余额账户清单。 + +## 典型情境 + +> [!example] 真实情境 +> 物业财务王主管每周一上午看 `DepositPrepaidDashboard`: +> +> - 低余额业户清单 widget 显示 **45 户**业户的预存款余额 < 下月预计账单 +> - 其中 **12 户**已有当月未付账单 +> - 主管按清单逐户联系: +> - 12 户已欠 → 督促立即充值 +> - 33 户预警(还没欠)→ 友好提醒"下月账单 ¥X,余额 ¥Y 不够,建议充值" + +## 业务人员视角 + +### Dashboard 查看 + +后台 → 仪表盘 → `DepositPrepaidDashboard` 页面 → 看 `LowBalancePrepaidListWidget`。 + +Widget 显示: + +| 业户 | 当前余额 | 下月预计账单 | 差额 | 当前未付账单 | +|---|---|---|---|---| +| 张阿姨(12-3-501)| ¥200 | ¥800 | -¥600 | 0 | +| 陈先生(12-3-502)| ¥1,500 | ¥2,200 | -¥700 | 1(水电费 ¥220 已逾期 3 天)| +| 刘先生(12-3-503)| ¥0 | ¥800 | -¥800 | 1(物业费 ¥800 未付)| +| (省略)| | | | | + +### 处置策略(分级) + +| 紧急度 | 业户特征 | 处置 | +|---|---|---| +| **🔴 紧急** | 已有逾期账单 + 余额不够 | 立即联系 + 督促充值 + 必要时手动催收 | +| **🟡 警告** | 当前余额低于下月预计账单 | 提前 3-7 天友好提醒 | +| **🟢 关注** | 余额低于 2 个月账单合计 | 月度提醒一次,无需紧急动作 | + +### 第 1 步:扫描清单 + +打开 `DepositPrepaidDashboard` → 看清单 widget。也可手动 SQL 查: + +```sql +-- 低余额预存款业户(余额 < 下月预计账单) +SELECT + p.id AS account_id, + p.community_user_profile_id, + cup.name AS resident_name, + p.balance AS current_balance, + estimated_next_bill, -- 子查询算下月预计 + (estimated_next_bill - p.balance) AS shortage, + COUNT(b.id) AS overdue_bills_count +FROM acc_prepaid_accounts p +JOIN community_user_profiles cup ON p.community_user_profile_id = cup.id +LEFT JOIN acc_bills b ON b.resident_id = cup.id + AND b.community_id = p.community_id + AND b.status = 'unpaid' + AND b.due_at < NOW() +WHERE p.status = 'active' +GROUP BY p.id, ... +HAVING current_balance < estimated_next_bill +ORDER BY shortage DESC; +``` + +### 第 2 步:分级处置 + +**🔴 紧急(已欠款)**: + +- 短信 / 微信 / 电话联系业户 +- 告知"您欠 X 月物业费 ¥800,请立即处理(充值预存款 / 现金 / 微信付)" +- 业户回应 → 处理(走 [[deposit-additional-topup]] 或其他渠道收款) +- 业户不回应 → 走逾期催收流程(本文不展开) + +**🟡 警告(还没欠)**: + +- 微信 / App 推送"友好提醒" +- "您的预存款余额 ¥X,下月账单约 ¥Y,建议提前充值" +- 不强制 + +**🟢 关注**: + +- 月度汇总报告(给业户的"预存款健康度月报") +- 不打扰 + +### 第 3 步:出周报 + +```markdown +# 2026 年 5 月 第 4 周 预存款健康度周报 + +## 低余额业户清单(共 45 户) +- 🔴 紧急(已欠款):12 户,合计欠款 ¥9,860 +- 🟡 警告:24 户 +- 🟢 关注:9 户 + +## 已处置 +- 紧急 12 户:已联系 + - 5 户已充值 / 付清 + - 4 户承诺本周内处理 + - 3 户失联(进入催收) +- 警告 24 户:已推送提醒 + +## 趋势 +- 比上周(38 户低余额)增加 7 户 → 趋势变差 +- 可能原因:5 月账单出账,部分业户余额不够付 + +## 建议 +- 加强自动抵扣 job 落地的紧迫性(目前业户充值后还得业务人员手动抵) +- 主动给余额接近 0 的业户 push 充值提醒(改 widget 配置) +``` + +## 业户视角 + +### 您可能收到的通知 + +#### 🟡 警告(友好提醒) + +> 张阿姨您好,您的预存款余额 ¥200,下月物业费约 ¥800,**预计余额不够付**。建议提前充值,避免账单逾期产生提醒费用。 + +业户可选择: + +- 立即充值 +- 现金 / 微信付下月账单 +- 不管(后果自负) + +#### 🔴 紧急(欠款提醒) + +> 张阿姨您好,您 5 月物业费 ¥800 **已逾期 3 天**未付。请尽快通过以下方式付清: +> 1. 微信小程序"我的预存款"充值后系统自动抵 +> 2. 到前台现金 / POS 付 +> 3. 微信扫码付 + +## 系统流程 + +```mermaid +flowchart TD + A[每周/月触发扫描] --> B[SQL 查低余额账户 + 未付账单] + B --> C{分级} + C -->|🔴 紧急已欠| D[强提醒 + 业务人员介入催收] + C -->|🟡 警告未欠| E[友好推送提醒] + C -->|🟢 关注| F[月度汇总报告] + + D --> G{业户响应?} + G -->|充值| H[走 deposit-additional-topup] + G -->|其他渠道付| I[正常收款流程] + G -->|无响应| J[逾期催收流程
本文外] + + E --> K{业户行动?} + K -->|充值| H + K -->|不充| L[转入🔴紧急] + + H --> M[业务人员手动 ConsumeAction 抵账单] + Note over M: 月初自动 job 落地后此步自动 +``` + +## 关联工具 + +- **`DepositPrepaidDashboard`**:后台 Dashboard 页面,统一展示押金 + 预存款的健康指标 +- **`LowBalancePrepaidListWidget`**:本场景核心 Widget,实时列出低余额账户 +- **`MonthlyPrepaidFlowChart`**:展示预存款流入 / 流出趋势(充值 vs 消费 vs 退款),业务总监层面看 +- **`DepositPrepaidStatsOverviewWidget`**:总览数字(总账户数、总余额、本月流水量) + +## 常见问题 + +> [!question] "下月预计账单" 怎么算? +> 不在系统内的硬规则,看 Widget 实现: +> +> - 取业户近 3 个月平均月账单 +> - 或取上月账单 +> - 或固定基数(物业费固定 ¥800) +> +> 实际取哪种,看 `LowBalancePrepaidListWidget` 内置逻辑。可调整。 + +> [!question] 业户被提醒"低余额"但其实人家就喜欢月月手动付,不想预存,怎么办? +> 这种业户应**关账户**(走 [[close-resident-moveout|主动关账]]),避免后续骚扰。或者 Widget 上加"忽略"按钮,业户的"预存款关账"决策可以让业务人员跟进。 + +> [!question] 低余额预警有没有自动化(短信)? +> 看产品决策。**强烈推荐**: +> +> - 🟢 关注:不推送(避免骚扰) +> - 🟡 警告:每月 1 次推送(可控) +> - 🔴 紧急:立即推送(必要) +> +> 实施需要短信 / 微信模板消息接入。 + +> [!question] 月初批量自动抵扣 job 落地后,本场景作用还大吗? +> **仍重要**。job 跑完后,跳过的余额不足业户**仍需要业务人员介入**: +> +> - 通知充值 +> - 跟踪是否充值 +> - 充值后 / 业户其他渠道付后,手动 ConsumeAction 补抵 +> +> 详见 [[consume-batch-auto-monthly]] "业务人员视角 - 异常介入" 段。 + +> [!question] 多社区的业户低余额清单怎么展示? +> Widget 按当前 panel 的 community 过滤(若是社区级 Filament Panel)。或者展示"按社区分组"。如果业务人员管多个社区,可在 Dashboard 选社区切换。 + +## 与 deposit 长期未关账户排查的对比 + +| 维度 | deposit audit-long-pending-accounts | prepaid 本场景 | +|---|---|---| +| 关注什么 | 长期 Active 未关账户(>2 年)| 低余额账户(下月可能欠)| +| 业务侧重 | 清理代管资金边界 | 预防业户欠费 | +| 频率 | 季度 / 半年 | 每周 / 每月 | +| 处置 | 关账 / retain / 联系业户 | 通知充值 / 催收 | + +两者**都是审计扫描场景**,但关注点和频率不同。 + +## 异常分支 + +- 业户充值后忘了抵 → 业务人员手动 ConsumeAction +- 业户长期不响应低余额预警 → 进入逾期催收流程(本文外) +- 业户决定不再用预存款 → [[close-with-zero-balance-decision]] / [[close-resident-moveout]] + +## 相关文档 + +- [[consume-batch-auto-monthly]] +- [[auto-deduction-design]] +- [[deposit-additional-topup]] +- [[close-resident-moveout]] +- [[../deposit/audit-long-pending-accounts]](deposit 对应审计场景对比) diff --git a/prop-acc/scenarios/prepaid/exception-cross-community-consume.md b/prop-acc/scenarios/prepaid/exception-cross-community-consume.md new file mode 100644 index 0000000..3c8c84f --- /dev/null +++ b/prop-acc/scenarios/prepaid/exception-cross-community-consume.md @@ -0,0 +1,197 @@ +--- +title: prop-acc · prepaid · 场景 - 跨社区消费防御 +aliases: + - 跨社区消费拦截 + - 跨小区抵账单防御 + - exception-cross-community-consume + - 场景-预存款跨社区消费防御 +tags: + - 场景 + - prop-acc + - 预存款 + - 异常 +audience: + - 业务人员 + - 架构师 +status: 已发布 +sub_feature: prepaid +last_review: 2026-05-25 +code_version: 2026-05-22 +--- + +# 场景:跨社区消费防御 + +业务人员**误选**了"A 社区业户预存款账户"去抵"B 社区账单",系统**直接拦截**,不允许。`PrepaidAccount::consume()` 模型方法内置 community 校验,任何调用方都跑不掉。 + +## 典型情境 + +> [!example] 真实情境 +> 业户陈先生在 A 社区(自住)和 B 社区(投资房出租)各有预存款账户: +> +> - A 社区账户余额 ¥5,000 +> - B 社区账户余额 ¥200 +> +> B 社区刚出账单"出租房物业费 ¥800",**B 社区余额 ¥200 不够付**。业务人员小李心想"陈先生 A 社区还有 ¥5,000,先抵 B 社区账单凑合下,以后再给陈先生说"。他打开陈先生 A 社区账户,选 B 社区账单,点抵扣 —— **系统直接拦截**,提示"预存款账户与账单不在同一社区,无法抵扣"。 + +## 业务人员视角 + +### 您看到什么 + +| 时刻 | 看到 | +|---|---| +| 进入 A 社区账户的 ViewPrepaidAccount | 状态 Active,balance 5000 | +| `ConsumeAction` Modal 表单 → 账单下拉 | **下拉只显示 A 社区账单**(B 社区账单不在下拉里,UI 层已过滤) | +| 如果硬调 API / tinker | 抛 InvalidArgumentException:"预存款账户与账单不在同一社区,无法抵扣" | + +### 三道防御 + +防御层级与 [[exception-refund-on-frozen]] 类似: + +1. **UI 层**:Modal 的账单下拉**只显示当前账户所在 community 的账单** +2. **Action 层**:`ConsumeFromPrepaidAccountAction` 入口校验 community 匹配 +3. **模型层**(最严):`PrepaidAccount::consume()` 内置 community 校验,抛 InvalidArgumentException + +```php +// PrepaidAccount.php +public function consume(Bill $bill, ...): PrepaidTransaction +{ + if ($bill->community_id !== $this->community_id) { + throw new InvalidArgumentException( + '预存款账户与账单不在同一社区,无法抵扣' + ); + } + // ... 其余逻辑 +} +``` + +任何对 `PrepaidAccount::consume()` 的修改都会触发测试,确保守护不被无意中放宽。 + +### 正确路径 + +不同社区**独立处理**: + +| 业户场景 | 正确处置 | +|---|---| +| A 社区想抵 A 社区账单 | 用 A 社区账户(本场景正常情况)| +| B 社区想抵 B 社区账单 | 用 B 社区账户 | +| **A 社区有钱 + B 社区缺钱** | **业户先在 B 社区充值**(走 [[deposit-additional-topup]]),再用 B 社区账户抵 B 社区账单 | + +> [!info] 业务上能"A 社区退款 → 业户拿钱 → B 社区充值"吗? +> 完全可以,但**是业户自己的操作**: +> +> 1. 业务人员从 A 社区账户退 ¥800 给业户 +> 2. 业户拿到 ¥800 +> 3. 业户在 B 社区充 ¥800 +> 4. B 社区账户抵账单 +> +> 三步业务流程,**资金不直接跨社区流动** —— 各社区财务独立核算。 + +## 为什么这条守护这么严 + +> [!warning] 跨社区抵扣的灾难性后果 +> +> 假设系统允许跨社区抵扣: +> +> | 反例 | 后果 | +> |---|---| +> | A 社区物业的钱**流出**到 B 社区物业 | 账面对不上,银行流水追溯困难 | +> | A 社区财务报表显示"代收 B 社区物业费" | 越权,A 社区无权管 B 社区收款 | +> | 业户提现:"我在 A 社区有 ¥1,000,在 B 社区抵 ¥1,000,然后从 A 社区退 ¥1,000" | A 社区净流出 ¥2,000(实际只该 ¥1,000)| +> | 各社区物业可能独立公司化,跨社区抵扣 = 关联交易 | 法务 / 税务问题 | +> +> 每个物业项目独立财务核算是行业基本要求。**跨社区抵扣 = 财务边界破坏**。 + +## 业户视角 + +业户在小程序"我的预存款"看到自己有 A、B 两个独立账户。**互不联通**,各自余额、各自流水、各自抵扣范围。 + +如果想跨社区"调资金",**只能业户自己做**:A 社区退款 → 自己拿钱 → B 社区充值。 + +## 系统流程 + +```mermaid +sequenceDiagram + participant 业务 + participant Filament + participant ConsumeFromPrepaidAccountAction + participant PrepaidAccount[A 社区账户] + participant Bill[B 社区账单] + + Note over 业务: 业务人员误想抵跨社区 + + 业务->>Filament: 直接调 API 或 tinker:account_A.consume(bill_B, 800) + Filament->>ConsumeFromPrepaidAccountAction: handle(account_A, bill_B, 800) + ConsumeFromPrepaidAccountAction->>PrepaidAccount: consume(bill_B, 800) + PrepaidAccount->>PrepaidAccount: bill_B.community_id != self.community_id? + PrepaidAccount-->>ConsumeFromPrepaidAccountAction: throw InvalidArgumentException + ConsumeFromPrepaidAccountAction-->>Filament: 拦截 + 日志 + Filament-->>业务: 报错:"预存款账户与账单不在同一社区,无法抵扣" + + Note over PrepaidAccount,Bill: 无任何资金动作 / 流水产生 +``` + +## 测试断言 + +代码层有专门测试覆盖此异常路径: + +```php +test('cannot consume cross-community bill', function () { + $accountA = PrepaidAccount::factory()->for($communityA)->create(['balance' => 5000]); + $billB = Bill::factory()->for($communityB)->create(['amount' => 800]); + + expect(fn () => $accountA->consume($billB, 800)) + ->toThrow(InvalidArgumentException::class, '预存款账户与账单不在同一社区'); + + expect($accountA->fresh()->balance)->toBe(5000.0); // 余额未变 + expect(PrepaidTransaction::count())->toBe(0); // 流水未建 +}); +``` + +## 常见问题 + +> [!question] 同一物业公司管理多个社区,能不能允许跨社区抵? +> **业务层面**也不行。即使物业公司一家,每个社区**独立财务核算**(看营业执照、税务登记)。除非: +> +> - 多社区合并财务(罕见,需法务批准) +> - 业务方明确要求(走架构师评估) +> +> 当前设计假设"社区独立",未来若改变,需重新评估守护逻辑。 + +> [!question] 业户在小程序操作时能看到跨社区账户吗? +> 设计上**应该分开显示**。例如: +> +> ``` +> 我的预存款 +> ├── A 社区(自住):¥5,000 +> └── B 社区(投资):¥200 +> ``` +> +> 不要混合显示总余额(避免业户误以为"跨社区可用")。 + +> [!question] 业户跨社区调资金的体验差,有什么改进? +> 长期可考虑: +> +> - **跨社区互转**:业户在小程序点"从 A 社区转 ¥1000 到 B 社区" → 系统两步操作(A 退 + B 充)+ 一张统一凭证 +> - 但**资金仍走业户**(银行 / 微信回退再充值),系统不直接跨社区流动 +> - 当前没有,需业务方推动 + +> [!question] 业户失联,A 社区有钱,B 社区欠费严重,能挪吗? +> **不能挪**。业户失联是业户的事,各社区独立催收。B 社区欠费走法务流程。 + +## 与 deposit 的对比 + +deposit 也有类似多账户(同业户可以有多种押金类型账户),但**没有跨社区消费场景**(押金不抵账单,不存在跨账户消费需求)。所以这条守护是 prepaid 独有。 + +## 异常分支 + +- 业务人员真的需要跨社区操作 → 业户自己走"A 退 + B 充"两步 +- 多社区合并财务的特殊业务 → 架构师评估后改设计(目前无) +- 业务方提需求要跨社区抵扣 → 走架构评审,理由要充分 + +## 相关文档 + +- [[one-account-per-resident]] +- [[consume-monthly-property-bill]] +- [[consume-via-bill-collection-type]] +- [[exception-refund-on-frozen]] +- [[../cross/concepts/org-hierarchy|组织结构]] diff --git a/prop-acc/scenarios/prepaid/exception-refund-on-frozen.md b/prop-acc/scenarios/prepaid/exception-refund-on-frozen.md new file mode 100644 index 0000000..3f5bb3e --- /dev/null +++ b/prop-acc/scenarios/prepaid/exception-refund-on-frozen.md @@ -0,0 +1,233 @@ +--- +title: prop-acc · prepaid · 场景 - 冻结状态退款被三层守护拦截 +aliases: + - 冻结状态退款被拒 + - exception-refund-on-frozen + - 场景-冻结状态退款拦截 +tags: + - 场景 + - prop-acc + - 预存款 + - 异常 +audience: + - 业务人员 + - 架构师 +status: 已发布 +sub_feature: prepaid +last_review: 2026-05-25 +code_version: 2026-05-22 +--- + +# 场景:冻结状态退款被三层守护拦截 + +业务人员对 Frozen 账户**误**点退款 / 充值 / 消费 等按钮,系统**三层**(UI / Policy / 模型)守护拦截。**最严**在模型层 —— 即使绕过 UI 和 Policy,模型方法的 `canOperate()` 检查兜底,任何调用方都跑不掉。 + +> [!info] 历史教训 +> 这是 issue.md Q4 第二轮明确修复的**严重漏洞**(项目第 9 项):原 `PrepaidAccount::refund()` 方法**完全不查状态**(只查金额),Frozen / Closed 账户都能被退款。模型层是最底层防御,Action 类绕过 = 没人挡住。修复后所有写入模型方法(`deposit / consume / refund`)统一调 `canOperate()`,严格只允许 Active。 + +## 典型情境 + +> [!example] 真实情境 +> 王女士预存款账户因风控异常被冻结(详见 [[freeze-suspected-fraud]]),余额 ¥50,000。王女士的"亲戚"找到物业说:"她身体不好,委托我领回余额。" 出示了模糊的身份证复印件(无授权书)。 +> +> 物业职员小李没核实关系真实性,**直接打开账户点 RefundAction**。系统拦截: +> +> | 拦截层 | 触发 | +> |---|---| +> | UI 层(Filament Action visible)| `canOperate()` 返 false → 按钮**灰化**(理论上点不到)| +> | Policy 层(`RefundAction->authorize('refund')`)| 即使绕 UI 直接调,Policy 拦 → 抛 AuthorizationException | +> | 模型层(`PrepaidAccount::refund()`)| 即使绕 Policy 调模型方法,`canOperate()` 内置检查 → 抛 RuntimeException | +> +> 任何一层挡住即拦截。**模型层是最后兜底**,即使 tinker / artisan / 第三方包都跑不掉。 + +## 业务人员视角 + +### 您看到什么 + +| 时刻 | 看到 | +|---|---| +| 进入 Frozen 账户的 ViewPrepaidAccount | 状态显示 🧊 Frozen | +| 状态管理组按钮 | "充值 / 消费 / 退款 / 冻结 / 关账" 全部**灰化**,只剩 "解冻" 可点 | +| 如果硬调 API | 抛错(看不同层抛哪个)| + +### 三道防御详解 + +```mermaid +flowchart TD + A[业务人员点 RefundAction] --> B[UI 层:button.visible
canOperate] + B -->|false| C[按钮灰化,点不到] + B -->|绕过 UI
直接调表单| D[Policy 层:->authorize 'refund'
PrepaidAccountPolicy::refund] + D -->|拒绝| E[抛 AuthorizationException] + D -->|绕过 Policy
直接调模型| F[模型层:account->refund
canOperate 检查] + F -->|拒绝| G[抛 RuntimeException
账户处于非可操作状态] +``` + +**三道独立** —— 任意一道挡住即拦截。 + +### 业务上正确做法 + +不要硬绕过。**沟通业户 + 走调解 / 法务流程**: + +| 业户/请求方反应 | 处理 | +|---|---| +| "我急用钱" | 解释:账户冻结调查中,需先完成风控核实 → [[unfreeze-after-verification]] | +| "我证明身份了你为什么不退" | 核实材料是否充分(身份证复印件不够,需原件 + 当面确认 + 业户授权书)| +| "我亲戚委托我" | 委托关系需公证书,否则**绝不退**(常见欺诈套路)| +| 真的本人核实通过 | 走 [[unfreeze-after-verification|解冻]] → 解冻后可正常退款 | + +## 三层守护的代码层面 + +### UI 层(Filament Action) + +```php +RefundAction::make() + ->visible(fn (PrepaidAccount $record) => + $record->canOperate() && $record->balance > 0 + ) +``` + +Frozen → `canOperate()=false` → 按钮 hidden。 + +### Policy 层 + +```php +// PrepaidAccountPolicy.php +public function refund(AuthUser $user, PrepaidAccount $record): bool +{ + return $user->can('update prepaid accounts') + && $record->canOperate() + && $record->hasBalance(); +} +``` + +`RefundAction::make()->authorize('refund')` 触发此方法,Frozen → 返 false → 抛 AuthorizationException。 + +### 模型层(最严) + +```php +// PrepaidAccount.php +public function refund(float $amount, ...): PrepaidTransaction +{ + if (! $this->canOperate()) { + throw new RuntimeException( + "账户处于 {$this->status->value} 状态,无法操作" + ); + } + // ... 其余逻辑 +} +``` + +任何调用方(Filament Action / Action 类 / tinker / artisan / 测试)调 `$account->refund()`,模型层 `canOperate()` 检查兜底。 + +## 系统流程(API 被绕过的极端情况) + +```mermaid +sequenceDiagram + participant 调用方[非 Filament 调用方] + participant Action[RefundFromPrepaidAccountAction] + participant Model[PrepaidAccount] + participant DB + + Note over 调用方: 例如 tinker:RefundFromPrepaidAccountAction::handle(frozen_account, 5000) + + 调用方->>Action: handle(frozen_account, 5000, channel) + Action->>Model: refund(5000) + Model->>Model: canOperate()? Frozen → false + Model-->>Action: throw RuntimeException + Action-->>调用方: 抛出 + 日志记录 + + Note over DB: 无任何写入,事务自动回滚 +``` + +## 测试断言 + +```php +test('cannot refund on frozen account', function () { + $account = PrepaidAccount::factory()->frozen()->create(['balance' => 5000]); + + // 模型层 + expect(fn () => $account->refund(2000)) + ->toThrow(RuntimeException::class, '无法操作'); + + // Action 层 + expect(fn () => app(RefundFromPrepaidAccountAction::class) + ->handle($account, 2000, $channel)) + ->toThrow(RuntimeException::class); + + expect($account->fresh()->balance)->toBe(5000.0); // 余额未变 + expect(PrepaidTransaction::count())->toBe(0); // 流水未建 +}); + +test('cannot consume on frozen account', function () { + // 同样的三层守护 +}); + +test('cannot deposit on frozen account', function () { + // 同样的三层守护 +}); +``` + +**3 个测试覆盖三种写入操作的 Frozen 状态拒绝**,确保守护不被无意中放宽。 + +## 与 deposit 的对比 + +deposit 模块同样三层守护,但**守护方法粒度不同**: + +| 模块 | 模型层守护方法 | +|---|---| +| deposit | `canDeposit()` / `canWithdraw()`(分二种)| +| prepaid | `canOperate()`(统一)| + +理由:deposit 业务上有"只能加不能减"的中间状态(理论上 Frozen 时**加**没问题?现已收紧为都不允许)。prepaid 设计简单,统一拒绝所有写入。 + +详见 [[account-state-machine]] "canOperate 是模型层的最严防御" 段。 + +## 常见问题 + +> [!question] 业户特别紧急要钱(如医疗),冻结状态能绕过吗? +> **绝对不能从系统层绕**。业务流程上: +> +> 1. 业务人员加急核实业户身份 + 紧急情况 +> 2. 走 [[unfreeze-after-verification|解冻]] 流程 +> 3. 解冻后立即 RefundAction +> +> 整个流程**1-2 小时内可完成**,比"擅自绕守护退款"的合规风险小得多。 + +> [!question] 三层守护是不是过度设计了?一层就够吧? +> **不是过度**。每一层都有可能被绕: +> +> - UI 灰化 → 用户可能开浏览器开发者工具篡改 DOM +> - Policy → API 调用者可能直接调 Action 类(绕过 Filament Action) +> - **模型层** → tinker / artisan / 测试 / 第三方包都直接操作模型 +> +> 多层独立 = 任意一层挡住即安全。代码层面成本极低(每层一两行)。 + +> [!question] 为什么 prepaid 的修复是"第二轮"才做? +> issue.md Q4 提到的"第一轮"是 prepaid 模块刚做时,只有 `voidReverse` 一个 Policy 方法(其他都没)。第二轮全面审查发现这个**严重漏洞**(模型层不查状态),补齐了 9 个 Policy 方法 + 模型方法守护 + 测试。 + +> [!question] 已发现的所有漏洞都补齐了吗? +> 详见 issue.md Q4 "第二轮已落地 (2026-05-22)" 段,8 项修复: +> +> 7. 删 DeleteAction / DeleteBulkAction +> 8. Policy 从 1 个补到 9 个 +> 9. **`PrepaidAccount.refund()` 加 canOperate 守护(最严重)** +> 10. RefundAction UI 守护改为 canOperate && balance>0 +> 11. 6 个 Filament Action 加显式 authorize 调用 +> 12. EditAction 加 visible 守护 +> 13. PrepaidAccount.hasBalance() 辅助方法 +> +> 当前未发现其他漏洞,但任何重要修改都应增量做安全审计。 + +## 异常分支 + +- 业务上需要退冻结账户 → 先 [[unfreeze-after-verification]] +- 业户失联无法核实 → 留 Frozen,等业户出现 +- 冻结期间充值被拦 → 同样三层守护,处理一致 + +## 相关文档 + +- [[freeze-suspected-fraud]] +- [[unfreeze-after-verification]] +- [[account-state-machine]] +- [[exception-cross-community-consume]] +- [[../deposit/account-state-machine]](deposit 状态机对比)