90 lines
3.4 KiB
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;
|
|
}
|
|
}
|