83 Commits (6b8412f0c394981c091fdba767fc2defe3627124)
| Author | SHA1 | Message | Date |
|---|---|---|---|
|
|
35b312d5ef |
Invoice every renewal, and tell Stripe when the package changes
Two things Stripe was doing on its own, without us. **A renewal produced no invoice and no mail.** InvoiceMail went out from exactly one place — the first purchase — so a customer paying every month for a year received one invoice, for month one. Stripe already says when the money lands: invoice.paid with billing_reason subscription_cycle. What was missing was the document. A renewal has no Order behind it, and one was not invented for the occasion: an Order is something a customer bought, and writing a purchase nobody made would corrupt the record of what they have actually ordered. IssueInvoice::forBilledPeriod() issues from the CONTRACT instead, at its frozen net price, for the term Stripe billed. Idempotent against the Stripe invoice id, which is unique in the database rather than checked in PHP — and because the number is drawn inside the same transaction as the row, a redelivery takes its number back with it and the series keeps its sequence. Nothing in the paperwork can fail the webhook: issuing and mailing are both caught and logged, exactly as confirmByMail() does. Only a cycle renewal gets a document. The checkout's own invoice already has one; an upgrade's proration was worked out against Stripe's boundaries and would not match a document written from our snapshot; a charge raised by hand is for something this catalogue cannot describe. All three stay in the register, and are logged once as money no document of ours covers. **After a plan change, Stripe went on billing the old price.** There was no way to move a subscription onto another price at all, so a customer who upgraded paid for the smaller package every month afterwards. The client can now swap one, the item id it needs is learnt from Stripe's own events (and asked for once, for contracts older than the column), and the behaviour is chosen per direction: an upgrade settles immediately, so the cycle invoice stays exactly the contract price the renewal document states; a downgrade lands at the period boundary and settles nothing. A granted contract has no Stripe subscription and is left untouched. A change that reaches the machine and not Stripe is never rolled back — it is parked on the contract with the error and the behaviour it needed, logged as an error, and retried hourly by clupilot:sync-stripe-subscriptions. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
d76de5ada3 |
Restart a machine, enforce the quota that was sold, end a route that ended
Three places where the product said one thing and did another. **Nothing could restart a virtual machine.** ProxmoxClient had startVm and no way to stop anything, so `restart_required_since` — set by every plan change that grows a running guest — could only ever be cleared by a resize step that happened to find the machine stopped, which nothing in the product could arrange. A paid upgrade's cores and RAM could stay unreached for the life of the contract, and the cloud page's "Neu starten" was a toast. There is now a `restart` pipeline: shutdown, start, wait for the agent, confirm. A shutdown and a start rather than a reboot, because only a cold boot makes qemu read the VM definition again — a reset would take the machine round and bring it back exactly as small as it was. The shutdown is a REQUEST, bounded at ten minutes, and there is no escalation: ProxmoxClient deliberately does not expose Proxmox's forceStop, because the guest is a Nextcloud and cutting power to a database mid-write to apply a CPU change trades an inconvenience for a restore from backup. A guest that ignores ACPI for ten minutes fails the run instead, and a person decides what happens to it. `restart_required_since` is cleared by the last step, from what the RUNNING guest reports (`cpus`/`maxmem`) — not by the button, and not by the run merely finishing. A machine that comes back smaller than it was sold fails the run. Started by App\Actions\RestartInstance, which re-checks authorisation itself rather than trusting a hidden button: an operator holding the new `instances.restart` may restart anything, a portal user their own machine and nothing else. Refused while another run is in flight against the same order. Confirmed in a modal on both sides (R23), addressed by uuid (R11). **Existing instances had no storage quota.** ApplyStorageQuota reaches new and changed machines; everything built before it kept the whole disk whatever it had paid for, and nothing recorded which was which. `instances.quota_applied_gb` is now what the guest was actually told, written by the step only after the occ call succeeds — so "sold" and "enforced" are separate facts and can be compared. `clupilot:apply-quotas` sweeps the estate: one `quota` run per instance through the same step the pipelines use, skipping what it should and saying why, a no-op once an allowance is enforced, and inert under --dry-run. Deliberately NOT scheduled — a nightly sweep would quietly cover for the pipeline step the day it stopped working, which is how the original hole survived this long. **A Traefik router was never torn down.** TraefikWriter::remove() had no caller anywhere. What was missing was the moment: ConfirmCancelPackage wrote a date into `service_ends_at` and nothing ever went back to it, so every route this platform ever wrote was written for good — pointing at a guest address the host is free to reassign. `clupilot:end-due-services` (hourly) keeps that appointment through App\Actions\EndInstanceService. "Ended" means cancellation_scheduled AND `service_ends_at` passed, never the day a cancellation is scheduled: that customer has paid to the end of the term and is working in it. The DNS record goes with the route — it is in our zone, pointing at a host that serves other people, which is the shape of every subdomain takeover there has ever been. The customer's own domain is in their zone and is not ours to touch; the virtual machine is left alone, because the cancellation flow promises a data export first and destroying disks is not this change's decision. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
98afd1d7e0 |
Re-apply what the last release commit silently reverted
tests / pest (push) Failing after 7m54s
Details
tests / assets (push) Successful in 20s
Details
tests / release (push) Has been skipped
Details
The English paths and the zone migration went out in
|
|
|
|
1620cb9395 |
Release 1.3.22
tests / pest (push) Failing after 8m15s
Details
tests / assets (push) Successful in 22s
Details
tests / release (push) Has been skipped
Details
v1.3.21 was taken by a parallel release between reading main and writing the commit; the version file went out saying 1.3.21 on a commit that is not that tag. Corrected here rather than by rewriting a pushed commit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
5f74a5f370 |
Move the zone by migration, and put every path in English
tests / pest (push) Failing after 8m8s
Details
tests / assets (push) Successful in 21s
Details
tests / release (push) Has been skipped
Details
── The zone, for the fourth time ────────────────────────────────────────────
"Change it in the console" was the wrong answer three times running. An
installation with nothing to migrate should simply arrive on the new zone, so
a migration does it — guarded by exactly the condition clupilot:check-zone
reports on: no instance in service, therefore nothing carrying the old name in
DNS, in a certificate or in trusted_domains. With even one instance it does
nothing and logs why, because re-addressing a running Nextcloud is a migration
with an outage and a migration is not the place to start one. It leaves an
installation already on another zone alone: the setting exists so somebody
else can run this on their own domain.
That fixes both reports at once — the security page and the price sheet read
the same setting, which is the entire reason neither of them has the domain
typed into it.
── R13, applied to all of them this time ────────────────────────────────────
"Ich schreibe das nun zum dritten Mal" — fair. The rule was cited, I changed
the one route that had been pointed at, and left every other German path
standing. A fragment is part of a URL too.
#produkt → #product /legal/impressum → /legal/imprint
#ablauf → #process /legal/datenschutz → /legal/privacy
#preise → #pricing /legal/agb → /legal/terms
#fragen → #faq (old paths redirect, permanently — they have been
#kontakt → #contact in the footer and in mail for weeks)
The headings stay German. "Impressum" is the legal term an Austrian company
has to use; the address is not part of the copy.
A test now walks every registered route and every fragment the site links to,
so the next German path fails the suite instead of being reported a fourth
time.
Three existing tests set config('provisioning.dns.zone') and read it back
through the accessor. That worked while the setting was empty and stopped the
moment the migration filled it — they set the setting now, and the one whose
premise is "nothing saved yet" establishes that premise itself.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
|
|
34874adec3 |
Apply a bought plan change instead of only pricing it
tests / pest (push) Failing after 8m6s
Details
tests / assets (push) Successful in 21s
Details
tests / release (push) Has been skipped
Details
PlanChange could say what a move would cost and whether it was allowed, and that was all it could do. Billing::purchase() wrote an upgrade order and nothing ever consumed it: same snapshot, same machine, same quota. A customer could pay for a bigger package and receive nothing. ApplyPlanChange is now the single place a change lands — it moves the contract onto the target's current version, writes one register row, settles the custom domain, and starts a run that resizes the machine. Applying the same order twice is a no-op, enforced by a unique event key rather than by a check two callers could both pass. Two things a plan change must not do, and now does not. A disk is never shrunk — Proxmox cannot, so the QUOTA shrinks instead, which is what was sold anyway. And a live machine is never rebooted as a side effect: cores and RAM are written, and where they need a restart the instance says so where an operator and the customer can both see it. The storage allowance also joins the build pipeline. ApplyStorageQuota was written for plan changes and ran only there, so a brand-new customer still got no quota at all — quota_gb reached the instance row and stopped, and every package delivered the whole disk. The test guarding that pipeline only counted its steps, which is how a list missing the one step that makes a package's storage real stayed green for its whole life. It now names the step. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
712803edd6 |
Serve the custom domain, not just announce it
A verified custom domain was reported as the customer's address by
Instance::address(), by the portal and by the credentials mail while
nothing on the platform routed it: the Traefik router's rule was
hard-coded to {subdomain}.{zone}. The address a customer was handed
answered nothing, and a withdrawn domain stayed in Nextcloud's
trusted_domains forever, because the only thing that ever wrote either
was the initial provisioning run.
- TraefikWriter::write() takes a LIST of hostnames under one stable
router name — the platform address always, the verified domain as
well when there is one. One file per instance, so a withdrawal is a
rewrite rather than a second thing somebody has to remember.
- ConfigureDnsAndTls records the custom domain's certificate instead of
waiting on it: it depends on an A record in the customer's own zone,
which may never appear, and must never fail a run. The platform
address keeps its 840s deadline. instances.domain_cert_ok tells
"proven" apart from "answering", and the portal now says which.
- ConfigureNextcloud deletes trusted_domains 2 when there is no verified
domain, so a withdrawn one stops being trusted.
- New `address` pipeline (those two steps) plus ReapplyInstanceAddress,
which starts one against the order and refuses to start a second while
any run is in flight. The route is rewritten when the hostname list
differs from what the router carries — not on route_written, which
would short-circuit exactly the case a re-apply exists for.
- Triggered where the address changes: verification flipping either way
in clupilot:verify-domains, the customer's own domain page, and
CustomDomainAccess::deactivate() on a package downgrade.
- A maintenance run no longer condemns its subject: an address run that
failed used to mark the order failed and release the live instance
with it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
|
|
6ef9b510ce |
Merge branch 'feat/plan-reopen'
# Conflicts: # app/Http/Controllers/LandingController.php |
|
|
|
ea643b5e73 |
Prove a custom domain before serving it, and keep proving it
tests / pest (push) Failing after 8m17s
Details
tests / assets (push) Successful in 23s
Details
tests / release (push) Has been skipped
Details
Two things. ── The update screen, still opening twice ─────────────────────────────────── Reported again on 1.3.9, and the cause was not the one fixed in 1.3.8. The agent consumes the request file BEFORE it resolves the release — deliberately, because update.sh may kill the shell and a request left in place would loop — and writes `state: running` only once it has decided to go ahead. In between, the request is gone and the status does not say running yet, so the endpoint honestly answers "nothing is running". The watcher read that as "the run has finished" and reloaded the page: overlay on the click, gone a poll later, 503 after it. The overlay now closes only once the server has BOTH confirmed a run and then stopped reporting it. Before the confirmation, silence means the agent has not got there yet. Bounded at twenty polls so a request the agent refuses does not leave the console covered forever. ── Custom domains, proven and re-proven ───────────────────────────────────── `custom_domain` was a free-text field and everything downstream believed it: the proxy served it, the certificate was issued for it, Nextcloud trusted it. Anyone who pointed any hostname at the platform got somebody else's files under their own name. The proof is a TXT record at _clupilot-challenge.<domain> holding a token only this instance has. Nothing is served until it has been read. Every reader now goes through Instance::address(), which is the one place the decision is made — `custom_domain ?: subdomain` was the hole, written out four times. It is re-read every night at 03:40, because a token checked once can be taken straight back out and a domain that later lapses keeps resolving here. Three consecutive misses before a live domain is withdrawn: one failed lookup is a nameserver having a bad minute, and withdrawing takes a working Nextcloud off its own address. The domain and its token stay on the row so the customer can put the record back rather than start over. Changing the domain mints a NEW token. Reusing it would let somebody who once verified example.com claim any other domain later without touching its DNS — the old record is still sitting there and only the value is compared. The domain is changeable at any time, and removable. Fixing it once set was considered and rejected: adding or moving an address is a proxy entry, a certificate and one line in trusted_domains. 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.
|
|
|
|
b85df4c141 |
Rebuild the status page as a status page
Asked for after looking at how real ones are built. The shape they all share is a convention, and a convention is what lets somebody find the answer without being taught the page: banner, components each with ninety days of uptime, then the written record underneath. This page had only the banner — the version that is of no use the morning after, because "Alle Dienste in Betrieb" is true and says nothing to somebody who was locked out the evening before. Three things are new. The ninety-day bar. It cannot be reconstructed after the fact: the monitoring table holds the last verdict per instance, not a history. So a sampler (clupilot:sample-status, every five minutes beside the monitoring sync) writes counts into one row per component per day, and the page divides them. A day with no row is drawn as a gap and says "nicht aufgezeichnet" — never green. Degraded samples count as reachable in the percentage but still colour the day: folding them into downtime reports a slow morning as an outage, ignoring them loses the only trace of it. The incident record. An operator reports a disruption in the console, posts updates as it develops, and the entry that says "behoben" is the same action that closes it — two separate steps is how a page ends up with a resolved note under an incident that still reports as ongoing. There is deliberately no way to edit an update: it is a statement made at a time, corrections are a further entry. An open incident may make a component look worse than the probes found it, never better. Scheduled maintenance, from the windows the console already keeps rather than a second list somebody has to remember to fill in. The measurement moved out of the controller into ServiceHealth, shared with the sampler, so the banner and the bars cannot disagree about what "healthy" means. The page now uses the shared design system and the site's header and footer. Its self-contained stylesheet was there to survive a broken asset build; it does not survive one — if the stylesheet cannot be served the application serving this page is already failing, and mid-deployment every route answers with the maintenance page. What the exemption bought was a third design nobody maintained. Found while building: durationMinutes had its operands the wrong way round, and diffInMinutes is signed, so the public page printed "Dauer -86 Minuten". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
602602864a |
Let a subscription or add-on be opened for free, with who and why on the row
A grant is an order without money, not a second code path: GrantSubscription and GrantAddon create the same Order/Subscription/SubscriptionAddon rows a paid purchase does, with stripe_subscription_id left null and price_cents set to whatever the customer actually pays. OpenSubscription and BookAddon gained an optional overrides parameter so the price and provenance (who granted it, when, a note, an optional end date, and the catalogue price for legibility) land on the same snapshot every real purchase goes through, not a duplicate. New customers.grant_plan capability, Owner-only like secrets.manage — giving away service is exactly the kind of blast radius that precedent reserves for the Owner. |
|
|
|
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> |
|
|
|
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> |
|
|
|
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> |
|
|
|
2609393e3a |
Recognise the devices an account signs in from, and warn about a new one
tests / pest (push) Failing after 7m52s
Details
tests / assets (push) Successful in 20s
Details
tests / release (push) Has been skipped
Details
Sessions lived in Redis, where they cannot be enumerated — one key per session, no index by user — so "where am I signed in" and "end that one" were not answerable at all. The database driver keeps a row per session and makes ending one a delete. It costs every currently signed-in person one forced sign-in, once, which is why it is happening now rather than later. The warning is only worth anything if it is rare, so the two obvious signals are deliberately not used. Not the IP: it changes on every train, every café, every mobile handover, and behind a VPN it is not theirs to begin with. Not the user-agent: Chrome ships every four weeks and the string changes each time, which would be twelve warnings a year per customer, describing nothing. A device is a long-lived signed cookie holding a random token, and nothing else is consulted. The honest cost — cleared cookies, a private window, a second browser — is written into the mail itself, because a reader who thinks the warning is wrong stops reading the next one. Three things the tests found or hold: The first device on an account is new and must not warn. Replying to somebody creating an account by telling them they signed in from a new device is noise on the one message that has to stay worth reading. token_hash was globally unique, and the shared-browser test failed on it. Two people on one machine share one cookie and must each own a device row against it; the obvious repair — issue a fresh token for the second — would orphan the first person's row and warn them about their own desk. Unique is now per identity. Recording a device may never block a sign-in. A safeguard that locks the owner out when it breaks has stopped being one, so the listener logs and swallows, and a test drops the tables to prove it. Sessions are linked by a uuid carried inside the session payload rather than by the session id, which Laravel rotates on every sign-in and every regenerate(). Laravel's own sessions.user_id cannot carry the link either: it is filled from the default guard, while operators and customers are two guards and two tables (R21) — operator 1 and customer 1 would each be offered the other's sessions. Also lands the shared mail layout the rest of the templates will use: tables and inline styles for Outlook, no image and no web font because half of all clients block remote content, and the button in --accent-active rather than the brand orange, which is 2.9:1 against white. Not tagged: the session list is not built yet, and without a tag no server can install this. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
669d0471a8 |
Repair two comment blocks the admin-hosts edit ran together
tests / pest (push) Failing after 8m1s
Details
tests / assets (push) Successful in 21s
Details
tests / release (push) Has been skipped
Details
An earlier edit removed the ADMIN_HOSTS example line and left its "Beispiel:" dangling into the VPN_CONFIG_KEY block, so the file documented neither properly. SECRETS_KEY was never in here at all — without it the console silently stores no credentials, which is exactly the kind of thing an example file exists to prevent. |
|
|
|
75b4a47768 |
Stop mailing the initial admin password, hold it in the panel until noted
The password no longer travels through third-party mail servers or sits forever in an inbox: it moves onto the instance (encrypted, same as nc_admin_ref) and shows on the dashboard until the customer clicks "Notiert", which deletes it for good. |
|
|
|
d65ab71029 |
Let a datacenter code be corrected while nothing depends on it, and say which building
The code was immutable, which made a typo permanent. It cannot be freely mutable either, and not only because hosts.datacenter references it: each host's DNS name was minted from it and registered at the provider, the machine's own hostname was set from it as it was built, the counter handing out those numbers is keyed by it, and every past order stores it as plain text with no foreign key to follow. Renaming a code with hosts in it is not a rename — it is a datacenter called hel full of machines called fsn-01. So it is editable exactly while nothing references it, which is the case that matters: a typo noticed shortly after creating one. Once a host or an order depends on it the field is disabled and says what is holding it, with the numbers. The lock is recomputed from the database on every save, never read back from the property the browser returns. Alongside it, an optional free-text field for the specific datacenter — fsn-dc-15. A provider runs several within one location, on separate power and separate uplinks, and two entries both reading "Falkenstein (fsn)" are not a choice anybody can make. Nothing keys off it; the code remains the identifier. Also renames the country loop variables in the edit modal. They were $code and $name — the component's own properties — and shadowed them for the rest of the file. Harmless while nothing below the loop read them, which stopped being true the moment the code became an editable field above it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
495d314aa5 |
Preflight every customer conflict before this migration mutates anything
tests / pest (push) Failing after 7m24s
Details
tests / assets (push) Successful in 20s
Details
tests / release (push) Has been skipped
Details
tests / pest (pull_request) Failing after 7m32s
Details
tests / assets (pull_request) Successful in 20s
Details
tests / release (pull_request) Has been skipped
Details
|
|
|
|
4ae5281c91 |
Carry operators into the RBAC move via direct permissions too, not only roles
operatorUserIds() only discovered console users through model_has_roles. A
pre-existing account granted console.view straight through
model_has_permissions, holding no role at all, was invisible to the
worklist — meanwhile the permission's own guard is switched to `operator`
a few lines above, so that account would silently lose console access
while staying behind as a working portal login. Worklist is now the
deduplicated, sorted union of both tables; orderBy('model_id') kept on
each side since carryAcross() remaps in place and the ordering still
matters regardless of which table an id came from.
|
|
|
|
54fb6deb03 |
Make the RBAC move retry-safe and order-safe, and round-trip permissions
Three faults in the same migration:
- up()'s removal loop was driven by operatorUserIds() (model_has_roles
WHERE model_type=User) via the $carried array — but carryAcross()
repoints model_has_roles to Operator for every operator before a
single users row is deleted. A run that stops partway (the
customer-conflict abort, or a dropped connection on real MariaDB,
which this file deliberately runs without a transaction) leaves that
worklist empty on the next attempt, so `migrate` records the
migration as applied while every leftover users row still grants a
live portal login on the operator's own password. Now driven by
`operators` joined to `users` on email: that finds every
carried-but-not-yet-deleted row regardless of which run carried it.
- operatorUserIds() had no ORDER BY. carryAcross() remaps four owned
columns in place, keyed on the old users.id, while operators.id hands
out new values in the same order the list is walked — unordered, a
later operator's freshly assigned id can equal an earlier operator's
still-unprocessed users.id, and the second remap catches a row the
first one already rewrote. One operator ends up silently owning
another operator's VPN peer. Added ->orderBy('model_id').
- down() restored model_has_roles but not model_has_permissions, so an
operator holding a direct (not role-granted) permission lost it on
rollback. Mirrored the same move carryAcross() does forward.
Also removes seed_roles_and_permissions' now-stale
User::OPERATOR_ROLES/hasAnyRole()/assignRole() bootstrap loop — all
three gone from User per this branch's own work — which only a deep
rollback (down() of the move migration recreates is_admin users) would
ever have reached, but would fatal when it did.
|
|
|
|
f54d00b3e7 | Move the value remap and constraint swap before the delete, and fix the seeder | |
|
|
87c49de6e5 | Move the console's identity out of the customer table | |
|
|
8d1fa52a5a | Give the people who run CluPilot a table of their own | |
|
|
a6a9c76660 |
Trust an explicitly configured mail host or port over the placeholder
env('MAIL_HOST')/env('MAIL_PORT') returning anything, or MAIL_URL supplying
either, now counts as "supplied" and wins over the sentinel comparison — a
relay genuinely on 127.0.0.1:2525 no longer reads as unconfigured. The
sentinel stays as a fallback for when neither signal says anything (including
under config:cache, where env() goes null and this collapses to today's
behaviour).
|
|
|
|
15c81489d4 |
Stop downgrading an opportunistic smtp scheme to plaintext
MAIL_SCHEME=smtp is Symfony's opportunistic scheme (upgrade to STARTTLS if offered, don't fail if not) — not a "no encryption" statement. Mapping it to 'none' silently turned every existing MAIL_SCHEME=smtp installation's encrypted-when-possible connection into an always- plaintext one. Maps to 'tls' (STARTTLS required) instead: stricter than smtp's own default, so a relay lacking STARTTLS now fails loudly rather than leaking credentials. |
|
|
|
5d306fce31 |
Let a mailbox send without a password when it never needed one
An SMTP relay with a host and a from address but no username or password - a trusted local or private-network relay - always used to be seeded as a placeholder, because the takeover required a username to treat an account as real, and Mailbox::isConfigured() always required a stored password. Laravel's own SMTP mailer has supported unauthenticated relays all along. Add mailboxes.authenticates (default true, so every existing row keeps its current behaviour) to tell "does not authenticate" apart from "authenticates, but nobody has typed the password yet" - the same empty password column used to mean both. The takeover migration now seeds the real account off the host/address alone and marks it authenticates=false only when neither a username nor a password resolved; isConfigured(), MailboxTransport and MailboxTester all read it before requiring or sending a password; the edit modal gets a checkbox for it with the password field's hint updated to match, in both languages. |
|
|
|
40a6b3269d |
Trust a stated mail scheme over the port guess when seeding encryption
MAIL_SCHEME=smtps or an smtps:// MAIL_URL on a non-465 port used to be downgraded to STARTTLS because mail.encryption was derived from the port alone. Resolve the scheme first (from MAIL_SCHEME directly, or from the URL's own scheme via the same ConfigurationUrlParser Laravel uses) and only fall back to the port heuristic when nothing was actually stated - which is also what keeps this machine's invalid MAIL_SCHEME=tls from silently steering the result. |
|
|
|
f0c3bd9c2e |
Resolve MAIL_URL before seeding, so a takeover doesn't blank out a working relay
The seed migration read config('mail.mailers.smtp.host') and its three
siblings directly. An install configuring SMTP through MAIL_URL (the
other form config/mail.php supports) never sets those individual keys —
MailManager only substitutes the URL's components in at transport-build
time, inside its own protected getConfig(). Read raw, that left the
migration seeding config/mail.php's own placeholder defaults, which the
UNCONFIGURED_HOST/UNCONFIGURED_PORT guards then read as genuinely unset:
blank host, port 0, an unconfigured mailbox, for an install that was
sending mail successfully.
resolveSmtpConfig() calls the same Illuminate\Support\ConfigurationUrlParser
MailManager::getConfig() itself delegates to, so this is the same
resolution rather than a second copy of its merge order. Verified against
the real (protected) getConfig() via reflection: the URL wins for
host/port/username/password wherever it actually supplies one, and an
unset MAIL_URL leaves this a no-op.
|
|
|
|
e8a3b8d5be |
Seed the mailbox address from mail.from.address, not the SMTP login
The takeover migration seeded `address` from MAIL_USERNAME and left `username` null unconditionally, conflating the SMTP login with the sender address. Whenever the two differ — an API-key username, a shared relay login — mail would go out From the login instead of the address the system was actually configured to send from, exactly like this installation: MAIL_USERNAME is no-reply@clupilot.com but MAIL_FROM_ADDRESS is hello@clupilot.local. `address` now reads config('mail.from.address'), falling back to the SMTP login only when that reads back config/mail.php's own unset placeholder (env('MAIL_FROM_ADDRESS', 'hello@example.com') — the same masking problem UNCONFIGURED_HOST already guards against for MAIL_HOST). `username` is set to the SMTP login only when it actually differs from the resolved address, since Mailbox::smtpUsername() already falls back to address when username is null. display_name now reads config('mail.from.name') instead of a hardcoded literal. The placeholder domain for the four non-configured mailboxes still comes from the SMTP login, not from mail.from.address — the two can name different domains, and only the login's is proven to accept mail for a given install. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
99b7393361 |
Stop committing a real password, and say the truth about what carried over
MailboxSeedMigrationTest.php had the actual .env MAIL_PASSWORD for no-reply@clupilot.com hard-coded into a test fixture — replaced with an obvious dummy. Grepped the whole branch (576cfca..HEAD) and the working tree for that string and for every other real secret value in .env; nothing else leaked. Separately, the seed migration's SECRETS_KEY-missing warning claimed a password "was NOT copied into the mailbox table" even when a vault row already existed and its ciphertext WAS carried across a few lines below, unconditionally on $canEncrypt. An operator debugging a live mail outage would have been sent to re-enter a password that was already sitting in the column. The warning now distinguishes the two cases and says which one actually happened. |
|
|
|
67a3e78cb4 |
Stop the mailbox password save from crashing without SECRETS_KEY
EditMailbox::save() now checks SecretCipher::isUsable() before touching a typed password and reports it as a form error, instead of letting SecretCipher::encrypt() throw all the way to Laravel's debug page. Also switches the mail.manage migration to Role::findOrCreate, matching every sibling capability migration, so a squashed replay can't hit RoleDoesNotExist. |
|
|
|
4211f3dfab | Give the mailboxes a page, and the support sender its own capability | |
|
|
a12ab148b3 |
Close the data-loss paths the review found in the mailbox takeover
The vault's mail.password row is no longer deleted by this migration at all, in either direction: deleting it unconditionally destroyed a real password whenever MAIL_USERNAME was empty (nowhere to carry it to), and even on a successful carry-over a later rollback had nothing left to fall back to. Reproduced both against real MariaDB, along with a migrations- table drift that crashed a retry on the mailboxes.key unique constraint — fixed with Mailbox::firstOrNew() and a transaction around each direction. down() now goes through the new Settings::forget() instead of a raw delete, so it stops leaving the settings cache holding what it just removed. A skipped .env password (SECRETS_KEY still unset) now prints an operator-visible line instead of migrating clean and failing silently later. Host/port that resolve to config/mail.php's own placeholder defaults are treated as unset rather than as a real relay. |
|
|
|
3e4d5472a7 |
Move the one SMTP account into the mailbox table it outgrew
The seed migration also guards against SECRETS_KEY being unset at migrate time (encrypting the .env password would otherwise throw and take the whole migration down, on a fresh install as much as in the test suite), and the four tests/Feature/Mail files written before this migration reset the mailbox table in their beforeEach so they keep testing what they did before mailboxes.key collided with the seeded rows. |
|
|
|
270ec942d6 | Give every sending address a record of its own | |
|
|
9b8d5dfd1e |
Editing in modals, an update button that is not gated on a stale reading, and a support page that is real
tests / pest (push) Successful in 7m18s
Details
tests / assets (push) Successful in 19s
Details
tests / release (push) Successful in 3s
Details
Three things reported together. ── Editing belongs in a modal (R20) The seats table grew its input fields into the row. It worked and it looked broken: the row grew, the columns beside it jumped, and a table half in edit mode reads as a rendering fault rather than as a form. The project already had the answer — EditDatacenter, whose own header comment says it avoids exactly that row-height jump — and the seats table simply did not use it. EditSeat is now a ModalComponent. A modal is reachable WITHOUT the page's route middleware, so it resolves the customer itself and re-reads the record rather than trusting a hydrated property: a forged addressEditable would otherwise open the address of an accepted seat, and the address is the person — editing it hands one employee's access to another with nobody told. Only an invitation still in flight can have its address corrected, which is the case that actually comes up. The actions column is now always drawn. It used to disappear when the only seat was the owner, on the reasoning that there was nothing to act on. But every seat can be renamed, and a column that vanishes does not read as "not applicable here" — it reads as "this product cannot do that", which is how it was reported, three times. The owner's row says "Geschützt" rather than leaving an empty cell. Also caught here: the edit fields carried class-wide #[Validate] attributes, so an empty edit form made the INVITE button fail on a field the invite form does not have. Rules for an action belong to the action. ── The update button "I cannot run an update, it says everything is current." `behind` is a READING taken by the agent every five minutes, not the state of the world — push a commit and the console insists it is up to date and refuses to act. The agent does its own fetch before deciding, so asking against a stale reading costs one fetch and finds nothing; being locked out costs the deployment. The button is now offered whenever an agent is alive and no run is in flight, labelled for what it does rather than for what the last reading said. ── The update never announced that it had finished Because the thing being watched restarts the thing doing the watching. Mid-run every wire:poll request fails, and what answers afterwards is a new build being questioned by the old page's JavaScript — so the card sat on "läuft" until somebody reloaded by hand. A small Alpine watcher now asks a plain JSON endpoint (no Livewire, no component state, no assets), treats a failed request as the restart rather than as a fault worth giving up over, and reloads once the build it is looking at is no longer the build it started with. ── Support The page was a decorated placeholder: a button that raised a toast saying the form was "only hinted at in the prototype", three invented ticket titles living in the translation file, and no way to see what became of anything. It looked thin because nothing on it was real. It now leads with the customer's own requests — what somebody arriving here wants to know is what they asked and whether anyone answered — with contact details moved to the side where they belong. The form attaches the plan, the instance and who is asking automatically: making a customer describe their own server back to the people who built it is the part of support people hate. FAQ answers end where the thing can actually be done, and "is it me or is it you" is answered by a link to the status page. 637 tests. R20 recorded in CLAUDE.md and enforced by EditInModalTest: no page view may grow an input field inside a <td>. 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> |
|
|
|
ee5e92c0ed |
Measure what the template draws
The panel could not show the storage ring or the transfer trend because nothing measured them. instance_traffic keeps one row per billing period — right for an allowance, useless as a series — and storage consumption was never sampled at all, so the card could only ever state what was sold rather than what is used. instance_metrics holds one row per instance per day, written by the traffic collector on the visit it already makes. A second scheduler entry against the same Proxmox API would have doubled the load and the failure modes for no gain. Disk usage comes from `df` inside the guest rather than from Proxmox's own disk figure, which reports the allocated image: that would show every customer a full 500 GB from their first day. The rule the whole thing is built on: a reading that could not be taken is not a reading of zero. The disk columns are nullable and left untouched when the guest agent does not answer, so yesterday's figure stands instead of the ring dropping to empty — which would tell a customer their data had vanished. Missing days stay missing in the series rather than being filled with zeroes, which would draw an outage that never happened. Both are covered by tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
a58faf3f85 |
Manage the Stripe key from the console, behind a password and a test
tests / pest (push) Successful in 8m56s
Details
tests / assets (push) Successful in 20s
Details
tests / release (push) Successful in 4s
Details
Changing a key meant a shell, a file edit and a cache rebuild — and the person who owns the Stripe account is not necessarily the person who owns the server. Two gates, not one. The capability decides who may open the page; every operator has console.view, and that must not mean "can read the payment key". The password decides whether this SESSION may see or change anything, because the realistic threat is not a stranger but an unlocked machine, and a session is exactly what that hands over. Both are re-checked server-side on every action — a Livewire action is reachable by anyone who can post to /livewire/update. The value is stored encrypted under a key of its own, SECRETS_KEY, and the vault refuses to work without it rather than falling back to APP_KEY: rotating APP_KEY is ordinary maintenance and would otherwise destroy every stored credential, discovered when Stripe stops answering. It is read where it is used, not overlaid onto config at boot — an overlay adds a query to every request including the public site, and leaves queue workers holding whatever was true when they started. It is never shown again, only outlined, and never enters a Livewire property that would carry it to the browser and back in the snapshot. A registry, not an env editor: a form that can set any environment variable is a privilege-escalation primitive, and one bad value bricks the installation with no way back through that same form. The test button is the part that matters. It reports which Stripe account the key belongs to, whether it is LIVE or test — the most expensive mistake here is pasting one where the other belongs, and both look identical in a form — and which webhook endpoints exist with the events each subscribes to. A key can be perfectly valid while the endpoint listens for the wrong five events, and nothing fails until a payment goes unrecorded. The webhook signing secret deliberately stays in .env. It is read on every incoming payment event; in the database, a database problem becomes silently failing signature checks. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
de6821b53e |
Move the console off /admin, give the status page its own address, and measure monitoring
tests / pest (push) Successful in 7m43s
Details
tests / assets (push) Successful in 19s
Details
tests / release (push) Successful in 5s
Details
Three things the owner asked for, and one the review found underneath them. The console leaves /admin. It has its own route file now, registered before routes/web.php because once it has a hostname to itself it sits at the ROOT of that host — where `/` and `/settings` also exist for the portal, and the first matching route wins. Where it mounts is one decision in one place: `/` on the console hostname where it has one to itself, `/admin` on any host otherwise. Names stay admin.* in both modes, so nothing that builds a URL has to know. Exclusivity is its own switch, not a consequence of having a hostname. The first attempt made "this host is the console" follow from ADMIN_HOSTS, and a development machine lists its own IP there so the console works without DNS — which took the public site and the portal off that machine entirely. The switch also registers the console on every listed hostname, canonical last, so the alternates that exist as recovery paths keep working. The status page moves out of /legal, where it sat beside the imprint and the terms. Nothing about the current health of the platform is a legal document. It is a real page now: portal, instances, provisioning and backups, each derived from records, aggregate only, and a component with no signal reports "unknown" rather than "operational". That last rule is what exposed the real bug. monitoring_targets.status was written once — 'up', at provisioning — and nothing ever updated it. Both the console's notices and the new public page read it, so an outage would have been published as healthy indefinitely. There is a sync job now, on a five-minute schedule, and a checked_at column so a verdict can go stale instead of standing forever. The monitoring contract had to grow a third state for that to be honest. isHealthy() answers true when no monitoring is configured and false when the monitor is unreachable; recording that boolean would have published either a fleet-wide all-clear nobody measured or a fleet-wide outage that was really one broken monitor. health() returns null for both, the recorder leaves the old verdict to go stale, and isHealthy() keeps its forgiving semantics for the one caller that wants them — the provisioning acceptance check, which must not fail a delivery because monitoring is not set up. Backups are counted from the instances that need protecting rather than from the backup rows that exist, so an instance with no schedule at all cannot be missing from the arithmetic that declares the estate protected. Also: the update panel polls itself, offers the button only when there is something to install, and opens the log while a run is in progress. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
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>
|
|
|
|
52b41bb0d5 |
fix(billing): a paid order opens a contract, and provisioning obeys it
The pipeline re-resolved config('provisioning.plans') by order.plan, so the
subscription snapshot protected a customer's price but not their machine:
shrinking a plan resized an existing customer's VM on its next run. Nothing
created a subscription either, so closing this meant opening the contract at
purchase and pointing provisioning at it.
- OpenSubscription freezes the catalogue onto a subscription when a checkout
is paid; StartCustomerProvisioning calls it inside the order transaction.
- CustomerStep::plan() reads the frozen snapshot. ValidateOrder and
ReserveResources fail closed with no_subscription rather than falling back
to the catalogue, which is the bug itself.
- template_vmid joins the snapshot so a re-clone cannot pick up a blueprint
published after the sale. Deliberately outside FROZEN: it is how we build
the machine, not a term the customer is owed, and a dead template must be
replaceable without cancelling a contract.
- TrafficMeter reads the allowance off the contract too — cutting a plan's
traffic was otherwise enough to start throttling someone who bought more.
- The migration backfills contracts for orders that already bought something,
reconstructed from what was actually delivered where an instance exists,
and adopts an existing order-less contract instead of opening a second.
Orders paid in a currency the catalogue cannot price get none, matching the
checkout path.
price_cents stays the catalogue's NET price, which is what PlanChange
prorates against — not Order::amount_cents, which holds Stripe's GROSS total.
Reconciling the two belongs to the proof register and Stripe (phases 4/5).
Also pins STRIPE_WEBHOOK_SECRET blank in phpunit.xml: the operator's real
secret was reaching the suite from .env and rejecting every unsigned test
payload, which is why 7 webhook tests failed before any of this.
Verified in the browser: with team traffic cut from 3000 to 500 GB in the
catalogue, the customer's portal still shows 3 TB.
373 tests green. Codex review clean after three rounds.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|