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(