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
3295f47556
commit
aae456954a
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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue