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:
Grant
2026-05-08 19:50:04 -05:00
parent 8ce78ab9d3
commit f8affdb11f
7 changed files with 1443 additions and 1 deletions
+37
View File
@@ -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(