option('dry-run'); if (! $dryRun && ! $stripe->isConfigured()) { $this->error('Stripe is not configured (STRIPE_SECRET is empty). Nothing was moved.'); return self::FAILURE; } // Active contracts Stripe actually bills. A cancelled one is not billed // again, and a granted one has no Stripe subscription at all — moving // either would be housekeeping on something that charges nobody. $contracts = Subscription::query() ->whereNotNull('stripe_subscription_id') ->where('status', 'active') ->orderBy('id') ->limit((int) $this->option('limit')) ->get(); $packages = 0; $items = 0; $failed = 0; foreach ($contracts as $contract) { $target = $this->packagePrice($contract); if ($target !== null && $contract->stripe_price_id !== $target) { $this->line(sprintf( ' package %s %s → %s', $contract->uuid, $contract->stripe_price_id ?? '(unknown)', $target, )); $packages++; // PRORATE_NONE: the term in progress is paid for. See the class // comment — this must never raise a charge for days already // served at the old figure. if (! $dryRun && ! $move($contract, StripeClient::PRORATE_NONE)) { $failed++; } } foreach ($modules->reprice($contract, $dryRun) as $item) { $this->line(sprintf( ' module %s %s %s → %s', $contract->uuid, $item['addon'], $item['from'] ?? '(none)', $item['to'] ?? '(to be created)', )); $items++; } } $this->newLine(); if ($packages === 0 && $items === 0) { $this->info('Every live contract is already on the price the catalogue sells.'); return self::SUCCESS; } $this->info($dryRun ? "{$packages} package(s) and {$items} module item(s) would move. Run without --dry-run to move them." : "{$packages} package(s) and {$items} module item(s) moved."); if ($failed > 0) { // Parked on the contract by the action and retried hourly by // clupilot:sync-stripe-subscriptions, so this is a warning and not a // failure — but somebody watching the output has to be told. $this->warn("{$failed} package move(s) did not reach Stripe and are parked for the hourly retry."); } return self::SUCCESS; } /** * The Stripe Price the catalogue sells this contract's package on now. * * By plan VERSION and term, exactly as MoveStripeSubscriptionPrice resolves * it — resolving it by plan name would hand a grandfathered contract today's * terms. Null where the version was never synced, which is a contract this * command has nothing to say about. */ private function packagePrice(Subscription $subscription): ?string { $priceId = PlanPrice::query() ->where('plan_version_id', $subscription->plan_version_id) ->where('term', $subscription->term) ->value('stripe_price_id'); return is_string($priceId) && $priceId !== '' ? $priceId : null; } }