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) { // Whether the catalogue prices this contract's package at all, asked // separately from WHICH Price it should be on. A version that was never // priced is a contract this command has nothing to say about; one that // is priced but has no Stripe Price for this customer's treatment is a // contract that must move, and the move is what mints it. $priced = $prices->rowFor($contract) !== null; $target = $priced ? $prices->liveForSubscription($contract) : null; if ($priced && $contract->stripe_price_id !== $target) { $this->line(sprintf( ' package %s %s → %s', $contract->uuid, $contract->stripe_price_id ?? '(unknown)', // Null where the catalogue has no Price for what this customer // should be charged. Said out loud rather than skipped: the move // below parks the reason on the contract and the run warns about // it, and the cure is one command. $target ?? '(not synced)', )); $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; } }