diff --git a/app/Support/Readiness.php b/app/Support/Readiness.php new file mode 100644 index 0000000..183455d --- /dev/null +++ b/app/Support/Readiness.php @@ -0,0 +1,58 @@ + + */ + public static function all(): array + { + return [ + ...BillingChecks::all(), + ]; + } + + /** @return array */ + 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> */ + public static function byGroup(): array + { + $grouped = []; + + foreach (self::all() as $check) { + $grouped[$check->group][] = $check; + } + + return $grouped; + } +} diff --git a/app/Support/Readiness/BillingChecks.php b/app/Support/Readiness/BillingChecks.php new file mode 100644 index 0000000..10dc061 --- /dev/null +++ b/app/Support/Readiness/BillingChecks.php @@ -0,0 +1,84 @@ + */ + 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(), + ), + ]; + } +} diff --git a/app/Support/Readiness/Check.php b/app/Support/Readiness/Check.php new file mode 100644 index 0000000..4096906 --- /dev/null +++ b/app/Support/Readiness/Check.php @@ -0,0 +1,33 @@ +severity === self::SEVERITY_BLOCKING && ! $this->satisfied; + } +} diff --git a/lang/de/readiness.php b/lang/de/readiness.php new file mode 100644 index 0000000..d3d56ac --- /dev/null +++ b/lang/de/readiness.php @@ -0,0 +1,26 @@ + [ + '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.', + ], +]; diff --git a/lang/en/readiness.php b/lang/en/readiness.php new file mode 100644 index 0000000..74b3d06 --- /dev/null +++ b/lang/en/readiness.php @@ -0,0 +1,26 @@ + [ + '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.', + ], +]; diff --git a/tests/Feature/Readiness/BillingChecksTest.php b/tests/Feature/Readiness/BillingChecksTest.php new file mode 100644 index 0000000..04fbb2a --- /dev/null +++ b/tests/Feature/Readiness/BillingChecksTest.php @@ -0,0 +1,56 @@ +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(); +});