Stripe-Client: SetupIntent und Vorgabe-Zahlungsmittel
parent
62e5e0c346
commit
0ae752d2ae
|
|
@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
Loading…
Reference in New Issue