153 lines
6.7 KiB
PHP
153 lines
6.7 KiB
PHP
<?php
|
|
|
|
namespace App\Console\Commands;
|
|
|
|
use App\Actions\MoveStripeSubscriptionPrice;
|
|
use App\Actions\SyncStripeAddonItems;
|
|
use App\Models\Subscription;
|
|
use App\Services\Billing\PlanPrices;
|
|
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.
|
|
*
|
|
* **Two reasons a contract moves, not one.** The first is a change of FIGURE, as
|
|
* above. The second is a change of CUSTOMER: a business whose VAT id is verified
|
|
* the week after they bought must be billed the net Price from then on, because
|
|
* reverse charge means they owe no VAT to us at all — and one whose registration
|
|
* has lapsed must go back onto the gross one. Neither is a change of package, so
|
|
* nothing else in the system would ever notice, and both converge here on the next
|
|
* run. PlanPrices::liveForSubscription() answers "which Price does this contract
|
|
* belong on?" for both, which is why this command has no resolver of its own: two
|
|
* places working that out is how they come to disagree.
|
|
*
|
|
* 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,
|
|
PlanPrices $prices,
|
|
): 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) {
|
|
// 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;
|
|
}
|
|
}
|