CluPilotCloud/app/Support/Readiness/BillingChecks.php

139 lines
6.6 KiB
PHP

<?php
namespace App\Support\Readiness;
use App\Models\InvoiceSeries;
use App\Models\PlanPrice;
use App\Services\Secrets\SecretVault;
use App\Support\CompanyProfile;
use App\Support\OperatingMode;
use App\Support\StripeCatalogueMode;
use App\Support\StripeWebhookSecret;
/**
* Was gesetzt sein muss, damit überhaupt ein Auftrag entstehen und ein Beleg
* herauskommen kann.
*/
final class BillingChecks
{
public const GROUP = 'billing';
/** @return array<int, Check> */
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.
$wrongAccount = (clone $published)->whereNotNull('stripe_price_id')->exists()
&& ! StripeCatalogueMode::matchesActiveMode();
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 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).
breaks: $wrongAccount
? __('readiness.billing.catalogue_account_breaks', [
'mode' => __('readiness.mode.'.$mode->value),
])
: __('readiness.billing.catalogue_synced_breaks'),
tab: 'services',
satisfied: ! $unsyncedPrices && ! $wrongAccount,
),
];
}
}