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:
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user