166 Commits (272cfe45e89be3c0bab4cf262fc97b38d9b8032a)
| Author | SHA1 | Message | Date |
|---|---|---|---|
|
|
272cfe45e8 |
Keep IP addresses out of a certificate overview
127.0.0.1 was in the list on the live server, marked as console, showing "no valid certificate: Connection refused" in red. It came from ADMIN_HOSTS, where a bare IP is deliberately allowed — it is the way back into the console when a name does not resolve. My filter only asked for a dot, and 127.0.0.1 has three. Let's Encrypt does not issue for IP addresses, so that row was permanently red and nobody could ever fix it. A red line that cannot be acted on is worse than no line: it teaches the reader to skip the colour, which is the one thing the overview needs them not to do. Two checks now, not one: the address test, and a last label that is not numeric. A TLD is never a number, and that also catches the forms FILTER_VALIDATE_IP lets through. The row already in the database goes away on the next sync, because otherwise my mistake would sit on every installation that has already updated. Vanished config rows are handled by what they carry: one that never had a certificate was a mistake and is deleted; one that HAS a live certificate becomes a manual entry instead, so nothing with a running expiry disappears without the operator deciding. That is a change of mind from the previous commit, which said config rows are never removed — this case showed the cost of that rule where the row should never have existed. 2043 tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
cfaea7dc82 |
Fill the hostname page from the installation, not from a blank form
The page was empty, and that was a design fault rather than a missing button. I built a register that starts blank — while the installation already serves half a dozen names whose certificates were exactly what the operator wanted to see. An overview you have to populate first does not answer "what do I have". So the names are derived now, from the same configuration routes/web.php builds its domain bindings from: SITE_HOSTS, APP_HOST, FILES_HOST, ADMIN_HOSTS. If a name is in the environment, the application answers on it, and then it belongs in this list without anybody typing it a second time. Opening the page syncs them; the list is never empty again. Syncing and measuring are deliberately separate. The sync costs nothing and runs on page load. The measurement goes out over the network and runs on the button or on a schedule — doing it on page load would mean waiting through half a dozen TLS handshakes, and one of them is always the name that currently does not resolve. Which is the other half of what was missing: there was no overview because nothing measured unless somebody pressed a button. A daily run at 04:17 fills it, because the question that matters is not "is it valid right now" but "is renewal running" — a certificate expiring in forty days is fine, the same one at twenty means something has been broken for a week. Only a measurement taken while nobody is looking can tell those apart. The page now opens with four counts — total, valid, expiring soon, without a certificate — and says when it last measured. A name that comes from the environment is marked as such and cannot be removed here: it would come back at the next sync, and a button that does nothing is worse than no button, because it gets believed once. 2040 tests pass, assets build. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
006ce3568b |
Manage hostnames and watch their certificates from the console
files.clupilot.com is why this exists. DNS pointed at the right machine, the env var was set, the release was deployed — and there was still no certificate, because /etc/caddy/Caddyfile is maintained by hand and nobody thought of it as a second, separate step. Nothing in the console would have said so. Three settings looked correct and the address did not answer. So the page holds two things side by side. The WISH — which names should be served — and the REALITY: whether the name has a certificate and for how much longer. The second is measured by opening a TLS connection and reading the expiry, not by reading configuration, because the configuration is exactly what looked right while the address was dead. verify_peer stays on: a certificate that fails validation is not a certificate for this question, and a display that called it valid would be the fake R19 records. Applying goes through the existing agent, not a new channel. The console writes a request, the path unit wakes the agent within a second, and the agent calls one fixed command line of the root-owned helper. What that helper is allowed to do is the careful part. It fetches the list ITSELF rather than being handed one, and the list is HOSTNAMES, never Caddy blocks — `php artisan clupilot:proxy-hosts` prints `<name> <purpose>` and nothing else. Each name is matched against a strict pattern before it is used, and the template around it lives in the helper, which the service account cannot touch. install-agent.sh already states the principle for the sudoers grant: a grant is only worth anything if the holder cannot change what it grants. A service account that could write proxy configuration would have everything the proxy can do — redirects anywhere, files from any directory. Purpose is a column rather than a habit. A console name gets the network lock, a public one does not, and a console name published without it looks exactly like a working page. Removing takes the name out of the list and NOT out of the running proxy. Two decisions in one click, and the second one takes a site off the air. There is deliberately no "renew" button. Caddy renews on its own at two thirds of the lifetime; what an operator actually needs is a second attempt after an issuance has failed, and that is a reload — which is what Apply does. Thirty days is treated as a problem rather than a warning: at ninety days' lifetime, a renewal should long since have run, so anything under it is not a tight certificate but a renewal that is not happening. The ACME contact falls back to the owner's address, because a contact nobody reads is the step before expired customer certificates. CONTRACT and HOST_STEP_NEEDS both move to 2, which is what tells a server carrying the older helper to run the installer again — caught by the guard test that compares the two halves. 2035 tests pass, assets build. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
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> |
|
|
|
ecfced9257 | Merge branch 'main' into claude/cool-sammet-368fee | |
|
|
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> |
|
|
|
2d915be003 |
Put back the docblock space pint took on the way past
One character in a @return line of webhooks(), untouched by anything this branch does. It was only reformatted because the file happened to be on the pint path of the prefix-rule commit. A style fix to an unrelated line is noise in a diff a reviewer reads line by line. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
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> |
|
|
|
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> |
|
|
|
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>
|
|
|
|
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> |
|
|
|
865fd16f58 |
Put the price recognition back, all 3526 lines of it
|
|
|
|
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>
|
|
|
|
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> |
|
|
|
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>
|
|
|
|
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> |
|
|
|
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> |
|
|
|
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>
|
|
|
|
dba5df5d1e |
One row per Stripe price on the module side too
The plan register has had this since 2026_07_30_110000. The module register never did, so two rows could claim one Price and archiving the one would have withdrawn the Price the other was still selling — Block D of the real-run handoff. It is also the net under the adoption step: at a VAT rate of nought both treatments charge the same amount, and the check that refuses an already-claimed Price is then the only thing keeping the two apart. Duplicates are deleted, not archived. An archived row goes on claiming the id and a unique index knows nothing about archived_at; the row rebuilds itself on the next ensure(), and subscription_addons holds the Stripe id as text rather than a foreign key, so no booking loses its price. AddonPrices::remember()'s catch comment described the guard as the only thing standing between two rows and one Price; now that the index exists, rewritten to describe it as the net under that guard instead. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
dfbd3b5c1f |
Restore the key's own reason and pin the plan-side identifying key
Two things the plan's own text asked back. First, the idempotency-key comment lost its substance when Task 5 rewrote it: the charged amount and the treatment are in the key because at a rate of nought the two Prices are the same amount and one key would have Stripe hand back a single object for both — a maintainer who simplified that key would only rediscover why from a unique-constraint violation. Restored alongside the newer sentence about what actually stops a second Price (adoption, not this key), so the module and plan sides read alike again. Spelled out "twenty-four hours" in both places in the same comment, matching the sibling. Second, nothing pinned identifying: ['plan_price_id'] — the one decision that makes the plan side different from the module side, because a family's Product carries a Price for every version, term and treatment, so plan_family alone cannot say which row a Price belongs to. Added a test that plants a Price proving only the family, confirmed it fails when identifying is loosened to ['plan_family'], and reverted. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
355f9cce01 |
Ask before minting a package price
Same defect, same fix, one difference: a family Product carries every version and every term, so the amount alone does not say which row a Price belongs to. plan_price_id in the metadata does, and it is what has to agree before a Price is taken over. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
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> |
|
|
|
40f7818757 |
Fix round 1: give the frozen-booking test real teeth
Two Important findings from review, both against the plan's own text. The frozen-booking test could not fail for a defect this diff introduces. Digging into why turned up two problems, not one. The two assertions the review named were never exercised: sync-catalogue never writes subscription_addons.stripe_price_id, and the orphan planted for the new figure sits at a different amount than the booking's own price, so archiveSuperseded's net_cents scoping was never going to reach it either. Underneath that, the test's own orphan was inert regardless of any of that: with no StripeAddonPrice row seeded first, AddonPrices::product() minted its own Stripe Product instead of reusing 'prod_support', so the competing orphan planted under that literal id sat on a product nothing ever asked about. Fixed both — seeded the product the way the first two tests in this file already do, added the three assertions the review specified, and a comment on what the two original assertions do and do not prove. Verified the fix actually bites, since a test whose teeth are asserted but never demonstrated is what produced this finding in the first place. With just the amount check in AdoptStripePrice disabled, the booking still survived: the `claimed` callback this diff wires into AddonPrices::ensure() caught it as a second, independent guard. Only disabling both together reproduced the failure — the honest demonstration, not the single-line sabotage first suggested, which this diff's own defense-in-depth absorbs. The remember() comment credited a unique index stripe_addon_prices does not have — that index sits on stripe_plan_prices, the plan side. Reworded to name what actually keeps two rows off one Price id today (the `claimed` callback), what 2026_07_31_210000_one_row_per_stripe_price adds as a net under it, and why two rows sharing one Price is dangerous: archiving one — the ordinary response to a superseded figure — would withdraw the very Price the other row is still selling. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
97b655938e |
Ask before minting a module price
The sweep that died on 2026-07-29 left price_1TygdEC7u8NpJ8pOt3nsoyYw at Stripe and no row here. This is the step that finds it: same money, same interval, our metadata on it, unclaimed — taken over and brought up to date instead of duplicated. The comment on the key was the root of it. It read "keyed on what the Price IS", and a key is not that: identity is product, amount, currency and interval, and recognising those is AdoptStripePrice's job. Stripe forgets a key after a day; it never was the guard against a duplicate. A booking stays where it was sold. Adoption only ever matches an identical amount, so a frozen booking cannot be moved by it — there is a test that says so. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
727f2e41db |
Make source() respect strict entries like get() does
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
06c214e597 |
Make the money gate compare the whole recurrence
activePricesFor() reported interval alone, never interval_count or usage_type. A Price we could not have created ourselves — billed every three months, or on metered usage — could carry our own metadata (Stripe's dashboard "duplicate price" copies it) and pass AdoptStripePrice's amount/currency/interval check on 'month' alone. Adopting it would move money onto a different recurrence, which is exactly what nothing here may do. Filtered at the boundary instead of in the predicate: HttpStripeClient now skips anything besides Stripe's own defaults (interval_count 1, usage_type licensed), so activePricesFor() can promise callers it returns only prices we could have minted. FakeStripeClient needed no filter — it only ever holds prices its own createPrice()/plantPrice() made, all standard — but says so, so the omission doesn't read as forgotten. Two more silent-failure paths, closed with the same three lines each: - The metadata-differs check was a bare !==, which is key-order sensitive and blind to Stripe's own merge. A Price carrying an extra key of its own, or metadata handed back in a different order, could never compare equal and would be rewritten every sweep forever. Compared through array_intersect_key() plus ksort() on both sides instead, so only what WE would send is what gets compared. - Metadata is normalised to strings once, up front, so an un-cast value from a caller can't make confirms() fail forever and silently turn adoption off for that Price. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
4de44e7ab0 |
Recognise the price Stripe already has
An abandoned run leaves an orphan: Stripe made the Price, our insert never happened, and the table that decides everything afterwards does not know it exists. The key covers a day; after that the next run makes a second live Price for the same money. What may be taken over is narrow. Same amount, currency and interval — a Price at another figure would move money, and nothing here may: a running contract keeps the Price it was sold on, and a booking stays frozen at what it cost that day. Plus proof in the metadata, because an unexplained active Price at the right money is what somebody clicking through Stripe's dashboard leaves behind, and adopting that is worse than minting a second one. Several orphans: the oldest is adopted — likeliest to be the one a lost row was billing on — and the rest are archived, which stops them being sold and moves nobody. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
59e8689630 |
Never spend real money while the switch says test
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
ca575e6df1 |
Let the catalogue ask Stripe what it already has
Nothing here could. The client could create, archive and unarchive a Price and had no way to list one, so a run that died between Stripe's create and our insert left an orphan our table never learned about — and once the key expired, the next run made a second live Price for the same money. Paged to the end like invoiceLines(), because a family Product collects a Price per version, term, treatment and rate change, and stopping at the first page would leave the orphan unfound and mint the duplicate anyway. updatePriceMetadata() alongside it: metadata is one of the few fields a Price lets you change, which is why the metadata format is not part of a Price's identity and why an adopted orphan is brought up to date rather than replaced. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
bc6eee4dc3 |
Give every credential a test slot beside its live one
Each entry in the vault now stores under key:mode instead of a bare key, and get() resolves the active mode internally — falling back from an empty test slot to live (never the reverse) so test operation works before anyone has filled in test credentials, but live operation never silently reaches for a test one. get()'s signature is unchanged on purpose: its three callers (HttpStripeClient, StripeCheck, SshTraefikWriter) have no business knowing which mode is active. put()/forget()/source()/outline()/ updatedAt() take an optional trailing $mode for the console, which will use it in a later task. SecretVaultTest's raw app_secrets queries assumed the old bare key and needed the :live suffix plus a pinned OperatingMode — otherwise they'd depend on whatever mode an earlier test file left in the array cache. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
cdea9e923f |
One source for what counts as the same call
IdempotencyKey::forPrice()/forProduct() built the parameter array they fingerprint, and FakeStripeClient::createPrice()/createProduct() rebuilt the identical array literal by hand purely to feed replay()/rememberKey(). Nothing tied those two literals together — a later change to what a Price call fingerprints could drift from what the fake checks for a collision, and no test would catch it, because the HttpStripeClient tests exercise forPrice() alone and the FakeStripeClient tests exercise its own local array alone. priceParameters()/productParameters() are now the one answer to "what counts as the same call"; forPrice()/forProduct() and the fake both ask it instead of each keeping their own copy. Fingerprint and key format are unchanged — this is the duplication coming out, not new behaviour. refund(), cancelSubscription(), addSubscriptionItem() and createCheckoutSession() are untouched: their local parameter arrays are the fake's own record of the call, never fingerprinted into a key, so there was nothing to duplicate there. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
60e93aafd7 |
Put in the key everything the call actually sends
A key says "I already sent this call", not "this is what the object is". The
catalogue calls sent metadata that the key knew nothing about, so adding the
tax_treatment field in
|
|
|
|
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> |
|
|
|
889b401faf |
Fix nine defects in the provisioning pipelines
The capacity park could not survive its own first poll. ReserveResources
polls every 120 s and declared a maxDuration of 60, and RunRunner measures
a step's budget from a started_at it deliberately does not reset on a poll
— so every re-entry was ruled timed out before the body ran, a timeout
consumes an attempt, and five of them failed a paid order in about six
minutes. The fourteen-day promise, the console's capacity queue and the
"go and buy a server" workflow were unreachable code. maxDuration is now
the whole park plus one poll interval, and a test drives a park through
the runner rather than calling execute() directly, which is why this was
invisible.
Every guest occ call ran as root. `docker compose exec` defaults to root
and the Nextcloud image's console.php exits 1 unless the caller owns
config/config.php, so all five call sites were failing on every instance.
There is now one builder, App\Support\NextcloudOcc, and a test refuses a
second: nothing in app/ may spell the invocation out by hand. deploy/
update.sh had learned this for our own container and nobody carried it
across.
RunAcceptanceChecks was terminal on the first no. certReachable() answers
false for a connect timeout as readily as for a missing certificate, so
one bad second ended a finished, certified Nextcloud as a failed order
with the instance released. The probes retry now and the run still fails
for good with the probe's own reason once the budget is spent; the two
facts a retry cannot change stay terminal.
The in-flight guard asked whether anything was running, not whether it
would do the work. App\Provisioning\WorkInFlight asks the second question,
reading the step lists the runner executes: a domain proven during a
restart is routed, and a storage pack booked during an address run is
delivered instead of charged monthly and forgotten.
The address pipeline ran its steps in the wrong order for the direction it
exists for. Nextcloud before the router in `address` and `plan-change`, as
in `customer`, because a run that got the certificate and then failed left
the customer's own domain serving an untrusted-domain error under a valid
certificate while the portal called it live. Safe in both directions; the
reasoning is written above the pipeline.
A stale guest_ip could never be corrected. ConfigureDnsAndTls compared the
hostnames but never the backend, and nothing re-read the address after the
build. It now records and compares `routed_backend`, and ConfigureNetwork
joins the `restart` pipeline — a cold boot is what moves a DHCP lease — and
asks for the address to be re-applied when the guest has actually moved.
Also: HetznerDnsClient::upsertRecord read only the first page of a zone
that pages at 100, so past a hundred records it created a second A record
for the same name and the cloud was up about half the time; the console's
retry left a live customer's order in `provisioning` for ever for any
maintenance pipeline; and a revived run's first pass burned an attempt on a
timeout that had already happened, because the clause written for it used
`??=`.
Seven test sites wrote config('provisioning.plans.*'), a key that has not
existed since the catalogue moved into the database. Every write was a
no-op, so each test proved something other than its name: the missing
template, the two snapshot regressions and the grandfathered-price scenario
are now actually constructed, ValidateOrder's reasons are asserted rather
than only its type, and the end-to-end run checks that the quota step
delivered something.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
|
|
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> |
|
|
|
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> |