Subscription cancellation from the dashboard (cancel at period end + resume) + checkin reason field #23
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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 (resolveSeatChangepattern: session → admin role → ownership → unrevoked → subscription-backed). Calls PaddlePOST /subscriptions/{sub}/cancelwith{"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. StoreCancelAt(the period end Paddle returns) on the record.POST /v1/licenses/{id}/cancel/resume— undo before period end: PaddlePATCH /subscriptions/{sub}with{"scheduled_change": null}; clearCancelAt.paddleAPIneeds aposthelper (get/patch exist).PADDLE_WEBHOOKside needs no change —subscription.canceledstays acknowledge-only, which is already correct.CancelAtinrefreshWithwhen a renewal transaction lands (covers resume done via Paddle-side tooling).cancelAtin theGET /v1/licenseslicView.Dashboard (cairn-site account.html):
subscription && !cancelAt→ "Cancel renewal" (inline confirm bar, house pattern — noconfirm()), copy stating exactly what happens: "Renewal stops. The license stays valid until YYYY-MM-DD, then the instance falls back to the free tier."cancelAtset → badge "renews until YYYY-MM-DD, then expires" + "Resume renewal" button.Checkin
reason(server half of Cairn#late-renewal-sync)POST /v1/checkincurrently 403s identically for unknown / revoked / expired. Add areasonfield:{"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 (
refreshWithfinds the record byPaddleSuband 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.
Shipped and deployed (PR #24 on the server, cairn-site PR #10 for the dashboard; both live).
POST /v1/licenses/{id}/cancel(Paddle period-end cancel,CancelAtrecorded + surfaced ascancelAt),POST .../cancel/resume, renewals clearCancelAt, checkin 403 now carriesreason(consumed by Cairn v0.6.63 / #308's 180-day watch).cancels YYYY-MM-DDbadge + 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_KEYneeds read+write on Subscriptions (same scope the seat flow already uses).