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

@ -146,6 +146,8 @@ class BoostService
$credits = $this->pricing->boostCredits($days, $placement);
$boost = DB::transaction(function () use ($user, $pressRelease, $days, $credits, $placement): Boost {
$this->lockSlotInventory($pressRelease, $placement);
$availability = $this->slotAvailability($pressRelease, $placement);
if (! $availability['available']) {
throw BoostNotAllowedException::soldOut($placement, $availability['freeAt']);
@ -177,6 +179,36 @@ class BoostService
return $boost;
}
/**
* Serialisiert Käufe, die dasselbe sichtbare Slot-Inventar verändern.
*
* Eine eigene stabile Lock-Zeile ist nötig: Ein Lock auf vorhandenen
* Boost-Zeilen schützt gerade das leere Inventar nicht vor zwei
* gleichzeitigen Erstkäufen.
*/
private function lockSlotInventory(PressRelease $pressRelease, BoostPlacement $placement): void
{
$lockKeys = collect($this->affectedPortals($pressRelease))
->map(fn (string $portal): string => implode(':', [
$placement->value,
$portal,
$pressRelease->language,
$placement === BoostPlacement::Category ? (string) $pressRelease->category_id : 'all',
]))
->sort()
->values();
DB::table('boost_slot_locks')->insertOrIgnore(
$lockKeys->map(fn (string $lockKey): array => ['lock_key' => $lockKey])->all(),
);
DB::table('boost_slot_locks')
->whereIn('lock_key', $lockKeys)
->orderBy('lock_key')
->lockForUpdate()
->get();
}
/**
* Gecachte Start-/Branchenseiten der betroffenen Ausgaben verwerfen,
* damit die bezahlte Platzierung sofort sichtbar wird statt erst nach
@ -219,4 +251,17 @@ class BoostService
? [Portal::Presseecho->value, Portal::Businessportal24->value]
: [$pressRelease->portal->value];
}
/**
* Entfernt die (bei Kategorie-Boosts angelegten) Slot-Lock-Zeilen einer
* gelöschten Rubrik. Die Lock-Keys haben die Form
* `category:<portal>:<sprache>:<category_id>` — nach dem Löschen der Rubrik
* sind sie verwaist und werden hier aufgeräumt.
*/
public function forgetSlotLocksForCategory(int $categoryId): void
{
DB::table('boost_slot_locks')
->where('lock_key', 'like', 'category:%:'.$categoryId)
->delete();
}
}