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(
+29 -1
View File
@@ -764,6 +764,7 @@ const POLICY_COLS: &str = "id, product_id, name, slug, duration_seconds, grace_s
max_machines, is_trial, price_sats_override,
entitlements_json, metadata_json, active, public,
is_recurring, renewal_period_days, grace_period_days, trial_days,
tier_rank,
created_at, updated_at";
/// Bundles the recurring-subscription knobs so we don't keep growing
@@ -807,6 +808,7 @@ pub async fn create_policy(
tip_pct_bps: i64,
tip_label: Option<&str>,
recurring: RecurringConfig,
tier_rank: Option<i64>,
) -> AppResult<Policy> {
let id = Uuid::new_v4().to_string();
let now = Utc::now().to_rfc3339();
@@ -820,8 +822,9 @@ pub async fn create_policy(
is_trial, price_sats_override, entitlements_json, metadata_json, active, public,
tip_recipient, tip_pct_bps, tip_label,
is_recurring, renewal_period_days, grace_period_days, trial_days,
tier_rank,
created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(&id)
.bind(product_id)
@@ -841,6 +844,7 @@ pub async fn create_policy(
.bind(recurring.renewal_period_days)
.bind(recurring.grace_period_days)
.bind(recurring.trial_days)
.bind(tier_rank)
.bind(&now)
.bind(&now)
.execute(pool)
@@ -923,6 +927,11 @@ pub struct RecurringUpdate {
pub renewal_period_days: Option<i64>,
pub grace_period_days: Option<i64>,
pub trial_days: Option<i64>,
/// Tier-upgrade ladder rank. Outer Option = "did the patch touch
/// this field?", inner Option = the value (Some(n) sets a rank,
/// None removes it from the ladder). Mirrors the `price_sats_override`
/// nullable-patch pattern.
pub tier_rank: Option<Option<i64>>,
}
#[allow(clippy::too_many_arguments)]
@@ -976,6 +985,9 @@ pub async fn update_policy(
if recurring.trial_days.is_some() {
sets.push("trial_days = ?");
}
if recurring.tier_rank.is_some() {
sets.push("tier_rank = ?");
}
if sets.is_empty() {
return get_policy_by_id(pool, id)
.await?
@@ -1025,6 +1037,9 @@ pub async fn update_policy(
if let Some(v) = recurring.trial_days {
q = q.bind(v);
}
if let Some(opt_r) = recurring.tier_rank {
q = q.bind(opt_r);
}
q = q.bind(&now).bind(id);
let rows = q.execute(pool).await?.rows_affected();
if rows == 0 {
@@ -1080,6 +1095,18 @@ fn row_to_policy(row: sqlx::sqlite::SqliteRow) -> Policy {
let renewal_period_days: i64 = row.try_get("renewal_period_days").unwrap_or(0);
let grace_period_days: i64 = row.try_get("grace_period_days").unwrap_or(7);
let trial_days: i64 = row.try_get("trial_days").unwrap_or(0);
// tier_rank lands in migration 0013. The column is nullable —
// NULL = "policy not in any ladder", a valid state. We must
// ask sqlx to return Option<i64> directly so a NULL produces
// Ok(None) instead of decode-erroring out (which would also
// give us None via .ok(), but an error is the wrong signal
// for legitimate NULL data and would mask a real schema gap).
// For pre-0013 databases that lack the column, try_get errors
// with ColumnNotFound; .ok().flatten() collapses that to None.
let tier_rank: Option<i64> = row
.try_get::<Option<i64>, _>("tier_rank")
.ok()
.flatten();
Policy {
id: row.get("id"),
product_id: row.get("product_id"),
@@ -1101,6 +1128,7 @@ fn row_to_policy(row: sqlx::sqlite::SqliteRow) -> Policy {
renewal_period_days,
grace_period_days,
trial_days,
tier_rank,
created_at: row.get("created_at"),
updated_at: row.get("updated_at"),
}
+1
View File
@@ -22,6 +22,7 @@ pub mod rates;
pub mod reconcile;
pub mod subscriptions;
pub mod tipping;
pub mod upgrades;
pub mod webhooks;
/// Hex-encoded SHA-256 of a string — used everywhere we need a deterministic
+9
View File
@@ -186,6 +186,15 @@ pub struct Policy {
/// `trial_days`.
#[serde(default)]
pub trial_days: i64,
/// Operator-defined ladder ordering for in-place tier upgrades
/// (migration 0013). Higher rank = better tier. Per-product space:
/// "free" → 0, "standard" → 1, "pro" → 2, "patron" → 3 etc.
/// `None` means the policy isn't part of any ladder — buyer-facing
/// upgrade endpoints reject changes that touch a NULL-rank policy
/// on either side. Admin endpoints can force-change to/from any
/// policy. See TIER_UPGRADES_DESIGN.md for the full semantics.
#[serde(default)]
pub tier_rank: Option<i64>,
pub created_at: String,
pub updated_at: String,
}
+575
View File
@@ -0,0 +1,575 @@
//! In-place tier upgrades / downgrades.
//!
//! Companion to migration 0013 (schema) and TIER_UPGRADES_DESIGN.md
//! at the repo root. This module owns:
//!
//! 1. **Quote logic** — given a license + target policy, what
//! does the buyer owe right now, and when do new entitlements
//! take effect? Branches on perpetual (flat price diff) vs
//! recurring (prorated against time-remaining-in-cycle).
//! 2. **Apply step** — when a tier-change invoice settles (or
//! an admin force-changes), mutate the license row's policy_id
//! + entitlements + expiry + max_machines, mutate the
//! subscription's listed_value (so future cycles bill the new
//! tier), insert the `tier_changes` audit row.
//!
//! Phase 3 wires these into HTTP endpoints (`POST /v1/upgrade-quote`,
//! `POST /v1/upgrade`, `POST /v1/admin/licenses/:id/change-tier`).
//! Phase 2 (this file) is pure logic — no router changes, fully
//! exercised by integration tests under `tests/upgrades.rs`.
//!
//! Pricing primitive: every quote is computed in the LISTED
//! currency (whatever `product.price_currency` is — SAT, USD, EUR).
//! At purchase time the upgrade endpoint converts to sats via
//! `crate::rates::convert_to_sats`, exactly like the existing
//! purchase + renewal paths. Quotes are always in the same currency
//! the buyer originally paid, which is what they expect.
use crate::api::AppState;
use crate::db::repo;
use crate::error::{AppError, AppResult};
use crate::models::{License, Policy, Product};
use anyhow::{anyhow, Context, Result};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use serde_json::json;
use sqlx::SqlitePool;
use uuid::Uuid;
/// What an upgrade or downgrade will cost the buyer right now,
/// what currency it's quoted in, and when the new entitlements
/// take effect. Returned by `compute_upgrade_quote`; consumed by
/// the (Phase 3) HTTP endpoint, which serializes it to JSON for
/// the buyer-app frontend.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct UpgradeQuote {
pub from_policy_id: String,
pub from_policy_slug: String,
pub to_policy_id: String,
pub to_policy_slug: String,
/// 'upgrade' | 'downgrade'.
pub direction: TierDirection,
/// 'SAT' | 'USD' | 'EUR' — same currency the product is priced in.
pub listed_currency: String,
/// Smallest unit of `listed_currency` (sats for SAT, cents for fiat).
/// 0 for downgrades on recurring subs (they take effect at next
/// cycle, no charge today) and for free→paid first-cycle changes
/// where the charge is the full new price (we still set this
/// to that amount; only zero-charge case is comp/admin or downgrade).
pub proration_charge_value: i64,
/// Effective time of the new entitlements:
/// - upgrade on perpetual / recurring: "immediate" on settle.
/// - downgrade on recurring: end of current cycle (RFC3339 UTC).
/// - downgrade on perpetual: rejected (admin must force).
pub effective_at: EffectiveAt,
/// What the next renewal cycle will charge, in the listed currency
/// smallest unit. `None` for perpetual (no next cycle).
pub next_renewal_charge: Option<i64>,
/// Recurring period of the target. None for perpetual.
pub next_renewal_period_days: Option<i64>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum TierDirection {
Upgrade,
Downgrade,
}
impl TierDirection {
pub fn as_str(&self) -> &'static str {
match self {
TierDirection::Upgrade => "upgrade",
TierDirection::Downgrade => "downgrade",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum EffectiveAt {
/// Applies on payment settle (or immediately for comp/admin changes).
Immediate,
/// RFC3339 UTC timestamp — typically end of current cycle for
/// recurring downgrades.
At(String),
}
/// Compute a buyer-facing tier change quote. Enforces the
/// ladder rules: both policies must have non-NULL `tier_rank`,
/// target must be different from current, direction must match
/// the rank delta. Admin force-changes use the lower-level
/// `apply_tier_change` directly and are not subject to these
/// checks (Phase 4 admin endpoint covers that path).
pub async fn compute_upgrade_quote(
state: &AppState,
license: &License,
target_policy: &Policy,
) -> AppResult<UpgradeQuote> {
// 1. Resolve current policy from the license. License rows can
// legitimately have policy_id=NULL (legacy issuance / manual
// comp), in which case the buyer can't self-upgrade — they
// have no tier to upgrade FROM. Admin can force.
let current_policy_id = license
.policy_id
.as_deref()
.ok_or_else(|| AppError::BadRequest(
"license has no policy attached — admin must assign a tier first".into()
))?;
let current_policy = repo::get_policy_by_id(&state.db, current_policy_id)
.await?
.ok_or_else(|| AppError::NotFound(format!("license's current policy '{current_policy_id}'")))?;
// 2. Same-policy is a noop, not an error per se, but rejecting
// keeps the API contract clean (the endpoint should return
// 400 rather than a $0 quote for an identity change).
if current_policy.id == target_policy.id {
return Err(AppError::BadRequest(
"target policy is the same as current — no change to make".into(),
));
}
if current_policy.product_id != target_policy.product_id {
return Err(AppError::BadRequest(
"target policy belongs to a different product — cross-product changes not supported".into(),
));
}
if !target_policy.active {
return Err(AppError::BadRequest(
"target policy is inactive".into(),
));
}
// 3. Ladder rules: both policies must be in the ladder
// (non-NULL tier_rank), and target must differ in rank.
let from_rank = current_policy.tier_rank.ok_or_else(|| {
AppError::BadRequest(
"current policy is not in any tier ladder — admin must set tier_rank first".into(),
)
})?;
let to_rank = target_policy.tier_rank.ok_or_else(|| {
AppError::BadRequest(
"target policy is not in any tier ladder — admin must set tier_rank first".into(),
)
})?;
let direction = match to_rank.cmp(&from_rank) {
std::cmp::Ordering::Greater => TierDirection::Upgrade,
std::cmp::Ordering::Less => TierDirection::Downgrade,
std::cmp::Ordering::Equal => {
return Err(AppError::BadRequest(
"sideways tier changes (same rank) are admin-only".into(),
));
}
};
// 4. Look up the product so we can read price_currency and
// fall back to product.price_value when a policy doesn't
// set its own override.
let product = repo::get_product_by_id(&state.db, &current_policy.product_id)
.await?
.ok_or_else(|| AppError::NotFound(format!("product '{}'", current_policy.product_id)))?;
let listed_currency = product.price_currency.clone();
// 5. Effective listed price for each policy.
let from_listed = effective_listed_value(&current_policy, &product);
let to_listed = effective_listed_value(target_policy, &product);
// 6. Branch on perpetual vs recurring (driven by the TARGET
// policy — the buyer is choosing what kind of license they
// want to be on going forward).
let sub = crate::subscriptions::get_subscription_by_license_id(&state.db, &license.id)
.await
.map_err(AppError::Internal)?;
if target_policy.is_recurring {
compute_recurring_quote(
sub.as_ref(),
&current_policy,
target_policy,
&product,
from_listed,
to_listed,
direction,
)
} else {
// Target is perpetual. Downgrade from recurring to perpetual
// is technically a topology change; we treat it as admin-only.
if direction == TierDirection::Downgrade && sub.is_some() {
return Err(AppError::BadRequest(
"downgrading from a recurring subscription to a perpetual policy is admin-only".into(),
));
}
// Perpetual downgrades by buyer also rejected — no proration
// model that doesn't bake in a refund decision (operator's call).
if direction == TierDirection::Downgrade {
return Err(AppError::BadRequest(
"perpetual downgrades are admin-only — they imply a refund decision".into(),
));
}
compute_perpetual_quote(
&current_policy,
target_policy,
&listed_currency,
from_listed,
to_listed,
direction,
)
}
}
/// Per-policy listed price, with fallback to the product's base
/// price when the policy's override is NULL. Always in the
/// product's listed currency's smallest unit.
fn effective_listed_value(policy: &Policy, product: &Product) -> i64 {
policy.price_sats_override.unwrap_or(product.price_value)
}
fn compute_perpetual_quote(
from: &Policy,
to: &Policy,
listed_currency: &str,
from_listed: i64,
to_listed: i64,
direction: TierDirection,
) -> AppResult<UpgradeQuote> {
// Perpetual upgrade: flat difference. No proration (no cycle
// to prorate against). max(0) defends against an unusual case
// where the operator priced an upgrade lower than the current
// tier — should not happen with correct tier_rank, but the
// buyer should never owe a negative amount.
let charge = (to_listed - from_listed).max(0);
Ok(UpgradeQuote {
from_policy_id: from.id.clone(),
from_policy_slug: from.slug.clone(),
to_policy_id: to.id.clone(),
to_policy_slug: to.slug.clone(),
direction,
listed_currency: listed_currency.to_string(),
proration_charge_value: charge,
effective_at: EffectiveAt::Immediate,
// Perpetual has no next-cycle charge to surface.
next_renewal_charge: None,
next_renewal_period_days: None,
})
}
fn compute_recurring_quote(
sub: Option<&crate::subscriptions::Subscription>,
from: &Policy,
to: &Policy,
product: &Product,
from_listed: i64,
to_listed: i64,
direction: TierDirection,
) -> AppResult<UpgradeQuote> {
let listed_currency = product.price_currency.clone();
let target_period = to.renewal_period_days.max(1); // CHECK at API layer; clamp defensively
match direction {
TierDirection::Upgrade => {
let charge = if let Some(sub) = sub {
// Buyer is currently on a recurring sub. Prorate the
// difference against time-remaining in the current cycle.
let days_remaining = days_remaining_in_cycle(sub).unwrap_or(0);
let period = sub.period_days.max(1);
// Use i128 for the multiply to avoid overflow on
// (price_diff * days_remaining) for high-precision fiat.
let diff = (to_listed as i128) - (from_listed as i128);
let prorated = diff * (days_remaining as i128) / (period as i128);
prorated.max(0).min(i64::MAX as i128) as i64
} else {
// No active subscription (probably perpetual or
// free-tier trial) upgrading TO a recurring tier.
// Charge the full first-cycle price; the renewal
// worker will handle subsequent cycles.
to_listed.max(0)
};
Ok(UpgradeQuote {
from_policy_id: from.id.clone(),
from_policy_slug: from.slug.clone(),
to_policy_id: to.id.clone(),
to_policy_slug: to.slug.clone(),
direction,
listed_currency,
proration_charge_value: charge,
effective_at: EffectiveAt::Immediate,
next_renewal_charge: Some(to_listed),
next_renewal_period_days: Some(target_period),
})
}
TierDirection::Downgrade => {
// Recurring downgrade: no charge today. New tier kicks
// in at next_renewal_at; buyer keeps full current-tier
// entitlements through end of cycle. If there's no sub
// (shouldn't happen — current policy was recurring), fall
// back to immediate to avoid a stuck quote.
let effective_at = match sub.and_then(|s| s.next_renewal_at.clone()) {
Some(next) => EffectiveAt::At(next),
None => EffectiveAt::Immediate,
};
Ok(UpgradeQuote {
from_policy_id: from.id.clone(),
from_policy_slug: from.slug.clone(),
to_policy_id: to.id.clone(),
to_policy_slug: to.slug.clone(),
direction,
listed_currency,
proration_charge_value: 0,
effective_at,
next_renewal_charge: Some(to_listed),
next_renewal_period_days: Some(target_period),
})
}
}
}
/// Days from now until the sub's next_renewal_at. Returns None if
/// the sub has no scheduled renewal (cancelled). Floored at 0 so
/// past-due subs don't produce negative proration.
fn days_remaining_in_cycle(sub: &crate::subscriptions::Subscription) -> Option<i64> {
let next = sub.next_renewal_at.as_deref()?;
let next_dt = DateTime::parse_from_rfc3339(next).ok()?.with_timezone(&Utc);
let now = Utc::now();
let dur = next_dt.signed_duration_since(now);
let days = dur.num_days().max(0);
// Cap at period_days so a clock-skew or test fixture can't
// produce a quote larger than a full cycle's price diff.
Some(days.min(sub.period_days))
}
/// Persist a tier_changes audit row. Called from both the buyer
/// settle path and the admin force-change path. invoice_id is
/// nullable for comp / 0-charge changes.
#[allow(clippy::too_many_arguments)]
pub async fn record_tier_change(
pool: &SqlitePool,
license_id: &str,
from_policy_id: &str,
to_policy_id: &str,
direction: TierDirection,
listed_currency: &str,
proration_charge_value: i64,
invoice_id: Option<&str>,
effective_at: &str,
actor: &str, // 'buyer' | 'admin'
reason: Option<&str>,
) -> Result<String> {
if !["buyer", "admin"].contains(&actor) {
return Err(anyhow!("actor must be 'buyer' or 'admin', got '{actor}'"));
}
let id = Uuid::new_v4().to_string();
let now = Utc::now().to_rfc3339();
sqlx::query(
"INSERT INTO tier_changes(id, license_id, from_policy_id, to_policy_id, \
direction, listed_currency, proration_charge_value, invoice_id, \
effective_at, actor, reason, created_at) \
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(&id)
.bind(license_id)
.bind(from_policy_id)
.bind(to_policy_id)
.bind(direction.as_str())
.bind(listed_currency)
.bind(proration_charge_value)
.bind(invoice_id)
.bind(effective_at)
.bind(actor)
.bind(reason)
.bind(&now)
.execute(pool)
.await
.context("INSERT tier_changes")?;
Ok(id)
}
/// Apply a tier change: update the license row's policy_id +
/// entitlements + expires_at + max_machines + grace_seconds to
/// match the new policy, and (if a subscription exists) update
/// the sub's policy_id + listed_value + period_days. Caller is
/// responsible for `record_tier_change` separately so the audit
/// log captures the move.
///
/// This does NOT issue a new license key — the existing
/// `license_id` and signed-payload-key are kept; on next online
/// validation the buyer's app sees the new entitlements via
/// `/v1/validate`'s response. Per design doc: "matches buyers'
/// mental model — my license now does more."
pub async fn apply_tier_change(
pool: &SqlitePool,
license_id: &str,
target_policy: &Policy,
product: &Product,
) -> Result<()> {
let now = Utc::now();
let now_str = now.to_rfc3339();
let entitlements_json =
serde_json::to_string(&target_policy.entitlements).unwrap_or_else(|_| "[]".into());
// Compute new expires_at based on target.duration_seconds.
// 0 = perpetual; license.expires_at NULL.
let expires_at = if target_policy.duration_seconds > 0 {
Some((now + chrono::Duration::seconds(target_policy.duration_seconds)).to_rfc3339())
} else {
None
};
sqlx::query(
"UPDATE licenses SET \
policy_id = ?, entitlements_json = ?, expires_at = ?, \
max_machines = ?, grace_seconds = ?, is_trial = ? \
WHERE id = ?",
)
.bind(&target_policy.id)
.bind(&entitlements_json)
.bind(expires_at.as_deref())
.bind(target_policy.max_machines)
.bind(target_policy.grace_seconds)
.bind(target_policy.is_trial as i64)
.bind(license_id)
.execute(pool)
.await
.context("UPDATE licenses for tier change")?;
// If there's an active subscription for this license, update
// its policy_id and listed_value so future renewals bill the
// new tier. period_days also updates if the cadence changed
// (e.g. monthly → annual).
let sub = crate::subscriptions::get_subscription_by_license_id(pool, license_id)
.await
.context("fetch sub for tier-change apply")?;
if let Some(sub) = sub {
if target_policy.is_recurring {
// Stay on a recurring sub at the new tier.
let new_listed_value = effective_listed_value(target_policy, product);
let new_period = target_policy.renewal_period_days.max(1);
sqlx::query(
"UPDATE subscriptions SET \
policy_id = ?, listed_value = ?, period_days = ?, \
updated_at = ? \
WHERE id = ?",
)
.bind(&target_policy.id)
.bind(new_listed_value)
.bind(new_period)
.bind(&now_str)
.bind(&sub.id)
.execute(pool)
.await
.context("UPDATE subscriptions for tier change")?;
} else {
// Target is perpetual — the subscription has no role
// anymore. Cancel it so the renewal worker doesn't
// pick it up. The license itself stays valid (we
// just updated expires_at above).
sqlx::query(
"UPDATE subscriptions SET \
status = 'cancelled', cancelled_at = ?, updated_at = ? \
WHERE id = ?",
)
.bind(&now_str)
.bind(&now_str)
.bind(&sub.id)
.execute(pool)
.await
.context("cancel sub on perpetual tier-change apply")?;
}
}
// Audit row in the existing audit_log. tier_changes is a
// separate table that captures the upgrade-specific fields
// (proration, invoice_id, etc.); audit_log is the generic
// "what happened" stream. record_tier_change handles the
// former; the latter is the caller's job (admin vs webhook
// path each have their own actor + actor_hash semantics).
Ok(())
}
/// Look up a tier_changes row by id. Used by Phase 4 admin
/// endpoints to surface change history.
pub async fn get_tier_change(
pool: &SqlitePool,
id: &str,
) -> Result<Option<TierChangeRow>> {
let row = sqlx::query_as::<_, TierChangeRow>(
"SELECT id, license_id, from_policy_id, to_policy_id, direction, \
listed_currency, proration_charge_value, invoice_id, \
effective_at, actor, reason, created_at \
FROM tier_changes WHERE id = ?",
)
.bind(id)
.fetch_optional(pool)
.await
.context("SELECT tier_changes")?;
Ok(row)
}
/// Ordered history of tier changes for a license. Newest first.
pub async fn list_tier_changes_for_license(
pool: &SqlitePool,
license_id: &str,
) -> Result<Vec<TierChangeRow>> {
let rows = sqlx::query_as::<_, TierChangeRow>(
"SELECT id, license_id, from_policy_id, to_policy_id, direction, \
listed_currency, proration_charge_value, invoice_id, \
effective_at, actor, reason, created_at \
FROM tier_changes WHERE license_id = ? \
ORDER BY created_at DESC",
)
.bind(license_id)
.fetch_all(pool)
.await
.context("list tier_changes for license")?;
Ok(rows)
}
/// Look up an in-flight tier-change by its invoice_id. Used by the
/// (Phase 3) webhook handler to decide on settle whether the
/// settling invoice is a tier-change vs a normal purchase or
/// subscription renewal.
pub async fn get_tier_change_by_invoice(
pool: &SqlitePool,
invoice_id: &str,
) -> Result<Option<TierChangeRow>> {
let row = sqlx::query_as::<_, TierChangeRow>(
"SELECT id, license_id, from_policy_id, to_policy_id, direction, \
listed_currency, proration_charge_value, invoice_id, \
effective_at, actor, reason, created_at \
FROM tier_changes WHERE invoice_id = ?",
)
.bind(invoice_id)
.fetch_optional(pool)
.await
.context("SELECT tier_changes by invoice")?;
Ok(row)
}
#[derive(Debug, Clone, sqlx::FromRow, Serialize, Deserialize)]
pub struct TierChangeRow {
pub id: String,
pub license_id: String,
pub from_policy_id: String,
pub to_policy_id: String,
pub direction: String,
pub listed_currency: String,
pub proration_charge_value: i64,
pub invoice_id: Option<String>,
pub effective_at: String,
pub actor: String,
pub reason: Option<String>,
pub created_at: String,
}
// Suppress dead-code warnings on the audit-payload helper until
// Phase 3 wires it into the webhook + admin endpoints.
#[allow(dead_code)]
fn _audit_payload(quote: &UpgradeQuote) -> serde_json::Value {
json!({
"from_policy_id": quote.from_policy_id,
"from_policy_slug": quote.from_policy_slug,
"to_policy_id": quote.to_policy_id,
"to_policy_slug": quote.to_policy_slug,
"direction": quote.direction.as_str(),
"listed_currency": quote.listed_currency,
"proration_charge_value": quote.proration_charge_value,
})
}