会员升级报价拒绝已刷新账期的过期窗口
修正 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:
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user