Subscription cancellation from the dashboard (cancel at period end + resume) + checkin reason field #23

Closed
opened 2026-08-24 00:57:01 +00:00 by Cordy · 1 comment
Owner

Flagged by Nikola 2026-08-24. Today there is no cancellation path at all — no endpoint, no UI. A subscriber cannot stop renewal without emailing sales.

Design

Server (this repo):

  • POST /v1/licenses/{id}/cancel — org-admin gated, same validation chain as the seat endpoints (resolveSeatChange pattern: session → admin role → ownership → unrevoked → subscription-backed). Calls Paddle POST /subscriptions/{sub}/cancel with {"effective_from": "next_billing_period"} — the customer keeps the period they paid for; the license then simply runs to its expiry (+3d margin) and check-in starts 403ing. Store CancelAt (the period end Paddle returns) on the record.
  • POST /v1/licenses/{id}/cancel/resume — undo before period end: Paddle PATCH /subscriptions/{sub} with {"scheduled_change": null}; clear CancelAt.
  • paddleAPI needs a post helper (get/patch exist). PADDLE_WEBHOOK side needs no change — subscription.canceled stays acknowledge-only, which is already correct.
  • Clear CancelAt in refreshWith when a renewal transaction lands (covers resume done via Paddle-side tooling).
  • Surface cancelAt in the GET /v1/licenses licView.

Dashboard (cairn-site account.html):

  • License card: when subscription && !cancelAt → "Cancel renewal" (inline confirm bar, house pattern — no confirm()), copy stating exactly what happens: "Renewal stops. The license stays valid until YYYY-MM-DD, then the instance falls back to the free tier."
  • When cancelAt set → badge "renews until YYYY-MM-DD, then expires" + "Resume renewal" button.
  • Action keeps its name through the flow (Cancel renewal → Renewal cancelled).

Checkin reason (server half of Cairn#late-renewal-sync)

POST /v1/checkin currently 403s identically for unknown / revoked / expired. Add a reason field: {"error": "...", "reason": "expired"|"revoked"|"unknown"} so the instance can distinguish "keep trying, a renewal may land" (expired) from "stop" (revoked). Backwards-compatible — old clients ignore the field. The client half is filed on the Cairn repo.

Retention (decided, no code)

License records are kept indefinitely — that is what makes late renewal re-sync work (refreshWith finds the record by PaddleSub and re-signs the same license id, so the instance's next successful check-in delivers the new key with no re-ingestion). Size math: ~0.5 KB/record → 10k licenses ≈ 5 MB JSON; no pruning needed at any realistic scale. Revisit only at the planned SQLite swap. Recorded in the Wiki.js architecture page.

Edge documented, not solved here: a subscription that is fully cancelled and later re-purchased is a new Paddle subscription → new license id → that one key genuinely needs re-ingesting. Dashboard copy should say so on the cancel confirm.

Flagged by Nikola 2026-08-24. Today there is **no cancellation path at all** — no endpoint, no UI. A subscriber cannot stop renewal without emailing sales. ## Design **Server (this repo):** - `POST /v1/licenses/{id}/cancel` — org-admin gated, same validation chain as the seat endpoints (`resolveSeatChange` pattern: session → admin role → ownership → unrevoked → subscription-backed). Calls Paddle `POST /subscriptions/{sub}/cancel` with `{"effective_from": "next_billing_period"}` — the customer keeps the period they paid for; the license then simply runs to its expiry (+3d margin) and check-in starts 403ing. Store `CancelAt` (the period end Paddle returns) on the record. - `POST /v1/licenses/{id}/cancel/resume` — undo before period end: Paddle `PATCH /subscriptions/{sub}` with `{"scheduled_change": null}`; clear `CancelAt`. - `paddleAPI` needs a `post` helper (get/patch exist). `PADDLE_WEBHOOK` side needs no change — `subscription.canceled` stays acknowledge-only, which is already correct. - Clear `CancelAt` in `refreshWith` when a renewal transaction lands (covers resume done via Paddle-side tooling). - Surface `cancelAt` in the `GET /v1/licenses` licView. **Dashboard (cairn-site account.html):** - License card: when `subscription && !cancelAt` → "Cancel renewal" (inline confirm bar, house pattern — no `confirm()`), copy stating exactly what happens: *"Renewal stops. The license stays valid until YYYY-MM-DD, then the instance falls back to the free tier."* - When `cancelAt` set → badge "renews until YYYY-MM-DD, then expires" + "Resume renewal" button. - Action keeps its name through the flow (Cancel renewal → Renewal cancelled). ## Checkin `reason` (server half of Cairn#late-renewal-sync) `POST /v1/checkin` currently 403s identically for unknown / revoked / expired. Add a `reason` field: `{"error": "...", "reason": "expired"|"revoked"|"unknown"}` so the instance can distinguish "keep trying, a renewal may land" (expired) from "stop" (revoked). Backwards-compatible — old clients ignore the field. The client half is filed on the Cairn repo. ## Retention (decided, no code) License records are **kept indefinitely** — that is what makes late renewal re-sync work (`refreshWith` finds the record by `PaddleSub` and re-signs the *same* license id, so the instance's next successful check-in delivers the new key with no re-ingestion). Size math: ~0.5 KB/record → 10k licenses ≈ 5 MB JSON; no pruning needed at any realistic scale. Revisit only at the planned SQLite swap. Recorded in the Wiki.js architecture page. Edge documented, not solved here: a subscription that is fully cancelled and later **re-purchased** is a new Paddle subscription → new license id → that one key genuinely needs re-ingesting. Dashboard copy should say so on the cancel confirm.
Cordy closed this issue 2026-08-24 01:09:55 +00:00
Author
Owner

Shipped and deployed (PR #24 on the server, cairn-site PR #10 for the dashboard; both live).

  • Server: POST /v1/licenses/{id}/cancel (Paddle period-end cancel, CancelAt recorded + surfaced as cancelAt), POST .../cancel/resume, renewals clear CancelAt, checkin 403 now carries reason (consumed by Cairn v0.6.63 / #308's 180-day watch).
  • Dashboard: subscription licenses show Cancel renewal with inline confirm (copy: stays valid until expiry → free-tier fallback → re-purchase mints a new key), scheduled state shows a cancels YYYY-MM-DD badge + Resume renewal.

Dogfood (needs the Paddle sandbox): buy a sandbox monthly license → Cancel renewal → badge appears, Paddle sandbox shows the scheduled cancellation → Resume → badge clears, Paddle shows it removed. Non-subscription (admin-issued) licenses correctly refuse with "not subscription-backed". Note PADDLE_API_KEY needs read+write on Subscriptions (same scope the seat flow already uses).

**Shipped and deployed** (PR #24 on the server, cairn-site PR #10 for the dashboard; both live). - Server: `POST /v1/licenses/{id}/cancel` (Paddle period-end cancel, `CancelAt` recorded + surfaced as `cancelAt`), `POST .../cancel/resume`, renewals clear `CancelAt`, checkin 403 now carries `reason` (consumed by Cairn v0.6.63 / #308's 180-day watch). - Dashboard: subscription licenses show **Cancel renewal** with inline confirm (copy: stays valid until expiry → free-tier fallback → re-purchase mints a new key), scheduled state shows a `cancels YYYY-MM-DD` badge + **Resume renewal**. **Dogfood (needs the Paddle sandbox):** buy a sandbox monthly license → Cancel renewal → badge appears, Paddle sandbox shows the scheduled cancellation → Resume → badge clears, Paddle shows it removed. Non-subscription (admin-issued) licenses correctly refuse with "not subscription-backed". Note `PADDLE_API_KEY` needs read+write on Subscriptions (same scope the seat flow already uses).
Sign in to join this conversation.
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: Cordy/cairn-license-server#23
No description provided.