13 Commits (feat/mailboxes)
| Author | SHA1 | Message | Date |
|---|---|---|---|
|
|
85fcc154b8 |
Measure availability, and let a customer move down again
Availability is now counted from our own monitoring answers. Uptime Kuma knows the figure, but the REST bridge in front of it only answers "healthy, yes or no", and waiting for the bridge to grow an endpoint would have left the panel without the number indefinitely. The sync already asks on a schedule; counting the answers gives a percentage that is ours and explainable. An unanswered check is not a failed one — the counter only moves when a real answer arrived, so our monitoring going down does not become the customer's outage on the one figure they are asked to trust. An instance nobody has ever checked shows no availability rather than 100 %. The downgrade path existed nowhere. Going down is not the mirror of going up: it can ask an instance to hold more than the target plan allows. Smaller plans are now listed even when they cannot be taken, with the obstacle in the customer's own numbers — "you have 31 users, this package allows 10" — because a greyed-out button that does not name what is in the way is the thing people ring about. Storage is checked against the last real reading rather than the contractual allowance, or anyone who once bought a large plan could never leave it. The limit is re-checked in the action: a rule enforced only in markup is not enforced. And the smaller corrections from this round: - The cloud tab carried a hardcoded "B" as the instance's initial, and the PHP and MariaDB versions, which tell a tenant nothing they can act on. - "EU — Serverstandort im Angebot festgehalten" under a label already reading "Serverstandort" was two sentences saying nothing. It is the country. - Support promised a reply "binnen 4 Std." with nothing behind it. What is true everywhere else is an answer on the same working day. - The actions column appeared even when no row in the table had an action. 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> |
|
|
|
5aef5e693c |
fix(billing): verification vouches for a value, not for a row
tests / pest (push) Successful in 6m52s
Details
tests / assets (push) Successful in 20s
Details
tests / release (push) Has been skipped
Details
A timestamp alone said "some number was checked once": editing the field left it intact, so a customer could swap a verified foreign VAT ID for any plausible-looking one and keep zero-VAT pricing. The verified value is stored and compared, which makes the rule self-enforcing — no writer has to remember to clear a flag, and there are several writers. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
|
|
|
42ceafb57f |
fix(billing): a self-declared VAT ID must not zero the tax
tests / pest (push) Successful in 7m13s
Details
tests / assets (push) Successful in 22s
Details
tests / release (push) Has been skipped
Details
Any non-empty string starting with two characters other than AT switched the customer to reverse charge — typing "XX123" was a 20 % discount. Reverse charge now requires a VAT ID that is verified, belongs to an EU member state other than ours, and looks like a VAT number at all. Unverified is the normal state and means the domestic rate: over-collecting is correctable, under-collecting is a tax liability. Changing the number clears its verification. Verification itself (VIES) is not built yet, so reverse charge stays off until someone confirms a number — which is the safe direction to be wrong in. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
|
|
|
76510a59a3 |
fix(billing): VAT follows the customer, not a global setting
tests / pest (push) Successful in 7m28s
Details
tests / assets (push) Successful in 21s
Details
tests / release (push) Has been skipped
Details
Codex was right that this could misstate real charges: an EU business with a VAT ID registered in another country is billed under reverse charge, and we were adding 20 % Austrian VAT to their total anyway. TaxTreatment resolves it from the customer's VAT ID, and the whole page — cart, plan cards, add-on cards — now states one treatment instead of contradicting itself. Explicitly NOT handled: cross-border sales to private individuals, which are taxed at the buyer's national rate under OSS. That needs a maintained rate table and a tax adviser, not a guess, so those fall back to the domestic rate — over-collecting rather than under-collecting. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |