Zahlungsprobleme: geplatzte Kaeufe aufzeichnen, beide Arten an einer Stelle
parent
9304382f62
commit
7f7b654fcb
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
|
@ -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',
|
||||
],
|
||||
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
];
|
||||
|
|
@ -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',
|
||||
],
|
||||
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
];
|
||||
|
|
@ -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>
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
Loading…
Reference in New Issue