From 5d7f68fef83917e7d1492cfb4699337b1cc7ec8c Mon Sep 17 00:00:00 2001 From: Grant Date: Fri, 8 May 2026 17:53:42 -0500 Subject: [PATCH] =?UTF-8?q?Recurring=20subs=20Phase=206=20=E2=80=94=20canc?= =?UTF-8?q?ellation=20flow=20(admin=20+=20buyer=20self-serve)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the recurring-subs feature loop: operators can cancel subs from the admin UI, buyers can self-cancel by submitting their signed license key. Cancellation is non-destructive — the license stays valid through end-of-cycle, the renewal worker just stops creating new invoices because its WHERE filter excludes status='cancelled'. New API - GET /v1/admin/subscriptions — list (filter: status=...) - POST /v1/admin/subscriptions/:id/cancel — operator cancel (audited) - POST /v1/subscriptions/cancel — buyer self-service; auth via license_key in body, verified by signature Repo helpers (src/subscriptions.rs) - get_subscription_by_id - get_subscription_by_license_id (1:1 unique on license_id, used by buyer self-service) - list_subscriptions(status_filter, limit) - cancel_subscription (idempotent UPDATE, returns whether it actually transitioned) Behavior details - Both endpoints fire `subscription.cancelled` webhook with actor=admin/buyer so operators can distinguish self-service. - Audit log differentiates by actor_kind: 'admin_api_key' vs 'buyer_license_key'. - Buyer endpoint returns 401 (not 404) on bad/wrong key so a probe can't enumerate which licenses have active subs. - Buyer endpoint returns 401 on revoked or suspended licenses too — same reason. - Admin endpoint returns 200 with `{already: }` on re-cancel (idempotency); 404 on unknown sub. Tests (+4, total now 57) - admin_cancel_subscription_happy_path: full flow + DB invariants + audit row + idempotency - admin_cancel_unknown_subscription_404s - buyer_cancel_subscription_via_license_key: full flow + actor_kind - buyer_cancel_rejects_garbage_key: 401 not 404 Admin UI for the cancel button + subscriptions tab lands in a follow-up commit (kept this one to the API surface so it's reviewable in isolation). --- licensing-service/src/api/mod.rs | 15 ++ licensing-service/src/api/subscriptions.rs | 285 +++++++++++++++++++++ licensing-service/src/subscriptions.rs | 91 +++++++ licensing-service/tests/api.rs | 251 ++++++++++++++++++ 4 files changed, 642 insertions(+) create mode 100644 licensing-service/src/api/subscriptions.rs diff --git a/licensing-service/src/api/mod.rs b/licensing-service/src/api/mod.rs index f112c50..fd03214 100644 --- a/licensing-service/src/api/mod.rs +++ b/licensing-service/src/api/mod.rs @@ -62,6 +62,7 @@ pub mod machines; pub mod policies; pub mod products; pub mod purchase; +pub mod subscriptions; pub mod buy_page; pub mod issuer_key; pub mod redeem; @@ -333,6 +334,20 @@ pub fn router(state: AppState) -> Router { get(policies::list_public_policies), ) .route("/v1/admin/tips", get(policies::list_tips)) + // Subscriptions (recurring billing) — admin list + cancel. + .route( + "/v1/admin/subscriptions", + get(subscriptions::admin_list), + ) + .route( + "/v1/admin/subscriptions/:id/cancel", + post(subscriptions::admin_cancel), + ) + // Buyer self-service cancel — auth via license key in the body. + .route( + "/v1/subscriptions/cancel", + post(subscriptions::buyer_cancel), + ) // Machines (admin views). .route("/v1/admin/machines", get(machines::admin_list)) .route( diff --git a/licensing-service/src/api/subscriptions.rs b/licensing-service/src/api/subscriptions.rs new file mode 100644 index 0000000..08d8ba0 --- /dev/null +++ b/licensing-service/src/api/subscriptions.rs @@ -0,0 +1,285 @@ +//! Subscriptions admin + buyer self-service API. +//! +//! This is the HTTP surface for the recurring-subscription state machine +//! whose underlying schema (migration 0011), helpers (`crate::subscriptions`), +//! and renewal worker live elsewhere. v0.2.x ships: +//! +//! - `GET /v1/admin/subscriptions` — list subscriptions (admin) +//! - `POST /v1/admin/subscriptions/:id/cancel` — operator-side cancel (admin) +//! - `POST /v1/subscriptions/cancel` — buyer self-service cancel; auth +//! is via the buyer's license key +//! (no admin token, no cookie). +//! +//! The cancel paths are deliberately split: admin cancellation is a +//! fully-trusted call by the operator (e.g. customer service flow, +//! refund follow-through), while the buyer-side endpoint requires the +//! caller to prove ownership by sending the signed license key. Both +//! paths share the same downstream behavior — flip status to +//! `cancelled`, stamp `cancelled_at`, fire a `subscription.cancelled` +//! webhook, write an audit row. +//! +//! Cancellation does NOT immediately revoke the license. The buyer +//! keeps access through the end of the current billing cycle (the +//! license's `expires_at` is unchanged); the renewal worker simply +//! stops creating new invoices because its query filters for +//! `status IN ('active', 'past_due')`. This matches industry +//! convention (Stripe, Zaprite, etc.) and avoids a UX where the +//! buyer cancels mid-month and immediately loses what they paid for. + +use crate::api::admin::{request_context, require_admin}; +use crate::api::AppState; +use crate::error::{AppError, AppResult}; +use axum::{ + extract::{Path, Query, State}, + http::HeaderMap, + Json, +}; +use serde::Deserialize; +use serde_json::{json, Value}; + +#[derive(Debug, Deserialize)] +pub struct ListQuery { + /// Filter on subscription status: 'active' | 'past_due' | 'cancelled' | 'lapsed'. + /// Omit to get all. + #[serde(default)] + pub status: Option, + #[serde(default = "default_limit")] + pub limit: i64, +} +fn default_limit() -> i64 { + 200 +} + +/// `GET /v1/admin/subscriptions` — list subscriptions for the admin UI. +/// Filterable by status. Returned newest-first; renders as a table in +/// the SPA's "Subscriptions" tab with action buttons (Cancel / View). +pub async fn admin_list( + State(state): State, + headers: HeaderMap, + Query(q): Query, +) -> AppResult> { + require_admin(&state, &headers)?; + if let Some(s) = q.status.as_deref() { + if !["active", "past_due", "cancelled", "lapsed"].contains(&s) { + return Err(AppError::BadRequest(format!( + "unknown status filter '{s}' (allowed: active, past_due, cancelled, lapsed)" + ))); + } + } + let subs = crate::subscriptions::list_subscriptions( + &state.db, + q.status.as_deref(), + q.limit, + ) + .await + .map_err(|e| AppError::Internal(e))?; + // Hand-shape JSON so we can include cancelled_at consistently and + // hide internal fields if any get added later. + let payload: Vec = subs + .into_iter() + .map(|s| { + json!({ + "id": s.id, + "license_id": s.license_id, + "policy_id": s.policy_id, + "product_id": s.product_id, + "period_days": s.period_days, + "listed_currency": s.listed_currency, + "listed_value": s.listed_value, + "status": s.status, + "started_at": s.started_at, + "next_renewal_at": s.next_renewal_at, + "cancelled_at": s.cancelled_at, + "consecutive_failures": s.consecutive_failures, + }) + }) + .collect(); + Ok(Json(json!({ "subscriptions": payload }))) +} + +#[derive(Debug, Deserialize, Default)] +pub struct CancelReq { + /// Optional free-form reason (audit log only — not user-visible). + #[serde(default)] + pub reason: Option, +} + +/// `POST /v1/admin/subscriptions/:id/cancel` — admin cancellation. +/// +/// Idempotent: cancelling a sub that's already cancelled (or lapsed) +/// returns 200 with `{ok: true, already: }`. Cancelling +/// a non-existent sub returns 404. +pub async fn admin_cancel( + State(state): State, + headers: HeaderMap, + Path(id): Path, + body: Option>, +) -> AppResult> { + let actor_hash = require_admin(&state, &headers)?; + let (ip, ua) = request_context(&headers); + let reason = body.and_then(|Json(b)| b.reason).filter(|s| !s.trim().is_empty()); + + let sub = crate::subscriptions::get_subscription_by_id(&state.db, &id) + .await + .map_err(AppError::Internal)? + .ok_or_else(|| AppError::NotFound(format!("subscription '{id}'")))?; + + let did_cancel = crate::subscriptions::cancel_subscription(&state.db, &id) + .await + .map_err(AppError::Internal)?; + + if !did_cancel { + // Already in a terminal state. + return Ok(Json(json!({ + "ok": true, + "already": sub.status, + "subscription_id": id, + }))); + } + + let _ = crate::db::repo::insert_audit( + &state.db, + "admin_api_key", + Some(&actor_hash), + "subscription.cancel", + Some("subscription"), + Some(&id), + ip.as_deref(), + ua.as_deref(), + &json!({ + "license_id": sub.license_id, + "product_id": sub.product_id, + "policy_id": sub.policy_id, + "reason": reason, + "actor": "admin", + }), + ) + .await; + + crate::webhooks::dispatch( + &state, + "subscription.cancelled", + &json!({ + "subscription_id": id, + "license_id": sub.license_id, + "product_id": sub.product_id, + "policy_id": sub.policy_id, + "actor": "admin", + "reason": reason, + }), + ) + .await; + + Ok(Json(json!({ + "ok": true, + "subscription_id": id, + "status": "cancelled", + }))) +} + +#[derive(Debug, Deserialize)] +pub struct BuyerCancelReq { + /// The buyer's full license key (LIC1...). Used as proof-of-ownership + /// — we re-validate it (signature + DB row) before honoring the + /// cancellation. There is no admin token / no cookie / no email. + pub license_key: String, + #[serde(default)] + pub reason: Option, +} + +/// `POST /v1/subscriptions/cancel` — buyer self-service cancellation. +/// +/// Authentication: the request body carries the full signed license key. +/// We decode + verify the signature, look up the license, then resolve +/// the subscription tied to that license_id. This means a cancellation +/// CAN be initiated by anyone holding the key — which is the same +/// trust model as the rest of `/v1/validate`. If the buyer has shared +/// their key, that's already a security problem they need to rotate. +/// +/// Returns the same shape as the admin endpoint so SDK code paths can +/// share a parser. Fires the `subscription.cancelled` webhook with +/// `actor=buyer` so operators can distinguish self-service cancels. +pub async fn buyer_cancel( + State(state): State, + headers: HeaderMap, + Json(body): Json, +) -> AppResult> { + let (ip, ua) = request_context(&headers); + + // Verify the license key against our pubkey + DB row. + // parse_key returns (payload, signature, signed_bytes). verify_payload + // takes the raw signed_bytes (NOT a re-serialized payload, because v1 + // keys round-trip through a different serializer and we'd break the + // signature if we re-encoded). + let (payload, signature, signed_bytes) = + crate::crypto::parse_key(&body.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() { + // Don't leak revocation state via a 404; treat as not-authorized. + return Err(AppError::Unauthorized); + } + + let sub = crate::subscriptions::get_subscription_by_license_id(&state.db, &license_id) + .await + .map_err(AppError::Internal)? + .ok_or_else(|| AppError::NotFound("no subscription tied to this license".into()))?; + + let reason = body.reason.filter(|s| !s.trim().is_empty()); + + let did_cancel = crate::subscriptions::cancel_subscription(&state.db, &sub.id) + .await + .map_err(AppError::Internal)?; + + if !did_cancel { + return Ok(Json(json!({ + "ok": true, + "already": sub.status, + "subscription_id": sub.id, + }))); + } + + let _ = crate::db::repo::insert_audit( + &state.db, + "buyer_license_key", + Some(&license_id), + "subscription.cancel", + Some("subscription"), + Some(&sub.id), + ip.as_deref(), + ua.as_deref(), + &json!({ + "license_id": sub.license_id, + "product_id": sub.product_id, + "policy_id": sub.policy_id, + "reason": reason, + "actor": "buyer", + }), + ) + .await; + + crate::webhooks::dispatch( + &state, + "subscription.cancelled", + &json!({ + "subscription_id": sub.id, + "license_id": sub.license_id, + "product_id": sub.product_id, + "policy_id": sub.policy_id, + "actor": "buyer", + "reason": reason, + }), + ) + .await; + + Ok(Json(json!({ + "ok": true, + "subscription_id": sub.id, + "status": "cancelled", + }))) +} diff --git a/licensing-service/src/subscriptions.rs b/licensing-service/src/subscriptions.rs index 8c335c3..6c6153c 100644 --- a/licensing-service/src/subscriptions.rs +++ b/licensing-service/src/subscriptions.rs @@ -215,6 +215,97 @@ pub async fn find_subscription_for_invoice( Ok(row.map(|r| r.get::("subscription_id"))) } +/// Look up a subscription by id. +pub async fn get_subscription_by_id( + pool: &SqlitePool, + sub_id: &str, +) -> Result> { + let row = sqlx::query(&format!( + "SELECT {SUB_COLS} FROM subscriptions WHERE id = ?" + )) + .bind(sub_id) + .fetch_optional(pool) + .await + .context("get_subscription_by_id")?; + Ok(row.map(row_to_subscription)) +} + +/// Look up the subscription tied to a given license_id. There's at +/// most one (the schema enforces 1:1 via UNIQUE on license_id) — used +/// by the buyer self-service cancel endpoint, which authenticates via +/// license key, not subscription id. +pub async fn get_subscription_by_license_id( + pool: &SqlitePool, + license_id: &str, +) -> Result> { + let row = sqlx::query(&format!( + "SELECT {SUB_COLS} FROM subscriptions WHERE license_id = ?" + )) + .bind(license_id) + .fetch_optional(pool) + .await + .context("get_subscription_by_license_id")?; + Ok(row.map(row_to_subscription)) +} + +/// List all subscriptions, optionally filtered by status. Used by the +/// admin UI's subscriptions tab. Sorted newest-first by started_at. +pub async fn list_subscriptions( + pool: &SqlitePool, + status_filter: Option<&str>, + limit: i64, +) -> Result> { + let limit = limit.clamp(1, 1000); + let rows = if let Some(s) = status_filter { + sqlx::query(&format!( + "SELECT {SUB_COLS} FROM subscriptions WHERE status = ? \ + ORDER BY started_at DESC LIMIT ?" + )) + .bind(s) + .bind(limit) + .fetch_all(pool) + .await + } else { + sqlx::query(&format!( + "SELECT {SUB_COLS} FROM subscriptions \ + ORDER BY started_at DESC LIMIT ?" + )) + .bind(limit) + .fetch_all(pool) + .await + } + .context("list_subscriptions query")?; + Ok(rows.into_iter().map(row_to_subscription).collect()) +} + +/// Mark a subscription as cancelled. The license stays valid through +/// the end of the current cycle (per design doc — no immediate +/// revoke); the renewal worker's `WHERE status IN ('active', 'past_due')` +/// filter ensures cancelled subs simply stop renewing. Idempotent — +/// re-cancelling an already-cancelled sub is a no-op (returns Ok). +pub async fn cancel_subscription( + pool: &SqlitePool, + sub_id: &str, +) -> Result { + let now = Utc::now().to_rfc3339(); + let rows = sqlx::query( + "UPDATE subscriptions \ + SET status = 'cancelled', cancelled_at = ?, updated_at = ? \ + WHERE id = ? AND status IN ('active', 'past_due')", + ) + .bind(&now) + .bind(&now) + .bind(sub_id) + .execute(pool) + .await + .context("cancel_subscription")? + .rows_affected(); + // rows_affected = 0 means the sub was already cancelled, lapsed, + // or doesn't exist. Return false so the caller can decide whether + // that's a 404 (caller already verified existence) or a no-op. + Ok(rows > 0) +} + /// Atomic creation of a subscription + the first cycle's invoice. /// Used at purchase time when an operator's policy has /// `is_recurring = 1`. Not invoked by the worker (the worker diff --git a/licensing-service/tests/api.rs b/licensing-service/tests/api.rs index 82ad69e..7cc1a4f 100644 --- a/licensing-service/tests/api.rs +++ b/licensing-service/tests/api.rs @@ -2091,3 +2091,254 @@ async fn edit_policy_to_recurring_respects_tier_gate() { "name-only patch on a recurring policy must not re-fire the tier gate" ); } + +// --------------------------------------------------------------------- +// Subscription cancellation (Phase 6) +// +// Admin cancel: full trust, just needs the bearer token + the sub id. +// Buyer cancel: auth via license key in the body. The cancelled state +// is terminal — license stays valid through end-of-cycle, renewal +// worker stops creating new invoices, webhook fires. +// --------------------------------------------------------------------- + +/// Helper: seed a license + active subscription tied to it, plus a +/// product + recurring policy. Returns (license_id, sub_id, key_string) +/// where `key_string` is the signed license key the buyer would have +/// in hand (used by the self-service cancel test). +async fn seed_subscription(state: &AppState) -> (String, String, String) { + let product = repo::create_product( + &state.db, + "sub-cancel-prod", + "Cancel Test", + "", + 25_000, + &json!({}), + ) + .await + .expect("create_product"); + let policy = repo::create_policy( + &state.db, + &product.id, + "Monthly", + "monthly", + 30 * 86_400, + 0, + 1, + false, + None, + &[], + &json!({}), + None, + 0, + None, + repo::RecurringConfig { + is_recurring: true, + renewal_period_days: 30, + grace_period_days: 7, + trial_days: 0, + }, + ) + .await + .expect("create_policy"); + + 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(&policy.id), + None, + 0, + 1, + &[], + false, + None, + None, + ) + .await + .expect("create_license"); + + // Seed a placeholder cycle-1 invoice so the FK on subscription_invoices + // is satisfiable — the invoice details don't matter for the cancel + // tests, only that a row exists. + let invoice_id = Uuid::new_v4().to_string(); + sqlx::query( + "INSERT INTO invoices(id, btcpay_invoice_id, product_id, amount_sats, \ + checkout_url, status, created_at, updated_at, listed_currency, \ + listed_value, policy_id) \ + VALUES(?, ?, ?, 0, ?, 'pending', ?, ?, 'SAT', 0, ?)", + ) + .bind(&invoice_id) + .bind(&format!("test-inv-{}", &invoice_id[..8])) + .bind(&product.id) + .bind("http://test.invalid/inv") + .bind(issued_at.to_rfc3339()) + .bind(issued_at.to_rfc3339()) + .bind(&policy.id) + .execute(&state.db) + .await + .expect("seed invoice"); + + let sub = keysat::subscriptions::create_subscription( + &state.db, + &license_id.to_string(), + &policy.id, + &product.id, + 30, + "SAT", + 25_000, + &invoice_id, + ) + .await + .expect("create_subscription"); + + // Build a real signed key the buyer-cancel endpoint can verify. + 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![], + }; + let signature = crypto::sign_payload(&state.keypair.signing, &payload); + let key_string = crypto::encode_key(&payload, &signature); + + (license_id.to_string(), sub.id, key_string) +} + +#[tokio::test] +async fn admin_cancel_subscription_happy_path() { + let (state, _tmp) = make_test_state().await; + let auth = format!("Bearer {}", TEST_ADMIN_KEY); + let (_license_id, sub_id, _key) = seed_subscription(&state).await; + + // Cancel. + let req = build_request( + "POST", + &format!("/v1/admin/subscriptions/{}/cancel", sub_id), + &[("authorization", &auth)], + Some(json!({"reason": "customer requested"})), + ); + let resp = send(&state, req).await; + assert_eq!(resp.status(), StatusCode::OK); + let body = body_json(resp).await; + assert_eq!(body["ok"], true); + assert_eq!(body["status"], "cancelled"); + + // DB row reflects the new state + cancelled_at is stamped. + let (status, cancelled_at): (String, Option) = sqlx::query_as( + "SELECT status, cancelled_at FROM subscriptions WHERE id = ?", + ) + .bind(&sub_id) + .fetch_one(&state.db) + .await + .unwrap(); + assert_eq!(status, "cancelled"); + assert!(cancelled_at.is_some(), "cancelled_at must be stamped"); + + // Audit row exists. + let n: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM audit_log WHERE action = 'subscription.cancel' \ + AND target_id = ?", + ) + .bind(&sub_id) + .fetch_one(&state.db) + .await + .unwrap(); + assert_eq!(n, 1, "exactly one audit row for the cancel"); + + // Idempotency: cancelling a cancelled sub returns ok with the prior state. + let req = build_request( + "POST", + &format!("/v1/admin/subscriptions/{}/cancel", sub_id), + &[("authorization", &auth)], + Some(json!({})), + ); + let resp = send(&state, req).await; + assert_eq!(resp.status(), StatusCode::OK); + let body = body_json(resp).await; + assert_eq!(body["already"], "cancelled"); +} + +#[tokio::test] +async fn admin_cancel_unknown_subscription_404s() { + let (state, _tmp) = make_test_state().await; + let auth = format!("Bearer {}", TEST_ADMIN_KEY); + + let req = build_request( + "POST", + "/v1/admin/subscriptions/no-such-sub/cancel", + &[("authorization", &auth)], + Some(json!({})), + ); + let resp = send(&state, req).await; + assert_eq!(resp.status(), StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn buyer_cancel_subscription_via_license_key() { + let (state, _tmp) = make_test_state().await; + let (_license_id, sub_id, key_string) = seed_subscription(&state).await; + + // Buyer self-cancels by POSTing the signed key. No admin auth. + let req = build_request( + "POST", + "/v1/subscriptions/cancel", + &[], + Some(json!({ + "license_key": key_string, + "reason": "no longer needed" + })), + ); + let resp = send(&state, req).await; + assert_eq!( + resp.status(), + StatusCode::OK, + "buyer cancel should succeed with a valid key" + ); + let body = body_json(resp).await; + assert_eq!(body["status"], "cancelled"); + assert_eq!(body["subscription_id"], sub_id); + + // Audit row carries actor=buyer. + let actor: Option = sqlx::query_scalar( + "SELECT actor_kind FROM audit_log WHERE target_id = ? \ + AND action = 'subscription.cancel'", + ) + .bind(&sub_id) + .fetch_optional(&state.db) + .await + .unwrap(); + assert_eq!( + actor.as_deref(), + Some("buyer_license_key"), + "audit must record the buyer-key actor kind" + ); +} + +#[tokio::test] +async fn buyer_cancel_rejects_garbage_key() { + let (state, _tmp) = make_test_state().await; + let _ = seed_subscription(&state).await; + + let req = build_request( + "POST", + "/v1/subscriptions/cancel", + &[], + Some(json!({"license_key": "not-a-real-key"})), + ); + let resp = send(&state, req).await; + assert_eq!( + resp.status(), + StatusCode::UNAUTHORIZED, + "garbage key must be 401, not 404 — don't leak which subs exist" + ); +}