presseportale/tests/Feature/CreditTopupTest.php
Kevin Adametz ff2eee0988 billing: asynchrone Zahlarten (PayPal, SEPA) korrekt erfüllen
checkout.session.completed feuert bei asynchronen Zahlarten schon
nach dem Redirect mit payment_status=unpaid — der Listener hat
trotzdem sofort erfüllt (Credits gutgeschrieben / Kauf bezahlt),
auch wenn die Zahlung später scheitert. Erfüllung jetzt nur bei
payment_status paid/no_payment_required, zusätzlich reagiert der
Listener auf checkout.session.async_payment_succeeded; Fehlschläge
werden geloggt, der Kauf bleibt wiederholbar auf Pending.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 14:27:09 +00:00

137 lines
5.3 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',
'payment_status' => 'paid',
'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();
});