Commit Graph

10 Commits (457eeeeaefbb6ddcc83defc58bd091502bbf7710)

Author SHA1 Message Date
nexxo 8c3a79e258 fix(billing): hold only what cannot be matched, and let migrations roll back
Two findings from reviewing all five phases together rather than one at a time.

The webhook held every event a handler answered `null` to — but `null` also
means "already recorded" and "deliberately skipped", which is every checkout's
own invoice and every redelivered renewal. Their contract is right there, so
`replayHeldFor()` could never come back for them, and ordinary Stripe traffic
silted up the holding area until the weekly prune. It now holds only what it
genuinely cannot match — and if the contract appeared in the meantime, applies
the event instead of dropping it: the creation race is narrow, but losing a
cancellation to it would leave us serving someone who had left.

Also fixes a rollback that predates this work and blocks `migrate:fresh` on
MariaDB entirely: dropping the unique index on customers.user_id fails while
the foreign key added one migration earlier still depends on it. Verified by
building all 33 migrations from nothing on MariaDB, rolling every one of them
back, and building them again.

458 tests green. Codex clean on the full branch diff.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 13:57:11 +02:00
nexxo 0560ae743d feat(billing): Stripe owns the billing cycle, we own capability
A Product per plan family and a Price per priced row of a published version,
plus the four subscribed webhook events that were arriving and being ignored.

`stripe:sync-catalogue` mirrors the catalogue. Idempotent twice over: a stored
id is skipped, and each call carries an idempotency key derived from our own
row, so a crash between Stripe creating a Price and us recording its id gives
back the same object rather than a second one. That matters more here than
usual — a Stripe Price cannot be edited or deleted, so a duplicate is
permanent. Drafts are not synced at all: a version that has promised nothing
has no business in a price list. --dry-run shows what would be created.

The webhook now handles invoice.paid, invoice.payment_failed,
customer.subscription.updated and customer.subscription.deleted. It never
re-derives an amount: the invoice is the authority for what was charged, and
recomputing it from our catalogue would produce a second, disagreeing answer.
A failed payment is recorded and nothing else — Stripe runs the dunning
schedule, and cutting a customer off on the first failure would punish an
expired card as though it were a refusal to pay.

Only a cycle renewal moves the term on. Stripe also sends paid invoices for
prorations and manual charges, and the checkout's own invoice — which is
already in the register as the purchase, and would otherwise double every
customer's first payment.

Stripe does not guarantee delivery order, which is the source of most of the
care here:
- state changes are judged and written in one transaction with the row held,
  so two deliveries cannot both pass a check made on a stale copy;
- an older event never overwrites a newer one, with a rank breaking ties
  between events sharing a second — their timestamps have one-second
  resolution, and a failed attempt and its successful retry routinely do;
- a term is never shortened, and an ended contract is never revived;
- an event for a contract that does not exist yet is HELD and replayed the
  moment it appears, rather than acknowledged and forgotten. A cancellation
  dropped that way would leave us serving someone who had already left. Held
  rows that never match are pruned after a week.

Register entries are keyed by what they are about — the invoice, the attempt,
the subscription — with a unique index doing the work, because a check two
concurrent deliveries can both pass is not idempotency.

PlanChange is now documented as the preview shown before someone confirms, not
the invoice: Stripe's proration accounts for tax, existing credit and the exact
second the change lands, and ours cannot.

Not run against the live account — `stripe:sync-catalogue` creates objects that
cannot be deleted, so that is the owner's call. The dry run lists 12.

457 tests green. Codex review clean after seven rounds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 13:36:28 +02:00
nexxo 7582df3de6 feat(billing): a proof register, and modules frozen at their booked price
Two things the contract could not answer on its own: what happened, and what
the customer's whole bill is.

`subscription_records` is append-only, one row per commercial event. Flat
columns for everything searched or relied on as evidence — event, customer,
subscription, plan family and version, term, net/tax/gross, currency, tax rate,
reverse charge, Stripe ids — PLUS a versioned JSON copy of the whole snapshot.
Not JSON alone: you cannot query it, and "the amount is in there somewhere" is
poor evidence. Copied, not joined, so a row still answers after the customer,
the plan or the version have gone.

The flat columns describe the TRANSACTION. Gross is what was actually taken;
net and tax are that gross split by the rate that applied on the day. A
discount lowers the taxable amount rather than creating negative VAT, and a
free checkout is recorded as free instead of as paid in full. What was agreed
sits beside it in the snapshot, so a charge that differs from the catalogue is
preserved as a question rather than reconciled away.

The register refuses to be rewritten, in bulk as well as one row at a time —
model events do not fire for `query()->update()`, which is exactly the shape a
careless data fix takes.

`subscription_addons` carries the owner's rule: a customer's total is their
subscription plus their modules, and all of it is frozen at what was agreed.
Price protection covering only the plan would let a module quietly double for
someone who booked it two years ago. A module they have NOT booked is a sale
still to be made, at today's price — so the catalogue is consulted only for
what is not in this table. Several bookings of one module are summed rather
than reduced to one arbitrary row, or the page and the bill would disagree.

Concurrency: one order books one module, enforced by a unique index rather than
a lookup two retries can both pass; cancellation is an atomic claim; and each
booking commits with its own register entry, so a failure cannot leave a sale
without evidence and a retry that finds the booking cannot skip the event.

Verified in the browser: with the module raised from 29,00 € to 59,00 € in the
catalogue, the customer who booked it still sees 29,00 € and a total of
208,00 €, while a new customer is quoted 59,00 €.

436 tests green. Codex review clean after seven rounds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 13:07:34 +02:00
nexxo f6ddf45dc0 feat(admin): a console for creating, pricing and scheduling plans
The owner can now do from the console what only a config edit could do before:
create a plan line, draft a version, price it, publish it into a window, and
pull a plan out of the shop.

Two pages and one modal, because the catalogue has exactly two levels. The
plan list is a name, a rank and a kill switch. Everything that can be got
wrong — capabilities, prices, windows — lives on the versions, where the page
is built around the one rule that matters: a draft is freely editable, a
published version is not touchable at all. So drafting and publishing are
separate acts, and publishing says plainly that it is final.

The console refuses everything the catalogue refuses, on the form rather than
as a stack trace: an overlapping window, a window that ends before it starts,
a version with no price for a term we sell on, and — new here — a version with
no VM template, which would be bought and then fail provisioning every time.
Numbers are bounded on both sides, because a mistyped price otherwise
overflows the column and answers with a 500 instead of saying which field was
wrong. Performance class and features are picked from the keys we have labels
for; free text would be frozen at publication and shown to customers as a raw
translation key forever.

Concurrency, since two admins share one catalogue: draft numbers are allocated
under a lock on the family, publication is an atomic conditional claim, and
discarding a draft is one statement conditional on it still being a draft —
otherwise a draft published in the meantime could be deleted out from under
the customers now contracted to it.

`plans.manage` (Owner and Admin) guards the pages themselves, not only the
buttons, and the modal authorises itself — prices, drafts and unreleased plans
are not something to leave readable to anyone who types the URL.

Also fixes the shared checkbox, which ticked in the browser's own blue on
every page that used one: a native checkbox ignores text colour and needs
accent-color.

Verified in the browser: withdrawing a plan in the console removes it from the
customer's billing page immediately, and restoring it brings it back. The
public landing page carries no catalogue-driven plan list, so there is nothing
there to hide.

421 tests green. Codex review clean after six rounds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 12:41:51 +02:00
nexxo 6387c747d0 feat(billing): the plan catalogue becomes three tables, and config stops selling
Plans lived in config/provisioning.php, where the owner cannot reach them and
where a plan has no history. They now live in plan_families / plan_versions /
plan_prices — a name that never moves, what the plan WAS at a point in time,
and a price per version and term with its own Stripe Price id.

- Availability is computed on every read (available_from <= now < until,
  half-open, UTC) plus a per-family sales kill switch. Nothing schedules a plan
  into or out of sale: a job that fails to run is a plan that silently
  misbehaves.
- Overlaps crash rather than resolve. currentVersion() uses sole(), and
  scheduling takes a lock on the family and rejects an overlapping window —
  two versions on sale at once would decide a customer's price by row order.
- A version is frozen from publication, not from first sale, and so is its
  price: a checkout is not instant, and an amount edited between the session
  opening and the webhook landing would contract someone at a price they were
  never quoted. Repricing publishes a new version, as Stripe requires anyway.
- Neither a published version, its price, nor a family with customers can be
  deleted, and a family key cannot be renamed. All of those would null the
  provenance off existing contracts.
- Subscriptions and orders record plan_version_id. A historical reference
  resolved by plan NAME hands back today's terms, which is the split-brain one
  level up. A checkout that carried its version is honoured even after the
  window closes — they paid for what they were shown.

Switched atomically and failing closed: config('provisioning.plans') and
plan_features are gone, and nothing falls back to them. A fallback would
resurrect a plan the owner had just switched off. The seed lives in the
migration, and PlanCatalogueTest pins what the config catalogue sold as the
shadow comparison. `php artisan plans:check` reports overlaps, gaps and
missing prices before a customer finds them.

Contract-backed displays, which were reading the live catalogue:
seat limits, the cloud card, the plan card, and admin MRR — the last three now
divide a yearly contract down via Subscription::monthlyPriceCents(), since all
three label the figure per month.

Two recovery gaps closed along the way: the order now commits before the
contract is opened (so a contract failure can never erase the record of a
payment), a Stripe retry repairs a missing contract and restarts a run stranded
with no_subscription, and TickProvisioning sweeps runs left pending by a crash
rather than only running/waiting ones.

402 tests green. Codex review clean after thirteen rounds. Verified in the
browser: portal, billing and console render unchanged off the new catalogue.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 12:05:56 +02:00
nexxo 6119044669 fix(billing): no plan changes after cancellation, no unknown terms
tests / pest (push) Successful in 6m46s Details
tests / assets (push) Successful in 19s Details
tests / release (push) Has been skipped Details
A cancelled subscription still reported an upgrade as allowed and priced it, so
a caller trusting that would have provisioned and billed a customer who had
already left. And any term string other than the two we support was silently
priced monthly while keeping the unknown value — a subscription whose price and
billing period disagree. Both are refused now.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 09:57:53 +02:00
nexxo 35324b986a fix(billing): no free upgrade in the last hours of a term
tests / pest (push) Successful in 6m48s Details
tests / assets (push) Successful in 19s Details
tests / release (push) Has been skipped Details
Flooring the remaining time made an upgrade with under a day left cost nothing,
and an upgrade requested after the period had ended cost nothing while being
applied immediately — the bigger plan for free. Remaining time now rounds up
while any service is left, and an expired period defers the change to the next
term instead of pricing it at zero.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 09:55:53 +02:00
nexxo 52b1acbb80 fix(billing): a scheduled downgrade must be applicable, and never cost money
tests / pest (push) Successful in 6m49s Details
tests / assets (push) Successful in 19s Details
tests / release (push) Has been skipped Details
Once the term is over, the downgrade has to be allowed — otherwise the job that
is supposed to carry it out never can, and the change waits forever for a date
that has already passed.

And a grandfathered plan can be cheaper than the smaller plan costs today, which
made the goodwill credit negative: an invoice for the privilege of downgrading.
Clamped to zero.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 09:54:16 +02:00
nexxo 460fac01b1 fix(billing): upgrade or downgrade is decided by rank, not by price
tests / pest (push) Successful in 6m51s Details
tests / assets (push) Successful in 20s Details
tests / release (push) Has been skipped Details
With grandfathered prices the comparison inverts: a business plan bought when
it cost less than today's team plan would have treated a move to team as an
upgrade — charged immediately, while the customer loses resources. The plan's
rank is frozen with the rest of the snapshot and decides the direction; prices
only decide the amount.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 09:52:28 +02:00
nexxo ac250071cb feat(billing): immutable subscription snapshots and pro-rata plan changes
The plan catalogue describes what we sell today; a customer who signed up last
year bought last year's terms. Every commercially relevant condition — price,
quotas, seats, the hardware behind the plan — is now frozen onto a subscription
at signup, and the model refuses to let any of it be rewritten afterwards. A
price rise applies to new subscriptions and cannot reach back into an existing
contract.

PlanChange holds the two rules, computed against the frozen price rather than
today's catalogue:

- Upgrading is immediate and pro rata: the new plan for the days left in the
  paid term, minus what the old plan was worth over those same days. On the
  last day of a month that is one day's difference, not a month's.
- Downgrading waits for the end of the term. A yearly customer bought a year
  and can move down when it is up; a month is a month. A mid-term downgrade is
  a goodwill decision, not a self-service button, and its credit covers only
  the unused part of the difference.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 09:49:48 +02:00