Tier upgrades Phase 2 — quote logic + apply step
Builds on 8ce78ab (Phase 1 schema). Pure module work — no HTTP
endpoints yet (those are Phase 3). Operator-invisible until Phase
3-5 wire up the buyer / admin / UI surfaces.
src/upgrades.rs:
- UpgradeQuote / TierDirection / EffectiveAt structs (serde-ready
for the future endpoint).
- compute_upgrade_quote(state, license, target_policy) — the
buyer-facing quote function. Enforces ladder rules:
* both policies must have non-NULL tier_rank
* sideways (same-rank) changes rejected — admin-only
* cross-product target rejected
* inactive target rejected
* same-policy noop rejected
* perpetual downgrades rejected (refund decision = admin-only)
* recurring → perpetual downgrade rejected (admin-only)
- Branches on perpetual vs recurring:
* Perpetual upgrade: flat (target - current) listed price diff,
effective_at = Immediate.
* Recurring upgrade: prorated (target - current) × days_remaining
/ period_days; effective_at = Immediate; surfaces
next_renewal_charge for the buyer to see what they'll pay
going forward.
* Recurring downgrade: zero-charge today, effective_at =
next_renewal_at (full current cycle at old price).
* Free → recurring: full first-cycle price (no proration since
"remaining value" of free is 0).
- record_tier_change — INSERT helper for the audit row.
- apply_tier_change — UPDATE helper that mutates the license row
(policy_id, entitlements_json, expires_at, max_machines,
grace_seconds, is_trial) and any tied subscription
(policy_id, listed_value, period_days). Recurring → perpetual
apply also cancels the now-orphaned subscription so the renewal
worker stops touching it.
- get_tier_change / list_tier_changes_for_license /
get_tier_change_by_invoice — read helpers (Phase 3 webhook
handler will use the by_invoice variant).
tier_rank threading:
- models::Policy gains `tier_rank: Option<i64>`.
- POLICY_COLS + row_to_policy include tier_rank with try_get
Option<i64> + flatten so NULL stays NULL (a valid state) and
pre-0013 databases also resolve to None.
- repo::create_policy gets a `tier_rank: Option<i64>` param.
- repo::RecurringUpdate gains `tier_rank: Option<Option<i64>>`
for nullable-patch semantics matching price_sats_override.
- CreatePolicyReq + UpdatePolicyReq accept tier_rank with the
same shape; range-validated 0..=1000.
tests/upgrades.rs (8 new tests):
- perpetual_upgrade_quote_returns_flat_price_difference
- perpetual_downgrade_is_admin_only (rejection w/ helpful msg)
- quote_rejects_target_with_null_tier_rank
- quote_rejects_same_policy
- recurring_upgrade_prorates_against_time_remaining (asserts
~half-of-diff for ~half-of-cycle remaining; tolerance window)
- recurring_downgrade_is_zero_charge_at_next_cycle (verifies
effective_at lands on next_renewal_at)
- apply_tier_change_mutates_license_and_subscription (Standard
monthly → Pro annual changes max_machines, entitlements,
expires_at, sub policy_id + listed_value + period_days)
- record_and_lookup_tier_change_round_trip
Test count: 66 (was 58; +8).
This commit is contained in:
@@ -66,6 +66,12 @@ pub struct CreatePolicyReq {
|
||||
/// Optional free-trial length at the first cycle. 0 = no trial.
|
||||
#[serde(default)]
|
||||
pub trial_days: i64,
|
||||
/// Operator-defined ladder rank for in-place tier upgrades.
|
||||
/// `None` (or omitted) leaves the policy out of any ladder —
|
||||
/// buyer-facing upgrade flows reject changes touching it.
|
||||
/// Higher rank = better tier. See TIER_UPGRADES_DESIGN.md.
|
||||
#[serde(default)]
|
||||
pub tier_rank: Option<i64>,
|
||||
}
|
||||
|
||||
fn default_max_machines() -> i64 {
|
||||
@@ -185,6 +191,17 @@ pub async fn create(
|
||||
trial_days: req.trial_days,
|
||||
};
|
||||
|
||||
// Tier-rank validation: if set, must be 0..=1000 — high enough
|
||||
// for any real ladder, low enough to keep arithmetic in i32 if
|
||||
// we ever expose a tier-rank UI dropdown.
|
||||
if let Some(r) = req.tier_rank {
|
||||
if !(0..=1000).contains(&r) {
|
||||
return Err(AppError::BadRequest(
|
||||
"tier_rank must be between 0 and 1000".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let policy = repo::create_policy(
|
||||
&state.db,
|
||||
&product.id,
|
||||
@@ -201,6 +218,7 @@ pub async fn create(
|
||||
req.tip_pct_bps,
|
||||
tip_label,
|
||||
recurring,
|
||||
req.tier_rank,
|
||||
)
|
||||
.await?;
|
||||
let _ = repo::insert_audit(
|
||||
@@ -434,6 +452,13 @@ pub struct UpdatePolicyReq {
|
||||
pub grace_period_days: Option<i64>,
|
||||
#[serde(default)]
|
||||
pub trial_days: Option<i64>,
|
||||
/// Tier-upgrade ladder rank. Outer Option = "did the patch
|
||||
/// touch this field?", inner Option = the value. Use
|
||||
/// `Some(Some(n))` to set, `Some(null)` to remove from the
|
||||
/// ladder, omit to leave alone. Mirrors `price_sats_override`'s
|
||||
/// nullable-patch pattern.
|
||||
#[serde(default, deserialize_with = "deser_double_option_i64", skip_serializing_if = "Option::is_none")]
|
||||
pub tier_rank: Option<Option<i64>>,
|
||||
}
|
||||
|
||||
fn deser_double_option_i64<'de, D>(de: D) -> Result<Option<Option<i64>>, D::Error>
|
||||
@@ -496,11 +521,23 @@ pub async fn update(
|
||||
}
|
||||
}
|
||||
|
||||
// Tier-rank: if the patch sets it, validate range. None-from-the-
|
||||
// outer-Option means "leave alone"; Some(None) means "remove from
|
||||
// ladder" and is always allowed.
|
||||
if let Some(Some(r)) = req.tier_rank {
|
||||
if !(0..=1000).contains(&r) {
|
||||
return Err(AppError::BadRequest(
|
||||
"tier_rank must be between 0 and 1000".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let recurring_update = repo::RecurringUpdate {
|
||||
is_recurring: req.is_recurring,
|
||||
renewal_period_days: req.renewal_period_days,
|
||||
grace_period_days: req.grace_period_days,
|
||||
trial_days: req.trial_days,
|
||||
tier_rank: req.tier_rank,
|
||||
};
|
||||
|
||||
let updated = repo::update_policy(
|
||||
|
||||
Reference in New Issue
Block a user