240 Commits (f294db5259fa82bfc3520c3ac6febd3bcff87c73)
| Author | SHA1 | Message | Date |
|---|---|---|---|
|
|
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> |
|
|
|
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>
|
|
|
|
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> |
|
|
|
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> |
|
|
|
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> |
|
|
|
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>
|
|
|
|
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>
|
|
|
|
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> |
|
|
|
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> |
|
|
|
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> |
|
|
|
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> |
|
|
|
ff92466b47 |
Give the order a checkout page, and stop hiding what a year costs
**There was no checkout page.** The package list posted straight to Stripe, so the last thing a customer saw in this product's design was a grid of cards and the next was somebody else's form asking for a card number. What they were buying, what it costs in total, what happens afterwards and what they were agreeing to were never stated in one place. App\Livewire\Checkout is that place: the package and its figures on the left, a sticky summary on the right — net, the saving where there is one, the setup fee, the tax, what is due today, and what will be charged after it. Every figure comes from the catalogue, TaxTreatment and SetupFee, the same three the checkout itself reads; nothing is recomputed, because a summary that works out its own total is a second answer waiting to disagree with the one Stripe is handed. **The terms acceptance moved to where the money is spent.** It sat at the top of the package list as a condition of LOOKING at prices, with every button dead beneath it. It is a condition of buying, so it is now at the foot of the summary, directly above the button — after the total, which is the order those three things are read in. The package list has no form on it at all now; its buttons are links. **The page used the left half of a 1240px shell.** A 68-character title, a switch under it and an 80-character footnote, beside a grid that used the whole width. Now: a header band with the name left and the term switch right, where a control belongs, and a closing band of three columns instead of a paragraph trailing off. **The yearly view showed the monthly equivalent as its headline**, so on a package with no free months the big figure did not move at all when the switch was thrown — the page looked like it had ignored the click. The headline is the price of the term in the unit it is charged in; what a year works out to per month is said underneath. Same on the public sheet. **The switch was unreadable.** `text-on-ink` is not in this palette — the ink pairing is bg-ink/text-bg, as x-ui.button has always had it — so the label kept its inherited grey on a near-black pill. And its shell was rounded-lg with rounded-md options inside: two radius scales, one of them Tailwind's untokenised 6px. One scale now, nested. **The mails drew an empty orange tile.** The mark's ascent lives in an SVG the site loads and a mail cannot — Gmail strips inline SVG, Outlook renders with Word, and this layout carries no image by design. The shape is drawn with a character every mail font has, white on the accent tile, with the whole wordmark beside it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
e83b17ec37 |
Put every missing field on one page before a customer pays
Admin\Readiness renders App\Support\Readiness::byGroup() end to end: state, label, breaks sentence and a fix link for every check, a button on the four that have to ask a real service (DnsTokenCheck, WireguardEndpointCheck, VmTemplateCheck, StripeCheck) and only ever run on demand, and one headline line — ready for the operator's current mode, or how many blocking items are still open. Wired into the nav and into Overview's own notice list beside the same gate as Admin\Integrations, since both halves it configures come back together on this page. Fixed two check keys the guard test caught for real: onboarding.ssh_key and delivery.inbound_password did not textually contain the vault key they check (ssh.private_key, inbound_mail.password), which is exactly the kind of silent gap this whole task exists to catch. Renamed to onboarding.ssh_private_key and delivery.inbound_mail_password, with the two existing test files that referenced the old keys updated to match. Also fixed the guard test's own assertion: Pest's toContain() is variadic, so a second string argument is a second required substring, not a failure message (the same trap tests/Feature/ModalHeightTest.php already documents about itself) — it failed on every entry, including ones already covered. Replaced with str_contains()+toBeTrue(), which keeps the check and the message both. Two pre-existing Overview tests assumed a bare install has nothing to report; it now correctly always starts with a readiness gap, so both were narrowed to what they actually test — no invented data, no invented incident. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
b32c6fc33f |
Sell the year as well as the month, and say what it saves
Both prices have existed since the catalogue did — one `plan_prices` row
per term, each with its own Stripe Price, and the checkout has taken a
`term` all along. Nothing ever sent one. The shop showed the monthly
figure, the order page posted no term, and the yearly Price sat there
being paid for by nobody.
**The console asks for free months, not a second price.** It used to take
the yearly TOTAL as its own free-form figure, so nothing in the system
knew WHY 588 belonged to a package costing 49 a month — and no page could
say "zwei Monate gratis" without a person working it out again and writing
it somewhere it would then drift. Now: the monthly price and how many of
the twelve months are free. The total is derived from the two
(PlanVersions::yearlyCents), previewed live on the form, and written to
the catalogue from the same arithmetic.
`free_months` sits on the VERSION, beside the capabilities publication
freezes, because that is what it is — part of the terms a customer bought.
The migration reads it back out of the prices that already exist rather
than defaulting to nought: an installation selling twelve-for-ten must not
lose that on a deploy. Only an exact division counts; a hand-negotiated
yearly figure keeps its amount and simply goes unexplained.
**The customer chooses.** A switch on the order page and on the public
sheet, both figures rendered and one shown, so switching costs no request.
The headline stays "per month" in either term — a yearly total as one big
number reads as five times dearer at a glance — with the amount actually
taken, the net figure, the months free and what twelve monthly payments
would have cost underneath. The form posts the term the customer was
looking at, and the checkout picks that term's Stripe Price.
The billing card now says which term is running. It states a monthly
figure whatever the term, so a yearly customer was reading a number that
never appears on a statement.
Also: **a Blade comment must never spell a directive.** Blade compiles
what is inside `{{-- --}}` too — the comment is stripped from the output,
but its directives are compiled on the way there. A comment explaining why
the parenthesised php directive had been avoided produced exactly the open
PHP tag it warned about, and the rest of the file was swallowed as PHP
source: no exception, no warning, the page one block short. That is how
the term switch vanished after being written. BladeCommentsTest scans for
the forms that open a tag.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
|
|
fe1110de71 |
Fix round: name the slot on the card, and cover the modal that already worked
Names which of the two slots a card actually reads and writes right now, at the card an operator types into (not only the page-top badge) — save() and render() resolve without a mode argument, so the card looked identical in both modes while quietly acting on whichever slot was active. Also adds the ConfirmSwitchMode coverage IntegrationsPageTest already carries for ConfirmSaveSecret/ConfirmForgetSecret: a modal is reachable without the page's route middleware, and reading the code is not the same as testing it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
fed4acf31c |
Accept terms instead of a start date, and fix the sender no server accepts
**The test mail was rejected: "553 5.7.1 Sender address rejected: not owned by user no-reply@…".** Every purpose mailbox here has its own SMTP account, and a mail server lets an account send only from the address it owns. Mail::to() hands back a pending mail bound to the DEFAULT mailer and sendNow() then ignores the mailer the mailable asked for, so a mail addressed support@ went out over the no-reply@ login. It sends through the mailable's own mailer now, and the cloud-ready preview — the one mailable with no mailbox at all — takes the provisioning mailbox like the real notification does. Writing the test for that found the same bug in production code: **InvoiceMail and OrderConfirmationMail set their From to billing@ and never named a mailer**, so both went out over mail.default's no-reply@ login. On this installation no customer had ever received an invoice mail or an order confirmation; they rendered perfectly, queued without complaint and were refused at the door. MailSenderOwnershipTest now scans for the mismatch: a mail that takes a mailbox From must use that mailbox's mailer. **The box on the order page accepts the terms now**, not an immediate start. It used to carry the whole FAGG §16 sentence, which read like a choice between "now" and "in fourteen days" — and there is no second option. The terms are what regulate the sale, so they had to exist: resources/views/legal/terms.blade.php replaces the placeholder with fourteen sections written from what this software actually does — the delivery, the capacity queue, the full refund on withdrawal, the cancellation at period end, the deletion deadlines. The company data comes from CompanyProfile, so the page and the invoices cannot drift. No availability figure and no liability cap has been invented. No order goes through without it: the button is unusable until the box is ticked and says why, and CheckoutController still refuses server-side — the browser half refuses nothing. The request field is `terms_accepted`; the Stripe metadata key stays `immediate_start`, because a session opened before a deploy is paid after it and the webhook would find nothing under a new name. **"Wird der Account nach fünf Tagen gelöscht?"** Only an unconfirmed one. That was the whole of what we said, so the answer looked like yes. The second rule now exists and is stated: PruneDormantAccounts removes a confirmed account after a year when it never had a package — no customer record at all, which is where every order, contract and seven-year invoice hangs. A fortnight's notice goes out first, once, and `dormant_warned_at` is what permits the deletion: an account whose warning never went out is never removed. Signing in resets the clock, measured off the device rows because users has no last_login_at. Both deadlines are said in the portal settings, on the verification page, and in the terms — each reading the number off the command that enforces it. Also: the wordmark scan matched any element whose text merely BEGINS with the company name, which a paragraph of terms does. It looks for the lockup form now (no whitespace after the tag), which is what it was always about. The seven checkout tests in the parallel session's files were posting the old field name and now post the new one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
96f171b3b3 |
Put the switch where it governs, and say when it is thrown
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
c354ad1463 |
Make mails readable on a phone, and let an operator look at one
Three things the owner reported, and one sentence that promised something
this product does not do.
**The mails had a fixed width.** components/mail/layout.blade.php set
width="600" and width:600px, so every phone showed a 600px canvas in a
390px window and one had to scroll sideways to read a sentence. Now the
table is width="100%" with max-width:600px — fluid, with 600 as a ceiling
rather than a floor. A mail cannot fix this with a media query (Outlook
renders with Word and drops <style> blocks), which is why the whole file
is inline attributes and why the shrinking has to be built in. Side
padding went 40px -> 24px in the layout and in all nine mail views:
40 each side leaves 278px of a 390px screen to read in.
**There was no way to look at a mail.** An invoice mail needs an invoice,
a maintenance announcement needs a window, and "register an account to
see whether the confirmation reads well" is not a workflow — which is how
the fixed width survived for months. App\Services\Mail\MailPreviews
builds all nine from sample data, every record with make() and never
create(), so a preview leaves no invoice, order or window behind and
draws no number from a series. Two ways to see one: in a tab as the
document itself, or sent to the signed-in operator's own address. There
is no recipient field — that would be a form for mailing strangers.
sendNow(), not send(): every mailable here implements ShouldQueue, and
send() defers to the queue for those, which would have put a failure in a
worker's log while the button reported success to somebody waiting for
the mail. The test caught that.
**The booking page had no shell.** Order declared layouts.portal — the
bare shell the sign-in pages use, no navigation and no padding — so the
page sat flush against the top and bottom of the window with no way back
to anything. layouts.portal-app, like every other portal page.
**The consent box said something untrue.** Its refusal read "otherwise we
can only build your cloud once the withdrawal period has ended", which
describes a path that does not exist: nothing here delivers fourteen days
later, and an unticked box simply means no order. Said plainly now, and
said on the page before somebody runs into the refusal.
Also: GrantedPlanPortalTest asserted assertDontSee('179'), which matches
any id containing those digits — and ids climb through a suite run
because a rolled-back transaction does not reset an auto-increment
counter. It passed alone and failed in company. Asserts on '179,00' now,
which is what a leaked price would actually look like.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
|
|
6ca3e6ef4a |
Notice when nobody is picking up the queue
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
3e7026407b |
Stop inventing a package, and rebuild the booking page
tests / pest (push) Failing after 8m12s
Details
tests / assets (push) Successful in 20s
Details
tests / release (push) Has been skipped
Details
Two pages said opposite things about one account, straight after registering: the dashboard said "noch keine Instanz", and Paket & Addons said "AKTUELLES PAKET Start — Status Aktiv". The dashboard was right. The package page fell back to `$instance?->plan ?? 'start'` and rendered that fallback as a contract the customer had entered into, complete with a status of Aktiv, on the one page somebody opens to find out what they pay. The contract is resolved from the CUSTOMER now, not from the instance — a paid order parked for want of a machine has a subscription and no instance yet, and asking the instance would tell somebody who has paid that they have no package. An instance counts as well, whatever its subscription row says. With neither, the page says so and points at the booking page: an "Upgrade" from nothing is a purchase, and that has its own page. The cart stays outside that gate. A customer can have something in it before they have any contract at all, and hiding it there would hide the thing they came to pay for — which is what my first version did, and seven existing tests said so. And the booking page. Five cards in a four-column grid left the fifth alone on its own row, reading as a mistake; the consent box sat at the BOTTOM with every buy button disabled until it was ticked, so the page arrived looking broken before anybody had done anything wrong; and the price ran across the card in one block with nothing to compare. Now the consent comes first, because it is the condition for everything under it, and it colours itself green once given. Three columns, so five plans read as 3 + 2. Each card is the same four rows in the same order — price, delivery, what you get, the button — so the eye can run across a row and compare like with like. The button stays ENABLED with the reason it will not go through said under it: a disabled control explains nothing, and the server is the gate either way. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
2321854967 |
Discard an unconfirmed registration, and sweep the abandoned ones
tests / pest (push) Failing after 8m17s
Details
tests / assets (push) Successful in 19s
Details
tests / release (push) Has been skipped
Details
"Abmelden und neu registrieren" was advice that could not work. Signing out frees nothing: the address is still held by the unique index, so registering again with the address somebody meant fails — and every abandoned attempt stayed in `users` for ever. Reported exactly that way, and it was right. The button discards the account instead. Only an UNCONFIRMED one, and only one with nothing behind it: the webhook creates a customer's login from a paid checkout, so a user can sit on this page unconfirmed while already having a contract, and deleting that is data loss dressed up as a convenience. It refuses and says where to go. A confirmed account is closed, not discarded — that is ConfirmCloseAccount, with an invoice history behind it. R23: confirmed in a modal. The modal mutates nothing; it dispatches the event the page listens for, so the checks stay in the one place they already are rather than being duplicated where they could drift. And nothing has to be discarded by hand. clupilot:prune-unverified removes registrations nobody confirmed after five days — long enough for somebody who signed up on a Friday and found the mail in a spam folder on Monday, short enough that a typo does not hold the correct address for a month. It skips a confirmed account whatever its age, and any account with a customer record behind it, matched on user_id AND on the address, because either link means somebody is a customer. Every removal is logged with the address: a line that disappears is what somebody asks about later, and a count answers nothing. The deadline is said on the page as well as in the mail. Somebody who never received the mail is looking at the page, and the deadline is the reason they do not have to do anything about an attempt they abandon. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
f49f1dca09 |
Say when no mail is being delivered at all
tests / pest (push) Failing after 8m40s
Details
tests / assets (push) Successful in 28s
Details
tests / release (push) Has been skipped
Details
"Ich versuche mich zu registrieren, erhalte aber keine Verify-Mail, auch nicht beim erneuten Senden." Traced rather than guessed, and the evidence is in storage/logs: the address appears three times, so the mails were built and handed to a transport that writes them to a file. MAIL_MAILER=log. MailboxTransport::resolution() short-circuits on it — NON_DELIVERING is ['log', 'array', null] — so every purpose mailer becomes the log transport however well the five mailboxes are configured, which is correct for a development machine and exactly what was happening here. The reason it deserves a notice is that the failure is silent in every direction an operator would look. Mail::queue() succeeds. No job fails. The queue empties. And the console's own mailbox test reports SUCCESS, because MailboxTester builds its own transport on purpose — a check that honoured MAIL_MAILER=log would report success while writing to a file. So every instrument on the page said fine. Now the front page says it, linked to the mail page, and the mail page says it above the mailboxes it makes decorative. All three swallowing values are named, not only 'log': array and an unset default do the same thing, and a notice that only knew about one would be silent in the two cases nobody thinks to check. Two existing tests asserted that a clean installation raises no notice, and went red — correctly, because phpunit.xml forces MAIL_MAILER=array so a suite cannot send mail. They pin a delivering mailer now: the warning is about the environment, those tests are about the estate. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
70fa136e22 |
Say on the front page that the site is switched off
tests / pest (push) Failing after 8m54s
Details
tests / assets (push) Successful in 20s
Details
tests / release (push) Has been skipped
Details
"Ich komme auf dem lokalen Dev-Server nicht auf www.dev." Diagnosed rather than guessed: Laravel answers 200 on that host, and the request never reaches it — site.public is false, so PublicSiteGate returns the placeholder with a 503 to every visitor who is not on the management VPN and not signed in as an operator. Which is precisely what a broken deployment looks like from outside, and the only place that said so was the switch itself, on another page. So the console says it now, on the page an operator opens when something looks wrong, and the notice links to the switch. The second half of the same report — being sent to app.… — is the missing SITE_HOST. Without it the marketing site is registered without a hostname: it answers on the PORTAL's host as well, and every route() it generates points at APP_URL, so a link on the site lands on the portal's domain. Also said, also with the host named. Only where the installation is host-separated at all, though. A fresh checkout binds nothing to a hostname by design — RestrictAdminHost: "upgrading must not lock anyone out of a system that was working" — and a warning there is furniture. My first version fired on every clean installation and two existing tests said so immediately; they were right. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
0f4226c488 |
Fix round: read the mailer against NON_DELIVERING, not against 'log' alone
delivery.mailer_not_log now reads MailboxTransport::NON_DELIVERING (made public) instead of a narrower `!== 'log'`, so 'array' — this suite's own MAIL_MAILER — and an unset default no longer pass as ready when nothing would actually be delivered. provisioning.monitoring_token's severity now follows provisioning.monitoring.required the same way RegisterMonitoring itself does, instead of asserting a fixed 'warning' the pipeline does not always honour. Two breaks sentences (dns_zone, mail_templates) now name a concrete failure mechanism instead of a correct-but-vague description. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
a84251260c |
Read the mailbox password where it actually is, and let the console test it
tests / pest (push) Failing after 9m6s
Details
tests / assets (push) Successful in 22s
Details
tests / release (push) Has been skipped
Details
The inbox kept saying no mailbox was set up while the password sat right
there on the settings page. isConfigured() read
config('services.inbound_mail.password'), which is only ever the .env
value: this project deliberately does not overlay stored secrets onto config
at boot — SecretVault's own docblock says so, and HttpMonitoringClient reads
its token at the point of use for exactly that reason. My docblock even
asserted the opposite mechanism, which I never verified. It reads the vault
now, and falls back to .env because SecretVault::get() already does.
And a way to find out. "Verbindung prüfen" saves the form first — testing
what is on screen while the server still holds the previous values would
report on a mailbox nobody configured — then performs the same four verbs
the real fetch performs: LOGIN, SELECT, SEARCH UNSEEN. A check that proves
something easier than the actual job is worse than none.
What it reports back is one of five fixed words, never the server's own
prose: an IMAP error carries the command it failed on, and for LOGIN that
command contains the password. Each word maps to a sentence an operator can
act on — address and port, mailbox and password, the folder.
The line saying when the mailbox was last reached is on both pages, and it
is written by the SCHEDULED run as well as by the button. Otherwise "last
checked" would mean "last time somebody pressed a button", which is the less
interesting of the two: a mailbox that stopped answering at three in the
morning is the one worth seeing, and nobody presses a button at three in the
morning. It is also shown when the answer is "never" — an empty inbox and a
mailbox that stopped answering look identical, and this line is the whole
difference.
A fetch against a mailbox that just refused the login now takes nothing in
and records the refusal, rather than reporting "nothing new" — which was
true and useless.
Also: HostStepsTest asserted the literal clupilot.com for a host name. That
was true until CLUPILOT_DNS_ZONE was set to clupilot.cloud on this
installation, and then a correct configuration failed a test. The zone is
pinned in the test now; what it is about is the shape of the name and which
address it carries.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
|
|
325cee50ca |
Check the machines, the DNS and the post as well
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
b1e5bf8023 |
Fix round: match the actual selection IssueInvoice and sync use
Two of the five billing checks measured something close to, but not
the same as, what actually gates the flow they report on:
- billing.invoice_series counted distinct kinds without filtering on
`active`, but IssueInvoice draws its series with
where('kind', ...)->where('active', true) (IssueInvoice.php:373,
:444) — a deactivated row is invisible to it exactly like a missing
one. An installation with a switched-off 'invoice' series was
reported ready while no invoice could draw a number: false-green.
- billing.catalogue_synced counted every PlanPrice with a null
stripe_price_id, but stripe:sync-catalogue only ever mirrors
PUBLISHED versions (SyncStripeCatalogue's own header comment). A
draft's unsynced price is an ordinary state, not a gap; the old
query kept this check permanently unsatisfied on any installation
that had ever drafted a next version — an alarm always on and
therefore never read.
Both queries now mirror the condition the real gate uses instead of a
similar-looking one of their own. Two tests added that were red before
the fix: an inactive required series that must not read as ready, and
an unpublished draft's unsynced price that must not raise an alarm.
Also strengthens the weakest breaks sentence: invoice_series_breaks
named the mechanism ("a document cannot draw a number") rather than
the consequence, unlike its four siblings. It now says what the
customer and operator actually experience — a running cloud with no
invoice, the failure only logged — matching company_details_breaks.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
|
|
d9c0ad0e4a |
Say what breaks, not just which field is empty
Task 7: the readiness collector (App\Support\Readiness) and its first group, billing (App\Support\Readiness\BillingChecks). Reports missing prerequisites — the active mode's Stripe key, its webhook signing secret, complete company details, an invoice series per document kind, a fully Stripe-synced catalogue — without enforcing anything itself; the real locks (IssueInvoice, the checkout's Stripe-key guard) stay where they are. The webhook check uses App\Support\StripeWebhookSecret::current() rather than querying config() by mode a second time, and the company check calls CompanyProfile::missingForInvoicing() rather than duplicating its list — two sources for one question is how they drift apart. Added lang/de/readiness.php and lang/en/readiness.php with every `readiness.*` key this group uses, each `_breaks` sentence naming the actual downstream failure rather than repeating the field name. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
32a8bd9a88 |
Refuse the sale instead of reaching Stripe without a key
Task 5: Billing::purchase() now checks SecretVault for a stripe.secret before touching anything else — no customer, no plan lookup, no Order row — and the customer reads a plain sentence instead of a 500 page. HttpStripeClient's own secret() throws the same StripeNotConfigured exception rather than sending a request with an empty bearer token and letting Stripe's 401 stand in for the real cause. isConfigured() was fixed alongside it: it used to call secret() too, which would have turned every "is Stripe set up?" check (the checkout controller, the catalogue sync commands) into an uncaught exception the moment a key went missing — the opposite of what this task is for. It now reads the vault directly. The new guard is unconditional, including for the cart-only purchase types (storage, addon, plan changes) that never call Stripe themselves — a cart order nobody can ever pay for is pointless to create. That broke every existing Billing/Cart/Downgrade test that calls purchase() without a stored key; each affected fixture now deposits one via the new withStripeSecret() Pest helper. A few of those tests (CustomDomainAccessTest, DowngradeTest) were passing already but for the wrong reason — the new guard, not the check they were named for — since both only assert an order was NOT created; they get the same fixture fix so they still prove what they claim. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
9da1358802 |
Stop charging VAT to the customers who owe us none
A verified EU business outside Austria gets reverse charge — rate 0, no VAT line, the note on the invoice — and was still charged 214,80 € for a 179,00 € package, because one Stripe Price carried the domestic gross for everybody. There is no VAT line on their document to reclaim, so it was a flat 20 % surcharge on exactly the customers who read their invoices, and the document then stated the whole 214,80 € as net at 0 %, so they self-accounted their own VAT on a base a fifth too large. A Stripe Price can ask who is buying after all: there are two per sellable thing now, the domestic gross and the bare net, both live on one Product, and the checkout picks by TaxTreatment. The rule is Austria B2B 20 %, other EU B2B without VAT — a domestic business still pays the gross, reclaims it as input tax, and the price on the website is still the price charged for them. - stripe:sync-catalogue mints and archives both halves of every pair, per plan and per module; `reverse_charge` on stripe_plan_prices/stripe_addon_prices says which is which, and joining it on subscriptions.stripe_price_id says which one a running contract is billed on. A rate change moves the gross Price and leaves the net one alone, because the net owes nothing to the rate. - A status that changes after the sale converges: a verification (or a lapse) makes stripe:reprice-subscriptions move the contract onto the other Price with PRORATE_NONE, because the term is already paid for. The module items follow the same way. - Downstream follows: the invoice total equals what was taken, the proof register expects the figure this customer is actually charged (so a correct reverse-charge sale is no longer flagged as a mismatch, and an overcharged one is), the setup fee obeys the same rule, and the booking page quotes what it will charge. - StripeClient gained activatePrice(). Archived Prices were brought back in our own table and left inactive at Stripe, so a rate that moved and moved back pointed the catalogue at a Price no checkout could be opened for. An unverified number is still charged the gross — an unchecked string must never be a discount — and where the net Price is missing the checkout refuses rather than reaching for the domestic one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
8ca0d4d257 |
Say the same thing to the customer, the register and the bank
tests / pest (push) Failing after 8m5s
Details
tests / assets (push) Successful in 25s
Details
tests / release (push) Has been skipped
Details
Five places where the product told somebody a figure that nothing behind it produced. **The invoice page showed five invoices nobody was ever issued.** A mock that outlived its purpose: CP-2026-0003 to CP-2026-0007, a prefix belonging to no configured series, all "paid", a spend curve from 179 to 198 € and a next charge of 198 € on 01.08.2026 — behind ['auth','verified','customer.active'], so every signed-in customer saw it. Their real invoices existed in `invoices`, were rendered as PDFs and mailed to them, and appeared nowhere in the portal at all. The page now lists their own documents, newest first, with the numbers, dates and totals off the frozen snapshot, a Storno marked as one, and a download. The PDF route resolves the document against the signed-in customer in the query — a URL anybody can type is not made private by not printing it. No next charge and no chart: nothing here computes what Stripe will take next cycle, and a figure nobody has worked out is worse than a blank space. **The setup fee was quoted everywhere and charged nowhere.** On the price sheet, on the booking page, set by the operator in the console — and CompanyProfile::setupFeeCents() had no reader outside the two sentences that quoted it. It is charged now, as a second non-recurring line on the same checkout session, which Stripe puts on the initial invoice only. `orders.setup_fee_cents` records how much of the charge it was, so the whole total still goes back on a withdrawal while the register holds the PACKAGE's charge against the contract price and the invoice prints the fee as a line of its own. The booking page was also quoting the fee NET under the same sentence the website quotes GROSS — 99,00 against 118,80 — and now quotes what will be taken. **An upgrade click cancelled a booked downgrade and delivered nothing.** Nothing in this application marks a cart order paid, so the customer traded a downgrade they had genuinely booked for a cart line that would never be fulfilled. The booking now stands until the upgrade actually lands on the contract, where ApplyPlanChange clears it. The fulfilment seam is reachable for every type that can be paid — a module and a storage pack book through BookAddon, which was the missing path from a paid order to a SubscriptionAddon and left AddonPrices, SyncStripeAddonItems and clupilot:end-cancelled-addons with no caller behind a payment. A paid traffic pack logs an error rather than pretending: the metered allowance is a standing count of packs while a top-up is sold for one month, and which of the two is meant is not a guess to make here. That no path to `paid` exists yet is now stated in both places rather than in one. **The proof register called correct charges wrong.** expected_gross_cents used the CUSTOMER's rate, which is zero under reverse charge, against the domestic gross Stripe actually took — so matches_catalogue was false for every EU business sale charged exactly the advertised amount, and for every renewal on a contract with a module, which was held against `price_cents` alone. It compares against TaxTreatment::chargedCents() and against the whole term now. Where no money moved, charged and the flag are null instead of the expected figure agreeing with itself, and a plan change records the terms with the pro-rata figures in the snapshot rather than money that arrives again on Stripe's proration invoice. **The seller's own VAT id was on the invoice, unlabelled**, between the register number and the court. Labelled — and the footer's composition moved out of the TCPDF method, because inside one the only way to read what it says is to render a PDF and un-subset its fonts. One test enshrined a figure the code does not produce: a reverse-charge purchase recorded with no charged amount, asserting 17900/0/17900, where OpenSubscription always passes the charged figure and a real one records 21480/0/21480. Another asserted that an upgrade click cancels a booked downgrade. Full suite: 1676 passed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
336ca9d88c |
Make host onboarding survive the machine it runs on
tests / pest (push) Failing after 7m52s
Details
tests / assets (push) Successful in 20s
Details
tests / release (push) Has been skipped
Details
Nine defects in the host pipeline, every one of which either stopped an
install or let a host reach `active` while it could not do its job.
The install-stoppers:
- InstallProxmoxVe hard-coded `bookworm`. A server ordered today boots
Debian 13 trixie, so it added the PVE 8 repo and keyring to a trixie
base. The codename now comes from /etc/os-release, the suite and keyring
are looked up per release, and an unknown one fails naming what it found
instead of guessing. Keys land in /usr/share/keyrings with Signed-By
rather than in the deprecated trusted.gpg.d, where they would vouch for
every other repository on the machine too.
- keyLogin() dialled wg_ip whenever it was filled, but wg_ip is persisted
inside the allocation lock BEFORE the handshake is ever proven. Attempt
1 reserved the address and failed at the handshake; every later attempt,
including a console Retry, died connecting to a tunnel that did not work
— before reaching the only code that could repair wg0.conf. Recovery
meant nulling hosts.wg_ip by hand. SSH now uses the tunnel only once the
`wg_peer` breadcrumb proves a handshake succeeded, which also rescues
the same shape in RebootIntoPveKernel, ConfigureProxmox,
CreateAutomationToken and SecureHostFirewall. Not a hole: 22 on the
public IP is open until SecureHostFirewall runs, that step runs last,
and by then the tunnel is necessarily proven. And nothing validated the
hub key/endpoint, so renderConfig() would emit `PublicKey = ` — now
refused before anything is installed, allocated or written.
- `systemctl enable --now wg-quick@wg0 || wg-quick up wg0` brought the
interface up without the enablement, so the tunnel did not come back
after the step-6 reboot; on the next attempt both halves failed with
"wg0 already exists" and the step retried forever on a working tunnel.
The three states are handled separately now, and a changed wg0.conf is
actually applied instead of only written.
- RebootIntoPveKernel removed the Debian kernel with `|| true`, ignored
update-grub's exit status, and rebooted. A /boot that filled up during
the full-upgrade could leave a machine only the provider's console can
reach. The pve kernel image, its initramfs and update-grub's exit status
are all proven first; anything missing fails loudly and does not reboot.
- ConfigureProxmox ran `ip link show vmbr0` and threw the result away
(its comment claimed it recorded the absence; it recorded nothing), then
repeated a rm -f, and always advanced. Proxmox on top of Debian does not
create vmbr0, so onboarding reported `active` with no bridge. It now
fails with the default route in the message and deliberately does not
build the bridge over the primary NIC from a remote shell.
The things that were quietly inert:
- Proxmox applies a guest's firewall rules only while the firewall is
enabled at datacenter level, and it ships disabled — so applyFirewall()'s
"80/443 only" rules were inert on every customer VM. Enabling it blindly
is the opposite trap (input policy defaults to DROP, node firewall
defaults to on, and its management ipset is seeded from the PUBLIC
subnet, not the tunnel), so: policy ACCEPT and the node firewall off
first, master switch last, read back to confirm. nftables stays the one
owner of the host's input policy.
- role_privs lacked Sys.Modify, which POST /cluster/backup requires
(pve-manager's PVE/API2/Backup.pm), so RegisterBackup 403'd and failed
EVERY customer run. And `pveum role add … || true` only ever applied the
privilege list on the run that created the role — it converges now, above
the token short-circuit so a replay reaches it.
- The nftables ruleset dropped all ICMP and ICMPv6. On a real host that
breaks IPv6 outright once the neighbour cache expires and black-holes
large transfers through PMTUD. Both families accept the types they need,
policy drop stays, and a DHCP client reply is allowed so a rebind cannot
lose the IPv4 address.
- api_token_ref used the `encrypted` cast, i.e. APP_KEY, against
SecretVault's own written rule. One rotation would have made every host's
Proxmox token undecryptable at once. Moved onto SecretCipher with a
migration that leaves any value it cannot read exactly as it is and says
so, rather than destroying a credential that exists nowhere else.
- Every plan version points at template_vmid 9000 and nothing created or
verified it, so the first paid order died in CloneVirtualMachine after
the customer had paid. VerifyVmTemplate reports that during onboarding
instead. It does not build a template: what goes into the golden image is
a product decision.
Three tests that proved nothing, fixed: `ran(…emergency-open-firewall.sh)`
only ever matched the `chmod 700` above it, because putFile() is not
recorded — the script's contents are now asserted, including that nothing
in it reopens the firewall on a timer or in the background. The port policy
was tested one-sidedly, so a mutation adding `tcp dport { 8006 } accept`
passed the suite; the ruleset is now read as a list of what it opens to an
unrestricted source. And the end-to-end test says in writing that it proves
sequencing only.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
|
|
8440266ed3 |
Stop charging for a service that has ended
Nothing in this application could cancel a Stripe subscription — there was no such method on the client at all. So a customer who cancelled and a consumer who withdrew both stayed subscribed, the card went on being charged every month, and every one of those payments arrived as invoice.paid, drew a real number out of the gapless Austrian series and mailed the PDF, for a machine that had been switched off. Indefinitely. StripeClient::cancelSubscription() closes the first half, with the timing passed explicitly at the call site — CANCEL_AT_PERIOD_END for a cancellation, because the customer keeps the term they paid for, and CANCEL_IMMEDIATELY for a withdrawal, because that unwinds the contract and the whole amount has just gone back. The immediate form states prorate and invoice_now as false rather than inheriting them: a credit note of Stripe's beside our own Storno would be the same money twice. The second half is ApplyStripeBillingEvent::owesADocument(). Refusing every invoice on a cancelled contract would lose the legitimate final one — the cycle for a term the customer really did use, unpaid, dunned for three weeks and settled after the contract ended. So the question is not when the payment landed but what it is for: an invoice whose billed period STARTED before the contract ended is documented, one whose period begins at or after that moment is not. Neither is a contract marked cancelled with no date, nor an invoice with no period, because a number handed out in error can never be withdrawn while a missing document can still be issued. The register entry is written either way — the money moved, and that is where an operator finds what has to go back. A cancellation also took its date from the order date plus MONTHS, whatever the term said, so a yearly customer who cancelled in March lost the nine months they had already paid for. It reads current_period_end now, which Stripe keeps current for both terms; the month-walk survives only for a machine with no contract behind it, where nothing records a term at all. Stripe is asked first and our rows are written only if it agreed: a cancellation we could not make effective is the defect above wearing a "gekündigt" label on the settings page, and a cancellation has no deadline, so being asked to try again costs nobody anything. A withdrawal cancelled the OPENING invoice only, found by invoices.order_id — which a renewal and a module document leave null on purpose. A storage pack booked on day three and withdrawn from on day ten was therefore neither cancelled nor refunded. Every charge inside the window is covered now, one Storno per document and one refund per payment, each against the payment that actually took it: a single refund of the total against the opening PaymentIntent is what Stripe would have refused for exceeding it, and one idempotency key for the whole withdrawal would have had Stripe replay the first refund's answer for the second. StartCustomerProvisioning::resume() opened a missing contract and returned, and the only production call to IssueInvoice::forOrders() is after the order commits — so a worker killed in between cost that customer their invoice for good, silently, with nothing anywhere sweeping for it. resume() finishes that work too, guarded by the invoice already filed against the order and by invoices.sent_at, so however often Stripe redelivers it comes out as one invoice, one number, one mail. The order confirmation stays on the first pass alone: it has nothing to stamp. subscriptions.cancel_requested_at is new because the billing half of the application had no way to tell a cancelled contract from an untouched one. Deliberately not a status: until the term runs out this is a paying customer, which is the distinction EndInstanceService is built around, and `cancelled_at` beside it still means the day it genuinely ended. clupilot:verify-vat-ids is scheduled monthly on the first, and its docblock no longer points at a note in routes/console.php that never existed. Reverse charge rested on a one-off VIES answer, a withdrawn registration keeps earning rate 0 for ever, and the unpaid VAT on those invoices is the seller's — so the cadence is the width of that window, and the first of the month is the rhythm the return is filed on. Two tests that proved nothing are now about behaviour. SettingsTest asserted service_ends_at was not null, a column the code had just written and which the broken monthly arithmetic satisfied exactly as well as the right answer; it states the yearly date, the contract's own record and the order to Stripe. EndInstanceServiceTest travelled to whatever value the implementation had computed, so it recomputed the code instead of checking it, and never looked at billing at all; the boundary is a literal date now and the money stopping is asserted beside the address coming down. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
f00cbf18d3 |
Let an operator correct a customer's details
tests / pest (push) Failing after 7m59s
Details
tests / assets (push) Successful in 23s
Details
tests / release (push) Has been skipped
Details
The console could show them and not touch them, so a moved office or a new contact person meant impersonating the customer or editing the database by hand. Both are worse than a form. R20: a modal, opened from the Stammdaten tab, authorising again and reading the record itself rather than trusting anything the browser hydrated. Most of the work is in what saving must NOT quietly do. The customer type is never reset to "nobody asked". The field opens on "leave unchanged" precisely so that saving an address correction cannot undo it — that answer decides whether a withdrawal right exists, and losing it silently would be the expensive kind of quiet. A changed VAT number is unverified from the moment it is stored — the verification is bound to the value, so that happens by itself — but `vat_id_verified_value` is left alone: it is the record of what WAS checked, and clearing it would destroy the evidence rather than the claim. The address is also the sign-in address and the key inbound mail is matched by, so changing it moves the linked portal login in the same transaction and puts it back to unverified, which is what Laravel's own profile update does for the same reason: nobody has shown they can read the new address. No mail is sent from here — the portal asks for the confirmation at the next sign-in, and a mail an operator did not knowingly send is a surprise. Three addresses are refused: one belonging to an operator (R21 — no index spans the two tables, so it is checked), one belonging to another customer, and one belonging to somebody else's user row, which can outlive or predate a matching customer. Not offered here, each for its own reason: `status`, because suspending has consequences for access and a lifecycle switch hidden among address fields is one somebody flips by accident; `stripe_customer_id`, because a wrong one does not move a contract, it points our records at somebody else's payments; the brand fields, because the customer sets those for themselves and an operator overwriting a logo is not a correction. There is a test that fails if any of them appears — with the comments stripped first, since the class documents at length why they are absent and that documentation is not the offence. This repository has fallen into that trap three times. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
2277dfe4cb |
Ask the EU register whether a VAT number is real
tests / pest (push) Failing after 8m24s
Details
tests / assets (push) Successful in 26s
Details
tests / release (push) Has been skipped
Details
vat_id_verified_at was declared on the customer and read by TaxTreatment, and set by no code anywhere. hasVerifiedVatId() therefore never returned true, so reverse charge could not trigger for anybody - and every EU business customer was invoiced Austrian VAT they cannot reclaim in their own country, which is not a pass-through for them but a real cost until the invoice is corrected. The check has THREE outcomes, and the third is why VatIdCheck exists. VIES is a gateway onto twenty-seven national registers and any of them is regularly down. Reading "I could not ask" as "not registered" would clear a verification that has stood for months, move that customer onto the domestic rate, and change what their next invoice says - because a register was asleep. Reading it as "registered" would put reverse charge on an unchecked number, which is our liability. So on UNAVAILABLE nothing at all is written. Asked at the moment the number is entered, because that is when somebody is looking at the field and can fix a typo. The save has already happened by then and the check cannot undo it: a register that is down must not cost a customer their address change. Shape and membership are refused before a request is spent, and the fake refuses on the same rule as the real verifier. A fake that answers where VIES would never have been asked is not a simplification but a different product, and free text normalises into a well-formed number more easily than it looks - "not a number" becomes NOTANUMBER, which the shape rule alone reads as a Norwegian one. clupilot:verify-vat-ids re-asks about the numbers reverse charge rests on, since a registration can be withdrawn. Deliberately not scheduled: it queries a public service with a concurrency limit on behalf of somebody else's tax position, and the cadence is the owner's to set. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
f02e86769b |
Charge the price the website shows, and hand a withdrawal back in full
tests / pest (push) Failing after 9m21s
Details
tests / assets (push) Successful in 21s
Details
tests / release (push) Has been skipped
Details
Stripe was charging the catalogue's NET figure while the document added the domestic rate on top: a customer quoted 214,80 paid 179,00 and was then invoiced for VAT nobody had collected. Under para 11(12) UStG that VAT is owed to the tax office whether or not it ever arrived, so every document issued created a liability against revenue that did not contain it. The Stripe Price now carries the GROSS figure. price_cents stays net - it is frozen onto every contract and PlanChange prorates against it, so redefining it would corrupt every pro-rata sum ever computed. Only the amount at the till moved, and it is formed by the one call TaxTreatment already answers for the price sheet and for the invoice, so displayed, charged and invoiced cannot drift apart. Stripe's automatic_tax is deliberately not used. TaxTreatment is the single tax authority here, and a second rate computed by Stripe would take 19 % from a German consumer while our document said 20 %. A Stripe Price cannot be edited, so a changed figure means a new Price and the old one archived - which stops it being SOLD and leaves every subscription already on it billing the old amount for ever. stripe:reprice-subscriptions is the second half, and a command of its own because it touches live money. A withdrawing consumer is refunded in full, as the owner decided. That is more generous than FAGG para 16 requires, and it makes the express-request consent the statute hinges on irrelevant - so the gate is gone rather than left looking load-bearing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
33566cd404 |
Show the customer, and write the answers once
tests / pest (push) Failing after 9m49s
Details
tests / assets (push) Successful in 21s
Details
tests / release (push) Has been skipped
Details
Two complaints, one cause: the customer page was a compose box with three
lists under it. It showed nothing ABOUT the customer, and every answer was
typed from scratch.
The page is five tabs now. Stammdaten — contact, phone, since when,
language, and the tax standing: consumer or business, with "nobody asked"
saying so rather than passing for business, because that answer decides
whether a withdrawal right exists at all. Paket — the contract's own
FROZEN figures, the modules at the price they were booked at, and the
machine: address, state, host, datacenter. Zahlungen — invoices with net,
gross and their PDF, and the orders beside them, because an order is a
purchase and an invoice is a document about one. Nachrichten — the portal
requests in the customer's own words, the mail they sent, the mail we
sent. Schreiben — last, because it is what you do after reading.
The tab is in the query string, like Settings and Integrations: a reload,
a bookmark and the back button all land where the operator was, and a link
can point at the tab that matters.
And templates, under Betrieb → E-Mail-Vorlagen. Most customer questions
are the same five questions, and typing the answer again every time is how
two customers get told two different things about one subject. A template
is INSERTED into the compose field with the customer's own details filled
in — never sent as it stands, because the last two sentences always belong
to the person asking. What goes out is what the operator saw and edited.
Three decisions worth naming. {{contact}} takes the contact person where
one is on record and the company name otherwise, because "Guten Tag
Beispiel GmbH" is the line that gives away a mail written by a database.
{{amount}} is the gross figure — what the price sheet quoted them and what
their bank statement says. And a typo comes out as {{kunde}}, standing
there in the field where the operator sees it, rather than as an empty
string that produces "Guten Tag ,". A placeholder with no value for this
customer stays empty; nothing is invented.
Placeholders are named in English because they are identifiers, not prose:
a template has to survive somebody switching the console's language. The
list the page documents is the renderer's own, so a token added in one
place appears in the other without being written down twice.
Blade's echo tag ends at the first }} it finds, which turned a literal
placeholder in the documentation into compiled nonsense. Assembled in PHP
now, and caught by the test that renders the page rather than by somebody
opening it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
|
|
e376b571be |
Read the support mailbox into the console
tests / pest (push) Failing after 7m59s
Details
tests / assets (push) Successful in 20s
Details
tests / release (push) Has been skipped
Details
No, not an API token: mailcow's API administers domains and mailboxes — it does not read mail. Reading a mailbox is IMAP whoever runs the server, so that is what this is. Host, user and folder are console settings; the password is a vault entry, like every other credential that opens something. IMAP by hand rather than a dependency. Four verbs are needed — LOGIN, SELECT, SEARCH UNSEEN, FETCH — and the parsing that follows is the real work either way. It is bounded by what the console shows: a sender, a subject, the words a person wrote, and the NAMES of anything attached. Real mail from real people arrives encoded, so it handles RFC 2047 subjects (every German subject with an umlaut in it), quoted-printable and base64 bodies, Windows charsets, and multipart — text wins over HTML, because a console is not a mail client and the words are in the text part. A message it cannot make sense of is skipped and logged, never guessed at: a garbled question in front of an operator is worse than one they go and read in the mailbox, where it still is. ATTACHMENTS ARE NOT STORED. Only their names and sizes, so an operator knows something was attached and can go and look if it matters. Keeping whatever a stranger chooses to send would make this application a malware store with a web interface in front of it. Whose a mail is, is decided by the sender's ADDRESS and by nothing else — not by a name in the subject, not by anything in the body. A mail is easy to write and this decision attaches a stranger's words to a real customer's file; there is a test that forges the name and the subject and still gets nothing. An address nobody recognises stays unassigned, shown FIRST and never hidden: that is a new enquiry, or a customer writing from their private account, and it is the mail that must not be missed. An operator can place it by hand from the same row. Nothing is deleted on the mail server, ever. Messages are flagged seen once the row is safely written — and only then, because flagging first and failing after would lose the message with no second copy anywhere. Filing one away is this console's own state; the mailbox is untouched. Two fixes on the way past: the customer page printed "customers.status.active" and "support.status.open" — a lang key with no file behind it renders as itself and Laravel says nothing — and the same page is now covered by a test proving each list is scoped to the customer in the URL, which was reported as "unterhalb steht immer das selbe". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
6d7c2bdf35 |
Ask whether they are a consumer, and let one change their mind
tests / pest (push) Failing after 8m35s
Details
tests / assets (push) Successful in 22s
Details
tests / release (push) Has been skipped
Details
Three things the product owed its customers and did not have. **Who is on the other side.** There was no consumer/business flag anywhere, and `vat_id` was standing in for one — which it cannot: a business without a VAT number is an ordinary small business, and a consumer with one does not exist. It is asked at sign-up now, correctable in the portal, and NULL where nobody has been asked. Unknown is read as CONSUMER everywhere it decides a right, because that mistake costs us a refund while the other one takes a statutory right away from somebody who has it. Reverse charge asks the recorded type instead of the number: an explicit consumer is charged the domestic rate whatever `vat_id` says — previously they were not, and anyone could zero their own VAT by getting a number verified. An unrecorded type still falls back to the verified number, so no contract that is already running changes rate. **The fourteen-day right of withdrawal** (FAGG, §312g BGB), for consumers only, at every door: the window is stamped on the contract when it is concluded, the customer exercises it from the portal, an operator records one that arrived by telephone or post, and both go through one action that refuses a business customer on the server rather than by hiding a card. The money follows the paperwork rather than being computed beside it. The invoice is cancelled by a Storno with its own gapless number — nothing is ever edited or deleted — a new invoice states the pro-rata value of the service actually delivered (FAGG §16, by days over the term paid for), and the refund is exactly the difference between the two documents. Where the consumer never expressly asked for the service to begin at once, they owe nothing and the whole amount goes back. `StripeClient::refund()` is new, keyed so a retry cannot send the money twice. The service ends through EndInstanceService and the `cancellation_scheduled` machinery that was already there. **The cancel button for modules.** BookAddon::cancelAtPeriodEnd() had no caller in the interface at all, so a customer could book a recurring charge in two clicks and had no way to stop it. It is on the module card now, with the date it runs until and a way back while the cancellation is still pending — and putting a module back settles nothing, because the term it was cancelled for was paid for in advance. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
0e25fe88d4 |
Keep a register of what was sent, and answer the customer from here
Three complaints, one shape: the console knew things and could not act on them, so the work happened somewhere else and left no trace. The header said "4 Hinweis(e)" and led nowhere. Whoever read it had to know that the list was a card further down the same page, and then go looking for the host or the failed run by hand. The count is a link now, every notice carries the page that shows the thing it is about, and the plural is a word rather than a bracket. Nothing recorded what this installation had sent. "Hat der Kunde die Zugangsdaten je bekommen, und wann?" was answered on the mail server — a different machine, a different program, and nothing an operator can put in front of somebody who says nothing ever arrived. Every mail now writes a row: when, to whom, which mailable, and the customer it belongs to. Written from MessageSent, so a row means the transport accepted it; that is the strongest thing an application can honestly claim, and a column called "delivered" would be pretending otherwise. Verification and reset mails are left out on purpose — they go to unconfirmed addresses and anybody who can type into a form can send them, so recording them would hand the register to whoever wants to fill it. And the customer list was the end of the road. There is a page behind it now: what they bought, what they asked in their own words, what we sent them, and a box to write the next message. It goes out from the support mailbox and is recorded on the way — with its body, because an operator typed it and "what exactly did I write to them in March" is a question the mail server's log cannot answer either. Answering a request from here closes it, which is the reason to answer from here at all: answering from a mail client leaves it open for ever, because nothing tells it otherwise. Found on the way, by a test that sent a real mail rather than asserting it was queued: resources/views/mail/reset-password.blade.php was missing its closing </x-mail.layout>. The component's contents were never terminated, the layout's own @if ran off the end of the file, and the mail raised a Blade syntax error instead of rendering. Password reset has therefore never delivered a link since it was built. Closed, plus a test that renders it and one that counts opening against closing tags across every mail view. Reading INCOMING mail in the console is not in this: it needs the mailbox polled over IMAP, which is a credential and a dependency. Requests filed through the portal appear here in full; a customer who writes by mail instead still has to be read in the mail client. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
8f1630ba91 |
Bill a booked module every month, and put it on the invoice
A module was never an item on the Stripe subscription, so it was charged in the month it was booked and never again. And the renewal document was written from our own contract snapshot, so it would not have said so even if the money had been taken. Two halves of one fault, closed together. Modules are items now. stripe:sync-catalogue mirrors a Product and a Price per module on both terms — a Stripe Price carries its own interval, and a booking frozen at an older figure needs its own Price, so they are keyed by module, amount, currency and interval in stripe_addon_prices. Booking adds an item with always_invoice: prorated by days AND charged there and then, which is the invoice the owner wants for a module booked mid-term. A second storage pack is a quantity on one item, not a second item. Cancelling takes the item off with no proration — no credit, and the next cycle is simply smaller — while the module itself is kept until the period end on subscription_addons.cancels_at and ended by clupilot:end-cancelled-addons. Documents are built from Stripe's own invoice lines. Stripe is what charged the customer; a document assembled from our figures would state a sum that is not the one taken. So every paid Stripe invoice gets one document with one number, idempotent on stripe_invoice_id — a cycle carrying package and every module together, a mid-period booking carrying just its prorated line, an upgrade carrying its proration. Only the wording is ours: each line is named from the catalogue rather than printed as a Price id, and a line nobody can name stays on the document under Stripe's own description and is logged. The checkout's own invoice is still the one exception; that purchase already has a document. Stripe being away never undoes a booking that has already reached the machine. The failure is parked on the contract in stripe_addon_sync and swept by the same clupilot:sync-stripe-subscriptions that retries a plan change, which finds its work from the bookings themselves rather than from the marker. A granted contract has no Stripe subscription and is not touched at all. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
6b8412f0c3 |
Write an invoice for work that came off no price list
tests / pest (push) Failing after 8m11s
Details
tests / assets (push) Successful in 20s
Details
tests / release (push) Has been skipped
Details
Not everything we are paid for is a package. Somebody asks whether their data can be moved into Nextcloud; we look at it, we say what it costs, we do it. That is a real invoice with no order and no contract behind it, and the only way to produce one was to write it somewhere else — which puts it outside the series. A numbered series with a document missing from it is worth nothing at an audit. So it goes through the same door as every other invoice: IssueInvoice::forService() draws from the same series and therefore the same consecutive number, applies the same TaxTreatment (a verified business in another member state gets its reverse charge and the note that makes a zero-rated document lawful), freezes the same immutable snapshot and dispatches the same archive jobs. The page under Rechnungen writes what the operator typed and nothing else. Amounts are in euro because it is a form — 12,95 becomes 1295 by round(), not by a cast that would make it 1294 — and quantities go to a thousandth because hours are billed in quarters. The total beside the lines is the same InvoiceMath the document uses; a preview doing its own arithmetic would be a second answer, and the first one anybody notices differs is on a document that cannot be changed. There is no draft: a "save as draft" that draws a number is an issued invoice with a friendlier name. The website's buy buttons go to the sign-in now, not to /order. Booking belongs in the panel, where the account exists and its address has been confirmed — which matters because that address is where the finished cloud's credentials are sent. Sending an anonymous visitor at a page behind auth worked, but only via a middleware bounce off a page they never asked for. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
f85f57152e |
Let the customer buy it themselves, and tab the integrations page
tests / pest (push) Failing after 21m20s
Details
tests / assets (push) Successful in 20s
Details
tests / release (push) Has been skipped
Details
Every path on the site ended in "anfragen": a mailto: link and a promise to answer the same working day. That is an afternoon of somebody's time per customer, for a product whose whole point is that the machine does the work — and it scales exactly as far as one inbox does. Now: sign in, pick a package, pay, and provisioning starts by itself. The pieces were already there — Stripe holds a Price per plan version (the catalogue sync writes the ids), the webhook opens the contract, the pipeline builds the VM — and what was missing was the checkout session between them. CheckoutController opens one and gets out of the way; the purchase becomes real on the webhook, which stays the only place allowed to believe a payment happened. The success URL is a redirect target and therefore a URL anybody can type, so it says "we are building it", not "you have paid". Nothing in the request may decide the price. The plan key arrives from a form and everything else is re-derived from the catalogue, including the plan VERSION, so a version published while the customer types their card number cannot change what they were quoted. Two cases that are not errors: no capacity is a parked order (already built), and an existing customer is sent to the plan change rather than sold a second, empty cloud that bills twice. The site says what it can keep. "danach wissen Sie … wie Ihre Daten sicher umziehen" was a promise made before seeing the thing it is about: we do not know where a visitor's data is, what shape it is in, or whether it can be moved at all. Migration is named as its own question, looked at first and quoted afterwards. The integrations page gets the settings page's treatment for the same reason: it was one narrow column of six cards, so an operator connecting DNS scrolled past Stripe to reach it and the raw .env editor sat at the bottom of everything. Three tabs by what a section is FOR — services we buy from outside, our own machines, the file underneath — the same axis the page was rebuilt around when it stopped being split by storage mechanism. The tab is in the query string; unlocking survives a switch, because a confirmed password is a session fact and not a property of a tab. The .env tab is Owner-only, like the editor it holds. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
35b312d5ef |
Invoice every renewal, and tell Stripe when the package changes
Two things Stripe was doing on its own, without us. **A renewal produced no invoice and no mail.** InvoiceMail went out from exactly one place — the first purchase — so a customer paying every month for a year received one invoice, for month one. Stripe already says when the money lands: invoice.paid with billing_reason subscription_cycle. What was missing was the document. A renewal has no Order behind it, and one was not invented for the occasion: an Order is something a customer bought, and writing a purchase nobody made would corrupt the record of what they have actually ordered. IssueInvoice::forBilledPeriod() issues from the CONTRACT instead, at its frozen net price, for the term Stripe billed. Idempotent against the Stripe invoice id, which is unique in the database rather than checked in PHP — and because the number is drawn inside the same transaction as the row, a redelivery takes its number back with it and the series keeps its sequence. Nothing in the paperwork can fail the webhook: issuing and mailing are both caught and logged, exactly as confirmByMail() does. Only a cycle renewal gets a document. The checkout's own invoice already has one; an upgrade's proration was worked out against Stripe's boundaries and would not match a document written from our snapshot; a charge raised by hand is for something this catalogue cannot describe. All three stay in the register, and are logged once as money no document of ours covers. **After a plan change, Stripe went on billing the old price.** There was no way to move a subscription onto another price at all, so a customer who upgraded paid for the smaller package every month afterwards. The client can now swap one, the item id it needs is learnt from Stripe's own events (and asked for once, for contracts older than the column), and the behaviour is chosen per direction: an upgrade settles immediately, so the cycle invoice stays exactly the contract price the renewal document states; a downgrade lands at the period boundary and settles nothing. A granted contract has no Stripe subscription and is left untouched. A change that reaches the machine and not Stripe is never rolled back — it is parked on the contract with the error and the behaviour it needed, logged as an error, and retried hourly by clupilot:sync-stripe-subscriptions. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
7265799881 |
Quote what a person pays, and tab the settings page
tests / pest (push) Failing after 7m50s
Details
tests / assets (push) Successful in 21s
Details
tests / release (push) Has been skipped
Details
Prices. The sheet quoted net, which is the wrong number for one of its two readers: a private customer has no way to add 20 % in their head and no business being surprised by it at checkout. The gross figure is now the big one, with the net underneath for a company's books, and the same rule applies to the module prices further down — a sheet that quotes the package with VAT and the module without it is a sheet whose numbers cannot be added up. The rate is the one on the Finance page, the same one an invoice uses; a percentage written into the template would be the second source that makes the two disagree. "zzgl. einmaliger Einrichtung" had run for months without ever naming a figure, which leaves a visitor knowing only that there is one. It is a field on the Finance page now and prints with the price. Zero removes the sentence — and the comparison card that listed the fee as the honest downside of the offer — rather than admitting to a cost that is not charged. Layout. Four packages in 1120px left each card 202px of text, which is what broke "Bereitstellung in 2-3 Werktagen" over two lines and made the cards look pressed against each other. The pricing section is wider than the rest of the page (only it: the reading columns elsewhere are narrow on purpose, a price sheet is four columns that must be comparable at a glance), the gap between cards is larger, and the delivery line is short enough to fit with whitespace-nowrap to keep it that way. Navigation. VPN moves from Betrieb to System: the tunnel is not a task of the day, it is how the console reaches the estate at all. Settings. The page had grown to three headed sections in one column with the operator's own password somewhere in the middle. It is tabbed now, and two-factor enrolment is one of the tabs instead of a menu entry of its own — it is a setting, and it was the one thing an operator had to leave this page to change about their own access. Same component, no second copy of the enrolment logic; it still answers at its own route because RequireOperatorTwoFactor sends an operator there who may not open anything else yet, this page included. The open tab lives in the query string, so a reload, a bookmark and the back button all land where the operator was. That matters here more than elsewhere: the installation tab refreshes itself while an update runs, and a tab kept only in the component would drop them back to the first one mid-deployment. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
5b63fdb86c |
Deliver the storage a customer actually buys
A booked storage pack was a row in `subscription_addons` and nothing else: priced, frozen, charged every month, and delivered nowhere. Nextcloud's quota came from `instances.quota_gb`, which is the package alone; the disk was sized for the package alone; and on the one occasion a disk did grow — a plan upgrade — the guest never saw it, because nothing stretched the partition or the filesystem over the new space. One authority for the allowance. StorageAllowance adds the package and the booked packs, and everything that needs the figure asks it: the quota step, the downgrade check, the portal, the console, the repair command. It is DERIVED rather than stored — a column would have to be rewritten by booking, cancelling, granting, a grant expiring and a plan change, and the day one of those paths forgot it would be silently wrong in whichever direction costs somebody money. `quota_applied_gb` keeps its own meaning: what the guest was last actually told, which is how a machine with the allowance enforced is told from one where the figure has only ever been a row in our database. Buying it delivers it. BookAddon asks ApplyStorageAllowance for a run on booking AND on cancellation; the new `storage` pipeline grows the virtual disk to the allowance plus the package's own overhead (read off the package — 20/40/50/100 GB on the four catalogue packages — never a ratio invented here), then GrowGuestFilesystem makes the guest see it, then the quota is applied. No cold boot is involved: the data disk is scsi0 and Proxmox's resize on a running guest is a qemu block_resize, so the capacity reaches the guest while it runs. The new step rescans, growparts and grows the filesystem with the tool its type actually needs — ext2/3/4, xfs, btrfs — and fails loudly on one it does not know rather than handing it to resize2fs and hoping. Idempotent end to end: NOCHANGE from growpart is not a failure, and every tool here exits 0 when there is nothing left to grow. The same step now runs in the plan-change pipeline, where an upgrade used to stop one step short of the customer. A downgrade blocked by data gets a way out. The block stays — it is correct — but the check now measures against the target package PLUS the packs the customer already owns, and reports the numbers behind the refusal: what is stored, what would be allowed, how much has to go, and how many packs would cover it instead. The portal offers both routes: book exactly those packs (confirmed in a modal, R23), or delete data and have the fill level measured on demand rather than waiting for the nightly sampler. That reading is taken by DiskUsageProbe, which CollectInstanceTraffic now uses too, so there is one notion of "how full is it" and not two. A plan change keeps booked packs. They were paid for separately and have nothing to do with which package the customer is on; the new package's disk and quota are sized with them included. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
ec8675861e |
Take the order, park it, and say when it will be delivered
tests / pest (push) Failing after 7m44s
Details
tests / assets (push) Successful in 21s
Details
tests / release (push) Has been skipped
Details
A paid order that no host has room for used to FAIL at the reservation
step. The customer has paid by then, so that turned a sale into an
incident and left somebody holding money against nothing. The estate is
finite and booked thick, so "no host has room right now" is an ordinary
Tuesday — it means a machine has to be bought, which takes days.
So the order waits instead. poll(), not retry(): the wait is measured in
days and must not eat the run's attempt budget. It only fails after
PARK_DAYS (14) — long enough to buy, rack and onboard a server over a
weekend, short enough that a forgotten order surfaces instead of waiting
for ever.
Then say so, in the same words on both sides of the payment:
- The price sheet marks each package "wird sofort ausgeliefert" or
"Bereitstellung in 2-3 Werktagen", read from free capacity rather
than written into the page.
- The customer's own overview says their cloud is being prepared while
it is parked, instead of a stepper standing still for two days under
"wird eingerichtet" — which reads as broken.
- The console front page shows the same per-package answer, so an
operator can see what visitors are being promised.
And a capacity page for the decision that follows: who is waiting, what
the roomiest host still has, what the queue needs ("3x Start, 1x
Business" - the LARGEST parked package sets the minimum machine, because
an instance lives on one host), and what such a machine costs today from
the provider's live list. It orders nothing: buying a server is a
contract, and a bug in a capacity calculation must not sign one.
The operator can also say where each parked order goes. A pin is
honoured or it waits — never quietly redirected — because placing it
elsewhere answers a different question than the one they answered, and
they would find out from the finished instance.
Two things this shook out:
- `current_step` is an INDEX into the pipeline, not the step's key, and
it is cast to integer. `where('current_step', 'reserve_resources')`
reads as correct and matches step 0 of EVERY pipeline instead. Both
lookups go through HostCapacity::parkedRuns() now, which resolves the
index and filters by pipeline.
- The auction feed sends `hdd_hr` as a list, not a string. Casting it
printed "Array" into the console and raised a warning that took the
page down with it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
|
|
d76de5ada3 |
Restart a machine, enforce the quota that was sold, end a route that ended
Three places where the product said one thing and did another. **Nothing could restart a virtual machine.** ProxmoxClient had startVm and no way to stop anything, so `restart_required_since` — set by every plan change that grows a running guest — could only ever be cleared by a resize step that happened to find the machine stopped, which nothing in the product could arrange. A paid upgrade's cores and RAM could stay unreached for the life of the contract, and the cloud page's "Neu starten" was a toast. There is now a `restart` pipeline: shutdown, start, wait for the agent, confirm. A shutdown and a start rather than a reboot, because only a cold boot makes qemu read the VM definition again — a reset would take the machine round and bring it back exactly as small as it was. The shutdown is a REQUEST, bounded at ten minutes, and there is no escalation: ProxmoxClient deliberately does not expose Proxmox's forceStop, because the guest is a Nextcloud and cutting power to a database mid-write to apply a CPU change trades an inconvenience for a restore from backup. A guest that ignores ACPI for ten minutes fails the run instead, and a person decides what happens to it. `restart_required_since` is cleared by the last step, from what the RUNNING guest reports (`cpus`/`maxmem`) — not by the button, and not by the run merely finishing. A machine that comes back smaller than it was sold fails the run. Started by App\Actions\RestartInstance, which re-checks authorisation itself rather than trusting a hidden button: an operator holding the new `instances.restart` may restart anything, a portal user their own machine and nothing else. Refused while another run is in flight against the same order. Confirmed in a modal on both sides (R23), addressed by uuid (R11). **Existing instances had no storage quota.** ApplyStorageQuota reaches new and changed machines; everything built before it kept the whole disk whatever it had paid for, and nothing recorded which was which. `instances.quota_applied_gb` is now what the guest was actually told, written by the step only after the occ call succeeds — so "sold" and "enforced" are separate facts and can be compared. `clupilot:apply-quotas` sweeps the estate: one `quota` run per instance through the same step the pipelines use, skipping what it should and saying why, a no-op once an allowance is enforced, and inert under --dry-run. Deliberately NOT scheduled — a nightly sweep would quietly cover for the pipeline step the day it stopped working, which is how the original hole survived this long. **A Traefik router was never torn down.** TraefikWriter::remove() had no caller anywhere. What was missing was the moment: ConfirmCancelPackage wrote a date into `service_ends_at` and nothing ever went back to it, so every route this platform ever wrote was written for good — pointing at a guest address the host is free to reassign. `clupilot:end-due-services` (hourly) keeps that appointment through App\Actions\EndInstanceService. "Ended" means cancellation_scheduled AND `service_ends_at` passed, never the day a cancellation is scheduled: that customer has paid to the end of the term and is working in it. The DNS record goes with the route — it is in our zone, pointing at a host that serves other people, which is the shape of every subdomain takeover there has ever been. The customer's own domain is in their zone and is not ours to touch; the virtual machine is left alone, because the cancellation flow promises a data export first and destroying disks is not this change's decision. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
98d884727a |
Warn that a package cannot be placed, before somebody pays for it
tests / pest (push) Failing after 7m55s
Details
tests / assets (push) Successful in 23s
Details
tests / release (push) Has been skipped
Details
The platform books thick: a package reserves its whole disk_gb — the sold quota plus the machine's own overhead — on ONE host at placement. That is the right choice for a small operator, because nothing can quietly overfill. It has one consequence nobody was being told about: the catalogue goes on offering a package no host has room for, and the first anyone hears of it is a PAID order failing at the reservation step. On the machine this was found on the numbers are stark. 2 x 512 GB NVMe in a mirror is about 400 GB usable; Start reserves 120, Team 540, Business 1050, Enterprise 2100. Three of the four packages on sale cannot be placed at all, and the fourth fits three times. So the console says so now, per package, with the shortfall — "Team needs 540 GB, the roomiest host has 260" — because "buy a server" is not the decision. "Buy a server big enough for what I am selling" is. Two things it deliberately does not do. It does not warn on an installation with no host at all: that is a machine nobody has onboarded yet, it has its own notice, and without the guard a fresh console greeted its operator with one warning per package before they had done anything. And it does not take the overview down when the catalogue cannot be read — a capacity warning does not get to break the page somebody opens when something is already wrong. The largest FREE HOST, never the sum: an instance goes on one machine, so two hosts with 300 GB each cannot take a 540 GB package between them. And when placement does fail, the log now names the shortfall instead of only 'no_capacity'. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
8f2252e81b |
Book a downgrade for a date that cannot move, and sell a module once
Two ways of charging a customer for something they did not get. A downgrade was due when `subscriptions.current_period_end` said so, and Stripe pushes that column forward on every renewal. So a downgrade booked in March was deferred to the end of April by April's renewal, and to the end of May by May's — each time by a whole term, and each time the customer was billed again for the package they had asked to leave. The due date was being re-derived from a moving target on every scheduler tick. It is decided once now, at the moment the customer decides, and stamped onto the contract: `pending_plan` and `pending_effective_at`, the two columns that have been in the schema since the first migration and were written by nothing at all. The shop stamps them, PlanChange reads them instead of the period end when a move is already booked, and clupilot:apply-due-plan-changes finds contracts by the stamped date rather than orders by a date it recomputes. The order stays what it always was — the customer's own record of the request, what the cart shows them and what they remove to change their mind — so it is still consumed with the change, and removing it unbooks the change. What it no longer does is decide when. A second downgrade replaces the first rather than queueing behind it; an upgrade clears one, or it would come due months later and undo the bigger package; a contract cancelled before the date takes the booking with it; and a customer with no contract can no longer place a downgrade at all, which was accepted before and could never be carried out. A booked change is now visible where the contract is read: on the plan card in the portal, because the cart entry disappears when it is paid for and the booking does not, and on the customer row in the console, because that is where an operator answers "what is this customer on". The second defect is the same money in one step: BookAddon guarded duplicates per ORDER — the unique index on (order_id, addon_key) — so two orders for the same module on one contract both went through. A stale tab or a double click bought priority support twice. But storage is deliberately sold in packs, and AddonCatalogue sums the quantities for exactly that reason, so "refuse the second" is only right for some of them. Which is which is declared beside each price in config/provisioning.php (`sold_as`), because it is a commercial decision about each module and not something to infer from a key: storage is a quantity; off-site backups are on or off, support is prioritised or it is not, Collabora Pro is one licence, and a machine answers to one own domain. An undeclared module counts as an entitlement — the cheaper of the two mistakes — and a test refuses to let one ship undeclared. Enforced in the action, where the portal, the console's grant screen and a webhook all pass, with the sentence the customer is shown rather than a developer's. A retried webhook for the SAME order still gets its one booking back; that is one purchase arriving twice, not two purchases. The portal stops offering what would be refused — booked, or already waiting in the cart — and goes on offering the pack. What is missing is on the Stripe side and is not invented here: nothing moves the subscription's item onto the new Price, so the renewal after a plan change still bills the old plan at Stripe. StripeClient only creates and archives catalogue objects, and swapping a price also needs the subscription ITEM id, which nothing stores. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
34874adec3 |
Apply a bought plan change instead of only pricing it
tests / pest (push) Failing after 8m6s
Details
tests / assets (push) Successful in 21s
Details
tests / release (push) Has been skipped
Details
PlanChange could say what a move would cost and whether it was allowed, and that was all it could do. Billing::purchase() wrote an upgrade order and nothing ever consumed it: same snapshot, same machine, same quota. A customer could pay for a bigger package and receive nothing. ApplyPlanChange is now the single place a change lands — it moves the contract onto the target's current version, writes one register row, settles the custom domain, and starts a run that resizes the machine. Applying the same order twice is a no-op, enforced by a unique event key rather than by a check two callers could both pass. Two things a plan change must not do, and now does not. A disk is never shrunk — Proxmox cannot, so the QUOTA shrinks instead, which is what was sold anyway. And a live machine is never rebooted as a side effect: cores and RAM are written, and where they need a restart the instance says so where an operator and the customer can both see it. The storage allowance also joins the build pipeline. ApplyStorageQuota was written for plan changes and ran only there, so a brand-new customer still got no quota at all — quota_gb reached the instance row and stopped, and every package delivered the whole disk. The test guarding that pipeline only counted its steps, which is how a list missing the one step that makes a package's storage real stayed green for its whole life. It now names the step. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
3a4324fb6f |
Give people a way back in, and put the URL in English
tests / pest (push) Failing after 7m54s
Details
tests / assets (push) Successful in 21s
Details
tests / release (push) Has been skipped
Details
── The hole ──────────────────────────────────────────────────────────────── There was no password reset. Fortify's feature was commented out, so there was no link on the sign-in form, no page and no route — a customer who forgot their password was locked out of their own cloud until somebody opened a shell. On a product whose selling point is that you can ring somebody, that is a support call a week and an embarrassing one. Two pages of ours under Fortify's route names (R1/R2), because with views off Fortify registers only the POST endpoints. The mail is a Mailable in this product's design rather than the framework's MailMessage: somebody who has just been locked out is exactly the person a phishing mail is aimed at, and a message that looks nothing like the rest of our post is one they cannot check — ours carries the footer that names our domains. Three decisions worth stating, each with a test: The answer is identical whether the address is known or not. "No account with that address" turns the form into a way of finding out who is a customer. The reset kills every other session. Whoever knew the old password may still be signed in somewhere, and a reset that leaves them there has fixed nothing. It does NOT sign the visitor in. The link arrived by email, and a mailbox somebody else can read would otherwise be a session somebody else gets. ── /sicherheit → /security ───────────────────────────────────────────────── R13: paths are English. Mine was not. The old path stays as a permanent redirect because it has already gone out in mail footers. ── One wordmark ──────────────────────────────────────────────────────────── "Sometimes it says Cloud and sometimes it does not" — correct. It was on the footer, the placeholder and the maintenance screen, and absent from the header, the sidebar and the sign-in plate. The product is called CluPilot Cloud; that is the name on the invoices and in every mail, so it is now the name everywhere. The console sidebar takes the small size so the lockup, the ADMIN badge and the close button still share one line (R18). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
712803edd6 |
Serve the custom domain, not just announce it
A verified custom domain was reported as the customer's address by
Instance::address(), by the portal and by the credentials mail while
nothing on the platform routed it: the Traefik router's rule was
hard-coded to {subdomain}.{zone}. The address a customer was handed
answered nothing, and a withdrawn domain stayed in Nextcloud's
trusted_domains forever, because the only thing that ever wrote either
was the initial provisioning run.
- TraefikWriter::write() takes a LIST of hostnames under one stable
router name — the platform address always, the verified domain as
well when there is one. One file per instance, so a withdrawal is a
rewrite rather than a second thing somebody has to remember.
- ConfigureDnsAndTls records the custom domain's certificate instead of
waiting on it: it depends on an A record in the customer's own zone,
which may never appear, and must never fail a run. The platform
address keeps its 840s deadline. instances.domain_cert_ok tells
"proven" apart from "answering", and the portal now says which.
- ConfigureNextcloud deletes trusted_domains 2 when there is no verified
domain, so a withdrawn one stops being trusted.
- New `address` pipeline (those two steps) plus ReapplyInstanceAddress,
which starts one against the order and refuses to start a second while
any run is in flight. The route is rewritten when the hostname list
differs from what the router carries — not on route_written, which
would short-circuit exactly the case a re-apply exists for.
- Triggered where the address changes: verification flipping either way
in clupilot:verify-domains, the customer's own domain page, and
CustomDomainAccess::deactivate() on a package downgrade.
- A maintenance run no longer condemns its subject: an address run that
failed used to mark the order failed and release the live instance
with it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
|
|
6ef9b510ce |
Merge branch 'feat/plan-reopen'
# Conflicts: # app/Http/Controllers/LandingController.php |
|
|
|
f8874f32ea |
Put customer instances on their own domain, and say which domains are ours
tests / pest (push) Failing after 8m1s
Details
tests / assets (push) Successful in 20s
Details
tests / release (push) Has been skipped
Details
── The zone ──────────────────────────────────────────────────────────────── A fresh install now addresses customer instances under clupilot.cloud rather than clupilot.com. Not branding: a Nextcloud is third-party software that strangers sign in to, and on the same registrable domain as the portal it shares cookie scope, document.domain and CAA with it. The same reason googleusercontent.com and vercel.app exist. Existing installations keep whatever the console says — the zone has been a setting all along. `clupilot:check-zone <zone>` reports what a change would touch before anything is touched: the DNS records, the monitoring targets, the certificates and Nextcloud's trusted_domains all carry the old name and the setting reaches none of them. With no instances in service it says so and stops. Changing it later is a migration with an outage, and the command says that too. ── Which addresses are ours ──────────────────────────────────────────────── A new public page at /sicherheit: the domains that belong to us, the rule for reading an address bar, what we never do, and — the part that matters — what to do if you have already typed your password into a copy of our sign-in form. Change it, end every other session, change it wherever else you use it, tell us. In that order. Reachable without an account, deliberately: somebody who has just given their password away is signed in nowhere, and the page they need cannot be behind the thing they lost. Linked from the three places a person is when the question comes up: every mail footer (the mail is the vector), the sign-in form (this is the page a phishing kit copies, so the real one says which host you are on), and the site footer. The domain list is DERIVED from configuration and keyed by what each domain is for. A list typed into a page would eventually tell a customer that a phishing domain is one of ours, or that one of ours is not — the same two-sources-of-truth failure that had the price sheet promising an address on a domain the company does not own. The operator console is deliberately absent from it: naming it on a public page tells an attacker where to aim. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
b3651a14be |
Decide who may have their own domain, and decide it in one place
tests / pest (push) Failing after 9m46s
Details
tests / assets (push) Successful in 21s
Details
tests / release (push) Has been skipped
Details
The domain page shipped with no access control at all: the `custom_domain`
plan feature was checked nowhere in the codebase, and every customer on
every package could point a domain at their instance. This is the owner's
rule, built as one authority that everything else asks.
Start impossible — not bookable, not upgradable, not offered
Team optional, via the `custom_domain` module
Business included, because the plan version carries the feature
Enterprise included, likewise
App\Services\Billing\CustomDomainAccess answers all of it: may this
contract use one, may it book the module, why not in words a customer can
be shown, what a downgrade would do to a domain they have, and — after a
change lands — making the state match. Read against the FROZEN plan
version, never today's catalogue, so a feature added to a package later
cannot reach into a contract signed before it.
Which packages cannot have one at all is an explicit list in
config/provisioning.php, beside the module's price. The catalogue cannot
answer it: plan versions model what a package HAS, and nothing models what
a package may BUY. Both available proxies are dishonest — "the lowest tier
on sale" hands the right to a grandfathered Start customer the day the
owner stops selling Start or adds something cheaper below it, and reading
it off `branding` would sell a domain to anyone allowed to upload a logo.
The config comment says so.
Enforced where it can be enforced today:
- BookAddon refuses the module, with the sentence the customer is shown.
GrantAddon now writes its synthetic order inside the transaction, so a
refusal cannot leave a paid order for a module nobody got.
- The portal drops the card where it cannot be booked and shows it as
included where the package carries it — no price, no button.
- Billing::purchase() re-checks, because a hidden button is markup.
- DowngradeCheck reports the consequence beside the blockers, so the
customer reads it above the button rather than after it: losing the
domain (Team -> Start), or the choice (Business -> Team, where keeping
it means booking the module).
- PlanChange::settleCustomDomain() applies it once a change lands: the
module stops being charged for and the instance goes back to its
platform address. It clears the whole domain, not only the verified
flag — clupilot:verify-domains re-reads every row that still has a
domain and a token, and would switch it back on the next night.
- The price sheet's own-domain row now reads dash / optional + price /
included, from the same authority; the three cell states already
existed and the view is unchanged.
STILL TO APPLY, ONCE THIS AND THE CUSTOM-DOMAIN BRANCH MEET — one line at
the top of App\Livewire\CustomDomain::mount(), which belongs to the other
session and is deliberately untouched here:
abort_unless(app(\App\Services\Billing\CustomDomainAccess::class)->allowsCustomer($this->customer()), 403);
routes/web.php is left alone for the same reason. The mount() guard covers
the route and the component both; a middleware would only repeat it.
Follow-ups, named rather than built:
- Nothing applies a plan change anywhere yet, so settleCustomDomain()
has no caller. Whoever builds that must call it after the new contract
opens, and must decide whether a booked module is carried onto the new
contract — enforce() cancels the booking on the contract it is given.
- Deactivation sets the state; it does not re-run provisioning. Traefik
and Nextcloud's trusted_domains still name the old address until
ConfigureDnsAndTls / ConfigureNextcloud run again.
- App\Support\Navigation still shows the Domain tab to every customer;
it can ask allowsCustomer() the same way.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
|
|
a4626d2569 |
Stop root workers breaking every page, and let the panel be closed
tests / pest (push) Failing after 12m38s
Details
tests / assets (push) Successful in 26s
Details
tests / release (push) Has been skipped
Details
── The 500 ───────────────────────────────────────────────────────────────── "touch(): Utime failed: Operation not permitted", from BladeCompiler, on every request for the affected view. Blade compiles a view and then touch()es the compiled file to the source's mtime. touch() with an explicit time needs ownership of the file. A compose service without `user:` runs as ROOT — and queue, scheduler and reverb had none — so the first worker to render a view wrote a root-owned compiled file that the web process, as www-data, could never refresh again. The queue is what renders mails, which is why this surfaced now. queue, reverb and scheduler run as www-data. queue-provisioning stays root and says why: it brings wg0 up and runs `wg set` for every peer change, which needs NET_ADMIN on the running process. It renders no mail. And update.sh normalises ownership at the END of a run as well as at the start. The first call heals what a previous run left; the second heals what this one made — `git checkout` rewrites the tree as the service account while the old build is still serving. ── The panel that would not go away ───────────────────────────────────────── The same 500 is why it kept coming back: every poll failed, the watcher treats a failed request as "still restarting" (which it normally is), and the overlay stayed up over a console nobody could then reach to find out why. A restart is seconds. After two minutes of nothing the panel now says so and offers a way out — for an operator only, behind the same flag as the step and the log, so a customer on the 503 page is not shown a button suggesting they can call the deployment off. A successful answer clears it again: one bad minute must not leave a "something is wrong" notice sitting there for the rest of the run. Not an always-present close button. The deployment does not stop because somebody dismissed a panel, and offering that at the wrong moment is a lie. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
da76a260fb |
Give the settings page an outline
tests / pest (push) Failing after 8m0s
Details
tests / assets (push) Successful in 20s
Details
tests / release (push) Has been skipped
Details
Reported as cramped boxes with empty space either side, and it was: one column half the window wide, eight cards of identical weight stacked in an order nobody chose — the operator's own password sitting between two platform-wide switches — and every card taking a full row to hold three lines of text. The page is wider now and has three groups, each with a heading: what belongs to the installation, what belongs to the signed-in operator, and the team. Short cards sit side by side instead of one per row. The two-factor policy moved into the installation group, where it belongs: it is a rule for everybody who signs in, set by the owner, not a setting on one account — and it fills the space the short website card was leaving beside the long allow-list. Fields are sized to what goes in them. The three password boxes were laid out three across inside a narrow card; they stack in a half-width one. The window times are four characters and were as wide as their column. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
517606b4db |
Split the price sheet into what everyone gets, what differs, and what costs extra
One boolean matrix was carrying three different kinds of statement, and it made all three unreadable. Updates, backup and monitoring were a tick in every column — a comparison that compares nothing — and the same promises were repeated in a prose line under the table. They are now stated once, above it, together with the platform address in the shape a customer will actually type (ihrefirma.<zone>, from the configured zone) rather than as the phrase "eigene Subdomain", which said nothing and was modelled as though an own domain replaced it. Provisioning issues both. Support was two independent booleans on one axis, so enterprise — which carries the SLA and not the priority flag — rendered "Bevorzugter Support —" beside team's tick: the top plan looking worse than the middle one. It is one row of levels now, and a plan with neither shows Standard, because standard support is a level and not an absence. A cell had two states, so a dash meant both "you cannot have this" and "you can have this for nine euros". There are three now, and the middle one quotes AddonCatalogue — which is also how the modules we have been selling all along finally appear on the page at all. Nothing is invented: where we sell no module for a missing feature, the cell stays a dash. custom_domain joins the module list at the owner's figure so the two plans without it can be quoted a price instead of told no. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
6c92aa5dd7 |
Let an incident be deleted, and start measuring whether the hosts answer
tests / pest (push) Failing after 8m13s
Details
tests / assets (push) Successful in 21s
Details
tests / release (push) Has been skipped
Details
Three things, all from trying to actually use the incident page.
── Where the later entries go ───────────────────────────────────────────────
The form only ever creates the first one, and nothing said so — so it looked
as though the stage had to be chosen up front and there was only a dropdown
for the impact. The field now says what it becomes ("wird untersucht") and
where the rest are added: on the incident itself, once it exists.
── Deleting ────────────────────────────────────────────────────────────────
Asked for in order to test the thing at all, and right. A whole incident can
now be removed, with its entries, behind a confirmation in the product's own
dialog (R23).
Deliberately not the same as editing an entry, which there is still no way to
do: an update is a statement made at a time, and rewriting one afterwards is
what the record exists to prevent. Removing the whole incident is a different
act — it is how a test entry, or one published against the wrong service, is
taken back, and it leaves nothing half-standing behind. A test holds that
distinction so the delete button does not quietly become an exception to it.
── The hosts, which nothing was watching ────────────────────────────────────
`hosts.last_seen_at` drove the health dot in the console and was written
exactly once, at onboarding — RegisterCapacity stamped it and that was the
only write in the codebase. So every host read "offline" thirty minutes after
it was added, permanently, and the dot meant nothing. It is also why host
reachability could not appear on the status page: it was never being measured.
PingHosts asks every host's API the cheapest question it answers, once a
minute, and stamps the timestamp on success. Failure writes nothing on
purpose: absence IS the signal, and a second column counting failures would be
a second version of the same fact.
The status page has a fifth component for it. Counts only — never a host name,
never an address; the page is world-readable and the estate is not public
information. A host between five and thirty minutes silent reads as degraded,
not as healthy: "stale" is one of three answers healthState() gives and it is
not the good one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
|
|
0e3c50d9a1 |
Update on a schedule if the owner wants one, and say where incidents go
tests / pest (push) Failing after 8m22s
Details
tests / assets (push) Successful in 20s
Details
tests / release (push) Has been skipped
Details
── Where the status page gets its data ────────────────────────────────────── Asked in as many words, and fairly: the page for maintaining incidents exists (console → Betrieb → Störungen) and nothing anywhere said so. The page now carries the answer at the top — what is written there appears publicly at once, the four components measure themselves, and there is a link straight to the live status page. ── Automatic updates, inside a window, switchable ─────────────────────────── Tick it and name the days and hours; leave it alone and nothing changes. The button stays either way: manual is always possible, at any hour. It leaves exactly the same request the button leaves — same file, same agent, same run. A second path into a deployment would be a second set of rules to keep in step with the first, and the automation must not be able to do what an operator is prevented from doing by hand: it checks the same three things the button is disabled by (agent alive, something released, nothing running). Nothing guards against having already updated by hand, and nothing needs to: an installation that is current has nothing available, so pressing the button at four leaves the five o'clock window with nothing to do. That was the owner's own question and it answers itself. Defaults are Tuesday to Thursday, 05:00–06:00. Not Monday — the week starts and every fault costs double. Not Friday — nobody wants to repair a deployment on a Friday afternoon. Not three in the morning: if it goes wrong, the person who has to fix it is asleep. A window whose end is before its start wraps past midnight, because 23:00 to 02:00 is a reasonable thing to ask for and reading it as an empty range would silently mean "never". Switched on with no weekday selected is refused rather than corrected — it is a configuration that reads as working and does nothing. The clock is the wall clock (R19): an operator who writes 05:00 means five in the morning where they live, and a window that drifts by an hour twice a year is a window nobody trusts. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
d4e8822dbb |
Paketversionen: Verkauf wieder aufnehmen und saubere Übergabe
tests / pest (push) Failing after 7m58s
Details
tests / assets (push) Successful in 20s
Details
tests / release (push) Has been skipped
Details
Eine geschlossene Version konnte nie wieder in den Verkauf. Schlimmer: um ein Paket zu ändern, musste die laufende Version zuerst beendet werden — und wenn die Nachfolgerin dann nicht sofort veröffentlicht wurde, war das Paket dauerhaft dunkel. - PlanCatalogue::reopen() setzt available_until zurück auf null, über schedule(), damit Family-Lock und Überlappungsprüfung dieselben bleiben. Ein nie veröffentlichter Entwurf wird abgelehnt. - PlanCatalogue::publish() übergibt eine laufende Version an ihre Nachfolgerin (available_until = deren Start) statt die Überlappung abzulehnen — unter demselben Lock, in derselben Transaktion. Nur die unmittelbare Vorgängerin, und nur wenn es genau eine gibt. - Konsole: Knopf „Wieder verkaufen", übersetzte Ablehnung statt der englischen Ausnahme, und die Übergabe wird im Veröffentlichen-Formular vorher angekündigt (Version und Zeitpunkt, ->local()). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
ea643b5e73 |
Prove a custom domain before serving it, and keep proving it
tests / pest (push) Failing after 8m17s
Details
tests / assets (push) Successful in 23s
Details
tests / release (push) Has been skipped
Details
Two things. ── The update screen, still opening twice ─────────────────────────────────── Reported again on 1.3.9, and the cause was not the one fixed in 1.3.8. The agent consumes the request file BEFORE it resolves the release — deliberately, because update.sh may kill the shell and a request left in place would loop — and writes `state: running` only once it has decided to go ahead. In between, the request is gone and the status does not say running yet, so the endpoint honestly answers "nothing is running". The watcher read that as "the run has finished" and reloaded the page: overlay on the click, gone a poll later, 503 after it. The overlay now closes only once the server has BOTH confirmed a run and then stopped reporting it. Before the confirmation, silence means the agent has not got there yet. Bounded at twenty polls so a request the agent refuses does not leave the console covered forever. ── Custom domains, proven and re-proven ───────────────────────────────────── `custom_domain` was a free-text field and everything downstream believed it: the proxy served it, the certificate was issued for it, Nextcloud trusted it. Anyone who pointed any hostname at the platform got somebody else's files under their own name. The proof is a TXT record at _clupilot-challenge.<domain> holding a token only this instance has. Nothing is served until it has been read. Every reader now goes through Instance::address(), which is the one place the decision is made — `custom_domain ?: subdomain` was the hole, written out four times. It is re-read every night at 03:40, because a token checked once can be taken straight back out and a domain that later lapses keeps resolving here. Three consecutive misses before a live domain is withdrawn: one failed lookup is a nameserver having a bad minute, and withdrawing takes a working Nextcloud off its own address. The domain and its token stay on the row so the customer can put the record back rather than start over. Changing the domain mints a NEW token. Reusing it would let somebody who once verified example.com claim any other domain later without touching its DNS — the old record is still sitting there and only the value is compared. The domain is changeable at any time, and removable. Fixing it once set was considered and rejected: adding or moving an address is a proxy entry, a certificate and one line in trusted_domains. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
8c105c17d6 |
Move each plan's marketing copy from the controller into the console
tests / pest (push) Failing after 8m10s
Details
tests / assets (push) Successful in 20s
Details
tests / release (push) Has been skipped
Details
Audience line and note were LandingController::COPY, a hardcoded array keyed on exactly the four plan keys the seed migration created — a plan made under any other key rendered with no copy at all. They move onto the family for the same reason the recommendation mark does: they are a stance about the product line, not a capability that changes with a price, so they must not be frozen per version. Backfilled in the migration from the controller's own strings, so the public page prints exactly what it printed a moment ago. A family with no copy yet renders without the audience line rather than an empty gap. Feature labels (managed_updates, daily_backups, ...) stay in the controller: they name catalogue features, not individual plans, so there is no per-plan or per-version place to hang an editable customer-facing label without re-introducing a shared feature table. That table existed once and was deliberately removed when the plan catalogue closed its pricing split-brain; bringing it back for wording alone is a bigger, separate decision. |
|
|
|
4ef5b9519c |
Let the owner mark one plan as recommended, from the console
Recommended lives on the plan family, not a version: it is a stance about
the product line ("which plan we point a visitor towards"), not a
capability that changes with a price, and freezing it per version would
make the mark silently vanish every time a new version publishes.
Setting a plan recommended clears whichever other plan carried the mark,
inside one transaction locking the whole table — two operators
recommending two different plans at once must not both win. The public
price sheet already had the highlighted-card treatment for this (it was
driven by a hardcoded "team" constant); it now reads the family instead.
|
|
|
|
012d0f5092 |
Say EU, and stop promising to fix somebody else's deleted folder
tests / pest (push) Failing after 8m13s
Details
tests / assets (push) Successful in 23s
Details
tests / release (push) Has been skipped
Details
Two corrections from a read of the live pages. The location. Three public places named countries or a city — "aus deutschen und finnischen Rechenzentren" on the placeholder, "in Österreich und Deutschland" in the FAQ, "EU · Wien" in the product panel — and they did not even agree with each other. They all say EU now, which is the thing that is promised, the thing the AV-Vertrag pins down, and the thing that stays true when a host moves. The same string in the customer dashboard was worse: 'EU · Rechenzentrum Falkenstein' was hard-coded and shown to every customer whatever host their instance actually runs on. A specific claim nobody derived from the record. The closing headline asked who you call when a folder is missing on a Monday morning. It promised the wrong thing: what happens inside a customer's own Nextcloud is their own house — the recycle bin and the version history are right there — and presenting ourselves as the first call for somebody else's user error offers a service we do not run. It now asks who looks after updates, backups and outages, which is what the comparison section already says in the operator's own words and what the product is actually for. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
4755a4f362 |
Redesign the pre-launch placeholder, and fix its wordmark and mark
tests / pest (push) Failing after 8m7s
Details
tests / assets (push) Successful in 20s
Details
tests / release (push) Has been skipped
Details
Reported from a phone on status.clupilot.com, which serves this page because the site is still switched to private. Two real defects behind the complaint: The wordmark read "Clu Pilot Cloud". `gap` on a flex container applies between EVERY child and a bare text node is a child — so an inline-flex row wrapped around "Clu", <i>Pilot</i> and " Cloud" put nine pixels between each of them. The wordmark is one element now and the gap only ever separates it from the mark. The logo was a plain gradient tile with nothing in it, which reads as an image that failed to load. That is a poor thing to leave somebody with on a page whose whole message is "we are building this properly". The real mark — cloud plus autopilot ascent — is inlined; it has to be in the file because this page renders when the asset build may not exist at all. The redesign itself: the card is gone. The page is now a centred composition on the open ground — the state as a pill above everything, the mark, the headline at the size the rest of the site uses, and the three promises the paragraph was already making set as their own line so there is something to look at besides one block of text. One warm bloom behind it, drawn as a gradient rather than a blurred disc, which banded into visible rings. Still self-contained, and still the only page exempt from the shared stylesheet: it is served mid-deploy and on a fresh install, where @vite would throw instead of rendering the reassurance somebody came for. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
b85df4c141 |
Rebuild the status page as a status page
Asked for after looking at how real ones are built. The shape they all share is a convention, and a convention is what lets somebody find the answer without being taught the page: banner, components each with ninety days of uptime, then the written record underneath. This page had only the banner — the version that is of no use the morning after, because "Alle Dienste in Betrieb" is true and says nothing to somebody who was locked out the evening before. Three things are new. The ninety-day bar. It cannot be reconstructed after the fact: the monitoring table holds the last verdict per instance, not a history. So a sampler (clupilot:sample-status, every five minutes beside the monitoring sync) writes counts into one row per component per day, and the page divides them. A day with no row is drawn as a gap and says "nicht aufgezeichnet" — never green. Degraded samples count as reachable in the percentage but still colour the day: folding them into downtime reports a slow morning as an outage, ignoring them loses the only trace of it. The incident record. An operator reports a disruption in the console, posts updates as it develops, and the entry that says "behoben" is the same action that closes it — two separate steps is how a page ends up with a resolved note under an incident that still reports as ongoing. There is deliberately no way to edit an update: it is a statement made at a time, corrections are a further entry. An open incident may make a component look worse than the probes found it, never better. Scheduled maintenance, from the windows the console already keeps rather than a second list somebody has to remember to fill in. The measurement moved out of the controller into ServiceHealth, shared with the sampler, so the banner and the bars cannot disagree about what "healthy" means. The page now uses the shared design system and the site's header and footer. Its self-contained stylesheet was there to survive a broken asset build; it does not survive one — if the stylesheet cannot be served the application serving this page is already failing, and mid-deployment every route answers with the maintenance page. What the exemption bought was a third design nobody maintained. Found while building: durationMinutes had its operands the wrong way round, and diffInMinutes is signed, so the public page printed "Dauer -86 Minuten". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
3b85e8f1d1 |
Grant a package or module from the customer's row in the console
tests / pest (push) Failing after 7m55s
Details
tests / assets (push) Successful in 20s
Details
tests / release (push) Has been skipped
Details
GrantPlan is a modal (R20), opened from a "Schenken" button on the customer's own row in Admin\Customers, gated by customers.grant_plan rather than merely hidden. It offers a whole package (plan, term, datacenter, an optional quota bonus baked into the frozen snapshot) or a module on an existing contract, either free or at a discount the operator sets against today's catalogue price. Existing grants show on the same modal — who gave what, when, and until when — with a warning once a dated grant is nearing its end; nothing lapses on its own, exactly as decided. The customers list also badges a granted plan in the Paket column. |
|
|
|
c71b2c362b |
Skip the invoice for a full gift, keep it out of revenue, hide its price
IssueInvoice now rejects free-grant orders before it will consume an invoice number for them — a full gift produces no invoice at all, while a discounted grant still charges something and is invoiced exactly like an ordinary sale. Keyed on the linked subscription/add-on's own provenance, not on the amount being zero, so a genuinely fully-discounted Stripe checkout is unaffected. Revenue excludes granted contracts from MRR/ARR/ARPU and the contracts count — a gift at 0 must not drag the average down — and shows their number as its own KPI instead of folding it in, since the owner measures himself on it. The portal (Cloud, Billing) shows a granted package or module without a price at all: not "free", not struck through, just the service, so a later conversion to paid does not read as a negotiation. |
|
|
|
15b536f393 |
Mint the archive collection key from the console instead of by hand
tests / pest (push) Failing after 7m49s
Details
tests / assets (push) Successful in 22s
Details
tests / release (push) Has been skipped
Details
Setting a NAS up to collect the invoice archive meant three machines and a dozen commands: generate a keypair there, carry the public half to the server, write it into authorized_keys with a restriction, install rsync, and get every path right on the first try. Every one of those steps was a place to be told "permission denied" with no clue which of the three was wrong. It was, and several times over. One button now. The panel asks, the host does it — because the host is where all of it lives: the home directory, ssh-keygen, rrsync, and the archive itself. The panel is www-data in a container and owns none of that, so it uses the mailbox it already uses for updates. The private half is shown exactly once, alongside the finished rsync command, and is never written to the database: it exists to be copied into a NAS, and storing it "for convenience" would put a working credential in every backup of that database. rrsync does the restricting, not a pinned rsync option string. That string differs between rsync versions and fails silently — a refusal with no reason given, which is the shape of the afternoon this replaces. If rrsync is missing the agent refuses rather than issuing an unrestricted key while the panel says it is restricted. rsync now comes with install-agent.sh, which already runs as root once per machine. It has to be on the HOST: a NAS connects by ssh and sshd starts `rsync --server` here, so without it the pull fails with "command not found" from a NAS whose own setup is perfectly correct. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
d8ff717cb5 |
Let a destination say how it is laid out and how long anything stays
tests / pest (push) Failing after 7m29s
Details
tests / assets (push) Successful in 23s
Details
tests / release (push) Has been skipped
Details
Two destinations doing two jobs. A handover directory a NAS collects from wants a folder per day and wants emptying — thirty days is enough to notice a NAS that stopped collecting, and keeping more only hides it. An archive on the NAS itself wants a folder per year and wants nothing deleted, ever. So both are per destination rather than global. Deleting is safe here in a way it usually is not, and that is worth saying plainly: the invoice is a frozen document in the database and its PDF is rendered from that on demand. Nothing is moved anywhere — it is copied, and anything removed from a directory can be produced again and re-exported. The directory is a handover point, not the record. keep_days is null by default and stays null unless somebody sets it: a retention rule nobody asked for is a deletion nobody expected. The prune only touches folders whose NAME is one of the two shapes this application writes, and leaves everything else alone — a prune that deletes what it does not recognise is how an archive loses something nobody was watching. It also skips a destination whose path is missing rather than acting on whatever is underneath an absent mountpoint. The shape is matched with a pattern before parsing rather than by handing nonsense to Carbon: under strict mode createFromFormat throws instead of returning false, so one folder somebody made by hand would have ended the whole prune instead of being skipped. The test with a foreign folder in it found that. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
0528b56a32 |
Name no vendor in the export settings
tests / pest (push) Failing after 7m36s
Details
tests / assets (push) Successful in 21s
Details
tests / release (push) Has been skipped
Details
A destination is a kind of connection, not a product. The wording said "Hetzner Storage Box" and the host field suggested one particular provider's hostname format, which reads as a requirement rather than an example — and the whole point of the directory kind is that whatever is behind it is nobody's business here. Both kinds stay. A directory is how an NFS 4.1 mount is used: the mount is made on the server, and the application writes into the path it produces. SFTP is for a destination that is not mounted. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
b3081a544b |
Let the export have as many destinations as somebody wants
tests / pest (push) Failing after 7m35s
Details
tests / assets (push) Successful in 21s
Details
tests / release (push) Has been skipped
Details
One path and one exported_at could not say "on the office NAS, not yet on the off-site box" — which is the only interesting question once there is more than one destination, and the reason for a second is precisely that any single one can fail. A row per destination now, and a row per (invoice, destination) pair. Two kinds, and the difference is where the protocol lives. 'local' is a directory this machine can already write to: a NAS mounted over NFS 4.1, a bind mount, a disk. The application knows nothing about the protocol, the target changes without code changing with it, and the whole thing stays testable against a temporary directory. 'sftp' is for something reachable with credentials — a Hetzner Storage Box being the obvious one — and the password goes into the secret vault with only its key on the row, because a credential in a settings table is a credential in every database dump. An empty password field on an existing target means unchanged, not cleared: the field cannot show what is stored, so blank-means-delete would wipe it on every unrelated edit. One job per pair rather than one per invoice. A job writing to both would retry the one that worked every time the other did not, and report one outcome for two different things. The ordering fix the tests found: Laravel's local adapter creates its root when it is CONSTRUCTED, so the "never create the root" guard was checking a directory the disk had just made for it. It runs before the disk is built now. That guard is the difference between an archive and a hole in the ground — mkdir on an absent mountpoint succeeds, writes onto the container's own disk and reports success, and an archive that silently is not the archive is worse than none. The single path from the previous commit is migrated into a destination called "Archiv" rather than dropped. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
ab4b0311d4 |
Copy every invoice to the archive when it is issued, and notice when that fails
tests / pest (push) Failing after 11m24s
Details
tests / assets (push) Successful in 21s
Details
tests / release (push) Has been skipped
Details
The application knows nothing about NFS and should not. It writes to a directory; whether that is a local disk, a bind mount or a NAS over NFS 4.1 belongs to the mount. No protocol library, the target changes without code changing with it, and the whole thing is testable against a temp directory. The finding that shaped it: mkdir -p on a path whose mount is NOT there succeeds. It creates the empty directory beneath the mountpoint, writes the invoice onto the container's own disk and reports success — so an unmounted NAS would quietly collect invoices locally while every check said it worked. An archive that silently is not the archive is worse than none. The root is therefore never created, only used: a missing root means a missing mount, and that is a failure. Only the year subdirectory is created, inside a root already proven to exist. Written to a .part and renamed. A rename within one filesystem is atomic, so whatever watches that folder never sees a truncated PDF and takes it for a finished invoice. The size is read back afterwards rather than the write being trusted: over a network mount a short write can report success, and an archive of truncated files looks exactly like one that worked. Queued, not done during the request. A network mount can block for its whole timeout, and an invoice must not fail to be issued because a NAS is rebooting. Dispatched after the commit, or a worker could look for an invoice that has not landed yet. And copy-on-issue is not enough on its own: an invoice issued while the office connection was down never arrives, the queue eventually gives up, and nobody opens an archive to check whether last Tuesday is in it. An hourly sweep re-queues whatever is still missing, the failure is recorded on the invoice rather than only in a log, and the Finance page says how many are outstanding. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
33e5b15099 |
Restart the workers automatically after saving .env, instead of handing the operator back to the shell
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
86bf4f26e0 |
List issued invoices in the console, with no way to change one
tests / pest (push) Failing after 7m34s
Details
tests / assets (push) Successful in 22s
Details
tests / release (push) Has been skipped
Details
Read-only by design, and the absence is the feature: an issued invoice is corrected by cancelling it and issuing another, which is the only lawful way to correct one. There is no edit button here whose absence needs explaining, and a test asserts there is none. The PDF is a plain route rather than a Livewire action. A download is a download, and routing one through a component round-trip only adds a way for it to fail. Two findings on the way, and both were mine. The year filter used YEAR() in raw SQL, which is MySQL's. SQLite has no such function, so the page worked against the real database and threw against the test one — a query that only runs on one engine makes the test database a different product from the one being shipped. The years are derived in PHP now. And a test put a User on the operator guard, where EnsureAdmin calls isActive() on it and a 403 becomes a 500. Not a code defect: actingAs() calls Auth::shouldUse(), so a second actingAs() in the same test with no guard named lands on whichever guard the FIRST one named. That trap is written up in CLAUDE.md, I quoted it earlier today, and I walked into it anyway. The tests are split now and both name their guard. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
92bcdcd186 |
Send the invoice with the invoice attached
tests / pest (push) Failing after 7m32s
Details
tests / assets (push) Successful in 22s
Details
tests / release (push) Has been skipped
Details
The PDF is rendered when the mail goes out rather than fetched from anywhere, because nothing stores it — and rendering from the frozen snapshot means the attachment is the document as issued no matter when the queue gets to it or how often it retries. fromData rather than fromPath: there is no path. Three attempts at testing it, and the first two were wrong in ways worth recording. Attachment::$data does not exist — the closure lives in a protected $resolver, and reaching for it was me testing the framework's internals rather than my own code. And a byte comparison of the PDF cannot work at all: TCPDF embeds a creation time and a document id, so two renders of one invoice differ. Split into the two things that can actually go wrong. The mail carries an attachment with the right filename and type — that is metadata, and it is asserted as metadata. And the renderer produces a real PDF rather than an empty one, asserted where the renderer is, because an attachment closure that returns nothing still makes a perfectly valid mail with a nought-byte file on it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
cd54212b34 |
Issue an invoice from what somebody bought, and freeze it there
tests / pest (push) Failing after 7m32s
Details
tests / assets (push) Successful in 25s
Details
tests / release (push) Has been skipped
Details
One invoice per purchase rather than per order: a customer who buys a plan and two add-ons in one go has bought once, and three invoices for one purchase is three times the paperwork for the same money. Each order becomes a line, which is also how the add-ons get listed individually. Everything the document says is copied into the snapshot at the moment the number is assigned — issuer, recipient, lines, rate, the reason for the rate. A test changes the company name and the customer name afterwards and asserts the invoice still says what it said. That is the whole reason no PDF is stored. The number and the invoice commit in one transaction. A number taken and then lost to a failure is a gap in a series that must not have one, and the refusal test asserts that a rejected invoice leaves the counter where it was. It refuses outright until the company details are complete. An invoice without a registered name, an address or a VAT number is not a valid invoice here, and issuing one consumes a number that can never be handed out again. VAT comes from TaxTreatment, which already existed and already handles a verified EU VAT ID correctly — including the reverse-charge note, without which a zero-rated invoice says nothing about why no VAT was charged. The billing address is carried as the lines it was written as. It is one free-text field today, and guessing which line is the postcode would put it where the street belongs, on a document nobody can correct afterwards. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
4646880b90 |
Print every line at full price and take the discount off once, in euros
tests / pest (push) Failing after 7m40s
Details
tests / assets (push) Successful in 22s
Details
tests / release (push) Has been skipped
Details
Second correction to the same block, and this one came from the right place: a line reading 202,50 beside "Rabatt 10 %" does not say whether the discount is in that number or still to come. It was unreadable, and unreadable on the one figure a customer checks. Lines are at full price now. Discounts are still entered per line — that is where they belong and where somebody decides them — but they are summed and shown once, in euros, under a subtotal that the "Gesamt netto" column adds up to. Subtotal, one subtraction, VAT, total: a sum anybody can follow without being told which figures are already adjusted. The gross line-total column is gone and a gross UNIT price takes its place. Full-price gross totals would sum to 315,60 against a total of 288,60 — the same contradiction in a different column. Nobody adds up unit prices, and a consumer reading this wants to know what one of the things costs. lineTotal() and adjustmentLabel() went with it. Nothing calls them any more, and a helper kept for a layout that no longer exists is the next person's wrong turn. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
d21e4f0eb2 |
Put a discount on the line it belongs to, so the columns add up
tests / pest (push) Failing after 8m5s
Details
tests / assets (push) Successful in 21s
Details
tests / release (push) Has been skipped
Details
With one discount applied under the table, the gross column summed to 315,60 against a total of 284,04. Nothing was miscalculated — the discount simply came after — but a reader who adds up a column found a document that does not agree with itself, and no way to tell which figure was the real one. On an invoice that is not a presentation detail. A discount or surcharge now sits on its line, percentage or fixed amount, and is inside that line's total. The label under the line is built from the same value that did the arithmetic, so the words cannot end up describing a different number than the one printed beside them. The invoice-wide adjustment stays in the arithmetic for anything that genuinely applies to the whole document, but nothing uses it by default. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
90131f088a |
Add a raw .env editor to the Integrations page, with a net under it
Everything the console has no field for is still real — MAIL_*, DB_*, APP_KEY, whichever key nobody has built a form for yet — and without a way to touch it from here, the operator needs a shell and the console page misses its own point. EnvFileEditor is the net, not just the warning: 1. Validates before writing. A line that is neither blank, nor a comment, nor KEY=value is rejected outright, and an empty file (syntactically "valid" by that rule, but not survivable) is refused too. write() never touches the file before checking the new content. 2. Backs up before every write that actually happens — a timestamped copy beside .env, before the new content lands. Never pruned automatically; the page says so, next to where it says where they land. 3. Names the keys a mistake here can lock an operator out with — APP_KEY, DB_*, REDIS_*, SESSION_* — rather than a blanket warning nobody reads. 4. Gated by secrets.manage and the same confirmed password the vault entries use — this is the one place on the page that can reach every credential the vault otherwise keeps write-only. 5. Says plainly what saving does not do: queue, queue-provisioning, scheduler and reverb only read .env at their own startup, and names the restart command plus config:clear. 6. Marks which .env keys SecretVault currently overrides, so editing a line that a stored vault value already shadows does not look broken. Two issues surfaced by Codex review and fixed before this commit: the raw file content stayed in the Livewire component snapshot after the password confirmation window expired on its own (not only on an explicit re-lock), and the first save on an installation with no .env yet failed trying to back up a file that was never there. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
3fadaa14b0 |
Merge credentials and infrastructure into one Integrations page
Zugangsdaten and Infrastruktur split the same subject by storage mechanism — SecretVault vs App\Support\Settings — instead of by what each field configures, which is not a distinction an operator setting up Stripe or DNS should have to know or care about. One page now, grouped by integration: Zahlungen (Stripe), DNS (Hetzner), Monitoring, VPN/WireGuard, SSH-Identität. A secret and a plain setting sit side by side within a section; the difference stays visible — a vault entry is write-only and shows only an outline, a setting shows its value in full — as a property of the field, not a reason for a separate page. Both storage mechanisms are unchanged underneath: this is a presentation change, not a data migration. The vault keeps its own gate (secrets.manage + a recently confirmed password); plain settings keep theirs (hosts.manage alone). Reachable with either capability, since the two are no longer two pages a role happens to see one, both, or neither of — every section and every save action still checks its own capability server-side. admin.secrets and admin.infrastructure redirect here permanently rather than 404ing a bookmark. The nav entry that replaces both is visible to either capability — Navigation's capability field now accepts an array meaning "any of these", not only a single ability. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
c1896e70fc |
Render an invoice, with the arithmetic held to integer cents
tests / pest (push) Failing after 7m9s
Details
tests / assets (push) Successful in 24s
Details
tests / release (push) Has been skipped
Details
TCPDF 6, deliberately, after installing 7 and finding out what it is. Version 7 is not a newer TCPDF; it is a compatibility shim over tc-lib-pdf whose own mapping table lists 115 of 293 methods as stubs, and whose font package ships no font files at all — the first render died on a missing helvetica.json. The 6.x line carries a hundred and sixty-five fonts, a working writeHTML and real header and footer hooks, and is what everybody means by TCPDF. The body is a Blade template handed to writeHTML rather than a hundred Cell() calls with millimetre coordinates, because this table has to be adjusted the first time somebody sees it on paper, and nobody adjusts the second kind. InvoiceMath is integer cents end to end, and rounds once. A percentage applied line by line and rounded each time drifts from the same percentage applied to the sum — three lines at 3,33 € less 10 % is 99 cents that way and 100 cents the honest way, and the customer's calculator is what finds it. Gross is always derived from net and never the reverse: taking VAT back out of a gross figure returns a different number often enough to matter. A discount and a surcharge are one operation with opposite signs, so nothing has to remember which of two flags means which. Quantities are thousandths, because hours, gigabytes and part-months are all real, and are printed with the trailing zeroes stripped — "12,000 Std." reads as a defect. Every line shows quantity, unit price net, total net AND total gross: this goes to businesses and to consumers, and each reads a different column first. Verifying it took two goes and both failures were mine. `strings` over a PDF set in a Unicode font finds nothing, because the text is stored as subset glyph ids — the check has to render in a core font to be a check at all. And two of the strings I then declared missing were simply expectations I had got wrong. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
77bd30ca56 |
Answer on every name for the website, and send them all to one
tests / pest (push) Failing after 7m23s
Details
tests / assets (push) Successful in 22s
Details
tests / release (push) Has been skipped
Details
SITE_HOST takes a comma-separated list now, the first name canonical. It serves the site; every other name in the list redirects there permanently, path and query intact. An apex domain and its www are two names for one thing, and answering on both without picking one splits search rankings and cookies between them. The redirect is a host-bound catch-all rather than middleware. Middleware that only some routes carry is the same half-measure this file already made once, and a catch-all registered without a host would swallow every path in the application — which is what the third test is there to stop. The test harness needed fixing too, and the fix is the finding. Router::dispatch on a standalone router does not rebind the container's request, so a route reading one — through the helper OR through an injected parameter, both of which resolve from the container — gets the test's request rather than the one being answered. The redirect silently lost its query string in the test while working in production. dispatchOn() binds it where the framework would. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
e6d2e6dc33 |
Give the console a Finance tab: company details, VAT rate, invoice series
tests / pest (push) Failing after 7m49s
Details
tests / assets (push) Successful in 22s
Details
tests / release (push) Has been skipped
Details
Its own tab rather than a section of Settings, as asked. What is set here appears on a legal document, and burying it beside the site-visibility switch invites somebody to change one in passing. Everything the printed invoice needs — registered name, address, Firmenbuch number and court, VAT ID, bank details, payment terms, logo — with the logo replaceable, because a company changes its mark. None of it reaches an invoice that already exists: the values are copied into each document when its number is assigned, so a corrected address is correct from the next one onwards and cannot rewrite an old one. The console refuses to consider itself ready while a name, an address or a VAT number is missing, and says which. An invoice without them is not a valid invoice in Austria, and issuing one is worse than refusing to. The series editor carries the same shape as EditDatacenter and for the same reason. The prefix is free while the series is empty and frozen once a document carries it: changing RE to AR renames nothing and leaves a series whose past says RE and whose future says AR, with no record they belong together. The counter may be raised — an operator migrating from another system needs exactly that — but never lowered, because a lower number is one already printed on a document somebody holds. Both rules are recomputed from the database on save rather than read back from the properties the browser returns. CompanyProfile drops keys that are not part of the profile. It is fed from a form, and a forged key must not be able to write an arbitrary setting into the same table the site-visibility switch lives in. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
864126ec7f |
Add the SSH identity to the vault, and give deployment config a console page
tests / pest (push) Failing after 7m28s
Details
tests / assets (push) Successful in 19s
Details
tests / release (push) Has been skipped
Details
The SSH private key CluPilot deploys to every host is now stored through SecretVault (ssh.private_key), preferred over CLUPILOT_SSH_PRIVATE_KEY(_PATH) at all three real consumers — SshTraefikWriter and HostStep::keyLogin. kuma.password/kuma.totp stay in .env: they authenticate a separate Python container at boot, and nothing in this app reads them, so the vault would have nothing to wire them to. A new /admin/infrastructure page, backed by App\Support\ProvisioningSettings, makes the non-secret deployment settings that used to force a shell — DNS zone, WireGuard hub endpoint and public key, Traefik's dynamic-config path, the SSH public key, and the monitoring bridge URL — visible and editable from the console, each wired to every real consumer. WG subnet/hub IP and Kuma's own connection details stay in .env: the compose file and a separate container read those before this application ever does. |
|
|
|
49a3ca2e33 |
Keep the shop window off the portal's front door
tests / pest (push) Failing after 7m35s
Details
tests / assets (push) Successful in 19s
Details
tests / release (push) Has been skipped
Details
The landing page was registered host-agnostically, so app.clupilot.com served it: marketing copy, a pricing table and a "sign up" call to action, at the address where a customer's servers live. Reported exactly that way. SITE_HOST binds the website to its own hostname. Every other host — the portal, a bare IP — answers "/" with the product: the dashboard for somebody signed in, the sign-in page for everybody else. Never a 404 there: "/" is what people type from memory, and turning that into an error page to make a point about hostnames helps nobody. Empty keeps today's behaviour, which every development machine reached by IP depends on. The console is unaffected — its routes are registered first and win on their own host, the ordering the status page above already documents. The legal pages are deliberately not bound. An imprint has to be reachable from wherever the reader is standing, and a mail footer links to it from whichever host sent the mail. Also drops "just reply to this message" from two mails. A reply lands in the billing or provisioning mailbox rather than in the support queue somebody actually works through — so it now says to raise it in the portal, where it gets a place in that queue instead of getting lost. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
b1ab0f7f31 |
Put every mail in one design, and confirm an order when the money arrives
tests / pest (push) Failing after 7m48s
Details
tests / assets (push) Successful in 25s
Details
tests / release (push) Has been skipped
Details
Four templates, one layout. Two of them — the maintenance announcement and its cancellation — still wore Laravel's markdown component, which brings its own logo, its own button and its own footer: the product was sending mail that looked like two different companies, one of which was the framework. New: an order confirmation, sent when the payment lands rather than when provisioning finishes. Those are minutes apart at best and can be much longer, and somebody who has just been charged and heard nothing assumes the worst about both the charge and the product. It deliberately promises no time for the cloud itself — provisioning takes what it takes, CloudReady announces the end, and a promised minute that slips is worse than no promise. Queued after the transaction, never inside it, and never allowed to fail the webhook: Stripe retries anything that is not a 2xx, and a retry would re-enter provisioning over a mail server having a bad minute. The finished-cloud mail now names what was actually built — address, plan, storage, location — because somebody who ordered a fortnight ago is checking it against what they bought, and that is a comparison rather than a sentence. Still no credential in it. Two tests earn their place. One holds every template on the shared layout, so a new one written in a hurry cannot copy-paste its way back to the framework's. The other proves the finished-cloud mail carries no password — asserted against the data the mail is given and the HTML it renders, not against the template's source, because the first version of that test tripped over the word in a comment and would have passed a template that leaked the value through a variable. It tested the prose, not the mail. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
eb743d34ed | Show WireGuard tunnel state per host in the hosts list | |
|
|
ed4167eba3 | Close SSH and the Proxmox UI to the world once the tunnel is proven up | |
|
|
5b8c28595a |
Require a confirmed address before an account can use anything
Registration opened an account that could sign in immediately on an address nobody had proved was theirs — and anybody can type a stranger's into a registration form. Both halves were commented out: the Fortify feature and MustVerifyEmail on the model. `verified` sits on the whole portal group rather than on the pages that spend money. An unconfirmed address is not a billing problem to be caught at checkout; it is an account that may not belong to the person holding it, and the servers, the users and the backups behind it are worth as much to whoever typed the address as to whoever owns it. The mail is a Mailable in this product's design rather than Laravel's notification, which would arrive with the framework's logo, button and footer as the first thing anybody ever receives from CluPilot. It also has to leave through the SYSTEM mailbox like every other account mail, which the notification path does not do on its own. The link is signed and expires, and the address is part of what is signed — so a link issued to a mistyped address stops working the moment the address is corrected, rather than confirming an address nobody can read. Tests cover the tampered link, the expired one, the corrected address, and one account trying to confirm another. The waiting page is ours (Fortify has views off) and offers the only two things that move somebody forward: send it again, or sign out and register with the address they meant. The resend is throttled, because otherwise it is a button that makes the server send mail to an arbitrary address as fast as it can be clicked. Somebody already confirmed is redirected away from it — the `verified` middleware only guards the other direction. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
07141a96bf |
Show where an account is signed in, and let it sign the other places out
Completes the device work: the warning mail now has somewhere to send people. A warning whose only advice is "if this was not you, take action" names a problem and hands back nothing to do about it. Both sides get their own component rather than one shared with the guard passed in. Operators and customers are two guards and two tables (R21) — a component taking the guard as input is one forged property away from listing and ending the other side's sessions. Every query in SessionRegistry is scoped by guard as well as by id for the same reason, and three tests hold that line, because the unscoped version looks exactly like a working feature while doing it. TouchLoginSession keeps each row pointing at the session it describes. Both sign-in paths raise the Login event BEFORE session()->regenerate(), so the row is written against an id that is dead a moment later — and the once-a-minute throttle would have held the correction back for the first minute of every session, hiding the session its reader is sitting in. A changed id is therefore never throttled. A session counts as live only if the framework's row exists AND was touched within the session lifetime. The join alone is not enough: Laravel collects expired sessions by lottery, so on a quiet installation rows sit there for days and the list would offer somebody a laptop they shut last month. Ending the others goes through the product's own modal (R23) and leaves the asking session alone: signing somebody out of the page they are using to sign other people out is not what the button says. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
2609393e3a |
Recognise the devices an account signs in from, and warn about a new one
tests / pest (push) Failing after 7m52s
Details
tests / assets (push) Successful in 20s
Details
tests / release (push) Has been skipped
Details
Sessions lived in Redis, where they cannot be enumerated — one key per session, no index by user — so "where am I signed in" and "end that one" were not answerable at all. The database driver keeps a row per session and makes ending one a delete. It costs every currently signed-in person one forced sign-in, once, which is why it is happening now rather than later. The warning is only worth anything if it is rare, so the two obvious signals are deliberately not used. Not the IP: it changes on every train, every café, every mobile handover, and behind a VPN it is not theirs to begin with. Not the user-agent: Chrome ships every four weeks and the string changes each time, which would be twelve warnings a year per customer, describing nothing. A device is a long-lived signed cookie holding a random token, and nothing else is consulted. The honest cost — cleared cookies, a private window, a second browser — is written into the mail itself, because a reader who thinks the warning is wrong stops reading the next one. Three things the tests found or hold: The first device on an account is new and must not warn. Replying to somebody creating an account by telling them they signed in from a new device is noise on the one message that has to stay worth reading. token_hash was globally unique, and the shared-browser test failed on it. Two people on one machine share one cookie and must each own a device row against it; the obvious repair — issue a fresh token for the second — would orphan the first person's row and warn them about their own desk. Unique is now per identity. Recording a device may never block a sign-in. A safeguard that locks the owner out when it breaks has stopped being one, so the listener logs and swallows, and a test drops the tables to prove it. Sessions are linked by a uuid carried inside the session payload rather than by the session id, which Laravel rotates on every sign-in and every regenerate(). Laravel's own sessions.user_id cannot carry the link either: it is filled from the default guard, while operators and customers are two guards and two tables (R21) — operator 1 and customer 1 would each be offered the other's sessions. Also lands the shared mail layout the rest of the templates will use: tables and inline styles for Outlook, no image and no web font because half of all clients block remote content, and the button in --accent-active rather than the brand orange, which is 2.9:1 against white. Not tagged: the session list is not built yet, and without a tag no server can install this. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
75b4a47768 |
Stop mailing the initial admin password, hold it in the panel until noted
The password no longer travels through third-party mail servers or sits forever in an inbox: it moves onto the instance (encrypted, same as nc_admin_ref) and shows on the dashboard until the customer clicks "Notiert", which deletes it for good. |
|
|
|
21b7aaca42 |
Update only to a released version, never to whatever landed on main
tests / pest (push) Failing after 7m21s
Details
tests / assets (push) Successful in 20s
Details
tests / release (push) Has been skipped
Details
Asked for directly: a commit on main must change nothing on a server, and only a tag whose version is higher than the installed one may be installed. The agent asked two different questions before this. A pinned server compared tags; everything else counted commits on its branch — so a console following main offered an update for every push, verified or not, and "Jetzt aktualisieren" meant "take whatever is on the branch right now". Landing on main and being released are different events, and only the second is a decision somebody made. It is one question now, regardless of what the checkout is attached to: is there a tag whose version is higher than the deployed one? Compared with sort -V against the version in the deployment manifest — not the VERSION file, which an update moves before it migrates, so a run that failed in between would leave the checkout claiming a version it never finished installing. A run always targets that tag; the branch path is gone. A request that arrives with nothing to install is answered rather than obeyed, before anything announces a run. The console follows: the button is disabled with nothing released, says so in a line rather than leaving a grey button to be guessed at, names the version it would install rather than a count, and refuses the action server-side as well — a Livewire action is a public endpoint, and one place a rule may not live is only in the disabled attribute of a button. VERSION becomes 1.1.0, to be tagged as the first release this mechanism can see. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
184350494d |
Let a half-finished two-factor enrolment be cancelled, and rework the setup page
tests / pest (push) Failing after 7m39s
Details
tests / assets (push) Successful in 20s
Details
tests / release (push) Has been skipped
Details
Found uncommitted in the working tree at the start of this session and carried in as its own commit so it can be reverted on its own. Not written here: a cancel action that clears an unconfirmed secret, the wording for every state of the page, and a rebuilt setup view. The suite is green with it in place, including the R18-R23 scan tests. |
|
|
|
d65ab71029 |
Let a datacenter code be corrected while nothing depends on it, and say which building
The code was immutable, which made a typo permanent. It cannot be freely mutable either, and not only because hosts.datacenter references it: each host's DNS name was minted from it and registered at the provider, the machine's own hostname was set from it as it was built, the counter handing out those numbers is keyed by it, and every past order stores it as plain text with no foreign key to follow. Renaming a code with hosts in it is not a rename — it is a datacenter called hel full of machines called fsn-01. So it is editable exactly while nothing references it, which is the case that matters: a typo noticed shortly after creating one. Once a host or an order depends on it the field is disabled and says what is holding it, with the numbers. The lock is recomputed from the database on every save, never read back from the property the browser returns. Alongside it, an optional free-text field for the specific datacenter — fsn-dc-15. A provider runs several within one location, on separate power and separate uplinks, and two entries both reading "Falkenstein (fsn)" are not a choice anybody can make. Nothing keys off it; the code remains the identifier. Also renames the country loop variables in the edit modal. They were $code and $name — the component's own properties — and shadowed them for the rest of the file. Harmless while nothing below the loop read them, which stopped being true the moment the code became an editable field above it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
07634c8a1d |
Answer a check at once, start an update at once, and count down to neither
Checking for updates was a request written into the same mailbox as a real update, collected by the same agent on the same systemd timer — so asking what was new was answered on the next tick, and the console, having nothing else to show, counted down to it. An operator who asked a question was told an update would start in 3:44. A systemd path unit now wakes the agent the moment the request file is written, for a check and for a run alike. PathChanged, not PathExists: PathExists re-arms as soon as the service goes inactive, so a request the agent could not consume — it holds a lock for the length of an update — would restart it in a tight loop. The timer stays as the fallback, and a drop-in disables systemd's start rate limit, because two triggers on one oneshot service can otherwise wedge the unit and leave neither of them able to run. The countdown is gone from both the settings card and the maintenance overlay. It could not be made honest: its target was recomputed on every poll, and where the interval the agent reports is shorter than the timer actually installed on that host it had already gone by, so the fallback moved it forward again each time. It ran backwards four seconds and snapped back to a full minute, forever — reported exactly that way. A queued run says it is starting; a queued check says nothing beyond its badge, because a check starts nothing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
64efe45f0d |
Recover silently from an expired session, and show a connection banner when offline
tests / pest (push) Failing after 7m22s
Details
tests / assets (push) Successful in 24s
Details
tests / release (push) Has been skipped
Details
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |