31 Commits (b3651a14be91cddb8e9352c223d0079880d475a5)
| Author | SHA1 | Message | Date |
|---|---|---|---|
|
|
b3651a14be |
Decide who may have their own domain, and decide it in one place
tests / pest (push) Failing after 9m46s
Details
tests / assets (push) Successful in 21s
Details
tests / release (push) Has been skipped
Details
The domain page shipped with no access control at all: the `custom_domain`
plan feature was checked nowhere in the codebase, and every customer on
every package could point a domain at their instance. This is the owner's
rule, built as one authority that everything else asks.
Start impossible — not bookable, not upgradable, not offered
Team optional, via the `custom_domain` module
Business included, because the plan version carries the feature
Enterprise included, likewise
App\Services\Billing\CustomDomainAccess answers all of it: may this
contract use one, may it book the module, why not in words a customer can
be shown, what a downgrade would do to a domain they have, and — after a
change lands — making the state match. Read against the FROZEN plan
version, never today's catalogue, so a feature added to a package later
cannot reach into a contract signed before it.
Which packages cannot have one at all is an explicit list in
config/provisioning.php, beside the module's price. The catalogue cannot
answer it: plan versions model what a package HAS, and nothing models what
a package may BUY. Both available proxies are dishonest — "the lowest tier
on sale" hands the right to a grandfathered Start customer the day the
owner stops selling Start or adds something cheaper below it, and reading
it off `branding` would sell a domain to anyone allowed to upload a logo.
The config comment says so.
Enforced where it can be enforced today:
- BookAddon refuses the module, with the sentence the customer is shown.
GrantAddon now writes its synthetic order inside the transaction, so a
refusal cannot leave a paid order for a module nobody got.
- The portal drops the card where it cannot be booked and shows it as
included where the package carries it — no price, no button.
- Billing::purchase() re-checks, because a hidden button is markup.
- DowngradeCheck reports the consequence beside the blockers, so the
customer reads it above the button rather than after it: losing the
domain (Team -> Start), or the choice (Business -> Team, where keeping
it means booking the module).
- PlanChange::settleCustomDomain() applies it once a change lands: the
module stops being charged for and the instance goes back to its
platform address. It clears the whole domain, not only the verified
flag — clupilot:verify-domains re-reads every row that still has a
domain and a token, and would switch it back on the next night.
- The price sheet's own-domain row now reads dash / optional + price /
included, from the same authority; the three cell states already
existed and the view is unchanged.
STILL TO APPLY, ONCE THIS AND THE CUSTOM-DOMAIN BRANCH MEET — one line at
the top of App\Livewire\CustomDomain::mount(), which belongs to the other
session and is deliberately untouched here:
abort_unless(app(\App\Services\Billing\CustomDomainAccess::class)->allowsCustomer($this->customer()), 403);
routes/web.php is left alone for the same reason. The mount() guard covers
the route and the component both; a middleware would only repeat it.
Follow-ups, named rather than built:
- Nothing applies a plan change anywhere yet, so settleCustomDomain()
has no caller. Whoever builds that must call it after the new contract
opens, and must decide whether a booked module is carried onto the new
contract — enforce() cancels the booking on the contract it is given.
- Deactivation sets the state; it does not re-run provisioning. Traefik
and Nextcloud's trusted_domains still name the old address until
ConfigureDnsAndTls / ConfigureNextcloud run again.
- App\Support\Navigation still shows the Domain tab to every customer;
it can ask allowsCustomer() the same way.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
|
|
d4e8822dbb |
Paketversionen: Verkauf wieder aufnehmen und saubere Übergabe
tests / pest (push) Failing after 7m58s
Details
tests / assets (push) Successful in 20s
Details
tests / release (push) Has been skipped
Details
Eine geschlossene Version konnte nie wieder in den Verkauf. Schlimmer: um ein Paket zu ändern, musste die laufende Version zuerst beendet werden — und wenn die Nachfolgerin dann nicht sofort veröffentlicht wurde, war das Paket dauerhaft dunkel. - PlanCatalogue::reopen() setzt available_until zurück auf null, über schedule(), damit Family-Lock und Überlappungsprüfung dieselben bleiben. Ein nie veröffentlichter Entwurf wird abgelehnt. - PlanCatalogue::publish() übergibt eine laufende Version an ihre Nachfolgerin (available_until = deren Start) statt die Überlappung abzulehnen — unter demselben Lock, in derselben Transaktion. Nur die unmittelbare Vorgängerin, und nur wenn es genau eine gibt. - Konsole: Knopf „Wieder verkaufen", übersetzte Ablehnung statt der englischen Ausnahme, und die Übergabe wird im Veröffentlichen-Formular vorher angekündigt (Version und Zeitpunkt, ->local()). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
8c105c17d6 |
Move each plan's marketing copy from the controller into the console
tests / pest (push) Failing after 8m10s
Details
tests / assets (push) Successful in 20s
Details
tests / release (push) Has been skipped
Details
Audience line and note were LandingController::COPY, a hardcoded array keyed on exactly the four plan keys the seed migration created — a plan made under any other key rendered with no copy at all. They move onto the family for the same reason the recommendation mark does: they are a stance about the product line, not a capability that changes with a price, so they must not be frozen per version. Backfilled in the migration from the controller's own strings, so the public page prints exactly what it printed a moment ago. A family with no copy yet renders without the audience line rather than an empty gap. Feature labels (managed_updates, daily_backups, ...) stay in the controller: they name catalogue features, not individual plans, so there is no per-plan or per-version place to hang an editable customer-facing label without re-introducing a shared feature table. That table existed once and was deliberately removed when the plan catalogue closed its pricing split-brain; bringing it back for wording alone is a bigger, separate decision. |
|
|
|
4ef5b9519c |
Let the owner mark one plan as recommended, from the console
Recommended lives on the plan family, not a version: it is a stance about
the product line ("which plan we point a visitor towards"), not a
capability that changes with a price, and freezing it per version would
make the mark silently vanish every time a new version publishes.
Setting a plan recommended clears whichever other plan carried the mark,
inside one transaction locking the whole table — two operators
recommending two different plans at once must not both win. The public
price sheet already had the highlighted-card treatment for this (it was
driven by a hardcoded "team" constant); it now reads the family instead.
|
|
|
|
c71b2c362b |
Skip the invoice for a full gift, keep it out of revenue, hide its price
IssueInvoice now rejects free-grant orders before it will consume an invoice number for them — a full gift produces no invoice at all, while a discounted grant still charges something and is invoiced exactly like an ordinary sale. Keyed on the linked subscription/add-on's own provenance, not on the amount being zero, so a genuinely fully-discounted Stripe checkout is unaffected. Revenue excludes granted contracts from MRR/ARR/ARPU and the contracts count — a gift at 0 must not drag the average down — and shows their number as its own KPI instead of folding it in, since the owner measures himself on it. The portal (Cloud, Billing) shows a granted package or module without a price at all: not "free", not struck through, just the service, so a later conversion to paid does not read as a negotiation. |
|
|
|
132be7fdd5 |
Leave the archive readable by whoever collects from it
tests / pest (push) Failing after 7m32s
Details
tests / assets (push) Successful in 21s
Details
tests / release (push) Has been skipped
Details
Reported as "the folder arrives empty". It was not empty. The folder was 0700 and owned by the account that wrote it, so the account that COLLECTS — an rsync over ssh, a backup agent, anything that is not the writer — could not enter it, and an unreadable directory looks exactly like an empty one. The invoice had been sitting in it the whole time. The cause is that the default follows the process umask, so under a restrictive one 0755 quietly becomes 0700. The permissions are declared on the disk now rather than left to inherit, and a test writes under umask 0077 and asserts the mode — the ordinary umask hides this completely, which is why it was found on a server and not here. Two mistakes of mine on the way to it, both worth recording. I read the folder as the wrong user, got no entries back, and concluded the file had been deleted — it was a permission failure reading as an unprivileged account, not a missing file. And the first fix was a chmod on a variable that does not exist in that method, silenced by an @ so it failed without a word. Flysystem takes its permissions from configuration; a chmod afterwards was the wrong layer even before it was the wrong variable. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
d8ff717cb5 |
Let a destination say how it is laid out and how long anything stays
tests / pest (push) Failing after 7m29s
Details
tests / assets (push) Successful in 23s
Details
tests / release (push) Has been skipped
Details
Two destinations doing two jobs. A handover directory a NAS collects from wants a folder per day and wants emptying — thirty days is enough to notice a NAS that stopped collecting, and keeping more only hides it. An archive on the NAS itself wants a folder per year and wants nothing deleted, ever. So both are per destination rather than global. Deleting is safe here in a way it usually is not, and that is worth saying plainly: the invoice is a frozen document in the database and its PDF is rendered from that on demand. Nothing is moved anywhere — it is copied, and anything removed from a directory can be produced again and re-exported. The directory is a handover point, not the record. keep_days is null by default and stays null unless somebody sets it: a retention rule nobody asked for is a deletion nobody expected. The prune only touches folders whose NAME is one of the two shapes this application writes, and leaves everything else alone — a prune that deletes what it does not recognise is how an archive loses something nobody was watching. It also skips a destination whose path is missing rather than acting on whatever is underneath an absent mountpoint. The shape is matched with a pattern before parsing rather than by handing nonsense to Carbon: under strict mode createFromFormat throws instead of returning false, so one folder somebody made by hand would have ended the whole prune instead of being skipped. The test with a foreign folder in it found that. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
0528b56a32 |
Name no vendor in the export settings
tests / pest (push) Failing after 7m36s
Details
tests / assets (push) Successful in 21s
Details
tests / release (push) Has been skipped
Details
A destination is a kind of connection, not a product. The wording said "Hetzner Storage Box" and the host field suggested one particular provider's hostname format, which reads as a requirement rather than an example — and the whole point of the directory kind is that whatever is behind it is nobody's business here. Both kinds stay. A directory is how an NFS 4.1 mount is used: the mount is made on the server, and the application writes into the path it produces. SFTP is for a destination that is not mounted. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
f0ec2fe03f |
Keep the SFTP credential on the row it belongs to, encrypted
tests / pest (push) Failing after 7m22s
Details
tests / assets (push) Successful in 20s
Details
tests / release (push) Has been skipped
Details
The first version put it in the credential vault. The vault refuses it, and correctly: it keeps a registry of fixed, documented credentials so the Secrets page can describe each one, and a per-destination password is not that. Forcing a dynamic key into that registry would have made the registry meaningless. Encrypted onto the row with the same cipher instead — the same key, so it is no more readable in a database dump than anything the vault holds, and it lives with the record it describes. A rotated SECRETS_KEY now says so by name rather than surfacing as an authentication failure against a host that is perfectly fine. Also answers the question that prompted this: the host, port and user fields are there, and appear once SFTP is chosen. A mounted directory has no host on purpose — the mount is made on the server with mount(8), and the application only names the directory it writes into. A test asserts both halves, because "the fields are there, you just cannot see them yet" is not something anybody should have to take on trust. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
b3081a544b |
Let the export have as many destinations as somebody wants
tests / pest (push) Failing after 7m35s
Details
tests / assets (push) Successful in 21s
Details
tests / release (push) Has been skipped
Details
One path and one exported_at could not say "on the office NAS, not yet on the off-site box" — which is the only interesting question once there is more than one destination, and the reason for a second is precisely that any single one can fail. A row per destination now, and a row per (invoice, destination) pair. Two kinds, and the difference is where the protocol lives. 'local' is a directory this machine can already write to: a NAS mounted over NFS 4.1, a bind mount, a disk. The application knows nothing about the protocol, the target changes without code changing with it, and the whole thing stays testable against a temporary directory. 'sftp' is for something reachable with credentials — a Hetzner Storage Box being the obvious one — and the password goes into the secret vault with only its key on the row, because a credential in a settings table is a credential in every database dump. An empty password field on an existing target means unchanged, not cleared: the field cannot show what is stored, so blank-means-delete would wipe it on every unrelated edit. One job per pair rather than one per invoice. A job writing to both would retry the one that worked every time the other did not, and report one outcome for two different things. The ordering fix the tests found: Laravel's local adapter creates its root when it is CONSTRUCTED, so the "never create the root" guard was checking a directory the disk had just made for it. It runs before the disk is built now. That guard is the difference between an archive and a hole in the ground — mkdir on an absent mountpoint succeeds, writes onto the container's own disk and reports success, and an archive that silently is not the archive is worse than none. The single path from the previous commit is migrated into a destination called "Archiv" rather than dropped. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
ab4b0311d4 |
Copy every invoice to the archive when it is issued, and notice when that fails
tests / pest (push) Failing after 11m24s
Details
tests / assets (push) Successful in 21s
Details
tests / release (push) Has been skipped
Details
The application knows nothing about NFS and should not. It writes to a directory; whether that is a local disk, a bind mount or a NAS over NFS 4.1 belongs to the mount. No protocol library, the target changes without code changing with it, and the whole thing is testable against a temp directory. The finding that shaped it: mkdir -p on a path whose mount is NOT there succeeds. It creates the empty directory beneath the mountpoint, writes the invoice onto the container's own disk and reports success — so an unmounted NAS would quietly collect invoices locally while every check said it worked. An archive that silently is not the archive is worse than none. The root is therefore never created, only used: a missing root means a missing mount, and that is a failure. Only the year subdirectory is created, inside a root already proven to exist. Written to a .part and renamed. A rename within one filesystem is atomic, so whatever watches that folder never sees a truncated PDF and takes it for a finished invoice. The size is read back afterwards rather than the write being trusted: over a network mount a short write can report success, and an archive of truncated files looks exactly like one that worked. Queued, not done during the request. A network mount can block for its whole timeout, and an invoice must not fail to be issued because a NAS is rebooting. Dispatched after the commit, or a worker could look for an invoice that has not landed yet. And copy-on-issue is not enough on its own: an invoice issued while the office connection was down never arrives, the queue eventually gives up, and nobody opens an archive to check whether last Tuesday is in it. An hourly sweep re-queues whatever is still missing, the failure is recorded on the invoice rather than only in a log, and the Finance page says how many are outstanding. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
cd54212b34 |
Issue an invoice from what somebody bought, and freeze it there
tests / pest (push) Failing after 7m32s
Details
tests / assets (push) Successful in 25s
Details
tests / release (push) Has been skipped
Details
One invoice per purchase rather than per order: a customer who buys a plan and two add-ons in one go has bought once, and three invoices for one purchase is three times the paperwork for the same money. Each order becomes a line, which is also how the add-ons get listed individually. Everything the document says is copied into the snapshot at the moment the number is assigned — issuer, recipient, lines, rate, the reason for the rate. A test changes the company name and the customer name afterwards and asserts the invoice still says what it said. That is the whole reason no PDF is stored. The number and the invoice commit in one transaction. A number taken and then lost to a failure is a gap in a series that must not have one, and the refusal test asserts that a rejected invoice leaves the counter where it was. It refuses outright until the company details are complete. An invoice without a registered name, an address or a VAT number is not a valid invoice here, and issuing one consumes a number that can never be handed out again. VAT comes from TaxTreatment, which already existed and already handles a verified EU VAT ID correctly — including the reverse-charge note, without which a zero-rated invoice says nothing about why no VAT was charged. The billing address is carried as the lines it was written as. It is one free-text field today, and guessing which line is the postcode would put it where the street belongs, on a document nobody can correct afterwards. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
3fb7e5f08c |
Decide the VAT rate in one place, before the two disagree
tests / pest (push) Failing after 7m48s
Details
tests / assets (push) Successful in 26s
Details
tests / release (push) Has been skipped
Details
The Finance tab wrote a rate into settings while TaxTreatment kept reading one from config — two sources for one number, added by me two commits ago. It had not bitten yet only because both happened to say 20. TaxTreatment now takes the domestic rate from CompanyProfile, which falls back to the .env-derived config until an operator saves one. Same shape as ProvisioningSettings, which already solves this for the deployment values, and the same reason: an operator can change a settings page and cannot change a config value, so a page that appears to set the rate and does not is worse than no page at all. Reverse charge and the seller country stay where they are. That logic already existed, already handles a verified EU VAT ID correctly, and nothing here needed to know about it — which is exactly why it should not have been duplicated. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
4646880b90 |
Print every line at full price and take the discount off once, in euros
tests / pest (push) Failing after 7m40s
Details
tests / assets (push) Successful in 22s
Details
tests / release (push) Has been skipped
Details
Second correction to the same block, and this one came from the right place: a line reading 202,50 beside "Rabatt 10 %" does not say whether the discount is in that number or still to come. It was unreadable, and unreadable on the one figure a customer checks. Lines are at full price now. Discounts are still entered per line — that is where they belong and where somebody decides them — but they are summed and shown once, in euros, under a subtotal that the "Gesamt netto" column adds up to. Subtotal, one subtraction, VAT, total: a sum anybody can follow without being told which figures are already adjusted. The gross line-total column is gone and a gross UNIT price takes its place. Full-price gross totals would sum to 315,60 against a total of 288,60 — the same contradiction in a different column. Nobody adds up unit prices, and a consumer reading this wants to know what one of the things costs. lineTotal() and adjustmentLabel() went with it. Nothing calls them any more, and a helper kept for a layout that no longer exists is the next person's wrong turn. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
d21e4f0eb2 |
Put a discount on the line it belongs to, so the columns add up
tests / pest (push) Failing after 8m5s
Details
tests / assets (push) Successful in 21s
Details
tests / release (push) Has been skipped
Details
With one discount applied under the table, the gross column summed to 315,60 against a total of 284,04. Nothing was miscalculated — the discount simply came after — but a reader who adds up a column found a document that does not agree with itself, and no way to tell which figure was the real one. On an invoice that is not a presentation detail. A discount or surcharge now sits on its line, percentage or fixed amount, and is inside that line's total. The label under the line is built from the same value that did the arithmetic, so the words cannot end up describing a different number than the one printed beside them. The invoice-wide adjustment stays in the arithmetic for anything that genuinely applies to the whole document, but nothing uses it by default. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
835f3eeb64 |
Give the specimen invoice a placeholder in every field, not only the required ones
tests / pest (push) Failing after 6m58s
Details
tests / assets (push) Successful in 21s
Details
tests / release (push) Has been skipped
Details
The footer is four columns of three lines, as designed — but the specimen only filled the fields an invoice legally needs, so two of the four columns came out empty and it read as a missing footer rather than as missing data. Reported that way, which is the correct reading of what was on the page. A specimen exists to show the layout. Every field now carries an obvious placeholder, so the shape is visible before any real detail has been entered, and what is still a placeholder is obvious at a glance. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
c1896e70fc |
Render an invoice, with the arithmetic held to integer cents
tests / pest (push) Failing after 7m9s
Details
tests / assets (push) Successful in 24s
Details
tests / release (push) Has been skipped
Details
TCPDF 6, deliberately, after installing 7 and finding out what it is. Version 7 is not a newer TCPDF; it is a compatibility shim over tc-lib-pdf whose own mapping table lists 115 of 293 methods as stubs, and whose font package ships no font files at all — the first render died on a missing helvetica.json. The 6.x line carries a hundred and sixty-five fonts, a working writeHTML and real header and footer hooks, and is what everybody means by TCPDF. The body is a Blade template handed to writeHTML rather than a hundred Cell() calls with millimetre coordinates, because this table has to be adjusted the first time somebody sees it on paper, and nobody adjusts the second kind. InvoiceMath is integer cents end to end, and rounds once. A percentage applied line by line and rounded each time drifts from the same percentage applied to the sum — three lines at 3,33 € less 10 % is 99 cents that way and 100 cents the honest way, and the customer's calculator is what finds it. Gross is always derived from net and never the reverse: taking VAT back out of a gross figure returns a different number often enough to matter. A discount and a surcharge are one operation with opposite signs, so nothing has to remember which of two flags means which. Quantities are thousandths, because hours, gigabytes and part-months are all real, and are printed with the trailing zeroes stripped — "12,000 Std." reads as a defect. Every line shows quantity, unit price net, total net AND total gross: this goes to businesses and to consumers, and each reads a different column first. Verifying it took two goes and both failures were mine. `strings` over a PDF set in a Unicode font finds nothing, because the text is stored as subset glyph ids — the check has to render in a core font to be a check at all. And two of the strings I then declared missing were simply expectations I had got wrong. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
735daf46a4 |
Lay the foundation for self-issued invoices: series, numbers, frozen documents
tests / pest (push) Failing after 7m49s
Details
tests / assets (push) Successful in 21s
Details
tests / release (push) Has been skipped
Details
An invoice is a tax document, so two things drive the shape of this. It is frozen at issue. Everything the finished document says — issuer address, VAT number, bank details, customer address, every line, the rate, the totals — is copied into the invoice row when the number is assigned. The PDF is rendered from that and never stored, which is what was asked for; rendering it from today's settings instead would have been simpler and wrong, because an address changed in 2028 would rewrite an invoice from 2026. The width of this table IS the feature. Numbers are gapless and ascending, which is a legal requirement rather than a preference, and that is why none of it is derived from the invoices table. "Highest number plus one" hands the same number to two sales that land together and hands a used number back out after a row is removed — a number already printed on a document somebody holds. The counter is a column, read and advanced under a row lock inside the caller's transaction, and taking one outside a transaction throws rather than quietly working. Four Rechnungskreise seeded — Rechnung, Gutschrift, Storno, Anzahlung — each with its own prefix and its own counter, because a credit note is not an invoice with a minus sign in front of it. Seeded rather than left to the operator: an installation with no series cannot issue anything, and discovering that at the first sale is the worst possible moment. TCPDF added, for the renderer that comes next. There is deliberately no test for the outside-a-transaction guard, and a note where it would have been: RefreshDatabase opens a transaction around every test, so the guard cannot fire and a green test for it would be green for a reason that has nothing to do with the guard. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
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> |