Fix: seat upgrades stored the prorated delta instead of the absolute count #16

Merged
Cordy merged 2 commits from fix/abs-seats into main 2026-07-31 01:00:45 +00:00
Owner

Live bug found by Nikola in sandbox testing: upgrading 51→60 re-signed the license with 9 seats; the next change 60→61 stored 1. Cairn then showed "Seats: 0 / 50" because a 1-seat license falls below the free tier's floor.

Root cause: a subscription_update transaction's line items carry the prorated delta quantity (the seats being added for the remainder of the cycle), not the subscription's new absolute quantity. The webhook summed item quantities — correct for initial purchases and renewals, which bill the full quantity, and exactly wrong for updates.

Fix: on origin == "subscription_update", the webhook now reads the authoritative absolute quantity from the subscription entity (GET /subscriptions/{id}, summing item quantities) via a shared server.paddle client (the accounts API reuses the same instance instead of constructing its own). Guard rails: a failed lookup keeps the existing seat count rather than storing garbage (refreshWith treats 0 as "no change"), and an update for an unknown subscription can never fall through to minting a zero-seat license.

The regression test replays the live scenario end-to-end at the webhook layer: delta-9 transaction → license re-signed with 60 (from the subscription entity), expiry untouched; a renewal still extends and trusts its own full-quantity items; a failed lookup preserves the current count.

Nikola's existing license record still carries the bad seat count — one real seat change after deploy heals it, since the webhook will then store the absolute value.

Live bug found by Nikola in sandbox testing: upgrading 51→60 re-signed the license with **9** seats; the next change 60→61 stored **1**. Cairn then showed "Seats: 0 / 50" because a 1-seat license falls below the free tier's floor. Root cause: a `subscription_update` transaction's line items carry the **prorated delta quantity** (the seats being added for the remainder of the cycle), not the subscription's new absolute quantity. The webhook summed item quantities — correct for initial purchases and renewals, which bill the full quantity, and exactly wrong for updates. Fix: on `origin == "subscription_update"`, the webhook now reads the authoritative absolute quantity from the subscription entity (`GET /subscriptions/{id}`, summing item quantities) via a shared `server.paddle` client (the accounts API reuses the same instance instead of constructing its own). Guard rails: a failed lookup keeps the existing seat count rather than storing garbage (`refreshWith` treats 0 as "no change"), and an update for an unknown subscription can never fall through to minting a zero-seat license. The regression test replays the live scenario end-to-end at the webhook layer: delta-9 transaction → license re-signed with 60 (from the subscription entity), expiry untouched; a renewal still extends and trusts its own full-quantity items; a failed lookup preserves the current count. Nikola's existing license record still carries the bad seat count — one real seat change after deploy heals it, since the webhook will then store the absolute value.
Cordy merged commit 1bb60b8f74 into main 2026-07-31 01:00:45 +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#16
No description provided.