stripe_subscription_id === null) { $this->settled($subscription); return true; } try { foreach ($this->groups($subscription) as $group) { $this->reconcile($subscription, $group, $chargeForAdditions); } $this->settled($subscription); return true; } catch (Throwable $e) { $this->park($subscription, $e); return false; } } /** * The bookings that matter, grouped into the items they should be. * * By module AND by frozen price, because that pair is what a Stripe Price * is: two packs booked at the same money are one item with a quantity of * two, and a third booked after the catalogue moved is a second item at the * price that customer agreed to. Grouping by module alone would have to pick * one of two prices and charge everybody that. * * Cancelled bookings are in here as long as they still carry an item id — * that is the only record of what Stripe was last told, and without it the * quantity could never be brought back down. * * @return Collection> */ private function groups(Subscription $subscription): Collection { return SubscriptionAddon::query() ->where('subscription_id', $subscription->id) ->where(fn ($q) => $q->whereNull('cancelled_at')->orWhereNotNull('stripe_item_id')) ->orderBy('id') ->get() ->groupBy(fn (SubscriptionAddon $addon) => $addon->addon_key.'@'.$addon->price_cents); } /** * Bring one module's item into step with what the contract holds. * * @param Collection $group */ private function reconcile(Subscription $subscription, Collection $group, bool $chargeForAdditions = true): void { // What Stripe should bill: every booking still running and not already // on its way out. A module with `cancels_at` set is deliberately absent // — the customer keeps it, nobody is billed for it again. $billable = $group->filter( fn (SubscriptionAddon $addon) => $addon->cancelled_at === null && $addon->cancels_at === null ); // What Stripe was last told, read off the rows that carry the item id. $stamped = $group->filter(fn (SubscriptionAddon $addon) => $addon->stripe_item_id !== null); $desired = (int) $billable->sum('quantity'); $known = (int) $stamped->sum('quantity'); $itemId = $stamped->first()?->stripe_item_id; $first = $group->first(); // A module given away costs nothing, so there is nothing for Stripe to // bill — and a zero-amount item would put a line saying so on every // invoice the customer ever gets. Treated exactly like a module that is // no longer held: if one was ever billed, it comes off. if ($desired === 0 || (int) $first->price_cents <= 0) { if ($itemId !== null) { $this->guardConfigured(); $this->stripe->removeSubscriptionItem($itemId, StripeClient::PRORATE_NONE); } $this->stamp($group, null, null); return; } // Stripe already bills exactly this. Only the bookings are restamped — // one may have been cancelled while another was booked in the same // breath, which leaves the quantity right and the rows out of date. if ($itemId !== null && $known === $desired) { $this->stamp($group, $itemId, $stamped->first()?->stripe_price_id); return; } // Asked for only now that something is going to be said to Stripe: an // unconfigured account must fail here, where it is parked and retried, // rather than while resolving a Price nobody is going to use. $this->guardConfigured(); $priceId = $this->prices->ensure( (string) $first->addon_key, (int) $first->price_cents, (string) $first->currency, (string) $subscription->term, ); if ($priceId === null) { $this->stamp($group, null, null); return; } if ($itemId === null) { $itemId = $this->stripe->addSubscriptionItem( (string) $subscription->stripe_subscription_id, $priceId, $desired, $chargeForAdditions ? StripeClient::PRORATE_IMMEDIATELY : StripeClient::PRORATE_NONE, // Keyed on the contract, the module and how many of it: a retry // after a timeout that in fact went through replays Stripe's // first answer instead of adding the module a second time. idempotencyKey: sprintf( 'clupilot-addon-item-%d-%s-%d-%d', $subscription->id, $first->addon_key, $first->price_cents, $desired, ), ); } else { $this->stripe->updateSubscriptionItemQuantity( $itemId, $desired, // More of a pack is bought now and paid for the days that are // left; fewer is a cancellation, which earns no credit. A // restoration is neither and settles nothing. $desired > $known && $chargeForAdditions ? StripeClient::PRORATE_IMMEDIATELY : StripeClient::PRORATE_NONE, ); } $this->stamp($group, $itemId, $priceId); } /** * Write down what Stripe now bills, on the bookings it bills. * * Every running booking in the group carries the item id, so a second pack * booked tomorrow can see that it is joining an item rather than starting * one. A booking that has stopped gives its id back, because the quantity * has already been taken down by it and counting it again would take the * same pack off twice. * * Through the query builder: none of these columns is part of what the * booking froze, and the model would otherwise have to be re-read first. * * @param Collection $group */ private function stamp(Collection $group, ?string $itemId, ?string $priceId): void { foreach ($group as $addon) { $billed = $itemId !== null && $addon->cancelled_at === null && $addon->cancels_at === null; $values = [ 'stripe_item_id' => $billed ? $itemId : null, 'stripe_price_id' => $billed ? $priceId : null, ]; if ($addon->stripe_item_id === $values['stripe_item_id'] && $addon->stripe_price_id === $values['stripe_price_id']) { continue; } SubscriptionAddon::query()->whereKey($addon->getKey())->update($values + ['updated_at' => now()]); $addon->forceFill($values)->syncOriginal(); } } private function guardConfigured(): void { if (! $this->stripe->isConfigured()) { throw new RuntimeException('Stripe is not configured (STRIPE_SECRET is empty).'); } } /** Stripe bills exactly what the contract holds; nothing is outstanding. */ private function settled(Subscription $subscription): void { if ($subscription->stripe_addon_sync === null) { return; } $subscription->update(['stripe_addon_sync' => null]); } /** * A module was booked or cancelled here and Stripe was not told. * * On the contract rather than only in a log line, for the same reason as * `stripe_price_sync` beside it: what this describes is a customer holding a * module nobody is billing them for — or being billed for one they gave up — * and a log line scrolls away. */ private function park(Subscription $subscription, Throwable $e): void { $parked = (array) ($subscription->stripe_addon_sync ?? []); $attempts = (int) ($parked['attempts'] ?? 0) + 1; Log::error('A module booking landed but did not reach Stripe: this contract is not billed for what it holds.', [ 'subscription' => $subscription->uuid, 'stripe_subscription' => $subscription->stripe_subscription_id, 'attempts' => $attempts, 'error' => $e->getMessage(), ]); $subscription->update(['stripe_addon_sync' => [ 'error' => mb_substr($e->getMessage(), 0, 250), 'failed_at' => now()->toIso8601String(), 'attempts' => $attempts, ]]); } }