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 b3fdd6f522
commit 7043ea8ad5
14 changed files with 418 additions and 106 deletions

View file

@ -59,6 +59,27 @@ test('fulfilling a topup credits the wallet exactly once', function () {
expect($user->creditTransactions()->count())->toBe(1);
});
test('two concurrent webhook deliveries credit the wallet only once', function () {
/**
* Regression zu Befund H4 (Audit 14.07.2026): Stripe stellt Webhooks
* at-least-once zu. Beide Zustellungen laden die Topup-Zeile, BEVOR eine
* committet (credited_at je noch null). Nur eine darf gutschreiben.
*/
$user = User::factory()->create();
$topup = $this->topups->startTopup($user, 'p50');
// Zwei separate Instanzen derselben Zeile – wie zwei parallele Deliveries,
// die die Zeile vor der ersten Gutschrift geladen haben.
$deliveryA = $topup->fresh();
$deliveryB = $topup->fresh();
$this->topups->fulfill($deliveryA, 'cs_test_1', 'pi_test_1');
$this->topups->fulfill($deliveryB, 'cs_test_1', 'pi_test_1');
expect($this->wallet->balance($user))->toBe(55);
expect($user->creditTransactions()->count())->toBe(1);
});
test('the checkout webhook credits the wallet from session metadata', function () {
$user = User::factory()->create();
$topup = $this->topups->startTopup($user, 'p100');