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

@ -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);
});
}
}

View file

@ -6,7 +6,7 @@ use App\Enums\CreditTransactionType;
use App\Enums\SinglePurchaseStatus;
use App\Models\CreditTopup;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use App\Services\Billing\Concerns\LocksModelForUpdate;
/**
* Wallet-Aufladung über Credit-Pakete (Stripe). Der Kauf wird als Pending-
@ -15,6 +15,8 @@ use Illuminate\Support\Facades\DB;
*/
class CreditTopupService
{
use LocksModelForUpdate;
public function __construct(private readonly CreditWalletService $wallet) {}
/**
@ -67,26 +69,32 @@ class CreditTopupService
*/
public function fulfill(CreditTopup $topup, ?string $sessionId = null, ?string $paymentIntentId = null): void
{
if ($topup->credited_at !== null) {
return;
}
// Zeile sperren UND Idempotenz innerhalb der Transaktion prüfen: Stripe
// liefert Webhooks at-least-once (Original + Retry). Läge der Guard außen,
// könnten zwei parallele Zustellungen beide `credited_at === null` lesen
// und die Wallet doppelt gutschreiben. Der Lock serialisiert sie.
$this->withLockedByKey($topup, function (CreditTopup $locked) use ($topup, $sessionId, $paymentIntentId): void {
if ($locked->credited_at !== null) {
return;
}
DB::transaction(function () use ($topup, $sessionId, $paymentIntentId): void {
$topup->update([
$locked->update([
'status' => SinglePurchaseStatus::Paid,
'paid_at' => $topup->paid_at ?? now(),
'paid_at' => $locked->paid_at ?? now(),
'credited_at' => now(),
'stripe_checkout_session_id' => $sessionId ?? $topup->stripe_checkout_session_id,
'stripe_payment_intent_id' => $paymentIntentId ?? $topup->stripe_payment_intent_id,
'stripe_checkout_session_id' => $sessionId ?? $locked->stripe_checkout_session_id,
'stripe_payment_intent_id' => $paymentIntentId ?? $locked->stripe_payment_intent_id,
]);
$this->wallet->credit(
$topup->user,
$topup->credits,
$locked->user,
$locked->credits,
CreditTransactionType::Topup,
"Credit-Paket {$topup->pack_key}",
$topup,
"Credit-Paket {$locked->pack_key}",
$locked,
);
$topup->setRawAttributes($locked->getAttributes());
});
}
}

View file

@ -7,9 +7,9 @@ use App\Enums\UserPaymentOptionStatus;
use App\Models\Invoice;
use App\Models\InvoiceBillingAddress;
use App\Models\UserPaymentOption;
use App\Services\Billing\Concerns\LocksModelForUpdate;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
/**
@ -31,6 +31,8 @@ use Illuminate\Support\Facades\Log;
*/
class ManualInvoiceService
{
use LocksModelForUpdate;
public function __construct(
private readonly InvoiceNumberGenerator $numbers,
private readonly VatResolver $vat,
@ -66,40 +68,50 @@ class ManualInvoiceService
{
$asOf = $asOf ?? today();
$user = $option->user;
$interval = $this->billingInterval($option);
return $this->withLockedByKey($option, function (UserPaymentOption $lockedOption) use ($asOf): ?Invoice {
if (
! in_array($lockedOption->status, [
UserPaymentOptionStatus::Active,
UserPaymentOptionStatus::Grandfathered,
], true)
|| $lockedOption->stripe_subscription_id !== null
|| $lockedOption->current_period_end === null
|| $lockedOption->current_period_end->isAfter($asOf)
) {
return null;
}
if (! $user) {
Log::warning('MAN-Rechnung übersprungen: Vereinbarung ohne User.', ['user_payment_option_id' => $option->id]);
$lockedOption->loadMissing(['user.billingAddress', 'paymentOption']);
$user = $lockedOption->user;
$interval = $this->billingInterval($lockedOption);
return null;
}
if (! $user) {
Log::warning('MAN-Rechnung übersprungen: Vereinbarung ohne User.', ['user_payment_option_id' => $lockedOption->id]);
if (! $interval) {
Log::warning('MAN-Rechnung übersprungen: kein abrechenbares Intervall.', ['user_payment_option_id' => $option->id]);
return null;
}
return null;
}
if (! $interval) {
Log::warning('MAN-Rechnung übersprungen: kein abrechenbares Intervall.', ['user_payment_option_id' => $lockedOption->id]);
$billingAddress = $user->billingAddress;
return null;
}
if (! $billingAddress) {
Log::warning('MAN-Rechnung übersprungen: User ohne Rechnungsadresse.', [
'user_payment_option_id' => $option->id,
'user_id' => $user->id,
]);
$billingAddress = $user->billingAddress;
return null;
}
if (! $billingAddress) {
Log::warning('MAN-Rechnung übersprungen: User ohne Rechnungsadresse.', [
'user_payment_option_id' => $lockedOption->id,
'user_id' => $user->id,
]);
$netCents = $this->resolveNetCents($option);
return null;
}
// USt zur Rechnungsstellung bestimmen: DE immer mit Steuer, EU nur
// mit gültiger USt-ID befreit (Reverse Charge), Drittland befreit.
$treatment = $this->vat->resolve($billingAddress->country_code, $billingAddress->vat_id);
$taxCents = $this->vat->taxCentsFor($netCents, $treatment);
$netCents = $this->resolveNetCents($lockedOption);
$treatment = $this->vat->resolve($billingAddress->country_code, $billingAddress->vat_id);
$taxCents = $this->vat->taxCentsFor($netCents, $treatment);
return DB::transaction(function () use ($option, $user, $billingAddress, $netCents, $taxCents, $treatment, $interval, $asOf): Invoice {
// Adresse pro Rechnung einfrieren (Snapshot-Tabelle).
$addressSnapshot = InvoiceBillingAddress::query()->create([
'salutation_key' => $billingAddress->salutation_key,
@ -122,18 +134,18 @@ class ManualInvoiceService
'amount_cents' => $netCents,
'tax_cents' => $taxCents,
'total_cents' => $netCents + $taxCents,
'currency' => $option->paymentOption?->currency ?? config('billing.default_currency'),
'currency' => $lockedOption->paymentOption?->currency ?? config('billing.default_currency'),
'is_netto' => $treatment->isTaxExempt(),
'tax_note' => $treatment->taxNote(),
'invoice_date' => $asOf,
'due_date' => $asOf->copy()->addDays((int) config('billing.manual_due_days', 14)),
]);
$option->update([
'current_period_start' => $option->current_period_end,
$lockedOption->update([
'current_period_start' => $lockedOption->current_period_end,
'current_period_end' => $interval === 'yearly'
? $option->current_period_end->copy()->addYear()
: $option->current_period_end->copy()->addMonth(),
? $lockedOption->current_period_end->copy()->addYear()
: $lockedOption->current_period_end->copy()->addMonth(),
]);
return $invoice;

View file

@ -53,16 +53,21 @@ class ProofPdfService
throw ProofPdfNotAvailableException::notPublished();
}
$existing = $this->purchaseQuery($user, $pressRelease)->first();
if ($existing) {
return $existing;
}
$credits = $this->priceCredits();
return DB::transaction(function () use ($user, $pressRelease, $credits): SinglePurchase {
$purchase = $user->singlePurchases()->create([
$lockedUser = User::query()
->whereKey($user->getKey())
->lockForUpdate()
->firstOrFail();
$existing = $this->purchaseQuery($lockedUser, $pressRelease)->first();
if ($existing) {
return $existing;
}
$purchase = $lockedUser->singlePurchases()->create([
'type' => SinglePurchaseType::ProofPdf,
'status' => SinglePurchaseStatus::Paid,
'price_cents' => $credits * 100,
@ -72,7 +77,7 @@ class ProofPdfService
]);
$this->wallet->debit(
$user,
$lockedUser,
$credits,
"Veröffentlichungsnachweis – PM #{$pressRelease->id}",
$purchase,

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();
}
}