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