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:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user