119 lines
5.5 KiB
PHP
119 lines
5.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\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;
|
|
|
|
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'),
|
|
breaks: __('readiness.billing.catalogue_synced_breaks'),
|
|
tab: 'services',
|
|
// 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.
|
|
satisfied: PlanPrice::query()
|
|
->whereHas('version', fn ($query) => $query->whereNotNull('published_at'))
|
|
->whereNull('stripe_price_id')
|
|
->doesntExist(),
|
|
),
|
|
];
|
|
}
|
|
}
|