diff --git a/app/Livewire/Billing.php b/app/Livewire/Billing.php
index 8b16e09..70baaf9 100644
--- a/app/Livewire/Billing.php
+++ b/app/Livewire/Billing.php
@@ -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
diff --git a/app/Livewire/OpenInvoices.php b/app/Livewire/OpenInvoices.php
new file mode 100644
index 0000000..a8a3f68
--- /dev/null
+++ b/app/Livewire/OpenInvoices.php
@@ -0,0 +1,77 @@
+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()),
+ ]);
+ }
+}
diff --git a/app/Livewire/PaymentMethod.php b/app/Livewire/PaymentMethod.php
new file mode 100644
index 0000000..948fbc0
--- /dev/null
+++ b/app/Livewire/PaymentMethod.php
@@ -0,0 +1,95 @@
+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(),
+ ]);
+ }
+}
diff --git a/lang/de/billing.php b/lang/de/billing.php
index 661d018..7ec2b71 100644
--- a/lang/de/billing.php
+++ b/lang/de/billing.php
@@ -1,6 +1,24 @@
'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',
diff --git a/lang/en/billing.php b/lang/en/billing.php
index 8fe0421..654acaa 100644
--- a/lang/en/billing.php
+++ b/lang/en/billing.php
@@ -1,6 +1,24 @@
'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',
diff --git a/resources/views/livewire/billing.blade.php b/resources/views/livewire/billing.blade.php
index 894e5db..46fb73f 100644
--- a/resources/views/livewire/billing.blade.php
+++ b/resources/views/livewire/billing.blade.php
@@ -7,6 +7,24 @@
{{ __('billing.subtitle') }}
+ {{-- 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)
+
+
+ @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.
diff --git a/resources/views/livewire/open-invoices.blade.php b/resources/views/livewire/open-invoices.blade.php
new file mode 100644
index 0000000..7cc6662
--- /dev/null
+++ b/resources/views/livewire/open-invoices.blade.php
@@ -0,0 +1,60 @@
+
+
+
{{ __('billing.invoices_open_title') }}
+
{{ __('billing.invoices_open_body') }}
+
+
+ @if ($failure !== null)
+ {{-- Stripes Wortlaut, nicht unsere Zusammenfassung: er nennt den Grund,
+ und danach kann der Kunde handeln. --}}
+ {{ $failure }}
+ @endif
+
+ @if ($invoices === [])
+
{{ __('billing.invoices_none') }}
+ @else
+
+
+
+
+
{{ __('billing.invoice_number') }}
+
{{ __('billing.invoice_date') }}
+
{{ __('billing.invoice_amount') }}
+
+
+
+
+ @foreach ($invoices as $invoice)
+
+
{{ $invoice['number'] ?? $invoice['id'] }}
+
+ {{-- 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') }}
+
+ @endif
+
+ @if ($clientSecret === null)
+ {{ __('billing.card_unavailable') }}
+ @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. --}}
+
diff --git a/tests/Feature/Portal/OpenInvoicesTest.php b/tests/Feature/Portal/OpenInvoicesTest.php
new file mode 100644
index 0000000..1415511
--- /dev/null
+++ b/tests/Feature/Portal/OpenInvoicesTest.php
@@ -0,0 +1,106 @@
+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'));
+});
diff --git a/tests/Feature/Portal/PastDueBannerTest.php b/tests/Feature/Portal/PastDueBannerTest.php
new file mode 100644
index 0000000..7029302
--- /dev/null
+++ b/tests/Feature/Portal/PastDueBannerTest.php
@@ -0,0 +1,62 @@
+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'));
+});
diff --git a/tests/Feature/Portal/PaymentMethodTest.php b/tests/Feature/Portal/PaymentMethodTest.php
new file mode 100644
index 0000000..8574738
--- /dev/null
+++ b/tests/Feature/Portal/PaymentMethodTest.php
@@ -0,0 +1,104 @@
+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);
+});