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