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
|
|
@ -11,11 +11,15 @@ use App\Mail\PressReleaseDepublished;
|
|||
use App\Models\CreditTopup;
|
||||
use App\Models\Invoice;
|
||||
use App\Models\InvoiceBillingAddress;
|
||||
use App\Models\PressRelease;
|
||||
use App\Models\SinglePurchase;
|
||||
use App\Models\User;
|
||||
use App\Scopes\PortalScope;
|
||||
use App\Services\Billing\CreditTopupService;
|
||||
use App\Services\Billing\InvoiceNumberGenerator;
|
||||
use App\Services\PressRelease\PressReleaseService;
|
||||
use Illuminate\Database\UniqueConstraintViolationException;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Laravel\Cashier\Cashier;
|
||||
|
|
@ -97,7 +101,6 @@ class ProcessStripeWebhook
|
|||
return;
|
||||
}
|
||||
|
||||
// Idempotent: Stripe liefert Webhooks mindestens einmal.
|
||||
if (Invoice::query()->where('stripe_invoice_id', $stripeInvoiceId)->exists()) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -135,20 +138,38 @@ class ProcessStripeWebhook
|
|||
return;
|
||||
}
|
||||
|
||||
$invoice = Invoice::query()->create([
|
||||
'user_id' => $user->id,
|
||||
'invoice_billing_address_id' => $this->snapshotAddress($user, $stripeInvoice)->id,
|
||||
'number' => $this->numbers->nextStripeNumber(),
|
||||
'status' => InvoiceStatus::Paid->value,
|
||||
'amount_cents' => $subtotal,
|
||||
'tax_cents' => $tax,
|
||||
'total_cents' => $total,
|
||||
'currency' => strtoupper((string) ($stripeInvoice['currency'] ?? config('billing.default_currency'))),
|
||||
'is_netto' => $tax === 0,
|
||||
'invoice_date' => now()->toDateString(),
|
||||
'paid_at' => now(),
|
||||
'stripe_invoice_id' => $stripeInvoiceId,
|
||||
]);
|
||||
try {
|
||||
$invoice = DB::transaction(function () use ($user, $stripeInvoice, $stripeInvoiceId, $subtotal, $tax, $total): ?Invoice {
|
||||
if (Invoice::query()->where('stripe_invoice_id', $stripeInvoiceId)->exists()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Invoice::query()->create([
|
||||
'user_id' => $user->id,
|
||||
'invoice_billing_address_id' => $this->snapshotAddress($user, $stripeInvoice)->id,
|
||||
'number' => $this->numbers->nextStripeNumber(),
|
||||
'status' => InvoiceStatus::Paid->value,
|
||||
'amount_cents' => $subtotal,
|
||||
'tax_cents' => $tax,
|
||||
'total_cents' => $total,
|
||||
'currency' => strtoupper((string) ($stripeInvoice['currency'] ?? config('billing.default_currency'))),
|
||||
'is_netto' => $tax === 0,
|
||||
'invoice_date' => now()->toDateString(),
|
||||
'paid_at' => now(),
|
||||
'stripe_invoice_id' => $stripeInvoiceId,
|
||||
]);
|
||||
});
|
||||
} catch (UniqueConstraintViolationException $exception) {
|
||||
if (Invoice::query()->where('stripe_invoice_id', $stripeInvoiceId)->exists()) {
|
||||
return;
|
||||
}
|
||||
|
||||
throw $exception;
|
||||
}
|
||||
|
||||
if ($invoice === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
Log::info('Stripe-Rechnung in den STR-Kreis gespiegelt.', [
|
||||
'number' => $invoice->number,
|
||||
|
|
@ -229,55 +250,57 @@ class ProcessStripeWebhook
|
|||
return;
|
||||
}
|
||||
|
||||
$purchase = SinglePurchase::query()->find((int) $purchaseId);
|
||||
/** @var array{purchase: SinglePurchase, marked_paid: bool, depublished_release: PressRelease|null}|null $result */
|
||||
$result = DB::transaction(function () use ($purchaseId, $session): ?array {
|
||||
$purchase = SinglePurchase::query()
|
||||
->whereKey((int) $purchaseId)
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
|
||||
if (! $purchase || $purchase->status !== SinglePurchaseStatus::Pending) {
|
||||
if (! $purchase) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$markedPaid = false;
|
||||
|
||||
if ($purchase->status === SinglePurchaseStatus::Pending) {
|
||||
$purchase->update([
|
||||
'status' => SinglePurchaseStatus::Paid->value,
|
||||
'paid_at' => now(),
|
||||
'stripe_checkout_session_id' => $session['id'] ?? $purchase->stripe_checkout_session_id,
|
||||
'stripe_payment_intent_id' => $session['payment_intent'] ?? null,
|
||||
]);
|
||||
$markedPaid = true;
|
||||
} elseif (
|
||||
$purchase->type !== SinglePurchaseType::Depublication
|
||||
|| $purchase->status !== SinglePurchaseStatus::Paid
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'purchase' => $purchase,
|
||||
'marked_paid' => $markedPaid,
|
||||
'depublished_release' => $this->fulfillDepublication($purchase),
|
||||
];
|
||||
});
|
||||
|
||||
if ($result === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$purchase->update([
|
||||
'status' => SinglePurchaseStatus::Paid->value,
|
||||
'paid_at' => now(),
|
||||
'stripe_checkout_session_id' => $session['id'] ?? $purchase->stripe_checkout_session_id,
|
||||
'stripe_payment_intent_id' => $session['payment_intent'] ?? null,
|
||||
]);
|
||||
$purchase = $result['purchase'];
|
||||
|
||||
Log::info('Einmalkauf als bezahlt markiert.', ['single_purchase_id' => $purchase->id]);
|
||||
if ($result['marked_paid']) {
|
||||
Log::info('Einmalkauf als bezahlt markiert.', ['single_purchase_id' => $purchase->id]);
|
||||
}
|
||||
|
||||
$this->fulfillDepublication($purchase);
|
||||
}
|
||||
$pressRelease = $result['depublished_release'];
|
||||
|
||||
/**
|
||||
* T12: Nach bezahltem Depublikations-Kauf die PM sofort aus der
|
||||
* öffentlichen Ansicht nehmen (Tombstone bleibt). Replays sind über den
|
||||
* Pending-Status des Kaufs abgefangen; der Kauf gilt mit der
|
||||
* Depublikation als eingelöst.
|
||||
*/
|
||||
private function fulfillDepublication(SinglePurchase $purchase): void
|
||||
{
|
||||
if ($purchase->type !== SinglePurchaseType::Depublication) {
|
||||
if ($pressRelease === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$pressRelease = $purchase->pressRelease;
|
||||
|
||||
if (! $pressRelease || $pressRelease->status !== PressReleaseStatus::Published) {
|
||||
Log::warning('Depublikation bezahlt, aber PM nicht (mehr) veröffentlicht.', [
|
||||
'single_purchase_id' => $purchase->id,
|
||||
'press_release_id' => $purchase->press_release_id,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
app(PressReleaseService::class)->depublish($pressRelease);
|
||||
$purchase->update([
|
||||
'status' => SinglePurchaseStatus::Consumed->value,
|
||||
'consumed_at' => now(),
|
||||
]);
|
||||
|
||||
// Bestätigung an den Käufer (nicht zwingend der PM-Autor — der
|
||||
// Kauf kann von einem Firmen-Kollegen mit Zugriff stammen).
|
||||
$buyer = $purchase->user;
|
||||
|
||||
if ($buyer && $buyer->email && $buyer->wantsEmailNotification(EmailNotificationType::PressReleaseDepublished)) {
|
||||
|
|
@ -289,4 +312,40 @@ class ProcessStripeWebhook
|
|||
'press_release_id' => $pressRelease->id,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* T12: Nach bezahltem Depublikations-Kauf die PM sofort aus der
|
||||
* öffentlichen Ansicht nehmen (Tombstone bleibt). Replays sind über den
|
||||
* Pending-Status des Kaufs abgefangen; der Kauf gilt mit der
|
||||
* Depublikation als eingelöst.
|
||||
*/
|
||||
private function fulfillDepublication(SinglePurchase $purchase): ?PressRelease
|
||||
{
|
||||
if ($purchase->type !== SinglePurchaseType::Depublication) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$pressRelease = PressRelease::query()
|
||||
->withoutGlobalScope(PortalScope::class)
|
||||
->whereKey($purchase->press_release_id)
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
|
||||
if (! $pressRelease || $pressRelease->status !== PressReleaseStatus::Published) {
|
||||
Log::warning('Depublikation bezahlt, aber PM nicht (mehr) veröffentlicht.', [
|
||||
'single_purchase_id' => $purchase->id,
|
||||
'press_release_id' => $purchase->press_release_id,
|
||||
]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
app(PressReleaseService::class)->depublish($pressRelease);
|
||||
$purchase->update([
|
||||
'status' => SinglePurchaseStatus::Consumed->value,
|
||||
'consumed_at' => now(),
|
||||
]);
|
||||
|
||||
return $pressRelease;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,27 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('boost_slot_locks', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('lock_key', 191)->unique();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('boost_slot_locks');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('invoices', function (Blueprint $table): void {
|
||||
$table->dropIndex('invoices_stripe_invoice_idx');
|
||||
$table->unique('stripe_invoice_id', 'invoices_stripe_invoice_unique');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('invoices', function (Blueprint $table): void {
|
||||
$table->dropUnique('invoices_stripe_invoice_unique');
|
||||
$table->index('stripe_invoice_id', 'invoices_stripe_invoice_idx');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -64,6 +64,8 @@ new #[Layout('components.layouts.app'), Title('Kategorie bearbeiten')] class ext
|
|||
|
||||
public function save(): void
|
||||
{
|
||||
$this->authorize('categories:manage');
|
||||
|
||||
$this->validate([
|
||||
'parentId' => ['nullable', 'integer', Rule::notIn([$this->id]), 'exists:categories,id'],
|
||||
'descriptionDe' => ['nullable', 'string', 'max:1000'],
|
||||
|
|
@ -117,6 +119,8 @@ new #[Layout('components.layouts.app'), Title('Kategorie bearbeiten')] class ext
|
|||
|
||||
public function deleteCategory(): void
|
||||
{
|
||||
$this->authorize('categories:manage');
|
||||
|
||||
$category = Category::query()->findOrFail($this->id);
|
||||
|
||||
if ($category->pressReleases()->withoutGlobalScopes()->exists()) {
|
||||
|
|
@ -133,6 +137,9 @@ new #[Layout('components.layouts.app'), Title('Kategorie bearbeiten')] class ext
|
|||
|
||||
$category->delete();
|
||||
|
||||
// Verwaiste Boost-Slot-Locks der gelöschten Rubrik aufräumen.
|
||||
app(\App\Services\PressRelease\BoostService::class)->forgetSlotLocksForCategory((int) $category->id);
|
||||
|
||||
session()->flash('success', __('Kategorie wurde gelöscht.'));
|
||||
$this->redirect(route('admin.categories.index'), navigate: true);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -227,7 +227,7 @@ test('the paid webhook depublishes the release and the public page shows a tombs
|
|||
// „Eingelöste Käufe" auf der Buchungs-Seite.
|
||||
expect($purchase->fresh()->status)->toBe(SinglePurchaseStatus::Consumed);
|
||||
expect($purchase->fresh()->consumed_at)->not->toBeNull();
|
||||
expect($release->statusLogs()->count())->toBeGreaterThan(0);
|
||||
expect($release->statusLogs()->where('source', 'depublication')->count())->toBe(1);
|
||||
|
||||
// Der Käufer erhält genau eine Bestätigung (Replay löst keine zweite aus).
|
||||
Mail::assertQueued(PressReleaseDepublished::class, function (PressReleaseDepublished $mail) use ($user, $release) {
|
||||
|
|
|
|||
|
|
@ -156,6 +156,22 @@ test('agreements with a future period end are not due', function () {
|
|||
expect(app(ManualInvoiceService::class)->duePaymentOptions())->toHaveCount(0);
|
||||
});
|
||||
|
||||
test('a stale due agreement instance cannot be invoiced twice', function () {
|
||||
Carbon::setTestNow('2026-06-12 08:00:00');
|
||||
$agreement = manualAgreement(['current_period_end' => '2026-06-11']);
|
||||
$service = app(ManualInvoiceService::class);
|
||||
|
||||
$firstInvoice = $service->invoiceFor($agreement);
|
||||
$replayedInvoice = $service->invoiceFor($agreement);
|
||||
|
||||
expect($firstInvoice)->not->toBeNull();
|
||||
expect($replayedInvoice)->toBeNull();
|
||||
expect(Invoice::count())->toBe(1);
|
||||
expect($agreement->fresh()->current_period_end->toDateString())->toBe('2026-07-11');
|
||||
|
||||
Carbon::setTestNow();
|
||||
});
|
||||
|
||||
test('the command invoices all due agreements', function () {
|
||||
manualAgreement();
|
||||
manualAgreement();
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ use App\Models\BillingAddress;
|
|||
use App\Models\Invoice;
|
||||
use App\Models\SinglePurchase;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Laravel\Cashier\Events\WebhookReceived;
|
||||
|
||||
|
|
@ -81,6 +82,17 @@ test('duplicate webhook deliveries do not create a second invoice', function ()
|
|||
expect(Invoice::count())->toBe(1);
|
||||
});
|
||||
|
||||
test('stripe invoice ids are unique in the database while null remains repeatable', function () {
|
||||
Invoice::factory()->create(['stripe_invoice_id' => 'in_database_unique']);
|
||||
|
||||
expect(fn () => Invoice::factory()->create(['stripe_invoice_id' => 'in_database_unique']))
|
||||
->toThrow(QueryException::class);
|
||||
|
||||
Invoice::factory()->count(2)->create(['stripe_invoice_id' => null]);
|
||||
|
||||
expect(Invoice::query()->whereNull('stripe_invoice_id')->count())->toBe(2);
|
||||
});
|
||||
|
||||
test('the snapshot falls back to the local billing address including vat id', function () {
|
||||
$user = User::factory()->create(['stripe_id' => 'cus_test_1']);
|
||||
BillingAddress::factory()->create([
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ use App\Models\PressRelease;
|
|||
use App\Models\User;
|
||||
use App\Services\Billing\CreditWalletService;
|
||||
use App\Services\PressRelease\BoostService;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tests\TestCase;
|
||||
|
||||
beforeEach(function (): void {
|
||||
$this->service = app(BoostService::class);
|
||||
|
|
@ -77,6 +79,10 @@ test('booking a boost debits the wallet and activates the placement', function (
|
|||
expect($this->wallet->balance($user))->toBe(0);
|
||||
expect($this->service->isBoosted($pr))->toBeTrue();
|
||||
expect($pr->fresh()->isBoosted())->toBeTrue();
|
||||
expect(DB::table('boost_slot_locks')->where(
|
||||
'lock_key',
|
||||
'home:'.$pr->portal->value.':'.$pr->language.':all',
|
||||
)->exists())->toBeTrue();
|
||||
});
|
||||
|
||||
test('boosting a non-green release is rejected at the gate', function () {
|
||||
|
|
@ -135,6 +141,10 @@ test('a category boost costs half the staffel and only activates the category pl
|
|||
expect($this->wallet->balance($user))->toBe(4);
|
||||
expect($this->service->isBoosted($pr, BoostPlacement::Category))->toBeTrue();
|
||||
expect($this->service->isBoosted($pr, BoostPlacement::Home))->toBeFalse();
|
||||
expect(DB::table('boost_slot_locks')->where(
|
||||
'lock_key',
|
||||
'category:'.$pr->portal->value.':'.$pr->language.':'.$pr->category_id,
|
||||
)->exists())->toBeTrue();
|
||||
});
|
||||
|
||||
test('the placements extend independently of each other', function () {
|
||||
|
|
@ -248,3 +258,23 @@ test('home boosts do not consume category slots and vice versa', function () {
|
|||
|
||||
expect($this->service->boost($user, $pr, 7, BoostPlacement::Category))->toBeInstanceOf(Boost::class);
|
||||
});
|
||||
|
||||
test('deleting a category prunes its orphaned boost slot locks', function () {
|
||||
/** @var TestCase $this */
|
||||
$categoryId = 4711;
|
||||
|
||||
// Simuliert vorhandene Lock-Zeilen: eine Kategorie-Lock plus eine
|
||||
// Home-Lock (die nicht betroffen sein darf).
|
||||
DB::table('boost_slot_locks')->insert([
|
||||
['lock_key' => 'category:presseecho:de:'.$categoryId],
|
||||
['lock_key' => 'category:businessportal24:de:'.$categoryId],
|
||||
['lock_key' => 'home:presseecho:de:all'],
|
||||
['lock_key' => 'category:presseecho:de:99'],
|
||||
]);
|
||||
|
||||
$this->service->forgetSlotLocksForCategory($categoryId);
|
||||
|
||||
expect(DB::table('boost_slot_locks')->where('lock_key', 'like', 'category:%:'.$categoryId)->count())->toBe(0)
|
||||
->and(DB::table('boost_slot_locks')->where('lock_key', 'home:presseecho:de:all')->exists())->toBeTrue()
|
||||
->and(DB::table('boost_slot_locks')->where('lock_key', 'category:presseecho:de:99')->exists())->toBeTrue();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -59,6 +59,27 @@ test('fulfilling a topup credits the wallet exactly once', function () {
|
|||
expect($user->creditTransactions()->count())->toBe(1);
|
||||
});
|
||||
|
||||
test('two concurrent webhook deliveries credit the wallet only once', function () {
|
||||
/**
|
||||
* Regression zu Befund H4 (Audit 14.07.2026): Stripe stellt Webhooks
|
||||
* at-least-once zu. Beide Zustellungen laden die Topup-Zeile, BEVOR eine
|
||||
* committet (credited_at je noch null). Nur eine darf gutschreiben.
|
||||
*/
|
||||
$user = User::factory()->create();
|
||||
$topup = $this->topups->startTopup($user, 'p50');
|
||||
|
||||
// Zwei separate Instanzen derselben Zeile – wie zwei parallele Deliveries,
|
||||
// die die Zeile vor der ersten Gutschrift geladen haben.
|
||||
$deliveryA = $topup->fresh();
|
||||
$deliveryB = $topup->fresh();
|
||||
|
||||
$this->topups->fulfill($deliveryA, 'cs_test_1', 'pi_test_1');
|
||||
$this->topups->fulfill($deliveryB, 'cs_test_1', 'pi_test_1');
|
||||
|
||||
expect($this->wallet->balance($user))->toBe(55);
|
||||
expect($user->creditTransactions()->count())->toBe(1);
|
||||
});
|
||||
|
||||
test('the checkout webhook credits the wallet from session metadata', function () {
|
||||
$user = User::factory()->create();
|
||||
$topup = $this->topups->startTopup($user, 'p100');
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue