diff --git a/docs/superpowers/plans/2026-07-31-zahlungsmittel-und-nachzahlen.md b/docs/superpowers/plans/2026-07-31-zahlungsmittel-und-nachzahlen.md new file mode 100644 index 0000000..8becf03 --- /dev/null +++ b/docs/superpowers/plans/2026-07-31-zahlungsmittel-und-nachzahlen.md @@ -0,0 +1,988 @@ +# Zahlungsmittel und Nachzahlen — Umsetzungsplan (Lieferung 1) + +> **Für agentische Bearbeiter:** ERFORDERLICHE UNTER-SKILL: +> `superpowers:subagent-driven-development` (empfohlen) oder +> `superpowers:executing-plans`. Schritte sind als `- [ ]` geführt. + +**Ziel:** Ein Kunde kann sein Zahlungsmittel im Portal austauschen und offene +Rechnungen selbst begleichen. + +**Architektur:** Der Stripe-Client lernt vier Aufrufe (SetupIntent, +Vorgabe-Zahlungsmittel, offene Rechnungen, Rechnung bezahlen). Darauf sitzt ein +eigenes Livewire-Bauteil im Portal, das die Kartenmaske über Stripe Elements +einbindet und die offenen Rechnungen listet. Kein Kartendatum berührt jemals +diesen Server — Elements tauscht die Karte direkt bei Stripe gegen eine ID. + +**Technik:** Laravel 13.8, Livewire 3, Stripe REST (`HttpStripeClient`), +Stripe.js v3 im Browser, Pest. + +## Globale Vorgaben + +- **Stripe.js kommt von `js.stripe.com` und darf NICHT selbst gehostet werden.** + Das ist die eine Ausnahme von der Regel „keine fremden Quellen im Browser" + (R14, selbst gehostete Schriften): Stripe untersagt das Spiegeln + ausdrücklich, und eine lokal ausgelieferte Kopie nimmt dieser Installation + die vereinfachte PCI-Einstufung (SAQ-A), weil dann wieder Kartendaten durch + eigenen Code laufen könnten. Das Skript wird nur auf der Portalseite geladen, + die es braucht, nicht im Layout. +- **Der Publishable Key kommt aus `App\Support\StripePublishableKey::current()`**, + nie aus `config()` direkt. Er ist je Betriebsmodus getrennt. +- **Der Secret Key kommt aus dem Tresor** (`SecretVault::get('stripe.secret')`), + gelesen am Ort der Benutzung — `HttpStripeClient` macht das bereits. +- **Jede neue Client-Methode bekommt eine Entsprechung in `FakeStripeClient`**, + die dasselbe Format liefert. Ein Fake, der eine andere Sprache spricht, + macht jeden Test darüber wertlos (siehe `FakeHetznerDnsClient`, 31.7.2026). +- **Alle Kundentexte in `lang/de` UND `lang/en`.** `TranslationParityTest` + erzwingt das. +- Tests laufen mit + `docker compose exec -T -e HOME=/tmp -u www-data app php artisan test`. + +--- + +### Task 1: Der Stripe-Client lernt Zahlungsmittel + +**Dateien:** +- Ändern: `app/Services/Stripe/StripeClient.php` (Interface, ans Ende) +- Ändern: `app/Services/Stripe/HttpStripeClient.php` (ans Ende) +- Ändern: `app/Services/Stripe/FakeStripeClient.php` +- Test: `tests/Feature/Billing/PaymentMethodClientTest.php` + +**Schnittstellen:** +- Liefert: `createSetupIntent(string $customerId): array{id: string, client_secret: string}`, + `setDefaultPaymentMethod(string $customerId, string $paymentMethodId): void`, + `defaultPaymentMethod(string $customerId): ?array{id: string, brand: string, last4: string, exp_month: int, exp_year: int}` + +- [ ] **Schritt 1: Den fehlschlagenden Test schreiben** + +```php +set('services.stripe.secret', 'sk_test_x'); + Http::preventStrayRequests(); +}); + +it('asks Stripe for a setup intent bound to the customer', function () { + Http::fake(['api.stripe.com/v1/setup_intents' => Http::response([ + 'id' => 'seti_1', 'client_secret' => 'seti_1_secret_abc', + ])]); + + $intent = app(HttpStripeClient::class)->createSetupIntent('cus_42'); + + expect($intent['client_secret'])->toBe('seti_1_secret_abc'); + + Http::assertSent(fn ($request) => $request['customer'] === 'cus_42' + // Off-session, weil die naechste Abbuchung ohne den Kunden laeuft. + // Ohne dieses Feld verlangt Stripe bei jeder Verlaengerung eine + // Freigabe, die niemand geben kann — genau der Fall, der zur + // Mahnung fuehrt. + && $request['usage'] === 'off_session'); +}); + +it('makes a payment method the customer default', function () { + Http::fake(['api.stripe.com/v1/customers/cus_42' => Http::response(['id' => 'cus_42'])]); + + app(HttpStripeClient::class)->setDefaultPaymentMethod('cus_42', 'pm_9'); + + Http::assertSent(fn ($request) => $request->method() === 'POST' + && $request['invoice_settings']['default_payment_method'] === 'pm_9'); +}); + +it('reads back the card in force, or null when there is none', function () { + Http::fake(['api.stripe.com/v1/customers/cus_42*' => Http::response([ + 'id' => 'cus_42', + 'invoice_settings' => ['default_payment_method' => [ + 'id' => 'pm_9', + 'card' => ['brand' => 'visa', 'last4' => '4242', 'exp_month' => 5, 'exp_year' => 2031], + ]], + ])]); + + $card = app(HttpStripeClient::class)->defaultPaymentMethod('cus_42'); + + expect($card['last4'])->toBe('4242')->and($card['brand'])->toBe('visa'); +}); +``` + +- [ ] **Schritt 2: Laufen lassen, Fehlschlag bestätigen** + +Aufruf: `docker compose exec -T -e HOME=/tmp -u www-data app php artisan test tests/Feature/Billing/PaymentMethodClientTest.php` +Erwartet: FAIL, „Call to undefined method … createSetupIntent" + +- [ ] **Schritt 3: Interface erweitern** + +In `app/Services/Stripe/StripeClient.php`, vor der schließenden Klammer: + +```php + /** + * Ein SetupIntent, mit dem der Browser eine Karte hinterlegt. + * + * `usage: off_session`, weil die naechste Abbuchung ohne den Kunden laeuft: + * ohne dieses Feld verlangt Stripe bei jeder Verlaengerung eine Freigabe, + * die zu dem Zeitpunkt niemand geben kann. + * + * @return array{id: string, client_secret: string} + */ + public function createSetupIntent(string $customerId): array; + + /** Das Zahlungsmittel, mit dem kuenftig abgebucht wird. */ + public function setDefaultPaymentMethod(string $customerId, string $paymentMethodId): void; + + /** + * Die hinterlegte Karte, oder null. + * + * @return array{id: string, brand: string, last4: string, exp_month: int, exp_year: int}|null + */ + public function defaultPaymentMethod(string $customerId): ?array; +``` + +- [ ] **Schritt 4: `HttpStripeClient` umsetzen** + +```php + public function createSetupIntent(string $customerId): array + { + $intent = $this->request() + ->asForm() + ->post($this->url('setup_intents'), [ + 'customer' => $customerId, + 'usage' => 'off_session', + ]) + ->throw() + ->json(); + + return [ + 'id' => (string) $intent['id'], + 'client_secret' => (string) $intent['client_secret'], + ]; + } + + public function setDefaultPaymentMethod(string $customerId, string $paymentMethodId): void + { + $this->request() + ->asForm() + ->post($this->url('customers/'.$customerId), [ + 'invoice_settings' => ['default_payment_method' => $paymentMethodId], + ]) + ->throw(); + } + + public function defaultPaymentMethod(string $customerId): ?array + { + $customer = $this->request() + ->get($this->url('customers/'.$customerId), [ + // Ausgeklappt, sonst kommt nur die ID zurueck und die Seite + // muesste einen zweiten Aufruf machen, um vier Ziffern zu zeigen. + 'expand' => ['invoice_settings.default_payment_method'], + ]) + ->throw() + ->json(); + + $method = $customer['invoice_settings']['default_payment_method'] ?? null; + + if (! is_array($method) || ! isset($method['card'])) { + return null; + } + + return [ + 'id' => (string) $method['id'], + 'brand' => (string) $method['card']['brand'], + 'last4' => (string) $method['card']['last4'], + 'exp_month' => (int) $method['card']['exp_month'], + 'exp_year' => (int) $method['card']['exp_year'], + ]; + } +``` + +- [ ] **Schritt 5: `FakeStripeClient` nachziehen** + +```php + /** @var array customerId => paymentMethodId */ + public array $defaultPaymentMethods = []; + + public function createSetupIntent(string $customerId): array + { + return ['id' => 'seti_fake', 'client_secret' => 'seti_fake_secret_'.$customerId]; + } + + public function setDefaultPaymentMethod(string $customerId, string $paymentMethodId): void + { + $this->defaultPaymentMethods[$customerId] = $paymentMethodId; + } + + public function defaultPaymentMethod(string $customerId): ?array + { + if (! isset($this->defaultPaymentMethods[$customerId])) { + return null; + } + + return [ + 'id' => $this->defaultPaymentMethods[$customerId], + 'brand' => 'visa', 'last4' => '4242', 'exp_month' => 12, 'exp_year' => 2030, + ]; + } +``` + +- [ ] **Schritt 6: Tests laufen lassen** + +Aufruf: wie Schritt 2. Erwartet: PASS, 3 Tests. + +- [ ] **Schritt 7: Committen** + +```bash +git add app/Services/Stripe tests/Feature/Billing/PaymentMethodClientTest.php +git commit -m "Stripe-Client: SetupIntent und Vorgabe-Zahlungsmittel" +``` + +--- + +### Task 2: Der Stripe-Client lernt offene Rechnungen + +**Dateien:** +- Ändern: `app/Services/Stripe/StripeClient.php` +- Ändern: `app/Services/Stripe/HttpStripeClient.php` +- Ändern: `app/Services/Stripe/FakeStripeClient.php` +- Test: `tests/Feature/Billing/OpenInvoiceClientTest.php` + +**Schnittstellen:** +- Benutzt: nichts aus Task 1. +- Liefert: `openInvoices(string $customerId): array`, + `payInvoice(string $invoiceId): array{paid: bool, status: string, failure: ?string}` + +- [ ] **Schritt 1: Den fehlschlagenden Test schreiben** + +```php +set('services.stripe.secret', 'sk_test_x'); + Http::preventStrayRequests(); +}); + +it('lists only the invoices the customer still owes', function () { + Http::fake(['api.stripe.com/v1/invoices*' => Http::response(['data' => [ + ['id' => 'in_1', 'number' => 'R-1', 'amount_due' => 21480, 'currency' => 'eur', 'created' => 1750000000], + ]])]); + + $open = app(HttpStripeClient::class)->openInvoices('cus_42'); + + expect($open)->toHaveCount(1) + ->and($open[0]['amount_due_cents'])->toBe(21480); + + // `open` ist Stripes Status fuer finalisiert und unbezahlt. Ohne den + // Filter kaeme die ganze Rechnungshistorie zurueck, und der Kunde bekaeme + // einen Bezahlknopf neben Rechnungen, die er laengst bezahlt hat. + Http::assertSent(fn ($request) => $request['customer'] === 'cus_42' + && $request['status'] === 'open'); +}); + +it('reports a refused charge instead of throwing', function () { + // Der haeufigste Fall ueberhaupt: die Karte lehnt ab. Das ist kein + // Serverfehler, das ist die Antwort — und die Seite muss sie zeigen + // koennen, statt in eine 500 zu laufen. + Http::fake(['api.stripe.com/v1/invoices/in_1/pay' => Http::response([ + 'error' => ['code' => 'card_declined', 'message' => 'Your card was declined.'], + ], 402)]); + + $result = app(HttpStripeClient::class)->payInvoice('in_1'); + + expect($result['paid'])->toBeFalse() + ->and($result['failure'])->toContain('declined'); +}); + +it('reports a successful charge', function () { + Http::fake(['api.stripe.com/v1/invoices/in_1/pay' => Http::response([ + 'id' => 'in_1', 'status' => 'paid', 'paid' => true, + ])]); + + expect(app(HttpStripeClient::class)->payInvoice('in_1')['paid'])->toBeTrue(); +}); +``` + +- [ ] **Schritt 2: Laufen lassen, Fehlschlag bestätigen** + +Aufruf: `… php artisan test tests/Feature/Billing/OpenInvoiceClientTest.php` +Erwartet: FAIL, „Call to undefined method … openInvoices" + +- [ ] **Schritt 3: Interface erweitern** + +```php + /** + * Die finalisierten, unbezahlten Rechnungen dieses Kunden. + * + * @return array + */ + public function openInvoices(string $customerId): array; + + /** + * Eine offene Rechnung mit dem hinterlegten Zahlungsmittel einziehen. + * + * Wirft NICHT bei einer abgelehnten Karte: das ist die haeufigste Antwort + * ueberhaupt und gehoert auf die Seite, nicht in einen 500er. + * + * @return array{paid: bool, status: string, failure: ?string} + */ + public function payInvoice(string $invoiceId): array; +``` + +- [ ] **Schritt 4: `HttpStripeClient` umsetzen** + +```php + public function openInvoices(string $customerId): array + { + $invoices = $this->request() + ->get($this->url('invoices'), [ + 'customer' => $customerId, + // Finalisiert und unbezahlt. Ohne den Filter kaeme die ganze + // Historie zurueck. + 'status' => 'open', + 'limit' => 100, + ]) + ->throw() + ->json('data', []); + + return array_map(fn (array $invoice) => [ + 'id' => (string) $invoice['id'], + 'number' => isset($invoice['number']) ? (string) $invoice['number'] : null, + 'amount_due_cents' => (int) ($invoice['amount_due'] ?? 0), + 'currency' => strtoupper((string) ($invoice['currency'] ?? 'eur')), + 'created_at' => (string) ($invoice['created'] ?? ''), + ], $invoices); + } + + public function payInvoice(string $invoiceId): array + { + $response = $this->request()->asForm()->post($this->url('invoices/'.$invoiceId.'/pay')); + + if ($response->successful()) { + return [ + 'paid' => (bool) $response->json('paid', false), + 'status' => (string) $response->json('status', 'unknown'), + 'failure' => null, + ]; + } + + // 402 ist Stripes Antwort auf eine abgelehnte Karte — eine Aussage + // ueber das Zahlungsmittel, kein Fehler dieser Anwendung. Alles + // andere (401, 5xx) bleibt ein Wurf, weil es NICHTS ueber die Karte + // sagt und der Kunde sonst liest, seine Karte sei abgelehnt worden. + if ($response->status() !== 402) { + $response->throw(); + } + + return [ + 'paid' => false, + 'status' => 'open', + 'failure' => (string) $response->json('error.message', ''), + ]; + } +``` + +- [ ] **Schritt 5: `FakeStripeClient` nachziehen** + +```php + /** @var array> Was openInvoices() zurueckgibt. */ + public array $openInvoiceRows = []; + + public bool $payDeclines = false; + + public array $paidInvoices = []; + + public function openInvoices(string $customerId): array + { + return $this->openInvoiceRows; + } + + public function payInvoice(string $invoiceId): array + { + if ($this->payDeclines) { + return ['paid' => false, 'status' => 'open', 'failure' => 'Your card was declined.']; + } + + $this->paidInvoices[] = $invoiceId; + $this->openInvoiceRows = array_values(array_filter( + $this->openInvoiceRows, + fn (array $invoice) => $invoice['id'] !== $invoiceId, + )); + + return ['paid' => true, 'status' => 'paid', 'failure' => null]; + } +``` + +- [ ] **Schritt 6: Tests laufen lassen** — Erwartet: PASS, 3 Tests. + +- [ ] **Schritt 7: Committen** + +```bash +git add app/Services/Stripe tests/Feature/Billing/OpenInvoiceClientTest.php +git commit -m "Stripe-Client: offene Rechnungen listen und einziehen" +``` + +--- + +### Task 3: Kartenmaske im Portal + +**Dateien:** +- Erstellen: `app/Livewire/PaymentMethod.php` +- Erstellen: `resources/views/livewire/payment-method.blade.php` +- Ändern: `lang/de/billing.php`, `lang/en/billing.php` +- Test: `tests/Feature/Portal/PaymentMethodTest.php` + +**Schnittstellen:** +- Benutzt: `createSetupIntent()`, `setDefaultPaymentMethod()`, + `defaultPaymentMethod()` aus Task 1. +- Liefert: Livewire-Bauteil `payment-method`, Methode + `confirmed(string $paymentMethodId): void` (vom Browser aufgerufen, nachdem + Elements den SetupIntent bestätigt hat). + +- [ ] **Schritt 1: Den fehlschlagenden Test schreiben** + +```php +create(['stripe_customer_id' => 'cus_42']); + + return User::factory()->create(['customer_id' => $customer->id]); +} + +it('hands the browser a client secret and the publishable key', function () { + StripePublishableKey::set('pk_test_visible'); + app()->instance(StripeClient::class, new FakeStripeClient); + + Livewire::actingAs(portalUser()) + ->test(PaymentMethod::class) + ->assertSet('clientSecret', 'seti_fake_secret_cus_42') + ->assertSee('pk_test_visible'); +}); + +it('stores the confirmed payment method as the default', function () { + $fake = new FakeStripeClient; + app()->instance(StripeClient::class, $fake); + + Livewire::actingAs(portalUser()) + ->test(PaymentMethod::class) + ->call('confirmed', 'pm_neu'); + + expect($fake->defaultPaymentMethods['cus_42'])->toBe('pm_neu'); +}); + +it('says so instead of drawing an empty form when no publishable key is set', function () { + // Sonst zeichnet die Seite ein Kartenfeld, das nie laedt, und der Kunde + // haelt seine Karte fuer abgelehnt. + StripePublishableKey::set(''); + config()->set('services.stripe.key', ''); + config()->set('services.stripe.key_test', ''); + app()->instance(StripeClient::class, new FakeStripeClient); + + Livewire::actingAs(portalUser()) + ->test(PaymentMethod::class) + ->assertSee(__('billing.card_unavailable')) + ->assertSet('clientSecret', null); +}); + +it('refuses a payment method id for somebody else', function () { + // `confirmed()` kommt aus dem Browser. Ohne diese Pruefung waere es ein + // Weg, ein fremdes Zahlungsmittel an das eigene Konto zu haengen. + $fake = new FakeStripeClient; + app()->instance(StripeClient::class, $fake); + + Livewire::actingAs(portalUser()) + ->test(PaymentMethod::class) + ->call('confirmed', 'nicht-eine-pm-id') + ->assertHasErrors(); + + expect($fake->defaultPaymentMethods)->toBe([]); +}); +``` + +- [ ] **Schritt 2: Laufen lassen, Fehlschlag bestätigen** + +Aufruf: `… php artisan test tests/Feature/Portal/PaymentMethodTest.php` +Erwartet: FAIL, „Class App\Livewire\PaymentMethod not found" + +- [ ] **Schritt 3: Das Bauteil schreiben** + +```php +customer; + abort_if($customer?->stripe_customer_id === null, 404); + + $stripe = app(StripeClient::class); + $this->card = $stripe->defaultPaymentMethod($customer->stripe_customer_id); + + // Ohne Publishable Key laedt Elements nicht. Dann wird das gesagt, + // statt ein Feld zu zeichnen, das nie erscheint. + if (StripePublishableKey::current() === '') { + return; + } + + $this->clientSecret = $stripe->createSetupIntent($customer->stripe_customer_id)['client_secret']; + } + + /** + * Vom Browser gerufen, nachdem Elements den SetupIntent bestaetigt hat. + * + * Die ID kommt aus dem Browser und wird deshalb geprueft, bevor sie an + * Stripe weitergereicht wird: sonst waere das ein Weg, ein fremdes + * Zahlungsmittel an das eigene Konto zu haengen. + */ + public function confirmed(string $paymentMethodId): void + { + $this->validate( + ['paymentMethodId' => ['required', 'string', 'regex:/^pm_[A-Za-z0-9]+$/']], + [], + [], + ); + + $customer = Auth::user()?->customer; + abort_if($customer?->stripe_customer_id === null, 404); + + $stripe = app(StripeClient::class); + $stripe->setDefaultPaymentMethod($customer->stripe_customer_id, $paymentMethodId); + $this->card = $stripe->defaultPaymentMethod($customer->stripe_customer_id); + + $this->dispatch('notify', message: __('billing.card_saved')); + } + + protected function rules(): array + { + return []; + } + + public function render() + { + return view('livewire.payment-method', [ + 'publishableKey' => StripePublishableKey::current(), + ]); + } +} +``` + +Hinweis für die Umsetzung: `validate()` mit einer lokalen Variablen geht in +Livewire 3 nicht direkt — stattdessen `Validator::make(['paymentMethodId' => +$paymentMethodId], [...])->validate()` benutzen und die Ausnahme durchreichen, +damit `assertHasErrors()` greift. + +- [ ] **Schritt 4: Die Ansicht schreiben** + +`resources/views/livewire/payment-method.blade.php` — Kartenfeld, Knopf, +Fehlerzeile. Stripe.js NUR hier laden: + +```blade +
+
+

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

+

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

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

+ {{ __('billing.card_on_file', [ + 'brand' => Str::title($card['brand']), + 'last4' => $card['last4'], + 'month' => $card['exp_month'], + 'year' => $card['exp_year'], + ]) }} +

+ @endif + + @if ($clientSecret === null) + {{ __('billing.card_unavailable') }} + @else + {{-- js.stripe.com, und nur hier. Siehe die globalen Vorgaben des + Plans: Stripe untersagt das Selbsthosten, und eine eigene Kopie + nimmt dieser Installation die vereinfachte PCI-Einstufung. --}} +
+
+

+
+ + {{ __('billing.card_save') }} + + @script + + + @endscript + @endif +
+``` + +- [ ] **Schritt 5: Sprachdateien ergänzen** + +`lang/de/billing.php` und `lang/en/billing.php`, jeweils: +`card_title`, `card_body`, `card_on_file`, `card_save`, `card_saved`, +`card_unavailable`. + +Deutsch: + +```php +'card_title' => 'Zahlungsmittel', +'card_body' => 'Die Karte, von der Ihre Pakete abgebucht werden.', +'card_on_file' => 'Hinterlegt: :brand •••• :last4, gültig bis :month/:year', +'card_save' => 'Karte speichern', +'card_saved' => 'Karte gespeichert. Ab der nächsten Abbuchung gilt sie.', +'card_unavailable' => 'Die Kartenmaske kann gerade nicht geladen werden. Bitte wenden Sie sich an den Support.', +``` + +- [ ] **Schritt 6: Tests laufen lassen** — Erwartet: PASS, 4 Tests. + +- [ ] **Schritt 7: Bauen und committen** + +```bash +npm run build +git add app/Livewire/PaymentMethod.php resources/views/livewire/payment-method.blade.php lang tests/Feature/Portal/PaymentMethodTest.php +git commit -m "Portal: Karte tauschen ueber Stripe Elements" +``` + +--- + +### Task 4: Offene Rechnungen im Portal begleichen + +**Dateien:** +- Erstellen: `app/Livewire/OpenInvoices.php` +- Erstellen: `resources/views/livewire/open-invoices.blade.php` +- Ändern: `lang/de/billing.php`, `lang/en/billing.php` +- Test: `tests/Feature/Portal/OpenInvoicesTest.php` + +**Schnittstellen:** +- Benutzt: `openInvoices()`, `payInvoice()` aus Task 2. +- Liefert: Livewire-Bauteil `open-invoices`, Methoden `pay(string $invoiceId)` + und `payAll()`. + +- [ ] **Schritt 1: Den fehlschlagenden Test schreiben** + +```php +openInvoiceRows = [[ + 'id' => 'in_1', 'number' => 'R-1', 'amount_due_cents' => 21480, + 'currency' => 'EUR', 'created_at' => '1750000000', + ]]; + + $customer = Customer::factory()->create(['stripe_customer_id' => 'cus_42']); + + return User::factory()->create(['customer_id' => $customer->id]); +} + +it('shows what is still owed', function () { + $fake = new FakeStripeClient; + app()->instance(StripeClient::class, $fake); + + Livewire::actingAs(customerWithOpenInvoice($fake)) + ->test(OpenInvoices::class) + ->assertSee('R-1'); +}); + +it('settles an invoice when the customer asks', function () { + $fake = new FakeStripeClient; + app()->instance(StripeClient::class, $fake); + + Livewire::actingAs(customerWithOpenInvoice($fake)) + ->test(OpenInvoices::class) + ->call('pay', 'in_1'); + + expect($fake->paidInvoices)->toBe(['in_1']); +}); + +it('shows a refusal instead of pretending it worked', function () { + // Der Fall, der wirklich eintritt. Eine gruene Meldung ueber einer + // abgelehnten Karte ist die teuerste Anzeige, die diese Seite haben kann. + $fake = new FakeStripeClient; + $fake->payDeclines = true; + app()->instance(StripeClient::class, $fake); + + Livewire::actingAs(customerWithOpenInvoice($fake)) + ->test(OpenInvoices::class) + ->call('pay', 'in_1') + ->assertSee('declined'); +}); + +it('refuses to pay an invoice that is not this customer\'s', function () { + // Die ID kommt aus dem Browser. Ohne diese Pruefung koennte jemand eine + // fremde Rechnungs-ID einreichen — und Stripe zoege sie beim fremden + // Kunden ein. + $fake = new FakeStripeClient; + app()->instance(StripeClient::class, $fake); + + Livewire::actingAs(customerWithOpenInvoice($fake)) + ->test(OpenInvoices::class) + ->call('pay', 'in_fremd') + ->assertForbidden(); + + expect($fake->paidInvoices)->toBe([]); +}); +``` + +- [ ] **Schritt 2: Laufen lassen, Fehlschlag bestätigen** — „Class … OpenInvoices not found" + +- [ ] **Schritt 3: Das Bauteil schreiben** + +```php +stripeCustomerId(); + + // Die ID kommt aus dem Browser. Nur was in DIESER Liste steht, darf + // eingezogen werden — sonst zieht Stripe bei einem fremden Kunden ein. + $stripe = app(StripeClient::class); + $open = collect($stripe->openInvoices($customerId))->pluck('id')->all(); + abort_unless(in_array($invoiceId, $open, true), 403); + + $result = $stripe->payInvoice($invoiceId); + $this->failure = $result['paid'] ? null : $result['failure']; + + if ($result['paid']) { + $this->dispatch('notify', message: __('billing.invoice_paid')); + } + } + + public function payAll(): void + { + foreach (app(StripeClient::class)->openInvoices($this->stripeCustomerId()) as $invoice) { + $this->pay($invoice['id']); + + // Nach der ersten Ablehnung anhalten: die zweite scheitert am + // selben Zahlungsmittel, und drei Ablehnungen hintereinander + // lassen Stripes Betrugserkennung anschlagen. + if ($this->failure !== null) { + return; + } + } + } + + private function stripeCustomerId(): string + { + $customer = Auth::user()?->customer; + abort_if($customer?->stripe_customer_id === null, 404); + + return $customer->stripe_customer_id; + } + + public function render() + { + return view('livewire.open-invoices', [ + 'invoices' => app(StripeClient::class)->openInvoices($this->stripeCustomerId()), + ]); + } +} +``` + +- [ ] **Schritt 4: Die Ansicht schreiben** + +Tabelle mit Nummer, Betrag (`number_format($invoice['amount_due_cents'] / 100, 2, ',', '.')`), +Datum und Knopf je Zeile, darunter „alles offene bezahlen". + +**Zum Datum:** `created_at` ist Stripes Unix-Zeitstempel als String, kein +Carbon. In der Ansicht deshalb +`\Illuminate\Support\Carbon::createFromTimestamp((int) $invoice['created_at'])->local()->isoFormat('D. MMM YYYY')` +— das `->local()` ist R19 und nicht verhandelbar. +`$failure` als `` darüber. + +- [ ] **Schritt 5: Sprachdateien** — `invoices_open_title`, `invoices_open_body`, + `invoice_pay`, `invoice_pay_all`, `invoice_paid`, `invoices_none`. + +- [ ] **Schritt 6: Tests laufen lassen** — Erwartet: PASS, 4 Tests. + +- [ ] **Schritt 7: Committen** + +```bash +git add app/Livewire/OpenInvoices.php resources/views/livewire/open-invoices.blade.php lang tests/Feature/Portal/OpenInvoicesTest.php +git commit -m "Portal: offene Rechnungen anzeigen und begleichen" +``` + +--- + +### Task 5: Der Hinweis, dass etwas offen ist + +**Dateien:** +- Ändern: `app/Livewire/Billing.php` (render, um `pastDue` zu liefern) +- Ändern: `resources/views/livewire/billing.blade.php` (Banner oben) +- Ändern: `lang/de/billing.php`, `lang/en/billing.php` +- Test: `tests/Feature/Portal/PastDueBannerTest.php` + +**Schnittstellen:** +- Benutzt: die zwei Bauteile aus Task 3 und 4 (`@livewire('payment-method')`, + `@livewire('open-invoices')`). + +- [ ] **Schritt 1: Den fehlschlagenden Test schreiben** + +```php +create(['stripe_customer_id' => 'cus_42']); + Subscription::factory()->for($customer)->create(['stripe_status' => 'past_due']); + $user = User::factory()->create(['customer_id' => $customer->id]); + + Livewire::actingAs($user) + ->test(Billing::class) + ->assertSee(__('billing.past_due_title')); +}); + +it('stays quiet when nothing is owed', function () { + $customer = Customer::factory()->create(['stripe_customer_id' => 'cus_42']); + Subscription::factory()->for($customer)->create(['stripe_status' => 'active']); + $user = User::factory()->create(['customer_id' => $customer->id]); + + Livewire::actingAs($user) + ->test(Billing::class) + ->assertDontSee(__('billing.past_due_title')); +}); +``` + +- [ ] **Schritt 2: Laufen lassen, Fehlschlag bestätigen** + +- [ ] **Schritt 3: `Billing::render()` um das Flag erweitern** + +```php + 'pastDue' => $customer->subscriptions() + ->where('stripe_status', 'past_due') + ->exists(), +``` + +- [ ] **Schritt 4: Banner in die Ansicht** + +Ganz oben in `billing.blade.php`, vor allem anderen: + +```blade +@if ($pastDue) + +

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

+

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

+
+@endif + +@livewire('open-invoices') +@livewire('payment-method') +``` + +- [ ] **Schritt 5: Sprachdateien** — `past_due_title`, `past_due_body`. + +Deutsch: + +```php +'past_due_title' => 'Eine Abbuchung ist fehlgeschlagen.', +'past_due_body' => 'Bitte prüfen Sie Ihre Karte und begleichen Sie den offenen Betrag. Ihre Cloud läuft vorerst weiter.', +``` + +- [ ] **Schritt 6: Tests laufen lassen** — Erwartet: PASS, 2 Tests. + +- [ ] **Schritt 7: Gesamte Suite, dann committen** + +```bash +docker compose exec -T -e HOME=/tmp -u www-data app php artisan test +npm run build +git add app/Livewire/Billing.php resources/views/livewire/billing.blade.php lang tests/Feature/Portal/PastDueBannerTest.php +git commit -m "Portal: Hinweis auf eine fehlgeschlagene Abbuchung" +``` + +--- + +## Abschluss der Lieferung + +- [ ] Gesamte Suite grün (`php artisan test`). +- [ ] `./vendor/bin/pint` über die geänderten Dateien. +- [ ] Codex-Review (R15) über den Diff: + `node "$CLAUDE_PLUGIN_ROOT/scripts/codex-companion.mjs" review "--scope branch --base "`, + vorher `export PATH="$HOME/.local/bin:$PATH"`. +- [ ] Eine Fix-Runde, ein Re-Review (R22), dann Release nach dem bekannten + Ablauf: `pwd` und `git branch --show-current` vor jedem Schritt, nie mit + `&&` verkettet. + +## Was in Lieferung 2 gehört, nicht hierher + +Mahnstufen, Fristen, Gebührenrechnungen, die Sperre, die Konsolen-Seite +`admin.dunning` und die sechs Mail-Vorlagen. Siehe +`docs/superpowers/specs/2026-07-31-zahlungsmittel-und-mahnlauf-design.md`.