CluPilotCloud/app/Services/Stripe/HttpStripeClient.php

532 lines
21 KiB
PHP

<?php
namespace App\Services\Stripe;
use App\Exceptions\StripeNotConfigured;
use App\Services\Secrets\SecretVault;
use App\Support\OperatingMode;
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
{
// Reads the vault directly rather than through secret(): that method
// now THROWS when the key is missing (see below), and every caller
// here — the checkout controller, the catalogue sync commands — asks
// this question to decide whether to proceed gracefully. Routing it
// through secret() would turn "is it configured?" into an exception.
return filled(app(SecretVault::class)->get('stripe.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'] ?? []);
$prices[] = [
'id' => (string) ($price['id'] ?? ''),
'unit_amount' => (int) ($price['unit_amount'] ?? 0),
'currency' => strtoupper((string) ($price['currency'] ?? '')),
'interval' => (string) ($recurring['interval'] ?? ''),
// The four properties that decide what a Price CHARGES
// beyond its amount. createPrice() sends none of them, so
// Stripe defaults every one — and an ABSENT key is that
// default, never a reason to reject. Reported rather than
// filtered: the class that promises adoption cannot move
// money is AdoptStripePrice, and it could not keep that
// promise while these lived here, where FakeStripeClient
// cannot express them and no test of that class could reach
// them.
'interval_count' => (int) ($recurring['interval_count'] ?? 1),
'usage_type' => (string) ($recurring['usage_type'] ?? 'licensed'),
'transform_quantity' => (array) ($price['transform_quantity'] ?? []) !== [],
'billing_scheme' => (string) ($price['billing_scheme'] ?? 'per_unit'),
'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);
// invoiceLines() stops here, and there that costs a short document.
// Here it costs an unseen orphan and a second live Price for one
// figure — the incident. So it is an error, not a stopping point.
if (($page['has_more'] ?? false) === true && $after === null) {
throw new RuntimeException(
'Stripe reported another page of prices and the last item of this one carried no id; '
.'refusing to stop half-read.'
);
}
} while (($page['has_more'] ?? false) === true);
return $prices;
}
public function activeProducts(): array
{
$products = [];
$after = null;
do {
$page = $this->request()
->get($this->url('products'), array_filter([
'active' => 'true',
'limit' => 100,
'starting_after' => $after,
], fn ($value) => $value !== null))
->throw()
->json();
$data = (array) ($page['data'] ?? []);
foreach ($data as $product) {
$products[] = [
'id' => (string) ($product['id'] ?? ''),
'name' => (string) ($product['name'] ?? ''),
'created' => (int) ($product['created'] ?? 0),
'metadata' => array_map(
fn ($value) => (string) $value,
(array) ($product['metadata'] ?? []),
),
];
}
$after = $data === [] ? null : ($data[array_key_last($data)]['id'] ?? null);
// Same reason as activePricesFor(): a half-read list here means a
// Product we own goes unrecognised, and the next run mints a second
// one — which blinds the Price recognition for the whole family.
if (($page['has_more'] ?? false) === true && $after === null) {
throw new RuntimeException(
'Stripe reported another page of products and the last item of this one carried no id; '
.'refusing to stop half-read.'
);
}
} while (($page['has_more'] ?? false) === true);
return $products;
}
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() itself throws when the key is missing, so there is nothing
// blank left to check here — see its docblock.
$request = Http::withToken($this->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 for the active operating
* mode. Throws rather than returning blank — see StripeNotConfigured.
*
* 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
{
$secret = app(SecretVault::class)->get('stripe.secret');
// Ohne Token abzusenden hieße, Stripe mit einem leeren Bearer zu fragen
// und einen 401 zu bekommen, den niemand einem fehlenden Schlüssel
// zuordnet. Hier abzubrechen sagt, was fehlt.
if (blank($secret)) {
throw StripeNotConfigured::forMode(OperatingMode::current()->value);
}
return $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;
}
}