Tier upgrades Phase 4 — admin force-change + renewal-worker hook

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.
This commit is contained in:
Grant
2026-05-08 20:12:44 -05:00
parent b7fa6c7dae
commit c5d716a6d4
6 changed files with 832 additions and 48 deletions
+6
View File
@@ -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(
+268 -4
View File
@@ -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<QuoteReq>,
) -> AppResult<Json<Value>> {
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<String>,
}
/// `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<AppState>,
headers: HeaderMap,
Path(license_id): Path<String>,
Json(body): Json<AdminChangeReq>,
) -> AppResult<Json<Value>> {
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,
&quote.from_policy_id,
&quote.to_policy_id,
quote.direction,
&quote.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,
&quote.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(&quote.to_policy_id),
Some(&quote.listed_currency),
Some(quote.proration_charge_value),
conversion.rate_centibps,
Some(conversion.source.as_str()),
)
.await?;
let effective_at = match &quote.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,
&quote.from_policy_id,
&quote.to_policy_id,
quote.direction,
&quote.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"),
+130
View File
@@ -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).
+79 -37
View File
@@ -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<UpgradeQuote> {
// 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, &current_policy.product_id)
.await?
.ok_or_else(|| AppError::NotFound(format!("product '{}'", current_policy.product_id)))?;
let listed_currency = product.price_currency.clone();
// 5. Effective listed price for each policy.
// 4. Effective listed price for each policy.
let from_listed = effective_listed_value(&current_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(),
));
+138
View File
@@ -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]
+211 -7
View File
@@ -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<CreatedInvoiceHandle> {
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<ProviderInvoiceStatus> {
Ok(ProviderInvoiceStatus::Pending)
}
fn validate_webhook(
&self,
_h: &axum::http::HeaderMap,
_b: &[u8],
) -> anyhow::Result<ProviderWebhookEvent> {
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<dyn PaymentProvider>);
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.