diff --git a/app/Http/Controllers/StripeWebhookController.php b/app/Http/Controllers/StripeWebhookController.php index b1f4c19..711ee4d 100644 --- a/app/Http/Controllers/StripeWebhookController.php +++ b/app/Http/Controllers/StripeWebhookController.php @@ -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 $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; + } } diff --git a/app/Livewire/Admin/PaymentProblems.php b/app/Livewire/Admin/PaymentProblems.php new file mode 100644 index 0000000..fa33ab3 --- /dev/null +++ b/app/Livewire/Admin/PaymentProblems.php @@ -0,0 +1,82 @@ + 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(), + ]); + } +} diff --git a/app/Models/FailedCheckout.php b/app/Models/FailedCheckout.php new file mode 100644 index 0000000..94b3bb1 --- /dev/null +++ b/app/Models/FailedCheckout.php @@ -0,0 +1,30 @@ + 'integer', + 'failed_at' => 'datetime', + 'resolved_at' => 'datetime', + ]; + } + + public function isOpen(): bool + { + return $this->resolved_at === null; + } +} diff --git a/app/Support/Navigation.php b/app/Support/Navigation.php index cb59306..7839c21 100644 --- a/app/Support/Navigation.php +++ b/app/Support/Navigation.php @@ -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. diff --git a/database/migrations/2026_07_31_150000_create_failed_checkouts_table.php b/database/migrations/2026_07_31_150000_create_failed_checkouts_table.php new file mode 100644 index 0000000..ddee0c2 --- /dev/null +++ b/database/migrations/2026_07_31_150000_create_failed_checkouts_table.php @@ -0,0 +1,53 @@ +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'); + } +}; diff --git a/lang/de/admin.php b/lang/de/admin.php index 4b7f82a..71cf60a 100644 --- a/lang/de/admin.php +++ b/lang/de/admin.php @@ -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', ], diff --git a/lang/de/payment_problems.php b/lang/de/payment_problems.php new file mode 100644 index 0000000..c16b2f7 --- /dev/null +++ b/lang/de/payment_problems.php @@ -0,0 +1,24 @@ + '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.', +]; diff --git a/lang/en/admin.php b/lang/en/admin.php index 30b9d03..d1a703e 100644 --- a/lang/en/admin.php +++ b/lang/en/admin.php @@ -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', ], diff --git a/lang/en/payment_problems.php b/lang/en/payment_problems.php new file mode 100644 index 0000000..1c5b036 --- /dev/null +++ b/lang/en/payment_problems.php @@ -0,0 +1,24 @@ + '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.', +]; diff --git a/resources/views/livewire/admin/payment-problems.blade.php b/resources/views/livewire/admin/payment-problems.blade.php new file mode 100644 index 0000000..0ee445d --- /dev/null +++ b/resources/views/livewire/admin/payment-problems.blade.php @@ -0,0 +1,93 @@ +
+
+

{{ __('payment_problems.title') }}

+

{{ __('payment_problems.subtitle') }}

+
+ + {{-- 1. Laufende Verträge im Rückstand. --}} +
+
+

{{ __('payment_problems.cases_title') }}

+

{{ __('payment_problems.cases_body') }}

+
+ + @if ($cases->isEmpty()) +

{{ __('payment_problems.cases_none') }}

+ @else +
+ + + + + + + + + + + @foreach ($cases as $case) + + + + {{-- R19: alles, was ein Mensch liest, geht über ->local(). --}} + + + + @endforeach + +
{{ __('payment_problems.customer') }}{{ __('payment_problems.level') }}{{ __('payment_problems.since') }}{{ __('payment_problems.next_step') }}
{{ $case->subscription?->customer?->name ?? '—' }} + $case->isSuspended(), + 'border-warning-border bg-warning-bg text-warning' => ! $case->isSuspended(), + ])>{{ __('payment_problems.level_'.$case->level) }} + {{ $case->opened_at->local()->isoFormat('D. MMM YYYY') }} + {{ $case->next_step_at?->local()->isoFormat('D. MMM YYYY') ?? '—' }} +
+
+ @endif +
+ + {{-- 2. Käufe, deren Zahlung Tage später geplatzt ist. --}} +
+
+

{{ __('payment_problems.failed_title') }}

+

{{ __('payment_problems.failed_body') }}

+
+ + @if ($failed->isEmpty()) +

{{ __('payment_problems.failed_none') }}

+ @else +
    + @foreach ($failed as $problem) +
  • +
    +
    +

    {{ $problem->name ?: $problem->email }}

    +

    {{ $problem->email }}

    +

    + {{ $problem->plan ?? '—' }} · + {{ number_format($problem->amount_cents / 100, 2, ',', '.') }} {{ $problem->currency }} · + {{ $problem->failed_at->local()->isoFormat('D. MMM YYYY, HH:mm') }} +

    +
    +
    + + {{-- Begründung neben dem Knopf, nicht danach: „erledigt" + allein ist beim nächsten Nachfragen wertlos. --}} +
    +
    + +
    + + {{ __('payment_problems.resolve') }} + +
    +
  • + @endforeach +
+ @endif +
+
diff --git a/routes/admin.php b/routes/admin.php index 37772ca..90313d6 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -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'); diff --git a/tests/Feature/Billing/PaymentProblemsTest.php b/tests/Feature/Billing/PaymentProblemsTest.php new file mode 100644 index 0000000..f3daede --- /dev/null +++ b/tests/Feature/Billing/PaymentProblemsTest.php @@ -0,0 +1,124 @@ + '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(); +});