119 lines
4.8 KiB
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();
|
|
}
|
|
}
|