CluPilotCloud/app/Support/Readiness/BillingChecks.php

104 lines
4.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\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();
return [
new Check(
key: 'billing.stripe_secret',
group: self::GROUP,
severity: Check::SEVERITY_BLOCKING,
label: __('readiness.billing.stripe_secret', ['mode' => __('readiness.mode.'.$mode->value)]),
breaks: __('readiness.billing.stripe_secret_breaks'),
tab: 'services',
// get() löst den aktiven Modus selbst auf UND ist für Stripe
// strikt — ein Live-Schlüssel zählt im Testbetrieb nicht als
// Nachweis, sonst widerspräche diese Seite der Kassensperre.
satisfied: filled(app(SecretVault::class)->get('stripe.secret')),
),
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(),
),
];
}
}