394 lines
15 KiB
PHP
394 lines
15 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)
|
|
->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 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 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;
|
|
}
|
|
}
|