CluPilotCloud/app/Services/Stripe/HttpStripeClient.php

125 lines
4.1 KiB
PHP

<?php
namespace App\Services\Stripe;
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
* three 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 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();
}
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(\App\Services\Secrets\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;
}
}