CluPilotCloud/tests/Feature/Billing/PaymentProblemsTest.php

125 lines
4.6 KiB
PHP

<?php
use App\Livewire\Admin\PaymentProblems;
use App\Models\Customer;
use App\Models\DunningCase;
use App\Models\FailedCheckout;
use App\Models\Order;
use App\Models\Subscription;
use Illuminate\Support\Carbon;
use Livewire\Livewire;
/**
* Zwei Arten, wie Geld ausbleibt, an einer Stelle.
*
* 1. Ein laufender Vertrag, dessen Abbuchung scheitert — daraus wird ein
* Mahnfall (DunningCase).
* 2. Ein Kauf, der nie zustande kam: bei SEPA oder Sofortüberweisung schliesst
* der Kunde die Kasse ab, die Zahlung platzt Tage später, und
* `checkout.session.async_payment_failed` wurde bis hierher EMPFANGEN UND
* IGNORIERT. Es entsteht kein Auftrag — das ist richtig —, aber es entsteht
* auch keine Zeile. Für den Betreiber ein verlorener Verkauf, von dem er
* nie hört.
*/
function failedAsyncCheckout(string $sessionId = 'cs_geplatzt'): array
{
return [
'id' => '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(),
]);
Livewire::actingAs(operator('Owner'), 'operator')
->test(PaymentProblems::class)
->set("note.{$problem->id}", 'Kunde hat überwiesen')
->call('resolve', $problem->id)
->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();
});