Commit Graph

680 Commits (d62a2c8ff8cf8d29246ee0ea01e9680a7e83c2ef)

Author SHA1 Message Date
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