Origin-aware refresh + self-service seat changes #13

Merged
Cordy merged 3 commits from feat/seats into main 2026-07-30 23:55:28 +00:00
Owner

Closes #11
Closes #12

#11 — upgrades no longer extend expiry. refresh() split into a thin wrapper (old contract, always extends — existing renewal tests untouched) and refreshWith(..., extend bool). The webhook passes extend = txn.Origin != "subscription_update", so Paddle's mid-cycle quantity-change transactions update seats and re-sign the same license id while leaving ExpiresAt exactly where it was; renewals behave as before. Regression test proves an upgrade leaves expiry untouched to the second and a subsequent renewal extends from the unchanged expiry.

#12POST /v1/licenses/{id}/seats. Org-admin only; license must belong to the caller's org, be unrevoked, and be subscription-backed (409 otherwise, with not-found/not-owned indistinguishable). Reads the subscription's current items from Paddle, PATCHes with the new quantity on the seat-carrying item and proration_billing_mode: "prorated_immediately", returns 202 — the license itself updates asynchronously when the prorated transaction completes and hits the webhook, and the instance installs the refreshed key at its next check-in. A Paddle 403 is logged with the key-scope hint since that's the almost-certain cause. End-to-end test against a fake Paddle asserts the exact PATCH body.

The commercial floor (>50 seats) is deliberately not duplicated server-side — the Paddle price's quantity minimum owns that rule.

Site UI ("Change seats" on account.html) follows separately; the endpoint is inert until PADDLE_API_KEY gains subscription write scope.

Closes #11 Closes #12 **#11 — upgrades no longer extend expiry.** `refresh()` split into a thin wrapper (old contract, always extends — existing renewal tests untouched) and `refreshWith(..., extend bool)`. The webhook passes `extend = txn.Origin != "subscription_update"`, so Paddle's mid-cycle quantity-change transactions update seats and re-sign the same license id while leaving `ExpiresAt` exactly where it was; renewals behave as before. Regression test proves an upgrade leaves expiry untouched to the second and a subsequent renewal extends from the unchanged expiry. **#12 — `POST /v1/licenses/{id}/seats`.** Org-admin only; license must belong to the caller's org, be unrevoked, and be subscription-backed (409 otherwise, with not-found/not-owned indistinguishable). Reads the subscription's current items from Paddle, PATCHes with the new quantity on the seat-carrying item and `proration_billing_mode: "prorated_immediately"`, returns **202** — the license itself updates asynchronously when the prorated transaction completes and hits the webhook, and the instance installs the refreshed key at its next check-in. A Paddle 403 is logged with the key-scope hint since that's the almost-certain cause. End-to-end test against a fake Paddle asserts the exact PATCH body. The commercial floor (>50 seats) is deliberately not duplicated server-side — the Paddle price's quantity minimum owns that rule. Site UI ("Change seats" on account.html) follows separately; the endpoint is inert until `PADDLE_API_KEY` gains subscription write scope.
Cordy merged commit f387b7ea7b into main 2026-07-30 23:55:28 +00:00
Sign in to join this conversation.
No reviewers
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#13
No description provided.