diff --git a/licensing-service/src/api/policies.rs b/licensing-service/src/api/policies.rs index 015af36..86eb07e 100644 --- a/licensing-service/src/api/policies.rs +++ b/licensing-service/src/api/policies.rs @@ -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, } 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, #[serde(default)] pub trial_days: Option, + /// 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>, } fn deser_double_option_i64<'de, D>(de: D) -> Result>, 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( diff --git a/licensing-service/src/db/repo.rs b/licensing-service/src/db/repo.rs index 03de5ab..829ed1a 100644 --- a/licensing-service/src/db/repo.rs +++ b/licensing-service/src/db/repo.rs @@ -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, ) -> AppResult { 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, pub grace_period_days: Option, pub trial_days: Option, + /// 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>, } #[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 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 = row + .try_get::, _>("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"), } diff --git a/licensing-service/src/lib.rs b/licensing-service/src/lib.rs index 249e425..47372b7 100644 --- a/licensing-service/src/lib.rs +++ b/licensing-service/src/lib.rs @@ -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 diff --git a/licensing-service/src/models.rs b/licensing-service/src/models.rs index 8845dd4..1a980a4 100644 --- a/licensing-service/src/models.rs +++ b/licensing-service/src/models.rs @@ -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, pub created_at: String, pub updated_at: String, } diff --git a/licensing-service/src/upgrades.rs b/licensing-service/src/upgrades.rs new file mode 100644 index 0000000..1271d2b --- /dev/null +++ b/licensing-service/src/upgrades.rs @@ -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, + /// Recurring period of the target. None for perpetual. + pub next_renewal_period_days: Option, +} + +#[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 { + // 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, ¤t_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(¤t_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(), + ¤t_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( + ¤t_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 { + // 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 { + 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 { + 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 { + 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> { + 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> { + 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> { + 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, + pub effective_at: String, + pub actor: String, + pub reason: Option, + 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, + }) +} diff --git a/licensing-service/tests/api.rs b/licensing-service/tests/api.rs index 7cc1a4f..66aef82 100644 --- a/licensing-service/tests/api.rs +++ b/licensing-service/tests/api.rs @@ -2028,6 +2028,7 @@ async fn edit_policy_to_recurring_respects_tier_gate() { 0, None, repo::RecurringConfig::off(), + None, ) .await .expect("create_policy"); @@ -2137,6 +2138,7 @@ async fn seed_subscription(state: &AppState) -> (String, String, String) { grace_period_days: 7, trial_days: 0, }, + None, ) .await .expect("create_policy"); diff --git a/licensing-service/tests/upgrades.rs b/licensing-service/tests/upgrades.rs new file mode 100644 index 0000000..5a302c1 --- /dev/null +++ b/licensing-service/tests/upgrades.rs @@ -0,0 +1,790 @@ +//! Integration tests for tier upgrades — the quote logic + apply +//! step that lives in `src/upgrades.rs`. Phase 2 of +//! TIER_UPGRADES_DESIGN.md. No HTTP layer yet (Phase 3); these +//! tests exercise the pure module API. + +use anyhow::Result; +use chrono::Utc; +use keysat::api::AppState; +use keysat::config::Config; +use keysat::db::repo; +use keysat::license_self::Tier; +use keysat::upgrades::{ + apply_tier_change, compute_upgrade_quote, list_tier_changes_for_license, + record_tier_change, EffectiveAt, TierDirection, +}; +use serde_json::json; +use sqlx::sqlite::{ + SqliteConnectOptions, SqliteJournalMode, SqlitePool, SqlitePoolOptions, SqliteSynchronous, +}; +use std::path::PathBuf; +use std::str::FromStr; +use std::sync::Arc; +use std::time::Duration; +use tempfile::NamedTempFile; +use tokio::sync::RwLock; +use uuid::Uuid; + +const TEST_ADMIN_KEY: &str = "test_admin_api_key_with_at_least_32_chars_present"; + +async fn make_state() -> (AppState, NamedTempFile) { + let tmp = NamedTempFile::new().expect("tempfile"); + let url = format!("sqlite://{}", tmp.path().display()); + let opts = SqliteConnectOptions::from_str(&url) + .expect("parse url") + .create_if_missing(true) + .journal_mode(SqliteJournalMode::Wal) + .synchronous(SqliteSynchronous::Normal) + .foreign_keys(true) + .busy_timeout(Duration::from_secs(5)); + let pool = SqlitePoolOptions::new() + .max_connections(2) + .connect_with(opts) + .await + .expect("connect"); + sqlx::migrate!("./migrations") + .run(&pool) + .await + .expect("migrations"); + let keypair = keysat::crypto::keys::load_or_generate(&pool) + .await + .expect("keypair"); + let cfg = Config { + bind: "127.0.0.1:0".parse().unwrap(), + db_path: PathBuf::from(":memory:"), + admin_api_key: TEST_ADMIN_KEY.to_string(), + btcpay_url: "http://btcpay.test".to_string(), + btcpay_browser_url: None, + btcpay_public_url: None, + btcpay_api_key: None, + btcpay_store_id: None, + btcpay_webhook_secret: None, + public_base_url: "http://keysat.test".to_string(), + operator_name: None, + }; + let state = AppState { + db: pool, + keypair: Arc::new(keypair), + payment: Arc::new(RwLock::new(None)), + config: Arc::new(cfg), + self_tier: Arc::new(RwLock::new(Tier::Unlicensed { + reason: "test".into(), + })), + rates: keysat::rates::RateCache::new(), + }; + (state, tmp) +} + +/// Seed a USD-priced product, two perpetual policies (Standard +/// rank 1 / $25, Pro rank 2 / $75), a license currently on +/// Standard. Returns (license_id, standard_policy_id, pro_policy_id). +async fn seed_perpetual_ladder(state: &AppState) -> (String, String, String) { + let product = repo::create_product( + &state.db, + "perp-ladder", + "Perpetual Ladder", + "", + 25_00, // $25.00 (cents); price_sats backfill from product create + &json!({}), + ) + .await + .expect("create_product"); + // Update product to USD currency. create_product hits the SAT + // default; bump it via a direct SQL UPDATE so the test setup + // doesn't require going through the multi-currency admin path. + sqlx::query( + "UPDATE products SET price_currency = 'USD', price_value = 2500 WHERE id = ?", + ) + .bind(&product.id) + .execute(&state.db) + .await + .unwrap(); + + // Standard tier: $25 perpetual, rank 1. + let standard = repo::create_policy( + &state.db, + &product.id, + "Standard", + "standard", + 0, // perpetual + 0, + 1, + false, + Some(2500), // $25.00 in cents + &["core".into()], + &json!({}), + None, + 0, + None, + repo::RecurringConfig::off(), + Some(1), + ) + .await + .expect("create standard"); + + // Pro tier: $75 perpetual, rank 2, more entitlements. + let pro = repo::create_policy( + &state.db, + &product.id, + "Pro", + "pro", + 0, + 0, + 3, + false, + Some(7500), // $75.00 in cents + &["core".into(), "ai_summaries".into(), "export".into()], + &json!({}), + None, + 0, + None, + repo::RecurringConfig::off(), + Some(2), + ) + .await + .expect("create pro"); + + // Issue a license under Standard. + let license_id = Uuid::new_v4().to_string(); + repo::create_license( + &state.db, + &license_id, + &product.id, + None, + &Utc::now().to_rfc3339(), + &json!({}), + Some(&standard.id), + None, // perpetual + 0, + 1, + &["core".to_string()], + false, + None, + None, + ) + .await + .expect("create_license"); + + (license_id, standard.id, pro.id) +} + +#[tokio::test] +async fn perpetual_upgrade_quote_returns_flat_price_difference() { + let (state, _tmp) = make_state().await; + let (license_id, _standard_id, pro_id) = seed_perpetual_ladder(&state).await; + + let license = repo::get_license_by_id(&state.db, &license_id) + .await + .unwrap() + .unwrap(); + let pro = repo::get_policy_by_id(&state.db, &pro_id).await.unwrap().unwrap(); + + let quote = compute_upgrade_quote(&state, &license, &pro).await.unwrap(); + + assert_eq!(quote.direction, TierDirection::Upgrade); + assert_eq!(quote.listed_currency, "USD"); + // Pro $75 - Standard $25 = $50 = 5000 cents. + assert_eq!(quote.proration_charge_value, 5000); + assert_eq!(quote.effective_at, EffectiveAt::Immediate); + // Perpetual: no next-cycle charge. + assert_eq!(quote.next_renewal_charge, None); + assert_eq!(quote.next_renewal_period_days, None); +} + +#[tokio::test] +async fn perpetual_downgrade_is_admin_only() { + let (state, _tmp) = make_state().await; + let (_lic, standard_id, pro_id) = seed_perpetual_ladder(&state).await; + + // Re-issue a license, but on Pro this time, so we can attempt + // a Pro → Standard downgrade (which should be rejected). + let license_id = Uuid::new_v4().to_string(); + let now = Utc::now().to_rfc3339(); + let pro = repo::get_policy_by_id(&state.db, &pro_id).await.unwrap().unwrap(); + repo::create_license( + &state.db, + &license_id, + &pro.product_id, + None, + &now, + &json!({}), + Some(&pro.id), + None, + 0, + 3, + &["core".to_string()], + false, + None, + None, + ) + .await + .unwrap(); + + let license = repo::get_license_by_id(&state.db, &license_id) + .await + .unwrap() + .unwrap(); + let standard = repo::get_policy_by_id(&state.db, &standard_id) + .await + .unwrap() + .unwrap(); + + let err = compute_upgrade_quote(&state, &license, &standard) + .await + .expect_err("perpetual downgrade should be rejected"); + let msg = format!("{err}"); + assert!( + msg.contains("admin-only"), + "perpetual downgrade error must mention admin-only path: {msg}" + ); +} + +#[tokio::test] +async fn quote_rejects_target_with_null_tier_rank() { + let (state, _tmp) = make_state().await; + let (license_id, _, _) = seed_perpetual_ladder(&state).await; + + // Make a target policy that DELIBERATELY has tier_rank = NULL. + let license = repo::get_license_by_id(&state.db, &license_id) + .await + .unwrap() + .unwrap(); + let unlisted = repo::create_policy( + &state.db, + &license.product_id, + "Promo", + "promo", + 0, + 0, + 1, + false, + Some(5000), + &["core".into()], + &json!({}), + None, + 0, + None, + repo::RecurringConfig::off(), + None, // out of ladder + ) + .await + .unwrap(); + + let err = compute_upgrade_quote(&state, &license, &unlisted) + .await + .expect_err("unlisted target should be rejected"); + let msg = format!("{err}"); + assert!( + msg.contains("not in any tier ladder"), + "expected ladder rejection; got: {msg}" + ); +} + +#[tokio::test] +async fn quote_rejects_same_policy() { + let (state, _tmp) = make_state().await; + let (license_id, standard_id, _) = seed_perpetual_ladder(&state).await; + + let license = repo::get_license_by_id(&state.db, &license_id) + .await + .unwrap() + .unwrap(); + let same = repo::get_policy_by_id(&state.db, &standard_id) + .await + .unwrap() + .unwrap(); + let err = compute_upgrade_quote(&state, &license, &same) + .await + .expect_err("same-policy target should be rejected"); + assert!(format!("{err}").contains("same as current")); +} + +/// Recurring upgrade with the buyer halfway through a 30-day cycle. +/// The quote should bill ~half of the price diff. We assert a +/// tolerance window since "now" depends on test execution time. +#[tokio::test] +async fn recurring_upgrade_prorates_against_time_remaining() { + let (state, _tmp) = make_state().await; + let now = Utc::now(); + let now_str = now.to_rfc3339(); + + // USD-priced product. + let product = repo::create_product( + &state.db, + "rec-ladder", + "Recurring Ladder", + "", + 2500, + &json!({}), + ) + .await + .unwrap(); + sqlx::query( + "UPDATE products SET price_currency = 'USD', price_value = 2500 WHERE id = ?", + ) + .bind(&product.id) + .execute(&state.db) + .await + .unwrap(); + + // Standard $25/mo monthly recurring, rank 1. + let standard = repo::create_policy( + &state.db, + &product.id, + "Standard", + "standard", + 30 * 86_400, + 0, + 1, + false, + Some(2500), + &["core".into()], + &json!({}), + None, + 0, + None, + repo::RecurringConfig { + is_recurring: true, + renewal_period_days: 30, + grace_period_days: 7, + trial_days: 0, + }, + Some(1), + ) + .await + .unwrap(); + + // Pro $75/mo monthly recurring, rank 2. + let pro = repo::create_policy( + &state.db, + &product.id, + "Pro", + "pro", + 30 * 86_400, + 0, + 3, + false, + Some(7500), + &["core".into(), "ai_summaries".into()], + &json!({}), + None, + 0, + None, + repo::RecurringConfig { + is_recurring: true, + renewal_period_days: 30, + grace_period_days: 7, + trial_days: 0, + }, + Some(2), + ) + .await + .unwrap(); + + // License + active subscription on Standard, ~15 days into a + // 30-day cycle. + let license_id = Uuid::new_v4().to_string(); + repo::create_license( + &state.db, + &license_id, + &product.id, + None, + &now_str, + &json!({}), + Some(&standard.id), + Some(&(now + chrono::Duration::days(30)).to_rfc3339()), + 0, + 1, + &["core".to_string()], + false, + None, + None, + ) + .await + .unwrap(); + + let next_renewal = (now + chrono::Duration::days(15)).to_rfc3339(); + sqlx::query( + "INSERT INTO subscriptions(id, license_id, policy_id, product_id, period_days, \ + listed_currency, listed_value, status, started_at, next_renewal_at, \ + consecutive_failures, created_at, updated_at) \ + VALUES('sub1', ?, ?, ?, 30, 'USD', 2500, 'active', ?, ?, 0, ?, ?)", + ) + .bind(&license_id) + .bind(&standard.id) + .bind(&product.id) + .bind(&now_str) + .bind(&next_renewal) + .bind(&now_str) + .bind(&now_str) + .execute(&state.db) + .await + .unwrap(); + + let license = repo::get_license_by_id(&state.db, &license_id) + .await + .unwrap() + .unwrap(); + let quote = compute_upgrade_quote(&state, &license, &pro).await.unwrap(); + + assert_eq!(quote.direction, TierDirection::Upgrade); + assert_eq!(quote.listed_currency, "USD"); + assert_eq!(quote.next_renewal_charge, Some(7500)); + assert_eq!(quote.next_renewal_period_days, Some(30)); + assert_eq!(quote.effective_at, EffectiveAt::Immediate); + + // Diff is $50 (5000 cents). 15 days remaining out of 30, so + // ~$25 (2500 cents). num_days() floors, so we expect 14 or 15 + // days remaining depending on test-execution timing. Tolerance + // window: 2300..=2600. + assert!( + (2300..=2600).contains("e.proration_charge_value), + "proration should be ~half of $50 diff for ~15 days remaining; got {}", + quote.proration_charge_value + ); +} + +#[tokio::test] +async fn recurring_downgrade_is_zero_charge_at_next_cycle() { + let (state, _tmp) = make_state().await; + let now = Utc::now(); + let now_str = now.to_rfc3339(); + + let product = repo::create_product( + &state.db, + "rec-down", + "Down", + "", + 2500, + &json!({}), + ) + .await + .unwrap(); + sqlx::query( + "UPDATE products SET price_currency = 'USD', price_value = 2500 WHERE id = ?", + ) + .bind(&product.id) + .execute(&state.db) + .await + .unwrap(); + + let standard = repo::create_policy( + &state.db, + &product.id, + "Standard", + "standard", + 30 * 86_400, + 0, + 1, + false, + Some(2500), + &["core".into()], + &json!({}), + None, + 0, + None, + repo::RecurringConfig { + is_recurring: true, + renewal_period_days: 30, + grace_period_days: 7, + trial_days: 0, + }, + Some(1), + ) + .await + .unwrap(); + let pro = repo::create_policy( + &state.db, + &product.id, + "Pro", + "pro", + 30 * 86_400, + 0, + 3, + false, + Some(7500), + &["core".into(), "ai_summaries".into()], + &json!({}), + None, + 0, + None, + repo::RecurringConfig { + is_recurring: true, + renewal_period_days: 30, + grace_period_days: 7, + trial_days: 0, + }, + Some(2), + ) + .await + .unwrap(); + + // License on Pro, with a sub. Buyer wants to downgrade to Standard. + let license_id = Uuid::new_v4().to_string(); + repo::create_license( + &state.db, + &license_id, + &product.id, + None, + &now_str, + &json!({}), + Some(&pro.id), + Some(&(now + chrono::Duration::days(30)).to_rfc3339()), + 0, + 3, + &["core".to_string()], + false, + None, + None, + ) + .await + .unwrap(); + let next_renewal = (now + chrono::Duration::days(20)).to_rfc3339(); + sqlx::query( + "INSERT INTO subscriptions(id, license_id, policy_id, product_id, period_days, \ + listed_currency, listed_value, status, started_at, next_renewal_at, \ + consecutive_failures, created_at, updated_at) \ + VALUES('sub2', ?, ?, ?, 30, 'USD', 7500, 'active', ?, ?, 0, ?, ?)", + ) + .bind(&license_id) + .bind(&pro.id) + .bind(&product.id) + .bind(&now_str) + .bind(&next_renewal) + .bind(&now_str) + .bind(&now_str) + .execute(&state.db) + .await + .unwrap(); + + let license = repo::get_license_by_id(&state.db, &license_id) + .await + .unwrap() + .unwrap(); + let quote = compute_upgrade_quote(&state, &license, &standard).await.unwrap(); + + assert_eq!(quote.direction, TierDirection::Downgrade); + assert_eq!(quote.proration_charge_value, 0, + "recurring downgrade should be zero-charge today"); + // Effective at next renewal — full Pro through current cycle. + match quote.effective_at { + EffectiveAt::At(ref s) => assert_eq!(s, &next_renewal), + EffectiveAt::Immediate => panic!("recurring downgrade should defer to next cycle"), + } + assert_eq!(quote.next_renewal_charge, Some(2500)); +} + +/// apply_tier_change must update licenses (policy_id + +/// entitlements + max_machines + grace + expires_at) and, if a +/// recurring sub exists, the sub's policy_id + listed_value + +/// period_days. +#[tokio::test] +async fn apply_tier_change_mutates_license_and_subscription() { + let (state, _tmp) = make_state().await; + let now = Utc::now(); + let now_str = now.to_rfc3339(); + + // Build a USD product + Standard/Pro recurring policies + a + // license + sub on Standard (basically the same scaffolding as + // the recurring-upgrade quote test). + let product = repo::create_product( + &state.db, + "apply-test", + "Apply", + "", + 2500, + &json!({}), + ) + .await + .unwrap(); + sqlx::query( + "UPDATE products SET price_currency = 'USD', price_value = 2500 WHERE id = ?", + ) + .bind(&product.id) + .execute(&state.db) + .await + .unwrap(); + let standard = repo::create_policy( + &state.db, + &product.id, + "Standard", + "standard", + 30 * 86_400, + 0, + 1, + false, + Some(2500), + &["core".into()], + &json!({}), + None, + 0, + None, + repo::RecurringConfig { + is_recurring: true, + renewal_period_days: 30, + grace_period_days: 7, + trial_days: 0, + }, + Some(1), + ) + .await + .unwrap(); + let pro = repo::create_policy( + &state.db, + &product.id, + "Pro", + "pro", + 365 * 86_400, // annual entitlement window + 0, + 5, // bigger max_machines on Pro + false, + Some(75_000), // $750 / yr, paid annually + &["core".into(), "ai_summaries".into(), "export".into()], + &json!({}), + None, + 0, + None, + repo::RecurringConfig { + is_recurring: true, + renewal_period_days: 365, // annual cadence + grace_period_days: 14, + trial_days: 0, + }, + Some(2), + ) + .await + .unwrap(); + + let license_id = Uuid::new_v4().to_string(); + repo::create_license( + &state.db, + &license_id, + &product.id, + None, + &now_str, + &json!({}), + Some(&standard.id), + Some(&(now + chrono::Duration::days(30)).to_rfc3339()), + 0, + 1, + &["core".to_string()], + false, + None, + None, + ) + .await + .unwrap(); + let next_renewal = (now + chrono::Duration::days(20)).to_rfc3339(); + sqlx::query( + "INSERT INTO subscriptions(id, license_id, policy_id, product_id, period_days, \ + listed_currency, listed_value, status, started_at, next_renewal_at, \ + consecutive_failures, created_at, updated_at) \ + VALUES('sub-apply', ?, ?, ?, 30, 'USD', 2500, 'active', ?, ?, 0, ?, ?)", + ) + .bind(&license_id) + .bind(&standard.id) + .bind(&product.id) + .bind(&now_str) + .bind(&next_renewal) + .bind(&now_str) + .bind(&now_str) + .execute(&state.db) + .await + .unwrap(); + + let product_full = repo::get_product_by_id(&state.db, &product.id) + .await + .unwrap() + .unwrap(); + + apply_tier_change(&state.db, &license_id, &pro, &product_full) + .await + .expect("apply_tier_change"); + + // License now reflects Pro's policy_id, entitlements, max_machines. + let license_after = repo::get_license_by_id(&state.db, &license_id) + .await + .unwrap() + .unwrap(); + assert_eq!(license_after.policy_id.as_deref(), Some(pro.id.as_str())); + assert_eq!(license_after.max_machines, 5); + assert!(license_after.entitlements.contains(&"ai_summaries".to_string())); + assert!(license_after.entitlements.contains(&"export".to_string())); + assert!(license_after.expires_at.is_some(), "annual Pro should set expires_at"); + + // Subscription now reflects Pro's policy_id, $750 listed_value, + // 365-day period. + let (pol_id, val, period): (String, i64, i64) = sqlx::query_as( + "SELECT policy_id, listed_value, period_days FROM subscriptions WHERE id = 'sub-apply'", + ) + .fetch_one(&state.db) + .await + .unwrap(); + assert_eq!(pol_id, pro.id); + assert_eq!(val, 75_000); + assert_eq!(period, 365); +} + +/// record_tier_change writes the audit row, and +/// list_tier_changes_for_license / get_tier_change_by_invoice +/// surface it back. Round-trips the data we'd write at settle time. +#[tokio::test] +async fn record_and_lookup_tier_change_round_trip() { + let (state, _tmp) = make_state().await; + let (license_id, standard_id, pro_id) = seed_perpetual_ladder(&state).await; + + // Seed a placeholder invoice so the FK on tier_changes.invoice_id + // can succeed. + let invoice_id = Uuid::new_v4().to_string(); + sqlx::query( + "INSERT INTO invoices(id, btcpay_invoice_id, product_id, amount_sats, \ + checkout_url, status, created_at, updated_at, listed_currency, \ + listed_value, policy_id) \ + VALUES(?, ?, (SELECT product_id FROM licenses WHERE id = ?), 0, \ + ?, 'pending', ?, ?, 'USD', 5000, ?)", + ) + .bind(&invoice_id) + .bind(format!("test-inv-{}", &invoice_id[..8])) + .bind(&license_id) + .bind("http://test.invalid/inv") + .bind(Utc::now().to_rfc3339()) + .bind(Utc::now().to_rfc3339()) + .bind(&pro_id) + .execute(&state.db) + .await + .unwrap(); + + let id = record_tier_change( + &state.db, + &license_id, + &standard_id, + &pro_id, + TierDirection::Upgrade, + "USD", + 5000, + Some(&invoice_id), + &Utc::now().to_rfc3339(), + "buyer", + Some("user clicked upgrade in app"), + ) + .await + .expect("record_tier_change"); + + // list_for_license returns the row. + let history = list_tier_changes_for_license(&state.db, &license_id) + .await + .unwrap(); + assert_eq!(history.len(), 1); + assert_eq!(history[0].id, id); + assert_eq!(history[0].direction, "upgrade"); + assert_eq!(history[0].proration_charge_value, 5000); + assert_eq!(history[0].listed_currency, "USD"); + assert_eq!(history[0].invoice_id.as_deref(), Some(invoice_id.as_str())); + + // get_by_invoice round-trips too. + let by_inv = keysat::upgrades::get_tier_change_by_invoice(&state.db, &invoice_id) + .await + .unwrap() + .expect("found by invoice"); + assert_eq!(by_inv.id, id); +}