Spezifikation und Plan: Zahlungsmittel, Nachzahlen, Mahnlauf
parent
1e490e03ee
commit
62e5e0c346
|
|
@ -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`.
|
||||
Loading…
Reference in New Issue