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:
parent
b3fdd6f522
commit
7043ea8ad5
14 changed files with 418 additions and 106 deletions
46
app/Services/Billing/Concerns/LocksModelForUpdate.php
Normal file
46
app/Services/Billing/Concerns/LocksModelForUpdate.php
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
<?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);
|
||||
});
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue