Recurring subs Phase 6 — cancellation flow (admin + buyer self-serve)

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: <prior_state>}` 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).
This commit is contained in:
Grant
2026-05-08 17:53:42 -05:00
parent c301eacfaa
commit 5d7f68fef8
4 changed files with 642 additions and 0 deletions
+15
View File
@@ -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(
+285
View File
@@ -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<String>,
#[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<AppState>,
headers: HeaderMap,
Query(q): Query<ListQuery>,
) -> AppResult<Json<Value>> {
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<Value> = 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<String>,
}
/// `POST /v1/admin/subscriptions/:id/cancel` — admin cancellation.
///
/// Idempotent: cancelling a sub that's already cancelled (or lapsed)
/// returns 200 with `{ok: true, already: <prior_state>}`. Cancelling
/// a non-existent sub returns 404.
pub async fn admin_cancel(
State(state): State<AppState>,
headers: HeaderMap,
Path(id): Path<String>,
body: Option<Json<CancelReq>>,
) -> AppResult<Json<Value>> {
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<String>,
}
/// `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<AppState>,
headers: HeaderMap,
Json(body): Json<BuyerCancelReq>,
) -> AppResult<Json<Value>> {
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",
})))
}
+91
View File
@@ -215,6 +215,97 @@ pub async fn find_subscription_for_invoice(
Ok(row.map(|r| r.get::<String, _>("subscription_id")))
}
/// Look up a subscription by id.
pub async fn get_subscription_by_id(
pool: &SqlitePool,
sub_id: &str,
) -> Result<Option<Subscription>> {
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<Option<Subscription>> {
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<Vec<Subscription>> {
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<bool> {
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
+251
View File
@@ -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<String>) = 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<String> = 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"
);
}