150 lines
5.9 KiB
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;
|
|
}
|
|
}
|