CluPilotCloud/app/Services/Stripe/HttpStripeClient.php

484 lines
19 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,
?array $oneOff = 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,
...$this->oneOffLine($oneOff),
'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');
}
/**
* The setup fee as a second, non-recurring line item.
*
* Priced inline (`price_data`) rather than against a stored Price id, and this
* is the one place in the file that does. A plan Price is minted once and
* billed against for years — grandfathering a customer needs the Price to
* outlive the catalogue row, which is why those are stored. A setup fee is
* taken once, at this checkout, and never referred to again: an immutable
* Stripe Price per figure the operator ever types would be a table of dead
* objects, and nothing would ever bill against yesterday's.
*
* No `recurring` key, which is what makes it one-off — Stripe charges it on
* the initial invoice and on no later one.
*
* @param array{amount_cents: int, label: string, currency: string}|null $oneOff
* @return array<string, string|int>
*/
private function oneOffLine(?array $oneOff): array
{
$amount = (int) ($oneOff['amount_cents'] ?? 0);
if ($oneOff === null || $amount <= 0) {
return [];
}
return [
'line_items[1][quantity]' => 1,
'line_items[1][price_data][currency]' => strtolower((string) $oneOff['currency']),
'line_items[1][price_data][unit_amount]' => $amount,
'line_items[1][price_data][product_data][name]' => (string) $oneOff['label'],
];
}
public function createProduct(string $name, array $metadata = [], ?string $idempotencyKey = null): string
{
return (string) $this->request(IdempotencyKey::forProduct($idempotencyKey, $name, $metadata))
->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::forPrice(
$idempotencyKey, $productId, $amountCents, $currency, $interval, $metadata,
))
->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 activatePrice(string $priceId): void
{
// `active` is one of the very few fields a Stripe Price does let you
// change, which is what makes bringing one back possible at all — the
// amount is not, and that is why a changed figure is always a new Price.
$this->request()
->asForm()
->post($this->url('prices/'.$priceId), ['active' => 'true'])
->throw();
}
public function activePricesFor(string $productId): array
{
$prices = [];
$after = null;
// Paged through to the end, for the same reason invoiceLines() is: Stripe
// caps a page at a hundred, and a family Product collects a Price per
// version, term, treatment and rate change. Stopping at the first page
// would leave an orphan unfound and mint the duplicate anyway.
do {
$page = $this->request()
->get($this->url('prices'), array_filter([
'product' => $productId,
'active' => 'true',
'type' => 'recurring',
'limit' => 100,
'starting_after' => $after,
], fn ($value) => $value !== null))
->throw()
->json();
$data = (array) ($page['data'] ?? []);
foreach ($data as $price) {
$recurring = (array) ($price['recurring'] ?? []);
// Only prices we could have minted ourselves. createPrice() sends
// product, unit_amount, currency, recurring[interval] and metadata
// and nothing else, so Stripe defaults every other field that
// decides what a Price CHARGES — and anything but those defaults
// is a Price this platform did not write. Skipped here rather than
// returned, because AdoptStripePrice compares amount, currency and
// interval only and trusts that triple as the whole of what a
// Price charges:
//
// - interval_count multiplies the period — 3 bills every three
// months — while `interval` still reads 'month';
// - usage_type other than 'licensed' bills metered usage;
// - a transform_quantity divides the quantity before charging,
// and modules are billed BY quantity — SyncStripeAddonItems
// sums a pack into one item at quantity n — so divide_by 10
// would charge a customer holding three for one;
// - billing_scheme 'tiered' keeps the money in tiers, leaving
// unit_amount null. Read as 0 below, so such a Price fails the
// amount match only while the caller's own figure is not 0 —
// and PlanPrices::ensure() has no zero guard, so that is not a
// condition to rest on.
//
// An ABSENT key is Stripe's default and no reason to reject.
// Read the other way round this filter would refuse every
// legitimate price and turn recognition into a permanent no-op.
if ((int) ($recurring['interval_count'] ?? 1) !== 1
|| ($recurring['usage_type'] ?? 'licensed') !== 'licensed'
|| (array) ($price['transform_quantity'] ?? []) !== []
|| ($price['billing_scheme'] ?? 'per_unit') !== 'per_unit') {
continue;
}
$prices[] = [
'id' => (string) ($price['id'] ?? ''),
'unit_amount' => (int) ($price['unit_amount'] ?? 0),
'currency' => strtoupper((string) ($price['currency'] ?? '')),
'interval' => (string) ($recurring['interval'] ?? ''),
'created' => (int) ($price['created'] ?? 0),
'metadata' => array_map(
fn ($value) => (string) $value,
(array) ($price['metadata'] ?? []),
),
];
}
$after = $data === [] ? null : ($data[array_key_last($data)]['id'] ?? null);
} while (($page['has_more'] ?? false) === true && $after !== null);
return $prices;
}
public function updatePriceMetadata(string $priceId, array $metadata): void
{
$this->request()
->asForm()
->post($this->url('prices/'.$priceId), $this->flatten('metadata', $metadata))
->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 — but ONLY for a call repeated exactly; a
// changed parameter under a used key is HTTP 400 for twenty-four hours.
// The catalogue calls therefore fold a fingerprint of their parameters
// into the key (see IdempotencyKey); the money calls deliberately do
// not. Their keys expire after 24 hours, so this protects a retry, not
// a sync re-run next week — for which the stored ids and
// AdoptStripePrice 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;
}
}