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 $values * @return array */ 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; } }