CluPilotCloud/app/Console/Commands/ApplyDuePlanChanges.php

119 lines
4.8 KiB
PHP

<?php
namespace App\Console\Commands;
use App\Actions\ApplyPlanChange;
use App\Models\Order;
use App\Models\Subscription;
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 the contract, which carries the
* package it is moving to and the date that move comes due — `pending_plan` and
* `pending_effective_at`, stamped by the shop the moment the customer books it.
*
* This used to be read off the pending Order instead, with the due date worked
* out from `subscriptions.current_period_end` on every tick. That date is not a
* fact about the booking, it is a fact about the billing cycle, and Stripe moves
* it forward on every renewal: a downgrade booked in March was pushed to the end
* of April by April's renewal, then to the end of May by May's, and the customer
* went on paying for the package they had asked to leave. A due date has to be
* decided once, at the moment the customer decides — so it is stamped then, and
* only read afterwards.
*
* The order is still the customer's own record of the request — it is what the
* cart shows them and what they remove to change their mind — so it is consumed
* here alongside the contract, and removing it clears the stamp (see
* App\Livewire\ConfirmRemoveOrder). What it no longer does is decide WHEN.
*
* **Safe to run as often as the scheduler likes.** A contract is picked up only
* while its stamped date has passed and it is still active, and the stamp is
* cleared the moment the change has landed — so a second tick finds nothing
* left to do rather than doing it again.
*/
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): int
{
$applied = 0;
$due = 0;
// Cancelled contracts are left where they are, and not merely because
// ApplyPlanChange would refuse them: a customer who has left is not
// moved onto a smaller package on their way out, and the booking they
// made goes with the contract it was made on.
$contracts = Subscription::query()
->whereNotNull('pending_plan')
->whereNotNull('pending_effective_at')
->where('pending_effective_at', '<=', now())
->where('status', 'active')
->with('customer')
->orderBy('id')
->cursor();
foreach ($contracts as $subscription) {
$due++;
$plan = (string) $subscription->pending_plan;
try {
$apply($subscription, $plan, $this->orderFor($subscription, $plan));
// Read back rather than assumed: a package the catalogue has
// since withdrawn cannot be moved onto, and ApplyPlanChange
// says so by logging and leaving the contract alone. Clearing
// the stamp there would drop the customer's request silently,
// so it stays booked and this says so on every tick.
$subscription->refresh();
if ((string) $subscription->plan !== $plan) {
$this->warn("Contract {$subscription->uuid}: still on {$subscription->plan}, {$plan} was not applied.");
continue;
}
$subscription->clearPendingPlanChange();
$applied++;
} catch (Throwable $e) {
// One customer's withdrawn package must not stop everybody
// else's downgrade from landing.
$this->warn("Contract {$subscription->uuid}: {$e->getMessage()}");
}
}
$this->info("{$due} downgrade(s) due, {$applied} applied.");
return self::SUCCESS;
}
/**
* The cart entry this booking came from, so it is consumed with the change
* and the register row files under it.
*
* Null is a perfectly ordinary answer — an operator can book a change
* without a purchase, and the contract is the authority on what was agreed
* either way.
*/
private function orderFor(Subscription $subscription, string $plan): ?Order
{
return Order::query()
->where('customer_id', $subscription->customer_id)
->where('type', 'downgrade')
->where('status', 'pending')
->where('plan', $plan)
->orderBy('id')
->first();
}
}