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
parent
a1e3484fba
commit
d9c0ad0e4a
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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.',
|
||||
],
|
||||
];
|
||||
|
|
@ -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.',
|
||||
],
|
||||
];
|
||||
|
|
@ -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();
|
||||
});
|
||||
Loading…
Reference in New Issue