10 Commits (457eeeeaefbb6ddcc83defc58bd091502bbf7710)
| Author | SHA1 | Message | Date |
|---|---|---|---|
|
|
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> |
|
|
|
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> |
|
|
|
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> |
|
|
|
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> |
|
|
|
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>
|
|
|
|
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> |
|
|
|
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> |
|
|
|
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> |
|
|
|
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> |
|
|
|
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> |