billing: Nebenläufigkeit & Idempotenz härten (H4, Stripe, Boost-Slots)

- CreditTopup-Gutschrift, MAN-Rechnung, Proof-PDF und Stripe-Webhook-
  Spiegelung sperren die betroffene Zeile innerhalb der Transaktion und
  prüfen die Idempotenz erst danach (verhindert Doppelgutschrift bei
  at-least-once-Webhooks, H4).
- Gemeinsamer LocksModelForUpdate-Trait für die häufige Lock-by-Key-Form
  (CreditTopup, ManualInvoice).
- Unique-Index auf invoices.stripe_invoice_id; Boost-Inventar über
  boost_slot_locks serialisiert, verwaiste Kategorie-Locks werden beim
  Löschen der Rubrik entfernt.

Deployment: vor migrate prüfen, ob invoices.stripe_invoice_id bereits
Duplikate enthält (die Unique-Migration bricht sonst bewusst ab).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-07-14 13:47:21 +00:00
parent 3295f47556
commit aae456954a
14 changed files with 418 additions and 106 deletions

View file

@ -12,6 +12,8 @@ use App\Models\PressRelease;
use App\Models\User;
use App\Services\Billing\CreditWalletService;
use App\Services\PressRelease\BoostService;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
beforeEach(function (): void {
$this->service = app(BoostService::class);
@ -77,6 +79,10 @@ test('booking a boost debits the wallet and activates the placement', function (
expect($this->wallet->balance($user))->toBe(0);
expect($this->service->isBoosted($pr))->toBeTrue();
expect($pr->fresh()->isBoosted())->toBeTrue();
expect(DB::table('boost_slot_locks')->where(
'lock_key',
'home:'.$pr->portal->value.':'.$pr->language.':all',
)->exists())->toBeTrue();
});
test('boosting a non-green release is rejected at the gate', function () {
@ -135,6 +141,10 @@ test('a category boost costs half the staffel and only activates the category pl
expect($this->wallet->balance($user))->toBe(4);
expect($this->service->isBoosted($pr, BoostPlacement::Category))->toBeTrue();
expect($this->service->isBoosted($pr, BoostPlacement::Home))->toBeFalse();
expect(DB::table('boost_slot_locks')->where(
'lock_key',
'category:'.$pr->portal->value.':'.$pr->language.':'.$pr->category_id,
)->exists())->toBeTrue();
});
test('the placements extend independently of each other', function () {
@ -248,3 +258,23 @@ test('home boosts do not consume category slots and vice versa', function () {
expect($this->service->boost($user, $pr, 7, BoostPlacement::Category))->toBeInstanceOf(Boost::class);
});
test('deleting a category prunes its orphaned boost slot locks', function () {
/** @var TestCase $this */
$categoryId = 4711;
// Simuliert vorhandene Lock-Zeilen: eine Kategorie-Lock plus eine
// Home-Lock (die nicht betroffen sein darf).
DB::table('boost_slot_locks')->insert([
['lock_key' => 'category:presseecho:de:'.$categoryId],
['lock_key' => 'category:businessportal24:de:'.$categoryId],
['lock_key' => 'home:presseecho:de:all'],
['lock_key' => 'category:presseecho:de:99'],
]);
$this->service->forgetSlotLocksForCategory($categoryId);
expect(DB::table('boost_slot_locks')->where('lock_key', 'like', 'category:%:'.$categoryId)->count())->toBe(0)
->and(DB::table('boost_slot_locks')->where('lock_key', 'home:presseecho:de:all')->exists())->toBeTrue()
->and(DB::table('boost_slot_locks')->where('lock_key', 'category:presseecho:de:99')->exists())->toBeTrue();
});