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',