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,
})
}
+2
View File
@@ -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");
+790
View File
@@ -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(&quote.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);
}