diff --git a/app/Services/Stripe/StripeCheck.php b/app/Services/Stripe/StripeCheck.php
index ac0338e..a9d953e 100644
--- a/app/Services/Stripe/StripeCheck.php
+++ b/app/Services/Stripe/StripeCheck.php
@@ -3,6 +3,7 @@
namespace App\Services\Stripe;
use App\Services\Secrets\SecretVault;
+use App\Support\OperatingMode;
use Illuminate\Support\Facades\Http;
use Throwable;
@@ -50,7 +51,11 @@ final class StripeCheck
'business' => $account->json('settings.dashboard.display_name') ?: $account->json('business_profile.name'),
// Stripe does not label the key; the mode is inferred from the
// prefix, which is the only thing that is true of both key kinds.
- 'live' => str_starts_with($secret, 'sk_live_') || str_starts_with($secret, 'rk_live_'),
+ // Asked of OperatingMode, not spelled out again here: the same
+ // question decides which slot the migration files a key into and
+ // whether the readiness page reports a key sitting in the wrong
+ // one. Unrecognisable (null) stays false, exactly as before.
+ 'live' => OperatingMode::ofStripeKey($secret) === OperatingMode::Live,
'restricted' => str_starts_with($secret, 'rk_'),
'webhooks' => $this->webhooks($secret),
];
@@ -63,7 +68,7 @@ final class StripeCheck
* valid while the endpoint listens for the wrong five events, and nothing
* fails until a payment is not recorded.
*
- * @return array>|null null when the key may not read them
+ * @return array>|null null when the key may not read them
*/
private function webhooks(string $secret): ?array
{
diff --git a/app/Support/OperatingMode.php b/app/Support/OperatingMode.php
index 72afaca..d14d0a0 100644
--- a/app/Support/OperatingMode.php
+++ b/app/Support/OperatingMode.php
@@ -34,6 +34,37 @@ enum OperatingMode: string
Settings::set(self::SETTING, $mode->value);
}
+ /**
+ * Zu welchem Modus ein Stripe-Schlüssel gehört — am Präfix, ohne Netzverkehr.
+ *
+ * Stripe beschriftet den Schlüssel nicht; das Präfix ist das Einzige, was
+ * bei beiden Schlüsselarten (`sk_`, und der eingeschränkte `rk_`) wahr ist.
+ * Das reicht, und es muss reichen: die Bereitschaftsseite darf beim
+ * Seitenaufruf nichts erreichen, und die Migration läuft, bevor irgendein
+ * Dienst antwortet.
+ *
+ * `null` heißt „nicht entscheidbar", nicht „live". Wer daraus eine
+ * Vorgabe braucht, sagt sie an seiner Stelle selbst — die Migration setzt
+ * `?? Live` (der Zustand vor ihr, also Fortsetzung statt neuer Behauptung),
+ * die Bereitschaftsprüfung meldet dagegen NUR einen Widerspruch, den sie
+ * beweisen kann.
+ *
+ * Diese eine Stelle, weil dieselbe Frage vorher an drei Orten unabhängig
+ * beantwortet wurde: in der Migration, in StripeCheck und — gar nicht — auf
+ * der Bereitschaftsseite. Drei Formulierungen einer Frage sind der Weg, auf
+ * dem sie auseinanderlaufen.
+ */
+ public static function ofStripeKey(?string $secret): ?self
+ {
+ $secret = trim((string) $secret);
+
+ return match (true) {
+ str_starts_with($secret, 'sk_test_'), str_starts_with($secret, 'rk_test_') => self::Test,
+ str_starts_with($secret, 'sk_live_'), str_starts_with($secret, 'rk_live_') => self::Live,
+ default => null,
+ };
+ }
+
public function isTest(): bool
{
return $this === self::Test;
diff --git a/app/Support/Readiness/BillingChecks.php b/app/Support/Readiness/BillingChecks.php
index 461ae36..fdbb9a6 100644
--- a/app/Support/Readiness/BillingChecks.php
+++ b/app/Support/Readiness/BillingChecks.php
@@ -22,18 +22,33 @@ final class BillingChecks
{
$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)]),
- breaks: __('readiness.billing.stripe_secret_breaks'),
+ // 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',
- // 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')),
+ satisfied: filled($secret) && ! $wrongSlot,
),
new Check(
key: 'billing.webhook_secret',
diff --git a/database/migrations/2026_08_01_090000_give_every_secret_a_test_slot.php b/database/migrations/2026_08_01_090000_give_every_secret_a_test_slot.php
index dec975a..6bb4449 100644
--- a/database/migrations/2026_08_01_090000_give_every_secret_a_test_slot.php
+++ b/database/migrations/2026_08_01_090000_give_every_secret_a_test_slot.php
@@ -59,7 +59,12 @@ return new class extends Migration
return OperatingMode::Live;
}
- return Str::contains($secret, '_test_') ? OperatingMode::Test : OperatingMode::Live;
+ // Die Präfixregel steht in OperatingMode und wird hier gerufen, nicht
+ // wiederholt: dieselbe Frage entscheidet auf der Bereitschaftsseite, ob
+ // ein Schlüssel im falschen Platz liegt, und in StripeCheck, was der
+ // Konsole als Kontoart gemeldet wird. `?? Live` ist wie oben der
+ // Status quo für einen Wert, der sich nicht zuordnen lässt.
+ return OperatingMode::ofStripeKey($secret) ?? OperatingMode::Live;
}
public function down(): void
diff --git a/lang/de/readiness.php b/lang/de/readiness.php
index 0db7b84..4fe3bab 100644
--- a/lang/de/readiness.php
+++ b/lang/de/readiness.php
@@ -29,6 +29,8 @@ return [
'run_check' => 'Prüfen',
'check_ok' => 'Geprüft: in Ordnung',
'check_failed' => 'Geprüft: nicht in Ordnung',
+ 'check_key_mode' => 'Der Schlüssel gehört zum Konto für :mode.',
+ 'check_key_mode_conflict' => 'Diese Installation läuft aber im :mode — der Schlüssel liegt im falschen Platz.',
'fix_link' => 'Beheben',
'last_heartbeat' => 'Letzter Herzschlag: :when',
@@ -40,6 +42,12 @@ return [
'billing' => [
'stripe_secret' => 'Stripe-Schlüssel (:mode)',
'stripe_secret_breaks' => 'Ohne ihn nimmt die Kasse keine Bestellung an — es entsteht gar kein Auftrag.',
+ // Zwei getrennte Sätze, weil die Folgen entgegengesetzt sind: einmal
+ // fließt echtes Geld, das nicht fließen sollte, einmal fließt keines,
+ // das fließen sollte. Ein gemeinsamer Satz müsste beides andeuten und
+ // sagte am Ende keins von beidem.
+ 'stripe_secret_test_slot_breaks' => 'Im Testplatz liegt ein Live-Schlüssel. Jede Bestellung bucht damit echtes Geld ab, während diese Seite und die Konsole „Testbetrieb" anzeigen — ein Probekauf ist dann ein echter Kauf, mit echter Karte und echter Abbuchung.',
+ 'stripe_secret_live_slot_breaks' => 'Im Live-Platz liegt ein Testschlüssel. Der Kunde durchläuft die Kasse, es wird nie Geld eingezogen, und die Bestellung sieht trotzdem bezahlt aus — die Bereitstellung läuft, der Beleg wird ausgestellt, und auf dem Konto kommt nichts an.',
'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',
diff --git a/lang/en/readiness.php b/lang/en/readiness.php
index f23b629..5d6e5bb 100644
--- a/lang/en/readiness.php
+++ b/lang/en/readiness.php
@@ -29,6 +29,8 @@ return [
'run_check' => 'Check',
'check_ok' => 'Checked: OK',
'check_failed' => 'Checked: not OK',
+ 'check_key_mode' => 'This key belongs to the :mode account.',
+ 'check_key_mode_conflict' => 'This installation is running in :mode — the key is in the wrong slot.',
'fix_link' => 'Fix',
'last_heartbeat' => 'Last heartbeat: :when',
@@ -40,6 +42,10 @@ return [
'billing' => [
'stripe_secret' => 'Stripe key (:mode)',
'stripe_secret_breaks' => 'Without it the checkout accepts no order at all — no order is ever created.',
+ // Two separate sentences, because the consequences are opposite ones:
+ // real money moving that should not, and no money moving that should.
+ 'stripe_secret_test_slot_breaks' => 'The test slot holds a live key. Every order charges real money while this page and the console say "test mode" — a trial purchase is a real purchase, on a real card.',
+ 'stripe_secret_live_slot_breaks' => 'The live slot holds a test key. The customer completes checkout, no money is ever taken, and the order still looks paid — provisioning runs, the invoice is issued, and nothing arrives in the account.',
'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',
diff --git a/resources/views/livewire/admin/readiness.blade.php b/resources/views/livewire/admin/readiness.blade.php
index 0797b64..07382c1 100644
--- a/resources/views/livewire/admin/readiness.blade.php
+++ b/resources/views/livewire/admin/readiness.blade.php
@@ -60,6 +60,22 @@
— {{ $result['reason'] }}
@endif
+
+ {{-- StripeCheck rechnet die Kontoart des Schlüssels längst aus,
+ und diese Seite hat sie bislang weggeworfen: wer „Prüfen"
+ drückte, las „Geprüft: in Ordnung" über einen Live-Schlüssel
+ im Testplatz. Die Prüfung selbst blockiert dafür jetzt
+ (BillingChecks); hier steht, was der Knopf gerade gesehen
+ hat, damit er ihr nicht widerspricht. --}}
+ @if (array_key_exists('live', $result))
+ @php $keyMode = $result['live'] ? 'live' : 'test'; @endphp
+
+ {{ __('readiness.check_key_mode', ['mode' => __('readiness.mode.'.$keyMode)]) }}
+ @unless ($keyMode === $mode->value)
+ {{ __('readiness.check_key_mode_conflict', ['mode' => __('readiness.mode.'.$mode->value)]) }}
+ @endunless
+
+ @endif
@endif
diff --git a/tests/Feature/Readiness/BillingChecksTest.php b/tests/Feature/Readiness/BillingChecksTest.php
index 2d80adc..1fd88e7 100644
--- a/tests/Feature/Readiness/BillingChecksTest.php
+++ b/tests/Feature/Readiness/BillingChecksTest.php
@@ -43,6 +43,55 @@ it('does not accept a live key as proof while the test mode is active', function
expect(checkFor('billing.stripe_secret')->satisfied)->toBeFalse();
});
+/**
+ * Ein Schlüssel im falschen Platz ist ein blockierender Befund.
+ *
+ * Gemessen im Schluss-Review: `put('stripe.secret', 'sk_live_REALMONEY')` im
+ * Testbetrieb wird angenommen, `get()` liefert ihn, die Bereitschaftsseite
+ * meldete „erfüllt" — und oben auf derselben Seite steht die Plakette
+ * „Testbetrieb". Das ist derselbe Ausgang, gegen den die strikte Ausnahme im
+ * Tresor gebaut wurde („bucht echtes Geld ab, während die Konsole Testbetrieb
+ * anzeigt"), nur über den eingetippten statt den automatischen Weg.
+ *
+ * Am Präfix ohne Netzverkehr entscheidbar — OperatingMode::ofStripeKey(),
+ * dieselbe Stelle, die auch die Migration und StripeCheck fragen.
+ */
+it('refuses a live key sitting in the test slot', function () {
+ OperatingMode::set(OperatingMode::Test);
+ app(SecretVault::class)->put('stripe.secret', 'sk_live_REALMONEY', Operator::factory()->create(), OperatingMode::Test);
+
+ expect(checkFor('billing.stripe_secret')->satisfied)->toBeFalse();
+ expect(checkFor('billing.stripe_secret')->severity)->toBe(Check::SEVERITY_BLOCKING);
+});
+
+it('refuses a test key sitting in the live slot', function () {
+ // Die andere Richtung kostet kein Geld, sondern nimmt keins ein: der Kunde
+ // zahlt in Stripes Testkonto, und die Bestellung sieht bezahlt aus.
+ OperatingMode::set(OperatingMode::Live);
+ app(SecretVault::class)->put('stripe.secret', 'sk_test_notreal', Operator::factory()->create(), OperatingMode::Live);
+
+ expect(checkFor('billing.stripe_secret')->satisfied)->toBeFalse();
+});
+
+it('accepts the restricted key of the active mode', function () {
+ // rk_ ist ein echter Schlüssel, kein Sonderfall — die Erkennung darf ihn
+ // nicht für einen Widerspruch halten, nur weil er nicht sk_ heißt.
+ OperatingMode::set(OperatingMode::Test);
+ app(SecretVault::class)->put('stripe.secret', 'rk_test_readonly', Operator::factory()->create(), OperatingMode::Test);
+
+ expect(checkFor('billing.stripe_secret')->satisfied)->toBeTrue();
+});
+
+it('says what the wrong key does, not that a field is empty', function () {
+ OperatingMode::set(OperatingMode::Test);
+ app(SecretVault::class)->put('stripe.secret', 'sk_live_REALMONEY', Operator::factory()->create(), OperatingMode::Test);
+
+ // Der Satz für „gar kein Schlüssel" wäre hier eine Lüge: einer liegt da,
+ // er ist der falsche, und die Folge ist eine ganz andere.
+ expect(checkFor('billing.stripe_secret')->breaks)
+ ->not->toBe(__('readiness.billing.stripe_secret_breaks'));
+});
+
it('reports incomplete company details without repeating the list', function () {
Settings::forget('company.name');
diff --git a/tests/Feature/ReadinessPageTest.php b/tests/Feature/ReadinessPageTest.php
index d0a7b36..32a4be5 100644
--- a/tests/Feature/ReadinessPageTest.php
+++ b/tests/Feature/ReadinessPageTest.php
@@ -3,6 +3,7 @@
use App\Livewire\Admin\Readiness as ReadinessPage;
use App\Models\Operator;
use App\Services\Secrets\SecretVault;
+use App\Support\OperatingMode;
use App\Support\Readiness;
use Illuminate\Support\Facades\Http;
use Livewire\Livewire;
@@ -47,6 +48,26 @@ it('runs an active check on demand and shows its answer', function () {
->assertSet('results.provisioning.dns_token.ok', false);
});
+/**
+ * Der „Prüfen"-Knopf darf der Prüfung nicht widersprechen.
+ *
+ * StripeCheck::run() rechnet die Kontoart des Schlüssels aus, die Seite hat
+ * sie verworfen und nur `ok`/`reason` gerendert — ein Live-Schlüssel im
+ * Testplatz antwortet bei Stripe einwandfrei, und die Seite las darüber
+ * „Geprüft: in Ordnung", während die Plakette oben „Testbetrieb" sagte.
+ */
+it('names the account a checked key belongs to, and says when it is the wrong one', function () {
+ Http::fake(['*' => Http::response(['id' => 'acct_1'], 200)]);
+ OperatingMode::set(OperatingMode::Test);
+ app(SecretVault::class)->put('stripe.secret', 'sk_live_REALMONEY', Operator::factory()->create(), OperatingMode::Test);
+
+ Livewire::actingAs(Operator::factory()->role('Owner')->create(), 'operator')
+ ->test(ReadinessPage::class)
+ ->call('runCheck', 'billing.stripe_secret')
+ ->assertSee(__('readiness.check_key_mode', ['mode' => __('readiness.mode.live')]))
+ ->assertSee(__('readiness.check_key_mode_conflict', ['mode' => __('readiness.mode.test')]));
+});
+
it('refuses to run a check that is not on the list', function () {
// Ein Schlüssel aus dem Browser. Ohne diese Prüfung wäre das ein Weg,
// beliebige Klassen aufzurufen.