Zahlungsprobleme: geplatzte Kaeufe aufzeichnen, beide Arten an einer Stelle

main
nexxo 2026-07-31 21:58:52 +02:00
parent 9304382f62
commit 7f7b654fcb
12 changed files with 486 additions and 0 deletions

View File

@ -4,6 +4,7 @@ namespace App\Http\Controllers;
use App\Actions\ApplyStripeBillingEvent;
use App\Actions\StartCustomerProvisioning;
use App\Models\FailedCheckout;
use App\Support\StripeWebhookSecret;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
@ -58,6 +59,16 @@ class StripeWebhookController extends Controller
// Paid triggers: a synchronous checkout (completed + paid) OR an async
// method clearing later (async_payment_succeeded). Ignore everything else,
// including the still-unpaid completed event for async methods.
// Eine Zahlung, die Tage später platzt. Bei SEPA oder
// Sofortüberweisung klärt sie erst nach dem Abschluss; klärt sie
// nicht, kommt genau dieses Ereignis — bis zum 31.7.2026 empfangen
// und ignoriert. Dass daraus KEIN Auftrag entsteht, war richtig und
// bleibt so. Falsch war, dass gar nichts entstand: ein verlorener
// Verkauf, von dem niemand hört.
if ($type === 'checkout.session.async_payment_failed') {
return response()->json(['recorded' => $this->recordFailedCheckout($object)]);
}
$paid = ($type === 'checkout.session.completed' && ($object['payment_status'] ?? null) === 'paid')
|| $type === 'checkout.session.async_payment_succeeded';
if (! $paid) {
@ -164,4 +175,40 @@ class StripeWebhookController extends Controller
return false;
}
/**
* Den geplatzten Kauf festhalten so viel, wie nötig ist, um jemanden
* zurückzurufen, und keine Zeile mehr.
*
* Ohne E-Mail-Adresse gar nicht: eine Zeile, die niemanden benennt, ist
* für den Betreiber nicht handhabbar und für den Kunden folgenlos.
*
* @param array<string, mixed> $object
*/
private function recordFailedCheckout(array $object): bool
{
$email = $object['customer_details']['email'] ?? $object['customer_email'] ?? null;
$sessionId = (string) ($object['id'] ?? '');
if (blank($email) || $sessionId === '') {
return false;
}
// firstOrCreate, weil Stripe einen Webhook wiederholt, bis er ein 2xx
// bekommt — sonst stünde derselbe verlorene Verkauf dreimal da. Die
// Eindeutigkeit steht zusätzlich als Index in der Tabelle.
FailedCheckout::query()->firstOrCreate(
['stripe_session_id' => $sessionId],
[
'email' => (string) $email,
'name' => $object['customer_details']['name'] ?? null,
'plan' => $object['metadata']['plan'] ?? null,
'amount_cents' => (int) ($object['amount_total'] ?? 0),
'currency' => strtoupper((string) ($object['currency'] ?? 'eur')),
'failed_at' => now(),
],
);
return true;
}
}

View File

@ -0,0 +1,82 @@
<?php
namespace App\Livewire\Admin;
use App\Models\DunningCase;
use App\Models\FailedCheckout;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Gate;
use Livewire\Attributes\Layout;
use Livewire\Component;
/**
* Wo Geld ausbleibt beide Arten an einer Stelle.
*
* 1. **Mahnfälle.** Ein laufender Vertrag, dessen Abbuchung gescheitert ist.
* Der Fall wandert durch die Stufen des DunningSchedule bis zur Begleichung
* oder zur Sperre.
* 2. **Geplatzte Käufe.** Bei SEPA oder Sofortüberweisung klärt die Zahlung
* erst nach dem Abschluss der Kasse. Klärt sie nicht, entsteht kein Auftrag
* richtig so und bis zum 31.7.2026 auch sonst nichts. Ein verlorener
* Verkauf, von dem niemand hörte.
*
* Eine Seite und nicht zwei: beide Male ist die Frage dieselbe wer schuldet
* was, und was ist der nächste Schritt. Zwei Seiten hiessen zwei Orte, an denen
* man nachsehen muss, und einer davon wird vergessen.
*
* Hinter `billing.manage`, nicht hinter `hosts.manage`: hier stehen Kundennamen,
* Beträge und offene Schulden. Die Rolle `Billing` hat genau dieses Recht und
* sonst nur `console.view` die Buchhaltung sieht das, ohne Admin zu sein.
*/
#[Layout('layouts.admin')]
class PaymentProblems extends Component
{
/** @var array<int, string> FailedCheckout-ID => Begründung */
public array $note = [];
public function mount(): void
{
abort_unless(Gate::allows('billing.manage'), 403);
}
/**
* Einen geplatzten Kauf als erledigt abhaken.
*
* Mit Begründung, nicht nur mit einem Haken: „erledigt" allein ist beim
* nächsten Nachfragen wertlos überwiesen? neu bestellt? nicht erreichbar?
* Wer es war, steht mit dabei.
*/
public function resolve(int $id): void
{
$this->authorize('billing.manage');
$problem = FailedCheckout::query()->findOr($id, fn () => abort(404));
$begruendung = trim($this->note[$id] ?? '');
$wer = Auth::guard('operator')->user()?->email ?? 'console';
$problem->update([
'resolved_at' => Carbon::now(),
'note' => $begruendung !== '' ? $begruendung.' — '.$wer : $wer,
]);
unset($this->note[$id]);
$this->dispatch('notify', message: __('payment_problems.resolved'));
}
public function render()
{
return view('livewire.admin.payment-problems', [
'cases' => DunningCase::query()
->whereNull('settled_at')
->with('subscription.customer')
->orderBy('next_step_at')
->get(),
'failed' => FailedCheckout::query()
->whereNull('resolved_at')
->orderByDesc('failed_at')
->get(),
]);
}
}

View File

@ -0,0 +1,30 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
/**
* Ein Kauf, dessen Zahlung Tage später geplatzt ist siehe die Migration.
*/
class FailedCheckout extends Model
{
protected $fillable = [
'stripe_session_id', 'email', 'name', 'plan',
'amount_cents', 'currency', 'failed_at', 'resolved_at', 'note',
];
protected function casts(): array
{
return [
'amount_cents' => 'integer',
'failed_at' => 'datetime',
'resolved_at' => 'datetime',
];
}
public function isOpen(): bool
{
return $this->resolved_at === null;
}
}

View File

@ -67,6 +67,7 @@ final class Navigation
// site-visibility switch invites somebody to change one in passing.
['admin.finance', 'receipt', 'finance', 'site.manage'],
['admin.invoices', 'file-text', 'invoices', 'site.manage'],
['admin.payment-problems', 'alert-triangle', 'payment_problems', 'billing.manage'],
// What customers wrote to us, and what we sent them. Under
// Betrieb rather than System: both are read while answering
// somebody, not while configuring a machine.

View File

@ -0,0 +1,53 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Käufe, deren Zahlung Tage später geplatzt ist.
*
* Bei SEPA oder Sofortüberweisung schliesst der Kunde die Kasse ab, und die
* Zahlung klärt erst danach. Klärt sie NICHT, schickt Stripe
* `checkout.session.async_payment_failed` bis zum 31.7.2026 empfangen und
* ignoriert.
*
* Dass daraus kein Auftrag entsteht, war richtig und bleibt so. Falsch war,
* dass gar nichts entstand: für den Betreiber ein verlorener Verkauf, von dem
* er nie hört, für den Kunden eine Bestellung, die es nicht gibt.
*
* Bewusst KEIN Auftrag und keine Kundenzeile: es hat nie jemand bezahlt. Nur
* so viel, wie nötig ist, um jemanden zurückzurufen.
*/
return new class extends Migration
{
public function up(): void
{
Schema::create('failed_checkouts', function (Blueprint $table) {
$table->id();
// Stripe wiederholt einen Webhook, bis er ein 2xx bekommt. Ohne
// diese Eindeutigkeit stünde derselbe verlorene Verkauf dreimal da.
$table->string('stripe_session_id')->unique();
$table->string('email');
$table->string('name')->nullable();
$table->string('plan')->nullable();
$table->unsignedInteger('amount_cents')->default(0);
$table->string('currency', 3)->default('EUR');
$table->timestamp('failed_at');
$table->timestamp('resolved_at')->nullable()->index();
/** Warum erledigt — überwiesen, erneut bestellt, nicht erreichbar. */
$table->text('note')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('failed_checkouts');
}
};

View File

@ -30,11 +30,13 @@ return [
'vpn' => 'VPN',
'finance' => 'Finanzen',
'invoices' => 'Rechnungen',
'payment_problems' => 'Zahlungsprobleme',
'revenue' => 'Umsatz',
'mail' => 'E-Mail',
'integrations' => 'Integrationen',
'readiness' => 'Bereitschaft',
'settings' => 'Einstellungen',
'roles' => 'Rollen',
'two_factor_setup' => 'Zwei-Faktor-Anmeldung',
],

View File

@ -0,0 +1,24 @@
<?php
return [
'title' => 'Zahlungsprobleme',
'subtitle' => 'Wo Geld ausbleibt — laufende Verträge im Rückstand und Käufe, deren Zahlung geplatzt ist.',
'cases_title' => 'Verträge im Rückstand',
'cases_body' => 'Eine Abbuchung ist gescheitert. Der Fall wandert durch die Mahnstufen, bis er beglichen ist oder die Cloud abgeschaltet wird.',
'cases_none' => 'Kein Vertrag ist im Rückstand.',
'customer' => 'Kunde',
'level' => 'Stufe',
'since' => 'Offen seit',
'next_step' => 'Nächster Schritt',
'level_0' => 'Hinweis',
'level_1' => '1. Mahnung',
'level_2' => '2. Mahnung',
'level_3' => '3. Mahnung',
'level_4' => 'Gesperrt',
'failed_title' => 'Geplatzte Zahlungen',
'failed_body' => 'Bei SEPA oder Sofortüberweisung klärt die Zahlung erst nach dem Abschluss. Klärt sie nicht, entsteht kein Auftrag — der Kunde wollte aber kaufen. Ein Rückruf lohnt sich meist.',
'failed_none' => 'Keine geplatzte Zahlung offen.',
'note' => 'Begründung',
'resolve' => 'Erledigt',
'resolved' => 'Als erledigt vermerkt.',
];

View File

@ -30,11 +30,13 @@ return [
'vpn' => 'VPN',
'finance' => 'Finance',
'invoices' => 'Invoices',
'payment_problems' => 'Payment problems',
'revenue' => 'Revenue',
'mail' => 'Email',
'integrations' => 'Integrations',
'readiness' => 'Readiness',
'settings' => 'Settings',
'roles' => 'Roles',
'two_factor_setup' => 'Two-factor login',
],

View File

@ -0,0 +1,24 @@
<?php
return [
'title' => 'Payment problems',
'subtitle' => 'Where money is missing — contracts in arrears, and purchases whose payment bounced.',
'cases_title' => 'Contracts in arrears',
'cases_body' => 'A charge failed. The case moves through the reminder levels until it is settled or the cloud is shut down.',
'cases_none' => 'No contract is in arrears.',
'customer' => 'Customer',
'level' => 'Level',
'since' => 'Open since',
'next_step' => 'Next step',
'level_0' => 'Notice',
'level_1' => '1st reminder',
'level_2' => '2nd reminder',
'level_3' => '3rd reminder',
'level_4' => 'Suspended',
'failed_title' => 'Bounced payments',
'failed_body' => 'With SEPA or Sofort the payment clears only after checkout. When it does not, no order is created — but the customer meant to buy. A call back is usually worth it.',
'failed_none' => 'No bounced payment outstanding.',
'note' => 'Reason',
'resolve' => 'Done',
'resolved' => 'Marked as dealt with.',
];

View File

@ -0,0 +1,93 @@
<div class="mx-auto max-w-[1120px] space-y-6">
<div class="animate-rise">
<h1 class="text-2xl font-bold tracking-tight text-ink">{{ __('payment_problems.title') }}</h1>
<p class="mt-1 max-w-[70ch] text-sm text-muted">{{ __('payment_problems.subtitle') }}</p>
</div>
{{-- 1. Laufende Verträge im Rückstand. --}}
<div class="space-y-4 rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise">
<div>
<h2 class="font-semibold text-ink">{{ __('payment_problems.cases_title') }}</h2>
<p class="mt-1 text-sm text-muted">{{ __('payment_problems.cases_body') }}</p>
</div>
@if ($cases->isEmpty())
<p class="text-sm text-muted">{{ __('payment_problems.cases_none') }}</p>
@else
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-line text-left text-xs font-medium text-muted">
<th class="py-2 pr-4">{{ __('payment_problems.customer') }}</th>
<th class="py-2 pr-4">{{ __('payment_problems.level') }}</th>
<th class="py-2 pr-4">{{ __('payment_problems.since') }}</th>
<th class="py-2">{{ __('payment_problems.next_step') }}</th>
</tr>
</thead>
<tbody>
@foreach ($cases as $case)
<tr wire:key="case-{{ $case->id }}" class="border-b border-line last:border-0">
<td class="py-3 pr-4 text-ink">{{ $case->subscription?->customer?->name ?? '—' }}</td>
<td class="py-3 pr-4">
<span @class([
'rounded-pill border px-2.5 py-0.5 text-xs font-medium',
'border-danger-border bg-danger-bg text-danger' => $case->isSuspended(),
'border-warning-border bg-warning-bg text-warning' => ! $case->isSuspended(),
])>{{ __('payment_problems.level_'.$case->level) }}</span>
</td>
{{-- R19: alles, was ein Mensch liest, geht über ->local(). --}}
<td class="py-3 pr-4 text-muted">{{ $case->opened_at->local()->isoFormat('D. MMM YYYY') }}</td>
<td class="py-3 text-muted">
{{ $case->next_step_at?->local()->isoFormat('D. MMM YYYY') ?? '—' }}
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@endif
</div>
{{-- 2. Käufe, deren Zahlung Tage später geplatzt ist. --}}
<div class="space-y-4 rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise [animation-delay:30ms]">
<div>
<h2 class="font-semibold text-ink">{{ __('payment_problems.failed_title') }}</h2>
<p class="mt-1 max-w-[70ch] text-sm text-muted">{{ __('payment_problems.failed_body') }}</p>
</div>
@if ($failed->isEmpty())
<p class="text-sm text-muted">{{ __('payment_problems.failed_none') }}</p>
@else
<ul class="space-y-3">
@foreach ($failed as $problem)
<li wire:key="failed-{{ $problem->id }}" class="rounded-lg border border-line bg-surface-2 p-4">
<div class="flex flex-wrap items-start justify-between gap-3">
<div class="min-w-0">
<p class="text-sm font-medium text-ink">{{ $problem->name ?: $problem->email }}</p>
<p class="mt-0.5 font-mono text-xs text-muted">{{ $problem->email }}</p>
<p class="mt-1 text-xs text-muted">
{{ $problem->plan ?? '—' }} ·
{{ number_format($problem->amount_cents / 100, 2, ',', '.') }} {{ $problem->currency }} ·
{{ $problem->failed_at->local()->isoFormat('D. MMM YYYY, HH:mm') }}
</p>
</div>
</div>
{{-- Begründung neben dem Knopf, nicht danach: „erledigt"
allein ist beim nächsten Nachfragen wertlos. --}}
<div class="mt-3 flex flex-wrap items-end gap-2">
<div class="min-w-56 flex-1">
<x-ui.input name="note-{{ $problem->id }}" wire:model="note.{{ $problem->id }}"
:label="__('payment_problems.note')" />
</div>
<x-ui.button variant="secondary" wire:click="resolve({{ $problem->id }})"
wire:loading.attr="disabled" wire:target="resolve({{ $problem->id }})">
{{ __('payment_problems.resolve') }}
</x-ui.button>
</div>
</li>
@endforeach
</ul>
@endif
</div>
</div>

View File

@ -135,6 +135,10 @@ Route::get('/settings', Admin\Settings::class)->name('settings');
// Einstellungen: eine Matrix aus sechs Rollen und einundzwanzig Rechten ist
// keine Karte mehr, und wer sie öffnet, tut genau eine Sache.
Route::get('/roles', Admin\Roles::class)->name('roles');
// Wo Geld ausbleibt, beide Arten an einer Stelle: Verträge im Rückstand und
// Käufe, deren Zahlung Tage später geplatzt ist. Hinter billing.manage, damit
// die Buchhaltung es sieht, ohne Admin zu sein.
Route::get('/payment-problems', Admin\PaymentProblems::class)->name('payment-problems');
// Its own route so RequireOperatorTwoFactor can exempt ENROLMENT without
// exempting the rest of admin.settings — see that middleware for why.
Route::get('/two-factor-setup', Admin\TwoFactorSetup::class)->name('two-factor-setup');

View File

@ -0,0 +1,124 @@
<?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();
});