CluPilotCloud/app/Actions/ApplyPlanChange.php

349 lines
16 KiB
PHP

<?php
namespace App\Actions;
use App\Models\Instance;
use App\Models\Order;
use App\Models\ProvisioningRun;
use App\Models\Subscription;
use App\Models\SubscriptionRecord;
use App\Provisioning\Jobs\AdvanceRunJob;
use App\Services\Billing\CustomDomainAccess;
use App\Services\Billing\PlanChange;
use App\Services\Billing\TaxTreatment;
use App\Services\Stripe\StripeClient;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Throwable;
/**
* The single place a plan change actually happens.
*
* PlanChange is the preview: what a move would cost and whether it is allowed
* yet. Nothing carried one out. A customer could buy an upgrade, see it sit in
* the cart as a paid order, and keep the same contract, the same machine and the
* same quota forever — the order was created and never consumed by anything.
*
* This is the consumer. It does the four things a plan change is, in one place
* so they cannot be done by halves:
*
* - the CONTRACT moves onto the target package's current sellable version, so
* the snapshot every step, bill and entitlement check reads says what the
* customer is now on;
* - the REGISTER gets one row, because a change of terms is a commercial event
* and the register is the evidence of what was agreed;
* - the ENTITLEMENTS that hang off the package are settled — today that is the
* own domain, which a smaller package can take away;
* - the MACHINE is resized, through a provisioning run like every other piece
* of remote work in this application, so it is retried, logged and visible in
* the console instead of happening in a web request;
* - STRIPE is moved onto the new price, because it owns the billing cycle and
* would otherwise keep charging for the package the customer has left. Last,
* outside the transaction, and unable to fail the change — see below.
*
* Idempotent from both ends. A contract already on the target version is a
* no-op rather than an error — the order is consumed and nothing else moves —
* and the register row carries the order's own event key, so a duplicate write
* collides in the database rather than in a check that two callers could both
* pass.
*/
class ApplyPlanChange
{
public function __construct(private RecordCommercialEvent $record) {}
/**
* Carry out the change an order bought.
*
* The contract is resolved through CustomDomainAccess::contractOf() rather
* than re-queried here: it is already the one place that answers "which
* contract is this customer on", and a second copy of that query is the one
* that would disagree the day the rule changes.
*/
public function forOrder(Order $order, ?Carbon $at = null): ?ProvisioningRun
{
$subscription = app(CustomDomainAccess::class)->contractOf($order->customer);
if ($subscription === null) {
Log::warning('No contract to move: this plan change has no live subscription behind it.', [
'order_id' => $order->id, 'plan' => $order->plan,
]);
return null;
}
return $this($subscription, (string) $order->plan, $order, $at);
}
/**
* @return ProvisioningRun|null the resize run, or null when the change was
* refused, was a no-op, or there is no machine
* to resize
*/
public function __invoke(Subscription $subscription, string $targetPlan, ?Order $order = null, ?Carbon $at = null): ?ProvisioningRun
{
$at = $at?->copy() ?? now();
try {
// The version on sale RIGHT NOW, which is what someone moving today
// is being sold. Throws for a plan that has been withdrawn or is not
// priced on this term, and that is the correct outcome: a contract
// moved onto a guess is worse than a change that visibly fails.
$target = Subscription::snapshotFrom($targetPlan, $subscription->term);
} catch (Throwable $e) {
Log::error('Cannot move a contract onto a package the catalogue will not sell.', [
'subscription' => $subscription->uuid, 'plan' => $targetPlan, 'error' => $e->getMessage(),
]);
return null;
}
// Already there. Not an error — a retried trigger, a command run twice,
// an operator pressing the same thing — but the order is consumed so it
// stops asking.
if ((string) $subscription->plan === $targetPlan
&& (int) $subscription->plan_version_id === (int) $target['plan_version_id']) {
$this->consume($order);
return null;
}
// The gate, and the only one. An upgrade with the period already over,
// a downgrade before the term the customer paid for has run out, and any
// move on a contract that is no longer active all stop here — see
// PlanChange::evaluate() for why each of them is refused.
$change = PlanChange::evaluate($subscription, $targetPlan, $at);
if (! $change->allowedNow) {
return null;
}
$instance = $this->instanceOf($subscription);
// Read BEFORE the snapshot moves: the resize step has to know what the
// machine had, to tell a change of CPU or RAM from a change that only
// touched storage or price.
$before = [
'plan' => (string) $subscription->plan,
'cores' => (int) ($instance?->cores ?? $subscription->cores),
'ram_mb' => (int) ($instance?->ram_mb ?? $subscription->ram_mb),
];
$run = DB::transaction(function () use ($subscription, $target, $targetPlan, $change, $order, $instance, $before, $at) {
$subscription->applyPlanSnapshot($target);
// A booked change is spent the moment the contract moves — whether it
// was this one arriving on its date, or a move in the other direction
// overtaking it. This is where the shop used to do it: an upgrade CLICK
// cleared a booked downgrade, in the cart, before a cent had been paid,
// and since nothing marks a cart order paid the customer lost the
// booking and got nothing in exchange. The contradiction is real —
// a downgrade left booked would come due months later and quietly undo
// the bigger package — but it is only real once the bigger package is
// actually theirs, which is here.
//
// Cleared AFTER PlanChange::evaluate() above, which reads the booked
// due date to decide whether a downgrade is allowed yet.
$subscription->clearPendingPlanChange();
// The register row is written from the contract that has just been
// moved, so the evidence and the contract cannot describe different
// packages — the same order OpenSubscription writes a purchase in.
($this->record)(
event: $change->isUpgrade
? SubscriptionRecord::EVENT_UPGRADE
: SubscriptionRecord::EVENT_DOWNGRADE,
subscription: $subscription,
// Zero, because a plan change moves TERMS and no money at all.
// Stripe settles the difference on a proration invoice of its
// own (PRORATE_IMMEDIATELY below), and that invoice arrives here
// as its own EVENT_INVOICE_PAID row carrying the real figure. An
// amount on this row as well counted one upgrade's money twice in
// any sum over the register.
//
// What the move is WORTH is not lost — it goes into the snapshot
// below, which is where the question "what were they quoted?"
// belongs. Deliberately NOT the order's amount_cents either:
// that is the shop's headline price for the whole package, and
// recording it for a move made on the sixteenth would state a sum
// nobody agreed to.
netCents: 0,
at: $at,
extra: ['plan_change' => [
'from_plan' => $before['plan'],
'to_plan' => $targetPlan,
'remaining_days' => $change->remainingDays,
'term_days' => $change->termDays,
// The preview's pro-rata difference, and what the till would
// take from THIS customer for it. Both, because PlanChange
// prorates the NET catalogue figures while the Stripe Price it
// prorates against is the one that customer is billed on — so
// for anybody who is charged VAT the net figure alone reads as
// a fifth less than the proration invoice that follows, and
// somebody reconciling the two needs the number to compare.
// For a reverse-charge business the two are equal, and saying
// so is what stops a reader looking for a missing fifth.
'prorated_net_cents' => $change->chargeCents,
'prorated_charge_cents' => TaxTreatment::for($subscription->customer)
->chargeCents($change->chargeCents),
]],
// Nothing has been charged AT THIS EVENT. Left null rather than
// reported as zero, which the register would read as a free sale.
chargedGrossCents: null,
order: $order,
// One row per order, enforced by the unique index rather than by
// a check two concurrent triggers could both pass.
eventKey: $order !== null ? 'plan-change:order:'.$order->id : null,
);
$this->resizeInstance($instance, $target, $targetPlan);
$this->consume($order);
return $this->startResize($instance, $targetPlan, $before, $change->isUpgrade, $order);
});
// After the run exists, never before. settleCustomDomain() withdraws a
// domain the new package no longer carries, and withdrawing one asks
// ReapplyInstanceAddress for a run to stop serving it — which finds this
// run already in flight against the same order and stands aside, because
// this pipeline carries the address steps itself. The other way round
// there would be two runs writing one router.
PlanChange::settleCustomDomain($subscription);
// Stripe bills the next term, so it has to be told which package that
// is — without this a customer who upgraded goes on paying for the one
// they left, every month, for as long as the contract runs.
//
// After the transaction and never inside it. The change has already
// landed on the contract and on the machine, and an unreachable or
// unconfigured Stripe must not roll any of that back: the action never
// throws, and parks what it could not do on the contract for the hourly
// sweep to pick up. A contract with no Stripe subscription behind it —
// a granted package — is left entirely alone.
//
// Resolved from the container here rather than injected, so a test that
// swaps the client in after this action exists still gets its fake.
app(MoveStripeSubscriptionPrice::class)(
$subscription,
$change->isUpgrade ? StripeClient::PRORATE_IMMEDIATELY : StripeClient::PRORATE_NONE,
);
if ($run !== null) {
AdvanceRunJob::dispatch($run->uuid); // after commit
Log::info('Applying a plan change.', [
'subscription' => $subscription->uuid,
'from' => $before['plan'],
'to' => $targetPlan,
'upgrade' => $change->isUpgrade,
'run' => $run->uuid,
]);
}
return $run;
}
/**
* The order is spent. Anything but `pending` takes it out of the customer's
* cart, and `applied` says which way it went — it was never paid in the sense
* an invoice means, and calling it that would put a charge in the record that
* never happened.
*/
private function consume(?Order $order): void
{
if ($order !== null && $order->status !== 'applied') {
$order->update(['status' => 'applied']);
}
}
/**
* Bring the machine's own row in line with the package it now serves.
*
* Everything except the disk. A disk cannot be shrunk — Proxmox grows one
* online and has no way back — so a downgrade leaves the guest exactly as
* large as it already is, and writing the smaller figure here would make this
* row claim a size the machine does not have. Two things read it and both
* would be wrong: the host's storage accounting, which would think it had
* space it does not, and the next resize, which would ask Proxmox to shrink.
*
* What actually shrinks is the QUOTA, which Nextcloud enforces and which is
* what was sold — see the plan-change pipeline's quota step. The disk is
* infrastructure; the quota is the product. The contract's own `disk_gb` still
* records what the smaller package includes, so nothing has been lost: this
* column is what the machine HAS, and that is all it has ever meant.
*
* @param array<string, mixed> $target
*/
private function resizeInstance(?Instance $instance, array $target, string $targetPlan): void
{
$instance?->update([
'plan' => $targetPlan,
'quota_gb' => $target['quota_gb'],
'ram_mb' => $target['ram_mb'],
'cores' => $target['cores'],
]);
}
/**
* Start the run that makes the machine match the package.
*
* Subject is the instance's own purchase order, exactly as the `address`
* pipeline does it — not the upgrade order. That is what CustomerStep::order()
* and ::subscription() resolve from, so the steps read this customer's
* contract rather than nothing; it is what ReapplyInstanceAddress checks for
* a run already in flight, so two runs cannot write one router; and it keeps
* RunRunner from marking a paid, running customer's order failed because a
* resize could not be applied (see ProvisioningSubject::provisioningPipeline).
*
* @param array{plan: string, cores: int, ram_mb: int} $before
*/
private function startResize(?Instance $instance, string $targetPlan, array $before, bool $isUpgrade, ?Order $order): ?ProvisioningRun
{
// A contract with no machine — one still being built, one whose build
// failed, a package granted before provisioning ran — has nothing to
// resize. The contract has still moved, and the machine will be built
// from it whenever it is built.
if ($instance === null || ! $instance->hasLiveMachine()) {
return null;
}
return ProvisioningRun::create([
'subject_type' => Order::class,
'subject_id' => $instance->order_id,
'pipeline' => 'plan-change',
'status' => ProvisioningRun::STATUS_PENDING,
'current_step' => 0,
'context' => [
'instance_id' => $instance->id,
'host_id' => $instance->host_id,
'node' => $instance->host?->node,
'vmid' => $instance->vmid,
'subdomain' => $instance->subdomain,
// What the machine had before, so the resize step can tell which
// of its changes need the guest to come back round before they
// are true. Kept on the run rather than recomputed: by the time
// the step executes, the instance row already says the new size.
'plan_change' => [
'order_id' => $order?->id,
'from_plan' => $before['plan'],
'to_plan' => $targetPlan,
'from_cores' => $before['cores'],
'from_ram_mb' => $before['ram_mb'],
'upgrade' => $isUpgrade,
],
],
]);
}
/**
* The machine this contract pays for: the link when provisioning has written
* it, and otherwise the customer's newest instance — the same fallback
* CustomDomainAccess makes, for the same reason.
*/
private function instanceOf(Subscription $subscription): ?Instance
{
return $subscription->instance
?? $subscription->customer?->instances()->latest('id')->first();
}
}