Design: quantity reduction at renewal (OUTSTANDING 14)
A reduction is a quantity operation like an addition's, through the same worker, record, key discipline and fence, with proration none, sent only in the last 72 hours of the period when no addition is pending, and its assignment dated at the period end so the allowance follows what was paid. The reconcile recognises the account's own confirmed reduction and does not file the hand-lowered review for it; a renewal that arrives first is provable when its quantity is the reduction's target. Reductions run on the fence proof alone (approval E), not on the proration policy (B). Nothing built; billing off.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1 file changed,
+73
−0
docs/superpowers/specs/2026-10-02-quantity-reduction-at-renewal-design.md+73 −0
CI passed — run details
diff --git a/docs/superpowers/specs/2026-10-02-quantity-reduction-at-renewal-design.md b/docs/superpowers/specs/2026-10-02-quantity-reduction-at-renewal-design.md| new file mode 100644 |
| index 000000000..2c18afed6 |
| --- /dev/null |
| +++ b/docs/superpowers/specs/2026-10-02-quantity-reduction-at-renewal-design.md |
| @@ -0,0 +1,73 @@ |
| +# Quantity reduction at renewal: design |
| + |
| +Status: design, written 2026-10-02 on `feat/carloku-quantity-reduction-20261002` (off the multi-currency branch at `68f6ee23`, which is not yet merged; this branch carries it). Nothing here is built. Billing is OFF. Nothing here writes to Stripe or to production. |
| + |
| +The approved pricing spec (`2026-09-07-carloku-pricing-plans-design.md`, "Stripe object model") says: "Member changes update the subscription quantity. Additions prorate; people who leave come off at renewal." Additions are built (`billingquantity.go`: the quantity worker, its operations, the fence). Reductions are not: `OUTSTANDING.md` item 14, "a renewal bills the old quantity", and team plans cannot be sold until it is closed. Today a removal is recorded as a quantity event with the effect `at_renewal`, the account's summary reads `at_renewal` when the desired quantity is below the billed one, and nothing acts on either. |
| + |
| +## What "comes off at renewal" means here |
| + |
| +1. **Money.** The seats a member occupied are paid through the end of the period they were paid for. Nothing is refunded or credited for the rest of the period. The renewal invoice bills the seats the account occupies then, never fewer than the plan's minimum. |
| +2. **Allowance.** The pooled allowance follows what was paid: the old quantity's allowance until the period ends, the new quantity's from the renewal. |
| +3. **Access.** The member who left is gone the moment they left (membership is the console's own table). Nothing about billing changes that. |
| + |
| +## The mechanism |
| + |
| +A reduction is a quantity operation like an addition's, sent through the same worker, the same record (`billing_quantity_operations`), the same idempotency key discipline and the same fence, with three differences: |
| + |
| +- **Proration behaviour is `none`, always.** Stripe changes the subscription item's quantity at once, writes no proration line, issues no credit, and bills the new quantity on the next invoice, which is the renewal. The proration policy (approval B) is about additions and does not apply: a reduction never prorates. |
| +- **It is sent only inside the renewal window:** the last 72 hours of the period (`quantityReductionWindow`), and only when the account has no unprocessed addition. Earlier than that, a member who leaves and is replaced inside the period would have the replacement's seat prorated and charged, though the seat was already paid for the whole period. Inside the window the exposure is at most 72 hours of one seat, and that only when a member joins after the reduction was sent, which raises the quantity again through the ordinary addition path. A reduction sent at the period's end exactly is never attempted (`created >= PeriodEnd` is refused, as for additions). |
| +- **Its assignment is dated at the period end, not at its instant.** The customer paid for the old quantity through the period; the allowance stays with it until the renewal. The reconcile that observes the lowered quantity (the worker's own, or a webhook's) records the state change with the assignment's `effective_at` equal to the period end, when it can show the lowered quantity is this operation's (below). |
| + |
| +The alternative, a Stripe subscription schedule with a second phase at the period end, was considered and rejected: a scheduled subscription refuses direct item updates, which the addition worker makes, and it needs a permission (`subscription_schedules` write) the runtime key does not have and the enablement plan does not list. The update with `proration_behavior=none` uses the call, the permission and the fence the additions already use. |
| + |
| +Entitlement publication (`queueEntitlementTx`) carries the billing row's quantity, which is lowered at once. That is correct: the quantity is what the account is charged for from the renewal, and the seats it bounds are the members present, who are already fewer. The allowance, which is what was paid for, comes from the assignment history and keeps the old quantity until the period end. |
| + |
| +## The worker |
| + |
| +`billingQuantityPass` lists, besides the accounts with pending additions and unacknowledged operations, every account whose summary is `at_renewal` and whose period ends within the window (`pendingQuantityAccounts` takes `now`). For such an account, with no unprocessed addition and no outstanding operation, `lowerQuantity` runs under the account's billing lock, as `raiseQuantity` does: |
| + |
| +1. Re-read the billing row. Not active, or over: nothing (an ended subscription's removals are closed below). |
| +2. The target is `desiredTeamQuantity` (members now, never below the plan's minimum). Target not below the billed quantity: nothing to send (the summary is refreshed). |
| +3. Read the provider's item (`quantityProviderItem`, the same checks: the console's subscription, active, the recorded period). Item quantity already at the target: nothing is sent; the reconcile records it (reference `observed`). Item quantity neither the billed one nor the target: `quantityMismatch` (a review), as for additions. |
| +4. `created = max(now, PeriodStart)`; `created >= PeriodEnd` sends nothing. Record the operation with `kind = reduction`, `behavior = none`, `from = billed`, `target`, no proration date, key `carloku-qty-<sqid>-<sub>-<periodStart>-<generation>-<target>-none-<created>-red`. `beginQuantityOperation` refuses when the world moved (an operation outstanding, the revision changed, a member came or went); nothing is sent then. |
| +5. `sendQuantityOperation` as for additions: the send lease, the request, the confirmation, the replayed-500 and refused-parameter branches. An answer with another item, quantity, price or currency is not this operation's. |
| +6. Reconcile (below) records the state. Then the account's unprocessed removal events up to the operation's generation are marked processed with the outcome `reduced_at_renewal` and the reference `<item>:quantity=<target>:none`, and the operation is acknowledged, in one transaction (`markQuantityReductionProcessed`, the removal counterpart of `markQuantityAdditionsProcessed`). `quantityOutcomesSQL` gains the outcome; it is the one list. |
| + |
| +A removal event of a subscription that is over, or of a subscription that is not the account's current one, is closed by `closeEndedQuantityAdditions` with the outcome `included` and the reference `subscription_ended:<sub>`, as an addition's is: there is nothing to lower. |
| + |
| +The worker's gate: a reduction runs when `CONSOLE_BILLING_QUANTITY_FENCE` records the sandbox verification (`billingQuantityFenceVerified`), whether or not a proration policy is set. The fence proof is about the provider's idempotency semantics, which a reduction relies on exactly as an addition does; the proration policy is about what an addition charges, which a reduction never does. `quantityWorker()` therefore answers two questions: may additions run (both switches), may reductions run (the fence alone). Until the fence is verified in Stripe test mode (approval E), nothing is sent either way, and reductions stay `at_renewal` in the summary, as today. |
| + |
| +## Reconcile and the assignment's date |
| + |
| +Today `reconcileBillingSubscription` treats a quantity that went down inside a period as a hand at the provider: it records the number and files `billing_quantity_lowered`. With reductions, a lowered quantity that is the account's own confirmed reduction operation (same subscription, same period, `from = state.Quantity`, `target = next.Quantity`, `kind = reduction`, `ConfirmedAt` set) is not that review's case: it is passed to `applyBillingStateDated` as the operation's change, with `quantityStart = PeriodEnd` and `quantityKind = reduction`. |
| + |
| +`applyBillingStateDated` gains the reduction's dating rule beside the addition's: an outstanding, confirmed reduction whose only change is the quantity, inside the recorded period, is dated at `next.PeriodEnd` when no assignment is later than that instant and no month from that instant's month on is frozen. A future-dated assignment is sound for `AllowanceForMonth`: assignments are read in `effective_at` order, then revision order, and the month that holds the period end gets the old quantity's span up to it and the new quantity's from it. The renewal that follows writes its own assignment at the same instant with a higher revision (equal instants are ordered by revision), which `AllowanceForMonth` accepts, and `applyBillingStateDated`'s renewal rule (`latest <= renewalStart`) holds with `latest == renewalStart`. |
| + |
| +A reduction that cannot be dated (another assignment already past the period end; the month frozen; the operation unconfirmed with no lease live) is recorded when observed and holds the account with the quantity-date review, as an undatable addition does. A lowered quantity that matches no operation of the account's is the hand case and files `billing_quantity_lowered`, as today. |
| + |
| +The renewal itself: `provableRenewal` requires `current.Quantity == next.Quantity`. A renewal event that arrives before the reconcile of the reduction (the webhook of the reduction lost or late, the renewal observed first) carries the new period and the lowered quantity together. That renewal is provable too when the lowered quantity is the account's confirmed reduction's target for the period that just ended: `provableRenewal` takes the operation and accepts `next.Quantity == op.Target` with `op.PeriodEnd == next.PeriodStart`. The renewal's assignment then carries the new quantity from `renewalStart`, which is what the reduction's own assignment would have said. The reduction's removal events are marked processed by the worker's next pass (the reconcile has landed; `markQuantityReductionProcessed` accepts an operation whose target the billing row already carries in the following period). |
| + |
| +## Membership during the window |
| + |
| +- A member added after the reduction was sent is an addition like any other: desired above billed, the ordinary path raises the quantity (prorated under the proration policy, for the hours left). The reduction's operation must be acknowledged first; one operation per account at a time holds, as today. |
| +- A member removed after the reduction was sent: a further removal event, `at_renewal`; the next pass inside the window may send a second reduction (a new operation, a new key) once the first is acknowledged. A reduction never sends below the plan's minimum. |
| +- The membership gate (`ErrMembershipGrowthHeld`) is unchanged: a reduction outstanding does not hold growth, because it charges nothing. |
| + |
| +## What is recorded, and the operator |
| + |
| +- The operation row carries `kind` (`addition` | `reduction`, added with a default of `addition` for existing rows), so a reader can tell what was sent and the reconcile can tell which dating rule applies. |
| +- Removal events carry `processed_at`, `processing_outcome = reduced_at_renewal` and the reference, so `billingQuantityEvents` shows when each departure came off and by which request. |
| +- The reviews that can be filed are the existing ones: `billing_quantity_mismatch` (the provider's number is neither the billed nor the target), `billing_quantity_unsettled:<serial>` (a replayed server error), the quantity-date review (undatable), `billing_quantity_lowered` (a lowering that is nobody's operation). No new review reason. |
| + |
| +## What must be verified in Stripe test mode before any of this runs (approval E) |
| + |
| +1. An item update with `proration_behavior=none` and no `proration_date`, inside a period, changes the subscription's quantity at once, writes no proration invoice item, issues no credit note, and the renewal invoice bills the new quantity. |
| +2. `customer.subscription.updated` for that update carries the new quantity with the unchanged period. |
| +3. The idempotency semantics the fence rests on (`TestSandboxQuantityFence`) hold for this request as for an addition's; the reduction's key is distinct from every addition's. |
| +4. A second update inside the window (a member returns) with the proration policy's behaviour prorates from the lowered quantity for the remaining hours. |
| + |
| +## What is not decided here |
| + |
| +- The window's length (72 hours) is a choice, stated with its reason; a shorter window lowers the exposure to a returning member's prorated seat and raises the exposure to a worker that was down. It is a constant, not a configuration. |
| +- Whether the customer is told, on the billing page, that seats come off at the next renewal with the date. Copy is Paul's; the page can state it from the summary's `at_renewal` and the period end once the copy exists. |
| +- Nothing here changes the runtime key's permissions: the item update is the same call the additions make, under the same deliberately withheld permission until B and the fence (ENABLEMENT §4). Reductions need the fence; they do not need B. |