Commit Graph

2 Commits (fced87d23a0af2f07baa4a88680a72f7fb19bb49)

Author SHA1 Message Date
nexxo 8f1630ba91 Bill a booked module every month, and put it on the invoice
A module was never an item on the Stripe subscription, so it was charged in
the month it was booked and never again. And the renewal document was written
from our own contract snapshot, so it would not have said so even if the money
had been taken. Two halves of one fault, closed together.

Modules are items now. stripe:sync-catalogue mirrors a Product and a Price per
module on both terms — a Stripe Price carries its own interval, and a booking
frozen at an older figure needs its own Price, so they are keyed by module,
amount, currency and interval in stripe_addon_prices. Booking adds an item with
always_invoice: prorated by days AND charged there and then, which is the
invoice the owner wants for a module booked mid-term. A second storage pack is
a quantity on one item, not a second item. Cancelling takes the item off with
no proration — no credit, and the next cycle is simply smaller — while the
module itself is kept until the period end on subscription_addons.cancels_at
and ended by clupilot:end-cancelled-addons.

Documents are built from Stripe's own invoice lines. Stripe is what charged the
customer; a document assembled from our figures would state a sum that is not
the one taken. So every paid Stripe invoice gets one document with one number,
idempotent on stripe_invoice_id — a cycle carrying package and every module
together, a mid-period booking carrying just its prorated line, an upgrade
carrying its proration. Only the wording is ours: each line is named from the
catalogue rather than printed as a Price id, and a line nobody can name stays on
the document under Stripe's own description and is logged. The checkout's own
invoice is still the one exception; that purchase already has a document.

Stripe being away never undoes a booking that has already reached the machine.
The failure is parked on the contract in stripe_addon_sync and swept by the
same clupilot:sync-stripe-subscriptions that retries a plan change, which finds
its work from the bookings themselves rather than from the marker. A granted
contract has no Stripe subscription and is not touched at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 20:22:02 +02:00
nexxo 35b312d5ef Invoice every renewal, and tell Stripe when the package changes
Two things Stripe was doing on its own, without us.

**A renewal produced no invoice and no mail.** InvoiceMail went out from
exactly one place — the first purchase — so a customer paying every month
for a year received one invoice, for month one. Stripe already says when
the money lands: invoice.paid with billing_reason subscription_cycle. What
was missing was the document.

A renewal has no Order behind it, and one was not invented for the
occasion: an Order is something a customer bought, and writing a purchase
nobody made would corrupt the record of what they have actually ordered.
IssueInvoice::forBilledPeriod() issues from the CONTRACT instead, at its
frozen net price, for the term Stripe billed. Idempotent against the Stripe
invoice id, which is unique in the database rather than checked in PHP —
and because the number is drawn inside the same transaction as the row, a
redelivery takes its number back with it and the series keeps its sequence.
Nothing in the paperwork can fail the webhook: issuing and mailing are both
caught and logged, exactly as confirmByMail() does.

Only a cycle renewal gets a document. The checkout's own invoice already
has one; an upgrade's proration was worked out against Stripe's boundaries
and would not match a document written from our snapshot; a charge raised
by hand is for something this catalogue cannot describe. All three stay in
the register, and are logged once as money no document of ours covers.

**After a plan change, Stripe went on billing the old price.** There was no
way to move a subscription onto another price at all, so a customer who
upgraded paid for the smaller package every month afterwards. The client
can now swap one, the item id it needs is learnt from Stripe's own events
(and asked for once, for contracts older than the column), and the
behaviour is chosen per direction: an upgrade settles immediately, so the
cycle invoice stays exactly the contract price the renewal document states;
a downgrade lands at the period boundary and settles nothing.

A granted contract has no Stripe subscription and is left untouched. A
change that reaches the machine and not Stripe is never rolled back — it is
parked on the contract with the error and the behaviour it needed, logged
as an error, and retried hourly by clupilot:sync-stripe-subscriptions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 19:35:15 +02:00