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:
@@ -63,6 +63,7 @@ pub mod policies;
|
||||
pub mod products;
|
||||
pub mod purchase;
|
||||
pub mod subscriptions;
|
||||
pub mod upgrade;
|
||||
pub mod buy_page;
|
||||
pub mod issuer_key;
|
||||
pub mod redeem;
|
||||
@@ -348,6 +349,12 @@ pub fn router(state: AppState) -> Router {
|
||||
"/v1/subscriptions/cancel",
|
||||
post(subscriptions::buyer_cancel),
|
||||
)
|
||||
// Tier upgrades (buyer self-service). Quote is read-only;
|
||||
// start kicks off a payment for the prorated charge.
|
||||
// Both auth via signed license_key in the body, same model
|
||||
// as /v1/recover and /v1/subscriptions/cancel.
|
||||
.route("/v1/upgrade-quote", post(upgrade::quote))
|
||||
.route("/v1/upgrade", post(upgrade::start))
|
||||
// Machines (admin views).
|
||||
.route("/v1/admin/machines", get(machines::admin_list))
|
||||
.route(
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
//! Buyer-facing tier upgrade endpoints.
|
||||
//!
|
||||
//! Phase 3 of TIER_UPGRADES_DESIGN.md. Two endpoints:
|
||||
//!
|
||||
//! - `POST /v1/upgrade-quote` — read-only quote: "what would I owe
|
||||
//! if I upgraded to <tier>?"
|
||||
//! - `POST /v1/upgrade` — start an upgrade: creates a payment
|
||||
//! invoice for the prorated charge,
|
||||
//! returns the checkout URL. Webhook
|
||||
//! handler applies the change on settle.
|
||||
//!
|
||||
//! Auth model matches the recovery + buyer-cancel endpoints — the
|
||||
//! buyer's signed license key in the request body is the credential.
|
||||
//! The daemon verifies the signature, looks up the local license row,
|
||||
//! computes the quote, optionally creates an invoice. No admin token,
|
||||
//! no cookie.
|
||||
//!
|
||||
//! Out of scope for Phase 3 (Phase 4 with admin endpoint):
|
||||
//! - **Buyer-initiated recurring downgrades.** The quote function in
|
||||
//! `crate::upgrades` already returns a 0-charge quote with
|
||||
//! `effective_at = next_renewal_at`, but actually applying the
|
||||
//! change at the right moment (cycle boundary) requires renewal-
|
||||
//! worker integration. Phase 4 lands that. For now this endpoint
|
||||
//! rejects buyer downgrades with a 403 and a hint to contact support.
|
||||
//! - **Admin force-change.** `POST /v1/admin/licenses/:id/change-tier`
|
||||
//! ships in Phase 4.
|
||||
|
||||
use crate::api::admin::request_context;
|
||||
use crate::api::AppState;
|
||||
use crate::error::{AppError, AppResult};
|
||||
use crate::payment::{CreateInvoiceParams, Money};
|
||||
use axum::{
|
||||
extract::State,
|
||||
http::HeaderMap,
|
||||
Json,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct QuoteReq {
|
||||
/// Buyer's signed license key. Verified before we compute anything.
|
||||
pub license_key: String,
|
||||
/// Slug of the policy the buyer wants to move to. Resolved within
|
||||
/// the license's product (cross-product changes are not supported
|
||||
/// by the quote function).
|
||||
pub target_policy_slug: String,
|
||||
}
|
||||
|
||||
/// `POST /v1/upgrade-quote` — quote-only. No DB writes, no invoice.
|
||||
/// Returns the same shape `crate::upgrades::UpgradeQuote` produces,
|
||||
/// flattened to JSON for SDK consumption.
|
||||
pub async fn quote(
|
||||
State(state): State<AppState>,
|
||||
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?;
|
||||
Ok(Json(quote_to_json(&q)))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct StartReq {
|
||||
pub license_key: String,
|
||||
pub target_policy_slug: String,
|
||||
/// Optional buyer-supplied redirect target on payment-provider
|
||||
/// success. Mirrors the purchase flow's same-named field.
|
||||
#[serde(default)]
|
||||
pub redirect_url: Option<String>,
|
||||
}
|
||||
|
||||
/// `POST /v1/upgrade` — buyer commits to the upgrade. We:
|
||||
/// 1. Recompute the quote (DON'T trust client-side shaping; the
|
||||
/// on-chain charge must match what the daemon's logic decides).
|
||||
/// 2. Reject buyer-initiated downgrades for v0.2.x (Phase 4 ships).
|
||||
/// 3. Reject zero-charge upgrades — those are admin-only (e.g., free
|
||||
/// upgrades come through the comp path, not the buyer path).
|
||||
/// 4. Create a provider invoice for the prorated charge.
|
||||
/// 5. Persist the local invoice + tier_changes row tying them
|
||||
/// together. The webhook handler picks it up on settle.
|
||||
///
|
||||
/// Returns `{ invoice_id, checkout_url, amount_sats }` so the SDK can
|
||||
/// open the checkout URL in the buyer's browser, then poll the
|
||||
/// existing `/v1/purchase/:invoice_id` to detect settle (the webhook
|
||||
/// applies the change server-side).
|
||||
pub async fn start(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Json(body): Json<StartReq>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
let (ip, ua) = request_context(&headers);
|
||||
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?;
|
||||
|
||||
// Phase 3 scope: buyer endpoint handles UPGRADE only. Downgrades
|
||||
// (even 0-charge ones) need the cycle-boundary apply path which
|
||||
// ships with Phase 4 admin endpoint + renewal-worker integration.
|
||||
if quote.direction == crate::upgrades::TierDirection::Downgrade {
|
||||
return Err(AppError::Forbidden);
|
||||
}
|
||||
|
||||
if quote.proration_charge_value <= 0 {
|
||||
return Err(AppError::BadRequest(
|
||||
"this upgrade has no charge owed; admin must apply it as a comp via \
|
||||
POST /v1/admin/licenses/:id/change-tier (Phase 4)".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// Convert proration to sats. SAT-currency licenses skip the rate
|
||||
// fetcher (identity). Fiat licenses re-quote against the live rate.
|
||||
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);
|
||||
|
||||
// Create provider invoice. Same trait method the purchase + renewal
|
||||
// paths use, so any provider-specific concerns (URL rewriting,
|
||||
// metadata enrichment) live inside the impl.
|
||||
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 redirect_url = body
|
||||
.redirect_url
|
||||
.as_deref()
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or(&default_redirect);
|
||||
|
||||
let created = provider
|
||||
.create_invoice(CreateInvoiceParams {
|
||||
amount: Money::sats(amount_sats),
|
||||
redirect_url,
|
||||
metadata: json!({
|
||||
"productId": target_policy.product_id,
|
||||
"intent": "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:#}")))?;
|
||||
|
||||
// Persist invoice. The exchange rate fields capture the conversion
|
||||
// so the receipt UI can show "you paid X sats which is $Y at the
|
||||
// moment of charge." Same shape as the regular purchase path.
|
||||
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("tier upgrade"),
|
||||
Some("e.to_policy_id),
|
||||
Some("e.listed_currency),
|
||||
Some(quote.proration_charge_value),
|
||||
conversion.rate_centibps,
|
||||
Some(conversion.source.as_str()),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Record the tier_change row, tied to this invoice. The webhook
|
||||
// handler looks it up by invoice_id on settle and applies.
|
||||
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,
|
||||
"buyer",
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.map_err(AppError::Internal)?;
|
||||
|
||||
// Audit row in the generic stream; tier_changes is its own
|
||||
// audit-shaped table, but audit_log is the single "what
|
||||
// happened" feed operators read from.
|
||||
let _ = crate::db::repo::insert_audit(
|
||||
&state.db,
|
||||
"buyer_license_key",
|
||||
Some(&license.id),
|
||||
"subscription.upgrade.started",
|
||||
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,
|
||||
"invoice_id": invoice.id,
|
||||
"listed_currency": quote.listed_currency,
|
||||
"proration_charge_value": quote.proration_charge_value,
|
||||
"amount_sats": amount_sats,
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok(Json(json!({
|
||||
"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,
|
||||
"tier_change_id": tier_change_id,
|
||||
"from_policy_slug": quote.from_policy_slug,
|
||||
"to_policy_slug": quote.to_policy_slug,
|
||||
})))
|
||||
}
|
||||
|
||||
/// Verify the buyer's license key, look up the local license row, and
|
||||
/// resolve the target policy by slug (under the license's product).
|
||||
/// Centralises the auth + lookup so quote and start handlers can
|
||||
/// stay narrow. 401 on auth failure (don't leak whether the policy
|
||||
/// exists), 404 on missing target.
|
||||
async fn resolve_request(
|
||||
state: &AppState,
|
||||
license_key: &str,
|
||||
target_policy_slug: &str,
|
||||
) -> AppResult<(crate::models::License, crate::models::Policy)> {
|
||||
let (payload, signature, signed_bytes) =
|
||||
crate::crypto::parse_key(license_key).map_err(|_| AppError::Unauthorized)?;
|
||||
crate::crypto::verify_payload(&state.keypair.verifying, &signed_bytes, &signature)
|
||||
.map_err(|_| AppError::Unauthorized)?;
|
||||
|
||||
let license_id = payload.license_id.to_string();
|
||||
let license = crate::db::repo::get_license_by_id(&state.db, &license_id)
|
||||
.await?
|
||||
.ok_or(AppError::Unauthorized)?;
|
||||
if license.revoked_at.is_some() || license.suspended_at.is_some() {
|
||||
return Err(AppError::Unauthorized);
|
||||
}
|
||||
|
||||
let target_policy = crate::db::repo::get_policy_by_slug(
|
||||
&state.db,
|
||||
&license.product_id,
|
||||
target_policy_slug,
|
||||
)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("target policy '{target_policy_slug}'")))?;
|
||||
|
||||
Ok((license, target_policy))
|
||||
}
|
||||
|
||||
fn quote_to_json(q: &crate::upgrades::UpgradeQuote) -> Value {
|
||||
let effective_at = match &q.effective_at {
|
||||
crate::upgrades::EffectiveAt::Immediate => json!("immediate"),
|
||||
crate::upgrades::EffectiveAt::At(s) => json!(s),
|
||||
};
|
||||
json!({
|
||||
"from_policy_id": q.from_policy_id,
|
||||
"from_policy_slug": q.from_policy_slug,
|
||||
"to_policy_id": q.to_policy_id,
|
||||
"to_policy_slug": q.to_policy_slug,
|
||||
"direction": q.direction.as_str(),
|
||||
"listed_currency": q.listed_currency,
|
||||
"proration_charge_value": q.proration_charge_value,
|
||||
"effective_at": effective_at,
|
||||
"next_renewal_charge": q.next_renewal_charge,
|
||||
"next_renewal_period_days": q.next_renewal_period_days,
|
||||
})
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -2326,6 +2326,401 @@ async fn buyer_cancel_subscription_via_license_key() {
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Tier upgrade endpoints (Phase 3 of TIER_UPGRADES_DESIGN)
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/// Seed a USD perpetual product with Standard (rank 1) + Pro (rank 2)
|
||||
/// policies, plus a license under Standard with a real signed key the
|
||||
/// buyer would hold. Returns (license_id, key_string, standard_id, pro_id).
|
||||
async fn seed_perpetual_ladder_with_key(state: &AppState) -> (String, String, String, String) {
|
||||
let product = repo::create_product(
|
||||
&state.db,
|
||||
"upgrade-test",
|
||||
"Upgrade Test",
|
||||
"",
|
||||
2500,
|
||||
&json!({}),
|
||||
)
|
||||
.await
|
||||
.expect("create_product");
|
||||
sqlx::query("UPDATE products SET price_currency='USD', price_value=2500 WHERE id = ?")
|
||||
.bind(&product.id)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
.unwrap();
|
||||
let standard = repo::create_policy(
|
||||
&state.db,
|
||||
&product.id,
|
||||
"Standard",
|
||||
"standard",
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
false,
|
||||
Some(2500),
|
||||
&["core".into()],
|
||||
&json!({}),
|
||||
None,
|
||||
0,
|
||||
None,
|
||||
repo::RecurringConfig::off(),
|
||||
Some(1),
|
||||
)
|
||||
.await
|
||||
.expect("create standard");
|
||||
let pro = repo::create_policy(
|
||||
&state.db,
|
||||
&product.id,
|
||||
"Pro",
|
||||
"pro",
|
||||
0,
|
||||
0,
|
||||
3,
|
||||
false,
|
||||
Some(7500),
|
||||
&["core".into(), "ai_summaries".into()],
|
||||
&json!({}),
|
||||
None,
|
||||
0,
|
||||
None,
|
||||
repo::RecurringConfig::off(),
|
||||
Some(2),
|
||||
)
|
||||
.await
|
||||
.expect("create pro");
|
||||
|
||||
let license_id = Uuid::new_v4();
|
||||
let issued_at = Utc::now();
|
||||
repo::create_license(
|
||||
&state.db,
|
||||
&license_id.to_string(),
|
||||
&product.id,
|
||||
None,
|
||||
&issued_at.to_rfc3339(),
|
||||
&json!({}),
|
||||
Some(&standard.id),
|
||||
None,
|
||||
0,
|
||||
1,
|
||||
&["core".to_string()],
|
||||
false,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("create_license");
|
||||
|
||||
let product_uuid = Uuid::parse_str(&product.id).expect("product id is uuid");
|
||||
let payload = LicensePayload {
|
||||
version: 2,
|
||||
flags: 0,
|
||||
product_id: product_uuid,
|
||||
license_id,
|
||||
issued_at: issued_at.timestamp(),
|
||||
expires_at: 0,
|
||||
fingerprint_hash: [0; 32],
|
||||
entitlements: vec!["core".into()],
|
||||
};
|
||||
let signature = crypto::sign_payload(&state.keypair.signing, &payload);
|
||||
let key_string = crypto::encode_key(&payload, &signature);
|
||||
|
||||
(license_id.to_string(), key_string, standard.id, pro.id)
|
||||
}
|
||||
|
||||
/// `/v1/upgrade-quote` returns the prorated charge for a valid
|
||||
/// license + target combo.
|
||||
#[tokio::test]
|
||||
async fn upgrade_quote_returns_perpetual_difference() {
|
||||
let (state, _tmp) = make_test_state().await;
|
||||
let (_lic, key, _std, _pro) = seed_perpetual_ladder_with_key(&state).await;
|
||||
|
||||
let req = build_request(
|
||||
"POST",
|
||||
"/v1/upgrade-quote",
|
||||
&[],
|
||||
Some(json!({
|
||||
"license_key": key,
|
||||
"target_policy_slug": "pro"
|
||||
})),
|
||||
);
|
||||
let resp = send(&state, req).await;
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let body = body_json(resp).await;
|
||||
assert_eq!(body["direction"], "upgrade");
|
||||
assert_eq!(body["listed_currency"], "USD");
|
||||
// Pro $75 - Standard $25 = $50 = 5000 cents.
|
||||
assert_eq!(body["proration_charge_value"], 5000);
|
||||
assert_eq!(body["effective_at"], "immediate");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn upgrade_quote_rejects_garbage_key() {
|
||||
let (state, _tmp) = make_test_state().await;
|
||||
let req = build_request(
|
||||
"POST",
|
||||
"/v1/upgrade-quote",
|
||||
&[],
|
||||
Some(json!({
|
||||
"license_key": "not-a-real-key",
|
||||
"target_policy_slug": "pro"
|
||||
})),
|
||||
);
|
||||
let resp = send(&state, req).await;
|
||||
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn upgrade_quote_rejects_unknown_target_policy() {
|
||||
let (state, _tmp) = make_test_state().await;
|
||||
let (_lic, key, _, _) = seed_perpetual_ladder_with_key(&state).await;
|
||||
let req = build_request(
|
||||
"POST",
|
||||
"/v1/upgrade-quote",
|
||||
&[],
|
||||
Some(json!({
|
||||
"license_key": key,
|
||||
"target_policy_slug": "no-such-policy"
|
||||
})),
|
||||
);
|
||||
let resp = send(&state, req).await;
|
||||
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
/// `/v1/upgrade` against a paid path: creates a real provider invoice
|
||||
/// (mock), persists a tier_changes row, returns checkout URL.
|
||||
#[tokio::test]
|
||||
async fn upgrade_start_creates_invoice_and_tier_change_row() {
|
||||
let (state, _tmp) = make_test_state_with_mock_provider().await;
|
||||
// Pin a USD/BTC rate so the rates fetcher doesn't try the network
|
||||
// when we hit the upgrade path.
|
||||
sqlx::query(
|
||||
"INSERT INTO settings(key, value, updated_at) \
|
||||
VALUES('manual_rate_pin_USD', '50000', ?)",
|
||||
)
|
||||
.bind(Utc::now().to_rfc3339())
|
||||
.execute(&state.db)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let (license_id, key, _std, pro_id) = seed_perpetual_ladder_with_key(&state).await;
|
||||
|
||||
let req = build_request(
|
||||
"POST",
|
||||
"/v1/upgrade",
|
||||
&[],
|
||||
Some(json!({
|
||||
"license_key": key,
|
||||
"target_policy_slug": "pro"
|
||||
})),
|
||||
);
|
||||
let resp = send(&state, req).await;
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::OK,
|
||||
"upgrade start should succeed; got {}",
|
||||
resp.status()
|
||||
);
|
||||
let body = body_json(resp).await;
|
||||
let invoice_id = body["invoice_id"].as_str().expect("invoice_id").to_string();
|
||||
assert!(body["checkout_url"].as_str().unwrap().contains("mock-checkout"));
|
||||
assert_eq!(body["proration_charge_value"], 5000); // 5000 cents
|
||||
assert!(body["amount_sats"].as_i64().unwrap() > 0,
|
||||
"fiat conversion should produce a non-zero sat charge");
|
||||
|
||||
// tier_changes row exists with this invoice_id.
|
||||
let tc = keysat::upgrades::get_tier_change_by_invoice(&state.db, &invoice_id)
|
||||
.await
|
||||
.unwrap()
|
||||
.expect("tier_change row");
|
||||
assert_eq!(tc.license_id, license_id);
|
||||
assert_eq!(tc.to_policy_id, pro_id);
|
||||
assert_eq!(tc.actor, "buyer");
|
||||
assert_eq!(tc.direction, "upgrade");
|
||||
assert_eq!(tc.invoice_id.as_deref(), Some(invoice_id.as_str()));
|
||||
|
||||
// License is NOT yet on Pro — that happens on settle (next test).
|
||||
let license_now = repo::get_license_by_id(&state.db, &license_id)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_ne!(
|
||||
license_now.policy_id.as_deref(),
|
||||
Some(pro_id.as_str()),
|
||||
"license should NOT change tier until invoice settles"
|
||||
);
|
||||
}
|
||||
|
||||
/// Webhook settle on a tier-change invoice applies the change instead
|
||||
/// of issuing a new license.
|
||||
#[tokio::test]
|
||||
async fn webhook_settle_on_tier_change_applies_instead_of_issuing() {
|
||||
let (state, _tmp) = make_test_state_with_mock_provider().await;
|
||||
sqlx::query(
|
||||
"INSERT INTO settings(key, value, updated_at) \
|
||||
VALUES('manual_rate_pin_USD', '50000', ?)",
|
||||
)
|
||||
.bind(Utc::now().to_rfc3339())
|
||||
.execute(&state.db)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let (license_id, key, _std, pro_id) = seed_perpetual_ladder_with_key(&state).await;
|
||||
|
||||
// Start the upgrade, capture the provider invoice id.
|
||||
let req = build_request(
|
||||
"POST",
|
||||
"/v1/upgrade",
|
||||
&[],
|
||||
Some(json!({
|
||||
"license_key": key,
|
||||
"target_policy_slug": "pro"
|
||||
})),
|
||||
);
|
||||
let resp = send(&state, req).await;
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let body = body_json(resp).await;
|
||||
let invoice_id = body["invoice_id"].as_str().unwrap().to_string();
|
||||
let provider_invoice_id = body["provider_invoice_id"].as_str().unwrap().to_string();
|
||||
|
||||
// Fire a "settled" webhook on that invoice. The MockPaymentProvider's
|
||||
// validate_webhook reads the body as JSON.
|
||||
let req = build_request(
|
||||
"POST",
|
||||
"/v1/btcpay/webhook",
|
||||
&[],
|
||||
Some(json!({
|
||||
"kind": "settled",
|
||||
"provider_invoice_id": provider_invoice_id
|
||||
})),
|
||||
);
|
||||
let resp = send(&state, req).await;
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::OK,
|
||||
"webhook should ack 200 on tier-change settle"
|
||||
);
|
||||
|
||||
// The license is now on Pro. No NEW license was issued (count
|
||||
// for this product still 1).
|
||||
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()),
|
||||
"settle webhook should have applied the tier change"
|
||||
);
|
||||
assert!(
|
||||
license_after.entitlements.contains(&"ai_summaries".to_string()),
|
||||
"Pro entitlements should now be on the license: {:?}",
|
||||
license_after.entitlements
|
||||
);
|
||||
|
||||
let n_licenses: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM licenses WHERE product_id = ?",
|
||||
)
|
||||
.bind(&license_after.product_id)
|
||||
.fetch_one(&state.db)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
n_licenses, 1,
|
||||
"tier-change must NOT issue a new license; count must stay at 1"
|
||||
);
|
||||
|
||||
// Re-delivering the same webhook is idempotent.
|
||||
let req = build_request(
|
||||
"POST",
|
||||
"/v1/btcpay/webhook",
|
||||
&[],
|
||||
Some(json!({
|
||||
"kind": "settled",
|
||||
"provider_invoice_id": provider_invoice_id
|
||||
})),
|
||||
);
|
||||
let resp = send(&state, req).await;
|
||||
assert_eq!(resp.status(), StatusCode::OK, "re-delivery must ack 200");
|
||||
let n_licenses_after: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM licenses WHERE product_id = ?",
|
||||
)
|
||||
.bind(&license_after.product_id)
|
||||
.fetch_one(&state.db)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(n_licenses_after, 1, "re-delivery must not duplicate licenses");
|
||||
|
||||
// Suppress unused-var warning: invoice_id is used implicitly via
|
||||
// the tier_changes lookup but kept named for readability.
|
||||
let _ = invoice_id;
|
||||
}
|
||||
|
||||
/// Buyer-initiated downgrade is rejected from this endpoint in v0.2.x
|
||||
/// (Phase 4 admin endpoint covers downgrades).
|
||||
#[tokio::test]
|
||||
async fn upgrade_endpoint_rejects_buyer_downgrade() {
|
||||
let (state, _tmp) = make_test_state().await;
|
||||
let (lic, _key, std_id, pro_id) = seed_perpetual_ladder_with_key(&state).await;
|
||||
|
||||
// Move the license to Pro by direct SQL so we can attempt a
|
||||
// downgrade back to Standard. (Real flow: admin would have done
|
||||
// this; we don't have an admin-change-tier endpoint until Phase 4.)
|
||||
sqlx::query("UPDATE licenses SET policy_id = ? WHERE id = ?")
|
||||
.bind(&pro_id)
|
||||
.bind(&lic)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Re-sign a key for the now-Pro license. We can reuse the same
|
||||
// license_id + product_id — the entitlements in the payload are
|
||||
// not checked by the upgrade endpoint (it goes by license_id).
|
||||
let license = repo::get_license_by_id(&state.db, &lic).await.unwrap().unwrap();
|
||||
let product_uuid = Uuid::parse_str(&license.product_id).unwrap();
|
||||
let payload = LicensePayload {
|
||||
version: 2,
|
||||
flags: 0,
|
||||
product_id: product_uuid,
|
||||
license_id: Uuid::parse_str(&lic).unwrap(),
|
||||
issued_at: Utc::now().timestamp(),
|
||||
expires_at: 0,
|
||||
fingerprint_hash: [0; 32],
|
||||
entitlements: vec![],
|
||||
};
|
||||
let signature = crypto::sign_payload(&state.keypair.signing, &payload);
|
||||
let key_string = crypto::encode_key(&payload, &signature);
|
||||
|
||||
let req = build_request(
|
||||
"POST",
|
||||
"/v1/upgrade",
|
||||
&[],
|
||||
Some(json!({
|
||||
"license_key": key_string,
|
||||
"target_policy_slug": "standard"
|
||||
})),
|
||||
);
|
||||
let resp = send(&state, req).await;
|
||||
// The quote function intercepts perpetual downgrades with a 400
|
||||
// "admin-only" before the endpoint's blanket-Forbidden check
|
||||
// fires. Either status is "this is not a buyer path"; the
|
||||
// message-level distinction matters more than the code.
|
||||
let status = resp.status();
|
||||
assert!(
|
||||
status == StatusCode::BAD_REQUEST || status == StatusCode::FORBIDDEN,
|
||||
"buyer-initiated downgrade must be 400 or 403; got {status}"
|
||||
);
|
||||
if status == StatusCode::BAD_REQUEST {
|
||||
let body = body_json(resp).await;
|
||||
assert!(
|
||||
body["message"].as_str().unwrap_or("").contains("admin-only"),
|
||||
"400 should explain that downgrades are admin-only: {body:?}"
|
||||
);
|
||||
}
|
||||
|
||||
let _ = std_id;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn buyer_cancel_rejects_garbage_key() {
|
||||
let (state, _tmp) = make_test_state().await;
|
||||
|
||||
Reference in New Issue
Block a user