领域层:新增会员补差升级报价纯函数

- 新增 membership_upgrade.rs:quote_runtime_profile_membership_upgrade 与 RuntimeProfileMembershipUpgradeQuoteInput / RuntimeProfileMembershipUpgradeQuote
- 月付按「新月价 − 当前月价」「新每期额度 − 当前每期额度」;年付按 (新年价 − 当前年价)/12×(后续完整月数 + 本期剩余比例)
- check_runtime_profile_membership_upgrade_allowed 实现允许操作矩阵:非会员 / 周期变更 / 同档降档 / 目标不可购买一律拒绝
- membership_cycle_remaining_ratio_ppm 计算本期剩余比例(ppm,向下取整)
- 金额向上取到分、补点向下取整;有效期 / 刷新日 / 期数保持不变
- 单测复现 spec 示例:Plus 月→Pro 月 ¥200 / 2500 点 / 余额 3115;Plus 年→Pro 年 ¥1083.34 / 1250 点 / 余额 1865
- 设计文档同步报价函数签名与「后续完整月数 = cycle_count − cycle_index」口径

Co-authored-by: Junie <junie@jetbrains.com>
This commit is contained in:
2026-10-02 14:11:54 +08:00
parent 33a4c8dbc3
commit 2b77fa08c3
3 changed files with 403 additions and 2 deletions
@@ -269,8 +269,14 @@ pub struct RuntimeProfileMembershipUpgradeQuote {
`advance_beijing_months_clamped(anchor, months)`、`beijing_local_date_parts(micros) -> (年月日, 当日微秒)`、
`beijing_local_micros(年月日, 当日微秒)`、`membership_expires_at(anchor, cycle_kind)`、
`membership_cycle_remaining_micros(resets_at, now)`。
- `quote_membership_upgrade(current, target, now) -> quote`:
金额向上取到分、补点向下取整;月付 / 年付分支见 §6;年价取目录 `year_price_cents`。
- `quote_runtime_profile_membership_upgrade(input) -> Result<quote, rejection>`:
入参 `RuntimeProfileMembershipUpgradeQuoteInput`(当前档位 / 周期、目标档位 / 周期、期序号 / 期总数、
本期窗口、当前月度余额、`now`);金额向上取到分、补点向下取整;月付 / 年付分支见 §6;年价取目录 `year_price_cents`。
- `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` 重新锚定推算:
月末夹取后的日期(1/31 锚点得到 2/28)不再是原始开通日,重锚会把后续月份漂移成 3/28。
- `apply_membership_upgrade(row, quote) -> row'`:`cycle_remaining += 补点`、
`cycle_granted_points += 补点`、`plan` 更新,刷新日与到期日不变。
- `is_unlimited_concurrency(limit) -> bool`:`limit >= 128` 视为不设上限(哨兵值集中在领域层一处)。
@@ -10,6 +10,8 @@ mod membership_limits;
pub use membership_limits::*;
mod membership_catalog;
pub use membership_catalog::*;
mod membership_upgrade;
pub use membership_upgrade::*;
mod application;
pub use agc_models::*;
mod commands;
@@ -0,0 +1,393 @@
//! 会员补差升级报价(纯函数,无 IO)。
//!
//! 规则来自实施计划 §3.3,并且**金额与取整只有这一处实现**:报价接口与下单重算必须调用同一个函数,
//! 逐字段一致,前端展示价不是真相源。
//!
//! - 月付:`应付 = 新月价 − 当前月价`,`补点 = 新每期额度 − 当前每期额度`(不按时间折算)。
//! - 年付:`应付 = (新年价 − 当前年价) / 12 × (后续完整月数 + 本期剩余比例)`,
//! `补点 = (新每期额度 − 当前每期额度) × 本期剩余比例`。
//! - 取整:金额**向上**取到分,补点**向下**取整。
//! - 有效期、刷新日、期数都不变;已用点不返还,永久泥点不动。
//!
//! 「后续完整月数」取 `cycle_count − cycle_index`,**不**从 `cycle_resets_at` 重新锚定推算:
//! 月末夹取后的日期(如 1/31 锚点得到 2/28)不再是原始开通日,重锚会漂移成 3/28。
//! 「本期剩余比例」用本期实际时长做分母,单位取百万分之一(ppm)。
use serde::{Deserialize, Serialize};
use crate::membership_catalog::resolve_runtime_profile_membership_plan_snapshot;
use crate::membership_cycle::membership_cycle_remaining_micros;
use crate::{RuntimeProfileMembershipCycleKind, RuntimeProfileMembershipPlan};
/// 比例分母:百万分之一。
const REMAINING_RATIO_PPM: u128 = 1_000_000;
/// 年付按 12 个自然月折算。
const MONTHS_PER_YEAR: u128 = 12;
/// 报价接口与下单重算的共用输入。
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RuntimeProfileMembershipUpgradeQuoteInput {
pub current_plan: RuntimeProfileMembershipPlan,
pub current_cycle_kind: RuntimeProfileMembershipCycleKind,
pub target_plan: RuntimeProfileMembershipPlan,
pub target_cycle_kind: RuntimeProfileMembershipCycleKind,
/// 当前期序号(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,
pub cycle_remaining_points: u64,
pub now_micros: i64,
}
/// 补差升级报价结果;有效期与刷新日与变更前一致。
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RuntimeProfileMembershipUpgradeQuote {
pub amount_cents: u64,
pub granted_points_delta: u64,
pub monthly_balance_after: u64,
pub expires_at_micros: i64,
pub cycle_resets_at_micros: i64,
pub cycle_index: u32,
pub remaining_full_months: u32,
pub remaining_ratio_ppm: u32,
}
/// 升级被拒绝的原因;对外应映射为 4xx 与可读文案,而不是静默算出金额。
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum RuntimeProfileMembershipUpgradeRejection {
/// 当前不是会员:应走下单购买流程,而不是补差升级。
NotAMember,
/// 月转年 / 年转月:本期不支持即时转换,到期后按完整价格重新选择。
CycleKindChangeUnsupported,
/// 同档位(重复购买)或降档:不允许,到期后再重新选择。
NotUpgrade,
/// 目标档位不可购买(非会员占位或已下架)。
TargetPlanNotPurchasable,
/// 目录缺少对应档位 / 周期的价格。
MissingCatalogEntry,
}
impl RuntimeProfileMembershipUpgradeRejection {
pub fn as_str(&self) -> &'static str {
match self {
Self::NotAMember => "not_a_member",
Self::CycleKindChangeUnsupported => "cycle_kind_change_unsupported",
Self::NotUpgrade => "not_upgrade",
Self::TargetPlanNotPurchasable => "target_plan_not_purchasable",
Self::MissingCatalogEntry => "missing_catalog_entry",
}
}
}
/// 本期剩余比例(ppm):`剩余微秒 / 本期实际微秒`,向下取整,已过期或时长非法时为 0。
pub fn membership_cycle_remaining_ratio_ppm(
cycle_started_at_micros: i64,
cycle_resets_at_micros: i64,
now_micros: i64,
) -> u32 {
let total_micros = cycle_resets_at_micros.saturating_sub(cycle_started_at_micros);
if total_micros <= 0 {
return 0;
}
let remaining_micros = membership_cycle_remaining_micros(cycle_resets_at_micros, now_micros);
let remaining_micros = remaining_micros.min(total_micros);
(u128::from(remaining_micros as u64) * REMAINING_RATIO_PPM / u128::from(total_micros as u64))
as u32
}
/// 允许操作矩阵(实施计划 §3.4):只有「已是会员 + 同周期 + 升更高档位 + 目标可购买」才允许升级。
pub fn check_runtime_profile_membership_upgrade_allowed(
current_plan: RuntimeProfileMembershipPlan,
target_plan: RuntimeProfileMembershipPlan,
current_cycle_kind: RuntimeProfileMembershipCycleKind,
target_cycle_kind: RuntimeProfileMembershipCycleKind,
) -> Result<(), RuntimeProfileMembershipUpgradeRejection> {
if !current_plan.is_purchasable() {
return Err(RuntimeProfileMembershipUpgradeRejection::NotAMember);
}
if current_cycle_kind != target_cycle_kind {
return Err(RuntimeProfileMembershipUpgradeRejection::CycleKindChangeUnsupported);
}
if crate::runtime_profile_membership_plan_rank(target_plan)
<= crate::runtime_profile_membership_plan_rank(current_plan)
{
return Err(RuntimeProfileMembershipUpgradeRejection::NotUpgrade);
}
if !crate::is_runtime_profile_membership_plan_purchasable(target_plan) {
return Err(RuntimeProfileMembershipUpgradeRejection::TargetPlanNotPurchasable);
}
Ok(())
}
/// 计算补差升级报价;报价与下单重算必须共用本函数。
pub fn quote_runtime_profile_membership_upgrade(
input: RuntimeProfileMembershipUpgradeQuoteInput,
) -> Result<RuntimeProfileMembershipUpgradeQuote, RuntimeProfileMembershipUpgradeRejection> {
check_runtime_profile_membership_upgrade_allowed(
input.current_plan,
input.target_plan,
input.current_cycle_kind,
input.target_cycle_kind,
)?;
let current = resolve_runtime_profile_membership_plan_snapshot(
input.current_plan,
input.current_cycle_kind,
)
.ok_or(RuntimeProfileMembershipUpgradeRejection::MissingCatalogEntry)?;
let target = resolve_runtime_profile_membership_plan_snapshot(
input.target_plan,
input.target_cycle_kind,
)
.ok_or(RuntimeProfileMembershipUpgradeRejection::MissingCatalogEntry)?;
let remaining_full_months = input.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,
input.now_micros,
);
let price_delta_cents = target.price_cents.saturating_sub(current.price_cents);
let points_delta = target.period_points.saturating_sub(current.period_points);
let (amount_cents, granted_points_delta) = match input.target_cycle_kind {
RuntimeProfileMembershipCycleKind::Monthly => (price_delta_cents, points_delta),
RuntimeProfileMembershipCycleKind::Yearly => {
let weighted_months_ppm = u128::from(remaining_full_months) * REMAINING_RATIO_PPM
+ u128::from(remaining_ratio_ppm);
let amount_cents = ceil_div(
u128::from(price_delta_cents) * weighted_months_ppm,
MONTHS_PER_YEAR * REMAINING_RATIO_PPM,
);
let granted_points_delta = (u128::from(points_delta) * u128::from(remaining_ratio_ppm)
/ REMAINING_RATIO_PPM) as u64;
(amount_cents, granted_points_delta)
}
};
Ok(RuntimeProfileMembershipUpgradeQuote {
amount_cents,
granted_points_delta,
monthly_balance_after: input
.cycle_remaining_points
.saturating_add(granted_points_delta),
expires_at_micros: input.expires_at_micros,
cycle_resets_at_micros: input.cycle_resets_at_micros,
cycle_index: input.cycle_index,
remaining_full_months,
remaining_ratio_ppm,
})
}
/// 向上取整的整数除法(金额取整到分)。
fn ceil_div(numerator: u128, denominator: u128) -> u64 {
if denominator == 0 {
return 0;
}
((numerator + denominator - 1) / denominator) as u64
}
#[cfg(test)]
mod tests {
use super::*;
use crate::membership_cycle::beijing_local_micros;
const DAY_MICROS: i64 = 86_400_000_000;
fn beijing(year: i32, month: u8, day: u8, hour: i64) -> i64 {
beijing_local_micros(year, month, day, hour * 3_600_000_000)
}
fn base_input() -> RuntimeProfileMembershipUpgradeQuoteInput {
let cycle_started_at_micros = beijing(2026, 6, 20, 9);
let cycle_resets_at_micros = beijing(2026, 7, 20, 9);
RuntimeProfileMembershipUpgradeQuoteInput {
current_plan: RuntimeProfileMembershipPlan::Plus,
current_cycle_kind: RuntimeProfileMembershipCycleKind::Yearly,
target_plan: RuntimeProfileMembershipPlan::Pro,
target_cycle_kind: RuntimeProfileMembershipCycleKind::Yearly,
cycle_index: 3,
cycle_count: 9,
cycle_started_at_micros,
cycle_resets_at_micros,
expires_at_micros: beijing(2027, 3, 20, 9),
cycle_remaining_points: 615,
now_micros: cycle_started_at_micros
+ (cycle_resets_at_micros - cycle_started_at_micros) / 2,
}
}
#[test]
fn monthly_upgrade_matches_the_spec_example() {
// 中文注释:Plus 月 → Pro 月:补付 ¥200、补点 2500、余额 615 + 2500 = 3115。
let quote =
quote_runtime_profile_membership_upgrade(RuntimeProfileMembershipUpgradeQuoteInput {
current_cycle_kind: RuntimeProfileMembershipCycleKind::Monthly,
target_cycle_kind: RuntimeProfileMembershipCycleKind::Monthly,
cycle_index: 1,
cycle_count: 1,
cycle_remaining_points: 615,
..base_input()
})
.expect("月付升级应可报价");
assert_eq!(quote.amount_cents, 20_000);
assert_eq!(quote.granted_points_delta, 2_500);
assert_eq!(quote.monthly_balance_after, 3_115);
assert_eq!(quote.remaining_full_months, 0);
}
#[test]
fn yearly_upgrade_matches_the_spec_example() {
// 中文注释:Plus 年 → Pro 年,剩 6 个完整月 + 半个当前月 → 补付 ¥1083.34、补点 1250。
let quote =
quote_runtime_profile_membership_upgrade(base_input()).expect("年付升级应可报价");
assert_eq!(quote.amount_cents, 108_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_ratio_ppm, 500_000);
}
#[test]
fn upgrade_keeps_expiry_reset_and_cycle_index() {
let input = base_input();
let quote = quote_runtime_profile_membership_upgrade(input).expect("年付升级应可报价");
assert_eq!(quote.expires_at_micros, input.expires_at_micros);
assert_eq!(quote.cycle_resets_at_micros, input.cycle_resets_at_micros);
assert_eq!(quote.cycle_index, input.cycle_index);
}
#[test]
fn remaining_ratio_is_zero_at_or_after_reset() {
let input = base_input();
assert_eq!(
membership_cycle_remaining_ratio_ppm(
input.cycle_started_at_micros,
input.cycle_resets_at_micros,
input.cycle_resets_at_micros
),
0
);
assert_eq!(
membership_cycle_remaining_ratio_ppm(
input.cycle_started_at_micros,
input.cycle_resets_at_micros,
input.cycle_resets_at_micros + DAY_MICROS
),
0
);
let quote =
quote_runtime_profile_membership_upgrade(RuntimeProfileMembershipUpgradeQuoteInput {
now_micros: input.cycle_resets_at_micros + DAY_MICROS,
..input
})
.expect("年付升级应可报价");
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);
}
#[test]
fn non_member_must_purchase_instead_of_upgrading() {
assert_eq!(
quote_runtime_profile_membership_upgrade(RuntimeProfileMembershipUpgradeQuoteInput {
current_plan: RuntimeProfileMembershipPlan::Normal,
target_plan: RuntimeProfileMembershipPlan::Starter,
..base_input()
}),
Err(RuntimeProfileMembershipUpgradeRejection::NotAMember)
);
}
#[test]
fn same_plan_and_downgrade_are_rejected() {
assert_eq!(
quote_runtime_profile_membership_upgrade(RuntimeProfileMembershipUpgradeQuoteInput {
target_plan: RuntimeProfileMembershipPlan::Plus,
..base_input()
}),
Err(RuntimeProfileMembershipUpgradeRejection::NotUpgrade)
);
assert_eq!(
quote_runtime_profile_membership_upgrade(RuntimeProfileMembershipUpgradeQuoteInput {
current_plan: RuntimeProfileMembershipPlan::Pro,
target_plan: RuntimeProfileMembershipPlan::Plus,
..base_input()
}),
Err(RuntimeProfileMembershipUpgradeRejection::NotUpgrade)
);
}
#[test]
fn cycle_kind_change_is_rejected() {
assert_eq!(
quote_runtime_profile_membership_upgrade(RuntimeProfileMembershipUpgradeQuoteInput {
current_cycle_kind: RuntimeProfileMembershipCycleKind::Monthly,
target_cycle_kind: RuntimeProfileMembershipCycleKind::Yearly,
..base_input()
}),
Err(RuntimeProfileMembershipUpgradeRejection::CycleKindChangeUnsupported)
);
}
#[test]
fn continuing_upgrade_compares_against_the_current_plan() {
// 中文注释:Starter → Plus → Pro 时,第二次按 Plus vs Pro 计算(¥1083.34),
// 而不是按 Starter 直接跳 Pro(¥1408.34)。
let starter_to_plus =
quote_runtime_profile_membership_upgrade(RuntimeProfileMembershipUpgradeQuoteInput {
current_plan: RuntimeProfileMembershipPlan::Starter,
target_plan: RuntimeProfileMembershipPlan::Plus,
cycle_remaining_points: 0,
..base_input()
})
.expect("Starter → Plus 应可报价");
let starter_to_pro =
quote_runtime_profile_membership_upgrade(RuntimeProfileMembershipUpgradeQuoteInput {
current_plan: RuntimeProfileMembershipPlan::Starter,
target_plan: RuntimeProfileMembershipPlan::Pro,
cycle_remaining_points: 0,
..base_input()
})
.expect("Starter → Pro 应可报价");
let plus_to_pro =
quote_runtime_profile_membership_upgrade(RuntimeProfileMembershipUpgradeQuoteInput {
cycle_remaining_points: 0,
..base_input()
})
.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_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。
let started = beijing(2026, 6, 20, 9);
let quote =
quote_runtime_profile_membership_upgrade(RuntimeProfileMembershipUpgradeQuoteInput {
cycle_started_at_micros: started,
cycle_resets_at_micros: started + 3 * DAY_MICROS,
now_micros: started + 2 * DAY_MICROS,
..base_input()
})
.expect("年付升级应可报价");
assert_eq!(quote.remaining_ratio_ppm, 333_333);
assert_eq!(quote.amount_cents, 105_556);
assert_eq!(quote.granted_points_delta, 833);
}
}