stripe_subscription_id === null) { return true; } $priceId = null; try { $priceId = $this->targetPrice($subscription); if ($priceId === null) { throw new RuntimeException( 'The catalogue has no Stripe price for this plan version on this term. Run stripe:sync-catalogue.' ); } // Already billing it. A retry of a swap that in fact went through, // or a second change back onto the price Stripe is on: either way // asking again would raise a proration for a move that is not one. if ($subscription->stripe_price_id === $priceId) { $this->settled($subscription, $priceId); return true; } if (! $this->stripe->isConfigured()) { throw new RuntimeException('Stripe is not configured (STRIPE_SECRET is empty).'); } $itemId = $this->itemId($subscription); if ($itemId === null) { throw new RuntimeException( 'Stripe reports no item on this subscription, so there is nothing to put another price on.' ); } $this->stripe->updateSubscriptionPrice( $subscription->stripe_subscription_id, $itemId, $priceId, $behaviour, ); $this->settled($subscription, $priceId); return true; } catch (Throwable $e) { $this->park($subscription, $priceId, $behaviour, $e); return false; } } /** * Try a parked swap again. * * The target price is worked out afresh from the contract rather than read * back from the parked record: another change may have landed in between, * and the contract is the authority on which package the customer is on. * The BEHAVIOUR is the one that was chosen at the time, because it belongs * to the direction the move went, which cannot be read off the contract * once it has moved. * * A retried upgrade prorates from the moment Stripe finally hears about it, * not from the moment the change landed here — so a long outage charges the * customer for slightly fewer days than they had the bigger package. In * their favour, and preferable to the alternative of back-dating a charge. */ public function retry(Subscription $subscription): bool { $parked = (array) ($subscription->stripe_price_sync ?? []); return $this($subscription, (string) ($parked['behaviour'] ?? StripeClient::PRORATE_NONE)); } /** * The Stripe Price for the package the contract is on now, for the customer * who is paying for it. * * By plan VERSION and term, which is what a Stripe Price is: one per priced * row and per tax treatment, written by stripe:sync-catalogue. Resolving it by * plan name would hand back today's terms for a contract that is * grandfathered on older ones. * * The treatment is part of the answer, which is what makes a verification * converge: a business whose VAT id is confirmed the week after they bought * belongs on the net Price from that moment, and one whose registration has * lapsed belongs back on the gross one. Neither is a change of package, so * nothing here looks like one — the move is made with PRORATE_NONE by the * sweep that finds it, because the term is already paid for. */ private function targetPrice(Subscription $subscription): ?string { return $this->prices->liveForSubscription($subscription); } /** * The item the subscription bills through. * * Stored when we have it — a `customer.subscription.updated` event carries * it and ApplyStripeBillingEvent keeps it. A contract older than that column * has none, so Stripe is asked once and the answer is kept: the item id * survives a price swap, so this happens at most once per contract. */ private function itemId(Subscription $subscription): ?string { if ($subscription->stripe_item_id !== null) { return $subscription->stripe_item_id; } $itemId = $this->stripe->subscriptionItemId((string) $subscription->stripe_subscription_id); if ($itemId !== null) { $subscription->update(['stripe_item_id' => $itemId]); } return $itemId; } /** Stripe and the contract agree; nothing is outstanding. */ private function settled(Subscription $subscription, string $priceId): void { $subscription->update([ 'stripe_price_id' => $priceId, 'stripe_price_synced_at' => now(), 'stripe_price_sync' => null, ]); } /** * The change landed here and did not reach Stripe. * * Recorded on the contract rather than only logged: a log line scrolls away, * and what this describes is a customer being charged for a package they are * no longer on. The row is what the retry sweep reads and what an operator * can be shown. */ private function park(Subscription $subscription, ?string $priceId, string $behaviour, Throwable $e): void { $parked = (array) ($subscription->stripe_price_sync ?? []); $attempts = (int) ($parked['attempts'] ?? 0) + 1; Log::error('A plan change landed but did not reach Stripe: this contract is still being billed at the old price.', [ 'subscription' => $subscription->uuid, 'plan' => $subscription->plan, 'stripe_subscription' => $subscription->stripe_subscription_id, 'stripe_price' => $priceId, 'attempts' => $attempts, 'error' => $e->getMessage(), ]); $subscription->update(['stripe_price_sync' => [ 'price' => $priceId, 'behaviour' => $behaviour, 'error' => mb_substr($e->getMessage(), 0, 250), 'failed_at' => now()->toIso8601String(), 'attempts' => $attempts, ]]); } }