Compare commits

...

605 Commits

Author SHA1 Message Date
nexxo dac84f024a Release v1.3.71
tests / pest (push) Failing after 8m16s Details
tests / assets (push) Successful in 21s Details
tests / release (push) Has been skipped Details
The hostnames page is now in the navigation under Betrieb — it had a route and
no entry, which meant it existed only for whoever knew the URL.

And files.… no longer redirects to app.… when called without a path. It answers
404, like every other address in this installation that has nothing to offer:
the redirect told anyone who tried the name where the portal lives and that both
share a machine.

2037 tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 23:21:39 +02:00
nexxo 60c501e40d Put the page in the navigation, and stop files. handing out the portal
Two faults, both mine, both reported from the live server.

The page had a route and no navigation entry. A page reachable only by typing
its URL does not exist as far as the operator is concerned, and "Betrieb →
Hostnamen und Zertifikate" was exactly as findable as I had made it: not at all.
It sits under Betrieb rather than System because what is set there decides
whether an address ANSWERS — that is operations, not configuration.

And calling files.… without a path redirected to app.… The host-bound group only
claimed /bootstrap.tar.gz and /{file}; a bare / matches neither, so the request
fell through to the host-agnostic routes and landed on the portal. Two holes,
because / and a multi-segment path miss the placeholder for different reasons,
and both are closed now.

404 rather than a redirect, and that is the point rather than a detail. The
redirect told anybody who tried the name where the portal lives and that both
sit on the same machine — the one thing every other hostname in routes/web.php
is careful not to say. An address with nothing to offer has nothing to tell
either.

2037 tests pass, assets build.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 23:21:15 +02:00
nexxo 3c5bded19c Release v1.3.70
tests / pest (push) Failing after 8m57s Details
tests / assets (push) Successful in 19s Details
tests / release (push) Has been skipped Details
Hostnames and certificates from the console: add a name, see whether it has a
certificate and for how long, and apply the list to the reverse proxy without
logging into the server.

Two numbers are skipped and both were my mistake, so they are named here rather
than left as gaps somebody has to work out later. v1.3.68 was cut while the
working directory was still inside the feature worktree, so it landed on the
branch instead of on main; this release carries it. v1.3.69 was pushed pointing
at v1.3.67's commit. Deleted rather than moved, both times: a published tag that
changes what it points at is worse than a number nobody uses.

This release needs one thing done once per server, because the root-owned helper
gained a verb:

  sudo bash deploy/install-agent.sh

and one line in /etc/caddy/Caddyfile:

  import /etc/caddy/clupilot-proxy-hosts.conf

The installer creates that file before anything imports it — an import of a
missing file makes the whole proxy configuration invalid, and that takes the
public site with it.

2035 tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 22:57:47 +02:00
nexxo 87264ef1f7 Merge console-managed hostnames, and put the release line back on main
One conflict, in VERSION, and it is worth naming rather than resolving quietly.

v1.3.68 was cut on the FEATURE branch, not on main: the working directory was
still inside .worktrees/host-bootstrap when the merge-and-tag ran, so the merge
was a no-op against itself, the version commit landed on the branch, and the tag
went with it. main stayed on 1.3.67 while a tag claimed otherwise.

This merge brings that commit onto main, so v1.3.68 becomes reachable from the
line it always claimed to be on, and the version continues at 1.3.69 rather than
pretending the number was never used.

The project's own memory records this exact failure — check which branch you are
on before every commit — from the time two releases landed on feat/granted-plans
while live stayed where it was. It was a parallel session then and a stale
working directory now; the rule is the same one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 22:56:49 +02:00
nexxo 006ce3568b Manage hostnames and watch their certificates from the console
files.clupilot.com is why this exists. DNS pointed at the right machine, the
env var was set, the release was deployed — and there was still no certificate,
because /etc/caddy/Caddyfile is maintained by hand and nobody thought of it as a
second, separate step. Nothing in the console would have said so. Three settings
looked correct and the address did not answer.

So the page holds two things side by side. The WISH — which names should be
served — and the REALITY: whether the name has a certificate and for how much
longer. The second is measured by opening a TLS connection and reading the
expiry, not by reading configuration, because the configuration is exactly what
looked right while the address was dead. verify_peer stays on: a certificate
that fails validation is not a certificate for this question, and a display that
called it valid would be the fake R19 records.

Applying goes through the existing agent, not a new channel. The console writes
a request, the path unit wakes the agent within a second, and the agent calls one
fixed command line of the root-owned helper.

What that helper is allowed to do is the careful part. It fetches the list
ITSELF rather than being handed one, and the list is HOSTNAMES, never Caddy
blocks — `php artisan clupilot:proxy-hosts` prints `<name> <purpose>` and nothing
else. Each name is matched against a strict pattern before it is used, and the
template around it lives in the helper, which the service account cannot touch.
install-agent.sh already states the principle for the sudoers grant: a grant is
only worth anything if the holder cannot change what it grants. A service account
that could write proxy configuration would have everything the proxy can do —
redirects anywhere, files from any directory.

Purpose is a column rather than a habit. A console name gets the network lock,
a public one does not, and a console name published without it looks exactly
like a working page.

Removing takes the name out of the list and NOT out of the running proxy. Two
decisions in one click, and the second one takes a site off the air.

There is deliberately no "renew" button. Caddy renews on its own at two thirds
of the lifetime; what an operator actually needs is a second attempt after an
issuance has failed, and that is a reload — which is what Apply does. Thirty days
is treated as a problem rather than a warning: at ninety days' lifetime, a
renewal should long since have run, so anything under it is not a tight
certificate but a renewal that is not happening.

The ACME contact falls back to the owner's address, because a contact nobody
reads is the step before expired customer certificates.

CONTRACT and HOST_STEP_NEEDS both move to 2, which is what tells a server
carrying the older helper to run the installer again — caught by the guard test
that compares the two halves.

2035 tests pass, assets build.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 22:55:55 +02:00
nexxo a3b43c5175 Release v1.3.68
The reference Caddyfile now carries a block for FILES_HOST. Setting the variable
alone was never enough: the proxy has to terminate TLS for that name and forward
it, and without a site block Caddy never even asks for a certificate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 22:34:15 +02:00
nexxo 20d87c0474 Put the files hostname in the reference proxy configuration
FILES_HOST tells Laravel which hostname to bind the routes to. It does not make
the reverse proxy terminate TLS for that name or forward it anywhere, and the
live server showed exactly that gap: DNS correct, the right machine, port 80
answering — and a TLS handshake that fails because no certificate exists for the
name.

The block carries two things the other public names do not have to think about.

No console allowlist. The archive is fetched by a server in a RESCUE SYSTEM: a
machine with no tunnel and a public address that is in nobody's allow list. The
console's network lock here would be a lock against the only caller that needs
it. The protection lives in the application instead — no valid one-time code,
404 — and the legal documents are public by intent.

And no `http://… { abort }` either, unlike admin and ws below it. Those two hide
that they exist; this one is meant to be known, and killing port 80 takes the
path away from an ACME check on the day Caddy's own challenge handler is not the
first thing to answer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 22:34:15 +02:00
nexxo fced87d23a Release v1.3.67
tests / pest (push) Failing after 8m48s Details
tests / assets (push) Successful in 20s Details
tests / release (push) Has been skipped Details
Add host rebuilt as a page with one job: a sticky rail of six short steps, the
two provider prerequisites in a panel above the form, and after saving the
command line as the page itself.

Downloads now pass PublicSiteGate. While the site is hidden, a server in a
rescue system would have received the 503 placeholder and piped it into tar — an
unpack error on a machine only reachable through the provider's console.

The number skips 1.3.66. That tag was pushed pointing at v1.3.65's commit,
because the merge behind it had not actually happened, and a server pinning it
would have installed the older tree. It was deleted rather than moved: a
published tag that changes what it points at is worse than a number nobody uses.

2026 tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 21:53:59 +02:00
nexxo cfc4985f89 Merge the host-takeover branch
No conflicts: the branch had already taken main up to v1.3.65, and the only
commit main carried on its own was that release itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 21:52:29 +02:00
nexxo 398028a57d Rebuild Add host as a page with one job, and let downloads through the gate
The page was a wall. Six paragraphs of procedure stacked above a form, so the
thing the page actually asks for — four fields — sat underneath an essay about
what would happen afterwards. It answered everything and showed nothing.

It is now built the way the settings page is built, because that page was
rebuilt for the same reason and there is no case for a second idiom: an eyebrow,
a title, a sticky rail on the left and panels of rows on the right.

The rail carries the six steps as two-word labels rather than paragraphs. That
is what the question at this moment actually is — where am I, how much is left —
and it fits in 232 pixels. The numbers carry the state; a tick beside them would
be a second sign for one statement, and a number can be counted.

What a step MEANS now appears in the row where it is due, not six times in
advance. The two provider steps get a panel of their own above the form, because
they have to be done before you save: the one-time code starts expiring the
moment you do. Everything after the form waits until there is something to say.

After saving the command becomes the page. Full width, its own framed block with
the warning in the header strip rather than floating above, and the two
remaining steps below it as ordinary rows.

Two token bugs went with it. `bg-canvas` does not exist — it was a class that
compiled to nothing, which is part of why the block looked wrong. And
`text-accent` on white is 2.9:1; the config says in as many words to use
accent-text for anything read, so the current step number does.

Also fixed, and it would have broken every takeover in production: PublicSiteGate
is appended to the whole web group, so while the site is hidden a server in a
rescue system fetching the archive would have received the 503 placeholder and
piped it into tar. The operator would have seen an unpack error on a machine
only reachable through the provider's console, with nothing pointing at a switch
in the admin area — and they are by definition neither on a management network
nor signed in, since leaving that state is the whole point. Exempted by ROUTE
NAME, not by hostname: the docblock rightly warns that exempting by host means
trusting a header the caller picks, but that warning is about the entire portal.
Behind these two routes are documents that are public anyway and an archive that
404s without a valid one-time code.

2026 tests pass, assets build.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 21:51:30 +02:00
nexxo fc9cc20610 Release v1.3.65
tests / pest (push) Failing after 8m51s Details
tests / assets (push) Successful in 20s Details
tests / release (push) Has been skipped Details
A hostname of its own for downloads. Public: the terms, the DPA and the TOM,
anonymous and indexable, under versioned filenames so an address in a signed
contract keeps meaning what it meant. Private: the bootstrap archive, gated by
the one-time enrolment code that is already in the copied line.

Set FILES_HOST to switch it on. Left empty, the archive stays on the portal
host and nothing changes.

2025 tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 21:37:48 +02:00
nexxo 156de12c8c Give the downloads a hostname of their own, with two halves
A domain that exists only to serve one installer is not worth a certificate.
One that also carries the AGB, the AV and the TOM is — those addresses go into
contracts and onto invoices and have to still resolve in three years, which an
address that moves with the next rebuild of the portal cannot promise. That
reason is what changed the answer.

files.… over cdn./storage./archiv.: a CDN is an edge network and this is not
one, so the name would be a lie the day a real CDN goes in front of it.
"storage" reads like object storage or customer data, and a customer seeing it
will wonder whether their files live there. "archiv" says superseded, which the
terms currently in force are not — and R13 keeps paths and names English
anyway.

Two halves on that host, with opposite rules, and that is the whole point of
giving it its own name:

Public — storage/app/files/public/, served to anyone, indexable, because
somebody looking for the terms should find them. Versioned filenames:
agb-2026-01.pdf, never agb.pdf, so a contract signed in January cannot come to
point at conditions written in July. The rule is written where somebody will
look for it rather than enforced, because a upload that rejects a filename
helps nobody.

Private — the installer, and it is not a file in that directory at all: it is
built from deploy/bootstrap on demand. The gate is the one-time enrolment code
that is ALREADY in the pasted line, resolved without being consumed, because
the code is still needed for every progress report and for the registration at
the end. No second secret: a dedicated download token would never expire, would
sit in shell histories forever, and would travel in the same line as the
WireGuard private key — protecting the least sensitive thing with the exposure
of the most sensitive one. 404 rather than 403 on a bad code, and noindex on
the response.

Path traversal is answered before it starts: basename() only, no directory tree
under public/ by design, and dotfiles refused. The test walks ../../.env three
different ways.

Empty FILES_HOST keeps the archive on the portal host exactly as before, so
nothing breaks in the window between setting the variable and the DNS record
existing.

The hostname had to move into phpunit.xml rather than a config()->set(): routes
are bound at boot, so a test that sets it afterwards is setting it too late.

2025 tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 21:37:47 +02:00
nexxo 1e22d66639 Release v1.3.64
tests / pest (push) Failing after 8m53s Details
tests / assets (push) Successful in 20s Details
tests / release (push) Has been skipped Details
Two features reach a tag at once, because the host-takeover branch was cut from
the operating-mode branch and carries all of it.

The operating mode: a test and a live setting for the whole installation, every
credential with a test slot beside its live one, a stored key that says which
mode it belongs to, and a Stripe catalogue that refuses to act on objects from
the other account.

The host takeover: the bootstrap script that turns a machine in a rescue system
into a finished Proxmox host, the archive that serves it, the one-time enrolment
code, and the six-step guide in the console under Hosts and Add host. None of
the script's steps has been accepted on real hardware yet — every hardware step
in its plan is still unticked, and the console work is what this release makes
visible.

Also in here, found while writing the takeover: host names were being built from
the CUSTOMER zone. RegisterHostDns says clupilot.com in its own docblock, and on
this installation a host was actually called fsn-01.node.clupilot.cloud. There
is a platform_zone now and the step uses it.

2017 tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 21:22:00 +02:00
nexxo 447d6fe0f3 Merge main into the host-takeover branch
One real conflict, in SyncStripeCatalogue::handle(), and both sides were right.

Main's parallel work resolves the adoption singletons and takes "before" counts
so a run can tell created from adopted. The operating-mode work refuses a
catalogue whose stored objects belong to the other mode, and records which mode
the catalogue now belongs to.

Kept both, with the mode guard first. It REFUSES, so it must not run behind
anything that has already built state; the counters only need to be in place
before the create loop, and they still are. Reversing that order would have the
command resolve singletons and take counts on a catalogue it is about to reject.

2017 tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 21:21:45 +02:00
nexxo ba4996f316 Keep the platform on .com and the customers on .cloud
The owner caught me putting the archive on clupilot.cloud. That is the customer
zone, and looking into it turned up the same confusion already sitting in the
code — in the one place that matters most.

RegisterHostDns says "fsn-01.node.clupilot.com" in its own docblock. The
validation comment in Datacenters says it. ServicesTest writes it out verbatim.
The step itself built the name from config('provisioning.dns.zone') — the
CUSTOMER zone — so on this installation a host was actually called
fsn-01.node.clupilot.cloud. Three places asserting one thing and the code doing
another.

OfficialDomains explains why that matters and is worth not weakening: two
registrable domains by design, the company's for site, portal and console, the
instance zone for customer workloads. A Nextcloud is third-party software that
strangers sign into, and on the same registrable domain as the portal it shares
cookie scope with it. A host name in that zone does not break the separation,
but it puts it in question, and the next slip is more expensive.

So there is now a platform_zone, derived from APP_URL when unset, and
RegisterHostDns uses it.

My own archiveUrl was broken for a second reason. It fell back to url() when
APP_HOST is empty — and APP_HOST is empty on most installations, because empty
means "the portal answers on any hostname" and that is the default. Called from
the console, url() would have produced the CONSOLE hostname, and the line would
have 404'd on a machine that is not allowed to reach the admin area at all. It
takes the host from APP_URL now.

The script no longer guesses its own name. It used reverse DNS, then the tunnel
address, then a hard-coded clupilot.net — a third domain that appears nowhere
else in this project and was simply invented. PrepareBaseSystem has the same
invention. A guessed name does not stay guessed: it ends up in /etc/hostname, in
/etc/hosts, in every log line and in every certificate request the machine ever
makes. CluPilot knows the name because it just assigned it, so it passes --fqdn
and the script refuses without it. The value now also survives the reboot in the
arguments file, which it would not have.

The ACME contact moved to .com for the same reason it was wrong: the operator
does not live in the customer zone.

Open, and NOT decided here: the owner also wants the host to get a public DNS
record and a certificate on the .com name. RegisterHostDns deliberately writes
host names only into the tunnel's dnsmasq, and says why — publishing them hands
every scanner the internal subnet and roughly how many hosts sit behind it.
Nothing in the current design needs a public certificate for a host's own name;
Traefik serves customer domains, not this one. Reversing that is a security
decision and belongs to the owner, not to this commit.

1992 tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 21:04:08 +02:00
nexxo 2194ab8079 Put the instructions where somebody reads them before they start
The previous commit shipped the guide AFTER the host was created. Its first two
steps happen at the provider — order the machine, boot the rescue system — and
whoever reads them there has the one-time code already in the clipboard with its
24-hour window running. The instructions were correct and in the wrong place,
which is its own kind of wrong.

Six steps now, not three, and the two provider ones are named rather than
assumed. They are marked "first" so the list does not read as something you can
start at the top of and work down: step 2 is half an hour of waiting, and doing
it inside a running code is exactly the mistake the page should prevent.

All six are visible the whole time, including the done ones and the ones still
to come. A guide that shows only the current step cannot answer "how much is
left", which is the question somebody has while a server they are paying for
sits in a rescue system.

It is on the hosts list too, collapsed. Somebody standing there may not have
ordered the machine yet — and that is step 1. Requiring them to click "add host"
to find out what the procedure is puts the answer behind the action it describes.

One component, two places, so the archive address in the instructions is the same
string the command line will carry. Two copies of that would drift, and the
difference would surface on a server that has already been paid for.

The step-4 command box moved into the guide rather than sitting beside it, so
after creating a host the whole procedure stays on screen. Somebody stuck at
step 5 should not be looking at a page that has become a single box.

1991 tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 20:51:47 +02:00
nexxo 903ebdd2b2 Give the operator one line to copy and three steps around it
The console could describe a takeover it had no way to start. This is the
vertical slice that closes that: a one-time code, the archive the rescue system
fetches, and the page that says what to do with both.

The command carries EVERYTHING the script needs before the tunnel exists,
because there is nothing to fetch — that is the whole point of spec §5. Which
means CluPilot generates the WireGuard keypair and admits the peer at the hub
before the machine has ever booted, and hands the private half over in the line.
It is worthless within minutes: task 9 of the script replaces it with one
generated on the machine.

Shown exactly once. The database holds only the code's hash and never the
private key, so leaving the page does not bring it back — it mints a new code,
which invalidates the old one. That is deliberate: a glance at somebody's screen
should be worth nothing an hour later.

Which is also why save() no longer redirects. Sending the operator to the host
detail page sends them away from the only value they need, and an existing test
asserted that redirect — it now asserts the opposite, with the reason written
next to it.

SHA-256 rather than bcrypt for the code, and the reason is not speed. Both
endpoints have to FIND the host by the code; with bcrypt that means trying every
row. The code is 32 characters of CSPRNG output, so it has the entropy that
stretching exists to manufacture.

resolve() and claim() are separate because progress reports arrive BEFORE
registration. If reporting consumed the code, a host could never register after
its first message.

The archive URL is always the public hostname. The console runs under admin.…,
but this line executes on a machine that must not reach the admin area — it is
locked down for exactly that reason — so route() from the console would emit a
hostname that 404s on a server only reachable through the provider's console.

The page warns about missing tunnel settings BEFORE the host is created, not
after. An empty hub key produces a line that looks clean, copies fine, runs, and
ends in a tunnel that never handshakes — discovered on the machine, after
somebody has already paid for it.

The three steps lead with the rescue system, because that is the one nobody
knows by heart, and it says enabling is not the same as booting into it — the
script refuses a running production machine, which is what a half-done switch
looks like from the inside.

1986 tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 20:40:19 +02:00
nexxo 668ed67de4 Write down what to do when the bootstrap stops
One entry per section: how you notice, where to look, what it usually is. Above
all of them the rule the plan already states — a half installed machine is
reinstalled, not repaired — with the reason, which is not convenience. The
script is built so a second run on a freshly imaged machine always comes out the
same; a run on a machine somebody straightened out by hand is no longer that.
Repairing trades a repeatable result for a one-off, and the difference only
shows up on the next host.

rescue_checked is the stated exception, because nothing has been written yet. It
is the only section where fixing in place is right, and the runbook says so
rather than leaving the reader to infer it.

Two things that are easy to get wrong are answered up front. Whether the reboot
has happened is one `findmnt -no FSTYPE /` — tmpfs or overlay means still in the
rescue system, zfs means the first-boot hook already resumed. And when a host is
unreachable, the first question is whether the firewall had even run: it is
applied last, so if `registered` is not done, the cause is the network and not
the rules.

The installer can be watched while it runs. If QEMU is still up, forwarding 5900
over SSH shows what the ISO is doing, and an input mask there means the answer
file was not accepted and the installer has dropped into interactive mode where
it will sit until the hour expires. That failure otherwise arrives as a timeout
saying nothing.

The last heading is the honest one: everything here is written from the script
and the findings behind it, not from incidents. What actually stops on the first
real run belongs in this file afterwards, quoted as it appeared in the console.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 20:22:32 +02:00
nexxo f45f290f00 Hand over the token and let the console take it from here
The last section carries three things, and none of them could sit anywhere
else: the registration and key swap, Traefik's token plus the proof that a route
table actually arrives, and the nftables lockdown that ran last in the old
pipeline for a reason.

`|| true` is gone from the pveum calls, and not by deleting it. `role add ||
true` applied the privilege list only on the run that first created the role, so
a privilege added later reached new hosts and no existing one — which is exactly
how Sys.Modify was missing everywhere, surfacing much later and somewhere else
as a 403 on POST /cluster/backup that failed a paying customer at
register_backup. So: add, and on failure modify, as two separate commands so a
failure can be attributed. For the user and the ACL the tolerated `|| true` is
replaced by looking: try, and if it fails, check whether the desired state holds
anyway. A `|| true` hides "already there" and "went wrong" equally well.

The privilege list is copied verbatim from config/provisioning.php and checked
against it — 18 privileges, byte-identical, Sys.Modify included. It is granted on
/ because both endpoints that need it check / and nothing narrower satisfies
them.

The key swap follows the four steps this plan was amended to require. Register
first and hold the answer, switch wg0 to the new private key, PROVE a fresh
handshake, and only then let the old key go. If the handshake does not come, it
puts the old key back and fails loudly. Ordering alone says when to discard, not
whether the new key carries — and a host that locks itself out on the final step
is the one failure nobody fixes remotely.

Traefik's token is written here because here is where it exists, and the route
table is then fetched directly with that token and that URL. Section 7 could not
give this proof; it is not skipped, it is given where it can be.

The lockdown comes dead last, with the full ruleset from SecureHostFirewall
including the ICMP correction — ICMPv6 neighbour discovery and packet-too-big,
IPv4 fragmentation-needed — because a bare policy drop takes IPv6 down within
minutes and black-holes large transfers instead of failing them. The ruleset is
validated with `nft -c` before it is applied, since a partially loaded ruleset
means policy drop is in place and the exceptions are not. There is no automatic
reopening: a firewall that reopens itself under failure is not a firewall, so the
emergency release is a script for the provider's console.

Verified without hardware: all seven files dash-clean; json_field reads
full-tokenid, value and host_token without jq; the privilege list is identical to
the config's; and the rendered ruleset carries policy drop, both ICMP families,
80/443, the DHCP rebind rule and the tunnel-only 22/8006. Step 2 unticked.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 20:20:20 +02:00
nexxo 5d25018cc6 Build the golden template so nobody has to remember how
All three Block A traps are CHECKED here rather than assumed, because each one
has already cost a paid order and none of them shows a symptom until it is too
late.

Trap 2 is checked before anything is built: virt-filesystems is asked whether
the image uses LVM and whether root is the last partition, and the section
refuses if either is wrong. Debian's cloud image satisfies both by construction,
which is exactly why it is the base — but "satisfies it by construction" is a
claim about an artefact somebody else builds, and it gets verified rather than
trusted.

Traps 1 and 3 are checked AFTER the image is customised, because virt-customize
reports success even when apt quietly did not install something. qemu-guest-agent
has to be in /usr/bin or every provisioning run hangs in WaitForGuestAgent until
it times out, and the compose file inside the image has to carry `user:
www-data` or every occ call fails — including the acceptance check that decides
whether a customer's instance is usable.

The template is verified by its ATTRIBUTE, not its existence. VerifyVmTemplate
checks only that 9000 is there, which a VM that merely happens to be numbered
9000 also passes; `template: 1` is passed only by a template.

Docker comes from Docker's own repository, not Debian's. docker.io ships no
compose plugin and Debian's docker-compose is the old Python one, which does not
read this file at all.

The compose file is deliberately customer-independent: no password, no name, no
domain. Everything variable arrives in /opt/nextcloud/.env, written into the
guest by cloud-init at clone time. Baking a password into an image copies it
onto every host and into every instance, and leaves it there long after the
customer has changed it.

OVERWRITEPROTOCOL, OVERWRITEHOST and TRUSTED_PROXIES are set because TLS ends at
Traefik on the host. Without them Nextcloud builds its own URLs with http://,
the login loops, and WebDAV clients get handed an address that does not exist.

Storage is discovered rather than named: local-zfs is what the PVE installer
creates on ZFS, local-lvm on ext4, and `local` frequently cannot hold disks at
all — an importdisk there fails only after the copy.

Verified without hardware: dash-clean; the compose file parses, carries `user:
www-data` on the app service, publishes 80, and every credential is a ${...}
reference rather than a literal. Step 2 unticked, and it is the one the plan is
most insistent about: the template must actually be cloned, started, and asked
`occ status` as www-data. Without that clone it is not proven.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 20:16:50 +02:00
nexxo a25e9c2fe6 Run Traefik as a service that fetches its own routes
Binary plus systemd unit, no Docker on the hypervisor: nftables stays the only
owner of the host's firewall rules and a Docker daemon would bring its own
chain.

The names were read out of SshTraefikWriter::render() rather than guessed, which
is what the handoff asks for in as many words. The route endpoint emits
entryPoints ["websecure"] and certResolver "letsencrypt", so the static config
declares exactly those. Name them differently here and the routers point at
nothing while Traefik still reports a clean start — a failure with no symptom
at the place it happens.

An ordering problem this plan had not resolved: the http provider needs the
durable host token, and that token only exists after POST /host/register, two
sections later. Rather than reorder the section keys — they are the contract
with the platform plan — the static config is written twice. Here with an empty
token, so the service stands and holds 80 and 443, and again in Task 9 with the
real one. The proof does not disappear, it moves to where it can be given.

That proof asks the endpoint directly with the same token and URL the config
carries, instead of counting Traefik's routers. A fresh host has no customers,
so its table is legitimately empty, and "zero routers" would mean both "fine"
and "never fetched".

Ports are checked separately from the service. "Running" and "listening" are two
claims, and Traefik starts cleanly even when a typo means an entryPoint was
never created.

The binary is checksummed against the release's own checksums file, for the same
reason the ISO is: what listens on 80 and 443 and holds every customer's
certificate does not get taken off the network unverified. The version is
discovered at runtime, because a pinned number becomes a 404 mid-takeover.

acme.json is created at 600 before Traefik ever runs. It holds the private keys
of every customer certificate, and Traefik refuses wider permissions — rightly.
The unit runs with CAP_NET_BIND_SERVICE and nothing else, ProtectSystem=strict,
NoNewPrivileges: this is the one process on the box reachable from the open
internet.

Verified without hardware: dash-clean, the generated config parses as YAML, and
it carries web/websecure/traefik as entryPoints, letsencrypt as the resolver,
the Bearer header on the http provider, web redirecting to websecure, and 640 on
the config with 600 on acme.json and the token file. Step 2 unticked — it wants a
fetched route set, and that is Task 9's to show.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 20:13:54 +02:00
nexxo c345f21e7d Join the tunnel, then tell the console everything so far
The three interface states come over from ConfigureWireguard.php exactly as
that file works them out, because the one line they replace got two of them
wrong. `systemctl enable --now wg-quick@wg0 || wg-quick up wg0` brought the
interface up via its fallback WITHOUT the systemd enablement, so the tunnel did
not come back after a reboot; and on the next attempt both halves failed with
"wg0 already exists", so it retried forever against a tunnel that was working.
Enable before start, because enable is the half that survives a reboot, and it
is repaired even when the interface is already up by other means.

The configuration is compared before it is written. The file used to be
rewritten unconditionally with nothing reloading it, so correcting a wrong hub
key showed a new file and identical behaviour — the running interface still
held the old peer. Knowing whether it CHANGED is what lets an unchanged, working
tunnel be left alone and a corrected one actually be applied. A change means a
restart rather than `wg syncconf`, because syncconf applies peers only and a
corrected Address or AllowedIPs would silently do nothing.

AllowedIPs is computed, not copied. Writing the host's own address there would
let the tunnel handshake and leave every other participant unreachable — a fault
that looks like a routing problem somewhere else entirely.

The handshake target is derived from --api rather than taking its own argument.
CluPilot's tunnel address is already in there, and a second value meaning the
same thing is a second value that eventually disagrees with the first.

The tunnel counts only when it has been PROVEN, never when the file is on disk.
A stored tunnel address without proof made every later connection attempt
useless and the recovery was hand-editing the database. Once it is proven,
flush_reports runs and the whole history from the rescue system onwards reaches
the console carrying its original timestamps.

Verified without hardware: dash-clean; the subnet arithmetic is right on octet
boundaries and off them (192.168.5.130/25 gives 192.168.5.128/25, 172.16.4.9/12
gives 172.16.0.0/12); the API host parser handles a bare address, a scheme, a
port and a path; and wg0.conf renders with AllowedIPs on the network rather than
the host. Step 2 unticked — it wants all five earlier sections showing up in the
console with real timestamps, and that needs a hub.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 20:08:39 +02:00
nexxo 9e485efe87 Write the firewall's running order into the plan, not just the commit
The previous commit moved the nftables lockdown out of Task 5 and explained why
in its message. A commit message is not where the next person looks. Task 5 now
carries it: the bridge is normally already there because the ISO installer
writes it, the lockdown belongs in Task 9 because SecureHostFirewall ran last
for a reason, and "check from outside" cannot mean what it says before the
tunnel exists — so the script checks inside-out and says so.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 20:06:48 +02:00
nexxo e9b2862331 Bridge the network, and take it back if the host goes quiet
Reading ConfigureProxmox.php changed what this section does. The missing vmbr0
that killed the first paid order is a consequence of installing Proxmox ON TOP
OF DEBIAN — "only the ISO installer writes that bridge into
/etc/network/interfaces". This design installs from the ISO, so the bridge is
normally already there. The section verifies it instead of building it: exists,
has an address, and actually carries the default route. The old step checked
only the first of those, threw the result away, and its comment claimed it
recorded the absence.

When there is no bridge, it builds one, and only then is this the dangerous
section the plan calls it. Backup, five-minute rollback timer, switch, look,
cancel — and the timer is a systemd unit rather than a backgrounded sleep,
because a background job dies with its session and the session is exactly what
breaks when the switch goes wrong. It is set BEFORE the change; setting it after
would mean setting it at the moment the connection is already gone.

The three provider shapes differ in one line, and that line decides whether the
machine is still there afterwards. A routed single address needs pointopoint,
because its gateway is outside its own subnet and without it the kernel has no
route to reach it. Which shape applies is decided by asking `ip route get` about
the gateway rather than by doing subnet arithmetic here — same logic the kernel
will apply later.

The reachability check is named for what it is: inside-out. A true outside-in
proof would need a counterpart, and before the tunnel there is none. It is
enough because a bridge that takes the host off the network takes both
directions with it, and what it does not cover is precisely what the timer
covers.

One deviation from the plan, written into it: the nftables lockdown does NOT
belong here. SecureHostFirewall ran LAST in the old pipeline, deliberately, so
the tunnel had already carried every earlier step before SSH-to-the-world was
closed. Here it would sit BEFORE wireguard_joined and shut port 22 with no
handshake yet proven — the ordering that makes a host permanently unreachable.
It moves to Task 9, where it used to be. The self-rollback stays here and covers
the network change; the firewall keeps its manual emergency release, because a
firewall that reopens itself under failure is not a firewall.

The datacenter firewall comes over verbatim, including why `enable 1` alone is a
trap: the management ipset is seeded from the public subnet, so enabling it
blindly drops the tunnel address that every later step arrives on. Three
settings in order, then read back — a pvesh set returning 0 is not the same as
the value being in cluster.fw, and a write that lost quorum is exactly the case
where every customer VM's rules stay inert while onboarding reports success.

Verified without hardware: all four files dash-clean, and the generated
interfaces file is correct for subnet, routed (with pointopoint) and dhcp.
Step 2 unticked — the self-rollback has to be triggered on purpose on real
hardware, and the plan is right that without that it is an assertion.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 20:06:18 +02:00
nexxo f214713113 Point Proxmox at the sources this Debian actually has
PVE arrives with the image now, so this section accepts it rather than installs
it — but it needs every line of knowledge from InstallProxmoxVe.php to do that,
just pointed the other way.

The kernel is the claim, not the package. An installed proxmox-ve running on a
Debian kernel is exactly the half state RebootIntoPveKernel exists because of,
so uname is what gets checked.

The codename table now verifies a pairing instead of creating one. An image
whose PVE major does not match its Debian base was built wrong, and no amount
of fixing package sources heals a PVE compiled against a different libc — that
machine gets reinstalled with the right image. The other half of the table,
codename to PVE major, is written down here because the ISO route needs it and
the original file only had the suite side.

Removing the subscription repositories is the part that everything after this
depends on. A Proxmox image ships pve-enterprise, and without a subscription it
fails every apt-get update — after which no package install in any later
section succeeds. Both spellings are handled, because PVE 8 used one-line .list
files and PVE 9 moved to deb822, and ceph is in the list because the same trap
is set there a second time. The proof is a clean apt-get update at the end,
which is the only thing that tells removal apart from overwriting.

One thing changed after the first draft: when pveversion cannot be parsed, the
pairing check used to be skipped in silence. That is the fake that R19 records
as worse than no check at all, because it stops the next person from looking.
It now says "Paarung UNGEPRÜFT" in the report and in the log.

Verified without hardware: dash-clean; bookworm maps to PVE 8, trixie to PVE 9,
forky is refused; the pveversion parser reads 9 and 8 out of both real formats
and yields nothing for garbage; and against a copy of /etc/apt/sources.list.d
holding all five subscription files plus an unrelated one, exactly the five go
and the unrelated one stays. Step 2 unticked — there is no Proxmox here to
accept.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 20:00:01 +02:00
nexxo 0f563545fd Write the image and survive the first reboot
The official Proxmox ISO, an answer file baked into it, and QEMU running it
against the physical disks from the rescue system. Options were looked up
rather than recalled: prepare-iso takes --fetch-from iso, --answer-file and
--on-first-boot, and it names its own output, so the finished ISO is found by
glob afterwards instead of being handed an --output flag I would have invented.

validate-answer runs BEFORE the ISO is baked. An answer file with a typo drops
the installer into interactive mode, where it then hangs invisibly inside QEMU
until the hour runs out — the failure would arrive as a timeout and say nothing
about the cause.

The codename table comes over verbatim from InstallProxmoxVe.php, which the
platform plan deletes. It is used here to install the assistant into the RESCUE
system, so it keys off the rescue system's own codename. An unknown one aborts;
not "trixie is newest, so trixie", because the next Debian will be unknown too
and by then nobody is watching.

The ISO is checksummed against the mirror's SHA256SUMS. Booting an operating
system that could have become anything in transit is not a risk worth taking,
and a truncated download is enough to cause it — malice is not required.

The reboot carry-across is the gap this plan was amended for, and [first-boot]
takes exactly one executable. So that one file carries everything: the script,
its lib/, the progress file and the arguments, as a base64 archive in its own
body. That also avoids importing the fresh ZFS pool from the rescue system to
copy files into it.

Two bugs found by running it rather than reading it. The staging copy put the
work directory inside the state directory, so cp refused to copy a directory
into itself — and had it not refused, the gigabyte ISO would have been base64'd
into the hook that gets baked into that same ISO. The fix is not a better
exclude: the two directories have opposite lifetimes, so they now live in
different places, and a guard refuses loudly if anyone points them at each other
again. Structure beats a rule someone has to remember.

Verified without hardware: both files pass sh and dash. The generated answer
file parses as TOML with the expected disk-list, zfs.raid, zfs.arc-max,
first-boot and network.filter, and a 64-character root password. The codename
table accepts bookworm and trixie and refuses bullseye and forky. The shim comes
out at 26 KB with a 3 MB dummy ISO sitting in the work directory, proving it
stayed out. Run in a fresh debian:13-slim container it unpacks to
/opt/clupilot/bootstrap and /var/lib/clupilot, restores the progress file with
its original timestamp, has 700 on the directory and 600 on the arguments that
hold the WireGuard key, skips rescue_checked as already done, and fails cleanly
on the missing qemu. Step 2 stays unticked: a container is not a rescue system,
and nothing here has yet written to a disk.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 19:57:29 +02:00
nexxo 0b26e45147 Install Proxmox the way Proxmox says to, not the way I guessed
The previous commit put installimage with a Proxmox image into Task 3. That
image does not exist. Hetzner's installimage carries no Proxmox and lists it as
unsupported, so anyone following that instruction finds only Debian and ends up
back at the intermediate base system the same commit had just removed. Looked
up rather than remembered, which is what should have happened before writing it
down.

The mechanism the owner described does exist and is the official one since PVE
8.2: build the real Proxmox ISO with an embedded answer file via
proxmox-auto-install-assistant, run it under QEMU against the physical disks
from the rescue system, reboot into a finished node. Hetzner publishes a
tutorial for exactly this. No Debian, no VNC, no click — which is what the
owner said in the first place.

Two things fall out of the answer file that improve the plan.

The hand-written systemd resume service is gone. [first-boot] with
source = "from-iso" is the supported way to have the script pick itself up
after the reboot, and it is the only variant that can work before the tunnel
exists — a from-url hook would be pointing at a CluPilot it cannot reach yet.
The carry-across of the progress file stays necessary regardless: the rescue
system's copy still lives in RAM.

Trap 2 was being applied to the wrong machine. "Root partition last, not on
LVM" comes from the template traps, and the reason it exists is
GrowGuestFilesystem, which grows a GUEST filesystem. The template is built in
Task 8 from a Debian cloud image, not from the host's own layout, so the
justification written into Task 3 ("it applies to the host too, because the
template is built from here") does not hold. It remains binding for the
template, where it belongs. The host gets ZFS, and that it happens to involve
no LVM is a side effect rather than the reason — with arc-max pinned, because
the default takes half the RAM a host needs for its guests.

Also recorded: the answer file needs an fqdn and a root password that the
command line does not carry. The password is generated and thrown away, because
the platform no longer logs in over SSH and the plan already says a half
installed machine is reinstalled rather than repaired — keeping a secret for an
emergency the plan does not have only creates another place a secret lives.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 19:45:29 +02:00
nexxo 8f943f3d1b Take the Debian step out of a plan that never needed one
The owner contradicted the plan, and the plan was wrong. It had the script
write Debian 13 and then had Task 4 turn that into a Proxmox host. In reality
you boot the provider's rescue system and install Proxmox directly — there is
no intermediate Debian for anyone to convert.

The section keys do NOT change. They are the only agreement with the platform
plan, and they are not worth touching for a rename — especially since they
still fit: Proxmox VE is Debian with the PVE kernel and packages on top. What
changes is what they mean, written down so the console labels them honestly:
debian_installed is "base system written", proxmox_installed is "PVE proven to
run".

Task 3 now uses installimage with a Proxmox image, and says the image name gets
pinned during acceptance instead of guessed here — the list belongs to the
provider and moves. No debootstrap and no hand-rolled partitioner: the provider
knows its own hardware, and a self-built boot path on someone else's metal only
comes back through their console. Hetzner dedicated only; a netcup path gets
written when there is a netcup machine to accept it on, because an unproven
fallback is exactly the assertion this plan avoids everywhere else (R22).

Task 4 keeps every line of knowledge from InstallProxmoxVe.php and needs all of
it — it just applies it to an image instead of a bare base. The codename table
now VERIFIES the pairing rather than creating it, and the enterprise repo that
ships with a Proxmox image has to go, because without a subscription it fails
every apt-get update and therefore every package install in the sections that
follow. Its acceptance now includes a clean apt-get update, which is the only
way to tell removal from overwriting.

Delivery is settled too, and was written down in neither document: a tar.gz
unpacked to /opt/clupilot/bootstrap. A curl | sh cannot carry a lib/, and the
separate files are load-bearing. A static archive is not an endpoint that
answers questions, so spec §5 stands.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 19:41:10 +02:00
nexxo 25bcab345d Refuse a machine that cannot do the job
Four checks, all of them before a single byte is written, because this is the
only moment the script still has the option of not overwriting a disk.

The expensive one is "is this actually a rescue system". It demands POSITIVE
evidence rather than the absence of a counter-argument: a rescue system runs
from RAM, so its root is tmpfs, an overlay or a ramdisk, and an installed
system has a real partition there. A running pveversion is refused outright —
that is a hypervisor with customers on it. Mounted partitions of real disks are
refused too, because at that point the script cannot tell an empty machine from
someone's data.

There is deliberately no flag to skip the check. Such a flag gets used exactly
once, on the evening it should not have been. A rescue system this does not
recognise belongs in the runbook, not in a bypass.

The disk floor is written as arithmetic rather than a number, because the repo
has no threshold to borrow — plans live in the database, not in
config/provisioning.php. Template ~20 GB, Proxmox and its swap and backups
~20 GB, one smallest customer ~50 GB, rounded up to 100. It is explicitly not
capacity planning; HostCapacity and reserve_pct do that after registration.
This only turns away the machine the whole thing cannot fit on.

Network and clock come from one plain-HTTP HEAD, deliberately without TLS —
otherwise a wrong clock would be checking itself and would report a certificate
error instead of the time. Where `date -d` is missing (busybox), the clock is
NOT checked and the report says so: a check that silently waves things through
is worse than none, because it stops the next person from looking. That is the
same lesson R19 records about ->timezone(config('app.timezone')).

Verified here, in both directions where a direction existed. On this VM (root
on /dev/sda1, 80 GB, /dev/kvm present): refuses with three findings at once,
exits 1, writes nothing. In a debian:13-slim container (root on overlay, no
/dev/kvm): the overlay root is accepted as rescue-like and the missing
/dev/kvm is caught, which is the abort case the plan names. Against a receiver
serving a Date header two years off: 63074141 seconds of drift, refused, with
the exact `date -u -s` line to fix it. Step 2 stays unticked — none of these is
a rescue system on a dedicated server.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 19:36:15 +02:00
nexxo af284d7d26 Give the bootstrap a spine and a way to report
Argument parsing, the state directory, and the half of the script that knows
the way back to CluPilot. No sections are hooked up yet; main lists the nine it
will get, in the order from spec §7, so what is missing stays visible.

report() writes locally first and only then tries to send, because before
wireguard_joined there is no path at all — the lines queue up and flush_reports
delivers them later WITH THEIR OWN TIMESTAMPS. That last part is the whole
point: a twenty-minute install that arrives in one batch looks like one second
in the console, and nobody can see which section was slow.

Every network operation is explicitly caught. The script runs under `set -e`,
and a failing send is the normal case for the first five sections, not an
error — a report that aborts the run would be the diagnosis that kills the
patient.

What is sent had no agreed shape. The section keys were the only agreement
between the two plans; the envelope around them was not written down anywhere,
so it is written down here, at the top of report.sh, for the platform side to
read once. There is deliberately no "running" state: the console derives "open"
from the absence of a report (spec §7), and a third state would be a second
truth about the same thing.

The sent-marker is a line count in its own file rather than a flag rewritten
into progress.jsonl. That keeps the log append-only, so a power cut mid-write
costs at most a partial last line instead of a rewritten file — and after the
reboot in Task 3 that file is the only thing that remembers anything.

Verified here, without hardware: both syntax checks pass; two reports against
an unreachable CluPilot queue up without aborting and leave progress.sent at 0;
a receiver brought up afterwards gets both in one batch, carrying 17:28:55 and
17:28:57 rather than the 17:29:22 they arrived at; a message containing quotes,
a backslash, a tab and a newline survives as valid JSON; and section_done
counts a `done` as done and a `failed` as not. Step 2 of this task is NOT
ticked — none of that is a rescue system, and the plan is right to insist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 19:30:19 +02:00
nexxo ecfced9257 Merge branch 'main' into claude/cool-sammet-368fee
tests / pest (push) Failing after 9m11s Details
tests / assets (push) Successful in 20s Details
tests / release (push) Has been skipped Details
2026-07-30 19:28:55 +02:00
nexxo 79654333a8 Name the recurring filter, and stop the spec contradicting itself
Three sentences left over from the wave before, all of the kind this branch
exists to end.

- "Every active Price" was one word short. activePricesFor() sends
  `type: recurring` as well as `active: true`, so a one-time Price is never
  listed — which made the hand-built-payment-link sentence false for a one-time
  link: neither listed nor archived. Both places say `recurring` now, and the
  docblock says why the filter is not a gap: createPrice() always sends
  `recurring[interval]` and is the only call that mints a Price at one of our
  Products, so the filter can only ever hide somebody else's one-time Price.
  A filter left out of a sentence is a gap the next reader goes looking for.
- The sweep test file still opened "The orphans nobody can adopt" — the exact
  narrowing the command's own docblock had to be corrected away from. It happens
  to describe the fixtures, which are all metadata-less, so it now says that
  instead of claiming it of the command.
- The design doc called --archive "harmlos" seven lines above the correction
  establishing that it is not, for an adoptable orphan. A document that
  contradicts itself seven lines apart teaches nobody anything.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 19:24:18 +02:00
nexxo 61c2501136 Close three gaps before they become three bugs in the script
Read on review, before a line of shell was written. All three would have been
found on hardware instead — two of them late, and one of them only by noticing
something that was silently absent.

The progress file did not survive the reboot. Task 1 writes it in the rescue
system, whose root lives in RAM; Task 3 installs Debian and reboots. The plan
said the resuming service uses "the same progress file" without saying how it
gets there. Because flush_reports first runs in Task 6, AFTER the reboot,
rescue_checked and debian_installed would simply never have reached the console
— and nothing would have pointed at the reboot as the reason. Task 3 now carries
the file, the script, and the arguments across, the last one root-readable only
because the WireGuard key is in it.

RebootIntoPveKernel.php was on the platform plan's delete list but not on this
plan's read list. Six files were named; seven are deleted. This is the one that
proves the boot path BEFORE issuing the one irreversible command in the whole
run: a -pve kernel image present, its initramfs present (a full /boot leaves
exactly the half-written one), and update-grub checked on its exit status, which
its predecessor discarded. Losing that would have cost a machine that does not
come back, and on a dedicated server that means the provider's console.

Reading it also corrected a rule this plan states. Task 9 rightly refuses
`pveum ... || true`, but that is not a rule against `|| true` — RebootIntoPveKernel
tolerates one deliberately, with the reason written next to it, and a script
that applies the ban literally breaks the kernel removal in Task 3. The rule is
now stated as what it is: no `|| true` over a command whose failure means
something.

The key swap in Task 9 had no proven handshake. Task 6 insists a tunnel counts
only with one, then Task 9 re-keys that same tunnel and discards the old key on
ordering alone. Ordering says when to discard, not whether the new key carries.
Four numbered steps now, with the proof third and the discard fourth, and a
fallback to the old key if the handshake does not come — the one failure nobody
fixes remotely, at the end of a run that did everything else right.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 19:24:15 +02:00
nexxo fe07fa2e77 Break the host takeover into two plans that can run side by side
Platform: ten tasks, all provable by the suite. Enrolment code and key pair,
the tunnel-only restriction (its test is the reason the design looks the way it
does), register, progress, routes, retiring SshTraefikWriter, the console, the
customer's own subdomain, shrinking the pipeline to six steps that only look,
and pulling the readiness checks along.

Script: ten tasks, none provable by the suite — a script that installs an
operating system cannot be run in SQLite. Each one ends on real hardware
instead, and two of them insist on it: the bridge section must have its
self-revert triggered on purpose, and the template must actually be cloned and
booted before it counts.

The one real coupling is written into both: platform task 9 DELETES the seven
step files the script plan reads its knowledge from. Do not run it before that
knowledge has been lifted — git log keeps them, but nobody reads history they
do not know to look for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 19:13:04 +02:00
nexxo a43be4186a Say what the sweep did not check, before telling anyone to archive
stripe:sweep-orphan-prices described itself as listing "what AdoptStripePrice
will not take over". It computes nothing of the sort: it lists every active
Price at one of our Products that no register row knows, adoptable or not — the
only question it asks is whether a row holds the id. And then it invited the
destructive path, with no word anywhere about running stripe:sync-catalogue
first.

Following that advice can make a plan unsellable. Archive an orphan the next
sync would have adopted and the sync can no longer see it: activePricesFor()
asks Stripe for active prices only, so adoption returns null and createPrice()
fires under the same key the crashed run used. For twenty-four hours Stripe
answers a repeated key by replaying the stored response — the archived Price's
id, in the body it had while it was live. The register writes it down as live,
and the checkout is refused. This is the same class of defect the branch exists
against: a comment that was eloquent and wrong.

- The docblock now says what the command lists, names the twenty-four-hour
  replay so nobody has to rediscover it, and the ordering requirement is in
  $description and printed on every path that found something, --archive
  included. The reassurance that archiving is harmless is now scoped to what
  THIS codebase sells; a payment link an operator built by hand against one of
  our Products is withdrawn all the same, and says so.
- stripe:sync-catalogue --dry-run says "created or adopted" again. A dry run
  counts intents without calling ensure(), so it cannot know which Stripe
  already holds — and a dry run is the first thing an operator runs after an
  interrupted one. The live line keeps both figures, where they are knowable.
- The sweep's plan side gets a test: an orphan at a family Product is reported
  and a known plan Price never is. Deleting either half of the lookup turns it
  red; the five tests it joins all stayed green for the products() half.
- The throttle test pinned "once" and neither "per price" nor "per day". A key
  mutated to a constant would have silenced every orphan after the first and
  kept it green — the silent no-op the branch is built against. It now plants a
  second orphan, and travels a day to hear the first one again.
- Two of my own documents corrected. The spec sentence claiming an adoptable
  orphan never appears in this list is what the implementation faithfully built;
  it holds only if a sync has run since the orphan appeared, which nothing
  enforced. And the two adoption classes do not carry the same run log:
  $adoptions on the Price side, $duplicates on the Product side.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 19:12:57 +02:00
nexxo 32016d9b80 Write down the network boundary, the subdomain, and two decided follow-ups
The route pull stays inside the tunnel. The operator's criterion was not
availability but "publicly reachable, yes or no" — and an endpoint that survives
a tunnel outage must by definition be reachable outside it. So nothing new opens
from outside, on the host or here.

The subdomain becomes the customer's choice at checkout, with suggestions from
their own name and a sentence saying the address is permanently public: every
Let's Encrypt certificate lands in the Certificate Transparency logs, which are
public, searchable and never deleted.

Also recorded, both decided and both getting their own spec: resetting a
Nextcloud to factory state with a 14-day safety backup, and an outage mail to
the operator after two consecutive failed checks — measured today that neither
an incident nor a mail exists, so a customer VM can fail unnoticed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 19:05:23 +02:00
nexxo 571acd0013 Check Stripe is configured on every path the sweep actually uses
The dry-run guard was copied from stripe:sync-catalogue, where it is right:
that command's --dry-run touches nothing, because everything it compares
already lives in our own tables. This command has no such local-only mode —
the report IS a live activePricesFor() call, dry-run or not — so `! $dryRun &&`
made the guard fire only on the one path that would have worked anyway. A
--dry-run against an unconfigured account fell straight into
HttpStripeClient's own exception instead of the clean error and self::FAILURE.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 18:48:49 +02:00
nexxo 277a6bab83 Give the operator something to do about an orphan nobody can adopt
The recognition step refuses a price that proves nothing about itself, and that
is right — adopting a stranger's price is worse than minting a second one. It
left an operator with a log line and no way to act on it.

Reports by default and touches nothing. With --archive the orphans stop being
sold, which is harmless for the reason it always is here: Stripe goes on billing
every subscription already on an archived price, and nothing is sold on a price
no row knows.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 18:36:06 +02:00
nexxo c0cd367a0e Drop the public endpoint: the command line already carries what the host needs
The operator was right to challenge POST /enrol. The three values a script needs
before the tunnel exists — hub key, hub address, assigned tunnel IP — are all
known to the console when the host is created, so they travel in the copied
command instead of being fetched.

The one thing that forced an endpoint was the hub needing the host's public key
before the tunnel is up. So the console generates the pair and hands it over,
and the host swaps in a freshly generated key through the tunnel afterwards.
The key that passed through a clipboard lives for minutes.

Net effect: the network boundary is unchanged. No new way in from outside.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 18:25:02 +02:00
nexxo 048e5ba81f Subtract only what was counted, not everything a singleton adopted
A figure may only subtract what it counted as an intent in the first place. The
family loop counts a Product before knowing whether it will be adopted or
minted, so an adoption there could honestly be subtracted back out — but
syncModules() has never counted a module's own Product as an intent at all; it
only counts a module's Prices. Reading AdoptStripeProduct::adoptions as one
run-wide delta could not tell the two apart, so a module Product adopted from
an interrupted run silently inflated "adopted" and shrank "created" by exactly
one, for a Product the command never claimed to have made in the first place.

The fix counts the family side locally, at the one call site that already
counts the intent, and leaves a module's Product out of both figures entirely —
adopted or minted, it was never counted, so neither number may move for it.
AdoptStripeProduct::$adoptions is gone with it: nothing reads a singleton-wide
total that cannot be attributed to one side or the other, and keeping it around
unread would be exactly the kind of state this codebase does not leave lying
about.

The duplicate-product report had the same shape of bug one line down: it read
straight off the singleton's list with no before/after snapshot, so a second
handle() call in one process would reprint a duplicate an earlier run already
named. Sliced to what this run itself added, the same way the counters beside
it already were.

Also: the unread `$charged` line in the adoption test that TDD had already
exercised as dead weight, and its now-unused PlanPrices import.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 18:20:27 +02:00
nexxo 0f1f397823 Design a host that installs itself and a platform that only takes it over
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 18:16:14 +02:00
nexxo 69a9322a4e Release v1.3.63
tests / pest (push) Failing after 8m36s Details
tests / assets (push) Successful in 19s Details
tests / release (push) Has been skipped Details
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 17:59:16 +02:00
nexxo 5883989552 Give settings the shape a settings page actually has
Four attempts, and this is what was wrong with the last one: full-width
panels stacked down the page, each carrying its own title INSIDE it. Every
group had two frames — the title bar and the border — so the page read as
a column of long boxes with nothing saying where one topic ended and the
next began, and a phone number got a field a thousand pixels wide.

The shape now:

- **A section list on the left**, sticky, so the sections stay reachable
  however far a form runs. Below `lg` it becomes a scrolling strip of
  chips, because a vertical list on a telephone is four rows of nothing.
  Under it, who is signed in — on a shared office machine that is a real
  question, and a settings page is where it gets asked.
- **A content column of 820px**, not the whole 1240 shell.
- **The heading OUTSIDE the card it belongs to.** A section is a heading, a
  sentence, and then a card of rows. That single move is what stops them
  reading as boxes: the border now frames the fields, not the topic.
- **What cannot be undone is last, in a card whose border says so** — the
  deletion deadlines and the close button together, at the foot of the
  contract section, in danger colours.

The rows are unchanged (label left, control right) and so is every binding;
what changed is the frame around them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 17:59:16 +02:00
nexxo df7da334ee Say what was adopted, not that it was created
The count is taken before ensure() runs, so the sweep reported objects created in
Stripe when it had made none — and after the next interrupted run, that line is
the first thing a human reads. Telling the two apart is the whole point of the
recognition step.

Comparing the price id before and after the call does not distinguish them
either: there is no id before, in either case. AdoptStripePrice counts its own
adoptions instead, which is why it and its product sibling are now singletons —
resolved per call, a counter on the instance would never pass one.

Duplicate products are named in the report as well as the log. We deliberately do
not deactivate them, so only a person can resolve one, and a person reads this.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 17:56:13 +02:00
nexxo 880b5f1998 Merge main into the operating-mode branch
Three real conflicts, and one file that did not conflict and mattered more.

Overview::notices(): both sides added notices. Kept all of them, and pointed
main's mail check at MailboxTransport::NON_DELIVERING — its own comment already
named the constant while the code carried a copy of the list.

billing.blade.php: main wrapped the page in a contract branch. The "payment is
not set up" error moved OUTSIDE it, because the customer most likely to meet
that message is the one buying for the first time, who has no contract yet and
would never have seen it from inside the @else.

ConsoleReportsRealDataTest: kept both sides rather than choosing. The renamed
test and its docblock explain why admin.systems_ok can no longer be asserted on
a bare install; main's mail pin still keeps "clean" clean in the mail
dimension. Picking one would have quietly weakened the other's claim.

HostStepsTest: git combined main's config()->set('provisioning.dns.zone',
'clupilot.com') with this branch's assertion on clupilot.cloud, and produced a
test that contradicted itself with no marker. Main's approach is the better one
— it pins the zone in the test and asserts the SHAPE of the name rather than
this box's domain — so its assertion stands.

HttpStripeClient merged silently and correctly: secret() still throws,
isConfigured() still reads the vault directly. Had it taken main's
filled($this->secret()), six callers that ask in order NOT to get an exception
would have become exception throwers, and the suite would have stayed green.
CheckoutWithoutStripeKeyTest is the lock that would have caught it.

StripeIdempotencyKeyTest (new on main) leaned on the environment fallback for
stripe.secret. That entry is strict now — no fallback in either direction, so
the .env cannot be a back door for a live key in test mode. It stores a real
vault row instead.

1966 tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 17:53:20 +02:00
nexxo d6ec09a9b4 Recognise the product Stripe already has
The gap beside the guard. A run that creates a product and dies before storing
its id leaves an orphan exactly as a price does, and past the key's twenty-four
hours the next run makes a second one — after which activePricesFor() is asked
about the wrong product, price recognition goes blind for the whole family, and
every orphaned price on the first product is unreachable.

Two things are unlike the price side, deliberately. No money gate: a product
carries no amount, so its metadata is the whole of the proof. And a duplicate is
reported, never deactivated — archiving a price is provably harmless because
Stripe keeps billing subscriptions already on it, but deactivating a product
makes its prices unsellable and contracts can be running on those.

Silent about strangers, too. This asks for every active product on the account,
so an operator selling something else through it is an ordinary state, not a
finding worth a line on every run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 17:39:15 +02:00
nexxo 4b0eebeccc Release v1.3.62
tests / pest (push) Failing after 8m35s Details
tests / assets (push) Successful in 23s Details
tests / release (push) Has been skipped Details
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 17:38:57 +02:00
nexxo 2a97d76b2d Rebuild the settings page out of panels and rows
**The withdrawal was invisible in two different ways.** It rendered only
while the fourteen days were still running, so the moment they expired the
whole block vanished — indistinguishable from a feature nobody built. And
the account it was looked for on has no contract at all: no package, no
subscription, nothing to withdraw from, so there was never anything for it
to be about.

It is now a row for every CONSUMER who has a contract, open or not: while
it runs, the deadline and the button; afterwards, the sentence saying why
it is over. A business never sees it — WithdrawalRight answers that, and
Settings::withdraw() refuses them again on the server. And an account with
no package says so and offers the packages, instead of a dead sentence in
a box.

**The page is built out of two pieces now.** x-ui.panel is a group: a card
with a header that names it. x-ui.row is a setting inside it: what it is
on the left, the control on the right, dividers between rows. That is the
shape every settings page worth copying uses, and it is the shape that
uses the width — a 280px label column with the control beside it fills the
line, where a stack of full-width inputs leaves two thirds of it empty and
a grid of cards of different heights walks down the screen as a staircase.

All four tabs are rebuilt on it, so the page reads as one designed thing
rather than four pages that happen to share a tab bar. Below `sm` the rows
stack, because on a telephone a label belongs above its field.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 17:38:57 +02:00
nexxo 2d915be003 Put back the docblock space pint took on the way past
One character in a @return line of webhooks(), untouched by anything this
branch does. It was only reformatted because the file happened to be on
the pint path of the prefix-rule commit. A style fix to an unrelated line
is noise in a diff a reviewer reads line by line.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 17:31:04 +02:00
nexxo 786318b6d4 Never tell anyone to delete a catalogue their contracts bill on
The refusal added in the previous commit had a state it read wrongly, and
the wrong reading was destructive.

record() sat at the END of handle() and in no try/finally, while
createProduct() and ensure() throw uncaught. A run that died after the
first object left hasStoredObjects() true and recorded() null. The same
state arises with no failure at all: CheckoutController → PlanPrices::
ensure() and BookAddon → SyncStripeAddonItems → AddonPrices::ensure()
mint missing prices and never call record().

In that state the next run — in the SAME mode — refused with the foreign
account message and its "Clear plan_families.stripe_product_id,
plan_prices.stripe_price_id and the … registers first", and
billing.catalogue_synced blocked with the same text. The objects were
from the account in force. The right move was to resume, which is what
the idempotency keys exist for; instead an operator was handed a delete
instruction for a catalogue live contracts are billed on.

Two changes:

  1. record() moves ahead of the creation loop, right behind the refusal.
     There it is already proved that either nothing is stored or what is
     stored belongs to the active account, so the moment carries the
     claim just as well — and a run that dies part-way can no longer
     leave a state that contradicts itself.
  2. "Origin never recorded" gets its own sentence and its own cure,
     separate from "established: other account".
     StripeCatalogueMode::matchesActiveMode() becomes
     belongsToAnotherMode(), which is only true where the other account
     is fact. The check still blocks — the origin cannot be proved — but
     it says "run the sync again", and it names no register to empty.

Red first:

  ⨯ it takes up a catalogue whose origin was never written down
      Failed asserting that 1 matches expected 0.
  ⨯ it leaves no half-built catalogue that contradicts itself when a run dies
      Failed asserting that null is identical to an object of class "App\Support\OperatingMode".
  ⨯ it tells an unrecorded origin apart from a foreign account

The two states are held apart by assertion, not by wording: only the
foreign-account sentence may name stripe_plan_prices, and the unrecorded
one must name stripe:sync-catalogue instead. The existing foreign-account
test keeps its teeth.

Full suite: 1817 passed, 6366 assertions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 17:30:56 +02:00
nexxo f25a0030f0 Release v1.3.61
tests / pest (push) Failing after 8m45s Details
tests / assets (push) Successful in 20s Details
tests / release (push) Has been skipped Details
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 17:30:55 +02:00
nexxo 18193a731d Stop asking for a signature the law does not want, and lay out the tab
**The owner is right about the agreement.** Art. 28 wants a CONTRACT, not
a ceremony — and a contract is concluded by incorporating the agreement
into the terms the customer accepts at checkout, which is exactly how
every hoster they have bought from does it. Nothing in the regulation asks
for a second, separate click. What it does ask is that the agreement is in
writing (electronic form included, Art. 28(9)), that it is the version in
force, and that the customer can obtain it.

So: the terms now say the agreement is part of the contract and needs no
separate signing, and name where it is. The card states that rather than
flagging the customer as outstanding — the warning chip is gone. The
button stays, reworded to "Zusätzlich bestätigen": a practice or a firm
that gets audited often wants an explicit record, and it costs a click.
The console's counter says how many confirmed rather than how many are
"open", because none of them are.

**The contract tab was a staircase** — a short card, a wide one spanning
both columns, then a short one again. It is one column of full-width
sections now, each with the same shape: a header row carrying the state
and its action, then the body. Nothing tracks sideways any more.

The package section states its own state in the header instead of a
paragraph in the body, the withdrawal sits under it as a row rather than a
second card, and the agreement's four files each have a button — reading
and keeping, agreement and measures, which the single "Herunterladen"
could not express.

Also: the generated version no longer carries "Aus dem Repository erzeugt
(clupilot:publish-dpa)" in the console's list. That is the command line,
not information.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 17:30:55 +02:00
nexxo fcea7ff3a8 Let the catalogue ask Stripe what products it already has
The price half of this question was answered; the product half was left open and
named as a known gap. It is the more consequential one: a second product makes
every activePricesFor() ask about the wrong one, so price recognition goes blind
for that whole family and every orphan on the first product is unreachable.

The whole active list comes back with no metadata filter, because Stripe cannot
search metadata and an account holds a handful of products. Deciding which are
ours belongs to the next commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 17:25:38 +02:00
nexxo 976637afb2 Release v1.3.60
tests / pest (push) Failing after 8m51s Details
tests / assets (push) Successful in 20s Details
tests / release (push) Has been skipped Details
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 17:19:39 +02:00
nexxo 52aacddc56 Store the agreement where the web process can actually read it
Every link on the agreement page answered 404 — the download visibly, the
two inline ones just as dead — and nothing anywhere said why. The files
were there, whole and correct.

An artisan run inside a container is root; PHP-FPM is www-data. Laravel's
local disk creates a PRIVATE directory, which is 0700, so `dpa/` came out
as root-only. The web process could not enter it, `Storage::exists()`
answered false, and the routes did exactly what they were told: abort 404.
No exception, no log line, a page full of links to nothing.

Both writers — the command and the console's upload form — now store with
"public" visibility, which is the file MODE and nothing to do with the web:
0755/0644 instead of 0700/0600. The disk's root is outside the document
root either way, and the two routes still check who is asking. The command
also warns if a file it just wrote is not readable, because the alternative
is finding out from a customer.

The download itself now looks like one: the `download` attribute on both
anchors, and in the console a bordered control rather than a third link in
a row of two.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 17:19:39 +02:00
nexxo 4b99c6d5c6 Make the fake behave like Stripe where it matters
Three ways it did not. It replaced metadata where Stripe merges — and merging is
the property AdoptStripePrice's "write only when it differs" comparison is built
around. It handed metadata back uncast where the wire returns strings, so a test
could pass on an int that production's strict === rejects forever. And a tie on
`created` fell to insertion order, so two runs could adopt different prices.

Paging now throws instead of stopping when Stripe says there is another page and
the last item carries no id. invoiceLines() stops there and it costs a short
document; here it costs an unseen orphan and a second live price for one figure.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 17:14:46 +02:00
nexxo 26cf1a72a0 Remember which Stripe account the catalogue was built in
The mode switches the credentials. It does not switch what was made from
them: a Price id and a Product id belong to the account that issued them,
and test and live are two accounts. Every credential got two slots on this
branch; the ids derived from them sit in single-valued columns.

So the planned sequence of this installation ended in the exact false
green this page exists to rule out — sync in test, store the live key,
switch, and billing.catalogue_synced went on reporting satisfied because
it only asked whether the column was filled. "Bereit für Livebetrieb",
and the first real order got "No such price".

Detection, not repair. Stripe does not put the account in the id —
prod_… and price_… look the same in both, only KEYS carry _test_/_live_
— so the origin cannot be read back out of a stored id, and asking
Stripe is out: this page reaches nothing over the network on a page load.
What is left is to write it down at sync time, which is what
App\Support\StripeCatalogueMode does.

One setting for the whole catalogue is only honest because
stripe:sync-catalogue now REFUSES a run into a catalogue that belongs to
the other account. Without that, the run would skip every row that already
carries an id, answer "already in step", and record an account it never
touched — the same lie one level down. The registers count as stored
objects too: inStep() takes a registered row as proof on its own for the
reverse-charge half.

The `breaks` sentence says what happens (checkout fails, no order) and
what actually helps, including the awkward half: re-running the sync is
not enough, the pointers and both registers have to be cleared first.

The slot migration backfills the one case it can prove: whatever is at
Stripe was made with the one key this installation has ever stored, so it
belongs to the account that key opens. Otherwise a long-synced catalogue
would read as "origin unknown" and the page would demand a re-sync nobody
needs.

Red first:

  ⨯ it does not call the catalogue synced when its ids belong to the other account
  ⨯ it says the sale is refused and a fresh sync is needed, not that a column is empty
  ⨯ it records the mode its objects were created in
  ⨯ it refuses to work into a catalogue that belongs to the other account
  ⨯ it syncs into the new account once the stale ids are cleared

Full suite: 1814 passed, 6357 assertions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 17:12:56 +02:00
nexxo 3b0c6b19b9 Release v1.3.59
tests / pest (push) Failing after 8m48s Details
tests / assets (push) Successful in 20s Details
tests / release (push) Has been skipped Details
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 17:12:30 +02:00
nexxo d04ea712c7 Write the processing agreement and its annex, and let it be downloaded
The mechanism shipped without a document. Both are here now, generated
from this repository rather than uploaded — so the company is named from
CompanyProfile exactly as the invoices name it, the sub-processor list is
the three places customer data really leaves this application, and the
deletion deadlines are read off the commands that enforce them. A wording
change is a diff somebody can review, not a file that appears.

`clupilot:publish-dpa 1.0` renders both to PDF (TCPDF, the engine the
invoices already use), stores them on the private disk and puts them in
force; `--draft` stops short of that. The console's upload form is
untouched: a lawyer's revision arrives as a PDF and becomes a version like
any other.

**The annex says what this installation does, not what it would like to.**
Every measure in it was checked against the code first — the isolation is
one VM per customer because that is what provisioning builds, the backup
is a daily snapshot job at 02:00 because that is what RegisterBackup
creates, the deletion deadlines are the two prune commands. Claims the
system does not currently keep are NOT in the document, and they are named
in the handover instead. A TOM that promises more than the machine does is
the document an auditor reads before looking.

**Downloading** now carries the version in the filename —
CluPilot-AV-Vertrag-1.0.pdf — so "which fassung did I agree to" is
answerable from a downloads folder months later without opening anything.
Both sides get the button; inline stays the default, because a contract is
read before it is filed.

Placeholder register data is left off the document rather than printed: a
"FN 000000a" on a contract looks like a real number and is not one.

Also: the wordmark scan tripped on a paragraph whose first word is the
company name, for the second time. It now looks for the name as the whole
CONTENT of an element, which is what a lockup is, rather than for the name
after a tag, which is also how a sentence starts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 17:12:30 +02:00
nexxo 823eeaf413 Refuse a key that does not belong to the slot it sits in
put('stripe.secret', 'sk_live_REALMONEY') in test mode was accepted,
get() handed it out, billing.stripe_secret reported satisfied, and the
badge above it said "Testbetrieb". The strict rule closed the automatic
route into that state (no fallback); the typed one stayed open.

The prefix decides it without touching the network, and that rule now
lives in ONE place — OperatingMode::ofStripeKey() — called by the three
that were answering it separately: the slot migration (unchanged verdict,
`?? Live` for a value it cannot place), StripeCheck's `live` flag
(unchanged verdict, null stays false), and the readiness check, which
never asked at all. A key it cannot place is not reported as a
contradiction: this check only says what it can prove.

The two directions get their own `breaks` sentence, because the
consequences are opposite — real money moving while the console says
test, versus no money moving while the order looks paid.

The "Prüfen" button no longer contradicts the check either: the page
rendered only ok/reason, so a live key in the test slot answered
"Geprüft: in Ordnung". It now names the account the key belongs to.

Red first:

  ⨯ it refuses a live key sitting in the test slot
  ⨯ it refuses a test key sitting in the live slot
  ⨯ it says what the wrong key does, not that a field is empty

Guard tests (ConfirmInModal, ModalHeight, IconLayout, DisplayTimezone)
run with the blade change: green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 17:05:58 +02:00
nexxo 40532c6e02 Put the money gate in the class that promises it
AdoptStripePrice says in its own docblock that no adoption can move money, and
could compare three properties. The four that also decide what a Price charges
were filtered in HttpStripeClient — where FakeStripeClient cannot express them,
so no test of the class could reach the promise it makes, and a third client
implementation would drop the check in silence.

The client reports them now and the caller decides. Absent keys stay Stripe's
defaults, which is the direction that matters: read the other way round the gate
refuses every legitimate price and recognition becomes a no-op nothing alerts on.

And the unexplained-orphan warning is a state, not an event. It fired on every
sweep and every customer booking for as long as the orphan existed; once a day
per price is enough to act on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 17:04:25 +02:00
nexxo 5094f70c19 Say which slot the value in force is really coming from
Mode test, dns.token only in the :live slot, HETZNER_DNS_TOKEN also in the
.env — the state this installation is in the moment the migration runs:

  get()      -> "live-token-value"   (the stored live row is in force)
  source()   -> "environment"        -> card reads "Aus der Serverdatei"
  outline()  -> null                 -> no "Hinterlegt" block at all

So the console sent an operator to the .env to rotate a token that the
database supplies, where the edit would have had no effect. R19's dummy,
literally: a display that reads like an answer, is wrong, and stops the
next person looking.

source(), outline() and updatedAt() now ask the same one place get() does
(inForce()/fallback()), and the answer gets a THIRD value, 'stored_live'.
Not 'stored': that shows the Vergessen button, which would then have
pointed at the empty test slot. Strict entries (Stripe) still have no
fallback in either direction — pinned by its own test.

Red first (before the vault changed):

  ⨯ it names the live slot as the source when it is the one in force
    -'stored_live'
    +'environment'
  ⨯ it shows the outline and the date of the value that is actually in force

Two existing expectations moved from 'none' to 'stored_live' with the
reason written beside them: 'none' meant "nichts hinterlegt" about a
credential that was in use.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 17:01:33 +02:00
nexxo 507c976035 Pin the one question that must never throw
isConfigured() had no coverage at all: replacing its body with main's
`filled($this->secret())` left all 1795 tests green, while at runtime the
same mutation throws StripeNotConfigured. Six callers ask that question
precisely to avoid an exception — CheckoutController:87 would answer a
customer with a 500 instead of a sentence.

The merge makes it urgent rather than merely untidy: git auto-merges
HttpStripeClient.php without a conflict marker, so nothing forces anyone
to look at the one line where the two branches disagree.

Proved red by that exact mutation before it went in, then reverted:

  ⨯ it answers the configured question instead of throwing it
    StripeNotConfigured: No Stripe secret is stored for the [test] operating mode.
    at app/Services/Stripe/HttpStripeClient.php:376

Http::fake() + assertNothingSent(): the question is asked on every
checkout and may only read the vault.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 16:58:22 +02:00
nexxo 56daaef7de Release v1.3.58
tests / pest (push) Failing after 8m40s Details
tests / assets (push) Successful in 21s Details
tests / release (push) Has been skipped Details
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 16:56:58 +02:00
nexxo 955f1b874f Deliver the processing agreement, and hold the proof it was accepted
Art. 28(3) GDPR wants a contract wherever personal data is processed on
somebody else's behalf, which is the whole of what this product does. "In
writing" there includes electronic form (Art. 28(9)), so a document the
customer can read plus a recorded acceptance is enough — no signature on
paper. The website already promises "AV-Vertrag inklusive", which means it
has to be obtainable without asking us for it. It was not obtainable at
all.

**The text is never this application's.** An operator uploads the document
their lawyer wrote, names the version, and publishes it; the measures ride
along as a second file, because they are an annex to the agreement and
"which measures applied when this customer accepted" has to have one
answer. Inventing the text here would have been worse than having none.

**Uploading and publishing are two acts.** Acceptance is per version, so
publishing leaves every customer who accepted the previous one outstanding
again — correct, and far too expensive to trigger by dropping a file on a
form. It goes through a confirmation modal that says exactly that (R23).

**The customer's side** is a card in the contract tab: read the agreement,
read the measures, one press to conclude it. What that press records is
what makes it evidence rather than a flag — the version, the moment, the
address it came from, and the login that pressed. Pressing twice is one
agreement (unique index, not a check somebody can forget), and a
superseded acceptance is kept rather than overwritten: it was true when it
was made, and the history is the point.

Nothing renders until a version is in force. A card offering an agreement
that does not exist is worse than the silence.

The files live on the private disk and are served through routes that
check who is asking — an agreement is not a public asset, and a guessable
URL to one would be a list of who our customers are. The customer route
takes no version parameter: which document applies is ours to say.

`dpa.manage` is its own capability on the OPERATOR guard. Whoever keeps
the platform running does not thereby decide what every customer is asked
to agree to — and a capability written under `web` since the 2026-07-29
move lands in a guard nothing authenticates against, which is how this one
first shipped answering 403 to a role that visibly had it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 16:56:57 +02:00
nexxo 7d66a01a40 Break the follow-ups into six testable steps
Six tasks, each ending on a green billing folder and its own commit: move the
money gate into the class that promises it, four fixes that make the fake behave
like Stripe, ask Stripe for its products, recognise an orphaned one, count
adopted apart from created, and hand the operator a sweep for the orphans nobody
can adopt.

Self-review dropped one item the spec had carried over from the last review: the
zero guard in PlanPrices::ensure(). Its only purpose was to keep a tiered price
unreachable, and the gate move rejects those directly — so it would be a
behaviour change with no remaining benefit, and it would make a free plan
unsellable. Recorded as deliberately not built, with the reason, rather than
quietly dropped.

Also corrected: a test that counted every product where the run mints one per
family, and a step that named the test it replaces only by description.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 16:56:08 +02:00
nexxo 3ceec8fe5e Design the nine things the reviews found and nobody built
The one that matters is orphan PRODUCTS. A price orphan is recognised now; a
product orphan is not, and it is worse — a second product makes every
activePricesFor() ask about the wrong one, so price recognition goes blind for
that whole family and the guard disables itself through the gap beside it.

Duplicated products are reported, not deactivated. That is the owner's call and
it is the careful one: an archived PRICE is provably harmless, which is what the
grandfathering rule has always rested on, but deactivating a PRODUCT makes its
prices unsellable and contracts can be running on those. What was proven for one
is not proven for the other.

The money gate moves into the class whose docblock promises it. Today
AdoptStripePrice can compare three properties and the other four are filtered in
one client, which the fake cannot express — so no test can reach the promise the
class makes.

Also: a sweep command for orphans nobody can adopt, a day's throttle on the
warning that currently fires on every customer booking, and six mechanical
fixes.

Self-review caught one of my own: the plan to tell "created" from "adopted" by
comparing the price id before and after the call cannot work — there is no id
before, in either case. AdoptStripePrice counts its own adoptions instead, which
needs it bound as a singleton, and the spec now says why.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 16:45:39 +02:00
nexxo 48581c3978 Release v1.3.57
tests / pest (push) Failing after 8m31s Details
tests / assets (push) Successful in 20s Details
tests / release (push) Has been skipped Details
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 16:41:32 +02:00
nexxo 1056dddc62 Take the address by field, fix the customer type, ask why they leave
**The billing address was one textarea**, so what landed in it was
whatever somebody typed: a postcode on the street line, a city with no
postcode, no country at all. It is four fields now — street, postcode,
town, country — in the customer's settings and in the console's customer
editor, which had its own free-text copy of the same field and would have
had an operator's correction silently reverted the next time the customer
saved.

`customers.billing_address` is neither dropped nor parsed: guessing which
line of an existing block is the street would put a postcode where a
street belongs on a document nobody can correct afterwards. It becomes the
COMPOSED form, rewritten from the fields on every save, so IssueInvoice
and everything else that reads it keep working, and a record nobody has
re-saved keeps the block it always had.

**The customer type is fixed once it is on record.** It decides the
fourteen-day right of withdrawal, and a business able to set itself to
"Privatperson" on this page is a business able to withdraw from a contract
it may not withdraw from. Registration asks the question; this page
reports the answer, and saveProfile refuses to answer it a second time —
in the component, not by hiding a radio, because a form that hides a
control has never stopped anybody who can post to /livewire/update. A
record created before the question existed may still be given one, once.

The panel is a fact rather than a form now, which is also what was odd
about it, and the sentence about who has the right of withdrawal is gone.

**Cancelling asks why.** A reason from a fixed list, required, with a note
beside it that "Sonstiges" cannot go without — the one fact worth having
about a departure, asked at the only moment the customer is looking at the
question. It lands on the contract (`cancel_reason`, `cancel_reason_note`).

And where the fourteen days are still running, the dialogue offers the
WITHDRAWAL as a way out of itself rather than as one more reason inside
it: withdrawing ends the service the same day and sends the whole amount
back, cancelling keeps the term that was paid for, and somebody entitled
to the first should not lose it by taking the second unasked. A business
is never offered it — WithdrawalRight answers that, not the template.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 16:41:32 +02:00
nexxo 865fd16f58 Put the price recognition back, all 3526 lines of it
2321854 removed it. That commit's own work — discarding an unconfirmed
registration and sweeping abandoned ones — is untouched and correct; what went
with it was every file of the Stripe price adoption merged an hour earlier:
AdoptStripePrice, IdempotencyKey, the unique-index migration, both test files,
the spec, the plan, and the edits in six more.

Nothing was lost. The commits stayed in history and the files stayed on disk,
untracked, which is what a staged deletion from a stale tree leaves behind.
Restored from 4eb90c8, the reviewed head, by explicit path — the fourteen paths
2321854 damaged and not one more, so the ten files that commit legitimately
added or changed keep exactly what it gave them.

This is the failure the repo's own rule exists to prevent: stage by path, never
`git add -A`, because a second session's index does not know what a first one
merged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 16:35:55 +02:00
nexxo 49d528dbea Fix round: point at the real tab, hide the button nobody can press, prove the quiet page load
Fixes four Important findings from the review of Task 12.

Corrected nine check `tab` values at the source (BillingChecks was already
correct; OnboardingChecks/ProvisioningChecks/DeliveryChecks all carried the
pre-redesign 'integrations' value, which is not a member of
Integrations::TABS): four onboarding checks now point at 'platform' or 'env'
depending on where their field actually is, five provisioning/delivery
checks point at 'services'. checkUrl()'s match-block resolver — praised as
correct for the six checks that point at a genuinely different admin page —
is unchanged; its `default` arm is now a pure safety net, not a route any
check actually relies on.

Wrapped the "Prüfen" button in @can('secrets.manage'): mount() admits
hosts.manage OR secrets.manage, but runCheck() requires secrets.manage alone,
so an Admin-role operator could see a button that 403s on press.

Fixed a docblock on HEARTBEAT_KEYS that asserted a staleness threshold was
kept in sync with OperationChecks::STALE_AFTER_MINUTES — that constant is a
key/settings-name map with no threshold of its own, and no such
synchronisation exists.

Three new tests, each confirmed red against a deliberately reintroduced
version of the bug it covers before being confirmed green: every check's tab
value against where its field actually lives, the run-check button hidden
from an operator who cannot press it, and Http::assertNothingSent() after a
plain page load.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 16:26:10 +02:00
nexxo 74d925bc66 Release v1.3.56
tests / pest (push) Failing after 8m40s Details
tests / assets (push) Successful in 20s Details
tests / release (push) Has been skipped Details
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 16:25:10 +02:00
nexxo 1d4e98eb06 Rebuild the customer's settings page as four tabs
It was one column 768 pixels wide inside a 1240-pixel shell — a third of
the window empty beside it — holding everything a customer might ever
change: the company address, their password, two-factor, the devices they
are signed in on, the branding of their cloud, the contract, and the
button that closes the account. Two thousand pixels of scroll, and the way
to a particular thing was knowing how far down it lived.

Four tabs, by what somebody came to do: their details, security, the
appearance of their cloud, the contract. The same tab bar the console
uses, so one vocabulary holds on both sides of the login, and the choice
lives in the query string — `#[Url(history: true)]`, no `except`, so the
parameter is there from the first render and a link to this page can name
the part it means. An unknown tab out of the address falls back to the
first rather than rendering a settings page with no section on it.

The width is used now. Inside a tab the short cards sit side by side
instead of each taking a full row to hold three lines: the customer type
beside the company form (it is one question, and beside the VAT field it
was mistaken for it), the password beside two-factor, the logo beside the
colours, the contract beside the closing of the account.

Also: the subtitle listed the old sections by name.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 16:25:10 +02:00
nexxo 7da2939c55 Release v1.3.55
tests / pest (push) Failing after 8m19s Details
tests / assets (push) Successful in 25s Details
tests / release (push) Has been skipped Details
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 16:05:56 +02:00
nexxo 8479fc30f8 Fix the checkout link, and stop the switch moving under the cursor
**Every package button led to "Dieses Paket steht nicht (mehr) zum
Verkauf".** The link was built as route() plus "?term=" + term — but both
are QUERY parameters on this route, so route() had already opened the
query string and the result was `?plan=team?term=monthly`. The package
arrived as the whole string "team?term=monthly", matched nothing in the
catalogue, and the page said so politely while giving no hint why. The
address now comes from route() in one piece with a placeholder swapped
into it, and a test asserts every checkout link on the page contains
exactly one question mark.

**The saving moved the buttons it belonged to.** It hung under the switch
and was hidden on the yearly view, so choosing a term shifted the very
control that chooses it. It sits above now and never disappears: one line,
two wordings — an offer while it is an offer, a statement once it is
taken. Same on the public sheet, where its vanishing re-centred the row.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 16:05:56 +02:00
nexxo d4b70b7477 Release v1.3.54
tests / pest (push) Failing after 8m32s Details
tests / assets (push) Successful in 18s Details
tests / release (push) Has been skipped Details
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 16:00:09 +02:00
nexxo ff92466b47 Give the order a checkout page, and stop hiding what a year costs
**There was no checkout page.** The package list posted straight to
Stripe, so the last thing a customer saw in this product's design was a
grid of cards and the next was somebody else's form asking for a card
number. What they were buying, what it costs in total, what happens
afterwards and what they were agreeing to were never stated in one place.

App\Livewire\Checkout is that place: the package and its figures on the
left, a sticky summary on the right — net, the saving where there is one,
the setup fee, the tax, what is due today, and what will be charged after
it. Every figure comes from the catalogue, TaxTreatment and SetupFee, the
same three the checkout itself reads; nothing is recomputed, because a
summary that works out its own total is a second answer waiting to
disagree with the one Stripe is handed.

**The terms acceptance moved to where the money is spent.** It sat at the
top of the package list as a condition of LOOKING at prices, with every
button dead beneath it. It is a condition of buying, so it is now at the
foot of the summary, directly above the button — after the total, which is
the order those three things are read in. The package list has no form on
it at all now; its buttons are links.

**The page used the left half of a 1240px shell.** A 68-character title, a
switch under it and an 80-character footnote, beside a grid that used the
whole width. Now: a header band with the name left and the term switch
right, where a control belongs, and a closing band of three columns
instead of a paragraph trailing off.

**The yearly view showed the monthly equivalent as its headline**, so on a
package with no free months the big figure did not move at all when the
switch was thrown — the page looked like it had ignored the click. The
headline is the price of the term in the unit it is charged in; what a
year works out to per month is said underneath. Same on the public sheet.

**The switch was unreadable.** `text-on-ink` is not in this palette — the
ink pairing is bg-ink/text-bg, as x-ui.button has always had it — so the
label kept its inherited grey on a near-black pill. And its shell was
rounded-lg with rounded-md options inside: two radius scales, one of them
Tailwind's untokenised 6px. One scale now, nested.

**The mails drew an empty orange tile.** The mark's ascent lives in an SVG
the site loads and a mail cannot — Gmail strips inline SVG, Outlook renders
with Word, and this layout carries no image by design. The shape is drawn
with a character every mail font has, white on the accent tile, with the
whole wordmark beside it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 16:00:08 +02:00
nexxo e83b17ec37 Put every missing field on one page before a customer pays
Admin\Readiness renders App\Support\Readiness::byGroup() end to end: state,
label, breaks sentence and a fix link for every check, a button on the four
that have to ask a real service (DnsTokenCheck, WireguardEndpointCheck,
VmTemplateCheck, StripeCheck) and only ever run on demand, and one headline
line — ready for the operator's current mode, or how many blocking items are
still open. Wired into the nav and into Overview's own notice list beside the
same gate as Admin\Integrations, since both halves it configures come back
together on this page.

Fixed two check keys the guard test caught for real: onboarding.ssh_key and
delivery.inbound_password did not textually contain the vault key they check
(ssh.private_key, inbound_mail.password), which is exactly the kind of silent
gap this whole task exists to catch. Renamed to onboarding.ssh_private_key
and delivery.inbound_mail_password, with the two existing test files that
referenced the old keys updated to match.

Also fixed the guard test's own assertion: Pest's toContain() is variadic, so
a second string argument is a second required substring, not a failure
message (the same trap tests/Feature/ModalHeightTest.php already documents
about itself) — it failed on every entry, including ones already covered.
Replaced with str_contains()+toBeTrue(), which keeps the check and the
message both.

Two pre-existing Overview tests assumed a bare install has nothing to report;
it now correctly always starts with a readiness gap, so both were narrowed to
what they actually test — no invented data, no invented incident.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 15:59:34 +02:00
nexxo de3ecacc13 Release v1.3.53
tests / pest (push) Failing after 8m6s Details
tests / assets (push) Successful in 21s Details
tests / release (push) Has been skipped Details
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 15:44:45 +02:00
nexxo b32c6fc33f Sell the year as well as the month, and say what it saves
Both prices have existed since the catalogue did — one `plan_prices` row
per term, each with its own Stripe Price, and the checkout has taken a
`term` all along. Nothing ever sent one. The shop showed the monthly
figure, the order page posted no term, and the yearly Price sat there
being paid for by nobody.

**The console asks for free months, not a second price.** It used to take
the yearly TOTAL as its own free-form figure, so nothing in the system
knew WHY 588 belonged to a package costing 49 a month — and no page could
say "zwei Monate gratis" without a person working it out again and writing
it somewhere it would then drift. Now: the monthly price and how many of
the twelve months are free. The total is derived from the two
(PlanVersions::yearlyCents), previewed live on the form, and written to
the catalogue from the same arithmetic.

`free_months` sits on the VERSION, beside the capabilities publication
freezes, because that is what it is — part of the terms a customer bought.
The migration reads it back out of the prices that already exist rather
than defaulting to nought: an installation selling twelve-for-ten must not
lose that on a deploy. Only an exact division counts; a hand-negotiated
yearly figure keeps its amount and simply goes unexplained.

**The customer chooses.** A switch on the order page and on the public
sheet, both figures rendered and one shown, so switching costs no request.
The headline stays "per month" in either term — a yearly total as one big
number reads as five times dearer at a glance — with the amount actually
taken, the net figure, the months free and what twelve monthly payments
would have cost underneath. The form posts the term the customer was
looking at, and the checkout picks that term's Stripe Price.

The billing card now says which term is running. It states a monthly
figure whatever the term, so a yearly customer was reading a number that
never appears on a statement.

Also: **a Blade comment must never spell a directive.** Blade compiles
what is inside `{{-- --}}` too — the comment is stripped from the output,
but its directives are compiled on the way there. A comment explaining why
the parenthesised php directive had been avoided produced exactly the open
PHP tag it warned about, and the rest of the file was swallowed as PHP
source: no exception, no warning, the page one block short. That is how
the term switch vanished after being written. BladeCommentsTest scans for
the forms that open a tag.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 15:44:35 +02:00
nexxo fe1110de71 Fix round: name the slot on the card, and cover the modal that already worked
Names which of the two slots a card actually reads and writes right now, at
the card an operator types into (not only the page-top badge) — save() and
render() resolve without a mode argument, so the card looked identical in
both modes while quietly acting on whichever slot was active. Also adds the
ConfirmSwitchMode coverage IntegrationsPageTest already carries for
ConfirmSaveSecret/ConfirmForgetSecret: a modal is reachable without the
page's route middleware, and reading the code is not the same as testing it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 15:32:16 +02:00
nexxo ba6ed43219 Release v1.3.52
tests / pest (push) Failing after 8m32s Details
tests / assets (push) Successful in 24s Details
tests / release (push) Has been skipped Details
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 15:22:45 +02:00
nexxo fed4acf31c Accept terms instead of a start date, and fix the sender no server accepts
**The test mail was rejected: "553 5.7.1 Sender address rejected: not
owned by user no-reply@…".** Every purpose mailbox here has its own SMTP
account, and a mail server lets an account send only from the address it
owns. Mail::to() hands back a pending mail bound to the DEFAULT mailer and
sendNow() then ignores the mailer the mailable asked for, so a mail
addressed support@ went out over the no-reply@ login. It sends through the
mailable's own mailer now, and the cloud-ready preview — the one mailable
with no mailbox at all — takes the provisioning mailbox like the real
notification does.

Writing the test for that found the same bug in production code:
**InvoiceMail and OrderConfirmationMail set their From to billing@ and
never named a mailer**, so both went out over mail.default's no-reply@
login. On this installation no customer had ever received an invoice mail
or an order confirmation; they rendered perfectly, queued without
complaint and were refused at the door. MailSenderOwnershipTest now scans
for the mismatch: a mail that takes a mailbox From must use that
mailbox's mailer.

**The box on the order page accepts the terms now**, not an immediate
start. It used to carry the whole FAGG §16 sentence, which read like a
choice between "now" and "in fourteen days" — and there is no second
option. The terms are what regulate the sale, so they had to exist:
resources/views/legal/terms.blade.php replaces the placeholder with
fourteen sections written from what this software actually does — the
delivery, the capacity queue, the full refund on withdrawal, the
cancellation at period end, the deletion deadlines. The company data
comes from CompanyProfile, so the page and the invoices cannot drift. No
availability figure and no liability cap has been invented.

No order goes through without it: the button is unusable until the box is
ticked and says why, and CheckoutController still refuses server-side —
the browser half refuses nothing. The request field is `terms_accepted`;
the Stripe metadata key stays `immediate_start`, because a session opened
before a deploy is paid after it and the webhook would find nothing under
a new name.

**"Wird der Account nach fünf Tagen gelöscht?"** Only an unconfirmed one.
That was the whole of what we said, so the answer looked like yes. The
second rule now exists and is stated: PruneDormantAccounts removes a
confirmed account after a year when it never had a package — no customer
record at all, which is where every order, contract and seven-year invoice
hangs. A fortnight's notice goes out first, once, and `dormant_warned_at`
is what permits the deletion: an account whose warning never went out is
never removed. Signing in resets the clock, measured off the device rows
because users has no last_login_at.

Both deadlines are said in the portal settings, on the verification page,
and in the terms — each reading the number off the command that enforces
it.

Also: the wordmark scan matched any element whose text merely BEGINS with
the company name, which a paragraph of terms does. It looks for the lockup
form now (no whitespace after the tag), which is what it was always about.
The seven checkout tests in the parallel session's files were posting the
old field name and now post the new one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 15:22:35 +02:00
nexxo 96f171b3b3 Put the switch where it governs, and say when it is thrown
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 15:09:38 +02:00
nexxo 4ac9fb1d03 Release v1.3.51
tests / pest (push) Failing after 8m28s Details
tests / assets (push) Successful in 28s Details
tests / release (push) Has been skipped Details
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 14:53:07 +02:00
nexxo c354ad1463 Make mails readable on a phone, and let an operator look at one
Three things the owner reported, and one sentence that promised something
this product does not do.

**The mails had a fixed width.** components/mail/layout.blade.php set
width="600" and width:600px, so every phone showed a 600px canvas in a
390px window and one had to scroll sideways to read a sentence. Now the
table is width="100%" with max-width:600px — fluid, with 600 as a ceiling
rather than a floor. A mail cannot fix this with a media query (Outlook
renders with Word and drops <style> blocks), which is why the whole file
is inline attributes and why the shrinking has to be built in. Side
padding went 40px -> 24px in the layout and in all nine mail views:
40 each side leaves 278px of a 390px screen to read in.

**There was no way to look at a mail.** An invoice mail needs an invoice,
a maintenance announcement needs a window, and "register an account to
see whether the confirmation reads well" is not a workflow — which is how
the fixed width survived for months. App\Services\Mail\MailPreviews
builds all nine from sample data, every record with make() and never
create(), so a preview leaves no invoice, order or window behind and
draws no number from a series. Two ways to see one: in a tab as the
document itself, or sent to the signed-in operator's own address. There
is no recipient field — that would be a form for mailing strangers.

sendNow(), not send(): every mailable here implements ShouldQueue, and
send() defers to the queue for those, which would have put a failure in a
worker's log while the button reported success to somebody waiting for
the mail. The test caught that.

**The booking page had no shell.** Order declared layouts.portal — the
bare shell the sign-in pages use, no navigation and no padding — so the
page sat flush against the top and bottom of the window with no way back
to anything. layouts.portal-app, like every other portal page.

**The consent box said something untrue.** Its refusal read "otherwise we
can only build your cloud once the withdrawal period has ended", which
describes a path that does not exist: nothing here delivers fourteen days
later, and an unticked box simply means no order. Said plainly now, and
said on the page before somebody runs into the refusal.

Also: GrantedPlanPortalTest asserted assertDontSee('179'), which matches
any id containing those digits — and ids climb through a suite run
because a rolled-back transaction does not reset an auto-increment
counter. It passed alone and failed in company. Asserts on '179,00' now,
which is what a leaked price would actually look like.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 14:50:37 +02:00
nexxo 494a3a817e Fix round: guard the parse, bound the future, name the real tab
Covers the queue assignment the second heartbeat exists to prove, stops an
unreadable heartbeat value from taking the whole readiness page down with it,
rejects a heartbeat dated into the future, and points both checks at the tab
that actually restarts the scheduler and provisioning worker.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 14:49:06 +02:00
nexxo 6ca3e6ef4a Notice when nobody is picking up the queue
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 14:32:09 +02:00
nexxo 3e7026407b Stop inventing a package, and rebuild the booking page
tests / pest (push) Failing after 8m12s Details
tests / assets (push) Successful in 20s Details
tests / release (push) Has been skipped Details
Two pages said opposite things about one account, straight after
registering: the dashboard said "noch keine Instanz", and Paket & Addons
said "AKTUELLES PAKET Start — Status Aktiv". The dashboard was right. The
package page fell back to `$instance?->plan ?? 'start'` and rendered that
fallback as a contract the customer had entered into, complete with a status
of Aktiv, on the one page somebody opens to find out what they pay.

The contract is resolved from the CUSTOMER now, not from the instance — a
paid order parked for want of a machine has a subscription and no instance
yet, and asking the instance would tell somebody who has paid that they have
no package. An instance counts as well, whatever its subscription row says.
With neither, the page says so and points at the booking page: an "Upgrade"
from nothing is a purchase, and that has its own page.

The cart stays outside that gate. A customer can have something in it before
they have any contract at all, and hiding it there would hide the thing they
came to pay for — which is what my first version did, and seven existing
tests said so.

And the booking page. Five cards in a four-column grid left the fifth alone
on its own row, reading as a mistake; the consent box sat at the BOTTOM with
every buy button disabled until it was ticked, so the page arrived looking
broken before anybody had done anything wrong; and the price ran across the
card in one block with nothing to compare.

Now the consent comes first, because it is the condition for everything under
it, and it colours itself green once given. Three columns, so five plans read
as 3 + 2. Each card is the same four rows in the same order — price, delivery,
what you get, the button — so the eye can run across a row and compare like
with like. The button stays ENABLED with the reason it will not go through
said under it: a disabled control explains nothing, and the server is the gate
either way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 14:29:22 +02:00
nexxo 8321e825eb Fix round: CGNAT, guarded write/delete, and a silent host is not a missing template
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 14:19:44 +02:00
nexxo 2321854967 Discard an unconfirmed registration, and sweep the abandoned ones
tests / pest (push) Failing after 8m17s Details
tests / assets (push) Successful in 19s Details
tests / release (push) Has been skipped Details
"Abmelden und neu registrieren" was advice that could not work. Signing out
frees nothing: the address is still held by the unique index, so registering
again with the address somebody meant fails — and every abandoned attempt
stayed in `users` for ever. Reported exactly that way, and it was right.

The button discards the account instead. Only an UNCONFIRMED one, and only
one with nothing behind it: the webhook creates a customer's login from a
paid checkout, so a user can sit on this page unconfirmed while already
having a contract, and deleting that is data loss dressed up as a
convenience. It refuses and says where to go. A confirmed account is closed,
not discarded — that is ConfirmCloseAccount, with an invoice history behind
it.

R23: confirmed in a modal. The modal mutates nothing; it dispatches the event
the page listens for, so the checks stay in the one place they already are
rather than being duplicated where they could drift.

And nothing has to be discarded by hand. clupilot:prune-unverified removes
registrations nobody confirmed after five days — long enough for somebody who
signed up on a Friday and found the mail in a spam folder on Monday, short
enough that a typo does not hold the correct address for a month. It skips a
confirmed account whatever its age, and any account with a customer record
behind it, matched on user_id AND on the address, because either link means
somebody is a customer. Every removal is logged with the address: a line that
disappears is what somebody asks about later, and a count answers nothing.

The deadline is said on the page as well as in the mail. Somebody who never
received the mail is looking at the page, and the deadline is the reason they
do not have to do anything about an attempt they abandon.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 14:08:02 +02:00
nexxo 43d1d55909 Merge branch 'main' into claude/cool-sammet-368fee 2026-07-30 14:04:06 +02:00
nexxo 4eb90c834a Write down what the reviews found and nobody fixed
Ten follow-ups, none of them blocking and none of them done. The one that
matters: activePricesFor() recognises an orphaned Price and nothing recognises an
orphaned PRODUCT — and a second Product is worse than a duplicate Price, because
every activePricesFor() then asks about the wrong Product and recognition goes
blind for that whole family. The branch disables its own guard through the gap it
did not close.

Second: AdoptStripePrice promises in its own docblock that no adoption can move
money, but can only compare amount, currency and interval. The four properties
that also decide what a Price charges are filtered in HttpStripeClient, which the
fake cannot express — so no test of the class can reach the promise it makes.

Also recorded: two pre-existing test failures this branch surfaced without
causing, with the diagnosis, so the next person does not derive them again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 14:02:51 +02:00
nexxo 2eff3a5a3c Tell a read-only token apart from one that can write
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 13:56:28 +02:00
nexxo f49f1dca09 Say when no mail is being delivered at all
tests / pest (push) Failing after 8m40s Details
tests / assets (push) Successful in 28s Details
tests / release (push) Has been skipped Details
"Ich versuche mich zu registrieren, erhalte aber keine Verify-Mail, auch
nicht beim erneuten Senden." Traced rather than guessed, and the evidence is
in storage/logs: the address appears three times, so the mails were built and
handed to a transport that writes them to a file.

MAIL_MAILER=log. MailboxTransport::resolution() short-circuits on it —
NON_DELIVERING is ['log', 'array', null] — so every purpose mailer becomes
the log transport however well the five mailboxes are configured, which is
correct for a development machine and exactly what was happening here.

The reason it deserves a notice is that the failure is silent in every
direction an operator would look. Mail::queue() succeeds. No job fails. The
queue empties. And the console's own mailbox test reports SUCCESS, because
MailboxTester builds its own transport on purpose — a check that honoured
MAIL_MAILER=log would report success while writing to a file. So every
instrument on the page said fine.

Now the front page says it, linked to the mail page, and the mail page says
it above the mailboxes it makes decorative. All three swallowing values are
named, not only 'log': array and an unset default do the same thing, and a
notice that only knew about one would be silent in the two cases nobody
thinks to check.

Two existing tests asserted that a clean installation raises no notice, and
went red — correctly, because phpunit.xml forces MAIL_MAILER=array so a suite
cannot send mail. They pin a delivering mailer now: the warning is about the
environment, those tests are about the estate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 13:55:55 +02:00
nexxo 3cf16ecd63 Name every property that decides what a price charges
The money gate compared amount, currency and interval and left the rest to a
filter that excluded two fields. A recurring, licensed, monthly price with
transform_quantity divide_by 10, at our exact figure, on our product, carrying
our addon key passed all five conditions. Modules bill BY quantity — a pack is
one item at quantity n — so a customer holding three would have been charged
one. A tiered price was excluded only by accident: Stripe reports unit_amount
null for it and we read that as 0, which fails the amount match unless the
caller's own figure is 0, and PlanPrices::ensure() has no zero guard.

activePricesFor() now also skips a non-empty transform_quantity and any
billing_scheme other than per_unit. Absent keys stay Stripe's defaults, which
is the direction that matters: read the other way round the filter would refuse
every legitimate price and turn recognition into a permanent no-op. The
contract docblock names the four properties instead of claiming to cover all of
them, and says the list is not everything Stripe can put on a price — only
what is known to change what one charges.

And a comment that says a gap is covered is worse than the gap. The sync said a
crash between Stripe creating a product and us storing its id gives back the
same product next run: true for twenty-four hours, false afterwards, and the
exact claim this branch was written to refute. There is no recognition step for
products at all, so an interrupted run leaves an orphan and the next run past
the expiry mints a second one — worse than a duplicate price, because
activePricesFor() then looks at the wrong product and recognition goes blind
for the whole family. Written down as a known gap at both createProduct call
sites, with the trade this branch chose: folding name and metadata into the key
means a renamed family now mints a second product where it used to answer 400,
and a blockade reaches a paying customer while a duplicate product does not.

The sweep no longer reports "created" for what it adopted — the count is taken
before ensure() runs, and telling those two apart is the whole point.

The test named after 2026-07-29 could not reproduce it: the fake's key ledger
was empty, so createPrice() could not have thrown and the test could not tell
"adoption prevented the 400" from "no 400 was possible". Seeded now with the
key today's call sends and a fingerprint over the metadata the pre-9da1358 call
sent. Remove the adopt() call from AddonPrices::ensure() and the test dies with
Stripe's own sentence instead of an assertion.

Also: the plan document still instructed migrate:fresh --env=testing. There is
no .env.testing here, so it targets the live development database; struck
through with the reason, not deleted, and a Korrekturen section records the
five defects the plan's own reviews found in it. Four comments corrected —
"below" that meant above, a counter claimed unable to tie that ties with a
planted price, the unique index's second meaning for remember()'s catch, and
that MariaDB DDL is not transactional so the dedupe commits before the index.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 13:50:20 +02:00
nexxo 70fa136e22 Say on the front page that the site is switched off
tests / pest (push) Failing after 8m54s Details
tests / assets (push) Successful in 20s Details
tests / release (push) Has been skipped Details
"Ich komme auf dem lokalen Dev-Server nicht auf www.dev." Diagnosed rather
than guessed: Laravel answers 200 on that host, and the request never
reaches it — site.public is false, so PublicSiteGate returns the placeholder
with a 503 to every visitor who is not on the management VPN and not signed
in as an operator. Which is precisely what a broken deployment looks like
from outside, and the only place that said so was the switch itself, on
another page.

So the console says it now, on the page an operator opens when something
looks wrong, and the notice links to the switch.

The second half of the same report — being sent to app.… — is the missing
SITE_HOST. Without it the marketing site is registered without a hostname:
it answers on the PORTAL's host as well, and every route() it generates
points at APP_URL, so a link on the site lands on the portal's domain. Also
said, also with the host named.

Only where the installation is host-separated at all, though. A fresh
checkout binds nothing to a hostname by design — RestrictAdminHost:
"upgrading must not lock anyone out of a system that was working" — and a
warning there is furniture. My first version fired on every clean
installation and two existing tests said so immediately; they were right.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 13:36:42 +02:00
nexxo 0f4226c488 Fix round: read the mailer against NON_DELIVERING, not against 'log' alone
delivery.mailer_not_log now reads MailboxTransport::NON_DELIVERING (made
public) instead of a narrower `!== 'log'`, so 'array' — this suite's own
MAIL_MAILER — and an unset default no longer pass as ready when nothing
would actually be delivered. provisioning.monitoring_token's severity now
follows provisioning.monitoring.required the same way RegisterMonitoring
itself does, instead of asserting a fixed 'warning' the pipeline does not
always honour. Two breaks sentences (dns_zone, mail_templates) now name a
concrete failure mechanism instead of a correct-but-vague description.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 13:27:14 +02:00
nexxo f9993a1f48 Name the server variable under a credential, not the internal key
tests / pest (push) Failing after 8m35s Details
tests / assets (push) Successful in 20s Details
tests / release (push) Has been skipped Details
Under each credential stood `stripe.secret`, `dns.token`,
`inbound_mail.password`, `monitoring.token` — identifiers out of
SecretVault::REGISTRY. They mean nothing on a settings page and there is
nowhere an operator could type them; asked about directly, and the answer is
no, that was not deliberate.

The env variable name is the useful one: it is what stands in the server
file, it is what the .env tab of this same page lists, and when the badge
beside it says "Aus der Serverdatei" it answers the obvious next question —
which line?

The registry key stays where it belongs: in wire:key and in the modal
arguments, as an identifier nobody reads. The test asserts on the text node
rather than on the key's absence from the HTML, because absence would have
demanded breaking the page to pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 13:20:29 +02:00
nexxo a84251260c Read the mailbox password where it actually is, and let the console test it
tests / pest (push) Failing after 9m6s Details
tests / assets (push) Successful in 22s Details
tests / release (push) Has been skipped Details
The inbox kept saying no mailbox was set up while the password sat right
there on the settings page. isConfigured() read
config('services.inbound_mail.password'), which is only ever the .env
value: this project deliberately does not overlay stored secrets onto config
at boot — SecretVault's own docblock says so, and HttpMonitoringClient reads
its token at the point of use for exactly that reason. My docblock even
asserted the opposite mechanism, which I never verified. It reads the vault
now, and falls back to .env because SecretVault::get() already does.

And a way to find out. "Verbindung prüfen" saves the form first — testing
what is on screen while the server still holds the previous values would
report on a mailbox nobody configured — then performs the same four verbs
the real fetch performs: LOGIN, SELECT, SEARCH UNSEEN. A check that proves
something easier than the actual job is worse than none.

What it reports back is one of five fixed words, never the server's own
prose: an IMAP error carries the command it failed on, and for LOGIN that
command contains the password. Each word maps to a sentence an operator can
act on — address and port, mailbox and password, the folder.

The line saying when the mailbox was last reached is on both pages, and it
is written by the SCHEDULED run as well as by the button. Otherwise "last
checked" would mean "last time somebody pressed a button", which is the less
interesting of the two: a mailbox that stopped answering at three in the
morning is the one worth seeing, and nobody presses a button at three in the
morning. It is also shown when the answer is "never" — an empty inbox and a
mailbox that stopped answering look identical, and this line is the whole
difference.

A fetch against a mailbox that just refused the login now takes nothing in
and records the refusal, rather than reporting "nothing new" — which was
true and useless.

Also: HostStepsTest asserted the literal clupilot.com for a host name. That
was true until CLUPILOT_DNS_ZONE was set to clupilot.cloud on this
installation, and then a correct configuration failed a test. The zone is
pinned in the test now; what it is about is the shape of the name and which
address it carries.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 13:12:29 +02:00
nexxo cb9781d2e7 Name the mechanism that actually rebuilds the row, and log what the delete removed
The migration's own docblock justified an irreversible delete with a mechanism
that cannot fire: after the dedupe the deleted row's Price id is always still
claimed, by the survivor, so AdoptStripePrice's `claimed` callback always skips
it. The rebuild is real, but it goes through a fresh createPrice() under the
deleted row's own tuple-specific idempotency key, not through adoption. A
comment whose whole job is to justify deleting production rows has to name the
mechanism that runs, not the one that structurally cannot — this is the fourth
false justification this branch has carried, and the incident the whole
feature exists for started the same way.

The delete itself left no trace: down() restores the index, not the rows, and
two rows on one Price necessarily have different tuples, so at most one can
match what the Price actually charges — after the one real run there would be
nothing to check the survivor against. Added a Log::warning per duplicate
group naming the shared Price, the row kept, and every row thrown away.

Test: read back stripe_price_id and amount_cents on the surviving and
unrelated rows instead of only checking they still exist, closing the "kept
the right row but damaged it" gap. Added a second duplicate group of three
rows sharing one Price so the migration's foreach runs more than once and a
single iteration deletes more than one row. Re-verified the "lowest id
survives" assertion is load-bearing by flipping min('id') to max('id') during
this fix and confirming the test fails there, then reverting.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 13:07:24 +02:00
nexxo 325cee50ca Check the machines, the DNS and the post as well
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 13:05:18 +02:00
nexxo 2c9e9ea398 Watch the deletion branch delete the right row
2026_07_31_210000_one_row_per_stripe_price deletes rows from a live table —
the one place in this branch where a mistake loses data, not merely a place
where "read the migration" is enough. Nothing exercised that branch: every
other test in this file starts from an empty, already-migrated table, so the
delete never had anything to find.

RefreshDatabase has already run the migration by the time any test starts, so
its own unique index refuses the very duplicate this needs to insert. The new
test drops the index first, puts two rows on one Price back the way a
pre-migration table actually looked, and re-runs up() (require, not
require_once, the way the other migration-replay tests in this suite already
do) to reach the branch directly.

It asserts the row with the LOWER id survives, not merely that a row survives:
that ordering is a decision made on the operator's behalf, and it is the row
anything already billing is likeliest to have been reading. Checked by
temporarily inverting min('id') to max('id') during this work — the test
failed exactly there, then passed again once reverted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 12:52:27 +02:00
nexxo dba5df5d1e One row per Stripe price on the module side too
The plan register has had this since 2026_07_30_110000. The module register
never did, so two rows could claim one Price and archiving the one would have
withdrawn the Price the other was still selling — Block D of the real-run
handoff.

It is also the net under the adoption step: at a VAT rate of nought both
treatments charge the same amount, and the check that refuses an already-claimed
Price is then the only thing keeping the two apart.

Duplicates are deleted, not archived. An archived row goes on claiming the id
and a unique index knows nothing about archived_at; the row rebuilds itself on
the next ensure(), and subscription_addons holds the Stripe id as text rather
than a foreign key, so no booking loses its price.

AddonPrices::remember()'s catch comment described the guard as the only thing
standing between two rows and one Price; now that the index exists, rewritten
to describe it as the net under that guard instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 12:47:34 +02:00
nexxo b1e5bf8023 Fix round: match the actual selection IssueInvoice and sync use
Two of the five billing checks measured something close to, but not
the same as, what actually gates the flow they report on:

- billing.invoice_series counted distinct kinds without filtering on
  `active`, but IssueInvoice draws its series with
  where('kind', ...)->where('active', true) (IssueInvoice.php:373,
  :444) — a deactivated row is invisible to it exactly like a missing
  one. An installation with a switched-off 'invoice' series was
  reported ready while no invoice could draw a number: false-green.

- billing.catalogue_synced counted every PlanPrice with a null
  stripe_price_id, but stripe:sync-catalogue only ever mirrors
  PUBLISHED versions (SyncStripeCatalogue's own header comment). A
  draft's unsynced price is an ordinary state, not a gap; the old
  query kept this check permanently unsatisfied on any installation
  that had ever drafted a next version — an alarm always on and
  therefore never read.

Both queries now mirror the condition the real gate uses instead of a
similar-looking one of their own. Two tests added that were red before
the fix: an inactive required series that must not read as ready, and
an unpublished draft's unsynced price that must not raise an alarm.

Also strengthens the weakest breaks sentence: invoice_series_breaks
named the mechanism ("a document cannot draw a number") rather than
the consequence, unlike its four siblings. It now says what the
customer and operator actually experience — a running cloud with no
invoice, the failure only logged — matching company_details_breaks.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 12:42:09 +02:00
nexxo dfbd3b5c1f Restore the key's own reason and pin the plan-side identifying key
Two things the plan's own text asked back. First, the idempotency-key
comment lost its substance when Task 5 rewrote it: the charged amount and
the treatment are in the key because at a rate of nought the two Prices
are the same amount and one key would have Stripe hand back a single
object for both — a maintainer who simplified that key would only
rediscover why from a unique-constraint violation. Restored alongside the
newer sentence about what actually stops a second Price (adoption, not
this key), so the module and plan sides read alike again. Spelled out
"twenty-four hours" in both places in the same comment, matching the
sibling.

Second, nothing pinned identifying: ['plan_price_id'] — the one decision
that makes the plan side different from the module side, because a
family's Product carries a Price for every version, term and treatment,
so plan_family alone cannot say which row a Price belongs to. Added a
test that plants a Price proving only the family, confirmed it fails
when identifying is loosened to ['plan_family'], and reverted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 12:34:08 +02:00
nexxo d9c0ad0e4a Say what breaks, not just which field is empty
Task 7: the readiness collector (App\Support\Readiness) and its first
group, billing (App\Support\Readiness\BillingChecks). Reports missing
prerequisites — the active mode's Stripe key, its webhook signing
secret, complete company details, an invoice series per document kind,
a fully Stripe-synced catalogue — without enforcing anything itself;
the real locks (IssueInvoice, the checkout's Stripe-key guard) stay
where they are.

The webhook check uses App\Support\StripeWebhookSecret::current()
rather than querying config() by mode a second time, and the company
check calls CompanyProfile::missingForInvoicing() rather than
duplicating its list — two sources for one question is how they drift
apart.

Added lang/de/readiness.php and lang/en/readiness.php with every
`readiness.*` key this group uses, each `_breaks` sentence naming the
actual downstream failure rather than repeating the field name.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 12:25:27 +02:00
nexxo cdda38c6b2 Make the plan-side adoption tests able to fail
Both new tests passed whether adoption ran or not: Stripe forgets an
idempotency key after twenty-four hours, and that expiry is the only
condition under which the 2026-07-29 duplicate was ever minted. With the
sync's own key still in the fake's ledger, createPrice() replayed the
right answer on its own, so testing the recognition step with the key
still in force tested nothing.

Clearing $this->stripe->keys before the second call is what lets
createPrice() actually mint, which is what gives both tests something to
fail on. The second test needed one more thing: this row's own domestic
Price is a legitimate orphan the moment its register row is gone, so it
had to be archived first — otherwise adoption would rightly reclaim it
before ever weighing the wrong-owner Price, and the refusal this test
exists to prove would never be reached.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 12:21:36 +02:00
nexxo a1e3484fba Verify webhooks against the secret of the mode we are in
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 12:13:55 +02:00
nexxo 355f9cce01 Ask before minting a package price
Same defect, same fix, one difference: a family Product carries every version
and every term, so the amount alone does not say which row a Price belongs to.
plan_price_id in the metadata does, and it is what has to agree before a Price
is taken over.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 12:09:44 +02:00
nexxo dca74f0d78 Fix round 1, correction: name the assertion that actually bites
The comment from the last commit had it backwards. It called the two
original assertions "not the guard" and claimed the three DB-state
assertions fail when adoption reaches the wrong figure — both false.
AdoptStripePrice performs no database writes at all, so nothing it does can
fail an assertion that reads the 2900-net row's own table state, singly or
in combination. The `archived` assertion — the only one that reads what
Stripe was actually told — is the one adoption can fail, by archiving the
frozen booking's own Price as a false duplicate of the new figure's orphan.

Left as a trusted comment, this would have read as an invitation to delete
the real guard as redundant scaffolding around the "real" three. Reworded to
say what the DB-state assertions actually defend instead — archiveSuperseded()
losing its net_cents scoping, or remember() rewritten as an updateOrCreate()
keyed without amount_cents — and to name where each of adoption's two guards
(the amount match, the claimed callback) is tested in isolation elsewhere in
this file, since neither is isolated here.

Also closed the `archived_at` assertion: it read null for a row that is
still live and for one that is gone or rewritten onto the wrong net_cents
alike. An `exists()` check now stands beside it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 11:59:01 +02:00
nexxo 32a8bd9a88 Refuse the sale instead of reaching Stripe without a key
Task 5: Billing::purchase() now checks SecretVault for a stripe.secret before
touching anything else — no customer, no plan lookup, no Order row — and the
customer reads a plain sentence instead of a 500 page. HttpStripeClient's own
secret() throws the same StripeNotConfigured exception rather than sending a
request with an empty bearer token and letting Stripe's 401 stand in for the
real cause.

isConfigured() was fixed alongside it: it used to call secret() too, which
would have turned every "is Stripe set up?" check (the checkout controller,
the catalogue sync commands) into an uncaught exception the moment a key went
missing — the opposite of what this task is for. It now reads the vault
directly.

The new guard is unconditional, including for the cart-only purchase types
(storage, addon, plan changes) that never call Stripe themselves — a cart
order nobody can ever pay for is pointless to create. That broke every
existing Billing/Cart/Downgrade test that calls purchase() without a stored
key; each affected fixture now deposits one via the new withStripeSecret()
Pest helper. A few of those tests (CustomDomainAccessTest, DowngradeTest) were
passing already but for the wrong reason — the new guard, not the check they
were named for — since both only assert an order was NOT created; they get
the same fixture fix so they still prove what they claim.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 11:58:01 +02:00
nexxo 40f7818757 Fix round 1: give the frozen-booking test real teeth
Two Important findings from review, both against the plan's own text.

The frozen-booking test could not fail for a defect this diff introduces.
Digging into why turned up two problems, not one. The two assertions the
review named were never exercised: sync-catalogue never writes
subscription_addons.stripe_price_id, and the orphan planted for the new
figure sits at a different amount than the booking's own price, so
archiveSuperseded's net_cents scoping was never going to reach it either.
Underneath that, the test's own orphan was inert regardless of any of that:
with no StripeAddonPrice row seeded first, AddonPrices::product() minted its
own Stripe Product instead of reusing 'prod_support', so the competing
orphan planted under that literal id sat on a product nothing ever asked
about. Fixed both — seeded the product the way the first two tests in this
file already do, added the three assertions the review specified, and a
comment on what the two original assertions do and do not prove.

Verified the fix actually bites, since a test whose teeth are asserted but
never demonstrated is what produced this finding in the first place. With
just the amount check in AdoptStripePrice disabled, the booking still
survived: the `claimed` callback this diff wires into AddonPrices::ensure()
caught it as a second, independent guard. Only disabling both together
reproduced the failure — the honest demonstration, not the single-line
sabotage first suggested, which this diff's own defense-in-depth absorbs.

The remember() comment credited a unique index stripe_addon_prices does not
have — that index sits on stripe_plan_prices, the plan side. Reworded to
name what actually keeps two rows off one Price id today (the `claimed`
callback), what 2026_07_31_210000_one_row_per_stripe_price adds as a net
under it, and why two rows sharing one Price is dangerous: archiving one —
the ordinary response to a superseded figure — would withdraw the very
Price the other row is still selling.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 11:45:50 +02:00
nexxo ac44717d1b Cover the unreadable branch and make rollback pick live deterministically
The only test that reached SecretCipher::decrypt() failing was an accident of
a fixture bug fixed in the previous commit: it encrypted with the wrong key,
so decryption threw and the migration's catch(Throwable) branch ran without
anyone meaning to test it. Fixing that fixture silently removed the only
coverage of a branch that decides a payment key's mode, so it gets its own
test with a deliberately unreadable row.

down() picked whichever of the :live/:test rows an unordered get() happened to
return first to restore to the bare key, and deleted the other — so which
credential survived a rollback depended on row order, not a decision. It now
processes :live rows first unconditionally, then :test rows only where the
bare key is still free, so the live credential always wins when both exist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 11:32:58 +02:00
nexxo 97b655938e Ask before minting a module price
The sweep that died on 2026-07-29 left price_1TygdEC7u8NpJ8pOt3nsoyYw at Stripe
and no row here. This is the step that finds it: same money, same interval, our
metadata on it, unclaimed — taken over and brought up to date instead of
duplicated.

The comment on the key was the root of it. It read "keyed on what the Price IS",
and a key is not that: identity is product, amount, currency and interval, and
recognising those is AdoptStripePrice's job. Stripe forgets a key after a day;
it never was the guard against a duplicate.

A booking stays where it was sold. Adoption only ever matches an identical
amount, so a frozen booking cannot be moved by it — there is a test that says so.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 11:24:05 +02:00
nexxo 727f2e41db Make source() respect strict entries like get() does
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 11:21:07 +02:00
nexxo ecf948f30f Let the stored key say which mode it belongs to
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 11:16:15 +02:00
nexxo 06c214e597 Make the money gate compare the whole recurrence
activePricesFor() reported interval alone, never interval_count or usage_type.
A Price we could not have created ourselves — billed every three months, or on
metered usage — could carry our own metadata (Stripe's dashboard "duplicate
price" copies it) and pass AdoptStripePrice's amount/currency/interval check on
'month' alone. Adopting it would move money onto a different recurrence, which
is exactly what nothing here may do.

Filtered at the boundary instead of in the predicate: HttpStripeClient now
skips anything besides Stripe's own defaults (interval_count 1, usage_type
licensed), so activePricesFor() can promise callers it returns only prices we
could have minted. FakeStripeClient needed no filter — it only ever holds
prices its own createPrice()/plantPrice() made, all standard — but says so, so
the omission doesn't read as forgotten.

Two more silent-failure paths, closed with the same three lines each:

- The metadata-differs check was a bare !==, which is key-order sensitive and
  blind to Stripe's own merge. A Price carrying an extra key of its own, or
  metadata handed back in a different order, could never compare equal and
  would be rewritten every sweep forever. Compared through
  array_intersect_key() plus ksort() on both sides instead, so only what WE
  would send is what gets compared.
- Metadata is normalised to strings once, up front, so an un-cast value from a
  caller can't make confirms() fail forever and silently turn adoption off for
  that Price.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 11:15:16 +02:00
nexxo 4de44e7ab0 Recognise the price Stripe already has
An abandoned run leaves an orphan: Stripe made the Price, our insert never
happened, and the table that decides everything afterwards does not know it
exists. The key covers a day; after that the next run makes a second live Price
for the same money.

What may be taken over is narrow. Same amount, currency and interval — a Price
at another figure would move money, and nothing here may: a running contract
keeps the Price it was sold on, and a booking stays frozen at what it cost that
day. Plus proof in the metadata, because an unexplained active Price at the
right money is what somebody clicking through Stripe's dashboard leaves behind,
and adopting that is worse than minting a second one.

Several orphans: the oldest is adopted — likeliest to be the one a lost row was
billing on — and the rest are archived, which stops them being sold and moves
nobody.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 11:02:49 +02:00
nexxo 59e8689630 Never spend real money while the switch says test
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 11:01:33 +02:00
nexxo ca575e6df1 Let the catalogue ask Stripe what it already has
Nothing here could. The client could create, archive and unarchive a Price and
had no way to list one, so a run that died between Stripe's create and our
insert left an orphan our table never learned about — and once the key expired,
the next run made a second live Price for the same money.

Paged to the end like invoiceLines(), because a family Product collects a Price
per version, term, treatment and rate change, and stopping at the first page
would leave the orphan unfound and mint the duplicate anyway.

updatePriceMetadata() alongside it: metadata is one of the few fields a Price
lets you change, which is why the metadata format is not part of a Price's
identity and why an adopted orphan is brought up to date rather than replaced.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 10:54:23 +02:00
nexxo bc6eee4dc3 Give every credential a test slot beside its live one
Each entry in the vault now stores under key:mode instead of a bare
key, and get() resolves the active mode internally — falling back
from an empty test slot to live (never the reverse) so test operation
works before anyone has filled in test credentials, but live operation
never silently reaches for a test one.

get()'s signature is unchanged on purpose: its three callers
(HttpStripeClient, StripeCheck, SshTraefikWriter) have no business
knowing which mode is active. put()/forget()/source()/outline()/
updatedAt() take an optional trailing $mode for the console, which
will use it in a later task.

SecretVaultTest's raw app_secrets queries assumed the old bare key and
needed the :live suffix plus a pinned OperatingMode — otherwise they'd
depend on whatever mode an earlier test file left in the array cache.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 10:53:17 +02:00
nexxo cdea9e923f One source for what counts as the same call
IdempotencyKey::forPrice()/forProduct() built the parameter array they
fingerprint, and FakeStripeClient::createPrice()/createProduct() rebuilt the
identical array literal by hand purely to feed replay()/rememberKey(). Nothing
tied those two literals together — a later change to what a Price call
fingerprints could drift from what the fake checks for a collision, and no
test would catch it, because the HttpStripeClient tests exercise forPrice()
alone and the FakeStripeClient tests exercise its own local array alone.

priceParameters()/productParameters() are now the one answer to "what counts
as the same call"; forPrice()/forProduct() and the fake both ask it instead of
each keeping their own copy. Fingerprint and key format are unchanged — this
is the duplication coming out, not new behaviour. refund(),
cancelSubscription(), addSubscriptionItem() and createCheckoutSession() are
untouched: their local parameter arrays are the fake's own record of the call,
never fingerprinted into a key, so there was nothing to duplicate there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 10:46:46 +02:00
nexxo 3cf17e5cb0 Give the installation a test mode and a live mode
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 10:41:23 +02:00
nexxo 88d6dc620b Stop the suite from reading the operator's own .env for the DNS zone
HostStepsTest asserted fsn-01.node.clupilot.com and was green only because
this server's .env carried the wrong zone. Correcting the .env turned it red
without a line of production code changing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 10:38:12 +02:00
nexxo 60e93aafd7 Put in the key everything the call actually sends
A key says "I already sent this call", not "this is what the object is". The
catalogue calls sent metadata that the key knew nothing about, so adding the
tax_treatment field in 9da1358 poisoned yesterday's key for a day — and
AddonPrices::ensure() runs inside a customer's module booking, not only in the
sweep.

createPrice and createProduct now fold a fingerprint of their parameters into
the key. refund, cancelSubscription, addSubscriptionItem and the checkout
deliberately do not: there a second object is the customer's money taken twice,
and Stripe's refusal is the thing worth keeping.

The fake could not see any of this. Its ledger replayed the first id for a
repeated key without ever comparing what the second call asked for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 10:30:32 +02:00
nexxo fe4a74773e Break the mode switch and the readiness page into twelve steps
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 10:23:11 +02:00
nexxo 0ec5d93da8 Break the price-adoption spec into six testable steps
Six tasks, each ending on a green billing folder and its own commit: the key
fingerprint (and a fake that finally reproduces Stripe's refusal), the two
listing calls, the adoption step itself, then each price service in turn, then
one row per Stripe price.

The spec now names both test files. What goes on the wire is checked through
Http::fake against the real client — asserting against the fake would only prove
the fake agrees with itself, which is the mistake that let the incident through.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 10:18:20 +02:00
nexxo 3768433792 Design a test/live switch and a page that says what is missing
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 10:11:26 +02:00
nexxo af9d60469e Write down why a half-finished sync blocked itself for a day
A run on 2026-07-29 23:11 created price_1TygdEC7u8NpJ8pOt3nsoyYw at Stripe and
died before the row was written. 9da1358 then added the tax_treatment metadata
field, so the next sweep sent the same idempotency key with different parameters
and Stripe refused it — for twenty-four hours, and not only for the sweep:
AddonPrices::ensure() also runs inside a customer's module booking.

Two defects, one spec: nothing asks Stripe what it already has, and the key
covers less than the call sends. The spec settles what identity means here
(product, amount, currency, interval — never metadata, which Stripe lets you
edit) and where the key's fingerprint may go (the catalogue calls, never a
refund, where a duplicate costs the customer their money twice).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 10:06:44 +02:00
nexxo 36cb684d8b Write down what the audits found and what a real run needs
tests / pest (push) Failing after 8m0s Details
tests / assets (push) Successful in 20s Details
tests / release (push) Has been skipped Details
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 08:58:59 +02:00
nexxo 5d12983ef7 Release 1.3.44
tests / pest (push) Failing after 7m56s Details
tests / assets (push) Successful in 30s Details
tests / release (push) Has been skipped Details
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 02:18:15 +02:00
nexxo 9da1358802 Stop charging VAT to the customers who owe us none
A verified EU business outside Austria gets reverse charge — rate 0, no VAT
line, the note on the invoice — and was still charged 214,80 € for a 179,00 €
package, because one Stripe Price carried the domestic gross for everybody.
There is no VAT line on their document to reclaim, so it was a flat 20 %
surcharge on exactly the customers who read their invoices, and the document
then stated the whole 214,80 € as net at 0 %, so they self-accounted their own
VAT on a base a fifth too large.

A Stripe Price can ask who is buying after all: there are two per sellable
thing now, the domestic gross and the bare net, both live on one Product, and
the checkout picks by TaxTreatment. The rule is Austria B2B 20 %, other EU B2B
without VAT — a domestic business still pays the gross, reclaims it as input
tax, and the price on the website is still the price charged for them.

- stripe:sync-catalogue mints and archives both halves of every pair, per plan
  and per module; `reverse_charge` on stripe_plan_prices/stripe_addon_prices
  says which is which, and joining it on subscriptions.stripe_price_id says
  which one a running contract is billed on. A rate change moves the gross
  Price and leaves the net one alone, because the net owes nothing to the rate.
- A status that changes after the sale converges: a verification (or a lapse)
  makes stripe:reprice-subscriptions move the contract onto the other Price
  with PRORATE_NONE, because the term is already paid for. The module items
  follow the same way.
- Downstream follows: the invoice total equals what was taken, the proof
  register expects the figure this customer is actually charged (so a correct
  reverse-charge sale is no longer flagged as a mismatch, and an overcharged
  one is), the setup fee obeys the same rule, and the booking page quotes what
  it will charge.
- StripeClient gained activatePrice(). Archived Prices were brought back in our
  own table and left inactive at Stripe, so a rate that moved and moved back
  pointed the catalogue at a Price no checkout could be opened for.

An unverified number is still charged the gross — an unchecked string must
never be a discount — and where the net Price is missing the checkout refuses
rather than reaching for the domestic one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 02:15:41 +02:00
nexxo 889b401faf Fix nine defects in the provisioning pipelines
The capacity park could not survive its own first poll. ReserveResources
polls every 120 s and declared a maxDuration of 60, and RunRunner measures
a step's budget from a started_at it deliberately does not reset on a poll
— so every re-entry was ruled timed out before the body ran, a timeout
consumes an attempt, and five of them failed a paid order in about six
minutes. The fourteen-day promise, the console's capacity queue and the
"go and buy a server" workflow were unreachable code. maxDuration is now
the whole park plus one poll interval, and a test drives a park through
the runner rather than calling execute() directly, which is why this was
invisible.

Every guest occ call ran as root. `docker compose exec` defaults to root
and the Nextcloud image's console.php exits 1 unless the caller owns
config/config.php, so all five call sites were failing on every instance.
There is now one builder, App\Support\NextcloudOcc, and a test refuses a
second: nothing in app/ may spell the invocation out by hand. deploy/
update.sh had learned this for our own container and nobody carried it
across.

RunAcceptanceChecks was terminal on the first no. certReachable() answers
false for a connect timeout as readily as for a missing certificate, so
one bad second ended a finished, certified Nextcloud as a failed order
with the instance released. The probes retry now and the run still fails
for good with the probe's own reason once the budget is spent; the two
facts a retry cannot change stay terminal.

The in-flight guard asked whether anything was running, not whether it
would do the work. App\Provisioning\WorkInFlight asks the second question,
reading the step lists the runner executes: a domain proven during a
restart is routed, and a storage pack booked during an address run is
delivered instead of charged monthly and forgotten.

The address pipeline ran its steps in the wrong order for the direction it
exists for. Nextcloud before the router in `address` and `plan-change`, as
in `customer`, because a run that got the certificate and then failed left
the customer's own domain serving an untrusted-domain error under a valid
certificate while the portal called it live. Safe in both directions; the
reasoning is written above the pipeline.

A stale guest_ip could never be corrected. ConfigureDnsAndTls compared the
hostnames but never the backend, and nothing re-read the address after the
build. It now records and compares `routed_backend`, and ConfigureNetwork
joins the `restart` pipeline — a cold boot is what moves a DHCP lease — and
asks for the address to be re-applied when the guest has actually moved.

Also: HetznerDnsClient::upsertRecord read only the first page of a zone
that pages at 100, so past a hundred records it created a second A record
for the same name and the cloud was up about half the time; the console's
retry left a live customer's order in `provisioning` for ever for any
maintenance pipeline; and a revived run's first pass burned an attempt on a
timeout that had already happened, because the clause written for it used
`??=`.

Seven test sites wrote config('provisioning.plans.*'), a key that has not
existed since the catalogue moved into the database. Every write was a
no-op, so each test proved something other than its name: the missing
template, the two snapshot regressions and the grandfathered-price scenario
are now actually constructed, ValidateOrder's reasons are asserted rather
than only its type, and the end-to-end run checks that the quota step
delivered something.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 01:34:55 +02:00
nexxo 8ca0d4d257 Say the same thing to the customer, the register and the bank
tests / pest (push) Failing after 8m5s Details
tests / assets (push) Successful in 25s Details
tests / release (push) Has been skipped Details
Five places where the product told somebody a figure that nothing behind
it produced.

**The invoice page showed five invoices nobody was ever issued.** A mock
that outlived its purpose: CP-2026-0003 to CP-2026-0007, a prefix
belonging to no configured series, all "paid", a spend curve from 179 to
198 € and a next charge of 198 € on 01.08.2026 — behind
['auth','verified','customer.active'], so every signed-in customer saw
it. Their real invoices existed in `invoices`, were rendered as PDFs and
mailed to them, and appeared nowhere in the portal at all. The page now
lists their own documents, newest first, with the numbers, dates and
totals off the frozen snapshot, a Storno marked as one, and a download.
The PDF route resolves the document against the signed-in customer in
the query — a URL anybody can type is not made private by not printing
it. No next charge and no chart: nothing here computes what Stripe will
take next cycle, and a figure nobody has worked out is worse than a
blank space.

**The setup fee was quoted everywhere and charged nowhere.** On the
price sheet, on the booking page, set by the operator in the console —
and CompanyProfile::setupFeeCents() had no reader outside the two
sentences that quoted it. It is charged now, as a second non-recurring
line on the same checkout session, which Stripe puts on the initial
invoice only. `orders.setup_fee_cents` records how much of the charge it
was, so the whole total still goes back on a withdrawal while the
register holds the PACKAGE's charge against the contract price and the
invoice prints the fee as a line of its own. The booking page was also
quoting the fee NET under the same sentence the website quotes GROSS —
99,00 against 118,80 — and now quotes what will be taken.

**An upgrade click cancelled a booked downgrade and delivered nothing.**
Nothing in this application marks a cart order paid, so the customer
traded a downgrade they had genuinely booked for a cart line that would
never be fulfilled. The booking now stands until the upgrade actually
lands on the contract, where ApplyPlanChange clears it. The fulfilment
seam is reachable for every type that can be paid — a module and a
storage pack book through BookAddon, which was the missing path from a
paid order to a SubscriptionAddon and left AddonPrices,
SyncStripeAddonItems and clupilot:end-cancelled-addons with no caller
behind a payment. A paid traffic pack logs an error rather than pretending:
the metered allowance is a standing count of packs while a top-up is sold
for one month, and which of the two is meant is not a guess to make here.
That no path to `paid` exists yet is now stated in both places rather
than in one.

**The proof register called correct charges wrong.** expected_gross_cents
used the CUSTOMER's rate, which is zero under reverse charge, against
the domestic gross Stripe actually took — so matches_catalogue was false
for every EU business sale charged exactly the advertised amount, and
for every renewal on a contract with a module, which was held against
`price_cents` alone. It compares against TaxTreatment::chargedCents()
and against the whole term now. Where no money moved, charged and the
flag are null instead of the expected figure agreeing with itself, and a
plan change records the terms with the pro-rata figures in the snapshot
rather than money that arrives again on Stripe's proration invoice.

**The seller's own VAT id was on the invoice, unlabelled**, between the
register number and the court. Labelled — and the footer's composition
moved out of the TCPDF method, because inside one the only way to read
what it says is to render a PDF and un-subset its fonts.

One test enshrined a figure the code does not produce: a reverse-charge
purchase recorded with no charged amount, asserting 17900/0/17900, where
OpenSubscription always passes the charged figure and a real one records
21480/0/21480. Another asserted that an upgrade click cancels a booked
downgrade.

Full suite: 1676 passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 01:30:24 +02:00
nexxo 336ca9d88c Make host onboarding survive the machine it runs on
tests / pest (push) Failing after 7m52s Details
tests / assets (push) Successful in 20s Details
tests / release (push) Has been skipped Details
Nine defects in the host pipeline, every one of which either stopped an
install or let a host reach `active` while it could not do its job.

The install-stoppers:

- InstallProxmoxVe hard-coded `bookworm`. A server ordered today boots
  Debian 13 trixie, so it added the PVE 8 repo and keyring to a trixie
  base. The codename now comes from /etc/os-release, the suite and keyring
  are looked up per release, and an unknown one fails naming what it found
  instead of guessing. Keys land in /usr/share/keyrings with Signed-By
  rather than in the deprecated trusted.gpg.d, where they would vouch for
  every other repository on the machine too.

- keyLogin() dialled wg_ip whenever it was filled, but wg_ip is persisted
  inside the allocation lock BEFORE the handshake is ever proven. Attempt
  1 reserved the address and failed at the handshake; every later attempt,
  including a console Retry, died connecting to a tunnel that did not work
  — before reaching the only code that could repair wg0.conf. Recovery
  meant nulling hosts.wg_ip by hand. SSH now uses the tunnel only once the
  `wg_peer` breadcrumb proves a handshake succeeded, which also rescues
  the same shape in RebootIntoPveKernel, ConfigureProxmox,
  CreateAutomationToken and SecureHostFirewall. Not a hole: 22 on the
  public IP is open until SecureHostFirewall runs, that step runs last,
  and by then the tunnel is necessarily proven. And nothing validated the
  hub key/endpoint, so renderConfig() would emit `PublicKey = ` — now
  refused before anything is installed, allocated or written.

- `systemctl enable --now wg-quick@wg0 || wg-quick up wg0` brought the
  interface up without the enablement, so the tunnel did not come back
  after the step-6 reboot; on the next attempt both halves failed with
  "wg0 already exists" and the step retried forever on a working tunnel.
  The three states are handled separately now, and a changed wg0.conf is
  actually applied instead of only written.

- RebootIntoPveKernel removed the Debian kernel with `|| true`, ignored
  update-grub's exit status, and rebooted. A /boot that filled up during
  the full-upgrade could leave a machine only the provider's console can
  reach. The pve kernel image, its initramfs and update-grub's exit status
  are all proven first; anything missing fails loudly and does not reboot.

- ConfigureProxmox ran `ip link show vmbr0` and threw the result away
  (its comment claimed it recorded the absence; it recorded nothing), then
  repeated a rm -f, and always advanced. Proxmox on top of Debian does not
  create vmbr0, so onboarding reported `active` with no bridge. It now
  fails with the default route in the message and deliberately does not
  build the bridge over the primary NIC from a remote shell.

The things that were quietly inert:

- Proxmox applies a guest's firewall rules only while the firewall is
  enabled at datacenter level, and it ships disabled — so applyFirewall()'s
  "80/443 only" rules were inert on every customer VM. Enabling it blindly
  is the opposite trap (input policy defaults to DROP, node firewall
  defaults to on, and its management ipset is seeded from the PUBLIC
  subnet, not the tunnel), so: policy ACCEPT and the node firewall off
  first, master switch last, read back to confirm. nftables stays the one
  owner of the host's input policy.

- role_privs lacked Sys.Modify, which POST /cluster/backup requires
  (pve-manager's PVE/API2/Backup.pm), so RegisterBackup 403'd and failed
  EVERY customer run. And `pveum role add … || true` only ever applied the
  privilege list on the run that created the role — it converges now, above
  the token short-circuit so a replay reaches it.

- The nftables ruleset dropped all ICMP and ICMPv6. On a real host that
  breaks IPv6 outright once the neighbour cache expires and black-holes
  large transfers through PMTUD. Both families accept the types they need,
  policy drop stays, and a DHCP client reply is allowed so a rebind cannot
  lose the IPv4 address.

- api_token_ref used the `encrypted` cast, i.e. APP_KEY, against
  SecretVault's own written rule. One rotation would have made every host's
  Proxmox token undecryptable at once. Moved onto SecretCipher with a
  migration that leaves any value it cannot read exactly as it is and says
  so, rather than destroying a credential that exists nowhere else.

- Every plan version points at template_vmid 9000 and nothing created or
  verified it, so the first paid order died in CloneVirtualMachine after
  the customer had paid. VerifyVmTemplate reports that during onboarding
  instead. It does not build a template: what goes into the golden image is
  a product decision.

Three tests that proved nothing, fixed: `ran(…emergency-open-firewall.sh)`
only ever matched the `chmod 700` above it, because putFile() is not
recorded — the script's contents are now asserted, including that nothing
in it reopens the firewall on a timer or in the background. The port policy
was tested one-sidedly, so a mutation adding `tcp dport { 8006 } accept`
passed the suite; the ruleset is now read as a list of what it opens to an
unrestricted source. And the end-to-end test says in writing that it proves
sequencing only.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 00:54:16 +02:00
nexxo 8440266ed3 Stop charging for a service that has ended
Nothing in this application could cancel a Stripe subscription — there was
no such method on the client at all. So a customer who cancelled and a
consumer who withdrew both stayed subscribed, the card went on being
charged every month, and every one of those payments arrived as
invoice.paid, drew a real number out of the gapless Austrian series and
mailed the PDF, for a machine that had been switched off. Indefinitely.

StripeClient::cancelSubscription() closes the first half, with the timing
passed explicitly at the call site — CANCEL_AT_PERIOD_END for a
cancellation, because the customer keeps the term they paid for, and
CANCEL_IMMEDIATELY for a withdrawal, because that unwinds the contract and
the whole amount has just gone back. The immediate form states prorate and
invoice_now as false rather than inheriting them: a credit note of Stripe's
beside our own Storno would be the same money twice.

The second half is ApplyStripeBillingEvent::owesADocument(). Refusing every
invoice on a cancelled contract would lose the legitimate final one — the
cycle for a term the customer really did use, unpaid, dunned for three
weeks and settled after the contract ended. So the question is not when the
payment landed but what it is for: an invoice whose billed period STARTED
before the contract ended is documented, one whose period begins at or
after that moment is not. Neither is a contract marked cancelled with no
date, nor an invoice with no period, because a number handed out in error
can never be withdrawn while a missing document can still be issued. The
register entry is written either way — the money moved, and that is where an
operator finds what has to go back.

A cancellation also took its date from the order date plus MONTHS, whatever
the term said, so a yearly customer who cancelled in March lost the nine
months they had already paid for. It reads current_period_end now, which
Stripe keeps current for both terms; the month-walk survives only for a
machine with no contract behind it, where nothing records a term at all.
Stripe is asked first and our rows are written only if it agreed: a
cancellation we could not make effective is the defect above wearing a
"gekündigt" label on the settings page, and a cancellation has no deadline,
so being asked to try again costs nobody anything.

A withdrawal cancelled the OPENING invoice only, found by invoices.order_id
— which a renewal and a module document leave null on purpose. A storage
pack booked on day three and withdrawn from on day ten was therefore
neither cancelled nor refunded. Every charge inside the window is covered
now, one Storno per document and one refund per payment, each against the
payment that actually took it: a single refund of the total against the
opening PaymentIntent is what Stripe would have refused for exceeding it,
and one idempotency key for the whole withdrawal would have had Stripe
replay the first refund's answer for the second.

StartCustomerProvisioning::resume() opened a missing contract and returned,
and the only production call to IssueInvoice::forOrders() is after the order
commits — so a worker killed in between cost that customer their invoice for
good, silently, with nothing anywhere sweeping for it. resume() finishes that
work too, guarded by the invoice already filed against the order and by
invoices.sent_at, so however often Stripe redelivers it comes out as one
invoice, one number, one mail. The order confirmation stays on the first
pass alone: it has nothing to stamp.

subscriptions.cancel_requested_at is new because the billing half of the
application had no way to tell a cancelled contract from an untouched one.
Deliberately not a status: until the term runs out this is a paying
customer, which is the distinction EndInstanceService is built around, and
`cancelled_at` beside it still means the day it genuinely ended.

clupilot:verify-vat-ids is scheduled monthly on the first, and its docblock
no longer points at a note in routes/console.php that never existed. Reverse
charge rested on a one-off VIES answer, a withdrawn registration keeps
earning rate 0 for ever, and the unpaid VAT on those invoices is the
seller's — so the cadence is the width of that window, and the first of the
month is the rhythm the return is filed on.

Two tests that proved nothing are now about behaviour. SettingsTest asserted
service_ends_at was not null, a column the code had just written and which
the broken monthly arithmetic satisfied exactly as well as the right answer;
it states the yearly date, the contract's own record and the order to Stripe.
EndInstanceServiceTest travelled to whatever value the implementation had
computed, so it recomputed the code instead of checking it, and never looked
at billing at all; the boundary is a literal date now and the money stopping
is asserted beside the address coming down.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 00:52:19 +02:00
nexxo 518bc9aa41 Put the open tab in the address bar, including the first one
tests / pest (push) Failing after 11m2s Details
tests / assets (push) Successful in 20s Details
tests / release (push) Has been skipped Details
Reported as "die Tabs fehlen in der Query beim Kunden". The binding was
there and identical to the settings page — checked in the rendered
snapshot, which is where Livewire's JS reads it from — but `except` hid the
parameter while the DEFAULT tab was open. So arriving on a customer showed
no ?tab= at all, and a link to the page could not say which tab it meant
unless the tab happened not to be the first one. From the address bar that
is indistinguishable from the feature not existing.

No `except` now, on all three tabbed pages: the parameter is there from the
first render. And `history: true`, so each tab is a step the back button can
take rather than a silent replaceState — which also makes the change
unmistakable while clicking.

Asserted against the rendered page rather than the component, because the
parameter is written client-side from the `url` memo in the snapshot: the
memo IS the behaviour, and asserting the property would have passed
throughout the bug.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 23:46:13 +02:00
nexxo 0a89695189 R24: a modal is never taller than the screen
tests / pest (push) Failing after 11m6s Details
tests / assets (push) Successful in 20s Details
tests / release (push) Has been skipped Details
A modal grew with its content, so a form ran off the bottom of the window
and took its own save button with it — reachable only by scrolling the page
BEHIND the backdrop, which on a phone means not reachable at all. The
customer form with its eight fields is what made it obvious, but it was
true of every long modal in the console.

Two halves. The panel is capped once, in the published package view, so no
modal can opt out of it and none has to remember to: max-h in dvh rather
than vh, because on a phone the browser's own chrome counts towards vh and
that is exactly the case where the last centimetre decides whether the
button can be reached. And x-ui.modal is the other half — a header slot, a
scrolling body, a footer slot. min-h-0 on the body is the line that makes it
work: a flex child will not shrink below its content without it, and the
overflow never engages.

Fourteen modals converted: the ones that carry a field, which are the ones
that can grow. A two-line confirmation with one button has nothing that
needs to stay put, and the rule says so rather than leaving a footnote —
the test's criterion is the field, so the moment somebody adds an input to a
confirmation it fails and tells them.

A submit button in a footer sits outside the form it submits, so it carries
form="…". That is the price of a footer that does not scroll away, and an
HTML attribute rather than a workaround; there is a test for it, because
without it the button silently does nothing.

Two traps met on the way, both now scanned for. A Blade directive in a
component tag's attribute list (a conditional wire:poll on x-ui.modal)
compiles into the attribute bag and breaks the view outright — the same
mistake as @disabled on x-ui.button, which cost a debugging round earlier
this session; such attributes go on an element of their own. And Pest's
toContain() is variadic, so the second argument I passed as a failure
message was read as another needle: the assertion demanded that a footer
contain its own filename, and failed on a file that was correct.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 23:33:57 +02:00
nexxo f00cbf18d3 Let an operator correct a customer's details
tests / pest (push) Failing after 7m59s Details
tests / assets (push) Successful in 23s Details
tests / release (push) Has been skipped Details
The console could show them and not touch them, so a moved office or a new
contact person meant impersonating the customer or editing the database by
hand. Both are worse than a form. R20: a modal, opened from the Stammdaten
tab, authorising again and reading the record itself rather than trusting
anything the browser hydrated.

Most of the work is in what saving must NOT quietly do.

The customer type is never reset to "nobody asked". The field opens on
"leave unchanged" precisely so that saving an address correction cannot
undo it — that answer decides whether a withdrawal right exists, and losing
it silently would be the expensive kind of quiet.

A changed VAT number is unverified from the moment it is stored — the
verification is bound to the value, so that happens by itself — but
`vat_id_verified_value` is left alone: it is the record of what WAS checked,
and clearing it would destroy the evidence rather than the claim.

The address is also the sign-in address and the key inbound mail is matched
by, so changing it moves the linked portal login in the same transaction and
puts it back to unverified, which is what Laravel's own profile update does
for the same reason: nobody has shown they can read the new address. No mail
is sent from here — the portal asks for the confirmation at the next
sign-in, and a mail an operator did not knowingly send is a surprise.

Three addresses are refused: one belonging to an operator (R21 — no index
spans the two tables, so it is checked), one belonging to another customer,
and one belonging to somebody else's user row, which can outlive or predate
a matching customer.

Not offered here, each for its own reason: `status`, because suspending has
consequences for access and a lifecycle switch hidden among address fields
is one somebody flips by accident; `stripe_customer_id`, because a wrong one
does not move a contract, it points our records at somebody else's payments;
the brand fields, because the customer sets those for themselves and an
operator overwriting a logo is not a correction. There is a test that fails
if any of them appears — with the comments stripped first, since the class
documents at length why they are absent and that documentation is not the
offence. This repository has fallen into that trap three times.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 23:18:46 +02:00
nexxo 57c6912987 Say that a click landed, and make moving one actually move it
tests / pest (push) Failing after 7m52s Details
tests / assets (push) Successful in 20s Details
tests / release (push) Has been skipped Details
"Man klickt und sieht nicht was passiert." Every action in this console is
a round trip, and a round trip with no sign of itself reads as a click that
missed — so the answer is in three places, from the general to the
specific.

A thin bar at the top of the window for any Livewire request, in app.js.
Delayed by 140 ms, because a request that answers faster than that is
perceived as immediate and a bar flashing on every keystroke of a .live
field is worse than none. It creeps to 70 % and only completes on the
answer: a bar that reaches the end while the answer is still on its way is
a lie about the thing it exists to report. Released on failure as well —
the request somebody must not be left waiting on is precisely the one that
went wrong — and on wire:navigate, which is not a request hook at all and
would otherwise leave the bar behind on a page that is gone.

Per button: a pressed state on the way down, disabled while its own request
is out, scoped with wire:target to that button's own call so a row does not
grey itself out because a neighbour is moving.

The edit button carries its own spinner, because opening the modal is a
request made by the MODAL component — wire:loading on this page never sees
it. It clears when the package announces the modal is up, and on a six
second timer as well, so a request that never answers cannot leave a row
spinning for ever.

And the reordering was not only slow to look at, it was wrong. It added or
subtracted a fixed amount: with the seeded values ten apart that usually
landed right, and sometimes landed on a value another template already had
— or on the floor along with three others. Equal sort values fall back to
the name, so a click could reorder something else, or nothing, which is
indistinguishable from a click that never registered. It swaps with the
neighbour now: exactly one place, or nothing at the ends, both of which are
what an operator expects to see. Where two shared a value the swap leaves
them distinct, or the next click would do nothing again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 23:06:30 +02:00
nexxo 173f05c1bd Fix a cancel button that did nothing, and fit the answers on one screen
tests / pest (push) Failing after 10m22s Details
tests / assets (push) Successful in 20s Details
tests / release (push) Has been skipped Details
The cancel button on the template modal was written with Alpine's
$dispatch, which fires a browser DOM event — and wire-elements/modal
listens for a LIVEWIRE event of that name. So it did nothing at all, while
saving worked, which is the combination that makes somebody click twice and
then close the tab. Every other modal in the repo already used
Livewire.dispatch; this one was the odd one out, and nothing would have
caught it, so there is now a scan that fails on the Alpine form. Narrowed
to Alpine handlers deliberately: wire:click="$dispatch('closeModal')" IS
Livewire's own dispatcher and works.

The list was one column of thirteen entries, each carrying a name, a
subject, two clamped lines of body and two full-width buttons — four
screens to find the one you want, with the body preview taking most of the
height while being the least useful part of it. The whole text is one click
away in the modal.

Now: two columns of one-line entries — name and subject, which is what
tells two similar answers apart — with the actions as icons that do not
compete with the name. The create form is folded away behind the button
that creates one, because a form that is always open is a screen of form
between the operator and the thing they came for. And the placeholder list
runs as a grid across the page instead of a tall sidebar that squeezed the
entries into half the window.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 22:50:16 +02:00
nexxo 7fd45b01fb Ship the thirteen answers this business actually sends
tests / pest (push) Failing after 8m30s Details
tests / assets (push) Successful in 19s Details
tests / release (push) Has been skipped Details
An empty template list is a feature nobody starts using: writing the first
one costs the same as writing the mail, so the operator writes the mail.
These are the questions that come up in a managed-Nextcloud business —
enquiry received, migration question, migration quote, cloud being
prepared, credentials asked for again, storage filling up, package change,
incident update, incident resolved, maintenance announced, payment
reminder, cancellation confirmed, request closed.

Every figure and every date is a gap, never a number. A seeded "innerhalb
von 24 Stunden" is a promise put in the operator's mouth that nobody
agreed to, and a seeded price is wrong for every customer but one. Nothing
here says a migration is possible before somebody has looked at the source
system either — that was the whole point of the wording on the website.
There is a test that fails if a shipped template ever grows a duration or
an amount.

German only, deliberately: these are texts written TO customers, not
interface labels, and the audience is this company's customers. An
operator serving another market writes their own; the mechanism and the
page are already translated.

firstOrCreate on the name, so re-running changes nothing and an operator
who has edited or retired one keeps their version — a migration must never
overwrite text somebody worked on. The rollback deletes only rows still
identical to what was seeded.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 22:44:31 +02:00
nexxo 623acb3702 Release 1.3.37
tests / pest (push) Failing after 8m4s Details
tests / assets (push) Successful in 25s Details
tests / release (push) Has been skipped Details
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 22:43:00 +02:00
nexxo 2277dfe4cb Ask the EU register whether a VAT number is real
tests / pest (push) Failing after 8m24s Details
tests / assets (push) Successful in 26s Details
tests / release (push) Has been skipped Details
vat_id_verified_at was declared on the customer and read by TaxTreatment, and
set by no code anywhere. hasVerifiedVatId() therefore never returned true, so
reverse charge could not trigger for anybody - and every EU business customer was
invoiced Austrian VAT they cannot reclaim in their own country, which is not a
pass-through for them but a real cost until the invoice is corrected.

The check has THREE outcomes, and the third is why VatIdCheck exists. VIES is a
gateway onto twenty-seven national registers and any of them is regularly down.
Reading "I could not ask" as "not registered" would clear a verification that has
stood for months, move that customer onto the domestic rate, and change what
their next invoice says - because a register was asleep. Reading it as
"registered" would put reverse charge on an unchecked number, which is our
liability. So on UNAVAILABLE nothing at all is written.

Asked at the moment the number is entered, because that is when somebody is
looking at the field and can fix a typo. The save has already happened by then
and the check cannot undo it: a register that is down must not cost a customer
their address change.

Shape and membership are refused before a request is spent, and the fake refuses
on the same rule as the real verifier. A fake that answers where VIES would never
have been asked is not a simplification but a different product, and free text
normalises into a well-formed number more easily than it looks - "not a number"
becomes NOTANUMBER, which the shape rule alone reads as a Norwegian one.

clupilot:verify-vat-ids re-asks about the numbers reverse charge rests on, since
a registration can be withdrawn. Deliberately not scheduled: it queries a public
service with a concurrency limit on behalf of somebody else's tax position, and
the cadence is the owner's to set.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 22:42:29 +02:00
nexxo f02e86769b Charge the price the website shows, and hand a withdrawal back in full
tests / pest (push) Failing after 9m21s Details
tests / assets (push) Successful in 21s Details
tests / release (push) Has been skipped Details
Stripe was charging the catalogue's NET figure while the document added the
domestic rate on top: a customer quoted 214,80 paid 179,00 and was then invoiced
for VAT nobody had collected. Under para 11(12) UStG that VAT is owed to the tax
office whether or not it ever arrived, so every document issued created a
liability against revenue that did not contain it.

The Stripe Price now carries the GROSS figure. price_cents stays net - it is
frozen onto every contract and PlanChange prorates against it, so redefining it
would corrupt every pro-rata sum ever computed. Only the amount at the till
moved, and it is formed by the one call TaxTreatment already answers for the
price sheet and for the invoice, so displayed, charged and invoiced cannot drift
apart.

Stripe's automatic_tax is deliberately not used. TaxTreatment is the single tax
authority here, and a second rate computed by Stripe would take 19 % from a
German consumer while our document said 20 %.

A Stripe Price cannot be edited, so a changed figure means a new Price and the
old one archived - which stops it being SOLD and leaves every subscription
already on it billing the old amount for ever. stripe:reprice-subscriptions is
the second half, and a command of its own because it touches live money.

A withdrawing consumer is refunded in full, as the owner decided. That is more
generous than FAGG para 16 requires, and it makes the express-request consent the
statute hinges on irrelevant - so the gate is gone rather than left looking
load-bearing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 22:42:02 +02:00
nexxo 33566cd404 Show the customer, and write the answers once
tests / pest (push) Failing after 9m49s Details
tests / assets (push) Successful in 21s Details
tests / release (push) Has been skipped Details
Two complaints, one cause: the customer page was a compose box with three
lists under it. It showed nothing ABOUT the customer, and every answer was
typed from scratch.

The page is five tabs now. Stammdaten — contact, phone, since when,
language, and the tax standing: consumer or business, with "nobody asked"
saying so rather than passing for business, because that answer decides
whether a withdrawal right exists at all. Paket — the contract's own
FROZEN figures, the modules at the price they were booked at, and the
machine: address, state, host, datacenter. Zahlungen — invoices with net,
gross and their PDF, and the orders beside them, because an order is a
purchase and an invoice is a document about one. Nachrichten — the portal
requests in the customer's own words, the mail they sent, the mail we
sent. Schreiben — last, because it is what you do after reading.

The tab is in the query string, like Settings and Integrations: a reload,
a bookmark and the back button all land where the operator was, and a link
can point at the tab that matters.

And templates, under Betrieb → E-Mail-Vorlagen. Most customer questions
are the same five questions, and typing the answer again every time is how
two customers get told two different things about one subject. A template
is INSERTED into the compose field with the customer's own details filled
in — never sent as it stands, because the last two sentences always belong
to the person asking. What goes out is what the operator saw and edited.

Three decisions worth naming. {{contact}} takes the contact person where
one is on record and the company name otherwise, because "Guten Tag
Beispiel GmbH" is the line that gives away a mail written by a database.
{{amount}} is the gross figure — what the price sheet quoted them and what
their bank statement says. And a typo comes out as {{kunde}}, standing
there in the field where the operator sees it, rather than as an empty
string that produces "Guten Tag ,". A placeholder with no value for this
customer stays empty; nothing is invented.

Placeholders are named in English because they are identifiers, not prose:
a template has to survive somebody switching the console's language. The
list the page documents is the renderer's own, so a token added in one
place appears in the other without being written down twice.

Blade's echo tag ends at the first }} it finds, which turned a literal
placeholder in the documentation into compiled nonsense. Assembled in PHP
now, and caught by the test that renders the page rather than by somebody
opening it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 22:37:30 +02:00
nexxo e376b571be Read the support mailbox into the console
tests / pest (push) Failing after 7m59s Details
tests / assets (push) Successful in 20s Details
tests / release (push) Has been skipped Details
No, not an API token: mailcow's API administers domains and mailboxes —
it does not read mail. Reading a mailbox is IMAP whoever runs the server,
so that is what this is. Host, user and folder are console settings; the
password is a vault entry, like every other credential that opens
something.

IMAP by hand rather than a dependency. Four verbs are needed — LOGIN,
SELECT, SEARCH UNSEEN, FETCH — and the parsing that follows is the real
work either way. It is bounded by what the console shows: a sender, a
subject, the words a person wrote, and the NAMES of anything attached.
Real mail from real people arrives encoded, so it handles RFC 2047
subjects (every German subject with an umlaut in it), quoted-printable
and base64 bodies, Windows charsets, and multipart — text wins over HTML,
because a console is not a mail client and the words are in the text
part. A message it cannot make sense of is skipped and logged, never
guessed at: a garbled question in front of an operator is worse than one
they go and read in the mailbox, where it still is.

ATTACHMENTS ARE NOT STORED. Only their names and sizes, so an operator
knows something was attached and can go and look if it matters. Keeping
whatever a stranger chooses to send would make this application a malware
store with a web interface in front of it.

Whose a mail is, is decided by the sender's ADDRESS and by nothing else —
not by a name in the subject, not by anything in the body. A mail is easy
to write and this decision attaches a stranger's words to a real
customer's file; there is a test that forges the name and the subject and
still gets nothing. An address nobody recognises stays unassigned, shown
FIRST and never hidden: that is a new enquiry, or a customer writing from
their private account, and it is the mail that must not be missed. An
operator can place it by hand from the same row.

Nothing is deleted on the mail server, ever. Messages are flagged seen
once the row is safely written — and only then, because flagging first
and failing after would lose the message with no second copy anywhere.
Filing one away is this console's own state; the mailbox is untouched.

Two fixes on the way past: the customer page printed
"customers.status.active" and "support.status.open" — a lang key with no
file behind it renders as itself and Laravel says nothing — and the same
page is now covered by a test proving each list is scoped to the customer
in the URL, which was reported as "unterhalb steht immer das selbe".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 21:41:29 +02:00
nexxo e0c109c449 Ask for the express request to begin, and send it to Stripe
tests / pest (push) Failing after 7m59s Details
tests / assets (push) Successful in 18s Details
tests / release (push) Has been skipped Details
The withdrawal machinery has always read `immediate_start` off the
checkout session's metadata — StripeWebhookController stamps
`immediate_start_consent_at` from it, WithdrawalRight refunds pro rata
when it is there and in full when it is not. The checkout never put it
there. So every contract concluded through this application carried no
consent, and a withdrawal on day thirteen of a running cloud took back
the whole amount however many days the machine had run. That is the
lawful outcome without the consent, and it was the only outcome
available.

Now the checkout asks. The box is required — validated `accepted`, not
`present`, because an empty field is what an unticked checkbox sends and
a consent that can be produced by leaving something blank is not a
consent — and the metadata carries exactly the string '1' the webhook
compares against.

One box for the page rather than one per card: the sentence is the
statutory wording and four copies of it is a wall nobody reads. The forms
carry it as a hidden field bound to that box, and the buy buttons stay
disabled until it is ticked. With JavaScript off the field arrives empty
and the server refuses with the sentence explaining why — the safe
direction to fail in. Ticking a box in a browser was never the record
anyway: the record is the timestamp taken when the payment lands.

Tested at both seams and along the whole chain: refused without it,
refused for '', '0' and a word that is not yes, carried to Stripe as '1',
and a webhook carrying it lands as a timestamp on the order while one
without it lands as null.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 21:19:03 +02:00
nexxo 6d7c2bdf35 Ask whether they are a consumer, and let one change their mind
tests / pest (push) Failing after 8m35s Details
tests / assets (push) Successful in 22s Details
tests / release (push) Has been skipped Details
Three things the product owed its customers and did not have.

**Who is on the other side.** There was no consumer/business flag anywhere,
and `vat_id` was standing in for one — which it cannot: a business without a
VAT number is an ordinary small business, and a consumer with one does not
exist. It is asked at sign-up now, correctable in the portal, and NULL where
nobody has been asked. Unknown is read as CONSUMER everywhere it decides a
right, because that mistake costs us a refund while the other one takes a
statutory right away from somebody who has it.

Reverse charge asks the recorded type instead of the number: an explicit
consumer is charged the domestic rate whatever `vat_id` says — previously they
were not, and anyone could zero their own VAT by getting a number verified. An
unrecorded type still falls back to the verified number, so no contract that is
already running changes rate.

**The fourteen-day right of withdrawal** (FAGG, §312g BGB), for consumers only,
at every door: the window is stamped on the contract when it is concluded, the
customer exercises it from the portal, an operator records one that arrived by
telephone or post, and both go through one action that refuses a business
customer on the server rather than by hiding a card.

The money follows the paperwork rather than being computed beside it. The
invoice is cancelled by a Storno with its own gapless number — nothing is ever
edited or deleted — a new invoice states the pro-rata value of the service
actually delivered (FAGG §16, by days over the term paid for), and the refund
is exactly the difference between the two documents. Where the consumer never
expressly asked for the service to begin at once, they owe nothing and the whole
amount goes back. `StripeClient::refund()` is new, keyed so a retry cannot send
the money twice. The service ends through EndInstanceService and the
`cancellation_scheduled` machinery that was already there.

**The cancel button for modules.** BookAddon::cancelAtPeriodEnd() had no caller
in the interface at all, so a customer could book a recurring charge in two
clicks and had no way to stop it. It is on the module card now, with the date it
runs until and a way back while the cancellation is still pending — and putting
a module back settles nothing, because the term it was cancelled for was paid
for in advance.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 21:06:06 +02:00
nexxo 0e25fe88d4 Keep a register of what was sent, and answer the customer from here
Three complaints, one shape: the console knew things and could not act on
them, so the work happened somewhere else and left no trace.

The header said "4 Hinweis(e)" and led nowhere. Whoever read it had to
know that the list was a card further down the same page, and then go
looking for the host or the failed run by hand. The count is a link now,
every notice carries the page that shows the thing it is about, and the
plural is a word rather than a bracket.

Nothing recorded what this installation had sent. "Hat der Kunde die
Zugangsdaten je bekommen, und wann?" was answered on the mail server — a
different machine, a different program, and nothing an operator can put
in front of somebody who says nothing ever arrived. Every mail now writes
a row: when, to whom, which mailable, and the customer it belongs to.
Written from MessageSent, so a row means the transport accepted it; that
is the strongest thing an application can honestly claim, and a column
called "delivered" would be pretending otherwise. Verification and reset
mails are left out on purpose — they go to unconfirmed addresses and
anybody who can type into a form can send them, so recording them would
hand the register to whoever wants to fill it.

And the customer list was the end of the road. There is a page behind it
now: what they bought, what they asked in their own words, what we sent
them, and a box to write the next message. It goes out from the support
mailbox and is recorded on the way — with its body, because an operator
typed it and "what exactly did I write to them in March" is a question
the mail server's log cannot answer either. Answering a request from here
closes it, which is the reason to answer from here at all: answering from
a mail client leaves it open for ever, because nothing tells it otherwise.

Found on the way, by a test that sent a real mail rather than asserting it
was queued: resources/views/mail/reset-password.blade.php was missing its
closing </x-mail.layout>. The component's contents were never terminated,
the layout's own @if ran off the end of the file, and the mail raised a
Blade syntax error instead of rendering. Password reset has therefore
never delivered a link since it was built. Closed, plus a test that
renders it and one that counts opening against closing tags across every
mail view.

Reading INCOMING mail in the console is not in this: it needs the mailbox
polled over IMAP, which is a credential and a dependency. Requests filed
through the portal appear here in full; a customer who writes by mail
instead still has to be read in the mail client.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 21:02:36 +02:00
nexxo 559a466630 Show that the update is running, and stop showing the rsync log
tests / pest (push) Failing after 12m32s Details
tests / assets (push) Successful in 21s Details
tests / release (push) Has been skipped Details
The maintenance screen answered a question nobody was asking. Under the
headline sat a card with the deployment's raw output in it — rsync
listing every font file it had copied — and, before a step was known, a
box drawn around the words "Wird jetzt gestartet." A person waiting on
this page wants one thing settled: is anything happening, or am I looking
at a dead page.

So: an indeterminate bar, in CSS. Indeterminate because a deployment has
no honest percentage and a bar that claims one always stalls at 90 %; in
CSS because this page is served while the application is down, so it may
not depend on JavaScript having loaded or on the server answering another
request. Motion off gets a steady bar rather than none — the point is to
show the page is alive, and a still bar says that better than a gap.

Shown to everybody, not only in the console: a customer on the 503 is
asking exactly the same question.

What is left of the operator detail is the current step, as one quiet
line, because a run that stops on a step is worth seeing without a shell.
The elapsed time and the log are gone from the overlay entirely — with
them goes a file read on EVERY console page load, running or not. The
settings page still shows the log, where somebody is actually looking for
it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 20:33:02 +02:00
nexxo 8f1630ba91 Bill a booked module every month, and put it on the invoice
A module was never an item on the Stripe subscription, so it was charged in
the month it was booked and never again. And the renewal document was written
from our own contract snapshot, so it would not have said so even if the money
had been taken. Two halves of one fault, closed together.

Modules are items now. stripe:sync-catalogue mirrors a Product and a Price per
module on both terms — a Stripe Price carries its own interval, and a booking
frozen at an older figure needs its own Price, so they are keyed by module,
amount, currency and interval in stripe_addon_prices. Booking adds an item with
always_invoice: prorated by days AND charged there and then, which is the
invoice the owner wants for a module booked mid-term. A second storage pack is
a quantity on one item, not a second item. Cancelling takes the item off with
no proration — no credit, and the next cycle is simply smaller — while the
module itself is kept until the period end on subscription_addons.cancels_at
and ended by clupilot:end-cancelled-addons.

Documents are built from Stripe's own invoice lines. Stripe is what charged the
customer; a document assembled from our figures would state a sum that is not
the one taken. So every paid Stripe invoice gets one document with one number,
idempotent on stripe_invoice_id — a cycle carrying package and every module
together, a mid-period booking carrying just its prorated line, an upgrade
carrying its proration. Only the wording is ours: each line is named from the
catalogue rather than printed as a Price id, and a line nobody can name stays on
the document under Stripe's own description and is logged. The checkout's own
invoice is still the one exception; that purchase already has a document.

Stripe being away never undoes a booking that has already reached the machine.
The failure is parked on the contract in stripe_addon_sync and swept by the
same clupilot:sync-stripe-subscriptions that retries a plan change, which finds
its work from the bookings themselves rather than from the marker. A granted
contract has no Stripe subscription and is not touched at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 20:22:02 +02:00
nexxo 6b8412f0c3 Write an invoice for work that came off no price list
tests / pest (push) Failing after 8m11s Details
tests / assets (push) Successful in 20s Details
tests / release (push) Has been skipped Details
Not everything we are paid for is a package. Somebody asks whether their
data can be moved into Nextcloud; we look at it, we say what it costs, we
do it. That is a real invoice with no order and no contract behind it,
and the only way to produce one was to write it somewhere else — which
puts it outside the series. A numbered series with a document missing
from it is worth nothing at an audit.

So it goes through the same door as every other invoice:
IssueInvoice::forService() draws from the same series and therefore the
same consecutive number, applies the same TaxTreatment (a verified
business in another member state gets its reverse charge and the note
that makes a zero-rated document lawful), freezes the same immutable
snapshot and dispatches the same archive jobs.

The page under Rechnungen writes what the operator typed and nothing
else. Amounts are in euro because it is a form — 12,95 becomes 1295 by
round(), not by a cast that would make it 1294 — and quantities go to a
thousandth because hours are billed in quarters. The total beside the
lines is the same InvoiceMath the document uses; a preview doing its own
arithmetic would be a second answer, and the first one anybody notices
differs is on a document that cannot be changed. There is no draft: a
"save as draft" that draws a number is an issued invoice with a friendlier
name.

The website's buy buttons go to the sign-in now, not to /order. Booking
belongs in the panel, where the account exists and its address has been
confirmed — which matters because that address is where the finished
cloud's credentials are sent. Sending an anonymous visitor at a page
behind auth worked, but only via a middleware bounce off a page they
never asked for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 20:12:26 +02:00
nexxo f85f57152e Let the customer buy it themselves, and tab the integrations page
tests / pest (push) Failing after 21m20s Details
tests / assets (push) Successful in 20s Details
tests / release (push) Has been skipped Details
Every path on the site ended in "anfragen": a mailto: link and a promise
to answer the same working day. That is an afternoon of somebody's time
per customer, for a product whose whole point is that the machine does
the work — and it scales exactly as far as one inbox does.

Now: sign in, pick a package, pay, and provisioning starts by itself.
The pieces were already there — Stripe holds a Price per plan version
(the catalogue sync writes the ids), the webhook opens the contract, the
pipeline builds the VM — and what was missing was the checkout session
between them. CheckoutController opens one and gets out of the way; the
purchase becomes real on the webhook, which stays the only place allowed
to believe a payment happened. The success URL is a redirect target and
therefore a URL anybody can type, so it says "we are building it", not
"you have paid".

Nothing in the request may decide the price. The plan key arrives from a
form and everything else is re-derived from the catalogue, including the
plan VERSION, so a version published while the customer types their card
number cannot change what they were quoted.

Two cases that are not errors: no capacity is a parked order (already
built), and an existing customer is sent to the plan change rather than
sold a second, empty cloud that bills twice.

The site says what it can keep. "danach wissen Sie … wie Ihre Daten
sicher umziehen" was a promise made before seeing the thing it is about:
we do not know where a visitor's data is, what shape it is in, or whether
it can be moved at all. Migration is named as its own question, looked at
first and quoted afterwards.

The integrations page gets the settings page's treatment for the same
reason: it was one narrow column of six cards, so an operator connecting
DNS scrolled past Stripe to reach it and the raw .env editor sat at the
bottom of everything. Three tabs by what a section is FOR — services we
buy from outside, our own machines, the file underneath — the same axis
the page was rebuilt around when it stopped being split by storage
mechanism. The tab is in the query string; unlocking survives a switch,
because a confirmed password is a session fact and not a property of a
tab. The .env tab is Owner-only, like the editor it holds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 19:42:28 +02:00
nexxo a5ee7bdb67 Release 1.3.29
tests / pest (push) Failing after 7m57s Details
tests / assets (push) Successful in 21s Details
tests / release (push) Has been skipped Details
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 19:37:19 +02:00
nexxo 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>
2026-07-29 19:35:15 +02:00
nexxo 7265799881 Quote what a person pays, and tab the settings page
tests / pest (push) Failing after 7m50s Details
tests / assets (push) Successful in 21s Details
tests / release (push) Has been skipped Details
Prices. The sheet quoted net, which is the wrong number for one of its
two readers: a private customer has no way to add 20 % in their head and
no business being surprised by it at checkout. The gross figure is now
the big one, with the net underneath for a company's books, and the same
rule applies to the module prices further down — a sheet that quotes the
package with VAT and the module without it is a sheet whose numbers
cannot be added up. The rate is the one on the Finance page, the same one
an invoice uses; a percentage written into the template would be the
second source that makes the two disagree.

"zzgl. einmaliger Einrichtung" had run for months without ever naming a
figure, which leaves a visitor knowing only that there is one. It is a
field on the Finance page now and prints with the price. Zero removes the
sentence — and the comparison card that listed the fee as the honest
downside of the offer — rather than admitting to a cost that is not
charged.

Layout. Four packages in 1120px left each card 202px of text, which is
what broke "Bereitstellung in 2-3 Werktagen" over two lines and made the
cards look pressed against each other. The pricing section is wider than
the rest of the page (only it: the reading columns elsewhere are narrow
on purpose, a price sheet is four columns that must be comparable at a
glance), the gap between cards is larger, and the delivery line is short
enough to fit with whitespace-nowrap to keep it that way.

Navigation. VPN moves from Betrieb to System: the tunnel is not a task of
the day, it is how the console reaches the estate at all.

Settings. The page had grown to three headed sections in one column with
the operator's own password somewhere in the middle. It is tabbed now,
and two-factor enrolment is one of the tabs instead of a menu entry of
its own — it is a setting, and it was the one thing an operator had to
leave this page to change about their own access. Same component, no
second copy of the enrolment logic; it still answers at its own route
because RequireOperatorTwoFactor sends an operator there who may not open
anything else yet, this page included.

The open tab lives in the query string, so a reload, a bookmark and the
back button all land where the operator was. That matters here more than
elsewhere: the installation tab refreshes itself while an update runs,
and a tab kept only in the component would drop them back to the first
one mid-deployment.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 19:18:10 +02:00
nexxo 5b63fdb86c Deliver the storage a customer actually buys
A booked storage pack was a row in `subscription_addons` and nothing else:
priced, frozen, charged every month, and delivered nowhere. Nextcloud's
quota came from `instances.quota_gb`, which is the package alone; the disk
was sized for the package alone; and on the one occasion a disk did grow —
a plan upgrade — the guest never saw it, because nothing stretched the
partition or the filesystem over the new space.

One authority for the allowance. StorageAllowance adds the package and the
booked packs, and everything that needs the figure asks it: the quota step,
the downgrade check, the portal, the console, the repair command. It is
DERIVED rather than stored — a column would have to be rewritten by booking,
cancelling, granting, a grant expiring and a plan change, and the day one of
those paths forgot it would be silently wrong in whichever direction costs
somebody money. `quota_applied_gb` keeps its own meaning: what the guest was
last actually told, which is how a machine with the allowance enforced is
told from one where the figure has only ever been a row in our database.

Buying it delivers it. BookAddon asks ApplyStorageAllowance for a run on
booking AND on cancellation; the new `storage` pipeline grows the virtual
disk to the allowance plus the package's own overhead (read off the package
— 20/40/50/100 GB on the four catalogue packages — never a ratio invented
here), then GrowGuestFilesystem makes the guest see it, then the quota is
applied. No cold boot is involved: the data disk is scsi0 and Proxmox's
resize on a running guest is a qemu block_resize, so the capacity reaches
the guest while it runs. The new step rescans, growparts and grows the
filesystem with the tool its type actually needs — ext2/3/4, xfs, btrfs —
and fails loudly on one it does not know rather than handing it to resize2fs
and hoping. Idempotent end to end: NOCHANGE from growpart is not a failure,
and every tool here exits 0 when there is nothing left to grow.

The same step now runs in the plan-change pipeline, where an upgrade used to
stop one step short of the customer.

A downgrade blocked by data gets a way out. The block stays — it is correct
— but the check now measures against the target package PLUS the packs the
customer already owns, and reports the numbers behind the refusal: what is
stored, what would be allowed, how much has to go, and how many packs would
cover it instead. The portal offers both routes: book exactly those packs
(confirmed in a modal, R23), or delete data and have the fill level measured
on demand rather than waiting for the nightly sampler. That reading is taken
by DiskUsageProbe, which CollectInstanceTraffic now uses too, so there is one
notion of "how full is it" and not two.

A plan change keeps booked packs. They were paid for separately and have
nothing to do with which package the customer is on; the new package's disk
and quota are sized with them included.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 19:13:10 +02:00
nexxo ec8675861e Take the order, park it, and say when it will be delivered
tests / pest (push) Failing after 7m44s Details
tests / assets (push) Successful in 21s Details
tests / release (push) Has been skipped Details
A paid order that no host has room for used to FAIL at the reservation
step. The customer has paid by then, so that turned a sale into an
incident and left somebody holding money against nothing. The estate is
finite and booked thick, so "no host has room right now" is an ordinary
Tuesday — it means a machine has to be bought, which takes days.

So the order waits instead. poll(), not retry(): the wait is measured in
days and must not eat the run's attempt budget. It only fails after
PARK_DAYS (14) — long enough to buy, rack and onboard a server over a
weekend, short enough that a forgotten order surfaces instead of waiting
for ever.

Then say so, in the same words on both sides of the payment:

  - The price sheet marks each package "wird sofort ausgeliefert" or
    "Bereitstellung in 2-3 Werktagen", read from free capacity rather
    than written into the page.
  - The customer's own overview says their cloud is being prepared while
    it is parked, instead of a stepper standing still for two days under
    "wird eingerichtet" — which reads as broken.
  - The console front page shows the same per-package answer, so an
    operator can see what visitors are being promised.

And a capacity page for the decision that follows: who is waiting, what
the roomiest host still has, what the queue needs ("3x Start, 1x
Business" - the LARGEST parked package sets the minimum machine, because
an instance lives on one host), and what such a machine costs today from
the provider's live list. It orders nothing: buying a server is a
contract, and a bug in a capacity calculation must not sign one.

The operator can also say where each parked order goes. A pin is
honoured or it waits — never quietly redirected — because placing it
elsewhere answers a different question than the one they answered, and
they would find out from the finished instance.

Two things this shook out:

  - `current_step` is an INDEX into the pipeline, not the step's key, and
    it is cast to integer. `where('current_step', 'reserve_resources')`
    reads as correct and matches step 0 of EVERY pipeline instead. Both
    lookups go through HostCapacity::parkedRuns() now, which resolves the
    index and filters by pipeline.
  - The auction feed sends `hdd_hr` as a list, not a string. Casting it
    printed "Array" into the console and raised a warning that took the
    page down with it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 18:50:46 +02:00
nexxo 30b1d41c2e Release 1.3.26
tests / pest (push) Failing after 7m47s Details
tests / assets (push) Successful in 20s Details
tests / release (push) Has been skipped Details
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 18:30:27 +02:00
nexxo 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>
2026-07-29 18:28:28 +02:00
nexxo 98d884727a Warn that a package cannot be placed, before somebody pays for it
tests / pest (push) Failing after 7m55s Details
tests / assets (push) Successful in 23s Details
tests / release (push) Has been skipped Details
The platform books thick: a package reserves its whole disk_gb — the sold
quota plus the machine's own overhead — on ONE host at placement. That is the
right choice for a small operator, because nothing can quietly overfill. It
has one consequence nobody was being told about: the catalogue goes on
offering a package no host has room for, and the first anyone hears of it is a
PAID order failing at the reservation step.

On the machine this was found on the numbers are stark. 2 x 512 GB NVMe in a
mirror is about 400 GB usable; Start reserves 120, Team 540, Business 1050,
Enterprise 2100. Three of the four packages on sale cannot be placed at all,
and the fourth fits three times.

So the console says so now, per package, with the shortfall — "Team needs 540
GB, the roomiest host has 260" — because "buy a server" is not the decision.
"Buy a server big enough for what I am selling" is.

Two things it deliberately does not do. It does not warn on an installation
with no host at all: that is a machine nobody has onboarded yet, it has its
own notice, and without the guard a fresh console greeted its operator with
one warning per package before they had done anything. And it does not take
the overview down when the catalogue cannot be read — a capacity warning does
not get to break the page somebody opens when something is already wrong.

The largest FREE HOST, never the sum: an instance goes on one machine, so two
hosts with 300 GB each cannot take a 540 GB package between them.

And when placement does fail, the log now names the shortfall instead of only
'no_capacity'.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 18:20:51 +02:00
nexxo 8f2252e81b Book a downgrade for a date that cannot move, and sell a module once
Two ways of charging a customer for something they did not get.

A downgrade was due when `subscriptions.current_period_end` said so, and
Stripe pushes that column forward on every renewal. So a downgrade booked in
March was deferred to the end of April by April's renewal, and to the end of
May by May's — each time by a whole term, and each time the customer was
billed again for the package they had asked to leave. The due date was being
re-derived from a moving target on every scheduler tick.

It is decided once now, at the moment the customer decides, and stamped onto
the contract: `pending_plan` and `pending_effective_at`, the two columns that
have been in the schema since the first migration and were written by nothing
at all. The shop stamps them, PlanChange reads them instead of the period end
when a move is already booked, and clupilot:apply-due-plan-changes finds
contracts by the stamped date rather than orders by a date it recomputes.

The order stays what it always was — the customer's own record of the request,
what the cart shows them and what they remove to change their mind — so it is
still consumed with the change, and removing it unbooks the change. What it no
longer does is decide when. A second downgrade replaces the first rather than
queueing behind it; an upgrade clears one, or it would come due months later
and undo the bigger package; a contract cancelled before the date takes the
booking with it; and a customer with no contract can no longer place a
downgrade at all, which was accepted before and could never be carried out.

A booked change is now visible where the contract is read: on the plan card in
the portal, because the cart entry disappears when it is paid for and the
booking does not, and on the customer row in the console, because that is
where an operator answers "what is this customer on".

The second defect is the same money in one step: BookAddon guarded duplicates
per ORDER — the unique index on (order_id, addon_key) — so two orders for the
same module on one contract both went through. A stale tab or a double click
bought priority support twice.

But storage is deliberately sold in packs, and AddonCatalogue sums the
quantities for exactly that reason, so "refuse the second" is only right for
some of them. Which is which is declared beside each price in
config/provisioning.php (`sold_as`), because it is a commercial decision about
each module and not something to infer from a key: storage is a quantity;
off-site backups are on or off, support is prioritised or it is not, Collabora
Pro is one licence, and a machine answers to one own domain. An undeclared
module counts as an entitlement — the cheaper of the two mistakes — and a test
refuses to let one ship undeclared.

Enforced in the action, where the portal, the console's grant screen and a
webhook all pass, with the sentence the customer is shown rather than a
developer's. A retried webhook for the SAME order still gets its one booking
back; that is one purchase arriving twice, not two purchases. The portal stops
offering what would be refused — booked, or already waiting in the cart — and
goes on offering the pack.

What is missing is on the Stripe side and is not invented here: nothing moves
the subscription's item onto the new Price, so the renewal after a plan change
still bills the old plan at Stripe. StripeClient only creates and archives
catalogue objects, and swapping a price also needs the subscription ITEM id,
which nothing stores.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 17:51:37 +02:00
nexxo 82f95df04c Stop the 503 page appearing during an update
tests / pest (push) Failing after 8m3s Details
tests / assets (push) Successful in 25s Details
tests / release (push) Has been skipped Details
Reported with the network panel open: `update` and `state` both answering
503, then the overlay, then Laravel's 503 page, then a reload. Three
different screens for one event.

Livewire's default on a failed request is to render the response body. So a
wire:poll that happened to fire while the application was in maintenance mode
swapped the whole console for the 503 page — the panel the operator was
watching, replaced by an error page, for something that is not an error. A
deployment IS maintenance mode; that is the event, not a fault.

Three changes, and they are three because the request came from three places:

The status endpoint is exempt from maintenance mode. It is the deployment's
own status, read by the overlay every three seconds, and behind maintenance
mode it answered 503 for the entire run — so the overlay could never name the
step it was on. Safe to exempt: version, commit and a step name, behind the
console's host and network guards, and it writes nothing.

Livewire swallows a 503 instead of rendering it. For every request, not only
the poll: any action taken in that window did the same thing.

And the settings card stops polling for the duration. The overlay's own
watcher is a plain fetch with no component state behind it, which is why it
survives the restart; this poll cannot, and every attempt was a request that
answered 503.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 17:50:15 +02:00
nexxo 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 5f74a5f and were gone
again in the tag. Reported for the fourth time, and the fourth report was
right: nothing had changed on the server.

What happened is mine and worth writing down. I have been building commits in
a private index (GIT_INDEX_FILE) to avoid staging a parallel session's
in-flight work, and moving the branch with `git update-ref`. That moves HEAD
without touching the shared index — so the index still described the PREVIOUS
commit. The very next ordinary `git commit` (the version bump) wrote that
stale index as a tree, which reverted every file of the commit before it. The
tag was cut from the revert.

Two things follow. The private-index technique is only safe while the branch
is not checked out; once it is, `git branch -f` refuses for exactly this
reason and `update-ref` is the same operation with the safety removed. And a
release is not verified by a green push — it is verified by reading the tag,
which is what finally found this.

Content unchanged from 5f74a5f: English fragments and legal paths with
permanent redirects, the guarded zone migration, and the test that walks every
route and fragment so a German path fails the suite instead of being reported
again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 17:36:43 +02:00
nexxo 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>
2026-07-29 17:26:46 +02:00
nexxo 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>
2026-07-29 17:26:20 +02:00
nexxo 6c3cdffc4e Release 1.3.21
tests / pest (push) Failing after 7m51s Details
tests / assets (push) Successful in 22s Details
tests / release (push) Has been skipped Details
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 17:20:19 +02:00
nexxo 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>
2026-07-29 17:18:55 +02:00
nexxo d5b2612e24 Always name the host we are being served from, and stop promising a duration
tests / pest (push) Failing after 8m10s Details
tests / assets (push) Successful in 23s Details
tests / release (push) Has been skipped Details
── Why the domain list looked wrong ─────────────────────────────────────────
Reported three times, and it was not only the setting. The list came from
`admin_access.site_hosts` and the instance zone — and where SITE_HOST is not
configured, site_hosts is EMPTY. So an installation reached by a name nobody
wrote into the config listed ONLY the instance zone, and described it as the
customers' domain: the page a visitor opened to check our addresses omitted
the address they had just typed, and named the wrong thing as ours.

The host serving the request is now always in the list. It is the one address
that is true by construction and needs no configuration to be right.

(The zone itself is still one setting, and it is now clupilot.cloud on this
machine. On another installation it is Konsole → Integrationen → DNS-Zone.)

── The clock ────────────────────────────────────────────────────────────────
"Eine Zeit angeben geht nicht — wenn es fertig ist, ist es fertig." Correct,
and it was claimed three times over: an eyebrow saying "In 20 Minuten
betriebsbereit", a provisioning log with minute marks down the side, and a
closing line repeating it. A run waits on a hypervisor, on a certificate
authority and on DNS propagating. A customer who once waits forty minutes has
been told something untrue by their supplier, and the page cannot take it back.

What replaces it is what actually IS promised and kept: the order of the
steps, ticked off, numbered 1/5 to 5/5 — and then the sentence that answers
the real question behind "how long", which is not a number but "do I have to
sit here": you get an email with the address and the credentials when it is
ready.

The same claim is gone from the hero meta ("< 20 Min." → "automatisch"), from
the figure line ("20 Minuten bis zum Betrieb" → "0 Termine bis zum Start") and
from the FAQ, which now says outright that we do not name a duration and why.

Kept deliberately: the 45-minute training session and the three-to-seven
working days for setup. Those are appointments we schedule and commitments we
control, not predictions about a machine.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 17:10:06 +02:00
nexxo 3a4324fb6f Give people a way back in, and put the URL in English
tests / pest (push) Failing after 7m54s Details
tests / assets (push) Successful in 21s Details
tests / release (push) Has been skipped Details
── The hole ────────────────────────────────────────────────────────────────
There was no password reset. Fortify's feature was commented out, so there
was no link on the sign-in form, no page and no route — a customer who forgot
their password was locked out of their own cloud until somebody opened a
shell. On a product whose selling point is that you can ring somebody, that
is a support call a week and an embarrassing one.

Two pages of ours under Fortify's route names (R1/R2), because with views off
Fortify registers only the POST endpoints. The mail is a Mailable in this
product's design rather than the framework's MailMessage: somebody who has
just been locked out is exactly the person a phishing mail is aimed at, and a
message that looks nothing like the rest of our post is one they cannot check
— ours carries the footer that names our domains.

Three decisions worth stating, each with a test:

The answer is identical whether the address is known or not. "No account with
that address" turns the form into a way of finding out who is a customer.

The reset kills every other session. Whoever knew the old password may still
be signed in somewhere, and a reset that leaves them there has fixed nothing.

It does NOT sign the visitor in. The link arrived by email, and a mailbox
somebody else can read would otherwise be a session somebody else gets.

── /sicherheit → /security ─────────────────────────────────────────────────
R13: paths are English. Mine was not. The old path stays as a permanent
redirect because it has already gone out in mail footers.

── One wordmark ────────────────────────────────────────────────────────────
"Sometimes it says Cloud and sometimes it does not" — correct. It was on the
footer, the placeholder and the maintenance screen, and absent from the
header, the sidebar and the sign-in plate. The product is called CluPilot
Cloud; that is the name on the invoices and in every mail, so it is now the
name everywhere. The console sidebar takes the small size so the lockup, the
ADMIN badge and the close button still share one line (R18).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 16:50:29 +02:00
nexxo 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>
2026-07-29 16:44:52 +02:00
nexxo 085b110e7f Link the phishing page from the section people look in
tests / pest (push) Failing after 11m16s Details
tests / assets (push) Successful in 27s Details
tests / release (push) Has been skipped Details
Reported: "I cannot find that under Sicherheit on the website." Fair — the
page was linked from the site footer, from every mail footer and from under
the sign-in form, and none of those is where somebody goes when the thought
"is this really them?" occurs. They go to the section called Sicherheit.

It is now the last thing in that section, as its own card: what the page is
for, in one line, and where it leads.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 16:30:29 +02:00
nexxo 73313c4d4c Release 1.3.17
tests / pest (push) Failing after 13m8s Details
tests / assets (push) Successful in 20s Details
tests / release (push) Has been skipped Details
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 16:20:30 +02:00
nexxo 4e3beca759 Close the domain page to packages that may not have one
The rule was decided in CustomDomainAccess but nothing asked it on the way
in: /domain answered every customer on every package, so the entry package
could set a domain it is not sold and cannot keep.

The guard sits in mount(), not on the route. A Livewire component answers
POSTs to /livewire/update by itself, so a route-only check would have left
save() and the rest reachable to exactly the customers it was meant to stop.

The sidebar asks the same question through a gate rather than a second copy
of the rule — a customer holds no permissions at all (R21 puts all seventeen
on the operator guard), so can() had nothing to filter on and the tab was
shown to everyone, leading straight into the 403 the page now raises.

Customer::forUser() carries the resolution the Livewire concern used to own,
because the gate has to answer outside any component and a second copy of
its email fallback is how the two would drift apart.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 16:20:21 +02:00
nexxo 6ef9b510ce Merge branch 'feat/plan-reopen'
# Conflicts:
#	app/Http/Controllers/LandingController.php
2026-07-29 16:20:07 +02:00
nexxo f8874f32ea Put customer instances on their own domain, and say which domains are ours
tests / pest (push) Failing after 8m1s Details
tests / assets (push) Successful in 20s Details
tests / release (push) Has been skipped Details
── The zone ────────────────────────────────────────────────────────────────
A fresh install now addresses customer instances under clupilot.cloud rather
than clupilot.com. Not branding: a Nextcloud is third-party software that
strangers sign in to, and on the same registrable domain as the portal it
shares cookie scope, document.domain and CAA with it. The same reason
googleusercontent.com and vercel.app exist.

Existing installations keep whatever the console says — the zone has been a
setting all along. `clupilot:check-zone <zone>` reports what a change would
touch before anything is touched: the DNS records, the monitoring targets,
the certificates and Nextcloud's trusted_domains all carry the old name and
the setting reaches none of them. With no instances in service it says so and
stops. Changing it later is a migration with an outage, and the command says
that too.

── Which addresses are ours ────────────────────────────────────────────────
A new public page at /sicherheit: the domains that belong to us, the rule for
reading an address bar, what we never do, and — the part that matters — what
to do if you have already typed your password into a copy of our sign-in
form. Change it, end every other session, change it wherever else you use it,
tell us. In that order.

Reachable without an account, deliberately: somebody who has just given their
password away is signed in nowhere, and the page they need cannot be behind
the thing they lost.

Linked from the three places a person is when the question comes up: every
mail footer (the mail is the vector), the sign-in form (this is the page a
phishing kit copies, so the real one says which host you are on), and the
site footer.

The domain list is DERIVED from configuration and keyed by what each domain
is for. A list typed into a page would eventually tell a customer that a
phishing domain is one of ours, or that one of ours is not — the same
two-sources-of-truth failure that had the price sheet promising an address on
a domain the company does not own. The operator console is deliberately
absent from it: naming it on a public page tells an attacker where to aim.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 16:03:13 +02:00
nexxo 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>
2026-07-29 15:59:04 +02:00
nexxo a4626d2569 Stop root workers breaking every page, and let the panel be closed
tests / pest (push) Failing after 12m38s Details
tests / assets (push) Successful in 26s Details
tests / release (push) Has been skipped Details
── The 500 ─────────────────────────────────────────────────────────────────
"touch(): Utime failed: Operation not permitted", from BladeCompiler, on
every request for the affected view.

Blade compiles a view and then touch()es the compiled file to the source's
mtime. touch() with an explicit time needs ownership of the file. A compose
service without `user:` runs as ROOT — and queue, scheduler and reverb had
none — so the first worker to render a view wrote a root-owned compiled file
that the web process, as www-data, could never refresh again. The queue is
what renders mails, which is why this surfaced now.

queue, reverb and scheduler run as www-data. queue-provisioning stays root
and says why: it brings wg0 up and runs `wg set` for every peer change, which
needs NET_ADMIN on the running process. It renders no mail.

And update.sh normalises ownership at the END of a run as well as at the
start. The first call heals what a previous run left; the second heals what
this one made — `git checkout` rewrites the tree as the service account while
the old build is still serving.

── The panel that would not go away ─────────────────────────────────────────
The same 500 is why it kept coming back: every poll failed, the watcher
treats a failed request as "still restarting" (which it normally is), and the
overlay stayed up over a console nobody could then reach to find out why.

A restart is seconds. After two minutes of nothing the panel now says so and
offers a way out — for an operator only, behind the same flag as the step and
the log, so a customer on the 503 page is not shown a button suggesting they
can call the deployment off. A successful answer clears it again: one bad
minute must not leave a "something is wrong" notice sitting there for the
rest of the run.

Not an always-present close button. The deployment does not stop because
somebody dismissed a panel, and offering that at the wrong moment is a lie.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 15:43:54 +02:00
nexxo da76a260fb Give the settings page an outline
tests / pest (push) Failing after 8m0s Details
tests / assets (push) Successful in 20s Details
tests / release (push) Has been skipped Details
Reported as cramped boxes with empty space either side, and it was: one
column half the window wide, eight cards of identical weight stacked in an
order nobody chose — the operator's own password sitting between two
platform-wide switches — and every card taking a full row to hold three lines
of text.

The page is wider now and has three groups, each with a heading: what belongs
to the installation, what belongs to the signed-in operator, and the team.
Short cards sit side by side instead of one per row. The two-factor policy
moved into the installation group, where it belongs: it is a rule for
everybody who signs in, set by the owner, not a setting on one account — and
it fills the space the short website card was leaving beside the long
allow-list.

Fields are sized to what goes in them. The three password boxes were laid out
three across inside a narrow card; they stack in a half-width one. The window
times are four characters and were as wide as their column.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 15:29:07 +02:00
nexxo 517606b4db Split the price sheet into what everyone gets, what differs, and what costs extra
One boolean matrix was carrying three different kinds of statement, and it
made all three unreadable.

Updates, backup and monitoring were a tick in every column — a comparison
that compares nothing — and the same promises were repeated in a prose line
under the table. They are now stated once, above it, together with the
platform address in the shape a customer will actually type
(ihrefirma.<zone>, from the configured zone) rather than as the phrase
"eigene Subdomain", which said nothing and was modelled as though an own
domain replaced it. Provisioning issues both.

Support was two independent booleans on one axis, so enterprise — which
carries the SLA and not the priority flag — rendered "Bevorzugter Support —"
beside team's tick: the top plan looking worse than the middle one. It is
one row of levels now, and a plan with neither shows Standard, because
standard support is a level and not an absence.

A cell had two states, so a dash meant both "you cannot have this" and "you
can have this for nine euros". There are three now, and the middle one
quotes AddonCatalogue — which is also how the modules we have been selling
all along finally appear on the page at all. Nothing is invented: where we
sell no module for a missing feature, the cell stays a dash.

custom_domain joins the module list at the owner's figure so the two plans
without it can be quoted a price instead of told no.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 15:25:20 +02:00
nexxo 6c92aa5dd7 Let an incident be deleted, and start measuring whether the hosts answer
tests / pest (push) Failing after 8m13s Details
tests / assets (push) Successful in 21s Details
tests / release (push) Has been skipped Details
Three things, all from trying to actually use the incident page.

── Where the later entries go ───────────────────────────────────────────────
The form only ever creates the first one, and nothing said so — so it looked
as though the stage had to be chosen up front and there was only a dropdown
for the impact. The field now says what it becomes ("wird untersucht") and
where the rest are added: on the incident itself, once it exists.

── Deleting ────────────────────────────────────────────────────────────────
Asked for in order to test the thing at all, and right. A whole incident can
now be removed, with its entries, behind a confirmation in the product's own
dialog (R23).

Deliberately not the same as editing an entry, which there is still no way to
do: an update is a statement made at a time, and rewriting one afterwards is
what the record exists to prevent. Removing the whole incident is a different
act — it is how a test entry, or one published against the wrong service, is
taken back, and it leaves nothing half-standing behind. A test holds that
distinction so the delete button does not quietly become an exception to it.

── The hosts, which nothing was watching ────────────────────────────────────
`hosts.last_seen_at` drove the health dot in the console and was written
exactly once, at onboarding — RegisterCapacity stamped it and that was the
only write in the codebase. So every host read "offline" thirty minutes after
it was added, permanently, and the dot meant nothing. It is also why host
reachability could not appear on the status page: it was never being measured.

PingHosts asks every host's API the cheapest question it answers, once a
minute, and stamps the timestamp on success. Failure writes nothing on
purpose: absence IS the signal, and a second column counting failures would be
a second version of the same fact.

The status page has a fifth component for it. Counts only — never a host name,
never an address; the page is world-readable and the estate is not public
information. A host between five and thirty minutes silent reads as degraded,
not as healthy: "stale" is one of three answers healthState() gives and it is
not the good one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 15:16:48 +02:00
nexxo 0e3c50d9a1 Update on a schedule if the owner wants one, and say where incidents go
tests / pest (push) Failing after 8m22s Details
tests / assets (push) Successful in 20s Details
tests / release (push) Has been skipped Details
── Where the status page gets its data ──────────────────────────────────────
Asked in as many words, and fairly: the page for maintaining incidents exists
(console → Betrieb → Störungen) and nothing anywhere said so. The page now
carries the answer at the top — what is written there appears publicly at
once, the four components measure themselves, and there is a link straight to
the live status page.

── Automatic updates, inside a window, switchable ───────────────────────────
Tick it and name the days and hours; leave it alone and nothing changes. The
button stays either way: manual is always possible, at any hour.

It leaves exactly the same request the button leaves — same file, same agent,
same run. A second path into a deployment would be a second set of rules to
keep in step with the first, and the automation must not be able to do what an
operator is prevented from doing by hand: it checks the same three things the
button is disabled by (agent alive, something released, nothing running).

Nothing guards against having already updated by hand, and nothing needs to:
an installation that is current has nothing available, so pressing the button
at four leaves the five o'clock window with nothing to do. That was the
owner's own question and it answers itself.

Defaults are Tuesday to Thursday, 05:00–06:00. Not Monday — the week starts
and every fault costs double. Not Friday — nobody wants to repair a deployment
on a Friday afternoon. Not three in the morning: if it goes wrong, the person
who has to fix it is asleep.

A window whose end is before its start wraps past midnight, because 23:00 to
02:00 is a reasonable thing to ask for and reading it as an empty range would
silently mean "never". Switched on with no weekday selected is refused rather
than corrected — it is a configuration that reads as working and does nothing.

The clock is the wall clock (R19): an operator who writes 05:00 means five in
the morning where they live, and a window that drifts by an hour twice a year
is a window nobody trusts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 15:01:54 +02:00
nexxo 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>
2026-07-29 14:58:11 +02:00
nexxo 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>
2026-07-29 14:47:51 +02:00
nexxo 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.
2026-07-29 14:31:44 +02:00
nexxo 17a6fabcc2 One wordmark, one typeface, and the address the server actually issues
tests / pest (push) Failing after 11m28s Details
tests / assets (push) Successful in 19s Details
tests / release (push) Has been skipped Details
Reported as "logo and font are different everywhere" — maintenance page,
customer panel, console, homepage. They were. Counting them up: the site
header set the name in ink at -0.02em, the sidebar split it into "Clu" plus
an orange "Pilot", the sign-in plate set it white at a third size, the
customer two-factor page set it in MONO with no mark at all, the error pages
and the placeholder each inlined their own — and the maintenance screen had
no wordmark whatsoever. Nine surfaces, seven ideas of the brand.

There is one now: resources/views/components/ui/brand.blade.php. Mark plus
wordmark in one piece, in ink, with an optional muted "Cloud". No orange half
— that made the accent a permanent fixture instead of something used
sparingly. The four places that cannot use a Blade component (the two
self-contained pages, the error layout, the mail layout) inline it and are
held to the same shape by a test.

The typeface. The console loads IBM Plex through Vite; the 503 page has no
stylesheet at all, so the maintenance screen — the one page shown when
somebody is already worried — was rendering in the system sans. It declares
the fonts itself now. And the bold weight was missing from public/fonts
entirely: the placeholder and the error pages both asked for
ibm-plex-sans-latin-700-normal.woff2, which was not there. A @font-face
pointing at nothing fails silently, which is why nobody noticed.

The error pages carried the same two faults the placeholder shipped with: an
empty gradient tile where the mark belongs, and `gap` on an inline-flex row
spacing "Clu", "Pilot" and "Cloud" apart as three separate words.

And the price sheet promised "Adresse auf clupilot.cloud" — a domain the
company does not own — while provisioning was issuing addresses under
whatever CLUPILOT_DNS_ZONE says. It is assembled from that setting now, the
same reason the prices are read from the catalogue rather than typed into the
page.

welcome.blade.php removed: Laravel's leftover, reachable from no route.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 14:30:44 +02:00
nexxo 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.
2026-07-29 14:22:22 +02:00
nexxo 012d0f5092 Say EU, and stop promising to fix somebody else's deleted folder
tests / pest (push) Failing after 8m13s Details
tests / assets (push) Successful in 23s Details
tests / release (push) Has been skipped Details
Two corrections from a read of the live pages.

The location. Three public places named countries or a city — "aus deutschen
und finnischen Rechenzentren" on the placeholder, "in Österreich und
Deutschland" in the FAQ, "EU · Wien" in the product panel — and they did not
even agree with each other. They all say EU now, which is the thing that is
promised, the thing the AV-Vertrag pins down, and the thing that stays true
when a host moves.

The same string in the customer dashboard was worse: 'EU · Rechenzentrum
Falkenstein' was hard-coded and shown to every customer whatever host their
instance actually runs on. A specific claim nobody derived from the record.

The closing headline asked who you call when a folder is missing on a Monday
morning. It promised the wrong thing: what happens inside a customer's own
Nextcloud is their own house — the recycle bin and the version history are
right there — and presenting ourselves as the first call for somebody else's
user error offers a service we do not run. It now asks who looks after
updates, backups and outages, which is what the comparison section already
says in the operator's own words and what the product is actually for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 14:17:30 +02:00
nexxo b843c10ec3 Give the update screen the product's colours, and only one window
tests / pest (push) Failing after 8m16s Details
tests / assets (push) Successful in 20s Details
tests / release (push) Has been skipped Details
Two complaints, both correct, both mine.

Dark blue. The panel carried a `prefers-color-scheme: dark` block — the only
one in a product that has no dark mode. On a phone set to dark the update
screen, and nothing else, turned dark blue. A dark mode for one page is not a
dark mode; it is one page that does not match. It is gone.

And even in daylight the panel was not this product's colours: it used
Tailwind's slate ramp (#0f172a, #64748b, #e2e8f0), which is blue-tinted,
against neutrals that are warm. Every value is now a copy of a token from
portal-tokens.css, with the comment saying so, because this file cannot
import them — it renders when the asset build may not exist.

The small window then the big one. The settings card grew its own little
"läuft gerade" block the instant a run started; up to three seconds later the
full-page overlay covered it and said the same thing in a different size. The
card no longer says it at all, and the overlay now opens on the button's own
click — requestUpdate dispatches an event, but only once a run is actually
pending, so a refused second press does not black out the console.

Two more things found while looking:

The panel had `min-height: 100%`, which resolves against the parent's height.
On the 503 page the parent is <body>, whose height is auto — so it computed
to zero, the panel was only as tall as its text, and the bottom two thirds of
the screen stayed the browser's default white. Viewport units now, dvh so a
phone's collapsing address bar leaves no strip either.

And the mark was a plain orange tile again, the same empty-square that was
just fixed on the placeholder. The real logo is inlined.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 14:11:01 +02:00
nexxo 4755a4f362 Redesign the pre-launch placeholder, and fix its wordmark and mark
tests / pest (push) Failing after 8m7s Details
tests / assets (push) Successful in 20s Details
tests / release (push) Has been skipped Details
Reported from a phone on status.clupilot.com, which serves this page because
the site is still switched to private.

Two real defects behind the complaint:

The wordmark read "Clu Pilot Cloud". `gap` on a flex container applies
between EVERY child and a bare text node is a child — so an inline-flex row
wrapped around "Clu", <i>Pilot</i> and " Cloud" put nine pixels between each
of them. The wordmark is one element now and the gap only ever separates it
from the mark.

The logo was a plain gradient tile with nothing in it, which reads as an
image that failed to load. That is a poor thing to leave somebody with on a
page whose whole message is "we are building this properly". The real mark —
cloud plus autopilot ascent — is inlined; it has to be in the file because
this page renders when the asset build may not exist at all.

The redesign itself: the card is gone. The page is now a centred composition
on the open ground — the state as a pill above everything, the mark, the
headline at the size the rest of the site uses, and the three promises the
paragraph was already making set as their own line so there is something to
look at besides one block of text. One warm bloom behind it, drawn as a
gradient rather than a blurred disc, which banded into visible rings.

Still self-contained, and still the only page exempt from the shared
stylesheet: it is served mid-deploy and on a fresh install, where @vite would
throw instead of rendering the reassurance somebody came for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 13:43:56 +02:00
nexxo 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>
2026-07-29 12:45:18 +02:00
nexxo 3b85e8f1d1 Grant a package or module from the customer's row in the console
tests / pest (push) Failing after 7m55s Details
tests / assets (push) Successful in 20s Details
tests / release (push) Has been skipped Details
GrantPlan is a modal (R20), opened from a "Schenken" button on the customer's
own row in Admin\Customers, gated by customers.grant_plan rather than merely
hidden. It offers a whole package (plan, term, datacenter, an optional quota
bonus baked into the frozen snapshot) or a module on an existing contract,
either free or at a discount the operator sets against today's catalogue
price. Existing grants show on the same modal — who gave what, when, and
until when — with a warning once a dated grant is nearing its end; nothing
lapses on its own, exactly as decided.

The customers list also badges a granted plan in the Paket column.
2026-07-29 12:42:21 +02:00
nexxo 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.
2026-07-29 12:41:32 +02:00
nexxo 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.
2026-07-29 12:41:06 +02:00
nexxo 696c8834b8 Redesign the public website instead of recolouring it
tests / pest (push) Failing after 8m15s Details
tests / assets (push) Successful in 22s Details
tests / release (push) Has been skipped Details
Correctly rejected: the last pass was the old page in new paint. It kept the
skeleton — eight numbered sections, a split hero, a twelve-row comparison
matrix — and changed only the palette. A palette is not a design.

What is actually different now:

- The § numbering is gone. Numbered sections with a drawn rule are a document
  convention, and the document voice is exactly what the product's design
  system dropped.
- The hero shows the product instead of describing it: centred type, then a
  wide panel built from the real console's own tokens, cropped by the section
  below. The old hero put a table of facts beside the headline, so a visitor
  read two columns before seeing anything.
- Six identical cards, then six more, then six more became one asymmetric
  grid — a large panel, a dark tile, an accent tile, a wide module row — so
  the important thing is allowed to be bigger than the rest.
- The industries are one dense two-column list instead of six cards repeating
  each other's shape.
- The page has one dark hinge in the middle (the deployment) rather than a
  dark section among seven light ones.
- The evidence table became rows that pair the measure with the piece of
  paper you can hand somebody, which is what it is for.
- Prices are cards, with the full matrix folded away underneath. The matrix
  used to be the first thing a visitor met: twelve rows of ticks before a
  single price was legible.
- The FAQ heading sits still while the questions scroll past it.
- The footer is a real footer.

The header is a floating bar rather than a full-width band, and opaque in
both states — translucency plus backdrop-blur reads as a rendering fault over
the dark panels that scroll under it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 11:43:25 +02:00
nexxo 43d58de7f8 Rebuild the public website in the shared design system
Reported plainly: the site is still in the old style. It was — it was the
last of the three surfaces the token file describes that had never been
converted. landing.blade.php carried its own complete design system inside a
<style> block: warm paper, IBM Plex Serif headlines, 3px corners, its own
scale of greys, written before the tokens existed. Somebody who clicked
"Anmelden" left one company and arrived at another.

The page now draws from resources/css/portal-tokens.css like the portal and
the console: same surfaces, same radii, same button hierarchy, same
small-caps label, one family at weight 700 instead of a serif. What still
differs is rhythm, which is what a marketing page is allowed to differ in.

Content is unchanged. New: components/layouts/site.blade.php holds the header
and footer for every public page, and legal.blade.php uses it too — the
imprint used to be a stranded card in a third style again.

Two things found on the way:
- .lbl set `color: var(--muted)`, which is not a token. An undefined var() in
  `color` computes to `unset`, so every small-caps label on every surface was
  whatever colour its container happened to be.
- The big figures were set in mono. Mono gives the decimal comma a full glyph
  cell, so "99,95" read as three separate numbers.

coming-soon and the status page stay self-contained on purpose — both are
served when the application may not have a built stylesheet at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 11:26:35 +02:00
nexxo f8f0de4892 Let an update install host packages, without handing out root
Asked for directly: "später wenn ich etwas lokal erweitere will ich mich
nicht auf alle adminseiten einloggen müssen und extra rsync installieren".

An update runs as the service account, so anything root-owned on the host is
out of its reach and turns into "log into every server once and paste this".
The obvious fix — sudo on deploy/install-agent.sh — is not a grant at all:
the service account owns the checkout and can rewrite the very script it
would be allowed to run as root.

So the privileged part is written OUT of the checkout by install-agent.sh, to
/usr/local/sbin/clupilot-host-step, root:root 0755, from a quoted
here-document. The service account cannot influence a byte of it. sudoers
names one exact command line including its argument, so a step added to the
helper later is not covered by a grant written before it existed, and the
helper refuses anything not on its own list as well.

update.sh uses it only when rsync is actually missing, after the restart and
never fatally: the archive is collected hours later, and an update that died
over a package would be the bigger problem. A helper older than the steps the
updater wants is reported through the existing one-time-setup hint — silently
doing nothing is worse than an error.

Existing servers still need `sudo bash deploy/install-agent.sh` once, because
that is the run that puts the helper there. After it, they do not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 10:27:56 +02:00
nexxo 0671e8e119 Show one design while updating, not two swapping mid-run
Reported from live: pressing update showed a blue panel first, which then
vanished and was replaced by a completely different "we are updating"
screen. Both were mine. The console drew its own overlay in the app's light
tokens; a moment later the containers went down and Laravel's 503 page took
over with its own standalone styling. One event, two faces, swapping while
an operator watches.

There is now one panel — resources/views/partials/updating-panel.blade.php —
rendered by both. It carries its own inline CSS and cpu- prefixed class
names because the 503 page is served with the application down: no compiled
stylesheet, no Tailwind, nothing but what is in that file. Dark mode comes
from prefers-color-scheme for the same reason.

The operator detail (step, running since, log tail) rides inside the panel
behind a flag, since the same file is shown to customers on the 503 page.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 10:20:40 +02:00
nexxo 15b536f393 Mint the archive collection key from the console instead of by hand
tests / pest (push) Failing after 7m49s Details
tests / assets (push) Successful in 22s Details
tests / release (push) Has been skipped Details
Setting a NAS up to collect the invoice archive meant three machines and a
dozen commands: generate a keypair there, carry the public half to the server,
write it into authorized_keys with a restriction, install rsync, and get every
path right on the first try. Every one of those steps was a place to be told
"permission denied" with no clue which of the three was wrong. It was, and
several times over.

One button now. The panel asks, the host does it — because the host is where
all of it lives: the home directory, ssh-keygen, rrsync, and the archive itself.
The panel is www-data in a container and owns none of that, so it uses the
mailbox it already uses for updates. The private half is shown exactly once,
alongside the finished rsync command, and is never written to the database: it
exists to be copied into a NAS, and storing it "for convenience" would put a
working credential in every backup of that database.

rrsync does the restricting, not a pinned rsync option string. That string
differs between rsync versions and fails silently — a refusal with no reason
given, which is the shape of the afternoon this replaces. If rrsync is missing
the agent refuses rather than issuing an unrestricted key while the panel says
it is restricted.

rsync now comes with install-agent.sh, which already runs as root once per
machine. It has to be on the HOST: a NAS connects by ssh and sshd starts
`rsync --server` here, so without it the pull fails with "command not found"
from a NAS whose own setup is perfectly correct.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 10:08:32 +02:00
nexxo 186f7f2de7 Keep exported invoices out of the repository
tests / pest (push) Failing after 7m50s Details
tests / assets (push) Successful in 20s Details
tests / release (push) Has been skipped Details
storage/archive is runtime output, and on a real server it is a mount point.
Committing it would put customer documents into git and make a deploy fight the
archive for the directory.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 09:56:00 +02:00
nexxo 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>
2026-07-29 03:48:03 +02:00
nexxo 79564947d4 Actually issue the invoice when the money arrives
tests / pest (push) Failing after 7m24s Details
tests / assets (push) Successful in 20s Details
tests / release (push) Has been skipped Details
Everything was built — numbering, the frozen snapshot, the PDF, the mail, the
archive and its retention — and none of it was connected to anything. IssueInvoice
was called from nowhere but its own tests. So on a real server no invoice had
ever been created, the handover directory was empty, and rsync was correctly
copying nothing. Reported as "nothing gets copied", which it was.

One mail, not two. The invoice is issued the moment the payment lands, so an
order confirmation and an invoice mail would otherwise go out in the same second
for the same event — a defect rather than a preference. The invoice mail carries
the document and says everything the confirmation said, so it replaces it
whenever there is an invoice; the confirmation stays for when there cannot be
one.

Issuing is allowed to fail without taking the purchase with it. IssueInvoice
refuses while the company details are incomplete, and that refusal is correct —
an invoice without a registered name or a VAT number is not a valid invoice, and
issuing one consumes a number that can never be handed out again. The order
still stands, the machine is still built, and the invoice can be issued later.
Nothing here may throw at the webhook either: Stripe retries anything that is
not a 2xx, and a retry would re-enter provisioning and build a second machine
over a missing VAT number.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 03:25:56 +02:00
nexxo 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>
2026-07-29 03:08:27 +02:00
nexxo 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>
2026-07-29 02:49:33 +02:00
nexxo 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>
2026-07-29 02:46:06 +02:00
nexxo 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>
2026-07-29 02:43:15 +02:00
nexxo 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>
2026-07-29 02:32:08 +02:00
nexxo b5de002622 Take the invoice year off the storage clock too
tests / pest (push) Failing after 7m40s Details
tests / assets (push) Successful in 23s Details
tests / release (push) Has been skipped Details
DisplayTimezoneTest went red on main: the year filter formatted issued_on
without ->local(). A year looks timezone-proof until New Year's night,
when the UTC year is still the previous one and the filter offers a year
the operator has already left. R19 is absolute for exactly that reason.
2026-07-29 02:28:34 +02:00
nexxo 33e5b15099 Restart the workers automatically after saving .env, instead of handing the operator back to the shell
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 02:26:34 +02:00
nexxo 86bf4f26e0 List issued invoices in the console, with no way to change one
tests / pest (push) Failing after 7m34s Details
tests / assets (push) Successful in 22s Details
tests / release (push) Has been skipped Details
Read-only by design, and the absence is the feature: an issued invoice is
corrected by cancelling it and issuing another, which is the only lawful way to
correct one. There is no edit button here whose absence needs explaining, and a
test asserts there is none.

The PDF is a plain route rather than a Livewire action. A download is a
download, and routing one through a component round-trip only adds a way for it
to fail.

Two findings on the way, and both were mine.

The year filter used YEAR() in raw SQL, which is MySQL's. SQLite has no such
function, so the page worked against the real database and threw against the
test one — a query that only runs on one engine makes the test database a
different product from the one being shipped. The years are derived in PHP now.

And a test put a User on the operator guard, where EnsureAdmin calls isActive()
on it and a 403 becomes a 500. Not a code defect: actingAs() calls
Auth::shouldUse(), so a second actingAs() in the same test with no guard named
lands on whichever guard the FIRST one named. That trap is written up in
CLAUDE.md, I quoted it earlier today, and I walked into it anyway. The tests are
split now and both name their guard.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 02:21:15 +02:00
nexxo 92bcdcd186 Send the invoice with the invoice attached
tests / pest (push) Failing after 7m32s Details
tests / assets (push) Successful in 22s Details
tests / release (push) Has been skipped Details
The PDF is rendered when the mail goes out rather than fetched from anywhere,
because nothing stores it — and rendering from the frozen snapshot means the
attachment is the document as issued no matter when the queue gets to it or how
often it retries. fromData rather than fromPath: there is no path.

Three attempts at testing it, and the first two were wrong in ways worth
recording. Attachment::$data does not exist — the closure lives in a protected
$resolver, and reaching for it was me testing the framework's internals rather
than my own code. And a byte comparison of the PDF cannot work at all: TCPDF
embeds a creation time and a document id, so two renders of one invoice differ.

Split into the two things that can actually go wrong. The mail carries an
attachment with the right filename and type — that is metadata, and it is
asserted as metadata. And the renderer produces a real PDF rather than an empty
one, asserted where the renderer is, because an attachment closure that returns
nothing still makes a perfectly valid mail with a nought-byte file on it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 02:13:41 +02:00
nexxo 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>
2026-07-29 02:09:05 +02:00
nexxo 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>
2026-07-29 02:03:29 +02:00
nexxo 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>
2026-07-29 01:59:56 +02:00
nexxo 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>
2026-07-29 01:52:16 +02:00
nexxo 90131f088a Add a raw .env editor to the Integrations page, with a net under it
Everything the console has no field for is still real — MAIL_*, DB_*,
APP_KEY, whichever key nobody has built a form for yet — and without a way to
touch it from here, the operator needs a shell and the console page misses
its own point. EnvFileEditor is the net, not just the warning:

1. Validates before writing. A line that is neither blank, nor a comment, nor
   KEY=value is rejected outright, and an empty file (syntactically "valid" by
   that rule, but not survivable) is refused too. write() never touches the
   file before checking the new content.
2. Backs up before every write that actually happens — a timestamped copy
   beside .env, before the new content lands. Never pruned automatically; the
   page says so, next to where it says where they land.
3. Names the keys a mistake here can lock an operator out with — APP_KEY,
   DB_*, REDIS_*, SESSION_* — rather than a blanket warning nobody reads.
4. Gated by secrets.manage and the same confirmed password the vault entries
   use — this is the one place on the page that can reach every credential
   the vault otherwise keeps write-only.
5. Says plainly what saving does not do: queue, queue-provisioning, scheduler
   and reverb only read .env at their own startup, and names the restart
   command plus config:clear.
6. Marks which .env keys SecretVault currently overrides, so editing a line
   that a stored vault value already shadows does not look broken.

Two issues surfaced by Codex review and fixed before this commit: the raw
file content stayed in the Livewire component snapshot after the password
confirmation window expired on its own (not only on an explicit re-lock), and
the first save on an installation with no .env yet failed trying to back up a
file that was never there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 01:51:27 +02:00
nexxo 3fadaa14b0 Merge credentials and infrastructure into one Integrations page
Zugangsdaten and Infrastruktur split the same subject by storage mechanism —
SecretVault vs App\Support\Settings — instead of by what each field
configures, which is not a distinction an operator setting up Stripe or DNS
should have to know or care about. One page now, grouped by integration:
Zahlungen (Stripe), DNS (Hetzner), Monitoring, VPN/WireGuard, SSH-Identität. A
secret and a plain setting sit side by side within a section; the difference
stays visible — a vault entry is write-only and shows only an outline, a
setting shows its value in full — as a property of the field, not a reason
for a separate page.

Both storage mechanisms are unchanged underneath: this is a presentation
change, not a data migration. The vault keeps its own gate (secrets.manage +
a recently confirmed password); plain settings keep theirs (hosts.manage
alone). Reachable with either capability, since the two are no longer two
pages a role happens to see one, both, or neither of — every section and
every save action still checks its own capability server-side.

admin.secrets and admin.infrastructure redirect here permanently rather than
404ing a bookmark. The nav entry that replaces both is visible to either
capability — Navigation's capability field now accepts an array meaning "any
of these", not only a single ability.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 01:49:39 +02:00
nexxo 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>
2026-07-29 01:48:34 +02:00
nexxo f8c923b48a Stop the invoice printing my own indentation
tests / pest (push) Failing after 8m3s Details
tests / assets (push) Successful in 21s Details
tests / release (push) Has been skipped Details
TCPDF takes leading whitespace in HTML literally, so every line of the address
and every line description came out indented by however far the Blade template
happened to be nested — and the one line that had no indentation, the VAT
number, sat flush against the margin looking like the mistake. It was the only
correct one.

Also puts the title and the invoice number in one block. As an h1 with a
paragraph beneath it, TCPDF gave the heading a margin of its own and the two
drifted a centimetre apart.

Found by rendering the page and looking at it, which is the only way any of
this is ever found.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 01:45:55 +02:00
nexxo 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>
2026-07-29 01:41:46 +02:00
nexxo 77bd30ca56 Answer on every name for the website, and send them all to one
tests / pest (push) Failing after 7m23s Details
tests / assets (push) Successful in 22s Details
tests / release (push) Has been skipped Details
SITE_HOST takes a comma-separated list now, the first name canonical. It serves
the site; every other name in the list redirects there permanently, path and
query intact. An apex domain and its www are two names for one thing, and
answering on both without picking one splits search rankings and cookies
between them.

The redirect is a host-bound catch-all rather than middleware. Middleware that
only some routes carry is the same half-measure this file already made once,
and a catch-all registered without a host would swallow every path in the
application — which is what the third test is there to stop.

The test harness needed fixing too, and the fix is the finding. Router::dispatch
on a standalone router does not rebind the container's request, so a route
reading one — through the helper OR through an injected parameter, both of which
resolve from the container — gets the test's request rather than the one being
answered. The redirect silently lost its query string in the test while working
in production. dispatchOn() binds it where the framework would.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 01:29:33 +02:00
nexxo e6d2e6dc33 Give the console a Finance tab: company details, VAT rate, invoice series
tests / pest (push) Failing after 7m49s Details
tests / assets (push) Successful in 22s Details
tests / release (push) Has been skipped Details
Its own tab rather than a section of Settings, as asked. What is set here
appears on a legal document, and burying it beside the site-visibility switch
invites somebody to change one in passing.

Everything the printed invoice needs — registered name, address, Firmenbuch
number and court, VAT ID, bank details, payment terms, logo — with the logo
replaceable, because a company changes its mark. None of it reaches an invoice
that already exists: the values are copied into each document when its number
is assigned, so a corrected address is correct from the next one onwards and
cannot rewrite an old one.

The console refuses to consider itself ready while a name, an address or a VAT
number is missing, and says which. An invoice without them is not a valid
invoice in Austria, and issuing one is worse than refusing to.

The series editor carries the same shape as EditDatacenter and for the same
reason. The prefix is free while the series is empty and frozen once a document
carries it: changing RE to AR renames nothing and leaves a series whose past
says RE and whose future says AR, with no record they belong together. The
counter may be raised — an operator migrating from another system needs exactly
that — but never lowered, because a lower number is one already printed on a
document somebody holds. Both rules are recomputed from the database on save
rather than read back from the properties the browser returns.

CompanyProfile drops keys that are not part of the profile. It is fed from a
form, and a forged key must not be able to write an arbitrary setting into the
same table the site-visibility switch lives in.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 01:18:43 +02:00
nexxo 21762284a7 Bind the portal to its own hostname too, so the website cannot serve it
tests / pest (push) Failing after 7m48s Details
tests / assets (push) Successful in 23s Details
tests / release (push) Has been skipped Details
The previous release bound the landing page and stopped there. The website duly
vanished from the portal — and the portal stayed reachable from the website:
www.clupilot.com/dashboard, /login, /settings all kept working. Half a
separation is no separation, and it was reported back within the hour.

APP_HOST binds every portal route, Fortify's own POST actions included through
fortify.domain — a sign-in form on the website's hostname would otherwise post
to a route that answers there. SITE_HOST now takes the legal pages and
robots.txt with the landing page rather than leaving them everywhere; binding
them is also what makes route('legal.impressum') produce a www URL from inside
a queued mail, where there is no request to take a hostname from.

There is no clever middleware here and there should not be. Route::domain() is
the mechanism: a route registered for one host does not exist on another, and a
request for it gets the 404 it deserves. The only deliberate exception is "/" on
the portal host, which redirects to the dashboard or the sign-in page — that is
what people type from memory, and turning it into an error page to make a point
about hostnames helps nobody. The Stripe webhook and /up stay host-agnostic:
Stripe posts to whichever URL it was given, and a mismatch there loses payments.

Both are opt-in and empty by default, which every development machine reached
by a bare IP depends on.

The tests now check BOTH directions for every path rather than only the one
that was reported. That is the mistake this commit exists to correct.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 01:06:50 +02:00
nexxo 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>
2026-07-29 00:57:06 +02:00
nexxo 864126ec7f Add the SSH identity to the vault, and give deployment config a console page
tests / pest (push) Failing after 7m28s Details
tests / assets (push) Successful in 19s Details
tests / release (push) Has been skipped Details
The SSH private key CluPilot deploys to every host is now stored through
SecretVault (ssh.private_key), preferred over CLUPILOT_SSH_PRIVATE_KEY(_PATH)
at all three real consumers — SshTraefikWriter and HostStep::keyLogin.
kuma.password/kuma.totp stay in .env: they authenticate a separate Python
container at boot, and nothing in this app reads them, so the vault would
have nothing to wire them to.

A new /admin/infrastructure page, backed by App\Support\ProvisioningSettings,
makes the non-secret deployment settings that used to force a shell —
DNS zone, WireGuard hub endpoint and public key, Traefik's dynamic-config
path, the SSH public key, and the monitoring bridge URL — visible and
editable from the console, each wired to every real consumer. WG subnet/hub
IP and Kuma's own connection details stay in .env: the compose file and a
separate container read those before this application ever does.
2026-07-29 00:52:44 +02:00
nexxo 5f7b8ff81b Cut 1.3.0
tests / pest (push) Failing after 7m41s Details
tests / assets (push) Successful in 21s Details
tests / release (push) Has been skipped Details
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 00:42:06 +02:00
nexxo a457bea87e Read the DNS and monitoring tokens from the vault, not config
Two of the Zugangsdaten page's three entries were decorative: whatever an
operator typed was encrypted and stored, but HttpHetznerDnsClient and
HttpMonitoringClient still read config() directly, so the stored value never
reached an outgoing request. Both now resolve through SecretVault::get() at
the point of use, matching how the Stripe client already worked.
2026-07-29 00:34:54 +02:00
nexxo 49a3ca2e33 Keep the shop window off the portal's front door
tests / pest (push) Failing after 7m35s Details
tests / assets (push) Successful in 19s Details
tests / release (push) Has been skipped Details
The landing page was registered host-agnostically, so app.clupilot.com served
it: marketing copy, a pricing table and a "sign up" call to action, at the
address where a customer's servers live. Reported exactly that way.

SITE_HOST binds the website to its own hostname. Every other host — the portal,
a bare IP — answers "/" with the product: the dashboard for somebody signed in,
the sign-in page for everybody else. Never a 404 there: "/" is what people type
from memory, and turning that into an error page to make a point about
hostnames helps nobody. Empty keeps today's behaviour, which every development
machine reached by IP depends on.

The console is unaffected — its routes are registered first and win on their
own host, the ordering the status page above already documents.

The legal pages are deliberately not bound. An imprint has to be reachable from
wherever the reader is standing, and a mail footer links to it from whichever
host sent the mail.

Also drops "just reply to this message" from two mails. A reply lands in the
billing or provisioning mailbox rather than in the support queue somebody
actually works through — so it now says to raise it in the portal, where it
gets a place in that queue instead of getting lost.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 00:26:22 +02:00
nexxo 7a5a71dc89 Read VPN host state from the synced peer table, not a live hub call
tests / pest (push) Failing after 7m20s Details
tests / assets (push) Successful in 21s Details
tests / release (push) Has been skipped Details
2026-07-29 00:18:21 +02:00
nexxo b1ab0f7f31 Put every mail in one design, and confirm an order when the money arrives
tests / pest (push) Failing after 7m48s Details
tests / assets (push) Successful in 25s Details
tests / release (push) Has been skipped Details
Four templates, one layout. Two of them — the maintenance announcement and its
cancellation — still wore Laravel's markdown component, which brings its own
logo, its own button and its own footer: the product was sending mail that
looked like two different companies, one of which was the framework.

New: an order confirmation, sent when the payment lands rather than when
provisioning finishes. Those are minutes apart at best and can be much longer,
and somebody who has just been charged and heard nothing assumes the worst
about both the charge and the product. It deliberately promises no time for the
cloud itself — provisioning takes what it takes, CloudReady announces the end,
and a promised minute that slips is worse than no promise. Queued after the
transaction, never inside it, and never allowed to fail the webhook: Stripe
retries anything that is not a 2xx, and a retry would re-enter provisioning
over a mail server having a bad minute.

The finished-cloud mail now names what was actually built — address, plan,
storage, location — because somebody who ordered a fortnight ago is checking it
against what they bought, and that is a comparison rather than a sentence. Still
no credential in it.

Two tests earn their place. One holds every template on the shared layout, so a
new one written in a hurry cannot copy-paste its way back to the framework's.
The other proves the finished-cloud mail carries no password — asserted against
the data the mail is given and the HTML it renders, not against the template's
source, because the first version of that test tripped over the word in a
comment and would have passed a template that leaked the value through a
variable. It tested the prose, not the mail.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 00:14:04 +02:00
nexxo eb743d34ed Show WireGuard tunnel state per host in the hosts list 2026-07-29 00:10:15 +02:00
nexxo 4d4d1dd5d5 Stop the update agent dying on its own tag arithmetic
tests / pest (push) Failing after 8m4s Details
tests / assets (push) Successful in 22s Details
tests / release (push) Has been skipped Details
Since 1.2.0 the agent has ended with a non-zero status on every tick, before
writing anything. The console showed a check that never finished and a
last-checked time frozen at the moment of the deployment, and the update button
could not be reached at all — the one mechanism that would have delivered the
fix.

Two instances of the same trap, both mine, both under `set -Eeuo pipefail`:

The tag count was `release_version_gt … && echo`. The LAST iteration is almost
always a tag that is not newer, so the loop ended non-zero, pipefail carried it
out of the pipeline, the command substitution inherited it, and set -e ended
the agent. `if` rather than `&&` is the whole fix: an if whose condition is
false still returns 0.

The newest tag came from `git tag … | head -1`. head exits after one line, git
takes SIGPIPE, pipefail does the rest. install-agent.sh has carried a written
warning about exactly this since it was written; I read it and wrote it anyway.

Both now live in deploy/lib/release.sh, because the reason neither was caught
is that no PHP test can reach a bash pipeline.
tests/Feature/ReleaseComparisonTest.php runs the real functions in a real
throwaway repository under the agent's own set -Eeuo pipefail, and asserts the
EXIT CODE as well as the answer — an answer nobody lives to read is not an
answer. The SIGPIPE case needs four hundred tags to reproduce, because a short
list finishes writing before head leaves.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 00:00:17 +02:00
nexxo ed4167eba3 Close SSH and the Proxmox UI to the world once the tunnel is proven up
tests / pest (push) Failing after 7m48s Details
tests / assets (push) Successful in 20s Details
tests / release (push) Has been skipped Details
2026-07-28 23:57:07 +02:00
nexxo 6998a22527 Repair ownership by looking inside, not at the door
tests / pest (push) Failing after 8m2s Details
tests / assets (push) Successful in 21s Details
tests / release (push) Has been skipped Details
The ownership repair added in 1.1.1 sampled the owner of each top-level
directory and skipped the recursion when it already matched. node_modules was
owned by www-data; node_modules/.vite-temp, left behind by an earlier root
build, was not. So the repair walked past it, `npm run build` failed with
EACCES, and the deployment stopped in maintenance mode — on the release whose
whole point was that this could not happen.

A directory's owner says nothing about its contents. It now walks each tree
once with find and changes only the entries that are wrong, which is also
cheaper than the detect-then-chown-everything it replaces.

Verified against the shape that actually failed: a root-owned file inside a
www-data-owned node_modules, repaired.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 23:53:25 +02:00
nexxo 2b1989f53d Move host DNS names off the public zone into vpn-dns's internal hostsdir
tests / pest (push) Failing after 8m2s Details
tests / assets (push) Successful in 19s Details
tests / release (push) Has been skipped Details
2026-07-28 23:44:31 +02:00
nexxo c7a3224466 Cut 1.2.0
tests / pest (push) Failing after 7m51s Details
tests / assets (push) Successful in 23s Details
tests / release (push) Has been skipped Details
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 23:44:18 +02:00
nexxo 5b8c28595a Require a confirmed address before an account can use anything
Registration opened an account that could sign in immediately on an address
nobody had proved was theirs — and anybody can type a stranger's into a
registration form. Both halves were commented out: the Fortify feature and
MustVerifyEmail on the model.

`verified` sits on the whole portal group rather than on the pages that spend
money. An unconfirmed address is not a billing problem to be caught at
checkout; it is an account that may not belong to the person holding it, and
the servers, the users and the backups behind it are worth as much to whoever
typed the address as to whoever owns it.

The mail is a Mailable in this product's design rather than Laravel's
notification, which would arrive with the framework's logo, button and footer
as the first thing anybody ever receives from CluPilot. It also has to leave
through the SYSTEM mailbox like every other account mail, which the
notification path does not do on its own.

The link is signed and expires, and the address is part of what is signed — so
a link issued to a mistyped address stops working the moment the address is
corrected, rather than confirming an address nobody can read. Tests cover the
tampered link, the expired one, the corrected address, and one account trying
to confirm another.

The waiting page is ours (Fortify has views off) and offers the only two things
that move somebody forward: send it again, or sign out and register with the
address they meant. The resend is throttled, because otherwise it is a button
that makes the server send mail to an arbitrary address as fast as it can be
clicked. Somebody already confirmed is redirected away from it — the `verified`
middleware only guards the other direction.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 23:43:20 +02:00
nexxo 07141a96bf Show where an account is signed in, and let it sign the other places out
Completes the device work: the warning mail now has somewhere to send people.
A warning whose only advice is "if this was not you, take action" names a
problem and hands back nothing to do about it.

Both sides get their own component rather than one shared with the guard passed
in. Operators and customers are two guards and two tables (R21) — a component
taking the guard as input is one forged property away from listing and ending
the other side's sessions. Every query in SessionRegistry is scoped by guard as
well as by id for the same reason, and three tests hold that line, because the
unscoped version looks exactly like a working feature while doing it.

TouchLoginSession keeps each row pointing at the session it describes. Both
sign-in paths raise the Login event BEFORE session()->regenerate(), so the row
is written against an id that is dead a moment later — and the once-a-minute
throttle would have held the correction back for the first minute of every
session, hiding the session its reader is sitting in. A changed id is therefore
never throttled.

A session counts as live only if the framework's row exists AND was touched
within the session lifetime. The join alone is not enough: Laravel collects
expired sessions by lottery, so on a quiet installation rows sit there for days
and the list would offer somebody a laptop they shut last month.

Ending the others goes through the product's own modal (R23) and leaves the
asking session alone: signing somebody out of the page they are using to sign
other people out is not what the button says.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 23:38:17 +02:00
nexxo 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>
2026-07-28 23:28:34 +02:00
nexxo 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.
2026-07-28 23:20:43 +02:00
nexxo 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.
2026-07-28 23:19:20 +02:00
nexxo 00b9107ee3 Deploy as the application's user, and take back what root already took
tests / pest (push) Failing after 7m19s Details
tests / assets (push) Successful in 19s Details
tests / release (push) Has been skipped Details
The 500 on the VPN config download was never in the VPN code. storage/logs/
laravel.log was owned by root, mode 644, since 27.07 04:16 — the moment a
failed `artisan optimize` wrote its error there during a deployment. From then
on the application could not append to its own log: Monolog threw on every
attempt, and a throw while logging is a 500 on any page that logs, with nothing
written down to say why. The error page said "Er wurde protokolliert". It was
not.

It surfaced on the config download because that is one of the few pages writing
a log line on its way through — on success and on a wrong password alike. Pages
that log nothing were unaffected, which is exactly why it looked like a VPN
fault for days.

`docker compose exec` is root unless told otherwise, and every deployment
script relied on that default — including the agent's allowlist sync, which
runs every minute. docker/entrypoint.sh had it right all along and drops to
www-data for precisely these commands. Now so does everything else, help text
included: telling an operator to run artisan as root is how the file ends up
owned by root in the first place.

update.sh also repairs what an earlier run left behind, before its first
unprivileged step rather than after — a root-owned log file is not
self-healing, the page that trips over it is nowhere near the deployment that
caused it, and with in_app unprivileged a root-owned vendor/ would break the
next composer step outright.

tests/Feature/DeploymentRunsAsTheAppUserTest.php holds the line. It was checked
against the previous commit and finds all nine places that were wrong.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 22:52:09 +02:00
nexxo 21b7aaca42 Update only to a released version, never to whatever landed on main
tests / pest (push) Failing after 7m21s Details
tests / assets (push) Successful in 20s Details
tests / release (push) Has been skipped Details
Asked for directly: a commit on main must change nothing on a server, and only
a tag whose version is higher than the installed one may be installed.

The agent asked two different questions before this. A pinned server compared
tags; everything else counted commits on its branch — so a console following
main offered an update for every push, verified or not, and "Jetzt
aktualisieren" meant "take whatever is on the branch right now". Landing on main
and being released are different events, and only the second is a decision
somebody made.

It is one question now, regardless of what the checkout is attached to: is there
a tag whose version is higher than the deployed one? Compared with sort -V
against the version in the deployment manifest — not the VERSION file, which an
update moves before it migrates, so a run that failed in between would leave the
checkout claiming a version it never finished installing. A run always targets
that tag; the branch path is gone. A request that arrives with nothing to
install is answered rather than obeyed, before anything announces a run.

The console follows: the button is disabled with nothing released, says so in a
line rather than leaving a grey button to be guessed at, names the version it
would install rather than a count, and refuses the action server-side as well —
a Livewire action is a public endpoint, and one place a rule may not live is
only in the disabled attribute of a button.

VERSION becomes 1.1.0, to be tagged as the first release this mechanism can see.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 22:32:44 +02:00
nexxo b64a9f9238 Give the two-factor page a way back, and a shape
tests / pest (push) Failing after 7m32s Details
tests / assets (push) Successful in 19s Details
tests / release (push) Has been skipped Details
Clicking "Einrichten" generated a secret and showed the QR with no exit:
leaving meant navigating away, which left an unconfirmed enrolment on the
account that nothing on the console ever displayed. cancelSetup() clears
it — it was never confirmed, so it never protected anything.

The page was also one card with four states stacked into it. Now each
state is its own panel, the enrolment step says which step it is and
carries numbered instructions beside the code, and the recovery codes get
their own card with a copy button instead of being a footnote under
whatever came before them.
2026-07-28 22:25:42 +02:00
nexxo 184350494d Let a half-finished two-factor enrolment be cancelled, and rework the setup page
tests / pest (push) Failing after 7m39s Details
tests / assets (push) Successful in 20s Details
tests / release (push) Has been skipped Details
Found uncommitted in the working tree at the start of this session and carried
in as its own commit so it can be reverted on its own. Not written here: a
cancel action that clears an unconfirmed secret, the wording for every state of
the page, and a rebuilt setup view. The suite is green with it in place,
including the R18-R23 scan tests.
2026-07-28 22:25:27 +02:00
nexxo 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>
2026-07-28 22:25:21 +02:00
nexxo 07634c8a1d Answer a check at once, start an update at once, and count down to neither
Checking for updates was a request written into the same mailbox as a real
update, collected by the same agent on the same systemd timer — so asking what
was new was answered on the next tick, and the console, having nothing else to
show, counted down to it. An operator who asked a question was told an update
would start in 3:44.

A systemd path unit now wakes the agent the moment the request file is written,
for a check and for a run alike. PathChanged, not PathExists: PathExists re-arms
as soon as the service goes inactive, so a request the agent could not consume —
it holds a lock for the length of an update — would restart it in a tight loop.
The timer stays as the fallback, and a drop-in disables systemd's start rate
limit, because two triggers on one oneshot service can otherwise wedge the unit
and leave neither of them able to run.

The countdown is gone from both the settings card and the maintenance overlay.
It could not be made honest: its target was recomputed on every poll, and where
the interval the agent reports is shorter than the timer actually installed on
that host it had already gone by, so the fallback moved it forward again each
time. It ran backwards four seconds and snapped back to a full minute, forever —
reported exactly that way. A queued run says it is starting; a queued check says
nothing beyond its badge, because a check starts nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 22:25:07 +02:00
nexxo e44f6658db Hand off with the process failure at the top, not the feature list
tests / pest (push) Failing after 7m16s Details
tests / assets (push) Successful in 21s Details
tests / release (push) Has been skipped Details
2026-07-28 21:44:04 +02:00
nexxo 64efe45f0d Recover silently from an expired session, and show a connection banner when offline
tests / pest (push) Failing after 7m22s Details
tests / assets (push) Successful in 24s Details
tests / release (push) Has been skipped Details
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 21:36:21 +02:00
nexxo bda61ca560 Pin the sidebar header and footer so only the nav list scrolls, and shorten two action labels that wrapped
tests / pest (push) Failing after 7m9s Details
tests / assets (push) Successful in 20s Details
tests / release (push) Has been skipped Details
2026-07-28 20:52:12 +02:00
nexxo f05547921d Separate checking for updates from applying them, and show live progress
tests / pest (push) Failing after 7m11s Details
tests / assets (push) Successful in 21s Details
tests / release (push) Has been skipped Details
2026-07-28 20:23:06 +02:00
nexxo 973fcb3f2d Replace native confirm() dialogs with the app's own modal pattern
tests / pest (push) Failing after 7m14s Details
tests / assets (push) Successful in 21s Details
tests / release (push) Has been skipped Details
2026-07-28 19:34:27 +02:00
nexxo c2681f2801 Show a full-screen overlay on every console page while an update runs
tests / pest (push) Failing after 7m27s Details
tests / assets (push) Successful in 20s Details
tests / release (push) Has been skipped Details
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 18:50:45 +02:00
nexxo 164145463c Send an operator with an expired session to sign in, not a 500
tests / pest (push) Failing after 7m15s Details
tests / assets (push) Successful in 21s Details
tests / release (push) Has been skipped Details
VpnConfigAccess::reveal() dereferenced Auth::guard('operator')->user()
unguarded, which is null when a modal's session has ended since it was
opened (a Livewire action reaches its component through /livewire/update
on its own, without the page's original route middleware). The same
unguarded pattern was in TwoFactorSetup, InstanceAdminAccess and Settings.

Add App\Livewire\Concerns\ResolvesOperator with currentOperator(), the one
place that resolves the operator and redirects to admin.login when there
isn't one, and use it everywhere the pattern occurred.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 18:09:22 +02:00
nexxo 833f386e73 Document that the console host has to be exclusive to be the console
tests / pest (push) Failing after 7m23s Details
tests / assets (push) Successful in 20s Details
tests / release (push) Has been skipped Details
Without ADMIN_HOST_EXCLUSIVE the default is false, and https://admin.…/
then serves the PORTAL. That reads as a fault and is not one, so the
example file now says which setting decides it and why the host list must
hold nothing but the console's own name once it is on.
2026-07-28 16:52:58 +02:00
nexxo 254674f456 Bound the mail test-send and real send to a timeout instead of hanging
tests / pest (push) Failing after 7m25s Details
tests / assets (push) Successful in 21s Details
tests / release (push) Has been skipped Details
A blocked outbound port (587 filtered at a firewall, as on the live
server) left both MailboxTester and MailboxTransport blocking until
PHP's default_socket_timeout (60s) or the reverse proxy cut the
request — a 504 with no error the operator ever saw. Mail::build()
honours a 'timeout' config key by calling SocketStream::setTimeout()
(confirmed by reading MailManager::configureSmtpTransport() and by an
end-to-end run against a genuinely unroutable host), so MailboxTester
now passes 'timeout' => 10 through it. MailboxTransport builds its
EsmtpTransport directly rather than through Mail::build(), so it sets
the same call on the transport's stream, at 30s — a queue worker can
afford to wait longer than an operator watching a browser, and a
timed-out send is retried rather than lost.

The test button also now shows a visible "sending" state while the
request is in flight, matching the wire:loading span pattern already
used on admin/host-create's save button — previously only
wire:loading.attr="disabled" fired, leaving a greyed-out button with
no other sign anything was happening.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 16:36:14 +02:00
nexxo 9fc74eedc3 Hand off with the effort rule at the top, not the feature list
tests / pest (pull_request) Failing after 7m21s Details
tests / assets (pull_request) Successful in 23s Details
tests / release (pull_request) Has been skipped Details
tests / pest (push) Failing after 7m18s Details
tests / assets (push) Successful in 27s Details
tests / release (push) Has been skipped Details
2026-07-28 16:13:08 +02:00
nexxo 75cb45bcbc Write down that the effort has to match the task 2026-07-28 16:12:05 +02:00
nexxo 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
2026-07-28 16:07:11 +02:00
nexxo f970dda0bb Send an already signed-in operator back to the console, not the portal
guest:operator's authenticated-user redirect had no console-aware
override, so it fell to Laravel's own default: the named 'dashboard'
route, the portal. An operator revisiting admin.login while already
signed in bounced to the portal's own login instead — in exclusive
mode, to a 404, since dashboard isn't a console route there.

redirectUsersTo() now mirrors the existing redirectGuestsTo() override
right above it, keyed on the same AdminArea::isConsole($request)
condition, so the two read as a pair.
2026-07-28 15:55:39 +02:00
nexxo 92dd6cc2d8 Refuse a two-factor completion for a revoked operator
completeLogin() re-checks isActive()/isOperator() immediately before signing
in, not only at the password step. The two-factor challenge previously only
confirmed the operator row still existed (Operator::find() + a null check),
so disabling an operator or revoking every console role in the window between
the password step and the pending code did not stop the login from
completing — the realistic case is an owner locking someone out mid-login,
not a contrived one.

One check, in the static method both OperatorLogin's direct exit and
OperatorTwoFactorChallenge::verify() already share, rather than a second copy
in the challenge. completeLogin() now returns bool instead of void: false
means refused, and neither the guard nor the session was touched. Each caller
turns that into a ValidationException in its own vocabulary — 'email' /
auth.failed for the direct exit (already unreachable in practice, since
authenticate() just checked the same two conditions synchronously; kept for
defense in depth), 'code' / auth.not_an_operator for the challenge, since the
code entered may be genuinely correct and reusing "wrong code" would mislead.
2026-07-28 15:47:39 +02:00
nexxo 58835a1051 Check users directly for the reverse operator-identity collision, not just customers
The three sites that refuse to create or rename an operator onto a
customer's email (Admin\Settings::saveAccount(), ::inviteStaff(), and
clupilot:create-operator) all checked Customer::where('email', ...) as
a proxy for "does this address already have a portal login". A users
row with no matching customers row — an email changed on one side
only, or legacy/orphaned data — passed straight through: this dev
database already had one.

Extracted the three copies into Customer::emailTaken(), which checks
both tables directly, so the three sites cannot drift from each other
again.
2026-07-28 15:36:29 +02:00
nexxo 278c4b9953 Match the logout exemption through AdminArea, not a bare route check
The setup-route exemption already went through AdminArea::routeIs(),
which matches both admin.<name> and admin.via*.<name>. The logout
exemption next to it used a bare $request->routeIs('admin.logout'),
which matches only the canonical name — on a recovery hostname the
logout route is admin.via0.logout, so the check failed there and an
unenrolled operator's logout POST bounced back to enrolment instead
of being let through, on exactly the host that exists because the
canonical one is not working.
2026-07-28 15:27:06 +02:00
nexxo 27292237c3 Move operator two-factor enrolment off admin.settings onto its own page
RequireOperatorTwoFactor exempted the whole of admin.settings while
compulsory two-factor was on, on the theory that it was "the page
where two-factor is set up". That component also carries staff
management, site visibility, network restrictions, account changes,
and the two-factor policy switch itself, so an unenrolled operator got
unrestricted access to all of that, not just enrolment.

Enrolment (secret, QR, confirm, recovery codes, regenerate, disable)
moves to its own route and component, admin.two-factor-setup /
Admin\TwoFactorSetup, reachable by every operator regardless of
capability. That is now the only page the middleware exempts besides
logout, and its redirect points there instead. admin.settings goes
back behind the policy like every other console page; only the policy
switch itself stays there, since only someone who already satisfies
it should be the one changing it.
2026-07-28 15:11:20 +02:00
nexxo 9c851b28b4 Satisfy Pint on the two new guard-boundary tests
Import the classes instead of referencing them fully-qualified, and
use single quotes where no interpolation or escaping is needed.
2026-07-28 15:10:58 +02:00
nexxo 055c6228f4 Stop a suspended customer's sign-out from killing a shared operator session
EnsureCustomerActive used session()->invalidate() to sign a suspended
or closed customer out. In shared-host mode the console and the
portal keep their login key in one session, so invalidate() (flush()
+ migrate(true)) silently signed out an operator elsewhere in that
same browser too. Same fix as the console's own /logout route:
regenerate() gets a fresh session id without flushing attributes
belonging to another guard.
2026-07-28 15:02:00 +02:00
nexxo a5db914696 Key the password-confirmation marker by guard and identity
confirmPassword() already checked the right account per guard, but the
session marker it stamped, auth.password_confirmed_at, was one flat
key shared by every guard. In shared-host mode the portal and console
guards keep their login in the same session, so confirming a
customer's portal password left the marker in place for an
operator-only gate too. One private method now computes the key for
all three callers so they cannot drift apart again.
2026-07-28 15:00:46 +02:00
nexxo e8bd8268fd Stop a disabled operator from bypassing the hidden-site gate
PublicSiteGate let anyone through on a bare Auth::guard('operator')->check(),
which stays true for a disabled operator's live session — disabling
someone does not sign them out, so it never stopped this gate from
showing them the real site indefinitely, even though EnsureAdmin
already treats them as inactive. Now requires the same thing EnsureAdmin
does: an active operator holding a console role.

Chosen to merely ignore an invalid operator session here rather than log
it out. This gate only decides what one request sees; logging out would
ripple into whatever else that session is doing (a concurrent console
tab, mid-task), a bigger side effect than a visibility check exists to
have. EnsureAdmin sets the precedent — it also rejects a disabled
operator per request without touching their session.
2026-07-28 14:42:29 +02:00
nexxo 20329b176b Refuse a portal login for an address that already belongs to an operator
Customer::ensureUser() enforced uniqueness only within users, so a
customer provisioned (or registering publicly) with an operator's email
got a second, colliding identity for the same address — the exact thing
R21 exists to prevent. Checks Operator by address now, not by session
(the deleted assertNotAdmin() checked who was signed in, which could
never be the right question).

Public registration gets the same guard via a plain unique() validation
rule, reusing Laravel's stock "already taken" message rather than a
dedicated one — naming the collision specifically would tell a public
visitor an address belongs to staff, a worse leak than the generic
refusal every other collision here already gives them.

Impersonation answers 409 instead of crashing into it. The Stripe
webhook keeps recording the paid order (same principle as
openContract()'s own comment: the order is proof money changed hands)
and withholds only the colliding login, logged for an operator to
resolve by hand.

Verified the reverse direction Codex flagged as already covered: two
Admin\Settings actions and the clupilot:create-operator command all
already refuse an operator email that collides with a customer's, and
that check is sound today because every users row is created in
lockstep with a customers row of the same email (CreateNewUser and
ensureUser are the only two creation sites, both confirmed by grep) and
nothing in this app can change a user's email afterwards (Fortify's
updateProfileInformation feature is disabled, and no other code calls
that action).
2026-07-28 14:42:16 +02:00
nexxo 913d19d39c Give operators somewhere to actually set up two-factor
console.require_2fa exempted admin.settings from the redirect it forces on
an unconfirmed operator, but that page only ever configured the global
policy — nobody could enrol behind it. A newly invited operator stayed
permanently redirected to a page with no way to satisfy the requirement,
the opposite of what the switch was for.

Adds enable/confirm/regenerate/disable to Admin\Settings, reusing Fortify's
own actions exactly as the portal's Settings already does — they only ever
touch the model they're handed, no guard or auth() call inside them, so
pointing ConfirmsPassword's guard at 'operator' is the only change needed.
The new card is deliberately not gated behind the site.manage capability
that hides the rest of this page's Owner-only sections: the compulsory
switch applies to every operator, so enrolment has to be reachable by every
operator too. Dropped the account card's stale hint pointing at a "separate
security area" that never existed.
2026-07-28 14:21:02 +02:00
nexxo 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.
2026-07-28 14:20:49 +02:00
nexxo 2b2bb439a5 Stop the console logout from destroying a shared portal session
Where the console and the portal share a host (shared/fallback mode —
this VM's own mode), both guards keep their login key in the SAME
session. session()->invalidate() is flush() + migrate(true) — it
discards every attribute in the session, not only the operator's, so
signing out of the console silently signed a customer out of the
portal too if the same browser carried both. Replaced with
session()->regenerate(): a new session id, so there is no fixation
risk, while attributes belonging to any other guard survive untouched.
2026-07-28 13:58:42 +02:00
nexxo 5e22e16291 Send a guest bounced off the console to the console's own sign-in
ApplicationBuilder::withMiddleware() defaults redirectGuestsTo() to
fn () => route('login') before bootstrap/app.php's own callback ever
runs — always the portal's Fortify page, regardless of which side of
the site turned the guest away. In non-exclusive (shared, this VM's
own mode) that sends a guest bounced off /admin to a sign-in form whose
table holds no operators: no 404 any more, but a sign-in that can never
succeed. Exclusive mode happened to self-correct only because
RestrictAdminHost 404s the wrong host before this ever ran.

Fixed for both modes at once rather than depending on which one a given
deployment runs: ask AdminArea, the one place routes/web.php's own
registration and RestrictAdminHost already agree is the source of truth
for "is this the console".
2026-07-28 13:32:05 +02:00
nexxo 47812cfca9 Authorize the console's live feed on the operator guard, not the default
POST /broadcasting/auth carries only the 'web' middleware group
(Laravel's own withBroadcasting() registers it that way), so
PusherBroadcaster::auth() resolved retrieveUser() on the DEFAULT guard
before the admin.runs channel's own Auth::guard('operator') check ever
ran — found nobody, since an operator is never signed in on 'web', and
threw straight from there. The guard was correct but unreachable.
Fixed by naming both guards on the channel registration itself.

Also adds broadcasting/auth to RestrictAdminHost::SHARED: in exclusive
mode it was neither admin.* by name nor on the shared list, so it 404'd
on the console host too — degrading silently to wire:poll.4s rather
than breaking outright, which is why nothing screamed.
2026-07-28 13:31:50 +02:00
nexxo 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.
2026-07-28 13:31:34 +02:00
nexxo 6e81d6e93e Delete Customer::assertNotAdmin() — the fault it guarded is now impossible
It checked Auth::guard('operator')->check() instead of the users row it
was handed, so with an operator's own session still attached (shared
mode carries the same cookie through both legs of impersonation) it
threw the moment a customer's email already had a users row — a 500 on
the first impersonation of anyone who had signed into the portal
themselves. Deleting it rather than fixing the check: operators live in
`operators` now, never in `users` (R21), so a users row can no longer
BE an operator account for this to guard against. Also drops the dead
`'is_admin' => false` write — the column reads from nowhere any more.
2026-07-28 13:31:14 +02:00
nexxo 5a40107256 Return to the console by name after impersonation, not a bare path
redirect()->to(AdminArea::home()) resolved against whatever host the
leave() POST itself arrived on — the portal's, once the console has a
hostname of its own — landing the operator on the portal's dashboard
instead of back at the console. route('admin.overview') is domain-bound
to the console and generates the correct cross-host URL, matching what
this line did before this branch. The test named after this behaviour
now actually asserts it.
2026-07-28 13:30:56 +02:00
nexxo d6ea39b69a Say the two-factor rule once, not twice 2026-07-28 12:27:02 +02:00
nexxo 88d9a66f44 Sign the impersonation link against the portal's own host, not the console's 2026-07-28 12:24:53 +02:00
nexxo bdb9c0a04a Write down that the console and the portal share no identity 2026-07-28 12:15:13 +02:00
nexxo e519653f66 Let the owner make two-factor compulsory, without locking themselves out 2026-07-28 12:08:08 +02:00
nexxo 26c84e3695 Hand impersonation over by signed link, not by a cookie that cannot cross hosts 2026-07-28 12:02:22 +02:00
nexxo 73dec7f685 Prove ConfirmsPassword resolves its guard, not the ambient default
actingAs()/Livewire::actingAs() call Auth::shouldUse(), which made every
existing test for this trait pass identically whether confirmPassword() asked
for the right guard explicitly or just read whichever guard happened to be
default — reverting the explicit guard() call to a bare auth()->user() passed
the full suite. This test signs the operator in without actingAs and
deliberately decouples the ambient default guard from their session before
calling confirmPassword(), so only the explicit resolution can succeed.
2026-07-28 11:54:13 +02:00
nexxo 8f8a0ad4f7 Stop the console serving the portal's sign-in page
Deleted tests/Feature/Auth/ConsoleLoginLandsInTheConsoleTest.php — it tested
the three deleted response classes. Its intent (an operator's sign-in landing
in the console, not the portal) is covered by OperatorLoginTest's "signs an
operator in on the operator guard" and OperatorTwoFactorChallengeTest's "signs
the operator in once the code actually checks out", both confirmed present
before deletion; the route-separation half is covered by the new
ConsoleHostSeparationTest cases added in this commit.
2026-07-28 11:45:34 +02:00
nexxo 3f318c3f6a Give the console a front door of its own 2026-07-28 11:45:22 +02:00
nexxo 513cadc5ce Confirm a console password against the console's own record 2026-07-28 11:17:34 +02:00
nexxo f54d00b3e7 Move the value remap and constraint swap before the delete, and fix the seeder 2026-07-28 11:00:09 +02:00
nexxo 87c49de6e5 Move the console's identity out of the customer table 2026-07-28 10:31:43 +02:00
nexxo 8d1fa52a5a Give the people who run CluPilot a table of their own 2026-07-28 09:20:54 +02:00
nexxo 2a06b5dfb2 Say EU and stop there, as the approved template does 2026-07-28 09:19:24 +02:00
nexxo e6793c4dae Plan the operator identity work as nine testable steps
Re-measuring before writing found the spec's numbers had moved: seventeen
permissions now, not sixteen, because the mailbox work added mail.manage.
It also turned up something the spec did not anticipate — ConfirmsPassword
resolves the password through auth()->user(), the default guard, so all
five components using it break the moment a second guard exists. That is
now its own task.

Self-review caught two references to things that do not exist: users has
no uuid column, so the impersonation link addresses the customer instead,
and there is no layouts.guest — the auth pages use layouts.portal.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 09:13:42 +02:00
nexxo 2979f008b5 Stop a mismatched-key decrypt from crashing the mailbox test button
tests / pest (push) Failing after 7m15s Details
tests / assets (push) Successful in 21s Details
tests / release (push) Has been skipped Details
tests / pest (pull_request) Failing after 7m17s Details
tests / assets (pull_request) Successful in 24s Details
tests / release (pull_request) Has been skipped Details
2026-07-28 07:26:42 +02:00
nexxo 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).
2026-07-28 07:10:53 +02:00
nexxo 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.
2026-07-28 06:47:11 +02:00
nexxo 729f57755a Clear every mailbox's verification when the shared server config changes
saveServer() wrote host/port/encryption without touching any mailbox, so
the console kept showing every mailbox as verified against a server
nothing had tested since. Mirrors EditMailbox::save()'s existing guard on
a single mailbox's own identity, just at server scope: only clears when a
field actually changed, so re-saving unedited values leaves a real
verification alone.

Both places now delegate the actual clear to new Mailbox::
invalidateVerification()/invalidateAllVerifications() methods rather than
touching last_verified_at directly, sharing the mutation. The "did this
change" comparison stays separate in each caller - one diffs a loaded
model's attributes, the other diffs persisted settings against incoming
scalars, and forcing them through one function would add machinery no
actual duplication justifies.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 06:36:06 +02:00
nexxo ebe29f8a5d Stop demanding a cipher, a real port, or a decrypted password a mailbox never uses
MailboxTester required a usable SECRETS_KEY unconditionally, refusing an
unauthenticated relay's test-send on a fresh install with no key yet, even
though real sending never touches the cipher for that mailbox. Gated the
check on authenticates, same as the password-presence check beside it.

Comparison pass across MailboxTester and MailboxTransport turned up two more
of the same shape. MailboxTransport's delegate-cache fingerprint decrypted
$box->password unconditionally, even when unauthenticated and never going to
use it — a mailbox that once authenticated, stored a password, and later had
authenticates unchecked would crash real sending the moment SECRETS_KEY
became unusable. And MailboxTester had no guard against a stored port <1;
Symfony silently reinterprets that as port 25 rather than refusing it, so the
test button could reach whatever happens to listen there and report success
for a configuration MailboxTransport refuses outright.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 06:21:28 +02:00
nexxo 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.
2026-07-28 05:54:21 +02:00
nexxo 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.
2026-07-28 05:53:51 +02:00
nexxo 74ac406fc0 Make isUsable() apply the same 32-byte rule encrypter() does
isUsable() checked only "is SECRETS_KEY nonempty", so a set-but-malformed
key (wrong length, garbage base64) read back as usable even though
encrypter() rejects it two lines below. EditMailbox::save() and
MailboxTester::run() both gate on isUsable() specifically to avoid an
uncaught RuntimeException reaching the operator; a lying isUsable() meant
that guard did not fire in exactly the configuration it exists for.

Both methods now read resolveKey() — the base64: prefix, the raw-base64
path, the 32-byte check — so they cannot disagree about a value either
one is given. SecretVault::isUsable() and the secrets console page's
"no key" banner both delegate down to this and are covered here too, not
assumed to inherit the fix correctly.
2026-07-28 05:09:09 +02:00
nexxo 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.
2026-07-28 05:08:58 +02:00
nexxo 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>
2026-07-28 04:49:37 +02:00
nexxo 3278f063ea Validate purpose mappings against real mailboxes, and clear stale verification on identity changes
savePurposes() only checked that each key was a nonempty string, so a
stale or tampered mailbox key (reachable by posting straight to
/livewire/update) saved successfully while MailboxResolver silently
returned null for it. Every purpose's key must now name a mailbox that
exists; system's must additionally be active, since every unmapped or
inactive OTHER purpose falls through to it.

EditMailbox::save() also cleared last_verified_at only when the
password changed, but smtpUsername() depends on address and username
too (falling back to address when username is blank) — changing
either while leaving the password untouched kept showing a successful
verification for an identity nothing had actually tested.
2026-07-28 04:35:16 +02:00
nexxo c55a5d2b49 Make ssl, tls, and none mean what they say on the wire
EsmtpTransport's autoTls (default true) and requireTls (default false)
are independent of the implicit-TLS constructor argument: 'tls' left
these at their defaults, so a server that omitted STARTTLS sent
mailbox credentials in the clear while reporting success, and 'none'
would silently upgrade the moment a server offered STARTTLS. Both
MailboxTransport and MailboxTester already duplicated the "ssl, or
port 465 ⇒ implicit" half of this decision independently; MailTlsPolicy
is now the one place both read, for both dimensions.

MailboxTester still builds through Mail::build() rather than a hand-built
transport: MailManager::createSmtpTransport() forwards the whole config
array to EsmtpTransportFactory as DSN options, which already reads
auto_tls/require_tls itself, so the same policy reaches this path
through keys the config array already supports.
2026-07-28 04:35:03 +02:00
nexxo 6fc25051e4 Fix the mail scheme example — smtp and smtps, not tls
Symfony's mailer only knows the schemes smtp and smtps; the example's
MAIL_SCHEME=tls (and the "tls | smtps (465)" comment naming it as an
option) made the default mailer unconstructible. .env itself is left
alone — that is the operator's live file.
2026-07-28 03:50:28 +02:00
nexxo 5de1f45703 Close two ways a mail.manage session could take over outgoing mail
I3: nothing stopped an operator unchecking Aktiv on the mailbox that
mail.purpose.system points at. MailboxResolver::active() filters an
inactive mailbox at BOTH the direct lookup and the system fallback, so
that one checkbox could take mail.purpose.system to null and every
purpose without its own mapping down with it — the existing
system_required guard on savePurposes() reads as if this were covered,
but it only blocks the empty-mapping path, not this one.
EditMailbox::save() now refuses active=false against the mailbox 'system'
currently points at, the same way savePurposes() already refuses to leave
'system' empty.

I4: Admin\Mail::saveServer() writes mail.host — the platform's outbound
relay for every purpose mailbox at once — and CloudReady::toMail() puts a
customer's Nextcloud admin password in cleartext in the message body. So a
signed-in mail.manage session was already a credential-interception
primitive, the same threat Admin\Secrets' second gate exists for. Both
saveServer() and EditMailbox::save() (only when a new password is actually
being typed) now also require passwordRecentlyConfirmed(), following the
ConfirmsPassword pattern Secrets.php and Settings.php's two-factor actions
already use — including their per-action UI shape (an inline confirm form
scoped to the gated action), not a whole-page lock, so address, display
name, username, no-reply, active and the purpose mapping stay reachable
without confirming, per the brief's capability split.

Both gated and ungated paths are covered by tests, and both guards were
mutated away and confirmed to fail before being restored.
2026-07-28 03:50:19 +02:00
nexxo 7102d01bcc Keep the purpose mailer through the queue instead of losing it to the default
Mail::to($x)->queue($mail) resolves the DEFAULT mailer, and
Illuminate\Mail\Mailer::queue() (vendor Mailer.php:482) does
`$view->mailer($this->name)->queue($this->queue)` — overwriting
MaintenanceAnnouncementMail/MaintenanceCancelledMail's own 'cp_maintenance'
with the default mailer's name ('array' in tests, 'log'/whatever
MAIL_MAILER is in prod) before the job is even built. Both mails kept
sending through the old .env account instead of MailboxTransport. CloudReady
was never affected — MailChannel.php:66 preserves a notification's own
mailer.

Fix: name the mailer before queueing — Mail::mailer($mail->mailer)->to(...).
$this->name on the resolved Mailer is then already $mail->mailer, so the
vendor overwrite becomes a no-op instead of a silent downgrade. Confirmed by
reading the vendor source, not assumed.

SenderAddressTest's "names the purpose mailer..." test asserted
$mail->mailer on a bare, never-queued mailable, which proves nothing about
what survives queueing — the constructor sets it right regardless of what
MaintenanceNotifier does with it. Replaced it with one that drives
MaintenanceNotifier::deliver() under Queue::fake() and inspects the pushed
SendQueuedMailable's mailable. Verified it fails against the pre-fix code
first (the pushed job carried mailer = 'array'), then verified the fix
makes it pass, then mutated the fix away and watched it fail again.
2026-07-28 03:50:02 +02:00
nexxo 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.
2026-07-28 03:49:37 +02:00
nexxo befb67327f Prove a mailbox works by actually sending from it 2026-07-28 02:56:47 +02:00
nexxo a9c777c79a Rename the SECRETS_KEY page test to match what it actually checks
It only ever loaded the page and asserted the banner, never a password
save — but its name claimed the crash-on-save case, which is exactly the
promise that let that bug ship unnoticed once. The test that earns the
crash claim is its new neighbour, added for the same finding.
2026-07-28 02:24:58 +02:00
nexxo 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.
2026-07-28 02:22:36 +02:00
nexxo 4211f3dfab Give the mailboxes a page, and the support sender its own capability 2026-07-28 01:57:11 +02:00
nexxo 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.
2026-07-28 01:15:50 +02:00
nexxo 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.
2026-07-28 00:24:54 +02:00
nexxo c899d41946 Pin CloudReady's null-mailbox guard and share it with the trait
A mutation the reviewer ran (if ($box !== null) -> if (true)) left every
test green: CloudReady hand-rolled the same null-mailbox guard as
SendsFromMailbox, but nothing exercised its null branch. Added the
missing test, then extracted the decision itself (null mailbox -> no
sender fields, no_reply -> no Reply-To) into one shared method so an
Envelope-shaped and a MailMessage-shaped consumer can no longer disagree.
2026-07-27 23:59:05 +02:00
nexxo 64a33c675e Put a real sender on every mail, and a reply address where one helps 2026-07-27 23:40:34 +02:00
nexxo 961bf62032 Make the DSN agree with send() when a mailbox has no password
Re-review of the previous fix round found the "cannot disagree" part of
finding 4 was still open. resolution() had unified the log/array/null/
mailbox decision, but inside the mailbox branch describe() returned
$box?->address unconditionally while delegate() additionally required
isConfigured() before using it. A mailbox with active=true, a real
address, and no password produced
'mailbox://support/support@clupilot.com' — reading as entirely healthy
— while send() threw. That is exactly the harm the original finding
named: the log says fine, the mail fails.

resolution() now decides "unconfigured" too, not just "which
non-delivering mode": it calls isConfigured() itself, once, and returns
that verdict alongside the box. describe() and delegate() both branch
on the SAME result again, the same way they already did for log/array/
null — a mailbox with no password now reads as
'support@clupilot.com [unconfigured]' rather than a plain address, and
the address stays visible so an operator can tell WHICH mailbox needs
fixing.

Also renamed a test the re-review flagged as promising more than it
checked: "registers a mailer per purpose without touching the database
at boot" only ever verified the config array's shape — the DB-touching
claim is a separate, already-passing test just below it
(defines every purpose mailer as a plain literal...). Renamed to
"registers a mailer for every purpose". No behaviour change.

Added a test creating a mailbox with no password and asserting the DSN
both names the address AND carries the marker, plus that send() still
throws — proving agreement, not just a matching label painted on
separately. Mutated describe()'s unconfigured branch to drop the
marker: the test failed exactly on the missing 'unconfigured' string
(DSN read as a plain, healthy-looking address again). Reverted, reran,
passes.

Full suite: 667 passed. vendor/bin/pint --test clean on both files.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 23:17:42 +02:00
nexxo a39670f920 Close the safety-net gaps the review found in MailboxTransport
The reviewer continued the mutation sweep from Task 4's own commit and
found three more unpinned lines in delegate(), plus two real behaviour
gaps, all inside the same 40-line method:

- The fingerprint refresh (the headline feature: a worker's mailer
  must pick up a corrected password without a restart) had no test —
  mutating its check to "delegate === null" left the suite green.
- The implicit-TLS third argument to EsmtpTransport had no test either
  — mutating it to a hardcoded true left the suite green, because the
  only assertion touching it was instanceof EsmtpTransport, which the
  hanging variant also satisfies.
- isLogging() only ever checked for 'log', but phpunit.xml sets
  MAIL_MAILER=array for the whole suite. Task 5 wires real mailables
  to these mailers next; the first feature test that sends one without
  Mail::fake() would have opened a real SMTP connection.
- describe() and delegate() each ran their own copy of that guard —
  which is exactly how deleting it from ONLY delegate() passed the
  whole suite in the first place: __toString() kept reporting "log"
  from its own untouched copy.
- An unconfigured host/port (Settings::get('mail.host', '') and a
  stored null port casting to 0) built a transport pointed at
  smtp://:587 or silently downgraded to plaintext port 25, instead of
  refusing the way a missing mailbox already does.

Fixes: replaced isLogging() with resolution(), the one place that now
decides "log, array, null-default, or a real mailbox" — describe() and
delegate() both branch on ITS result, so a guard broken in one cannot
look fine in the other. Added an explicit RuntimeException for a blank
host or non-positive port, guarded the same way as the missing-mailbox
case just above it.

Tests: added coverage for the fingerprint rebuild (reuses the same
delegate until the password actually changes, proven by object
identity), the exact DSN string for both the STARTTLS (587) and
implicit-TLS (465) cases, 'array' and unset-default both landing on a
non-delivering transport, and the blank host/bad port throwing.
Mutated each of these four in turn on the final code and confirmed:
fingerprint check removed -> the reuse test fails (same object handed
back after the password changed); TLS argument hardcoded true -> the
587 DSN test fails ('smtps://...' where 'smtp://...:587' was
expected); NON_DELIVERING narrowed back to ['log'] -> both the array
and unset-default tests fail (a real EsmtpTransport where a safe
transport was expected); host/port guards removed -> both throw
expectations fail. Reverted each, reran, all twelve tests pass again.

Replaced the two tests that reached into delegate() via Closure::bind
to check "log or SMTP" with one behavioural test: point Settings at a
closed loopback port (127.0.0.1:1, so refusal is instant and entirely
local — no DNS, no dependency on this environment's network egress
policy) and assert Log::shouldReceive('debug')->once() while sending
for real through Mail::mailer('cp_support')->raw(...). Mutated
NON_DELIVERING to drop 'log' specifically and confirmed this test
alone catches it: a real connection attempt to the closed port throws
TransportException (Connection refused) in under 300ms. Reverted,
reran, passes again.

Also: the two tests that only checked the config array's shape and a
snapshot of the resolved address promised more than they verified —
CLAUDE.md R19 names a test that recomputes the implementation as
checking nothing. Added a source-level test that the five cp_* config
entries are parenthesis-free literals (same technique
DisplayTimezoneTest/IconLayoutTest use: a property of the file, not of
one run) as the actual "no query at boot" proof, and renamed the
send-time test to what it verifies now that the fingerprint test above
covers the live-refresh claim properly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 23:02:18 +02:00
nexxo 0be9c61484 Make a deactivated mailbox fall back like a deleted one
Review of Task 4: MailboxResolver::for() only fell back on a missing
mapping or a missing row. Deactivating a mailbox — the operator's
explicit "do not use this" — left it resolvable anyway, so
MailboxTransport reached it, found isConfigured() false, and threw
instead of quietly using the next candidate the way every other
"this mailbox is unusable" case does. Worse, __toString() still
reported the deactivated address as though it were healthy.

named() now runs through a new active() filter at BOTH lookup sites —
the purpose's own mapping and the system fallback — so an inactive
system mailbox is refused exactly like a missing one, not handed out
as the last resort. A missing password stays deliberately unfiltered
here: that is a MailboxTransport concern (loud failure naming the
mailbox), not a resolver fallback trigger — silently rerouting mail
because a password hasn't been typed yet would hide the gap instead
of surfacing it.

Added two tests and mutated active() to a no-op filter to confirm they
catch it: both failed (one expected the system mailbox, got null; the
other expected null, got the inactive mailbox's full attribute dump).
Reverted, reran, both pass again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 22:57:22 +02:00
nexxo 095d8e694a Resolve the sending mailbox when the mail goes out, not at boot
Five purpose-named mailers (cp_maintenance, cp_provisioning, cp_support,
cp_billing, cp_system) land in config/mail.php as plain static entries —
no query runs to build them. Mail::extend('mailbox', ...) in
AppServiceProvider registers a transport that looks up its mailbox
through MailboxResolver only once a mailer is actually resolved, which
for queued mail is inside the worker at send time, never at boot.

Mutation-tested the brief's own three tests by deleting the MAIL_MAILER
guard from MailboxTransport::delegate(): all three stayed green. They
only exercise __toString()/describe(), a code path separate from the one
that actually sends. Added two tests that reach into delegate() itself
(a bound closure, since it's private) and assert the transport it
actually builds: LogTransport while MAIL_MAILER=log, EsmtpTransport once
it isn't. Re-ran the same mutation against the strengthened suite: it
now fails (EsmtpTransport where LogTransport was expected), then passes
again once reverted.

Verified two things against the installed versions before writing this:
LogTransport takes a Psr\Log\LoggerInterface and Log::channel(null)
resolves to the default channel rather than throwing, and
Mail::extend('mailbox', ...) is read by
MailManager::createSymfonyTransport() via $config['transport'] before it
would fall back to createSmtpTransport() — both matched the brief
exactly, no changes needed there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 22:28:37 +02:00
nexxo 06b84c5cd5 Say which kind of mail leaves from which mailbox 2026-07-27 22:00:16 +02:00
nexxo c5021c4aa7 Make the SECRETS_KEY test actually discriminate from APP_KEY
The old test rotated app.key and expected the password to stay
readable, but Laravel's encrypter is a container singleton resolved
once — config()->set('app.key', ...) never rebuilds it, so the
rotation the test performed was invisible to any code path going
through it. Confirmed by mutation: rewiring Mailbox to Crypt::
encryptString/decryptString (genuinely APP_KEY-keyed, the exact
regression this constraint exists to forbid) left the old test green.

Replace it with two tests: a positive proof that rotating SECRETS_KEY
makes a stored password unreadable (the one that discriminates), and
a separate check that rotating APP_KEY does not, with
forgetInstance('encrypter') so that rotation is actually visible to
whatever the password accessor resolves.

Re-ran the same mutation against the new test: it fails (exit 1, the
SECRETS_KEY test reports the expected DecryptException was not
thrown), then passes again once the mutation is reverted.
2026-07-27 21:49:46 +02:00
nexxo c5252847bb Make the SECRETS_KEY test able to fail
The Task 2 reviewer rewired the model to Laravel's APP_KEY-based Crypt
facade and every test stayed green. Laravel's encrypter is a container
singleton resolved once from app.key, so config()->set('app.key') never
rebuilds it and the rotation the test performed was invisible.

Replaced with a positive proof — rotating SECRETS_KEY must break
decryption — plus the APP_KEY case with forgetInstance(), which is what
makes that direction mean anything.
2026-07-27 21:44:59 +02:00
nexxo aaddffc096 Reuse the existing UUID trait instead of a second copy of the logic
Mailbox hand-rolled the same creating-time UUID assignment that
App\Models\Concerns\HasUuid already provides to 17 other models under
R11 (URLs address records by UUID, not integer PK), and skipped the
route-key binding that comes with it. Swap onto the trait, and cover
both the assignment and the route key with a test matching the
convention already used for Host/Order.

Also match MailboxFactory's @extends annotation to how the other
factories in the repo write it (Pint's fully_qualified_strict_types
rule turns a fully-qualified docblock reference into an import plus a
short name anyway, so this is what it would end up as either way).
2026-07-27 21:31:39 +02:00
nexxo c642090d97 Point the mailbox model at the UUID trait the repo already has
Seventeen models use App\Models\Concerns\HasUuid, and R11 is the reason:
URLs address records by UUID, not by integer primary key. The plan told
the implementer to hand-roll booted() instead, losing getRouteKeyName()
with it. Authoring error, corrected before the remaining tasks copy it.
2026-07-27 21:28:23 +02:00
nexxo 270ec942d6 Give every sending address a record of its own 2026-07-27 21:25:35 +02:00
nexxo 5cbd949bd5 Say plainly that the path comment in the plan is not code
The Task 1 implementer copied '// path/to/file.php' into the files
verbatim, which breaks Pint's blank_line_after_opening_tag and matches
no file in the repo. The annotation was mine; the constraint now says so
before the next seven tasks repeat it.
2026-07-27 21:20:37 +02:00
nexxo 38c5518f45 Drop the stray path comment after the opening PHP tag 2026-07-27 21:19:32 +02:00
nexxo 93d409e18f Pull the secrets encrypter out where two callers can share it 2026-07-27 21:09:10 +02:00
nexxo 0b7961ec36 Keep the SDD scratch directory out of the repository 2026-07-27 21:06:07 +02:00
nexxo 576cfcafac Plan the mailbox work as eight testable steps
Self-review against the spec turned up four gaps and one latent bug in
what already ships.

The bug: Laravel 13's MailManager reads only 'scheme' when building an
SMTP transport (MailManager.php:196) — the old 'encryption' fallback is
gone — and .env carries MAIL_SCHEME=tls, which Symfony does not accept;
it knows smtp and smtps. Nothing has failed because MAIL_MAILER=log
means no connection has ever been opened. The plan stores a human-facing
tls/ssl choice and translates it where it is used, and never copies
MAIL_SCHEME through.

The gaps: the cancellation mail and the provisioning notification were
edited but not tested, secrets.manage was never proven insufficient for
the new page, and a missing SECRETS_KEY had no task making the page say
so rather than throw.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 21:03:13 +02:00
nexxo 743ba96d13 Design mailboxes as records, not as one more secret
The console offers a single mail.password pointing at one SMTP mailbox,
which is nowhere near enough for five sending addresses. A mailbox is
address, display name, username and password; five of them as registry
entries would be twenty secrets named mail.support.username, and the
question of which mail sends from which address would still have no home.

Send-only stays usable because every message carries Reply-To on its own
mailbox, so a customer's reply lands in a mailbox someone reads. That is
what makes IMAP unnecessary rather than merely deferred.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 20:51:03 +02:00
nexxo edaceb9597 Delete the users row once the operator has moved out of it
Decided: whoever is in operators has no business in users. A row left
behind is the same mixing the separation exists to end, only smaller.

The one case that deletes nothing is a row with a customer, a seat or an
order on it. That would mean the same person is operator and paying
customer, and a silent delete would take billing data with it — so the
migration stops and names the address instead. Neither existing account
is in that state.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 20:44:56 +02:00
nexxo ce970a5fac Design the console's own identity, separate from customer accounts
The operator console and the customer portal share one users table, one
login page and one guard. Three reported faults follow from that single
fact: the console serves the portal's sign-in page, that page offers
"Registrieren", and the link 404s because RestrictAdminHost::SHARED does
not list register.

Measured rather than assumed: driving the host guard directly gives
/login through and /register 404 on the console host, and all sixteen
Spatie permissions turn out to be console permissions — so RBAC moves to
the new guard rather than being duplicated across two.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 20:40:22 +02:00
nexxo 96dac236e8 Write the handoff, and rescue the approved templates into the repo
tests / pest (push) Successful in 7m3s Details
tests / assets (push) Successful in 20s Details
tests / release (push) Successful in 4s Details
The three templates the design was signed off against lived in a
session-scoped scratchpad and would have disappeared with the session — the one
artefact the whole conversion is measured against. They are in docs/design/ now,
with the measurements that were got wrong at least once written down beside
them, and a note that the way to check is to render and measure rather than to
read the source. That distinction already settled one disagreement: the source
said the metric grid used a 20px gap, rendered it is 14px.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 20:18:20 +02:00
nexxo 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>
2026-07-27 17:55:49 +02:00
nexxo bd779f00e5 Show times on the operator's clock, keep storing them in UTC
tests / pest (push) Successful in 7m55s Details
tests / assets (push) Successful in 30s Details
tests / release (push) Successful in 6s Details
The console announced an update for "spätestens um 15:21" while the clock on
the wall said 17:21. Not a formatting slip in one string: fourteen views and
four components printed database values straight out, so every absolute time in
the product was two hours out all summer and one hour out all winter.

Two of those views looked as though they had handled it. They called

    ->timezone(config('app.timezone'))

which reads like "convert to local time" and, app.timezone being the STORAGE
zone that must stay UTC, converts to UTC — a no-op wearing the costume of a
fix, which is worse than no call at all because it stops the next person
looking. That exact string is now banned by test.

Worse than the labels were two FORMS. Maintenance windows and plan versions
filled their datetime-local fields from UTC and parsed what came back as UTC. A
datetime-local field carries no offset — it is the digits a person reads off
their own clock — so an operator typing 21:00 scheduled a window for 23:00
their time, and nothing anywhere said so. App\Support\LocalTime now holds
toField() and fromField() side by side, because getting one end right is not
half a fix, it is a fresh bug.

The mechanism is one Carbon macro, ->local(). It copies before converting:
Illuminate\Support\Carbon is mutable, so without that, rendering a timestamp
would rewrite the model attribute as a side effect and anything comparing or
saving it afterwards would be an hour or two out. Both Carbon classes get the
same body — Carbon keeps macros in one global table, so the second registration
replaces the first for every Carbon class, and two different bodies meant the
immutable version, safe for itself, silently became the mutable one's
implementation too. My own test caught that.

The existing tests had not caught any of this because they built their expected
values with the same wrong call the views used — a test that recomputes the
implementation asserts nothing. They now assert the wall clock, and the queued
update additionally asserts that the UTC time is NOT shown.

Recorded as R19 in CLAUDE.md and enforced by tests/Feature/DisplayTimezoneTest:
no Blade or Livewire component may format an absolute time without ->local(),
the UTC no-op is banned, storage stays UTC, both sides of the DST boundary are
checked, ->local() must not mutate, and a form field must round-trip to the
same instant.

APP_DISPLAY_TIMEZONE, default Europe/Vienna. 615 tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 17:32:21 +02:00
nexxo 186c2e46d1 Merge the panel design conversion into main
tests / pest (push) Successful in 12m28s Details
tests / assets (push) Successful in 22s Details
tests / release (push) Successful in 4s Details
2026-07-27 17:10:35 +02:00
nexxo 4daf37a10b Give the demo account a real password, not one written in the source
The demo customer is a genuine login on a panel that answers from the public
internet. A default password in a seeder would sit on production for as long as
the demo does, and "it is only the demo account" is how the first one gets
taken. It now takes DEMO_PASSWORD if the operator sets one and otherwise mints
a 24-character random password, printed once at seed time.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 17:09:54 +02:00
nexxo 435a202fdd Match the panel to the approved template, measured rather than assumed
tests / pest (push) Successful in 8m49s Details
tests / assets (push) Successful in 21s Details
tests / release (push) Has been skipped Details
The panel had been built by reading the template and believing the result.
Every round of review found the same class of defect, so this round the
template was rendered in a browser and measured with getComputedStyle, and the
implementation measured the same way — two number columns instead of an
opinion.

That immediately settled a disagreement: a review of the template's SOURCE said
the metric grid used a 20px gap. Rendered, it is 14px. The measurement wins.

Brought to the template's figures: label 11.5px (it was 11px in three separate
places, which is how it drifted — it is now one .lbl rule), value tracking
-0.02em, unit weight 450, metric row 6px above and 14px between, page head
centred with a 14px gap, grid gap 14px, columns switching at 1101/561px and the
h1 at 901px to match the template's own breakpoints, ring 62px, bar 5px.

The shared button now takes its height from min-height (40px) rather than
vertical padding, at 14px/600 with 0 18px padding and a 10px radius — a button
keeps its height whatever sits inside it, icon, spinner or bare text.

Also here, from the same round of review:

- The chart's blue border was Tailwind's own `ring` utility colliding with a
  component class of the same name. Renamed to `.metric-ring`. This was
  dismissed once as a screenshot artefact; it was real.
- Page titles lost the `sm:text-3xl` (40px) an earlier bulk edit had appended
  to 23 of them. The template's h1 is 30px.
- The users table has its actions back — edit, suspend, lock and delete — for
  every seat that is not the owner, with the owner refused in the action itself
  and not merely hidden in the markup.

DemoCustomerSeeder writes one complete customer as real rows: instance,
subscription, six seats across four roles, a backup, current-period traffic and
thirty days of samples with a deliberate wobble and one day at 286/288 checks.
Nothing in the panel is drawn from a fixture any more, so anything missing
shows up as missing. Removing the demo is one deletion.

Verified: 607 tests, and a Codex comparison of the two measurement sets —
"a person would call them the same design", the only difference a 1px gap.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 17:08:05 +02:00
nexxo 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>
2026-07-27 16:41:15 +02:00
nexxo 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>
2026-07-27 16:29:28 +02:00
nexxo c2a56382e1 Build the dashboard cards to the template, and name what has no source
Card by card against the approved template: label with a chevron, the figure
and its unit on one line, a line of context underneath, a bar across the foot.
The order matches too — storage, seats, transfer, then the fourth slot.

Three of the template's four visuals have no data behind them, and the code now
says so where someone will read it rather than leaving the next person to
wonder why the charts are missing:

- Storage consumption is never sampled. The card states the contractually
  agreed allowance instead of drawing a ring at an invented level.
- instance_traffic keeps one row per period, not a daily series, so there is
  nothing to draw a sparkline from. The share of the allowance is real, so
  that card carries a bar.
- monitoring_targets records a state and a check time but no uptime figure, so
  there is no availability percentage to show. That slot carries the last
  backup — the thing this product is about and can actually prove.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 16:21:27 +02:00
nexxo 676c38643c Follow the approved template, and stop showing invented records
The dashboard was built to my own idea of the layout rather than to the
template that was signed off: a coloured pill where the template has a stamped
mono line, bare figures where it has a label with a chevron, a unit on its own
line instead of beside the number, and no ring at all. There is no point
agreeing a design and then building something adjacent to it. The metric card
is now a component that draws the template's form, and the pages use it.

Two more places were still showing data that does not exist:

- Support listed three tickets to every customer, complete with reference
  numbers and dates. There is no ticket model; it was fixture text. A request
  list also belongs in the console, where an operator can see who filed what.
  Removed until there is something real behind it.
- The datacentre name reached the customer through the cloud tab's copy as
  well. Customer surfaces name the jurisdiction; the building is operator
  information.

And the actions column rendered an empty cell for the owner, who can be neither
re-invited nor revoked. An empty cell reads as a missing feature; it now says
so with a dash and a title.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 16:17:06 +02:00
nexxo 21d8dc310a One definition of the navigation, and the country instead of the building
The breadcrumb said Übersicht on every page. The sidebar and the breadcrumb
each built their own copy of the structure, so the layout had no way to know
which entry was current and fell back to the first one. Navigation::portal()
and ::console() are now the single definition, and currentLabel() matches on the
route name — which matters for the console, whose PATH changes between
host-bound and fallback mode while its route names do not.

The datacentre name is out of everything a customer sees, for the third time
and now at the source rather than in a template. Falkenstein is how an
operator places an instance; a customer's processing record names the
jurisdiction, and putting the building in customer copy means editing that copy
every time the estate grows.

Also swept the views onto the new scale: --text-faint is 2.8:1 and a decoration
token, but it was carrying table headers, hints and timestamps — text people
have to read. All of it moved to --muted, which is 5.0:1 and passes AA.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 16:09:39 +02:00
nexxo 6376f5e9fa One design system for every surface
The console was a second product. admin-tokens.css held a complete dark set —
its own surfaces, its own orange, its own status triad — so every shared
component had to work in two worlds, and the two halves of the application
drifted apart in ways nobody could see from inside either one. That file now
carries density and nothing else: smaller type, tighter rows, tighter corners.
Colour comes from one place.

The tokens themselves are rebuilt around what the redesign settled on:

- Radii scaled to object size (9/11/16/22) instead of one value everywhere,
  which is what made the interface read as a design-system specification
  rather than as a product.
- Warm, broad, low shadows. A neutral grey drop shadow belongs to a different
  design language and reads as cold on this ground.
- --accent-press, which several rules already referenced and nothing defined.
  An undefined var() does not make a browser skip the declaration; it computes
  to , and for background-color that is transparent — the accent button
  lost its fill on hover and left white text on white.
- No serif. A serif headline is what made every page read as a document, which
  is the impression the whole redesign exists to remove. --font-serif now
  points at the sans so nothing breaks while call sites are cleaned up.

The two shells are one shell with two configurations, and both finally have a
mobile navigation: the sidebar used to simply disappear below 900px, leaving no
way to move around the application at all on a phone.

The customer dashboard is bound to real records — instance, seats, traffic,
backups, contract — instead of the fixtures it shipped with. Where something is
not measured, storage consumption, it says what was contractually agreed rather
than inventing a figure. This is the sheet a customer forwards to their auditor.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 16:03:47 +02:00
nexxo be082b7bf9 Stop iOS zooming the page on every tap into a field
tests / pest (push) Successful in 7m9s Details
tests / assets (push) Successful in 20s Details
tests / release (push) Successful in 4s Details
Safari zooms when a focused input is smaller than 16px, and then leaves the page
zoomed — so on an iPhone every form throws the layout about, and the way back is
a pinch. The usual fix is the meta-viewport switch, which suppresses the zoom by
disabling pinch-zoom entirely: it takes a real accessibility feature away from
everyone to solve a cosmetic problem for some. Sizing the fields at 16px on
touch devices removes the reason instead.

!important on purpose: it has to beat every text-size utility on every field in
the application, and a rule that loses to `text-sm` on one form is worth
nothing. Checkboxes and radios are left alone — no text to inflate, and sizing
them there breaks the box.

Also -webkit-text-size-adjust, so iOS stops inflating body copy in landscape.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 14:59:12 +02:00
nexxo b85c12e8b9 Give the readiness probe long enough for a restart to finish
tests / pest (push) Successful in 7m13s Details
tests / assets (push) Successful in 19s Details
tests / release (push) Successful in 3s Details
The gateway is restarted twice in a deployment — once by `up -d`, then again
after the hub whose network namespace it lives in — and `docker compose exec`
into a container that is still coming up fails outright rather than waiting.
Twenty seconds did not cover that, so the first run of the new probe reported a
gateway that answers as down, and the resolver stayed out of client configs for
one more deployment.

The health site also now binds the hub address instead of only matching on it.
Nothing was published either way, but the file should do what its comment says.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 14:46:47 +02:00
nexxo 3699c7cdb9 Ask the tunnel gateway where it actually listens
tests / pest (push) Successful in 7m15s Details
tests / assets (push) Successful in 20s Details
tests / release (push) Successful in 5s Details
The console has been unreachable over the VPN, and the cause was the readiness
probe rather than the tunnel. The gateway binds to the WireGuard hub address
alone; the probe asked 127.0.0.1, where nothing has ever listened. It could only
fail, so VPN_READY stayed false, and on that basis the application withheld the
`DNS = 10.66.0.1` line from every client config it issued. A phone then built
the tunnel, asked its normal resolver for the console hostname, got the public
address and had its connection closed — indistinguishable from "this site does
not exist", which is exactly what it looked like.

Probing over TLS on the bare address would not have worked either: the site
matches on the console's hostname, so a request without SNI is offered no
certificate. The gateway now answers a plain-HTTP health port on the hub
address, which removes TLS, SNI and name resolution from the question and
answers only what is being asked — is this gateway listening, in this network
namespace, right now. Caddy refuses to start when the certificate is unreadable,
so a health port that answers still proves the whole file loaded.

Also here, all found while looking:

- Icons pushed their label onto a second line and rendered a size larger than
  asked for. Tailwind's preflight makes an svg display:block, and `.size-4` and
  `.size-5` have equal specificity, so stylesheet order decided — and it emits
  size-4 first. Every icon written as 16px was silently 20px. Recorded as R18.
- Four error pages printed `errors.404.hint` in place of a sentence: the lang
  files give those codes a null hint and Laravel returns the key for a null line.
- The Developer role had no label in either language, so the dropdown showed
  `admin_settings.role_developer`.
- The secrets area held one key, which is not worth a password gate. It now
  carries the credentials that actually stop the business when they expire —
  DNS, monitoring, SMTP — and the test button appears only where a checker
  exists, instead of reporting on Stripe whatever was being looked at.
- The update button said nothing about when a queued run would start or where a
  running one had got to; both are now shown, and a failure names its step.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 14:43:23 +02:00
Claude 2bb1d7cc3b Make the private hostnames look like nothing is there, and close the way past the proxy
tests / pest (push) Successful in 6m56s Details
tests / assets (push) Successful in 27s Details
tests / release (push) Successful in 4s Details
An empty 404 is itself information: it says something terminates TLS here and
chose not to answer. The console and the websocket endpoint now close the
connection instead, which is what a hostname that serves nothing looks like.
The websocket endpoint answers a genuine upgrade — verified live, 101 — and
nothing else; a browser opening it gets a closed connection.

Reviewing that turned up two holes that mattered more than the thing being
reviewed.

The compose defaults published the application and Reverb on every interface.
Docker publishes ports ahead of UFW, so those backends were reachable from the
internet with the firewall closed — and reaching a backend directly skips the
proxy, and with it every hostname and address rule keeping the console private.
The defaults are loopback now, and an update rebinds an existing installation
that is behind a proxy actually running and holding 443. A development box
without one is left alone, because taking its port away would look like the
machine had broken.

And the console's own "open" switch was emitting 0.0.0.0/0 into the proxy's
allowlist. One click would have put the console on the public internet, which
is the exact state the owner's rule exists to prevent. Switching it off relaxes
the application's check; the proxy keeps its list, always.

The reference proxy config carries the arrangement: QUIC early data off, since
an address-based decision taken on 0-RTT can answer 425 and some browsers do
not retry — an intermittent lockout from the console is the worst kind — and
explicit handling for plain HTTP, whose automatic redirect otherwise announces
both hidden names to anyone who asks.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 11:26:48 +02:00
Claude c4649571df Wait for the tunnel interface, and prove the gateway answers
tests / pest (push) Successful in 7m23s Details
tests / assets (push) Successful in 25s Details
tests / release (push) Successful in 5s Details
Two things reported success while the tunnel console was unreachable.

The services were restarted the moment the hub container counted as started,
but the hub brings wg0 up as part of its start command — so they were binding an
address that did not exist yet. They now wait for the interface.

And readiness was a container-state check. A container can be up and running
while the process inside it listens in a network namespace that was torn down
underneath it: nothing errors, the address simply refuses connections, and
"running" reports everything fine. It asks the gateway now, and only a real
answer counts — because the consequence of getting this wrong is a client
config naming a resolver that is not there, which takes the device's whole name
resolution with it for as long as the tunnel is up.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 10:24:29 +02:00
Claude d2d79b4f5f Restart the tunnel services after the hub they live inside
tests / pest (push) Successful in 10m42s Details
tests / assets (push) Successful in 18s Details
tests / release (push) Successful in 6s Details
Both VPN services share the provisioning container's network namespace, and a
process holds the namespace it started in. Restarting the hub — which every
deploy does, to pick up new code — therefore leaves them listening inside a
namespace that no longer exists.

Nothing errors. The containers stay up, Caddy's log still reports it is serving
on the tunnel address, and connections to that address are simply refused. It
looks precisely like a gateway that never worked, which is how it presented on
the live server after the first successful start.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 10:22:19 +02:00
Claude 32ae64d5fd Pin the resolver to a tag that exists
tests / pest (push) Successful in 9m52s Details
tests / assets (push) Successful in 21s Details
tests / release (push) Successful in 4s Details
The dnsmasq image was pinned to 2.90-r0, which is not published. The gateway
therefore never started — correctly reported as not ready, so no client was
handed a resolver that was not there, but the tunnel console did not come up
either.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 10:17:20 +02:00
Claude 457eeeeaef Serve the console inside the tunnel, without publishing the internal network
tests / pest (push) Successful in 10m12s Details
tests / assets (push) Successful in 21s Details
tests / release (push) Successful in 5s Details
The owner's phone shows a blank page on the VPN. That page is the reverse proxy
refusing the request, and it refuses because the phone is not on the tunnel: the
client config routes only the management subnet, so a request to the console's
public hostname leaves over the mobile network and arrives from a carrier
address. It is also why the peer reads "last contact: never" — WireGuard only
handshakes when it has traffic to send, and nothing is ever routed in.

The obvious fixes are both wrong. Adding the server's public address to
AllowedIPs routes the WireGuard endpoint into the tunnel it is trying to
establish. A public DNS record pointing at 10.66.0.1 publishes the internal
subnet to anyone enumerating the domain — the owner raised that himself, and he
was right.

So the console is served INSIDE the tunnel instead. A small resolver and a small
gateway share the hub container's network namespace, which is where wg0 lives,
and answer on 10.66.0.1 directly. A client therefore needs no route beyond the
subnet it already has, the host's docker bridge is never exposed, and the
request reaches the application with its real 10.66.0.x source — which is what
the console's own allowlist checks. The gateway reuses the certificate the
public Caddy already renews: a certificate is bound to the name, not to the
address serving it, so nothing new is issued and nothing internal reaches a
public zone.

Most of this commit is the arithmetic of not lying about it. Review found
fourteen ways the arrangement could report itself working while it was not:
enabling the compose profile after the services were started rather than before;
starting a gateway with empty certificate paths, which can only crash; treating
"container created" as "container running"; carrying a stale readiness marker
through a failed restart; reusing the previous hostname's certificate after a
rename; leaving the profile enabled when the hostname was cleared, in a script
that exits early precisely when nothing else would fix it; reading the renewal
timestamp as a user who cannot traverse Caddy's storage; and a `find | head`
that aborts the whole installer under pipefail the moment two issuers hold a
certificate for the same name.

One of them was mine and worth naming: to let the container read the
certificate, I had made the TLS private key world-readable. On a multi-user host
that hands the console's identity to every local account.

The client is told about the resolver only when both services are confirmed
running, because a config naming a resolver that does not exist takes the
device's entire name resolution with it for as long as the tunnel is up.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 10:14:33 +02:00
Claude 99715989aa Dress the placeholder page, and record why the obvious VPN fix cannot work
tests / pest (push) Successful in 8m55s Details
tests / assets (push) Successful in 20s Details
tests / release (push) Successful in 4s Details
The page shown while the site is hidden was still in the old style — a coloured
square and system fonts — on the two hostnames a visitor is most likely to try.
It now uses the site's own plate, typeface and registration marks, still
entirely self-contained because it is shown precisely when the asset build may
not exist. Both it and the error pages lighten the accent in dark mode: the tone
is chosen for contrast against paper, and on the ink plate the small text is the
first thing to stop being readable.

The VPN investigation ended somewhere useful, and not where it started.

The blank page on the phone is the reverse proxy refusing the request — `respond
404` sends no body. It refuses because the phone is not on the tunnel: the
client config lists only the management subnet in AllowedIPs, so a request to
the console's public hostname goes out over the mobile network and arrives from
a carrier address. It also explains "last contact: never", since WireGuard only
performs a handshake when it has traffic to send, and nothing is ever routed
into the tunnel.

The obvious fix — add the server's public address to AllowedIPs — is written
down here as a comment and a test rather than as code, because it cannot work.
The WireGuard endpoint is that same address, so routing it into the tunnel
routes the handshake packets into the tunnel they are trying to establish. The
result is a loop and a connection that never comes up: the same blank page, now
with no way in at all.

Reaching the console over the VPN needs an address INSIDE the subnet — the proxy
answering on the hub address, and a name that resolves to it. That is a
deployment change, not something a config line can express.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 09:19:30 +02:00
Claude 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>
2026-07-27 09:01:16 +02:00
Claude f6b9181ed8 Give customers two-factor, and stop every button on the settings page reacting at once
tests / pest (push) Successful in 7m55s Details
tests / assets (push) Successful in 20s Details
tests / release (push) Successful in 6s Details
Clicking Save made every button on the console's settings page appear to fire.
Seven `wire:loading.attr="disabled"` had no `wire:target`, and without one
Livewire applies the loading state to ANY request on the component — so one save
put all of them into their disabled state simultaneously. Each names its own
action now.

Two-factor for customers. Fortify's endpoints already existed; only the screen
was missing. Setting up requires re-entering the password first, and every
action re-checks that server-side rather than relying on the button not being on
screen — a Livewire action is reachable by anyone who can post to
/livewire/update. The confirmation marker is Laravel's own session key, so it
and the framework's password.confirm middleware mean the same thing rather than
drifting apart.

The secret never enters a Livewire property. Component state travels to the
browser and back in the snapshot; the QR image is derived from the secret, the
secret is not in it — and there is a test that says so.

The status page moves to the ROOT of its hostname: status.clupilot.com/status
says the same word twice. That needed the domain-bound `/` registered BEFORE the
landing page, because Laravel takes the first match and the landing route is
host-agnostic — so the status host would have served the marketing site.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 08:48:34 +02:00
Claude 6082164f8b Give the status page its own hostname, and name the right one for Stripe
tests / pest (push) Successful in 7m43s Details
tests / assets (push) Successful in 20s Details
tests / release (push) Successful in 5s Details
STATUS_HOST binds /status to a single hostname. Every other host redirects
there rather than 404ing: a status page is the one address people keep in a
bookmark and reach for when something is already wrong, so breaking old links
to prove a point about separation would be exactly backwards. Unset, nothing
changes.

The /legal/status redirect goes through route() instead of a literal path. A
relative redirect lands on whatever host the visitor is already on — which,
once the page has a hostname of its own, is precisely where it no longer
answers.

The installer told the operator to point Stripe's webhook at the customer
portal. Stripe posts server-to-server and never sees the portal; the endpoint
is the api hostname, which the installer never even asked for. It asks now, and
prints the address that actually receives the events. Getting this wrong does
not fail loudly — payments simply stop being recorded.

It also writes ADMIN_HOST_EXCLUSIVE=false on a fresh install. Switching the
console onto its own hostname before the DNS for it exists makes the console
unreachable under any other name, and that is not a thing to have on by default.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 08:08:57 +02:00
Claude 935c9ae6ac Let people change their own password, and a location be switched off while editing it
tests / pest (push) Successful in 7m29s Details
tests / assets (push) Successful in 19s Details
tests / release (push) Successful in 4s Details
Three gaps, all of the "half-built" kind.

Nobody could change a password. Fortify's updatePasswords feature was switched
off, so an account created with a generated password kept it until someone
opened a shell on the server — including the owner's own. Both settings pages
have the form now, sharing one concern, because two copies of a password rule is
how one of them ends up weaker. It goes through Fortify's own action rather than
hashing here, and the current password is required: an unlocked machine should
not be two keystrokes away from locking its owner out.

The datacenter edit form had no active switch. The column and the scope existed,
and the list page has a toggle — but the form you open to change the thing did
not offer the one lifecycle action a location actually needs. Switching one off
now says what it does NOT do: existing hosts keep running. Once, on the actual
transition, and as the only message — the generic "saved" toast would otherwise
replace it, and an already-inactive location would have announced its own
deactivation every time its name was edited.

The staff table's actions cell is empty for your own row, because you cannot
revoke yourself. With a one-person team the column was therefore always blank
and read as broken rather than as a rule.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 07:49:07 +02:00
Claude bc8bbc56a5 Make the console's access list reach the proxy, price in euros, and answer errors
tests / pest (push) Successful in 7m4s Details
tests / assets (push) Successful in 19s Details
tests / release (push) Successful in 4s Details
The console was unreachable, and not for the reason it looked like. Two causes,
both mine.

A failed `artisan optimize` — the one that hit duplicate route names — left a
broken route cache behind, so every request to the console host answered 500.
Rebuilt.

Underneath that: the reverse proxy has its own console allowlist, hard-coded,
and it runs BEFORE the application. Everything added on the console's own
access page was therefore ineffective, and when the owner's address changed
they were turned away by the proxy before reaching the page that would have
fixed it. The proxy now imports a fragment generated from the same list the
console manages, regenerated by the host agent and reloaded when it changes.

Getting that safe took most of the review. It refuses to rewrite an ambiguous
Caddyfile rather than replacing some other site's matcher; it never falls back
to a loopback-only list when the application cannot be reached, because that
list validates cleanly and locks out every remote operator; it retries a reload
that failed instead of assuming it worked; and an installation that upgrades
without rerunning the installer is told, because otherwise the whole mechanism
is invisibly absent.

Prices are entered in euros. The form asked for cents, so €799 was typed as
79900 and one slipped digit was a factor of ten on an invoice. Conversion
happens in one place, on the string rather than through a float — (float)
'79.90' × 100 is 7989.999… and casting truncates to 7989, one cent short on
exactly the prices people charge — and it refuses an amount the column cannot
hold instead of failing at the database.

Every error code has a page now, in the site's own language and typeface,
self-contained so it still renders when the asset manifest is the thing that
broke. There was only a bare white 404.

The VPN list stops being a seven-column table nobody could fit: names broke
across two lines and so did the headings. These are attributes of one access,
not quantities compared down a column, so each access is a row — identity on
one line, measurements in mono on the next.

The status page says what it measures rather than what it promises. "New orders
are delivered without failures" read as a marketing claim on a page whose only
job is to be believed, and said nothing about the last 24 hours.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 06:51:05 +02:00
Claude 233fc73430 Let the console keep its recovery hostnames without breaking route caching
tests / pest (push) Successful in 7m22s Details
tests / assets (push) Successful in 24s Details
tests / release (push) Successful in 4s Details
Registering the console once per accepted hostname reused one set of route
names, and Laravel refuses to serialise two routes under the same name — so
`artisan optimize` failed on the live server the moment the separation was
switched on, mid-deploy.

Only the canonical registration carries the `admin.` names now; the alternates
answer under `admin.viaN.*`. They exist to be MATCHED — they are the addresses
someone locked out reaches for — never to have URLs generated for them, so
route() keeps producing the canonical hostname.

That renaming then broke every exact route-name check, which is how the console
navigation decides what is active: reached through a recovery address, nothing
in the sidebar was marked, exactly when something is already going wrong. The
check goes through AdminArea now and matches either form.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 06:20:44 +02:00
Claude 492b1925fb Land a sign-in where it was performed, not always in the customer portal
tests / pest (push) Successful in 7m42s Details
tests / assets (push) Successful in 21s Details
tests / release (push) Successful in 6s Details
Fortify sends every successful sign-in to config('fortify.home') — /dashboard.
That is why an operator signing in ended up in the customer portal, and once
the console has a hostname to itself it stops being merely confusing:
/dashboard does not exist on that host, so signing in would land on a 404.

Three outcomes now, decided by where the sign-in happened and who signed in:
the console for an operator on the console, the portal everywhere else, and for
a non-operator on the console the session is taken away again at the moment it
was created — a guard that merely refuses each page afterwards leaves that
session in the browser.

Four things the review caught, each of which would have left a hole:

- Fortify never reaches LoginResponse when a two-factor challenge was involved.
  Binding only that one left exactly the accounts most likely to be operators
  on the old behaviour, so both exits are bound and share one decision.
- The JSON branch ran before the authorization check, so a JSON client kept a
  session a browser would have lost.
- The two exits do not share a JSON success contract — an ordinary sign-in
  answers {"two_factor":false}, a completed challenge answers an empty 204 —
  and merging them breaks a client keying off the status code.
- In shared mode everyone posts to /login, so the request never looks like the
  console even when /admin is the destination. The intended URL is read too.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 06:15:16 +02:00
Claude 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>
2026-07-27 06:05:40 +02:00
Claude 276f926d57 Add the update button, and give it a host-side agent that can actually update
tests / pest (push) Successful in 7m41s Details
tests / assets (push) Successful in 20s Details
tests / release (push) Successful in 4s Details
The panel had no way to update the installation, and could not have one on its
own: it runs as www-data inside a container, and the update restarts that
container. A button that shelled out would kill its own web server
mid-response, and would need the application to hold host-level credentials —
a far worse thing to own than an out-of-date checkout.

So the button is a mailbox. The panel writes a request into the checkout; a
systemd timer on the host, running as the service account, consumes it, runs
deploy/update.sh and writes back what happened. The same timer answers the
question the panel cannot answer alone — is there anything to update — which
needs a git fetch, and therefore credentials the application does not have.

The parts that were wrong before review, all of which would have shipped as a
button that looks fine and does nothing:

- The agent was only installed by install.sh, which the installed base never
  runs again. Every existing server would have shown the button permanently
  disabled. It has its own root entry point now, install.sh delegates to it,
  and update.sh says so when the unit is missing.
- On a release-pinned server the checkout is detached, so it followed
  "origin/HEAD" and then ran an update that deliberately stays on the pinned
  tag: updates advertised, nothing applied. It now compares TAGS in release
  mode and passes the target release through.
- On a branch other than main it advertised that branch's commits and then
  deployed main's, because update.sh defaults to main.
- A request written while the agent was down was executed whenever the agent
  next started, days later.
- A single status file meant the routine five-minute check overwrote a failed
  update with "idle" before anyone saw it.
- An agent that had been stopped left the button enabled forever, because a
  status file written once counted as an agent.
- The agent's error messages were German strings rendered into an English
  interface; it reports codes now and the panel translates them.

Also: the add-address button in the console-access panel was as tall as the
field plus its hint, because the hint was a sibling inside the flex row.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 05:33:17 +02:00
Claude b860ce2e9b Give the three console guards one answer to "is this the console?"
tests / pest (push) Successful in 7m44s Details
tests / assets (push) Successful in 19s Details
tests / release (push) Successful in 3s Details
The hostname guard, the network allowlist and the public-site switch each
decided for themselves, by testing the path against admin/*. That works only
while the console sits under /admin. It is also a trap: the moment the console
moves to the root of its own hostname, PublicSiteGate stops recognising it, and
with the public site hidden the console answers 503 to the very person trying
to sign in and switch it back on. The guard does not fail loudly — it silently
stops matching.

AdminArea is now the single answer. It has two modes and no third: a console
hostname is configured, in which case the console IS that host and answers at
its root; or nothing is configured, in which case the console stays under
/admin on any host exactly as before, so upgrading cannot lock anyone out of a
system that was working.

RestrictAdminHost gains the half it was missing. Binding console routes to a
hostname does not stop the customer routes from answering there too, because
they are registered without one — so the console's hostname would still serve
portal pages wherever the paths did not collide. It now enforces both
directions, with the endpoints both sides genuinely share written out as a list
rather than inferred.

Nothing changes yet for an installation with no ADMIN_HOSTS set, which is every
development machine and every fresh checkout.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 04:52:21 +02:00
Claude 30a80b6c15 Report the estate from the database, and print the traffic that is sold
Three console pages were fiction. The front page claimed 42 customers, 39
instances, four hosts named pve-fsn-1..3 and €7,842 a month, over a twelve-month
growth curve; the instance list held seven invented machines; the revenue page
reported churn and a trend for a business with no recorded history. All of it
was hard-coded. It read like a running company and measured nothing.

They now read the database. Two figures are gone rather than approximated —
the revenue trend, which needs a monthly history nobody records, and churn,
which needs a base the data cannot supply. ARR stays, labelled as the
projection it is. The green "all systems normal" badge is computed from the
notice list instead of asserted, and the notices themselves come from failed
runs, hosts reporting errors or gone quiet, and monitoring that is down.

Host load is the one number that had to agree with something else: placement
counts the VM disk allocation, ignores a failed instance that never got a VM,
and subtracts the host's reserve. A dashboard doing its own arithmetic would
show a host as comfortable while orders were already being refused on it, so it
uses the host's own accounting — with the filter moved into a scope both share,
and the sum preloaded so listing hosts stays one query.

The instance list drops the Nextcloud version column: that version is not
recorded anywhere, and a column filled with a plausible number is worse than no
column. Statuses the lifecycle writes but nobody had translated no longer
render as "admin.status.failed".

The price sheet also gains the included traffic, which the catalogue has always
carried and the page simply never printed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 04:48:51 +02:00
Claude b844ff377d Rebuild the public site as a document, and read prices from the catalogue
tests / pest (push) Successful in 7m30s Details
tests / assets (push) Successful in 19s Details
tests / release (push) Successful in 4s Details
The marketing page was a generic centred hero in the system font stack: it
neither used the design system the console is built on nor looked like the
product it sells. Rebuilt around what CluPilot actually promises — security you
can produce evidence for — so the page reads as a controlled technical
document: a specification plate instead of a hero image, numbered sections, an
audit register with rhythm and proof per measure, and a price sheet rather than
floating cards. IBM Plex Serif joins Sans and Mono as the display voice, self
hosted as static files so the public page renders even without the Vite build.

The prices were written into the page by hand, and the page and the catalogue
had already drifted apart on three of four plans: the site advertised 249 € for
a plan the checkout charges 399 € for. The sheet now reads the catalogue, like
every other caller — including the currency, which is configurable and was also
hard-coded here.

The catalogue fails loudly on purpose; a public website must not. A broken or
overlapping catalogue is caught in the controller alone: the page still
renders, the sheet is replaced by "on request", and nobody is quoted a number
the checkout would not honour.

The sign-in and registration panels shared a copy-pasted orange gradient. They
now share one component and the same ink plate as the site, so the two halves
of the product no longer look like two companies.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 03:52:25 +02:00
nexxo 0066b1c6a0 feat(security): a way back into the console that does not need the console
tests / pest (push) Successful in 7m28s Details
tests / assets (push) Successful in 19s Details
tests / release (push) Successful in 4s Details
The allowlist is managed in the console, which is fine until the address you
manage it from changes — and then the page that would fix the problem is the
page the problem blocks. Every gate needs a door that does not depend on
itself, and on a server that door is a shell:

  php artisan clupilot:console-access show
  php artisan clupilot:console-access allow 203.0.113.7
  php artisan clupilot:console-access open

The address check moved to RestrictConsoleNetwork::isNetwork() so the command
and the console apply the same rule. Codex caught the version that did not: a
typo like 203.0.113.9/99 was stored, reported as success, and matched nothing —
leaving whoever was recovering still locked out, now believing they were not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 03:09:13 +02:00
nexxo 9ccd4f59d8 feat(security): the console decides who may reach it, and the owner keeps the list
tests / pest (push) Successful in 6m53s Details
tests / assets (push) Successful in 18s Details
tests / release (push) Successful in 3s Details
RestrictAdminHost answers under which NAME the console responds — and the
caller picks the Host header, so it can never answer who is asking. This does:
a network gate on the client address, which behind a trusted proxy is not
something the client chooses.

The management VPN is always in the list and cannot be removed — it is the one
way in that survives a bad entry. Beyond that the owner keeps their own
addresses in the console, because being away from the VPN should not mean being
locked out, and the person who needs to change that list is the person sitting
in front of it.

Two refusals rather than warnings, since by the time a warning renders the
request that would show it has already been rejected: switching the restriction
ON is refused unless the address doing the switching is already covered, and
removing the entry you are sitting behind is refused. Entries are validated as
an address or CIDR — a typo that matches nothing is how someone locks
themselves out while believing they have not.

404, never 403.

Registered as persistent Livewire middleware, and verified in a browser that it
holds there: with the settings page open, narrowing the list turned the next
action from 200 into 404. Codex read the path guard as skipping
/livewire/update; Livewire in fact replays middleware against a duplicate of
the request carrying the original component's path, so the guard matches and
the client address is preserved.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 20:27:26 +02:00
nexxo 5c2e481985 fix(security): console and account checks now survive a Livewire action
tests / pest (push) Successful in 7m8s Details
tests / assets (push) Successful in 20s Details
tests / release (push) Successful in 4s Details
Livewire re-applies only the middleware on its persistent list when an action
posts to /livewire/update. Its defaults cover auth; ours were not on the list —
only RestrictAdminHost had been added. So EnsureAdmin and EnsureCustomerActive
guarded the PAGE and not the actions, and the page is not where the actions run:
a signed-in non-operator who could reach the admin host could drive console
components, and a suspended customer could keep driving the portal.

Found by Codex while reviewing the domain-separation design, and confirmed
against Livewire's own default list in vendor.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 20:04:34 +02:00
nexxo 5d64da1cb3 docs: hiding the site also hides the login page, and that has a consequence
tests / pest (push) Successful in 7m2s Details
tests / assets (push) Successful in 20s Details
tests / release (push) Successful in 3s Details
The gate exempts admin/* so the console keeps working, but /admin sends a guest
to /login and /login is not the console — so with the site hidden and no VPN
yet, an operator cannot sign in to flip the switch back. Found while bringing
up the live server.

The mechanism for it already exists and is the right one: TRUSTED_RANGES. What
does NOT work is exempting the login flow by hostname, which was the obvious
patch — a Host header is chosen by the caller, so one forged header would have
lifted the gate for every route, portal included. Codex caught that; the comment
now says why the narrow-looking option is the wrong one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 19:27:51 +02:00
nexxo 875fd11982 fix(deploy): an answer file with a space in a value killed the install
tests / pest (push) Successful in 7m2s Details
tests / assets (push) Successful in 19s Details
tests / release (push) Successful in 3s Details
The file is sourced, so ADMIN_NAME=Boban Blaskovic is read as a command and
dies with "Blaskovic: command not found" — naming neither the file nor the
variable — and set -e ends the install there. A display name with a space is
entirely ordinary; the example just happened to use "Administrator", one word,
so nobody hit it.

Found while installing on the live server.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 18:49:58 +02:00
nexxo 9352935b88 fix(nginx): the live hostnames were missing from the admin denylist
tests / pest (push) Successful in 6m56s Details
tests / assets (push) Successful in 21s Details
tests / release (push) Successful in 6s Details
The map listed only *.dev.clupilot.com, with the production names commented out
under "add the real public hostnames here before launch". Launching without
them removes the outer of the two layers that keep /admin off the public
internet — the ADMIN_HOSTS check in the app would still answer 404, but the
whole point of the pair is that neither is relied on alone.

admin.clupilot.com stays out of the list on purpose: the list names the hosts
that are PUBLIC, and the console is not one of them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 18:43:44 +02:00
nexxo 35307e64cc fix(deploy): the installer could never finish, twice over
tests / pest (push) Successful in 6m55s Details
tests / assets (push) Successful in 22s Details
tests / release (push) Successful in 5s Details
Found by running it on a fresh Debian 12 with systemd, no docker, no git and no
users — which is the first time it has ever been run anywhere.

`optional_env` ended in `[[ -n "$2" ]] && set_env …`. With an empty value the
function returns 1, and under `set -e` that ends the install, silently, with a
half-written root-owned .env. Absent is the NORMAL case for those variables —
they are the optional ones — so this killed every installation that did not
happen to supply a Hetzner token. An `if` returns 0 and does not.

Then it waited for `php -v` before migrating. PHP answers in seconds; the
entrypoint is still running `composer install` on a fresh checkout, which takes
minutes. So `artisan migrate` ran against a checkout with no vendor/ and died
on a missing autoload.php. It now waits for vendor/autoload.php, exactly as the
dependent containers already do, and says where to look if it never appears.

After both: exit 0, 59 migrations applied, the plan catalogue seeded and
consistent, the Owner account created with its role, and the portal answering
200 while the console redirects to login and the unconfigured Stripe webhook
fails closed with 400.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 17:42:26 +02:00
nexxo e9240c1324 feat(deploy): releases you can pin to, and a version that tells the truth
VERSION at the repository root is the release number and the only place it is
written down — a file rather than `git describe`, because a source archive, a
shallow CI checkout and a container built without .git have no tags to
describe. Cutting a release is editing that file and merging it; CI creates the
annotated v<VERSION> tag once green, only on the commit that raised the number,
and never moves it. Servers are pinned to these.

Two modes, and the difference is the point. RELEASE=v1.0.0 pins a server to an
immutable tag, checked out detached: it moves when someone decides it moves.
BRANCH=main follows the edge, where CI tags a commit only after it is pushed.
The mode is remembered, so re-running the updater on a pinned box does not
quietly walk it back onto main.

Going backwards is refused, by commit ancestry rather than by comparing version
strings — a tag can be cut from anywhere, and only ancestry says whether history
is going back. The schema has already moved forward by then, and the migrations
needed to reverse it are not in the older checkout at all.

What the console reports comes from a manifest written atomically after every
step succeeded, never from live git: git says what the files are, not whether
the deployment came up. So a failed update keeps reporting the version that is
actually serving — including its version number, not the newer one the checkout
has already moved to. A deployment pinned to the tag reads "1.0.0 (abc1234)";
anything else reads "1.0.0-dev (abc1234) · main", because every commit after the
tag still carries VERSION=1.0.0 and is not that release. Reporting it as one is
how a bug gets filed against the wrong code.

Codex reviewed the design before it was written and the result six times after.
Its findings, all real: the version had to come from the manifest too, not just
the commit; branch names need JSON escaping or the manifest silently vanishes; a
`case` glob does not anchor and accepted 1x.2y.3garbage; pinning to the commit a
server already sits on skipped recording the mode, and then skipped detaching;
a manifest that failed to write would never be repaired; an unparseable
timestamp would 500 every admin page; and an empty tag list read as a
connectivity failure.

466 tests green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 15:21:38 +02:00
nexxo c119e8d96e fix(deploy): say which branches exist instead of failing inside git
The installer clones BRANCH, which defaults to main — and the repository has
no main. Git's own "Remote branch not found" reads like a bad token or an
unreachable server, and the branch is the one thing here that is routinely
wrong. It now asks first and, when the branch is missing, prints the ones that
do exist and how to pass one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 14:48:49 +02:00
nexxo 4f3e11ae5e docs(deploy): the installer's Stripe steps match what phase 5 actually needs
It still named two webhook events; the application handles six, and the four
new ones are the billing cycle itself — renewals, failed payments, status
changes and endings. It also said nothing about stripe:sync-catalogue, without
which Stripe has no prices and nothing can be sold at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 14:46:25 +02:00
nexxo 8c3a79e258 fix(billing): hold only what cannot be matched, and let migrations roll back
Two findings from reviewing all five phases together rather than one at a time.

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

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

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

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

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

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

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

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

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

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

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

457 tests green. Codex review clean after seven rounds.

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

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

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

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

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

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

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

436 tests green. Codex review clean after seven rounds.

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

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

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

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

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

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

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

421 tests green. Codex review clean after six rounds.

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 12:05:56 +02:00
nexxo 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>
2026-07-26 11:10:00 +02:00
nexxo 0dde76ad55 docs: handoff for the plan catalogue rebuild
tests / pest (push) Successful in 6m57s Details
tests / assets (push) Successful in 19s Details
tests / release (push) Has been skipped Details
Design is decided and Codex-reviewed; none of it is built. Records the live
split-brain bug to fix first, the three-table catalogue, computed availability,
the proof register with frozen add-ons, the Stripe split, what already exists,
and the working rules that cost time this session.

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 09:49:48 +02:00
nexxo c36ea17b39 fix(billing): normalise both sides of the VAT comparison; cast the timestamp
tests / pest (push) Successful in 6m45s Details
tests / assets (push) Successful in 22s Details
tests / release (push) Has been skipped Details
A verifier that returns the number in display form ("DE 811 907 980") would
have failed the comparison against the normalised current value and silently
switched a genuine reverse-charge customer back to domestic VAT. Both sides are
normalised now, and vat_id_verified_at is a real datetime rather than a string.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 09:36:07 +02:00
nexxo 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>
2026-07-26 09:34:40 +02:00
nexxo 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>
2026-07-26 09:32:42 +02:00
nexxo 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>
2026-07-26 09:30:17 +02:00
nexxo ac3f429958 fix(billing): round the recurring figure the same way as the total
tests / pest (push) Successful in 7m33s Details
tests / assets (push) Successful in 21s Details
tests / release (push) Has been skipped Details
The note rounded VAT after aggregating while the total rounded per order, so
cent-level prices made the two disagree — and a customer who spots that stops
trusting every other number on the page.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 09:27:02 +02:00
nexxo 9fab251fb3 fix(billing): make the one-plan-change rule hold under two clicks
tests / pest (push) Successful in 7m28s Details
tests / assets (push) Successful in 21s Details
tests / release (push) Has been skipped Details
Both requests could finish their delete before either inserted, leaving exactly
the two pending upgrades the rule exists to prevent. Replacement and insert now
run in one transaction with the customer row locked.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 09:25:29 +02:00
nexxo a87a4f512d feat(billing): one plan change at a time, and every price says what it is
Two contradicting plan upgrades sat in the cart at 799 and 399 € — no checkout
could resolve which one the customer meant. Choosing another now replaces the
pending one and says so; add-ons still stack, because buying 200 GB as two packs
is a sensible thing to want.

Every price now states net or gross and how often. The cart shows net per line
with "pro Monat" or "einmalig", then subtotal, VAT and gross — and separates
the monthly recurring amount from a one-off traffic top-up sharing the same
cart, because those are two different commitments. The rate is configurable
(CLUPILOT_TAX_PERCENT, 20 % default) since it follows the seller's country.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 09:23:49 +02:00
nexxo 55b1a1468f fix(admin): the admin password is shown once, and means it
tests / pest (push) Successful in 7m37s Details
tests / assets (push) Successful in 22s Details
tests / release (push) Has been skipped Details
The handoff stayed in the cache for its full ten minutes, so any replayed
component request could fetch the plaintext again — "shown once" was a figure
of speech. It is consumed on the first read and the token dropped; the payload
reaches the view and nothing else.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 05:34:57 +02:00
nexxo d40113be8e fix(admin): never reset a stranger's VM, never leave the console spinning
Two from Codex:

- The job accepted any stored instance with a host. A closed one's VMID can have
  been reused on the same machine, and the reset would then land on another
  customer's VM. It now requires a live instance, and the action is not offered
  for anything else.
- An unreachable Proxmox or guest agent throws rather than returning an exit
  code, so nothing was ever written to the handoff and the modal polled forever.
  The throw is caught, and a failed() handler covers anything that still escapes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 05:32:53 +02:00
nexxo aae0457e19 feat(admin): administrator access to a customer's Nextcloud
Impersonation borrows the customer's portal session; this is access to their
installation, which is a different thing and now a different button.

Nextcloud has no passwordless admin jump, so this does the only honest thing it
offers: it resets OUR managed admin account inside that installation and hands
the credentials over once. The customer's own accounts are untouched, and the
next request sets a new password again.

- New capability instances.adminlogin, Owner and Admin only — stronger than
  impersonation, because it hands over control rather than a session.
- The operator's own password is required every time, rate-limited: taking over
  a customer's installation is not something an unattended browser should manage
  on its own.
- The reset runs on the provisioning worker (it owns the tunnel and the Proxmox
  credentials); the console polls a handoff token, so the credentials never
  enter a Livewire snapshot.
- A silent instance is reported as such instead of appearing to succeed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 05:30:59 +02:00
nexxo d26200c74b feat(billing): a cart you can actually read and change
tests / pest (push) Successful in 7m10s Details
tests / assets (push) Successful in 20s Details
tests / release (push) Has been skipped Details
"5 purchases pending" told nobody what they had ordered and offered no way to
change their mind. The billing page now lists each pending purchase by name,
what it costs, when it was added, and the total — with a remove button per row
behind a confirmation, like every other destructive action in the console.

The wording lives on the Order model rather than in the view, so the cart, the
invoice list and any later confirmation mail cannot each invent their own name
for the same row. Removal is scoped to the customer's own still-pending orders,
checked in the modal itself: modals are reachable without the page's guards.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 05:26:49 +02:00
nexxo a6d7594e5c test: do not reach for Redis from the provisioning queue
tests / pest (push) Successful in 7m4s Details
tests / assets (push) Successful in 20s Details
tests / release (push) Has been skipped Details
Jobs that pin themselves to the provisioning connection bypass
QUEUE_CONNECTION, so two tests opened a Redis connection — fine on a machine
running the stack, a RedisException in CI. A test run must not depend on
infrastructure being up.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 02:20:46 +02:00
nexxo 819872bb79 fix(ci): install dependencies by cloning, not through GitHub's API
tests / pest (push) Failing after 6m59s Details
tests / assets (push) Successful in 20s Details
tests / release (push) Has been skipped Details
The dist path is rate-limited for anonymous callers, and a partial failure left
a half-installed vendor/ behind — the suite then failed with 500s that had
nothing to do with the code. Source clones need no quota. A GitHub token would
let us go back to the faster path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 02:11:52 +02:00
nexxo 7af3e2957b test: do not depend on a built asset manifest
tests / pest (push) Failing after 7m22s Details
tests / assets (push) Successful in 22s Details
tests / release (push) Has been skipped Details
CI has no build, so every page rendering through @vite threw a view exception —
dozens of failures with one cause. withoutVite() makes the suite test the
application rather than the state of the asset pipeline; a broken build shows up
in the build job, which is where it belongs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 02:03:58 +02:00
nexxo 2c0884357c fix(pages): the update and placeholder pages must not need a build
tests / pest (push) Failing after 7m49s Details
tests / assets (push) Successful in 26s Details
tests / release (push) Has been skipped Details
CI caught it: both rendered through @vite, so a missing manifest threw a view
exception instead of a page. That is not a test problem — the update page is
shown precisely while the application is mid-deploy or freshly installed, which
is exactly when the manifest may be absent. Both carry their own small
stylesheet now and render with nothing built at all.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 01:56:23 +02:00
nexxo b55a127a00 fix(ci): drop the cache steps this runner cannot serve
tests / pest (push) Failing after 7m55s Details
tests / assets (push) Successful in 25s Details
tests / release (push) Has been skipped Details
actions/cache expects a cache service the runner matching Gitea 1.20 does not
speak, and it failed the job outright. It was an optimisation; the source
fallback that makes the install work is what mattered.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 01:45:11 +02:00
nexxo 7cd3ff33bd fix(ci): survive GitHub's anonymous rate limit
tests / pest (push) Failing after 1m11s Details
tests / assets (push) Failing after 16s Details
tests / release (push) Has been skipped Details
Composer's dist downloads come from GitHub, which throttles anonymous callers —
on a shared address that is a coin flip, and it took the run down after four
minutes of setup. Source clones are the fallback, and both toolchains now cache
their package directories so a repeat run barely touches the network.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 01:39:32 +02:00
nexxo 6367f4a9fd fix(ci): pin actions to versions the runner can execute
tests / pest (push) Failing after 1m0s Details
tests / assets (push) Successful in 52s Details
tests / release (push) Has been skipped Details
The floating tags moved to node24; act_runner 0.2.6 — the version that matches
Gitea 1.20 — supports node20 at most, so every job died before running a single
test. Pinned to the newest releases that still declare node20.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 01:34:37 +02:00
nexxo 53d305340f fix(vpn): a late revocation must not disconnect whoever holds the key now
tests / pest (push) Failing after 49s Details
tests / assets (push) Successful in 51s Details
tests / release (push) Has been skipped Details
Once a key is free, someone can legitimately create a new access with it. The
forced revocation looked the key up and would have deleted that person's access
and pulled their key off the hub. It now acts only on an adoption artifact — a
system peer with no owner and no creator — and leaves anything else alone.

Re-issuing also threw when VPN_CONFIG_KEY was missing, which is exactly the
situation the console tells people to fix by re-issuing. It now hands the new
config over once instead of storing it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 01:24:55 +02:00
nexxo 4f9ca27732 fix(vpn): a replaced key must not stall the reconciliation
Codex found that a sync landing between the key swap and its removal would see
the old key as unknown. Writing the test showed something worse than the
duplicate he predicted: the adoption cannot even happen — the address still
belongs to the live access — so the insert violated the unique index and took
the ENTIRE reconciliation down with it. One stale key would have stopped every
peer's state from updating.

The sync now recognises that case and queues the removal instead of adopting,
and a key with no row of its own is revoked with force: it can only ever be
removed, never kept, so the removal cannot be talked out of it by a row that
should not exist.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 01:23:15 +02:00
nexxo 556a560506 feat(vpn): explain a missing download, and offer a way out
An access created without storing its config had no download button and no
explanation — it looked broken. It now shows a dimmed button that says why
(the private key only exists on your device), and every staff access gets
"Re-issue": a new keypair, old key off the hub before the new one goes on,
so two peers never claim the same tunnel address at once. A stored config is
re-encrypted in step, or the owner would download a key the hub no longer
accepts.

Host peers are excluded: their key belongs to the machine's own wg0, and
swapping it here would cut the host off with nothing left to repair it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 01:20:04 +02:00
nexxo e5aea84539 docs(ci): wire the runner up to the point where only the token is missing
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 01:18:42 +02:00
nexxo 419a1a8552 fix(ci): keep the workflow and runner compatible with Gitea 1.20
github.* is the documented context alias and exists in 1.20; gitea.* came
later. The runner is pinned instead of :latest — the registration protocol
moves with the server, and a newer runner refuses to register against an older
Gitea.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 01:15:45 +02:00
nexxo 8117630a65 feat(ops): update page, automatic 419 recovery, CI workflow
Three things that bit us on every deploy:

- artisan down now renders a branded page that says an update is running and
  reloads itself, instead of Laravel's bare 503.
- A page left open across a deploy carries a Livewire snapshot and CSRF token
  the new code rejects — the user got "419 Page Expired" and a dead interface.
  A 419 from a /livewire/ request now reloads the page; the session is still
  valid, so that is all it takes. Hooked at the fetch layer rather than through
  Livewire's request hook, whose failure callback is not invoked for this case
  in the installed version — verified against a real 419 in the browser, not a
  simulated one.
- Vite no longer empties its output directory: wiping it is what left an open
  page without its stylesheet mid-deploy, which is the "design is completely
  broken" symptom. update.sh also rebuilds the caches with optimize:clear +
  optimize instead of leaving them half-warm.

Plus CI: .gitea/workflows/tests.yml (Pest + asset build, and a tested- tag only
on a green main) and an opt-in act_runner service under the ci profile.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 00:58:27 +02:00
nexxo ac86efd9c3 feat(deploy): unattended install from a single answers file
--env-file makes the whole run non-interactive, which is what a bare server
needs: install git, clone, run, done. The file also carries the operational
secrets (Hetzner DNS, Stripe, SMTP, the Proxmox key path); those are optional,
so a first install can happen before the Stripe account exists and the features
stay dark until the values are filled in.

The installer warns when the file is readable by more than its owner, and
clupilot.env is gitignored — it holds every secret an installation will need.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 00:40:33 +02:00
nexxo 062d337dd0 fix(portal): move notices into the header, so the page stops scrolling twice
The banners sat outside the h-screen shell, so the page scrolled and the content
scrolled — two scrollbars whenever a maintenance window was active.

Maintenance notices are now a bell with a count in the header, next to the user
menu that was otherwise the only thing up there. Impersonation stays visible as
a header chip rather than moving into the dropdown: it is a mode, not a notice,
and acting as someone else without noticing is how mistakes happen.

Also: the VPN status pill wrapped onto two lines in a narrow column.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 00:13:20 +02:00
nexxo 3fad319053 fix(hosts): re-read the host before deciding there is no DNS record
A registration finishing while the purge waited on the run locks wrote
dns_record_id after the purge had already loaded the host, so it skipped the
deletion and then deleted the row holding the only id.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 00:02:18 +02:00
nexxo 7b57ab21cb fix(hosts): retry a failed DNS registration before advancing without a name
The failure may be a lost response to a request that did create the record.
Advancing straight away stored no id, so PurgeHost could never remove it and the
host's management address stayed published. The upsert is idempotent, so a retry
recovers the id; only after the attempts run out does the onboarding continue
without a name.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 00:01:16 +02:00
nexxo 952e8e5d2f fix(hosts): number DNS names by the normalised label, not the raw code
Two legacy codes can normalise to the same label (eu_west and eu-west), and
separate counters then handed both of them eu-west-01 — a unique-constraint
failure inside the reservation, which blocks onboarding rather than being a DNS
problem the step can shrug off. Lock, counter and the in-use check all key off
the label now.

(The helper was also called label(), which HostStep already declares as the
step's display name — renamed.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 23:59:58 +02:00
nexxo e96f4c1c1a fix(datacenters): a code becomes a DNS label, so validate it as one
alpha_dash accepted eu_west, -edge and edge-, none of which are valid DNS
labels — every host in such a datacenter would have failed registration and,
because DNS is non-fatal, silently ended up without a name. The console now
requires a proper label, and the step normalises anything created before that
rule so existing rows still get a usable name.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 23:56:50 +02:00
nexxo 69043f9ffc fix(dns): deleting a record that is already gone is success
A lost response, or a retry after a later step failed, made every further
attempt a 404 — and the host impossible to purge.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 23:54:45 +02:00
nexxo 9f8659da32 fix(hosts): a failed DNS deletion must not orphan the record
Swallowing it and carrying on deleted the row that held the only reference to
that record, publishing the machine's management address for good. The purge now
fails instead — and is retried, since it re-reads the host and its other steps
are idempotent — so a broken DNS provider becomes a visible failed job rather
than a silent leak.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 23:53:14 +02:00
nexxo edf24257b5 fix(hosts): reserve the DNS name under the lock, never reuse it, clean it up
Codex was right that my never-reuse claim did not hold:

- The lock was released with only a candidate in hand, so two concurrent
  onboardings in one datacenter could pick the same name and overwrite each
  other's record. The name is now persisted on the host while still locked.
- The number was derived from the hosts that happen to exist, so removing the
  highest one handed that number straight back out — a cached name would then
  resolve to a different machine. The counter is stored per datacenter, floored
  by what is actually in use so a wiped settings store cannot reissue live names.
- PurgeHost deleted the row that carried the record id, leaving the machine's
  management address published with nothing left to clean it up.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 23:51:25 +02:00
nexxo 5db2f7fda7 feat(hosts): give each host a DNS name under the public zone
fsn-01.node.clupilot.com, numbered per datacenter and never reused — removing a
host must not renumber its neighbours onto its name, so the number is stored
rather than derived.

The record points at the host's WireGuard address, not its public IP: the name
exists so an operator can reach a host by name over the VPN, and publishing a
Proxmox host's public address would hand every scanner a target, which is the
one thing this network design avoids.

DNS is convenience, not a prerequisite: a failure logs an event and lets the
onboarding finish rather than stranding a host that is otherwise ready.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 23:49:18 +02:00
nexxo 7941935f57 fix(traffic): a sample without counters is not zero traffic
Defaulting missing netin/netout to zero reset the baseline, so the next real
sample added the entire cumulative counter again — a customer throttled for
traffic that was counted twice. Such a sample is skipped and logged now.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 23:44:02 +02:00
nexxo 2415dde288 fix(deploy,traffic): clear caches before restarts, hide the token, widen the lock
- update.sh restarted the long-lived services before clearing the caches, so
  they booted from the old cached config and kept it for the life of the
  process — the release's configuration never reached the workers.
- install.sh put the Gitea token in the clone URL, which is visible in the
  process list to every local user while the clone runs. It goes through a
  short-lived askpass helper now.
- The collector's uniqueness lock expired at exactly the collection interval,
  so a delayed queue could run two collectors on the same baseline and count
  the same delta twice — throttling a customer for traffic they never used.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 23:42:12 +02:00
nexxo 0135f114d3 fix(traffic): keep retrying the throttle release after a Proxmox blip
Tying the release to the creation of the new month's row meant a single failed
call was never retried: the row exists from then on, and the current period
looks unthrottled, so nothing could detect the stale NIC limit. A customer would
have stayed slow for a whole month they had paid for. Checked on every sample
now, with a test that makes the first release fail.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 23:40:33 +02:00
nexxo 25c4c5df5a fix(deploy): restart the worker after the hub key, diff against what was deployed
- The provisioning worker boots before install.sh writes
  CLUPILOT_WG_HUB_PUBKEY and holds the empty value for the life of its process,
  so every host onboarded afterwards would have got an empty PublicKey in its
  wg0.conf.
- A failed update left the checkout at the target, so the rerun compared a
  commit with itself and skipped exactly the dependency and image steps that had
  not finished. The comparison base is now the last commit actually deployed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 23:38:32 +02:00
nexxo 2b70c9226a fix(traffic): release the throttle at month end; close the Livewire bypass
Two from Codex:

- A throttled instance kept its NIC limit into the new month: the new period
  row starts unthrottled, so neither branch of enforce() fired and nothing ever
  took the limit off. A customer would have stayed slow through a month they
  had already paid for.
- PublicSiteGate exempted livewire/* wholesale. That endpoint is shared by the
  console and the portal, so a signed-in customer could keep driving portal
  components — billing included — while the portal was supposed to be offline.
  Operators pass the normal check anyway, which is all the console needed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 23:36:38 +02:00
nexxo c1e81808a7 feat(traffic): meter the monthly allowance, show it, throttle instead of blocking
Customers can now see what they have used and what is left, and the service
slows down rather than stopping when the allowance is gone — a slow Nextcloud
gets a top-up, a dead one gets a cancellation.

- instance_traffic keeps one row per instance per month. Proxmox counters are
  cumulative since the VM last started, so usage is the difference between two
  samples, and a counter that went backwards means a restart, not a refund.
- Outbound is what counts: inbound is free at our providers and egress is what
  Hetzner's 20 TB per server applies to.
- CollectInstanceTraffic samples every 15 minutes, warns once per threshold
  (80/95 %) rather than on every run, and at 100 % limits the VM's NIC via
  Proxmox — released again as soon as the customer tops up or the month rolls
  over.
- The dashboard gets a full-width band (it throttles the service, so it is not
  a tile among tiles) with the top-up offer right next to the warning.
- Bytes are formatted in SI units now: allowances are computed in SI, so
  dividing by 1024 made a 1000 GB plan read as "931 GB".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 23:33:47 +02:00
nexxo 4681b135db feat(deploy): run under a dedicated account instead of root
The installer still needs root for apt and the firewall, but it now creates a
clupilot service account that owns the checkout and runs Docker, maps the
container user to it (HOST_UID/GID), and update.sh refuses to run as root —
root-owned files in the checkout are files the application cannot write.

The account has no password login: docker group membership is root-equivalent
on the host, so it is reachable only via sudo -u or an SSH key.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 23:25:59 +02:00
nexxo 0219e3c987 fix(deploy): recreate the container, keep the root password secret, forget failures
- update.sh recreated the app container only at the very end, so an update that
  changes the PHP runtime would have installed dependencies and migrated inside
  the old one.
- install.sh randomised DB_PASSWORD but left DB_ROOT_PASSWORD at the value
  committed in .env.example — the same root credential on every installation,
  reachable from any container on the compose network.
- Settings cached the fallback after a database failure, so one blip could
  leave a hidden site publicly visible until someone cleared the cache. Only a
  successful read is cached now, proven by a test that drops the table and
  brings it back.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 23:25:06 +02:00
nexxo 773ef4bd5f fix(deploy): migrate behind maintenance mode, resume a failed update, install deps
Codex was right on all three, and the first was a design error on my part: the
checkout is bind-mounted into every container, so 'git merge' swaps the running
code instantly — 'migrations before traffic' was not achievable by ordering.
The updater now goes down → merge → dependencies → migrate → assets → restart →
up, trading a short deliberate outage for never serving code whose schema does
not exist yet. If a step fails the site STAYS down: coming back up with new code
on an old schema is worse than staying dark until someone looks.

It also records the last successfully deployed commit. A run that died halfway
left the checkout ahead, so the next run said 'already up to date' and skipped
the rest forever.

And it installs dependencies when a lockfile moved: vendor/ and node_modules/
live in the bind mount and shadow the image, so rebuilding the image never
updated them — migrations could run against stale packages.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 23:15:15 +02:00
nexxo 834abcec40 feat(deploy): installer and updater for a fresh server
install.sh sets up a bare Debian/Ubuntu server end to end: Docker, git,
WireGuard, clone, generated secrets, stack, migrations, hub keypair, the Owner
account and a closed firewall. Re-runnable: it keeps an existing .env and never
regenerates the hub key, which would disconnect every onboarded host.

update.sh pulls and applies. Migrations run before the new containers take
traffic, the image is rebuilt only when its definition changed, and the queue
workers are restarted — they are long-running processes that otherwise keep the
old classes in memory, which cost us an hour during development.

clupilot:create-admin creates or promotes an Owner, so re-running the installer
fixes a lost role instead of failing on a taken address.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 23:13:24 +02:00
nexxo e2b4cdbac4 feat(site): switch the public website and portal off from the console
While the product is still being built, the marketing site and the customer
portal should not be reachable — but they must stay reachable for us.

- A toggle in the console (site.manage, Owner/Admin) stored in a new
  app_settings table, because this has to be flippable without a deploy.
- Outsiders get a placeholder with 503 + noindex, not 200: a 200 invites search
  engines to index the placeholder as the site's content, which is far harder
  to undo than to prevent.
- Anyone on the management VPN, and any signed-in operator, sees the real site.
  The console, Livewire's endpoint, the Stripe webhook and the health check are
  always reachable — otherwise the switch could only be flipped once.
- robots.txt is generated by the app and follows the switch. It had to stop
  being a static file: nginx short-circuited it and Laravel's stock file said
  'Disallow:' with an empty value, so crawlers were never told anything.
- Settings reads fall back to the default when the table is unavailable. The
  gate reads one on every request, so a deploy running new code before migrate
  would otherwise answer the entire site with a 500.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 23:10:27 +02:00
nexxo 7f60f88542 fix(maintenance): make the window form usable
The action labels wrapped to two lines and crushed the column — now icon
buttons with tooltips, like every other admin table. The form column was too
narrow for its content: the datetime fields silently clipped their own value,
so they are stacked and full width now, with duration chips because typing an
end timestamp by hand is the fiddliest part of the form. Host rows show a real
selected state and a per-datacenter count, and the impact column says
"1 Host · 1 Kunde" instead of "Host(s) · Kunde(n)".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 23:05:33 +02:00
nexxo 7ef1b3e5c8 docs(wireguard): document the ownership model and config storage
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 22:55:14 +02:00
nexxo 17d7dc2ad9 fix(vpn): store the config inside the creation transaction
Encryption and the write ran after the transaction had committed and the peer
had been dispatched to the hub. A failure there left a live access whose config
was never stored — unrecoverable, and the operator would create a second one
not knowing why. Both now happen in the same insert; nothing is dispatched
unless it succeeded.

Re-verified in the browser afterwards: create with storage, wrong password
refused, right password reveals the config.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 22:53:30 +02:00
nexxo 4ffdafc614 fix(vpn): losing the operator roles closes the owner doors by itself
Ownership alone kept download, block and delete reachable for someone whose
roles were removed by any path other than revokeStaff() — a direct role edit,
say. Being staff is now part of ownership, so revocation closes these doors
without depending on whoever removed the role also remembering the tunnel.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 22:50:13 +02:00
nexxo e5f19f8db3 fix(vpn): count downloads in the database; repair the race test
The download counter was read-modify-written, so two concurrent retrievals
recorded one. It is an audit trail — under-reporting is the failure mode it
must not have.

The creation-race test broke when issuing moved into a transaction: its
simulated competitor writes inside our transaction, so the rollback removes
that row too. It now asserts what actually matters (the loser is told, and
creates nothing) and states what a single-connection sqlite test cannot show.
This test was red in the previous commit — my mistake for committing before
reading the run.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 22:48:38 +02:00
nexxo 02750a7c9a fix(vpn): lock the owner row while issuing an access
A revocation committing between the operator check and the insert would find no
peer to remove and still leave the revoked colleague with a brand-new tunnel.
Issuing now runs in a transaction that locks the owner row — the same row
revokeStaff() locks — so the two cannot interleave.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 22:46:23 +02:00
nexxo 3bd3e64a3d fix(vpn): pause polling while a private config is on screen
Every five-second poll re-rendered the page, putting the private key into
another HTTP response — the handoff kept it out of the snapshot but not out of
the responses. Traffic figures can wait the minute it takes to copy a key.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 22:44:31 +02:00
nexxo c2b18ecfbc fix(vpn): drop the previous config before showing a new access
Creating a second access with a supplied key skipped the config branch, so the
first access's private configuration stayed on screen under the new name — and
someone would have handed it out.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 22:42:38 +02:00
nexxo 7e17bc74b2 fix(vpn): give the QR code the specified four-module quiet zone
Two modules is below the minimum, and against a bordered card some readers fail
to locate the symbol at all.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 22:40:54 +02:00
nexxo 04ba9dd2a9 fix(vpn): an open modal stops serving the config once access is revoked
Once revealed, the plaintext sits in the handoff cache for ten minutes, and the
modal read it from the token alone. A former owner could keep pulling the key
out of an open modal after revocation — the exact window purgeSecret() exists to
close. Every request now re-checks the policy and drops the handoff when it no
longer holds.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 22:39:30 +02:00
nexxo db7b8a6ee3 fix(vpn): keep the navigation entry after the capability split
The sidebar still gated the VPN entry on vpn.manage, which the split deleted —
so the page would have vanished from the console for every role, Owner
included. Every operator can hold their own access, so the entry is shown to
all of them and the page itself shows only what the policy allows. Guarded by a
test across all five roles, which is the assertion my rewrite had dropped.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 22:37:35 +02:00
nexxo 371d517261 fix(vpn): dispatch the revocation removal after the transaction commits
A worker could pick the job up mid-transaction, still see the peer as active,
leave it on the hub — and never be asked again, because the committed state is
only a soft delete. A revoked colleague would keep their tunnel until some
later reconciliation noticed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 22:36:11 +02:00
nexxo 1112b4cdec fix(vpn): classify adopted peers as system, not staff; drop duplicate env line
Codex spotted the migration labelling sync-adopted peers as staff with no
owner, which breaks the invariant the same migration introduces. The live sync
had the identical hole — it never set kind at all, so every adopted peer landed
on the 'staff' default. Both now use system for a peer nobody is behind, and
promote it to host once the link is known.

.env.example carried ADMIN_HOSTS twice; phpdotenv keeps the first, so a fresh
checkout would have got the list without localhost and 404'd its own console.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 22:34:18 +02:00
nexxo 0d9b62eb50 feat(vpn): ownership, a Developer role, and password-gated config retrieval
Reworked after a design consultation with Codex, which pushed back on my first
proposal in three useful ways.

Ownership and rights:
- vpn.manage split into vpn.view.all and vpn.manage.all. Seeing an access is
  not managing it, and neither is holding its private key.
- Record-level rules live in VpnPeerPolicy, not in permissions: an access
  belongs to a person, and ownership is what grants sight of it. Every operator
  reaches the page, but sees only their own unless they may see all.
- Issuing an access is not self-service — it reaches the management network, so
  it needs vpn.manage.all even for oneself.
- New Developer role: sees everything, manages nothing. Writing code does not
  imply authority over other people's access.
- kind (staff|host|system) replaces a null user_id that had to mean two
  different things at once.

Config storage, opt-in per access:
- Downloading is owner-only — explicitly NOT for view.all or manage.all. An
  admin who needs access revokes this one and issues their own, which keeps the
  record of who holds what honest.
- The password is asked on EVERY retrieval, rate-limited. Laravel's
  password.confirm keeps a 15-minute session stamp, which would authorise
  unlimited later downloads from an unattended browser.
- Stored under VPN_CONFIG_KEY, not APP_KEY: a leaked application key must not
  also hand over the management network. Purged on revocation and when a staff
  member is revoked — console taken away, tunnel left open was the worst case.
- The plaintext never enters a Livewire snapshot (the page polls every five
  seconds); the component carries an opaque handle instead.

Also: copy button works without HTTPS again (navigator.clipboard only exists in
a secure context, so over http it silently did nothing), plus config download
as a file and a QR code for the mobile apps.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 22:31:54 +02:00
nexxo cad245c5df docs(wireguard): document the VPN console and the invariants behind it
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 22:02:46 +02:00
nexxo d1302e17bc fix(vpn): purging a host no longer leaves a phantom access behind
The FK only nulled host_id, so the adopted peer stayed enabled-but-absent: shown
as "wird angewendet" forever, and re-enabling it would have put the deleted
host's key and address back on the hub. PurgeHost now tombstones that row, which
hands it to the same revocation machinery as everything else.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 22:00:13 +02:00
nexxo c54b718566 fix(vpn): never let an access hijack a host key; name adopted peers correctly
- A host's wg_pubkey may not be in vpn_peers yet, so the duplicate check passed
  and an operator could create an access with it. ApplyVpnPeer would then run
  wg set with a freshly allocated address, rewriting that host's allowed-ip and
  cutting CluPilot's management tunnel to a live machine. Creation now rejects
  keys already held by a host, checked under the allocation lock.
- A sync landing between addPeer() and the step storing wg_pubkey adopted the
  peer as "unknown", and a later sync filled in host_id but left the name, so
  the page showed that host as unknown forever. The name is now filled in when
  the link becomes known — only for sync-adopted rows, so an access an operator
  named keeps its name.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 21:58:03 +02:00
nexxo 28e681b9df fix(vpn): lock every hub mutation; resolve duplicate keys inside the lock
- ConfigureWireguard and RemoveWireguardPeer mutate the hub too, and were not
  taking the lock, so a sync could still read the interface around them and
  write stale state. Both now hold wireguard:hub, which is what makes the
  read/mutation guarantee actually hold.
- The duplicate-key check ran before the allocation lock, so two concurrent
  creations with the same key both passed it and the loser hit the unique index
  as an unhandled 500. The check moved inside the lock, with the index kept as
  a caught backstop for any writer that does not take it — reproduced in a test
  by inserting a colliding row from within the allocation call.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 21:55:57 +02:00
nexxo 8eb9d7ad30 fix(vpn): serialize hub reads against hub mutations
A sync could read the hub snapshot just before ApplyVpnPeer removed a peer and
purged its tombstone, then adopt what it had seen as a fresh enabled access —
restoring a revoked one. Both jobs now hold Cache::lock('wireguard:hub') around
read-decide-mutate, so a reconciliation can never straddle a removal. Retry
removals are dispatched after the lock is released, because the sync queue
driver runs them inline and they take the same lock.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 21:53:13 +02:00
nexxo 9137eae6df fix(vpn): a stale enable job can no longer resurrect a deleted access
If an add job was retried after the access had been revoked and its tombstone
purged, it found no row and trusted its own enabled=true payload — re-adding a
key that no longer had a row anyone could revoke it with. A missing row can only
mean the access is gone, so the captured payload may now remove but never
enable.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 21:50:57 +02:00
nexxo 1b193ba4ea fix(vpn): join the host pipeline's allocation lock; re-authorize on every poll
Two more from Codex:

- Addresses come from one subnet but live in two tables (hosts.wg_ip and
  vpn_peers.allowed_ip), so a concurrent host onboarding and console creation
  could each see the same address as free and both insert — neither unique
  index sees the other. Creation now waits on Cache::lock('wireguard:allocate'),
  the same lock ConfigureWireguard already holds while reserving a host address.

- mount() runs once, so revoking vpn.manage left an open tab polling every five
  seconds and still receiving fresh peer state. Authorization now also runs on
  hydration, which is what the poll goes through.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 21:49:09 +02:00
nexxo 300ea60f19 fix(docker): build assets on start when the manifest is missing
With the Vite dev server off by default, a fresh checkout had no public/build
(gitignored, and the image does not build it), so every @vite page failed with
ViteManifestNotFoundException. The entrypoint now builds once when the manifest
is absent, non-fatally — a broken build must not wedge the container into a
restart loop where the logs are unreachable.

Proven by deleting public/build and restarting: assets rebuilt automatically,
page 200.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 21:45:27 +02:00
nexxo 08670ecbee fix(vpn): stop revoked accesses coming back and stale jobs undoing intent
Two lifecycle holes Codex found in the new VPN management:

1. Delete removed the row before the hub knew. If the removal job was delayed
   or failed, SyncVpnPeers saw a peer it did not recognise and adopted it back
   as a live access — silently restoring what an operator had just revoked, and
   with no row left to revoke it again. Deletion is now a soft-delete
   tombstone: the row survives until the hub confirms the peer is gone, the
   sync retries the removal instead of adopting, and the tombstone is purged
   only once the peer is really absent. It also keeps the key and the tunnel
   address reserved meanwhile, so neither is handed out twice.

2. ApplyVpnPeer applied the state captured at dispatch. A retried block job
   could therefore undo a later unblock, and nothing would repair it — the sync
   only observes state, it never re-applies intent. The job now resolves the
   current desired state from the row and treats its payload as a fallback for
   the case where the row is already gone.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 21:41:03 +02:00
nexxo 71ca0e1394 feat(admin): VPN access management with live peer state
The console can now create, block and remove VPN accesses, and shows who is
actually connected — traffic counters, source endpoint and last handshake.

Design notes:
- The web container has no wg0, so the page never talks to WireGuard. Live
  state is copied in by SyncVpnPeers on the provisioning queue (the only worker
  whose container owns the interface); the page polls the database and merely
  nudges that job, throttled so many open tabs cannot flood the queue.
- enabled (operator intent) and present (observed on the hub) are stored
  separately, so a sync can never quietly undo a block and drift stays visible
  as "wird angewendet".
- The sync adopts peers the host pipeline registered, naming them after their
  host — otherwise "who is on the VPN" would have blind spots.
- Keypairs are generated in PHP via libsodium rather than shelling out to
  wg genkey (no interface in this container, and it keeps generation testable).
  The private key is shown once and never stored.
- allocateIp() now also considers vpn_peers, which would otherwise be handed a
  tunnel address a host already holds.
- vpn.manage is a new capability held by Owner/Admin only, and it hides the nav
  entry too — a VPN access reaches the management network.

Verified end to end against the real hub, not just mocks: created an access in
the browser, connected with the generated config, watched the console flip to
"Verbunden" with real counters, then blocked it and confirmed the peer was
gone from wg0 and the client could no longer handshake.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 21:38:12 +02:00
nexxo a5a261fa37 test: pin APP_URL/ADMIN_HOSTS so the suite does not follow deployment env
Setting the real APP_URL made route() build https://app.dev.clupilot.com/...,
which ADMIN_HOSTS does not list, so 30 admin tests 404'd. The suite must not
depend on operator hostnames.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 21:24:42 +02:00
nexxo 3e82b51f89 fix(ui): make modal cancel buttons work; serve the app over the HTTPS domains
The cancel buttons were dead: wire-elements registers Livewire.on('closeModal'),
but the buttons used Alpine's $dispatch, which fires a DOM event. Livewire
bridges its own events TO the DOM, not back, so nothing ever received them.
Proven in the browser both ways — before: modal stays open after Abbrechen;
after: it closes.

Domain operation behind Zoraxy:
- trustProxies for FOR/PORT/PROTO so Laravel sees https (otherwise it builds
  http:// URLs into an https page). X_FORWARDED_HOST is deliberately NOT
  trusted — the console is gated on the request host, and trusting it would let
  anyone reach /admin through a public domain by forging the header.
- APP_URL + VITE_REVERB_* point at the dev domains (wss via ws.dev).
- Vite dev server is now opt-in (VITE_AUTOSTART): over https the browser cannot
  load assets from http://<ip>:5173. Built assets are the default, and the
  entrypoint clears a stale public/hot, which otherwise 404s every asset.

Verified in the browser over https: login, styled pages, zero console errors,
zero unencrypted requests, /admin reachable only on admin.dev.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 21:24:03 +02:00
nexxo 8edb2a69e5 docs(wireguard): bring the management hub up and prove it end-to-end
The hub lives in queue-provisioning (the only worker on the provisioning queue,
and LocalWireguardHub shells out to wg in-process). Generated the hub keypair,
wrote wg0.conf, and filled CLUPILOT_WG_ENDPOINT/HUB_PUBKEY.

Verified against the running stack rather than mocks: registered a stand-in
Proxmox peer through the app's own addPeer(), got a handshake, opened hub->peer
TCP over the tunnel (what the SSH steps actually need — a handshake alone does
not prove routed TCP), confirmed wg-quick save persisted the peer, then removed
it through removePeer(). wg0 returns after a container restart.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 21:05:11 +02:00
nexxo 7247edf9d8 feat(security): keep the operator console off the public hostnames
Only the marketing site and the customer portal are public; /admin and the
Proxmox hosts stay on the private network. nginx was a single server_name _
catch-all, so /admin was served on all four dev domains.

Two layers:
- nginx denies /admin on the known-public hostnames before PHP is reached
  (denylist, so an allowlist typo cannot lock the operators out).
- ADMIN_HOSTS is now populated, making the app layer a strict allowlist that
  also covers /livewire/update, which nginx cannot attribute to /admin.

Verified live against the running stack: /admin is 404 on www/app/api/ws and on
any unlisted host, 302->login on admin.dev / the private IP / localhost, while
/ and /dashboard stay reachable everywhere.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 21:01:02 +02:00
nexxo ba861ad579 fix(security): match ADMIN_HOSTS case-insensitively; share test helpers
DNS names are case-insensitive and Symfony's getHost() always returns lowercase,
so ADMIN_HOSTS=Admin.Example.com rejected every request and locked operators
out. Normalised in the config AND at the comparison, so the value is safe
whichever route it took in (env, cached config, runtime set).

operator()/admin() moved from RbacTest/HostManagementTest to tests/Pest.php:
they were only loaded when those files happened to be in the run, so a targeted
single-file run died on 'undefined function operator()'.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 20:56:26 +02:00
nexxo f5f4d2a8cd fix(security): close the Livewire bypass of the admin host restriction
Admin actions post to /livewire/update, which a path-scoped guard skips — an
operator session could drive admin components through a PUBLIC hostname despite
ADMIN_HOSTS. The restriction is now registered as Livewire-persistent middleware
and listed on the admin route group, so Livewire re-applies it from the
component snapshot.

Proven by replaying a snapshot taken from the real rendered page: identical
payload -> 404 on a public host, 200 on the allowed host (positive control, so
the test cannot pass for the wrong reason).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 20:50:22 +02:00
nexxo 1f42c05648 feat(security): admin console can be pinned to non-public hostnames
The Proxmox fleet and the operator console must never be publicly reachable. The
primary control is the reverse proxy, but nginx here is a catch-all
(server_name _), so /admin was served on EVERY hostname — a proxy
misconfiguration would expose it. ADMIN_HOSTS pins it; any other host gets 404
(not 403: a public domain must not disclose that a console exists).

Prepended to the  group instead of the admin route group on purpose: route
middleware is reordered by Laravel's priority list, which runs  first — a
guest would then be redirected to /login and learn the console is there. Covered
by a test for exactly that case. Empty ADMIN_HOSTS = unrestricted, so nobody is
locked out by upgrading.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 20:45:54 +02:00
nexxo db3ef1642b chore: drop stray vim swap file
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 20:25:40 +02:00
nexxo 46d821a047 fix(monitoring): cap monitoring retries at the run budget; dependency-free liveness probe
- MONITORING_ATTEMPTS is capped by the run's max_attempts, so a large value can
  no longer burn the retry budget and fail the very provisioning the degradation
  exists to protect (covered by a new test at the budget boundary)
- /health is now dependency-free (200 in ~2ms with Kuma absent, verified: the
  container healthcheck stays healthy); Kuma state is exclusively in /ready

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 20:19:32 +02:00
nexxo d1db23398d fix(kuma-bridge): a rejected login is not-ready (was falsely 'up'); clearer connect vs login errors
Verified with a reachable Kuma and deliberately wrong credentials:
/ready -> 503 'Kuma login rejected: Incorrect username or password.'

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 20:16:24 +02:00
nexxo 353df9f054 fix(kuma-bridge): detect Kuma outages and never trust the cached monitor list
Verified against a real Kuma by stopping it mid-test:
- get_monitors() reads the library's CACHED monitorList event, so it reported
  'up' long after Kuma died. Reachability now does a real HTTP round-trip plus a
  live socket check.
- the cached list could yield an id for a monitor that no longer exists; a match
  is now confirmed with a live get_monitor before it is returned, so CluPilot can
  never record a target that is never checked.
- liveness (/health, always 200 while serving) split from readiness (/ready, 503
  when Kuma is unreachable) — the container healthcheck must not restart a
  healthy bridge just because a dependency is down.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 20:13:34 +02:00
nexxo 82916167cf fix(monitoring): MONITORING_ATTEMPTS is now exact (off-by-one); accept int or dict monitor id
- attempt is 0-based, so compare one-based: ATTEMPTS=2 really means two tries
- bridge accepts add_monitor returning {monitorID}|{id}|int (v1.2.1 returns a
  dict — re-verified: first create on a fresh Kuma is HTTP 200 with the id)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 20:08:07 +02:00
nexxo 5301ce4309 feat(monitoring): Uptime Kuma support via a REST bridge + resilient monitoring policy
Kuma has no write REST API (monitor CRUD is Socket.IO only), so docker/kuma-bridge
translates the exact generic REST contract HttpMonitoringClient already speaks —
no PHP client change needed. Opt-in compose profile 'monitoring'.

Verified end-to-end against a real Uptime Kuma 1.x:
  create -> id, create again -> same id (idempotent), list, status, delete
  and PHP MonitoringClient -> bridge -> Kuma round-trip.
The e2e run caught a real bug: add_monitor takes 'maxretries', not 'retries'.

Monitoring is now observability, not a delivery gate:
- RegisterMonitoring retries MONITORING_ATTEMPTS times on an outage, then
  continues degraded with a visible 'info' event
- RunAcceptanceChecks no longer fails when monitoring isn't green (a fresh Kuma
  monitor legitimately reports 'pending' — confirmed in the e2e run); it records
  the gap instead. MONITORING_REQUIRED=true restores strict gating.
- docs/monitoring-uptime-kuma.md documents setup, failure behaviour, alternatives

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 20:04:58 +02:00
nexxo f06f00c428 chore(config): documented env placeholders + SSH keys from file paths
- .env.example gains a full, commented CluPilot operations block (Stripe, Hetzner
  DNS, Traefik, WireGuard hub, SSH identity, monitoring, prod mail) with a note
  that everything is optional for local dev
- config/provisioning.php: CLUPILOT_SSH_{PUBLIC,PRIVATE}_KEY_PATH read the key
  from a file (a multi-line PEM cannot live in .env), falling back to inline vars
- documents that Uptime Kuma's REST API is read-only (monitor CRUD is Socket.IO),
  so it needs a bridge rather than being drop-in for the generic REST client

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 19:37:25 +02:00
nexxo 32fa1028cc fix(portal): cloud status labels for all instance states; scale storage curve into the quota
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 19:16:16 +02:00
nexxo d05932105d feat(portal): bind the cloud card to the real instance (plan/seats/domain/quota) so the maintenance badge matches it
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 19:14:42 +02:00
nexxo ac3b3a4e11 fix(portal): scope the per-instance maintenance badge to that instance's host
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 19:12:33 +02:00
nexxo a321d963d7 fix(portal): no silent no-ops without a customer; maintenance visible per instance; maintenance form redesign
- ResolvesCustomer trait replaces 5 duplicated customer() lookups; portal actions
  now SAY why nothing happened (operator accounts have no Customer) instead of
  returning silently — the actual cause of 'dead buttons', '0/0 seats' and
  'settings not saving' when signed in as admin
- portal layout: explicit notice for a login without a linked customer
- /cloud: per-instance maintenance badge + window details; seat note 'owner = seat 1'
- maintenance form: sectioned card, hints, placeholder, styled datetime fields,
  grouped host picker with per-datacenter select-all + selection counter

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 19:10:44 +02:00
nexxo 0b2d762b88 fix(auth): scope registration throttle to its own route (per-IP), not all Fortify endpoints
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 18:41:30 +02:00
nexxo 35a86c413c fix(auth): throttle Fortify endpoints (registration had no limiter — signup-spam guard)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 18:38:44 +02:00
nexxo 406f753311 fix(admin): remove is_admin self-heal — RBAC is the only console boundary (no revocation bypass)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 18:35:14 +02:00
nexxo 22cbe9ac39 fix(auth/admin): atomic user+customer signup; Rule::in for country validation (comma-safe)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 18:33:09 +02:00
nexxo 6ca1e3fba5 fix(auth): create + link a Customer on public signup so the portal works pre-purchase
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 18:31:08 +02:00
nexxo 39a1f708a1 fix(auth): reject signup with an existing customer email (account-claim guard)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 18:29:35 +02:00
nexxo 507ab485a0 fix(admin): derive legacy datacenter location allowlist from DB, not client property
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 18:27:41 +02:00
nexxo 3a794b05dd fix(admin): promote only role-less legacy admins; allow editing datacenters with legacy free-form location
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 18:26:09 +02:00
nexxo fd7cba7403 fix(admin): authorize datacenter modal mounts; stale threshold uses step maxDuration
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 18:23:44 +02:00
nexxo 2b73a895c9 fix(admin): don't flag scheduled-backoff runs as stale; edit-datacenter country validation from config
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 18:21:22 +02:00
nexxo 8304f2e7dc fix(admin): self-heal legacy is_admin into Owner (no RBAC bypass); validate datacenter country server-side
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 18:19:24 +02:00
nexxo 88957f8f57 feat(auth): enterprise split login + signup (Fortify registration) via frontend-design
- login/register redesigned as a split brand-panel + clean form (Apple-ish
  enterprise look), cross-linked; brand panel uses accent gradient + tokens
- Fortify registration enabled; /register full-page Livewire + RegisterTest

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 18:16:27 +02:00
nexxo be99f413f7 feat(admin): provisioning liveness — per-run progress bar, last-activity, stale warning
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 18:11:17 +02:00
nexxo b50f97568f fix(admin): no admin lockout (is_admin fallback); centered modal width; datacenter edit modal + country dropdown
- isOperator()/EnsureAdmin/broadcast fall back to is_admin so a legacy admin is
  never locked out by a stale permission cache
- published modal: centered max-w-lg card (dynamic modalWidth class was purged
  by Tailwind → full-width)
- datacenter edit moved to a modal (no row-height jump); location is now a
  country dropdown (config/countries.php) instead of free text

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 18:09:17 +02:00
nexxo 87a81f5f40 fix(admin): read-only send-time guard (never drop mail on retry); document exactly-once residual for real-mail outbox
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 16:40:44 +02:00
nexxo e09965f918 fix(admin): in-flight claim (claimed_at) for exactly-once maintenance send; scope permission rollback
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 16:38:44 +02:00
nexxo 28221549d1 fix(admin): defer maintenance sent_at to real delivery; read-only send-time dedup (no lost mail)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 16:35:52 +02:00
nexxo 0cc5be5479 fix(admin): atomic send-time delivery claim so backlog duplicates ship exactly once
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 16:34:02 +02:00
nexxo 5b85806605 fix(admin): catch racing ledger insert and re-fetch (idempotent under concurrency)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 16:31:43 +02:00
nexxo 72731112f4 fix(admin): atomically claim maintenance notification retries under a row lock
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 16:30:17 +02:00
nexxo 8bb93137a3 refactor(admin): extract MaintenanceNotifier; atomic provisioning retry; catch-up cancellation for race-delivered announcements
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 16:28:02 +02:00
nexxo ae1f78f534 fix(admin): shown-once temp password for invited staff; suppress announcements for cancelled windows at send time
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 16:23:21 +02:00
nexxo 718e8568c1 fix(admin): require host on any maintenance save; cancel-notify only delivered announcements
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 16:19:44 +02:00
nexxo 62b4d85ef7 fix(admin): release notification claim on dispatch failure so retries aren't blocked
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 16:17:44 +02:00
nexxo ab5ba2b169 fix(admin): dedup pending maintenance notifications (updated_at claim + staleness threshold)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 16:16:15 +02:00
nexxo 493b81aadf fix(admin): confirm maintenance delivery via MessageSent (retryable until sent); guard resend/cancel on derived state; fix flaky non-unique host wg_ip
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 16:14:01 +02:00
nexxo 52fa7a34a2 feat(admin): email affected customers when maintenance is cancelled (ledger-guarded)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 16:07:21 +02:00
nexxo 6593dae946 fix(admin): retry path for missed maintenance emails; lock Owner role to serialize last-owner guard
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 16:04:31 +02:00
nexxo bc2e95ba18 fix(admin): block role escalation of non-staff; validate maintenance host ids atomically; rollback ledger on dispatch failure
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 16:01:45 +02:00
nexxo e5c74c6bdd fix(admin): reject customer email on operator account update; do not claim mail delivery on dispatch
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 15:58:24 +02:00
nexxo d68d8e1f25 fix(rbac/mail): migrate legacy is_admin users to Owner in-migration; send maintenance mail in customer locale
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 15:56:22 +02:00
nexxo eff8c08258 feat(admin): maintenance windows — schedule once, assign many hosts, notify
- maintenance_windows + host pivot + notification ledger; derived state (never
  stored); affected-customer + banner queries live off instances
- admin /maintenance: create draft/publish, multi-host select, impact counts,
  cancel; capability-gated (maintenance.manage)
- publish emails affected customers once (queued Mailable, ledger-idempotent)
- customer portal maintenance banner (upcoming <=72h + active) merged per window

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 15:53:50 +02:00
nexxo c5d60340b7 feat(admin): staff RBAC (spatie) + admin settings page
- 5 operator roles (Owner/Admin/Support/Billing/Read-only) seeded via migration
  with a capability catalogue; app checks capabilities via Gate, never role names
- every mutating admin action authorizes server-side (hosts/datacenters/customers/
  impersonate/provisioning); is_admin reads migrated to console.view / isOperator()
- admin /settings: own account + Owner-only staff invite/role/revoke with
  last-owner, self-role and customer-collision guards (transactional)
- sidebar 'zum Kundenportal' link replaced with Settings

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 15:47:42 +02:00
nexxo 4336c3bb3f feat(brand): CluPilot logo mark + SVG favicon across layouts
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 15:31:33 +02:00
nexxo 08f61b43f5 fix(ui): modal above sidebar (z-index); datacenter edit buttons stay right; toast slides from bottom; provisioning retry action
- modal wrapper z-10 -> z-[70] (was behind the z-40 sidebar); toasts slide up/down
- datacenter inline-edit colspan fix so save/cancel stay right-aligned
- admin provisioning: retry a failed run (table + panel), mirrors host-detail retry

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 15:29:37 +02:00
nexxo 99081284a4 fix(portal): delete old logo only after the branding update commits
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 15:04:27 +02:00
nexxo f6efa4f200 fix(portal): allow closure after failed provisioning; serialize last-owner guard
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 15:02:37 +02:00
nexxo 8bcb5ec268 fix(admin): enforce hosts.datacenter FK (restrictOnDelete) — no deactivation side-effect
The DB now refuses to orphan a host; datacenter delete pre-checks for a friendly
message and catches the constraint as the race backstop.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 15:00:38 +02:00
nexxo 249efa0553 fix: serialize datacenter delete (lock+deactivate+recheck); store logo before deleting old
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 14:58:07 +02:00
nexxo 01383e7e5e fix(portal): race-safe owner-seat initialization (firstOrCreate + catch)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 14:55:09 +02:00
nexxo 3bf0dfb548 fix: seat limit + suspend guard per Codex; harden factory email uniqueness
- seat limit follows the active/cancelling package, not a newer inactive record
- suspend/reactivate toggle refuses closed accounts (terminal); closed shown in list
- factory emails use a large unique numeric space (safeEmail pool could collide
  late in a full suite run)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 14:53:15 +02:00
nexxo cbbb523f9c fix(portal): cancel the active instance explicitly, not just the newest record
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 14:48:57 +02:00
nexxo c011a11b48 fix(portal): atomic seat-limit (row lock) + billing-day-anchored cancellation date
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 14:47:04 +02:00
nexxo 074b0c041b fix(portal): enforce customer lifecycle per Codex review
- EnsureCustomerActive middleware: suspended/closed customers lose portal
  access (admins + active impersonation exempt)
- cancellation: reject non-active instances; service-end anchored on the
  subscription start date, not calendar month-end

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 14:45:03 +02:00
nexxo 3b288486cd feat(portal): seats management — invite/role/revoke against plan limit
- seats table + model; Users page manages team members (owner auto-created)
- invite respects plan seat allowance + dedupe; role change + revoke guarded
  so the last owner can never be removed or demoted

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 14:40:18 +02:00
nexxo df10448e5d feat(portal): settings page — company profile, branding (logo+colors), cancel package, close account
- customers gain profile + branding + closed_at; instances gain cancellation
  fields; branding resolver (NULL -> CluPilot defaults) snapshotted into the
  provisioning run context
- cancel package: term-end, irreversible, typed-confirm modal (R5)
- close account: guarded (no active package), typed-confirm modal (R5)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 14:36:52 +02:00
nexxo ef110b06db feat(admin): host reserve edit + maintenance drain; customer suspend/reactivate
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 14:30:21 +02:00
nexxo d3d686e575 feat(admin): scalable host list (search/filter/health) + host detail redesign
- host list: search + datacenter/status filters, dense table with heartbeat
  health dot, instance count, capacity meter
- host detail: health hero (last_seen), storage/compute breakdown, technical
  facts, hosted-instances table
- Host model: usedPct() + healthState() helpers

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 14:27:53 +02:00
nexxo b44d25404d test: widen DatacenterFactory code space (fix faker unique() pool exhaustion)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 14:23:49 +02:00
nexxo 54d9a05235 feat(admin): datacenter edit+delete (guarded); MRR chart fills card; host-load as meters
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 14:22:15 +02:00
nexxo e3ab3cb501 feat(portal): service framing + gradient line charts + billing button fix
- plans gain seats + performance class; customer views show storage/seats/
  performance/features, no raw vCPU/RAM (admin keeps specs)
- billing: scoped wire:target + wire:key so one purchase no longer spins all
- chart island: line-colour->transparent gradient for filled line datasets;
  MRR / backups / invoices bar charts converted to gradient line

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 14:18:08 +02:00
nexxo a3423a2cee docs: Phase D spec — service framing, redesign & CRUD completeness
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 14:13:23 +02:00
nexxo 80348d6a3f fix: unique customer↔user link + reject inactive datacenters on host create
- customers.user_id unique so impersonation/billing can't cross customers
- HostCreate validates datacenter is active (exists rule + active=1)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 13:56:07 +02:00
nexxo b44e224bee fix: backfill datacenters on migrate; resolve billing customer by user link
- migration seeds fsn/hel + any host-referenced code so existing installs
  keep selectable datacenters after upgrade
- Billing::customer() resolves via user_id, email only as legacy fallback

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 13:53:06 +02:00
nexxo 4e86e135a6 fix(admin): harden impersonation + datacenter code per Codex review
- normalize datacenter code before uniqueness validation
- ensureUser: race-safe create + refuse linking admin accounts
- impersonate start/leave are POST (CSRF-protected)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 13:50:16 +02:00
nexxo 879697c6ea feat(admin): impersonate customer portal — session login + return banner
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 13:46:48 +02:00
nexxo 515f26234e feat(portal): billing page — current plan, upgrades, extra storage, add-ons
New /billing page + nav; plans gained price_cents, storage_addon + addons
catalogue in config. Purchases create a pending Order intent (fulfillment
mocked). Dashboard storage upsell links here. DE+EN. 6 tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 13:36:52 +02:00
nexxo eabcb98f91 feat(admin): compact redesign of /admin/provisioning current-run panel
Progress bar + X/total, highlighted current step, faint next step, failure
inline — replaces the long repetitive per-step 'done' list. Tactical-Terminal
tokens, DE+EN.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 13:31:49 +02:00
nexxo 7c1a153446 fix(ui): fixed sidebars (admin + portal) + storage banner shimmer
App-shell layout: sidebar is a full-height fixed column on lg, only main
scrolls. Re-added the left->right shimmer sweep on the dashboard storage
banner (reduced-motion safe, token colours).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 13:28:05 +02:00
nexxo bc278c8fa1 feat(admin): datacenters management (create + select)
datacenters table/model + admin CRUD (/admin/datacenters, nav). HostCreate
picks from active datacenters (validated); hosts show the datacenter code.
Seeded fsn/hel. 5 tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 13:25:17 +02:00
nexxo 5fb4e637fc docs: panel improvements (phase C) design spec
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 13:20:55 +02:00
nexxo d7c2edb1df fix(engine-b): idempotent local DNS record (firstOrCreate on record_id)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 13:08:13 +02:00
nexxo 96aceeeaa1 fix(engine-b): stop customer dashboard polling once provisioning failed
Show the failure state but only poll while pending/running/waiting.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 13:06:31 +02:00
nexxo 66e8a4bbf6 fix(engine-b): clean re-clone on lost task ref; idempotent firewall rules
- Clone recovery: once the lock clears but the task ref is lost, destroy the
  possibly-incomplete VM and re-clone instead of advancing onto it.
- applyFirewall clears existing rules before adding, so retries don't accumulate
  duplicate Proxmox firewall entries.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 13:04:48 +02:00
nexxo 9162446b1f fix(engine-b): acceptance gate verifies real health, not just breadcrumbs
- Nextcloud: parse occ status JSON (installed + not in maintenance).
- Admin: query occ user:info for the account.
- Monitoring: MonitoringClient::isHealthy checks the provider, not the local row.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 13:00:49 +02:00
nexxo 491800e09c fix(engine-b): grant VM.Backup to the Proxmox automation role
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 12:57:11 +02:00
nexxo 157496f0c5 fix(engine-b): write Traefik route to the serving host over SSH, not locally
TraefikWriter now targets the host that serves the traffic (DNS points at it):
SshTraefikWriter SSHes in and writes the file-provider YAML there. Interface
takes the traffic host + guest backend.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 12:55:24 +02:00
nexxo 483a79a822 fix(engine-b): treat unreachable cert endpoint as not-ready (poll, not retry-burn)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 12:52:37 +02:00
nexxo 03c4b6508f fix(engine-b): Proxmox agent exec repeated command fields; Traefik write throws on failure
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 12:50:57 +02:00
nexxo 8825f9abfe fix(engine-b): Proxmox config uses PUT; guest-agent exec is form-encoded
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 12:48:58 +02:00
nexxo 778bb7f117 fix(engine-b): always scrub credentials once delivered; idempotent monitoring lookup
- CompleteProvisioning scrubs admin_password whenever credentials_sent exists,
  so a crash between recording and scrubbing can't leave the password in context.
- HttpMonitoringClient looks up an existing monitor by URL before creating one,
  so a retry after a crashed POST doesn't create a duplicate external monitor.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 12:46:34 +02:00
nexxo 661b52dbe0 fix(engine-b): clone recovery waits for lock; register rows before breadcrumb
- Clone recovery polls until the VM lock clears instead of advancing onto a
  possibly-incomplete clone.
- RegisterBackup/RegisterMonitoring create the local row BEFORE the run-resource
  breadcrumb, so a crash between them can't leave the row permanently missing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 12:44:22 +02:00
nexxo 3c5a18b5d7 fix(engine-b): apply plan CPU/RAM; idempotent backup + monitoring registration
- ConfigureCloudInit sets cores/memory from the plan (not the template default).
- RegisterBackup uses a deterministic Proxmox job id (ignore-exists) + firstOrCreate.
- RegisterMonitoring is idempotent by URL + firstOrCreate — no duplicate schedules
  or monitors after a crash/retry.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 12:42:10 +02:00
nexxo 5cf1964d7d fix(engine-b): default-deny firewall, disk-based capacity, synchronous credential mail
- applyFirewall sets policy_in=DROP so only 80/443 are exposed.
- Capacity/placement account for disk_gb (the real VM allocation), not the
  smaller Nextcloud user quota — no systematic overcommit.
- CloudReady sent synchronously again; the step retries on mail failure with the
  password preserved (no lost credential in a failed queue job).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 12:39:15 +02:00
nexxo c79874ccfe fix(engine-b): keep admin password ciphertext in the queued mail; valid backup schedule
- CloudReady receives the ENCRYPTED password (decrypts only when rendering), so
  the queued payload never holds plaintext.
- RegisterBackup uses a valid Proxmox calendar expression (02:00).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 12:35:49 +02:00
nexxo b4cf94ee1a fix(engine-b): real backup/monitoring registration + OC_PASS scoping
- CreateCustomerAdmin puts OC_PASS on the docker invocation (survives the &&).
- RegisterBackup creates a real vzdump job via ProxmoxClient::createBackupJob.
- RegisterMonitoring registers via a new MonitoringClient service (interface +
  fake + http). Both persist the external id [E] before the breadcrumb, so
  acceptance reflects a real registration, not a fabricated row.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 12:33:45 +02:00
nexxo 64e3ca421d fix(engine-b): async stripe payments, capacity honesty, durable credential mail
- Accept checkout.session.async_payment_succeeded (async methods) and dedupe on
  the checkout session id, not the event id.
- committedGb still counts a failed instance while its VM exists (has vmid);
  releases quota only when no VM was created — no overcommit vs no leak.
- CloudReady is queued (durable). Credential delivery is documented at-least-once
  (a rare duplicate welcome email beats a lost credential; exactly-once = v1.1 outbox).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 12:29:47 +02:00
nexxo e0e4c4e18d fix(engine-b): run guest-agent commands through /bin/sh -c
The Proxmox guest agent execs a program directly; our compound commands
(cd/&&/pipes/redirects/env) need an explicit shell.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 12:25:24 +02:00
nexxo f43ade79b0 fix(engine-b): address Codex round 6 (crash-window idempotency)
- Clone: recover via vmExists() when the task ref was lost, instead of
  re-cloning the reserved vmid (which Proxmox rejects).
- Deploy: persist the DB password ENCRYPTED before compose up and reuse it on
  retry, so a crash can't regenerate a mismatching credential.
- Complete: gate credential delivery on a credentials_sent breadcrumb so a
  retry after a crash doesn't re-send the email.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 12:23:28 +02:00
nexxo e2b8f25e78 fix(engine-b): address Codex round 5 (capacity release, activate-last, guest-ip poll, email unique)
- Failed run releases the instance (status=failed) so its quota stops counting
  against host capacity.
- CompleteProvisioning activates instance+order only AFTER onboarding tasks +
  credential delivery succeed.
- ConfigureNetwork polls until the guest has an address (no wrong Traefik route).
- customers.email unique + race-safe customer resolution in the intake action.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 12:19:31 +02:00
nexxo c56aa14f9d fix(engine): don't reset started_at on poll so step deadlines accumulate
RunRunner::onPoll no longer resets started_at, so a poll step's own deadline
(WaitForGuestAgent 270s, ConfigureDnsAndTls cert 840s) actually fires instead
of resetting every poll. maxDuration raised above each own-deadline so the
step's fail wins over the generic timeout. Shared core fix (A + B).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 12:15:26 +02:00
nexxo fffcecb152 fix(engine-b): address Codex round 3 (webhook email/replay/rotation, db secret)
- Require a real customer email (no unknown@ fallback merging customers).
- Stripe signature: enforce 5-min replay tolerance + accept any rotating v1.
- DeployApplicationStack no longer persists the DB password in the run context.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 12:12:24 +02:00
nexxo e6bd6c9354 fix(engine-b): address Codex round 2 (fail-closed webhook, placement lock, clone/admin idempotency)
- Stripe webhook fails closed when the signing secret is missing (outside local/testing).
- ReserveResources places + creates the instance under a per-datacenter lock so
  concurrent orders can't overcommit a host.
- CloneVirtualMachine reserves the vmid + breadcrumb BEFORE cloning, so a crash
  can't mint a new vmid and orphan the first clone.
- CreateCustomerAdmin checks user existence and resets the password instead of
  re-running user:add on retry.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 12:09:21 +02:00
nexxo 42fa92cf20 fix(engine-b): address Codex (paid-only stripe, guest routing, absolute disk)
- Stripe: only checkout.session.completed with payment_status=paid (dedupes the
  paired payment_intent event and blocks unpaid async sessions).
- Capture the guest IP (ConfigureNetwork) and point Traefik at the VM, not the
  Proxmox host, so ACME/HTTP-01 can reach Nextcloud.
- Resize the disk to an absolute target (not '+…') so a retry can't double it.
(Codex #4 timeout was a false positive — started_at resets per step.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 12:05:31 +02:00
nexxo 3e19778046 chore(engine-b): provisioning step/mail i18n (DE+EN) + demo seed data
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 11:59:06 +02:00
nexxo a9fbb395f9 feat(engine-b): live provisioning progress (admin console + customer dashboard)
Admin /admin/provisioning bound to real runs+steps (poll+admin.runs). Embedded
CustomerProvisioning card shows the logged-in customer their own run live
(per-customer StepAdvanced channel, email-bridged authz). Shared BuildsRunSteps.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 11:57:35 +02:00
nexxo 65ba3d6588 feat(engine-b): Stripe webhook -> idempotent customer provisioning intake
Signed webhook (HMAC verify when secret set), CSRF-exempt route. Paid checkout
creates customer+order+run (stripe_event_id unique => duplicate webhooks start
one run) and dispatches. 5 tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 11:52:46 +02:00
nexxo 446da58061 feat(engine-b): 15-step customer pipeline + DNS/Traefik services
validate->reserve(placement)->clone->cloudinit->start->guestagent->network->
deploy->nextcloud->admin->dns/tls->backup->monitoring->acceptance->complete.
HetznerDnsClient + TraefikWriter (interface+fake+real), CloudReady notification,
hosts.node (set by RegisterCapacity). Secrets transient/encrypted, never
plaintext. 21 tests incl. mocked end-to-end + crash idempotency.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 11:50:26 +02:00
nexxo e5aeb3989e feat(engine-b): ProxmoxClient VM-lifecycle extension (interface + fake + http)
nextVmid/cloneVm/setCloudInit/resizeDisk/startVm/vmStatus/guestAgentPing/
guestExec/taskStatus/applyFirewall. Fake is deterministic with configurable
task/guest failure hooks; Http polls UPID + guest exec-status.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 11:41:48 +02:00
nexxo 770a6cf7cd feat(engine-b): CustomerStep base + shared resource trait + config
ManagesRunResources trait (host+customer), CustomerStep base (order/instance/
plan/guest helpers), config plans + 15-step customer pipeline + dns/traefik.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 11:39:56 +02:00
nexxo 406251dfde feat(engine-b): customer domain models + migrations
customers, orders (ProvisioningSubject, stripe_event_id unique), instances
(subdomain unique, encrypted nc_admin_ref), dns_records, backups,
monitoring_targets, onboarding_tasks. Host capacity: committedGb/availableGb +
datacenter placement. 7 tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 11:38:01 +02:00
nexxo b9742f61ee docs: customer pipeline (Subsystem B) implementation plan
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 11:35:28 +02:00
nexxo 5e6d6b11b6 docs: engine customer-pipeline spec (the missing Section 3, aligned to the built core)
Detailed build contract for Subsystem B (15 customer-provisioning steps),
written against the real Subsystem-A contracts: ProvisioningStep/StepResult
(advance/retry/poll/fail), RunResource idempotency breadcrumbs, ProvisioningRun
context, the config/provisioning.php pipeline registry, and the read-only
ProxmoxClient it must extend. Covers new models (customers/orders/instances/…),
ProxmoxClient VM-lifecycle additions, Hetzner-DNS + Traefik services, Stripe
idempotent intake, live progress binding to the existing admin/customer views,
build order and DoD. Unblocks B.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 11:27:28 +02:00
nexxo c7fb1ce56d fix(engine): dispatch run continuation after releasing the lock
Prevents a second worker from consuming the follow-up job and bailing on the
still-held run lock, which would stall the run until the next scheduler tick.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 11:09:24 +02:00
nexxo 294c9aef2f fix(engine): store wg_pubkey right after addPeer so failed peers are cleanable
Keeps the wg_peer resource gated on a verified handshake (idempotency) while
ensuring PurgeHost can always remove the hub peer, even on terminal failure.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 11:07:41 +02:00
nexxo 9e664654a2 fix(engine): record wg peer only after verified handshake; async host purge
- ConfigureWireguard checks setup command results and only records the wg_peer
  resource after the handshake verifies, so a transient setup failure re-runs
  the full setup instead of getting stuck on the idempotent path.
- Host removal now deactivates immediately and queues PurgeHost, which deletes
  runs under the runner lock (waiting out a long step) — no 15s LockTimeout error.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 11:06:13 +02:00
nexxo a6a41719bb fix(engine): long SSH command timeout; wg peer removal on privileged worker
- PhpseclibRemoteShell sets a command timeout (default 2000s) so apt
  full-upgrade/install don't hit phpseclib's ~10s default and fail.
- Host removal dispatches RemoveWireguardPeer to the provisioning queue, which
  runs in the worker that owns wg0 (the web container can't manage WireGuard).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 11:01:02 +02:00
nexxo 5979470dda fix(engine): install wireguard-tools + bring up wg0; lock-coordinate host removal
- Image installs wireguard-tools/iproute2; the provisioning worker brings up
  wg0 before queue:work so LocalWireguardHub can manage peers.
- ConfirmRemoveHost deletes each run under its run:<uuid> lock so an in-flight
  worker can't keep mutating the server or write to a deleted run.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 10:57:51 +02:00
nexxo 93191f5de9 fix(engine): fail on unresolvable pipeline step; make worker the WG hub
- RunRunner catches step-resolution errors (removed/renamed pipeline) and
  fails the run terminally instead of looping forever every tick.
- queue-provisioning worker gains NET_ADMIN + tun + persistent wireguard
  volume + published UDP port so LocalWireguardHub can manage wg0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 10:53:40 +02:00
nexxo 00b8897b9d fix(engine): compute default WireGuard hub IP from the CIDR network
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 10:49:43 +02:00
nexxo 8a392bfd06 fix(engine): parse WireGuard CIDR for address allocation (any prefix length)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 10:48:16 +02:00
nexxo b65fe69088 fix(engine): drop wg peer on host removal; clear reboot state on deadline fail
- Removing a host now removes its WireGuard hub peer so a freed wg_ip can't
  route to the removed server via a stale peer.
- RebootIntoPveKernel clears reboot_issued/deadline when it times out, so the
  manual retry action actually re-issues a reboot instead of failing instantly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 10:46:11 +02:00
nexxo 91e308efb0 fix(engine): one host per public IP (unique constraint + validation)
Prevents two onboarding runs from fighting over the same physical server.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 10:43:30 +02:00
nexxo 48ed5e7d34 fix(engine): fail if the WireGuard peer can't be persisted (wg-quick save)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 10:41:11 +02:00
nexxo f240401a42 fix(engine): honour configured WireGuard prefix length in wg0.conf
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 10:39:46 +02:00
nexxo 18d1ed2424 fix(engine): derive WireGuard hub IP from the configured subnet
hub_ip is now defined in config (default: subnet .1), so a custom
CLUPILOT_WG_SUBNET no longer pings the wrong handshake target.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 10:38:17 +02:00
nexxo ced7e6103d fix(engine): durable queueing for long provisioning steps
- Dedicated 'provisioning' queue connection (retry_after 2400) + worker
  (--timeout=2100); AdvanceRunJob tries=1, timeout=2100 on that queue.
- Run lock TTL raised to 2100s so a long step can't be run concurrently by a
  duplicate tick-dispatched job.
- StartHostOnboarding creates host+run in a transaction, dispatches after commit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 10:36:49 +02:00
nexxo fbe87d99d5 fix(engine): honour backoff timing + don't double-count Proxmox storage
- RunRunner returns early for a waiting run whose next_attempt_at is in the
  future, so stale/duplicate jobs can't bypass backoff.
- RegisterCapacity takes the largest VM-capable datastore instead of summing
  overlapping pools (local + local-lvm), preventing placement overcommit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 10:32:14 +02:00
nexxo 8a2a2ee695 fix(engine): reset step timer on retry so timed-out steps recover
A retried step (manual or after a timeout) now gets a fresh started_at, so
RunRunner no longer immediately re-detects the timeout and burns the retry
budget without executing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 10:29:11 +02:00
nexxo 62c4412623 fix(engine): address Codex round 2 (poll budget, host error state, wg race)
- StepResult::poll — polling steps (reboot) wait without consuming the retry
  budget; the step owns its deadline. Reboot maxDuration > deadline.
- Failed runs move a Host subject to 'error' via ProvisioningSubject hook
  (no host stuck 'onboarding').
- ConfigureWireguard allocates + reserves the wg_ip under a global lock;
  unique index on hosts.wg_ip as a backstop against duplicate addresses.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 10:26:37 +02:00
nexxo 65717bd3bd fix(engine): address Codex review (auth token bootstrap, tunnel recheck, ssh)
- CreateAutomationToken now bootstraps the pveum role/user/token over the
  authenticated SSH session (a fresh host has no API token yet); ProxmoxClient
  is read-only in A.
- ConfigureWireguard re-verifies the handshake on the idempotent replay path,
  never advancing over a dead tunnel.
- PhpseclibRemoteShell treats a missing SSH exit status as failure (255).
- connectWithKey verifies the pinned host key fingerprint on later logins.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 10:20:59 +02:00
nexxo 36a564d5c8 chore(seed): demo Proxmox hosts for the operator console (local/testing)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 10:15:12 +02:00
nexxo 8d12a40a42 feat(admin): host onboarding UI (add / live stepper / retry / remove)
Real hosts list, add-host form (StartHostOnboarding), host detail with live
progress stepper (Reverb + wire:poll fallback), retry failed run, remove via
wire-elements/modal (deregister only). DE+EN. 8 tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 10:12:41 +02:00
nexxo ac3d17cd6a feat(engine): 11-step host onboarding pipeline (SSH -> WG -> Proxmox)
Idempotent steps: validate, ssh-trust (deploy key + scrub password),
prepare base, wireguard (hub peer), install proxmox-ve, reboot-into-pve
(retry-poll), configure, automation token, verify api, register capacity,
complete. StartHostOnboarding action. 22 tests incl. mocked end-to-end +
crash idempotency.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 10:04:39 +02:00
nexxo c06ca0ae5d feat(engine): SSH / WireGuard / Proxmox service layer
RemoteShell (phpseclib + fake), WireguardHub (local wg + fake), ProxmoxClient
(REST + fake). Interfaces bound in AppServiceProvider; tests swap fakes via
fakeServices() helper.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 09:58:02 +02:00
nexxo 4f724d57c9 feat(engine): orchestrator core (state machine + tick + lock)
StepResult, ProvisioningStep contract, PipelineRegistry, RunRunner (per-run
lock, advance/retry/fail, backoff, timeout, append-only events + Reverb
StepAdvanced), AdvanceRunJob (provisioning queue), minutely Tick, admin.runs
channel. 21 tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 09:54:52 +02:00
nexxo 11cdcdd4da feat(engine): core provisioning data model + hosts
hosts, provisioning_runs (polymorphic), append-only provisioning_step_events,
run_resources (idempotency breadcrumbs). Models with encrypted api_token_ref,
json context helpers, UUID routing (R11).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 09:49:43 +02:00
nexxo 7ae81d8127 docs: host-onboarding implementation plan
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 09:46:58 +02:00
nexxo 772e9d35ff docs: host-onboarding (Subsystem A) design spec
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 09:44:11 +02:00
nexxo bf5813057b docs: provisioning engine v1.0 build handoff (standalone fleet + cluster-per-DC target)
Focused TDD build plan for the engine: topology decision baked in (Option 1
standalone fleet as the software model, cluster-per-datacenter as the growth
target — hosts get datacenter + nullable cluster fields, placement filters by
datacenter). Covers data model, DB-state-machine orchestrator, 15 customer steps,
new ProxmoxClient, Stripe idempotent intake, and wiring the existing admin/customer
progress views to real data via Reverb. References the state handoff for workflow.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 09:31:51 +02:00
nexxo 49b5b7a523 docs: state/continuation handoff (2026-07-25)
Full project state for a fresh session: env facts, stack, docker workflow, what's
built (landing + customer portal + admin console), verification workflow (pest /
R12 puppeteer / R15 codex), hard-won gotchas, open items, and the recommended next
block (provisioning engine v1.0).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 09:16:17 +02:00
nexxo b032d8808b feat(landing): public marketing homepage at / (+ legal placeholders)
Public www landing page (your design) as a self-contained Blade view at / — own
CSS/JS, system fonts (no CDN, R14), hero, marquee, typewriter, product mock with
scroll-zoom, security section, honest comparison, pricing gallery, FAQ, CTA. It is
intentionally outside the app token/component system (marketing page, not control
panel). Wired a sign-in link to route('login').

Robustness/a11y hardening from the Codex (R15) loop:
- reduced-motion: pin hero/reveal elements to their visible end state.
- <noscript> fallback so reveal content is visible without JS.
- login link kept visible on mobile (moved out of the hidden nav group).
- stats render their real values in HTML (correct without JS).
- footer legal links now resolve to real /legal/* routes (placeholder pages).
- reconciled contradictory data-migration pricing (add-on from €390).

48 Pest tests green; R12 browser: landing 0 console errors. Codex (R15) clean.

NOTE: the /legal/* pages (Impressum, Datenschutz, AGB) are placeholders — real
legal content must be supplied before this homepage goes public.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 08:59:39 +02:00
nexxo 1aa7535fc4 feat(admin): dark Tactical-Terminal operator console
Separate admin console at /admin, gated to is_admin users (EnsureAdmin middleware
+ migration/seed; a plain customer user is seeded to prove the gate). Distinct dark
aesthetic achieved purely by token scoping: .theme-admin overrides every CSS design
token to a dark graphite / signal-orange palette, so ALL shared components (button,
card, badge, table, stat, chart) render dark with zero new markup (R3). Chart island
now reads tokens from its own element, so charts are theme-aware.

Sections (each full-page class-based Livewire, English routes R13, localized DE/EN):
- Overview: fleet KPIs, fleet-growth line, host-load bars, MRR bars, active runs, alerts.
- Customers: table + plan doughnut. Instances: fleet table (vmid/host/storage).
- Hosts: capacity cards (storage/CPU bars). Provisioning: runs table + live stepper.
- Revenue: MRR/ARR/ARPU/churn KPIs, MRR line, plan doughnut, recent payments.
- Locale-aware month labels/currency (Carbon/Number).

18 new Pest tests (guest redirect / non-admin 403 / admin render per section) → 44
green. R12 browser: all six admin pages HTTP 200, ZERO console errors (Chart.js dark).
Codex (R15) — clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 08:35:03 +02:00
nexxo c4cff8f67c feat(portal): full sidebar — Cloud, Users, Backups, Invoices, Support
Each sidebar tab is now a full-page class-based Livewire component (R1/R2) with an
English route (R13), localized DE/EN (R16), Chart.js islands and token styling:
- Cloud: instance details + specs + storage-over-time line chart + actions.
- Users: user table (roles/groups/status badges) + users-by-group doughnut.
- Backups: 14-day size bar chart + backup history table + restore.
- Invoices: invoice table + next-charge card + monthly-spend bar chart (locale-
  aware month labels, Number::currency amounts).
- Support: contact cards + tickets table + FAQ accordion (Alpine).
- Sidebar links all tabs with routeIs() active state; global toast in app shell.
- All fixture dates/numbers locale-aware (Carbon isoFormat / Number::format).

12 new Pest tests (guard + render per tab) → 26 green. R12 browser: all six tabs
HTTP 200 with ZERO console errors (Chart.js clean). Codex (R15) — clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 08:08:33 +02:00
nexxo 499adaff00 feat(dashboard): rich overview with Chart.js (customer-portal template)
- Chart.js as an Alpine island (x-ui.chart): configs are PHP arrays; colours use
  token: strings resolved from CSS design tokens at runtime (R3). Respects
  reduced-motion; IBM Plex chart defaults.
- Tokens: soft --radius-xl (20px), --success-bright for dots/rings/charts.
  Staggered 'rise' reveal keyframe. Global toast in the app shell.
- Overview rebuilt to the customer template: storage doughnut ring, availability
  sparkline, KPI tiles, upsell, cloud card, interactive onboarding checklist
  (Alpine), backups, modules (Lucide icons — no emoji, R9), activity feed.
- Locale-aware fixture display: Carbon isoFormat dates + Number::format sizes so
  the EN dashboard is not mixed-language (R16).
- 14 Pest tests green; R12 browser: /dashboard 0 console errors with Chart.js;
  R7 no overflow at 375/768/1280. Reviewed with Codex (R15) — clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 07:59:19 +02:00
nexxo 2b08b072fe fix(docker): stop injecting .env as real env vars (test isolation)
env_file: .env exported the dev DB/cache config as real container env vars,
which overrode phpunit's forced test env — so the Pest suite ran RefreshDatabase
against the dev MariaDB and wiped it on every run. Laravel already reads .env
from the bind mount; only vite needs VITE_HMR_HOST/VITE_PORT at process level,
now injected explicitly. Tests now use sqlite :memory: and never touch dev data.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 01:28:51 +02:00
nexxo 254a7d46a0 feat(portal): Fortify auth + Login/2FA/Dashboard + component kit
Backend (Fortify):
- laravel/fortify with TOTP two-factor + recovery codes; User uses
  TwoFactorAuthenticatable; 2FA credentials hidden from serialization.
- Views off — pages are full-page class-based Livewire components (R1/R2);
  Fortify handles POST actions. Home redirect -> /dashboard. v1 scope: login +
  2FA only (no public register/reset/passkeys). Seeder gated to local/testing.

Component kit (Blade, token-based, a11y):
- button, input, checkbox, alert, card, badge, stat-tile, otp-input (Alpine,
  auto-advance/paste, -safe submit), progress-stepper, nav-item, icon
  (Lucide), plus layouts/portal-app app-shell (sidebar drawer + topbar + menu).

Screens (localized DE/EN, R16):
- Login (form -> login.store), Two-factor challenge (OTP + recovery fallback),
  Dashboard (KPI stat tiles, instance card, provisioning stepper fixtures,
  activity). Routes English (R13).

Tests + verification:
- Pest: 14 green (login ok/invalid/throttle, dashboard guard, component render).
- R12 browser (Puppeteer, prod assets): /, /login, /two-factor-challenge and
  the authenticated /dashboard all HTTP 200 with ZERO console errors; login
  flow verified end-to-end.
- Test isolation fixed (force test env over injected .env).
- Reviewed with Codex (R15): 4 rounds, all findings fixed, final pass clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 01:20:25 +02:00
nexxo 53c2a12d6d feat(portal): design foundation — Tailwind v3, tokens, self-hosted fonts
- Downgrade Tailwind v4 -> v3 (user decision): postcss.config.js,
  tailwind.config.js mapping framework-neutral CSS-var tokens onto utilities.
- portal-tokens.css: light enterprise palette, single orange accent, IBM Plex
  type scale, radius/shadow/motion/focus (design handoff §6). AA-safe accent
  text/fill tokens (accent-active/-press/-text) — #f97316 alone fails AA.
- Self-hosted IBM Plex Sans+Mono via @fontsource, Vite-bundled (R14, no CDN).
- app.css: v3 layers, base type, uniform :focus-visible, reduced-motion.
- layouts/portal.blade.php base layout; welcome page retokenised (guarded
  login CTA, DE/EN via lang/common) — no v4-only classes.
- Reviewed with Codex (R15): 5 rounds, all findings fixed, final pass clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 00:43:01 +02:00
1110 changed files with 149206 additions and 617 deletions

View File

@ -38,7 +38,7 @@ REDIS_HOST=redis
REDIS_PASSWORD=null REDIS_PASSWORD=null
REDIS_PORT=6379 REDIS_PORT=6379
SESSION_DRIVER=redis SESSION_DRIVER=database
SESSION_LIFETIME=120 SESSION_LIFETIME=120
SESSION_ENCRYPT=false SESSION_ENCRYPT=false
SESSION_PATH=/ SESSION_PATH=/
@ -63,23 +63,207 @@ REVERB_SERVER_PORT=8080
# browser-side (baked into built assets): the address a browser can reach # browser-side (baked into built assets): the address a browser can reach
VITE_REVERB_APP_KEY=change-me-app-key VITE_REVERB_APP_KEY=change-me-app-key
VITE_REVERB_HOST=localhost VITE_REVERB_HOST=localhost
VITE_REVERB_PORT=8080 VITE_REVERB_PORT=443
VITE_REVERB_SCHEME=http VITE_REVERB_SCHEME=https
VITE_APP_NAME=CluPilot VITE_APP_NAME=CluPilot
# Vite HMR host the browser connects to (dev). Set to the reachable VM address # Vite HMR host the browser connects to (dev). Set to the reachable VM address
# when developing against a remote host; localhost for local-only. # when developing against a remote host; localhost for local-only.
VITE_HMR_HOST=localhost VITE_HMR_HOST=localhost
# ── Mail (dev → log) ───────────────────────────────────────────────────── # ── Mail ─────────────────────────────────────────────────────────────────
# DEV: `log` writes mails to storage/logs (nothing is sent).
# PROD: switch to smtp and fill in the credentials below.
MAIL_MAILER=log MAIL_MAILER=log
MAIL_FROM_ADDRESS=hello@clupilot.local MAIL_FROM_ADDRESS=hello@clupilot.local
MAIL_FROM_NAME=CluPilot MAIL_FROM_NAME=CluPilot
# MAIL_MAILER=smtp
# MAIL_HOST=smtp.your-provider.tld
# MAIL_PORT=587
# MAIL_USERNAME=
# MAIL_PASSWORD=
# MAIL_SCHEME=smtp # smtp | smtps (465)
# ═════════════════════════════════════════════════════════════════════════
# CluPilot operations — fill these in to run real provisioning.
# Everything below is OPTIONAL for local dev: left blank, the services are
# no-ops/fakes and the app + test suite still run. Only real infrastructure
# work (host onboarding, customer provisioning) needs them.
# ═════════════════════════════════════════════════════════════════════════
# ── Stripe (payments) ────────────────────────────────────────────────────
# WEBHOOK_SECRET is READ BY CODE today (signature check on /webhooks/stripe).
# Outside local/testing a missing secret makes the webhook fail closed.
# Stripe hands out a SEPARATE signing secret per operating mode (test/live) —
# fill in the one matching each. Which one applies follows platform.mode
# (App\Support\StripeWebhookSecret), not this file.
STRIPE_WEBHOOK_SECRET=
STRIPE_WEBHOOK_SECRET_TEST=
# Not read yet — needed once the Checkout flow is built (currently the portal
# only records a purchase intent):
STRIPE_KEY=
STRIPE_SECRET=
# ── DNS (Hetzner DNS API) ────────────────────────────────────────────────
# Creates <subdomain>.<zone> A-records for each customer instance.
HETZNER_DNS_TOKEN=
CLUPILOT_DNS_ZONE=clupilot.cloud
# Host names (fsn-01.node.…) are NOT published here — that would leak the
# internal WireGuard subnet. They go into this directory instead, which the
# vpn-dns container reads (dnsmasq --hostsdir) over the dns-hosts volume; see
# docker-compose.yml. Only change if that volume is mounted somewhere else.
CLUPILOT_DNS_HOSTS_DIR=/etc/clupilot/dns-hosts
# ── Traefik (reverse proxy + TLS) ────────────────────────────────────────
# Directory ON THE PROXMOX HOST where CluPilot writes dynamic route files.
TRAEFIK_DYNAMIC_PATH=/etc/traefik/dynamic
# ── WireGuard hub (this CluPilot VM) ─────────────────────────────────────
# Hosts join this hub during onboarding; CluPilot reaches them over the tunnel.
CLUPILOT_WG_SUBNET=10.66.0.0/24
CLUPILOT_WG_HUB_IP=10.66.0.1
CLUPILOT_WG_ENDPOINT= # public host:port peers dial, e.g. vpn.clupilot.com:51820
CLUPILOT_WG_HUB_PUBKEY= # `wg pubkey < /etc/wireguard/privatekey`
CLUPILOT_WG_CONFIG_PATH=/etc/wireguard/wg0.conf
# ── SSH identity for host onboarding ─────────────────────────────────────
# Deployed to each fresh server after the one-time root password login.
# PREFERRED: point at files (a multi-line PEM cannot live in .env).
# ssh-keygen -t ed25519 -N '' -C clupilot -f storage/app/ssh/clupilot
CLUPILOT_SSH_PUBLIC_KEY_PATH=
CLUPILOT_SSH_PRIVATE_KEY_PATH=
# Alternative (single-line values only):
CLUPILOT_SSH_PUBLIC_KEY=
CLUPILOT_SSH_PRIVATE_KEY=
CLUPILOT_SSH_COMMAND_TIMEOUT=2000
# ── Monitoring ───────────────────────────────────────────────────────────
# The built-in client speaks a GENERIC REST API:
# GET/POST /monitors, GET/DELETE /monitors/{id}
# For Uptime Kuma use the bundled bridge below (Kuma's own REST API is
# read-only — monitor CRUD goes through Socket.IO):
# MONITORING_API_URL=http://kuma-bridge:8080
# Leave blank to disable monitoring entirely: provisioning records a stable
# breadcrumb and its own health checks (occ status, TLS, admin user) still
# gate acceptance.
MONITORING_API_URL=
MONITORING_API_TOKEN=
# ── Uptime Kuma (über die mitgelieferte Bridge) ───────────────────────────
# Kuma kann Monitore nur über Socket.IO anlegen, daher die Bridge:
# docker compose --profile monitoring up -d --build kuma-bridge
# Danach hier MONITORING_API_URL auf die Bridge zeigen lassen:
# MONITORING_API_URL=http://kuma-bridge:8080
# MONITORING_API_TOKEN ist gleichzeitig das Bridge-Token (frei wählbar, lang).
KUMA_URL=
KUMA_USERNAME=
KUMA_PASSWORD=
KUMA_TOTP=
# Monitoring-Verhalten bei Ausfall:
# MONITORING_REQUIRED=false -> Bereitstellung läuft weiter (Warn-Event), Standard
# MONITORING_REQUIRED=true -> Lauf schlägt fehl, wenn Monitoring nicht erreichbar
MONITORING_REQUIRED=false
MONITORING_ATTEMPTS=2
# ── VPN-Konfigurationen ──────────────────────────────────────────────────
# Schlüssel für hier gespeicherte WireGuard-Konfigurationen (32 Byte, base64).
# Bewusst getrennt von APP_KEY. Leer = Speichern deaktiviert.
# Erzeugen: head -c 32 /dev/urandom | base64
VPN_CONFIG_KEY=
# ── Zugangsdaten der Konsole (Stripe, DNS, Monitoring, Postfächer) ────────
# Eigener Schlüssel, ebenfalls getrennt von APP_KEY — der wird routinemäßig
# gewechselt und würde sonst jedes gespeicherte Geheimnis unlesbar machen.
# LEER = die Konsole speichert gar keine Zugangsdaten und sagt das auch.
# Erzeugen: head -c 32 /dev/urandom | base64
SECRETS_KEY=
# Netze, die als "wir" gelten, solange die Website versteckt ist
# (WireGuard-Subnetz). Alles andere sieht die Platzhalterseite.
TRUSTED_RANGES=10.66.0.0/24,127.0.0.1
# Umsatzsteuersatz des Verkäufers in Prozent. Preise in der Konfiguration
# sind NETTO; die Oberfläche weist beides aus.
CLUPILOT_TAX_PERCENT=20
# ── Wo die Konsole antwortet ─────────────────────────────────────────────
#
# ADMIN_HOSTS: Hostnamen, unter denen die Konsole erreichbar ist. Der ERSTE
# ist der kanonische — für ihn werden URLs erzeugt; die weiteren sind
# Ausweichadressen, über die jemand hereinkommt, der ausgesperrt ist.
#
# ADMIN_HOST_EXCLUSIVE: darf auf diesen Namen NUR die Konsole antworten?
#
# true Die Konsole liegt an der WURZEL ihres Hostnamens
# (https://admin.…/), und Portal wie Website antworten dort mit 404.
# Dann gehört hier ausschliesslich der Konsolen-Name hinein — eine
# IP oder localhost mit aufzunehmen schaltet Portal und Website auf
# genau dieser Adresse ab. Das ist die Betriebsform.
#
# false Die Konsole liegt unter /admin auf jedem gelisteten Namen, und
# diese Namen bedienen weiterhin auch Portal und Website. Nur für
# eine Maschine ohne eigene DNS-Namen sinnvoll.
#
# Ohne Eintrag gilt false — dann zeigt https://admin.…/ das PORTAL, nicht die
# Konsole, und das sieht wie ein Fehler aus, obwohl es die Vorgabe ist.
#
# APP_HOST: Hostname des Kundenportals, z. B. app.clupilot.com. Gesetzt, ist
# JEDE Portal-Route an diesen Host gebunden und existiert nirgends sonst —
# www.clupilot.com/dashboard ist dann ein 404, kein funktionierender Aufruf.
# Leer = Portal antwortet ueberall (Vorgabe, und was jede dev-Maschine ueber
# eine blanke IP braucht).
# Hostname fuer Dateien zum Herunterladen: AGB, AV, TOM (oeffentlich) und das
# Bootstrap-Archiv (nur mit gueltigem Einmal-Code). Leer = das Archiv bleibt
# auf dem Portal-Hostnamen.
FILES_HOST=
APP_HOST=
# SITE_HOST: Hostname der oeffentlichen Website. Gesetzt, antwortet die
# Startseite NUR dort — jeder andere Host (das Portal, eine blanke IP) zeigt
# unter "/" die Anmeldung bzw. das Dashboard. Leer heisst: Startseite ueberall,
# Mehrere Namen kommagetrennt, der ERSTE ist der kanonische — er liefert aus,
# alle weiteren leiten dauerhaft dorthin um (Pfad und Query bleiben erhalten):
# SITE_HOST=www.clupilot.com,clupilot.com
# und dann liefert app.clupilot.com die Website aus. Gebunden sind Startseite,
# robots.txt und die Legal-Seiten — dadurch erzeugt route('legal.impressum')
# auch aus einer Mail heraus eine www-Adresse.
SITE_HOST=
ADMIN_HOSTS=admin.dev.clupilot.com
ADMIN_HOST_EXCLUSIVE=true
# ── Nextcloud blueprint template ─────────────────────────────────────────
# NOT an env var: set the Proxmox template VMID per plan in
# config/provisioning.php → plans.*.template_vmid (default 9000).
# ── docker-compose knobs (host-side) ───────────────────────────────────── # ── docker-compose knobs (host-side) ─────────────────────────────────────
HOST_UID=1000 HOST_UID=1000
HOST_GID=1000 HOST_GID=1000
APP_PORT=80 # Behind a reverse proxy — which is every production install — these must stay
# on loopback: Docker publishes ports ahead of UFW, so anything on 0.0.0.0 is
# reachable from the internet regardless of the firewall, and reaching a backend
# directly skips every hostname and address rule the proxy enforces.
# For local development without a proxy, set APP_PORT=80.
APP_PORT=127.0.0.1:8080
# Vite dev server (HMR). Off by default: over the HTTPS domains the
# browser cannot load assets from http://<ip>:5173. Set true only when
# working over http://10.10.90.185, then run: docker compose up -d app
VITE_AUTOSTART=false
VITE_PORT=5173 VITE_PORT=5173
REVERB_HOST_PORT=8080 REVERB_HOST_PORT=127.0.0.1:8081
DB_HOST_PORT=3306 DB_HOST_PORT=3306
# ── CI (Gitea Actions) ──────────────────────────────────────────────────────
# Registrierungs-Token aus Gitea: Repo → Settings → Actions → Runners →
# "Create new runner". Danach: docker compose --profile ci up -d runner
GITEA_INSTANCE_URL=https://git.bave.dev
GITEA_RUNNER_TOKEN=
GITEA_RUNNER_NAME=clupilot-local
# Passend zur Server-Version halten (1.20 verträgt keinen aktuellen Runner).
GITEA_RUNNER_VERSION=0.2.6
# Zone in der Zeiten ANGEZEIGT werden. Gespeichert wird immer UTC.
APP_DISPLAY_TIMEZONE=Europe/Vienna

118
.gitea/workflows/tests.yml Normal file
View File

@ -0,0 +1,118 @@
# CluPilot CI — runs on Gitea Actions (GitHub-Actions syntax, own runner).
#
# Deliberately not mirrored to GitHub: this repository describes how our
# infrastructure is provisioned, and the same workflow runs at home. If we ever
# move, this file goes along unchanged.
name: tests
on:
push:
branches: [main, 'feat/**']
pull_request:
jobs:
pest:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4.2.2
- name: Set up PHP
# Pinned: the floating v2 tag now requires node24, which the runner
# matching Gitea 1.20 cannot execute. 2.34.1 is the newest that still
# declares node20.
uses: shivammathur/setup-php@2.34.1
with:
php-version: '8.4'
extensions: mbstring, pdo_sqlite, sodium, redis, bcmath, gd, zip
coverage: none
- name: Install PHP dependencies
# Source clones, not dist archives: dist downloads go through GitHub's
# API, which throttles anonymous callers and left a half-installed
# vendor/ behind — the tests then failed with 500s that had nothing to
# do with the code. Cloning is slower and does not need anyone's quota.
# Swap back to --prefer-dist once a GitHub token is configured.
run: composer install --no-interaction --prefer-source --no-progress
- name: Prepare environment
run: |
cp .env.example .env
php artisan key:generate
- name: Tests
# phpunit.xml pins its own sqlite/array drivers, so no services needed.
run: ./vendor/bin/pest --colors=always
assets:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4.2.2
- uses: actions/setup-node@v4.1.0
with:
node-version: '22'
- name: Install JS dependencies
run: npm ci --no-fund --no-audit
# A build failure here is what used to surface as "the design is broken"
# only after deploying — catch it before it ships.
- name: Build assets
run: npm run build
release:
# Only a green run produces the tag the console offers as an update.
needs: [pest, assets]
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4.2.2
with:
fetch-depth: 0
- name: Tag this commit as tested
run: |
tag="tested-$(date -u +%Y%m%d-%H%M)-$(git rev-parse --short HEAD)"
git tag "$tag"
git push origin "$tag"
# A release is cut by editing VERSION and merging it — nothing else. The
# tag is created here, only after the suite is green, and only if it does
# not already exist. Never moved: servers are pinned to these, and a tag
# that changes underneath them means two machines claiming one version.
- name: Tag the release when VERSION changed
run: |
version="$(tr -d ' \n\r' < VERSION)"
# Anchored, because a `case` glob does not anchor: `[0-9]*.[0-9]*`
# happily accepts 1x.2y.3garbage and would tag it.
printf '%s' "$version" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$' \
|| { echo "VERSION is not MAJOR.MINOR.PATCH: '$version'" >&2; exit 1; }
# Only the commit that RAISED the version is the release. Without
# this, any later green push finds the tag missing and claims it —
# and the tag would then name code the bump never described. Runs
# overlap and finish out of order; that is enough for it to happen.
previous="$(git show 'HEAD^:VERSION' 2>/dev/null | tr -d ' \n\r' || true)"
if [ "$previous" = "$version" ]; then
echo "::notice::VERSION is unchanged ($version) — nothing to release."
exit 0
fi
tag="v${version}"
git fetch --tags --force origin
if existing="$(git rev-parse -q --verify "refs/tags/${tag}^{commit}")"; then
# Already released. The invariant is that the tag points at the
# commit VERSION was raised in — if a later commit still carries
# that number, that is fine, but the tag must not be re-pointed.
if [ "$existing" != "$(git rev-parse HEAD)" ]; then
echo "::notice::${tag} already exists at ${existing}; leaving it alone."
fi
exit 0
fi
git config user.name "CluPilot CI"
git config user.email "ci@clupilot.local"
git tag -a "$tag" -m "CluPilot ${version}"
git push origin "$tag"
echo "::notice::Released ${tag}"

12
.gitignore vendored
View File

@ -3,6 +3,10 @@
.env .env
.env.backup .env.backup
.env.production .env.production
# EnvFileEditor's own backups (App\Services\Env\EnvFileEditor::backup()) — a
# timestamped copy beside .env before every write from the console's raw
# editor. Never pruned automatically; see the warning on that page.
.env.bak-*
.phpactor.json .phpactor.json
.phpunit.result.cache .phpunit.result.cache
/.codex /.codex
@ -20,6 +24,10 @@
/public/storage /public/storage
/storage/*.key /storage/*.key
/storage/pail /storage/pail
# Exported invoice PDFs — runtime output, and on a real server this is a mount
# point. Committing it would put customer documents into the repository and
# make a deploy fight the archive for the directory.
/storage/archive
/vendor /vendor
_ide_helper.php _ide_helper.php
Homestead.json Homestead.json
@ -28,3 +36,7 @@ Thumbs.db
# Gitea push token (parent home dir; never track) # Gitea push token (parent home dir; never track)
.env.gitea .env.gitea
clupilot.env
# SDD-Arbeitsverzeichnis (Ledger, Briefs, Review-Pakete) — Kladde, nicht Quelltext.
.superpowers/

314
CLAUDE.md Normal file
View File

@ -0,0 +1,314 @@
# CluPilot — verbindliche Regeln (Repo-Teil)
Die **Vollfassung der Regeln R1R17 liegt beim Nutzer**, nicht im Repo. Die
Kurzfassung steht in `docs/handoffs/2026-07-25-clupilot-state-handoff.md` §9.
Bei Konflikt: **STOP & fragen.**
Diese Datei hält die Regeln fest, die aus konkreten Fehlern im laufenden Betrieb
entstanden sind — sie sind nicht verhandelbar und werden per Test erzwungen.
---
## R18 — Icon-Größe und Zeilenumbruch
**Ein Icon steht neben seinem Text, nie darüber, und nie größer als bestellt.**
Verboten:
1. **Icon zwingt den Text auf eine zweite Zeile.** Ein Navigationseintrag, ein
Button, ein Tabellen-Action ist **einzeilig**. Zwei Zeilen sind nur erlaubt,
wenn der Text selbst bewusst zweizeilig gesetzt ist (Label + Unterzeile) —
dann steht das Icon links davon, nicht darüber.
2. **Icon größer als die Zeile, in der es sitzt.** Standard ist `size-5` (20px)
in der Navigation, `size-4` (16px) in Buttons, Tabellen und Fließtext.
Größer nur, wenn es als eigenständiges Element gemeint ist (Leerzustand,
Statusplakette).
### Warum das zweimal schiefging
- **Zeilenumbruch.** Tailwinds Preflight setzt `svg { display: block }`. Ein
Icon in einem *inline*-Elternteil schiebt den folgenden Text damit auf die
nächste Zeile. Genau so wurde aus dem Eintrag „Zugangsdaten" ein doppelt so
hoher Kasten mit Schloss oben und Wort darunter.
- **Größe.** `.size-4` und `.size-5` haben **dieselbe Spezifität**, also
entscheidet die Reihenfolge im Stylesheet — und Tailwind gibt `.size-4` *vor*
`.size-5` aus. Eine Komponente, die `size-5` bedingungslos mitmergt, überstimmt
damit **jedes** `class="size-4"` am Aufrufort. Alle so geschriebenen Icons
liefen still auf 20px.
### Wie es jetzt gebaut ist
- `resources/views/components/ui/icon.blade.php` setzt seine Standardgröße
**nur**, wenn der Aufrufort keine `size-`/`w-`/`h-`-Klasse mitgibt, und
rendert `inline-block shrink-0 align-middle` statt des Preflight-`block`.
- `resources/views/components/ui/nav-item.blade.php` legt Label und Icon in eine
eigene Flex-Zeile, damit ein Icon auch im falschen Slot daneben landet.
- Icons in `<x-ui.nav-item>` gehören in `<x-slot:icon>`, nicht in den
Default-Slot.
### Erzwungen durch
`tests/Feature/IconLayoutTest.php` — Größe des Aufruforts gewinnt, Icon bleibt
`inline-block`, Nav-Eintrag bleibt einzeilig aus beiden Slots, und kein
Blade-File im Repo darf ein Icon am Icon-Slot vorbeischmuggeln.
---
## R19 — Zeitzone: gespeichert in UTC, angezeigt auf der Wanduhr
**Jede Zeit, die ein Mensch liest, geht vorher durch `->local()`.**
Verboten:
1. **Einen gespeicherten Zeitstempel direkt formatieren.** `$model->created_at->isoFormat(…)`
liefert UTC. Richtig ist `$model->created_at->local()->isoFormat(…)`.
2. **`->timezone(config('app.timezone'))`.** Das ist die *Speicher*zone und bleibt
UTC — der Aufruf sieht aus wie eine Umrechnung und ist keine.
3. **Ein `datetime-local`-Feld nur in eine Richtung behandeln.** Rein und raus
gehören zusammen: `LocalTime::toField()` und `LocalTime::fromField()`.
Ein Feld hat keine Zeitzone; es sind die Ziffern, die jemand auf der eigenen
Uhr abliest.
Ausgenommen: `diffForHumans()` ist relativ und in jeder Zone gleich.
### Warum das durchgerutscht ist
Die Konsole kündigte eine Aktualisierung „spätestens um 15:21" an, während die
Uhr 17:21 zeigte. Zwei der vierzehn betroffenen Ansichten sahen sogar behandelt
aus — sie riefen `->timezone(config('app.timezone'))`, was sich liest wie „in
Ortszeit umrechnen" und, weil diese Zone UTC ist, nichts tut. Eine Attrappe ist
schlimmer als gar kein Aufruf: sie hält den Nächsten vom Nachsehen ab.
Schlimmer als die Anzeigen waren zwei **Formulare**: Wartungsfenster und
Paketversionen füllten ihre Felder mit UTC und lasen sie als UTC zurück. Ein
eingetragenes „21:00" wurde zu 23:00 Ortszeit.
Und die Tests deckten es nicht auf, weil sie den erwarteten Wert **mit demselben
falschen Aufruf** bildeten. Ein Test, der die Implementierung nachrechnet, prüft
nichts.
### Wie es jetzt gebaut ist
- `config('app.display_timezone')` (`APP_DISPLAY_TIMEZONE`, Vorgabe
`Europe/Vienna`) getrennt von `app.timezone`, das UTC bleibt.
- `->local()` als Carbon-Makro in `AppServiceProvider`. Es **kopiert** vor dem
Umstellen: `Illuminate\Support\Carbon` ist mutabel, sonst schriebe das bloße
Anzeigen das Modellattribut um. Beide Klassen bekommen denselben Rumpf —
Carbon führt Makros in *einer* globalen Tabelle, die zweite Registrierung
ersetzt die erste für alle.
- `App\Support\LocalTime` hält beide Feldrichtungen nebeneinander, damit niemand
eine ändert, ohne die andere zu sehen.
### Erzwungen durch
`tests/Feature/DisplayTimezoneTest.php` — kein Blade und kein Livewire-Bauteil
darf absolut formatieren ohne `->local()`, die UTC-Attrappe ist verboten,
Speicherzone bleibt UTC, Sommer- **und** Winterzeit werden geprüft, `->local()`
verändert das Original nicht, und der Feld-Round-Trip kommt als derselbe
Zeitpunkt zurück.
---
## R20 — Bearbeiten passiert im Modal, nie in der Zeile
**Sobald etwas Eingabefelder hat, geht ein Modal auf.**
Verboten:
1. **Inline-Bearbeitung in einer Tabellenzeile.** Kein `<input>`, kein
`<textarea>` in einem `<td>`. Die Zeile wächst, die Spalten daneben springen,
und eine halb im Bearbeitungsmodus stehende Tabelle liest sich wie ein
Darstellungsfehler, nicht wie ein Formular.
2. **Ein Bearbeiten-Knopf, der eine Methode am Seiten-Bauteil aufruft.** Er
schickt `openModal` — alles andere ist der Inline-Editor unter neuem Namen.
Nicht betroffen: Formulare, die *die Seite sind* — Anlegen-Formulare, die
Einstellungsseite, die Einladen-Zeile über einer Tabelle. Die bearbeiten keinen
bestehenden Datensatz an Ort und Stelle.
Ausnahmen, die kein Modal brauchen: ein einzelnes `<select>` oder eine
Checkbox in der Zeile (Rolle umstellen, aktiv schalten). Ein Klick, ein Wert,
keine Höhenänderung.
### Warum das aufgeschrieben wurde
Das Projekt hatte das Modal längst — `EditDatacenter`, mit genau dieser
Begründung im Kopfkommentar („avoids the row-height jump of inline editing").
Die Benutzertabelle hat es einfach nicht benutzt, und ich habe die
Bearbeitung inline gebaut, obwohl das Muster danebenlag.
### Wie es jetzt gebaut ist
- `App\Livewire\EditSeat` als `ModalComponent`, geöffnet über
`$dispatch('openModal', { component: 'edit-seat', arguments: { uuid } })`.
- Ein Modal ist **ohne** die Route-Middleware der Seite erreichbar. Es löst
deshalb den Kunden selbst auf und liest den Datensatz neu, statt einer vom
Browser hydrierten Eigenschaft zu glauben.
### Erzwungen durch
`tests/Feature/EditInModalTest.php` — kein Seiten-Blade darf ein Eingabefeld in
einem `<td>` wachsen lassen, und die Benutzertabelle muss `edit-seat` per
`openModal` öffnen.
---
## R21 — Konsole und Portal teilen keine Identität
**Betreiber und Kunden sind zwei Personengruppen, nicht zwei Zustände einer.**
Verboten:
1. **Eine Rolle oder Berechtigung am `User`.** Alle siebzehn Berechtigungen sind
Konsolen-Berechtigungen; sie liegen am `operator`-Guard. Ein `users`-Datensatz
mit Konsolenzugang ist die Vermischung in klein.
2. **Eine Auth-Route, die beide Seiten bedient.** `RestrictAdminHost::SHARED`
führt nur noch `livewire/*` und `up`.
3. **Eine Anmeldeansicht für beide.** Das Portal hat Fortify, die Konsole hat
`App\Livewire\Auth\OperatorLogin`.
### Warum das aufgeschrieben wurde
Die Konsole lieferte die Anmeldeseite des Portals aus — mit „Kein Konto?
Registrieren" darauf. `register` stand nicht in `SHARED` und konnte dort auch
nicht stehen, weil eine Konsole keine Selbstregistrierung hat. Der Link führte
also zwangsläufig in eine 404. Das war kein Anzeigefehler, sondern die Identität
zweier Personengruppen in einer Tabelle.
### Erzwungen durch
`tests/Feature/IdentitySeparationTest.php`
---
## R22 — Der Aufwand richtet sich nach der Aufgabe, nicht nach dem Verfahren
**Prüfen ist ein Mittel, kein Selbstzweck. Wer für eine Tabellenumstellung
zwölf Stunden und zehn Prüfrunden braucht, hat die Aufgabe nicht verstanden.**
Verbindliche Obergrenzen:
1. **Eine Prüfrunde, eine Fix-Runde, ein Re-Review über den Fix-Diff.** Danach
wird ein offener Befund **geparkt** — mit schriftlicher Begründung — und
nicht weitergeschliffen. Ein zweiter Re-Review desselben Befundes ist
verboten.
2. **Codex/R15: höchstens zwei Runden ohne P1.** Danach gehen die Restbefunde
als Folgepunkte in den Merge Request. Bis zur Nullmeldung zu schleifen ist
kein Qualitätsmerkmal, sondern fehlende Priorisierung.
3. **Ein Review sieht nur den Diff.** Dateien, die seit dem letzten grünen
Testlauf nicht angefasst wurden, werden nicht erneut geprüft — weder von
einem Prüfer noch beim Aufräumen. Was unverändert ist, war beim letzten
grünen Lauf schon in Ordnung.
4. **Mutationstests nur an der Grenze**, an der ein Fehler Daten verliert,
Rechte ausweitet oder Zugangsdaten preisgibt. Nicht flächendeckend.
5. **Aufgaben ohne Entwurfsentscheidung bekommen keinen eigenen Review.** Eine
Sprachdatei, eine Umbenennung, ein Feld mehr: bauen, Tests, weiter.
6. **Unabhängige Arbeit läuft gleichzeitig.** Analysen (Review, Codex,
Testlauf) blockieren einander nicht und werden parallel gestartet.
### Warum das aufgeschrieben wurde
Die Betreiber-Identität — eine Tabelle, ein Guard, eine Anmeldeseite — hat
zehn Codex-Runden und mehrere Re-Reviews gebraucht. Die Funde waren echt, aber
die Reihenfolge war falsch: Sicherheitsgrenzen zuerst, Randfälle in den
Folgepunkt. Der Nutzer wartet auf ein Ergebnis, nicht auf ein Verfahren.
---
## R23 — Bestätigung passiert im Modal, nie im nativen Browser-Dialog
**Eine Aktion mit Folgen wird im Design dieses Produkts bestätigt — nicht in
einem Fenster, das der Browser zeichnet.**
Verboten:
1. **`wire:confirm`.** Das Attribut ruft `window.confirm()` auf, bevor die
Anfrage beim Server ankommt — eine Systemmeldung mit dem Hostnamen darin,
kein Bestandteil der Oberfläche, die dieses Produkt zeichnet, nicht
gestaltbar und nur so übersetzt, wie der Browser gerade eingestellt ist.
2. **`confirm(` aus eigenem JavaScript.** Derselbe native Dialog, nur ohne die
Livewire-Beschriftung. Ein Skript, das ihn direkt aufruft, hat dasselbe
Problem unter anderem Namen.
### Warum das aufgeschrieben wurde
Die Konsole fragte vor dem Übernehmen eines Zahlungs-Schlüssels „Auf
admin.clupilot.com wird Folgendes angezeigt: Diesen Schlüssel wirklich
übernehmen?" — ein Systemfenster mit der Domain darin, mitten im sonst
durchgestalteten Betreiber-Bereich. Sechs Stellen in beiden Bereichen taten
dasselbe: ein Schlüssel speichern oder entfernen, ein VPN-Zugang neu
ausgestellt, Zwei-Faktor entfernt (Konsole UND Portal, zwei getrennte
Identitäten nach R21), ein Benutzerzugang entzogen.
### Wie es jetzt gebaut ist
- Sechs eigene Bestätigungs-Modals nach dem Muster von `ConfirmRemoveHost` und
den anderen bestehenden Lösch-Bestätigungen — `App\Livewire\Admin\
ConfirmSaveSecret`, `ConfirmForgetSecret`, `ConfirmReissueVpnPeer`,
`ConfirmDisableTwoFactor` in der Konsole; `App\Livewire\
ConfirmDisableTwoFactor` und `ConfirmRevokeSeat` im Portal. Geöffnet über
`$dispatch('openModal', { component: '…', arguments: { … } })`, derselbe
Mechanismus, dieselbe Knopf-Reihenfolge (Abbrechen sekundär, Bestätigen
benannt statt „OK").
- Das Modal mutiert nichts selbst. Der eigentliche Vorgang (`save()`,
`forget()`, `reissue()`, `disableTwoFactor()`, `revoke()`) bleibt
unverändert auf der Seiten-Komponente stehen, mit ihren eigenen
`guard()`-/Passwort-Prüfungen. Der Bestätigen-Knopf im Modal löst nur ein
Event aus (z. B. `secret-save-confirmed`), das die Seiten-Komponente per
`#[On(...)]` auffängt und an die bestehende Methode weiterreicht — das hält
die Berechtigungsprüfung an der einen Stelle, an der sie schon stand, statt
sie im Modal zu verdoppeln.
- Der bisherige Bestätigungssatz bleibt wortgleich als `_body` stehen; nur der
neue `_title` (und bei den Zwei-Faktor-Aktionen kein weiterer Text) kam
hinzu, `_confirm` heißt jetzt wie überall sonst im Repo die kurze
Knopfbeschriftung statt eines ganzen Satzes.
### Erzwungen durch
`tests/Feature/ConfirmInModalTest.php` — kein Blade-File im Repo darf
`wire:confirm` enthalten, und kein JavaScript darf `confirm(` aufrufen.
---
## R24 — Ein Modal wird nie höher als der Bildschirm
**Kopf und Fuß stehen fest, gescrollt wird nur die Mitte.**
Verboten:
1. **Ein Modal, das mit seinem Inhalt wächst.** Es läuft unten aus dem Fenster
heraus und nimmt seinen eigenen Speichern-Knopf mit — erreichbar nur, indem
man die Seite *hinter* dem Overlay scrollt, auf dem Telefon also gar nicht.
2. **Titel oder Knopfzeile im Scrollbereich.** Was man lesen muss, um zu wissen,
wo man ist, und was man erreichen muss, um fertig zu werden, gehören beide
außerhalb.
3. **Eine Blade-Direktive in der Attributliste eines Komponenten-Tags**
(`<x-ui.modal @if(…) wire:poll @endif>`). Sie landet im Attribut-Beutel und
zerlegt die View — dieselbe Falle wie `@disabled` an einer Komponente. Solche
Attribute gehören auf ein eigenes Element im Inhalt.
### Wie es gebaut ist
- `resources/views/vendor/wire-elements-modal/modal.blade.php` begrenzt das
Panel **einmal für alle Modals**: `max-h-[calc(100dvh-3rem)]`, Spalte,
`overflow-hidden`. `dvh` statt `vh`, weil am Telefon die Browserleiste zu `vh`
zählt — genau der Fall, in dem der letzte Zentimeter zählt.
- `resources/views/components/ui/modal.blade.php` ist die andere Hälfte:
`header`-Slot (oder `title`/`subtitle`), scrollender Rumpf, `footer`-Slot.
`min-h-0` am Rumpf ist die Zeile, die es wirken lässt — ein Flex-Kind wird
ohne sie nicht kleiner als sein Inhalt, und `overflow` greift nie.
- Ein Knopf im Fuß, der ein Formular im Rumpf abschickt, benutzt
`type="submit" form="…"`. Das ist der Preis für einen Fuß, der nicht
wegscrollt — und ein HTML-Attribut, kein Trick.
### Gilt für
Jedes Modal, in dem ein **Eingabefeld** steckt (`<form`, `<input`, `<textarea`,
`<select`, `x-ui.input`). Eine zweizeilige Rückfrage mit einem Knopf hat nichts,
was festbleiben müsste, und wird nicht umgebaut.
### Erzwungen durch
`tests/Feature/ModalHeightTest.php` — das Panel bleibt begrenzt, jedes Modal mit
einem Feld benutzt `<x-ui.modal>`, und keine Blade-Direktive steht in der
Attributliste eines Komponenten-Tags.

1
VERSION Normal file
View File

@ -0,0 +1 @@
1.3.71

View File

@ -0,0 +1,348 @@
<?php
namespace App\Actions;
use App\Models\Instance;
use App\Models\Order;
use App\Models\ProvisioningRun;
use App\Models\Subscription;
use App\Models\SubscriptionRecord;
use App\Provisioning\Jobs\AdvanceRunJob;
use App\Services\Billing\CustomDomainAccess;
use App\Services\Billing\PlanChange;
use App\Services\Billing\TaxTreatment;
use App\Services\Stripe\StripeClient;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Throwable;
/**
* The single place a plan change actually happens.
*
* PlanChange is the preview: what a move would cost and whether it is allowed
* yet. Nothing carried one out. A customer could buy an upgrade, see it sit in
* the cart as a paid order, and keep the same contract, the same machine and the
* same quota forever the order was created and never consumed by anything.
*
* This is the consumer. It does the four things a plan change is, in one place
* so they cannot be done by halves:
*
* - the CONTRACT moves onto the target package's current sellable version, so
* the snapshot every step, bill and entitlement check reads says what the
* customer is now on;
* - the REGISTER gets one row, because a change of terms is a commercial event
* and the register is the evidence of what was agreed;
* - the ENTITLEMENTS that hang off the package are settled today that is the
* own domain, which a smaller package can take away;
* - the MACHINE is resized, through a provisioning run like every other piece
* of remote work in this application, so it is retried, logged and visible in
* the console instead of happening in a web request;
* - STRIPE is moved onto the new price, because it owns the billing cycle and
* would otherwise keep charging for the package the customer has left. Last,
* outside the transaction, and unable to fail the change see below.
*
* Idempotent from both ends. A contract already on the target version is a
* no-op rather than an error the order is consumed and nothing else moves
* and the register row carries the order's own event key, so a duplicate write
* collides in the database rather than in a check that two callers could both
* pass.
*/
class ApplyPlanChange
{
public function __construct(private RecordCommercialEvent $record) {}
/**
* Carry out the change an order bought.
*
* The contract is resolved through CustomDomainAccess::contractOf() rather
* than re-queried here: it is already the one place that answers "which
* contract is this customer on", and a second copy of that query is the one
* that would disagree the day the rule changes.
*/
public function forOrder(Order $order, ?Carbon $at = null): ?ProvisioningRun
{
$subscription = app(CustomDomainAccess::class)->contractOf($order->customer);
if ($subscription === null) {
Log::warning('No contract to move: this plan change has no live subscription behind it.', [
'order_id' => $order->id, 'plan' => $order->plan,
]);
return null;
}
return $this($subscription, (string) $order->plan, $order, $at);
}
/**
* @return ProvisioningRun|null the resize run, or null when the change was
* refused, was a no-op, or there is no machine
* to resize
*/
public function __invoke(Subscription $subscription, string $targetPlan, ?Order $order = null, ?Carbon $at = null): ?ProvisioningRun
{
$at = $at?->copy() ?? now();
try {
// The version on sale RIGHT NOW, which is what someone moving today
// is being sold. Throws for a plan that has been withdrawn or is not
// priced on this term, and that is the correct outcome: a contract
// moved onto a guess is worse than a change that visibly fails.
$target = Subscription::snapshotFrom($targetPlan, $subscription->term);
} catch (Throwable $e) {
Log::error('Cannot move a contract onto a package the catalogue will not sell.', [
'subscription' => $subscription->uuid, 'plan' => $targetPlan, 'error' => $e->getMessage(),
]);
return null;
}
// Already there. Not an error — a retried trigger, a command run twice,
// an operator pressing the same thing — but the order is consumed so it
// stops asking.
if ((string) $subscription->plan === $targetPlan
&& (int) $subscription->plan_version_id === (int) $target['plan_version_id']) {
$this->consume($order);
return null;
}
// The gate, and the only one. An upgrade with the period already over,
// a downgrade before the term the customer paid for has run out, and any
// move on a contract that is no longer active all stop here — see
// PlanChange::evaluate() for why each of them is refused.
$change = PlanChange::evaluate($subscription, $targetPlan, $at);
if (! $change->allowedNow) {
return null;
}
$instance = $this->instanceOf($subscription);
// Read BEFORE the snapshot moves: the resize step has to know what the
// machine had, to tell a change of CPU or RAM from a change that only
// touched storage or price.
$before = [
'plan' => (string) $subscription->plan,
'cores' => (int) ($instance?->cores ?? $subscription->cores),
'ram_mb' => (int) ($instance?->ram_mb ?? $subscription->ram_mb),
];
$run = DB::transaction(function () use ($subscription, $target, $targetPlan, $change, $order, $instance, $before, $at) {
$subscription->applyPlanSnapshot($target);
// A booked change is spent the moment the contract moves — whether it
// was this one arriving on its date, or a move in the other direction
// overtaking it. This is where the shop used to do it: an upgrade CLICK
// cleared a booked downgrade, in the cart, before a cent had been paid,
// and since nothing marks a cart order paid the customer lost the
// booking and got nothing in exchange. The contradiction is real —
// a downgrade left booked would come due months later and quietly undo
// the bigger package — but it is only real once the bigger package is
// actually theirs, which is here.
//
// Cleared AFTER PlanChange::evaluate() above, which reads the booked
// due date to decide whether a downgrade is allowed yet.
$subscription->clearPendingPlanChange();
// The register row is written from the contract that has just been
// moved, so the evidence and the contract cannot describe different
// packages — the same order OpenSubscription writes a purchase in.
($this->record)(
event: $change->isUpgrade
? SubscriptionRecord::EVENT_UPGRADE
: SubscriptionRecord::EVENT_DOWNGRADE,
subscription: $subscription,
// Zero, because a plan change moves TERMS and no money at all.
// Stripe settles the difference on a proration invoice of its
// own (PRORATE_IMMEDIATELY below), and that invoice arrives here
// as its own EVENT_INVOICE_PAID row carrying the real figure. An
// amount on this row as well counted one upgrade's money twice in
// any sum over the register.
//
// What the move is WORTH is not lost — it goes into the snapshot
// below, which is where the question "what were they quoted?"
// belongs. Deliberately NOT the order's amount_cents either:
// that is the shop's headline price for the whole package, and
// recording it for a move made on the sixteenth would state a sum
// nobody agreed to.
netCents: 0,
at: $at,
extra: ['plan_change' => [
'from_plan' => $before['plan'],
'to_plan' => $targetPlan,
'remaining_days' => $change->remainingDays,
'term_days' => $change->termDays,
// The preview's pro-rata difference, and what the till would
// take from THIS customer for it. Both, because PlanChange
// prorates the NET catalogue figures while the Stripe Price it
// prorates against is the one that customer is billed on — so
// for anybody who is charged VAT the net figure alone reads as
// a fifth less than the proration invoice that follows, and
// somebody reconciling the two needs the number to compare.
// For a reverse-charge business the two are equal, and saying
// so is what stops a reader looking for a missing fifth.
'prorated_net_cents' => $change->chargeCents,
'prorated_charge_cents' => TaxTreatment::for($subscription->customer)
->chargeCents($change->chargeCents),
]],
// Nothing has been charged AT THIS EVENT. Left null rather than
// reported as zero, which the register would read as a free sale.
chargedGrossCents: null,
order: $order,
// One row per order, enforced by the unique index rather than by
// a check two concurrent triggers could both pass.
eventKey: $order !== null ? 'plan-change:order:'.$order->id : null,
);
$this->resizeInstance($instance, $target, $targetPlan);
$this->consume($order);
return $this->startResize($instance, $targetPlan, $before, $change->isUpgrade, $order);
});
// After the run exists, never before. settleCustomDomain() withdraws a
// domain the new package no longer carries, and withdrawing one asks
// ReapplyInstanceAddress for a run to stop serving it — which finds this
// run already in flight against the same order and stands aside, because
// this pipeline carries the address steps itself. The other way round
// there would be two runs writing one router.
PlanChange::settleCustomDomain($subscription);
// Stripe bills the next term, so it has to be told which package that
// is — without this a customer who upgraded goes on paying for the one
// they left, every month, for as long as the contract runs.
//
// After the transaction and never inside it. The change has already
// landed on the contract and on the machine, and an unreachable or
// unconfigured Stripe must not roll any of that back: the action never
// throws, and parks what it could not do on the contract for the hourly
// sweep to pick up. A contract with no Stripe subscription behind it —
// a granted package — is left entirely alone.
//
// Resolved from the container here rather than injected, so a test that
// swaps the client in after this action exists still gets its fake.
app(MoveStripeSubscriptionPrice::class)(
$subscription,
$change->isUpgrade ? StripeClient::PRORATE_IMMEDIATELY : StripeClient::PRORATE_NONE,
);
if ($run !== null) {
AdvanceRunJob::dispatch($run->uuid); // after commit
Log::info('Applying a plan change.', [
'subscription' => $subscription->uuid,
'from' => $before['plan'],
'to' => $targetPlan,
'upgrade' => $change->isUpgrade,
'run' => $run->uuid,
]);
}
return $run;
}
/**
* The order is spent. Anything but `pending` takes it out of the customer's
* cart, and `applied` says which way it went it was never paid in the sense
* an invoice means, and calling it that would put a charge in the record that
* never happened.
*/
private function consume(?Order $order): void
{
if ($order !== null && $order->status !== 'applied') {
$order->update(['status' => 'applied']);
}
}
/**
* Bring the machine's own row in line with the package it now serves.
*
* Everything except the disk. A disk cannot be shrunk Proxmox grows one
* online and has no way back so a downgrade leaves the guest exactly as
* large as it already is, and writing the smaller figure here would make this
* row claim a size the machine does not have. Two things read it and both
* would be wrong: the host's storage accounting, which would think it had
* space it does not, and the next resize, which would ask Proxmox to shrink.
*
* What actually shrinks is the QUOTA, which Nextcloud enforces and which is
* what was sold see the plan-change pipeline's quota step. The disk is
* infrastructure; the quota is the product. The contract's own `disk_gb` still
* records what the smaller package includes, so nothing has been lost: this
* column is what the machine HAS, and that is all it has ever meant.
*
* @param array<string, mixed> $target
*/
private function resizeInstance(?Instance $instance, array $target, string $targetPlan): void
{
$instance?->update([
'plan' => $targetPlan,
'quota_gb' => $target['quota_gb'],
'ram_mb' => $target['ram_mb'],
'cores' => $target['cores'],
]);
}
/**
* Start the run that makes the machine match the package.
*
* Subject is the instance's own purchase order, exactly as the `address`
* pipeline does it not the upgrade order. That is what CustomerStep::order()
* and ::subscription() resolve from, so the steps read this customer's
* contract rather than nothing; it is what ReapplyInstanceAddress checks for
* a run already in flight, so two runs cannot write one router; and it keeps
* RunRunner from marking a paid, running customer's order failed because a
* resize could not be applied (see ProvisioningSubject::provisioningPipeline).
*
* @param array{plan: string, cores: int, ram_mb: int} $before
*/
private function startResize(?Instance $instance, string $targetPlan, array $before, bool $isUpgrade, ?Order $order): ?ProvisioningRun
{
// A contract with no machine — one still being built, one whose build
// failed, a package granted before provisioning ran — has nothing to
// resize. The contract has still moved, and the machine will be built
// from it whenever it is built.
if ($instance === null || ! $instance->hasLiveMachine()) {
return null;
}
return ProvisioningRun::create([
'subject_type' => Order::class,
'subject_id' => $instance->order_id,
'pipeline' => 'plan-change',
'status' => ProvisioningRun::STATUS_PENDING,
'current_step' => 0,
'context' => [
'instance_id' => $instance->id,
'host_id' => $instance->host_id,
'node' => $instance->host?->node,
'vmid' => $instance->vmid,
'subdomain' => $instance->subdomain,
// What the machine had before, so the resize step can tell which
// of its changes need the guest to come back round before they
// are true. Kept on the run rather than recomputed: by the time
// the step executes, the instance row already says the new size.
'plan_change' => [
'order_id' => $order?->id,
'from_plan' => $before['plan'],
'to_plan' => $targetPlan,
'from_cores' => $before['cores'],
'from_ram_mb' => $before['ram_mb'],
'upgrade' => $isUpgrade,
],
],
]);
}
/**
* The machine this contract pays for: the link when provisioning has written
* it, and otherwise the customer's newest instance the same fallback
* CustomDomainAccess makes, for the same reason.
*/
private function instanceOf(Subscription $subscription): ?Instance
{
return $subscription->instance
?? $subscription->customer?->instances()->latest('id')->first();
}
}

View File

@ -0,0 +1,131 @@
<?php
namespace App\Actions;
use App\Models\Instance;
use App\Models\Order;
use App\Models\ProvisioningRun;
use App\Models\Subscription;
use App\Provisioning\Jobs\AdvanceRunJob;
use App\Provisioning\WorkInFlight;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
/**
* Deliver the storage a customer has just bought or take back what they have
* just cancelled.
*
* A booked pack was a row in `subscription_addons` and nothing else. It was
* priced, frozen, charged for every month and had no effect whatsoever on the
* machine: the disk stayed the size the package made it, the filesystem inside
* stayed the size the disk had been, and Nextcloud went on enforcing the
* package's own allowance. This is the missing half the moment a booking
* becomes space.
*
* It does not do the work. Growing a disk, growing a guest's filesystem and
* writing an occ setting are remote work on a live machine, so they go through
* the same run machinery as everything else see the `storage` pipeline where
* they are retried, logged and visible in the console instead of happening
* inside whichever web request happened to book the pack.
*
* Called from the one place a pack becomes real or stops being real:
* App\Actions\BookAddon, on booking and on cancellation. Cancelling matters as
* much as booking the allowance goes back down, and Nextcloud has to be told,
* or the customer would keep the storage they have stopped paying for.
*/
class ApplyStorageAllowance
{
/** Start a storage run for the machine this contract pays for. */
public function __invoke(?Subscription $subscription): ?ProvisioningRun
{
if ($subscription === null) {
return null;
}
// The link when provisioning has written it, and otherwise the
// customer's newest instance — the same fallback ApplyPlanChange and
// CustomDomainAccess make, for the same reason.
return $this->forInstance(
$subscription->instance ?? $subscription->customer?->instances()->latest('id')->first()
);
}
/**
* Start a storage run for this instance, or return null when there is
* nothing to start.
*
* Null is the ordinary answer and not a failure: a machine still being
* built, one whose VM has gone, one whose service has ended, or one that
* already has a run in flight all deliver the allowance from that run
* instead every pipeline that touches storage reads the allowance when it
* gets there, which is this state or newer.
*/
public function forInstance(?Instance $instance): ?ProvisioningRun
{
if ($instance === null || ! $instance->hasLiveMachine()) {
return null;
}
// The same lock ReapplyInstanceAddress and RestartInstance take, for the
// same reason: two bookings can land in one instant — a double click, a
// webhook delivered twice, an operator granting a pack while the
// customer buys one — and two runs against one machine would resize the
// same disk and race each other's occ calls. Nobody waits for it: a run
// that lost the race has nothing to add, because the one that won reads
// the same allowance.
$lock = Cache::lock('instance-storage:'.$instance->uuid, 30);
if (! $lock->get()) {
return null;
}
try {
return $this->start($instance);
} finally {
$lock->release();
}
}
private function start(Instance $instance): ?ProvisioningRun
{
$order = $instance->order;
// Stand aside only for a run that will deliver the whole allowance: the
// disk, the filesystem inside it and the figure Nextcloud enforces. A
// plan change does all three and there is nothing to add. A `quota` run
// does only the last of them, and letting it stand for the booking would
// enforce a quota over a filesystem that never grew — space the machine
// does not have. `address` and `restart` do none of them, and standing
// aside for one of those was how a pack could be charged monthly and
// never delivered. See App\Provisioning\WorkInFlight.
if (app(WorkInFlight::class)->covers($order, 'storage')) {
return null;
}
$run = ProvisioningRun::create([
'subject_type' => Order::class,
'subject_id' => $order->id,
'pipeline' => 'storage',
'status' => ProvisioningRun::STATUS_PENDING,
'current_step' => 0,
// Everything the three steps read: instance_id is how CustomerStep
// finds the machine, node and vmid are how it reaches inside it.
'context' => [
'instance_id' => $instance->id,
'host_id' => $instance->host_id,
'node' => $instance->host?->node,
'vmid' => $instance->vmid,
'subdomain' => $instance->subdomain,
],
]);
AdvanceRunJob::dispatch($run->uuid);
Log::info('Delivering a changed storage allowance.', [
'instance' => $instance->uuid,
'run' => $run->uuid,
]);
return $run;
}
}

View File

@ -0,0 +1,641 @@
<?php
namespace App\Actions;
use App\Models\StripePendingEvent;
use App\Models\Subscription;
use App\Models\SubscriptionRecord;
use Illuminate\Database\UniqueConstraintViolationException;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Throwable;
/**
* What Stripe tells us about the billing cycle, applied to the contract.
*
* The division of labour: Stripe owns the money retries, dunning, off-session
* SCA and we own capability, because Stripe does not know how big the VM
* should be. So these handlers move period boundaries and status, and write the
* register. They never re-derive an amount: the invoice is the authority for
* what was charged, and recomputing it here from our own catalogue would produce
* a second, disagreeing answer.
*
* The one thing Stripe does NOT own is the document. Their invoice numbering is
* theirs and is not a lawful Austrian invoice series; ours is, and it is gapless
* because it has to be. So EVERY invoice Stripe reports as paid produces a
* document of our own and one mail carrying it see App\Actions\IssueStripeInvoice,
* and invoicePaid() below for the two exceptions: the checkout's own invoice,
* which the purchase already has a document for, and a charge for a term that
* begins after the contract ended, which owes nobody anything see
* owesADocument().
*
* Every handler is idempotent. Stripe retries a webhook until it gets a 2xx,
* and it also sends the same event to several endpoints so "already applied"
* is the normal case, not the exception.
*/
class ApplyStripeBillingEvent
{
/**
* Precedence when two events share a second highest wins.
*
* Ordered by how much the event settles: a payment that went through is the
* last word, a snapshot of the subscription is a running picture, and a
* failed attempt is the least final of the three, since a retry may already
* have succeeded.
*/
private const RANK_PAID = 3;
private const RANK_UPDATED = 2;
private const RANK_FAILED = 1;
public function __construct(
private RecordCommercialEvent $record,
private IssueStripeInvoice $issueStripeInvoice,
) {}
/**
* A renewal was paid. Move the period on and enter it in the register.
*
* @param array<string, mixed> $invoice
*/
public function invoicePaid(array $invoice, ?Carbon $eventAt = null): ?SubscriptionRecord
{
$subscription = $this->resolve($invoice['subscription'] ?? null);
if ($subscription === null) {
return null;
}
$reason = (string) ($invoice['billing_reason'] ?? '');
// The checkout's own invoice is not a renewal — it is the purchase, and
// OpenSubscription has already entered it. Recording it again here
// would double every customer's first payment in the register.
if ($reason === 'subscription_create') {
return null;
}
// Only a cycle renewal moves the term on. Stripe also sends paid
// invoices for prorations and for charges raised by hand, and those are
// money received without being the start of a new month — calling them
// renewals would push the period forward on a top-up.
$isRenewal = $reason === 'subscription_cycle';
$invoiceId = (string) ($invoice['id'] ?? '');
[$start, $end] = $this->period($invoice);
// A contract that has ended stays ended (mutateInOrder refuses one), so
// a final invoice arriving after the deletion cannot hand a departed
// customer their service back. The payment is still entered in the
// register below; it did happen. Whether it also owes a DOCUMENT is a
// separate question with a separate answer — see owesADocument().
if ($isRenewal && $start !== null && $end !== null) {
$this->mutateInOrder($subscription, $eventAt, self::RANK_PAID, fn (Subscription $fresh) => $end
// Never backwards: a renewal delayed behind the next one would
// otherwise shorten the term the customer has already paid for.
->greaterThanOrEqualTo($fresh->current_period_end)
// Period boundaries are not part of the frozen snapshot:
// what the customer is owed does not change, only which
// month it is.
? [
'current_period_start' => $start,
'current_period_end' => $end,
'status' => 'active',
'stripe_status' => 'active',
]
: null);
}
// One invoice, one entry — enforced by the unique index rather than by
// a check, because Stripe can deliver the same invoice twice at once
// and both deliveries would pass a check.
$event = $isRenewal ? SubscriptionRecord::EVENT_RENEWAL : SubscriptionRecord::EVENT_INVOICE_PAID;
$record = $this->recordOnce(
fn () => ($this->record)(
event: $event,
subscription: $subscription->refresh(),
// The whole term, modules included. A cycle invoice bills the
// package item AND one item per booked module, so what Stripe
// took is the gross of all of them — held against `price_cents`
// alone, every renewal on a contract with a single module was
// filed as charged at the wrong amount. Nothing is agreed for a
// proration or a hand-raised charge: what those are worth is
// whatever Stripe worked out, and it arrives as the charged
// figure below.
netCents: $isRenewal ? $subscription->termNetCents() : 0,
stripe: [
'invoice' => $invoiceId ?: null,
'subscription' => $subscription->stripe_subscription_id,
],
// The exact kind, kept so a proration or a manual charge stays
// distinguishable from an ordinary renewal.
extra: ['billing_reason' => $reason ?: null],
// Stripe's total is what was actually taken, tax and all. Ours
// is what was agreed; the register states both.
chargedGrossCents: isset($invoice['amount_paid']) ? (int) $invoice['amount_paid'] : null,
eventKey: $invoiceId !== '' ? "{$event}:{$invoiceId}" : null,
)
);
if ($this->owesADocument($subscription->refresh(), $invoice, $start)) {
$this->invoicePayment($subscription, $invoice, $start, $end);
}
return $record;
}
/**
* Whether this charge is one the customer is still owed a document for.
*
* Nothing in this application could cancel a Stripe subscription, so a
* customer who cancelled and a customer who withdrew both stayed subscribed
* and every month Stripe took another payment, `invoice.paid` arrived here,
* and a real invoice was drawn out of the gapless Austrian series and mailed,
* for a service that no longer existed. Cancelling the subscription is the
* first half of the fix; this is the second, because a subscription somebody
* failed to cancel, or one cancelled by hand in Stripe's dashboard, must not
* be able to mint numbers here either.
*
* ## The rule, and why it is not simply "refuse a cancelled contract"
*
* A perfectly legitimate FINAL invoice can arrive after the contract has
* ended, and refusing every one of them would lose a document the customer is
* legally owed. The commonest case: the cycle invoice for the term they were
* in went unpaid, Stripe's dunning ran for three weeks, the contract ended in
* the middle of it, and then the customer paid. That money is for a term they
* really did have a running cloud in.
*
* So the question is not WHEN the payment landed Stripe's dunning makes that
* arbitrary but WHAT it is for:
*
* the period this invoice bills STARTED before the contract ended
* a term the customer was actually in. Document it.
* the period begins at or after the moment the contract ended
* a term that does not exist. No document, no number, and an error in
* the log, because a charge for a service that has ended means Stripe
* was never stopped and somebody has to send that money back.
*
* The boundary case a period starting exactly at `cancelled_at` is the
* first month the customer does NOT have, so it is refused.
*
* A contract marked cancelled with no date on it, or an invoice carrying no
* period, cannot be judged by that rule at all, and both are refused. The
* asymmetry is deliberate and it is the same one IssueInvoice is built on: a
* missing document is recoverable an operator issues it from the console
* while a number handed out in error can never be withdrawn, only corrected by
* a cancellation document plus a new one.
*
* The register entry is written either way, above. The money moved; refusing
* to record that would hide it, and the register is where an operator finds
* the payments that need sending back.
*
* @param array<string, mixed> $invoice
*/
private function owesADocument(Subscription $subscription, array $invoice, ?Carbon $periodStart): bool
{
if (! $subscription->hasEnded()) {
return true;
}
$endedAt = $subscription->cancelled_at;
if ($endedAt !== null && $periodStart !== null && $periodStart->lessThan($endedAt)) {
return true;
}
Log::error('Stripe charged for a contract that has ended, so no invoice was issued and no number was used. This money has to go back.', [
'subscription' => $subscription->uuid,
'stripe_subscription' => $subscription->stripe_subscription_id,
'stripe_invoice' => $invoice['id'] ?? null,
'billing_reason' => $invoice['billing_reason'] ?? null,
'amount_paid' => $invoice['amount_paid'] ?? null,
'contract_ended_at' => $endedAt?->toIso8601String(),
'billed_period_start' => $periodStart?->toIso8601String(),
]);
return false;
}
/**
* The document this payment owes the customer, and the one mail that carries
* it.
*
* **Every paid invoice, not only a renewal.** The rule is one document per
* charge, so a proration, a module booked in the middle of a term and a
* charge raised by hand in Stripe's dashboard each get one built from
* Stripe's own lines, which is what actually took the money. The single
* exception is the checkout's own invoice, which returns before this line:
* that purchase already has a document, and a second would put two invoices
* on one sale.
*
* Outside recordOnce() on purpose. The register entry and the invoice are
* two different obligations, and tying the second to the first succeeding
* would mean that a delivery which recorded the payment but could not issue
* the document an incomplete company profile, a mail server that was down
* could never be finished: every retry would find the register entry
* already there, return, and the customer would never get an invoice. The
* issuing is idempotent on its own, keyed on the Stripe invoice id.
*
* Caught here as well as inside the action. The action guards what it does;
* this guards the rest, because the rule is that no paperwork of ours may
* ever cost Stripe a 2xx a retried webhook is a second attempt at
* everything, and one of those things moves the customer's term.
*
* @param array<string, mixed> $invoice
*/
private function invoicePayment(Subscription $subscription, array $invoice, ?Carbon $start, ?Carbon $end): void
{
try {
($this->issueStripeInvoice)($subscription->refresh(), $invoice, $start, $end);
} catch (Throwable $e) {
Log::error('Failed to invoice a Stripe payment that went through.', [
'subscription' => $subscription->uuid,
'stripe_invoice' => $invoice['id'] ?? null,
'error' => $e->getMessage(),
]);
}
}
/**
* A renewal failed. Recorded, and nothing else: Stripe runs the dunning
* schedule, and cutting a customer off on the first failed attempt would
* punish an expired card as though it were a refusal to pay.
*
* @param array<string, mixed> $invoice
*/
public function invoicePaymentFailed(array $invoice, ?Carbon $eventAt = null): ?SubscriptionRecord
{
$subscription = $this->resolve($invoice['subscription'] ?? null);
if ($subscription === null) {
return null;
}
$invoiceId = (string) ($invoice['id'] ?? '');
// Only if this is the newest word we have. A failed attempt delayed
// behind the successful retry would otherwise flip a customer who has
// since paid back to past_due.
$this->mutateInOrder($subscription, $eventAt, self::RANK_FAILED, fn () => ['stripe_status' => 'past_due']);
return $this->recordOnce(
fn () => ($this->record)(
event: SubscriptionRecord::EVENT_PAYMENT_FAILED,
subscription: $subscription->refresh(),
netCents: 0,
stripe: [
'invoice' => $invoiceId ?: null,
'subscription' => $subscription->stripe_subscription_id,
],
extra: ['attempt' => $invoice['attempt_count'] ?? null],
chargedGrossCents: 0,
// Per attempt, not per invoice: Stripe retries a failing
// payment on a schedule, and each attempt is its own event.
eventKey: $invoiceId !== ''
? 'payment_failed:'.$invoiceId.':'.($invoice['attempt_count'] ?? 0)
: null,
)
);
}
/**
* Stripe's own status changed. Theirs is the authority on whether the money
* is arriving; we copy it, and leave what the customer may USE to our own
* status, which only a cancellation or an operator changes.
*
* @param array<string, mixed> $object
*/
public function subscriptionUpdated(array $object, ?Carbon $eventAt = null): ?Subscription
{
$subscription = $this->resolve($object['id'] ?? null);
if ($subscription === null) {
return null;
}
[$start, $end] = $this->period($object);
$this->rememberItem($subscription, $object);
// Ended is ended, whatever order events arrive in — and an older
// snapshot must never overwrite a newer one it was delivered behind.
$this->mutateInOrder($subscription, $eventAt, self::RANK_UPDATED, fn () => array_filter([
'stripe_status' => $object['status'] ?? null,
'current_period_start' => $start,
'current_period_end' => $end,
], fn ($value) => $value !== null));
return $subscription;
}
/**
* Keep the id of the item this subscription bills through.
*
* Stripe names it only on the subscription object, and moving a contract
* onto another price is impossible without it the price hangs off the
* item, not off the subscription. Learnt here because this event arrives on
* its own for every status change and every renewal, so the ordinary running
* of a contract fills it in without anyone having to ask Stripe.
*
* Written outside the ordering guard on purpose: this is not part of the
* running picture. An item id does not go stale, does not change when the
* price on it does, and a stale event carries exactly the same one.
*
* @param array<string, mixed> $object
*/
private function rememberItem(Subscription $subscription, array $object): void
{
if ($subscription->stripe_item_id !== null) {
return;
}
$itemId = $object['items']['data'][0]['id'] ?? null;
// One item, because one contract bills one package. A subscription with
// several would need a rule for which of them the package is, and there
// is nothing here to build one from — better to have none than to swap
// the price on whichever happened to be first.
if (is_string($itemId) && $itemId !== '' && count($object['items']['data'] ?? []) === 1) {
$subscription->update(['stripe_item_id' => $itemId]);
}
}
/**
* The subscription has ended at Stripe. That is the end of the contract,
* so it goes in the register a cancellation nobody recorded is exactly
* the kind of gap the register exists to prevent.
*
* @param array<string, mixed> $object
*/
public function subscriptionDeleted(array $object, ?Carbon $eventAt = null): ?SubscriptionRecord
{
$subscription = $this->resolve($object['id'] ?? null);
if ($subscription === null) {
return null;
}
$endedAt = isset($object['ended_at'])
? Carbon::createFromTimestamp((int) $object['ended_at'])
: now();
// The ending and its entry in the register commit together. Marking the
// contract cancelled first and failing before the record would leave a
// retry seeing "already cancelled" and returning — and the end of a
// contract would never be entered at all.
return $this->recordOnce(fn () => DB::transaction(function () use ($subscription, $object, $endedAt, $eventAt) {
// Held while we decide, so a renewal arriving at the same moment
// cannot reactivate the contract between this and the write.
$subscription = Subscription::query()->whereKey($subscription->getKey())->lockForUpdate()->firstOrFail();
// No ordering guard: an ending is final, so a late delivery of it
// is still correct. Only the running picture can go stale.
//
// A booked plan change goes with the contract it was booked on. It
// could never be carried out — clupilot:apply-due-plan-changes only
// touches active contracts — but leaving the stamp behind would have
// the portal and the console go on announcing that a package which
// no longer exists is about to shrink.
$subscription->update([
'status' => 'cancelled',
'stripe_status' => $object['status'] ?? 'canceled',
'cancelled_at' => $endedAt,
'stripe_event_at' => $eventAt,
'pending_plan' => null,
'pending_effective_at' => null,
]);
return ($this->record)(
event: SubscriptionRecord::EVENT_CANCELLATION,
subscription: $subscription->refresh(),
netCents: 0,
at: $endedAt,
stripe: ['subscription' => $subscription->stripe_subscription_id],
chargedGrossCents: 0,
// A contract ends once. Two deliveries arriving together would
// both pass a status check; only one can take this key.
eventKey: 'cancellation:'.$subscription->id,
);
}));
}
/**
* Whether this event is the newest word we have about the contract.
*
* Stripe does not guarantee delivery order, and the state here is a
* running picture rather than a log: applying a stale snapshot on top of a
* fresher one is how a paid-up customer ends up looking overdue. The
* register is unaffected each entry is keyed by what it is about, so a
* late arrival still lands once, in its proper place.
*/
/**
* Decide and write in one step, with the contract's row held.
*
* The ordering guard is only worth as much as its atomicity: two
* deliveries for the same contract can both read a stale copy, both pass
* the check, and the loser then overwrites the winner a renewal
* reactivating a contract a deletion had just ended, or an old failure
* burying a payment that went through. So the row is re-read under a lock
* and re-judged inside the transaction that writes it.
*
* `$changes` returns the columns to set, or null to decline.
*
* @param callable(Subscription): ?array<string, mixed> $changes
*/
private function mutateInOrder(Subscription $subscription, ?Carbon $eventAt, int $rank, callable $changes): void
{
DB::transaction(function () use ($subscription, $eventAt, $rank, $changes) {
$fresh = Subscription::query()->whereKey($subscription->getKey())->lockForUpdate()->first();
if ($fresh === null
|| $fresh->status === 'cancelled'
|| ! $this->appliesInOrder($fresh, $eventAt, $rank)) {
return;
}
$values = $changes($fresh);
if ($values === null) {
return;
}
if ($eventAt !== null) {
$values['stripe_event_at'] = $eventAt;
$values['stripe_event_rank'] = $rank;
}
$fresh->update($values);
});
$subscription->refresh();
}
private function appliesInOrder(Subscription $subscription, ?Carbon $eventAt, int $rank): bool
{
if ($eventAt === null || $subscription->stripe_event_at === null) {
return true; // nothing to compare against
}
if ($eventAt->greaterThan($subscription->stripe_event_at)) {
return true;
}
if ($eventAt->lessThan($subscription->stripe_event_at)) {
return false;
}
// Same second, which Stripe's one-second timestamps make common enough
// to matter: a failed attempt and the retry that succeeded can share
// one. Decide by what the event says about the money.
return $rank >= (int) ($subscription->stripe_event_rank ?? 0);
}
/**
* Write a register entry, unless one already exists for this invoice and
* event.
*
* The uniqueness lives in the database, on (stripe_invoice_id, event).
* Checking first and inserting afterwards leaves a window that Stripe
* delivering the same invoice twice at once walks straight through and a
* register that counts a payment twice is worse than one that is late.
*
* @param callable(): SubscriptionRecord $write
*/
private function recordOnce(callable $write): ?SubscriptionRecord
{
try {
return $write();
} catch (UniqueConstraintViolationException) {
return null; // already recorded by a concurrent delivery
}
}
/**
* Hold an event whose contract does not exist yet, so it is not lost.
*
* @param array<string, mixed> $event
*/
public function hold(array $event): void
{
$subscriptionId = $event['data']['object']['subscription']
?? $event['data']['object']['id']
?? null;
if (! is_string($subscriptionId) || ! is_string($event['id'] ?? null)) {
return;
}
// Only what we could not match. A handler returns null for several
// reasons — already recorded, or deliberately skipped, like every
// checkout's own invoice — and holding those would fill the table with
// rows that replayHeldFor() can never revisit, because their contract
// is right there. Nothing to wait for means nothing to hold.
if (Subscription::query()->where('stripe_subscription_id', $subscriptionId)->exists()) {
// Unless it appeared just now: the contract can be created between
// the handler missing it and this check, and the replay that would
// have collected it has then already been and gone. Apply it here
// instead of dropping it. Safe to repeat — the handlers are
// idempotent, so an event that was a no-op stays one.
$this->dispatch($event);
return;
}
StripePendingEvent::query()->updateOrCreate(
['stripe_event_id' => $event['id']],
[
'stripe_subscription_id' => $subscriptionId,
'type' => (string) ($event['type'] ?? ''),
'raised_at' => isset($event['created']) && is_numeric($event['created'])
? Carbon::createFromTimestamp((int) $event['created'])
: null,
'payload' => $event,
],
);
}
/**
* Replay everything held for a contract that has just appeared.
*
* In their original order, so the ordering guard sees them as Stripe raised
* them rather than as they happened to be stored.
*/
public function replayHeldFor(Subscription $subscription): int
{
if ($subscription->stripe_subscription_id === null) {
return 0;
}
$held = StripePendingEvent::query()
->where('stripe_subscription_id', $subscription->stripe_subscription_id)
->orderBy('raised_at')
->orderBy('id')
->get();
foreach ($held as $pending) {
$this->dispatch($pending->payload ?? []);
$pending->delete();
}
return $held->count();
}
/**
* Route one event to its handler. Returns false for a type we do not
* handle, so the caller can tell "not ours" from "nothing to do".
*
* @param array<string, mixed> $event
*/
public function dispatch(array $event): mixed
{
$object = $event['data']['object'] ?? [];
$raisedAt = isset($event['created']) && is_numeric($event['created'])
? Carbon::createFromTimestamp((int) $event['created'])
: null;
return match ($event['type'] ?? '') {
'invoice.paid' => $this->invoicePaid($object, $raisedAt),
'invoice.payment_failed' => $this->invoicePaymentFailed($object, $raisedAt),
'customer.subscription.updated' => $this->subscriptionUpdated($object, $raisedAt),
'customer.subscription.deleted' => $this->subscriptionDeleted($object, $raisedAt),
default => false,
};
}
/**
* Find the contract a Stripe event is about.
*
* An unknown id is logged and dropped rather than raised: Stripe delivers
* events for objects created by hand in the dashboard, and by other
* environments pointed at the same endpoint. Failing the webhook for those
* would have Stripe retry something that can never succeed.
*/
private function resolve(mixed $stripeSubscriptionId): ?Subscription
{
$id = is_string($stripeSubscriptionId) ? $stripeSubscriptionId : null;
if ($id === null) {
return null;
}
return Subscription::query()->where('stripe_subscription_id', $id)->first();
}
/**
* @param array<string, mixed> $object
* @return array{0: ?Carbon, 1: ?Carbon}
*/
private function period(array $object): array
{
$start = $object['period_start'] ?? $object['current_period_start'] ?? null;
$end = $object['period_end'] ?? $object['current_period_end'] ?? null;
return [
is_numeric($start) ? Carbon::createFromTimestamp((int) $start) : null,
is_numeric($end) ? Carbon::createFromTimestamp((int) $end) : null,
];
}
}

362
app/Actions/BookAddon.php Normal file
View File

@ -0,0 +1,362 @@
<?php
namespace App\Actions;
use App\Models\Order;
use App\Models\Subscription;
use App\Models\SubscriptionAddon;
use App\Models\SubscriptionRecord;
use App\Services\Billing\AddonCatalogue;
use App\Services\Billing\CustomDomainAccess;
use Illuminate\Database\UniqueConstraintViolationException;
use Illuminate\Support\Facades\DB;
use RuntimeException;
/**
* Books a module onto a contract at today's price, and freezes it there.
*
* Booking is the moment the module's price stops moving for this customer, for
* exactly the reason the plan's price does: they agreed to a figure. What they
* have NOT booked stays on the live catalogue that is a sale still to be
* made, at whatever it costs now.
*
* It is also the one place that decides a module may not be booked at all. Two
* rules live here rather than in the pages that call it, because a rule enforced
* in markup is not enforced: not every package may have an own domain, and not
* every module may be held twice. Both fail closed, with the sentence the
* customer would be shown rather than a developer's, so whatever refuses
* the portal, the console's grant screen, a webhook says the same thing.
*
* It is also where a booking stops being only a row. Storage packs are the one
* module that changes what the machine has to be, and they were sold for months
* without anything anywhere acting on them so booking or cancelling one asks
* ApplyStorageAllowance for the run that makes the disk, the guest's filesystem
* and Nextcloud's quota match what the customer now pays for. Every route into a
* booking goes through here the shop, a webhook, an operator's grant which
* is why the delivery hangs off this action and not off any one of them.
*
* `$overrides` exists for GrantAddon: a granted module is booked through this
* same action, with its price replaced by what the customer actually pays and
* its provenance stamped on the row. Empty for every ordinary booking. A grant
* is a booking like any other, so it is refused on the same terms giving away
* a module the customer is already paying for would bill them for both.
*
* It is also where a booking becomes MONEY, every month, and not only in the
* month it was made. A module is an item on the Stripe subscription, so booking
* one adds it and cancelling one takes it off see
* App\Actions\SyncStripeAddonItems, which is asked to bring the two into step
* after every change here. Like the storage delivery beside it, it never fails a
* booking: what it could not tell Stripe is parked on the contract and retried.
*/
class BookAddon
{
public function __construct(private RecordCommercialEvent $record) {}
/** @param array<string, mixed> $overrides */
public function __invoke(Subscription $subscription, string $addonKey, int $quantity = 1, ?Order $order = null, array $overrides = []): SubscriptionAddon
{
$price = app(AddonCatalogue::class)->priceCents($addonKey);
if ($price === null) {
throw new RuntimeException("Unknown add-on: {$addonKey}");
}
if ($quantity < 1) {
throw new RuntimeException('An add-on is booked at least once.');
}
// Not every module is for sale to everyone. The own domain is possible
// on some packages, included in others and impossible on the smallest,
// and the portal already leaves it out where it cannot be booked — but
// a hidden button is markup, and a rule enforced only in markup is not
// enforced. A stale tab, a second window or anything that can name this
// action arrives here too, so it fails closed, with the sentence the
// customer would be shown rather than a developer's.
$refusal = $addonKey === CustomDomainAccess::ADDON
? app(CustomDomainAccess::class)->bookingRefusal($subscription)
: null;
if ($refusal !== null) {
throw new RuntimeException($refusal);
}
try {
$addon = $this->book($subscription, $addonKey, $quantity, $order, $price, $overrides);
} catch (UniqueConstraintViolationException) {
// A concurrent retry won. The unique index on (order_id, addon_key)
// is what actually enforces "one order books one module" — the
// lookup below is only the fast path, and two transactions can both
// pass it.
$addon = SubscriptionAddon::query()
->where('order_id', $order?->id)
->where('addon_key', $addonKey)
->firstOrFail();
}
$this->deliverStorage($subscription, $addonKey);
$this->billThroughStripe($subscription);
return $addon;
}
/** @param array<string, mixed> $overrides */
private function book(Subscription $subscription, string $addonKey, int $quantity, ?Order $order, int $price, array $overrides = []): SubscriptionAddon
{
// The booking and its entry in the register commit together: if the
// record could fail afterwards, retrying the same order would find the
// add-on already there and skip the event for good.
return DB::transaction(function () use ($subscription, $addonKey, $quantity, $order, $price, $overrides) {
$catalogue = app(AddonCatalogue::class);
// Held while we look and write, for the same reason the shop holds
// the customer row: two clicks in flight would otherwise both find
// nothing booked and both book, which is precisely the double
// charge the check below exists to prevent. Only for a module of
// which there may be one — a second storage pack is meant to
// succeed, and serialising those would buy nothing.
if ($catalogue->isEntitlement($addonKey)) {
Subscription::query()->whereKey($subscription->getKey())->lockForUpdate()->first();
}
// Idempotent against a retried webhook: one order books one module.
if ($order !== null) {
$existing = SubscriptionAddon::query()
->where('order_id', $order->id)
->where('addon_key', $addonKey)
->first();
if ($existing !== null) {
return $existing;
}
}
// Asked AFTER the retry above, so a webhook delivered twice still
// gets its one booking back rather than an error: that is the same
// order arriving again, not a second purchase. What is refused here
// is a SECOND order for a module the contract already carries — a
// stale tab, a double click, an operator granting something the
// customer has bought. The unique index on (order_id, addon_key)
// never saw those: two orders are two different rows.
$refusal = $catalogue->duplicateRefusal($subscription, $addonKey);
if ($refusal !== null) {
throw new RuntimeException($refusal);
}
$addon = SubscriptionAddon::create(array_merge([
'subscription_id' => $subscription->id,
'order_id' => $order?->id,
'addon_key' => $addonKey,
'price_cents' => $price,
'currency' => Subscription::catalogueCurrency(),
'quantity' => $quantity,
'booked_at' => now(),
], $overrides));
($this->record)(
event: SubscriptionRecord::EVENT_ADDON_BOOKED,
subscription: $subscription,
netCents: $addon->monthlyCents(),
extra: ['addon' => ['key' => $addonKey, 'quantity' => $quantity, 'price_cents' => $addon->price_cents]],
order: $order,
stripe: ['event' => $order?->stripe_event_id],
// What was actually charged for the module, on the same terms
// as a plan purchase: a discount or a free booking has to be
// reconcilable, not reconstructed from the catalogue.
chargedGrossCents: $order?->stripe_event_id !== null ? (int) $order->amount_cents : null,
);
return $addon;
});
}
/**
* Book the end of a module for the end of the term it is paid up to.
*
* The owner's rule for a module is the same as for a package: what has been
* paid for is kept. A customer cancelling on the tenth keeps the module
* until the cycle turns and gets no credit for the days they did not use
* so the row stays active, `cancels_at` holds the appointment, and
* clupilot:end-cancelled-addons keeps it.
*
* Stripe is told at once all the same. Its item is only ever about what the
* NEXT cycle bills the term in progress was paid in advance and taking
* the item off with no proration moves no money while making sure the next
* invoice has no such line. Waiting for the boundary instead would be a race
* against Stripe raising that very invoice; see SyncStripeAddonItems.
*
* Ends the module outright when there is no term to wait for a contract
* with no period end, or one that is already past. There is nothing to keep.
*/
public function cancelAtPeriodEnd(SubscriptionAddon $addon): SubscriptionAddon
{
$subscription = $addon->subscription;
$endsOn = $subscription?->current_period_end;
if ($endsOn === null || $endsOn->isPast()) {
return $this->cancel($addon);
}
// Claimed conditionally, like the cancellation below: two requests
// arriving together would otherwise both write an appointment, and the
// second would move a date the customer had already been told.
$claimed = SubscriptionAddon::query()
->whereKey($addon->getKey())
->whereNull('cancelled_at')
->whereNull('cancels_at')
->update(['cancels_at' => $endsOn, 'updated_at' => now()]);
$addon->refresh();
if ($claimed === 0) {
return $addon;
}
$this->billThroughStripe($subscription);
return $addon;
}
/**
* Take back a cancellation that has not landed yet.
*
* The customer changed their mind again, which is allowed right up to the
* moment the appointment is kept: until then the module is still running and
* still theirs, and nothing has been undone that would have to be rebuilt.
* After it `cancelled_at` set this refuses, because the module has
* genuinely stopped and putting it back is a new booking at today's price.
*
* Claimed conditionally like the cancellation it undoes, so two clicks write
* one answer between them.
*
* **Nothing is charged for it.** The term this module was cancelled for was
* paid for in advance and the cancellation raised no credit that is the
* owner's rule so putting the item back must not raise a charge either.
* Left to its own devices the reconciler would add it with
* `always_invoice`, which is right for a module being bought in the middle
* of a term and wrong here: the customer would be billed a second time for
* days they have already paid for. So the reconciler is told, in the one
* place where the difference is known, that this addition is a restoration
* and not a sale.
*/
public function undoCancelAtPeriodEnd(SubscriptionAddon $addon): SubscriptionAddon
{
$claimed = SubscriptionAddon::query()
->whereKey($addon->getKey())
->whereNull('cancelled_at')
->whereNotNull('cancels_at')
->update(['cancels_at' => null, 'updated_at' => now()]);
$addon->refresh();
if ($claimed === 0) {
return $addon;
}
$subscription = $addon->subscription;
if ($subscription !== null) {
app(SyncStripeAddonItems::class)($subscription->refresh(), chargeForAdditions: false);
}
return $addon;
}
/**
* Stop charging for a module now, without losing what it cost.
*
* Cancelled, not deleted: what a customer was paying, and until when, is
* part of the same record as what they bought.
*
* This is the immediate end, and it is not what a customer's own
* cancellation does see cancelAtPeriodEnd() above. It is what happens when
* a module stops being POSSIBLE rather than wanted: a downgrade onto a
* package that cannot have an own domain takes the module with it, and
* leaving the customer billed for a feature that has already gone is a
* complaint we would deserve. It is also the appointment being kept, once
* cancelAtPeriodEnd()'s date has passed.
*/
public function cancel(SubscriptionAddon $addon): SubscriptionAddon
{
$cancelled = DB::transaction(function () use ($addon) {
// Claim the cancellation conditionally, so two requests arriving
// together write one event between them. Checking `isActive()` on
// separate instances and updating afterwards lets both through, and
// the register would show a module cancelled twice.
$claimed = SubscriptionAddon::query()
->whereKey($addon->getKey())
->whereNull('cancelled_at')
->update(['cancelled_at' => now(), 'updated_at' => now()]);
if ($claimed === 0) {
return $addon->refresh();
}
$addon->refresh();
($this->record)(
event: SubscriptionRecord::EVENT_ADDON_CANCELLED,
subscription: $addon->subscription,
netCents: -$addon->monthlyCents(),
extra: ['addon' => ['key' => $addon->addon_key, 'quantity' => $addon->quantity]],
order: $addon->order,
);
return $addon;
});
$this->deliverStorage($cancelled->subscription, (string) $cancelled->addon_key);
$this->billThroughStripe($cancelled->subscription);
return $cancelled;
}
/**
* Make a change to the storage packs real on the machine.
*
* Storage is the one module whose booking changes what the machine has to
* BE: a pack is a hundred gigabytes the disk, the guest's filesystem and
* Nextcloud's quota all have to grow to, and cancelling one is the same
* sentence read backwards. Every other module is a licence, a support tier
* or a flag nothing on the machine moves when one is booked.
*
* Deliberately AFTER the transaction has committed, never inside it. The run
* this starts is picked up by a queue worker in another process, and a
* worker that arrived first would read a contract whose booking had not been
* written yet and would then deliver the allowance as it was a moment ago.
*
* Nothing here fails a booking. ApplyStorageAllowance returns null for every
* ordinary "not now" no machine, one still being built, a run already in
* flight and that run applies the same allowance when it gets there.
*/
private function deliverStorage(?Subscription $subscription, string $addonKey): void
{
if ($addonKey !== AddonCatalogue::STORAGE) {
return;
}
app(ApplyStorageAllowance::class)($subscription);
}
/**
* Put what the contract now holds in front of Stripe.
*
* After the transaction for the same reason the storage delivery is: the
* reconciler reads the bookings back out of the database, and one running
* inside the transaction that wrote them would settle on the state as it was
* a moment ago.
*
* Nothing here fails a booking either. The module has been booked and, if it
* was storage, already delivered to the machine; Stripe being unreachable
* parks the difference on the contract and clupilot:sync-stripe-subscriptions
* finishes it.
*/
private function billThroughStripe(?Subscription $subscription): void
{
if ($subscription === null) {
return;
}
app(SyncStripeAddonItems::class)($subscription->refresh());
}
}

View File

@ -0,0 +1,136 @@
<?php
namespace App\Actions;
use App\Models\Instance;
use App\Services\Dns\HetznerDnsClient;
use App\Services\Traefik\TraefikWriter;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Log;
/**
* Take an instance's address down when its service has genuinely ended.
*
* `TraefikWriter::remove()` had no caller anywhere in the application. Every
* router this platform ever wrote was written for good: the file stayed on the
* serving host, the hostname kept resolving, and it kept being routed to a guest
* address that the host is free to hand to somebody else's VM later. That is not
* a tidiness problem it is one customer's hostname pointing at another
* customer's Nextcloud, with a valid certificate on it.
*
* ## What "ended" means here
*
* Exactly one thing: the instance is `cancellation_scheduled` AND its
* `service_ends_at` has passed. Not the moment somebody cancels a customer who
* schedules a cancellation on the second of the month has paid to the end of the
* month and must keep working through it, and pulling their address that
* afternoon would be a serious fault, worse than the leak this closes. Not a
* `failed` build either: those never got as far as a router, and the ones that
* did are being retried from the console. A `cancellation_scheduled` row without
* a `service_ends_at` at all is left alone on purpose that is a broken record
* rather than an expired one, and guessing an end date for it would invent the
* very moment this action exists to respect.
*
* ## The DNS record goes with the route
*
* Deliberately, and for the same reason the route does. The A record for
* `{subdomain}.{zone}` is in OUR zone and points at the serving host's public
* address a host that goes on serving other customers. Left published it is a
* name we own, resolving to a live machine, with nothing behind it that belongs
* to the person it is named after: the shape of every subdomain-takeover there
* has ever been. It also keeps the customer's old address looking alive, and it
* would collide the day the same subdomain is issued again. Removing the route
* without the record would leave the worse half standing.
*
* The CUSTOM domain is the opposite case and nothing is done to it. Its A record
* lives in the customer's own zone, which we have never had access to and never
* will; it is theirs, it stops reaching us the moment the router goes, and it is
* for them to point somewhere else.
*
* The virtual machine is NOT touched. The founder's cancellation flow promises a
* finished data export before deprovisioning, and destroying somebody's disks is
* not a decision this action gets to make on the way past. It takes the address
* away which is what was leaking and leaves the machine for whatever carries
* out that flow.
*/
class EndInstanceService
{
public function __construct(
private TraefikWriter $traefik,
private HetznerDnsClient $dns,
) {}
/**
* Has this instance's paid service actually run out?
*
* The rule lives here rather than in the command's query so that anything
* else that ever ends an instance asks the same question a second copy of
* "is it over yet" is how one caller ends up a month out from the other.
*/
public function hasEnded(Instance $instance, ?Carbon $at = null): bool
{
return $instance->status === 'cancellation_scheduled'
&& $instance->service_ends_at !== null
&& $instance->service_ends_at->lessThanOrEqualTo($at ?? now());
}
/**
* Tear the address down and mark the instance ended.
*
* Re-entrant: removing a router file that is not there is `rm -f`, a record
* id that has already been deleted is deleted again for nothing, and an
* instance already marked `ended` is returned false straight away. A DNS
* failure is deliberately not swallowed the record id survives on its row,
* so a retry can finish the job, and a provider that is permanently broken
* becomes a visible failure rather than a silent leak.
*
* @return bool whether this call was the one that ended it
*/
public function __invoke(Instance $instance): bool
{
if (! $this->hasEnded($instance)) {
return false;
}
$host = $instance->host;
if ($host !== null) {
// The host that actually serves the traffic — DNS points at it, and
// it is where ConfigureDnsAndTls wrote the file in the first place.
$this->traefik->remove($host->wg_ip ?? $host->public_ip, $instance->subdomain);
} else {
// No host left to reach: the router file went with the machine it
// lived on. Said out loud rather than passed over, because it means
// this teardown is only doing half of what it usually does.
Log::warning('Ending an instance whose host is gone — no router to remove.', [
'instance' => $instance->uuid,
]);
}
foreach ($instance->dnsRecords()->get() as $record) {
$this->dns->deleteRecord($record->record_id);
$record->delete();
}
$instance->update([
'status' => 'ended',
// The address state has to go with the address. Left as it was, a
// later run would read `route_written` plus a matching hostname list
// and decide there was nothing to write — so an instance that was
// ever revived would be announced and routed nowhere.
'route_written' => false,
'routed_hostnames' => null,
'routed_backend' => null,
'cert_ok' => false,
'domain_cert_ok' => false,
]);
Log::info('Instance service ended; address withdrawn.', [
'instance' => $instance->uuid,
'subdomain' => $instance->subdomain,
'service_ended_at' => $instance->service_ends_at?->toIso8601String(),
]);
return true;
}
}

View File

@ -0,0 +1,81 @@
<?php
namespace App\Actions\Fortify;
use App\Models\Customer;
use App\Models\Operator;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
use Laravel\Fortify\Contracts\CreatesNewUsers;
class CreateNewUser implements CreatesNewUsers
{
use PasswordValidationRules;
/**
* Validate and create a newly registered user.
*
* @param array<string, string> $input
*
* @throws ValidationException
*/
public function create(array $input): User
{
Validator::make($input, [
'name' => ['required', 'string', 'max:255'],
'email' => [
'required',
'string',
'email',
'max:255',
Rule::unique(User::class),
// Never let public signup claim an existing (possibly not-yet-
// provisioned) customer's email — ensureUser() would later link
// that account and hand over the customer's portal.
Rule::unique(Customer::class, 'email'),
// Nor an operator's — R21: one address must not represent both
// identities. No dedicated message: Laravel's stock "already
// taken" line (translated in both languages already) is what
// the two rules above fall back to as well, and telling a
// public visitor specifically that an address belongs to
// STAFF would be a worse leak than the generic refusal every
// other collision here already gives them.
Rule::unique(Operator::class, 'email'),
],
// Asked, never inferred. Everything downstream turns on it — the
// fourteen-day right of withdrawal, the wording of the invoice,
// whether reverse charge can apply — and a VAT number cannot stand
// in for it: plenty of businesses have none, and no consumer has
// one. Required, because the alternative is a field that is empty
// for most customers and therefore answers nothing.
'customer_type' => ['required', Rule::in(Customer::TYPES)],
'password' => $this->passwordRules(),
])->validate();
// Create the user and its linked customer atomically — a public signup is
// a customer, and the portal (Billing::purchase, Settings, …) needs one.
// Either both exist or neither, so a failure never orphans a user.
return DB::transaction(function () use ($input) {
$user = User::create([
'name' => $input['name'],
'email' => $input['email'],
'password' => Hash::make($input['password']),
]);
Customer::query()->create([
'user_id' => $user->id,
'name' => $input['name'],
'email' => $input['email'],
'customer_type' => $input['customer_type'],
'locale' => app()->getLocale(),
'status' => 'active',
]);
return $user;
});
}
}

View File

@ -0,0 +1,19 @@
<?php
namespace App\Actions\Fortify;
use Illuminate\Contracts\Validation\Rule;
use Illuminate\Validation\Rules\Password;
trait PasswordValidationRules
{
/**
* Get the validation rules used to validate passwords.
*
* @return array<int, Rule|array<mixed>|string>
*/
protected function passwordRules(): array
{
return ['required', 'string', Password::default(), 'confirmed'];
}
}

View File

@ -0,0 +1,32 @@
<?php
namespace App\Actions\Fortify;
use App\Models\User;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\ValidationException;
use Laravel\Fortify\Contracts\ResetsUserPasswords;
class ResetUserPassword implements ResetsUserPasswords
{
use PasswordValidationRules;
/**
* Validate and reset the user's forgotten password.
*
* @param array<string, string> $input
*
* @throws ValidationException
*/
public function reset(User $user, array $input): void
{
Validator::make($input, [
'password' => $this->passwordRules(),
])->validate();
$user->forceFill([
'password' => Hash::make($input['password']),
])->save();
}
}

View File

@ -0,0 +1,35 @@
<?php
namespace App\Actions\Fortify;
use App\Models\User;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\ValidationException;
use Laravel\Fortify\Contracts\UpdatesUserPasswords;
class UpdateUserPassword implements UpdatesUserPasswords
{
use PasswordValidationRules;
/**
* Validate and update the user's password.
*
* @param array<string, string> $input
*
* @throws ValidationException
*/
public function update(User $user, array $input): void
{
Validator::make($input, [
'current_password' => ['required', 'string', 'current_password:web'],
'password' => $this->passwordRules(),
], [
'current_password.current_password' => __('The provided password does not match your current password.'),
])->validateWithBag('updatePassword');
$user->forceFill([
'password' => Hash::make($input['password']),
])->save();
}
}

View File

@ -0,0 +1,61 @@
<?php
namespace App\Actions\Fortify;
use App\Models\User;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
use Laravel\Fortify\Contracts\UpdatesUserProfileInformation;
class UpdateUserProfileInformation implements UpdatesUserProfileInformation
{
/**
* Validate and update the given user's profile information.
*
* @param array<string, string> $input
*
* @throws ValidationException
*/
public function update(User $user, array $input): void
{
Validator::make($input, [
'name' => ['required', 'string', 'max:255'],
'email' => [
'required',
'string',
'email',
'max:255',
Rule::unique('users')->ignore($user->id),
],
])->validateWithBag('updateProfileInformation');
if ($input['email'] !== $user->email &&
$user instanceof MustVerifyEmail) {
$this->updateVerifiedUser($user, $input);
} else {
$user->forceFill([
'name' => $input['name'],
'email' => $input['email'],
])->save();
}
}
/**
* Update the given verified user's profile information.
*
* @param array<string, string> $input
*/
protected function updateVerifiedUser(User $user, array $input): void
{
$user->forceFill([
'name' => $input['name'],
'email' => $input['email'],
'email_verified_at' => null,
])->save();
$user->sendEmailVerificationNotification();
}
}

View File

@ -0,0 +1,77 @@
<?php
namespace App\Actions;
use App\Models\Operator;
use App\Models\Order;
use App\Models\Subscription;
use App\Models\SubscriptionAddon;
use App\Services\Billing\AddonCatalogue;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use RuntimeException;
/**
* Gives a customer a module without a payment "ein Plugin schenken".
*
* Books through the same BookAddon a paid order uses, on a synthetic Order
* (no Stripe id, price set to what the customer actually pays) so the module
* ends up frozen on `subscription_addons` exactly like a real booking would.
*/
class GrantAddon
{
public function __construct(private BookAddon $bookAddon) {}
public function __invoke(
Subscription $subscription,
Operator $grantedBy,
string $addonKey,
int $priceCents,
int $quantity = 1,
?string $note = null,
?Carbon $until = null,
): SubscriptionAddon {
$cataloguePrice = app(AddonCatalogue::class)->priceCents($addonKey);
if ($cataloguePrice === null) {
throw new RuntimeException("Unknown add-on: {$addonKey}");
}
if ($priceCents < 0 || $priceCents > $cataloguePrice) {
throw new RuntimeException('A grant cannot charge more than the catalogue price.');
}
// Vestigial for a module — nothing is provisioned at a datacenter for
// it — but the column is not nullable, and the contract's own order
// already carries the right one.
$datacenter = $subscription->order?->datacenter ?? 'fsn';
// The order and the booking together, or neither. Booking can refuse —
// not every module is for sale on every package — and an order left
// behind on its own is a paid purchase of nothing: it shows up in the
// customer's orders and on their invoice, for a module they never got.
return DB::transaction(function () use ($subscription, $grantedBy, $addonKey, $priceCents, $quantity, $note, $until, $cataloguePrice, $datacenter) {
$order = Order::create([
'customer_id' => $subscription->customer_id,
'plan' => $subscription->plan,
'type' => 'addon',
'addon_key' => $addonKey,
'amount_cents' => $priceCents,
'currency' => Subscription::catalogueCurrency(),
'datacenter' => $datacenter,
'stripe_event_id' => null,
'stripe_subscription_id' => null,
'status' => 'paid',
]);
return ($this->bookAddon)($subscription, $addonKey, $quantity, $order, [
'price_cents' => $priceCents,
'granted_by' => $grantedBy->id,
'granted_at' => now(),
'grant_note' => $note,
'granted_until' => $until,
'catalogue_price_cents' => $cataloguePrice,
]);
});
}
}

View File

@ -0,0 +1,116 @@
<?php
namespace App\Actions;
use App\Models\Customer;
use App\Models\Operator;
use App\Models\Order;
use App\Models\ProvisioningRun;
use App\Models\Subscription;
use App\Provisioning\Jobs\AdvanceRunJob;
use App\Services\Billing\PlanCatalogue;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use RuntimeException;
/**
* Gives a customer a package without a payment.
*
* A grant is an order without money, not a second code path: this creates the
* exact Order ProvisioningRun Subscription rows a Stripe purchase does
* same pipeline, same OpenSubscription with `stripe_subscription_id` left
* null and `price_cents` set to whatever the customer actually pays (0 for a
* full gift, less than the catalogue price for a discount). Provisioning,
* DowngradeCheck, seats, quotas, cancellation: all of it reads an ordinary
* Subscription row afterwards and never learns this one was free.
*/
class GrantSubscription
{
public function __construct(private OpenSubscription $openSubscription) {}
/**
* @param array{quota_gb?:int,seats?:int,traffic_gb?:int} $bonus Beyond what the plan tier already includes.
*/
public function __invoke(
Customer $customer,
Operator $grantedBy,
string $plan,
string $term,
string $datacenter,
int $priceCents,
array $bonus = [],
?string $note = null,
?Carbon $until = null,
): Subscription {
if (! Subscription::knowsPlan($plan)) {
throw new RuntimeException("Plan '{$plan}' is not sellable, so it cannot be granted either.");
}
$version = app(PlanCatalogue::class)->currentVersion($plan);
$cataloguePrice = $version->priceFor($term);
if ($cataloguePrice === null) {
throw new RuntimeException("Plan '{$plan}' is not sold on a {$term} term.");
}
// A "grant" that charges more than the plan actually costs is not a
// grant — that path already exists, and it goes through Stripe.
if ($priceCents < 0 || $priceCents > $cataloguePrice->amount_cents) {
throw new RuntimeException('A grant cannot charge more than the catalogue price.');
}
[$run, $subscription] = DB::transaction(function () use (
$customer, $grantedBy, $plan, $term, $datacenter, $priceCents, $bonus, $note, $until, $cataloguePrice,
) {
$order = Order::create([
'customer_id' => $customer->id,
'plan' => $plan,
'type' => 'new',
'amount_cents' => $priceCents,
'currency' => Subscription::catalogueCurrency(),
'datacenter' => $datacenter,
// Never Stripe's: this purchase never went through Stripe at
// all, which is the whole point, and is also what tells
// IssueInvoice and the revenue figures this row apart from an
// ordinary (possibly also zero-priced) checkout.
'stripe_event_id' => null,
'stripe_subscription_id' => null,
'status' => 'paid',
]);
$run = ProvisioningRun::create([
'subject_type' => Order::class,
'subject_id' => $order->id,
'pipeline' => 'customer',
'status' => ProvisioningRun::STATUS_PENDING,
'current_step' => 0,
'context' => ['branding' => $customer->brandingResolved()],
]);
$overrides = array_filter([
'price_cents' => $priceCents,
'quota_gb' => $bonus['quota_gb'] ?? null,
'seats' => $bonus['seats'] ?? null,
'traffic_gb' => $bonus['traffic_gb'] ?? null,
], fn ($value) => $value !== null);
$overrides += [
'granted_by' => $grantedBy->id,
'granted_at' => now(),
'grant_note' => $note,
'granted_until' => $until,
'catalogue_price_cents' => $cataloguePrice->amount_cents,
];
$subscription = ($this->openSubscription)($order, $term, $overrides);
return [$run, $subscription];
});
// After the commit, never inside it — a worker can pick the run up
// before the transaction lands (see StartCustomerProvisioning).
AdvanceRunJob::dispatch($run->uuid);
return $subscription;
}
}

View File

@ -0,0 +1,315 @@
<?php
namespace App\Actions;
use App\Mail\InvoiceMail;
use App\Models\Invoice;
use App\Models\Subscription;
use App\Services\Billing\IssueInvoice;
use App\Services\Stripe\StripeClient;
use Illuminate\Database\UniqueConstraintViolationException;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
use Throwable;
/**
* The document a paid Stripe invoice owes the customer, and the one mail that
* carries it.
*
* **One document per charge.** That is the owner's rule and it is a legal one,
* not a preference: every amount taken from a customer needs its own invoice
* with its own number. Two months of package, a storage module booked in the
* middle of month three, and the cycle that follows are four charges and four
* documents the fourth being the one that carries package and storage
* together, because they were charged together.
*
* This used to issue for `subscription_cycle` alone, and everything else a
* proration, a module, a charge raised by hand was money received that no
* paper of ours covered. It was written down as a decision and it was the wrong
* one: Stripe charged for those, so they need documents.
*
* **Never allowed to fail the webhook.** Stripe retries anything that is not a
* 2xx, and there is nothing a retry could fix about an incomplete company
* profile or a mail server that is down it would simply arrive again, fail
* again, and eventually be given up on, taking the register entry's retry with
* it. So everything here is caught and logged. The payment is recorded whatever
* happens to the paperwork; a missing document is recoverable, a lost payment is
* not.
*
* **Idempotent against the Stripe invoice.** An invoice number comes out of a
* gapless series and can never be handed out twice, so the guard is the unique
* `invoices.stripe_invoice_id` rather than a check two deliveries arriving
* together would both pass a check. The number is drawn inside the same
* transaction the row is written in, so a collision takes the number back with
* it and the series keeps its sequence.
*
* Issuing and mailing are separate steps on purpose. `sent_at` records that the
* mail was handed to the mailer, so a delivery arriving after the document was
* written but before the mail went out finishes the job instead of skipping it
* and one that arrives afterwards does neither twice.
*/
class IssueStripeInvoice
{
/**
* @param array<string, mixed> $stripeInvoice the invoice as Stripe sent it
* @param Carbon|null $from the term this invoice billed, for the fallback below
* @return Invoice|null the document, or null when there is not one and could not be one
*/
public function __invoke(
Subscription $subscription,
array $stripeInvoice,
?Carbon $from = null,
?Carbon $to = null,
): ?Invoice {
$stripeInvoiceId = (string) ($stripeInvoice['id'] ?? '');
if ($stripeInvoiceId === '') {
// Without it there is no way to tell a redelivery from a second
// charge, and guessing wrong costs an invoice number.
Log::warning('Not issuing a document: the Stripe invoice carries no id, so issuing it could not be made idempotent.', [
'subscription' => $subscription->uuid,
]);
return null;
}
$invoice = $this->issue($subscription, $stripeInvoiceId, $stripeInvoice, $from, $to);
if ($invoice === null || $invoice->sent_at !== null) {
return $invoice;
}
$this->send($subscription, $invoice);
return $invoice;
}
/** @param array<string, mixed> $stripeInvoice */
private function issue(
Subscription $subscription,
string $stripeInvoiceId,
array $stripeInvoice,
?Carbon $from,
?Carbon $to,
): ?Invoice {
$existing = Invoice::query()->where('stripe_invoice_id', $stripeInvoiceId)->first();
if ($existing !== null) {
return $existing;
}
try {
$lines = $this->lines($stripeInvoiceId, $stripeInvoice);
} catch (Throwable $e) {
// Deliberately no document at all rather than a partial one. The
// total would still be right and the reader would have no way to see
// what they had paid for, which is the kind of invoice that gets
// rejected at an audit. A later redelivery or a replay can still
// issue it.
Log::error('Could not read the lines of a paid Stripe invoice, so no document was issued for it.', [
'subscription' => $subscription->uuid,
'stripe_invoice' => $stripeInvoiceId,
'exception' => $e->getMessage(),
]);
return null;
}
try {
if ($lines === []) {
return $this->fromContract($subscription, $stripeInvoiceId, $stripeInvoice, $from, $to);
}
$unnamed = [];
$invoice = app(IssueInvoice::class)->forStripeInvoice(
subscription: $subscription,
stripeInvoiceId: $stripeInvoiceId,
lines: $lines,
currency: is_string($stripeInvoice['currency'] ?? null) ? $stripeInvoice['currency'] : null,
unnamed: $unnamed,
);
$this->reportUnnamed($subscription, $stripeInvoiceId, $unnamed);
$this->reportMismatch($invoice, $stripeInvoice);
return $invoice;
} catch (UniqueConstraintViolationException) {
// A concurrent delivery won the race. Its document is the one to
// send; this one's invoice number went back into the series when the
// transaction rolled back.
return Invoice::query()->where('stripe_invoice_id', $stripeInvoiceId)->first();
} catch (Throwable $e) {
// Refuses while the company details are incomplete, which is the
// correct outcome — see IssueInvoice. The payment itself stands: it
// is in the register, and the document can be issued once the
// details are there.
Log::warning('Could not issue a document for this paid Stripe invoice.', [
'subscription' => $subscription->uuid,
'stripe_invoice' => $stripeInvoiceId,
'exception' => $e->getMessage(),
]);
return null;
}
}
/**
* Every line Stripe charged for, complete.
*
* The event carries them already. It does not always carry ALL of them a
* contract with enough modules on it passes the number Stripe puts in a
* webhook and `has_more` is how it says so. A document missing a charged
* line is worse than an extra API call, so the complete list is fetched
* whenever Stripe admits to holding one.
*
* @param array<string, mixed> $stripeInvoice
* @return array<int, array<string, mixed>>
*/
private function lines(string $stripeInvoiceId, array $stripeInvoice): array
{
$lines = (array) ($stripeInvoice['lines']['data'] ?? []);
if (($stripeInvoice['lines']['has_more'] ?? false) !== true) {
return $lines;
}
return app(StripeClient::class)->invoiceLines($stripeInvoiceId);
}
/**
* The document for a cycle invoice that arrived without any line detail.
*
* The contract's own frozen price is an honest description of the term that
* was billed, and better than leaving a paid renewal with no paper at all.
* Only for a cycle: a proration or a charge raised by hand describes
* something the contract cannot answer for, and a line invented for those
* would be a guess printed on a document nobody can correct afterwards.
*
* @param array<string, mixed> $stripeInvoice
*/
private function fromContract(
Subscription $subscription,
string $stripeInvoiceId,
array $stripeInvoice,
?Carbon $from,
?Carbon $to,
): ?Invoice {
$isCycle = (string) ($stripeInvoice['billing_reason'] ?? '') === 'subscription_cycle';
if (! $isCycle || $from === null || $to === null) {
Log::warning('A paid Stripe invoice carried no lines, so no document could be built from it.', [
'subscription' => $subscription->uuid,
'stripe_invoice' => $stripeInvoiceId,
'billing_reason' => $stripeInvoice['billing_reason'] ?? null,
]);
return null;
}
// The total Stripe took, where it said so. A document has to add up to
// the money, and the contract's own price at today's rate is only the
// next best answer — it would miss a cycle billed before a rate change.
$total = $stripeInvoice['total'] ?? $stripeInvoice['amount_paid'] ?? null;
return app(IssueInvoice::class)->forBilledPeriod(
$subscription,
$from,
$to,
$stripeInvoiceId,
chargedCents: is_numeric($total) ? (int) $total : null,
);
}
/**
* Say out loud that a line was charged which the catalogue could not name.
*
* The line is on the document either way, carrying whatever description
* Stripe sent. This is the trail for whoever has to work out what was sold
* a Price created by hand in Stripe's dashboard, or a module removed from
* the catalogue while somebody was still paying for it.
*
* @param array<int, string> $unnamed
*/
private function reportUnnamed(Subscription $subscription, string $stripeInvoiceId, array $unnamed): void
{
if ($unnamed === []) {
return;
}
Log::warning('A charged line was put on an invoice under Stripes own description, because this catalogue could not name it.', [
'subscription' => $subscription->uuid,
'stripe_invoice' => $stripeInvoiceId,
'prices' => $unnamed,
]);
}
/**
* Say out loud when the document and the money do not agree.
*
* Our catalogue is pushed to Stripe as GROSS, so the line amounts that come
* back are what the customer paid and the document divides that sum rather
* than adding a rate to it. Stripe's total and our gross must therefore be
* the SAME NUMBER, to the cent, for every customer including a
* reverse-charge one, whose whole charged amount is stated as net at 0 %.
*
* This used to accept our net as an alternative, on the grounds that an
* account might not collect tax for us. That tolerance is what let the
* defect run: 179,00 taken against a document for 214,80 matched the
* "net" branch and was never reported. Anything but equality is now worth a
* warning, and the likeliest cause is a Price that stripe:sync-catalogue has
* replaced with nobody having run stripe:reprice-subscriptions.
*
* Never an exception. The document is issued and the payment is recorded;
* raising here would only cost Stripe its 2xx and change nothing.
*
* @param array<string, mixed> $stripeInvoice
*/
private function reportMismatch(Invoice $invoice, array $stripeInvoice): void
{
$total = $stripeInvoice['total'] ?? $stripeInvoice['amount_paid'] ?? null;
if (! is_numeric($total)) {
return;
}
$total = (int) $total;
if ($total === $invoice->gross_cents) {
return;
}
Log::warning('An invoice was issued for a sum Stripe did not charge.', [
'invoice' => $invoice->number,
'stripe_invoice' => $invoice->stripe_invoice_id,
'stripe_total' => $total,
'net' => $invoice->net_cents,
'gross' => $invoice->gross_cents,
]);
}
private function send(Subscription $subscription, Invoice $invoice): void
{
$customer = $subscription->customer;
$address = $customer?->email;
if (! is_string($address) || $address === '') {
return;
}
try {
Mail::to($address)->queue(new InvoiceMail($invoice, (string) $customer->name));
// Only once it is actually with the mailer. Stamped so a redelivery
// does not send the same invoice a second time, and left unstamped
// on a failure so that one still can.
$invoice->update(['sent_at' => now()]);
} catch (Throwable $e) {
Log::warning('Could not queue the invoice mail for this payment.', [
'invoice' => $invoice->number,
'exception' => $e->getMessage(),
]);
}
}
}

View File

@ -0,0 +1,224 @@
<?php
namespace App\Actions;
use App\Models\Subscription;
use App\Services\Billing\PlanPrices;
use App\Services\Stripe\StripeClient;
use Illuminate\Support\Facades\Log;
use RuntimeException;
use Throwable;
/**
* Telling Stripe that the contract now bills for a different package.
*
* A plan change moved the contract, the entitlements and the machine, and left
* Stripe billing the old price so a customer who upgraded in March went on
* paying for the smaller package every month afterwards, and one who downgraded
* went on paying for the bigger one. This is what closes that.
*
* **Proration, per direction, chosen here and nowhere else.**
*
* - An **upgrade** is settled immediately (`always_invoice`). PlanChange's own
* docblock already says Stripe's proration invoice is the authority for what
* an upgrade costs, and that is only true if there IS one the alternative,
* letting the proration ride along on the next cycle invoice, would make the
* cycle charge bigger than the renewal document we issue from the contract's
* frozen price, and a document that does not match the money taken is not a
* document. Immediate also matches what the customer was shown: they get the
* bigger package now and pay for the days that are left.
* - A **downgrade** settles nothing (`none`). It lands exactly at the period
* end, so there is nothing left of the term to prorate; anything other than
* `none` at a boundary risks a stray credit line for a fraction of a day.
* From the next cycle Stripe simply bills the smaller price, which is what
* the renewal document will say.
*
* Between them the register and Stripe cannot claim different money: the upgrade
* row records what was AGREED (with nothing charged), Stripe's proration invoice
* comes back through the webhook as an `invoice_paid` row recording what was
* TAKEN, and the cycle invoice stays exactly the contract price on both sides.
*
* **Never throws.** The change has already been applied to the contract and to
* the machine by the time this runs, and Stripe being unreachable, unconfigured
* or out of step with the catalogue must not undo any of that. What it must not
* do either is disappear: the failure is parked on the contract in
* `stripe_price_sync`, logged as an error, and retried hourly by
* clupilot:sync-stripe-subscriptions.
*/
class MoveStripeSubscriptionPrice
{
public function __construct(
private StripeClient $stripe,
private PlanPrices $prices,
) {}
/**
* @param string $behaviour one of StripeClient::PRORATE_*
* @return bool whether Stripe now agrees with the contract
*/
public function __invoke(Subscription $subscription, string $behaviour): bool
{
// A granted package was never sold through Stripe — GrantSubscription
// leaves stripe_subscription_id null on purpose — so there is nothing
// there to move and nothing has gone wrong. Answered before anything
// else so that no call is made and no failure is recorded.
if ($subscription->stripe_subscription_id === null) {
return true;
}
$priceId = null;
try {
$priceId = $this->targetPrice($subscription);
if ($priceId === null) {
throw new RuntimeException(
'The catalogue has no Stripe price for this plan version on this term. Run stripe:sync-catalogue.'
);
}
// Already billing it. A retry of a swap that in fact went through,
// or a second change back onto the price Stripe is on: either way
// asking again would raise a proration for a move that is not one.
if ($subscription->stripe_price_id === $priceId) {
$this->settled($subscription, $priceId);
return true;
}
if (! $this->stripe->isConfigured()) {
throw new RuntimeException('Stripe is not configured (STRIPE_SECRET is empty).');
}
$itemId = $this->itemId($subscription);
if ($itemId === null) {
throw new RuntimeException(
'Stripe reports no item on this subscription, so there is nothing to put another price on.'
);
}
$this->stripe->updateSubscriptionPrice(
$subscription->stripe_subscription_id,
$itemId,
$priceId,
$behaviour,
);
$this->settled($subscription, $priceId);
return true;
} catch (Throwable $e) {
$this->park($subscription, $priceId, $behaviour, $e);
return false;
}
}
/**
* Try a parked swap again.
*
* The target price is worked out afresh from the contract rather than read
* back from the parked record: another change may have landed in between,
* and the contract is the authority on which package the customer is on.
* The BEHAVIOUR is the one that was chosen at the time, because it belongs
* to the direction the move went, which cannot be read off the contract
* once it has moved.
*
* A retried upgrade prorates from the moment Stripe finally hears about it,
* not from the moment the change landed here so a long outage charges the
* customer for slightly fewer days than they had the bigger package. In
* their favour, and preferable to the alternative of back-dating a charge.
*/
public function retry(Subscription $subscription): bool
{
$parked = (array) ($subscription->stripe_price_sync ?? []);
return $this($subscription, (string) ($parked['behaviour'] ?? StripeClient::PRORATE_NONE));
}
/**
* The Stripe Price for the package the contract is on now, for the customer
* who is paying for it.
*
* By plan VERSION and term, which is what a Stripe Price is: one per priced
* row and per tax treatment, written by stripe:sync-catalogue. Resolving it by
* plan name would hand back today's terms for a contract that is
* grandfathered on older ones.
*
* The treatment is part of the answer, which is what makes a verification
* converge: a business whose VAT id is confirmed the week after they bought
* belongs on the net Price from that moment, and one whose registration has
* lapsed belongs back on the gross one. Neither is a change of package, so
* nothing here looks like one the move is made with PRORATE_NONE by the
* sweep that finds it, because the term is already paid for.
*/
private function targetPrice(Subscription $subscription): ?string
{
return $this->prices->liveForSubscription($subscription);
}
/**
* The item the subscription bills through.
*
* Stored when we have it a `customer.subscription.updated` event carries
* it and ApplyStripeBillingEvent keeps it. A contract older than that column
* has none, so Stripe is asked once and the answer is kept: the item id
* survives a price swap, so this happens at most once per contract.
*/
private function itemId(Subscription $subscription): ?string
{
if ($subscription->stripe_item_id !== null) {
return $subscription->stripe_item_id;
}
$itemId = $this->stripe->subscriptionItemId((string) $subscription->stripe_subscription_id);
if ($itemId !== null) {
$subscription->update(['stripe_item_id' => $itemId]);
}
return $itemId;
}
/** Stripe and the contract agree; nothing is outstanding. */
private function settled(Subscription $subscription, string $priceId): void
{
$subscription->update([
'stripe_price_id' => $priceId,
'stripe_price_synced_at' => now(),
'stripe_price_sync' => null,
]);
}
/**
* The change landed here and did not reach Stripe.
*
* Recorded on the contract rather than only logged: a log line scrolls away,
* and what this describes is a customer being charged for a package they are
* no longer on. The row is what the retry sweep reads and what an operator
* can be shown.
*/
private function park(Subscription $subscription, ?string $priceId, string $behaviour, Throwable $e): void
{
$parked = (array) ($subscription->stripe_price_sync ?? []);
$attempts = (int) ($parked['attempts'] ?? 0) + 1;
Log::error('A plan change landed but did not reach Stripe: this contract is still being billed at the old price.', [
'subscription' => $subscription->uuid,
'plan' => $subscription->plan,
'stripe_subscription' => $subscription->stripe_subscription_id,
'stripe_price' => $priceId,
'attempts' => $attempts,
'error' => $e->getMessage(),
]);
$subscription->update(['stripe_price_sync' => [
'price' => $priceId,
'behaviour' => $behaviour,
'error' => mb_substr($e->getMessage(), 0, 250),
'failed_at' => now()->toIso8601String(),
'attempts' => $attempts,
]]);
}
}

View File

@ -0,0 +1,134 @@
<?php
namespace App\Actions;
use App\Models\Order;
use App\Models\Subscription;
use App\Models\SubscriptionRecord;
use App\Services\Billing\WithdrawalRight;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
/**
* Opens the contract a paid order bought.
*
* This is the moment the catalogue stops applying. Everything the customer is
* owed price, quotas, seats, the hardware behind the plan is copied onto
* the subscription here and never read from the catalogue again. Provisioning
* sizes the machine from this row, so an operator editing a plan afterwards
* cannot reach a customer who has already paid.
*
* `price_cents` is the catalogue's NET price, which is what PlanChange prorates
* against deliberately not `Order::amount_cents`, which holds the total Stripe
* actually charged, whichever of a package's two Prices this customer was sold on.
* The two can legitimately differ (VAT, and later coupons) and for a
* reverse-charge business they legitimately agree, because no VAT is owed to us at
* all and reconciling them is not this action's job: the proof register
* records what was charged per event, and Stripe's invoice is the authority for
* the amount. Copying a gross total into this net field would silently corrupt
* every pro-rata calculation that reads it.
*
* `$overrides` exists for exactly one caller, GrantSubscription: a grant opens
* a contract through this same action rather than a second one, but needs to
* set what the customer actually pays (0, or less than the catalogue price)
* and stamp who granted it. Passed straight through to Subscription::create()
* on top of the catalogue snapshot empty for every ordinary purchase, which
* is the whole reason this stays a parameter and not a new method.
*/
class OpenSubscription
{
public function __construct(private RecordCommercialEvent $record) {}
/** @param array<string, mixed> $overrides */
public function __invoke(Order $order, string $term = Subscription::TERM_MONTHLY, array $overrides = []): Subscription
{
// A retried webhook must not open a second contract for one purchase.
$existing = Subscription::query()->where('order_id', $order->id)->first();
if ($existing !== null) {
return $existing;
}
$start = now();
// The contract and its entry in the register commit together. If the
// record could fail after the subscription is in, the next webhook
// retry would find the contract, return early, and leave a paid sale
// permanently missing from the evidence.
return DB::transaction(fn () => $this->open($order, $term, $start, $overrides));
}
/** @param array<string, mixed> $overrides */
private function open(Order $order, string $term, Carbon $start, array $overrides = []): Subscription
{
$subscription = Subscription::create(array_merge(
// The version the order carries, when the checkout recorded one:
// what the customer saw beats what happens to be on sale by the
// time their payment reaches us.
Subscription::snapshotFrom($order->plan, $term, $order->plan_version_id),
[
'customer_id' => $order->customer_id,
'order_id' => $order->id,
// Carried from the checkout: from here on, Stripe's events
// name this and nothing else.
'stripe_subscription_id' => $order->stripe_subscription_id,
'started_at' => $start,
'current_period_start' => $start,
'current_period_end' => $term === Subscription::TERM_YEARLY
? $start->copy()->addYear()
: $start->copy()->addMonth(),
// The fourteen days a consumer may withdraw in, stamped at the
// moment the contract is concluded rather than derived on every
// read. A statutory deadline is a date the customer was told,
// and a date that is recomputed is a date that can move.
//
// Stamped for a business customer too. Whether the right exists
// is WithdrawalRight's question and it asks the customer, not
// this column — and a customer can correct their recorded type
// afterwards, at which point the window has to have been running
// all along rather than starting from the correction.
'withdrawal_ends_at' => $start->copy()->addDays(WithdrawalRight::WINDOW_DAYS),
// Carried from the purchase, where it was given. Nothing reads
// it to decide anything any more — a withdrawing consumer gets
// the whole amount back — so this is a record of what was agreed
// at the checkout and not a gate. See Order's own cast.
'immediate_start_consent_at' => $order->immediate_start_consent_at,
'status' => 'active',
],
// Last, so a grant's price and provenance win over the catalogue
// snapshot above rather than the other way round.
$overrides,
));
// The sale, entered in the register the moment it happens. Written from
// the contract that was just frozen, so the evidence and the contract
// cannot describe different purchases.
($this->record)(
event: SubscriptionRecord::EVENT_PURCHASE,
subscription: $subscription,
netCents: $subscription->price_cents,
at: $start,
stripe: ['event' => $order->stripe_event_id],
// The order carries what Stripe actually took, including whatever tax
// or discount applied on the day. The contract price is what was
// agreed; this is what was paid, and the register has to be able to
// state both — which for a reverse-charge business is the same figure,
// because their Price carries the bare net.
//
// Keyed on the Stripe id, not on the amount being non-zero: a fully
// discounted checkout charges zero, and reading that as "no amount
// recorded" would file a free sale as though it had been paid for
// in full. An order without a Stripe id was never charged through
// a checkout at all, and has nothing to report.
// packageChargedCents(), not the whole total: a first purchase can
// carry the one-off setup fee on the same Stripe session, and that fee
// buys the setting-up rather than the package. Held against the
// contract price it would report every sale with a fee on it as charged
// at the wrong amount, which is precisely what the register's flag is
// for and precisely why it must not fire on a correct sale.
chargedGrossCents: $order->stripe_event_id !== null ? $order->packageChargedCents() : null,
);
return $subscription;
}
}

View File

@ -0,0 +1,121 @@
<?php
namespace App\Actions;
use App\Models\Instance;
use App\Models\Order;
use App\Models\ProvisioningRun;
use App\Provisioning\Jobs\AdvanceRunJob;
use App\Provisioning\WorkInFlight;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
/**
* Make an instance's address true again the router, the certificate and the
* hostname Nextcloud answers to without touching anything else about it.
*
* A custom domain used to be announced and never served: the portal, the
* credentials mail and Instance::address() all reported it the moment its TXT
* proof appeared, while the only thing that ever wrote a route was the initial
* provisioning run, and it wrote the platform subdomain. This is the missing
* half the moment a proven domain becomes an address, and the moment a
* withdrawn one stops being one.
*
* Called from the three places the address can change: the nightly proof check
* when verification flips, the customer's own domain page, and a package change
* that takes the right to a domain away. Deliberately not called from anywhere
* that merely LOOKS at the domain a re-apply is remote work on a live
* machine, so it happens on a change and not on a schedule.
*/
class ReapplyInstanceAddress
{
/**
* Start an address run for this instance, or return null when there is
* nothing to start.
*
* Null is the ordinary answer, not a failure: an instance still being
* built, one whose machine no longer exists, or one that already has a run
* in flight all have their address applied by that run instead.
*/
public function __invoke(?Instance $instance): ?ProvisioningRun
{
if ($instance === null || ! $this->isReapplyable($instance)) {
return null;
}
// The lock closes the window between asking whether a run exists and
// creating one. Two requests can reach this in the same instant — the
// customer's own "check now" and the nightly command are the obvious
// pair — and two runs against one machine would write the same router
// twice and race each other's occ calls. Nobody waits for the lock: a
// re-apply that lost the race has nothing to add, because the run that
// won reads the same domain state it would have.
$lock = Cache::lock('instance-address:'.$instance->uuid, 30);
if (! $lock->get()) {
return null;
}
try {
return $this->start($instance);
} finally {
$lock->release();
}
}
private function start(Instance $instance): ?ProvisioningRun
{
$order = $instance->order;
// A run already under way applies whatever the domain state says when it
// gets to the step — but only if it HAS the step. Asked of the two steps
// this pipeline consists of rather than of the mere existence of a run:
// `customer` and `plan-change` both carry them and there is nothing to
// add, while `restart`, `storage` and `quota` carry neither, and standing
// aside for one of those was how a domain proven during a restart stayed
// unrouted for good. See App\Provisioning\WorkInFlight.
if (app(WorkInFlight::class)->covers($order, 'address')) {
return null;
}
$run = ProvisioningRun::create([
'subject_type' => Order::class,
'subject_id' => $order->id,
'pipeline' => 'address',
'status' => ProvisioningRun::STATUS_PENDING,
'current_step' => 0,
// Everything the two steps read. instance_id is how CustomerStep
// finds the machine; node and vmid are how it reaches inside it.
'context' => [
'instance_id' => $instance->id,
'host_id' => $instance->host_id,
'node' => $instance->host?->node,
'vmid' => $instance->vmid,
'subdomain' => $instance->subdomain,
],
]);
AdvanceRunJob::dispatch($run->uuid);
Log::info('Re-applying an instance address.', [
'instance' => $instance->uuid,
'domain' => $instance->custom_domain,
'verified' => $instance->domainIsVerified(),
'run' => $run->uuid,
]);
return $run;
}
/**
* Is there a machine here whose address can be re-applied at all?
*
* The question is not this action's alone a plan change asks exactly the
* same one before it starts a run against a live machine so the rule sits
* on the model and both read it there. See Instance::hasLiveMachine().
*/
private function isReapplyable(Instance $instance): bool
{
return $instance->hasLiveMachine();
}
}

View File

@ -0,0 +1,171 @@
<?php
namespace App\Actions;
use App\Models\Order;
use App\Models\Subscription;
use App\Models\SubscriptionRecord;
use App\Services\Billing\TaxTreatment;
use Illuminate\Support\Carbon;
/**
* Writes one row into the proof register.
*
* Every commercial event goes through here so that the flat columns are filled
* the same way each time an evidence table where the amount means one thing
* in one row and another in the next is not evidence.
*
* Amounts are stated three ways on purpose. Net is what the catalogue and the
* contract deal in; gross is what was actually charged; the tax between them
* depends on the customer and on the day, and recomputing it later from a rate
* that has since changed would produce a different answer to the one on the
* invoice.
*/
class RecordCommercialEvent
{
/**
* @param array<string, mixed> $extra merged into the JSON snapshot
* @param array<string, string|null> $stripe event / invoice / subscription ids
*/
public function __invoke(
string $event,
Subscription $subscription,
int $netCents,
?Carbon $at = null,
array $extra = [],
array $stripe = [],
?int $chargedGrossCents = null,
?Order $order = null,
?string $eventKey = null,
): SubscriptionRecord {
$at ??= now();
$customer = $subscription->customer;
$tax = TaxTreatment::for($customer);
$agreedNet = $netCents;
// What the till would take from THIS customer for this catalogue figure:
// the domestic gross for everybody who is charged VAT, the bare net for a
// reverse-charge business, because that is the Stripe Price their checkout
// used. Asked of the treatment rather than of a single domestic figure,
// which is what it used to be — and every reverse-charge sale, charged
// exactly the amount it should have been, was then reported as a mismatch
// by the one flag whose job is "somebody must look at this".
//
// chargeCents() is the single place the amount taken is formed, and it is
// what the Stripe Price carries — so this is the figure the charge can
// honestly be held against.
$expectedGross = $tax->chargeCents($agreedNet);
// The flat columns describe the TRANSACTION, not the contract: gross is
// what was taken from the customer, and net and tax are that gross
// split by the rate that applied on the day.
//
// Split, not subtracted from the agreed price. A discount lowers the
// taxable amount; it does not create negative VAT, and recording
// 14900 charged against a 179,00 contract as "minus 30,00 tax" would
// make the register wrong in exactly the case someone audits.
if ($chargedGrossCents !== null) {
$gross = $chargedGrossCents;
$net = (int) round($gross / (1 + $tax->rate));
} else {
// Nothing was charged at this event. The columns still state what the
// terms are worth, split by this customer's own rate — a
// reverse-charge contract owes no VAT and must not have any invented
// for it here.
$net = $agreedNet;
$gross = $tax->grossCents($agreedNet);
}
$version = $subscription->planVersion;
return SubscriptionRecord::create([
'event' => $event,
// What makes this event "the same one" if it arrives again. Unique
// where set, so a duplicate delivery collides in the database
// rather than in a check that two of them can both pass.
'event_key' => $eventKey,
'customer_id' => $subscription->customer_id,
'subscription_id' => $subscription->id,
// The order this event belongs to — a booked module has its own,
// and pointing it at the original plan purchase would file every
// add-on under the wrong transaction.
'order_id' => $order?->id ?? $subscription->order_id,
'plan_version_id' => $subscription->plan_version_id,
// Copied rather than joined: these have to still answer the question
// after the customer, the plan or the version have gone.
'customer_name' => $customer?->name,
'plan_key' => $subscription->plan,
'plan_version' => $version?->version,
'term' => $subscription->term,
'net_cents' => $net,
'tax_cents' => $gross - $net,
'gross_cents' => $gross,
'currency' => $subscription->currency,
'tax_rate' => round($tax->rate * 100, 2),
'reverse_charge' => $tax->reverseCharge,
'stripe_event_id' => $stripe['event'] ?? null,
'stripe_invoice_id' => $stripe['invoice'] ?? null,
'stripe_subscription_id' => $stripe['subscription'] ?? null,
'occurred_at' => $at,
'snapshot_version' => SubscriptionRecord::SNAPSHOT_VERSION,
// The long tail: everything nobody has thought to ask about yet.
// The flat columns above are what gets queried.
'snapshot' => array_merge([
'subscription' => $subscription->only(Subscription::FROZEN),
'plan' => [
'key' => $subscription->plan,
'version' => $version?->version,
'version_id' => $subscription->plan_version_id,
'family' => $version?->family?->name,
'capabilities' => $version?->capabilities(),
],
'period' => [
'start' => $subscription->current_period_start?->toIso8601String(),
'end' => $subscription->current_period_end?->toIso8601String(),
],
'addons' => $subscription->addons()->active()->get()
->map(fn ($addon) => [
'key' => $addon->addon_key,
'price_cents' => $addon->price_cents,
'quantity' => $addon->quantity,
'booked_at' => $addon->booked_at?->toIso8601String(),
])->all(),
'customer' => [
'name' => $customer?->name,
'email' => $customer?->email,
'vat_id' => $customer?->vat_id,
],
// Kept even when they agree. A charge that differs from the
// catalogue plus tax is exactly the thing someone will ask
// about later, and reconciling it silently would erase the
// question along with the answer.
'amounts' => [
// What was agreed, beside what was actually taken. The
// flat columns state the transaction; this states whether
// it came out at the contract price, which is the question
// someone asks a year later.
'agreed_net_cents' => $agreedNet,
'expected_gross_cents' => $expectedGross,
// Null where no money moved at this event, and the flag with
// it. Stamping the expected figure into the charged column
// made a plan change — where the terms move and Stripe raises
// its proration invoice afterwards — claim it had been paid
// for, and then agree with itself. A row that answers "was
// the right amount taken?" with "yes" when nothing was taken
// is worse than one that says it does not know.
'charged_gross_cents' => $chargedGrossCents,
'matches_catalogue' => $chargedGrossCents === null
? null
: $chargedGrossCents === $expectedGross,
],
], $extra),
]);
}
}

View File

@ -0,0 +1,160 @@
<?php
namespace App\Actions;
use App\Models\Customer;
use App\Models\Instance;
use App\Models\Order;
use App\Models\ProvisioningRun;
use App\Provisioning\Jobs\AdvanceRunJob;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
/**
* Take a customer's machine round once properly.
*
* The product could not restart anything at all. ProxmoxClient had startVm and
* nothing else, the cloud page's "Neu starten" was a toast, and
* `restart_required_since` set by every plan change that touches CPU or memory
* on a running guest could only ever be cleared by a resize step that happened
* to find the machine stopped. Nothing could stop it. So a paid upgrade's cores
* and RAM were written into the VM's definition and could stay unreached for the
* life of the contract.
*
* This is the missing trigger. It does not do the work: a restart is remote work
* on a live machine, so it goes through the same run machinery as everything
* else see the `restart` pipeline where it is retried, logged, and visible
* in the console instead of happening inside a web request.
*/
class RestartInstance
{
/**
* May whoever is signed in RIGHT NOW restart this machine?
*
* Asked here and not only where the button is drawn. A Livewire action is
* reachable by anyone who can POST to /livewire/update with a component
* snapshot hiding a button hides a button, it does not close a door so
* the rule has to be re-checked at the point the run is created.
*
* Two identities, two answers (R21): an operator holding `instances.restart`
* may restart any machine on the estate, because that is what the console is
* for; a portal user may restart their own and nobody else's. Both guards
* are read, and either may say yes the two sessions coexist in one browser
* by design (see config/auth.php), so asking only the console guard would
* refuse an operator their own cloud, and asking only the portal guard would
* refuse every operator everything.
*
* The customer is resolved from the signed-in user rather than taken from
* the caller: a caller that passes the customer is a caller that can pass
* the wrong one.
*/
public function allows(Instance $instance): bool
{
$operator = Auth::guard('operator')->user();
if ($operator !== null && $operator->isActive() && $operator->can('instances.restart')) {
return true;
}
$customer = Customer::forUser(Auth::guard('web')->user());
return $customer !== null && (int) $instance->customer_id === (int) $customer->id;
}
/**
* Start a restart run for this instance, or return null when there is
* nothing to start.
*
* Null is the ordinary "no" and not a failure a machine that is still
* being built, one whose VM has gone, one whose service has ended, or one
* that already has a run in flight all have a better reason to be left
* alone than a restart has to interrupt them. A refusal on AUTHORISATION is
* the one case that throws, because it is not an ordinary outcome and the
* caller must not be able to mistake it for one.
*
* @throws AuthorizationException
*/
public function __invoke(Instance $instance): ?ProvisioningRun
{
if (! $this->allows($instance)) {
throw new AuthorizationException;
}
if (! $instance->hasLiveMachine()) {
return null;
}
// The same lock ReapplyInstanceAddress takes, for the same reason: two
// requests can reach this in one instant — the customer pressing the
// button while an operator presses it for them is the obvious pair —
// and two restart runs against one machine would have the second one
// shutting the guest down while the first waits for it to come back.
// Nobody waits for the lock: a restart that lost the race has nothing
// to add, because the run that won does exactly the same thing.
$lock = Cache::lock('instance-restart:'.$instance->uuid, 30);
if (! $lock->get()) {
return null;
}
try {
return $this->start($instance);
} finally {
$lock->release();
}
}
private function start(Instance $instance): ?ProvisioningRun
{
$order = $instance->order;
// Checked against the ORDER, because that is the subject every customer
// pipeline shares. ANY run, and deliberately not the narrower "does it do
// this work?" question ReapplyInstanceAddress and ApplyStorageAllowance
// ask: taking the guest away underneath a run is harmful to every one of
// them, and unlike a proven domain or a booked pack a restart is somebody
// pressing a button, so it can be pressed again once the run is done
// rather than needing to be remembered for them.
if ($this->hasRunInFlight($order)) {
return null;
}
$run = ProvisioningRun::create([
'subject_type' => Order::class,
'subject_id' => $order->id,
'pipeline' => 'restart',
'status' => ProvisioningRun::STATUS_PENDING,
'current_step' => 0,
// Everything the four steps read: instance_id is how CustomerStep
// finds the machine, node and vmid are how it reaches it.
'context' => [
'instance_id' => $instance->id,
'host_id' => $instance->host_id,
'node' => $instance->host?->node,
'vmid' => $instance->vmid,
'subdomain' => $instance->subdomain,
],
]);
AdvanceRunJob::dispatch($run->uuid);
Log::info('Restarting a customer instance.', [
'instance' => $instance->uuid,
'restart_pending_since' => $instance->restart_required_since?->toIso8601String(),
'run' => $run->uuid,
]);
return $run;
}
private function hasRunInFlight(Order $order): bool
{
return ProvisioningRun::query()
->where('subject_type', Order::class)
->where('subject_id', $order->id)
->inFlight()
->exists();
}
}

View File

@ -0,0 +1,436 @@
<?php
namespace App\Actions;
use App\Exceptions\IdentityCollisionException;
use App\Mail\InvoiceMail;
use App\Mail\OrderConfirmationMail;
use App\Models\Customer;
use App\Models\Invoice;
use App\Models\Order;
use App\Models\ProvisioningRun;
use App\Models\Subscription;
use App\Provisioning\Jobs\AdvanceRunJob;
use App\Services\Billing\IssueInvoice;
use App\Services\Billing\PlanCatalogue;
use Illuminate\Database\UniqueConstraintViolationException;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
use Throwable;
/**
* Turns a paid Stripe event into a customer + order + provisioning run. The
* order's unique stripe_event_id is the idempotency key: a duplicate webhook
* never starts a second run.
*/
class StartCustomerProvisioning
{
public function __construct(
private OpenSubscription $openSubscription,
private ApplyStripeBillingEvent $billing,
) {}
/**
* @param array{id:string,email:string,name:?string,stripe_customer_id:?string,plan:string,datacenter:string,amount_cents:int,setup_fee_cents?:int,currency:string,immediate_start_consent?:bool} $event
*/
public function fromStripeEvent(array $event): ?Order
{
$existing = Order::query()->where('stripe_event_id', $event['id'])->first();
if ($existing !== null) {
$this->resume($existing);
return null;
}
$customer = $this->resolveCustomer($event);
try {
$customer->ensureUser(); // portal login (also enables admin impersonation)
} catch (IdentityCollisionException) {
// Same principle as openContract() below: the order is the record
// that money changed hands, so a paid order still gets recorded
// and provisioned. Only the colliding portal LOGIN is withheld —
// R21 — until an operator resolves the address conflict by hand
// (rename the operator, or the customer). Until then this
// customer has no self-service sign-in and cannot be
// impersonated either; both call ensureUser() the same way.
Log::error('Portal login withheld: this customer email already belongs to an operator account.', [
'customer_id' => $customer->id, 'email' => $customer->email,
]);
}
try {
[$order, $run] = DB::transaction(function () use ($event, $customer) {
$order = Order::create([
'customer_id' => $customer->id,
'plan' => $event['plan'],
// Set once Stripe checkout carries it (phase 5). Null means
// "whatever is on sale when the webhook lands", which is
// what this did before the column existed.
'plan_version_id' => $event['plan_version_id'] ?? null,
'amount_cents' => $event['amount_cents'],
// How much of `amount_cents` was the one-off setup fee. Kept
// beside the total rather than subtracted from it: a withdrawal
// sends back everything that was taken and the confirmation
// mail states everything that was taken, while the register and
// the invoice need the package and the fee apart — see
// Order::packageChargedCents().
'setup_fee_cents' => (int) ($event['setup_fee_cents'] ?? 0),
'currency' => $event['currency'],
'datacenter' => $event['datacenter'],
'stripe_event_id' => $event['id'],
'stripe_subscription_id' => $event['stripe_subscription_id'] ?? null,
// Kept so a withdrawal has something to refund against; see
// App\Actions\WithdrawContract.
'stripe_payment_intent_id' => $event['stripe_payment_intent_id'] ?? null,
'stripe_invoice_id' => $event['stripe_invoice_id'] ?? null,
// Stamped only where the consumer actually gave it. Nothing
// turns on it any more: a withdrawal refunds the whole
// amount whether it is there or not, because the owner has
// decided not to charge for the days the cloud ran. Kept as
// a record of what the customer was shown and ticked.
'immediate_start_consent_at' => ($event['immediate_start_consent'] ?? false) === true ? now() : null,
'status' => 'paid',
]);
$run = ProvisioningRun::create([
'subject_type' => Order::class,
'subject_id' => $order->id,
'pipeline' => 'customer',
'status' => ProvisioningRun::STATUS_PENDING,
'current_step' => 0,
// Snapshot resolved branding so retries apply identical inputs
// (unset customer → CluPilot defaults, resolved once here).
'context' => ['branding' => $customer->brandingResolved()],
]);
return [$order, $run];
});
} catch (UniqueConstraintViolationException) {
// A concurrent delivery won the race. Both requests will answer
// Stripe with a 2xx, so this is the last look anyone takes at this
// payment — if the winner then dies before opening the contract, no
// retry is coming to fix it. So finish its work rather than just
// stepping aside. The unique index on subscriptions.order_id keeps
// both from opening one.
$winner = Order::query()->where('stripe_event_id', $event['id'])->first();
if ($winner !== null) {
$this->resume($winner);
}
return null;
}
$this->openContract($order);
AdvanceRunJob::dispatch($run->uuid); // after commit
$this->confirmByMail($order, $customer);
return $order;
}
/**
* Invoice the payment, and tell the customer once.
*
* Both halves are idempotent and live in invoiceOnce() and mailInvoiceOnce()
* below, because this is not the only caller any more: resume() has to be able
* to finish exactly this work when a crash cut it off, and two copies of "have
* we invoiced this yet" would be two answers.
*
* Sent here rather than when provisioning finishes: those are minutes apart
* at best and can be much longer, and somebody who has just been charged
* and heard nothing assumes the worst about both the charge and the
* product. CloudReady announces the end; this announces the beginning.
*
* After the transaction, never inside it: a queued job can be picked up by
* a worker before the commit lands, and it would then read an order that
* does not exist yet.
*
* Never allowed to fail the webhook. Stripe retries anything that is not a
* 2xx, and a retry here would re-enter provisioning a second machine
* built over a mail server or a missing VAT number.
*/
private function confirmByMail(Order $order, Customer $customer): void
{
$invoice = $this->invoiceOnce($order, $customer);
if ($invoice !== null) {
$this->mailInvoiceOnce($invoice, $customer);
return;
}
$address = $customer->email;
if (! is_string($address) || $address === '') {
return;
}
// No invoice, and there could not be one: an incomplete company profile,
// or a purchase that was a full gift. The confirmation says what it can.
//
// Sent from HERE only, never from resume(). It has nothing to stamp — an
// order carries no `sent_at` — so a redelivery could not tell that it had
// already gone out, and Stripe redelivers until it gets a 2xx. The invoice
// mail is the one a crash must not lose, and that one is stamped.
try {
Mail::to($address)->queue(new OrderConfirmationMail($order, (string) $customer->name));
} catch (Throwable $e) {
Log::warning('Could not queue the purchase confirmation', [
'order' => $order->id,
'exception' => $e->getMessage(),
]);
}
}
/**
* The document this purchase owes, issued exactly once however often we are
* asked.
*
* The guard is the invoice already filed against this order, because an
* invoice number comes out of a gapless series and can never be handed out
* twice. Cancellations are excluded from the search: a Storno carries the same
* `order_id` as the document it takes back, so counting one would leave a
* withdrawn purchase looking as though it had been invoiced by its own
* cancellation.
*
* Not a database-level guard, and it cannot be one `invoices.order_id` is
* legitimately shared by an invoice and its Storno, so it can never be unique.
* What this closes is the case that actually happens: Stripe redelivering the
* same event, one delivery after another.
*/
private function invoiceOnce(Order $order, Customer $customer): ?Invoice
{
$existing = Invoice::query()
->where('order_id', $order->id)
->whereNull('cancels_invoice_id')
->orderBy('id')
->first();
if ($existing !== null) {
return $existing;
}
try {
// Refuses while the company details are incomplete, which is the
// correct outcome — an invoice without a registered name or a VAT
// number is not a valid invoice, and issuing one consumes a number
// that can never be handed out again. The purchase is unaffected:
// the order stands, the machine gets built, and the invoice can be
// issued from the console once the details are there.
return app(IssueInvoice::class)->forOrders($customer, collect([$order]));
} catch (Throwable $e) {
Log::warning('Could not issue an invoice for this order', [
'order' => $order->id,
'exception' => $e->getMessage(),
]);
return null;
}
}
/**
* Hand the invoice to the mailer, once.
*
* `sent_at` is the stamp, the same one App\Actions\IssueStripeInvoice uses,
* and it is written only after the mailer has accepted the message so a
* redelivery arriving after the document was written but before the mail went
* out finishes the job, and one arriving afterwards does neither twice.
*
* ONE mail, not two. The invoice is issued at the moment the money lands, so
* an order confirmation and an invoice mail would otherwise go out in the same
* second for the same event. The invoice mail says everything the confirmation
* said and carries the document, so it replaces it whenever there is one.
*/
private function mailInvoiceOnce(Invoice $invoice, Customer $customer): void
{
$address = $customer->email;
if (! is_string($address) || $address === '' || $invoice->sent_at !== null) {
return;
}
try {
Mail::to($address)->queue(new InvoiceMail($invoice, (string) $customer->name));
$invoice->update(['sent_at' => now()]);
} catch (Throwable $e) {
Log::warning('Could not queue the invoice mail for this purchase', [
'order' => $invoice->order_id,
'invoice' => $invoice->number,
'exception' => $e->getMessage(),
]);
}
}
/**
* Finish what a crash interrupted.
*
* The order commits before the contract is opened, so that a failure there
* can never erase the record of a payment but that leaves a window in
* which a paid order exists with no contract and nothing running. Stripe
* retries until it gets a 2xx, so a retry is exactly the chance to close
* that window. Simply returning "already processed" would strand a paying
* customer permanently, because no later webhook would ever look again.
*
* Safe to run on every duplicate: opening a contract is idempotent. A run
* that was merely never dispatched needs no nudge from here the scheduler
* tick sweeps pending runs. A run that already ran and FAILED for want of
* the contract does, because nothing sweeps failed runs.
*
* ## The invoice is the other half, and it used to be the half that was lost
*
* This returned as soon as a contract existed, and the only production call to
* IssueInvoice::forOrders() is in confirmByMail() which runs AFTER the
* order commits. A worker killed in between left a paid purchase with no
* invoice, for good and in silence: the next redelivery found the contract
* already open, returned here, and nothing anywhere sweeps for an uninvoiced
* order. So the invoice and its mail are finished here too, on every
* redelivery, and the guards in invoiceOnce()/mailInvoiceOnce() are what make
* "however many times Stripe asks" come out as one invoice, one number and
* one mail.
*/
private function resume(Order $order): void
{
if (! $order->subscription()->exists()) {
$this->openContract($order);
if ($order->subscription()->exists()) {
$this->reviveRunStrandedWithoutAContract($order);
}
}
$customer = $order->customer;
if ($customer === null) {
return;
}
$invoice = $this->invoiceOnce($order, $customer);
if ($invoice !== null) {
$this->mailInvoiceOnce($invoice, $customer);
}
}
/**
* Restart a run that failed only because the contract was missing.
*
* Narrow on purpose. `no_subscription` is returned before anything is built
* the run never got past validating the order or reserving resources so
* there is nothing half-made to trip over. A run that failed for any other
* reason failed at something a contract does not fix, and restarting it
* would just repeat whatever went wrong.
*/
private function reviveRunStrandedWithoutAContract(Order $order): void
{
$run = $order->runs()->where('pipeline', 'customer')->latest('id')->first();
if ($run?->status !== ProvisioningRun::STATUS_FAILED
|| ! str_contains((string) $run->error, 'no_subscription')) {
return;
}
// onProvisioningFailed() marked the order failed on the way down.
$order->update(['status' => 'paid']);
$run->update([
'status' => ProvisioningRun::STATUS_PENDING,
'current_step' => 0,
'attempt' => 0,
'next_attempt_at' => null,
'error' => null,
]);
AdvanceRunJob::dispatch($run->uuid);
Log::info('Restarted a run that was stranded without a contract.', [
'order_id' => $order->id, 'run' => $run->uuid,
]);
}
/**
* Freeze what they bought, while the catalogue still says what they were
* shown.
*
* Deliberately AFTER the order is committed, and deliberately swallowing
* its failure. The order is the record that money changed hands: if opening
* the contract could roll it back, an unsellable plan, a missing price or
* any unforeseen fault would erase the evidence of a payment we have
* already taken and Stripe, seeing no 2xx, would retry a webhook that can
* never succeed. A missing contract is recoverable and loud: the run stops
* at ValidateOrder with `no_subscription`, in the operator's face.
*
* Two cases are expected to land here: a plan the catalogue cannot sell,
* and a payment in a currency it cannot price freezing a EUR price onto a
* CHF payment would put the contract and the payment in permanent
* disagreement.
*/
private function openContract(Order $order): void
{
try {
// A checkout that recorded its version is owed THAT version, even
// if the window has closed or the plan has been withdrawn since —
// they paid for what they were shown. Only a purchase with no
// recorded version has to ask what is on sale now.
$deliverable = $order->plan_version_id !== null
? app(PlanCatalogue::class)->isDeliverable($order->plan, $order->plan_version_id)
: Subscription::knowsPlan($order->plan);
$sellable = $deliverable
&& strtoupper((string) $order->currency) === Subscription::catalogueCurrency();
if (! $sellable) {
Log::warning('No contract opened: the catalogue cannot sell this order.', [
'order_id' => $order->id, 'plan' => $order->plan, 'currency' => $order->currency,
]);
return;
}
$subscription = ($this->openSubscription)($order);
// Anything Stripe sent about this contract before it existed —
// a renewal, a failure, a cancellation — applied now, in the order
// they were raised.
$this->billing->replayHeldFor($subscription);
} catch (Throwable $e) {
Log::error('Failed to open a contract for a paid order.', [
'order_id' => $order->id, 'plan' => $order->plan, 'error' => $e->getMessage(),
]);
}
}
/**
* Find or create the customer, race-safe against the unique email index so a
* concurrent first-time purchase can't create two customers for one email.
*
* `customer_type` is deliberately NOT set here, and that is a decision
* rather than an omission. Nobody in this code path has asked the question:
* a Stripe event carries a name, an address and a payment, and none of those
* says whether the person buying is a consumer or a business. Writing either
* answer would be inventing one so the column stays NULL, which every
* reader treats as a consumer, which is the protective answer. Ordinarily
* there is nothing to invent: the buyer signed up first and was asked then
* (App\Actions\Fortify\CreateNewUser). This branch is the customer whose
* Stripe email differs from the one they registered with, and the operator
* or the customer can record the answer afterwards in the portal settings.
*
* @param array{email:string,name:?string,stripe_customer_id:?string} $event
*/
private function resolveCustomer(array $event): Customer
{
try {
return Customer::query()->firstOrCreate(
['email' => $event['email']],
['name' => $event['name'] ?? $event['email'], 'stripe_customer_id' => $event['stripe_customer_id'] ?? null],
);
} catch (UniqueConstraintViolationException) {
return Customer::query()->where('email', $event['email'])->firstOrFail();
}
}
}

View File

@ -0,0 +1,49 @@
<?php
namespace App\Actions;
use App\Models\Host;
use App\Models\ProvisioningRun;
use App\Provisioning\Jobs\AdvanceRunJob;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
/**
* Registers a fresh host and kicks off its onboarding run. The one-time root
* password lives encrypted in the run context and is scrubbed after SSH trust
* is established.
*/
class StartHostOnboarding
{
/**
* @param array{name: string, datacenter: string, public_ip: string, root_password: string} $input
*/
public function run(array $input): Host
{
// Host + run are created atomically; a partial insert would otherwise
// leave a permanently pending host with no run.
[$host, $run] = DB::transaction(function () use ($input) {
$host = Host::create([
'name' => $input['name'],
'datacenter' => $input['datacenter'],
'public_ip' => $input['public_ip'],
'status' => 'pending',
]);
$run = ProvisioningRun::create([
'subject_type' => Host::class,
'subject_id' => $host->id,
'pipeline' => 'host',
'status' => ProvisioningRun::STATUS_PENDING,
'current_step' => 0,
'context' => ['root_password' => Crypt::encryptString($input['root_password'])],
]);
return [$host, $run];
});
AdvanceRunJob::dispatch($run->uuid); // after commit — the run definitely exists
return $host;
}
}

View File

@ -0,0 +1,421 @@
<?php
namespace App\Actions;
use App\Models\Subscription;
use App\Models\SubscriptionAddon;
use App\Services\Billing\AddonPrices;
use App\Services\Billing\TaxTreatment;
use App\Services\Stripe\StripeClient;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
use RuntimeException;
use Throwable;
/**
* Telling Stripe which modules a contract is paying for.
*
* A booked module was a row here and a line on the customer's first invoice, and
* nothing at all on the Stripe subscription so it was charged once, in the
* month it was booked, and never again. This is what closes that: every module
* still running becomes an item on the subscription, and Stripe bills it beside
* the package on the same invoice, every cycle, for as long as the customer
* keeps it.
*
* **It reconciles rather than replays.** Not "booking adds an item, cancelling
* removes one": the desired state is derivable from the bookings themselves —
* which modules are running, at which frozen price, how many of each and the
* state Stripe is in is stamped on those same rows. So this can be run at any
* moment, from a booking, from a cancellation or from the hourly sweep, and it
* settles on the same answer. That is also what makes the retry honest: a
* booking that could not reach Stripe leaves an active row with no item id, and
* there is nothing else to remember.
*
* **Proration, per direction, chosen here and nowhere else.**
*
* - **Booking** and taking more of a pack settles immediately
* (`always_invoice`). The owner has decided a mid-period booking is prorated
* by days, and `always_invoice` is the behaviour that does exactly that AND
* charges it there and then: Stripe raises its own invoice for the days that
* are left and takes the money. That invoice comes back as `invoice.paid`
* and is the fourth document in the owner's example. The alternative,
* `create_prorations`, would park the amount as a credit line on the next
* cycle invoice, and the customer would get one document a month later
* carrying two different things.
* - **Cancelling** and taking fewer of a pack settles nothing (`none`).
* The customer keeps the module to the end of the term they have already paid
* for and gets no credit, so there is nothing to settle; the item comes off
* now and the next cycle is simply smaller.
*
* **Why the item comes off at cancellation rather than at the period end.**
* Stripe can cancel a SUBSCRIPTION at a period end; an item has no such thing.
* Waiting for the boundary and removing it then is a race against Stripe's own
* invoice generation, which happens at that same instant lose it and the
* customer is charged a full month for a module they cancelled, which is the one
* mistake that costs real money. Removing it now with `none` moves no money at
* all: the current term is already paid for, no credit is raised, and the next
* cycle invoice simply has no such line. What the customer KEEPS until the
* period end is the module itself, and that is held on the booking as
* `cancels_at` and ended by clupilot:end-cancelled-addons.
*
* **Never throws.** The booking has already been made and delivered to the
* machine by the time this runs a storage pack is a bigger disk before Stripe
* hears a word about it and Stripe being unreachable must not undo any of
* that. The failure is parked on the contract in `stripe_addon_sync`, logged,
* and retried by clupilot:sync-stripe-subscriptions beside the plan swaps.
*/
class SyncStripeAddonItems
{
public function __construct(
private StripeClient $stripe,
private AddonPrices $prices,
) {}
/**
* @param bool $chargeForAdditions false when an item is being PUT BACK rather than
* bought: a cancellation taken back inside the term
* restores something the customer has already paid
* for, and charging the proration again would bill
* them twice for the same days. See
* App\Actions\BookAddon::undoCancelAtPeriodEnd().
* @return bool whether Stripe now bills exactly the modules this contract holds
*/
public function __invoke(Subscription $subscription, bool $chargeForAdditions = true): bool
{
// A granted package was never sold through Stripe — GrantSubscription
// leaves stripe_subscription_id null on purpose — so there is nothing
// there to put an item on and nothing has gone wrong. Answered before
// anything else, so no call is made and no failure is recorded.
if ($subscription->stripe_subscription_id === null) {
$this->settled($subscription);
return true;
}
try {
foreach ($this->groups($subscription) as $group) {
$this->reconcile($subscription, $group, $chargeForAdditions);
}
$this->settled($subscription);
return true;
} catch (Throwable $e) {
$this->park($subscription, $e);
return false;
}
}
/**
* Move every module item this contract carries onto the Price that charges
* today's figure for it, to the customer who is paying for it.
*
* Two reasons an item is on the wrong Price, and __invoke() above finds
* neither of them: the item exists and the quantity is right, so it has
* nothing to reconcile. This is the deliberate second pass, run by
* stripe:reprice-subscriptions.
*
* The first is a change of figure the catalogue moved from pushing NET
* amounts to Stripe to pushing the amount actually charged, and a Stripe Price
* cannot be edited, so every running module was on a Price that takes too
* little. The second is a change of CUSTOMER: a business whose VAT id is
* verified after they booked owes no VAT on the module either, and belongs on
* its net Price from then on the reverse too, when a registration lapses.
*
* PRORATE_NONE throughout. The customer has already paid for the term they
* are in, at whatever was taken then; charging the difference for days
* already served would be a bill nobody agreed to. From the next cycle
* Stripe simply takes the right amount.
*
* The module's own frozen `price_cents` is untouched, because that is what
* the customer agreed to pay net and it is what every pro-rata sum reads.
*
* @return array<int, array{addon: string, from: ?string, to: ?string}> what moved, for the command's output
*/
public function reprice(Subscription $subscription, bool $dryRun = false): array
{
if ($subscription->stripe_subscription_id === null) {
return [];
}
$moved = [];
foreach ($this->groups($subscription) as $group) {
$billable = $group->filter(
fn (SubscriptionAddon $addon) => $addon->cancelled_at === null && $addon->cancels_at === null
);
$stamped = $group->filter(fn (SubscriptionAddon $addon) => $addon->stripe_item_id !== null);
$itemId = $stamped->first()?->stripe_item_id;
$first = $billable->first();
// Nothing Stripe is billing, or nothing it should be: __invoke()
// owns both of those cases and would undo whatever this did.
if ($itemId === null || $first === null || (int) $first->price_cents <= 0) {
continue;
}
$target = $this->prices->liveFor(
(string) $first->addon_key,
(int) $first->price_cents,
(string) $first->currency,
(string) $subscription->term,
TaxTreatment::for($subscription->customer),
);
$current = $stamped->first()?->stripe_price_id;
if ($target !== null && $target === $current) {
continue;
}
$moved[] = ['addon' => (string) $first->addon_key, 'from' => $current, 'to' => $target];
if ($dryRun) {
continue;
}
$this->guardConfigured();
// Minted here where the sweep left a figure the catalogue no longer
// pre-creates — a grandfathered booking, or a discounted grant.
$target ??= $this->prices->ensure(
(string) $first->addon_key,
(int) $first->price_cents,
(string) $first->currency,
(string) $subscription->term,
TaxTreatment::for($subscription->customer),
);
if ($target === null) {
// Nothing to bill for it after all — a module given away. Taken
// back out of the report rather than announced as a move.
array_pop($moved);
continue;
}
$moved[count($moved) - 1]['to'] = $target;
$this->stripe->updateSubscriptionPrice(
(string) $subscription->stripe_subscription_id,
$itemId,
$target,
StripeClient::PRORATE_NONE,
);
$this->stamp($group, $itemId, $target);
}
return $moved;
}
/**
* The bookings that matter, grouped into the items they should be.
*
* By module AND by frozen price, because that pair is what a Stripe Price
* is: two packs booked at the same money are one item with a quantity of
* two, and a third booked after the catalogue moved is a second item at the
* price that customer agreed to. Grouping by module alone would have to pick
* one of two prices and charge everybody that.
*
* Cancelled bookings are in here as long as they still carry an item id
* that is the only record of what Stripe was last told, and without it the
* quantity could never be brought back down.
*
* @return Collection<string, Collection<int, SubscriptionAddon>>
*/
private function groups(Subscription $subscription): Collection
{
return SubscriptionAddon::query()
->where('subscription_id', $subscription->id)
->where(fn ($q) => $q->whereNull('cancelled_at')->orWhereNotNull('stripe_item_id'))
->orderBy('id')
->get()
->groupBy(fn (SubscriptionAddon $addon) => $addon->addon_key.'@'.$addon->price_cents);
}
/**
* Bring one module's item into step with what the contract holds.
*
* @param Collection<int, SubscriptionAddon> $group
*/
private function reconcile(Subscription $subscription, Collection $group, bool $chargeForAdditions = true): void
{
// What Stripe should bill: every booking still running and not already
// on its way out. A module with `cancels_at` set is deliberately absent
// — the customer keeps it, nobody is billed for it again.
$billable = $group->filter(
fn (SubscriptionAddon $addon) => $addon->cancelled_at === null && $addon->cancels_at === null
);
// What Stripe was last told, read off the rows that carry the item id.
$stamped = $group->filter(fn (SubscriptionAddon $addon) => $addon->stripe_item_id !== null);
$desired = (int) $billable->sum('quantity');
$known = (int) $stamped->sum('quantity');
$itemId = $stamped->first()?->stripe_item_id;
$first = $group->first();
// A module given away costs nothing, so there is nothing for Stripe to
// bill — and a zero-amount item would put a line saying so on every
// invoice the customer ever gets. Treated exactly like a module that is
// no longer held: if one was ever billed, it comes off.
if ($desired === 0 || (int) $first->price_cents <= 0) {
if ($itemId !== null) {
$this->guardConfigured();
$this->stripe->removeSubscriptionItem($itemId, StripeClient::PRORATE_NONE);
}
$this->stamp($group, null, null);
return;
}
// Stripe already bills exactly this. Only the bookings are restamped —
// one may have been cancelled while another was booked in the same
// breath, which leaves the quantity right and the rows out of date.
if ($itemId !== null && $known === $desired) {
$this->stamp($group, $itemId, $stamped->first()?->stripe_price_id);
return;
}
// Asked for only now that something is going to be said to Stripe: an
// unconfigured account must fail here, where it is parked and retried,
// rather than while resolving a Price nobody is going to use.
$this->guardConfigured();
$priceId = $this->prices->ensure(
(string) $first->addon_key,
(int) $first->price_cents,
(string) $first->currency,
(string) $subscription->term,
// Whoever holds the contract decides which of the module's Prices its
// item bills through: the domestic gross, or the bare net where the
// customer is a verified business in another member state. A module is
// a supply like the package beside it and cannot be taxed differently
// from it on the same invoice.
TaxTreatment::for($subscription->customer),
);
if ($priceId === null) {
$this->stamp($group, null, null);
return;
}
if ($itemId === null) {
$itemId = $this->stripe->addSubscriptionItem(
(string) $subscription->stripe_subscription_id,
$priceId,
$desired,
$chargeForAdditions ? StripeClient::PRORATE_IMMEDIATELY : StripeClient::PRORATE_NONE,
// Keyed on the contract, the module and how many of it: a retry
// after a timeout that in fact went through replays Stripe's
// first answer instead of adding the module a second time.
idempotencyKey: sprintf(
'clupilot-addon-item-%d-%s-%d-%d',
$subscription->id,
$first->addon_key,
$first->price_cents,
$desired,
),
);
} else {
$this->stripe->updateSubscriptionItemQuantity(
$itemId,
$desired,
// More of a pack is bought now and paid for the days that are
// left; fewer is a cancellation, which earns no credit. A
// restoration is neither and settles nothing.
$desired > $known && $chargeForAdditions
? StripeClient::PRORATE_IMMEDIATELY
: StripeClient::PRORATE_NONE,
);
}
$this->stamp($group, $itemId, $priceId);
}
/**
* Write down what Stripe now bills, on the bookings it bills.
*
* Every running booking in the group carries the item id, so a second pack
* booked tomorrow can see that it is joining an item rather than starting
* one. A booking that has stopped gives its id back, because the quantity
* has already been taken down by it and counting it again would take the
* same pack off twice.
*
* Through the query builder: none of these columns is part of what the
* booking froze, and the model would otherwise have to be re-read first.
*
* @param Collection<int, SubscriptionAddon> $group
*/
private function stamp(Collection $group, ?string $itemId, ?string $priceId): void
{
foreach ($group as $addon) {
$billed = $itemId !== null && $addon->cancelled_at === null && $addon->cancels_at === null;
$values = [
'stripe_item_id' => $billed ? $itemId : null,
'stripe_price_id' => $billed ? $priceId : null,
];
if ($addon->stripe_item_id === $values['stripe_item_id']
&& $addon->stripe_price_id === $values['stripe_price_id']) {
continue;
}
SubscriptionAddon::query()->whereKey($addon->getKey())->update($values + ['updated_at' => now()]);
$addon->forceFill($values)->syncOriginal();
}
}
private function guardConfigured(): void
{
if (! $this->stripe->isConfigured()) {
throw new RuntimeException('Stripe is not configured (STRIPE_SECRET is empty).');
}
}
/** Stripe bills exactly what the contract holds; nothing is outstanding. */
private function settled(Subscription $subscription): void
{
if ($subscription->stripe_addon_sync === null) {
return;
}
$subscription->update(['stripe_addon_sync' => null]);
}
/**
* A module was booked or cancelled here and Stripe was not told.
*
* On the contract rather than only in a log line, for the same reason as
* `stripe_price_sync` beside it: what this describes is a customer holding a
* module nobody is billing them for or being billed for one they gave up
* and a log line scrolls away.
*/
private function park(Subscription $subscription, Throwable $e): void
{
$parked = (array) ($subscription->stripe_addon_sync ?? []);
$attempts = (int) ($parked['attempts'] ?? 0) + 1;
Log::error('A module booking landed but did not reach Stripe: this contract is not billed for what it holds.', [
'subscription' => $subscription->uuid,
'stripe_subscription' => $subscription->stripe_subscription_id,
'attempts' => $attempts,
'error' => $e->getMessage(),
]);
$subscription->update(['stripe_addon_sync' => [
'error' => mb_substr($e->getMessage(), 0, 250),
'failed_at' => now()->toIso8601String(),
'attempts' => $attempts,
]]);
}
}

View File

@ -0,0 +1,84 @@
<?php
namespace App\Actions;
use App\Models\Customer;
use App\Services\Tax\VatIdCheck;
use App\Services\Tax\VatIdVerifier;
/**
* Ask the register about a customer's VAT number and record what it said.
*
* The one place a check becomes a stored verification, so the portal form, the
* console and the nightly re-check cannot disagree about what counts. It decides
* nothing about tax: TaxTreatment reads the stored verification and applies the
* reverse-charge rule. This only answers "is the number real".
*
* Deliberately not gated on the customer's recorded type. Whether a verified
* number earns reverse charge is TaxTreatment's question it already refuses a
* customer who has recorded themselves as a private person and a number that
* is registered is registered whoever holds it. Splitting that decision across
* two files is how the two would drift.
*/
class VerifyCustomerVatId
{
public function __construct(private VatIdVerifier $verifier) {}
/**
* Returns what the register said, so the caller can tell the customer.
*
* Null when there is no number to check at all not an outcome, an absence,
* and a caller showing "invalid" for an empty field would be wrong.
*/
public function __invoke(Customer $customer): ?VatIdCheck
{
$vatId = $customer->normalisedVatId();
if ($vatId === '') {
// Removing the number removes the verification with it. Otherwise the
// stamp would sit on the record with nothing to compare against, and
// the next number typed in would look pre-verified for one save.
$this->clear($customer);
return null;
}
$check = $this->verifier->check($vatId);
// The whole reason VatIdCheck has three outcomes. An outage tells us
// nothing, so nothing is written: a verification that has been standing
// for months must not be cleared — and a customer's invoices changed —
// because a national register was down for the night.
if ($check->isUnavailable()) {
return $check;
}
if ($check->isInvalid()) {
$this->clear($customer);
return $check;
}
// Bound to the value that was checked, not to a flag. Customer::
// hasVerifiedVatId() compares the two, so editing the number afterwards
// cannot inherit this confirmation whichever code path does the editing.
$customer->forceFill([
'vat_id_verified_at' => now(),
'vat_id_verified_value' => $vatId,
])->save();
return $check;
}
private function clear(Customer $customer): void
{
if ($customer->vat_id_verified_at === null && $customer->vat_id_verified_value === null) {
return;
}
$customer->forceFill([
'vat_id_verified_at' => null,
'vat_id_verified_value' => null,
])->save();
}
}

View File

@ -0,0 +1,548 @@
<?php
namespace App\Actions;
use App\Models\Instance;
use App\Models\Invoice;
use App\Models\Operator;
use App\Models\Subscription;
use App\Models\SubscriptionAddon;
use App\Models\SubscriptionRecord;
use App\Services\Billing\IssueInvoice;
use App\Services\Billing\WithdrawalRight;
use App\Services\Stripe\StripeClient;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
use RuntimeException;
use Throwable;
/**
* A consumer changes their mind inside fourteen days, and everything that
* follows from it.
*
* One action, because a withdrawal is one event with four consequences and
* three of them are irreversible. Split across a controller, a command and a
* page, they would come apart: a service ended with no credit note, a refund
* sent for a contract still running, a document issued twice by two clicks.
*
* In order, and the order is the design:
*
* 1. **The right is checked**, by WithdrawalRight and nowhere else. A business
* customer is refused here, on the server, whatever the page did or did not
* show every door into this action is the same door.
* 2. **The withdrawal is claimed**, conditionally, on the contract. Two clicks
* in flight write one withdrawal between them; the loser is told it has
* already happened rather than sending a second refund.
* 3. **The paperwork**: EVERY invoice raised inside the window is CANCELLED by a
* Storno with its own number never edited, never deleted. That is the
* established rule for correcting an issued document, and a withdrawal is the
* clearest case there is for it. Every one of them, and not just the opening
* purchase: a module booked on day three and a renewal that fell inside the
* fourteen days are charges too, and they used to be left standing because
* they carry no `order_id` see chargesInsideTheWindow().
* 4. **The money**, which falls out of the paperwork rather than being
* computed beside it: the whole of what each cancelled document said, sent
* back against the payment that actually took it. FAGG §16 would let us keep
* the pro-rata value of the days the cloud ran, and the owner has decided not
* to a withdrawing consumer gets everything back. Two sums that must agree
* cannot disagree if only one of them is ever calculated, and here there is
* only the one: the Stornos'.
* 5. **The service ends**, through App\Actions\EndInstanceService and the
* `cancellation_scheduled` machinery it already reads the address comes
* down, the DNS record goes, the instance is marked ended. No second way
* for an instance to stop being served. Stripe is told to stop billing in
* the same breath, immediately: a withdrawal unwinds the contract, so there
* is no paid-up term to run out and no next cycle to raise.
*
* ## What happens when the money side fails
*
* It is caught, written onto the contract and logged as an error and the
* withdrawal still stands. The customer's declaration is what ends the
* contract; a payment provider being unreachable does not un-declare it, and
* leaving the service running because a refund timed out would charge them for
* days they have withdrawn from. `withdrawal_refund_error` is where an operator
* finds the ones that need a hand.
*/
class WithdrawContract
{
/** Recorded by the customer themselves, in the portal. */
public const CHANNEL_PORTAL = 'portal';
/** Taken by telephone or post and typed in by an operator. */
public const CHANNEL_OPERATOR = 'operator';
public function __construct(
private EndInstanceService $endService,
private BookAddon $bookAddon,
private IssueInvoice $invoices,
private StripeClient $stripe,
private RecordCommercialEvent $record,
) {}
/**
* @param string $channel one of the CHANNEL_* constants
* @param Operator|null $by the operator who took the declaration, when it did not come from the portal
*
* @throws RuntimeException with the sentence the customer is shown, when the right does not apply
*/
public function __invoke(
Subscription $subscription,
string $channel = self::CHANNEL_PORTAL,
?Operator $by = null,
?Carbon $at = null,
): Subscription {
$at ??= now();
$right = WithdrawalRight::for($subscription);
if (! $right->open) {
// The refusal is the customer's own sentence, not a developer's:
// whatever refuses — the portal, the console, a stale tab — says
// the same thing, in the same words.
throw new RuntimeException((string) ($right->refusal ?? __('withdrawal.refusal_expired')));
}
// The one atomic gate. Everything below happens exactly once because
// exactly one caller can turn this column over.
$claimed = Subscription::query()
->whereKey($subscription->getKey())
->whereNull('withdrawn_at')
->update([
'withdrawn_at' => $at,
'withdrawal_channel' => $channel,
'withdrawal_recorded_by' => $by?->id,
'updated_at' => now(),
]);
$subscription->refresh();
if ($claimed === 0) {
throw new RuntimeException(__('withdrawal.refusal_already'));
}
$refunded = $this->settle($subscription, $at);
$this->endEverything($subscription, $at);
$this->enterInRegister($subscription, $refunded, $at);
return $subscription->refresh();
}
/**
* The paperwork and the money, for every charge inside the window.
*
* One Storno per document and one refund per payment, because that is what
* each of the two things IS: a Storno mirrors exactly one invoice and carries
* its number, and a Stripe refund goes against exactly one payment. A single
* refund for the whole sum, sent against the opening payment, would be
* refused by Stripe for exceeding it which is the trap the old one-invoice
* version was hiding behind.
*
* Each charge is settled in its own try, so a module invoice whose payment
* cannot be reached does not stop the opening purchase from being refunded.
* What actually went back is totalled from the refunds that succeeded, never
* from what was intended a contract claiming money moved that did not is
* the one state nobody may discover three weeks later.
*
* @return int what actually went back to the customer, gross, in cents
*/
private function settle(Subscription $subscription, Carbon $at): int
{
$charges = $this->chargesInsideTheWindow($subscription, $at);
$refunded = 0;
$references = [];
$failed = false;
if ($charges->isEmpty()) {
// No document was ever issued for this purchase — the company
// details were incomplete when it was made, or it was a grant. There
// is nothing to cancel, so the refund is worked out from what was
// charged instead. Stated here rather than silently skipped: a
// withdrawal without paperwork is a withdrawal an accountant will
// ask about.
try {
$gross = max(0, $subscription->order?->chargedCents() ?? 0);
$reference = $this->refund(
$subscription,
$gross,
$this->paymentBehind(null, $subscription),
'clupilot-withdrawal-'.$subscription->uuid,
$at,
);
if ($reference !== null) {
$refunded += $gross;
$references[] = $reference;
}
} catch (Throwable $e) {
$this->parkMoneyFailure($subscription, $e);
$failed = true;
}
}
foreach ($charges as $invoice) {
try {
// Taken back in full, with its own gapless number, pointing at
// the one it cancels. The original keeps its number for ever.
$storno = $this->invoices->cancelling($invoice);
// The refund IS what the cancellation took back — never a second
// figure computed beside it, which would have to agree with the
// document and one day would not.
$gross = max(0, -(int) $storno->gross_cents);
$reference = $this->refund(
$subscription,
$gross,
$this->paymentBehind($invoice, $subscription),
// Keyed per DOCUMENT and not per contract. One key for the
// whole withdrawal would have Stripe replay the first
// refund's answer for the second, and the second charge
// would silently never be sent back at all.
'clupilot-withdrawal-'.$subscription->uuid.'-invoice-'.$invoice->id,
$at,
);
if ($reference !== null) {
$refunded += $gross;
$references[] = $reference;
}
} catch (Throwable $e) {
$this->parkMoneyFailure($subscription, $e);
$failed = true;
}
}
$subscription->update([
'withdrawal_refund_cents' => $refunded,
// Every reference, because there is a refund per charge and an
// operator reconciling this against Stripe needs all of them.
'withdrawal_refund_reference' => $references === []
? null
: mb_substr(implode(',', $references), 0, 250),
// Cleared only when nothing went wrong. parkMoneyFailure() has
// already written the reason otherwise, and clearing it here would
// erase the one note saying this withdrawal needs a hand.
...($failed ? [] : ['withdrawal_refund_error' => null]),
]);
return $refunded;
}
/**
* Every charge this contract raised inside the withdrawal window.
*
* `invoices.order_id` alone was the defect: only the opening purchase carries
* one. A renewal and a Stripe-invoice document are written against the
* CONTRACT and leave `order_id` null on purpose see
* IssueInvoice::forBilledPeriod() and forStripeInvoice(), which say why so a
* customer who booked a storage pack on day three and withdrew on day ten had
* that pack neither cancelled nor refunded. The owner's rule is that a
* withdrawing consumer gets EVERYTHING back, so every document has to be in
* this list.
*
* Both links, therefore, and both are needed: the opening invoice has an
* `order_id` and no `subscription_id`, and everything after it is the other
* way round.
*
* Cancellations are excluded by construction one is not an invoice that can
* be taken back and so is anything that already carries a Storno.
* IssueInvoice::cancelling() refuses the latter in its own right; excluding it
* here as well means a second withdrawal attempt finds nothing to do rather
* than raising an exception that would be parked as a money failure.
*
* Bounded at the moment of withdrawal rather than at fourteen days. It is the
* same set a withdrawal can only be declared inside the window, so every
* document this contract has is inside it and the bound that matters is
* "before the customer changed their mind", which is what `$at` is.
*
* @return Collection<int, Invoice>
*/
private function chargesInsideTheWindow(Subscription $subscription, Carbon $at): Collection
{
return Invoice::query()
->where(function ($query) use ($subscription) {
$query->where('subscription_id', $subscription->id);
if ($subscription->order_id !== null) {
$query->orWhere('order_id', $subscription->order_id);
}
})
->whereNull('cancels_invoice_id')
->whereNotIn(
'id',
Invoice::query()->whereNotNull('cancels_invoice_id')->select('cancels_invoice_id')
)
->where('created_at', '<=', $at)
->orderBy('id')
->get();
}
/**
* The Stripe payment a charge was settled by.
*
* Stripe refunds a PAYMENT, not a subscription and not an invoice, and each
* charge inside the window was a different payment: the checkout, a renewal,
* a module prorated in the middle of a term. So every document is sent back
* against the money that actually paid for it.
*
* The opening purchase is the one document with no Stripe invoice of its own
* it is written from the ORDER, which is where the checkout's PaymentIntent
* was recorded, falling back to the invoice that charged the card, resolved
* through Stripe once. A contract with neither was never paid for through
* Stripe at all (a grant, a contract opened by hand), and there is nothing to
* send back.
*/
private function paymentBehind(?Invoice $invoice, Subscription $subscription): ?string
{
if ($invoice?->stripe_invoice_id !== null) {
return $this->stripe->invoicePaymentReference((string) $invoice->stripe_invoice_id);
}
$order = $subscription->order;
$reference = $order?->stripe_payment_intent_id;
if ($reference === null && $order?->stripe_invoice_id !== null) {
$reference = $this->stripe->invoicePaymentReference((string) $order->stripe_invoice_id);
}
return $reference;
}
/**
* Put one charge's money back where it came from.
*
* @return string|null Stripe's reference for the refund, or null when there
* was nothing to send back
*/
private function refund(
Subscription $subscription,
int $grossCents,
?string $reference,
string $idempotencyKey,
Carbon $at,
): ?string {
if ($grossCents <= 0) {
return null;
}
if ($reference === null) {
throw new RuntimeException(
'No Stripe payment is recorded for this charge, so the refund has to be sent by hand.'
);
}
// A retry after a timeout that in fact went through replays Stripe's
// first answer rather than sending the money a second time — which is
// the single most expensive mistake this file could make.
$refundId = $this->stripe->refund($reference, $grossCents, idempotencyKey: $idempotencyKey);
Log::info('Refunded a withdrawal.', [
'subscription' => $subscription->uuid,
'refund' => $refundId,
'gross_cents' => $grossCents,
'withdrawn_at' => $at->toIso8601String(),
]);
return $refundId;
}
/**
* A withdrawn contract ends: the modules stop, the address comes down and
* the contract is closed.
*
* Through the machinery that already does this, never beside it. The
* instance is put into `cancellation_scheduled` with its service ending NOW
* which is what a withdrawal means, unlike a cancellation, where the
* customer keeps the term they paid for and EndInstanceService is asked
* the same question it is asked by the nightly sweep.
*
* The modules are ended outright rather than at the period end, for the same
* reason: there is no term left to keep. The customer has withdrawn from the
* contract the modules hang off, and BookAddon::cancel() takes them off
* Stripe and out of the register.
*
* The order of the two Stripe calls is the reason the subscription is stopped
* LAST and not first. BookAddon::cancel() takes each module's item off the
* subscription, and an item cannot be removed from a subscription that no
* longer exists so cancelling the whole thing first would make every one of
* those removals fail and park a sync error on a contract that is already
* over.
*/
private function endEverything(Subscription $subscription, Carbon $at): void
{
foreach (SubscriptionAddon::query()->where('subscription_id', $subscription->id)->active()->get() as $addon) {
$this->bookAddon->cancel($addon);
}
$this->stopBilling($subscription);
$instance = $this->instanceOf($subscription);
if ($instance !== null && $instance->status !== 'ended') {
$instance->update([
'status' => 'cancellation_scheduled',
'cancel_requested_at' => $at,
// Now, not the end of the term. A withdrawal unwinds the
// contract; there is no paid-up period to run out.
'service_ends_at' => $at,
]);
($this->endService)($instance->refresh());
}
$subscription->update([
'status' => 'cancelled',
'cancelled_at' => $at,
]);
}
/**
* Tell Stripe to stop, now.
*
* The call nothing in this application used to make. A withdrawal marked the
* contract cancelled, ended the service and sent the money back, and the
* subscription at Stripe went on charging the card every month for a
* machine that had been switched off, with `invoice.paid` arriving here each
* time and drawing a real invoice number for it.
*
* Immediately and not at the period end, which is the whole difference
* between this and a customer's own cancellation: a withdrawal unwinds the
* contract from the beginning, the entire amount has just gone back, and
* there is no paid-up term left for the customer to use up.
*
* A failure does not un-declare the withdrawal same principle as the refund,
* for the same reason but it is the most urgent thing on this contract, so
* it is said on the contract as well as in the log. Written after settle(),
* so the note cannot be cleared by a refund that succeeded.
*/
private function stopBilling(Subscription $subscription): void
{
// Nothing to stop, and nothing has gone wrong. A granted package was
// never sold through Stripe — GrantSubscription leaves the id null on
// purpose — and neither was a contract opened by hand.
if ($subscription->stripe_subscription_id === null) {
return;
}
try {
$this->stripe->cancelSubscription(
(string) $subscription->stripe_subscription_id,
StripeClient::CANCEL_IMMEDIATELY,
// Keyed on the contract, because a contract is withdrawn from
// once. A retry after a timeout replays Stripe's first answer.
idempotencyKey: 'clupilot-withdrawal-stop-'.$subscription->uuid,
);
} catch (Throwable $e) {
Log::error('A withdrawal was declared and Stripe was not told to stop charging for it.', [
'subscription' => $subscription->uuid,
'stripe_subscription' => $subscription->stripe_subscription_id,
'error' => $e->getMessage(),
]);
$this->note($subscription, 'Stripe was not told to stop: '.$e->getMessage());
}
}
/**
* The machine this contract pays for.
*
* `subscriptions.instance_id` is the link, and it is written when resources
* are reserved so a contract withdrawn from before the build got that far
* has none. The order and then the customer are the fallbacks, in that
* order, because an order belongs to exactly one purchase and a customer can
* have more than one instance behind them.
*/
private function instanceOf(Subscription $subscription): ?Instance
{
if ($subscription->instance !== null) {
return $subscription->instance;
}
if ($subscription->order_id !== null) {
$byOrder = Instance::query()->where('order_id', $subscription->order_id)->latest('id')->first();
if ($byOrder !== null) {
return $byOrder;
}
}
return Instance::query()
->where('customer_id', $subscription->customer_id)
->whereIn('status', ['active', 'provisioning', 'cancellation_scheduled'])
->latest('id')
->first();
}
/**
* The refund did not go out. Said on the contract, not only in a log.
*
* What this describes is a customer who has withdrawn, whose service has
* stopped, and who has not had their money back the one state nobody may
* discover from a log file three weeks later.
*/
private function parkMoneyFailure(Subscription $subscription, Throwable $e): void
{
Log::error('A withdrawal was declared but the money did not go back.', [
'subscription' => $subscription->uuid,
'customer' => $subscription->customer_id,
'error' => $e->getMessage(),
]);
$this->note($subscription, $e->getMessage());
}
/**
* Write a sentence an operator has to read onto the contract itself.
*
* Appended rather than overwritten, because a withdrawal can go wrong in more
* than one way at once a module invoice whose payment could not be reached
* AND an order to stop billing that Stripe refused and an operator who read
* only the last of them would send the money back and leave the customer
* subscribed.
*/
private function note(Subscription $subscription, string $sentence): void
{
$existing = trim((string) $subscription->withdrawal_refund_error);
$subscription->update([
'withdrawal_refund_error' => mb_substr(
$existing === '' ? $sentence : $existing.' | '.$sentence,
0,
250,
),
]);
}
/**
* The entry that says this happened, in the register that cannot be edited.
*
* Negative, because it is revenue leaving. The gross is what actually went
* back, so a withdrawal that could not be refunded records a zero and the
* `withdrawal_refund_error` beside it says why rather than claiming money
* moved that did not.
*/
private function enterInRegister(Subscription $subscription, int $refundedGross, Carbon $at): void
{
($this->record)(
event: SubscriptionRecord::EVENT_WITHDRAWAL,
subscription: $subscription->refresh(),
netCents: -(int) $subscription->price_cents,
at: $at,
extra: ['withdrawal' => [
'channel' => $subscription->withdrawal_channel,
// Nothing is charged for them — the refund is the whole amount —
// but how long the service actually ran is a fact about this
// contract, and the register is where facts about it are kept.
'delivered_days' => WithdrawalRight::deliveredDays($subscription, $at),
'term_days' => WithdrawalRight::termDays($subscription),
'refunded_gross_cents' => $refundedGross,
]],
chargedGrossCents: -$refundedGross,
eventKey: 'withdrawal:'.$subscription->uuid,
);
}
}

View File

@ -0,0 +1,118 @@
<?php
namespace App\Console\Commands;
use App\Actions\ApplyPlanChange;
use App\Models\Order;
use App\Models\Subscription;
use Illuminate\Console\Command;
use Throwable;
/**
* Carries out the plan changes whose moment has come.
*
* An upgrade lands when it is paid for see App\Observers\OrderObserver. A
* downgrade cannot: someone on a yearly contract bought a year, so moving them
* down in month three would take away what they have already paid for. It waits
* for the end of the term, and nothing was waiting with it. This is what does.
*
* **How a pending downgrade is known.** By the contract, which carries the
* package it is moving to and the date that move comes due `pending_plan` and
* `pending_effective_at`, stamped by the shop the moment the customer books it.
*
* This used to be read off the pending Order instead, with the due date worked
* out from `subscriptions.current_period_end` on every tick. That date is not a
* fact about the booking, it is a fact about the billing cycle, and Stripe moves
* it forward on every renewal: a downgrade booked in March was pushed to the end
* of April by April's renewal, then to the end of May by May's, and the customer
* went on paying for the package they had asked to leave. A due date has to be
* decided once, at the moment the customer decides so it is stamped then, and
* only read afterwards.
*
* The order is still the customer's own record of the request it is what the
* cart shows them and what they remove to change their mind so it is consumed
* here alongside the contract, and removing it clears the stamp (see
* App\Livewire\ConfirmRemoveOrder). What it no longer does is decide WHEN.
*
* **Safe to run as often as the scheduler likes.** A contract is picked up only
* while its stamped date has passed and it is still active, and the stamp is
* cleared the moment the change has landed so a second tick finds nothing
* left to do rather than doing it again.
*/
class ApplyDuePlanChanges extends Command
{
protected $signature = 'clupilot:apply-due-plan-changes';
protected $description = 'Apply scheduled downgrades whose term has run out';
public function handle(ApplyPlanChange $apply): int
{
$applied = 0;
$due = 0;
// Cancelled contracts are left where they are, and not merely because
// ApplyPlanChange would refuse them: a customer who has left is not
// moved onto a smaller package on their way out, and the booking they
// made goes with the contract it was made on.
$contracts = Subscription::query()
->whereNotNull('pending_plan')
->whereNotNull('pending_effective_at')
->where('pending_effective_at', '<=', now())
->where('status', 'active')
->with('customer')
->orderBy('id')
->cursor();
foreach ($contracts as $subscription) {
$due++;
$plan = (string) $subscription->pending_plan;
try {
$apply($subscription, $plan, $this->orderFor($subscription, $plan));
// Read back rather than assumed: a package the catalogue has
// since withdrawn cannot be moved onto, and ApplyPlanChange
// says so by logging and leaving the contract alone. Clearing
// the stamp there would drop the customer's request silently,
// so it stays booked and this says so on every tick.
$subscription->refresh();
if ((string) $subscription->plan !== $plan) {
$this->warn("Contract {$subscription->uuid}: still on {$subscription->plan}, {$plan} was not applied.");
continue;
}
$subscription->clearPendingPlanChange();
$applied++;
} catch (Throwable $e) {
// One customer's withdrawn package must not stop everybody
// else's downgrade from landing.
$this->warn("Contract {$subscription->uuid}: {$e->getMessage()}");
}
}
$this->info("{$due} downgrade(s) due, {$applied} applied.");
return self::SUCCESS;
}
/**
* The cart entry this booking came from, so it is consumed with the change
* and the register row files under it.
*
* Null is a perfectly ordinary answer an operator can book a change
* without a purchase, and the contract is the authority on what was agreed
* either way.
*/
private function orderFor(Subscription $subscription, string $plan): ?Order
{
return Order::query()
->where('customer_id', $subscription->customer_id)
->where('type', 'downgrade')
->where('status', 'pending')
->where('plan', $plan)
->orderBy('id')
->first();
}
}

View File

@ -0,0 +1,175 @@
<?php
namespace App\Console\Commands;
use App\Models\Instance;
use App\Models\Order;
use App\Models\ProvisioningRun;
use App\Provisioning\Jobs\AdvanceRunJob;
use App\Services\Billing\StorageAllowance;
use Illuminate\Console\Command;
/**
* Give the existing estate the storage allowance it was already sold.
*
* ApplyStorageQuota now runs in the `customer` and `plan-change` pipelines, so
* everything built or changed from here on is enforced on the machine. Every
* instance from before that is not: `quota_gb` reached the row and stopped
* there, Nextcloud was never told, and those customers still have the whole disk
* whatever they are paying for. Nothing in the product could reach them, because
* the two pipelines only run on a build or a change and these machines are
* having neither.
*
* This is the sweep, and it is a REPAIR, not a routine. It is deliberately NOT
* in the scheduler:
*
* - the hole it closes is finite and closes for good. Once an instance's
* allowance is enforced and recorded, both pipelines keep it that way;
* - a nightly sweep would be a second authority writing the same value onto
* live machines on a timer, and the day the step in the pipeline silently
* stopped working the sweep would quietly cover for it the failure would
* never surface, which is how the original hole survived as long as it did;
* - a repair the owner runs is a repair the owner can read the output of. This
* one prints what it did and what it did not, and refuses to touch anything
* at all under --dry-run.
*
* The work itself goes through the `quota` pipeline rather than reaching into
* guests here: one run per instance, retried, logged and visible in the console
* like every other piece of remote work, and running the exact same step the
* other two pipelines run so a second implementation cannot drift from it.
*/
class ApplyStorageQuotas extends Command
{
protected $signature = 'clupilot:apply-quotas
{--dry-run : list what would happen and change nothing}
{--instance= : one instance uuid, for a single repair}';
protected $description = 'Apply the sold storage allowance to instances whose Nextcloud was never told about it';
public function handle(): int
{
$dryRun = (bool) $this->option('dry-run');
$started = 0;
/** @var array<string, int> reason => count */
$skipped = [];
$query = Instance::query()->with(['order', 'host']);
if ($uuid = $this->option('instance')) {
$query->where('uuid', $uuid);
}
foreach ($query->cursor() as $instance) {
$reason = $this->reasonToSkip($instance);
if ($reason !== null) {
$skipped[$reason] = ($skipped[$reason] ?? 0) + 1;
continue;
}
$started++;
// The whole allowance, packs included — the figure the run will
// actually write. Printing `quota_gb` would report the package while
// the sweep applied something larger.
$owed = StorageAllowance::for($instance)->totalGb();
$this->line(($dryRun ? '[dry-run] ' : '')
."{$instance->subdomain}: {$owed} GB "
.'('.($instance->quota_applied_gb === null ? 'noch nie gesetzt' : "zuletzt {$instance->quota_applied_gb} GB").')');
if (! $dryRun) {
$this->startQuotaRun($instance);
}
}
foreach ($skipped as $reason => $count) {
$this->line("übersprungen ({$reason}): {$count}");
}
$this->info($dryRun
? "Probelauf: {$started} Instanz(en) bekämen ihr Kontingent, ".array_sum($skipped).' übersprungen. Nichts wurde geändert.'
: "{$started} Kontingent-Lauf/Läufe gestartet, ".array_sum($skipped).' übersprungen.');
return self::SUCCESS;
}
/**
* Why this instance is being left alone, or null when it is not.
*
* The reason is the report a sweep that says "skipped 34" and nothing else
* is a sweep nobody can act on, because "34 machines have no allowance
* enforced" and "34 machines are already fine" look identical from here.
*/
private function reasonToSkip(Instance $instance): ?string
{
// Nothing remote work can reach: a reservation with no VM, a failed
// build, an ended service. The same rule every other maintenance action
// asks — see Instance::hasLiveMachine().
if (! $instance->hasLiveMachine()) {
return 'keine erreichbare Maschine';
}
// No allowance on the row at all. Writing "0 GB" would lock the customer
// out of their own files, which is a far worse answer than leaving what
// is there and letting somebody notice — the same decision the step
// itself makes, made here too so the machine is not visited for nothing.
if ((int) $instance->quota_gb <= 0) {
return 'kein Kontingent hinterlegt';
}
if ($instance->quotaIsEnforced()) {
return 'bereits gesetzt';
}
// Whatever is in flight against this order gets to finish. A quota run
// beside an unfinished build would race the same occ calls, and the
// build applies the quota itself anyway.
if ($this->hasRunInFlight($instance)) {
return 'anderer Lauf aktiv';
}
return null;
}
private function startQuotaRun(Instance $instance): void
{
$run = ProvisioningRun::create([
// The instance's own purchase order, as every customer pipeline
// uses — that is what CustomerStep::order() and ::instance()
// resolve from, and what the in-flight check above looks at.
'subject_type' => Order::class,
'subject_id' => $instance->order_id,
'pipeline' => 'quota',
'status' => ProvisioningRun::STATUS_PENDING,
'current_step' => 0,
'context' => [
'instance_id' => $instance->id,
'host_id' => $instance->host_id,
'node' => $instance->host?->node,
'vmid' => $instance->vmid,
'subdomain' => $instance->subdomain,
],
]);
AdvanceRunJob::dispatch($run->uuid);
}
/**
* ANY run, deliberately unlike the narrower "will it do this work?" question
* App\Provisioning\WorkInFlight answers for the two actions that must not lose
* a change they were asked to make. This is a repair sweep: it runs again, it
* skips instances whose quota is already enforced, and an instance passed over
* today is picked up on the next pass. Nothing is forgotten by waiting.
*/
private function hasRunInFlight(Instance $instance): bool
{
return ProvisioningRun::query()
->where('subject_type', Order::class)
->where('subject_id', $instance->order_id)
->inFlight()
->exists();
}
}

View File

@ -0,0 +1,63 @@
<?php
namespace App\Console\Commands;
use App\Jobs\ArchiveInvoice;
use App\Models\Invoice;
use App\Models\ExportTarget;
use Illuminate\Console\Command;
/**
* The sweep behind the copy-on-issue.
*
* Copying at issue is right and it is not enough. An invoice issued while the
* office connection was down never reaches the archive, the queue eventually
* gives up, and nothing about the invoice tells anybody nobody opens an
* archive to check whether last Tuesday is in it. This picks up whatever is
* still missing, however long ago it was issued.
*
* Deliberately re-queues rather than writing here: the job already knows how to
* write one, and two code paths that both write an invoice into an archive is
* one more than the number that can be kept correct.
*/
class ArchiveUnexportedInvoices extends Command
{
protected $signature = 'clupilot:archive-invoices {--limit=200}';
protected $description = 'Queue a copy of every invoice that is not on the archive yet';
public function handle(): int
{
$targets = ExportTarget::query()->where('active', true)->get();
if ($targets->isEmpty()) {
$this->line('No export target configured — nothing to do.');
return self::SUCCESS;
}
$queued = 0;
foreach ($targets as $target) {
// Per destination, because "arrived" is a fact about a pair. An
// invoice on the office NAS and not on the off-site box is exactly
// the case this exists to catch.
$pending = Invoice::query()
->whereDoesntHave('exports', fn ($q) => $q
->where('export_target_id', $target->id)
->whereNotNull('exported_at'))
->orderBy('id')
->limit((int) $this->option('limit'))
->get();
foreach ($pending as $invoice) {
ArchiveInvoice::dispatch($invoice, $target);
$queued++;
}
}
$this->line("Queued {$queued} copy/copies for {$targets->count()} destination(s).");
return self::SUCCESS;
}
}

View File

@ -0,0 +1,52 @@
<?php
namespace App\Console\Commands;
use App\Services\Deployment\UpdateChannel;
use App\Services\Deployment\UpdateWindow;
use Illuminate\Console\Command;
/**
* Takes an available update, inside the window, if the owner asked for that.
*
* It leaves exactly the same request the button leaves same file, same agent,
* same run. There is no second path into a deployment, because a second path is
* a second set of rules to keep in step with the first.
*
* Nothing here checks whether an update already ran today. It does not need to:
* an installation that is current has nothing available, so an operator who
* pressed the button at four finds the five o'clock window with nothing to do.
*/
class AutoUpdate extends Command
{
protected $signature = 'clupilot:auto-update {--force : ignore the window, for testing the wiring}';
protected $description = 'Install an available release during the configured update window';
public function handle(UpdateWindow $window, UpdateChannel $channel): int
{
if (! $window->enabled()) {
return self::SUCCESS;
}
if (! $this->option('force') && ! $window->isOpen()) {
return self::SUCCESS;
}
$state = $channel->state();
// The agent has to be alive, there has to be something to install, and
// nothing may be running. All three come from the same state the button
// is disabled by, so the automation cannot do what an operator is
// prevented from doing by hand.
if (! $state['agent_seen'] || ! $state['available'] || $state['running']) {
return self::SUCCESS;
}
if ($channel->request(__('admin_settings.update_by_schedule'))) {
$this->info('Requested an update inside the window.');
}
return self::SUCCESS;
}
}

View File

@ -0,0 +1,75 @@
<?php
namespace App\Console\Commands;
use App\Models\DnsRecord;
use App\Models\Instance;
use App\Models\MonitoringTarget;
use App\Support\ProvisioningSettings;
use Illuminate\Console\Command;
/**
* What changing the instance zone would touch, before anything is touched.
*
* The zone is one setting, and changing it silently re-addresses every
* instance: `subdomain` holds only the label, and every caller composes
* `label.zone` at read time. So the moment the setting changes, the console,
* the portal, the acceptance checks and the mails all name an address that
* does not resolve while the DNS records, the certificates and Nextcloud's
* trusted_domains still hold the old one.
*
* Harmless with no instances, which is exactly why it is worth doing now. This
* says which it is.
*/
class CheckDnsZone extends Command
{
protected $signature = 'clupilot:check-zone {zone? : the zone you are considering}';
protected $description = 'Report what a change of the instance DNS zone would affect';
public function handle(): int
{
$current = ProvisioningSettings::dnsZone();
$target = (string) ($this->argument('zone') ?? '');
$this->line('');
$this->line(" Current zone: <options=bold>{$current}</>");
$this->line(' Proposed zone: '.($target !== '' ? "<options=bold>{$target}</>" : '<comment>none given</comment>'));
$this->line('');
$instances = Instance::query()
->whereIn('status', ['active', 'provisioning', 'cancellation_scheduled'])
->get();
if ($instances->isEmpty()) {
$this->info(' No instances in service. The zone can be changed with nothing to migrate.');
$this->line('');
return self::SUCCESS;
}
$this->warn(" {$instances->count()} instance(s) are addressed under the current zone.");
$this->line('');
$this->line(' Each of these carries the old name in places this setting does NOT reach:');
$this->line('');
$records = DnsRecord::query()->whereIn('instance_id', $instances->pluck('id'))->count();
$targets = MonitoringTarget::query()->whereIn('instance_id', $instances->pluck('id'))->count();
$verified = $instances->filter(fn (Instance $i) => $i->domainIsVerified())->count();
$this->table(['What', 'Count', 'Consequence of changing the zone'], [
['DNS records (dns_records.fqdn)', $records, 'still point the OLD name at the host'],
['Monitoring targets', $targets, 'keep watching the old URL and report down'],
['TLS certificates', $instances->count(), 'issued for the old name; the new one has none'],
['Nextcloud trusted_domains', $instances->count(), 'rejects the new name until rewritten'],
['Instances on their own domain', $verified, 'unaffected — they are served at custom_domain'],
]);
$this->line('');
$this->line(' A change now is a migration with an outage, not a setting. Either do it');
$this->line(' before the first customer, or plan it as a maintenance window.');
$this->line('');
return self::SUCCESS;
}
}

View File

@ -0,0 +1,99 @@
<?php
namespace App\Console\Commands;
use App\Models\PlanFamily;
use App\Models\PlanVersion;
use App\Models\Subscription;
use Illuminate\Console\Command;
/**
* Tells the owner whether the catalogue is in a state that can actually sell.
*
* Everything here is computed at read time, which means a broken window or a
* missing price does not announce itself until a customer hits it. This is the
* thing to run after editing plans before finding out from a failed checkout.
*/
class CheckPlanCatalogue extends Command
{
protected $signature = 'plans:check';
protected $description = 'Check the plan catalogue for overlaps, gaps and missing prices';
public function handle(): int
{
$currency = Subscription::catalogueCurrency();
$problems = [];
$now = now();
$families = PlanFamily::query()->with('versions.prices')->orderBy('tier')->get();
if ($families->isEmpty()) {
$this->error('The catalogue is empty. Nothing can be sold.');
return self::FAILURE;
}
foreach ($families as $family) {
$live = $family->versions->filter(fn (PlanVersion $v) => $v->isAvailableAt($now));
if ($live->count() > 1) {
$problems[] = "{$family->key}: {$live->count()} versions on sale at once (".
$live->pluck('version')->implode(', ').') — overlapping windows.';
}
if ($family->sales_enabled && $live->isEmpty()) {
$problems[] = "{$family->key}: on sale, but no version is available right now.";
}
foreach ($family->versions as $version) {
if (! $version->isPublished()) {
continue;
}
foreach ([Subscription::TERM_MONTHLY, Subscription::TERM_YEARLY] as $term) {
$priced = $version->prices
->first(fn ($p) => $p->term === $term && $p->currency === $currency);
if ($priced === null) {
$problems[] = "{$family->key} v{$version->version}: no {$term} price in {$currency}.";
}
}
}
}
// A contract that cannot say which version it was sold under has lost
// its provenance, which is the one thing the version table is for.
$orphaned = Subscription::query()->whereNull('plan_version_id')->count();
if ($orphaned > 0) {
$problems[] = "{$orphaned} subscription(s) have no plan version recorded.";
}
foreach ($families as $family) {
$state = $family->sales_enabled ? 'on sale' : 'withdrawn';
$version = $family->versions->first(fn (PlanVersion $v) => $v->isAvailableAt($now));
$this->line(sprintf(
' %-12s tier %d %-10s %s',
$family->key,
$family->tier,
$state,
$version !== null ? "v{$version->version}" : '—',
));
}
if ($problems === []) {
$this->newLine();
$this->info('Catalogue is consistent.');
return self::SUCCESS;
}
$this->newLine();
foreach ($problems as $problem) {
$this->error(' '.$problem);
}
return self::FAILURE;
}
}

View File

@ -0,0 +1,145 @@
<?php
namespace App\Console\Commands;
use App\Http\Middleware\RestrictConsoleNetwork;
use App\Support\Settings;
use Illuminate\Console\Command;
/**
* The way back in when the console has locked its owner out.
*
* The allowlist is managed in the console which is fine until the address you
* manage it from changes, and then the page that would fix the problem is the
* page the problem blocks. Every gate needs a door that does not depend on
* itself, and on a server that door is a shell.
*/
class ConsoleAccess extends Command
{
protected $signature = 'clupilot:console-access
{action=show : show|allow|deny|open|close|caddy}
{value? : an address or CIDR, for allow and deny}';
protected $description = 'Show or change who may reach the operator console';
public function handle(): int
{
$action = (string) $this->argument('action');
$value = trim((string) $this->argument('value'));
return match ($action) {
'show' => $this->show(),
'allow' => $this->allow($value),
'deny' => $this->deny($value),
'open' => $this->setRestricted(false),
'close' => $this->setRestricted(true),
'caddy' => $this->caddy(),
default => $this->refuse("Unknown action: {$action}"),
};
}
/**
* The allowlist as a Caddy matcher, for the reverse proxy to import.
*
* The proxy has its own allowlist, hard-coded, and it runs FIRST so
* everything the owner adds in the console has no effect whatsoever, and
* the console's own access page is a decoration. Worse, when the owner's
* address changes they are turned away by the proxy before the application
* they could have fixed it in is ever reached.
*
* Emitting the matcher from the same list the console manages makes the
* console the single authority. The agent on the host writes this out and
* reloads the proxy.
*/
private function caddy(): int
{
// Always the allowlist — never 0.0.0.0/0, not even when the console's
// own restriction is switched off.
//
// The owner's rule is absolute: the console is reachable over the
// management VPN, or from an address they have listed, and from nowhere
// else. Letting the console's "open" switch also open the PROXY would
// put the console on the public internet with one click, which is
// exactly the state that rule exists to prevent. Switching it off
// relaxes the application's own check; the proxy keeps its list.
$ranges = RestrictConsoleNetwork::allowedRanges();
// Never empty: an empty remote_ip matcher matches NOTHING in Caddy, and
// the console would become unreachable from anywhere at all — including
// from the place someone would fix it. Loopback always survives, so a
// shell on the box is always a way back.
if ($ranges === []) {
$ranges = ['127.0.0.1', '::1'];
}
$this->line('# Generated from the console allowlist — do not edit by hand.');
$this->line('# Regenerated by deploy/update-agent.sh; edit it in the console.');
$this->line('@allowed remote_ip '.implode(' ', $ranges));
return self::SUCCESS;
}
private function show(): int
{
$this->line(' restricted : '.(RestrictConsoleNetwork::isRestricted() ? 'yes' : 'no — anyone reaching the hostname gets in'));
$this->line(' always : '.implode(', ', (array) config('admin_access.trusted_ranges', [])).' (VPN, not removable)');
$own = (array) Settings::get('console.allowed_ips', []);
$this->line(' additional : '.($own === [] ? '(none)' : implode(', ', $own)));
return self::SUCCESS;
}
private function allow(string $value): int
{
if ($value === '') {
return $this->refuse('Give an address or range: clupilot:console-access allow 203.0.113.7');
}
// An entry that matches nothing would be stored, reported as success,
// and leave whoever is recovering still locked out.
if (! RestrictConsoleNetwork::isNetwork($value)) {
return $this->refuse("Not an address or a range: {$value}");
}
$list = (array) Settings::get('console.allowed_ips', []);
if (! in_array($value, $list, true)) {
$list[] = $value;
Settings::set('console.allowed_ips', array_values($list));
}
$this->info("{$value} may now reach the console.");
return $this->show();
}
private function deny(string $value): int
{
Settings::set('console.allowed_ips', array_values(array_filter(
(array) Settings::get('console.allowed_ips', []),
fn ($entry) => $entry !== $value,
)));
$this->info("{$value} removed.");
return $this->show();
}
private function setRestricted(bool $on): int
{
// No lock-out check here on purpose: this command IS the recovery path,
// and it is only reachable by someone who already has the server.
Settings::set('console.network_restricted', $on);
$this->info($on ? 'Console restricted to the VPN and the listed addresses.' : 'Restriction lifted.');
return $this->show();
}
private function refuse(string $message): int
{
$this->error($message);
return self::FAILURE;
}
}

View File

@ -0,0 +1,84 @@
<?php
namespace App\Console\Commands;
use App\Models\Customer;
use App\Models\Operator;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rules\Password;
use Spatie\Permission\PermissionRegistrar;
/**
* Creates (or promotes) an operator with the Owner role the account the
* installer sets up so a fresh server is usable immediately.
*
* Promotion is deliberate: running this twice with the same address should fix
* a lost role, not fail with "email already taken".
*
* Renamed from clupilot:create-admin now that the account this creates is an
* Operator, not a User with is_admin set the old name stays reachable as an
* alias so existing install scripts and muscle memory keep working.
*/
class CreateAdmin extends Command
{
protected $signature = 'clupilot:create-operator
{--email= : Login address}
{--name= : Display name}
{--password= : Leave empty to be prompted}';
protected $aliases = ['clupilot:create-admin'];
protected $description = 'Create or promote an operator account with the Owner role';
public function handle(): int
{
$email = $this->option('email') ?: $this->ask('Email');
$name = $this->option('name') ?: $this->ask('Name', 'Administrator');
$password = $this->option('password') ?: $this->secret('Password');
$validator = Validator::make(
['email' => $email, 'name' => $name, 'password' => $password],
[
'email' => 'required|email|max:255',
'name' => 'required|string|max:255',
'password' => ['required', Password::min(12)],
],
);
if ($validator->fails()) {
foreach ($validator->errors()->all() as $error) {
$this->error($error);
}
return self::FAILURE;
}
// An operator address that also belongs to a customer would block that
// customer from ever getting a portal login. Checked directly against
// `users`, not only `customers`: see Customer::emailTaken().
if (Customer::emailTaken($email)) {
$this->error('That address already belongs to a customer.');
return self::FAILURE;
}
$operator = Operator::query()->firstOrNew(['email' => $email]);
$existed = $operator->exists;
$operator->fill([
'name' => $name,
'password' => Hash::make($password),
])->save();
$operator->syncRoles(['Owner']);
app(PermissionRegistrar::class)->forgetCachedPermissions();
$this->info($existed
? "Existing account {$email} promoted to Owner and password reset."
: "Owner account {$email} created.");
return self::SUCCESS;
}
}

View File

@ -0,0 +1,75 @@
<?php
namespace App\Console\Commands;
use App\Actions\BookAddon;
use App\Models\SubscriptionAddon;
use Illuminate\Console\Command;
use Throwable;
/**
* End the modules whose cancellation date has arrived.
*
* A module is cancelled monthly and the cancellation lands at the end of the
* term the customer has already paid for the same rule a package downgrade
* follows, and for the same reason: what has been paid for is kept. So
* BookAddon::cancelAtPeriodEnd() writes an appointment on the booking and leaves
* the module running. This keeps it.
*
* Stripe has already been told at the moment of cancellation; there is no item
* left to remove here. What ends now is the module itself the entitlement, and
* for a storage pack the space on the machine and the entry in the proof
* register that says the customer stopped paying for it.
*
* Safe to run as often as the scheduler likes: BookAddon::cancel() claims the
* cancellation conditionally, so a second tick over a module that has already
* ended writes nothing and records nothing.
*/
class EndCancelledAddons extends Command
{
protected $signature = 'clupilot:end-cancelled-addons
{--dry-run : list what would end and change nothing}';
protected $description = 'End the booked modules whose cancellation date has passed';
public function handle(BookAddon $bookAddon): int
{
$dryRun = (bool) $this->option('dry-run');
$ended = 0;
$due = SubscriptionAddon::query()
->whereNull('cancelled_at')
->whereNotNull('cancels_at')
->where('cancels_at', '<=', now())
->with('subscription')
->orderBy('id')
->get();
foreach ($due as $addon) {
$this->line(sprintf(
' %s on contract %s',
(string) $addon->addon_key,
(string) ($addon->subscription?->uuid ?? '—'),
));
if ($dryRun) {
continue;
}
try {
$bookAddon->cancel($addon);
$ended++;
} catch (Throwable $e) {
// One customer's module must not stop everybody else's from
// ending. It stays due and the next tick tries again.
$this->warn(" {$addon->uuid}: {$e->getMessage()}");
}
}
$this->info($dryRun
? "{$due->count()} module(s) due to end. Run without --dry-run to end them."
: "{$due->count()} module(s) due, {$ended} ended.");
return self::SUCCESS;
}
}

View File

@ -0,0 +1,74 @@
<?php
namespace App\Console\Commands;
use App\Actions\EndInstanceService;
use App\Models\Instance;
use Illuminate\Console\Command;
/**
* End the services whose cancellation date has arrived.
*
* ConfirmCancelPackage schedules an ending status `cancellation_scheduled`,
* `service_ends_at` at the close of the paid period and nothing in the
* application ever kept the appointment. That is why `TraefikWriter::remove()`
* had no caller: there was no moment at which an instance ended, so there was
* never a moment at which its router came down. Cancelled instances kept their
* hostname, their certificate and their route to a guest address the host may
* later hand to a different customer.
*
* This is the appointment. It ends nothing early: EndInstanceService::hasEnded()
* is the whole rule and a `cancellation_scheduled` instance whose date is still
* in the future is left exactly alone see that class for why that distinction
* is the important one here.
*
* Hourly. The end of a paid term is a moment, and a sweep that ran once a night
* would hand out up to a day of service nobody decided to give while running
* it every minute would mean a provider's bad five seconds became a failed job
* sixty times over. Nothing here is taken away that the customer did not
* themselves ask to give up, on a date they were shown, so there is no bad hour
* for it.
*/
class EndDueServices extends Command
{
protected $signature = 'clupilot:end-due-services
{--dry-run : list what would end and change nothing}';
protected $description = 'Withdraw the address of every instance whose cancellation date has passed';
public function handle(EndInstanceService $end): int
{
$dryRun = (bool) $this->option('dry-run');
$ended = 0;
// Narrowed in the query for the sake of the database, decided by
// hasEnded() for the sake of the rule: the query is an index, not a
// second opinion.
$due = Instance::query()
->with(['host', 'dnsRecords'])
->where('status', 'cancellation_scheduled')
->whereNotNull('service_ends_at')
->where('service_ends_at', '<=', now())
->cursor();
foreach ($due as $instance) {
if (! $end->hasEnded($instance)) {
continue;
}
$this->line(($dryRun ? '[dry-run] ' : '')
."{$instance->subdomain}: Laufzeit endete am "
.$instance->service_ends_at->local()->isoFormat('LLL'));
if ($dryRun || $end($instance)) {
$ended++;
}
}
$this->info($dryRun
? "Probelauf: {$ended} Dienst(e) würden beendet. Nichts wurde geändert."
: "{$ended} Dienst(e) beendet.");
return self::SUCCESS;
}
}

View File

@ -0,0 +1,29 @@
<?php
namespace App\Console\Commands;
use App\Services\Mail\IngestInboundMail;
use Illuminate\Console\Command;
/**
* Read the support mailbox into the console.
*
* Runs on the scheduler. A mail server has no way to tell us a message arrived
* IMAP is a mailbox you look in so this is polling, and the interval is the
* honest answer to "how long until I see it".
*/
class FetchInboundMail extends Command
{
protected $signature = 'clupilot:fetch-mail {--limit=50 : How many messages to take in one run}';
protected $description = 'Fetch new customer mail from the support mailbox';
public function handle(IngestInboundMail $ingest): int
{
$taken = $ingest((int) $this->option('limit'));
$this->info($taken === 0 ? 'Nothing new.' : $taken.' new message(s).');
return self::SUCCESS;
}
}

View File

@ -0,0 +1,38 @@
<?php
namespace App\Console\Commands;
use App\Models\ProxyHost;
use Illuminate\Console\Command;
/**
* Gibt die Hostnamen aus, die der Reverse Proxy bedienen soll eine je Zeile,
* mit ihrem Zweck.
*
* NUR die Liste, nicht die fertige Caddy-Konfiguration. Das ist der ganze Punkt.
*
* `install-agent.sh` begründet für die sudoers-Freigabe: „a grant is only worth
* anything if the holder cannot change what it grants." Dürfte diese Anwendung
* Caddy-Blöcke schreiben, die ein root-Helfer ungeprüft einbaut, hätte sie über
* den Umweg der Konfiguration alles, was der Proxy kann Weiterleitungen
* irgendwohin, Dateiauslieferung aus jedem Verzeichnis. Sie gibt deshalb NAMEN
* aus, und die Vorlage darum herum steht im root-eigenen Helfer, wo diese
* Anwendung sie nicht anfassen kann.
*
* Format je Zeile: `<hostname> <purpose>`.
*/
class ProxyHostList extends Command
{
protected $signature = 'clupilot:proxy-hosts';
protected $description = 'Lists the hostnames the reverse proxy should serve, one per line';
public function handle(): int
{
foreach (ProxyHost::query()->orderBy('hostname')->get() as $host) {
$this->line($host->hostname.' '.$host->purpose);
}
return self::SUCCESS;
}
}

View File

@ -0,0 +1,206 @@
<?php
namespace App\Console\Commands;
use App\Mail\DormantAccountWarningMail;
use App\Models\User;
use App\Models\UserDevice;
use Illuminate\Console\Command;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
use Throwable;
/**
* A confirmed account that has never had a package, removed after a year.
*
* The other sweep (PruneUnverifiedAccounts) is about registrations nobody
* confirmed and takes five days. This one is about the opposite case: somebody
* confirmed their address, looked around, never bought anything, and never came
* back. A year later there is a login, an address and a password hash on file
* for no purpose at all which is a data-protection question, not tidiness.
*
* ## What "no package" means, and why it is drawn this wide
*
* No instance, no contract, no order, no invoice ever, not merely "not now".
* A customer who cancelled last month has invoices, and invoices have to be kept
* for seven years (§ 132 BAO). Their login is part of how they reach those
* invoices, so nothing with commercial history is touched here. Both links
* between `users` and `customers` count, by id and by address, for the same
* reason as in the other sweep.
*
* ## The clock is reset by signing in
*
* Measured from the LATEST of the registration and the last time any device was
* seen (`user_devices.last_seen_at`) so a person who signs in once a year
* keeps their account, which is what the terms promise. There is no
* `last_login_at` on users, and the device rows already carry exactly this.
*
* ## Nobody is deleted without being told
*
* A warning goes out a fortnight before, once, and `dormant_warned_at` records
* it. An account is only removed when that warning is on file and old enough
* so a run that has never warned anybody deletes nobody, and a mail server that
* was down that night delays the deletion instead of skipping the warning.
*/
class PruneDormantAccounts extends Command
{
/** How long an account with no package is kept. */
public const AFTER_DAYS = 365;
/** How long before that the warning goes out. */
public const WARN_DAYS_BEFORE = 14;
protected $signature = 'clupilot:prune-dormant {--dry-run : Only say what would happen}';
protected $description = 'Warn, then delete confirmed accounts that never had a package';
public function handle(): int
{
$dry = (bool) $this->option('dry-run');
$warned = $this->sendWarnings($dry);
$removed = $this->remove($dry);
$this->info($dry
? "would warn {$warned}, would remove {$removed}."
: "{$warned} warned, {$removed} removed.");
return self::SUCCESS;
}
/**
* The fortnight's notice.
*
* Not called warn(): Command::warn() is the framework's "print a warning
* line" and overriding it with something private breaks the class outright.
*/
private function sendWarnings(bool $dry): int
{
$due = $this->dormant()
->whereNull('dormant_warned_at')
->get()
->filter(fn (User $user) => $this->dormantSince($user)
->lte(now()->subDays(self::AFTER_DAYS - self::WARN_DAYS_BEFORE)));
foreach ($due as $user) {
if ($dry) {
$this->line('would warn: '.$user->email);
continue;
}
try {
Mail::to($user->email)->queue(new DormantAccountWarningMail($user, self::WARN_DAYS_BEFORE));
} catch (Throwable $e) {
// Not stamped: the stamp is what permits the deletion, and
// stamping a mail that never went out would delete an account
// whose owner was never told.
Log::warning('Could not warn a dormant account', [
'email' => $user->email,
'exception' => $e->getMessage(),
]);
continue;
}
$user->forceFill(['dormant_warned_at' => now()])->save();
}
return $due->count();
}
/** The deletion itself, a fortnight after the warning at the earliest. */
private function remove(bool $dry): int
{
$due = $this->dormant()
->whereNotNull('dormant_warned_at')
->where('dormant_warned_at', '<=', now()->subDays(self::WARN_DAYS_BEFORE))
->get()
->filter(fn (User $user) => $this->dormantSince($user)
->lte(now()->subDays(self::AFTER_DAYS)));
foreach ($due as $user) {
if ($dry) {
$this->line('would remove: '.$user->email.' (dormant since '.$this->dormantSince($user)->toDateString().')');
continue;
}
// Logged individually rather than as a count: an address that
// disappears is the kind of thing somebody asks about later, and a
// number in a log answers nothing.
Log::info('Removing a dormant account', [
'email' => $user->email,
'registered_at' => $user->created_at?->toIso8601String(),
'warned_at' => $user->dormant_warned_at?->toIso8601String(),
]);
$user->delete();
}
return $due->count();
}
/**
* Confirmed, and with no customer record behind it at all.
*
* Drawn at the customer record rather than at "has no live package on
* record", and deliberately wider than it has to be:
*
* - A customer record means somebody in the business knows this person
* an operator entered them, or a checkout created them. Removing the
* login of a record we keep is half a deletion.
* - Anything commercial (an order, a contract, an instance, an invoice)
* hangs off a customer record, and invoices have to be kept for seven
* years (§ 132 BAO). Skipping every customer record skips all of that
* without having to enumerate it and without a new table sneaking past
* the enumeration later.
*
* What is left is exactly the case this exists for: somebody registered,
* confirmed their address, never bought anything, and never came back.
* Registration alone creates no customer record.
*
* Both links count, by id and by address see Customer::emailTaken for why
* one can exist without the other.
*
* @return \Illuminate\Database\Eloquent\Builder<User>
*/
private function dormant()
{
return User::query()
->whereNotNull('email_verified_at')
// Old enough to be worth looking at. The precise cut-off is applied
// per user afterwards, because it depends on the devices — and this
// one is <=, not <: a timestamp exactly at the threshold is stored to
// the second, and a stricter pre-filter would drop the very account
// the per-user check was about to catch.
->where('created_at', '<=', now()->subDays(self::AFTER_DAYS - self::WARN_DAYS_BEFORE))
->whereNotExists(fn ($q) => $q->selectRaw('1')->from('customers')
->whereColumn('customers.user_id', 'users.id'))
->whereNotExists(fn ($q) => $q->selectRaw('1')->from('customers')
->whereColumn('customers.email', 'users.email'));
}
/**
* Since when this account has been doing nothing.
*
* The later of the registration and the last time any of its devices was
* seen: signing in resets the clock, which is what the terms say.
*/
private function dormantSince(User $user): Carbon
{
// Scoped by guard as well as by id: operator 1 is not customer 1 (R21),
// and there is no devices() relation on User to hide that behind.
$lastSeen = UserDevice::query()
->where('guard', 'web')
->where('authenticatable_id', $user->id)
->max('last_seen_at');
$since = $user->created_at ?? now();
return $lastSeen === null
? $since
: Carbon::parse($lastSeen)->max($since);
}
}

View File

@ -0,0 +1,129 @@
<?php
namespace App\Console\Commands;
use App\Models\ExportTarget;
use Illuminate\Console\Command;
use Illuminate\Support\Carbon;
/**
* Empty a handover directory of folders nobody needs any more.
*
* Only where a destination asks for it keep_days is null by default, because
* a retention rule nobody configured is a deletion nobody expected.
*
* Safe in a way deleting usually is not: the invoice lives in the database as a
* frozen document and its PDF is rendered from that on demand, so anything
* removed here can be produced again and re-exported. The directory is a
* handover point, not the record.
*
* Only ever removes folders whose NAME is a date it can parse and that are old
* enough. Anything else in there a stray file, a directory somebody made by
* hand, a folder from a different layout is left alone. A prune that deletes
* what it does not recognise is how an archive loses something nobody was
* watching.
*/
class PruneExportFolders extends Command
{
protected $signature = 'clupilot:prune-exports {--dry-run}';
protected $description = 'Remove dated folders older than a destination keeps them';
public function handle(): int
{
$targets = ExportTarget::query()
->whereNotNull('keep_days')
->where('keep_days', '>', 0)
// Only where this machine can see the files. An SFTP destination is
// somebody else's disk with somebody else's retention on it.
->where('driver', ExportTarget::LOCAL)
->get();
foreach ($targets as $target) {
$this->prune($target);
}
return self::SUCCESS;
}
private function prune(ExportTarget $target): void
{
$root = rtrim($target->path, '/');
if (! is_dir($root)) {
// The mount is gone. Saying nothing and doing nothing is right:
// this is a cleanup, and a cleanup must never be the thing that
// reports a broken mount — nor act on whatever is underneath it.
$this->warn("{$target->name}: path is not there, skipping ({$root})");
return;
}
$cutoff = now()->subDays((int) $target->keep_days)->startOfDay();
$removed = 0;
foreach ((array) scandir($root) as $entry) {
if ($entry === '.' || $entry === '..' || ! is_dir($root.'/'.$entry)) {
continue;
}
$date = $this->dateFrom($entry);
// Not a dated folder: left alone, deliberately. A prune that
// deletes what it does not recognise is how an archive loses
// something nobody was watching.
if ($date === null || $date->gte($cutoff)) {
continue;
}
if ($this->option('dry-run')) {
$this->line("would remove {$root}/{$entry}");
$removed++;
continue;
}
$this->removeTree($root.'/'.$entry);
$removed++;
}
$this->line("{$target->name}: {$removed} folder(s) older than {$target->keep_days} days.");
}
/**
* Only the exact shapes this application writes.
*
* Matched with a pattern BEFORE parsing, not by asking Carbon and catching
* what it does with nonsense: under strict mode createFromFormat throws
* rather than returning false, so a folder somebody made by hand would end
* the whole prune instead of being skipped. The pattern also says plainly
* which two shapes are recognised, which is the thing a reader wants to
* know here.
*/
private function dateFrom(string $name): ?Carbon
{
if (preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $name, $m)) {
return Carbon::createFromFormat('Y-m-d', $name)?->startOfDay();
}
if (preg_match('/^\d{4}$/', $name)) {
return Carbon::createFromFormat('Y-m-d', $name.'-12-31')?->startOfDay();
}
return null;
}
private function removeTree(string $directory): void
{
foreach ((array) scandir($directory) as $entry) {
if ($entry === '.' || $entry === '..') {
continue;
}
$path = $directory.'/'.$entry;
is_dir($path) ? $this->removeTree($path) : @unlink($path);
}
@rmdir($directory);
}
}

View File

@ -0,0 +1,72 @@
<?php
namespace App\Console\Commands;
use App\Models\Host;
use App\Services\Dns\HetznerDnsClient;
use Illuminate\Console\Command;
use Throwable;
/**
* Cleans up host DNS records this app published in the PUBLIC Hetzner zone
* before RegisterHostDns moved to the internal vpn-dns hostsdir a host's
* WireGuard address in public DNS hands every scanner the internal subnet.
*
* The ids are whatever is still sitting in hosts.dns_record_id (PurgeHost
* already clears this column for any host removed normally; what remains
* belongs to hosts that are still active).
*
* Never deletes anything on its own: this runs against a live DNS account, a
* migration is the wrong place to make that call unattended, and a stale id
* could by now point at a record this app no longer owns. Without --force
* this only reports what it would remove.
*/
class PruneHostDns extends Command
{
protected $signature = 'clupilot:prune-host-dns {--force : Actually delete the records}';
protected $description = 'List (and, with --force, delete) public Hetzner DNS records this app published for a host';
public function handle(HetznerDnsClient $dns): int
{
$hosts = Host::query()->whereNotNull('dns_record_id')->orderBy('name')->get();
if ($hosts->isEmpty()) {
$this->info('No host carries a public dns_record_id. Nothing to do.');
return self::SUCCESS;
}
$force = (bool) $this->option('force');
$failed = 0;
foreach ($hosts as $host) {
$label = $host->name !== '' && $host->name !== null ? $host->name : $host->uuid;
$recordId = $host->dns_record_id;
if (! $force) {
$this->line(" would delete {$label} record={$recordId}");
continue;
}
try {
$dns->deleteRecord($recordId);
$host->update(['dns_record_id' => null]);
$this->info(" deleted {$label} record={$recordId}");
} catch (Throwable $e) {
$failed++;
$this->error(" failed {$label} record={$recordId}: {$e->getMessage()}");
}
}
if (! $force) {
$this->newLine();
$this->comment(sprintf('Dry run — %d record(s) found, nothing changed. Re-run with --force to delete.', $hosts->count()));
return self::SUCCESS;
}
return $failed === 0 ? self::SUCCESS : self::FAILURE;
}
}

View File

@ -0,0 +1,82 @@
<?php
namespace App\Console\Commands;
use App\Models\Customer;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
/**
* Registrations nobody ever confirmed, removed.
*
* An abandoned attempt used to stay in `users` for ever, and it holds the
* address hostage while it does: the unique index means the same person cannot
* register again with the address they meant. "Abmelden und neu registrieren"
* was therefore advice that could not work signing out frees nothing.
*
* Five days, because that is long enough for somebody who registered on a
* Friday, found the mail in a spam folder on Monday, and clicked it and short
* enough that a typo does not block the correct address for a month.
*
* What it will NOT touch, and this is the important half:
*
* - a confirmed account, whatever else is true of it;
* - an account with a customer record behind it. A paid checkout creates the
* login through the webhook, and such a user can sit unconfirmed while
* already having a contract. Deleting that is data loss, not housekeeping.
*/
class PruneUnverifiedAccounts extends Command
{
/** How long an unconfirmed registration is kept. */
public const AFTER_DAYS = 5;
protected $signature = 'clupilot:prune-unverified {--dry-run : Only say what would go}';
protected $description = 'Delete registrations that were never confirmed';
public function handle(): int
{
$stale = User::query()
->whereNull('email_verified_at')
->where('created_at', '<', now()->subDays(self::AFTER_DAYS))
// Not a join: the two tables are linked by user_id OR by address
// (see Customer::emailTaken — a row can predate or outlive the
// other), and either one means somebody is a customer.
->whereNotExists(fn ($q) => $q->selectRaw('1')->from('customers')
->whereColumn('customers.user_id', 'users.id'))
->whereNotExists(fn ($q) => $q->selectRaw('1')->from('customers')
->whereColumn('customers.email', 'users.email'))
->get();
if ($stale->isEmpty()) {
$this->info('Nothing to remove.');
return self::SUCCESS;
}
if ($this->option('dry-run')) {
foreach ($stale as $user) {
$this->line('would remove: '.$user->email.' (registered '.$user->created_at->toDateString().')');
}
return self::SUCCESS;
}
foreach ($stale as $user) {
// Logged individually rather than as a count: an address that
// disappears is the kind of thing somebody asks about later, and a
// number in a log answers nothing.
Log::info('Removing an unconfirmed registration', [
'email' => $user->email,
'registered_at' => $user->created_at?->toIso8601String(),
]);
$user->delete();
}
$this->info($stale->count().' unconfirmed registration(s) removed.');
return self::SUCCESS;
}
}

View File

@ -0,0 +1,85 @@
<?php
namespace App\Console\Commands;
use App\Models\DpaVersion;
use App\Services\Legal\DpaRenderer;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Storage;
/**
* Render the processing agreement and its annex, store them, put them in force.
*
* The documents are generated from the repository (resources/views/legal/dpa),
* so a change to the wording is a change somebody can review in a diff and the
* company name, the sub-processors and the deletion deadlines come from the same
* places the rest of the application reads them, which is what stops the
* agreement drifting away from what the software does.
*
* The console's upload form still exists and is unchanged: a lawyer's revision
* arrives as a PDF and becomes a version like any other. This command is for the
* version this application maintains itself.
*/
class PublishProcessingAgreement extends Command
{
protected $signature = 'clupilot:publish-dpa
{version : What the document calls itself, e.g. 1.0}
{--draft : Store it without putting it in force}';
protected $description = 'Render the processing agreement and its annex, and put them in force';
public function handle(DpaRenderer $renderer): int
{
$version = (string) $this->argument('version');
if (DpaVersion::query()->where('version', $version)->exists()) {
$this->error("Version {$version} already exists. Pick another name — two documents under one name makes every acceptance ambiguous.");
return self::FAILURE;
}
$agreementPath = 'dpa/av-vertrag-'.$this->slug($version).'.pdf';
$measuresPath = 'dpa/tom-'.$this->slug($version).'.pdf';
// Visibility "public" is about the FILE MODE, not about the web: this
// disk's root is outside the document root and nothing here becomes
// reachable by URL. What it changes is 0755/0644 instead of 0700/0600 —
// and that is the difference between a document the web process can read
// and one it cannot.
//
// It matters because this command is normally run as root (an artisan
// call in a container is root; PHP-FPM is www-data). The first run left
// a directory only root could enter, the route's exists() check answered
// false, and every link on the page 404'd with nothing in any log to say
// why.
Storage::disk('local')->put($agreementPath, $renderer->agreement($version), 'public');
Storage::disk('local')->put($measuresPath, $renderer->measures($version), 'public');
$row = DpaVersion::query()->create([
'version' => $version,
'agreement_path' => $agreementPath,
'measures_path' => $measuresPath,
// Publishing is the point of the command; --draft is for looking at
// it first. Every customer who accepted an earlier version is
// outstanding again from this moment.
'published_at' => $this->option('draft') ? null : now(),
]);
foreach ([$agreementPath, $measuresPath] as $path) {
if (Storage::disk('local')->getVisibility($path) !== 'public') {
$this->warn("{$path} is not readable by the web process. Run this as the user PHP runs as, or fix the mode — the page will 404 otherwise.");
}
}
$this->info(($this->option('draft') ? 'Drafted' : 'Published').' version '.$row->version.'.');
$this->line(' '.$agreementPath.' ('.number_format(strlen(Storage::disk('local')->get($agreementPath)) / 1024, 0).' KB)');
$this->line(' '.$measuresPath.' ('.number_format(strlen(Storage::disk('local')->get($measuresPath)) / 1024, 0).' KB)');
return self::SUCCESS;
}
private function slug(string $version): string
{
return preg_replace('/[^a-z0-9.-]+/i', '-', $version) ?: 'version';
}
}

View File

@ -0,0 +1,152 @@
<?php
namespace App\Console\Commands;
use App\Actions\MoveStripeSubscriptionPrice;
use App\Actions\SyncStripeAddonItems;
use App\Models\Subscription;
use App\Services\Billing\PlanPrices;
use App\Services\Stripe\StripeClient;
use Illuminate\Console\Command;
/**
* Move contracts that are still billing on a superseded Stripe Price.
*
* stripe:sync-catalogue mints the Price that charges today's figure and archives
* the one it replaces and that is all it does. Archiving a Price in Stripe
* stops it being SOLD; every subscription already on it goes on being billed at
* the old amount for ever. This is the second half, and it is a command of its
* own because it touches live contracts and money, which is a decision an owner
* makes deliberately rather than a side effect of syncing a price list.
*
* Run it after every stripe:sync-catalogue that reported a change:
*
* php artisan stripe:sync-catalogue --dry-run
* php artisan stripe:sync-catalogue
* php artisan stripe:reprice-subscriptions --dry-run
* php artisan stripe:reprice-subscriptions
*
* **Nothing is prorated.** The customer has already paid for the term they are
* in, at whatever was taken at the time; charging the difference for days already
* served would be a bill nobody agreed to, and Stripe's own default would raise
* exactly that. From the next cycle the right amount is taken.
*
* **Safe to re-run.** Both halves ask whether the contract is already on the
* right Price and do nothing when it is, so a second run is free and a run
* interrupted halfway finishes on the next attempt. The package half never
* throws MoveStripeSubscriptionPrice parks its failures on the contract and
* clupilot:sync-stripe-subscriptions retries them hourly.
*
* **Two reasons a contract moves, not one.** The first is a change of FIGURE, as
* above. The second is a change of CUSTOMER: a business whose VAT id is verified
* the week after they bought must be billed the net Price from then on, because
* reverse charge means they owe no VAT to us at all and one whose registration
* has lapsed must go back onto the gross one. Neither is a change of package, so
* nothing else in the system would ever notice, and both converge here on the next
* run. PlanPrices::liveForSubscription() answers "which Price does this contract
* belong on?" for both, which is why this command has no resolver of its own: two
* places working that out is how they come to disagree.
*
* The contract's own `price_cents` is not touched by any of this. It is the
* catalogue's NET figure, it is frozen, and PlanChange prorates against it.
*/
class RepriceStripeSubscriptions extends Command
{
protected $signature = 'stripe:reprice-subscriptions
{--dry-run : Show which contracts would move without touching Stripe}
{--limit=500 : How many contracts to look at in one run}';
protected $description = 'Move live contracts onto the Stripe prices the catalogue now sells';
public function handle(
StripeClient $stripe,
MoveStripeSubscriptionPrice $move,
SyncStripeAddonItems $modules,
PlanPrices $prices,
): int {
$dryRun = (bool) $this->option('dry-run');
if (! $dryRun && ! $stripe->isConfigured()) {
$this->error('Stripe is not configured (STRIPE_SECRET is empty). Nothing was moved.');
return self::FAILURE;
}
// Active contracts Stripe actually bills. A cancelled one is not billed
// again, and a granted one has no Stripe subscription at all — moving
// either would be housekeeping on something that charges nobody.
$contracts = Subscription::query()
->whereNotNull('stripe_subscription_id')
->where('status', 'active')
->orderBy('id')
->limit((int) $this->option('limit'))
->get();
$packages = 0;
$items = 0;
$failed = 0;
foreach ($contracts as $contract) {
// Whether the catalogue prices this contract's package at all, asked
// separately from WHICH Price it should be on. A version that was never
// priced is a contract this command has nothing to say about; one that
// is priced but has no Stripe Price for this customer's treatment is a
// contract that must move, and the move is what mints it.
$priced = $prices->rowFor($contract) !== null;
$target = $priced ? $prices->liveForSubscription($contract) : null;
if ($priced && $contract->stripe_price_id !== $target) {
$this->line(sprintf(
' package %s %s → %s',
$contract->uuid,
$contract->stripe_price_id ?? '(unknown)',
// Null where the catalogue has no Price for what this customer
// should be charged. Said out loud rather than skipped: the move
// below parks the reason on the contract and the run warns about
// it, and the cure is one command.
$target ?? '(not synced)',
));
$packages++;
// PRORATE_NONE: the term in progress is paid for. See the class
// comment — this must never raise a charge for days already
// served at the old figure.
if (! $dryRun && ! $move($contract, StripeClient::PRORATE_NONE)) {
$failed++;
}
}
foreach ($modules->reprice($contract, $dryRun) as $item) {
$this->line(sprintf(
' module %s %s %s → %s',
$contract->uuid,
$item['addon'],
$item['from'] ?? '(none)',
$item['to'] ?? '(to be created)',
));
$items++;
}
}
$this->newLine();
if ($packages === 0 && $items === 0) {
$this->info('Every live contract is already on the price the catalogue sells.');
return self::SUCCESS;
}
$this->info($dryRun
? "{$packages} package(s) and {$items} module item(s) would move. Run without --dry-run to move them."
: "{$packages} package(s) and {$items} module item(s) moved.");
if ($failed > 0) {
// Parked on the contract by the action and retried hourly by
// clupilot:sync-stripe-subscriptions, so this is a warning and not a
// failure — but somebody watching the output has to be told.
$this->warn("{$failed} package move(s) did not reach Stripe and are parked for the hourly retry.");
}
return self::SUCCESS;
}
}

View File

@ -0,0 +1,78 @@
<?php
namespace App\Console\Commands;
use App\Models\StatusDay;
use App\Services\Status\ServiceHealth;
use Illuminate\Console\Command;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
/**
* Writes one sample of every public component into today's row.
*
* The ninety-day bar on the status page is the one figure a reader checks
* against their own memory, so it has to come from something that was actually
* recorded at the time. There is no way to reconstruct it afterwards: the
* monitoring table holds the LAST verdict per instance, not a history, and by
* the time anybody asks about last Tuesday that verdict is about today.
*
* Runs on the scheduler beside the monitoring sync it depends on. A missed run
* costs one sample out of roughly three hundred in a day; a missed DAY leaves
* no row at all, and the page draws that as "not recorded" rather than
* inventing a colour for it.
*/
class SampleServiceStatus extends Command
{
protected $signature = 'clupilot:sample-status';
protected $description = 'Record the current state of every public status component into the daily history';
public function handle(ServiceHealth $health): int
{
// The wall clock, not UTC: the bar is labelled with days a reader
// recognises, and a sample taken at 01:30 local belongs to the day they
// would call it (R19).
$day = Carbon::now()->local()->toDateString();
foreach ($health->components() as $component) {
$state = $component['state'];
// One statement, so two schedulers racing on the same minute cannot
// read-modify-write over each other. The counters are increments,
// never assignments — a sample is a thing that happened, and a
// later run must not be able to undo it.
DB::table('status_days')->upsert(
[[
'day' => $day,
'component' => $component['key'],
'samples' => 1,
'operational' => $state === 'operational' ? 1 : 0,
'degraded' => $state === 'degraded' ? 1 : 0,
'down' => $state === 'down' ? 1 : 0,
'unknown' => $state === 'unknown' ? 1 : 0,
'created_at' => now(),
'updated_at' => now(),
]],
['day', 'component'],
[
'samples' => DB::raw('samples + 1'),
'operational' => DB::raw('operational + '.($state === 'operational' ? 1 : 0)),
'degraded' => DB::raw('degraded + '.($state === 'degraded' ? 1 : 0)),
'down' => DB::raw('down + '.($state === 'down' ? 1 : 0)),
'unknown' => DB::raw('unknown + '.($state === 'unknown' ? 1 : 0)),
'updated_at' => DB::raw('CURRENT_TIMESTAMP'),
],
);
}
// Beyond the window the page can draw, the rows are only weight. Kept a
// fortnight longer than the ninety days shown so a change of window
// does not immediately hit an empty table.
StatusDay::query()
->where('day', '<', Carbon::now()->local()->subDays(104)->toDateString())
->delete();
return self::SUCCESS;
}
}

View File

@ -0,0 +1,156 @@
<?php
namespace App\Console\Commands;
use App\Models\PlanFamily;
use App\Models\StripeAddonPrice;
use App\Models\StripePlanPrice;
use App\Services\Stripe\StripeClient;
use Illuminate\Console\Command;
/**
* The Prices at our Products that no row of ours knows.
*
* App\Services\Billing\AdoptStripePrice takes over an orphan it can prove is
* ours. What it will not take over a Price carrying none of our metadata, or
* one that charges in a way we never mint it names in the log and leaves,
* because adopting a stranger's Price is worse than minting a second one. That
* was the right call and it left an operator with a log line and no way to act
* on it.
*
* **What this lists is wider than that log line.** Every active RECURRING Price
* at one of our Products that no row of ours knows adoptable or not. The only
* question asked below is whether a register row holds the id; nothing here
* re-asks AdoptStripePrice whether it would have taken the Price over.
*
* Recurring, because activePricesFor() sends `type: recurring` as well as
* `active: true`. That filter can hide somebody else's one-time Price from the
* list; it can never hide one of ours. createPrice() always sends
* `recurring[interval]`, and it is the only call that mints a Price at one of
* our Products at all the setup fee is priced inline at the checkout, under an
* ad-hoc Product of its own. So the filter errs toward listing too little, never
* toward missing an orphan of ours.
*
* **So stripe:sync-catalogue runs first.** Until it has, an orphan listed here
* may be one the next sync would simply have adopted, and --archive on that one
* is worse than it looks. Archiving it makes it invisible to the very step that
* would have rescued it: activePricesFor() asks Stripe for ACTIVE prices only,
* so adoption finds nothing and the sync falls through to createPrice() under
* the same idempotency key the crashed run used, because the key is built from
* what is being sold and everything the call puts on the wire (see
* App\Services\Stripe\IdempotencyKey), and nothing about either has changed. For
* twenty-four hours Stripe answers a repeated key by replaying the response it
* stored rather than doing anything: the archived Price's id, in the body it had
* while it was still live. The register writes that id down as the live Price
* for the row, and a checkout on it is refused.
*
* Without --archive this reports and touches nothing. With it, the orphans are
* archived at Stripe, which is harmless for everything THIS codebase sells, for
* the reason it always is here: Stripe goes on billing every subscription
* already on an archived Price, it only stops being SOLD and nothing this
* codebase sells is sold on a Price no row knows. That reasoning reaches no
* further than this codebase. A Price an operator wired up by hand a
* SUBSCRIPTION payment link built in Stripe's own dashboard against one of our
* Products is exactly the Price carrying none of our metadata described above,
* and --archive withdraws it too. A one-time link is the other side of the
* recurring filter: neither listed nor archived, and so neither protected nor
* disturbed by this command.
*/
class SweepOrphanStripePrices extends Command
{
protected $signature = 'stripe:sweep-orphan-prices
{--archive : Stop selling the orphans instead of only listing them}
{--dry-run : Show what would be archived without touching Stripe}';
protected $description = 'List the Stripe prices at our products that no row knows, and optionally archive them — run stripe:sync-catalogue first';
public function handle(StripeClient $stripe): int
{
$dryRun = (bool) $this->option('dry-run');
$archive = (bool) $this->option('archive');
// Checked on EVERY path, unlike stripe:sync-catalogue's --dry-run,
// which touches nothing because everything it compares is already in
// our own tables. This command has no local-only mode: the report —
// the whole point of it, dry-run or not — is activePricesFor(), a
// live call. Skipping this check under --dry-run would not skip the
// call, only the graceful error in front of it.
if (! $stripe->isConfigured()) {
$this->error('Stripe is not configured (STRIPE_SECRET is empty). Nothing was read.');
return self::FAILURE;
}
$known = StripeAddonPrice::query()->pluck('stripe_price_id')
->merge(StripePlanPrice::query()->pluck('stripe_price_id'))
->filter()
->flip();
$found = 0;
foreach ($this->products() as $productId) {
foreach ($stripe->activePricesFor($productId) as $price) {
if ($known->has($price['id'])) {
continue;
}
$found++;
$this->line(sprintf(
' orphan %s %d %s %s product %s %s',
$price['id'],
$price['unit_amount'],
$price['currency'],
$price['interval'],
$productId,
$price['metadata'] === [] ? 'no metadata' : json_encode($price['metadata']),
));
if ($archive && ! $dryRun) {
$stripe->archivePrice($price['id']);
}
}
}
$this->newLine();
if ($found === 0) {
$this->info('Every active price at our products is accounted for.');
return self::SUCCESS;
}
// Said on every path that found something, including after --archive
// has already run: the only question asked above is whether a row holds
// the id, so an orphan in this list may be one stripe:sync-catalogue
// would have adopted. Archiving that one is the failure this class's
// docblock describes, and an operator who has just done it needs to
// know as much as one who is about to.
$this->warn(' Run stripe:sync-catalogue first — an orphan listed here may be one the next sync would adopt.');
$this->info(match (true) {
$dryRun && $archive => "{$found} orphan(s) would be archived. Run without --dry-run to archive them.",
$archive => "{$found} orphan(s) archived. Existing subscriptions on them keep billing.",
default => "{$found} orphan(s) found. Once the sync has run, --archive stops selling what is still listed.",
});
return self::SUCCESS;
}
/**
* Every Stripe Product this platform owns: one per plan family, one per
* module. Read from our own rows rather than from Stripe, because a Product
* we do not know is not one whose Prices we can judge.
*
* @return array<int, string>
*/
private function products(): array
{
return PlanFamily::query()->whereNotNull('stripe_product_id')->pluck('stripe_product_id')
->merge(StripeAddonPrice::query()->pluck('stripe_product_id'))
->filter()
->unique()
->values()
->all();
}
}

View File

@ -0,0 +1,428 @@
<?php
namespace App\Console\Commands;
use App\Models\PlanFamily;
use App\Models\PlanPrice;
use App\Models\PlanVersion;
use App\Models\Subscription;
use App\Services\Billing\AddonCatalogue;
use App\Services\Billing\AddonPrices;
use App\Services\Billing\AdoptStripePrice;
use App\Services\Billing\AdoptStripeProduct;
use App\Services\Billing\PlanPrices;
use App\Services\Billing\TaxTreatment;
use App\Services\Stripe\StripeClient;
use App\Support\OperatingMode;
use App\Support\StripeCatalogueMode;
use Illuminate\Console\Command;
/**
* Mirrors our catalogue into Stripe: a Product per plan family, a Price per
* priced row and the same for every module we sell.
*
* Stripe owns the recurring billing retries, dunning, off-session SCA,
* invoice numbering so it needs to know what it is billing for. It does not
* need to know how big the VM is, and it is not asked.
*
* **It pushes TWO Prices for everything it sells.** The catalogue's `amount_cents`
* is net and stays net it is frozen onto every contract and PlanChange prorates
* against it and what Stripe is asked to take from it depends on who is buying:
*
* - the DOMESTIC GROSS, which is the figure on the website and what everybody
* who is charged VAT pays, private and business alike. Pushing the net was the
* original defect: a customer quoted 214,80 paid 179,00 and was then
* invoiced 179,00 plus 35,80 VAT that nobody had collected;
* - the BARE NET, for a business in another member state whose VAT id is
* verified. Reverse charge means no VAT is owed to us at all, so the net is
* the whole of what they owe. Charging them the gross was a flat 20 % surcharge
* with no VAT line on the document for them to reclaim.
*
* Which of the two a checkout uses is TaxTreatment's decision see
* App\Services\Billing\PlanPrices, which owns the plan half of the mirror, and
* App\Services\Billing\AddonPrices, which owns the module half.
*
* Stripe's `automatic_tax` is deliberately NOT used. TaxTreatment is the single
* tax authority here; a second rate computed by Stripe would charge a German
* consumer 19 % while our document said 20 %.
*
* Idempotent by construction, and safe to re-run after a rate change: a row
* whose live Price already charges the right figure is skipped, and one that
* does not gets a NEW Price with the old one archived. A rate change moves the
* gross Price of each pair and leaves the net one untouched, because the net does
* not depend on the rate. That matters more here than usual, because a Stripe
* Price cannot be edited, so a run that minted duplicates would leave two live
* prices for one plan at one figure and no way to tell which a customer is on.
*
* It does not move anybody. Archiving a Price stops it being SOLD and leaves
* every subscription already on it exactly where it was moving those is
* stripe:reprice-subscriptions, which is a separate decision because it touches
* live contracts.
*
* Only PUBLISHED versions are synced. A draft has promised nothing, and a
* Product for it would be a price list entry for something that may never
* exist.
*
* **A run belongs to ONE Stripe account, and it says which.** Products and
* Prices are account-bound, and the operating mode picks the key so the mode
* a run worked in is recorded (App\Support\StripeCatalogueMode) and a run into
* a catalogue that belongs to the other account is refused rather than allowed
* to skip its way to "already in step". Without that, switching to live left
* every stored id pointing at test objects while the readiness page went on
* reporting a synced catalogue, and the first real order met "No such price".
*
* **Modules were missing from this entirely**, which is why a booked module was
* charged in the month it was booked and never again: nothing in Stripe existed
* to put on the subscription. They are pushed at today's catalogue price on both
* terms a Stripe Price carries its own interval, so a module on a yearly
* contract needs a yearly one. What this run cannot cover is a customer
* grandfathered on an older figure or holding a discounted grant; those mint
* their own Price at the moment they are billed. See App\Services\Billing\AddonPrices.
*/
class SyncStripeCatalogue extends Command
{
protected $signature = 'stripe:sync-catalogue {--dry-run : Show what would be created without touching Stripe}';
protected $description = 'Create the Stripe products and prices for the plan catalogue';
public function handle(StripeClient $stripe): int
{
$dryRun = (bool) $this->option('dry-run');
if (! $dryRun && ! $stripe->isConfigured()) {
$this->error('Stripe is not configured (STRIPE_SECRET is empty). Nothing was created.');
return self::FAILURE;
}
if (StripeCatalogueMode::hasStoredObjects() && StripeCatalogueMode::belongsToAnotherMode()) {
$this->error($this->staleCatalogueMessage());
// Auch der Trockenlauf endet hier, und mit FAILURE: er zählt, was
// FEHLT, und in diesem Zustand fehlt nichts — jede Zeile trägt
// schon eine ID, nur eine aus dem falschen Konto. „0 object(s)
// would be created" wäre die beruhigende Antwort auf die
// gefährliche Lage.
return self::FAILURE;
}
// VOR der Anlegeschleife, nicht danach. Hier ist bereits bewiesen, dass
// entweder nichts liegt oder das Vorhandene dem aktiven Konto gehört —
// der Zeitpunkt trägt die Aussage also genauso gut. Am Ende von
// handle() trug er sie NICHT: createProduct() und ensure() werfen
// ungefangen, und ein Lauf, der nach dem ersten angelegten Objekt
// stirbt, hinterließ IDs ohne Herkunft. Der nächste Lauf hielt das für
// ein fremdes Konto und trug dem Betreiber auf, einen Katalog zu leeren,
// an dem laufende Verträge abgerechnet werden — statt schlicht
// wiederaufzusetzen, wofür die Idempotenzschlüssel unten gebaut sind.
if (! $dryRun) {
if (StripeCatalogueMode::recorded() === null && StripeCatalogueMode::hasStoredObjects()) {
$this->line(' note taking up a catalogue whose account was never recorded; nothing is replaced.');
}
StripeCatalogueMode::record();
}
// Beide Seiten setzen hier an, und beide werden gebraucht.
// Die Modus-Sperre oben steht zuerst, weil sie VERWEIGERT: sie darf
// nicht hinter etwas laufen, das schon Zustand aufgebaut hat. Die
// Zähler darunter müssen nur vor der Anlegeschleife stehen, und das
// tun sie hier auch.
// Resolved once, as singletons: each counts across the WHOLE run, and a
// count taken before ensure()/the adoption call runs can never itself
// distinguish "created" from "adopted" — see AdoptStripePrice.
$adoptPrices = app(AdoptStripePrice::class);
$adoptProducts = app(AdoptStripeProduct::class);
$adoptedPricesBefore = $adoptPrices->adoptions;
// Duplicates accumulate on the SAME singleton across the whole process,
// not only this run — without this, a second handle() in one process
// would print a duplicate a previous run already reported.
$duplicatesBefore = count($adoptProducts->duplicates);
$created = 0;
// Counted here, locally, rather than on AdoptStripeProduct itself: the
// very same class also adopts a MODULE's Product, from
// AddonPrices::product() inside syncModules() below, and a module's
// Product is never counted as an intent at all — unlike a family's,
// which gets its own "product …" line and $created++ right where this
// is incremented. A single counter on the class could not tell the two
// apart; counting only at the one call site that also counts the
// intent avoids the question entirely.
$adoptedFamilyProducts = 0;
foreach (PlanFamily::query()->with('versions.prices')->orderBy('tier')->get() as $family) {
$published = $family->versions->filter(fn (PlanVersion $version) => $version->isPublished());
// A family whose versions are all drafts has promised nothing, so
// it has no business appearing in Stripe's price list yet.
if ($published->isEmpty()) {
continue;
}
$productId = $family->stripe_product_id;
if ($productId === null) {
$this->line(" product {$family->key}{$family->name}");
$created++;
if (! $dryRun) {
$metadata = ['plan_family' => $family->key, 'plan_family_id' => (string) $family->id];
// Asked BEFORE minting, for the same reason the Price side
// asks: a run that died between Stripe's create and our
// update left a Product this row does not know, and the key
// below stops protecting it after twenty-four hours.
// plan_family_id is what proves such a Product is this
// family's.
$productId = $adoptProducts($metadata, ['plan_family_id']);
if ($productId !== null) {
// The intent was counted three lines up, so — unlike a
// module's Product — an adoption here may honestly be
// subtracted back out of it below.
$adoptedFamilyProducts++;
}
$productId ??= $stripe->createProduct(
$family->name,
$metadata,
// Covers a RETRY, for twenty-four hours, and nothing
// beyond them. What stops a second Product for one
// family is the adoption step above.
idempotencyKey: "clupilot-product-{$family->id}",
);
$family->update(['stripe_product_id' => $productId]);
}
}
foreach ($published as $version) {
foreach ($version->prices as $price) {
$created += $this->syncPrice($family, $version, $price, $productId, $dryRun);
}
}
}
$created += $this->syncModules($dryRun);
$this->newLine();
// $adopted may only subtract what $created actually counted as an
// intent. Every price is one, family or module — syncPrice() and
// syncModules() both increment $created before knowing whether ensure()
// will mint or adopt, so the full price-adoption delta is honest. A
// family's Product is one too, counted above, so $adoptedFamilyProducts
// is honest for the same reason. A module's Product is NOT one — it
// prints no line of its own and increments nothing in syncModules(),
// however AdoptStripeProduct answers it — so its adoption stays out of
// both figures entirely. Subtracting it would report fewer objects
// created than Stripe actually gained.
$adopted = ($adoptPrices->adoptions - $adoptedPricesBefore) + $adoptedFamilyProducts;
$minted = max(0, $created - $adopted);
// Sliced to what THIS run added — the list lives on the same
// process-wide singleton as the count above, so without the same
// before/after care a second handle() in one process would print a
// duplicate a previous run already reported.
foreach (array_slice($adoptProducts->duplicates, $duplicatesBefore) as $duplicate) {
// Named here as well as in the log: an operator running the sweep
// reads this, and a second Product for one family is something only
// a person can resolve — we deliberately do not deactivate it.
$this->warn(" duplicate product {$duplicate} — left active, a product's prices become unsellable if it is deactivated");
}
if ($created === 0) {
$this->info('Stripe is already in step with the catalogue.');
return self::SUCCESS;
}
// "or adopted" on the dry run, and only there. A dry run counts intents
// without ever calling ensure(), so it cannot know which of them Stripe
// already holds a Price for — and a dry run is the first thing an
// operator runs after an interrupted one, which is exactly when some of
// these will be adopted rather than minted. The live line below has
// both figures because by then the adoption step has answered.
$this->info($dryRun
? "{$created} object(s) would be created or adopted. Run without --dry-run to create them."
: "{$minted} object(s) created, {$adopted} adopted in Stripe.");
return self::SUCCESS;
}
/**
* Why a run is refused outright rather than left to skip its way through.
*
* A Stripe Product and a Stripe Price belong to the ACCOUNT that issued
* them, and a test account and a live account are two accounts. This
* command skips every row that already carries an id (PlanPrices::inStep()
* asks the register, not Stripe), so a run after the switch would report
* "already in step" and change nothing while recording that the
* catalogue now belongs to the account it never touched.
*
* The registers have to go too, not only the two columns: inStep() takes
* a registered row as proof on its own for the reverse-charge half, so a
* cleared pointer with the register left behind would leave exactly that
* half pointing at the old account, and nothing would say so.
*
* Only reached where the OTHER account is established fact. A catalogue
* whose account was never recorded is a different state with a different
* cure see StripeCatalogueMode::belongsToAnotherMode(). Telling that
* operator to clear anything would be telling them to delete a catalogue
* their live contracts bill on.
*/
private function staleCatalogueMessage(): string
{
return sprintf(
'The stored catalogue was created in the [%s] account; this installation is now in [%s] mode. '
.'Stripe objects belong to the account that issued them, and this command skips every row that '
.'already carries an id — so it cannot repair this by running again. Clear '
.'plan_families.stripe_product_id, plan_prices.stripe_price_id and the stripe_plan_prices / '
.'stripe_addon_prices registers first, then run it. Nothing was created.',
StripeCatalogueMode::recorded()?->value,
OperatingMode::current()->value,
);
}
/**
* The Stripe Prices one priced catalogue row is sold on, brought into step.
*
* Both treatments, always: the domestic gross AND the bare net a
* reverse-charge business is charged. Unconditionally, rather than only when
* such a customer exists the Price has to be there BEFORE the checkout that
* needs it, and a verified business meeting a refused checkout is worse than
* an unused Price sitting in Stripe.
*
* What "in step" means, and the find-or-mint-or-replace that follows from it,
* belongs to PlanPrices: it is the same question the checkout and the plan
* swap ask, and asking it in two places is how they come to disagree. All
* this adds is the reporting, because an operator running a sync has to be
* able to see which half of which pair moved.
*
* Archiving stops a Price being offered and leaves every subscription on it
* untouched, which is exactly what is wanted: those are moved deliberately,
* by stripe:reprice-subscriptions.
*
* @return int how many objects this row changed, for the run's own count
*/
private function syncPrice(
PlanFamily $family,
PlanVersion $version,
PlanPrice $price,
?string $productId,
bool $dryRun,
): int {
$prices = app(PlanPrices::class);
$changed = 0;
foreach ($this->treatments() as $label => $treatment) {
if ($prices->inStep($price, $treatment)) {
continue;
}
$this->line(sprintf(
' price %s v%d %s %s %d %s net → %d %s charged',
$family->key, $version->version, $price->term, $label,
$price->amount_cents, $price->currency,
PlanPrices::chargedCents($price, $treatment), $price->currency,
));
$changed++;
// Nothing to create against: the Product for this family is minted
// by the caller, and on a dry run it does not exist yet either.
if ($dryRun || $productId === null) {
continue;
}
$prices->ensure($price, $treatment);
}
return $changed;
}
/**
* A Product and four Prices for every module on sale: monthly and yearly,
* each at the domestic gross and at the bare net.
*
* Both terms and both treatments, unconditionally, rather than only the ones
* somebody has bought a contract on: the Price has to exist BEFORE the booking
* that needs it, and a customer on a yearly package booking their first module
* would otherwise discover the gap at the moment their money was due.
*
* @return int how many objects were created, for the run's own count
*/
private function syncModules(bool $dryRun): int
{
$catalogue = app(AddonCatalogue::class);
$prices = app(AddonPrices::class);
$currency = Subscription::catalogueCurrency();
$created = 0;
$keys = array_merge(array_keys((array) config('provisioning.addons')), [AddonCatalogue::STORAGE]);
foreach ($keys as $key) {
$monthly = $catalogue->priceCents($key);
// Nothing to bill and nothing to mirror. A module priced at zero is
// either a placeholder or included, and an item at no money would
// put a line saying so on every invoice a customer ever gets.
if ($monthly === null || $monthly <= 0) {
continue;
}
foreach ([Subscription::TERM_MONTHLY, Subscription::TERM_YEARLY] as $term) {
foreach ($this->treatments() as $label => $treatment) {
// Asked of the CHARGED figure, so a rate change is seen as a
// Price that has to be replaced rather than as one that is
// already there.
if ($prices->liveFor($key, $monthly, $currency, $term, $treatment) !== null) {
continue;
}
$this->line(sprintf(
' module %s %s %s %d %s net → %d %s charged',
$key, $term, $label,
AddonPrices::termNetCents($monthly, $term), $currency,
AddonPrices::chargedCents($monthly, $term, $treatment), $currency,
));
$created++;
if ($dryRun) {
continue;
}
$prices->ensure($key, $monthly, $currency, $term, $treatment);
}
}
}
return $created;
}
/**
* The two customers everything on sale is mirrored for, labelled for the
* output so an operator reading a run can see which of a pair moved.
*
* Built from TaxTreatment rather than from a rate written down here: it is the
* single tax authority, and a second list of treatments is how the catalogue
* would come to sell a figure no invoice agrees with.
*
* @return array<string, TaxTreatment>
*/
private function treatments(): array
{
return [
'domestic' => TaxTreatment::domestic(),
'reverse-charge' => TaxTreatment::reverseCharge(),
];
}
/** Versions whose prices are live in Stripe, for the status line. */
public static function syncedVersions(): int
{
return PlanVersion::query()
->whereNotNull('published_at')
->whereHas('prices', fn ($q) => $q->whereNotNull('stripe_price_id'))
->count();
}
}

View File

@ -0,0 +1,137 @@
<?php
namespace App\Console\Commands;
use App\Actions\MoveStripeSubscriptionPrice;
use App\Actions\SyncStripeAddonItems;
use App\Models\Subscription;
use App\Models\SubscriptionAddon;
use Illuminate\Console\Command;
/**
* The sweep behind the plan change and behind the module booking.
*
* Moving Stripe onto the new price at the moment the change lands is right and
* it is not enough. Stripe can be unreachable, the key can have been rotated,
* the catalogue can be a sync behind and the change has already happened to
* the contract and to the machine by then, so it is not rolled back. What is
* left is a customer being charged for a package they are no longer on, and
* nothing about the contract would ever say so again.
*
* ApplyPlanChange parks exactly that on the contract. This picks it up, however
* long ago it happened.
*
* A module booking is the same failure wearing different clothes: the pack is
* already a bigger disk, the entitlement is already switched on, and if Stripe
* never heard about it the customer holds a module nobody is billing them for.
* So it is swept from here too rather than from a second command one sweep,
* one schedule entry, one place to look when Stripe has been away.
*
* Deliberately hands the work back to the actions rather than doing it here:
* they own which price, which item and which proration behaviour, and two code
* paths that both move a subscription is one more than the number that can be
* kept in agreement.
*/
class SyncStripeSubscriptions extends Command
{
protected $signature = 'clupilot:sync-stripe-subscriptions {--limit=100}';
protected $description = 'Retry the plan changes and module bookings that landed here but never reached Stripe';
public function handle(MoveStripeSubscriptionPrice $move, SyncStripeAddonItems $modules): int
{
$status = $this->sweepPlans($move);
$this->sweepModules($modules);
return $status;
}
private function sweepPlans(MoveStripeSubscriptionPrice $move): int
{
// Active contracts only. A cancelled one is not billed again, so moving
// it onto another price would be housekeeping on something Stripe has
// already finished with — and an upgrade's proration raised against a
// contract that has ended would charge a departed customer.
$parked = Subscription::query()
->whereNotNull('stripe_price_sync')
->whereNotNull('stripe_subscription_id')
->where('status', 'active')
->orderBy('id')
->limit((int) $this->option('limit'))
->get();
$moved = 0;
foreach ($parked as $subscription) {
if ($move->retry($subscription)) {
$moved++;
continue;
}
// The action has already logged why and counted the attempt. Said
// again here because somebody is watching this command's output
// and not the log.
$this->warn(sprintf(
'Contract %s: still billed at the old price — %s',
$subscription->uuid,
(string) ($subscription->fresh()?->stripe_price_sync['error'] ?? 'unknown'),
));
}
$this->info("{$parked->count()} contract(s) out of step with Stripe, {$moved} moved.");
return self::SUCCESS;
}
/**
* Contracts whose modules Stripe is not billing.
*
* Found from the BOOKINGS rather than from a parked marker, because the
* bookings are the authority: a module still running with no item id was
* never put on the subscription, and one that has stopped while still
* carrying an item id is being billed after it ended. `stripe_addon_sync` is
* the record of what went wrong and how often it is not where the work
* lives, so a contract whose failure was never written down (a worker killed
* mid-booking) is still found here.
*/
private function sweepModules(SyncStripeAddonItems $modules): void
{
$outstanding = SubscriptionAddon::query()
->where(fn ($q) => $q
->where(fn ($active) => $active->whereNull('cancelled_at')->whereNull('cancels_at')->whereNull('stripe_item_id'))
->orWhere(fn ($gone) => $gone->whereNotNull('stripe_item_id')
->where(fn ($ended) => $ended->whereNotNull('cancelled_at')->orWhereNotNull('cancels_at'))))
->select('subscription_id')
->distinct();
// Active contracts that Stripe actually bills. A granted contract has no
// Stripe subscription at all and its bookings will never carry an item
// id — sweeping those would retry, and log, something that is not wrong.
$contracts = Subscription::query()
->whereIn('id', $outstanding)
->whereNotNull('stripe_subscription_id')
->where('status', 'active')
->orderBy('id')
->limit((int) $this->option('limit'))
->get();
$settled = 0;
foreach ($contracts as $subscription) {
if ($modules($subscription)) {
$settled++;
continue;
}
$this->warn(sprintf(
'Contract %s: modules not billed by Stripe — %s',
$subscription->uuid,
(string) ($subscription->fresh()?->stripe_addon_sync['error'] ?? 'unknown'),
));
}
$this->info("{$contracts->count()} contract(s) with modules out of step, {$settled} settled.");
}
}

View File

@ -0,0 +1,101 @@
<?php
namespace App\Console\Commands;
use App\Actions\ReapplyInstanceAddress;
use App\Models\Instance;
use App\Services\Domains\DomainVerifier;
use Illuminate\Console\Command;
/**
* Re-reads every custom domain's proof, nightly.
*
* Asked for in exactly these words: the token must be checked again and again,
* so that nobody puts it in, passes the check, and takes it straight back out.
* At night, because a domain that is withdrawn is a domain that stops being
* served, and that is not a thing to do to somebody at eleven in the morning.
*
* Three consecutive misses before a verified domain is withdrawn. One failed
* lookup is a nameserver having a bad minute; taking a working Nextcloud
* offline over a hiccup would be far worse than the hole it closes.
*/
class VerifyCustomDomains extends Command
{
protected $signature = 'clupilot:verify-domains {--instance= : one instance uuid, for checking on demand}';
protected $description = 'Re-read the DNS proof for every custom domain and withdraw the ones that lost it';
public function handle(DomainVerifier $verifier, ReapplyInstanceAddress $reapply): int
{
$query = Instance::query()->whereNotNull('custom_domain')->whereNotNull('domain_token');
if ($uuid = $this->option('instance')) {
$query->where('uuid', $uuid);
}
$checked = 0;
$withdrawn = 0;
foreach ($query->cursor() as $instance) {
$checked++;
$present = $verifier->proofPresent($instance);
// What was true before this check, so the CHANGE can be acted on
// rather than the state. Re-applying an address is remote work on a
// live machine; doing it nightly for every domain that is simply
// still fine would be a hundred pointless runs a night.
$wasVerified = $instance->domainIsVerified();
if ($present) {
$instance->forceFill([
'domain_verified_at' => $instance->domain_verified_at ?? now(),
'domain_checked_at' => now(),
'domain_error' => null,
'domain_failures' => 0,
])->save();
// A domain that has just become provable is a domain nothing is
// serving yet: the router was written without it and Nextcloud
// does not trust it. This is where it becomes an address.
if (! $wasVerified) {
$reapply($instance);
}
continue;
}
$failures = $instance->domain_failures + 1;
// Withdrawal clears verified_at, which is the single flag every
// consumer reads. Nothing else is deleted: the domain and its token
// stay on the row, so the customer sees what is wrong and can put
// the record back rather than starting over.
$withdraw = $instance->domain_verified_at !== null
&& $failures >= DomainVerifier::FAILURES_BEFORE_WITHDRAWAL;
$instance->forceFill([
'domain_checked_at' => now(),
'domain_error' => 'missing_txt',
'domain_failures' => $failures,
'domain_verified_at' => $withdraw ? null : $instance->domain_verified_at,
'domain_cert_ok' => $withdraw ? false : $instance->domain_cert_ok,
])->save();
if ($withdraw) {
$withdrawn++;
// The other half of the flip, and the one that actually takes
// the domain away: the router stops carrying it and Nextcloud
// stops trusting it. Clearing verified_at alone only stops us
// TELLING people about it.
$reapply($instance);
$this->warn("Withdrew {$instance->custom_domain}: proof missing on {$failures} consecutive checks.");
}
}
$this->info("Checked {$checked} domain(s), withdrew {$withdrawn}.");
return self::SUCCESS;
}
}

View File

@ -0,0 +1,118 @@
<?php
namespace App\Console\Commands;
use App\Actions\VerifyCustomerVatId;
use App\Models\Customer;
use Illuminate\Console\Command;
/**
* Re-ask the register about the VAT numbers we are relying on.
*
* A number is not verified once and for ever. A company is wound up, a
* registration is withdrawn, a member state removes it and from that moment we
* are zero-rating an invoice against a number that no longer exists, which is our
* liability and not the customer's. So the ones reverse charge actually rests on
* are asked again.
*
* It also picks up the numbers nobody has checked yet: every number entered
* before this check existed is unverified, so those customers are on the domestic
* rate today whether or not they are entitled to reverse charge.
*
* ## Scheduled monthly, and why that is not the owner's decision to inherit
*
* This used to say it was deliberately NOT scheduled, and to point at a note in
* routes/console.php that did not exist so an owner reading the schedule could
* not learn the command was there at all, and reverse charge went on resting on a
* one-off answer for ever.
*
* It is scheduled now, because the exposure is the SELLER's. A registration that
* is withdrawn keeps earning rate 0 on every invoice we issue afterwards, and the
* unpaid VAT on those is ours to make good, not the customer's so the cadence is
* not a preference but the width of the window in which that can happen unnoticed.
* Monthly, on the first, because that is the rhythm the VAT return is filed on: a
* number that lapsed in March is caught before the March return is prepared.
*
* The public service's concurrency limit is answered by `--limit` rather than by
* not running: the query takes the longest-unchecked first, so a customer base
* larger than one run rotates through it instead of starving. `--dry-run` and
* `--customer` are for the by-hand run before a return.
*/
class VerifyVatIds extends Command
{
protected $signature = 'clupilot:verify-vat-ids
{--dry-run : Show what would be asked without touching the register or any record}
{--customer= : One customer, by uuid}
{--limit=200 : How many to look at in one run}';
protected $description = 'Check customer VAT numbers against the EU VIES register';
public function handle(VerifyCustomerVatId $verify): int
{
$customers = Customer::query()
->whereNotNull('vat_id')
->where('vat_id', '!=', '')
->when($this->option('customer'), fn ($q, $uuid) => $q->where('uuid', $uuid))
// Longest unchecked first, so a short run covers the ones we know
// least about rather than the ones checked yesterday. A plain
// ascending sort does that on its own — never-checked rows are NULL
// and both engines sort those first — which is why there is no raw
// SQL here: MariaDB and SQLite would need different expressions for
// it, and a query that only runs on one of them makes the test
// database a different product from the live one.
->orderBy('vat_id_verified_at')
->limit((int) $this->option('limit'))
->get();
if ($customers->isEmpty()) {
$this->info('No VAT numbers on record.');
return self::SUCCESS;
}
$counts = ['verified' => 0, 'rejected' => 0, 'unchecked' => 0];
foreach ($customers as $customer) {
if ($this->option('dry-run')) {
$this->line(sprintf('would ask: %s (%s)', $customer->normalisedVatId(), $customer->name));
continue;
}
$check = $verify($customer);
match (true) {
$check === null => null,
$check->isValid() => $counts['verified']++,
$check->isInvalid() => $counts['rejected']++,
default => $counts['unchecked']++,
};
if ($check?->isInvalid()) {
// Named individually, because this one needs a person: an invoice
// already issued at 0 % against a number the register now rejects
// has to be looked at, and a count in a summary hides that.
$this->warn(sprintf(
'not registered: %s (%s) — reverse charge no longer applies',
$customer->normalisedVatId(),
$customer->name,
));
}
}
if ($this->option('dry-run')) {
$this->info($customers->count().' number(s) would be asked about.');
return self::SUCCESS;
}
$this->info(sprintf(
'%d confirmed, %d rejected, %d could not be checked (register unavailable — nothing changed for those).',
$counts['verified'],
$counts['rejected'],
$counts['unchecked'],
));
return self::SUCCESS;
}
}

View File

@ -0,0 +1,46 @@
<?php
namespace App\Console;
use App\Models\ProvisioningRun;
use App\Provisioning\Jobs\AdvanceRunJob;
/**
* Scheduler tick (every minute): dispatch an advance job for every run that is
* due. Immediate dispatch on advance handles the fast path; this catches
* waiting/retrying runs and anything a crashed worker left behind.
*
* PENDING counts as left behind. A run is created and dispatched in two steps,
* and a process that dies between them leaves a paid customer with a run that
* nothing will ever pick up. Re-dispatching one that is merely fresh is free
* the runner takes a per-run lock and the second job returns immediately.
*/
class TickProvisioning
{
/**
* How long a run may sit pending before it counts as stranded.
*
* Long enough that an ordinary queue backlog is not mistaken for a crash:
* sweeping a run whose first job is merely still queued would start a
* second chain of continuations alongside the first, and both would keep
* dispatching for the rest of the pipeline.
*/
private const STRANDED_AFTER_MINUTES = 5;
public function __invoke(): void
{
ProvisioningRun::query()
->where(function ($q) {
$q
->whereIn('status', [ProvisioningRun::STATUS_RUNNING, ProvisioningRun::STATUS_WAITING])
->orWhere(fn ($stranded) => $stranded
->where('status', ProvisioningRun::STATUS_PENDING)
->where('created_at', '<=', now()->subMinutes(self::STRANDED_AFTER_MINUTES)));
})
->where(function ($q) {
$q->whereNull('next_attempt_at')->orWhere('next_attempt_at', '<=', now());
})
->get()
->each(fn (ProvisioningRun $run) => AdvanceRunJob::dispatch($run->uuid));
}
}

View File

@ -0,0 +1,21 @@
<?php
namespace App\Exceptions;
use RuntimeException;
/**
* Refused: this address already belongs to the other identity table.
*
* R21 operators and customers are two groups of people, never one row that
* could pass for both. Keyed on the address itself, not on who happens to be
* signed in when the check runs (see the deleted Customer::assertNotAdmin(),
* which checked the caller's session and mis-fired for exactly that reason).
*/
class IdentityCollisionException extends RuntimeException
{
public function __construct(public readonly string $email)
{
parent::__construct("Refusing to create or link a portal login for {$email}: that address already belongs to an operator (R21).");
}
}

View File

@ -0,0 +1,20 @@
<?php
namespace App\Exceptions;
use RuntimeException;
/**
* Der aktive Betriebsmodus hat keinen Stripe-Schlüssel.
*
* Eine eigene Klasse, damit Aufrufer und Tests genau diesen Fall greifen können
* statt „irgendetwas ging schief" — und damit die Kasse ihn in einen Satz
* übersetzen kann, den ein Kunde lesen kann.
*/
class StripeNotConfigured extends RuntimeException
{
public static function forMode(string $mode): self
{
return new self("No Stripe secret is stored for the [{$mode}] operating mode.");
}
}

View File

@ -0,0 +1,133 @@
<?php
namespace App\Http\Controllers;
use App\Support\HostEnrolment;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\Process\Process;
/**
* Liefert das Bootstrap-Skript als Archiv aus.
*
* Das ist der einzige Weg, auf dem das Skript auf eine nackte Maschine kommt:
* ein Rettungssystem hat nichts als curl. Es ist eine STATISCHE Datei und kein
* Endpunkt, der Auskunft gibt Spec §5 bleibt damit unangetastet. Es steht
* nichts Vertrauliches darin; alle Geheimnisse reist der Betreiber in der
* Befehlszeile mit, nicht im Archiv.
*
* Als Archiv und nicht als eine Datei, weil `curl … | sh` keine `lib/` haben
* kann und der Skript-Plan getrennte Dateien verlangt: `network.sh` und
* `template.sh` sollen einzeln nachlesbar und wiederholbar bleiben, wenn auf
* einer Maschine um drei Uhr morgens etwas klemmt.
*
* Gebaut wird beim ersten Abruf und danach nur, wenn sich unter `deploy/bootstrap`
* etwas geändert hat. Ein Archiv, das im Repo läge, wäre die Fassung von dem
* Tag, an dem jemand zuletzt daran gedacht hat, es neu zu bauen.
*/
class BootstrapArchiveController extends Controller
{
public function __invoke(Request $request): BinaryFileResponse
{
// Der Ausweis ist der Einmal-Code, der ohnehin schon in der kopierten
// Zeile steht. Kein zweites Geheimnis: ein eigener Download-Token liefe
// nie ab, stünde für immer in Shell-Historien, und er reiste in
// derselben Zeile wie der WireGuard-Schlüssel — er schützte also das
// Unwichtigste mit derselben Aussetzung wie das Wichtigste.
//
// `resolve()` und nicht `claim()`: der Code wird hier NICHT verbraucht.
// Er wird nach dem Herunterladen noch für jede Fortschrittsmeldung und
// zuletzt für die Registrierung gebraucht.
//
// 404 und nicht 403, dieselbe Regel wie bei der Konsole: ein Fremder
// soll nicht erfahren, dass es hier etwas gibt.
$code = (string) ($request->query('code') ?? $request->header('X-CluPilot-Code', ''));
if (HostEnrolment::resolve($code) === null) {
abort(404);
}
$archive = storage_path('app/bootstrap.tar.gz');
if ($this->isStale($archive)) {
$this->build($archive);
}
return response()
->file($archive, [
'Content-Type' => 'application/gzip',
'Content-Disposition' => 'attachment; filename="bootstrap.tar.gz"',
// Nicht in den Index. Die Adresse ist ohne Code wertlos, aber
// eine Suchmaschine, die sie kennt, verrät immerhin, dass es
// sie gibt.
'X-Robots-Tag' => 'noindex, nofollow',
]);
}
/**
* Neuer als alles, was drinsteckt?
*
* Über die Änderungszeit und nicht über einen Hash: das Verzeichnis ist
* klein, der Vergleich läuft bei jedem Abruf, und ein Hash über alle Dateien
* kostete bei jedem Abruf mehr als das Packen gelegentlich kostet.
*/
private function isStale(string $archive): bool
{
if (! is_file($archive)) {
return true;
}
$built = filemtime($archive);
foreach ($this->sourceFiles() as $file) {
if (filemtime($file) > $built) {
return true;
}
}
return false;
}
/** @return list<string> */
private function sourceFiles(): array
{
$root = base_path('deploy/bootstrap');
if (! is_dir($root)) {
return [];
}
$files = [];
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($root, \FilesystemIterator::SKIP_DOTS)
);
foreach ($iterator as $file) {
if ($file->isFile()) {
$files[] = $file->getPathname();
}
}
return $files;
}
/**
* Mit GNU tar und nicht mit PharData: `phar.readonly` steht in diesem
* Container auf 1, und das Ausführbar-Bit soll erhalten bleiben.
*
* Der oberste Eintrag heißt `bootstrap/`, damit die kopierte Befehlszeile
* mit `tar xz -C /opt/clupilot` genau `/opt/clupilot/bootstrap` ergibt
* der Ort, an dem das Skript sich selbst und seine Bibliothek erwartet.
*/
private function build(string $archive): void
{
@mkdir(dirname($archive), 0755, true);
$process = new Process(
['tar', 'czf', $archive, '-C', base_path('deploy'), 'bootstrap'],
timeout: 60,
);
$process->mustRun();
}
}

View File

@ -0,0 +1,209 @@
<?php
namespace App\Http\Controllers;
use App\Models\Customer;
use App\Models\Subscription;
use App\Services\Billing\PlanCatalogue;
use App\Services\Billing\PlanPrices;
use App\Services\Billing\SetupFee;
use App\Services\Billing\TaxTreatment;
use App\Services\Provisioning\HostCapacity;
use App\Services\Stripe\StripeClient;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Throwable;
/**
* Buying a package, without anyone having to write an email about it.
*
* The site used to end every path in "anfragen": a mailto: link and a promise
* to answer the same working day. That is a person's afternoon per customer,
* for a product whose entire point is that the machine does the work and it
* scales exactly as far as the operator's inbox does.
*
* So: a signed-in customer picks a package, pays, and provisioning starts by
* itself. Where no host has room the order is parked rather than refused (see
* ReserveResources) that is the ONE case that still needs a human, and it
* needs them to buy a server, not to answer a mail.
*
* This class does not take money and does not create a contract. It opens a
* Stripe hosted checkout and gets out of the way; the purchase becomes real
* when Stripe says so, on the webhook, which is the only place that may believe
* a payment happened. A "thank you" page proves nothing the customer can open
* it by typing the URL.
*/
class CheckoutController extends Controller
{
/**
* Send the customer to Stripe for one package.
*
* Everything here is re-derived from the catalogue: the plan key arrives
* from a form, and a form field is a string somebody can retype. The price
* charged is the one the catalogue holds for the version on sale right now,
* never a figure carried in the request.
*/
public function start(
Request $request,
StripeClient $stripe,
PlanCatalogue $catalogue,
PlanPrices $planPrices,
): RedirectResponse {
$data = $request->validate([
'plan' => ['required', 'string', 'max:64'],
'term' => ['nullable', 'in:'.Subscription::TERM_MONTHLY.','.Subscription::TERM_YEARLY],
// Acceptance of the terms, which is where everything about this
// sale is regulated: the delivery, the price, the fourteen-day
// withdrawal right, and the consumer's express request that we
// begin inside that window (FAGG §16, §356 BGB) — a cloud is built
// the moment the payment lands, so that request cannot be avoided
// and is stated on the checkbox as well as in the terms.
//
// Required. An order cannot be submitted without it: the box is the
// declaration, and the browser only disables the button — this is
// the line that actually refuses.
'terms_accepted' => ['accepted'],
], [
'terms_accepted.accepted' => __('checkout.terms_required'),
]);
$term = $data['term'] ?? Subscription::TERM_MONTHLY;
$user = $request->user();
// Read once and handed to everything below that has to know who is
// buying. Which of a package's two Stripe Prices this session uses is
// decided from it, and a second lookup is a second chance to decide it
// differently.
$customer = $user === null
? null
: Customer::query()->where('email', $user->email)->first();
// Somebody who already has a running contract is not buying a second
// one by accident: changing package is a plan change, which prorates
// and keeps their data where it is. A second checkout would build them
// a second, empty cloud and bill for both.
if ($this->hasLiveContract($customer)) {
return redirect()->route('billing')->with('status', __('checkout.already_customer'));
}
if (! $stripe->isConfigured()) {
// Nothing to send them to. Better a plain sentence than a redirect
// into an error page on somebody else's domain.
Log::error('Checkout attempted while Stripe is not configured.');
return back()->withErrors(['plan' => __('checkout.unavailable')]);
}
try {
$version = $catalogue->currentVersion($data['plan']);
$price = $version->prices->firstWhere('term', $term);
} catch (Throwable $e) {
// An unknown, withdrawn or overlapping plan. The catalogue throws
// by design; a shop must not guess which of two versions was meant.
Log::warning('Checkout for an unavailable plan', ['plan' => $data['plan'], 'exception' => $e]);
return back()->withErrors(['plan' => __('checkout.plan_gone')]);
}
// WHO is buying decides WHICH of the package's two Stripe Prices this
// session uses: the domestic gross for everybody who is charged VAT, the
// bare net for a business in another member state whose VAT id is
// verified, because reverse charge means no VAT is owed to us at all.
// Decided by TaxTreatment and read from PlanPrices — nothing about the
// customer is examined here, or there would be two answers to one
// question and no way to tell which an invoice was written against.
$treatment = TaxTreatment::for($customer);
$priceId = $price === null ? null : $planPrices->liveFor($price, $treatment);
// A Price Stripe has never been told about. Sending the customer on would
// open a checkout for nothing — and for a reverse-charge business, refusing
// is the only safe answer even though a Price does exist: the domestic one
// would take a fifth more than they owe, which is the overcharge this whole
// rule exists to end. A refused checkout is loud, one command cures it, and
// the log line names the command.
if ($priceId === null) {
Log::error('Checkout for a plan that is not on sale in Stripe. Run stripe:sync-catalogue.', [
'plan' => $data['plan'],
'term' => $term,
'reverse_charge' => $treatment->reverseCharge,
]);
return back()->withErrors(['plan' => __('checkout.plan_gone')]);
}
// The one-off setup fee as this customer is charged it, or null where the
// operator has set none. Advertised on the price sheet and on the booking
// page for months and charged to nobody; it rides along as a second,
// non-recurring line on this session, which Stripe puts on the initial
// invoice only. Subject to the same rule as the package: a reverse-charge
// business pays the bare net of it.
$setup = SetupFee::checkoutLine((string) $price->currency, $treatment);
try {
$url = $stripe->createCheckoutSession(
priceId: $priceId,
successUrl: route('checkout.done'),
cancelUrl: route('order'),
// Exactly the keys StripeWebhookController reads back. The
// VERSION goes along, so the contract is opened on what the
// customer was shown even if a new version is published while
// they are typing their card number.
metadata: [
'plan' => $version->family->key,
'plan_version_id' => (string) $version->id,
'datacenter' => app(HostCapacity::class)->preferredDatacenter(),
// Exactly '1', because StripeWebhookController compares
// against exactly that: a consent that can be produced by a
// typo is not a consent. It travels through Stripe rather
// than being stamped here — the contract only exists once
// the payment does, and a consent recorded for a checkout
// somebody abandoned would be a consent to nothing.
//
// The KEY keeps its old name deliberately. The form field it
// came from is called terms_accepted now, but a checkout
// session opened before a deploy is paid after it, and the
// webhook would find nothing under a new name. What it
// records is unchanged: the customer declared, at this
// moment, that we may start at once.
'immediate_start' => '1',
// What part of Stripe's amount_total is the setup fee, so the
// webhook can tell the two apart again: the package's charge
// is what the register holds against the contract price, and
// the fee is a line of its own on the first invoice.
'setup_fee_cents' => (string) ($setup['amount_cents'] ?? 0),
],
customerEmail: $user?->email,
oneOff: $setup,
);
} catch (Throwable $e) {
Log::error('Stripe would not open a checkout session', ['exception' => $e]);
return back()->withErrors(['plan' => __('checkout.unavailable')]);
}
return redirect()->away($url);
}
/**
* Where Stripe sends them back to.
*
* Deliberately says "we are building it", not "you have paid": this page is
* a redirect target, and a redirect target is a URL anybody can type. What
* the customer actually sees underneath is the provisioning card on their
* dashboard, which is driven by a run that only the webhook can start.
*/
public function done(): RedirectResponse
{
return redirect()->route('dashboard')->with('status', __('checkout.thanks'));
}
/** Does this customer already have a contract that is being billed? */
private function hasLiveContract(?Customer $customer): bool
{
return $customer !== null && Subscription::query()
->where('customer_id', $customer->id)
->whereIn('status', ['active', 'past_due'])
->exists();
}
}

View File

@ -2,7 +2,9 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
abstract class Controller abstract class Controller
{ {
// use AuthorizesRequests;
} }

View File

@ -0,0 +1,116 @@
<?php
namespace App\Http\Controllers;
use App\Exceptions\IdentityCollisionException;
use App\Models\Customer;
use App\Models\Operator;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\URL;
/**
* Looking at a customer's portal as that customer.
*
* Not a shared cookie. Once the console has a hostname of its own, a session
* cookie set on the console host never reaches the portal host SESSION_DOMAIN
* is null and cookies are host-bound. So the handover is a signed, single-use
* link: it works regardless of cookie scope, it expires, and it leaves a record
* of who looked at whom.
*/
class ImpersonationController extends Controller
{
/** Sixty seconds is a handover, not a credential. */
private const WINDOW = 60;
public function start(Customer $customer): RedirectResponse
{
$this->authorize('customers.impersonate');
$operator = Auth::guard('operator')->user();
// impersonate.enter carries no domain of its own — a portal route,
// reached wherever the portal already answers. Its SIGNATURE is
// computed over the full absolute URL: UrlGenerator::signedRoute()
// hashes the generated URL string, host included, and
// hasCorrectSignature() re-hashes $request->url() — the ACTUAL request
// URL — on the way back. Left alone, "the generated URL" means
// whichever host issued THIS request: the console's, once it has an
// exclusive hostname of its own. RestrictAdminHost then 404s every
// non-console route reached through the console's exclusive host,
// including this one — so the link would die before the customer ever
// saw it, the exact cross-host failure this controller exists to fix.
//
// Forced to the portal's own configured root instead, derived from
// config('app.url') rather than hardcoded, and restored immediately
// after: nothing else generated later in this request should inherit
// the override.
$portalRoot = rtrim((string) config('app.url'), '/');
URL::forceRootUrl($portalRoot);
try {
$signed = URL::temporarySignedRoute(
'impersonate.enter',
now()->addSeconds(self::WINDOW),
// Addressed by CUSTOMER uuid, not user: `users` has no uuid
// column, and naming the integer key in a URL is what R11
// forbids. The portal end resolves the account the same way
// this end would have.
['customer' => $customer->uuid, 'operator' => $operator->uuid],
);
} finally {
URL::forceRootUrl(null);
}
return redirect()->away($signed);
}
/** The portal end of the handover. Signed, and good exactly once. */
public function enter(Request $request, string $customer, string $operator): RedirectResponse
{
abort_unless($request->hasValidSignature(), 403);
// Single use: the signature's own hash is the key, and it lives exactly
// as long as the link could. Redis, not the database — the marker is as
// short-lived as the link and should not need tidying up.
$marker = 'impersonate:'.hash('sha256', (string) $request->query('signature'));
abort_unless(Cache::add($marker, true, self::WINDOW), 403);
// Stale data, not a normal outcome: a customer row sharing its email
// with an operator should never have been possible to reach this far
// (see Customer::ensureUser() and CreateNewUser, R21) — refuse rather
// than hand the operator's own address a portal session.
try {
$target = Customer::where('uuid', $customer)->firstOrFail()->ensureUser();
} catch (IdentityCollisionException) {
abort(409);
}
$who = Operator::where('uuid', $operator)->firstOrFail();
Auth::guard('web')->login($target);
session(['impersonator_id' => $who->id]);
return redirect()->route('dashboard');
}
/** Back out of the customer's portal. The operator session was never touched. */
public function leave(): RedirectResponse
{
session()->forget('impersonator_id');
Auth::guard('web')->logout();
// By NAME, not AdminArea::home(): this route is reached on the
// PORTAL's host, and home() is a bare path ('/' in exclusive mode) —
// resolved against whatever host the request is already on, which is
// the portal's, not the console's. admin.overview is domain-bound to
// the console in exclusive mode, so route() generates the correct
// cross-host, absolute URL back to it; in shared mode it resolves
// against the current host same as home() would, so nothing changes
// there.
return redirect()->route('admin.overview');
}
}

View File

@ -0,0 +1,647 @@
<?php
namespace App\Http\Controllers;
use App\Models\Subscription;
use App\Services\Billing\AddonCatalogue;
use App\Services\Billing\CustomDomainAccess;
use App\Services\Billing\PlanCatalogue;
use App\Services\Billing\SetupFee;
use App\Services\Billing\TaxTreatment;
use App\Services\Provisioning\HostCapacity;
use App\Support\CompanyProfile;
use App\Support\ProvisioningSettings;
use Illuminate\Contracts\View\View;
use Illuminate\Support\Facades\Log;
use Throwable;
/**
* The public price sheet.
*
* Prices, storage and seat counts are read from the catalogue rather than
* written into the page. They were hard-coded here once, and the page and the
* catalogue had already drifted apart on three of four plans a visitor was
* quoted 249 for a plan that charged 399 at checkout. The marketing site is
* a reader of the catalogue like every other caller.
*
* The catalogue fails loudly by design: an empty or overlapping catalogue is an
* outage for commerce, not something to paper over. But a public website is not
* commerce a mistyped availability window must not take the company's front
* page down with it. So the failure is caught HERE and only here: the page
* still renders, the price sheet is replaced by "on request", and nobody is
* shown a number the checkout would not honour.
*
* The sheet is three blocks, not one matrix. A single grid of ticks was being
* asked to carry three different kinds of sentence at once, and it made all
* three unreadable: what every package has (a tick straight down every column,
* which is no information at all), what tells the packages apart, and what
* costs extra. Worse, two of the columns were lying by construction support
* was modelled as two independent booleans, so the top plan rendered a dash
* where the middle one had a tick, and the platform address was modelled as if
* an own domain replaced it, when provisioning issues both. Each block now
* makes one kind of statement: BASELINE what everyone gets, the comparison
* table what differs, ADDONS what is for sale on top.
*/
class LandingController extends Controller
{
/**
* Catalogue feature keys, in the words a customer uses.
*
* Deliberately separate from the operator-facing labels in
* lang/*\/billing.php's `feature` array, which name the same keys for the
* console's checkboxes. The two are different registers for different
* readers "Protokollierung der Zugriffe" here versus "Audit-Log" there
* and features are catalogue-wide, not per-plan, so there is nowhere on a
* plan family or version to hang an editable customer-facing label
* without re-introducing a shared feature table. That table
* (`plan_features`) existed once and was deliberately removed in the
* catalogue rebuild that closed the pricing split-brain; bringing it back
* for wording alone is a bigger, separate decision.
*/
private const FEATURES = [
'managed_updates' => 'Updates & Wartung',
'daily_backups' => 'Tägliche Sicherung',
'monitoring' => 'Überwachung rund um die Uhr',
// Filled in from the configured zone by zone(), reached through
// label(), because a hard-coded domain here is the same
// two-sources-of-truth mistake as a hard-coded price — and it shipped
// naming clupilot.cloud, a domain the company does not own, while
// provisioning was issuing addresses under whatever CLUPILOT_DNS_ZONE
// said.
'subdomain' => 'Adresse auf :zone',
'custom_domain' => 'Eigene Domain',
'office' => 'Office im Browser',
'branding' => 'Ihr Logo & Ihre Farben',
'priority_support' => 'Bevorzugter Support',
'premium_sla' => 'Vereinbarte Reaktionszeiten',
'extended_retention' => 'Verlängerte Aufbewahrung',
'audit_log' => 'Protokollierung der Zugriffe',
'onboarding' => 'Begleitete Einführung',
];
/**
* What every package carries, said once above the table.
*
* Each of these was a row of ticks running straight down all four columns
* a comparison that compares nothing and the same promises were then
* repeated in a prose line under the table. `key` names the catalogue
* feature the claim stands on: it keeps the row out of the comparison
* table, and, unless the claim is `issued`, the whole item is dropped
* should a plan ever stop carrying it. The page must not promise for
* "jedes Paket" what one package does not have.
*
* The last two have no key because the catalogue does not model them. They
* are the promises the prose line under the table made, kept word for word
* rather than reworded on the way in.
*/
private const BASELINE = [
[
'icon' => 'refresh',
'label' => 'Updates & Wartung',
'detail' => 'Betriebssystem und Anwendung, eingespielt außerhalb Ihrer Arbeitszeit.',
'key' => 'managed_updates',
],
[
'icon' => 'database',
'label' => 'Tägliche verschlüsselte Sicherung',
'detail' => 'Mit monatlichem Wiederherstellungstest, protokolliert.',
'key' => 'daily_backups',
],
[
'icon' => 'activity',
'label' => 'Überwachung rund um die Uhr',
'detail' => 'Erreichbarkeit, Speicher und Dienste — Alarm, bevor Sie es merken.',
'key' => 'monitoring',
],
[
'icon' => 'globe',
'label' => 'Ihre Adresse ab der ersten Minute',
// The shape, not the word for it. "Eigene Subdomain" tells a
// customer nothing; the address they will actually type does.
'detail' => 'ihrefirma.:zone',
'mono' => true,
// Issued, not sold: ConfigureDnsAndTls gives every instance its
// platform address whatever `subdomain` says on the plan version
// that was frozen years ago — business and enterprise do not carry
// the flag and get the address anyway. The key is named so the row
// leaves the comparison table; the promise does not hang on it.
'key' => 'subdomain',
'issued' => true,
],
[
'icon' => 'file-text',
'label' => 'AV-Vertrag & TOM-Dokumentation',
'detail' => 'Schriftlich, für Ihre eigene Dokumentation und Prüfung.',
],
[
'icon' => 'life-buoy',
'label' => 'Support auf Deutsch',
'detail' => 'Antwort am selben Werktag, ohne Warteschleife.',
],
];
/**
* The support level a plan carries, strongest first.
*
* One axis, not two. `priority_support` and `premium_sla` are steps on the
* same ladder, and as independent tick boxes they made the top plan look
* worse than the middle one: enterprise carries the SLA and not the
* priority flag, so it rendered "Bevorzugter Support —" while team
* rendered a tick. No plan carrying either is not an absence standard
* support is a real level, and the baseline block above says what it is.
*/
private const SUPPORT = [
'premium_sla' => ['level' => 'Premium', 'note' => 'mit zugesagter Reaktionszeit'],
'priority_support' => ['level' => 'Bevorzugt', 'note' => 'Anfragen werden vorgezogen'],
];
private const SUPPORT_STANDARD = ['level' => 'Standard', 'note' => 'Antwort am selben Werktag'];
/**
* The modules we sell on top of a package, in the words a customer uses.
*
* Same split as FEATURES and for the same reason: lang/*\/billing.php names
* these for the portal's booking cards ("Collabora Online Pro"), which is
* the register of somebody who already bought. A visitor deciding has not
* heard of Collabora. The prices are never written here they come from
* AddonCatalogue, so the sheet and the booking page cannot disagree.
*/
private const ADDONS = [
'extra_backups' => [
'name' => 'Zweiter Sicherungsort',
'body' => 'Eine zusätzliche verschlüsselte Kopie Ihrer Daten, getrennt vom ersten Ort gelagert.',
],
'priority_support' => [
'name' => 'Bevorzugter Support',
'body' => 'Ihre Anfragen werden vorgezogen — Reaktion innerhalb einer Stunde statt am selben Werktag.',
],
'collabora_pro' => [
'name' => 'Office mit vollem Funktionsumfang',
'body' => 'Mehr gleichzeitige Bearbeiter und die erweiterten Funktionen der Office-Integration.',
],
'custom_domain' => [
'name' => 'Eigene Domain',
'body' => 'Ihre Cloud unter Ihrer eigenen Adresse, samt Zertifikat und Einrichtung.',
],
AddonCatalogue::STORAGE => [
'name' => 'Zusatzspeicher',
'body' => 'Ein Paket mit :size — jederzeit ergänzbar, ohne Umzug Ihrer Daten.',
],
];
public function __invoke(): View
{
$plans = $this->plans();
// Read once and handed to both blocks that quote a module price, so the
// "optional" cell in the table and the module list underneath it cannot
// print two different figures for the same thing.
$modules = $this->modulePrices();
$tax = CompanyProfile::taxRate();
// Through SetupFee, which is also what the checkout charges. The sentence
// on this page named a figure that nothing collected for months; now the
// quote and the charge come out of one place, so a change to either the fee
// or the rate cannot move one without moving the other.
$setupNet = SetupFee::netCents();
return view('landing', [
'plans' => $plans,
// What the page has to say about tax and about the one-off fee, in
// one place. Both are console-editable figures, and both were
// previously sentences on the page: "Netto pro Monat" and "zzgl.
// einmaliger Einrichtung" — the second of which never named a
// number at all, so a visitor learned only that there was one.
'vat' => [
'rate' => $tax,
'label' => rtrim(rtrim(number_format($tax, 1, ',', '.'), '0'), ','),
],
'setup' => $setupNet === 0 ? null : [
'gross' => $this->money(SetupFee::chargedCents(TaxTreatment::domestic()), Subscription::catalogueCurrency()),
'net' => $this->money($setupNet, Subscription::catalogueCurrency()),
],
'baseline' => $this->baseline($plans),
'comparison' => $this->comparison($plans, $modules),
'addons' => $this->addons($modules),
]);
}
/**
* The promises that hold for every package on sale.
*
* @param array<int, array<string, mixed>> $plans
* @return array<int, array<string, mixed>>
*/
private function baseline(array $plans): array
{
return array_values(array_filter(
array_map(fn (array $item) => [...$item, 'detail' => $this->zone($item['detail'])], self::BASELINE),
function (array $item) use ($plans) {
$key = $item['key'] ?? null;
if ($key === null || ($item['issued'] ?? false)) {
return true;
}
// "In jedem Paket" is a claim about all of them, so one plan
// without the feature retires the claim rather than weakening
// it to a footnote nobody reads.
foreach ($plans as $plan) {
if (! in_array($key, $plan['keys'], true)) {
return false;
}
}
return true;
},
));
}
/**
* The rows on which the packages actually differ, each cell in one of three
* states: carried, buyable, or neither.
*
* Two states could not tell "you do not get this" apart from "you can have
* it for nine euros", so the sheet said the first about four things we
* would happily have sold which is how the page came to mention none of
* the modules at all.
*
* Built from what is on sale rather than from the full feature list, so a
* feature no current plan offers does not appear as an empty row.
*
* @param array<int, array<string, mixed>> $plans
* @param array<string, int> $modules
* @return array<int, array{label: string, cells: array<string, array<string, mixed>>}>
*/
private function comparison(array $plans, array $modules): array
{
$baselineKeys = array_column(self::BASELINE, 'key');
$rows = [];
$supportPlaced = false;
foreach (array_keys(self::FEATURES) as $key) {
if (in_array($key, $baselineKeys, true)) {
continue;
}
// The support row takes the place of the first of the two keys it
// replaces, so the sheet keeps the catalogue's narrative order
// rather than growing an appendix at the bottom.
if (isset(self::SUPPORT[$key])) {
$row = $supportPlaced ? null : $this->supportRow($plans, $modules);
$supportPlaced = true;
if ($row !== null) {
$rows[] = $row;
}
continue;
}
$offered = array_filter($plans, fn (array $plan) => in_array($key, $plan['keys'], true));
if ($offered === []) {
continue;
}
$cells = [];
foreach ($plans as $plan) {
$cells[$plan['key']] = match (true) {
in_array($key, $plan['keys'], true) => ['state' => 'included'],
// Not carried, but on sale TO THIS PACKAGE — the only
// honest third answer. A module we do not sell stays a
// dash, however easy it would be to invent a price for it,
// and so does one this package may not book: an own domain
// is impossible on the entry package, so quoting nine euros
// there would be selling something we would then refuse.
isset($modules[$key]) && $this->bookableOn($key, $plan) => ['state' => 'optional', 'price' => $this->modulePrice($modules[$key])],
default => ['state' => 'absent'],
};
}
$rows[] = ['label' => (string) $this->label($key), 'cells' => $cells];
}
return $rows;
}
/**
* Whether a package could actually book this module.
*
* Only the own domain has such a rule today, and the rule is not restated
* here: CustomDomainAccess owns it, and the sheet asks. A page that decided
* for itself which packages may buy a domain would be a second answer to a
* question the shop already answers, and the two would part company the
* first time the owner changed one of them.
*
* @param array<string, mixed> $plan
*/
private function bookableOn(string $key, array $plan): bool
{
if ($key !== CustomDomainAccess::ADDON) {
return true;
}
return app(CustomDomainAccess::class)->bookableOnPlan((string) $plan['key'], $plan['keys']);
}
/**
* Support as one row of levels.
*
* Omitted entirely when no plan carries either key: four columns all
* reading "Standard" is the row of identical ticks this rebuild removed,
* and the baseline block has already said what standard support is.
*
* @param array<int, array<string, mixed>> $plans
* @param array<string, int> $modules
* @return array{label: string, cells: array<string, array<string, mixed>>}|null
*/
private function supportRow(array $plans, array $modules): ?array
{
$cells = [];
$differs = false;
foreach ($plans as $plan) {
$carried = self::SUPPORT_STANDARD;
// Strongest first: a plan carrying both is shown at the level it
// actually answers at, not at whichever key was checked first.
foreach (self::SUPPORT as $key => $level) {
if (in_array($key, $plan['keys'], true)) {
$carried = $level;
$differs = true;
break;
}
}
$cells[$plan['key']] = [
'state' => 'level',
'text' => $carried['level'],
// The upgrade displaces the level's own qualifier: a customer
// reading the standard cell is better served by what the next
// step costs than by a restatement of the line above.
'note' => $carried === self::SUPPORT_STANDARD && isset($modules['priority_support'])
? 'bevorzugt ab '.$this->modulePrice($modules['priority_support'])
: $carried['note'],
];
}
return $differs ? ['label' => 'Support', 'cells' => $cells] : null;
}
/**
* Today's price for every module actually on sale, in cents.
*
* Swallowed like the plan catalogue and for the same reason (see the class
* docblock): an empty or unreadable module list costs the page a block, not
* the page. Every caller then reads a plain array, so a module that is not
* in it is simply not for sale which is exactly what the dash in the
* table means.
*
* @return array<string, int>
*/
private function modulePrices(): array
{
try {
$catalogue = app(AddonCatalogue::class);
$keys = [...array_keys((array) config('provisioning.addons', [])), AddonCatalogue::STORAGE];
$prices = [];
foreach ($keys as $key) {
$cents = $catalogue->priceCents((string) $key);
// Nothing is sold for nothing. An entry without a price reads
// back as 0 cents, and "optional · 0 €" on a public price sheet
// is an offer somebody would hold us to.
if ($cents === null || $cents <= 0) {
continue;
}
// The storage pack is sold by the pack, so a pack of no size is
// not a product either.
if ($key === AddonCatalogue::STORAGE && (int) config('provisioning.storage_addon.gb', 0) <= 0) {
continue;
}
$prices[(string) $key] = $cents;
}
return $prices;
} catch (Throwable $e) {
Log::error('Landing page could not read the add-on catalogue', ['exception' => $e]);
return [];
}
}
/**
* The modules, in the order the catalogue lists them, with today's prices.
*
* @param array<string, int> $modules
* @return array<int, array{name: string, body: string, price: string}>
*/
private function addons(array $modules): array
{
$rows = [];
foreach ($modules as $key => $cents) {
$copy = self::ADDONS[$key] ?? null;
// Same rule as an unknown feature key: a module nobody has written
// up is left out rather than advertised as "collabora_pro".
if ($copy === null) {
continue;
}
$rows[] = [
'name' => $copy['name'],
'body' => str_replace(
':size',
$this->storage((int) config('provisioning.storage_addon.gb', 0)),
$copy['body'],
),
'price' => $this->modulePrice($cents),
];
}
return $rows;
}
/**
* @return array<int, array<string, mixed>> empty when the catalogue cannot be read
*/
private function plans(): array
{
try {
$sellable = app(PlanCatalogue::class)->sellable();
} catch (Throwable $e) {
// Deliberately swallowed: see the class docblock. Logged at error
// level because a shop that cannot list its plans is not selling.
Log::error('Landing page could not read the plan catalogue', ['exception' => $e]);
return [];
}
$plans = [];
$capacity = app(HostCapacity::class);
foreach ($sellable as $key => $plan) {
$plans[] = [
'key' => $key,
'name' => $plan['name'],
// How soon this package can actually be delivered, read from
// the estate rather than from a sentence somebody typed. Where
// a host has room it rolls out on its own within the hour;
// where none has, a machine has to be bought first — and
// saying "sofort" in that case is a promise the very next
// customer catches us breaking.
'delivery' => $capacity->deliveryFor((int) ($plan['disk_gb'] ?? 0)),
// Console-edited, from the family the plan belongs to — a
// family that has none yet (created but not written up) gets
// an empty string rather than a missing array key, and the
// template renders that sensibly instead of an empty gap.
'audience' => (string) ($plan['audience'] ?? ''),
'note' => (string) ($plan['note'] ?? ''),
// GROSS, because a private customer must never be quoted a
// figure that grows at checkout. A business reads the net one
// underneath and gets the VAT back anyway; a consumer does not,
// and for them the net figure was simply the wrong number.
'price' => $this->money($this->gross((int) $plan['price_cents']), (string) $plan['currency']),
'price_net' => $this->money((int) $plan['price_cents'], (string) $plan['currency']),
// Paying for a year, in the same three figures the order page
// shows: what leaves the account once, what that works out to
// per month, and what twelve monthly payments would have been.
// A sheet that quotes only the monthly price cannot mention a
// saving, and the saving is the reason to offer the term.
'price_yearly' => $this->money(
$yearlyGross = $this->gross((int) ($plan['yearly_price_cents'] ?? 0)),
(string) $plan['currency'],
),
'price_yearly_net' => $this->money((int) ($plan['yearly_price_cents'] ?? 0), (string) $plan['currency']),
// intdiv, so the headline never claims a tenth of a cent nobody
// is charged; the total beside it is the amount actually taken.
'price_yearly_monthly' => $this->money(intdiv($yearlyGross, 12), (string) $plan['currency']),
'price_twelve_months' => $this->money($this->gross((int) $plan['price_cents']) * 12, (string) $plan['currency']),
'free_months' => (int) ($plan['free_months'] ?? 0),
'storage' => $this->storage((int) $plan['quota_gb']),
'traffic' => $this->storage((int) $plan['traffic_gb']),
'seats' => (int) $plan['seats'],
'features' => $this->features($plan['features'] ?? []),
// The raw keys alongside the wording. Everything that has to
// decide something — which row this plan fills, which support
// level it answers at — asks the key; only the card bullets
// read the words. Deciding on the words matched until the
// first label gained a placeholder, and then quietly stopped.
'keys' => array_values((array) ($plan['features'] ?? [])),
'recommended' => (bool) ($plan['recommended'] ?? false),
];
}
return $plans;
}
/**
* One feature, in the words a customer uses.
*
* The address the platform hands out is a setting, not a constant a
* server can be installed against any zone so the sentence naming it is
* assembled rather than written down twice.
*/
private function label(string $key): ?string
{
$label = self::FEATURES[$key] ?? null;
return $label === null ? null : $this->zone($label);
}
/** The configured zone, wherever a sentence names it. */
private function zone(string $text): string
{
return str_replace(':zone', ProvisioningSettings::dnsZone(), $text);
}
/** Currencies we have a symbol for; anything else prints its ISO code. */
private const SYMBOLS = ['EUR' => '€', 'CHF' => 'CHF', 'USD' => '$', 'GBP' => '£'];
/**
* A price as the sheet prints it, currency included.
*
* The symbol comes from the catalogue rather than from the template: the
* currency is configurable (CLUPILOT_CURRENCY), and a page that says ""
* while the checkout charges francs is the same two-sources-of-truth
* mistake as a hard-coded amount, only harder to notice.
*
* Whole units when the amount is whole a price sheet reads "179", not
* "179,00".
*/
/**
* A net figure as the ordinary customer is charged it.
*
* Handed straight to TaxTreatment rather than worked out here. It is the same
* call the domestic Stripe Price is formed with, so the figure on this page and
* the figure on the card statement cannot drift apart. It used to be a
* multiplication of its own, which is a second source of the same number and
* therefore a second answer waiting to happen.
*
* This page is read by visitors nobody has met, so the domestic gross is the
* only honest figure to put on it. A business in another member state with a
* verified VAT id is charged less the bare net, no VAT owed to us at all
* and learns that on the pages that know who they are, not here, where
* advertising it would quote every private visitor a price they cannot have.
*/
private function gross(int $netCents): int
{
return TaxTreatment::advertisedCents($netCents);
}
private function money(int $cents, string $currency): string
{
$amount = $cents % 100 === 0
? number_format($cents / 100, 0, ',', '.')
: number_format($cents / 100, 2, ',', '.');
// Non-breaking: in a narrow table column "49 €" otherwise wraps, and the
// currency ends up on a line of its own under the number.
return $amount."\u{00A0}".(self::SYMBOLS[strtoupper($currency)] ?? strtoupper($currency));
}
/**
* A module price.
*
* A plan price carries its own currency from the catalogue; a module does
* not, and cannot config holds one net amount. The one currency the
* catalogue is priced in is therefore the honest answer, and it is the same
* one the booking page charges in.
*/
private function modulePrice(int $cents): string
{
// Gross, like every other figure on this page. A sheet that quotes the
// package with VAT and the module without it is a sheet where the two
// numbers cannot be added up.
return $this->money($this->gross($cents), Subscription::catalogueCurrency());
}
private function storage(int $gb): string
{
return $gb >= 1000 && $gb % 1000 === 0
? ($gb / 1000).' TB'
: $gb.' GB';
}
/**
* @param array<int, string> $keys
* @return array<int, string>
*/
private function features(array $keys): array
{
// Unknown keys are dropped rather than printed raw: a feature added to
// the catalogue without a translation would otherwise appear on the
// public page as "premium_sla".
return array_values(array_filter(array_map(
fn (string $key) => $this->label($key),
$keys,
)));
}
}

View File

@ -0,0 +1,68 @@
<?php
namespace App\Http\Controllers;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
/**
* Dateien, die jeder herunterladen darf: AGB, Auftragsverarbeitungsvertrag,
* technische und organisatorische Maßnahmen, Preisblätter.
*
* Sie liegen in `storage/app/files/public/` und werden ausgeliefert, wie sie
* sind. Kein Verwaltungsbereich, keine Datenbanktabelle: eine Datei ablegen
* genügt, und was nicht dort liegt, gibt es nicht.
*
* ---------------------------------------------------------------------------
* Warum eine eigene Adresse
* ---------------------------------------------------------------------------
*
* Diese Dateien stehen in Verträgen und auf Rechnungen. Eine Adresse, die beim
* nächsten Umbau des Portals wandert, macht aus jedem dieser Verweise einen
* toten Link und zwar rückwirkend, in Dokumenten, die längst verschickt sind.
* Ein eigener Hostname ist die einzige Zusage, die sich einhalten lässt.
*
* ---------------------------------------------------------------------------
* Versionierte Dateinamen
* ---------------------------------------------------------------------------
*
* `agb-2026-01.pdf`, nicht `agb.pdf`. Eine Fassung, auf die ein Vertrag
* verweist, darf sich unter derselben Adresse NIE ändern sonst zeigt der
* Verweis in einem Vertrag vom Januar plötzlich auf Bedingungen vom Juli, und
* niemand kann mehr sagen, was vereinbart war. Neue Fassung heißt neue Datei.
*
* Erzwungen wird das hier nicht eine Regel, die eine Datei mit dem falschen
* Namen abweist, hilft niemandem, der eine hochladen will. Sie steht hier, weil
* dies die Stelle ist, an der jemand nachsieht.
*/
class PublicFileController extends Controller
{
private const ROOT = 'app/files/public';
public function __invoke(string $file): BinaryFileResponse
{
// NUR der Dateiname, ohne Pfad. `basename()` macht aus
// `../../.env` ein `.env`, und das liegt in diesem Verzeichnis nicht —
// damit ist der Pfad-Ausbruch beantwortet, bevor er anfängt. Ein
// Verzeichnisbaum unter `public/` gibt es absichtlich nicht: er wäre
// die zweite Gelegenheit für denselben Fehler.
$name = basename($file);
// Ein Punktdatei-Name kommt hier nicht durch. Sie sind in diesem
// Verzeichnis nicht vorgesehen, und ihr Vorkommen wäre ein Versehen.
if ($name === '' || str_starts_with($name, '.')) {
abort(404);
}
$path = storage_path(self::ROOT.'/'.$name);
if (! is_file($path)) {
abort(404);
}
// Inline, nicht als Anhang: eine AGB, die der Browser anzeigt, wird
// gelesen; eine, die er herunterlädt, wird weggeklickt.
return response()->file($path, [
'Content-Disposition' => 'inline; filename="'.$name.'"',
]);
}
}

View File

@ -0,0 +1,192 @@
<?php
namespace App\Http\Controllers;
use App\Models\Incident;
use App\Models\MaintenanceWindow;
use App\Models\StatusDay;
use App\Services\Status\ServiceHealth;
use Illuminate\Contracts\View\View;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
/**
* The public service status page.
*
* It answers three questions, and they are different questions: what is
* happening now, what has happened, and what is about to happen. The page used
* to answer only the first, which is the version of a status page that is of no
* use the morning after "Alle Dienste in Betrieb" is true and says nothing to
* somebody who was locked out the evening before.
*
* The measurement itself lives in ServiceHealth, shared with the sampler that
* writes the history, so the banner and the bars cannot disagree.
*
* Aggregate only: counts, never customer names, instance addresses or host
* names. This page is world-readable and the estate is not public information.
*/
class StatusController extends Controller
{
/** How much history the bars show. Ninety days is the convention. */
private const HISTORY_DAYS = 90;
/** How far back the written record goes on this page. */
private const INCIDENT_DAYS = 90;
public function __invoke(ServiceHealth $health): View
{
$components = $health->components();
// An ongoing incident overrides the measurement. Monitoring can be
// perfectly happy while something is broken in a way it does not look
// at, and in that situation the operator's word beats the probe's.
$ongoing = Incident::query()
->published()->ongoing()
->with('updates')
->orderByDesc('started_at')
->get();
$components = $this->applyIncidents($components, $ongoing);
return view('status', [
'components' => $components,
'overall' => $health->overall($components),
'history' => $this->history(),
'ongoing' => $ongoing,
'past' => $this->past(),
'maintenance' => $this->maintenance(),
'checkedAt' => Carbon::now(),
'historyDays' => self::HISTORY_DAYS,
]);
}
/**
* Let an open incident set the state of the components it names.
*
* Only downwards. An incident cannot make a component look better than the
* probes found it that direction is how a status page ends up being used
* to hide something.
*
* @param array<int, array<string, mixed>> $components
* @param Collection<int, Incident> $ongoing
* @return array<int, array<string, mixed>>
*/
private function applyIncidents(array $components, Collection $ongoing): array
{
$rank = ['operational' => 0, 'unknown' => 1, 'degraded' => 2, 'down' => 3];
foreach ($components as $i => $component) {
foreach ($ongoing as $incident) {
if (! in_array($component['key'], $incident->components ?? [], true)) {
continue;
}
// Maintenance is announced work, not a fault. It shows on the
// component without claiming anything is broken.
$state = $incident->impact === Incident::IMPACT_MAINTENANCE ? 'degraded' : $incident->impact;
if (($rank[$state] ?? 0) > ($rank[$component['state']] ?? 0)) {
$components[$i]['state'] = $state;
$components[$i]['detail'] = null;
}
$components[$i]['incident'] = $incident;
}
}
return $components;
}
/**
* The daily bars, one array per component, oldest first.
*
* Every day in the window appears, including the ones nobody measured
* they come back as null and are drawn as a gap. Filling them in from the
* days around them would be inventing the only number on this page a reader
* can check against their own memory.
*
* @return array<string, array{days: array<int, array<string, mixed>>, uptime: float|null}>
*/
private function history(): array
{
$from = Carbon::now()->local()->startOfDay()->subDays(self::HISTORY_DAYS - 1);
$rows = StatusDay::query()
->where('day', '>=', $from->toDateString())
->get()
->groupBy('component');
$history = [];
foreach (ServiceHealth::COMPONENTS as $component) {
$byDay = ($rows[$component] ?? collect())->keyBy(fn (StatusDay $d) => $d->day->toDateString());
$days = [];
$measured = 0;
$up = 0;
for ($i = 0; $i < self::HISTORY_DAYS; $i++) {
$date = $from->copy()->addDays($i);
$row = $byDay[$date->toDateString()] ?? null;
$days[] = [
'date' => $date,
'state' => $row?->worst(),
'uptime' => $row?->uptimePercent(),
];
if ($row !== null) {
$measured += $row->samples - $row->unknown;
$up += $row->operational + $row->degraded;
}
}
$history[$component] = [
'days' => $days,
// Over the whole window, not the mean of the daily figures: a
// day with four samples must not weigh the same as a day with
// three hundred.
'uptime' => $measured > 0 ? round($up / $measured * 100, 2) : null,
];
}
return $history;
}
/**
* Resolved incidents, newest first, grouped by the day they started.
*
* @return Collection<string, Collection<int, Incident>>
*/
private function past(): Collection
{
return Incident::query()
->published()
->whereNotNull('resolved_at')
->where('started_at', '>=', Carbon::now()->subDays(self::INCIDENT_DAYS))
->with('updates')
->orderByDesc('started_at')
->get()
->groupBy(fn (Incident $incident) => $incident->started_at->local()->toDateString());
}
/**
* Announced work that has not finished yet.
*
* Taken from the maintenance windows the console already keeps rather than
* from a second list somebody has to remember to fill in. Published ones
* only: a draft is a plan, not an announcement.
*
* @return Collection<int, MaintenanceWindow>
*/
private function maintenance(): Collection
{
return MaintenanceWindow::query()
->whereNotNull('published_at')
->whereNull('cancelled_at')
->where('state', '!=', 'draft')
->where('ends_at', '>=', Carbon::now())
->orderBy('starts_at')
->get();
}
}

View File

@ -0,0 +1,167 @@
<?php
namespace App\Http\Controllers;
use App\Actions\ApplyStripeBillingEvent;
use App\Actions\StartCustomerProvisioning;
use App\Support\StripeWebhookSecret;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
/**
* Stripe webhook: verifies the signature (when a secret is configured) and turns
* a paid checkout into a provisioning run. Idempotent via the order's
* stripe_event_id Stripe retries a webhook until it gets a 2xx.
*/
class StripeWebhookController extends Controller
{
public function __invoke(
Request $request,
StartCustomerProvisioning $action,
ApplyStripeBillingEvent $billing,
): JsonResponse {
$payload = $request->getContent();
$secret = StripeWebhookSecret::current();
if (blank($secret)) {
// Fail closed: an unconfigured secret must not authorize provisioning
// outside local/testing.
abort_unless(app()->environment('local', 'testing'), 400, 'Stripe webhook secret not configured');
} elseif (! $this->signatureValid($payload, (string) $request->header('Stripe-Signature'), $secret)) {
abort(400, 'invalid signature');
}
$event = json_decode($payload, true) ?: [];
$object = $event['data']['object'] ?? [];
$type = $event['type'] ?? '';
// The billing cycle is Stripe's: once a contract exists, they decide
// when it renews, when a payment failed, and when it has ended. Handled
// before the checkout branch because none of these are checkouts.
$applied = $billing->dispatch($event);
if ($applied !== false) {
if ($applied === null) {
// Either already applied, or about a contract that does not
// exist yet — a checkout takes a moment to become one, and
// Stripe does not deliver in order. Holding it costs a row and
// saves a cancellation we would otherwise never hear about
// again; a replay is a no-op if it was simply a duplicate.
$billing->hold($event);
}
// 2xx either way. Stripe retrying would not change the answer, and
// anything worth replaying is now held rather than dropped.
return response()->json(['handled' => $type, 'applied' => $applied !== null]);
}
// Paid triggers: a synchronous checkout (completed + paid) OR an async
// method clearing later (async_payment_succeeded). Ignore everything else,
// including the still-unpaid completed event for async methods.
$paid = ($type === 'checkout.session.completed' && ($object['payment_status'] ?? null) === 'paid')
|| $type === 'checkout.session.async_payment_succeeded';
if (! $paid) {
return response()->json(['ignored' => true]);
}
$meta = $object['metadata'] ?? [];
// A real email is required — never merge unrelated customers under a
// manufactured address or send credentials into the void.
$email = $object['customer_details']['email'] ?? $object['customer_email'] ?? ($meta['email'] ?? null);
if (blank($email)) {
return response()->json(['ignored' => 'no_customer_email']);
}
$action->fromStripeEvent([
// Deduplicate on the checkout session id (stable across the completed
// and async-succeeded events for one purchase), not the event id.
'id' => (string) ($object['id'] ?? $event['id'] ?? ''),
'email' => $email,
'name' => $object['customer_details']['name'] ?? ($meta['name'] ?? null),
'stripe_customer_id' => $object['customer'] ?? null,
// The handle every later billing event arrives with. Without it an
// invoice.paid cannot be matched to the contract it renews.
'stripe_subscription_id' => is_string($object['subscription'] ?? null) ? $object['subscription'] : null,
// What a refund would have to be issued against, kept at the only
// moment Stripe tells us. A subscription checkout charges through
// an invoice and usually reports no PaymentIntent at all, so both
// are taken and App\Actions\WithdrawContract uses whichever it has.
'stripe_payment_intent_id' => is_string($object['payment_intent'] ?? null) ? $object['payment_intent'] : null,
'stripe_invoice_id' => is_string($object['invoice'] ?? null) ? $object['invoice'] : null,
// The consumer's express request that the service begin inside the
// fourteen-day withdrawal window. Set on the session metadata by the
// checkout; anything other than the exact string is a no, because a
// consent that can be produced by a typo is not a consent.
//
// Nothing decides anything on it now — a withdrawal refunds the
// whole amount either way — so it is carried as a record of what was
// agreed rather than as a gate. See App\Models\Order.
'immediate_start_consent' => ($meta['immediate_start'] ?? null) === '1',
'plan' => $meta['plan'] ?? 'start',
// What the customer was actually shown. Absent on a session created
// before phase 5 put it there, and then the version on sale applies.
'plan_version_id' => isset($meta['plan_version_id']) ? (int) $meta['plan_version_id'] : null,
'datacenter' => $meta['datacenter'] ?? 'fsn',
'amount_cents' => (int) ($object['amount_total'] ?? $object['amount'] ?? 0),
// How much of that total was the one-off setup fee, which rode along
// as a second line item on the session. From OUR metadata rather than
// from Stripe's line items: this is the figure we asked for, we asked
// for it once, and reading it back from the amounts would mean
// guessing which line was which.
//
// Clamped to the total. A negative or oversized value could only come
// from a session that was not ours, and the package's charge is derived
// by subtracting this — a wild figure there would put a negative price
// on the contract's own line.
'setup_fee_cents' => max(0, min(
(int) ($object['amount_total'] ?? $object['amount'] ?? 0),
(int) ($meta['setup_fee_cents'] ?? 0),
)),
'currency' => strtoupper($object['currency'] ?? 'eur'),
]);
return response()->json(['ok' => true]);
}
/** Stripe's default replay tolerance, in seconds. */
private const SIGNATURE_TOLERANCE = 300;
/** Verify Stripe's `t=…,v1=…` signature: HMAC-SHA256 of "{t}.{payload}". */
private function signatureValid(string $payload, string $header, string $secret): bool
{
if (blank($header)) {
return false;
}
$timestamp = null;
$signatures = [];
foreach (explode(',', $header) as $segment) {
[$key, $value] = array_pad(explode('=', $segment, 2), 2, '');
if ($key === 't') {
$timestamp = $value;
} elseif ($key === 'v1') {
$signatures[] = $value; // keep every v1 (secret rotation sends several)
}
}
if ($timestamp === null || $signatures === []) {
return false;
}
// Reject replays outside the tolerance window.
if (abs(time() - (int) $timestamp) > self::SIGNATURE_TOLERANCE) {
return false;
}
$expected = hash_hmac('sha256', $timestamp.'.'.$payload, $secret);
foreach ($signatures as $signature) {
if (hash_equals($expected, $signature)) {
return true;
}
}
return false;
}
}

View File

@ -0,0 +1,26 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Symfony\Component\HttpFoundation\Response;
class EnsureAdmin
{
/**
* Only operators with the console.view capability may reach the admin
* console. Checked on the operator guard explicitly, never on the default
* (`web`) guard's user a portal account can no longer hold a role at all,
* so trusting the default guard here would just mean nobody ever gets in.
*/
public function handle(Request $request, Closure $next): Response
{
$operator = Auth::guard('operator')->user();
abort_unless($operator !== null && $operator->isActive() && $operator->can('console.view'), 403);
return $next($request);
}
}

View File

@ -0,0 +1,54 @@
<?php
namespace App\Http\Middleware;
use App\Models\Customer;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Symfony\Component\HttpFoundation\Response;
/**
* Enforce customer account lifecycle on portal requests: a suspended or closed
* customer must lose access. An active impersonation session is exempt so
* operators can still inspect a suspended customer's portal. There is no
* operator special case here any more: a `web`-guard user can never be an
* operator, so the only thing left to exempt is an impersonation session.
*/
class EnsureCustomerActive
{
public function handle(Request $request, Closure $next): Response
{
$user = $request->user();
if ($user !== null && ! $request->session()->has('impersonator_id')) {
$customer = Customer::query()->where('user_id', $user->id)->first()
?? Customer::query()->where('email', $user->email)->first();
if ($customer !== null && ($customer->status === 'suspended' || $customer->status === 'closed' || $customer->closed_at !== null)) {
Auth::logout();
// NOT session()->invalidate(): where the console and the portal
// share a host (shared/fallback mode — this VM's own mode), both
// guards keep their login key in the SAME session. invalidate()
// is flush() + migrate(true) — it discards every attribute in the
// session, not just the 'web' guard's, so it would silently sign
// an operator elsewhere in that same browser out of the console
// too. regenerate() issues a new session id (no fixation risk)
// without touching the other attributes, so the 'operator'
// guard's own login key — untouched by the Auth::logout() call
// above, which only logs out the default ('web') guard —
// survives untouched. Same fix as routes/admin.php's own
// /logout route, mirrored the other way round.
$request->session()->regenerate();
$request->session()->regenerateToken();
$key = $customer->closed_at !== null || $customer->status === 'closed' ? 'auth.account_closed' : 'auth.account_suspended';
return redirect()->route('login')->withErrors(['email' => __($key)]);
}
}
return $next($request);
}
}

View File

@ -0,0 +1,125 @@
<?php
namespace App\Http\Middleware;
use App\Support\AdminArea;
use App\Support\Settings;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Symfony\Component\HttpFoundation\IpUtils;
use Symfony\Component\HttpFoundation\Response;
/**
* Hides the marketing site and the customer portal while the product is still
* being built, without hiding it from us.
*
* Switched from the console (site.public). Anyone coming through the management
* VPN, and any signed-in operator, sees the real thing; everyone else crawlers
* included gets a placeholder.
*
* Answers 503 with Retry-After and X-Robots-Tag rather than 200: a 200 would
* invite search engines to index the placeholder as the site's content, and
* getting that out of an index again is much harder than keeping it out.
*/
class PublicSiteGate
{
/**
* Paths that must keep working regardless: the console itself (otherwise the
* switch could not be flipped back), Stripe's webhook, and the health check.
*
* Livewire is deliberately NOT in this list. Its endpoint is shared by the
* console and the portal, so exempting it would leave a signed-in customer
* able to drive portal components including billing while the portal is
* supposed to be offline. Operators pass the check below anyway, which is
* what the console actually needs.
*
* Nor is /login, and that has a consequence worth knowing before you hide
* the site: /admin sends a guest to /login, which is not the console, so
* SIGNING IN while hidden needs the request to come from a trusted range
* the management VPN, or an address put in TRUSTED_RANGES for the initial
* setup. Exempting the login flow by hostname instead would mean trusting a
* Host header, which the caller chooses, and one forged header would unhide
* the entire portal.
*/
private const ALWAYS_ALLOWED = ['webhooks/*', 'up', 'robots.txt'];
public function handle(Request $request, Closure $next): Response
{
// The console is exempt wherever it currently lives. This used to be
// the literal paths 'admin' and 'admin/*', which stops matching the
// moment the console moves to the root of its own hostname — and the
// result would be 503 on the sign-in page of the console, which is the
// only place the switch can be turned back on. Asking AdminArea keeps
// the exemption attached to the console rather than to a path that
// happened to be true once.
if (AdminArea::isConsole($request) || $request->is(...self::ALWAYS_ALLOWED) || Settings::bool('site.public', true)) {
return $next($request);
}
if ($this->isDownload($request)) {
return $next($request);
}
if ($this->fromManagementNetwork($request) || $this->isActiveOperator()) {
return $next($request);
}
return response()->view('coming-soon', [], 503)->withHeaders([
'Retry-After' => 3600,
'X-Robots-Tag' => 'noindex, nofollow, noarchive',
'Cache-Control' => 'no-store',
]);
}
/**
* Die Datei-Domain bleibt erreichbar, auch solange die Website verborgen ist.
*
* Sonst bricht jede Host-Übernahme, und zwar unsichtbar: ein Server im
* Rettungssystem holt das Archiv mit `curl … | tar xz`, bekäme hier die
* 503-Platzhalterseite und schöbe sie in `tar`. Der Betreiber sähe einen
* Entpackfehler auf einer Maschine, an die er nur noch über die
* Anbieterkonsole kommt und nichts davon deutete auf einen Schalter in
* der Konsole hin. Er ist per Definition weder in einem verwalteten Netz
* noch angemeldet: das ist ja gerade der Zustand, den er verlassen will.
*
* Über den ROUTENNAMEN, nicht über den Pfad: die Route existiert nur, wenn
* die Domain gebunden hat. Der Docblock oben warnt zu Recht davor, per
* Hostname auszunehmen dort ginge es aber um das gesamte Portal, und ein
* gefälschter Host-Header hätte es aufgedeckt. Hier stehen zwei Routen
* dahinter: die Rechtsdokumente, die ohnehin öffentlich sind, und das
* Archiv, das ohne gültigen Einmal-Code mit 404 antwortet. Wer den Header
* fälscht, gewinnt damit nichts, was er nicht auch nach dem Start hätte.
*/
private function isDownload(Request $request): bool
{
return in_array($request->route()?->getName(), ['bootstrap.archive', 'files.public'], true);
}
private function fromManagementNetwork(Request $request): bool
{
$ranges = (array) config('admin_access.trusted_ranges', []);
return $ranges !== [] && IpUtils::checkIp((string) $request->ip(), $ranges);
}
/**
* Same standard as EnsureAdmin: an active operator holding a console role.
* A bare `check()` stays true for a disabled operator's live session
* disabling someone did not sign them out, so it never stopped this gate
* from waving them through indefinitely.
*
* Deliberately checked, not enforced by logging out: this gate only
* decides what ONE request sees, and destroying the session would ripple
* into whatever else that session is doing (a concurrent console tab,
* mid-task) a much bigger side effect than a visibility check exists to
* have. EnsureAdmin sets the precedent: it also 403s a disabled operator
* per request without touching their session.
*/
private function isActiveOperator(): bool
{
$operator = Auth::guard('operator')->user();
return $operator !== null && $operator->isActive() && $operator->can('console.view');
}
}

View File

@ -0,0 +1,56 @@
<?php
namespace App\Http\Middleware;
use App\Support\AdminArea;
use App\Support\Settings;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Symfony\Component\HttpFoundation\Response;
/**
* Two-factor for operators, when the owner has made it compulsory.
*
* Off by default. ENROLMENT (admin.two-factor-setup) is exempt, because it is
* where the operator sets two-factor up gating it would leave nobody able
* to satisfy the requirement. Nothing else is.
*
* This used to exempt the whole of admin.settings, on the theory that it was
* "the page where two-factor is set up". That component also carries staff
* management, site visibility, network restrictions, account changes, and
* the two-factor policy switch itself so an unenrolled operator got
* unrestricted access to all of that, not just enrolment (Codex R15, P1b).
* Enrolment has its own route and component for exactly this reason: one
* page to exempt is one decision, not eight actions each needing to
* remember to check.
*/
class RequireOperatorTwoFactor
{
public function handle(Request $request, Closure $next): Response
{
if (! Settings::bool('console.require_2fa', false)) {
return $next($request);
}
$operator = Auth::guard('operator')->user();
if ($operator === null || $operator->two_factor_confirmed_at !== null) {
return $next($request);
}
// The two pages that must stay reachable: where two-factor is
// enrolled, and signing out. Both through AdminArea::routeIs(), not
// a bare $request->routeIs() — a recovery hostname's routes are
// named admin.viaN.*, and a bare check matches only the canonical
// name, which would leave an unenrolled operator unable to sign out
// on exactly the host that exists because the canonical one is not
// working.
if (AdminArea::routeIs('admin.two-factor-setup') || AdminArea::routeIs('admin.logout')) {
return $next($request);
}
return redirect()->route('admin.two-factor-setup')
->with('status', __('admin_settings.two_factor_required'));
}
}

View File

@ -0,0 +1,103 @@
<?php
namespace App\Http\Middleware;
use App\Support\AdminArea;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* The console answers only on its own hostname and that hostname answers
* only the console.
*
* Both halves matter. The first keeps the console off the public domains. The
* second is what actually separates the two products: binding the console
* routes to a hostname does not stop the CUSTOMER routes from answering there
* as well, because they are registered without a hostname. Without this,
* app.clupilot.com and admin.clupilot.com would still serve each other's pages
* wherever the paths did not collide.
*
* 404 in both directions, never 403: a stranger must not learn that a console
* lives here, and an operator on the wrong host should see the same nothing a
* stranger does.
*
* A handful of endpoints are shared by both sides and must answer on either
* host Livewire's own endpoints and the authentication actions. They are
* listed explicitly below, because the failure mode of getting that list wrong
* is "the console is broken" rather than "the rule is wrong", and a list you
* can read is easier to correct than a rule you have to infer.
*/
class RestrictAdminHost
{
/**
* Endpoints both the console and the portal need, on whichever host the
* caller is currently on.
*
* `login`, `logout` and `two-factor-challenge` used to be here, from when
* one sign-in page served both. They are gone: the console has its own at
* `admin.login`, and leaving the portal's here is what put a registration
* link in front of an operator with a 404 behind it, because `register`
* was never in this list and could not be, since the console has no
* registration by design.
*
* Livewire's component endpoint is guarded separately and more strictly:
* the console's own middleware is registered as persistent, so an action
* posted to /livewire/update is re-checked against the component's real
* route. Letting the path through here does not let anything through there.
*/
private const SHARED = [
'livewire/*',
'up',
// The console's own live-provisioning feed authorizes here (see
// routes/channels.php, admin.runs) — a route Laravel registers
// without any admin.* name, so isConsoleRoute() alone would not
// exempt it, and in exclusive mode it would 404 on the console host
// exactly like a stranger's request would.
'broadcasting/auth',
];
public function handle(Request $request, Closure $next): Response
{
// Not host-bound (development, a fresh checkout): the console keeps its
// /admin prefix on any host and nothing is separated. Upgrading must
// not lock anyone out of a system that was working.
if (! AdminArea::isHostBound()) {
return $next($request);
}
$isConsoleRoute = $this->isConsoleRoute($request);
$onConsoleHost = AdminArea::covers($request->getHost());
// The console, reached through a hostname that is not the console's.
if ($isConsoleRoute && ! $onConsoleHost) {
abort(404);
}
// The portal or the public site, reached through the console's
// hostname. Only once exclusivity is switched on: a machine that lists
// its own IP in ADMIN_HOSTS so the console is reachable without DNS
// still has to serve the portal from that same address.
if (AdminArea::isExclusive() && $onConsoleHost && ! $isConsoleRoute && ! $request->is(...self::SHARED)) {
abort(404);
}
return $next($request);
}
/**
* Console routes are the ones named `admin.*`.
*
* By name rather than by path: the path is exactly what changes between
* host-bound and fallback mode, and a rule written against it would be
* wrong in one of them.
*/
private function isConsoleRoute(Request $request): bool
{
$name = $request->route()?->getName();
// Covers admin.* and the alternate-host registrations (admin.viaN.*),
// which are the same console reached by a recovery hostname.
return $name !== null && str_starts_with($name, 'admin.');
}
}

View File

@ -0,0 +1,134 @@
<?php
namespace App\Http\Middleware;
use App\Support\AdminArea;
use App\Support\Settings;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\IpUtils;
use Symfony\Component\HttpFoundation\Response;
/**
* Who may reach the operator console, by network address.
*
* RestrictAdminHost answers "under which NAME does the console respond" and a
* Host header is chosen by the caller, so it can never answer "who is asking".
* This one can: the client address behind a trusted proxy is not something the
* client gets to pick.
*
* The management VPN always counts. Beyond that the owner keeps a list an
* office line, a home connection so being away from the VPN does not mean
* being locked out. That list lives in the console rather than in a proxy
* config file, because the person who needs to change it is the person sitting
* in the console.
*
* 404, never 403: a stranger must not learn that a console lives here.
*
* This is not a substitute for a firewall. It runs after PHP has started, so it
* bounds who can USE the console, not who can make the server work. Where a
* network-level ACL is also possible, both belong there.
*/
class RestrictConsoleNetwork
{
public function handle(Request $request, Closure $next): Response
{
// Scoped to the console, wherever the console currently is. Asking
// AdminArea rather than testing the path: once the console moves to the
// root of its own hostname there is no `/admin` left to match, and a
// guard that silently stops matching is worse than no guard.
if (! AdminArea::isConsole($request)) {
return $next($request);
}
if (! self::isRestricted()) {
return $next($request);
}
if (! self::allows((string) $request->ip())) {
abort(404);
}
return $next($request);
}
/** Whether the owner has switched the restriction on at all. */
public static function isRestricted(): bool
{
return Settings::bool('console.network_restricted', false);
}
/**
* Every network that may reach the console.
*
* The VPN is always in it and is not removable it is the one path that
* exists independently of whatever the owner has typed into the list, and
* without it a bad entry would leave nobody able to fix the entry.
*
* @return array<int, string>
*/
public static function allowedRanges(): array
{
$vpn = (array) config('admin_access.trusted_ranges', []);
$own = (array) Settings::get('console.allowed_ips', []);
return array_values(array_unique(array_filter(array_merge(
$vpn,
array_map('trim', array_map('strval', $own)),
))));
}
public static function allows(string $ip): bool
{
return self::covers($ip, self::allowedRanges());
}
/**
* Would this address still get in, given that list?
*
* Split out so the "you are about to lock yourself out" decision can be
* made and tested without a request: it is the one check whose failure
* mode is that nobody can reach the page that would fix it.
*
* @param array<int, string> $extra the owner's list AFTER the change
*/
public static function wouldStillAllow(string $ip, array $extra): bool
{
$vpn = (array) config('admin_access.trusted_ranges', []);
return self::covers($ip, array_merge($vpn, $extra));
}
/**
* Whether a value is an address or a CIDR range at all.
*
* Shared by the console and the recovery command on purpose: an entry that
* matches nothing is stored happily and reports success, and the operator
* then switches the restriction on believing they are covered. That is the
* exact situation the recovery command exists to get out of.
*/
public static function isNetwork(string $value): bool
{
[$address, $prefix] = array_pad(explode('/', $value, 2), 2, null);
if (filter_var($address, FILTER_VALIDATE_IP) === false) {
return false;
}
if ($prefix === null) {
return true;
}
$max = str_contains((string) $address, ':') ? 128 : 32;
return ctype_digit((string) $prefix) && (int) $prefix >= 0 && (int) $prefix <= $max;
}
/** @param array<int, string> $ranges */
private static function covers(string $ip, array $ranges): bool
{
$ranges = array_values(array_filter($ranges));
return $ranges !== [] && $ip !== '' && IpUtils::checkIp($ip, $ranges);
}
}

View File

@ -0,0 +1,74 @@
<?php
namespace App\Http\Middleware;
use App\Models\LoginSession;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Symfony\Component\HttpFoundation\Response;
/**
* Keeps a session row pointing at the session it describes.
*
* Laravel rotates the session id on every sign-in and on every regenerate().
* A row that stored the id once stops matching within a request or two and
* the list would then show sessions that cannot be ended and hide the one the
* reader is sitting in. The uuid inside the payload is what survives; the id is
* refreshed from it here.
*
* Also the only thing that keeps "last seen" honest. Without it the list
* reports the moment somebody signed in, which for a browser left open for a
* fortnight is the least useful timestamp available.
*
* Written at most once a minute. This runs on every authenticated request, and
* a write per request would turn a page of thumbnails into forty updates of the
* same row.
*/
class TouchLoginSession
{
private const THROTTLE_SECONDS = 60;
public function handle(Request $request, Closure $next): Response
{
$response = $next($request);
if (! $request->hasSession()) {
return $response;
}
$uuid = $request->session()->get(LoginSession::KEY);
if (! is_string($uuid) || $uuid === '') {
return $response;
}
$current = $request->session()->getId();
// A bare update rather than a model round-trip: this is on the path of
// every authenticated request, and the row is not needed, only moved.
DB::table('login_sessions')
->where('uuid', $uuid)
->where(function ($q) use ($current) {
// A CHANGED id is never throttled. Both sign-in paths raise the
// Login event before calling session()->regenerate(), so the row
// is written against an id that is dead a moment later. Held
// back by the throttle, the row would point at nothing for the
// first minute of every session — the one it describes missing
// from the list, and "end this one" deleting a session that no
// longer exists.
$q->where('session_id', '!=', $current)
->orWhereNull('session_id')
->orWhereNull('last_seen_at')
->orWhere('last_seen_at', '<=', now()->subSeconds(self::THROTTLE_SECONDS));
})
->update([
'session_id' => $current,
'ip_address' => $request->ip(),
'last_seen_at' => now(),
'updated_at' => now(),
]);
return $response;
}
}

View File

@ -0,0 +1,70 @@
<?php
namespace App\Jobs;
use App\Models\ExportTarget;
use App\Models\Invoice;
use App\Models\InvoiceExport;
use App\Services\Billing\InvoiceArchive;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Throwable;
/**
* Put one invoice on ONE destination, and keep trying if that one is away.
*
* One job per pair rather than one per invoice: the reason for having a second
* destination is that the first can fail, and a job that writes to both would
* retry the one that worked every time the other did not and would report a
* single outcome for two different things.
*
* Queued rather than done during the request that issued the invoice: a network
* mount can block for its whole timeout, and an invoice must not fail to be
* issued because a NAS in an office is rebooting.
*/
class ArchiveInvoice implements ShouldQueue
{
use Queueable;
public int $tries = 5;
/** A destination that is gone is usually gone for a while. */
public array $backoff = [60, 300, 900, 3600];
public function __construct(public Invoice $invoice, public ExportTarget $target) {}
public function handle(InvoiceArchive $archive): void
{
if (! $this->target->active) {
return;
}
$record = InvoiceExport::query()->firstOrNew([
'invoice_id' => $this->invoice->id,
'export_target_id' => $this->target->id,
]);
// Already there. A retry after a timeout that in fact succeeded must
// not write it a second time.
if ($record->exists && $record->exported_at !== null) {
return;
}
$record->attempts = (int) $record->attempts + 1;
$record->save();
$archive->store($this->invoice, $this->target);
$record->forceFill(['exported_at' => now(), 'error' => null])->save();
}
public function failed(?Throwable $e): void
{
// Against the pair, not the invoice: "on the office NAS, not on the
// off-site box" is the thing somebody needs to be told.
InvoiceExport::query()->updateOrCreate(
['invoice_id' => $this->invoice->id, 'export_target_id' => $this->target->id],
['error' => mb_substr((string) $e?->getMessage(), 0, 250)],
);
}
}

View File

@ -0,0 +1,89 @@
<?php
namespace App\Listeners;
use App\Models\Customer;
use App\Models\SentMail;
use Illuminate\Mail\Events\MessageSent;
use Illuminate\Support\Facades\Log;
use Throwable;
/**
* Write every outgoing mail into the register.
*
* On MessageSent rather than MessageSending: a row here means the transport
* accepted the message. Recording the intention instead would fill the register
* with mails that never left, which is worse than no register an operator
* would show a customer a line proving something was sent that was not.
*
* One listener for every mailable in the application, rather than a call in
* each of them. A register that has to be remembered at each call site is a
* register with holes in it, and the holes are exactly the mails nobody thought
* about.
*/
class RecordSentMail
{
/**
* Mails that are not worth a row.
*
* The verification and reset mails go to an address that has not been
* confirmed to belong to anybody yet, and both are sent in bulk by anyone
* who can type an address into a form. Recording them would let a stranger
* write lines into the operator's register at will.
*/
private const IGNORED = [
\App\Mail\VerifyEmailMail::class,
\App\Mail\ResetPasswordMail::class,
];
public function handle(MessageSent $event): void
{
try {
$this->record($event);
} catch (Throwable $e) {
// A register that cannot be written must never be the reason a mail
// fails. The send already happened by the time this runs, and
// throwing here would turn a delivered message into a failed job
// that gets delivered a second time on retry.
Log::error('Could not record a sent mail', ['exception' => $e]);
}
}
private function record(MessageSent $event): void
{
$mailable = $event->data['__laravel_mailable'] ?? null;
if ($mailable !== null && in_array($mailable, self::IGNORED, true)) {
return;
}
$message = $event->message;
$to = collect($message->getTo())->map(fn ($address) => $address->getAddress())->all();
if ($to === []) {
return;
}
// One row per recipient. A mail to three addresses is three deliveries,
// and an operator asking "what did this customer get" must not have to
// read someone else's row to find out.
foreach ($to as $address) {
SentMail::create([
'customer_id' => $this->customerFor($address)?->id,
'to' => $address,
'subject' => (string) $message->getSubject(),
'mailable' => $mailable,
// Set by the console when an operator writes one themselves;
// see Admin\CustomerMessage, which fills the body in afterwards.
'from_operator' => false,
'sent_at' => now(),
]);
}
}
/** Whose mail this was, where the address belongs to somebody we know. */
private function customerFor(string $address): ?Customer
{
return Customer::query()->where('email', $address)->first();
}
}

View File

@ -0,0 +1,66 @@
<?php
namespace App\Listeners;
use App\Mail\NewDeviceSignInMail;
use App\Services\Devices\DeviceRecognition;
use Illuminate\Auth\Events\Login;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
use Throwable;
/**
* One listener for both ways in.
*
* The console signs in through App\Livewire\Auth\OperatorLogin and the portal
* through Fortify two code paths that share nothing. Both raise Laravel's
* Login event, and it carries the guard, which is the one thing that tells
* operators and customers apart (R21). Hooking the event rather than the two
* controllers means neither can be changed in a way that quietly stops
* recording sign-ins.
*
* Nothing here may prevent a sign-in. Recognising a device is a safeguard, and
* a safeguard that locks the owner out when it breaks has stopped being one
* so a failure is logged and swallowed. This is a deliberate exception to
* failing loudly, and the reason is written here so nobody removes it as an
* oversight.
*/
class RecordSignInDevice
{
public function __construct(
private readonly DeviceRecognition $devices,
private readonly Request $request,
) {}
public function handle(Login $event): void
{
try {
$outcome = $this->devices->recognise($event->user, $event->guard, $this->request);
$this->devices->attachSession($outcome->device, $this->request);
if (! $outcome->shouldWarn) {
return;
}
$address = $event->user->getAttribute('email');
if (! is_string($address) || $address === '') {
return;
}
// Queued: a sign-in must not wait on a mail server, and this one
// runs while somebody is looking at a spinner on the login button.
Mail::to($address)->queue(new NewDeviceSignInMail(
name: (string) ($event->user->getAttribute('name') ?? ''),
device: $outcome->device,
guard: $event->guard,
));
} catch (Throwable $e) {
Log::warning('Could not record the device for a sign-in', [
'guard' => $event->guard,
'exception' => $e->getMessage(),
]);
}
}
}

View File

@ -0,0 +1,112 @@
<?php
namespace App\Livewire\Admin;
use App\Models\Host;
use App\Services\Provisioning\HostCapacity;
use App\Services\Provisioning\ServerMarket;
use Livewire\Attributes\Layout;
use Livewire\Component;
/**
* Who is waiting for a machine, and what machine would clear them.
*
* A paid order that cannot be placed is parked rather than failed the
* customer has paid, and "no host has room" is a purchasing decision, not a
* fault. This is where that decision is taken: what is queued, what the
* roomiest host still has, what the queue needs, and what such a machine costs
* today.
*
* Nothing here buys anything. Ordering a server is a contract; a bug in a
* capacity calculation must not be able to sign one.
*/
#[Layout('layouts.admin')]
class Capacity extends Component
{
/**
* Look again now.
*
* The runner retries every couple of minutes on its own. This is for the
* operator who has just finished onboarding a host and does not want to
* wait for the tick to see the queue drain.
*/
public function recheck(): void
{
$this->authorize('hosts.manage');
// Through the same query that FINDS them, so "look again" can never
// mean a different set of runs than the table above it is showing.
app(HostCapacity::class)->parkedRuns()->update(['next_attempt_at' => now()]);
$this->dispatch('notify', message: __('capacity.rechecked'));
}
/**
* Decide which parked order goes on which host.
*
* The queue is read by somebody who knows things the placement rule does
* not that this customer belongs beside that one, that a machine is about
* to be taken out of service, that the big order should have the new box
* and the small ones can share. The rule stays in charge when nobody says
* otherwise; an empty choice hands it back.
*
* Only the preference is written. The reservation still happens in the
* provisioning step, under the placement lock, and still checks the host
* has room a console that created the instance itself would be a second
* placement path with no lock and its own arithmetic.
*/
public function pin(int $runId, string $hostId): void
{
$this->authorize('hosts.manage');
// Only a run that is actually parked. Writing a host preference onto a
// run that has moved on would sit in its context for ever and take
// effect on a re-run months later, on a host chosen for a queue that no
// longer exists.
$run = app(HostCapacity::class)->parkedRuns()->whereKey($runId)->first();
if ($run === null) {
return;
}
if ($hostId === '') {
$run->forgetContext('preferred_host_id');
$this->dispatch('notify', message: __('capacity.pin_cleared'));
return;
}
$host = Host::query()->where('status', 'active')->find((int) $hostId);
if ($host === null) {
return;
}
$run->mergeContext(['preferred_host_id' => $host->id]);
// Taken up now rather than at the next tick: an operator who has just
// said where it goes is watching for it to go there.
$run->forceFill(['next_attempt_at' => now()])->save();
$this->dispatch('notify', message: __('capacity.pinned', ['host' => $host->name]));
}
public function render()
{
$this->authorize('hosts.manage');
$capacity = app(HostCapacity::class);
$demand = $capacity->queueDemand();
return view('livewire.admin.capacity', [
'parked' => $capacity->parked(),
'demand' => $demand,
'unplaceable' => $capacity->unplaceablePlans(),
'hosts' => Host::query()->where('status', 'active')->orderBy('name')->get(),
// Only when something is actually waiting. A shopping list on an
// idle estate is an invitation to spend money for no reason.
'offers' => $demand['count'] > 0
? app(ServerMarket::class)->offers($demand['largest'])
: [],
]);
}
}

View File

@ -0,0 +1,60 @@
<?php
namespace App\Livewire\Admin;
use App\Models\Datacenter;
use App\Models\Host;
use Illuminate\Database\QueryException;
use LivewireUI\Modal\ModalComponent;
/**
* Confirmation for deleting a datacenter (R5). Guarded: a datacenter that still
* has hosts cannot be deleted it would orphan host placement so the modal
* blocks and explains instead. Otherwise it is safe to hard-delete an unused code.
*/
class ConfirmDeleteDatacenter extends ModalComponent
{
public string $uuid;
public string $name = '';
public int $hostCount = 0;
public function mount(string $uuid): void
{
$this->authorize('datacenters.manage'); // modals are reachable without the route middleware
$dc = Datacenter::query()->where('uuid', $uuid)->withCount('hosts')->firstOrFail();
$this->uuid = $uuid;
$this->name = $dc->name;
$this->hostCount = $dc->hosts_count;
}
public function delete()
{
$this->authorize('datacenters.manage');
$dc = Datacenter::query()->where('uuid', $this->uuid)->first();
if ($dc === null) {
return $this->redirectRoute('admin.datacenters', navigate: true);
}
// The hosts.datacenter → datacenters.code foreign key (restrictOnDelete)
// is the source of truth: the DB refuses to orphan a host. We pre-check
// for a friendly message and catch the constraint as the race backstop.
if (Host::query()->where('datacenter', $dc->code)->exists()) {
return $this->redirectRoute('admin.datacenters', navigate: true);
}
try {
$dc->delete();
} catch (QueryException) {
// A host was created for this code concurrently — leave it intact.
}
return $this->redirectRoute('admin.datacenters', navigate: true);
}
public function render()
{
return view('livewire.admin.confirm-delete-datacenter');
}
}

View File

@ -0,0 +1,40 @@
<?php
namespace App\Livewire\Admin;
use App\Models\Incident;
use LivewireUI\Modal\ModalComponent;
/**
* "Really remove this incident?"
*
* In the product's own dialog, not the browser's (R23). The modal mutates
* nothing itself: it dispatches, and Admin\Incidents which already holds the
* capability check does the work. Duplicating the authorisation here would
* mean two places to keep right.
*/
class ConfirmDeleteIncident extends ModalComponent
{
public string $uuid = '';
public string $title = '';
public function mount(string $uuid): void
{
$incident = Incident::query()->where('uuid', $uuid)->firstOrFail();
$this->uuid = $incident->uuid;
$this->title = $incident->title;
}
public function confirm(): void
{
$this->dispatch('incident-delete-confirmed', uuid: $this->uuid);
$this->closeModal();
}
public function render()
{
return view('livewire.admin.confirm-delete-incident');
}
}

View File

@ -0,0 +1,60 @@
<?php
namespace App\Livewire\Admin;
use App\Models\PlanVersion;
use App\Services\Billing\PlanCatalogue;
use LivewireUI\Modal\ModalComponent;
/**
* Confirmation for discarding an unpublished plan version.
*
* Only a draft can be discarded at all a published version is what customers
* are contracted to, and the model refuses to delete one. So this is a small
* destructive action, and the modal says which one it is rather than leaving
* "delete" next to a row that might be either.
*/
class ConfirmDeletePlanDraft extends ModalComponent
{
public string $uuid;
public int $version;
public string $plan = '';
public function mount(string $uuid): void
{
// Modals are reachable without the page's guards, so this is the real
// check, not a convenience one.
$this->authorize('plans.manage');
$version = PlanVersion::query()->with('family')->where('uuid', $uuid)->firstOrFail();
abort_if($version->isPublished(), 403);
$this->uuid = $uuid;
$this->version = $version->version;
$this->plan = $version->family->name;
}
public function delete(): void
{
$this->authorize('plans.manage');
$version = PlanVersion::query()->where('uuid', $this->uuid)->first();
// The catalogue decides, in one conditional statement. Checking here and
// deleting afterwards would leave a window in which the version is
// published between the two — and a published version must never go.
if ($version !== null && ! app(PlanCatalogue::class)->discardDraft($version)) {
$this->dispatch('notify', message: __('plans.discard_too_late'));
}
$this->dispatch('plan-draft-deleted');
$this->closeModal();
}
public function render()
{
return view('livewire.admin.confirm-delete-plan-draft');
}
}

View File

@ -0,0 +1,58 @@
<?php
namespace App\Livewire\Admin;
use App\Models\VpnPeer;
use App\Provisioning\Jobs\ApplyVpnPeer;
use LivewireUI\Modal\ModalComponent;
/**
* Confirmation for revoking a VPN access for good (R5). Deleting a peer that
* belongs to a host cuts CluPilot's own management path to that machine, so the
* modal says so plainly rather than hiding the option.
*/
class ConfirmDeleteVpnPeer extends ModalComponent
{
public string $uuid;
public string $name = '';
public ?string $hostName = null;
public function mount(string $uuid): void
{
// Modals are reachable without the route middleware, so this is the
// real guard, not a convenience check.
$peer = VpnPeer::query()->with('host')->where('uuid', $uuid)->firstOrFail();
$this->authorize('delete', $peer);
$this->uuid = $uuid;
$this->name = $peer->name;
$this->hostName = $peer->host?->name;
}
public function delete(): void
{
$peer = VpnPeer::query()->where('uuid', $this->uuid)->first();
if ($peer !== null) {
$this->authorize('delete', $peer);
// A tombstone that still carries a usable private key is a liability.
$peer->purgeSecret();
// Soft-delete, so the row survives as a tombstone until the hub has
// actually dropped the peer. A hard delete here would let the next
// sync adopt the still-present peer back as a live access —
// silently restoring what was just revoked. ApplyVpnPeer purges the
// tombstone once removal succeeded; SyncVpnPeers retries if not.
$peer->delete();
ApplyVpnPeer::dispatch($peer->public_key, null, false);
}
$this->dispatch('vpn-peer-deleted');
$this->closeModal();
}
public function render()
{
return view('livewire.admin.confirm-delete-vpn-peer');
}
}

View File

@ -0,0 +1,24 @@
<?php
namespace App\Livewire\Admin;
use LivewireUI\Modal\ModalComponent;
/**
* Confirmation before an operator's own two-factor login is removed (R23).
* No mutation here TwoFactorSetup::disableTwoFactor() still runs its own
* requireConfirmedPassword() check, unchanged.
*/
class ConfirmDisableTwoFactor extends ModalComponent
{
public function confirm(): void
{
$this->dispatch('two-factor-disable-confirmed');
$this->closeModal();
}
public function render()
{
return view('livewire.admin.confirm-disable-two-factor');
}
}

View File

@ -0,0 +1,24 @@
<?php
namespace App\Livewire\Admin;
use LivewireUI\Modal\ModalComponent;
/**
* Confirmation before an operator signs every other browser out (R23). The
* console's own, because operators and customers are two identities (R21) and
* one shared modal would raise an event both sides listen for.
*/
class ConfirmEndOtherSessions extends ModalComponent
{
public function confirm(): void
{
$this->dispatch('sessions-end-others-confirmed');
$this->closeModal();
}
public function render()
{
return view('livewire.admin.confirm-end-other-sessions');
}
}

View File

@ -0,0 +1,39 @@
<?php
namespace App\Livewire\Admin;
use App\Services\Secrets\SecretVault;
use LivewireUI\Modal\ModalComponent;
/**
* Confirmation before a stored override is removed (R23), reverting the key
* to whatever the server file provides. No mutation here it dispatches
* back to Secrets, whose forget() is unchanged.
*/
class ConfirmForgetSecret extends ModalComponent
{
public string $key;
public string $label = '';
public function mount(string $key): void
{
$this->authorize('secrets.manage');
abort_if(! array_key_exists($key, SecretVault::REGISTRY), 404);
$this->key = $key;
$this->label = __(SecretVault::REGISTRY[$key]['label']);
}
public function confirm(): void
{
$this->authorize('secrets.manage');
$this->dispatch('secret-forget-confirmed', key: $this->key);
$this->closeModal();
}
public function render()
{
return view('livewire.admin.confirm-forget-secret');
}
}

View File

@ -0,0 +1,45 @@
<?php
namespace App\Livewire\Admin;
use App\Models\DpaVersion;
use LivewireUI\Modal\ModalComponent;
/**
* Confirmation before a processing agreement goes into force (R23).
*
* Publishing is not a save: acceptance is per version, so from this moment every
* customer who had accepted the previous one is outstanding again. That is the
* correct behaviour and an expensive one to trigger by a misplaced click.
*
* Mutates nothing itself it dispatches back to ProcessingAgreements, whose
* publish() keeps the permission check it already had.
*/
class ConfirmPublishDpa extends ModalComponent
{
public string $uuid;
public string $version = '';
public function mount(string $uuid): void
{
$this->authorize('dpa.manage');
$version = DpaVersion::query()->where('uuid', $uuid)->firstOrFail();
$this->uuid = $uuid;
$this->version = $version->version;
}
public function confirm(): void
{
$this->authorize('dpa.manage');
$this->dispatch('dpa-publish-confirmed', uuid: $this->uuid);
$this->closeModal();
}
public function render()
{
return view('livewire.admin.confirm-publish-dpa');
}
}

View File

@ -0,0 +1,39 @@
<?php
namespace App\Livewire\Admin;
use App\Models\VpnPeer;
use LivewireUI\Modal\ModalComponent;
/**
* Confirmation before a staff access gets a fresh keypair (R23). The reissue
* itself still runs on Vpn::reissue(), unchanged this only gates it and,
* once confirmed, dispatches back so the page can show the new config.
*/
class ConfirmReissueVpnPeer extends ModalComponent
{
public string $uuid;
public string $name = '';
public function mount(string $uuid): void
{
$peer = VpnPeer::query()->where('uuid', $uuid)->firstOrFail();
$this->authorize('update', $peer);
abort_if($peer->kind !== VpnPeer::KIND_STAFF, 404);
$this->uuid = $uuid;
$this->name = $peer->name;
}
public function confirm(): void
{
$this->dispatch('vpn-peer-reissue-confirmed', uuid: $this->uuid);
$this->closeModal();
}
public function render()
{
return view('livewire.admin.confirm-reissue-vpn-peer');
}
}

View File

@ -0,0 +1,46 @@
<?php
namespace App\Livewire\Admin;
use App\Models\Host;
use App\Provisioning\Jobs\PurgeHost;
use LivewireUI\Modal\ModalComponent;
/**
* Confirmation for the destructive "remove host" action (R5). Deregisters the
* CluPilot record only it does not touch the physical server. The host is
* deactivated immediately and purged asynchronously so removal never blocks on
* (or races) an in-flight provisioning step.
*/
class ConfirmRemoveHost extends ModalComponent
{
public string $uuid;
public string $name = '';
public function mount(string $uuid): void
{
$this->uuid = $uuid;
$this->name = Host::query()->where('uuid', $uuid)->value('name') ?? '';
}
public function remove()
{
$this->authorize('hosts.manage');
$host = Host::query()->where('uuid', $this->uuid)->first();
if ($host !== null) {
// Deactivate now (out of placement, visibly removed), finalize on the
// provisioning worker which waits for the runner lock.
$host->update(['status' => 'disabled']);
PurgeHost::dispatch($host->uuid);
}
return $this->redirectRoute('admin.hosts', navigate: true);
}
public function render()
{
return view('livewire.admin.confirm-remove-host');
}
}

View File

@ -0,0 +1,48 @@
<?php
namespace App\Livewire\Admin;
use App\Models\Instance;
use LivewireUI\Modal\ModalComponent;
/**
* Confirmation before an operator restarts a customer's machine (R23).
*
* The customer is not asked and may be working in it, so the address is spelled
* out in the question an operator confirming a restart should be reading the
* hostname of the cloud that is about to go away, not the position of a row in a
* paginated table.
*
* No mutation here. Instances::restart() keeps the work and both authorisation
* checks it already had.
*/
class ConfirmRestartInstance extends ModalComponent
{
public string $uuid;
public string $address = '';
public function mount(string $uuid): void
{
// A modal is reachable without the page's own route middleware (R20), so
// it resolves the record itself rather than trusting a property the
// browser hydrated.
$instance = Instance::query()->where('uuid', $uuid)->firstOrFail();
$this->uuid = $uuid;
$this->address = $instance->domainIsVerified()
? (string) $instance->custom_domain
: (string) $instance->subdomain;
}
public function proceed(): void
{
$this->dispatch('instance-restart-confirmed', uuid: $this->uuid);
$this->closeModal();
}
public function render()
{
return view('livewire.admin.confirm-restart-instance');
}
}

View File

@ -0,0 +1,35 @@
<?php
namespace App\Livewire\Admin;
use LivewireUI\Modal\ModalComponent;
/**
* Confirmation before .env the file that holds every credential in the
* system is overwritten (R23).
*
* The content being written lives only in Integrations' own $envContent; this
* modal never sees it and cannot mutate anything itself. Confirming
* dispatches back to the page component, whose saveEnv() keeps its own
* guardSecrets() (secrets.manage + a recently confirmed password) unchanged
* same pattern as ConfirmSaveSecret/ConfirmForgetSecret.
*/
class ConfirmSaveEnv extends ModalComponent
{
public function mount(): void
{
$this->authorize('secrets.manage');
}
public function confirm(): void
{
$this->authorize('secrets.manage');
$this->dispatch('env-save-confirmed');
$this->closeModal();
}
public function render()
{
return view('livewire.admin.confirm-save-env');
}
}

View File

@ -0,0 +1,42 @@
<?php
namespace App\Livewire\Admin;
use App\Services\Secrets\SecretVault;
use LivewireUI\Modal\ModalComponent;
/**
* Confirmation before a credential takes effect (R23).
*
* The value being saved lives only in Secrets' own (deferred) `entered`
* property this modal never sees it and cannot mutate anything itself.
* Confirming dispatches back to the page component, whose save() keeps its
* own guard() (capability + recent password) unchanged.
*/
class ConfirmSaveSecret extends ModalComponent
{
public string $key;
public string $label = '';
public function mount(string $key): void
{
$this->authorize('secrets.manage');
abort_if(! array_key_exists($key, SecretVault::REGISTRY), 404);
$this->key = $key;
$this->label = __(SecretVault::REGISTRY[$key]['label']);
}
public function confirm(): void
{
$this->authorize('secrets.manage');
$this->dispatch('secret-save-confirmed', key: $this->key);
$this->closeModal();
}
public function render()
{
return view('livewire.admin.confirm-save-secret');
}
}

View File

@ -0,0 +1,46 @@
<?php
namespace App\Livewire\Admin;
use App\Support\OperatingMode;
use LivewireUI\Modal\ModalComponent;
/**
* Confirmation before the operating mode changes (R23).
*
* Wortgleiches Muster zu ConfirmSaveSecret: dieses Modal mutiert nichts selbst
* es ruft `OperatingMode::set()` nirgends auf. Bestätigen löst nur ein
* Ereignis aus, das Integrations::switchMode() auffängt; deren eigene Sperre
* (secrets.manage + kürzlich bestätigtes Passwort) bleibt die einzige Stelle,
* die tatsächlich etwas schreibt. Ein Modus, der hier gefälscht hereinkäme,
* muss trotzdem switchMode()s eigenes tryFrom() und guardSecrets() passieren.
*/
class ConfirmSwitchMode extends ModalComponent
{
public string $mode;
public function mount(string $mode): void
{
$this->authorize('secrets.manage');
// Ein Wert aus dem Aufrufort dieses Modals — dieselbe Prüfung, die
// switchMode() gleich noch einmal macht: ein unbekannter Modus 404t
// hier, statt eine Bestätigung für etwas zu zeigen, das nachher
// ohnehin still nichts tut.
abort_if(OperatingMode::tryFrom($mode) === null, 404);
$this->mode = $mode;
}
public function confirm(): void
{
$this->authorize('secrets.manage');
$this->dispatch('mode-switch-confirmed', mode: $this->mode);
$this->closeModal();
}
public function render()
{
return view('livewire.admin.confirm-switch-mode');
}
}

View File

@ -0,0 +1,240 @@
<?php
namespace App\Livewire\Admin;
use App\Mail\OperatorMessageMail;
use App\Models\Customer;
use App\Models\InboundMail;
use App\Models\Instance;
use App\Models\Invoice;
use App\Models\MailTemplate;
use App\Models\Order;
use App\Models\SentMail;
use App\Models\Subscription;
use App\Models\SupportRequest;
use App\Services\Mail\MailTemplateRenderer;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Mail;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Url;
use Livewire\Attributes\Validate;
use Livewire\Component;
/**
* One customer: who they are, what they bought, what they paid, what was said.
*
* The first version of this page was a compose box with three lists under it,
* and the reported complaint was exactly right it showed nothing ABOUT the
* customer. An operator on the phone needs the address, the package, whether
* the last invoice was paid and what was already said to them, and none of that
* was here.
*
* TABBED, like Settings and Integrations, for the same reason: this is four
* different questions, and stacking them makes a page nobody reads to the
* bottom. Writing is the LAST tab, because it is the thing you do after
* reading and it is where the templates come in, so the same answer is not
* retyped differently for every customer.
*
* The open tab is in the query string: a reload, a bookmark and the back button
* all land where the operator was, and a link can point straight at the tab
* that matters ("…/customers/x?tab=billing").
*/
#[Layout('layouts.admin')]
class CustomerDetail extends Component
{
/**
* The tabs, in order. The list IS the schema: it validates the query
* string, builds the bar and decides what renders.
*/
public const TABS = ['overview', 'package', 'billing', 'messages', 'compose'];
public Customer $customer;
/**
* Which tab is open, in the address bar.
*
* `history: true`, so each tab is a step the back button can take and no
* `except`, so the parameter is there from the first render rather than
* appearing only once somebody leaves the default tab. Hiding it on the
* default read as "the tab is not in the URL", which is exactly how it was
* reported: a link to this page could not say which tab it meant unless the
* tab happened not to be the first one.
*/
#[Url(history: true)]
public string $tab = 'overview';
// ---- Writing to them ----
#[Validate('required|string|max:200')]
public string $subject = '';
#[Validate('required|string|min:3|max:5000')]
public string $body = '';
/**
* The request being answered, if this message is an answer to one.
*
* Answering marks it answered which is the whole reason to answer from
* here rather than from a mail client, where the request stays open for
* ever because nothing ever told it otherwise.
*/
public ?string $answering = null;
public function mount(string $uuid): void
{
$this->authorize('customers.manage');
$this->customer = Customer::query()->where('uuid', $uuid)->firstOrFail();
if (! in_array($this->tab, self::TABS, true)) {
$this->tab = self::TABS[0];
}
}
/**
* Put a template into the fields, filled in for THIS customer.
*
* Inserted, never sent: the placeholders come out filled and the last two
* sentences are still the operator's to write. What goes out is what they
* saw and edited.
*/
public function useTemplate(string $uuid): void
{
$this->authorize('customers.manage');
$template = MailTemplate::query()->where('uuid', $uuid)->where('active', true)->first();
if ($template === null) {
return;
}
$filled = app(MailTemplateRenderer::class)->render(
$template,
$this->customer,
Auth::guard('operator')->user(),
);
// The subject is only taken when the operator has not written one — a
// template that overwrites what somebody typed is a template that eats
// work. The body is replaced, because that is what "use this template"
// means and the field is empty in the case that matters.
if (trim($this->subject) === '') {
$this->subject = $filled['subject'];
}
$this->body = $filled['body'];
$this->tab = 'compose';
}
/** Start an answer to one request: subject prefilled, the request remembered. */
public function answer(string $uuid): void
{
$this->authorize('customers.manage');
$request = $this->customer->supportRequests()->where('uuid', $uuid)->first();
if ($request === null) {
return;
}
$this->answering = $request->uuid;
$this->subject = __('customer_message.re', ['subject' => $request->subject]);
$this->body = '';
$this->tab = 'compose';
}
public function cancelAnswer(): void
{
$this->answering = null;
$this->subject = '';
$this->body = '';
}
/**
* Send it, record it, and close the request it answers.
*
* The register row is written here rather than left to the MessageSent
* listener alone, because this is the one mail whose BODY is worth keeping:
* an operator typed it, and "what exactly did I write to them in March" is a
* question the mail server's log cannot answer either.
*/
public function send(): void
{
$this->authorize('customers.manage');
$data = $this->validate();
Mail::to($this->customer->email)->send(
new OperatorMessageMail($this->customer, $data['subject'], $data['body'])
);
SentMail::query()
->where('customer_id', $this->customer->id)
->where('to', $this->customer->email)
->latest('id')
->limit(1)
->update(['from_operator' => true, 'body' => $data['body']]);
if ($this->answering !== null) {
$this->customer->supportRequests()
->where('uuid', $this->answering)
->update(['status' => 'answered', 'answered_at' => now()]);
}
$this->cancelAnswer();
$this->dispatch('notify', message: __('customer_message.sent'));
}
public function render()
{
$this->authorize('customers.manage');
return view('livewire.admin.customer-detail', [
'tabs' => self::TABS,
'subscription' => $this->subscription(),
'instance' => Instance::query()
->where('customer_id', $this->customer->id)
->latest('id')
->first(),
'orders' => Order::query()
->where('customer_id', $this->customer->id)
->latest('id')
->limit(20)
->get(),
'invoices' => Invoice::query()
->where('customer_id', $this->customer->id)
->orderByDesc('issued_on')
->orderByDesc('id')
->limit(20)
->get(),
'requests' => $this->customer->supportRequests()->latest('id')->get(),
'inbound' => InboundMail::query()
->where('customer_id', $this->customer->id)
->latest('received_at')
->limit(30)
->get(),
'mails' => SentMail::query()
->where('customer_id', $this->customer->id)
->latest('sent_at')
->limit(30)
->get(),
'templates' => MailTemplate::query()->usable()->get(),
// Counted for the tab bar, so an operator sees where the unread
// things are before opening anything.
'openRequests' => $this->customer->supportRequests()
->whereNotIn('status', SupportRequest::CLOSED)
->count(),
]);
}
/** The contract that is being billed, or the most recent one there was. */
private function subscription(): ?Subscription
{
return Subscription::query()
->where('customer_id', $this->customer->id)
->with('addons')
->orderByRaw("status = 'active' DESC")
->latest('id')
->first();
}
}

View File

@ -0,0 +1,140 @@
<?php
namespace App\Livewire\Admin;
use App\Models\Customer;
use App\Models\PlanFamily;
use App\Services\Billing\PlanCatalogue;
use App\Services\Billing\WithdrawalRight;
use Illuminate\Support\Number;
use Livewire\Attributes\Layout;
use Livewire\Component;
#[Layout('layouts.admin')]
class Customers extends Component
{
/** Suspend / reactivate a customer account (a guarded lifecycle action, not delete). */
public function toggleSuspend(string $uuid): void
{
$this->authorize('customers.manage');
$customer = Customer::query()->where('uuid', $uuid)->first();
if ($customer === null) {
return;
}
// A closed account is terminal — the suspend/reactivate toggle must not
// resurrect it to active while closed_at is still set.
if ($customer->closed_at !== null || $customer->status === 'closed') {
return;
}
$customer->update([
'status' => $customer->status === 'suspended' ? 'active' : 'suspended',
]);
$this->dispatch('notify', message: __('admin.customer_'.($customer->status === 'suspended' ? 'suspended' : 'reactivated')));
}
public function render()
{
$locale = app()->getLocale();
$plans = app(PlanCatalogue::class)->sellable();
$customers = Customer::query()
->with(['instances.subscription'])
->orderBy('name')
->get();
$rows = $customers->map(function (Customer $c) use ($plans, $locale) {
$instance = $c->instances->sortByDesc('id')->first();
$planKey = $instance->plan ?? null;
// What this customer actually pays, off their contract — not what
// the plan costs today. Otherwise a price rise inflates reported
// revenue for every grandfathered customer overnight.
//
// Per MONTH, whatever the term: a yearly contract stores the whole
// year, and adding that to a monthly column would report twelve
// times the revenue for anyone who paid up front.
$contract = $instance?->subscription;
// The customer is already loaded on this row, so hand it to the
// contract rather than letting WithdrawalRight fetch it again —
// that is one query per customer on a page that lists all of them.
$contract?->setRelation('customer', $c);
$priceCents = (int) ($contract?->monthlyPriceCents()
?? ($planKey !== null ? ($plans[$planKey]['price_cents'] ?? 0) : 0));
return [
'uuid' => $c->uuid,
'name' => $c->name,
'plan' => $planKey !== null ? __('billing.plan.'.$planKey) : '—',
// A gift or a discount, not a sale — flagged here rather than
// inferred from the price, which a genuinely cheap plan could
// also show.
'granted' => (bool) $contract?->isGranted(),
// What this package is about to become. An operator answering a
// question about a customer's plan has to be able to see that
// it is booked to shrink at the end of the term, or they will
// quote today's package for a bill that is already scheduled to
// change. The MRR beside it is still today's, which is correct:
// nothing has moved yet.
'pending_change' => $contract?->hasPendingPlanChange()
? [
'plan' => __('billing.plan.'.$contract->pending_plan),
'at' => $contract->pending_effective_at->local()->isoFormat('LL'),
]
: null,
'mrr' => Number::currency($priceCents / 100, in: 'EUR', locale: $locale),
'instance' => $instance->subdomain ?? '—',
// Only an instance that exists can hand out an admin login.
'instance_uuid' => ($instance?->status === 'active') ? $instance->uuid : null,
// Whether a withdrawal taken by telephone or post could still be
// recorded for this customer. Only a hint for the button: the
// refusal that counts is made again inside WithdrawContract, so
// a stale list cannot let one through.
'withdrawal_open' => WithdrawalRight::for($contract)->open,
'closed' => $c->closed_at !== null || $c->status === 'closed',
'suspended' => $c->status === 'suspended',
'status' => match (true) {
$c->closed_at !== null || $c->status === 'closed' => 'closed',
$c->status === 'suspended' => 'suspended',
default => $instance->status ?? $c->status ?? 'provisioning',
},
];
})->all();
// Plan distribution for the doughnut — derived from live instances, and
// keyed by what customers are actually ON, not by what is on sale. A
// withdrawn plan still has customers, and dropping them from the chart
// would quietly understate the estate.
$labels = [];
$counts = [];
foreach (PlanFamily::query()->orderBy('tier')->pluck('key') as $planKey) {
$n = $customers->filter(fn (Customer $c) => $c->instances->contains('plan', $planKey))->count();
if ($n > 0) {
$labels[] = __('billing.plan.'.$planKey);
$counts[] = $n;
}
}
return view('livewire.admin.customers', [
'rows' => $rows,
'plansChart' => [
'type' => 'doughnut',
'data' => [
'labels' => $labels,
'datasets' => [[
'data' => $counts,
'backgroundColor' => ['token:info', 'token:accent', 'token:success-bright', 'token:warning'],
'borderWidth' => 0,
]],
],
'options' => [
'cutout' => '62%',
'plugins' => ['legend' => ['position' => 'bottom', 'labels' => ['boxWidth' => 10, 'padding' => 12]]],
],
],
]);
}
}

View File

@ -0,0 +1,79 @@
<?php
namespace App\Livewire\Admin;
use App\Models\Datacenter;
use Illuminate\Validation\Rule;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Validate;
use Livewire\Component;
#[Layout('layouts.admin')]
class Datacenters extends Component
{
#[Validate(['required', 'string', 'max:8', 'regex:/^[a-z0-9]([a-z0-9-]{0,6}[a-z0-9])?$/', 'unique:datacenters,code'])]
public string $code = '';
#[Validate('required|string|max:255')]
public string $name = '';
/**
* Which building, not which city fsn-dc-15. Optional, free text, and
* nothing keys off it: see EditDatacenter and the migration that adds it.
*
* Offered here as well as in the edit modal so that creating a datacenter
* and saying which one it is are not two separate errands.
*/
#[Validate('nullable|string|max:255')]
public string $facility = '';
// Country picked from config/countries.php (no manual code typos).
#[Validate('nullable|string|max:2')]
public string $location = '';
public function save(): void
{
$this->authorize('datacenters.manage');
// Normalize before validating so the unique check matches how the row is
// stored — otherwise `FSN` passes the rule but the lowercased insert collides.
$this->code = strtolower(trim($this->code));
// Country must be one of the curated list — a forged request can bypass
// the <select>, so enforce membership server-side (mirrors EditDatacenter).
$data = $this->validate([
// A datacenter code becomes a DNS label (fsn-01.node.clupilot.com),
// so it has to be one: lowercase, no underscores, no leading or
// trailing dash.
'code' => ['required', 'string', 'max:8', 'regex:/^[a-z0-9]([a-z0-9-]{0,6}[a-z0-9])?$/', 'unique:datacenters,code'],
'name' => 'required|string|max:255',
'facility' => 'nullable|string|max:255',
'location' => ['nullable', Rule::in(array_keys((array) config('countries')))],
]);
Datacenter::create([
'code' => $data['code'],
'name' => $data['name'],
'facility' => $data['facility'] ?: null,
'location' => $data['location'] ?: null,
'active' => true,
]);
$this->reset('code', 'name', 'facility', 'location');
$this->dispatch('notify', message: __('datacenters.created'));
}
public function toggle(string $uuid): void
{
$this->authorize('datacenters.manage');
$datacenter = Datacenter::query()->where('uuid', $uuid)->first();
$datacenter?->update(['active' => ! $datacenter->active]);
}
public function render()
{
return view('livewire.admin.datacenters', [
'datacenters' => Datacenter::query()->withCount('hosts')->orderBy('name')->get(),
'countries' => config('countries'),
]);
}
}

View File

@ -0,0 +1,229 @@
<?php
namespace App\Livewire\Admin;
use App\Models\Customer;
use App\Models\Operator;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\Rule;
use LivewireUI\Modal\ModalComponent;
/**
* Correct a customer's own details.
*
* The console could show them and not touch them, so a moved office or a new
* contact person meant either impersonating the customer or editing the database
* by hand. Both are worse than a form.
*
* R20: a modal, not the row. And a modal is reachable WITHOUT the page's route
* middleware, so it authorises again and reads the record itself rather than
* trusting anything the browser hydrated.
*
* ## What is not here, and why
*
* `status` suspending or reactivating is its own action on the customer list,
* with its own consequences for access; a lifecycle switch hidden among address
* fields is one somebody flips by accident.
*
* `stripe_customer_id` Stripe's own identifier for them. Typing a different
* one does not move a contract; it points our records at somebody else's
* payments.
*
* The brand fields the customer sets those for themselves in the portal, and
* an operator overwriting somebody's logo is not a correction.
*/
class EditCustomer extends ModalComponent
{
/** The two languages this application actually has. Empty = as configured. */
public const LOCALES = ['de', 'en'];
public string $uuid = '';
public string $name = '';
public string $contactName = '';
public string $email = '';
public string $phone = '';
public string $customerType = '';
public string $vatId = '';
/**
* The address, field by field the same four the customer's own settings
* page writes. One free-text block here and four fields there would mean an
* operator's correction being silently reverted the next time the customer
* saved their profile: their fields still held the old values.
*/
public string $billingStreet = '';
public string $billingPostcode = '';
public string $billingCity = '';
public string $billingCountry = '';
public string $locale = '';
/** The address the record had when the modal opened, to notice a change. */
public string $originalEmail = '';
public string $originalVatId = '';
public function mount(string $uuid): void
{
$this->authorize('customers.manage');
$customer = Customer::query()->where('uuid', $uuid)->firstOrFail();
$this->uuid = $uuid;
$this->name = (string) $customer->name;
$this->contactName = (string) $customer->contact_name;
$this->email = (string) $customer->email;
$this->phone = (string) $customer->phone;
$this->customerType = (string) $customer->customer_type;
$this->vatId = (string) $customer->vat_id;
$this->billingStreet = (string) $customer->billing_street;
$this->billingPostcode = (string) $customer->billing_postcode;
$this->billingCity = (string) $customer->billing_city;
$this->billingCountry = (string) $customer->billing_country;
$this->locale = (string) $customer->locale;
$this->originalEmail = (string) $customer->email;
$this->originalVatId = (string) $customer->vat_id;
}
public function save()
{
$this->authorize('customers.manage');
// Read again, never trusted from the hydrated property: everything below
// is decided against the STORED record.
$customer = Customer::query()->where('uuid', $this->uuid)->firstOrFail();
$data = $this->validate([
'name' => 'required|string|max:255',
'contactName' => 'nullable|string|max:255',
'email' => [
'required', 'email', 'max:255',
// Among customers, ignoring this one. Two customers on one
// address would make every lookup that matches by address —
// inbound mail, the portal user, the register — ambiguous.
Rule::unique('customers', 'email')->ignore($customer->id),
],
'phone' => 'nullable|string|max:64',
// Only one of the two real answers may be stored; an empty string
// leaves the record as it was. See below for why it is never
// cleared back to "nobody asked".
'customerType' => ['nullable', Rule::in(Customer::TYPES)],
'vatId' => 'nullable|string|max:64',
'billingStreet' => 'nullable|string|max:255',
'billingPostcode' => 'nullable|string|max:32',
'billingCity' => 'nullable|string|max:255',
'billingCountry' => 'nullable|string|max:255',
'locale' => ['nullable', Rule::in(self::LOCALES)],
]);
$email = strtolower(trim($data['email']));
$emailChanged = $email !== strtolower($customer->email);
// R21: an address belonging to an operator may never also belong to a
// customer. Checked here rather than left to a unique index, because the
// two identities live in different tables and no index spans them.
if ($emailChanged && Operator::query()->where('email', $email)->exists()) {
$this->addError('email', __('edit_customer.email_is_operator'));
return null;
}
// A `users` row can outlive or predate a matching customer — see
// Customer::emailTaken(). One belonging to somebody else must not be
// walked into.
if ($emailChanged && User::query()->where('email', $email)->whereKeyNot($customer->user_id ?? 0)->exists()) {
$this->addError('email', __('edit_customer.email_taken'));
return null;
}
DB::transaction(function () use ($customer, $data, $email, $emailChanged) {
$customer->update([
'name' => trim($data['name']),
'contact_name' => trim((string) $data['contactName']) ?: null,
'email' => $email,
'phone' => trim((string) $data['phone']) ?: null,
// Never cleared back to "unrecorded". Once somebody has
// answered, the answer stands until it is replaced by the other
// one — a form saved with the field blank must not quietly undo
// a recorded consumer into an unknown, and that answer decides
// whether a withdrawal right exists.
'customer_type' => $data['customerType'] ?: $customer->customer_type,
// The verification is bound to the VALUE (see
// Customer::hasVerifiedVatId), so a new number is unverified the
// moment it is stored. `vat_id_verified_value` is deliberately
// left alone: it is the record of what was checked, and clearing
// it would destroy the evidence rather than the claim.
'vat_id' => trim((string) $data['vatId']) ?: null,
'billing_street' => trim((string) $data['billingStreet']) ?: null,
'billing_postcode' => trim((string) $data['billingPostcode']) ?: null,
'billing_city' => trim((string) $data['billingCity']) ?: null,
'billing_country' => trim((string) $data['billingCountry']) ?: null,
// The composed block, from the same composer the portal uses —
// one shape for one field, whichever side writes it. An address
// that was never split keeps the block it had.
'billing_address' => \App\Livewire\Settings::composeAddress($data) ?: $customer->billing_address,
'locale' => $data['locale'] ?: null,
]);
if (! $emailChanged || $customer->user_id === null) {
return;
}
// The portal login moves with it, or the customer is locked out of
// an account that no longer matches their record. And the new
// address is unconfirmed by definition — nobody has shown they can
// read it — so it goes back to unverified, which is what Laravel's
// own profile update does for the same reason. No mail is sent from
// here: the portal asks them to confirm on their next sign-in, and a
// mail an operator did not knowingly send is a surprise.
User::query()->whereKey($customer->user_id)->update([
'email' => $email,
'email_verified_at' => null,
]);
});
$this->dispatch('notify', message: $this->message($emailChanged, $data));
return $this->redirectRoute('admin.customer', ['uuid' => $this->uuid], navigate: true);
}
/**
* One message, and the consequence if there is one.
*
* Both go through the same toast and the second would replace the first, so
* the sentence that matters has to be the only one.
*
* @param array<string, mixed> $data
*/
private function message(bool $emailChanged, array $data): string
{
if ($emailChanged) {
return __('edit_customer.saved_email_changed');
}
if (trim((string) $data['vatId']) !== trim($this->originalVatId)) {
return __('edit_customer.saved_vat_unverified');
}
return __('edit_customer.saved');
}
public function render()
{
return view('livewire.admin.edit-customer', [
'types' => Customer::TYPES,
'locales' => self::LOCALES,
]);
}
}

View File

@ -0,0 +1,200 @@
<?php
namespace App\Livewire\Admin;
use App\Models\Datacenter;
use App\Models\Host;
use App\Models\Order;
use Illuminate\Validation\Rule;
use Livewire\Attributes\Validate;
use LivewireUI\Modal\ModalComponent;
/**
* Edit a datacenter in a modal (avoids the row-height jump of inline editing).
* Country is picked from a list so it can't be mistyped.
*
* The code is editable, but only while nothing depends on it.
*
* It was fully immutable, which made a typo permanent. It cannot be freely
* mutable either, because it is not only a foreign key: hosts.datacenter
* references it (and MariaDB refuses the update outright, there being no ON
* UPDATE clause), every past order stores it as plain text, each host's DNS
* name was minted from it (fsn-01 a record that exists at the provider and
* does not move), the machine's own hostname was set from it while it was being
* built, and the per-code counter that hands out those numbers is keyed by it.
* Renaming a code with hosts in it is therefore not a rename; it is a datacenter
* called hel full of machines called fsn-01.
*
* So: free while nothing references it which is the case that matters, a
* typo noticed shortly after creating one and locked with the reason named
* once anything does.
*/
class EditDatacenter extends ModalComponent
{
public string $uuid = '';
public string $code = '';
#[Validate('required|string|max:255')]
public string $name = '';
/**
* Which building, not which city fsn-dc-15.
*
* The provider's own label, free text on purpose: it is theirs to change,
* and nothing here may key off it. Only so that picking "fsn" twice for
* redundancy is a decision somebody can actually make.
*/
#[Validate('nullable|string|max:255')]
public string $facility = '';
public string $location = '';
/** The location present when the modal opened — a pre-curation free-form value. */
public string $originalLocation = '';
/**
* Whether new hosts and orders may still be placed here.
*
* The column and the scope existed; the form did not offer it, so a
* datacenter could be created and never switched off again the one
* lifecycle action a location actually needs.
*/
public bool $active = true;
/** What references this code, and therefore what stops it being renamed. */
public int $hostCount = 0;
public int $orderCount = 0;
public function mount(string $uuid): void
{
$this->authorize('datacenters.manage'); // modals are reachable without the route middleware
$dc = Datacenter::query()->where('uuid', $uuid)->firstOrFail();
$this->uuid = $uuid;
$this->code = $dc->code;
$this->name = $dc->name;
$this->facility = (string) $dc->facility;
$this->location = (string) $dc->location;
$this->originalLocation = (string) $dc->location;
$this->active = (bool) $dc->active;
$this->hostCount = $this->hostsIn($dc->code);
$this->orderCount = $this->ordersIn($dc->code);
}
/**
* May this code still be changed?
*
* Always answered from the database, never from a hydrated property: the
* lock is an authorisation decision, and a forged request that flipped it
* would take the DNS names of every host in this datacenter with it.
*/
public function codeIsFree(string $code): bool
{
return $this->hostsIn($code) === 0 && $this->ordersIn($code) === 0;
}
private function hostsIn(string $code): int
{
return Host::query()->where('datacenter', $code)->count();
}
private function ordersIn(string $code): int
{
return Order::query()->where('datacenter', $code)->count();
}
public function save()
{
$this->authorize('datacenters.manage');
// Accept the configured countries plus the record's own legacy value, so a
// datacenter created before the curated list can still be edited. The
// legacy value is read from the DB — never from the client-hydrated
// property, which could be forged to whitelist an arbitrary location.
$dc = Datacenter::query()->where('uuid', $this->uuid)->firstOrFail();
$allowed = array_keys((array) config('countries'));
if ($dc->location) {
$allowed[] = $dc->location;
}
// Recomputed here, never taken from the property the browser sent back:
// the lock is what protects every host's DNS name in this datacenter
// from being orphaned, and a forged request must not be able to lift it.
$codeIsFree = $this->codeIsFree($dc->code);
if ($codeIsFree) {
// Normalised before validating so the unique check matches how the
// row is stored — otherwise `FSN` passes the rule and the update
// then collides with an existing lowercased row (mirrors Datacenters).
$this->code = strtolower(trim($this->code));
} else {
// Whatever came back from the browser is discarded. Silently, and
// deliberately: the field was never offered, so there is nothing to
// report to an honest client, and a dishonest one gets no purchase.
$this->code = $dc->code;
}
$rules = [
'name' => 'required|string|max:255',
'facility' => 'nullable|string|max:255',
'location' => ['nullable', Rule::in($allowed)], // array form — a legacy value may contain commas
'active' => 'boolean',
];
if ($codeIsFree) {
// The same rule the create form uses: a code becomes a DNS label
// (fsn-01.node.clupilot.com), so it has to be one. Ignoring its own
// row, or saving without touching the code would collide with itself.
$rules['code'] = [
'required', 'string', 'max:8',
'regex:/^[a-z0-9]([a-z0-9-]{0,6}[a-z0-9])?$/',
Rule::unique('datacenters', 'code')->ignore($dc->id),
];
}
$data = $this->validate($rules);
// Only on the transition, and compared against what is STORED: an
// already-inactive location would otherwise announce that it had just
// been switched off every time its name was edited.
$justDeactivated = $dc->active && ! $data['active'];
$hostsLeftRunning = $justDeactivated ? $dc->hosts()->count() : 0;
Datacenter::query()->where('uuid', $this->uuid)->update([
// Only where it was actually offered. $data has no 'code' key at
// all when the field was locked, and writing $this->code back would
// be the same value anyway — but only by luck, and this is the line
// that would quietly stop being true if the lock ever moved.
...($codeIsFree ? ['code' => $data['code']] : []),
'name' => $data['name'],
'facility' => $data['facility'] ?: null,
'location' => $data['location'] ?: null,
'active' => $data['active'],
]);
// One message, not two. Both go through the same toast, and the second
// replaces the first — so the generic "saved" would swallow the only
// sentence that says the hosts there are still running.
$this->dispatch('notify', message: $hostsLeftRunning > 0
? __('datacenters.deactivated_with_hosts', ['n' => $hostsLeftRunning])
: __('datacenters.updated'));
return $this->redirectRoute('admin.datacenters', navigate: true);
}
public function render()
{
// Read again rather than shown from the mounted properties: a host can
// be onboarded into this datacenter while the modal sits open, and an
// input offered on the strength of a stale count would be refused on
// save with nothing on screen explaining why.
$stored = Datacenter::query()->where('uuid', $this->uuid)->value('code') ?? $this->code;
$this->hostCount = $this->hostsIn($stored);
$this->orderCount = $this->ordersIn($stored);
return view('livewire.admin.edit-datacenter', [
'countries' => config('countries'),
'codeFree' => $this->hostCount === 0 && $this->orderCount === 0,
]);
}
}

View File

@ -0,0 +1,138 @@
<?php
namespace App\Livewire\Admin;
use App\Models\ExportTarget;
use App\Services\Secrets\SecretCipher;
use LivewireUI\Modal\ModalComponent;
/**
* Adding or changing one destination.
*
* The password never lives here. It goes into the secret vault under a key
* derived from the target, and only that key is stored on the row a
* credential in a settings table is a credential in every database dump. An
* empty password field on an existing target means "leave it alone", not
* "clear it": the field cannot show what is stored, so treating blank as a
* deletion would wipe the credential every time somebody renamed the target.
*/
class EditExportTarget extends ModalComponent
{
public string $uuid = '';
public string $name = '';
public string $driver = ExportTarget::LOCAL;
public string $path = '';
/** 'year' for an archive somebody keeps, 'day' for a handover point. */
public string $layout = ExportTarget::BY_YEAR;
/** Empty means keep for ever, which is the default and the safe one. */
public ?int $keepDays = null;
public string $host = '';
public ?int $port = 22;
public string $username = '';
public string $password = '';
public bool $hasPassword = false;
public bool $active = true;
public function mount(?string $uuid = null): void
{
$this->authorize('site.manage'); // modals bypass the route middleware
if ($uuid === null) {
return;
}
$target = ExportTarget::query()->where('uuid', $uuid)->firstOrFail();
$this->uuid = $uuid;
$this->name = $target->name;
$this->driver = $target->driver;
$this->path = $target->path;
$this->layout = $target->layout;
$this->keepDays = $target->keep_days;
$this->host = (string) $target->host;
$this->port = $target->port ?: 22;
$this->username = (string) $target->username;
$this->active = (bool) $target->active;
$this->hasPassword = trim((string) $target->secret_key) !== '';
}
public function save()
{
$this->authorize('site.manage');
$rules = [
'name' => 'required|string|max:120',
'driver' => 'required|in:local,sftp',
'path' => 'required|string|max:255',
'layout' => 'required|in:year,day',
// Nothing, or a real number of days. Zero would read as "delete
// immediately" to somebody who typed it meaning "never".
'keepDays' => 'nullable|integer|min:1|max:3650',
'active' => 'boolean',
];
if ($this->driver === ExportTarget::SFTP) {
$rules += [
'host' => 'required|string|max:190',
'port' => 'required|integer|min:1|max:65535',
'username' => 'required|string|max:120',
// Required only when there is nothing stored yet. Blank on an
// existing target means "unchanged" — the field cannot show
// what is stored, so treating blank as a deletion would wipe
// the credential on every unrelated edit.
'password' => $this->hasPassword ? 'nullable|string|max:255' : 'required|string|max:255',
];
}
$data = $this->validate($rules);
$target = $this->uuid !== ''
? ExportTarget::query()->where('uuid', $this->uuid)->firstOrFail()
: new ExportTarget;
$target->fill([
'name' => $data['name'],
'driver' => $data['driver'],
'path' => rtrim($data['path'], '/') ?: '/',
'layout' => $data['layout'],
'keep_days' => $data['keepDays'] ?: null,
'host' => $this->driver === ExportTarget::SFTP ? $this->host : null,
'port' => $this->driver === ExportTarget::SFTP ? $this->port : null,
'username' => $this->driver === ExportTarget::SFTP ? $this->username : null,
'active' => $data['active'] ?? true,
]);
$target->save();
// Encrypted onto the row rather than into the vault. The vault keeps a
// registry of fixed, documented credentials and refuses anything else,
// which is right for it — a per-destination password is not one of
// those, and forcing it in would make the registry meaningless. Same
// cipher, same key, so it is no more readable in a dump than the rest.
if ($this->driver === ExportTarget::SFTP && $this->password !== '') {
$target->forceFill([
'secret_key' => app(SecretCipher::class)->encrypt($this->password),
])->save();
}
$this->dispatch('notify', message: __('finance.targets_saved'));
return $this->redirectRoute('admin.finance', navigate: true);
}
public function render()
{
return view('livewire.admin.edit-export-target');
}
}

View File

@ -0,0 +1,123 @@
<?php
namespace App\Livewire\Admin;
use App\Models\InvoiceSeries;
use Illuminate\Validation\Rule;
use LivewireUI\Modal\ModalComponent;
/**
* Editing a Rechnungskreis, with the parts that cannot move held still.
*
* The same shape as EditDatacenter and for the same reason: some fields stop
* being editable once something depends on them. Here the something is a
* document in somebody's accounts.
*
* The prefix is part of every number already issued. Changing it from RE to AR
* does not rename anything it leaves a series whose documents say RE-2026-
* and whose next document says AR-2026-, with no record that they belong
* together. Free while the series is empty, which is the case that matters: a
* prefix chosen wrongly on the day the shop opens.
*
* The counter may be raised but never lowered. Lowering it re-issues a number
* that is already printed on a document somebody holds, and gapless-and-
* ascending is a legal requirement rather than a preference.
*/
class EditInvoiceSeries extends ModalComponent
{
public string $uuid = '';
public string $name = '';
public string $prefix = '';
public int $digits = 4;
public bool $yearlyReset = true;
public int $nextNumber = 1;
public bool $active = true;
/** How many documents this series has already issued. */
public int $issued = 0;
/** The lowest number this counter may still be set to. */
public int $floor = 1;
public function mount(string $uuid): void
{
$this->authorize('site.manage'); // modals are reachable without the route middleware
$series = InvoiceSeries::query()->where('uuid', $uuid)->firstOrFail();
$this->uuid = $uuid;
$this->name = $series->name;
$this->prefix = $series->prefix;
$this->digits = (int) $series->digits;
$this->yearlyReset = (bool) $series->yearly_reset;
$this->nextNumber = (int) $series->next_number;
$this->active = (bool) $series->active;
$this->issued = $series->invoices()->count();
$this->floor = (int) $series->next_number;
}
public function save()
{
$this->authorize('site.manage');
$series = InvoiceSeries::query()->where('uuid', $this->uuid)->firstOrFail();
// Recomputed from the database, never read back from the properties the
// browser returns: these two rules are what keep already-issued numbers
// from being re-used, and a forged field must not lift either.
$issued = $series->invoices()->count();
$floor = (int) $series->next_number;
$rules = [
'name' => 'required|string|max:120',
'digits' => 'required|integer|min:3|max:10',
'active' => 'boolean',
'nextNumber' => ['required', 'integer', 'min:'.$floor],
];
if ($issued === 0) {
$rules['prefix'] = [
'required', 'string', 'max:12', 'regex:/^[A-Z0-9-]+$/',
Rule::unique('invoice_series', 'prefix')->ignore($series->id),
];
// Only while nothing has been issued: switching an established
// series between yearly and continuous puts a gap in one of them.
$rules['yearlyReset'] = 'boolean';
}
if ($issued === 0) {
$this->prefix = strtoupper(trim($this->prefix));
} else {
$this->prefix = $series->prefix;
$this->yearlyReset = (bool) $series->yearly_reset;
}
$data = $this->validate($rules);
$series->update([
'name' => $data['name'],
'digits' => $data['digits'],
'next_number' => $data['nextNumber'],
'active' => $data['active'],
...($issued === 0 ? [
'prefix' => $data['prefix'],
'yearly_reset' => $data['yearlyReset'],
] : []),
]);
$this->dispatch('notify', message: __('finance.series_saved'));
return $this->redirectRoute('admin.finance', navigate: true);
}
public function render()
{
return view('livewire.admin.edit-invoice-series');
}
}

View File

@ -0,0 +1,73 @@
<?php
namespace App\Livewire\Admin;
use App\Models\MailTemplate;
use App\Services\Mail\MailTemplateRenderer;
use Livewire\Attributes\Validate;
use LivewireUI\Modal\ModalComponent;
/**
* Edit one template in a modal.
*
* R20: a textarea growing inside a table row pushes every column beside it and
* reads like a rendering fault rather than a form. A modal is also the only
* place a five-line body has room.
*
* A modal is reachable WITHOUT the page's route middleware, so it authorises
* again and reads the record itself rather than trusting a property the browser
* hydrated.
*/
class EditMailTemplate extends ModalComponent
{
public string $uuid = '';
#[Validate('required|string|max:255')]
public string $name = '';
#[Validate('required|string|max:255')]
public string $subject = '';
#[Validate('required|string|max:5000')]
public string $body = '';
public bool $active = true;
public function mount(string $uuid): void
{
$this->authorize('customers.manage');
$template = MailTemplate::query()->where('uuid', $uuid)->firstOrFail();
$this->uuid = $uuid;
$this->name = $template->name;
$this->subject = $template->subject;
$this->body = $template->body;
$this->active = (bool) $template->active;
}
public function save()
{
$this->authorize('customers.manage');
$data = $this->validate();
MailTemplate::query()->where('uuid', $this->uuid)->update([
'name' => trim($data['name']),
'subject' => trim($data['subject']),
'body' => $data['body'],
'active' => $this->active,
]);
$this->dispatch('notify', message: __('templates.saved'));
return $this->redirectRoute('admin.templates', navigate: true);
}
public function render()
{
return view('livewire.admin.edit-mail-template', [
'placeholders' => MailTemplateRenderer::PLACEHOLDERS,
]);
}
}

View File

@ -0,0 +1,82 @@
<?php
namespace App\Livewire\Admin;
use App\Models\PlanFamily;
use App\Services\Billing\PlanCatalogue;
use LivewireUI\Modal\ModalComponent;
/**
* The marketing presentation of one plan line: who it is for, the sentence
* under its name on the price sheet, and whether it carries the
* "recommended" mark.
*
* These were a hardcoded array in LandingController, keyed on exactly four
* plan keys a plan created under any other key rendered on the public page
* with no copy at all. They live on the family, not a version: a version is
* what gets published and superseded, but "who this is for" and "which one
* plan we point a visitor towards" are a stance about the product line, not a
* capability that changes with a price.
*/
class EditPlanFamily extends ModalComponent
{
public string $uuid = '';
public string $audience = '';
public string $note = '';
public bool $recommended = false;
public function mount(string $uuid): void
{
$this->authorize('plans.manage'); // modals bypass the route middleware
$family = PlanFamily::query()->where('uuid', $uuid)->firstOrFail();
$this->uuid = $uuid;
$this->audience = (string) $family->audience;
$this->note = (string) $family->note;
$this->recommended = (bool) $family->is_recommended;
}
public function save()
{
$this->authorize('plans.manage');
$data = $this->validate([
'audience' => 'nullable|string|max:255',
'note' => 'nullable|string|max:500',
'recommended' => 'boolean',
]);
$family = PlanFamily::query()->where('uuid', $this->uuid)->firstOrFail();
PlanFamily::query()->whereKey($family->getKey())->update([
'audience' => $data['audience'] !== '' ? $data['audience'] : null,
'note' => $data['note'] !== '' ? $data['note'] : null,
]);
// The singular invariant — only one family recommended at a time —
// lives in the catalogue service, not here: two operators recommending
// two different plans at the same moment must not both win.
if ($data['recommended']) {
app(PlanCatalogue::class)->recommend($family);
} elseif ($family->is_recommended) {
PlanFamily::query()->whereKey($family->getKey())->update(['is_recommended' => false]);
}
$this->dispatch('notify', message: __('plans.marketing_saved'));
return $this->redirectRoute('admin.plans', navigate: true);
}
public function render()
{
return view('livewire.admin.edit-plan-family', [
// Read again rather than from the mounted properties: the header
// shows the plan's name, and that can change under an operator's
// own hands while a second tab has this modal open.
'family' => PlanFamily::query()->where('uuid', $this->uuid)->firstOrFail(),
]);
}
}

View File

@ -0,0 +1,224 @@
<?php
namespace App\Livewire\Admin;
use App\Models\ExportTarget;
use App\Models\Invoice;
use App\Models\InvoiceExport;
use App\Models\InvoiceSeries;
use App\Services\Deployment\UpdateChannel;
use App\Support\CompanyProfile;
use App\Support\Settings;
use Illuminate\Support\Facades\Storage;
use Livewire\Attributes\Layout;
use Livewire\Component;
use Livewire\WithFileUploads;
/**
* Everything an invoice needs before one can be issued.
*
* Its own tab rather than a section of Settings: the company's registered
* details, the VAT rate and the Rechnungskreise are not preferences, they are
* what appears on a legal document, and burying them under "Einstellungen"
* beside the site-visibility switch invites somebody to change one in passing.
*
* None of it reaches an invoice that already exists. Every value is copied into
* the invoice when its number is assigned, so an address corrected here is
* correct from the next document onwards and cannot rewrite an old one.
*/
#[Layout('layouts.admin')]
class Finance extends Component
{
use WithFileUploads;
/** @var array<string, mixed> */
public array $company = [];
public float $taxRate = 20.0;
/**
* The one-off setup fee, in euro, as a person types it.
*
* Held in euro rather than cents because this is a form field, and asking
* an operator to enter 9900 for ninety-nine euro is how a fee ends up a
* hundred times too large. Converted on the way in and out.
*/
public string $setupFee = '0';
/**
* A freshly minted collection key, shown once and never stored.
*
* Held in the component for the length of one page view. The private half
* exists to be copied into a NAS and nowhere else writing it into the
* database "for convenience" would put a working credential in every backup
* of that database.
*
* @var array<string, string>|null
*/
public ?array $archiveKey = null;
/** True between asking the host for a key and the host answering. */
public bool $waitingForKey = false;
/** New logo upload, validated on save. */
public $logo = null;
public string $logoPath = '';
public function mount(): void
{
$this->authorize('site.manage');
$this->company = CompanyProfile::all();
$this->logoPath = (string) ($this->company['logo_path'] ?? '');
$this->taxRate = CompanyProfile::taxRate();
$this->setupFee = number_format(CompanyProfile::setupFeeCents() / 100, 2, '.', '');
}
public function saveCompany(): void
{
$this->authorize('site.manage');
$data = $this->validate([
'company.name' => 'required|string|max:190',
'company.address' => 'required|string|max:190',
'company.postcode' => 'required|string|max:20',
'company.city' => 'required|string|max:120',
'company.country' => 'required|string|max:120',
'company.phone' => 'nullable|string|max:60',
'company.email' => 'nullable|email|max:190',
'company.website' => 'nullable|string|max:190',
'company.register_number' => 'nullable|string|max:60',
'company.register_court' => 'nullable|string|max:120',
'company.vat_id' => 'required|string|max:40',
'company.bank_name' => 'nullable|string|max:120',
'company.iban' => 'nullable|string|max:42',
'company.bic' => 'nullable|string|max:15',
'company.payment_days' => 'required|integer|min:0|max:180',
'company.payment_terms' => 'nullable|string|max:500',
// PNG or WEBP only, and small. A PDF embeds whatever it is given,
// and a four-megabyte photograph would be embedded in every invoice
// ever rendered from then on.
'logo' => 'nullable|image|mimes:png,webp|max:1024',
'taxRate' => 'required|numeric|min:0|max:100',
'setupFee' => 'required|numeric|min:0|max:100000',
]);
if ($this->logo !== null) {
$previous = $this->logoPath;
$this->logoPath = $this->logo->store('company', 'public');
$this->company['logo_path'] = $this->logoPath;
// The old file goes only once the new one is stored. The other
// order loses the logo entirely if the upload fails, and every
// invoice rendered afterwards is missing it.
if ($previous !== '' && $previous !== $this->logoPath) {
Storage::disk('public')->delete($previous);
}
$this->logo = null;
}
CompanyProfile::put($data['company']);
Settings::set('company.tax_rate', (float) $data['taxRate']);
// round(), not a cast: (int) (99.95 * 100) is 9994 on a binary float,
// and a fee one cent short of what was typed is the kind of thing
// nobody finds until a customer does.
Settings::set('company.setup_fee_cents', (int) round(((float) $data['setupFee']) * 100));
$this->dispatch('notify', message: __('finance.company_saved'));
}
/**
* Ask the host to mint an SSH key that may only read this archive.
*
* Everything an operator otherwise does by hand across three machines
* generate a keypair, install the public half restricted to one directory,
* carry the private half to the NAS. None of it can happen here: the panel
* is www-data in a container and the keys, the home directory and rrsync
* are all on the host. So it asks, and the agent does it.
*/
public function createPullAccess(string $uuid): void
{
$this->authorize('site.manage');
$target = ExportTarget::query()->where('uuid', $uuid)->firstOrFail();
if ($target->driver !== ExportTarget::LOCAL) {
// Only a directory on this host has a home directory and an
// authorized_keys to put anything in. A destination somewhere else
// is somebody else's machine.
$this->dispatch('notify', message: __('finance.pull_local_only'));
return;
}
$accepted = app(UpdateChannel::class)->requestArchiveKey(
(string) (auth('operator')->user()?->email ?? ''),
$target->path,
'clupilot-archiv-'.$target->uuid,
);
$this->archiveKey = null;
$this->waitingForKey = $accepted;
$this->dispatch('notify', message: __($accepted
? 'finance.pull_requested'
: 'admin_settings.update_already_requested'));
}
/** Polled while waiting. Takes the key the moment the host has left one. */
public function collectArchiveKey(): void
{
$this->authorize('site.manage');
if (! $this->waitingForKey) {
return;
}
$key = app(UpdateChannel::class)->takeArchiveKey();
if ($key !== null) {
$this->archiveKey = $key;
$this->waitingForKey = false;
}
}
public function removeLogo(): void
{
$this->authorize('site.manage');
if ($this->logoPath !== '') {
Storage::disk('public')->delete($this->logoPath);
}
$this->logoPath = '';
$this->company['logo_path'] = '';
CompanyProfile::put(['logo_path' => '']);
$this->dispatch('notify', message: __('finance.logo_removed'));
}
public function render()
{
return view('livewire.admin.finance', [
'series' => InvoiceSeries::query()->orderBy('kind')->get(),
// What is still missing before an invoice may be issued at all. An
// invoice without a name, an address or a VAT number is not a valid
// invoice here, and issuing one is worse than refusing to.
'missing' => CompanyProfile::missingForInvoicing(),
// Counted from the join table, never by asking the destinations.
// A stat() on a network mount that has gone away blocks for the
// mount's whole timeout, and this is a page an operator opens when
// something is already wrong.
'targets' => ExportTarget::query()->orderBy('name')->get()->map(fn (ExportTarget $t) => [
'model' => $t,
'pending' => Invoice::query()->whereDoesntHave('exports', fn ($q) => $q
->where('export_target_id', $t->id)->whereNotNull('exported_at'))->count(),
'failed' => InvoiceExport::query()->where('export_target_id', $t->id)
->whereNotNull('error')->whereNull('exported_at')->count(),
]),
]);
}
}

View File

@ -0,0 +1,293 @@
<?php
namespace App\Livewire\Admin;
use App\Actions\GrantAddon;
use App\Actions\GrantSubscription;
use App\Models\Customer;
use App\Models\Datacenter;
use App\Models\Subscription;
use App\Models\SubscriptionAddon;
use App\Services\Billing\AddonCatalogue;
use App\Services\Billing\PlanCatalogue;
use App\Support\Money;
use Illuminate\Support\Carbon;
use Illuminate\Validation\Rule;
use LivewireUI\Modal\ModalComponent;
use RuntimeException;
use Throwable;
/**
* Gives a customer a package, a module, or a discount without a payment.
*
* Reuses GrantSubscription / GrantAddon for the actual mutation: this
* component is only the form. What it grants goes through the exact Order
* Subscription/SubscriptionAddon rows a Stripe purchase would, so everything
* downstream (provisioning, quotas, cancellation, revenue) reads it the same
* way as a sale.
*/
class GrantPlan extends ModalComponent
{
public string $customerUuid = '';
public string $customerName = '';
/** Which form is on screen: a whole package, or a single module. */
public string $kind = 'package';
public bool $hasSubscription = false;
// Package fields.
public string $plan = '';
public string $term = Subscription::TERM_MONTHLY;
public string $datacenter = '';
public string $bonusQuotaGb = '';
public string $bonusSeats = '';
public string $bonusTrafficGb = '';
// Add-on fields.
public ?int $subscriptionId = null;
public string $addonKey = '';
public int $quantity = 1;
// Shared.
public string $priceEuros = '0';
public string $note = '';
/** A plain date — "until when", not "until what time". */
public string $until = '';
public function mount(string $uuid): void
{
$this->authorize('customers.grant_plan'); // modals are reachable without the route middleware
$customer = Customer::query()->where('uuid', $uuid)->firstOrFail();
$this->customerUuid = $uuid;
$this->customerName = (string) $customer->name;
$this->datacenter = (string) (Datacenter::query()->active()->orderBy('code')->value('code') ?? '');
$subscription = Subscription::query()
->where('customer_id', $customer->id)
->where('status', 'active')
->latest('id')
->first();
$this->hasSubscription = $subscription !== null;
$this->subscriptionId = $subscription?->id;
// A customer with nothing yet can only be given a package — there is
// no contract to hang a module off.
$this->kind = $this->hasSubscription ? 'addon' : 'package';
}
/** Today's price for whatever is picked, so a discount reads as one. */
public function cataloguePriceCents(): ?int
{
try {
if ($this->kind === 'package' && $this->plan !== '') {
return app(PlanCatalogue::class)->currentVersion($this->plan)->priceFor($this->term)?->amount_cents;
}
if ($this->kind === 'addon' && $this->addonKey !== '') {
return app(AddonCatalogue::class)->priceCents($this->addonKey);
}
} catch (Throwable) {
return null;
}
return null;
}
public function grant(): void
{
$this->authorize('customers.grant_plan');
$operator = auth('operator')->user();
abort_if($operator === null, 403);
$customer = Customer::query()->where('uuid', $this->customerUuid)->firstOrFail();
if ($this->kind === 'package') {
$this->grantPackage($customer, $operator);
} else {
$this->grantAddon($customer, $operator);
}
}
private function grantPackage(Customer $customer, $operator): void
{
$priceRule = function (string $attribute, mixed $value, \Closure $fail): void {
if (Money::toCents((string) $value) === null) {
$fail(__('admin.grant.price_invalid'));
}
};
$data = $this->validate([
'plan' => ['required', Rule::in(array_keys(app(PlanCatalogue::class)->sellable()))],
'term' => ['required', Rule::in([Subscription::TERM_MONTHLY, Subscription::TERM_YEARLY])],
'datacenter' => ['required', Rule::exists('datacenters', 'code')->where('active', true)],
'priceEuros' => ['required', 'string', $priceRule],
'bonusQuotaGb' => 'nullable|integer|min:0|max:1000000',
'bonusSeats' => 'nullable|integer|min:0|max:100000',
'bonusTrafficGb' => 'nullable|integer|min:0|max:10000000',
'note' => 'nullable|string|max:1000',
]);
try {
app(GrantSubscription::class)(
customer: $customer,
grantedBy: $operator,
plan: $data['plan'],
term: $data['term'],
datacenter: $data['datacenter'],
priceCents: Money::toCents($data['priceEuros']),
bonus: array_filter([
'quota_gb' => $data['bonusQuotaGb'] !== null && $data['bonusQuotaGb'] !== '' ? (int) $data['bonusQuotaGb'] : null,
'seats' => $data['bonusSeats'] !== null && $data['bonusSeats'] !== '' ? (int) $data['bonusSeats'] : null,
'traffic_gb' => $data['bonusTrafficGb'] !== null && $data['bonusTrafficGb'] !== '' ? (int) $data['bonusTrafficGb'] : null,
], fn ($value) => $value !== null),
note: $data['note'] !== null && $data['note'] !== '' ? $data['note'] : null,
until: $this->parseUntil($this->until),
);
} catch (RuntimeException $e) {
$this->addError('priceEuros', $e->getMessage());
return;
}
$this->dispatch('notify', message: __('admin.grant.granted'));
$this->closeModal();
}
private function grantAddon(Customer $customer, $operator): void
{
$priceRule = function (string $attribute, mixed $value, \Closure $fail): void {
if (Money::toCents((string) $value) === null) {
$fail(__('admin.grant.price_invalid'));
}
};
$data = $this->validate([
'addonKey' => ['required', Rule::in(array_keys((array) config('provisioning.addons')))],
'quantity' => 'required|integer|min:1|max:100',
'priceEuros' => ['required', 'string', $priceRule],
'note' => 'nullable|string|max:1000',
]);
// Re-resolved and re-checked, never trusted from the hydrated
// property: a forged request must not be able to book a module onto
// someone else's contract.
$subscription = Subscription::query()
->where('customer_id', $customer->id)
->whereKey($this->subscriptionId)
->first();
if ($subscription === null) {
$this->addError('addonKey', __('admin.grant.no_subscription'));
return;
}
try {
app(GrantAddon::class)(
subscription: $subscription,
grantedBy: $operator,
addonKey: $data['addonKey'],
priceCents: Money::toCents($data['priceEuros']),
quantity: $data['quantity'],
note: $data['note'] !== null && $data['note'] !== '' ? $data['note'] : null,
until: $this->parseUntil($this->until),
);
} catch (RuntimeException $e) {
$this->addError('priceEuros', $e->getMessage());
return;
}
$this->dispatch('notify', message: __('admin.grant.granted'));
$this->closeModal();
}
/**
* A date, read in the operator's own zone the same pairing LocalTime
* keeps for a datetime-local field, minus the time of day this form does
* not ask for. "Until 15 August" means through the end of that day where
* the operator is sitting, not UTC midnight.
*/
private function parseUntil(string $value): ?Carbon
{
if (trim($value) === '') {
return null;
}
try {
return Carbon::parse($value, config('app.display_timezone'))->endOfDay()->utc();
} catch (Throwable) {
return null;
}
}
/** @return array<int, array<string, mixed>> */
private function existingGrants(int $customerId): array
{
$fromSubscriptions = Subscription::query()
->where('customer_id', $customerId)
->whereNotNull('granted_at')
->with('grantedBy')
->get()
->map(fn (Subscription $s) => [
'kind' => 'package',
'label' => __('billing.plan.'.$s->plan),
'price_cents' => $s->price_cents,
'catalogue_price_cents' => $s->catalogue_price_cents,
'granted_by' => $s->grantedBy?->name ?? '—',
'granted_at' => $s->granted_at,
'granted_until' => $s->granted_until,
'note' => $s->grant_note,
]);
$fromAddons = SubscriptionAddon::query()
->whereHas('subscription', fn ($q) => $q->where('customer_id', $customerId))
->whereNotNull('granted_at')
->with('grantedBy')
->get()
->map(fn (SubscriptionAddon $a) => [
'kind' => 'addon',
'label' => __('billing.addon.'.$a->addon_key.'.name'),
'price_cents' => $a->price_cents,
'catalogue_price_cents' => $a->catalogue_price_cents,
'granted_by' => $a->grantedBy?->name ?? '—',
'granted_at' => $a->granted_at,
'granted_until' => $a->granted_until,
'note' => $a->grant_note,
]);
return $fromSubscriptions->concat($fromAddons)
->sortByDesc('granted_at')
->values()
->all();
}
public function render()
{
// Read again rather than off the mounted properties: a second grant
// can have happened while this modal sat open (EditDatacenter's
// pattern), and the existing-grants list has to show it.
$customer = Customer::query()->where('uuid', $this->customerUuid)->firstOrFail();
return view('livewire.admin.grant-plan', [
'plans' => app(PlanCatalogue::class)->sellable(),
'addons' => (array) config('provisioning.addons'),
'datacenters' => Datacenter::query()->active()->orderBy('code')->pluck('name', 'code'),
'cataloguePriceCents' => $this->cataloguePriceCents(),
'grants' => $this->existingGrants($customer->id),
]);
}
}

View File

@ -0,0 +1,70 @@
<?php
namespace App\Livewire\Admin;
use App\Actions\StartHostOnboarding;
use App\Support\HostEnrolment;
use App\Support\HostTakeoverCommand;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Validate;
use Livewire\Component;
#[Layout('layouts.admin')]
class HostCreate extends Component
{
/**
* Die fertige Befehlszeile gesetzt, sobald der Host angelegt ist.
*
* Sie wird GENAU EINMAL gezeigt. In der Datenbank steht nur der Hash des
* Codes, und der private Schlüssel steht dort überhaupt nicht; wer die Seite
* neu lädt, bekommt sie nicht wieder, sondern legt einen neuen Code an. Das
* ist kein Versehen, sondern der Grund, warum ein abgefangener Blick auf
* einen Bildschirm später nichts mehr wert ist.
*/
public ?string $command = null;
public ?string $createdUuid = null;
public ?string $createdName = null;
#[Validate('required|string|max:255')]
public string $name = '';
#[Validate('required|string|exists:datacenters,code,active,1')]
public string $datacenter = '';
public function mount(): void
{
$this->datacenter = (string) \App\Models\Datacenter::query()->active()->orderBy('name')->value('code');
}
#[Validate('required|ip|unique:hosts,public_ip')]
public string $public_ip = '';
#[Validate('required|string|min:8')]
public string $root_password = '';
public function save(StartHostOnboarding $action)
{
$this->authorize('hosts.manage');
$data = $this->validate();
$host = $action->run($data);
// Kein Weiterleiten mehr. Die Befehlszeile gibt es nur hier und nur
// jetzt — wer den Betreiber auf eine andere Seite schickt, schickt ihn
// von dem einzigen Wert weg, den er braucht.
$this->command = HostTakeoverCommand::for($host, HostEnrolment::issueWithKeys($host));
$this->createdUuid = $host->uuid;
$this->createdName = $host->name;
}
public function render()
{
return view('livewire.admin.host-create', [
'datacenters' => \App\Models\Datacenter::query()->active()->orderBy('name')->get(),
'archiveUrl' => HostTakeoverCommand::archiveUrl(),
'missingSettings' => HostTakeoverCommand::missingSettings(),
]);
}
}

Some files were not shown because too many files have changed in this diff Show More