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>
137 lines
5.3 KiB
PHP
137 lines
5.3 KiB
PHP
<?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();
|
||
});
|