会员升级报价拒绝已刷新账期的过期窗口

修正 upgrade.rs 报价校验:now 必须落在 [cycle_started_at, cycle_resets_at) 内,越界按 InvalidQuoteInput 拒绝,避免旧 cycle_index 配零剩余比例多收一个完整月
同步模块头注释、校验函数文档与 InvalidQuoteInput 枚举说明
把「过期窗口只收完整月」的用例改为「过期窗口被拒绝」,保留剩余比例自身的 0 断言

Co-authored-by: Junie <junie@jetbrains.com>
This commit is contained in:
2026-10-03 15:32:33 +08:00
parent c165a032c9
commit 4621f76a58
@@ -13,6 +13,9 @@
//! 「后续完整月数」取 `cycle_count − cycle_index`,**不**从 `cycle_resets_at` 重新锚定推算:
//! 月末夹取后的日期(如 1/31 锚点得到 2/28)不再是原始开通日,重锚会漂移成 3/28。
//! 「本期剩余比例」用本期实际时长做分母,单位取百万分之一(ppm)。
//!
//! 入参要求 `cycle_started_at <= now < cycle_resets_at`:调用方必须先把会员行投射到当前账期,
//! 不接受已刷新的旧窗口——否则剩余比例与期号口径不一致,会静默多收一个完整月。
use serde::{Deserialize, Serialize};
@@ -69,7 +72,7 @@ pub enum RuntimeProfileMembershipUpgradeRejection {
NotUpgrade,
/// 目标档位不可购买(非会员占位或已下架)。
TargetPlanNotPurchasable,
/// 报价入参的数值不变量被破坏(期号越界或账期窗口非法):拒绝,而不是静默夹取。
/// 报价入参的数值不变量被破坏(期号越界、账期窗口非法或 `now` 不在窗口内):拒绝,而不是静默夹取。
InvalidQuoteInput,
}
@@ -125,19 +128,24 @@ pub fn check_runtime_profile_membership_upgrade_allowed(
/// 在此显式校验是为了不让越界值被 `saturating_sub` 静默夹取后算出错误金额。
///
/// 总期数不在入参里,而是由目标周期类型推导(`cycle_kind.cycle_count()`),所以「期号越界」
/// 相对该推导值判断,`cycle_count` 与周期类型天然对齐。`now` 只校验下界——已过期报价按
/// 「剩余比例为 0」返回,属既有契约。
/// 相对该推导值判断,`cycle_count` 与周期类型天然对齐。
///
/// 账期窗口要求严格递增(`cycle_started_at < cycle_resets_at`):零时长窗口没有有效分母,
/// 按非法入参拒绝,而不是让它退化成「剩余比例为 0」。
///
/// `now` 必须落在账期窗口内(`cycle_started_at <= now < cycle_resets_at`):本期剩余比例与
/// 期号是同一期账期的两个口径,账期已刷新仍按旧 `cycle_index` 报价会多收整整一个完整月
/// (年付还会把补点算成 0)。生产入口先用 `project_profile_membership_cycle_at` 把会员行
/// 投射到当前账期,因此这里拒绝的只会是「拿过期账期直接报价」的错误调用方。
fn validate_runtime_profile_membership_upgrade_quote_input(
input: &RuntimeProfileMembershipUpgradeQuoteInput,
) -> Result<(), RuntimeProfileMembershipUpgradeRejection> {
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 {
let now_outside_window = input.now_micros < input.cycle_started_at_micros
|| input.now_micros >= input.cycle_resets_at_micros;
if cycle_index_out_of_range || window_out_of_order || now_outside_window {
return Err(RuntimeProfileMembershipUpgradeRejection::InvalidQuoteInput);
}
Ok(())
@@ -325,18 +333,27 @@ mod tests {
),
0
);
}
let quote =
#[test]
fn quote_rejects_now_at_or_after_cycle_reset() {
let input = base_input();
// 中文注释:账期已刷新仍按旧 `cycle_index` 报价会多收一个完整月(年付还把补点算成 0),
// 因此窗口上界与下界一样按非法入参拒绝;调用方应先投射账期到当前时刻。
assert_eq!(
quote_runtime_profile_membership_upgrade(RuntimeProfileMembershipUpgradeQuoteInput {
now_micros: input.cycle_resets_at_micros,
..input.clone()
}),
Err(RuntimeProfileMembershipUpgradeRejection::InvalidQuoteInput)
);
assert_eq!(
quote_runtime_profile_membership_upgrade(RuntimeProfileMembershipUpgradeQuoteInput {
now_micros: input.cycle_resets_at_micros + DAY_MICROS,
..input
})
.expect("年付升级应可报价");
// 中文注释:此处已把 `input` 整体移入调用,上面的比例断言必须先于本行执行。
assert_eq!(quote.remaining_ratio_ppm, 0);
assert_eq!(quote.granted_points_delta, 0);
// 中文注释:仅剩 9 个完整月,本期零头为 0,因此只收 9/12 的价差并向下取整补点。
assert_eq!(quote.amount_cents, 150_000);
}),
Err(RuntimeProfileMembershipUpgradeRejection::InvalidQuoteInput)
);
}
#[test]