From aae456954a14cda27b041de3bfe141ff1e21b261 Mon Sep 17 00:00:00 2001 From: Kevin Adametz Date: Tue, 14 Jul 2026 13:47:21 +0000 Subject: [PATCH] =?UTF-8?q?billing:=20Nebenl=C3=A4ufigkeit=20&=20Idempoten?= =?UTF-8?q?z=20h=C3=A4rten=20(H4,=20Stripe,=20Boost-Slots)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- app/Listeners/ProcessStripeWebhook.php | 167 ++++++++++++------ .../Billing/Concerns/LocksModelForUpdate.php | 46 +++++ app/Services/Billing/CreditTopupService.php | 34 ++-- app/Services/Billing/ManualInvoiceService.php | 72 ++++---- app/Services/Billing/ProofPdfService.php | 21 ++- app/Services/PressRelease/BoostService.php | 45 +++++ ...4_125600_create_boost_slot_locks_table.php | 27 +++ ...ue_stripe_invoice_id_to_invoices_table.php | 24 +++ .../livewire/admin/categories/edit.blade.php | 7 + tests/Feature/Billing/DepublicationTest.php | 2 +- .../Billing/ManualInvoiceGenerationTest.php | 16 ++ .../Billing/StripeWebhookProcessingTest.php | 12 ++ tests/Feature/BoostServiceTest.php | 30 ++++ tests/Feature/CreditTopupTest.php | 21 +++ 14 files changed, 418 insertions(+), 106 deletions(-) create mode 100644 app/Services/Billing/Concerns/LocksModelForUpdate.php create mode 100644 database/migrations/2026_07_14_125600_create_boost_slot_locks_table.php create mode 100644 database/migrations/2026_07_14_125700_add_unique_stripe_invoice_id_to_invoices_table.php diff --git a/app/Listeners/ProcessStripeWebhook.php b/app/Listeners/ProcessStripeWebhook.php index 1192cdc7..492d5266 100644 --- a/app/Listeners/ProcessStripeWebhook.php +++ b/app/Listeners/ProcessStripeWebhook.php @@ -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; + } } diff --git a/app/Services/Billing/Concerns/LocksModelForUpdate.php b/app/Services/Billing/Concerns/LocksModelForUpdate.php new file mode 100644 index 00000000..31051723 --- /dev/null +++ b/app/Services/Billing/Concerns/LocksModelForUpdate.php @@ -0,0 +1,46 @@ +newQuery() + ->whereKey($model->getKey()) + ->lockForUpdate() + ->first(); + + if ($locked === null) { + return null; + } + + return $callback($locked); + }); + } +} diff --git a/app/Services/Billing/CreditTopupService.php b/app/Services/Billing/CreditTopupService.php index 6318577c..7747d534 100644 --- a/app/Services/Billing/CreditTopupService.php +++ b/app/Services/Billing/CreditTopupService.php @@ -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()); }); } } diff --git a/app/Services/Billing/ManualInvoiceService.php b/app/Services/Billing/ManualInvoiceService.php index 4fd81414..74f1b545 100644 --- a/app/Services/Billing/ManualInvoiceService.php +++ b/app/Services/Billing/ManualInvoiceService.php @@ -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; diff --git a/app/Services/Billing/ProofPdfService.php b/app/Services/Billing/ProofPdfService.php index 7f7c1316..fdb18bef 100644 --- a/app/Services/Billing/ProofPdfService.php +++ b/app/Services/Billing/ProofPdfService.php @@ -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, diff --git a/app/Services/PressRelease/BoostService.php b/app/Services/PressRelease/BoostService.php index 12b03509..c2132b3d 100644 --- a/app/Services/PressRelease/BoostService.php +++ b/app/Services/PressRelease/BoostService.php @@ -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:::` — 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(); + } } diff --git a/database/migrations/2026_07_14_125600_create_boost_slot_locks_table.php b/database/migrations/2026_07_14_125600_create_boost_slot_locks_table.php new file mode 100644 index 00000000..24f50a29 --- /dev/null +++ b/database/migrations/2026_07_14_125600_create_boost_slot_locks_table.php @@ -0,0 +1,27 @@ +id(); + $table->string('lock_key', 191)->unique(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('boost_slot_locks'); + } +}; diff --git a/database/migrations/2026_07_14_125700_add_unique_stripe_invoice_id_to_invoices_table.php b/database/migrations/2026_07_14_125700_add_unique_stripe_invoice_id_to_invoices_table.php new file mode 100644 index 00000000..319fc708 --- /dev/null +++ b/database/migrations/2026_07_14_125700_add_unique_stripe_invoice_id_to_invoices_table.php @@ -0,0 +1,24 @@ +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'); + }); + } +}; diff --git a/resources/views/livewire/admin/categories/edit.blade.php b/resources/views/livewire/admin/categories/edit.blade.php index 72dc6487..f9eca2e2 100644 --- a/resources/views/livewire/admin/categories/edit.blade.php +++ b/resources/views/livewire/admin/categories/edit.blade.php @@ -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); } diff --git a/tests/Feature/Billing/DepublicationTest.php b/tests/Feature/Billing/DepublicationTest.php index 8b7d1739..042ec6c6 100644 --- a/tests/Feature/Billing/DepublicationTest.php +++ b/tests/Feature/Billing/DepublicationTest.php @@ -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) { diff --git a/tests/Feature/Billing/ManualInvoiceGenerationTest.php b/tests/Feature/Billing/ManualInvoiceGenerationTest.php index 5513ea54..c43110ee 100644 --- a/tests/Feature/Billing/ManualInvoiceGenerationTest.php +++ b/tests/Feature/Billing/ManualInvoiceGenerationTest.php @@ -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(); diff --git a/tests/Feature/Billing/StripeWebhookProcessingTest.php b/tests/Feature/Billing/StripeWebhookProcessingTest.php index cdfc0e49..b891f3c0 100644 --- a/tests/Feature/Billing/StripeWebhookProcessingTest.php +++ b/tests/Feature/Billing/StripeWebhookProcessingTest.php @@ -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([ diff --git a/tests/Feature/BoostServiceTest.php b/tests/Feature/BoostServiceTest.php index 6f46cb58..649322ba 100644 --- a/tests/Feature/BoostServiceTest.php +++ b/tests/Feature/BoostServiceTest.php @@ -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(); +}); diff --git a/tests/Feature/CreditTopupTest.php b/tests/Feature/CreditTopupTest.php index 656ca7f6..cfdd5381 100644 --- a/tests/Feature/CreditTopupTest.php +++ b/tests/Feature/CreditTopupTest.php @@ -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');