One row per Stripe price on the module side too

The plan register has had this since 2026_07_30_110000. The module register
never did, so two rows could claim one Price and archiving the one would have
withdrawn the Price the other was still selling — Block D of the real-run
handoff.

It is also the net under the adoption step: at a VAT rate of nought both
treatments charge the same amount, and the check that refuses an already-claimed
Price is then the only thing keeping the two apart.

Duplicates are deleted, not archived. An archived row goes on claiming the id
and a unique index knows nothing about archived_at; the row rebuilds itself on
the next ensure(), and subscription_addons holds the Stripe id as text rather
than a foreign key, so no booking loses its price.

AddonPrices::remember()'s catch comment described the guard as the only thing
standing between two rows and one Price; now that the index exists, rewritten
to describe it as the net under that guard instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feature/betriebsmodus^2
nexxo 2026-07-30 12:47:34 +02:00
parent dfbd3b5c1f
commit dba5df5d1e
4 changed files with 90 additions and 13 deletions

View File

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

View File

@ -0,0 +1,60 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* One row per Stripe Price on the module side too.
*
* `stripe_plan_prices.stripe_price_id` has been unique since
* 2026_07_30_110000; the module register was never given the same guard, so two
* rows could claim one Price and archiving the one would have withdrawn the
* Price the other was still selling. It is also the net under the adoption step
* (App\Services\Billing\AdoptStripePrice): at a VAT rate of nought both
* treatments charge the same amount, and the check that refuses an
* already-claimed Price is then the only thing keeping them apart.
*
* Duplicates are DELETED rather than archived. An archived row goes on claiming
* the id, and a unique index knows nothing about `archived_at`. Deleting is safe
* because the row rebuilds itself: the next ensure() finds the Price at Stripe
* through the adoption step and writes the row again. `subscription_addons`
* holds the Stripe id as text, not as a foreign key, so no booking loses its
* price.
*/
return new class extends Migration
{
public function up(): void
{
// Written with groupBy/having and a delete by id rather than a joined
// delete: production is MariaDB and the suite runs on SQLite.
$shared = DB::table('stripe_addon_prices')
->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');
});
}
};

View File

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

View File

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