CluPilotCloud/app/Livewire
nexxo 8f2252e81b Book a downgrade for a date that cannot move, and sell a module once
Two ways of charging a customer for something they did not get.

A downgrade was due when `subscriptions.current_period_end` said so, and
Stripe pushes that column forward on every renewal. So a downgrade booked in
March was deferred to the end of April by April's renewal, and to the end of
May by May's — each time by a whole term, and each time the customer was
billed again for the package they had asked to leave. The due date was being
re-derived from a moving target on every scheduler tick.

It is decided once now, at the moment the customer decides, and stamped onto
the contract: `pending_plan` and `pending_effective_at`, the two columns that
have been in the schema since the first migration and were written by nothing
at all. The shop stamps them, PlanChange reads them instead of the period end
when a move is already booked, and clupilot:apply-due-plan-changes finds
contracts by the stamped date rather than orders by a date it recomputes.

The order stays what it always was — the customer's own record of the request,
what the cart shows them and what they remove to change their mind — so it is
still consumed with the change, and removing it unbooks the change. What it no
longer does is decide when. A second downgrade replaces the first rather than
queueing behind it; an upgrade clears one, or it would come due months later
and undo the bigger package; a contract cancelled before the date takes the
booking with it; and a customer with no contract can no longer place a
downgrade at all, which was accepted before and could never be carried out.

A booked change is now visible where the contract is read: on the plan card in
the portal, because the cart entry disappears when it is paid for and the
booking does not, and on the customer row in the console, because that is
where an operator answers "what is this customer on".

The second defect is the same money in one step: BookAddon guarded duplicates
per ORDER — the unique index on (order_id, addon_key) — so two orders for the
same module on one contract both went through. A stale tab or a double click
bought priority support twice.

But storage is deliberately sold in packs, and AddonCatalogue sums the
quantities for exactly that reason, so "refuse the second" is only right for
some of them. Which is which is declared beside each price in
config/provisioning.php (`sold_as`), because it is a commercial decision about
each module and not something to infer from a key: storage is a quantity;
off-site backups are on or off, support is prioritised or it is not, Collabora
Pro is one licence, and a machine answers to one own domain. An undeclared
module counts as an entitlement — the cheaper of the two mistakes — and a test
refuses to let one ship undeclared.

Enforced in the action, where the portal, the console's grant screen and a
webhook all pass, with the sentence the customer is shown rather than a
developer's. A retried webhook for the SAME order still gets its one booking
back; that is one purchase arriving twice, not two purchases. The portal stops
offering what would be refused — booked, or already waiting in the cart — and
goes on offering the pack.

What is missing is on the Stripe side and is not invented here: nothing moves
the subscription's item onto the new Price, so the renewal after a plan change
still bills the old plan at Stripe. StripeClient only creates and archives
catalogue objects, and swapping a price also needs the subscription ITEM id,
which nothing stores.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 17:51:37 +02:00
..
Admin Book a downgrade for a date that cannot move, and sell a module once 2026-07-29 17:51:37 +02:00
Auth Give people a way back in, and put the URL in English 2026-07-29 16:50:29 +02:00
Concerns Close the domain page to packages that may not have one 2026-07-29 16:20:21 +02:00
Backups.php Show times on the operator's clock, keep storing them in UTC 2026-07-27 17:32:21 +02:00
Billing.php Book a downgrade for a date that cannot move, and sell a module once 2026-07-29 17:51:37 +02:00
Cloud.php Apply a bought plan change instead of only pricing it 2026-07-29 17:18:55 +02:00
ConfirmCancelPackage.php fix(portal): no silent no-ops without a customer; maintenance visible per instance; maintenance form redesign 2026-07-25 19:10:44 +02:00
ConfirmCloseAccount.php fix(portal): no silent no-ops without a customer; maintenance visible per instance; maintenance form redesign 2026-07-25 19:10:44 +02:00
ConfirmDisableTwoFactor.php Replace native confirm() dialogs with the app's own modal pattern 2026-07-28 19:34:27 +02:00
ConfirmEndOtherSessions.php Show where an account is signed in, and let it sign the other places out 2026-07-28 23:38:17 +02:00
ConfirmRemoveOrder.php Book a downgrade for a date that cannot move, and sell a module once 2026-07-29 17:51:37 +02:00
ConfirmRevokeSeat.php Replace native confirm() dialogs with the app's own modal pattern 2026-07-28 19:34:27 +02:00
CustomDomain.php Serve the custom domain, not just announce it 2026-07-29 16:44:52 +02:00
CustomerProvisioning.php Serve the custom domain, not just announce it 2026-07-29 16:44:52 +02:00
Dashboard.php Prove a custom domain before serving it, and keep proving it 2026-07-29 14:47:51 +02:00
EditMailbox.php Merge credentials and infrastructure into one Integrations page 2026-07-29 01:49:39 +02:00
EditSeat.php Editing in modals, an update button that is not gated on a stale reading, and a support page that is real 2026-07-27 17:55:49 +02:00
Invoices.php Show times on the operator's clock, keep storing them in UTC 2026-07-27 17:32:21 +02:00
NewSupportRequest.php Editing in modals, an update button that is not gated on a stale reading, and a support page that is real 2026-07-27 17:55:49 +02:00
Sessions.php Show where an account is signed in, and let it sign the other places out 2026-07-28 23:38:17 +02:00
Settings.php Replace native confirm() dialogs with the app's own modal pattern 2026-07-28 19:34:27 +02:00
Support.php Editing in modals, an update button that is not gated on a stale reading, and a support page that is real 2026-07-27 17:55:49 +02:00
Users.php Replace native confirm() dialogs with the app's own modal pattern 2026-07-28 19:34:27 +02:00