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
+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"
);
}