CluPilotCloud/app/Services/Stripe/HttpStripeClient.php

185 lines
6.5 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;
}
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;
}
}