diff --git a/licensing-service/src/api/mod.rs b/licensing-service/src/api/mod.rs index 0bf2ef9..acc5a06 100644 --- a/licensing-service/src/api/mod.rs +++ b/licensing-service/src/api/mod.rs @@ -355,6 +355,12 @@ pub fn router(state: AppState) -> Router { // as /v1/recover and /v1/subscriptions/cancel. .route("/v1/upgrade-quote", post(upgrade::quote)) .route("/v1/upgrade", post(upgrade::start)) + // Admin force-change: skip ladder rules, optional skip_payment + // for comp upgrades. Bears full audit trail. + .route( + "/v1/admin/licenses/:id/change-tier", + post(upgrade::admin_change), + ) // Machines (admin views). .route("/v1/admin/machines", get(machines::admin_list)) .route( diff --git a/licensing-service/src/api/upgrade.rs b/licensing-service/src/api/upgrade.rs index 9907127..581b521 100644 --- a/licensing-service/src/api/upgrade.rs +++ b/licensing-service/src/api/upgrade.rs @@ -25,12 +25,12 @@ //! - **Admin force-change.** `POST /v1/admin/licenses/:id/change-tier` //! ships in Phase 4. -use crate::api::admin::request_context; +use crate::api::admin::{request_context, require_admin}; use crate::api::AppState; use crate::error::{AppError, AppResult}; use crate::payment::{CreateInvoiceParams, Money}; use axum::{ - extract::State, + extract::{Path, State}, http::HeaderMap, Json, }; @@ -56,7 +56,7 @@ pub async fn quote( Json(body): Json, ) -> AppResult> { let (license, target_policy) = resolve_request(&state, &body.license_key, &body.target_policy_slug).await?; - let q = crate::upgrades::compute_upgrade_quote(&state, &license, &target_policy).await?; + let q = crate::upgrades::compute_upgrade_quote(&state, &license, &target_policy, crate::upgrades::QuoteMode::Buyer).await?; Ok(Json(quote_to_json(&q))) } @@ -93,7 +93,7 @@ pub async fn start( let (license, target_policy) = resolve_request(&state, &body.license_key, &body.target_policy_slug).await?; - let quote = crate::upgrades::compute_upgrade_quote(&state, &license, &target_policy).await?; + let quote = crate::upgrades::compute_upgrade_quote(&state, &license, &target_policy, crate::upgrades::QuoteMode::Buyer).await?; // Phase 3 scope: buyer endpoint handles UPGRADE only. Downgrades // (even 0-charge ones) need the cycle-boundary apply path which @@ -265,6 +265,270 @@ async fn resolve_request( Ok((license, target_policy)) } +// --------------------------------------------------------------------- +// Admin force-change endpoint (Phase 4) +// --------------------------------------------------------------------- + +#[derive(Debug, Deserialize)] +pub struct AdminChangeReq { + /// Slug of the policy to move the license to. Resolved within + /// the license's product. + pub to_policy_slug: String, + /// When true, apply the change immediately with no invoice + /// (operator absorbs the cost — comp upgrade, support fix-up, + /// fixing a misissue). When false, behave like the buyer + /// endpoint: create an invoice for the prorated charge, + /// webhook applies on settle. + #[serde(default)] + pub skip_payment: bool, + /// Free-form audit note. Surfaced in audit_log + tier_changes.reason. + #[serde(default)] + pub reason: Option, +} + +/// `POST /v1/admin/licenses/:id/change-tier` — admin force-change. +/// Bypasses ladder rules (sideways changes, NULL-rank policies, +/// perpetual downgrades all allowed). Two modes: +/// +/// - `skip_payment: true`: applies immediately. tier_changes row +/// is written with invoice_id = NULL and proration_charge_value = 0. +/// The license's policy_id + entitlements + expiry + max_machines +/// flip on the spot; any tied subscription's policy_id + +/// listed_value + period_days update so the next renewal bills the +/// new tier. +/// +/// - `skip_payment: false`: same flow as the buyer's `/v1/upgrade` — +/// creates a provider invoice for the prorated charge, persists +/// the local invoice + a tier_changes row tied to it. The webhook +/// handler applies on settle. The operator gets the checkout URL +/// back and forwards it to the buyer through whatever channel +/// they prefer (email, chat, etc.). +pub async fn admin_change( + State(state): State, + headers: HeaderMap, + Path(license_id): Path, + Json(body): Json, +) -> AppResult> { + let actor_hash = require_admin(&state, &headers)?; + let (ip, ua) = request_context(&headers); + let reason = body.reason.as_deref().filter(|s| !s.trim().is_empty()); + + let license = crate::db::repo::get_license_by_id(&state.db, &license_id) + .await? + .ok_or_else(|| AppError::NotFound(format!("license '{license_id}'")))?; + + let target_policy = crate::db::repo::get_policy_by_slug( + &state.db, + &license.product_id, + &body.to_policy_slug, + ) + .await? + .ok_or_else(|| AppError::NotFound(format!("target policy '{}'", body.to_policy_slug)))?; + + let quote = crate::upgrades::compute_upgrade_quote( + &state, + &license, + &target_policy, + crate::upgrades::QuoteMode::Admin, + ) + .await?; + + if body.skip_payment { + // Comp path: apply immediately, no invoice. + let product = crate::db::repo::get_product_by_id(&state.db, &target_policy.product_id) + .await? + .ok_or_else(|| AppError::NotFound(format!("product '{}'", target_policy.product_id)))?; + crate::upgrades::apply_tier_change(&state.db, &license.id, &target_policy, &product) + .await + .map_err(AppError::Internal)?; + + let tier_change_id = crate::upgrades::record_tier_change( + &state.db, + &license.id, + "e.from_policy_id, + "e.to_policy_id, + quote.direction, + "e.listed_currency, + 0, // comp: no charge + None, + &chrono::Utc::now().to_rfc3339(), + "admin", + reason, + ) + .await + .map_err(AppError::Internal)?; + + let _ = crate::db::repo::insert_audit( + &state.db, + "admin_api_key", + Some(&actor_hash), + "license.change_tier.comp", + Some("tier_change"), + Some(&tier_change_id), + ip.as_deref(), + ua.as_deref(), + &json!({ + "license_id": license.id, + "from_policy_id": quote.from_policy_id, + "to_policy_id": quote.to_policy_id, + "to_policy_slug": target_policy.slug, + "direction": quote.direction.as_str(), + "reason": reason, + "skip_payment": true, + }), + ) + .await; + + crate::webhooks::dispatch( + &state, + "license.tier_changed", + &json!({ + "license_id": license.id, + "product_id": product.id, + "from_policy_id": quote.from_policy_id, + "to_policy_id": quote.to_policy_id, + "to_policy_slug": target_policy.slug, + "direction": quote.direction.as_str(), + "actor": "admin", + "tier_change_id": tier_change_id, + }), + ) + .await; + + return Ok(Json(json!({ + "ok": true, + "applied": true, + "license_id": license.id, + "tier_change_id": tier_change_id, + "skip_payment": true, + "from_policy_slug": quote.from_policy_slug, + "to_policy_slug": quote.to_policy_slug, + }))); + } + + // Paid path: create invoice + tier_changes row tied to it. + // If the quote came back with proration <= 0 (sideways or + // operator forcing a same-price change), there's nothing to bill. + // Surface a clear error so the operator switches to skip_payment=true. + if quote.proration_charge_value <= 0 { + return Err(AppError::BadRequest( + "this change has no charge owed; use skip_payment=true to apply as a comp" + .into(), + )); + } + + let conversion = crate::rates::convert_to_sats( + &state, + "e.listed_currency, + quote.proration_charge_value, + ) + .await + .map_err(|e| AppError::Upstream(format!("rate conversion failed: {e:#}")))?; + let amount_sats = conversion.sats.max(1); + + let provider = state.payment_provider().await?; + let internal_invoice_id = Uuid::new_v4().to_string(); + let default_redirect = format!( + "{}/thank-you?invoice_id={}", + state.config.public_base_url, internal_invoice_id + ); + + let created = provider + .create_invoice(CreateInvoiceParams { + amount: Money::sats(amount_sats), + redirect_url: &default_redirect, + metadata: json!({ + "productId": target_policy.product_id, + "intent": "admin_tier_change", + "licenseId": license.id, + "fromPolicyId": quote.from_policy_id, + "toPolicyId": quote.to_policy_id, + }), + external_order_id: &internal_invoice_id, + buyer_email: license.buyer_email.as_deref(), + }) + .await + .map_err(|e| AppError::Upstream(format!("provider create_invoice: {e:#}")))?; + + let invoice = crate::db::repo::create_invoice_with_currency( + &state.db, + &internal_invoice_id, + &created.provider_invoice_id, + &target_policy.product_id, + amount_sats, + &created.checkout_url, + license.buyer_email.as_deref(), + Some("admin tier-change"), + Some("e.to_policy_id), + Some("e.listed_currency), + Some(quote.proration_charge_value), + conversion.rate_centibps, + Some(conversion.source.as_str()), + ) + .await?; + + let effective_at = match "e.effective_at { + crate::upgrades::EffectiveAt::Immediate => chrono::Utc::now().to_rfc3339(), + crate::upgrades::EffectiveAt::At(s) => s.clone(), + }; + let tier_change_id = crate::upgrades::record_tier_change( + &state.db, + &license.id, + "e.from_policy_id, + "e.to_policy_id, + quote.direction, + "e.listed_currency, + quote.proration_charge_value, + Some(&invoice.id), + &effective_at, + "admin", + reason, + ) + .await + .map_err(AppError::Internal)?; + + let _ = crate::db::repo::insert_audit( + &state.db, + "admin_api_key", + Some(&actor_hash), + "license.change_tier.invoice_created", + Some("tier_change"), + Some(&tier_change_id), + ip.as_deref(), + ua.as_deref(), + &json!({ + "license_id": license.id, + "from_policy_id": quote.from_policy_id, + "to_policy_id": quote.to_policy_id, + "to_policy_slug": target_policy.slug, + "direction": quote.direction.as_str(), + "invoice_id": invoice.id, + "amount_sats": amount_sats, + "listed_currency": quote.listed_currency, + "proration_charge_value": quote.proration_charge_value, + "reason": reason, + "skip_payment": false, + }), + ) + .await; + + Ok(Json(json!({ + "ok": true, + "applied": false, + "license_id": license.id, + "tier_change_id": tier_change_id, + "invoice_id": invoice.id, + "provider_invoice_id": created.provider_invoice_id, + "checkout_url": created.checkout_url, + "amount_sats": amount_sats, + "proration_charge_value": quote.proration_charge_value, + "listed_currency": quote.listed_currency, + "from_policy_slug": quote.from_policy_slug, + "to_policy_slug": quote.to_policy_slug, + "skip_payment": false, + }))) +} + fn quote_to_json(q: &crate::upgrades::UpgradeQuote) -> Value { let effective_at = match &q.effective_at { crate::upgrades::EffectiveAt::Immediate => json!("immediate"), diff --git a/licensing-service/src/subscriptions.rs b/licensing-service/src/subscriptions.rs index 6c6153c..8f94857 100644 --- a/licensing-service/src/subscriptions.rs +++ b/licensing-service/src/subscriptions.rs @@ -378,6 +378,114 @@ pub async fn create_subscription( }) } +/// Settle any pending tier changes whose `effective_at` has arrived +/// for this subscription's license. Returns the (possibly-updated) +/// subscription state plus a flag indicating whether at least one +/// change was applied. Used by the renewal worker before pricing +/// each cycle so the new cycle reflects any scheduled downgrade / +/// upgrade. +/// +/// "Pending" means: tier_changes row with `effective_at <= now` AND +/// the license's policy_id != to_policy_id (i.e. not yet applied — +/// the buyer-paid path applies via webhook on settle and that path +/// updates license.policy_id, so this query naturally excludes +/// already-applied rows). In practice the rows we apply here are +/// the comp / scheduled-downgrade rows that have invoice_id IS NULL +/// (since paid tier-changes are applied at webhook-settle time). +async fn apply_pending_tier_changes( + state: &AppState, + sub: &Subscription, +) -> Result<(Subscription, bool)> { + let now_str = Utc::now().to_rfc3339(); + // Find pending rows ordered oldest-first. We apply each in + // order so the audit trail makes sense if there's a chain. + let rows = sqlx::query( + "SELECT tc.id AS id, tc.to_policy_id AS to_policy_id \ + FROM tier_changes tc \ + JOIN licenses l ON l.id = tc.license_id \ + WHERE tc.license_id = ? \ + AND tc.effective_at <= ? \ + AND tc.invoice_id IS NULL \ + AND (l.policy_id IS NULL OR l.policy_id != tc.to_policy_id) \ + ORDER BY tc.effective_at ASC, tc.created_at ASC", + ) + .bind(&sub.license_id) + .bind(&now_str) + .fetch_all(&state.db) + .await + .context("find pending tier_changes")?; + + if rows.is_empty() { + return Ok((sub.clone(), false)); + } + + let mut applied_any = false; + for row in rows { + let to_policy_id: String = row.get("to_policy_id"); + let target_policy = match crate::db::repo::get_policy_by_id(&state.db, &to_policy_id).await? { + Some(p) => p, + None => { + tracing::warn!( + sub_id = %sub.id, + to_policy_id = %to_policy_id, + "pending tier_change references missing policy; skipping" + ); + continue; + } + }; + let product = match crate::db::repo::get_product_by_id(&state.db, &target_policy.product_id).await? { + Some(p) => p, + None => { + tracing::warn!( + sub_id = %sub.id, + product_id = %target_policy.product_id, + "pending tier_change references missing product; skipping" + ); + continue; + } + }; + crate::upgrades::apply_tier_change( + &state.db, + &sub.license_id, + &target_policy, + &product, + ) + .await + .context("apply pending tier_change in renewal hook")?; + applied_any = true; + + crate::webhooks::dispatch( + state, + "license.tier_changed", + &json!({ + "license_id": sub.license_id, + "product_id": product.id, + "to_policy_id": to_policy_id, + "to_policy_slug": target_policy.slug, + "actor": "system", + "applied_via": "renewal_worker", + }), + ) + .await; + } + + // Re-fetch the sub if we applied anything (apply_tier_change + // may have rewritten policy_id / listed_value / period_days / + // status — most notably status='cancelled' if the new policy + // is perpetual). + if applied_any { + match get_subscription_by_id(&state.db, &sub.id).await? { + Some(updated) => Ok((updated, true)), + None => { + // Sub was deleted somehow — extremely unlikely. + Ok((sub.clone(), true)) + } + } + } else { + Ok((sub.clone(), false)) + } +} + /// Per-attempt backoff schedule for renewal failures. Index = the /// upcoming consecutive-failures count (after this failure, what /// will the new value be). MAX_CONSECUTIVE_FAILURES (5) is the cap @@ -447,6 +555,28 @@ pub async fn tick(state: &AppState) -> Result<()> { /// next_renewal_at to the start of the next cycle, mark sub as /// past_due (returns to active when settle webhook fires). async fn renew_one(state: &AppState, sub: &Subscription) -> Result<()> { + // 0. Settle any pending tier changes whose effective_at has + // arrived. This fires recurring downgrades scheduled by the + // admin endpoint (or the future buyer-downgrade flow): the + // operator records "downgrade Pro → Standard at next cycle" + // and we apply it here, BEFORE pricing the next invoice, so + // the new cycle bills at the new tier. + // + // We re-load `sub` after applying so the renewal proceeds + // against the fresh policy_id / listed_value / period_days. + let (sub_for_renewal, updated_at_least_once) = + apply_pending_tier_changes(state, sub).await?; + let sub = &sub_for_renewal; + if updated_at_least_once { + tracing::info!( + sub_id = %sub.id, + new_policy_id = %sub.policy_id, + new_listed_value = sub.listed_value, + new_period_days = sub.period_days, + "applied pending tier change before renewal" + ); + } + // 1. Convert listed price to sats. SAT-currency subs are an // identity (no rate fetcher hit); fiat subs re-quote each // cycle (per MULTI_CURRENCY_DESIGN.md decision). diff --git a/licensing-service/src/upgrades.rs b/licensing-service/src/upgrades.rs index 1271d2b..2745f8c 100644 --- a/licensing-service/src/upgrades.rs +++ b/licensing-service/src/upgrades.rs @@ -95,16 +95,31 @@ pub enum EffectiveAt { 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). +/// 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 { // 1. Resolve current policy from the license. License rows can // legitimately have policy_id=NULL (legacy issuance / manual @@ -139,40 +154,64 @@ pub async fn compute_upgrade_quote( )); } - // 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 + // 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. + // 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(); - // 5. Effective listed price for each policy. + // 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). @@ -191,16 +230,19 @@ pub async fn compute_upgrade_quote( 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() { + // 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(), )); } - // Perpetual downgrades by buyer also rejected — no proration - // model that doesn't bake in a refund decision (operator's call). - if direction == TierDirection::Downgrade { + if mode == QuoteMode::Buyer && direction == TierDirection::Downgrade { return Err(AppError::BadRequest( "perpetual downgrades are admin-only — they imply a refund decision".into(), )); diff --git a/licensing-service/tests/api.rs b/licensing-service/tests/api.rs index 3573ca5..29f3bf0 100644 --- a/licensing-service/tests/api.rs +++ b/licensing-service/tests/api.rs @@ -2656,6 +2656,144 @@ async fn webhook_settle_on_tier_change_applies_instead_of_issuing() { let _ = invoice_id; } +/// Admin can force-change a license to any policy under the same +/// product. skip_payment=true applies immediately with no invoice. +#[tokio::test] +async fn admin_change_tier_skip_payment_applies_immediately() { + let (state, _tmp) = make_test_state().await; + let auth = format!("Bearer {}", TEST_ADMIN_KEY); + let (license_id, _key, _std, pro_id) = seed_perpetual_ladder_with_key(&state).await; + + let req = build_request( + "POST", + &format!("/v1/admin/licenses/{license_id}/change-tier"), + &[("authorization", &auth)], + Some(json!({ + "to_policy_slug": "pro", + "skip_payment": true, + "reason": "comp upgrade per support ticket #1234" + })), + ); + let resp = send(&state, req).await; + assert_eq!(resp.status(), StatusCode::OK); + let body = body_json(resp).await; + assert_eq!(body["applied"], true); + assert_eq!(body["skip_payment"], true); + let tc_id = body["tier_change_id"].as_str().unwrap().to_string(); + + 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()), + "skip_payment=true should apply on the spot" + ); + + let tc = keysat::upgrades::get_tier_change(&state.db, &tc_id) + .await + .unwrap() + .unwrap(); + assert_eq!(tc.actor, "admin"); + assert_eq!(tc.proration_charge_value, 0); + assert_eq!(tc.invoice_id, None, "comp upgrade has no invoice"); + assert_eq!( + tc.reason.as_deref(), + Some("comp upgrade per support ticket #1234") + ); +} + +/// Admin can force a perpetual downgrade. Buyer endpoint rejects +/// these (refund decision per design doc). +#[tokio::test] +async fn admin_change_tier_allows_perpetual_downgrade() { + let (state, _tmp) = make_test_state().await; + let auth = format!("Bearer {}", TEST_ADMIN_KEY); + let (license_id, _key, std_id, pro_id) = seed_perpetual_ladder_with_key(&state).await; + sqlx::query("UPDATE licenses SET policy_id = ? WHERE id = ?") + .bind(&pro_id) + .bind(&license_id) + .execute(&state.db) + .await + .unwrap(); + let req = build_request( + "POST", + &format!("/v1/admin/licenses/{license_id}/change-tier"), + &[("authorization", &auth)], + Some(json!({ + "to_policy_slug": "standard", + "skip_payment": true, + "reason": "honoring partial refund" + })), + ); + let resp = send(&state, req).await; + assert_eq!(resp.status(), StatusCode::OK); + let license_after = repo::get_license_by_id(&state.db, &license_id) + .await + .unwrap() + .unwrap(); + assert_eq!(license_after.policy_id.as_deref(), Some(std_id.as_str())); +} + +#[tokio::test] +async fn admin_change_tier_rejects_zero_charge_paid_path() { + let (state, _tmp) = make_test_state().await; + let auth = format!("Bearer {}", TEST_ADMIN_KEY); + let (license_id, _key, std_id, _pro) = seed_perpetual_ladder_with_key(&state).await; + let std_policy = repo::get_policy_by_id(&state.db, &std_id).await.unwrap().unwrap(); + let _sideways = repo::create_policy( + &state.db, + &std_policy.product_id, + "Standard Plus", + "standard-plus", + 0, + 0, + 1, + false, + Some(2500), + &["core".into()], + &json!({}), + None, + 0, + None, + repo::RecurringConfig::off(), + Some(1), + ) + .await + .unwrap(); + let req = build_request( + "POST", + &format!("/v1/admin/licenses/{license_id}/change-tier"), + &[("authorization", &auth)], + Some(json!({ + "to_policy_slug": "standard-plus", + "skip_payment": false + })), + ); + let resp = send(&state, req).await; + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); + let body = body_json(resp).await; + assert!( + body["message"].as_str().unwrap_or("").contains("skip_payment"), + "error should hint at the skip_payment toggle: {body:?}" + ); +} + +#[tokio::test] +async fn admin_change_tier_requires_admin_token() { + let (state, _tmp) = make_test_state().await; + let (license_id, _key, _std, _pro) = seed_perpetual_ladder_with_key(&state).await; + let req = build_request( + "POST", + &format!("/v1/admin/licenses/{license_id}/change-tier"), + &[], + Some(json!({"to_policy_slug": "pro", "skip_payment": true})), + ); + let resp = send(&state, req).await; + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); +} + /// Buyer-initiated downgrade is rejected from this endpoint in v0.2.x /// (Phase 4 admin endpoint covers downgrades). #[tokio::test] diff --git a/licensing-service/tests/upgrades.rs b/licensing-service/tests/upgrades.rs index 5a302c1..cdbf0cc 100644 --- a/licensing-service/tests/upgrades.rs +++ b/licensing-service/tests/upgrades.rs @@ -11,7 +11,7 @@ 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, + record_tier_change, EffectiveAt, QuoteMode, TierDirection, }; use serde_json::json; use sqlx::sqlite::{ @@ -179,7 +179,7 @@ async fn perpetual_upgrade_quote_returns_flat_price_difference() { .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(); + let quote = compute_upgrade_quote(&state, &license, &pro, QuoteMode::Buyer).await.unwrap(); assert_eq!(quote.direction, TierDirection::Upgrade); assert_eq!(quote.listed_currency, "USD"); @@ -229,7 +229,7 @@ async fn perpetual_downgrade_is_admin_only() { .unwrap() .unwrap(); - let err = compute_upgrade_quote(&state, &license, &standard) + let err = compute_upgrade_quote(&state, &license, &standard, QuoteMode::Buyer) .await .expect_err("perpetual downgrade should be rejected"); let msg = format!("{err}"); @@ -270,7 +270,7 @@ async fn quote_rejects_target_with_null_tier_rank() { .await .unwrap(); - let err = compute_upgrade_quote(&state, &license, &unlisted) + let err = compute_upgrade_quote(&state, &license, &unlisted, QuoteMode::Buyer) .await .expect_err("unlisted target should be rejected"); let msg = format!("{err}"); @@ -293,7 +293,7 @@ async fn quote_rejects_same_policy() { .await .unwrap() .unwrap(); - let err = compute_upgrade_quote(&state, &license, &same) + let err = compute_upgrade_quote(&state, &license, &same, QuoteMode::Buyer) .await .expect_err("same-policy target should be rejected"); assert!(format!("{err}").contains("same as current")); @@ -425,7 +425,7 @@ async fn recurring_upgrade_prorates_against_time_remaining() { .await .unwrap() .unwrap(); - let quote = compute_upgrade_quote(&state, &license, &pro).await.unwrap(); + let quote = compute_upgrade_quote(&state, &license, &pro, QuoteMode::Buyer).await.unwrap(); assert_eq!(quote.direction, TierDirection::Upgrade); assert_eq!(quote.listed_currency, "USD"); @@ -561,7 +561,7 @@ async fn recurring_downgrade_is_zero_charge_at_next_cycle() { .await .unwrap() .unwrap(); - let quote = compute_upgrade_quote(&state, &license, &standard).await.unwrap(); + let quote = compute_upgrade_quote(&state, &license, &standard, QuoteMode::Buyer).await.unwrap(); assert_eq!(quote.direction, TierDirection::Downgrade); assert_eq!(quote.proration_charge_value, 0, @@ -725,6 +725,210 @@ async fn apply_tier_change_mutates_license_and_subscription() { assert_eq!(period, 365); } +/// Pending tier_changes with effective_at <= now are applied by +/// the renewal worker before pricing the next cycle. Mirrors the +/// recurring-downgrade flow that ships alongside this hook: admin +/// records "downgrade Pro → Standard at next cycle" with +/// effective_at = next_renewal_at, and the worker fires it on tick. +#[tokio::test] +async fn renewal_worker_applies_pending_tier_change_before_billing() { + use keysat::payment::{ + CreateInvoiceParams, CreatedInvoiceHandle, PaymentProvider, ProviderInvoiceStatus, + ProviderKind, ProviderWebhookEvent, + }; + use std::any::Any; + use std::sync::atomic::{AtomicU64, Ordering}; + + // Local mock provider — same shape as the renewal-worker tests' + // mock. Captures the listed_value-derived sat amount so we can + // assert the worker billed AT THE NEW TIER, not the old one. + #[derive(Default)] + struct CapturingProvider { + next_id: AtomicU64, + last_amount_sats: std::sync::atomic::AtomicI64, + } + #[async_trait::async_trait] + impl PaymentProvider for CapturingProvider { + fn kind(&self) -> ProviderKind { + ProviderKind::Btcpay + } + async fn create_invoice( + &self, + params: CreateInvoiceParams<'_>, + ) -> anyhow::Result { + self.last_amount_sats + .store(params.amount.amount, Ordering::SeqCst); + let n = self.next_id.fetch_add(1, Ordering::SeqCst); + Ok(CreatedInvoiceHandle { + provider_invoice_id: format!("cap-{n}"), + checkout_url: format!("http://cap/{n}"), + }) + } + async fn get_invoice_status(&self, _id: &str) -> anyhow::Result { + Ok(ProviderInvoiceStatus::Pending) + } + fn validate_webhook( + &self, + _h: &axum::http::HeaderMap, + _b: &[u8], + ) -> anyhow::Result { + anyhow::bail!("not exercised") + } + fn as_any(&self) -> &dyn Any { + self + } + } + + let (state, _tmp) = make_state().await; + let mock = Arc::new(CapturingProvider::default()); + *state.payment.write().await = Some(mock.clone() as Arc); + + let now = Utc::now(); + let now_str = now.to_rfc3339(); + + // SAT-priced product (no rate fetcher) for a clean assertion on + // the amount billed. + let product = repo::create_product( + &state.db, + "rw-pending", + "Renewal worker pending", + "", + 2500, // 2500 sats base + &json!({}), + ) + .await + .unwrap(); + + let standard = repo::create_policy( + &state.db, + &product.id, + "Standard", + "standard", + 30 * 86_400, + 0, + 1, + false, + Some(2500), // 2500 sats / mo + &["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), // 7500 sats / mo + &["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 + sub on Pro, due now (next_renewal_at in the past). + 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(), "ai_summaries".to_string()], + false, + None, + None, + ) + .await + .unwrap(); + + let past_due = (now - chrono::Duration::minutes(5)).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-rw-pending', ?, ?, ?, 30, 'SAT', 7500, 'active', ?, ?, 0, ?, ?)", + ) + .bind(&license_id) + .bind(&pro.id) + .bind(&product.id) + .bind(&now_str) + .bind(&past_due) + .bind(&now_str) + .bind(&now_str) + .execute(&state.db) + .await + .unwrap(); + + // Operator (or the future admin endpoint) records a downgrade + // tier_change with effective_at = now (= already past). No + // invoice attached (this is the comp / scheduled-downgrade + // shape). + record_tier_change( + &state.db, + &license_id, + &pro.id, + &standard.id, + TierDirection::Downgrade, + "SAT", + 0, + None, + &now_str, + "admin", + Some("scheduled downgrade for cycle boundary"), + ) + .await + .unwrap(); + + // Tick the renewal worker. + keysat::subscriptions::tick(&state).await.unwrap(); + + // The new invoice was created at the NEW tier's price (2500 + // sats), not the old one (7500 sats). This proves the renewal + // worker applied the pending tier change BEFORE pricing. + let billed = mock.last_amount_sats.load(Ordering::SeqCst); + assert_eq!( + billed, 2500, + "renewal must bill at the new (Standard) tier after the pending downgrade applied; got {billed} sats" + ); + + // License is now on Standard (apply_tier_change ran during the hook). + let license_after = repo::get_license_by_id(&state.db, &license_id) + .await + .unwrap() + .unwrap(); + assert_eq!(license_after.policy_id.as_deref(), Some(standard.id.as_str())); +} + /// 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.