From 26cf1a72a0a09a5be132dd1cf17a9844d4355dd5 Mon Sep 17 00:00:00 2001 From: nexxo Date: Thu, 30 Jul 2026 17:12:56 +0200 Subject: [PATCH] Remember which Stripe account the catalogue was built in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mode switches the credentials. It does not switch what was made from them: a Price id and a Product id belong to the account that issued them, and test and live are two accounts. Every credential got two slots on this branch; the ids derived from them sit in single-valued columns. So the planned sequence of this installation ended in the exact false green this page exists to rule out — sync in test, store the live key, switch, and billing.catalogue_synced went on reporting satisfied because it only asked whether the column was filled. "Bereit für Livebetrieb", and the first real order got "No such price". Detection, not repair. Stripe does not put the account in the id — prod_… and price_… look the same in both, only KEYS carry _test_/_live_ — so the origin cannot be read back out of a stored id, and asking Stripe is out: this page reaches nothing over the network on a page load. What is left is to write it down at sync time, which is what App\Support\StripeCatalogueMode does. One setting for the whole catalogue is only honest because stripe:sync-catalogue now REFUSES a run into a catalogue that belongs to the other account. Without that, the run would skip every row that already carries an id, answer "already in step", and record an account it never touched — the same lie one level down. The registers count as stored objects too: inStep() takes a registered row as proof on its own for the reverse-charge half. The `breaks` sentence says what happens (checkout fails, no order) and what actually helps, including the awkward half: re-running the sync is not enough, the pointers and both registers have to be cleared first. The slot migration backfills the one case it can prove: whatever is at Stripe was made with the one key this installation has ever stored, so it belongs to the account that key opens. Otherwise a long-synced catalogue would read as "origin unknown" and the page would demand a re-sync nobody needs. Red first: ⨯ it does not call the catalogue synced when its ids belong to the other account ⨯ it says the sale is refused and a fresh sync is needed, not that a column is empty ⨯ it records the mode its objects were created in ⨯ it refuses to work into a catalogue that belongs to the other account ⨯ it syncs into the new account once the stale ids are cleared Full suite: 1814 passed, 6357 assertions. Co-Authored-By: Claude Opus 5 --- app/Console/Commands/SyncStripeCatalogue.php | 60 ++++++++++++ app/Support/Readiness/BillingChecks.php | 46 ++++++--- app/Support/StripeCatalogueMode.php | 93 +++++++++++++++++++ ...1_090000_give_every_secret_a_test_slot.php | 16 ++++ lang/de/readiness.php | 6 ++ lang/en/readiness.php | 4 + .../Billing/StripeCatalogueModeTest.php | 72 ++++++++++++++ tests/Feature/Readiness/BillingChecksTest.php | 51 ++++++++++ tests/Feature/SecretSlotMigrationTest.php | 37 ++++++++ 9 files changed, 372 insertions(+), 13 deletions(-) create mode 100644 app/Support/StripeCatalogueMode.php create mode 100644 tests/Feature/Billing/StripeCatalogueModeTest.php diff --git a/app/Console/Commands/SyncStripeCatalogue.php b/app/Console/Commands/SyncStripeCatalogue.php index 839041a..30e8776 100644 --- a/app/Console/Commands/SyncStripeCatalogue.php +++ b/app/Console/Commands/SyncStripeCatalogue.php @@ -11,6 +11,8 @@ use App\Services\Billing\AddonPrices; use App\Services\Billing\PlanPrices; use App\Services\Billing\TaxTreatment; use App\Services\Stripe\StripeClient; +use App\Support\OperatingMode; +use App\Support\StripeCatalogueMode; use Illuminate\Console\Command; /** @@ -59,6 +61,14 @@ use Illuminate\Console\Command; * Product for it would be a price list entry for something that may never * exist. * + * **A run belongs to ONE Stripe account, and it says which.** Products and + * Prices are account-bound, and the operating mode picks the key — so the mode + * a run worked in is recorded (App\Support\StripeCatalogueMode) and a run into + * a catalogue that belongs to the other account is refused rather than allowed + * to skip its way to "already in step". Without that, switching to live left + * every stored id pointing at test objects while the readiness page went on + * reporting a synced catalogue, and the first real order met "No such price". + * * **Modules were missing from this entirely**, which is why a booked module was * charged in the month it was booked and never again: nothing in Stripe existed * to put on the subscription. They are pushed at today's catalogue price on both @@ -83,6 +93,17 @@ class SyncStripeCatalogue extends Command return self::FAILURE; } + if (StripeCatalogueMode::hasStoredObjects() && ! StripeCatalogueMode::matchesActiveMode()) { + $this->error($this->staleCatalogueMessage()); + + // Auch der Trockenlauf endet hier, und mit FAILURE: er zählt, was + // FEHLT, und in diesem Zustand fehlt nichts — jede Zeile trägt + // schon eine ID, nur eine aus dem falschen Konto. „0 object(s) + // would be created" wäre die beruhigende Antwort auf die + // gefährliche Lage. + return self::FAILURE; + } + $created = 0; foreach (PlanFamily::query()->with('versions.prices')->orderBy('tier')->get() as $family) { @@ -122,6 +143,15 @@ class SyncStripeCatalogue extends Command $created += $this->syncModules($dryRun); + // Wessen Konto die IDs gehören, die jetzt in der Datenbank stehen. + // Erst HIER, und nur nach einem echten Lauf: der Trockenlauf legt + // nichts an, und die Weigerung oben ist es, die diese Zeile wahr hält — + // ohne sie könnte ein Lauf einen halb gemischten Katalog hinterlassen, + // über den ein einzelner Wert dann lügen müsste. + if (! $dryRun) { + StripeCatalogueMode::record(); + } + $this->newLine(); if ($created === 0) { @@ -137,6 +167,36 @@ class SyncStripeCatalogue extends Command return self::SUCCESS; } + /** + * Why a run is refused outright rather than left to skip its way through. + * + * A Stripe Product and a Stripe Price belong to the ACCOUNT that issued + * them, and a test account and a live account are two accounts. This + * command skips every row that already carries an id (PlanPrices::inStep() + * asks the register, not Stripe), so a run after the switch would report + * "already in step" and change nothing — while recording that the + * catalogue now belongs to the account it never touched. + * + * The registers have to go too, not only the two columns: inStep() takes + * a registered row as proof on its own for the reverse-charge half, so a + * cleared pointer with the register left behind would leave exactly that + * half pointing at the old account, and nothing would say so. + */ + private function staleCatalogueMessage(): string + { + $recorded = StripeCatalogueMode::recorded(); + + return sprintf( + 'The stored catalogue was created in the [%s] account; this installation is now in [%s] mode. ' + .'Stripe objects belong to the account that issued them, and this command skips every row that ' + .'already carries an id — so it cannot repair this by running again. Clear ' + .'plan_families.stripe_product_id, plan_prices.stripe_price_id and the stripe_plan_prices / ' + .'stripe_addon_prices registers first, then run it. Nothing was created.', + $recorded?->value ?? 'unknown', + OperatingMode::current()->value, + ); + } + /** * The Stripe Prices one priced catalogue row is sold on, brought into step. * diff --git a/app/Support/Readiness/BillingChecks.php b/app/Support/Readiness/BillingChecks.php index fdbb9a6..1fdc041 100644 --- a/app/Support/Readiness/BillingChecks.php +++ b/app/Support/Readiness/BillingChecks.php @@ -7,6 +7,7 @@ use App\Models\PlanPrice; use App\Services\Secrets\SecretVault; use App\Support\CompanyProfile; use App\Support\OperatingMode; +use App\Support\StripeCatalogueMode; use App\Support\StripeWebhookSecret; /** @@ -35,6 +36,27 @@ final class BillingChecks $keyMode = OperatingMode::ofStripeKey($secret); $wrongSlot = $keyMode !== null && $keyMode !== $mode; + // 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. + $published = PlanPrice::query() + ->whereHas('version', fn ($query) => $query->whereNotNull('published_at')); + + $unsyncedPrices = (clone $published)->whereNull('stripe_price_id')->exists(); + + // Eine gefüllte Spalte beweist nur, dass IRGENDEIN Konto diese ID + // ausgestellt hat. Welches, steht seit dem Abgleich in einer eigenen + // Zeile (StripeCatalogueMode) — ohne sie meldete diese Prüfung nach + // dem Umschalten auf Live weiter „erfüllt", und die erste echte + // Bestellung bekam von Stripe *No such price*. Ohne Netzverkehr + // entschieden: die Bereitschaftsseite erreicht beim Aufruf nichts. + $wrongAccount = (clone $published)->whereNotNull('stripe_price_id')->exists() + && ! StripeCatalogueMode::matchesActiveMode(); + return [ new Check( key: 'billing.stripe_secret', @@ -98,20 +120,18 @@ final class BillingChecks group: self::GROUP, severity: Check::SEVERITY_BLOCKING, label: __('readiness.billing.catalogue_synced'), - breaks: __('readiness.billing.catalogue_synced_breaks'), + // Der Widerspruch zuerst: „noch nicht abgeglichen" wäre über + // einen Katalog, der abgeglichen IST — nur im anderen Konto — + // die falsche Anweisung, und es ist die teure Richtung + // (der Betreiber ruft den Befehl, bekommt „already in step" + // und glaubt, es sei erledigt). + breaks: $wrongAccount + ? __('readiness.billing.catalogue_account_breaks', [ + 'mode' => __('readiness.mode.'.$mode->value), + ]) + : __('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(), + satisfied: ! $unsyncedPrices && ! $wrongAccount, ), ]; } diff --git a/app/Support/StripeCatalogueMode.php b/app/Support/StripeCatalogueMode.php new file mode 100644 index 0000000..c2e6607 --- /dev/null +++ b/app/Support/StripeCatalogueMode.php @@ -0,0 +1,93 @@ +value); + } + + /** + * Gehört der gespeicherte Katalog zum Konto des aktiven Modus? + * + * „Noch nie abgeglichen" (null) ist hier bewusst KEINE Übereinstimmung: eine + * Installation, in der IDs liegen, über deren Herkunft nichts festgehalten + * ist, kann nicht behaupten, sie gehörten zum aktiven Konto. Die Migration, + * die die Plätze einführt, trägt für den einen Fall nach, den sie belegen + * kann (der vorhandene Schlüssel ist der einzige, mit dem je etwas angelegt + * worden sein kann). + */ + public static function matchesActiveMode(): bool + { + return self::recorded() === OperatingMode::current(); + } + + /** + * Liegt überhaupt eine Stripe-Objekt-ID in der Datenbank? + * + * Alle vier Orte, an denen der Abgleich etwas hinterlässt — auch die zwei + * Register, weil PlanPrices::inStep() für den Netto-Preis eines + * Reverse-Charge-Kunden ALLEIN das Register fragt: eine geleerte Spalte in + * `plan_prices` ohne gelöschte Registerzeile ließe genau diese Hälfte des + * Katalogs still auf das alte Konto zeigen. + */ + public static function hasStoredObjects(): bool + { + return PlanFamily::query()->whereNotNull('stripe_product_id')->exists() + || PlanPrice::query()->whereNotNull('stripe_price_id')->exists() + || StripePlanPrice::query()->exists() + || StripeAddonPrice::query()->exists(); + } +} 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 6bb4449..c5ba9b8 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 @@ -3,6 +3,7 @@ use App\Services\Secrets\SecretCipher; use App\Support\OperatingMode; use App\Support\Settings; +use App\Support\StripeCatalogueMode; use Illuminate\Database\Migrations\Migration; use Illuminate\Support\Facades\DB; use Illuminate\Support\Str; @@ -45,6 +46,20 @@ return new class extends Migration // stehen, und die ist live. if ($stripeMode !== null) { Settings::set(OperatingMode::SETTING, $stripeMode->value); + + // Und derselbe Schluss noch einmal, eine Ebene weiter: was bei + // Stripe an Produkten und Preisen angelegt wurde, wurde mit + // GENAU DIESEM Schlüssel angelegt — ein anderer war auf dieser + // Installation nie hinterlegt. Die IDs gehören also dem Konto, das + // dieser Schlüssel öffnet, und das ist ab jetzt festgehalten + // (StripeCatalogueMode). Ohne diesen Nachtrag stünde ein längst + // abgeglichener Katalog als „Herkunft unbekannt" da, und die + // Bereitschaftsseite verlangte einen neuen Abgleich, den niemand + // braucht. Nur wenn wirklich IDs da sind: eine Zeile über einen + // Katalog, den es nicht gibt, wäre eine Behauptung ohne Gegenstand. + if (StripeCatalogueMode::hasStoredObjects()) { + Settings::set(StripeCatalogueMode::SETTING, $stripeMode->value); + } } } @@ -106,5 +121,6 @@ return new class extends Migration } Settings::forget(OperatingMode::SETTING); + Settings::forget(StripeCatalogueMode::SETTING); } }; diff --git a/lang/de/readiness.php b/lang/de/readiness.php index 4fe3bab..2418df3 100644 --- a/lang/de/readiness.php +++ b/lang/de/readiness.php @@ -56,6 +56,12 @@ return [ 'invoice_series_breaks' => 'IssueInvoice bricht beim Ausstellen ab, sobald eine Serie fehlt oder deaktiviert ist. Die Bereitstellung läuft trotzdem durch — der Kunde bekommt eine laufende Cloud ohne Beleg, und der Fehler landet nur im Log.', 'catalogue_synced' => 'Stripe-Katalog abgeglichen', 'catalogue_synced_breaks' => 'Ein geprüfter EU-Firmenkunde kann nicht bestellen, weil sein Netto-Preis bei Stripe fehlt.', + // Sagt, was passiert, und was dagegen hilft — inklusive der + // unbequemen Hälfte: `stripe:sync-catalogue` überspringt jede Zeile, + // die schon eine ID trägt, repariert das also NICHT von selbst. Ein + // Satz, der nur „bitte neu abgleichen" sagte, schickte den Betreiber + // auf einen Befehl, der ihm „already in step" antwortet. + 'catalogue_account_breaks' => 'Die gespeicherten Stripe-Produkt- und Preis-IDs wurden in einem anderen Konto angelegt als dem, das jetzt gilt (:mode) — Stripe-Objekte gehören dem Konto, das sie ausgestellt hat. Jede Bestellung scheitert damit an der Kasse mit „No such price": kein Auftrag, keine Zahlung. Ein neuer Lauf von stripe:sync-catalogue allein genügt nicht, er überspringt jede Zeile, die schon eine ID trägt; erst nach dem Leeren von plan_families.stripe_product_id und plan_prices.stripe_price_id sowie der Register stripe_plan_prices und stripe_addon_prices legt er den Katalog im aktiven Konto neu an.', ], 'onboarding' => [ diff --git a/lang/en/readiness.php b/lang/en/readiness.php index 5d6e5bb..e99f10d 100644 --- a/lang/en/readiness.php +++ b/lang/en/readiness.php @@ -54,6 +54,10 @@ return [ 'invoice_series_breaks' => 'IssueInvoice aborts while issuing whenever a series is missing or switched off. Provisioning still goes through — the customer gets a running cloud with no invoice, and the failure is only written to the log.', '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.', + // Says what happens and what actually helps, including the awkward + // half: stripe:sync-catalogue skips every row that already carries an + // id, so it does not repair this on its own. + 'catalogue_account_breaks' => 'The stored Stripe product and price ids were created in a different account from the one now in force (:mode) — a Stripe object belongs to the account that issued it. Every order then fails at the checkout with "No such price": no order, no payment. Re-running stripe:sync-catalogue is not enough on its own, because it skips every row that already carries an id; only after clearing plan_families.stripe_product_id and plan_prices.stripe_price_id as well as the stripe_plan_prices and stripe_addon_prices registers does it recreate the catalogue in the active account.', ], 'onboarding' => [ diff --git a/tests/Feature/Billing/StripeCatalogueModeTest.php b/tests/Feature/Billing/StripeCatalogueModeTest.php new file mode 100644 index 0000000..dbc468f --- /dev/null +++ b/tests/Feature/Billing/StripeCatalogueModeTest.php @@ -0,0 +1,72 @@ +stripe = new FakeStripeClient; + app()->instance(StripeClient::class, $this->stripe); +}); + +it('records the mode its objects were created in', function () { + OperatingMode::set(OperatingMode::Test); + + $this->artisan('stripe:sync-catalogue')->assertSuccessful(); + + expect(StripeCatalogueMode::recorded())->toBe(OperatingMode::Test) + ->and(StripeCatalogueMode::hasStoredObjects())->toBeTrue(); +}); + +it('records nothing on a dry run, which creates nothing', function () { + OperatingMode::set(OperatingMode::Test); + + $this->artisan('stripe:sync-catalogue', ['--dry-run' => true])->assertSuccessful(); + + expect(StripeCatalogueMode::recorded())->toBeNull(); +}); + +it('refuses to work into a catalogue that belongs to the other account', function () { + OperatingMode::set(OperatingMode::Test); + $this->artisan('stripe:sync-catalogue')->assertSuccessful(); + + OperatingMode::set(OperatingMode::Live); + + $this->artisan('stripe:sync-catalogue')->assertFailed(); + + // Und die Zeile lügt danach nicht: der Katalog gehört weiterhin dem + // Testkonto, weil dieser Lauf nichts angelegt hat. + expect(StripeCatalogueMode::recorded())->toBe(OperatingMode::Test); +}); + +it('syncs into the new account once the stale ids are cleared', function () { + OperatingMode::set(OperatingMode::Test); + $this->artisan('stripe:sync-catalogue')->assertSuccessful(); + + OperatingMode::set(OperatingMode::Live); + + // Was der `breaks`-Satz und die Fehlermeldung dem Betreiber auftragen — + // Zeiger UND Register, weil PlanPrices::inStep() für den Netto-Preis eines + // Reverse-Charge-Kunden allein das Register fragt. + DB::table('plan_families')->update(['stripe_product_id' => null]); + DB::table('plan_prices')->update(['stripe_price_id' => null]); + DB::table('stripe_plan_prices')->delete(); + DB::table('stripe_addon_prices')->delete(); + + $this->artisan('stripe:sync-catalogue')->assertSuccessful(); + + expect(StripeCatalogueMode::recorded())->toBe(OperatingMode::Live) + ->and(PlanPrice::query()->whereNotNull('stripe_price_id')->exists())->toBeTrue(); +}); diff --git a/tests/Feature/Readiness/BillingChecksTest.php b/tests/Feature/Readiness/BillingChecksTest.php index 1fd88e7..1ae7461 100644 --- a/tests/Feature/Readiness/BillingChecksTest.php +++ b/tests/Feature/Readiness/BillingChecksTest.php @@ -9,6 +9,7 @@ use App\Support\OperatingMode; use App\Support\Readiness; use App\Support\Readiness\Check; use App\Support\Settings; +use App\Support\StripeCatalogueMode; /** * Die Bereitschaftsprüfung berichtet, was fehlt — und sagt dazu, was @@ -137,5 +138,55 @@ it('does not raise a permanent alarm for a draft version stripe never touches', ]); $draft->prices()->create(['term' => 'monthly', 'amount_cents' => 1000, 'currency' => 'EUR']); + // Der Abgleich hält seit dieser Runde fest, in welchem Konto er die IDs + // angelegt hat; ohne diese Zeile wäre der Katalog oben von unbekannter + // Herkunft und die Prüfung meldete zu Recht einen Widerspruch. + StripeCatalogueMode::record(); + + expect(checkFor('billing.catalogue_synced')->satisfied)->toBeTrue(); +}); + +/** + * Der Modus schaltet die Zugangsdaten um — nicht die Objekte, die aus ihnen + * entstanden sind. + * + * Eine Stripe-Price-ID gehört dem Konto, das sie ausgestellt hat. Der geplante + * Ablauf dieser Installation war: im Testbetrieb abgleichen, Live-Schlüssel + * hinterlegen, umschalten — und diese Prüfung meldete weiter „erfüllt", weil + * sie nur nachsah, ob die Spalte gefüllt ist. Die Seite sagte „Bereit für + * Livebetrieb", und die erste echte Bestellung bekam von Stripe *No such + * price*. Kein Auftrag, und die Kernzusage dieser Seite gebrochen in genau dem + * Moment, für den sie existiert. + */ +it('does not call the catalogue synced when its ids belong to the other account', function () { + PlanPrice::query()->update(['stripe_price_id' => 'price_from_the_test_account']); + StripeCatalogueMode::record(OperatingMode::Test); + + OperatingMode::set(OperatingMode::Live); + + expect(checkFor('billing.catalogue_synced')->satisfied)->toBeFalse(); + expect(checkFor('billing.catalogue_synced')->severity)->toBe(Check::SEVERITY_BLOCKING); +}); + +it('says the sale is refused and a fresh sync is needed, not that a column is empty', function () { + PlanPrice::query()->update(['stripe_price_id' => 'price_from_the_test_account']); + StripeCatalogueMode::record(OperatingMode::Test); + + OperatingMode::set(OperatingMode::Live); + + // Der Satz für „nie abgeglichen" wäre hier falsch: abgeglichen wurde, nur + // in einem anderen Konto — und ein blosser neuer Lauf repariert das NICHT, + // weil stripe:sync-catalogue jede Zeile überspringt, die schon eine ID + // trägt. Das muss dastehen, sonst schickt die Seite den Betreiber auf einen + // Befehl, der ihm „already in step" antwortet. + expect(checkFor('billing.catalogue_synced')->breaks) + ->not->toBe(__('readiness.billing.catalogue_synced_breaks')); +}); + +it('is satisfied again once the catalogue was synced in the mode that is running', function () { + PlanPrice::query()->update(['stripe_price_id' => 'price_from_the_live_account']); + OperatingMode::set(OperatingMode::Live); + StripeCatalogueMode::record(); + expect(checkFor('billing.catalogue_synced')->satisfied)->toBeTrue(); }); diff --git a/tests/Feature/SecretSlotMigrationTest.php b/tests/Feature/SecretSlotMigrationTest.php index 4af4a8e..60877a5 100644 --- a/tests/Feature/SecretSlotMigrationTest.php +++ b/tests/Feature/SecretSlotMigrationTest.php @@ -1,8 +1,10 @@ toBe(OperatingMode::Live); }); +/** + * Der Katalog, der schon bei Stripe liegt, bekommt seine Herkunft mit. + * + * Ein Stripe-Produkt und ein Stripe-Preis gehören dem Konto, das sie + * ausgestellt hat. Auf dieser Installation kann das nur das Konto sein, das + * der eine hinterlegte Schlüssel öffnet — ein zweiter war nie da. Ohne diesen + * Nachtrag stünde ein längst abgeglichener Katalog nach der Migration als + * „Herkunft unbekannt" da, und die Bereitschaftsseite verlangte einen neuen + * Abgleich, den niemand braucht. + */ +it('files the already synced catalogue under the account that key opens', function () { + DB::table('app_secrets')->insert([ + 'key' => 'stripe.secret', + 'value' => app(SecretCipher::class)->encrypt('sk_test_abc'), + 'created_at' => now(), 'updated_at' => now(), + ]); + PlanPrice::query()->limit(1)->update(['stripe_price_id' => 'price_minted_with_that_key']); + + runSlotMigration(); + + expect(StripeCatalogueMode::recorded())->toBe(OperatingMode::Test); +}); + +it('claims no catalogue where none was ever synced', function () { + DB::table('app_secrets')->insert([ + 'key' => 'stripe.secret', + 'value' => app(SecretCipher::class)->encrypt('sk_test_abc'), + 'created_at' => now(), 'updated_at' => now(), + ]); + + runSlotMigration(); + + expect(StripeCatalogueMode::recorded())->toBeNull(); +}); + it('files every other credential in the live slot', function () { DB::table('app_secrets')->insert([ 'key' => 'dns.token',