CluPilotCloud/app/Console/Commands/ApplyDuePlanChanges.php

90 lines
3.4 KiB
PHP

<?php
namespace App\Console\Commands;
use App\Actions\ApplyPlanChange;
use App\Models\Order;
use App\Services\Billing\CustomDomainAccess;
use App\Services\Billing\PlanChange;
use Illuminate\Console\Command;
use Throwable;
/**
* Carries out the plan changes whose moment has come.
*
* An upgrade lands when it is paid for — see App\Observers\OrderObserver. A
* downgrade cannot: someone on a yearly contract bought a year, so moving them
* down in month three would take away what they have already paid for. It waits
* for the end of the term, and nothing was waiting with it. This is what does.
*
* **How a pending downgrade is known.** By its order, and only by its order.
* `subscriptions.pending_plan` and `pending_effective_at` exist in the schema and
* are written by nothing at all — the shop records a plan change as an Order with
* type `downgrade` sitting at `pending`, and that order is the customer's own
* record of the request: it is what the cart shows them, what they remove to
* change their mind, and what App\Livewire\Billing replaces when they pick a
* different package. Mirroring it onto the contract would make two sources of
* truth for one decision, and the second is the one that goes stale.
*
* **Safe to run as often as the scheduler likes.** Nothing here decides whether
* a change is due — PlanChange does, from the contract's own period — and nothing
* here applies one twice: ApplyPlanChange consumes the order, refuses a contract
* already on the target version, and writes its register row under an event key
* unique to that order.
*/
class ApplyDuePlanChanges extends Command
{
protected $signature = 'clupilot:apply-due-plan-changes';
protected $description = 'Apply scheduled downgrades whose term has run out';
public function handle(ApplyPlanChange $apply, CustomDomainAccess $contracts): int
{
$applied = 0;
$due = 0;
$orders = Order::query()
->where('type', 'downgrade')
->where('status', 'pending')
->with('customer')
->orderBy('id')
->cursor();
foreach ($orders as $order) {
$subscription = $contracts->contractOf($order->customer);
if ($subscription === null) {
continue;
}
try {
// Asked before applying rather than left to ApplyPlanChange's own
// refusal, only so that a downgrade sitting out a yearly term does
// not write a line into the log every quarter of an hour for a year.
if (! PlanChange::evaluate($subscription, (string) $order->plan)->allowedNow) {
continue;
}
$due++;
$apply->forOrder($order);
// Counted from the order rather than from the returned run: a
// contract whose machine is still being built has no resize to
// start, and the change has landed all the same.
if ($order->fresh()?->status === 'applied') {
$applied++;
}
} catch (Throwable $e) {
// One customer's withdrawn package must not stop everybody
// else's downgrade from landing.
$this->warn("Order {$order->uuid}: {$e->getMessage()}");
}
}
$this->info("{$due} downgrade(s) due, {$applied} applied.");
return self::SUCCESS;
}
}