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; } } /** * Move every module item this contract carries onto the Price that charges * today's figure for it, to the customer who is paying for it. * * Two reasons an item is on the wrong Price, and __invoke() above finds * neither of them: the item exists and the quantity is right, so it has * nothing to reconcile. This is the deliberate second pass, run by * stripe:reprice-subscriptions. * * The first is a change of figure — the catalogue moved from pushing NET * amounts to Stripe to pushing the amount actually charged, and a Stripe Price * cannot be edited, so every running module was on a Price that takes too * little. The second is a change of CUSTOMER: a business whose VAT id is * verified after they booked owes no VAT on the module either, and belongs on * its net Price from then on — the reverse too, when a registration lapses. * * PRORATE_NONE throughout. The customer has already paid for the term they * are in, at whatever was taken then; charging the difference for days * already served would be a bill nobody agreed to. From the next cycle * Stripe simply takes the right amount. * * The module's own frozen `price_cents` is untouched, because that is what * the customer agreed to pay net and it is what every pro-rata sum reads. * * @return array what moved, for the command's output */ public function reprice(Subscription $subscription, bool $dryRun = false): array { if ($subscription->stripe_subscription_id === null) { return []; } $moved = []; foreach ($this->groups($subscription) as $group) { $billable = $group->filter( fn (SubscriptionAddon $addon) => $addon->cancelled_at === null && $addon->cancels_at === null ); $stamped = $group->filter(fn (SubscriptionAddon $addon) => $addon->stripe_item_id !== null); $itemId = $stamped->first()?->stripe_item_id; $first = $billable->first(); // Nothing Stripe is billing, or nothing it should be: __invoke() // owns both of those cases and would undo whatever this did. if ($itemId === null || $first === null || (int) $first->price_cents <= 0) { continue; } $target = $this->prices->liveFor( (string) $first->addon_key, (int) $first->price_cents, (string) $first->currency, (string) $subscription->term, TaxTreatment::for($subscription->customer), ); $current = $stamped->first()?->stripe_price_id; if ($target !== null && $target === $current) { continue; } $moved[] = ['addon' => (string) $first->addon_key, 'from' => $current, 'to' => $target]; if ($dryRun) { continue; } $this->guardConfigured(); // Minted here where the sweep left a figure the catalogue no longer // pre-creates — a grandfathered booking, or a discounted grant. $target ??= $this->prices->ensure( (string) $first->addon_key, (int) $first->price_cents, (string) $first->currency, (string) $subscription->term, TaxTreatment::for($subscription->customer), ); if ($target === null) { // Nothing to bill for it after all — a module given away. Taken // back out of the report rather than announced as a move. array_pop($moved); continue; } $moved[count($moved) - 1]['to'] = $target; $this->stripe->updateSubscriptionPrice( (string) $subscription->stripe_subscription_id, $itemId, $target, StripeClient::PRORATE_NONE, ); $this->stamp($group, $itemId, $target); } return $moved; } /** * 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, // Whoever holds the contract decides which of the module's Prices its // item bills through: the domestic gross, or the bare net where the // customer is a verified business in another member state. A module is // a supply like the package beside it and cannot be taxed differently // from it on the same invoice. TaxTreatment::for($subscription->customer), ); 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, ]]); } }