125 lines
4.6 KiB
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();
|
|
});
|