presseportale/app/Services/Billing/Concerns/LocksModelForUpdate.php
Kevin Adametz 7043ea8ad5 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>
2026-07-14 13:47:21 +00:00

46 lines
1.4 KiB
PHP

<?php
namespace App\Services\Billing\Concerns;
use Closure;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\DB;
/**
* Gemeinsame Idempotenz-Grundform der Stripe-Verarbeitung (at-least-once):
* innerhalb einer Transaktion die betroffene Zeile per Primärschlüssel neu
* laden und sperren, bevor der Idempotenz-Guard geprüft und die Mutation
* ausgeführt wird. So serialisieren parallele Webhook-Zustellungen, statt beide
* denselben „noch nicht verarbeitet"-Zustand zu lesen.
*
* Deckt die häufige Form ab (Entity per Key sperren). Pfade mit abweichender
* Sperrstrategie (z. B. Lock auf der User-Zeile oder Unique-Constraint-Catch)
* bleiben bewusst eigenständig.
*/
trait LocksModelForUpdate
{
/**
* @template TModel of Model
* @template TReturn
*
* @param TModel $model
* @param Closure(TModel): TReturn $callback
* @return TReturn|null Ergebnis des Callbacks, oder null, wenn die Zeile
* zwischenzeitlich verschwunden ist.
*/
protected function withLockedByKey(Model $model, Closure $callback): mixed
{
return DB::transaction(function () use ($model, $callback) {
$locked = $model->newQuery()
->whereKey($model->getKey())
->lockForUpdate()
->first();
if ($locked === null) {
return null;
}
return $callback($locked);
});
}
}