401 Commits (v1.3.68)
| Author | SHA1 | Message | Date |
|---|---|---|---|
|
|
398028a57d |
Rebuild Add host as a page with one job, and let downloads through the gate
The page was a wall. Six paragraphs of procedure stacked above a form, so the thing the page actually asks for — four fields — sat underneath an essay about what would happen afterwards. It answered everything and showed nothing. It is now built the way the settings page is built, because that page was rebuilt for the same reason and there is no case for a second idiom: an eyebrow, a title, a sticky rail on the left and panels of rows on the right. The rail carries the six steps as two-word labels rather than paragraphs. That is what the question at this moment actually is — where am I, how much is left — and it fits in 232 pixels. The numbers carry the state; a tick beside them would be a second sign for one statement, and a number can be counted. What a step MEANS now appears in the row where it is due, not six times in advance. The two provider steps get a panel of their own above the form, because they have to be done before you save: the one-time code starts expiring the moment you do. Everything after the form waits until there is something to say. After saving the command becomes the page. Full width, its own framed block with the warning in the header strip rather than floating above, and the two remaining steps below it as ordinary rows. Two token bugs went with it. `bg-canvas` does not exist — it was a class that compiled to nothing, which is part of why the block looked wrong. And `text-accent` on white is 2.9:1; the config says in as many words to use accent-text for anything read, so the current step number does. Also fixed, and it would have broken every takeover in production: PublicSiteGate is appended to the whole web group, so while the site is hidden a server in a rescue system fetching the archive would have received the 503 placeholder and piped it into tar. The operator would have seen an unpack error on a machine only reachable through the provider's console, with nothing pointing at a switch in the admin area — and they are by definition neither on a management network nor signed in, since leaving that state is the whole point. Exempted by ROUTE NAME, not by hostname: the docblock rightly warns that exempting by host means trusting a header the caller picks, but that warning is about the entire portal. Behind these two routes are documents that are public anyway and an archive that 404s without a valid one-time code. 2026 tests pass, assets build. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
156de12c8c |
Give the downloads a hostname of their own, with two halves
A domain that exists only to serve one installer is not worth a certificate. One that also carries the AGB, the AV and the TOM is — those addresses go into contracts and onto invoices and have to still resolve in three years, which an address that moves with the next rebuild of the portal cannot promise. That reason is what changed the answer. files.… over cdn./storage./archiv.: a CDN is an edge network and this is not one, so the name would be a lie the day a real CDN goes in front of it. "storage" reads like object storage or customer data, and a customer seeing it will wonder whether their files live there. "archiv" says superseded, which the terms currently in force are not — and R13 keeps paths and names English anyway. Two halves on that host, with opposite rules, and that is the whole point of giving it its own name: Public — storage/app/files/public/, served to anyone, indexable, because somebody looking for the terms should find them. Versioned filenames: agb-2026-01.pdf, never agb.pdf, so a contract signed in January cannot come to point at conditions written in July. The rule is written where somebody will look for it rather than enforced, because a upload that rejects a filename helps nobody. Private — the installer, and it is not a file in that directory at all: it is built from deploy/bootstrap on demand. The gate is the one-time enrolment code that is ALREADY in the pasted line, resolved without being consumed, because the code is still needed for every progress report and for the registration at the end. No second secret: a dedicated download token would never expire, would sit in shell histories forever, and would travel in the same line as the WireGuard private key — protecting the least sensitive thing with the exposure of the most sensitive one. 404 rather than 403 on a bad code, and noindex on the response. Path traversal is answered before it starts: basename() only, no directory tree under public/ by design, and dotfiles refused. The test walks ../../.env three different ways. Empty FILES_HOST keeps the archive on the portal host exactly as before, so nothing breaks in the window between setting the variable and the DNS record existing. The hostname had to move into phpunit.xml rather than a config()->set(): routes are bound at boot, so a test that sets it afterwards is setting it too late. 2025 tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
447d6fe0f3 |
Merge main into the host-takeover branch
One real conflict, in SyncStripeCatalogue::handle(), and both sides were right. Main's parallel work resolves the adoption singletons and takes "before" counts so a run can tell created from adopted. The operating-mode work refuses a catalogue whose stored objects belong to the other mode, and records which mode the catalogue now belongs to. Kept both, with the mode guard first. It REFUSES, so it must not run behind anything that has already built state; the counters only need to be in place before the create loop, and they still are. Reversing that order would have the command resolve singletons and take counts on a catalogue it is about to reject. 2017 tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
ba4996f316 |
Keep the platform on .com and the customers on .cloud
The owner caught me putting the archive on clupilot.cloud. That is the customer
zone, and looking into it turned up the same confusion already sitting in the
code — in the one place that matters most.
RegisterHostDns says "fsn-01.node.clupilot.com" in its own docblock. The
validation comment in Datacenters says it. ServicesTest writes it out verbatim.
The step itself built the name from config('provisioning.dns.zone') — the
CUSTOMER zone — so on this installation a host was actually called
fsn-01.node.clupilot.cloud. Three places asserting one thing and the code doing
another.
OfficialDomains explains why that matters and is worth not weakening: two
registrable domains by design, the company's for site, portal and console, the
instance zone for customer workloads. A Nextcloud is third-party software that
strangers sign into, and on the same registrable domain as the portal it shares
cookie scope with it. A host name in that zone does not break the separation,
but it puts it in question, and the next slip is more expensive.
So there is now a platform_zone, derived from APP_URL when unset, and
RegisterHostDns uses it.
My own archiveUrl was broken for a second reason. It fell back to url() when
APP_HOST is empty — and APP_HOST is empty on most installations, because empty
means "the portal answers on any hostname" and that is the default. Called from
the console, url() would have produced the CONSOLE hostname, and the line would
have 404'd on a machine that is not allowed to reach the admin area at all. It
takes the host from APP_URL now.
The script no longer guesses its own name. It used reverse DNS, then the tunnel
address, then a hard-coded clupilot.net — a third domain that appears nowhere
else in this project and was simply invented. PrepareBaseSystem has the same
invention. A guessed name does not stay guessed: it ends up in /etc/hostname, in
/etc/hosts, in every log line and in every certificate request the machine ever
makes. CluPilot knows the name because it just assigned it, so it passes --fqdn
and the script refuses without it. The value now also survives the reboot in the
arguments file, which it would not have.
The ACME contact moved to .com for the same reason it was wrong: the operator
does not live in the customer zone.
Open, and NOT decided here: the owner also wants the host to get a public DNS
record and a certificate on the .com name. RegisterHostDns deliberately writes
host names only into the tunnel's dnsmasq, and says why — publishing them hands
every scanner the internal subnet and roughly how many hosts sit behind it.
Nothing in the current design needs a public certificate for a host's own name;
Traefik serves customer domains, not this one. Reversing that is a security
decision and belongs to the owner, not to this commit.
1992 tests pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
|
|
2194ab8079 |
Put the instructions where somebody reads them before they start
The previous commit shipped the guide AFTER the host was created. Its first two steps happen at the provider — order the machine, boot the rescue system — and whoever reads them there has the one-time code already in the clipboard with its 24-hour window running. The instructions were correct and in the wrong place, which is its own kind of wrong. Six steps now, not three, and the two provider ones are named rather than assumed. They are marked "first" so the list does not read as something you can start at the top of and work down: step 2 is half an hour of waiting, and doing it inside a running code is exactly the mistake the page should prevent. All six are visible the whole time, including the done ones and the ones still to come. A guide that shows only the current step cannot answer "how much is left", which is the question somebody has while a server they are paying for sits in a rescue system. It is on the hosts list too, collapsed. Somebody standing there may not have ordered the machine yet — and that is step 1. Requiring them to click "add host" to find out what the procedure is puts the answer behind the action it describes. One component, two places, so the archive address in the instructions is the same string the command line will carry. Two copies of that would drift, and the difference would surface on a server that has already been paid for. The step-4 command box moved into the guide rather than sitting beside it, so after creating a host the whole procedure stays on screen. Somebody stuck at step 5 should not be looking at a page that has become a single box. 1991 tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
903ebdd2b2 |
Give the operator one line to copy and three steps around it
The console could describe a takeover it had no way to start. This is the vertical slice that closes that: a one-time code, the archive the rescue system fetches, and the page that says what to do with both. The command carries EVERYTHING the script needs before the tunnel exists, because there is nothing to fetch — that is the whole point of spec §5. Which means CluPilot generates the WireGuard keypair and admits the peer at the hub before the machine has ever booted, and hands the private half over in the line. It is worthless within minutes: task 9 of the script replaces it with one generated on the machine. Shown exactly once. The database holds only the code's hash and never the private key, so leaving the page does not bring it back — it mints a new code, which invalidates the old one. That is deliberate: a glance at somebody's screen should be worth nothing an hour later. Which is also why save() no longer redirects. Sending the operator to the host detail page sends them away from the only value they need, and an existing test asserted that redirect — it now asserts the opposite, with the reason written next to it. SHA-256 rather than bcrypt for the code, and the reason is not speed. Both endpoints have to FIND the host by the code; with bcrypt that means trying every row. The code is 32 characters of CSPRNG output, so it has the entropy that stretching exists to manufacture. resolve() and claim() are separate because progress reports arrive BEFORE registration. If reporting consumed the code, a host could never register after its first message. The archive URL is always the public hostname. The console runs under admin.…, but this line executes on a machine that must not reach the admin area — it is locked down for exactly that reason — so route() from the console would emit a hostname that 404s on a server only reachable through the provider's console. The page warns about missing tunnel settings BEFORE the host is created, not after. An empty hub key produces a line that looks clean, copies fine, runs, and ends in a tunnel that never handshakes — discovered on the machine, after somebody has already paid for it. The three steps lead with the rescue system, because that is the one nobody knows by heart, and it says enabling is not the same as booting into it — the script refuses a running production machine, which is what a half-done switch looks like from the inside. 1986 tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
ecfced9257 | Merge branch 'main' into claude/cool-sammet-368fee | |
|
|
79654333a8 |
Name the recurring filter, and stop the spec contradicting itself
Three sentences left over from the wave before, all of the kind this branch exists to end. - "Every active Price" was one word short. activePricesFor() sends `type: recurring` as well as `active: true`, so a one-time Price is never listed — which made the hand-built-payment-link sentence false for a one-time link: neither listed nor archived. Both places say `recurring` now, and the docblock says why the filter is not a gap: createPrice() always sends `recurring[interval]` and is the only call that mints a Price at one of our Products, so the filter can only ever hide somebody else's one-time Price. A filter left out of a sentence is a gap the next reader goes looking for. - The sweep test file still opened "The orphans nobody can adopt" — the exact narrowing the command's own docblock had to be corrected away from. It happens to describe the fixtures, which are all metadata-less, so it now says that instead of claiming it of the command. - The design doc called --archive "harmlos" seven lines above the correction establishing that it is not, for an adoptable orphan. A document that contradicts itself seven lines apart teaches nobody anything. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
a43be4186a |
Say what the sweep did not check, before telling anyone to archive
stripe:sweep-orphan-prices described itself as listing "what AdoptStripePrice will not take over". It computes nothing of the sort: it lists every active Price at one of our Products that no register row knows, adoptable or not — the only question it asks is whether a row holds the id. And then it invited the destructive path, with no word anywhere about running stripe:sync-catalogue first. Following that advice can make a plan unsellable. Archive an orphan the next sync would have adopted and the sync can no longer see it: activePricesFor() asks Stripe for active prices only, so adoption returns null and createPrice() fires under the same key the crashed run used. For twenty-four hours Stripe answers a repeated key by replaying the stored response — the archived Price's id, in the body it had while it was live. The register writes it down as live, and the checkout is refused. This is the same class of defect the branch exists against: a comment that was eloquent and wrong. - The docblock now says what the command lists, names the twenty-four-hour replay so nobody has to rediscover it, and the ordering requirement is in $description and printed on every path that found something, --archive included. The reassurance that archiving is harmless is now scoped to what THIS codebase sells; a payment link an operator built by hand against one of our Products is withdrawn all the same, and says so. - stripe:sync-catalogue --dry-run says "created or adopted" again. A dry run counts intents without calling ensure(), so it cannot know which Stripe already holds — and a dry run is the first thing an operator runs after an interrupted one. The live line keeps both figures, where they are knowable. - The sweep's plan side gets a test: an orphan at a family Product is reported and a known plan Price never is. Deleting either half of the lookup turns it red; the five tests it joins all stayed green for the products() half. - The throttle test pinned "once" and neither "per price" nor "per day". A key mutated to a constant would have silenced every orphan after the first and kept it green — the silent no-op the branch is built against. It now plants a second orphan, and travels a day to hear the first one again. - Two of my own documents corrected. The spec sentence claiming an adoptable orphan never appears in this list is what the implementation faithfully built; it holds only if a sync has run since the orphan appeared, which nothing enforced. And the two adoption classes do not carry the same run log: $adoptions on the Price side, $duplicates on the Product side. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
571acd0013 |
Check Stripe is configured on every path the sweep actually uses
The dry-run guard was copied from stripe:sync-catalogue, where it is right: that command's --dry-run touches nothing, because everything it compares already lives in our own tables. This command has no such local-only mode — the report IS a live activePricesFor() call, dry-run or not — so `! $dryRun &&` made the guard fire only on the one path that would have worked anyway. A --dry-run against an unconfigured account fell straight into HttpStripeClient's own exception instead of the clean error and self::FAILURE. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
277a6bab83 |
Give the operator something to do about an orphan nobody can adopt
The recognition step refuses a price that proves nothing about itself, and that is right — adopting a stranger's price is worse than minting a second one. It left an operator with a log line and no way to act on it. Reports by default and touches nothing. With --archive the orphans stop being sold, which is harmless for the reason it always is here: Stripe goes on billing every subscription already on an archived price, and nothing is sold on a price no row knows. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
048e5ba81f |
Subtract only what was counted, not everything a singleton adopted
A figure may only subtract what it counted as an intent in the first place. The family loop counts a Product before knowing whether it will be adopted or minted, so an adoption there could honestly be subtracted back out — but syncModules() has never counted a module's own Product as an intent at all; it only counts a module's Prices. Reading AdoptStripeProduct::adoptions as one run-wide delta could not tell the two apart, so a module Product adopted from an interrupted run silently inflated "adopted" and shrank "created" by exactly one, for a Product the command never claimed to have made in the first place. The fix counts the family side locally, at the one call site that already counts the intent, and leaves a module's Product out of both figures entirely — adopted or minted, it was never counted, so neither number may move for it. AdoptStripeProduct::$adoptions is gone with it: nothing reads a singleton-wide total that cannot be attributed to one side or the other, and keeping it around unread would be exactly the kind of state this codebase does not leave lying about. The duplicate-product report had the same shape of bug one line down: it read straight off the singleton's list with no before/after snapshot, so a second handle() call in one process would reprint a duplicate an earlier run already named. Sliced to what this run itself added, the same way the counters beside it already were. Also: the unread `$charged` line in the adoption test that TDD had already exercised as dead weight, and its now-unused PlanPrices import. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
df7da334ee |
Say what was adopted, not that it was created
The count is taken before ensure() runs, so the sweep reported objects created in Stripe when it had made none — and after the next interrupted run, that line is the first thing a human reads. Telling the two apart is the whole point of the recognition step. Comparing the price id before and after the call does not distinguish them either: there is no id before, in either case. AdoptStripePrice counts its own adoptions instead, which is why it and its product sibling are now singletons — resolved per call, a counter on the instance would never pass one. Duplicate products are named in the report as well as the log. We deliberately do not deactivate them, so only a person can resolve one, and a person reads this. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
880b5f1998 |
Merge main into the operating-mode branch
Three real conflicts, and one file that did not conflict and mattered more.
Overview::notices(): both sides added notices. Kept all of them, and pointed
main's mail check at MailboxTransport::NON_DELIVERING — its own comment already
named the constant while the code carried a copy of the list.
billing.blade.php: main wrapped the page in a contract branch. The "payment is
not set up" error moved OUTSIDE it, because the customer most likely to meet
that message is the one buying for the first time, who has no contract yet and
would never have seen it from inside the @else.
ConsoleReportsRealDataTest: kept both sides rather than choosing. The renamed
test and its docblock explain why admin.systems_ok can no longer be asserted on
a bare install; main's mail pin still keeps "clean" clean in the mail
dimension. Picking one would have quietly weakened the other's claim.
HostStepsTest: git combined main's config()->set('provisioning.dns.zone',
'clupilot.com') with this branch's assertion on clupilot.cloud, and produced a
test that contradicted itself with no marker. Main's approach is the better one
— it pins the zone in the test and asserts the SHAPE of the name rather than
this box's domain — so its assertion stands.
HttpStripeClient merged silently and correctly: secret() still throws,
isConfigured() still reads the vault directly. Had it taken main's
filled($this->secret()), six callers that ask in order NOT to get an exception
would have become exception throwers, and the suite would have stayed green.
CheckoutWithoutStripeKeyTest is the lock that would have caught it.
StripeIdempotencyKeyTest (new on main) leaned on the environment fallback for
stripe.secret. That entry is strict now — no fallback in either direction, so
the .env cannot be a back door for a live key in test mode. It stores a real
vault row instead.
1966 tests pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
|
|
d6ec09a9b4 |
Recognise the product Stripe already has
The gap beside the guard. A run that creates a product and dies before storing its id leaves an orphan exactly as a price does, and past the key's twenty-four hours the next run makes a second one — after which activePricesFor() is asked about the wrong product, price recognition goes blind for the whole family, and every orphaned price on the first product is unreachable. Two things are unlike the price side, deliberately. No money gate: a product carries no amount, so its metadata is the whole of the proof. And a duplicate is reported, never deactivated — archiving a price is provably harmless because Stripe keeps billing subscriptions already on it, but deactivating a product makes its prices unsellable and contracts can be running on those. Silent about strangers, too. This asks for every active product on the account, so an operator selling something else through it is an ordinary state, not a finding worth a line on every run. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
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> |
|
|
|
fcea7ff3a8 |
Let the catalogue ask Stripe what products it already has
The price half of this question was answered; the product half was left open and named as a known gap. It is the more consequential one: a second product makes every activePricesFor() ask about the wrong one, so price recognition goes blind for that whole family and every orphan on the first product is unreachable. The whole active list comes back with no metadata filter, because Stripe cannot search metadata and an account holds a handful of products. Deciding which are ours belongs to the next commit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
52aacddc56 |
Store the agreement where the web process can actually read it
Every link on the agreement page answered 404 — the download visibly, the two inline ones just as dead — and nothing anywhere said why. The files were there, whole and correct. An artisan run inside a container is root; PHP-FPM is www-data. Laravel's local disk creates a PRIVATE directory, which is 0700, so `dpa/` came out as root-only. The web process could not enter it, `Storage::exists()` answered false, and the routes did exactly what they were told: abort 404. No exception, no log line, a page full of links to nothing. Both writers — the command and the console's upload form — now store with "public" visibility, which is the file MODE and nothing to do with the web: 0755/0644 instead of 0700/0600. The disk's root is outside the document root either way, and the two routes still check who is asking. The command also warns if a file it just wrote is not readable, because the alternative is finding out from a customer. The download itself now looks like one: the `download` attribute on both anchors, and in the console a bordered control rather than a third link in a row of two. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
4b99c6d5c6 |
Make the fake behave like Stripe where it matters
Three ways it did not. It replaced metadata where Stripe merges — and merging is the property AdoptStripePrice's "write only when it differs" comparison is built around. It handed metadata back uncast where the wire returns strings, so a test could pass on an int that production's strict === rejects forever. And a tie on `created` fell to insertion order, so two runs could adopt different prices. Paging now throws instead of stopping when Stripe says there is another page and the last item carries no id. invoiceLines() stops there and it costs a short document; here it costs an unseen orphan and a second live price for one figure. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
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>
|
|
|
|
40532c6e02 |
Put the money gate in the class that promises it
AdoptStripePrice says in its own docblock that no adoption can move money, and could compare three properties. The four that also decide what a Price charges were filtered in HttpStripeClient — where FakeStripeClient cannot express them, so no test of the class could reach the promise it makes, and a third client implementation would drop the check in silence. The client reports them now and the caller decides. Absent keys stay Stripe's defaults, which is the direction that matters: read the other way round the gate refuses every legitimate price and recognition becomes a no-op nothing alerts on. And the unexplained-orphan warning is a state, not an event. It fired on every sweep and every customer booking for as long as the orphan existed; once a day per price is enough to act on. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
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>
|
|
|
|
507c976035 |
Pin the one question that must never throw
isConfigured() had no coverage at all: replacing its body with main's
`filled($this->secret())` left all 1795 tests green, while at runtime the
same mutation throws StripeNotConfigured. Six callers ask that question
precisely to avoid an exception — CheckoutController:87 would answer a
customer with a 500 instead of a sentence.
The merge makes it urgent rather than merely untidy: git auto-merges
HttpStripeClient.php without a conflict marker, so nothing forces anyone
to look at the one line where the two branches disagree.
Proved red by that exact mutation before it went in, then reverted:
⨯ it answers the configured question instead of throwing it
StripeNotConfigured: No Stripe secret is stored for the [test] operating mode.
at app/Services/Stripe/HttpStripeClient.php:376
Http::fake() + assertNothingSent(): the question is asked on every
checkout and may only read the vault.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
|
|
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> |
|
|
|
865fd16f58 |
Put the price recognition back, all 3526 lines of it
|
|
|
|
49d528dbea |
Fix round: point at the real tab, hide the button nobody can press, prove the quiet page load
Fixes four Important findings from the review of Task 12.
Corrected nine check `tab` values at the source (BillingChecks was already
correct; OnboardingChecks/ProvisioningChecks/DeliveryChecks all carried the
pre-redesign 'integrations' value, which is not a member of
Integrations::TABS): four onboarding checks now point at 'platform' or 'env'
depending on where their field actually is, five provisioning/delivery
checks point at 'services'. checkUrl()'s match-block resolver — praised as
correct for the six checks that point at a genuinely different admin page —
is unchanged; its `default` arm is now a pure safety net, not a route any
check actually relies on.
Wrapped the "Prüfen" button in @can('secrets.manage'): mount() admits
hosts.manage OR secrets.manage, but runCheck() requires secrets.manage alone,
so an Admin-role operator could see a button that 403s on press.
Fixed a docblock on HEARTBEAT_KEYS that asserted a staleness threshold was
kept in sync with OperationChecks::STALE_AFTER_MINUTES — that constant is a
key/settings-name map with no threshold of its own, and no such
synchronisation exists.
Three new tests, each confirmed red against a deliberately reintroduced
version of the bug it covers before being confirmed green: every check's tab
value against where its field actually lives, the run-check button hidden
from an operator who cannot press it, and Http::assertNothingSent() after a
plain page load.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
|
|
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>
|
|
|
|
494a3a817e |
Fix round: guard the parse, bound the future, name the real tab
Covers the queue assignment the second heartbeat exists to prove, stops an unreadable heartbeat value from taking the whole readiness page down with it, rejects a heartbeat dated into the future, and points both checks at the tab that actually restarts the scheduler and provisioning worker. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
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> |
|
|
|
8321e825eb |
Fix round: CGNAT, guarded write/delete, and a silent host is not a missing template
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
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> |
|
|
|
43d1d55909 | Merge branch 'main' into claude/cool-sammet-368fee | |
|
|
2eff3a5a3c |
Tell a read-only token apart from one that can write
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> |
|
|
|
3cf16ecd63 |
Name every property that decides what a price charges
The money gate compared amount, currency and interval and left the rest to a filter that excluded two fields. A recurring, licensed, monthly price with transform_quantity divide_by 10, at our exact figure, on our product, carrying our addon key passed all five conditions. Modules bill BY quantity — a pack is one item at quantity n — so a customer holding three would have been charged one. A tiered price was excluded only by accident: Stripe reports unit_amount null for it and we read that as 0, which fails the amount match unless the caller's own figure is 0, and PlanPrices::ensure() has no zero guard. activePricesFor() now also skips a non-empty transform_quantity and any billing_scheme other than per_unit. Absent keys stay Stripe's defaults, which is the direction that matters: read the other way round the filter would refuse every legitimate price and turn recognition into a permanent no-op. The contract docblock names the four properties instead of claiming to cover all of them, and says the list is not everything Stripe can put on a price — only what is known to change what one charges. And a comment that says a gap is covered is worse than the gap. The sync said a crash between Stripe creating a product and us storing its id gives back the same product next run: true for twenty-four hours, false afterwards, and the exact claim this branch was written to refute. There is no recognition step for products at all, so an interrupted run leaves an orphan and the next run past the expiry mints a second one — worse than a duplicate price, because activePricesFor() then looks at the wrong product and recognition goes blind for the whole family. Written down as a known gap at both createProduct call sites, with the trade this branch chose: folding name and metadata into the key means a renamed family now mints a second product where it used to answer 400, and a blockade reaches a paying customer while a duplicate product does not. The sweep no longer reports "created" for what it adopted — the count is taken before ensure() runs, and telling those two apart is the whole point. The test named after 2026-07-29 could not reproduce it: the fake's key ledger was empty, so createPrice() could not have thrown and the test could not tell "adoption prevented the 400" from "no 400 was possible". Seeded now with the key today's call sends and a fingerprint over the metadata the pre-9da1358 call sent. Remove the adopt() call from AddonPrices::ensure() and the test dies with Stripe's own sentence instead of an assertion. Also: the plan document still instructed migrate:fresh --env=testing. There is no .env.testing here, so it targets the live development database; struck through with the reason, not deleted, and a Korrekturen section records the five defects the plan's own reviews found in it. Four comments corrected — "below" that meant above, a counter claimed unable to tie that ties with a planted price, the unique index's second meaning for remember()'s catch, and that MariaDB DDL is not transactional so the dedupe commits before the index. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
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> |