Tier upgrades Phase 3 — buyer-facing HTTP endpoints

Closes the buyer self-service tier-upgrade loop. With this in,
SDKs can wire an "Upgrade to Pro" button inside the operator's
app and the daemon handles quote → invoice → settle → apply
without operator involvement.

New endpoints (auth via signed license_key in body, same model
as /v1/recover and /v1/subscriptions/cancel — no admin token,
no cookie):

- POST /v1/upgrade-quote   — read-only quote. "If I upgraded to
                             <tier>, what would I owe right now,
                             when do entitlements take effect,
                             what will the next renewal charge?"
- POST /v1/upgrade         — buyer commits. Daemon recomputes the
                             quote (don't trust client shaping),
                             rejects 0-charge upgrades (admin path
                             only), creates a provider invoice for
                             the prorated charge in the listed
                             currency converted to sats, persists
                             the local invoice + a tier_changes
                             row tying them together, returns the
                             checkout URL.

Webhook handler change (src/api/webhook.rs):
- On invoice settle, BEFORE the subscription / license-issuance
  branches, look up the invoice in tier_changes via
  upgrades::get_tier_change_by_invoice. If present, run the
  apply path: mutate the existing license's policy_id +
  entitlements + max_machines + grace + expires_at, mutate any
  tied subscription's policy_id + listed_value + period_days
  (so future renewals charge the new tier), audit, fire the new
  `license.tier_changed` webhook event, ack 200.
- Idempotent: re-delivered webhook on an already-applied
  tier change is a no-op (license.policy_id == target.id check).
- Critically: the existing license_id is preserved. Buyers
  keep the same signed key; on next online validation their
  app sees the new entitlements. No new license is issued.

Phase 3 scope deliberately excludes:
- Buyer-initiated DOWNGRADES. compute_upgrade_quote already
  returns 0-charge quotes for recurring downgrades (effective at
  next_renewal_at), but applying that at the cycle boundary
  needs renewal-worker integration. Phase 4 lands the admin
  endpoint AND the worker hook in one go. For v0.2.x the buyer
  endpoint rejects with 400 "admin-only".
- Admin force-change (POST /v1/admin/licenses/:id/change-tier).
  Phase 4.

Tests (+6, total now 72):
- upgrade_quote_returns_perpetual_difference (Standard $25 →
  Pro $75 = $50 = 5000 cents quote, "immediate" effective)
- upgrade_quote_rejects_garbage_key (401, doesn't leak whether
  the target slug exists)
- upgrade_quote_rejects_unknown_target_policy (404)
- upgrade_start_creates_invoice_and_tier_change_row (verifies
  the tier_changes row is written tied to the new invoice; the
  license is NOT yet on Pro until settle)
- webhook_settle_on_tier_change_applies_instead_of_issuing
  (full end-to-end: settle webhook fires → license flips to Pro
  + Pro entitlements appear; license count stays at 1, NO new
  license issued; re-delivery idempotent)
- upgrade_endpoint_rejects_buyer_downgrade (400 "admin-only" —
  the clear-message path the quote function intercepts with;
  Phase 4 will introduce a separate buyer-downgrade path)
This commit is contained in:
Grant
2026-05-08 20:06:13 -05:00
parent f8affdb11f
commit b7fa6c7dae
4 changed files with 801 additions and 0 deletions
+114
View File
@@ -124,6 +124,19 @@ pub async fn handle(
return Ok(StatusCode::OK);
};
// Tier-change branch: this settled invoice may be a tier upgrade
// (recorded by POST /v1/upgrade or the future admin-change-tier
// endpoint) rather than a fresh purchase or a subscription
// renewal. If so, apply the change against the existing license
// — DON'T issue a new license — and short-circuit the rest.
if let Some(tier_change) =
crate::upgrades::get_tier_change_by_invoice(&state.db, &invoice.id)
.await
.map_err(AppError::Internal)?
{
return apply_tier_change_on_settle(&state, &invoice, &tier_change).await;
}
// If this settled invoice is associated with a subscription
// (renewal cycle), flip the sub back to `active` and fire
// `subscription.renewed`. Idempotent — re-running on a sub
@@ -315,6 +328,107 @@ pub async fn issue_license_for_invoice(
Ok(license_id)
}
/// Webhook-side handler for a settled tier-change invoice. Idempotent:
/// if the license is already on the target tier (re-delivered webhook),
/// the UPDATE is a no-op and we still ack 200.
async fn apply_tier_change_on_settle(
state: &AppState,
invoice: &crate::models::Invoice,
tier_change: &crate::upgrades::TierChangeRow,
) -> AppResult<StatusCode> {
// Resolve the bits we need: the license, the target policy, and
// the product (so apply_tier_change can compute the new
// listed_value for the subscription if any).
let license = repo::get_license_by_id(&state.db, &tier_change.license_id)
.await?
.ok_or_else(|| {
AppError::Internal(anyhow::anyhow!(
"tier_change references missing license '{}'",
tier_change.license_id
))
})?;
let target_policy = repo::get_policy_by_id(&state.db, &tier_change.to_policy_id)
.await?
.ok_or_else(|| {
AppError::Internal(anyhow::anyhow!(
"tier_change references missing target policy '{}'",
tier_change.to_policy_id
))
})?;
let product = repo::get_product_by_id(&state.db, &target_policy.product_id)
.await?
.ok_or_else(|| {
AppError::Internal(anyhow::anyhow!(
"target policy references missing product '{}'",
target_policy.product_id
))
})?;
// Idempotency: if the license's policy_id already matches the
// target, the change has already been applied by an earlier
// webhook delivery. Ack and move on.
if license.policy_id.as_deref() == Some(target_policy.id.as_str()) {
tracing::info!(
license_id = %license.id,
tier_change_id = %tier_change.id,
"tier-change already applied (idempotent re-delivery); acking"
);
return Ok(StatusCode::OK);
}
// Apply the change.
crate::upgrades::apply_tier_change(&state.db, &license.id, &target_policy, &product)
.await
.map_err(AppError::Internal)?;
let _ = repo::insert_audit(
&state.db,
"system",
None,
"subscription.upgrade.applied",
Some("tier_change"),
Some(&tier_change.id),
None,
None,
&serde_json::json!({
"license_id": license.id,
"from_policy_id": tier_change.from_policy_id,
"to_policy_id": tier_change.to_policy_id,
"invoice_id": invoice.id,
"actor": tier_change.actor,
"direction": tier_change.direction,
}),
)
.await;
crate::webhooks::dispatch(
state,
"license.tier_changed",
&serde_json::json!({
"license_id": license.id,
"product_id": product.id,
"from_policy_id": tier_change.from_policy_id,
"to_policy_id": tier_change.to_policy_id,
"to_policy_slug": target_policy.slug,
"direction": tier_change.direction,
"actor": tier_change.actor,
"invoice_id": invoice.id,
"tier_change_id": tier_change.id,
}),
)
.await;
tracing::info!(
license_id = %license.id,
from_policy_id = %tier_change.from_policy_id,
to_policy_id = %tier_change.to_policy_id,
invoice_id = %invoice.id,
"tier change applied on settle"
);
Ok(StatusCode::OK)
}
// Small helper to attach a log line to an error conversion.
trait TapLog {
fn tap_log(self, msg: String) -> Self;