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