sweepPlans($move); $this->sweepModules($modules); return $status; } private function sweepPlans(MoveStripeSubscriptionPrice $move): int { // Active contracts only. A cancelled one is not billed again, so moving // it onto another price would be housekeeping on something Stripe has // already finished with — and an upgrade's proration raised against a // contract that has ended would charge a departed customer. $parked = Subscription::query() ->whereNotNull('stripe_price_sync') ->whereNotNull('stripe_subscription_id') ->where('status', 'active') ->orderBy('id') ->limit((int) $this->option('limit')) ->get(); $moved = 0; foreach ($parked as $subscription) { if ($move->retry($subscription)) { $moved++; continue; } // The action has already logged why and counted the attempt. Said // again here because somebody is watching this command's output // and not the log. $this->warn(sprintf( 'Contract %s: still billed at the old price — %s', $subscription->uuid, (string) ($subscription->fresh()?->stripe_price_sync['error'] ?? 'unknown'), )); } $this->info("{$parked->count()} contract(s) out of step with Stripe, {$moved} moved."); return self::SUCCESS; } /** * Contracts whose modules Stripe is not billing. * * Found from the BOOKINGS rather than from a parked marker, because the * bookings are the authority: a module still running with no item id was * never put on the subscription, and one that has stopped while still * carrying an item id is being billed after it ended. `stripe_addon_sync` is * the record of what went wrong and how often — it is not where the work * lives, so a contract whose failure was never written down (a worker killed * mid-booking) is still found here. */ private function sweepModules(SyncStripeAddonItems $modules): void { $outstanding = SubscriptionAddon::query() ->where(fn ($q) => $q ->where(fn ($active) => $active->whereNull('cancelled_at')->whereNull('cancels_at')->whereNull('stripe_item_id')) ->orWhere(fn ($gone) => $gone->whereNotNull('stripe_item_id') ->where(fn ($ended) => $ended->whereNotNull('cancelled_at')->orWhereNotNull('cancels_at')))) ->select('subscription_id') ->distinct(); // Active contracts that Stripe actually bills. A granted contract has no // Stripe subscription at all and its bookings will never carry an item // id — sweeping those would retry, and log, something that is not wrong. $contracts = Subscription::query() ->whereIn('id', $outstanding) ->whereNotNull('stripe_subscription_id') ->where('status', 'active') ->orderBy('id') ->limit((int) $this->option('limit')) ->get(); $settled = 0; foreach ($contracts as $subscription) { if ($modules($subscription)) { $settled++; continue; } $this->warn(sprintf( 'Contract %s: modules not billed by Stripe — %s', $subscription->uuid, (string) ($subscription->fresh()?->stripe_addon_sync['error'] ?? 'unknown'), )); } $this->info("{$contracts->count()} contract(s) with modules out of step, {$settled} settled."); } }