Say what breaks, not just which field is empty

Task 7: the readiness collector (App\Support\Readiness) and its first
group, billing (App\Support\Readiness\BillingChecks). Reports missing
prerequisites — the active mode's Stripe key, its webhook signing
secret, complete company details, an invoice series per document kind,
a fully Stripe-synced catalogue — without enforcing anything itself;
the real locks (IssueInvoice, the checkout's Stripe-key guard) stay
where they are.

The webhook check uses App\Support\StripeWebhookSecret::current()
rather than querying config() by mode a second time, and the company
check calls CompanyProfile::missingForInvoicing() rather than
duplicating its list — two sources for one question is how they drift
apart.

Added lang/de/readiness.php and lang/en/readiness.php with every
`readiness.*` key this group uses, each `_breaks` sentence naming the
actual downstream failure rather than repeating the field name.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feature/betriebsmodus
nexxo 2026-07-30 12:25:27 +02:00
parent a1e3484fba
commit d9c0ad0e4a
6 changed files with 283 additions and 0 deletions

58
app/Support/Readiness.php Normal file
View File

@ -0,0 +1,58 @@
<?php
namespace App\Support;
use App\Support\Readiness\BillingChecks;
use App\Support\Readiness\Check;
/**
* Jedes Pflichtfeld dieser Installation an einer Stelle, gruppiert nach dem,
* was es blockiert nicht danach, wo der Wert gespeichert ist.
*
* Ein Betreiber, der eine Installation befüllt, denkt in „was kann ich noch
* nicht", nicht in „liegt das im Tresor oder in den Einstellungen".
*
* Diese Klasse SPERRT NICHTS. Die harten Sperren bleiben, wo sie stehen
* IssueInvoice bei unvollständigen Firmendaten, VerifyVmTemplate bei fehlender
* Vorlage, die Kasse bei fehlendem Stripe-Schlüssel. Eine Bereitschaftsseite,
* die selbst sperrt, wäre eine zweite Wahrheit neben diesen Prüfungen.
*/
final class Readiness
{
/**
* Die Gruppen, angehängt. Jede Gruppe kennt nur ihre eigenen
* Abhängigkeiten (Stripe, Proxmox, DNS, Mail) eine Datei, die alle
* Gruppen importiert, wäre in jedem einzelnen Gruppentest die ganze Welt.
*
* @return array<int, Check>
*/
public static function all(): array
{
return [
...BillingChecks::all(),
];
}
/** @return array<int, Check> */
public static function blocking(): array
{
return array_values(array_filter(self::all(), fn (Check $c) => $c->isBlocking()));
}
public static function isReady(): bool
{
return self::blocking() === [];
}
/** @return array<string, array<int, Check>> */
public static function byGroup(): array
{
$grouped = [];
foreach (self::all() as $check) {
$grouped[$check->group][] = $check;
}
return $grouped;
}
}

View File

@ -0,0 +1,84 @@
<?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',
satisfied: InvoiceSeries::query()
->whereIn('kind', ['invoice', 'credit_note', 'cancellation'])
->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',
satisfied: PlanPrice::query()->whereNull('stripe_price_id')->doesntExist(),
),
];
}
}

View File

@ -0,0 +1,33 @@
<?php
namespace App\Support\Readiness;
/**
* Ein Befund der Bereitschaftsprüfung.
*
* `breaks` ist das Feld, an dem diese Seite hängt. Eine Liste von Feldnamen ist
* keine Auskunft sie sagt, was fehlt, nicht was daraus folgt. Am 2026-07-30
* war jedes fehlende Stück in der Konsole unsichtbar, und mehrere brachen die
* Kette erst NACH der Zahlung. Genau das gehört hier hinein.
*/
final class Check
{
public const SEVERITY_BLOCKING = 'blocking';
public const SEVERITY_WARNING = 'warning';
public function __construct(
public string $key,
public string $group,
public string $severity,
public string $label,
public string $breaks,
public string $tab,
public bool $satisfied,
) {}
public function isBlocking(): bool
{
return $this->severity === self::SEVERITY_BLOCKING && ! $this->satisfied;
}
}

26
lang/de/readiness.php Normal file
View File

@ -0,0 +1,26 @@
<?php
/*
* Die Bereitschaftsprüfung (App\Support\Readiness). Jeder Satz unter
* `*_breaks` beantwortet nicht „was fehlt", sondern „was passiert, wenn es
* fehlt bleibt" — siehe den Kopfkommentar von App\Support\Readiness\Check.
*/
return [
'mode' => [
'test' => 'Testbetrieb',
'live' => 'Livebetrieb',
],
'billing' => [
'stripe_secret' => 'Stripe-Schlüssel (:mode)',
'stripe_secret_breaks' => 'Ohne ihn nimmt die Kasse keine Bestellung an — es entsteht gar kein Auftrag.',
'webhook_secret' => 'Stripe-Signaturschlüssel',
'webhook_secret_breaks' => 'Ohne ihn wird eine Zahlung nie verbucht: der Kunde zahlt, und nichts passiert.',
'company_details' => 'Firmendaten vollständig',
'company_details_breaks' => 'IssueInvoice verweigert die Ausstellung. Die Bereitstellung läuft trotzdem durch — der Kunde bekommt eine laufende Cloud ohne Beleg.',
'invoice_series' => 'Rechnungsserie je Belegart',
'invoice_series_breaks' => 'Ein Beleg kann keine Nummer ziehen.',
'catalogue_synced' => 'Stripe-Katalog abgeglichen',
'catalogue_synced_breaks' => 'Ein geprüfter EU-Firmenkunde kann nicht bestellen, weil sein Netto-Preis bei Stripe fehlt.',
],
];

26
lang/en/readiness.php Normal file
View File

@ -0,0 +1,26 @@
<?php
/*
* The readiness check (App\Support\Readiness). Every `*_breaks` sentence
* answers not "what is missing" but "what happens if it stays missing" see
* the header comment of App\Support\Readiness\Check.
*/
return [
'mode' => [
'test' => 'Test mode',
'live' => 'Live mode',
],
'billing' => [
'stripe_secret' => 'Stripe key (:mode)',
'stripe_secret_breaks' => 'Without it the checkout accepts no order at all — no order is ever created.',
'webhook_secret' => 'Stripe signing secret',
'webhook_secret_breaks' => 'Without it a payment is never recorded: the customer pays, and nothing happens.',
'company_details' => 'Company details complete',
'company_details_breaks' => 'IssueInvoice refuses to issue the invoice. Provisioning still goes through — the customer gets a running cloud with no invoice.',
'invoice_series' => 'Invoice series for every document kind',
'invoice_series_breaks' => 'A document cannot draw a number.',
'catalogue_synced' => 'Stripe catalogue in sync',
'catalogue_synced_breaks' => 'A verified EU business customer cannot check out, because their net price is missing at Stripe.',
],
];

View File

@ -0,0 +1,56 @@
<?php
use App\Models\Operator;
use App\Services\Secrets\SecretVault;
use App\Support\OperatingMode;
use App\Support\Readiness;
use App\Support\Readiness\Check;
use App\Support\Settings;
/**
* Die Bereitschaftsprüfung berichtet, was fehlt und sagt dazu, was
* kaputtgeht. Ein Feldname allein ist keine Auskunft: „vat_id fehlt" sagt
* nichts, „der Kunde bekommt keinen Beleg" sagt alles.
*/
function checkFor(string $key): ?Check
{
return collect(Readiness::all())->firstWhere('key', $key);
}
it('reports the stripe key of the active mode as missing', function () {
OperatingMode::set(OperatingMode::Test);
expect(checkFor('billing.stripe_secret')->satisfied)->toBeFalse();
expect(checkFor('billing.stripe_secret')->severity)->toBe(Check::SEVERITY_BLOCKING);
});
it('reports it as satisfied once the key of that mode is stored', function () {
OperatingMode::set(OperatingMode::Test);
app(SecretVault::class)->put('stripe.secret', 'sk_test_x', Operator::factory()->create(), OperatingMode::Test);
expect(checkFor('billing.stripe_secret')->satisfied)->toBeTrue();
});
it('does not accept a live key as proof while the test mode is active', function () {
// Sonst meldete die Seite Bereitschaft für einen Modus, in dem die Kasse
// sperrt — die Seite widerspräche der Sperre aus Task 5.
OperatingMode::set(OperatingMode::Test);
app(SecretVault::class)->put('stripe.secret', 'sk_live_x', Operator::factory()->create(), OperatingMode::Live);
expect(checkFor('billing.stripe_secret')->satisfied)->toBeFalse();
});
it('reports incomplete company details without repeating the list', function () {
Settings::forget('company.name');
expect(checkFor('billing.company_details')->satisfied)->toBeFalse();
});
it('names what breaks, not just what is missing', function () {
expect(checkFor('billing.company_details')->breaks)->not->toBe('');
});
it('says the installation is not ready while something blocking is open', function () {
expect(Readiness::isReady())->toBeFalse();
expect(Readiness::blocking())->not->toBeEmpty();
});