# 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
<?php

use App\Services\Stripe\HttpStripeClient;
use Illuminate\Support\Facades\Http;

beforeEach(function () {
    config()->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<string, string> 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<int, array{id: string, number: ?string, amount_due_cents: int, currency: string, created_at: string}>`,
  `payInvoice(string $invoiceId): array{paid: bool, status: string, failure: ?string}`

- [ ] **Schritt 1: Den fehlschlagenden Test schreiben**

```php
<?php

use App\Services\Stripe\HttpStripeClient;
use Illuminate\Support\Facades\Http;

beforeEach(function () {
    config()->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<int, array{id: string, number: ?string, amount_due_cents: int, currency: string, created_at: string}>
     */
    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<int, array<string, mixed>> 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
<?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;

function portalUser(): User
{
    $customer = Customer::factory()->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
<?php

namespace App\Livewire;

use App\Services\Stripe\StripeClient;
use App\Support\StripePublishableKey;
use Illuminate\Support\Facades\Auth;
use Livewire\Component;

/**
 * Die Karte des Kunden, getauscht ohne dass ein Kartendatum diesen Server
 * beruehrt: Elements schickt sie direkt an Stripe und bekommt eine ID zurueck,
 * die hier ankommt.
 *
 * Der SetupIntent wird beim ersten Rendern erzeugt, nicht bei jedem — er ist
 * an den Kunden gebunden und ueberlebt einen Neuaufbau der Seite.
 */
class PaymentMethod extends Component
{
    public ?string $clientSecret = null;

    public ?array $card = null;

    public function mount(): void
    {
        $customer = Auth::user()?->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
<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 text-sm text-muted">{{ __('billing.card_body') }}</p>
    </div>

    @if ($card !== null)
        <p class="text-sm text-body">
            {{ __('billing.card_on_file', [
                'brand' => Str::title($card['brand']),
                'last4' => $card['last4'],
                'month' => $card['exp_month'],
                'year' => $card['exp_year'],
            ]) }}
        </p>
    @endif

    @if ($clientSecret === null)
        <x-ui.alert variant="warning">{{ __('billing.card_unavailable') }}</x-ui.alert>
    @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. --}}
        <div wire:ignore>
            <div id="card-element" class="rounded border border-line-strong bg-surface px-3 py-3"></div>
            <p id="card-error" class="mt-1.5 text-xs text-danger"></p>
        </div>

        <x-ui.button variant="primary" id="card-submit">{{ __('billing.card_save') }}</x-ui.button>

        @script
        <script src="https://js.stripe.com/v3/"></script>
        <script>
            const stripe = Stripe(@js($publishableKey));
            const card = stripe.elements().create('card');
            card.mount('#card-element');

            document.getElementById('card-submit').addEventListener('click', async () => {
                const { setupIntent, error } = await stripe.confirmCardSetup(
                    @js($clientSecret), { payment_method: { card } }
                );

                if (error) {
                    document.getElementById('card-error').textContent = error.message;
                    return;
                }

                $wire.confirmed(setupIntent.payment_method);
            });
        </script>
        @endscript
    @endif
</div>
```

- [ ] **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
<?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 customerWithOpenInvoice(FakeStripeClient $fake): User
{
    $fake->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
<?php

namespace App\Livewire;

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 bezahlt
 * ist — und der zweite Klick ist dann eine zweite Abbuchung.
 */
class OpenInvoices extends Component
{
    public ?string $failure = null;

    public function pay(string $invoiceId): void
    {
        $customerId = $this->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 `<x-ui.alert variant="danger">` 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
<?php

use App\Livewire\Billing;
use App\Models\Customer;
use App\Models\Subscription;
use App\Models\User;
use Livewire\Livewire;

it('says plainly that a payment failed', function () {
    // Heute steht dazu NICHTS im Portal: invoicePaymentFailed() setzt
    // past_due und schweigt. Der Kunde erfaehrt vom Problem erst, wenn seine
    // Cloud steht.
    $customer = Customer::factory()->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)
    <x-ui.alert variant="danger">
        <p class="font-semibold">{{ __('billing.past_due_title') }}</p>
        <p class="mt-1 text-sm">{{ __('billing.past_due_body') }}</p>
    </x-ui.alert>
@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 <letzter Commit vor Task 1>"`,
  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`.
