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 $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; } }