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>
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>
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>
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>
── 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>
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>
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>
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.
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.
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>
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.
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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.
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.
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>
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>
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>
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>
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>
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>
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.
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.
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.
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.
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.
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>