138 lines
5.5 KiB
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.");
|
|
}
|
|
}
|