CluPilotCloud/app/Console/Commands/RepriceStripeSubscriptions.php

150 lines
5.9 KiB
PHP

<?php
namespace App\Console\Commands;
use App\Actions\MoveStripeSubscriptionPrice;
use App\Actions\SyncStripeAddonItems;
use App\Models\PlanPrice;
use App\Models\Subscription;
use App\Services\Stripe\StripeClient;
use Illuminate\Console\Command;
/**
* Move contracts that are still billing on a superseded Stripe Price.
*
* stripe:sync-catalogue mints the Price that charges today's figure and archives
* the one it replaces — and that is all it does. Archiving a Price in Stripe
* stops it being SOLD; every subscription already on it goes on being billed at
* the old amount for ever. This is the second half, and it is a command of its
* own because it touches live contracts and money, which is a decision an owner
* makes deliberately rather than a side effect of syncing a price list.
*
* Run it after every stripe:sync-catalogue that reported a change:
*
* php artisan stripe:sync-catalogue --dry-run
* php artisan stripe:sync-catalogue
* php artisan stripe:reprice-subscriptions --dry-run
* php artisan stripe:reprice-subscriptions
*
* **Nothing is prorated.** The customer has already paid for the term they are
* in, at whatever was taken at the time; charging the difference for days already
* served would be a bill nobody agreed to, and Stripe's own default would raise
* exactly that. From the next cycle the right amount is taken.
*
* **Safe to re-run.** Both halves ask whether the contract is already on the
* right Price and do nothing when it is, so a second run is free and a run
* interrupted halfway finishes on the next attempt. The package half never
* throws — MoveStripeSubscriptionPrice parks its failures on the contract and
* clupilot:sync-stripe-subscriptions retries them hourly.
*
* The contract's own `price_cents` is not touched by any of this. It is the
* catalogue's NET figure, it is frozen, and PlanChange prorates against it.
*/
class RepriceStripeSubscriptions extends Command
{
protected $signature = 'stripe:reprice-subscriptions
{--dry-run : Show which contracts would move without touching Stripe}
{--limit=500 : How many contracts to look at in one run}';
protected $description = 'Move live contracts onto the Stripe prices the catalogue now sells';
public function handle(
StripeClient $stripe,
MoveStripeSubscriptionPrice $move,
SyncStripeAddonItems $modules,
): int {
$dryRun = (bool) $this->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;
}
}