112 lines
3.7 KiB
PHP
112 lines
3.7 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(config('services.stripe.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) config('services.stripe.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;
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|