Portal: Karte tauschen, offene Rechnungen begleichen, Hinweis bei Faelligkeit

main
nexxo 2026-07-31 21:12:31 +02:00
parent 88382069b8
commit b8a774b604
11 changed files with 654 additions and 0 deletions

View File

@ -606,6 +606,19 @@ class Billing extends Component
: collect();
return view('livewire.billing', [
// Ob eine Abbuchung offen ist. Bis zum 31.7.2026 stand dazu
// nichts im Portal: invoicePaymentFailed() setzt `past_due` und
// schweigt, und der Kunde erfuhr davon erst, wenn seine Cloud
// stand.
'pastDue' => $customer !== null && $customer->subscriptions()
->where('stripe_status', 'past_due')
->exists(),
// Die zwei Bauteile darunter brauchen einen Stripe-Kunden: ohne
// ihn gibt es weder ein Zahlungsmittel zu tauschen noch eine
// Rechnung zu begleichen. Wer noch nie an der Kasse war, hat
// keinen — und bekommt hier folgerichtig auch keine leere Karte
// gezeigt, sondern gar keine.
'hasStripeCustomer' => $customer !== null && filled($customer->stripe_customer_id),
'hasContract' => $hasContract,
'currentKey' => $currentKey,
// What they HAVE comes from their contract; only what they could

View File

@ -0,0 +1,77 @@
<?php
namespace App\Livewire;
use App\Models\Customer;
use App\Services\Stripe\StripeClient;
use Illuminate\Support\Facades\Auth;
use Livewire\Component;
/**
* Was der Kunde noch schuldet, und der Knopf, es zu begleichen.
*
* Die Liste kommt bei jedem Rendern frisch von Stripe. Eine zwischengelagerte
* Kopie zeigte einen Bezahlknopf neben einer Rechnung, die inzwischen beglichen
* ist und der zweite Klick wäre eine zweite Abbuchung.
*/
class OpenInvoices extends Component
{
/**
* Was Stripe zur letzten Zahlung gesagt hat, wenn sie nicht durchging.
*
* Stripes eigener Wortlaut, nicht eine eigene Umschreibung: er nennt den
* Grund abgelaufen, abgelehnt, Deckung und danach kann der Kunde
* handeln. „Zahlung fehlgeschlagen" könnte er nur weiterreichen.
*/
public ?string $failure = null;
public function pay(string $invoiceId): void
{
$stripe = app(StripeClient::class);
$customerId = $this->stripeCustomerId();
// Die ID kommt aus dem Browser. Nur was in DIESER Liste steht, darf
// eingezogen werden — sonst reicht jemand eine fremde Rechnungs-ID ein
// und Stripe bucht sie von der Karte eines fremden Kunden ab.
$offen = array_column($stripe->openInvoices($customerId), 'id');
abort_unless(in_array($invoiceId, $offen, true), 403);
$ergebnis = $stripe->payInvoice($invoiceId);
$this->failure = $ergebnis['paid'] ? null : $ergebnis['failure'];
if ($ergebnis['paid']) {
$this->dispatch('notify', message: __('billing.invoice_paid'));
}
}
public function payAll(): void
{
foreach (app(StripeClient::class)->openInvoices($this->stripeCustomerId()) as $rechnung) {
$this->pay($rechnung['id']);
// Nach der ersten Ablehnung anhalten. Alle weiteren scheitern an
// derselben Karte, und drei Ablehnungen hintereinander lassen
// Stripes Betrugserkennung anschlagen — danach lehnt sie auch die
// Karte ab, die in Ordnung wäre.
if ($this->failure !== null) {
return;
}
}
}
private function stripeCustomerId(): string
{
$customer = Customer::forUser(Auth::user());
abort_if($customer === null || blank($customer->stripe_customer_id), 404);
return (string) $customer->stripe_customer_id;
}
public function render()
{
return view('livewire.open-invoices', [
'invoices' => app(StripeClient::class)->openInvoices($this->stripeCustomerId()),
]);
}
}

View File

@ -0,0 +1,95 @@
<?php
namespace App\Livewire;
use App\Models\Customer;
use App\Services\Stripe\StripeClient;
use App\Support\StripePublishableKey;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Validator;
use Livewire\Component;
/**
* Die Karte des Kunden, getauscht ohne dass ein Kartendatum diesen Server
* berührt: Stripe Elements schickt sie direkt an Stripe und bekommt eine ID
* zurück, und nur die kommt hier an.
*
* Bis zum 31. Juli 2026 gab es das nicht im ganzen Portal keine
* Zahlungsmittel-Verwaltung. Eine abgelaufene Karte war damit eine Sackgasse,
* und jeder Mahnlauf darüber hätte jemanden für etwas gemahnt, das er nicht
* abstellen kann.
*
* Der SetupIntent entsteht beim Aufbau der Seite. Er ist an den Stripe-Kunden
* gebunden und überlebt einen Neuaufbau, kostet also nichts, wenn niemand
* etwas eingibt.
*/
class PaymentMethod extends Component
{
/**
* Das Geheimnis, mit dem der Browser den SetupIntent bestätigt.
*
* Null heißt: es fehlt der veröffentlichbare Schlüssel. Dann wird das
* gesagt, statt ein Kartenfeld zu zeichnen, das nie lädt der Kunde tippt
* sonst seine Nummer ein, nichts passiert, und er hält seine Karte für
* abgelehnt.
*/
public ?string $clientSecret = null;
/** @var array{id: string, brand: string, last4: string, exp_month: int, exp_year: int}|null */
public ?array $card = null;
public function mount(): void
{
$stripe = app(StripeClient::class);
$customerId = $this->stripeCustomerId();
$this->card = $stripe->defaultPaymentMethod($customerId);
if (StripePublishableKey::current() === '') {
return;
}
$this->clientSecret = $stripe->createSetupIntent($customerId)['client_secret'];
}
/**
* Vom Browser gerufen, nachdem Elements den SetupIntent bestätigt hat.
*
* Die ID kommt damit aus dem Browser und wird geprüft, bevor sie an Stripe
* weitergereicht wird sonst wäre das ein Weg, einen beliebigen Bezeichner
* an das eigene Stripe-Konto zu hängen.
*/
public function confirmed(string $paymentMethodId): void
{
Validator::make(
['paymentMethodId' => $paymentMethodId],
['paymentMethodId' => ['required', 'string', 'max:255', 'regex:/^pm_[A-Za-z0-9]+$/']],
)->validate();
$stripe = app(StripeClient::class);
$customerId = $this->stripeCustomerId();
// Hinterlegen genügt nicht — ohne diesen Schritt bucht Stripe weiter
// mit der alten Karte ab.
$stripe->setDefaultPaymentMethod($customerId, $paymentMethodId);
$this->card = $stripe->defaultPaymentMethod($customerId);
$this->dispatch('notify', message: __('billing.card_saved'));
}
private function stripeCustomerId(): string
{
$customer = Customer::forUser(Auth::user());
abort_if($customer === null || blank($customer->stripe_customer_id), 404);
return (string) $customer->stripe_customer_id;
}
public function render()
{
return view('livewire.payment-method', [
'publishableKey' => StripePublishableKey::current(),
]);
}
}

View File

@ -1,6 +1,24 @@
<?php
return [
'past_due_title' => 'Eine Abbuchung ist fehlgeschlagen.',
'past_due_body' => 'Bitte prüfen Sie Ihr Zahlungsmittel und begleichen Sie den offenen Betrag. Ihre Cloud läuft vorerst weiter.',
'invoices_open_title' => 'Offene Rechnungen',
'invoices_open_body' => 'Wird über das hinterlegte Zahlungsmittel eingezogen.',
'invoices_none' => 'Es ist nichts offen.',
'invoice_number' => 'Nummer',
'invoice_date' => 'Datum',
'invoice_amount' => 'Betrag',
'invoice_pay' => 'Jetzt bezahlen',
'invoice_pay_all' => 'Alles offene bezahlen',
'invoice_paid' => 'Bezahlt. Danke.',
'card_title' => 'Zahlungsmittel',
'card_body' => 'Die Karte, von der Ihre Pakete abgebucht werden. Sie wird direkt an Stripe übermittelt — die Nummer erreicht diesen Server nie.',
'card_on_file' => 'Hinterlegt: :brand •••• :last4, gültig bis :month/:year',
'card_new' => 'Neue Karte',
'card_save' => 'Karte speichern',
'card_saved' => 'Karte gespeichert. Ab der nächsten Abbuchung gilt sie.',
'card_unavailable' => 'Die Kartenmaske kann gerade nicht geladen werden — dieser Installation fehlt der veröffentlichbare Stripe-Schlüssel. Bitte wenden Sie sich an den Support.',
// Kleinere Pakete werden auch dann gezeigt, wenn sie gerade nicht gehen —
// mit dem Grund in den Zahlen des Kunden.
'downgrade' => 'Kleineres Paket',

View File

@ -1,6 +1,24 @@
<?php
return [
'past_due_title' => 'A payment failed.',
'past_due_body' => 'Please check your payment method and settle the outstanding amount. Your cloud keeps running for now.',
'invoices_open_title' => 'Outstanding invoices',
'invoices_open_body' => 'Charged to the payment method on file.',
'invoices_none' => 'Nothing is outstanding.',
'invoice_number' => 'Number',
'invoice_date' => 'Date',
'invoice_amount' => 'Amount',
'invoice_pay' => 'Pay now',
'invoice_pay_all' => 'Pay everything outstanding',
'invoice_paid' => 'Paid. Thank you.',
'card_title' => 'Payment method',
'card_body' => 'The card your packages are charged to. It goes straight to Stripe — the number never reaches this server.',
'card_on_file' => 'On file: :brand •••• :last4, valid until :month/:year',
'card_new' => 'New card',
'card_save' => 'Save card',
'card_saved' => 'Card saved. It applies from the next charge.',
'card_unavailable' => 'The card form cannot be loaded right now — this installation has no publishable Stripe key. Please contact support.',
// Smaller plans are listed even when they cannot be taken, with the reason
// in the customer's own numbers.
'downgrade' => 'Smaller package',

View File

@ -7,6 +7,24 @@
<p class="mt-1 text-sm text-muted">{{ __('billing.subtitle') }}</p>
</div>
{{-- Ganz oben, vor allem anderen: wer hier landet, weil eine Abbuchung
gescheitert ist, soll nicht erst an Paketen vorbeiscrollen. Darunter
die zwei Bauteile, die das Problem tatsächlich lösen offene
Rechnungen begleichen und die Karte tauschen. --}}
@if ($pastDue)
<x-ui.alert variant="danger" class="animate-rise">
<p class="font-semibold">{{ __('billing.past_due_title') }}</p>
<p class="mt-1 text-sm">{{ __('billing.past_due_body') }}</p>
</x-ui.alert>
@endif
@if ($hasStripeCustomer)
<div class="grid gap-5 animate-rise [animation-delay:20ms] lg:grid-cols-2 lg:items-start">
@livewire('open-invoices')
@livewire('payment-method')
</div>
@endif
{{-- purchase() refuses before touching anything when the active mode has
no Stripe key a sentence here, not a 500 page, and not a cart entry
nobody can ever pay for.

View File

@ -0,0 +1,60 @@
<div class="space-y-4 rounded-lg border border-line bg-surface p-6 shadow-xs">
<div>
<h2 class="font-semibold text-ink">{{ __('billing.invoices_open_title') }}</h2>
<p class="mt-1 max-w-[70ch] text-sm text-muted">{{ __('billing.invoices_open_body') }}</p>
</div>
@if ($failure !== null)
{{-- Stripes Wortlaut, nicht unsere Zusammenfassung: er nennt den Grund,
und danach kann der Kunde handeln. --}}
<x-ui.alert variant="danger">{{ $failure }}</x-ui.alert>
@endif
@if ($invoices === [])
<p class="text-sm text-muted">{{ __('billing.invoices_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">{{ __('billing.invoice_number') }}</th>
<th class="py-2 pr-4">{{ __('billing.invoice_date') }}</th>
<th class="py-2 pr-4 text-right">{{ __('billing.invoice_amount') }}</th>
<th class="py-2"></th>
</tr>
</thead>
<tbody>
@foreach ($invoices as $invoice)
<tr wire:key="invoice-{{ $invoice['id'] }}" class="border-b border-line last:border-0">
<td class="py-3 pr-4 font-mono text-xs text-body">{{ $invoice['number'] ?? $invoice['id'] }}</td>
<td class="py-3 pr-4 text-muted">
{{-- Stripes `created` ist ein Unix-Zeitstempel, kein
Carbon. ->local() ist R19 und nicht verhandelbar:
was ein Mensch liest, geht auf die Wanduhr. --}}
{{ \Illuminate\Support\Carbon::createFromTimestamp((int) $invoice['created_at'])->local()->isoFormat('D. MMM YYYY') }}
</td>
<td class="py-3 pr-4 text-right font-medium text-ink">
{{ number_format($invoice['amount_due_cents'] / 100, 2, ',', '.') }} {{ $invoice['currency'] }}
</td>
<td class="py-3 text-right">
<x-ui.button variant="secondary" size="sm"
wire:click="pay('{{ $invoice['id'] }}')"
wire:loading.attr="disabled"
wire:target="pay('{{ $invoice['id'] }}')">
{{ __('billing.invoice_pay') }}
</x-ui.button>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@if (count($invoices) > 1)
<x-ui.button variant="primary" wire:click="payAll"
wire:loading.attr="disabled" wire:target="payAll">
{{ __('billing.invoice_pay_all') }}
</x-ui.button>
@endif
@endif
</div>

View File

@ -0,0 +1,83 @@
<div class="space-y-4 rounded-lg border border-line bg-surface p-6 shadow-xs">
<div>
<h2 class="font-semibold text-ink">{{ __('billing.card_title') }}</h2>
<p class="mt-1 max-w-[70ch] text-sm text-muted">{{ __('billing.card_body') }}</p>
</div>
@if ($card !== null)
<p class="rounded-lg border border-line bg-surface-2 px-4 py-3 text-sm text-body">
{{ __('billing.card_on_file', [
'brand' => \Illuminate\Support\Str::title($card['brand']),
'last4' => $card['last4'],
'month' => str_pad((string) $card['exp_month'], 2, '0', STR_PAD_LEFT),
'year' => $card['exp_year'],
]) }}
</p>
@endif
@if ($clientSecret === null)
<x-ui.alert variant="warning">{{ __('billing.card_unavailable') }}</x-ui.alert>
@else
{{-- Stripe.js kommt von js.stripe.com und wird bewusst NICHT selbst
gehostet. Das ist die eine Ausnahme von „keine fremden Quellen im
Browser": Stripe untersagt das Spiegeln, und eine eigene Kopie
nähme dieser Installation die vereinfachte PCI-Einstufung (SAQ-A),
weil dann wieder Kartendaten durch eigenen Code laufen könnten.
Nur auf dieser Seite geladen, nicht im Layout jede andere Seite
des Portals kommt ohne aus und soll auch keine Verbindung dorthin
aufbauen.
`wire:ignore` um das Feld: Elements hängt sein eigenes iframe
hinein, und ein Livewire-Neuaufbau würde es abreißen. --}}
<div wire:ignore>
<label for="card-element" class="block text-sm font-medium text-body">{{ __('billing.card_new') }}</label>
<div id="card-element" class="mt-1.5 rounded border border-line-strong bg-surface px-3.5 py-3"></div>
<p id="card-error" class="mt-1.5 text-xs text-danger" role="alert"></p>
</div>
<x-ui.button variant="primary" id="card-submit" type="button">
{{ __('billing.card_save') }}
</x-ui.button>
@script
<script>
const laden = () => new Promise((fertig) => {
if (window.Stripe) { fertig(); return; }
const skript = document.createElement('script');
skript.src = 'https://js.stripe.com/v3/';
skript.onload = fertig;
document.head.appendChild(skript);
});
laden().then(() => {
const stripe = Stripe(@js($publishableKey));
const karte = stripe.elements().create('card');
karte.mount('#card-element');
const knopf = document.getElementById('card-submit');
const fehler = document.getElementById('card-error');
knopf.addEventListener('click', async () => {
knopf.disabled = true;
fehler.textContent = '';
const { setupIntent, error } = await stripe.confirmCardSetup(
@js($clientSecret), { payment_method: { card: karte } }
);
knopf.disabled = false;
// Stripes eigene Meldung, nicht eine eigene Umschreibung:
// sie nennt den Grund (abgelaufen, abgelehnt, falsche
// Prüfziffer), und der Kunde kann danach handeln.
if (error) { fehler.textContent = error.message; return; }
$wire.confirmed(setupIntent.payment_method);
});
});
</script>
@endscript
@endif
</div>

View File

@ -0,0 +1,106 @@
<?php
use App\Livewire\OpenInvoices;
use App\Models\Customer;
use App\Models\User;
use App\Services\Stripe\FakeStripeClient;
use App\Services\Stripe\StripeClient;
use Livewire\Livewire;
function portalUserOwing(FakeStripeClient $fake): User
{
$fake->openInvoiceRows = [[
'id' => 'in_1', 'number' => 'R-1', 'amount_due_cents' => 21480,
'currency' => 'EUR', 'created_at' => '1750000000',
]];
$user = User::factory()->create(['email' => 'schuldner@portal.test', 'is_admin' => false]);
Customer::factory()->create(['email' => 'schuldner@portal.test', 'stripe_customer_id' => 'cus_42']);
return $user;
}
it('shows what is still owed', function () {
$fake = new FakeStripeClient;
app()->instance(StripeClient::class, $fake);
Livewire::actingAs(portalUserOwing($fake))
->test(OpenInvoices::class)
->assertSee('R-1')
->assertSee('214,80');
});
it('settles an invoice when the customer asks', function () {
$fake = new FakeStripeClient;
app()->instance(StripeClient::class, $fake);
Livewire::actingAs(portalUserOwing($fake))
->test(OpenInvoices::class)
->call('pay', 'in_1')
->assertHasNoErrors();
expect($fake->paidInvoices)->toBe(['in_1']);
});
it('shows a refusal instead of pretending it worked', function () {
// Der Fall, der wirklich eintritt. Eine grüne Meldung über einer
// abgelehnten Karte ist die teuerste Anzeige, die diese Seite haben kann:
// der Kunde geht davon aus, es sei erledigt, und die nächste Nachricht,
// die er bekommt, ist eine Mahnung.
$fake = new FakeStripeClient;
$fake->payDeclines = true;
app()->instance(StripeClient::class, $fake);
Livewire::actingAs(portalUserOwing($fake))
->test(OpenInvoices::class)
->call('pay', 'in_1')
->assertSee('declined');
expect($fake->paidInvoices)->toBe([]);
});
it('refuses to pay an invoice that is not this customer\'s', function () {
// Die ID kommt aus dem Browser. Ohne diese Prüfung könnte jemand eine
// fremde Rechnungs-ID einreichen — und Stripe zöge sie beim fremden Kunden
// ein, auf dessen Karte.
$fake = new FakeStripeClient;
app()->instance(StripeClient::class, $fake);
Livewire::actingAs(portalUserOwing($fake))
->test(OpenInvoices::class)
->call('pay', 'in_fremd')
->assertForbidden();
expect($fake->paidInvoices)->toBe([]);
});
it('stops paying the rest after the first refusal', function () {
// Alle weiteren scheitern an derselben Karte, und drei Ablehnungen
// hintereinander lassen Stripes Betrugserkennung anschlagen — danach
// lehnt sie auch die Karte ab, die in Ordnung wäre.
$fake = new FakeStripeClient;
$fake->payDeclines = true;
app()->instance(StripeClient::class, $fake);
$user = portalUserOwing($fake);
$fake->openInvoiceRows[] = [
'id' => 'in_2', 'number' => 'R-2', 'amount_due_cents' => 1000,
'currency' => 'EUR', 'created_at' => '1750000000',
];
Livewire::actingAs($user)->test(OpenInvoices::class)->call('payAll');
expect($fake->paidInvoices)->toBe([]);
});
it('says so plainly when nothing is open', function () {
$fake = new FakeStripeClient;
app()->instance(StripeClient::class, $fake);
$user = User::factory()->create(['email' => 'schuldenfrei@portal.test', 'is_admin' => false]);
Customer::factory()->create(['email' => 'schuldenfrei@portal.test', 'stripe_customer_id' => 'cus_9']);
Livewire::actingAs($user)
->test(OpenInvoices::class)
->assertSee(__('billing.invoices_none'));
});

View File

@ -0,0 +1,62 @@
<?php
use App\Livewire\Billing;
use App\Models\Customer;
use App\Models\Instance;
use App\Models\Order;
use App\Models\Subscription;
use App\Models\User;
use App\Services\Stripe\FakeStripeClient;
use App\Services\Stripe\StripeClient;
use Livewire\Livewire;
/**
* Bis hierher stand im Portal NICHTS, wenn eine Abbuchung scheiterte.
* `ApplyStripeBillingEvent::invoicePaymentFailed()` setzt `past_due` und
* schweigt der Kunde erfuhr davon erst, wenn seine Cloud stand.
*/
function portalCustomerWith(string $stripeStatus): User
{
$user = User::factory()->create(['email' => 'faellig@portal.test', 'is_admin' => false]);
$customer = Customer::factory()->create(['email' => 'faellig@portal.test', 'stripe_customer_id' => 'cus_42']);
$order = Order::factory()->create(['customer_id' => $customer->id, 'plan' => 'start']);
Instance::factory()->create([
'customer_id' => $customer->id, 'order_id' => $order->id,
'plan' => 'start', 'status' => 'active',
]);
Subscription::factory()->create([
'customer_id' => $customer->id, 'stripe_status' => $stripeStatus,
]);
withStripeSecret();
app()->instance(StripeClient::class, new FakeStripeClient);
return $user;
}
it('says plainly that a payment failed', function () {
Livewire::actingAs(portalCustomerWith('past_due'))
->test(Billing::class)
->assertSee(__('billing.past_due_title'));
});
it('stays quiet when nothing is owed', function () {
// Ein Hinweis, der immer da ist, ist keiner.
Livewire::actingAs(portalCustomerWith('active'))
->test(Billing::class)
->assertDontSee(__('billing.past_due_title'));
});
it('still renders for a customer who has never been to the checkout', function () {
// Der Fehler, den ich beim ersten Anlauf gebaut habe: die zwei Bauteile
// waren bedingungslos eingebettet, und beide brechen ohne Stripe-Kunden
// mit 404 ab. 53 Bestandstests fielen um — jeder Kunde ohne
// stripe_customer_id bekam statt seiner Abrechnungsseite eine 404.
$user = User::factory()->create(['email' => 'ohnestripe@portal.test', 'is_admin' => false]);
Customer::factory()->create(['email' => 'ohnestripe@portal.test', 'stripe_customer_id' => null]);
Livewire::actingAs($user)
->test(Billing::class)
->assertOk()
->assertDontSee(__('billing.invoices_open_title'));
});

View File

@ -0,0 +1,104 @@
<?php
use App\Livewire\PaymentMethod;
use App\Models\Customer;
use App\Models\User;
use App\Services\Stripe\FakeStripeClient;
use App\Services\Stripe\StripeClient;
use App\Support\StripePublishableKey;
use Livewire\Livewire;
/**
* Bis hierher konnte ein Kunde seine Karte NICHT tauschen es gab im ganzen
* Portal keine Zahlungsmittel-Verwaltung. Eine abgelaufene Karte war damit
* eine Sackgasse: die Abbuchung scheitert, der Vertrag geht auf `past_due`,
* und der Kunde hat keine Stelle, an der er die Ursache beheben könnte.
*
* Deshalb ist das die ERSTE Lieferung und der Mahnlauf die zweite. Ein
* Mahnlauf über einem Portal ohne Kartenwechsel mahnt jemanden für etwas, das
* er nicht abstellen kann.
*/
function portalUserWithStripe(): User
{
$user = User::factory()->create(['email' => 'karte@portal.test', 'is_admin' => false]);
Customer::factory()->create(['email' => 'karte@portal.test', 'stripe_customer_id' => 'cus_42']);
return $user;
}
beforeEach(function () {
app()->instance(StripeClient::class, new FakeStripeClient);
StripePublishableKey::set('pk_test_sichtbar');
});
it('hands the browser a client secret and the publishable key', function () {
// Am übergebenen Wert geprüft, nicht am HTML: der Schlüssel steht in einem
// `@script`-Block, und den legt Livewire in die Effects-Nutzlast statt in
// das gerenderte Markup. Ein assertSee darauf prüfte die Testfassung von
// Livewire, nicht diese Komponente.
Livewire::actingAs(portalUserWithStripe())
->test(PaymentMethod::class)
->assertSet('clientSecret', 'seti_fake_secret_cus_42')
->assertViewHas('publishableKey', 'pk_test_sichtbar');
});
it('stores the confirmed payment method as the default', function () {
// Der Kern: eine Karte zu hinterlegen genügt nicht. Ohne diesen Schritt
// bucht Stripe weiter mit der kaputten ab, und der Kunde steht im nächsten
// Monat wieder in derselben Mahnung.
$fake = new FakeStripeClient;
app()->instance(StripeClient::class, $fake);
Livewire::actingAs(portalUserWithStripe())
->test(PaymentMethod::class)
->call('confirmed', 'pm_neu123')
->assertHasNoErrors();
expect($fake->defaultPaymentMethods['cus_42'])->toBe('pm_neu123');
});
it('shows which card is on file once one is', function () {
$fake = new FakeStripeClient;
$fake->defaultPaymentMethods['cus_42'] = 'pm_vorhanden';
app()->instance(StripeClient::class, $fake);
Livewire::actingAs(portalUserWithStripe())
->test(PaymentMethod::class)
->assertSee('4242');
});
it('says so instead of drawing a form that never loads', function () {
// Ohne Publishable Key lädt Elements nicht. Ein gezeichnetes, aber totes
// Kartenfeld ist die schlimmste Variante: der Kunde tippt seine Nummer
// ein, nichts passiert, und er hält seine Karte für abgelehnt.
StripePublishableKey::set('');
config()->set('services.stripe.key', '');
config()->set('services.stripe.key_test', '');
Livewire::actingAs(portalUserWithStripe())
->test(PaymentMethod::class)
->assertSet('clientSecret', null)
->assertSee(__('billing.card_unavailable'));
});
it('refuses a payment method id that is not one', function () {
// `confirmed()` wird aus dem Browser gerufen. Ohne diese Prüfung wäre das
// ein Weg, einen fremden Bezeichner an das eigene Stripe-Konto zu hängen.
$fake = new FakeStripeClient;
app()->instance(StripeClient::class, $fake);
Livewire::actingAs(portalUserWithStripe())
->test(PaymentMethod::class)
->call('confirmed', 'nicht-eine-pm-id')
->assertHasErrors();
expect($fake->defaultPaymentMethods)->toBe([]);
});
it('is not reachable for a visitor without a customer record', function () {
$fremder = User::factory()->create(['email' => 'ohne@portal.test', 'is_admin' => false]);
Livewire::actingAs($fremder)
->test(PaymentMethod::class)
->assertStatus(404);
});