diff --git a/app/Services/Billing/AddonPrices.php b/app/Services/Billing/AddonPrices.php index acabe91..e2aa6db 100644 --- a/app/Services/Billing/AddonPrices.php +++ b/app/Services/Billing/AddonPrices.php @@ -318,17 +318,14 @@ final class AddonPrices // nothing to correct here beyond letting the first row stand. // // What keeps two ROWS off one Price id is the `claimed` callback in - // ensure(), asked before minting — not this table. Today - // `stripe_addon_prices` is unique on (addon_key, reverse_charge, - // amount_cents, currency, interval) only; nothing here refuses a - // second row for a Price id another row already claims. (That unique - // index is `stripe_plan_prices.stripe_price_id`, on the PLAN side — - // easy to mistake for a guarantee this table shares, and it does - // not.) 2026_07_31_210000_one_row_per_stripe_price adds a matching - // index here, as a net under `claimed` rather than instead of it: - // two rows sharing one Price is worse than a duplicate, because - // archiving one — the ordinary response to a superseded figure — - // would then withdraw the very Price the other row is still + // ensure(), asked before minting. The unique index on + // `stripe_price_id` (`stripe_addon_prices_price_unique`, since + // 2026_07_31_210000_one_row_per_stripe_price, matching + // `stripe_plan_prices.stripe_price_id` on the plan side) is the net + // UNDER that callback, not a substitute for it — two rows sharing one + // Price would be worse than the ordinary duplicate this catch + // handles: archiving one — the ordinary response to a superseded + // figure — would then withdraw the very Price the other row is still // selling. } } diff --git a/database/migrations/2026_07_31_210000_one_row_per_stripe_price.php b/database/migrations/2026_07_31_210000_one_row_per_stripe_price.php new file mode 100644 index 0000000..ba3cc10 --- /dev/null +++ b/database/migrations/2026_07_31_210000_one_row_per_stripe_price.php @@ -0,0 +1,60 @@ +select('stripe_price_id') + ->groupBy('stripe_price_id') + ->havingRaw('count(*) > 1') + ->pluck('stripe_price_id'); + + foreach ($shared as $priceId) { + // The lowest id stays — the row that was written first, which is the + // one anything already billing is likeliest to have been reading. + $keep = DB::table('stripe_addon_prices')->where('stripe_price_id', $priceId)->min('id'); + + DB::table('stripe_addon_prices') + ->where('stripe_price_id', $priceId) + ->where('id', '!=', $keep) + ->delete(); + } + + Schema::table('stripe_addon_prices', function (Blueprint $table) { + $table->unique('stripe_price_id', 'stripe_addon_prices_price_unique'); + }); + } + + public function down(): void + { + Schema::table('stripe_addon_prices', function (Blueprint $table) { + $table->dropUnique('stripe_addon_prices_price_unique'); + }); + } +}; diff --git a/docs/handoffs/2026-07-30-real-run-handoff.md b/docs/handoffs/2026-07-30-real-run-handoff.md index 8f51e21..3364113 100644 --- a/docs/handoffs/2026-07-30-real-run-handoff.md +++ b/docs/handoffs/2026-07-30-real-run-handoff.md @@ -172,8 +172,11 @@ Neuer Schritt zwischen `SecureHostFirewall` und `CompleteHostOnboarding`: - `VerifyVmTemplate` prüft nur **Existenz**, nicht `template: 1`. - `applyFirewall()` setzt **nicht** `firewall=1` an `net0` der VM — ohne das greifen die Gastregeln trotz Datacenter-Firewall nicht. Prüfen, ob die Vorlage es mitbringt. -- `stripe_addon_prices.stripe_price_id` ist **nicht** eindeutig (Plan-Seite schon): - zwei Zeilen könnten einen Preis teilen, Archivieren würde den anderen mitentziehen. +- ~~`stripe_addon_prices.stripe_price_id` ist **nicht** eindeutig (Plan-Seite schon): + zwei Zeilen könnten einen Preis teilen, Archivieren würde den anderen mitentziehen.~~ + **Erledigt** — eindeutig seit `2026_07_31_210000_one_row_per_stripe_price`; zugleich + das Netz unter dem Wiedererkennungsschritt, siehe + `docs/superpowers/specs/2026-07-30-stripe-price-adoption-design.md`. - `clupilot:end-due-services` schließt den **Vertrag** nicht — ein geschenkter Vertrag bleibt nach Ende der Instanz für immer `active` und zählt in Umsatz/Dashboard mit. - Eine Abbuchung, die **vor** dem Widerruf entstand und **danach** bezahlt wird, diff --git a/tests/Feature/Billing/StripePriceAdoptionTest.php b/tests/Feature/Billing/StripePriceAdoptionTest.php index f5c3caa..54336c1 100644 --- a/tests/Feature/Billing/StripePriceAdoptionTest.php +++ b/tests/Feature/Billing/StripePriceAdoptionTest.php @@ -12,6 +12,7 @@ use App\Services\Billing\TaxTreatment; use App\Services\Stripe\FakeStripeClient; use App\Services\Stripe\StripeClient; use Illuminate\Contracts\Console\Kernel; +use Illuminate\Database\UniqueConstraintViolationException; use Illuminate\Support\Facades\Log; /** @@ -450,3 +451,19 @@ it('does not adopt a package price that only proves the family, not this row', f ->and(count($this->stripe->prices))->toBe($before + 1) ->and($this->stripe->archived)->not->toContain('price_family_only'); }); + +it('lets no two module rows claim one stripe price', function () { + $shared = [ + 'addon_key' => 'priority_support', 'net_cents' => 2900, 'currency' => 'EUR', + 'stripe_product_id' => 'prod_support', 'stripe_price_id' => 'price_shared', + ]; + + StripeAddonPrice::query()->create([...$shared, + 'reverse_charge' => false, 'amount_cents' => 3480, 'interval' => 'month']); + + // Two rows on one Price is what Block D warned about: archiving the one + // would withdraw the Price the other is still selling. + expect(fn () => StripeAddonPrice::query()->create([...$shared, + 'reverse_charge' => true, 'amount_cents' => 2900, 'interval' => 'month'])) + ->toThrow(UniqueConstraintViolationException::class); +});