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

- 删除 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
}
```
@@ -9,6 +9,7 @@
//! - 取整:金额**向上**取到分,补点**向下**取整。
//! - 有效期、刷新日、期数都不变;已用点不返还,永久泥点不动。
//!
//! 「本次购买总期数」由周期类型推导(`cycle_kind.cycle_count()`:月付 1 / 年付 12),不作为入参;
//! 「后续完整月数」取 `cycle_count − cycle_index`,**不**从 `cycle_resets_at` 重新锚定推算:
//! 月末夹取后的日期(如 1/31 锚点得到 2/28)不再是原始开通日,重锚会漂移成 3/28。
//! 「本期剩余比例」用本期实际时长做分母,单位取百万分之一(ppm)。
@@ -28,14 +29,15 @@ const MONTHS_PER_YEAR: u128 = 12;
///
/// 两个套餐快照由调用方从 `profile_membership_plan` 目录表解析后传入,
/// 这样价格与权益的唯一真相源始终是那张可被后台改价的表,而不是编译进代码的种子。
///
/// 本次购买总期数**不是**入参:它由目标周期类型推导(`target.cycle_kind.cycle_count()`,
/// 月付 1 / 年付 12),避免调用方传入与周期类型不一致的期数而算出错误金额。
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RuntimeProfileMembershipUpgradeQuoteInput {
pub current: RuntimeProfileMembershipPlanSnapshot,
pub target: RuntimeProfileMembershipPlanSnapshot,
/// 当前期序号(1-based)。
pub cycle_index: u32,
/// 本次购买总期数(月付 1 / 年付 12)。
pub cycle_count: u32,
pub cycle_started_at_micros: i64,
pub cycle_resets_at_micros: i64,
pub expires_at_micros: i64,
@@ -122,12 +124,14 @@ pub fn check_runtime_profile_membership_upgrade_allowed(
/// 报价入参的数值自检。生产入口从会员行构造入参,这些不变量恒成立;
/// 在此显式校验是为了不让越界值被 `saturating_sub` 静默夹取后算出错误金额。
///
/// 刻意不做的两项(见 review 第 12 项):`cycle_count` 与周期类型的对齐(月付 1 / 年付 12)、
/// `now` 的窗口上界——已过期报价按「剩余比例为 0」返回,属既有契约。
/// 总期数不在入参里,而是由目标周期类型推导(`cycle_kind.cycle_count()`),所以「期号越界」
/// 相对该推导值判断,`cycle_count` 与周期类型天然对齐。`now` 只校验下界——已过期报价按
/// 「剩余比例为 0」返回,属既有契约。
fn validate_runtime_profile_membership_upgrade_quote_input(
input: &RuntimeProfileMembershipUpgradeQuoteInput,
) -> Result<(), RuntimeProfileMembershipUpgradeRejection> {
let cycle_index_out_of_range = input.cycle_index == 0 || input.cycle_index > input.cycle_count;
let cycle_count = input.target.cycle_kind.cycle_count();
let cycle_index_out_of_range = input.cycle_index == 0 || input.cycle_index > cycle_count;
let window_out_of_order = input.cycle_resets_at_micros < input.cycle_started_at_micros;
let now_before_window = input.now_micros < input.cycle_started_at_micros;
if cycle_index_out_of_range || window_out_of_order || now_before_window {
@@ -146,7 +150,8 @@ pub fn quote_runtime_profile_membership_upgrade(
let current = input.current;
let target = input.target;
let remaining_full_months = input.cycle_count.saturating_sub(input.cycle_index);
let cycle_count = target.cycle_kind.cycle_count();
let remaining_full_months = cycle_count.saturating_sub(input.cycle_index);
let remaining_ratio_ppm = membership_cycle_remaining_ratio_ppm(
input.cycle_started_at_micros,
input.cycle_resets_at_micros,
@@ -227,7 +232,6 @@ mod tests {
RuntimeProfileMembershipCycleKind::Yearly,
),
cycle_index: 3,
cycle_count: 9,
cycle_started_at_micros,
cycle_resets_at_micros,
expires_at_micros: beijing(2027, 3, 20, 9),
@@ -251,7 +255,6 @@ mod tests {
RuntimeProfileMembershipCycleKind::Monthly,
),
cycle_index: 1,
cycle_count: 1,
cycle_remaining_points: 615,
..base_input()
})
@@ -265,14 +268,15 @@ mod tests {
#[test]
fn yearly_upgrade_matches_the_spec_example() {
// 中文注释:Plus 年 → Pro 年,剩 6 个完整月 + 半个当前月 → 补付 ¥1083.34、补点 1250。
// 中文注释:Plus 年 → Pro 年,年付 12 期、当前第 3 期 → 剩 9 个完整月 + 半个当前月,
// 补付 ¥1583.34、补点 1250。
let quote =
quote_runtime_profile_membership_upgrade(base_input()).expect("年付升级应可报价");
assert_eq!(quote.amount_cents, 108_334);
assert_eq!(quote.amount_cents, 158_334);
assert_eq!(quote.granted_points_delta, 1_250);
assert_eq!(quote.monthly_balance_after, 1_865);
assert_eq!(quote.remaining_full_months, 6);
assert_eq!(quote.remaining_full_months, 9);
assert_eq!(quote.remaining_ratio_ppm, 500_000);
}
@@ -315,8 +319,8 @@ mod tests {
// 中文注释:此处已把 `input` 整体移入调用,上面的比例断言必须先于本行执行。
assert_eq!(quote.remaining_ratio_ppm, 0);
assert_eq!(quote.granted_points_delta, 0);
// 中文注释:仅剩 6 个完整月,本期零头为 0,因此只收 6/12 的价差并向下取整补点。
assert_eq!(quote.amount_cents, 100_000);
// 中文注释:仅剩 9 个完整月,本期零头为 0,因此只收 9/12 的价差并向下取整补点。
assert_eq!(quote.amount_cents, 150_000);
}
#[test]
@@ -376,7 +380,7 @@ mod tests {
);
assert_eq!(
quote_runtime_profile_membership_upgrade(RuntimeProfileMembershipUpgradeQuoteInput {
cycle_index: base_input().cycle_count + 1,
cycle_index: RuntimeProfileMembershipCycleKind::Yearly.cycle_count() + 1,
..base_input()
}),
Err(RuntimeProfileMembershipUpgradeRejection::InvalidQuoteInput)
@@ -417,8 +421,8 @@ mod tests {
#[test]
fn continuing_upgrade_compares_against_the_current_plan() {
// 中文注释:Starter → Plus → Pro 时,第二次按 Plus vs Pro 计算(¥1083.34),
// 而不是按 Starter 直接跳 Pro(¥1408.34)。
// 中文注释:Starter → Plus → Pro 时,第二次按 Plus vs Pro 计算(¥1583.34),
// 而不是按 Starter 直接跳 Pro(¥2058.34)。
let starter_to_plus =
quote_runtime_profile_membership_upgrade(RuntimeProfileMembershipUpgradeQuoteInput {
current: snapshot(
@@ -454,17 +458,17 @@ mod tests {
})
.expect("Plus → Pro 应可报价");
assert_eq!(starter_to_plus.amount_cents, 32_500);
assert_eq!(starter_to_pro.amount_cents, 140_834);
assert_eq!(plus_to_pro.amount_cents, 108_334);
assert_eq!(starter_to_plus.amount_cents, 47_500);
assert_eq!(starter_to_pro.amount_cents, 205_834);
assert_eq!(plus_to_pro.amount_cents, 158_334);
assert_ne!(starter_to_pro.amount_cents, plus_to_pro.amount_cents);
}
#[test]
fn yearly_amount_rounds_up_to_cent_and_points_round_down() {
// 中文注释:本期实际时长 3 天、已过 2 天 → 剩余比例 333_333 ppm(向下取整);
// 金额 200000 × (6 + 0.333333) / 12 = 105555.55 分 → 向上到 105556;
// 补点 2500 × 0.333333 = 833.33 → 向下取整为 833。
// 年付 12 期、当前第 3 期 → 剩 9 个完整月,金额 200000 × (9 + 0.333333) / 12
// = 155555.55 分 → 向上到 155556;补点 2500 × 0.333333 = 833.33 → 向下取整为 833。
let started = beijing(2026, 6, 20, 9);
let quote =
quote_runtime_profile_membership_upgrade(RuntimeProfileMembershipUpgradeQuoteInput {
@@ -476,7 +480,7 @@ mod tests {
.expect("年付升级应可报价");
assert_eq!(quote.remaining_ratio_ppm, 333_333);
assert_eq!(quote.amount_cents, 105_556);
assert_eq!(quote.amount_cents, 155_556);
assert_eq!(quote.granted_points_delta, 833);
}
}
@@ -9067,7 +9067,6 @@ fn membership_upgrade_quote_input(
current: current.clone(),
target: target.clone(),
cycle_index: row.cycle_index.max(1),
cycle_count: row.cycle_count.max(1),
cycle_started_at_micros: row
.cycle_started_at
.map(|value| value.to_micros_since_unix_epoch())