Tier upgrades Phase 2 — quote logic + apply step
Builds on 8ce78ab (Phase 1 schema). Pure module work — no HTTP
endpoints yet (those are Phase 3). Operator-invisible until Phase
3-5 wire up the buyer / admin / UI surfaces.
src/upgrades.rs:
- UpgradeQuote / TierDirection / EffectiveAt structs (serde-ready
for the future endpoint).
- compute_upgrade_quote(state, license, target_policy) — the
buyer-facing quote function. Enforces ladder rules:
* both policies must have non-NULL tier_rank
* sideways (same-rank) changes rejected — admin-only
* cross-product target rejected
* inactive target rejected
* same-policy noop rejected
* perpetual downgrades rejected (refund decision = admin-only)
* recurring → perpetual downgrade rejected (admin-only)
- Branches on perpetual vs recurring:
* Perpetual upgrade: flat (target - current) listed price diff,
effective_at = Immediate.
* Recurring upgrade: prorated (target - current) × days_remaining
/ period_days; effective_at = Immediate; surfaces
next_renewal_charge for the buyer to see what they'll pay
going forward.
* Recurring downgrade: zero-charge today, effective_at =
next_renewal_at (full current cycle at old price).
* Free → recurring: full first-cycle price (no proration since
"remaining value" of free is 0).
- record_tier_change — INSERT helper for the audit row.
- apply_tier_change — UPDATE helper that mutates the license row
(policy_id, entitlements_json, expires_at, max_machines,
grace_seconds, is_trial) and any tied subscription
(policy_id, listed_value, period_days). Recurring → perpetual
apply also cancels the now-orphaned subscription so the renewal
worker stops touching it.
- get_tier_change / list_tier_changes_for_license /
get_tier_change_by_invoice — read helpers (Phase 3 webhook
handler will use the by_invoice variant).
tier_rank threading:
- models::Policy gains `tier_rank: Option<i64>`.
- POLICY_COLS + row_to_policy include tier_rank with try_get
Option<i64> + flatten so NULL stays NULL (a valid state) and
pre-0013 databases also resolve to None.
- repo::create_policy gets a `tier_rank: Option<i64>` param.
- repo::RecurringUpdate gains `tier_rank: Option<Option<i64>>`
for nullable-patch semantics matching price_sats_override.
- CreatePolicyReq + UpdatePolicyReq accept tier_rank with the
same shape; range-validated 0..=1000.
tests/upgrades.rs (8 new tests):
- perpetual_upgrade_quote_returns_flat_price_difference
- perpetual_downgrade_is_admin_only (rejection w/ helpful msg)
- quote_rejects_target_with_null_tier_rank
- quote_rejects_same_policy
- recurring_upgrade_prorates_against_time_remaining (asserts
~half-of-diff for ~half-of-cycle remaining; tolerance window)
- recurring_downgrade_is_zero_charge_at_next_cycle (verifies
effective_at lands on next_renewal_at)
- apply_tier_change_mutates_license_and_subscription (Standard
monthly → Pro annual changes max_machines, entitlements,
expires_at, sub policy_id + listed_value + period_days)
- record_and_lookup_tier_change_round_trip
Test count: 66 (was 58; +8).
This commit is contained in:
@@ -66,6 +66,12 @@ pub struct CreatePolicyReq {
|
|||||||
/// Optional free-trial length at the first cycle. 0 = no trial.
|
/// Optional free-trial length at the first cycle. 0 = no trial.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub trial_days: i64,
|
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 {
|
fn default_max_machines() -> i64 {
|
||||||
@@ -185,6 +191,17 @@ pub async fn create(
|
|||||||
trial_days: req.trial_days,
|
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(
|
let policy = repo::create_policy(
|
||||||
&state.db,
|
&state.db,
|
||||||
&product.id,
|
&product.id,
|
||||||
@@ -201,6 +218,7 @@ pub async fn create(
|
|||||||
req.tip_pct_bps,
|
req.tip_pct_bps,
|
||||||
tip_label,
|
tip_label,
|
||||||
recurring,
|
recurring,
|
||||||
|
req.tier_rank,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
let _ = repo::insert_audit(
|
let _ = repo::insert_audit(
|
||||||
@@ -434,6 +452,13 @@ pub struct UpdatePolicyReq {
|
|||||||
pub grace_period_days: Option<i64>,
|
pub grace_period_days: Option<i64>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub trial_days: Option<i64>,
|
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>
|
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 {
|
let recurring_update = repo::RecurringUpdate {
|
||||||
is_recurring: req.is_recurring,
|
is_recurring: req.is_recurring,
|
||||||
renewal_period_days: req.renewal_period_days,
|
renewal_period_days: req.renewal_period_days,
|
||||||
grace_period_days: req.grace_period_days,
|
grace_period_days: req.grace_period_days,
|
||||||
trial_days: req.trial_days,
|
trial_days: req.trial_days,
|
||||||
|
tier_rank: req.tier_rank,
|
||||||
};
|
};
|
||||||
|
|
||||||
let updated = repo::update_policy(
|
let updated = repo::update_policy(
|
||||||
|
|||||||
@@ -764,6 +764,7 @@ const POLICY_COLS: &str = "id, product_id, name, slug, duration_seconds, grace_s
|
|||||||
max_machines, is_trial, price_sats_override,
|
max_machines, is_trial, price_sats_override,
|
||||||
entitlements_json, metadata_json, active, public,
|
entitlements_json, metadata_json, active, public,
|
||||||
is_recurring, renewal_period_days, grace_period_days, trial_days,
|
is_recurring, renewal_period_days, grace_period_days, trial_days,
|
||||||
|
tier_rank,
|
||||||
created_at, updated_at";
|
created_at, updated_at";
|
||||||
|
|
||||||
/// Bundles the recurring-subscription knobs so we don't keep growing
|
/// 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_pct_bps: i64,
|
||||||
tip_label: Option<&str>,
|
tip_label: Option<&str>,
|
||||||
recurring: RecurringConfig,
|
recurring: RecurringConfig,
|
||||||
|
tier_rank: Option<i64>,
|
||||||
) -> AppResult<Policy> {
|
) -> AppResult<Policy> {
|
||||||
let id = Uuid::new_v4().to_string();
|
let id = Uuid::new_v4().to_string();
|
||||||
let now = Utc::now().to_rfc3339();
|
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,
|
is_trial, price_sats_override, entitlements_json, metadata_json, active, public,
|
||||||
tip_recipient, tip_pct_bps, tip_label,
|
tip_recipient, tip_pct_bps, tip_label,
|
||||||
is_recurring, renewal_period_days, grace_period_days, trial_days,
|
is_recurring, renewal_period_days, grace_period_days, trial_days,
|
||||||
|
tier_rank,
|
||||||
created_at, updated_at)
|
created_at, updated_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
)
|
)
|
||||||
.bind(&id)
|
.bind(&id)
|
||||||
.bind(product_id)
|
.bind(product_id)
|
||||||
@@ -841,6 +844,7 @@ pub async fn create_policy(
|
|||||||
.bind(recurring.renewal_period_days)
|
.bind(recurring.renewal_period_days)
|
||||||
.bind(recurring.grace_period_days)
|
.bind(recurring.grace_period_days)
|
||||||
.bind(recurring.trial_days)
|
.bind(recurring.trial_days)
|
||||||
|
.bind(tier_rank)
|
||||||
.bind(&now)
|
.bind(&now)
|
||||||
.bind(&now)
|
.bind(&now)
|
||||||
.execute(pool)
|
.execute(pool)
|
||||||
@@ -923,6 +927,11 @@ pub struct RecurringUpdate {
|
|||||||
pub renewal_period_days: Option<i64>,
|
pub renewal_period_days: Option<i64>,
|
||||||
pub grace_period_days: Option<i64>,
|
pub grace_period_days: Option<i64>,
|
||||||
pub trial_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)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
@@ -976,6 +985,9 @@ pub async fn update_policy(
|
|||||||
if recurring.trial_days.is_some() {
|
if recurring.trial_days.is_some() {
|
||||||
sets.push("trial_days = ?");
|
sets.push("trial_days = ?");
|
||||||
}
|
}
|
||||||
|
if recurring.tier_rank.is_some() {
|
||||||
|
sets.push("tier_rank = ?");
|
||||||
|
}
|
||||||
if sets.is_empty() {
|
if sets.is_empty() {
|
||||||
return get_policy_by_id(pool, id)
|
return get_policy_by_id(pool, id)
|
||||||
.await?
|
.await?
|
||||||
@@ -1025,6 +1037,9 @@ pub async fn update_policy(
|
|||||||
if let Some(v) = recurring.trial_days {
|
if let Some(v) = recurring.trial_days {
|
||||||
q = q.bind(v);
|
q = q.bind(v);
|
||||||
}
|
}
|
||||||
|
if let Some(opt_r) = recurring.tier_rank {
|
||||||
|
q = q.bind(opt_r);
|
||||||
|
}
|
||||||
q = q.bind(&now).bind(id);
|
q = q.bind(&now).bind(id);
|
||||||
let rows = q.execute(pool).await?.rows_affected();
|
let rows = q.execute(pool).await?.rows_affected();
|
||||||
if rows == 0 {
|
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 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 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);
|
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 {
|
Policy {
|
||||||
id: row.get("id"),
|
id: row.get("id"),
|
||||||
product_id: row.get("product_id"),
|
product_id: row.get("product_id"),
|
||||||
@@ -1101,6 +1128,7 @@ fn row_to_policy(row: sqlx::sqlite::SqliteRow) -> Policy {
|
|||||||
renewal_period_days,
|
renewal_period_days,
|
||||||
grace_period_days,
|
grace_period_days,
|
||||||
trial_days,
|
trial_days,
|
||||||
|
tier_rank,
|
||||||
created_at: row.get("created_at"),
|
created_at: row.get("created_at"),
|
||||||
updated_at: row.get("updated_at"),
|
updated_at: row.get("updated_at"),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ pub mod rates;
|
|||||||
pub mod reconcile;
|
pub mod reconcile;
|
||||||
pub mod subscriptions;
|
pub mod subscriptions;
|
||||||
pub mod tipping;
|
pub mod tipping;
|
||||||
|
pub mod upgrades;
|
||||||
pub mod webhooks;
|
pub mod webhooks;
|
||||||
|
|
||||||
/// Hex-encoded SHA-256 of a string — used everywhere we need a deterministic
|
/// Hex-encoded SHA-256 of a string — used everywhere we need a deterministic
|
||||||
|
|||||||
@@ -186,6 +186,15 @@ pub struct Policy {
|
|||||||
/// `trial_days`.
|
/// `trial_days`.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub trial_days: i64,
|
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 created_at: String,
|
||||||
pub updated_at: String,
|
pub updated_at: String,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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, ¤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<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,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -2028,6 +2028,7 @@ async fn edit_policy_to_recurring_respects_tier_gate() {
|
|||||||
0,
|
0,
|
||||||
None,
|
None,
|
||||||
repo::RecurringConfig::off(),
|
repo::RecurringConfig::off(),
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.expect("create_policy");
|
.expect("create_policy");
|
||||||
@@ -2137,6 +2138,7 @@ async fn seed_subscription(state: &AppState) -> (String, String, String) {
|
|||||||
grace_period_days: 7,
|
grace_period_days: 7,
|
||||||
trial_days: 0,
|
trial_days: 0,
|
||||||
},
|
},
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.expect("create_policy");
|
.expect("create_policy");
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user