151 lines
7.5 KiB
PHP
151 lines
7.5 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.
|
|
//
|
|
// 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,
|
|
),
|
|
];
|
|
}
|
|
}
|