CluPilotCloud/app/Console/Commands/SyncStripeSubscriptions.php

138 lines
5.5 KiB
PHP

<?php
namespace App\Console\Commands;
use App\Actions\MoveStripeSubscriptionPrice;
use App\Actions\SyncStripeAddonItems;
use App\Models\Subscription;
use App\Models\SubscriptionAddon;
use Illuminate\Console\Command;
/**
* The sweep behind the plan change — and behind the module booking.
*
* Moving Stripe onto the new price at the moment the change lands is right and
* it is not enough. Stripe can be unreachable, the key can have been rotated,
* the catalogue can be a sync behind — and the change has already happened to
* the contract and to the machine by then, so it is not rolled back. What is
* left is a customer being charged for a package they are no longer on, and
* nothing about the contract would ever say so again.
*
* ApplyPlanChange parks exactly that on the contract. This picks it up, however
* long ago it happened.
*
* A module booking is the same failure wearing different clothes: the pack is
* already a bigger disk, the entitlement is already switched on, and if Stripe
* never heard about it the customer holds a module nobody is billing them for.
* So it is swept from here too rather than from a second command — one sweep,
* one schedule entry, one place to look when Stripe has been away.
*
* Deliberately hands the work back to the actions rather than doing it here:
* they own which price, which item and which proration behaviour, and two code
* paths that both move a subscription is one more than the number that can be
* kept in agreement.
*/
class SyncStripeSubscriptions extends Command
{
protected $signature = 'clupilot:sync-stripe-subscriptions {--limit=100}';
protected $description = 'Retry the plan changes and module bookings that landed here but never reached Stripe';
public function handle(MoveStripeSubscriptionPrice $move, SyncStripeAddonItems $modules): int
{
$status = $this->sweepPlans($move);
$this->sweepModules($modules);
return $status;
}
private function sweepPlans(MoveStripeSubscriptionPrice $move): int
{
// Active contracts only. A cancelled one is not billed again, so moving
// it onto another price would be housekeeping on something Stripe has
// already finished with — and an upgrade's proration raised against a
// contract that has ended would charge a departed customer.
$parked = Subscription::query()
->whereNotNull('stripe_price_sync')
->whereNotNull('stripe_subscription_id')
->where('status', 'active')
->orderBy('id')
->limit((int) $this->option('limit'))
->get();
$moved = 0;
foreach ($parked as $subscription) {
if ($move->retry($subscription)) {
$moved++;
continue;
}
// The action has already logged why and counted the attempt. Said
// again here because somebody is watching this command's output
// and not the log.
$this->warn(sprintf(
'Contract %s: still billed at the old price — %s',
$subscription->uuid,
(string) ($subscription->fresh()?->stripe_price_sync['error'] ?? 'unknown'),
));
}
$this->info("{$parked->count()} contract(s) out of step with Stripe, {$moved} moved.");
return self::SUCCESS;
}
/**
* Contracts whose modules Stripe is not billing.
*
* Found from the BOOKINGS rather than from a parked marker, because the
* bookings are the authority: a module still running with no item id was
* never put on the subscription, and one that has stopped while still
* carrying an item id is being billed after it ended. `stripe_addon_sync` is
* the record of what went wrong and how often — it is not where the work
* lives, so a contract whose failure was never written down (a worker killed
* mid-booking) is still found here.
*/
private function sweepModules(SyncStripeAddonItems $modules): void
{
$outstanding = SubscriptionAddon::query()
->where(fn ($q) => $q
->where(fn ($active) => $active->whereNull('cancelled_at')->whereNull('cancels_at')->whereNull('stripe_item_id'))
->orWhere(fn ($gone) => $gone->whereNotNull('stripe_item_id')
->where(fn ($ended) => $ended->whereNotNull('cancelled_at')->orWhereNotNull('cancels_at'))))
->select('subscription_id')
->distinct();
// Active contracts that Stripe actually bills. A granted contract has no
// Stripe subscription at all and its bookings will never carry an item
// id — sweeping those would retry, and log, something that is not wrong.
$contracts = Subscription::query()
->whereIn('id', $outstanding)
->whereNotNull('stripe_subscription_id')
->where('status', 'active')
->orderBy('id')
->limit((int) $this->option('limit'))
->get();
$settled = 0;
foreach ($contracts as $subscription) {
if ($modules($subscription)) {
$settled++;
continue;
}
$this->warn(sprintf(
'Contract %s: modules not billed by Stripe — %s',
$subscription->uuid,
(string) ($subscription->fresh()?->stripe_addon_sync['error'] ?? 'unknown'),
));
}
$this->info("{$contracts->count()} contract(s) with modules out of step, {$settled} settled.");
}
}