Remember which Stripe account the catalogue was built in

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 <noreply@anthropic.com>
feature/betriebsmodus
nexxo 2026-07-30 17:12:56 +02:00
parent 823eeaf413
commit 26cf1a72a0
9 changed files with 372 additions and 13 deletions

View File

@ -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.
*

View File

@ -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,
),
];
}

View File

@ -0,0 +1,93 @@
<?php
namespace App\Support;
use App\Models\PlanFamily;
use App\Models\PlanPrice;
use App\Models\StripeAddonPrice;
use App\Models\StripePlanPrice;
/**
* In welchem Stripe-KONTO die gespeicherten Objekt-IDs entstanden sind.
*
* Der Betriebsmodus schaltet die Zugangsdaten um. Er schaltet nicht um, was aus
* ihnen entstanden ist: eine Stripe-Price-ID und eine Product-ID gehören dem
* Konto, das sie ausgestellt hat, und ein Testkonto und ein Livekonto sind zwei
* Konten. Jedes Zugangsdatum hat auf dieser Installation zwei Plätze; die
* daraus abgeleiteten IDs stehen in einwertigen Spalten
* (`plan_families.stripe_product_id`, `plan_prices.stripe_price_id`) und in den
* zwei Registern (`stripe_plan_prices`, `stripe_addon_prices`).
*
* Ohne diese Zeile lief der geplante Ablauf dieser Installation ins Leere:
* abgleichen im Testbetrieb, Live-Schlüssel hinterlegen, umschalten und
* `billing.catalogue_synced` meldete weiter „erfüllt", weil es nur prüfte, ob
* die Spalte gefüllt ist. Die Seite sagte „Bereit für Livebetrieb", und die
* erste echte Bestellung schickte eine Test-Preis-ID an die Live-API: *No such
* price*, kein Auftrag. Genau die falsche Grünmeldung, gegen die diese Seite
* gebaut ist, ausgelöst durch genau den Schalter, den dieses Vorhaben einführt.
*
* **Warum eine Einstellungszeile und nicht die Objekte selbst.** Stripe schreibt
* die Kontoart nicht in die ID: `prod_…` und `price_…` sehen im Test- und im
* Livekonto gleich aus (nur SCHLÜSSEL tragen `_test_`/`_live_` im Präfix das
* ist OperatingMode::ofStripeKey()). Die Herkunft ist aus einer gespeicherten ID
* also nicht ableitbar, und nachfragen dürfte die Bereitschaftsseite nicht: sie
* erreicht beim Seitenaufruf nichts über das Netz. Bleibt: beim Abgleich
* festhalten, in welchem Modus die IDs entstanden sind.
*
* **Warum EINE Zeile für den ganzen Katalog.** Ein Lauf von
* stripe:sync-catalogue benutzt genau einen Schlüssel, also genau ein Konto. Und
* der Befehl weigert sich, in einen Katalog hineinzuarbeiten, der zu einem
* anderen Konto gehört (siehe dort) ohne diese Weigerung könnte ein Lauf
* einen halb gemischten Zustand hinterlassen, über den eine einzelne Zeile dann
* lügen würde.
*/
final class StripeCatalogueMode
{
public const SETTING = 'billing.catalogue_mode';
/** Der Modus, in dem der Katalog zuletzt angelegt wurde — null: noch nie. */
public static function recorded(): ?OperatingMode
{
$stored = Settings::get(self::SETTING);
return $stored === null ? null : OperatingMode::tryFrom((string) $stored);
}
/** Festhalten, wessen Konto die gerade angelegten IDs gehören. */
public static function record(?OperatingMode $mode = null): void
{
Settings::set(self::SETTING, ($mode ?? OperatingMode::current())->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();
}
}

View File

@ -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);
}
};

View File

@ -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' => [

View File

@ -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' => [

View File

@ -0,0 +1,72 @@
<?php
use App\Models\PlanPrice;
use App\Services\Stripe\FakeStripeClient;
use App\Services\Stripe\StripeClient;
use App\Support\OperatingMode;
use App\Support\StripeCatalogueMode;
/**
* Der Abgleich sagt, in wessen Konto er gearbeitet hat und weigert sich, in
* einen Katalog hineinzuarbeiten, der einem anderen gehört.
*
* Eine Stripe-Price-ID gehört dem Konto, das sie ausgestellt hat. Der Befehl
* überspringt jede Zeile, die schon eine ID trägt (PlanPrices::inStep()), also
* repariert ein zweiter Lauf nach dem Umschalten gar nichts er meldete
* „already in step" und ließe eine Zeile zurück, die behauptet, der Katalog
* gehöre jetzt zum Livekonto. Genau diese Lüge macht die Weigerung unnötig.
*/
beforeEach(function () {
$this->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();
});

View File

@ -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();
});

View File

@ -1,8 +1,10 @@
<?php
use App\Models\PlanPrice;
use App\Services\Secrets\SecretCipher;
use App\Support\OperatingMode;
use App\Support\Settings;
use App\Support\StripeCatalogueMode;
use Illuminate\Support\Facades\DB;
/**
@ -54,6 +56,41 @@ it('files a stripe live key in the live slot and leaves the mode alone', functio
expect(OperatingMode::current())->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',