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) + +

{{ __('billing.past_due_title') }}

+

{{ __('billing.past_due_body') }}

+
+ @endif + + @if ($hasStripeCustomer) +
+ @livewire('open-invoices') + @livewire('payment-method') +
+ @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 +
+ + + + + + + + + + + @foreach ($invoices as $invoice) + + + + + + + @endforeach + +
{{ __('billing.invoice_number') }}{{ __('billing.invoice_date') }}{{ __('billing.invoice_amount') }}
{{ $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') }} + + {{ number_format($invoice['amount_due_cents'] / 100, 2, ',', '.') }} {{ $invoice['currency'] }} + + + {{ __('billing.invoice_pay') }} + +
+
+ + @if (count($invoices) > 1) + + {{ __('billing.invoice_pay_all') }} + + @endif + @endif +
diff --git a/resources/views/livewire/payment-method.blade.php b/resources/views/livewire/payment-method.blade.php new file mode 100644 index 0000000..99145f8 --- /dev/null +++ b/resources/views/livewire/payment-method.blade.php @@ -0,0 +1,83 @@ +
+
+

{{ __('billing.card_title') }}

+

{{ __('billing.card_body') }}

+
+ + @if ($card !== null) +

+ {{ __('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'], + ]) }} +

+ @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. --}} +
+ +
+ +
+ + + {{ __('billing.card_save') }} + + + @script + + @endscript + @endif +
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); +});