c5d716a6d4
Closes the operator side of TIER_UPGRADES_DESIGN.md. With this in,
operators can force-change any license to any policy under the same
product (sideways, cross-NULL-rank, perpetual downgrades all
allowed) — and scheduled tier changes (e.g. recurring downgrades
recorded with future effective_at) actually fire at cycle boundaries.
New endpoint:
- POST /v1/admin/licenses/:id/change-tier
Body: { to_policy_slug, skip_payment: bool, reason?: string }
skip_payment=true (comp upgrade / support fix-up): apply
immediately, write a tier_changes row with proration=0 and
invoice_id=NULL, fire the license.tier_changed webhook, audit-log
with actor=admin_api_key.
skip_payment=false: same as buyer's /v1/upgrade — create a
provider invoice for the prorated charge, persist the local
invoice + a tier_changes row tied to it, return the checkout URL.
Operator forwards it to the buyer through whatever channel they
use. Webhook applies on settle.
Bypasses ladder rules entirely (sideways, perpetual downgrade,
recurring → perpetual all OK). Same-product / different-policy /
active-target checks still apply.
QuoteMode refactor (src/upgrades.rs):
- compute_upgrade_quote now takes QuoteMode::{Buyer, Admin}.
- Buyer mode = strict ladder rules (per Phase 2).
- Admin mode = bypass ladder + downgrade gates; infer direction
from rank-diff if both ranked, else from price-diff.
- Buyer endpoint passes Buyer; admin endpoint passes Admin.
Renewal-worker hook (src/subscriptions.rs):
- Before pricing each renewal cycle, the worker calls
apply_pending_tier_changes(state, sub). This finds tier_changes
rows for the sub's license where effective_at <= now AND
invoice_id IS NULL AND license.policy_id != to_policy_id (i.e.
scheduled comp/admin changes that haven't been applied yet).
Each pending change is applied via apply_tier_change (which
also rewrites the sub's policy_id / listed_value / period_days).
After applying, the worker re-fetches the sub and prices the
next invoice at the NEW tier's listed_value.
- This is what makes recurring downgrades actually take effect at
the cycle boundary (admin records "Pro → Standard at next
renewal", the worker applies it, the new invoice bills at
Standard's price).
- Idempotent: re-running the hook on a license already on the
target tier finds zero pending rows (the policy_id != check
filters them out).
Tests (+5, total now 77):
- admin_change_tier_skip_payment_applies_immediately — comp path
flips license + writes tier_change row with no invoice
- admin_change_tier_allows_perpetual_downgrade — the case the
buyer endpoint rejects with 400 "admin-only"
- admin_change_tier_rejects_zero_charge_paid_path — sideways
attempt with skip_payment=false hints at switching to true
- admin_change_tier_requires_admin_token — 401 without auth
- renewal_worker_applies_pending_tier_change_before_billing —
the headline behavior: a pending downgrade tier_change with
effective_at=now causes the next renewal to bill at the new
(lower) tier's price, NOT the old one. Uses a CapturingProvider
mock that stashes the last sat amount it saw so the assertion
is on what the worker actually billed.
618 lines
24 KiB
Rust
618 lines
24 KiB
Rust
//! 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),
|
|
}
|
|
|
|
/// Quote computation mode. The math is identical for buyer and
|
|
/// admin paths; only the validation rules differ.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum QuoteMode {
|
|
/// Strict ladder rules: both policies must have non-NULL
|
|
/// tier_rank; sideways changes rejected; perpetual downgrades
|
|
/// rejected (admin-only refund decision); recurring → perpetual
|
|
/// downgrades rejected.
|
|
Buyer,
|
|
/// Permissive: skip ladder + downgrade-direction rules. Admin
|
|
/// can force-change to/from any policy. Same product / active
|
|
/// target / different policy checks still apply.
|
|
Admin,
|
|
}
|
|
|
|
/// Compute a tier change quote. Buyer mode enforces ladder rules
|
|
/// per TIER_UPGRADES_DESIGN.md; admin mode bypasses them so
|
|
/// operators can force-change any license to any policy under
|
|
/// the same product (sideways, cross-NULL-rank, perpetual
|
|
/// downgrade — all allowed).
|
|
pub async fn compute_upgrade_quote(
|
|
state: &AppState,
|
|
license: &License,
|
|
target_policy: &Policy,
|
|
mode: QuoteMode,
|
|
) -> 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. 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. Done before direction inference
|
|
// so admin mode can fall back to price-diff if rank is NULL.
|
|
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();
|
|
|
|
// 4. Effective listed price for each policy.
|
|
let from_listed = effective_listed_value(¤t_policy, &product);
|
|
let to_listed = effective_listed_value(target_policy, &product);
|
|
|
|
// 5. Direction. Buyer mode enforces ladder rules; admin mode
|
|
// skips them and infers direction (used for audit + the
|
|
// perpetual-downgrade gate further down).
|
|
let direction = match mode {
|
|
QuoteMode::Buyer => {
|
|
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(),
|
|
)
|
|
})?;
|
|
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(),
|
|
));
|
|
}
|
|
}
|
|
}
|
|
QuoteMode::Admin => {
|
|
// Admin: infer from rank when both ranked, fall back to
|
|
// price-diff otherwise. tier_changes.direction CHECK
|
|
// requires upgrade|downgrade; treat ties as upgrade
|
|
// (operator can still proceed; audit reads honestly via
|
|
// proration_charge_value).
|
|
match (current_policy.tier_rank, target_policy.tier_rank) {
|
|
(Some(a), Some(b)) if b > a => TierDirection::Upgrade,
|
|
(Some(a), Some(b)) if b < a => TierDirection::Downgrade,
|
|
_ => {
|
|
if to_listed >= from_listed {
|
|
TierDirection::Upgrade
|
|
} else {
|
|
TierDirection::Downgrade
|
|
}
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
// 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. Buyer mode rejects two cases that admin
|
|
// mode allows:
|
|
// 1. recurring → perpetual downgrade — the in-flight sub
|
|
// complicates the apply step (have to cancel the sub).
|
|
// Admin can force.
|
|
// 2. perpetual → perpetual downgrade — bakes in a refund
|
|
// decision the operator has to make.
|
|
if mode == QuoteMode::Buyer && direction == TierDirection::Downgrade && sub.is_some() {
|
|
return Err(AppError::BadRequest(
|
|
"downgrading from a recurring subscription to a perpetual policy is admin-only".into(),
|
|
));
|
|
}
|
|
if mode == QuoteMode::Buyer && 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,
|
|
})
|
|
}
|