Spezifikation und Plan: Zahlungsmittel, Nachzahlen, Mahnlauf

main
nexxo 2026-07-31 19:57:31 +02:00
parent 1e490e03ee
commit 62e5e0c346
1 changed files with 988 additions and 0 deletions

View File

@ -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
<?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`.