*/ public static function all(): array { $mode = OperatingMode::current(); // get() löst den aktiven Modus selbst auf UND ist für Stripe strikt — // ein Live-Schlüssel im LIVE-Platz zählt im Testbetrieb nicht als // Nachweis, sonst widerspräche diese Seite der Kassensperre. $secret = app(SecretVault::class)->get('stripe.secret'); // Was im Platz des aktiven Modus liegt, kann trotzdem der Schlüssel des // anderen Kontos sein — eingetippt, nicht zurückgefallen. Am Präfix // ohne Netzverkehr entscheidbar; `null` heißt „nicht entscheidbar" und // wird hier bewusst NICHT als Widerspruch gemeldet: diese Prüfung sagt // nur, was sie beweisen kann. $keyMode = OperatingMode::ofStripeKey($secret); $wrongSlot = $keyMode !== null && $keyMode !== $mode; // stripe:sync-catalogue (SyncStripeCatalogue::handle()) only ever // touches PUBLISHED versions — "a draft has promised nothing, and a // Product for it would be a price list entry for something that may // never exist" (its own header comment). A draft's own unsynced price // is therefore not a gap; counting it anyway turns this check into an // alarm that never clears on any installation that has ever drafted a // next version, which stops it being read at all. $published = PlanPrice::query() ->whereHas('version', fn ($query) => $query->whereNotNull('published_at')); $unsyncedPrices = (clone $published)->whereNull('stripe_price_id')->exists(); // Eine gefüllte Spalte beweist nur, dass IRGENDEIN Konto diese ID // ausgestellt hat. Welches, steht seit dem Abgleich in einer eigenen // Zeile (StripeCatalogueMode) — ohne sie meldete diese Prüfung nach // dem Umschalten auf Live weiter „erfüllt", und die erste echte // Bestellung bekam von Stripe *No such price*. Ohne Netzverkehr // entschieden: die Bereitschaftsseite erreicht beim Aufruf nichts. // // ZWEI Fälle, nicht einer. „Steht fest: anderes Konto" verlangt das // Leeren der Zeiger und Register; „Herkunft nie festgehalten" verlangt // nur einen neuen Lauf. Der zweite entsteht ohne jeden Kontowechsel — // ein abgebrochener Abgleich, oder eine Bestellung, die sich ihren // fehlenden Preis selbst geholt hat — und die Leeranweisung wäre dort // die Aufforderung, einen Katalog zu löschen, an dem laufende Verträge // abgerechnet werden. $somethingSynced = (clone $published)->whereNotNull('stripe_price_id')->exists(); $wrongAccount = $somethingSynced && StripeCatalogueMode::belongsToAnotherMode(); $unknownOrigin = $somethingSynced && StripeCatalogueMode::recorded() === null; return [ new Check( key: 'billing.stripe_secret', group: self::GROUP, severity: Check::SEVERITY_BLOCKING, label: __('readiness.billing.stripe_secret', ['mode' => __('readiness.mode.'.$mode->value)]), // Zwei Ausfälle, zwei Folgen — und „ohne ihn nimmt die Kasse // keine Bestellung an" wäre über einen Schlüssel, der DA ist, // schlicht falsch. breaks: $wrongSlot ? __('readiness.billing.stripe_secret_'.$mode->value.'_slot_breaks') : __('readiness.billing.stripe_secret_breaks'), tab: 'services', satisfied: filled($secret) && ! $wrongSlot, ), new Check( key: 'billing.webhook_secret', group: self::GROUP, severity: Check::SEVERITY_BLOCKING, label: __('readiness.billing.webhook_secret'), breaks: __('readiness.billing.webhook_secret_breaks'), tab: 'env', // Die Auswahl nach Modus liegt schon in StripeWebhookSecret — // hier noch einmal per config() nach Modus zu unterscheiden // wäre eine zweite Stelle, die dieselbe Entscheidung trifft. satisfied: filled(StripeWebhookSecret::current()), ), new Check( key: 'billing.company_details', group: self::GROUP, severity: Check::SEVERITY_BLOCKING, label: __('readiness.billing.company_details'), breaks: __('readiness.billing.company_details_breaks'), tab: 'company', // Die vorhandene Liste wird gerufen, nicht verdoppelt: zwei // Quellen für eine Frage ist der Weg, auf dem sie auseinanderlaufen. satisfied: CompanyProfile::missingForInvoicing() === [], ), new Check( key: 'billing.invoice_series', group: self::GROUP, severity: Check::SEVERITY_BLOCKING, label: __('readiness.billing.invoice_series'), breaks: __('readiness.billing.invoice_series_breaks'), tab: 'company', // IssueInvoice draws a series with `where('kind', $kind) // ->where('active', true)` (IssueInvoice.php:373, :444) — a // deactivated row is invisible to it exactly like a missing // one, so the same `active` filter belongs here too. Without // it, an installation with a switched-off 'invoice' series // still counted as ready, the false-green this page exists to // rule out. satisfied: InvoiceSeries::query() ->whereIn('kind', ['invoice', 'credit_note', 'cancellation']) ->where('active', true) ->distinct() ->count('kind') === 3, ), new Check( key: 'billing.catalogue_synced', group: self::GROUP, severity: Check::SEVERITY_BLOCKING, label: __('readiness.billing.catalogue_synced'), // Der bewiesene Widerspruch zuerst: „noch nicht abgeglichen" // wäre über einen Katalog, der abgeglichen IST — nur im anderen // Konto — die falsche Anweisung, und es ist die teure Richtung // (der Betreiber ruft den Befehl, bekommt „already in step" // und glaubt, es sei erledigt). Danach die unbekannte Herkunft, // deren Kur ein Lauf ist und kein Löschen. Zuletzt die Lücke. breaks: match (true) { $wrongAccount => __('readiness.billing.catalogue_account_breaks', [ 'mode' => __('readiness.mode.'.$mode->value), ]), $unknownOrigin => __('readiness.billing.catalogue_origin_breaks'), default => __('readiness.billing.catalogue_synced_breaks'), }, tab: 'services', satisfied: ! $unsyncedPrices && ! $wrongAccount && ! $unknownOrigin, ), ]; } }