Stripe-Client: SetupIntent und Vorgabe-Zahlungsmittel

main
nexxo 2026-07-31 20:01:14 +02:00
parent 62e5e0c346
commit 0ae752d2ae
5 changed files with 183 additions and 1 deletions

View File

@ -582,4 +582,32 @@ class FakeStripeClient implements StripeClient
$this->keys[$key] = ['id' => $id, 'fingerprint' => IdempotencyKey::fingerprint($parameters)];
}
}
/** @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,
];
}
}

View File

@ -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'],
];
}
}

View File

@ -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<int, array<string, mixed>>|null null when the key may not read them
* @return array<int, array<string, mixed>>|null null when the key may not read them
*/
private function webhooks(string $secret): ?array
{

View File

@ -337,4 +337,30 @@ interface StripeClient
* @return array<int, array<string, mixed>>
*/
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;
}

View File

@ -0,0 +1,73 @@
<?php
use App\Services\Stripe\HttpStripeClient;
use Illuminate\Support\Facades\Http;
/**
* Der Stripe-Client konnte bislang alles außer dem, was ein Kunde selbst
* braucht: seine Karte tauschen. Ohne diese drei Aufrufe mahnt jeder spätere
* Mahnlauf jemanden, der die Ursache gar nicht beheben kann.
*/
beforeEach(function () {
// Der Secret Key ist ein STRIKTER Tresoreintrag — `config()` greift dort
// bewusst nicht, damit ein Testkauf bei fehlendem Testschlüssel nicht
// still den Live-Schlüssel benutzt. Also die vorhandene Vorrichtung.
config()->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();
});