612 Commits (main)
| Author | SHA1 | Message | Date |
|---|---|---|---|
|
|
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> |
|
|
|
0f4226c488 |
Fix round: read the mailer against NON_DELIVERING, not against 'log' alone
delivery.mailer_not_log now reads MailboxTransport::NON_DELIVERING (made public) instead of a narrower `!== 'log'`, so 'array' — this suite's own MAIL_MAILER — and an unset default no longer pass as ready when nothing would actually be delivered. provisioning.monitoring_token's severity now follows provisioning.monitoring.required the same way RegisterMonitoring itself does, instead of asserting a fixed 'warning' the pipeline does not always honour. Two breaks sentences (dns_zone, mail_templates) now name a concrete failure mechanism instead of a correct-but-vague description. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
f9993a1f48 |
Name the server variable under a credential, not the internal key
tests / pest (push) Failing after 8m35s
Details
tests / assets (push) Successful in 20s
Details
tests / release (push) Has been skipped
Details
Under each credential stood `stripe.secret`, `dns.token`, `inbound_mail.password`, `monitoring.token` — identifiers out of SecretVault::REGISTRY. They mean nothing on a settings page and there is nowhere an operator could type them; asked about directly, and the answer is no, that was not deliberate. The env variable name is the useful one: it is what stands in the server file, it is what the .env tab of this same page lists, and when the badge beside it says "Aus der Serverdatei" it answers the obvious next question — which line? The registry key stays where it belongs: in wire:key and in the modal arguments, as an identifier nobody reads. The test asserts on the text node rather than on the key's absence from the HTML, because absence would have demanded breaking the page to pass. 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>
|
|
|
|
cb9781d2e7 |
Name the mechanism that actually rebuilds the row, and log what the delete removed
The migration's own docblock justified an irreversible delete with a mechanism
that cannot fire: after the dedupe the deleted row's Price id is always still
claimed, by the survivor, so AdoptStripePrice's `claimed` callback always skips
it. The rebuild is real, but it goes through a fresh createPrice() under the
deleted row's own tuple-specific idempotency key, not through adoption. A
comment whose whole job is to justify deleting production rows has to name the
mechanism that runs, not the one that structurally cannot — this is the fourth
false justification this branch has carried, and the incident the whole
feature exists for started the same way.
The delete itself left no trace: down() restores the index, not the rows, and
two rows on one Price necessarily have different tuples, so at most one can
match what the Price actually charges — after the one real run there would be
nothing to check the survivor against. Added a Log::warning per duplicate
group naming the shared Price, the row kept, and every row thrown away.
Test: read back stripe_price_id and amount_cents on the surviving and
unrelated rows instead of only checking they still exist, closing the "kept
the right row but damaged it" gap. Added a second duplicate group of three
rows sharing one Price so the migration's foreach runs more than once and a
single iteration deletes more than one row. Re-verified the "lowest id
survives" assertion is load-bearing by flipping min('id') to max('id') during
this fix and confirming the test fails there, then reverting.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
|
|
325cee50ca |
Check the machines, the DNS and the post as well
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
2c9e9ea398 |
Watch the deletion branch delete the right row
2026_07_31_210000_one_row_per_stripe_price deletes rows from a live table —
the one place in this branch where a mistake loses data, not merely a place
where "read the migration" is enough. Nothing exercised that branch: every
other test in this file starts from an empty, already-migrated table, so the
delete never had anything to find.
RefreshDatabase has already run the migration by the time any test starts, so
its own unique index refuses the very duplicate this needs to insert. The new
test drops the index first, puts two rows on one Price back the way a
pre-migration table actually looked, and re-runs up() (require, not
require_once, the way the other migration-replay tests in this suite already
do) to reach the branch directly.
It asserts the row with the LOWER id survives, not merely that a row survives:
that ordering is a decision made on the operator's behalf, and it is the row
anything already billing is likeliest to have been reading. Checked by
temporarily inverting min('id') to max('id') during this work — the test
failed exactly there, then passed again once reverted.
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> |
|
|
|
b1e5bf8023 |
Fix round: match the actual selection IssueInvoice and sync use
Two of the five billing checks measured something close to, but not
the same as, what actually gates the flow they report on:
- billing.invoice_series counted distinct kinds without filtering on
`active`, but IssueInvoice draws its series with
where('kind', ...)->where('active', true) (IssueInvoice.php:373,
:444) — a deactivated row is invisible to it exactly like a missing
one. An installation with a switched-off 'invoice' series was
reported ready while no invoice could draw a number: false-green.
- billing.catalogue_synced counted every PlanPrice with a null
stripe_price_id, but stripe:sync-catalogue only ever mirrors
PUBLISHED versions (SyncStripeCatalogue's own header comment). A
draft's unsynced price is an ordinary state, not a gap; the old
query kept this check permanently unsatisfied on any installation
that had ever drafted a next version — an alarm always on and
therefore never read.
Both queries now mirror the condition the real gate uses instead of a
similar-looking one of their own. Two tests added that were red before
the fix: an inactive required series that must not read as ready, and
an unpublished draft's unsynced price that must not raise an alarm.
Also strengthens the weakest breaks sentence: invoice_series_breaks
named the mechanism ("a document cannot draw a number") rather than
the consequence, unlike its four siblings. It now says what the
customer and operator actually experience — a running cloud with no
invoice, the failure only logged — matching company_details_breaks.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
|
|
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> |
|
|
|
d9c0ad0e4a |
Say what breaks, not just which field is empty
Task 7: the readiness collector (App\Support\Readiness) and its first group, billing (App\Support\Readiness\BillingChecks). Reports missing prerequisites — the active mode's Stripe key, its webhook signing secret, complete company details, an invoice series per document kind, a fully Stripe-synced catalogue — without enforcing anything itself; the real locks (IssueInvoice, the checkout's Stripe-key guard) stay where they are. The webhook check uses App\Support\StripeWebhookSecret::current() rather than querying config() by mode a second time, and the company check calls CompanyProfile::missingForInvoicing() rather than duplicating its list — two sources for one question is how they drift apart. Added lang/de/readiness.php and lang/en/readiness.php with every `readiness.*` key this group uses, each `_breaks` sentence naming the actual downstream failure rather than repeating the field name. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
cdda38c6b2 |
Make the plan-side adoption tests able to fail
Both new tests passed whether adoption ran or not: Stripe forgets an idempotency key after twenty-four hours, and that expiry is the only condition under which the 2026-07-29 duplicate was ever minted. With the sync's own key still in the fake's ledger, createPrice() replayed the right answer on its own, so testing the recognition step with the key still in force tested nothing. Clearing $this->stripe->keys before the second call is what lets createPrice() actually mint, which is what gives both tests something to fail on. The second test needed one more thing: this row's own domestic Price is a legitimate orphan the moment its register row is gone, so it had to be archived first — otherwise adoption would rightly reclaim it before ever weighing the wrong-owner Price, and the refusal this test exists to prove would never be reached. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
a1e3484fba |
Verify webhooks against the secret of the mode we are in
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> |
|
|
|
dca74f0d78 |
Fix round 1, correction: name the assertion that actually bites
The comment from the last commit had it backwards. It called the two original assertions "not the guard" and claimed the three DB-state assertions fail when adoption reaches the wrong figure — both false. AdoptStripePrice performs no database writes at all, so nothing it does can fail an assertion that reads the 2900-net row's own table state, singly or in combination. The `archived` assertion — the only one that reads what Stripe was actually told — is the one adoption can fail, by archiving the frozen booking's own Price as a false duplicate of the new figure's orphan. Left as a trusted comment, this would have read as an invitation to delete the real guard as redundant scaffolding around the "real" three. Reworded to say what the DB-state assertions actually defend instead — archiveSuperseded() losing its net_cents scoping, or remember() rewritten as an updateOrCreate() keyed without amount_cents — and to name where each of adoption's two guards (the amount match, the claimed callback) is tested in isolation elsewhere in this file, since neither is isolated here. Also closed the `archived_at` assertion: it read null for a row that is still live and for one that is gone or rewritten onto the wrong net_cents alike. An `exists()` check now stands beside it. 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> |
|
|
|
ac44717d1b |
Cover the unreadable branch and make rollback pick live deterministically
The only test that reached SecretCipher::decrypt() failing was an accident of a fixture bug fixed in the previous commit: it encrypted with the wrong key, so decryption threw and the migration's catch(Throwable) branch ran without anyone meaning to test it. Fixing that fixture silently removed the only coverage of a branch that decides a payment key's mode, so it gets its own test with a deliberately unreadable row. down() picked whichever of the :live/:test rows an unordered get() happened to return first to restore to the bare key, and deleted the other — so which credential survived a rollback depended on row order, not a decision. It now processes :live rows first unconditionally, then :test rows only where the bare key is still free, so the live credential always wins when both exist. 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> |
|
|
|
ecf948f30f |
Let the stored key say which mode it belongs to
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> |
|
|
|
3cf17e5cb0 |
Give the installation a test mode and a live mode
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
88d6dc620b |
Stop the suite from reading the operator's own .env for the DNS zone
HostStepsTest asserted fsn-01.node.clupilot.com and was green only because this server's .env carried the wrong zone. Correcting the .env turned it red without a line of production code changing. 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
|
|
|
|
fe4a74773e |
Break the mode switch and the readiness page into twelve steps
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
0ec5d93da8 |
Break the price-adoption spec into six testable steps
Six tasks, each ending on a green billing folder and its own commit: the key fingerprint (and a fake that finally reproduces Stripe's refusal), the two listing calls, the adoption step itself, then each price service in turn, then one row per Stripe price. The spec now names both test files. What goes on the wire is checked through Http::fake against the real client — asserting against the fake would only prove the fake agrees with itself, which is the mistake that let the incident through. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
3768433792 |
Design a test/live switch and a page that says what is missing
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
af9d60469e |
Write down why a half-finished sync blocked itself for a day
A run on 2026-07-29 23:11 created price_1TygdEC7u8NpJ8pOt3nsoyYw at Stripe and
died before the row was written.
|
|
|
|
36cb684d8b |
Write down what the audits found and what a real run needs
tests / pest (push) Failing after 8m0s
Details
tests / assets (push) Successful in 20s
Details
tests / release (push) Has been skipped
Details
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
5d12983ef7 |
Release 1.3.44
tests / pest (push) Failing after 7m56s
Details
tests / assets (push) Successful in 30s
Details
tests / release (push) Has been skipped
Details
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
9da1358802 |
Stop charging VAT to the customers who owe us none
A verified EU business outside Austria gets reverse charge — rate 0, no VAT line, the note on the invoice — and was still charged 214,80 € for a 179,00 € package, because one Stripe Price carried the domestic gross for everybody. There is no VAT line on their document to reclaim, so it was a flat 20 % surcharge on exactly the customers who read their invoices, and the document then stated the whole 214,80 € as net at 0 %, so they self-accounted their own VAT on a base a fifth too large. A Stripe Price can ask who is buying after all: there are two per sellable thing now, the domestic gross and the bare net, both live on one Product, and the checkout picks by TaxTreatment. The rule is Austria B2B 20 %, other EU B2B without VAT — a domestic business still pays the gross, reclaims it as input tax, and the price on the website is still the price charged for them. - stripe:sync-catalogue mints and archives both halves of every pair, per plan and per module; `reverse_charge` on stripe_plan_prices/stripe_addon_prices says which is which, and joining it on subscriptions.stripe_price_id says which one a running contract is billed on. A rate change moves the gross Price and leaves the net one alone, because the net owes nothing to the rate. - A status that changes after the sale converges: a verification (or a lapse) makes stripe:reprice-subscriptions move the contract onto the other Price with PRORATE_NONE, because the term is already paid for. The module items follow the same way. - Downstream follows: the invoice total equals what was taken, the proof register expects the figure this customer is actually charged (so a correct reverse-charge sale is no longer flagged as a mismatch, and an overcharged one is), the setup fee obeys the same rule, and the booking page quotes what it will charge. - StripeClient gained activatePrice(). Archived Prices were brought back in our own table and left inactive at Stripe, so a rate that moved and moved back pointed the catalogue at a Price no checkout could be opened for. An unverified number is still charged the gross — an unchecked string must never be a discount — and where the net Price is missing the checkout refuses rather than reaching for the domestic one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
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> |
|
|
|
518bc9aa41 |
Put the open tab in the address bar, including the first one
tests / pest (push) Failing after 11m2s
Details
tests / assets (push) Successful in 20s
Details
tests / release (push) Has been skipped
Details
Reported as "die Tabs fehlen in der Query beim Kunden". The binding was there and identical to the settings page — checked in the rendered snapshot, which is where Livewire's JS reads it from — but `except` hid the parameter while the DEFAULT tab was open. So arriving on a customer showed no ?tab= at all, and a link to the page could not say which tab it meant unless the tab happened not to be the first one. From the address bar that is indistinguishable from the feature not existing. No `except` now, on all three tabbed pages: the parameter is there from the first render. And `history: true`, so each tab is a step the back button can take rather than a silent replaceState — which also makes the change unmistakable while clicking. Asserted against the rendered page rather than the component, because the parameter is written client-side from the `url` memo in the snapshot: the memo IS the behaviour, and asserting the property would have passed throughout the bug. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
0a89695189 |
R24: a modal is never taller than the screen
tests / pest (push) Failing after 11m6s
Details
tests / assets (push) Successful in 20s
Details
tests / release (push) Has been skipped
Details
A modal grew with its content, so a form ran off the bottom of the window and took its own save button with it — reachable only by scrolling the page BEHIND the backdrop, which on a phone means not reachable at all. The customer form with its eight fields is what made it obvious, but it was true of every long modal in the console. Two halves. The panel is capped once, in the published package view, so no modal can opt out of it and none has to remember to: max-h in dvh rather than vh, because on a phone the browser's own chrome counts towards vh and that is exactly the case where the last centimetre decides whether the button can be reached. And x-ui.modal is the other half — a header slot, a scrolling body, a footer slot. min-h-0 on the body is the line that makes it work: a flex child will not shrink below its content without it, and the overflow never engages. Fourteen modals converted: the ones that carry a field, which are the ones that can grow. A two-line confirmation with one button has nothing that needs to stay put, and the rule says so rather than leaving a footnote — the test's criterion is the field, so the moment somebody adds an input to a confirmation it fails and tells them. A submit button in a footer sits outside the form it submits, so it carries form="…". That is the price of a footer that does not scroll away, and an HTML attribute rather than a workaround; there is a test for it, because without it the button silently does nothing. Two traps met on the way, both now scanned for. A Blade directive in a component tag's attribute list (a conditional wire:poll on x-ui.modal) compiles into the attribute bag and breaks the view outright — the same mistake as @disabled on x-ui.button, which cost a debugging round earlier this session; such attributes go on an element of their own. And Pest's toContain() is variadic, so the second argument I passed as a failure message was read as another needle: the assertion demanded that a footer contain its own filename, and failed on a file that was correct. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
f00cbf18d3 |
Let an operator correct a customer's details
tests / pest (push) Failing after 7m59s
Details
tests / assets (push) Successful in 23s
Details
tests / release (push) Has been skipped
Details
The console could show them and not touch them, so a moved office or a new contact person meant impersonating the customer or editing the database by hand. Both are worse than a form. R20: a modal, opened from the Stammdaten tab, authorising again and reading the record itself rather than trusting anything the browser hydrated. Most of the work is in what saving must NOT quietly do. The customer type is never reset to "nobody asked". The field opens on "leave unchanged" precisely so that saving an address correction cannot undo it — that answer decides whether a withdrawal right exists, and losing it silently would be the expensive kind of quiet. A changed VAT number is unverified from the moment it is stored — the verification is bound to the value, so that happens by itself — but `vat_id_verified_value` is left alone: it is the record of what WAS checked, and clearing it would destroy the evidence rather than the claim. The address is also the sign-in address and the key inbound mail is matched by, so changing it moves the linked portal login in the same transaction and puts it back to unverified, which is what Laravel's own profile update does for the same reason: nobody has shown they can read the new address. No mail is sent from here — the portal asks for the confirmation at the next sign-in, and a mail an operator did not knowingly send is a surprise. Three addresses are refused: one belonging to an operator (R21 — no index spans the two tables, so it is checked), one belonging to another customer, and one belonging to somebody else's user row, which can outlive or predate a matching customer. Not offered here, each for its own reason: `status`, because suspending has consequences for access and a lifecycle switch hidden among address fields is one somebody flips by accident; `stripe_customer_id`, because a wrong one does not move a contract, it points our records at somebody else's payments; the brand fields, because the customer sets those for themselves and an operator overwriting a logo is not a correction. There is a test that fails if any of them appears — with the comments stripped first, since the class documents at length why they are absent and that documentation is not the offence. This repository has fallen into that trap three times. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
57c6912987 |
Say that a click landed, and make moving one actually move it
tests / pest (push) Failing after 7m52s
Details
tests / assets (push) Successful in 20s
Details
tests / release (push) Has been skipped
Details
"Man klickt und sieht nicht was passiert." Every action in this console is a round trip, and a round trip with no sign of itself reads as a click that missed — so the answer is in three places, from the general to the specific. A thin bar at the top of the window for any Livewire request, in app.js. Delayed by 140 ms, because a request that answers faster than that is perceived as immediate and a bar flashing on every keystroke of a .live field is worse than none. It creeps to 70 % and only completes on the answer: a bar that reaches the end while the answer is still on its way is a lie about the thing it exists to report. Released on failure as well — the request somebody must not be left waiting on is precisely the one that went wrong — and on wire:navigate, which is not a request hook at all and would otherwise leave the bar behind on a page that is gone. Per button: a pressed state on the way down, disabled while its own request is out, scoped with wire:target to that button's own call so a row does not grey itself out because a neighbour is moving. The edit button carries its own spinner, because opening the modal is a request made by the MODAL component — wire:loading on this page never sees it. It clears when the package announces the modal is up, and on a six second timer as well, so a request that never answers cannot leave a row spinning for ever. And the reordering was not only slow to look at, it was wrong. It added or subtracted a fixed amount: with the seeded values ten apart that usually landed right, and sometimes landed on a value another template already had — or on the floor along with three others. Equal sort values fall back to the name, so a click could reorder something else, or nothing, which is indistinguishable from a click that never registered. It swaps with the neighbour now: exactly one place, or nothing at the ends, both of which are what an operator expects to see. Where two shared a value the swap leaves them distinct, or the next click would do nothing again. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
173f05c1bd |
Fix a cancel button that did nothing, and fit the answers on one screen
tests / pest (push) Failing after 10m22s
Details
tests / assets (push) Successful in 20s
Details
tests / release (push) Has been skipped
Details
The cancel button on the template modal was written with Alpine's
$dispatch, which fires a browser DOM event — and wire-elements/modal
listens for a LIVEWIRE event of that name. So it did nothing at all, while
saving worked, which is the combination that makes somebody click twice and
then close the tab. Every other modal in the repo already used
Livewire.dispatch; this one was the odd one out, and nothing would have
caught it, so there is now a scan that fails on the Alpine form. Narrowed
to Alpine handlers deliberately: wire:click="$dispatch('closeModal')" IS
Livewire's own dispatcher and works.
The list was one column of thirteen entries, each carrying a name, a
subject, two clamped lines of body and two full-width buttons — four
screens to find the one you want, with the body preview taking most of the
height while being the least useful part of it. The whole text is one click
away in the modal.
Now: two columns of one-line entries — name and subject, which is what
tells two similar answers apart — with the actions as icons that do not
compete with the name. The create form is folded away behind the button
that creates one, because a form that is always open is a screen of form
between the operator and the thing they came for. And the placeholder list
runs as a grid across the page instead of a tall sidebar that squeezed the
entries into half the window.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
|
|
7fd45b01fb |
Ship the thirteen answers this business actually sends
tests / pest (push) Failing after 8m30s
Details
tests / assets (push) Successful in 19s
Details
tests / release (push) Has been skipped
Details
An empty template list is a feature nobody starts using: writing the first one costs the same as writing the mail, so the operator writes the mail. These are the questions that come up in a managed-Nextcloud business — enquiry received, migration question, migration quote, cloud being prepared, credentials asked for again, storage filling up, package change, incident update, incident resolved, maintenance announced, payment reminder, cancellation confirmed, request closed. Every figure and every date is a gap, never a number. A seeded "innerhalb von 24 Stunden" is a promise put in the operator's mouth that nobody agreed to, and a seeded price is wrong for every customer but one. Nothing here says a migration is possible before somebody has looked at the source system either — that was the whole point of the wording on the website. There is a test that fails if a shipped template ever grows a duration or an amount. German only, deliberately: these are texts written TO customers, not interface labels, and the audience is this company's customers. An operator serving another market writes their own; the mechanism and the page are already translated. firstOrCreate on the name, so re-running changes nothing and an operator who has edited or retired one keeps their version — a migration must never overwrite text somebody worked on. The rollback deletes only rows still identical to what was seeded. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
623acb3702 |
Release 1.3.37
tests / pest (push) Failing after 8m4s
Details
tests / assets (push) Successful in 25s
Details
tests / release (push) Has been skipped
Details
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |