get('stripe.secret')); } public function createCheckoutSession( string $priceId, string $successUrl, string $cancelUrl, array $metadata = [], ?string $customerEmail = null, ?string $idempotencyKey = null, ?array $oneOff = 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, ...$this->oneOffLine($oneOff), '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'); } /** * The setup fee as a second, non-recurring line item. * * Priced inline (`price_data`) rather than against a stored Price id, and this * is the one place in the file that does. A plan Price is minted once and * billed against for years — grandfathering a customer needs the Price to * outlive the catalogue row, which is why those are stored. A setup fee is * taken once, at this checkout, and never referred to again: an immutable * Stripe Price per figure the operator ever types would be a table of dead * objects, and nothing would ever bill against yesterday's. * * No `recurring` key, which is what makes it one-off — Stripe charges it on * the initial invoice and on no later one. * * @param array{amount_cents: int, label: string, currency: string}|null $oneOff * @return array */ private function oneOffLine(?array $oneOff): array { $amount = (int) ($oneOff['amount_cents'] ?? 0); if ($oneOff === null || $amount <= 0) { return []; } return [ 'line_items[1][quantity]' => 1, 'line_items[1][price_data][currency]' => strtolower((string) $oneOff['currency']), 'line_items[1][price_data][unit_amount]' => $amount, 'line_items[1][price_data][product_data][name]' => (string) $oneOff['label'], ]; } public function createProduct(string $name, array $metadata = [], ?string $idempotencyKey = null): string { return (string) $this->request(IdempotencyKey::forProduct($idempotencyKey, $name, $metadata)) ->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::forPrice( $idempotencyKey, $productId, $amountCents, $currency, $interval, $metadata, )) ->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 activatePrice(string $priceId): void { // `active` is one of the very few fields a Stripe Price does let you // change, which is what makes bringing one back possible at all — the // amount is not, and that is why a changed figure is always a new Price. $this->request() ->asForm() ->post($this->url('prices/'.$priceId), ['active' => 'true']) ->throw(); } public function activePricesFor(string $productId): array { $prices = []; $after = null; // Paged through to the end, for the same reason invoiceLines() is: Stripe // caps a page at a hundred, and a family Product collects a Price per // version, term, treatment and rate change. Stopping at the first page // would leave an orphan unfound and mint the duplicate anyway. do { $page = $this->request() ->get($this->url('prices'), array_filter([ 'product' => $productId, 'active' => 'true', 'type' => 'recurring', 'limit' => 100, 'starting_after' => $after, ], fn ($value) => $value !== null)) ->throw() ->json(); $data = (array) ($page['data'] ?? []); foreach ($data as $price) { $recurring = (array) ($price['recurring'] ?? []); $prices[] = [ 'id' => (string) ($price['id'] ?? ''), 'unit_amount' => (int) ($price['unit_amount'] ?? 0), 'currency' => strtoupper((string) ($price['currency'] ?? '')), 'interval' => (string) ($recurring['interval'] ?? ''), // The four properties that decide what a Price CHARGES // beyond its amount. createPrice() sends none of them, so // Stripe defaults every one — and an ABSENT key is that // default, never a reason to reject. Reported rather than // filtered: the class that promises adoption cannot move // money is AdoptStripePrice, and it could not keep that // promise while these lived here, where FakeStripeClient // cannot express them and no test of that class could reach // them. 'interval_count' => (int) ($recurring['interval_count'] ?? 1), 'usage_type' => (string) ($recurring['usage_type'] ?? 'licensed'), 'transform_quantity' => (array) ($price['transform_quantity'] ?? []) !== [], 'billing_scheme' => (string) ($price['billing_scheme'] ?? 'per_unit'), 'created' => (int) ($price['created'] ?? 0), 'metadata' => array_map( fn ($value) => (string) $value, (array) ($price['metadata'] ?? []), ), ]; } $after = $data === [] ? null : ($data[array_key_last($data)]['id'] ?? null); // invoiceLines() stops here, and there that costs a short document. // Here it costs an unseen orphan and a second live Price for one // figure — the incident. So it is an error, not a stopping point. if (($page['has_more'] ?? false) === true && $after === null) { throw new RuntimeException( 'Stripe reported another page of prices and the last item of this one carried no id; ' .'refusing to stop half-read.' ); } } while (($page['has_more'] ?? false) === true); return $prices; } public function activeProducts(): array { $products = []; $after = null; do { $page = $this->request() ->get($this->url('products'), array_filter([ 'active' => 'true', 'limit' => 100, 'starting_after' => $after, ], fn ($value) => $value !== null)) ->throw() ->json(); $data = (array) ($page['data'] ?? []); foreach ($data as $product) { $products[] = [ 'id' => (string) ($product['id'] ?? ''), 'name' => (string) ($product['name'] ?? ''), 'created' => (int) ($product['created'] ?? 0), 'metadata' => array_map( fn ($value) => (string) $value, (array) ($product['metadata'] ?? []), ), ]; } $after = $data === [] ? null : ($data[array_key_last($data)]['id'] ?? null); // Same reason as activePricesFor(): a half-read list here means a // Product we own goes unrecognised, and the next run mints a second // one — which blinds the Price recognition for the whole family. if (($page['has_more'] ?? false) === true && $after === null) { throw new RuntimeException( 'Stripe reported another page of products and the last item of this one carried no id; ' .'refusing to stop half-read.' ); } } while (($page['has_more'] ?? false) === true); return $products; } public function updatePriceMetadata(string $priceId, array $metadata): void { $this->request() ->asForm() ->post($this->url('prices/'.$priceId), $this->flatten('metadata', $metadata)) ->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; } public function addSubscriptionItem( string $subscriptionId, string $priceId, int $quantity, string $prorationBehaviour, ?string $idempotencyKey = null, ): string { // Straight onto the item collection rather than through the // subscription: posting items[] to the subscription REPLACES the set, // so an item added that way takes the package's off with it. return (string) $this->request($idempotencyKey) ->asForm() ->post($this->url('subscription_items'), [ 'subscription' => $subscriptionId, 'price' => $priceId, 'quantity' => $quantity, 'proration_behavior' => $prorationBehaviour, ]) ->throw() ->json('id'); } public function updateSubscriptionItemQuantity( string $itemId, int $quantity, string $prorationBehaviour, ): void { $this->request() ->asForm() ->post($this->url('subscription_items/'.$itemId), [ 'quantity' => $quantity, 'proration_behavior' => $prorationBehaviour, ]) ->throw(); } public function removeSubscriptionItem(string $itemId, string $prorationBehaviour): void { $this->request() ->asForm() ->delete($this->url('subscription_items/'.$itemId), [ 'proration_behavior' => $prorationBehaviour, ]) ->throw(); } public function cancelSubscription( string $subscriptionId, string $when, ?string $idempotencyKey = null, ): void { $atPeriodEnd = match ($when) { self::CANCEL_AT_PERIOD_END => true, self::CANCEL_IMMEDIATELY => false, // A typo would take a paying customer's service away this afternoon, // or leave a withdrawn one subscribed for ever. Neither is a thing to // fall back to a default about. default => throw new RuntimeException("Unknown cancellation timing: {$when}"), }; if ($atPeriodEnd) { // An UPDATE and not a delete. The subscription stays alive to the // boundary the customer has paid to, raises no further cycle, and // Stripe ends it there of its own accord; deleting it here would take // the service away the moment somebody clicked "cancel". $this->request($idempotencyKey) ->asForm() ->post($this->url('subscriptions/'.$subscriptionId), ['cancel_at_period_end' => 'true']) ->throw(); return; } // Both stated rather than left to Stripe's defaults, because both decide // money. `prorate` would raise a credit for the unused days and // `invoice_now` would charge whatever is unbilled — and the caller that // asks for an immediate stop is a withdrawal, which sends the WHOLE // amount back from a cancellation document of its own. A credit note of // Stripe's beside that would be the same money twice. $this->request($idempotencyKey) ->asForm() ->delete($this->url('subscriptions/'.$subscriptionId), [ 'prorate' => 'false', 'invoice_now' => 'false', ]) ->throw(); } public function refund( string $paymentReference, ?int $amountCents = null, ?string $idempotencyKey = null, ): string { // Stripe takes the payment under one key or the other, never both, and // the id says which it is. Guessing wrong is a 400, which is at least // loud — but sending a charge id as a payment intent would refund // nothing on some API versions and everything on others. $key = str_starts_with($paymentReference, 'ch_') ? 'charge' : 'payment_intent'; return (string) $this->request($idempotencyKey) ->asForm() ->post($this->url('refunds'), array_filter([ $key => $paymentReference, // Omitted for a full refund, which is what Stripe's own absence // of an amount means. Passing the full figure explicitly would // be the same thing with one more chance to be wrong. 'amount' => $amountCents, // The only reason we ever have. A withdrawal is the customer // asking for their money back, and Stripe's dispute analytics // read this field. 'reason' => 'requested_by_customer', ], fn ($value) => $value !== null)) ->throw() ->json('id'); } public function invoicePaymentReference(string $invoiceId): ?string { $invoice = $this->request() ->get($this->url('invoices/'.$invoiceId)) ->throw() ->json(); // Both fields, in this order. Which one an invoice carries depends on // the API version the account is pinned to — newer ones dropped // `payment_intent` from the invoice — and reading only one of them // would make refunds work on some installations and not others. foreach (['payment_intent', 'charge'] as $field) { $value = $invoice[$field] ?? null; if (is_string($value) && $value !== '') { return $value; } } return null; } public function invoiceLines(string $invoiceId): array { $lines = []; $after = null; // Paged through to the end. Stripe caps a page at a hundred, and the // point of asking at all is to get the lines the event did not carry — // stopping at the first page would reintroduce exactly that gap. do { $page = $this->request() ->get($this->url('invoices/'.$invoiceId.'/lines'), array_filter([ 'limit' => 100, 'starting_after' => $after, ])) ->throw() ->json(); $data = (array) ($page['data'] ?? []); $lines = [...$lines, ...$data]; $after = $data === [] ? null : ($data[array_key_last($data)]['id'] ?? null); } while (($page['has_more'] ?? false) === true && $after !== null); return $lines; } private function request(?string $idempotencyKey = null): PendingRequest { // secret() itself throws when the key is missing, so there is nothing // blank left to check here — see its docblock. $request = Http::withToken($this->secret())->acceptJson()->timeout(20); // Stripe replays the original response for a repeated key instead of // creating a second object — but ONLY for a call repeated exactly; a // changed parameter under a used key is HTTP 400 for twenty-four hours. // The catalogue calls therefore fold a fingerprint of their parameters // into the key (see IdempotencyKey); the money calls deliberately do // not. Their keys expire after 24 hours, so this protects a retry, not // a sync re-run next week — for which the stored ids and // AdoptStripePrice are the guard. return $idempotencyKey !== null ? $request->withHeaders(['Idempotency-Key' => $idempotencyKey]) : $request; } /** * The key in force: the one stored in the console for the active operating * mode. Throws rather than returning blank — see StripeNotConfigured. * * 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 { $secret = app(SecretVault::class)->get('stripe.secret'); // Ohne Token abzusenden hieße, Stripe mit einem leeren Bearer zu fragen // und einen 401 zu bekommen, den niemand einem fehlenden Schlüssel // zuordnet. Hier abzubrechen sagt, was fehlt. if (blank($secret)) { throw StripeNotConfigured::forMode(OperatingMode::current()->value); } return $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; } }