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); // 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, // What this move is worth: the pro-rata difference for an // upgrade, nothing for a downgrade. Deliberately NOT the order's // amount_cents, which is the shop's headline price for the whole // package — charging or recording that for a move made on the // sixteenth would state a sum nobody agreed to. netCents: $change->chargeCents, at: $at, extra: ['plan_change' => [ 'from_plan' => $before['plan'], 'to_plan' => $targetPlan, 'remaining_days' => $change->remainingDays, 'term_days' => $change->termDays, ]], // Nothing has been charged: payment is mocked repo-wide and no // invoice exists for this move yet. 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); 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 $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(); } }