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

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

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

View file

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

View file

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

View file

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

View file

@ -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) {

View file

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

View file

@ -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([

View file

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

View file

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