修复(计费): 升级报价总期数改由周期类型推导

- 删除 RuntimeProfileMembershipUpgradeQuoteInput.cycle_count 入参,改由 target.cycle_kind.cycle_count() 推导(月付 1 / 年付 12),杜绝入参期数与周期类型不一致导致按错误期数计费
- 报价数值自检的期号上界改为相对推导出的总期数判断
- spacetime-module 构造报价入参时不再传 cycle_count
- 同步升级报价单测:年付 12 期、当前第 3 期 → 剩余完整月数 9,金额与测试夹具一并修正
- 同步设计文档 §3 入参与自检说明、§5.3 年付示例(remainingFullMonths 9 / amountCents 158334)

Co-authored-by: Junie <junie@jetbrains.com>
This commit is contained in:
2026-10-02 20:26:48 +08:00
parent bc3d8dc7da
commit 06eb4e668c
3 changed files with 35 additions and 30 deletions
@@ -271,15 +271,17 @@ pub struct RuntimeProfileMembershipUpgradeQuote {
`beijing_local_micros(年月日, 当日微秒)`、`membership_expires_at(anchor, cycle_kind)`、
`membership_cycle_remaining_micros(resets_at, now)`。
- `quote_runtime_profile_membership_upgrade(input) -> Result<quote, rejection>`:
入参 `RuntimeProfileMembershipUpgradeQuoteInput`(当前档位 / 周期、目标档位 / 周期、期序号 / 期总数、
入参 `RuntimeProfileMembershipUpgradeQuoteInput`(当前档位 / 周期、目标档位 / 周期、期序号、
本期窗口、当前月度余额、`now`);金额向上取到分、补点向下取整;月付 / 年付分支见 §6;年价取目录 `year_price_cents`。
- 报价函数对数值入参做防御式自检:`1 <= cycle_index <= cycle_count`、`cycle_started_at <= now`、
`cycle_started_at <= cycle_resets_at`,越界返回 `invalid_quote_input`(`InvalidQuoteInput`),
不再被 `saturating_sub` 静默夹取;生产入口从会员行构造的入参恒满足这些不变量。
本次购买总期数**不是入参**:由目标周期类型推导(`target.cycle_kind.cycle_count()`,月付 1 / 年付 12),
杜绝「入参期数与周期类型不一致」导致按错误期数计费。
- 报价函数对数值入参做防御式自检:`1 <= cycle_index <= cycle_count`(`cycle_count` 由上一步推导)、
`cycle_started_at <= now`、`cycle_started_at <= cycle_resets_at`,越界返回 `invalid_quote_input`
(`InvalidQuoteInput`),不再被 `saturating_sub` 静默夹取;生产入口从会员行构造的入参恒满足这些不变量。
- `check_runtime_profile_membership_upgrade_allowed(...) -> Result<(), rejection>`:
§3.4 允许操作矩阵的唯一实现(非会员不可升级 / 不支持月转年与年转月 / 同档与降档拒绝 / 目标不可购买拒绝)。
- `membership_cycle_remaining_ratio_ppm(cycle_started_at, cycle_resets_at, now) -> u32`:本期剩余比例(ppm,向下取整)。
- **「后续完整月数」= `cycle_count − cycle_index`**,不从 `cycle_resets_at` 重新锚定推算:
- **「后续完整月数」= `cycle_count − cycle_index`**(`cycle_count` 由周期类型推导),不从 `cycle_resets_at` 重新锚定推算:
月末夹取后的日期(1/31 锚点得到 2/28)不再是原始开通日,重锚会把后续月份漂移成 3/28。
- `apply_membership_upgrade(row, quote) -> row'`:`cycle_remaining += 补点`、
`cycle_granted_points += 补点`、`plan` 更新,刷新日与到期日不变。
@@ -365,12 +367,12 @@ flowchart TD
"cycleKind": "yearly",
"cycleIndex": 3,
"cycleCount": 12,
"amountCents": 108334,
"amountCents": 158334,
"grantedPointsDelta": 1250,
"monthlyBalanceAfter": 1865,
"expiresAt": "...",
"cycleResetsAt": "...",
"remainingFullMonths": 6
"remainingFullMonths": 9
}
```