From 0ae752d2ae0f1536b6ebde6aee706f65cbc32fab Mon Sep 17 00:00:00 2001 From: nexxo Date: Fri, 31 Jul 2026 20:01:14 +0200 Subject: [PATCH] Stripe-Client: SetupIntent und Vorgabe-Zahlungsmittel --- app/Services/Stripe/FakeStripeClient.php | 28 +++++++ app/Services/Stripe/HttpStripeClient.php | 55 ++++++++++++++ app/Services/Stripe/StripeCheck.php | 2 +- app/Services/Stripe/StripeClient.php | 26 +++++++ .../Billing/PaymentMethodClientTest.php | 73 +++++++++++++++++++ 5 files changed, 183 insertions(+), 1 deletion(-) create mode 100644 tests/Feature/Billing/PaymentMethodClientTest.php diff --git a/app/Services/Stripe/FakeStripeClient.php b/app/Services/Stripe/FakeStripeClient.php index 8de2c57..d0e4bbf 100644 --- a/app/Services/Stripe/FakeStripeClient.php +++ b/app/Services/Stripe/FakeStripeClient.php @@ -582,4 +582,32 @@ class FakeStripeClient implements StripeClient $this->keys[$key] = ['id' => $id, 'fingerprint' => IdempotencyKey::fingerprint($parameters)]; } } + + /** @var array customerId => paymentMethodId */ + public array $defaultPaymentMethods = []; + + public function createSetupIntent(string $customerId): array + { + return ['id' => 'seti_fake', 'client_secret' => 'seti_fake_secret_'.$customerId]; + } + + public function setDefaultPaymentMethod(string $customerId, string $paymentMethodId): void + { + $this->defaultPaymentMethods[$customerId] = $paymentMethodId; + } + + public function defaultPaymentMethod(string $customerId): ?array + { + if (! isset($this->defaultPaymentMethods[$customerId])) { + return null; + } + + return [ + 'id' => $this->defaultPaymentMethods[$customerId], + 'brand' => 'visa', + 'last4' => '4242', + 'exp_month' => 12, + 'exp_year' => 2030, + ]; + } } diff --git a/app/Services/Stripe/HttpStripeClient.php b/app/Services/Stripe/HttpStripeClient.php index becdb79..1fe32da 100644 --- a/app/Services/Stripe/HttpStripeClient.php +++ b/app/Services/Stripe/HttpStripeClient.php @@ -528,4 +528,59 @@ class HttpStripeClient implements StripeClient return $flat; } + + 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 zurück und die Seite + // bräuchte einen zweiten Aufruf für vier Ziffern. + 'expand' => ['invoice_settings.default_payment_method'], + ]) + ->throw() + ->json(); + + $method = $customer['invoice_settings']['default_payment_method'] ?? null; + + // Ein Kunde, der über eine Rechnung bezahlt hat, hat keine — das ist + // der Normalfall und kein halbes Array wert. + 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'], + ]; + } } diff --git a/app/Services/Stripe/StripeCheck.php b/app/Services/Stripe/StripeCheck.php index ba45313..a9d953e 100644 --- a/app/Services/Stripe/StripeCheck.php +++ b/app/Services/Stripe/StripeCheck.php @@ -68,7 +68,7 @@ final class StripeCheck * valid while the endpoint listens for the wrong five events, and nothing * fails until a payment is not recorded. * - * @return array>|null null when the key may not read them + * @return array>|null null when the key may not read them */ private function webhooks(string $secret): ?array { diff --git a/app/Services/Stripe/StripeClient.php b/app/Services/Stripe/StripeClient.php index 57195cb..c63c6a2 100644 --- a/app/Services/Stripe/StripeClient.php +++ b/app/Services/Stripe/StripeClient.php @@ -337,4 +337,30 @@ interface StripeClient * @return array> */ public function invoiceLines(string $invoiceId): array; + + /** + * Ein SetupIntent, mit dem der Browser eine Karte hinterlegt. + * + * `usage: off_session`, weil die nächste Abbuchung ohne den Kunden läuft: + * ohne dieses Feld verlangt Stripe bei jeder Verlängerung 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 künftig abgebucht wird. + * + * Eine Karte zu hinterlegen genügt nicht — ohne diesen Schritt bucht + * Stripe weiter mit der alten ab. + */ + 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; } diff --git a/tests/Feature/Billing/PaymentMethodClientTest.php b/tests/Feature/Billing/PaymentMethodClientTest.php new file mode 100644 index 0000000..6bc8d8d --- /dev/null +++ b/tests/Feature/Billing/PaymentMethodClientTest.php @@ -0,0 +1,73 @@ +set('admin_access.secrets_key', 'base64:'.base64_encode(random_bytes(32))); + withStripeSecret(); + 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 nächste Abbuchung ohne den Kunden läuft. Ohne + // dieses Feld verlangt Stripe bei jeder Verlängerung eine Freigabe, + // die zu dem Zeitpunkt niemand geben kann — genau der Fall, der in die + // Mahnung führt. + && $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'); + + // Der Kern: eine Karte zu hinterlegen genügt nicht, sie muss die VORGABE + // werden. Sonst bucht Stripe weiter mit der kaputten ab, und der Kunde + // steht im nächsten Monat wieder in der Mahnung. + Http::assertSent(fn ($request) => $request->method() === 'POST' + && $request['invoice_settings']['default_payment_method'] === 'pm_9'); +}); + +it('reads back the card in force', 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') + ->and($card['exp_year'])->toBe(2031); +}); + +it('answers null when no card is on file, rather than half an array', function () { + // Ein Kunde, der über eine Rechnung bezahlt hat, ohne eine Karte zu + // hinterlegen, ist der Normalfall — nicht der Ausnahmefall. + Http::fake(['api.stripe.com/v1/customers/cus_42*' => Http::response([ + 'id' => 'cus_42', 'invoice_settings' => ['default_payment_method' => null], + ])]); + + expect(app(HttpStripeClient::class)->defaultPaymentMethod('cus_42'))->toBeNull(); +});