'evt_'.$sessionId, 'type' => 'checkout.session.async_payment_failed', 'data' => ['object' => [ 'id' => $sessionId, 'customer_details' => ['email' => 'kunde@example.test', 'name' => 'Berger GmbH'], 'amount_total' => 21480, 'currency' => 'eur', 'metadata' => ['plan' => 'team'], ]], ]; } it('records a payment that bounced days later', function () { $this->postJson(route('webhooks.stripe'), failedAsyncCheckout())->assertOk(); $problem = FailedCheckout::query()->sole(); expect($problem->email)->toBe('kunde@example.test') ->and($problem->amount_cents)->toBe(21480) ->and($problem->plan)->toBe('team') ->and($problem->resolved_at)->toBeNull(); }); it('records it once, however often Stripe retries the webhook', function () { foreach (range(1, 3) as $ignored) { $this->postJson(route('webhooks.stripe'), failedAsyncCheckout())->assertOk(); } expect(FailedCheckout::query()->count())->toBe(1); }); it('still creates no order for it', function () { // Das war schon richtig und bleibt es: eine geplatzte Zahlung ist kein // Auftrag. Neu ist nur, dass sie eine Spur hinterlässt. $this->postJson(route('webhooks.stripe'), failedAsyncCheckout())->assertOk(); expect(Order::query()->count())->toBe(0); }); it('shows both kinds of problem on one page', function () { $customer = Customer::factory()->create(['name' => 'Berger GmbH', 'stripe_customer_id' => 'cus_42']); $subscription = Subscription::factory()->create(['customer_id' => $customer->id]); DunningCase::query()->create([ 'subscription_id' => $subscription->id, 'stripe_invoice_id' => 'in_1', 'level' => 2, 'opened_at' => Carbon::now()->subDays(10), 'next_step_at' => Carbon::now()->addDays(7), 'fee_invoice_ids' => [], ]); FailedCheckout::query()->create([ 'stripe_session_id' => 'cs_geplatzt', 'email' => 'neu@example.test', 'name' => 'Neu GmbH', 'plan' => 'start', 'amount_cents' => 4900, 'currency' => 'EUR', 'failed_at' => Carbon::now(), ]); Livewire::actingAs(operator('Owner'), 'operator') ->test(PaymentProblems::class) ->assertSee('Berger GmbH') ->assertSee('neu@example.test'); }); it('lets somebody mark a bounced payment as dealt with', function () { $problem = FailedCheckout::query()->create([ 'stripe_session_id' => 'cs_geplatzt', 'email' => 'neu@example.test', 'plan' => 'start', 'amount_cents' => 4900, 'currency' => 'EUR', 'failed_at' => Carbon::now(), ]); // Seit R20 geht die Begründung durch das Modal (siehe ResolveInModalTest): // die Seite fängt nur noch das Ereignis auf. Ein Feld in der Zeile wäre // genau der Inline-Editor, den die Regel verbietet. Livewire::actingAs(operator('Owner'), 'operator') ->test(PaymentProblems::class) ->call('onResolved', $problem->id, 'Kunde hat überwiesen') ->assertHasNoErrors(); $problem->refresh(); expect($problem->resolved_at)->not->toBeNull() // Wer und warum, nicht nur dass. Ein erledigter Vorgang ohne // Begründung ist beim nächsten Nachfragen wertlos. ->and($problem->note)->toContain('überwiesen'); }); it('is not reachable without billing.manage', function () { // Zahlungsprobleme nennen Kundennamen, Beträge und offene Schulden. // `Support` hat console.view, aber nicht billing.manage. Livewire::actingAs(operator('Support'), 'operator') ->test(PaymentProblems::class) ->assertForbidden(); }); it('is reachable for the billing role', function () { // Genau der Fall aus dem Gespräch: die Buchhaltung soll das sehen, ohne // Admin zu sein. Livewire::actingAs(operator('Billing'), 'operator') ->test(PaymentProblems::class) ->assertOk(); });