CluPilotCloud/app/Services/Stripe/HttpStripeClient.php

348 lines
13 KiB
PHP

<?php
namespace App\Services\Stripe;
use App\Services\Secrets\SecretVault;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Http;
use RuntimeException;
/**
* Stripe's REST API over the HTTP client, rather than the SDK: this touches a
* handful of endpoints, and a fake for the tests is easier to trust than a
* mocked library. Not unit-tested against the real service (live I/O).
*/
class HttpStripeClient implements StripeClient
{
public function isConfigured(): bool
{
return filled($this->secret());
}
public function createCheckoutSession(
string $priceId,
string $successUrl,
string $cancelUrl,
array $metadata = [],
?string $customerEmail = null,
?string $idempotencyKey = null,
): string {
return (string) $this->request($idempotencyKey)
->asForm()
->post($this->url('checkout/sessions'), array_filter([
'mode' => 'subscription',
'line_items[0][price]' => $priceId,
'line_items[0][quantity]' => 1,
'success_url' => $successUrl,
'cancel_url' => $cancelUrl,
// Prefilled, not fixed: Stripe still lets the customer correct
// it, and whatever they confirm is what customer_details.email
// carries back — which is the address the credentials go to.
'customer_email' => $customerEmail,
...$this->flatten('metadata', $metadata),
// The same values on the subscription, because a renewal or a
// payment failure months from now arrives with the subscription
// and not with the session that opened it.
...$this->flatten('subscription_data[metadata]', $metadata),
], fn ($value) => $value !== null && $value !== ''))
->throw()
->json('url');
}
public function createProduct(string $name, array $metadata = [], ?string $idempotencyKey = null): string
{
return (string) $this->request($idempotencyKey)
->asForm()
->post($this->url('products'), array_filter([
'name' => $name,
...$this->flatten('metadata', $metadata),
]))
->throw()
->json('id');
}
public function createPrice(
string $productId,
int $amountCents,
string $currency,
string $interval,
array $metadata = [],
?string $idempotencyKey = null,
): string {
return (string) $this->request($idempotencyKey)
->asForm()
->post($this->url('prices'), [
'product' => $productId,
'unit_amount' => $amountCents,
'currency' => strtolower($currency),
// Recurring, because a plan is a subscription. The interval
// belongs to the Price, which is why monthly and yearly cannot
// share one.
'recurring[interval]' => $interval,
...$this->flatten('metadata', $metadata),
])
->throw()
->json('id');
}
public function archivePrice(string $priceId): void
{
// Deactivated, never deleted: a Stripe Price is immutable and existing
// subscriptions keep billing against it. That is the same
// grandfathering our own catalogue does, enforced on their side too.
$this->request()
->asForm()
->post($this->url('prices/'.$priceId), ['active' => 'false'])
->throw();
}
public function updateSubscriptionPrice(
string $subscriptionId,
string $itemId,
string $priceId,
string $prorationBehaviour,
): void {
// The item is named as well as the price. Without its id Stripe adds a
// SECOND item rather than replacing the first, and the customer is then
// billed for both packages at once.
$this->request()
->asForm()
->post($this->url('subscriptions/'.$subscriptionId), [
'items[0][id]' => $itemId,
'items[0][price]' => $priceId,
'proration_behavior' => $prorationBehaviour,
])
->throw();
}
public function subscriptionItemId(string $subscriptionId): ?string
{
$id = $this->request()
->get($this->url('subscriptions/'.$subscriptionId))
->throw()
->json('items.data.0.id');
return is_string($id) && $id !== '' ? $id : null;
}
public function addSubscriptionItem(
string $subscriptionId,
string $priceId,
int $quantity,
string $prorationBehaviour,
?string $idempotencyKey = null,
): string {
// Straight onto the item collection rather than through the
// subscription: posting items[] to the subscription REPLACES the set,
// so an item added that way takes the package's off with it.
return (string) $this->request($idempotencyKey)
->asForm()
->post($this->url('subscription_items'), [
'subscription' => $subscriptionId,
'price' => $priceId,
'quantity' => $quantity,
'proration_behavior' => $prorationBehaviour,
])
->throw()
->json('id');
}
public function updateSubscriptionItemQuantity(
string $itemId,
int $quantity,
string $prorationBehaviour,
): void {
$this->request()
->asForm()
->post($this->url('subscription_items/'.$itemId), [
'quantity' => $quantity,
'proration_behavior' => $prorationBehaviour,
])
->throw();
}
public function removeSubscriptionItem(string $itemId, string $prorationBehaviour): void
{
$this->request()
->asForm()
->delete($this->url('subscription_items/'.$itemId), [
'proration_behavior' => $prorationBehaviour,
])
->throw();
}
public function cancelSubscription(
string $subscriptionId,
string $when,
?string $idempotencyKey = null,
): void {
$atPeriodEnd = match ($when) {
self::CANCEL_AT_PERIOD_END => true,
self::CANCEL_IMMEDIATELY => false,
// A typo would take a paying customer's service away this afternoon,
// or leave a withdrawn one subscribed for ever. Neither is a thing to
// fall back to a default about.
default => throw new RuntimeException("Unknown cancellation timing: {$when}"),
};
if ($atPeriodEnd) {
// An UPDATE and not a delete. The subscription stays alive to the
// boundary the customer has paid to, raises no further cycle, and
// Stripe ends it there of its own accord; deleting it here would take
// the service away the moment somebody clicked "cancel".
$this->request($idempotencyKey)
->asForm()
->post($this->url('subscriptions/'.$subscriptionId), ['cancel_at_period_end' => 'true'])
->throw();
return;
}
// Both stated rather than left to Stripe's defaults, because both decide
// money. `prorate` would raise a credit for the unused days and
// `invoice_now` would charge whatever is unbilled — and the caller that
// asks for an immediate stop is a withdrawal, which sends the WHOLE
// amount back from a cancellation document of its own. A credit note of
// Stripe's beside that would be the same money twice.
$this->request($idempotencyKey)
->asForm()
->delete($this->url('subscriptions/'.$subscriptionId), [
'prorate' => 'false',
'invoice_now' => 'false',
])
->throw();
}
public function refund(
string $paymentReference,
?int $amountCents = null,
?string $idempotencyKey = null,
): string {
// Stripe takes the payment under one key or the other, never both, and
// the id says which it is. Guessing wrong is a 400, which is at least
// loud — but sending a charge id as a payment intent would refund
// nothing on some API versions and everything on others.
$key = str_starts_with($paymentReference, 'ch_') ? 'charge' : 'payment_intent';
return (string) $this->request($idempotencyKey)
->asForm()
->post($this->url('refunds'), array_filter([
$key => $paymentReference,
// Omitted for a full refund, which is what Stripe's own absence
// of an amount means. Passing the full figure explicitly would
// be the same thing with one more chance to be wrong.
'amount' => $amountCents,
// The only reason we ever have. A withdrawal is the customer
// asking for their money back, and Stripe's dispute analytics
// read this field.
'reason' => 'requested_by_customer',
], fn ($value) => $value !== null))
->throw()
->json('id');
}
public function invoicePaymentReference(string $invoiceId): ?string
{
$invoice = $this->request()
->get($this->url('invoices/'.$invoiceId))
->throw()
->json();
// Both fields, in this order. Which one an invoice carries depends on
// the API version the account is pinned to — newer ones dropped
// `payment_intent` from the invoice — and reading only one of them
// would make refunds work on some installations and not others.
foreach (['payment_intent', 'charge'] as $field) {
$value = $invoice[$field] ?? null;
if (is_string($value) && $value !== '') {
return $value;
}
}
return null;
}
public function invoiceLines(string $invoiceId): array
{
$lines = [];
$after = null;
// Paged through to the end. Stripe caps a page at a hundred, and the
// point of asking at all is to get the lines the event did not carry —
// stopping at the first page would reintroduce exactly that gap.
do {
$page = $this->request()
->get($this->url('invoices/'.$invoiceId.'/lines'), array_filter([
'limit' => 100,
'starting_after' => $after,
]))
->throw()
->json();
$data = (array) ($page['data'] ?? []);
$lines = [...$lines, ...$data];
$after = $data === [] ? null : ($data[array_key_last($data)]['id'] ?? null);
} while (($page['has_more'] ?? false) === true && $after !== null);
return $lines;
}
private function request(?string $idempotencyKey = null): PendingRequest
{
$secret = (string) $this->secret();
if (blank($secret)) {
throw new RuntimeException('Stripe is not configured (STRIPE_SECRET is empty).');
}
$request = Http::withToken($secret)->acceptJson()->timeout(20);
// Stripe replays the original response for a repeated key instead of
// creating a second object. That covers the gap this cannot close on
// its own: a crash between Stripe creating a Price and us storing its
// id. Their keys expire after 24 hours, so it protects a retry, not a
// sync re-run next week — for which the stored ids are the guard.
return $idempotencyKey !== null
? $request->withHeaders(['Idempotency-Key' => $idempotencyKey])
: $request;
}
/**
* The key in force: the one stored in the console if there is one, else the
* environment.
*
* Resolved HERE, at the point of use, rather than overlaid onto config at
* boot — an overlay would add a query to every request including the public
* site, and a queue worker would keep whatever was true when it started.
*/
private function secret(): ?string
{
return app(SecretVault::class)->get('stripe.secret');
}
private function url(string $path): string
{
return rtrim((string) config('services.stripe.api_base'), '/').'/'.$path;
}
/**
* Stripe's form API takes nested keys as `metadata[key]`.
*
* @param array<string, string|int|null> $values
* @return array<string, string>
*/
private function flatten(string $prefix, array $values): array
{
$flat = [];
foreach ($values as $key => $value) {
if ($value !== null && $value !== '') {
$flat["{$prefix}[{$key}]"] = (string) $value;
}
}
return $flat;
}
}