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
parent
dfbd3b5c1f
commit
dba5df5d1e
|
|
@ -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.
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue