presseportale/tests/Feature/CreditTopupTest.php
Kevin Adametz 7043ea8ad5 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>
2026-07-14 13:47:21 +00:00

136 lines
5.2 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
use App\Enums\SinglePurchaseStatus;
use App\Listeners\ProcessStripeWebhook;
use App\Models\BillingAddress;
use App\Models\BookingConfirmation;
use App\Models\User;
use App\Services\Billing\CreditTopupService;
use App\Services\Billing\CreditWalletService;
use App\Services\Billing\StripeCheckoutService;
use Database\Seeders\RolesAndPermissionsSeeder;
use Illuminate\Http\RedirectResponse;
use Laravel\Cashier\Checkout;
use Laravel\Cashier\Events\WebhookReceived;
beforeEach(function (): void {
$this->seed(RolesAndPermissionsSeeder::class);
$this->topups = app(CreditTopupService::class);
$this->wallet = app(CreditWalletService::class);
});
test('the bonus pack staffel is configured as agreed', function () {
expect($this->topups->packs())->toBe([
['key' => 'p10', 'price_cents' => 1000, 'credits' => 10],
['key' => 'p25', 'price_cents' => 2500, 'credits' => 27],
['key' => 'p50', 'price_cents' => 5000, 'credits' => 55],
['key' => 'p100', 'price_cents' => 10000, 'credits' => 115],
]);
});
test('starting a topup creates a pending record without crediting yet', function () {
$user = User::factory()->create();
$topup = $this->topups->startTopup($user, 'p25');
expect($topup->status)->toBe(SinglePurchaseStatus::Pending);
expect($topup->credits)->toBe(27);
expect($topup->price_cents)->toBe(2500);
expect($this->wallet->balance($user))->toBe(0);
});
test('an unknown pack is rejected', function () {
$user = User::factory()->create();
expect(fn () => $this->topups->startTopup($user, 'nope'))
->toThrow(InvalidArgumentException::class);
});
test('fulfilling a topup credits the wallet exactly once', function () {
$user = User::factory()->create();
$topup = $this->topups->startTopup($user, 'p50');
$this->topups->fulfill($topup, 'cs_test_1', 'pi_test_1');
$this->topups->fulfill($topup->fresh(), 'cs_test_1', 'pi_test_1'); // Replay
expect($this->wallet->balance($user))->toBe(55);
expect($topup->fresh()->status)->toBe(SinglePurchaseStatus::Paid);
expect($topup->fresh()->credited_at)->not->toBeNull();
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');
$event = new WebhookReceived([
'type' => 'checkout.session.completed',
'data' => ['object' => [
'id' => 'cs_test_hook',
'payment_intent' => 'pi_test_hook',
'metadata' => ['credit_topup_id' => (string) $topup->id],
]],
]);
app(ProcessStripeWebhook::class)->handle($event);
expect($this->wallet->balance($user))->toBe(115);
expect($topup->fresh()->credited_at)->not->toBeNull();
});
test('the topup checkout route redirects to the profile without a billing address', function () {
$user = User::factory()->create(['is_active' => true, 'email_verified_at' => now()]);
$user->assignRole('customer');
$this->actingAs($user)
->get(route('me.checkout.credit-topup', ['pack' => 'p25']))
->assertRedirect(route('me.profile'));
});
test('the topup checkout route starts a stripe checkout with a complete billing address', function () {
$user = User::factory()->create(['is_active' => true, 'email_verified_at' => now()]);
$user->assignRole('customer');
BillingAddress::factory()->create(['user_id' => $user->id]);
// B2B-Gate (T1neu): Checkout setzt eine zeitnahe Unternehmer-Bestätigung voraus.
BookingConfirmation::query()->create([
'user_id' => $user->id,
'booking_type' => 'credit_pack',
'booking_ref' => 'p25',
'agb_version' => (string) config('legal.agb_version'),
'confirmed_at' => now(),
]);
$checkout = Mockery::mock(Checkout::class);
$checkout->shouldReceive('toResponse')->andReturn(new RedirectResponse('https://checkout.stripe.com/c/pay/test'));
$mock = Mockery::mock(StripeCheckoutService::class);
$mock->shouldReceive('forCreditTopup')->once()->andReturn($checkout);
app()->instance(StripeCheckoutService::class, $mock);
$this->actingAs($user)
->get(route('me.checkout.credit-topup', ['pack' => 'p25']))
->assertRedirect('https://checkout.stripe.com/c/pay/test');
expect($user->creditTopups()->where('pack_key', 'p25')->exists())->toBeTrue();
});