From 34874adec3b7c08fa69c807d0f2ff3941433f28c Mon Sep 17 00:00:00 2001 From: nexxo Date: Wed, 29 Jul 2026 17:18:55 +0200 Subject: [PATCH] Apply a bought plan change instead of only pricing it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PlanChange could say what a move would cost and whether it was allowed, and that was all it could do. Billing::purchase() wrote an upgrade order and nothing ever consumed it: same snapshot, same machine, same quota. A customer could pay for a bigger package and receive nothing. ApplyPlanChange is now the single place a change lands — it moves the contract onto the target's current version, writes one register row, settles the custom domain, and starts a run that resizes the machine. Applying the same order twice is a no-op, enforced by a unique event key rather than by a check two callers could both pass. Two things a plan change must not do, and now does not. A disk is never shrunk — Proxmox cannot, so the QUOTA shrinks instead, which is what was sold anyway. And a live machine is never rebooted as a side effect: cores and RAM are written, and where they need a restart the instance says so where an operator and the customer can both see it. The storage allowance also joins the build pipeline. ApplyStorageQuota was written for plan changes and ran only there, so a brand-new customer still got no quota at all — quota_gb reached the instance row and stopped, and every package delivered the whole disk. The test guarding that pipeline only counted its steps, which is how a list missing the one step that makes a package's storage real stayed green for its whole life. It now names the step. Co-Authored-By: Claude Opus 5 --- app/Actions/ApplyPlanChange.php | 292 +++++++++++++++ app/Actions/ReapplyInstanceAddress.php | 14 +- app/Console/Commands/ApplyDuePlanChanges.php | 89 +++++ app/Livewire/Admin/Instances.php | 5 + app/Livewire/Cloud.php | 9 +- app/Models/Instance.php | 39 +- app/Models/Order.php | 3 + app/Models/Subscription.php | 39 ++ app/Observers/OrderObserver.php | 57 +++ .../Steps/Customer/ApplyStorageQuota.php | 64 ++++ .../Steps/Customer/ResizeVirtualMachine.php | 162 +++++++++ .../Steps/Customer/SettlePlanServices.php | 113 ++++++ app/Services/Proxmox/ProxmoxClient.php | 11 +- config/provisioning.php | 30 ++ ...40000_add_pending_restart_to_instances.php | 37 ++ lang/de/admin.php | 2 + lang/de/cloud.php | 2 + lang/de/provisioning.php | 3 + lang/en/admin.php | 2 + lang/en/cloud.php | 2 + lang/en/provisioning.php | 3 + .../views/livewire/admin/instances.blade.php | 12 +- resources/views/livewire/cloud.blade.php | 10 + routes/console.php | 29 +- tests/Feature/Billing/ApplyPlanChangeTest.php | 334 ++++++++++++++++++ .../Provisioning/CustomerStepBaseTest.php | 13 +- 26 files changed, 1354 insertions(+), 22 deletions(-) create mode 100644 app/Actions/ApplyPlanChange.php create mode 100644 app/Console/Commands/ApplyDuePlanChanges.php create mode 100644 app/Observers/OrderObserver.php create mode 100644 app/Provisioning/Steps/Customer/ApplyStorageQuota.php create mode 100644 app/Provisioning/Steps/Customer/ResizeVirtualMachine.php create mode 100644 app/Provisioning/Steps/Customer/SettlePlanServices.php create mode 100644 database/migrations/2026_07_29_240000_add_pending_restart_to_instances.php create mode 100644 tests/Feature/Billing/ApplyPlanChangeTest.php diff --git a/app/Actions/ApplyPlanChange.php b/app/Actions/ApplyPlanChange.php new file mode 100644 index 0000000..ce5268e --- /dev/null +++ b/app/Actions/ApplyPlanChange.php @@ -0,0 +1,292 @@ +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(); + } +} diff --git a/app/Actions/ReapplyInstanceAddress.php b/app/Actions/ReapplyInstanceAddress.php index 971bbb7..6b989b0 100644 --- a/app/Actions/ReapplyInstanceAddress.php +++ b/app/Actions/ReapplyInstanceAddress.php @@ -107,19 +107,13 @@ class ReapplyInstanceAddress /** * Is there a machine here whose address can be re-applied at all? * - * A reservation with no VM, a failed build and an instance whose order has - * gone all fail the same way: the steps would reach for a guest agent that - * is not there and burn the run's retries doing it. An instance that is - * still `provisioning` is excluded for a different reason — its own run - * will apply the address on its way past, and it holds the same lock this - * would. + * The question is not this action's alone — a plan change asks exactly the + * same one before it starts a run against a live machine — so the rule sits + * on the model and both read it there. See Instance::hasLiveMachine(). */ private function isReapplyable(Instance $instance): bool { - return $instance->order !== null - && $instance->host !== null - && $instance->vmid !== null - && in_array($instance->status, ['active', 'cancellation_scheduled'], true); + return $instance->hasLiveMachine(); } private function hasRunInFlight(Order $order): bool diff --git a/app/Console/Commands/ApplyDuePlanChanges.php b/app/Console/Commands/ApplyDuePlanChanges.php new file mode 100644 index 0000000..b710785 --- /dev/null +++ b/app/Console/Commands/ApplyDuePlanChanges.php @@ -0,0 +1,89 @@ +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; + } +} diff --git a/app/Livewire/Admin/Instances.php b/app/Livewire/Admin/Instances.php index 2f7fe6e..708475b 100644 --- a/app/Livewire/Admin/Instances.php +++ b/app/Livewire/Admin/Instances.php @@ -40,6 +40,11 @@ class Instances extends Component 'vmid' => $i->vmid ?? '—', 'plan' => $i->plan !== null ? __('billing.plan.'.$i->plan) : '—', 'quota' => $i->quota_gb !== null ? $i->quota_gb.' GB' : '—', + // A machine running on less CPU or RAM than it has been sold — + // see Instance::restartIsPending(). Shown beside the status + // rather than as one, because the instance is genuinely active + // and the operator needs both facts at once. + 'restart' => $i->restartIsPending(), 'status' => $status = $i->status ?? 'provisioning', // A status the lifecycle adds later must show as itself, never // as "admin.status.whatever" in front of the owner. diff --git a/app/Livewire/Cloud.php b/app/Livewire/Cloud.php index 9eb8c52..1c1260a 100644 --- a/app/Livewire/Cloud.php +++ b/app/Livewire/Cloud.php @@ -4,6 +4,7 @@ namespace App\Livewire; use App\Livewire\Concerns\ResolvesCustomer; use App\Models\MaintenanceWindow; +use App\Support\ProvisioningSettings; use Illuminate\Support\Number; use Livewire\Attributes\Layout; use Livewire\Component; @@ -44,7 +45,7 @@ class Cloud extends Component // printing it here would tell a customer to visit a hostname that does // not resolve to them. $domain = $shown?->subdomain - ? $shown->address(\App\Support\ProvisioningSettings::dnsZone()) + ? $shown->address(ProvisioningSettings::dnsZone()) : 'cloud.example.com'; return view('livewire.cloud', [ @@ -94,6 +95,12 @@ class Cloud extends Component ], 'storageLabel' => Number::format($used, locale: $locale).' / '.Number::format($quota, locale: $locale).' GB', 'maintenance' => $maintenance, + // A plan change writes the new CPU and memory into the VM's + // configuration and a running guest only takes them at its next cold + // boot. Nobody restarts somebody's cloud as a side effect of a + // purchase, so the customer is told instead — otherwise they are + // paying for cores they cannot see and have no way of finding out. + 'restartPending' => $shown?->restartIsPending() ?? false, ]); } } diff --git a/app/Models/Instance.php b/app/Models/Instance.php index d4b12df..733ae2a 100644 --- a/app/Models/Instance.php +++ b/app/Models/Instance.php @@ -18,7 +18,8 @@ class Instance extends Model protected $fillable = [ 'customer_id', 'order_id', 'host_id', 'vmid', 'guest_ip', 'plan', 'quota_gb', 'traffic_addons', 'disk_gb', - 'ram_mb', 'cores', 'subdomain', 'custom_domain', 'nc_admin_ref', 'admin_password', 'credentials_acknowledged_at', + 'ram_mb', 'cores', 'restart_required_since', + 'subdomain', 'custom_domain', 'nc_admin_ref', 'admin_password', 'credentials_acknowledged_at', 'route_written', 'routed_hostnames', 'cert_ok', 'status', 'cancel_requested_at', 'service_ends_at', 'domain_token', 'domain_verified_at', 'domain_cert_ok', 'domain_checked_at', 'domain_error', 'domain_failures', ]; @@ -50,9 +51,45 @@ class Instance extends Model 'cores' => 'integer', 'cancel_requested_at' => 'datetime', 'service_ends_at' => 'datetime', + 'restart_required_since' => 'datetime', ]; } + /** + * Is this machine running on less CPU or RAM than it has been sold? + * + * Proxmox writes a new `cores`/`memory` into the VM's configuration + * immediately and a running guest picks it up at its next cold boot. A plan + * change therefore leaves an honest gap, and rebooting somebody's cloud as a + * side effect of a purchase is not a way to close it — so the gap is stated + * instead, here, and shown to the operator and to the customer. + */ + public function restartIsPending(): bool + { + return $this->restart_required_since !== null; + } + + /** + * Is there a machine here that remote work can actually reach? + * + * A reservation with no VM, a failed build and an instance whose order has + * gone all fail the same way: the steps reach for a guest agent that is not + * there and burn a run's retries doing it. An instance that is still + * `provisioning` is excluded for a different reason — its own run is already + * writing this machine, and a second run beside it would write the same + * router and race the same occ calls. + * + * Asked by everything that starts a maintenance run against a live instance + * (ReapplyInstanceAddress, ApplyPlanChange), so the rule is one rule. + */ + public function hasLiveMachine(): bool + { + return $this->order !== null + && $this->host !== null + && $this->vmid !== null + && in_array($this->status, ['active', 'cancellation_scheduled'], true); + } + /** * Instances that are actually occupying storage on their host. * diff --git a/app/Models/Order.php b/app/Models/Order.php index 3a8d85f..4c54b85 100644 --- a/app/Models/Order.php +++ b/app/Models/Order.php @@ -3,15 +3,18 @@ namespace App\Models; use App\Models\Concerns\HasUuid; +use App\Observers\OrderObserver; use App\Provisioning\Contracts\ProvisioningSubject; use App\Services\Billing\TaxTreatment; use Database\Factories\OrderFactory; +use Illuminate\Database\Eloquent\Attributes\ObservedBy; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasOne; use Illuminate\Database\Eloquent\Relations\MorphMany; +#[ObservedBy(OrderObserver::class)] class Order extends Model implements ProvisioningSubject { /** @use HasFactory */ diff --git a/app/Models/Subscription.php b/app/Models/Subscription.php index 56611d5..ac60c7e 100644 --- a/app/Models/Subscription.php +++ b/app/Models/Subscription.php @@ -192,6 +192,45 @@ class Subscription extends Model ]; } + /** + * Move this contract onto another package — the one sanctioned exception to + * the freeze above. + * + * The freeze exists so that nothing can rewrite what a customer is owed + * behind their back: a price rise, an edited plan, a stray `update()`. A + * plan change is the opposite of that. It is the customer asking for + * different terms, it is priced and recorded before it lands, and afterwards + * the contract has to state the package they are actually on — otherwise the + * snapshot stops being the authority every step and every bill reads it as. + * + * So the exception is named and lives here rather than being a `saveQuietly()` + * somewhere in an action, where it would read as a way around the guard + * instead of a decision. Exactly one caller: App\Actions\ApplyPlanChange, + * which refuses unless PlanChange says the move is allowed today and writes + * the proof-register row in the same transaction. + * + * Only the snapshot's own columns move. `started_at` stays where it is — the + * contract began when it began, and a change of package does not restart it + * — and so do the period boundaries, which belong to the billing cycle and + * not to the package. + * + * @param array $snapshot from snapshotFrom() + */ + public function applyPlanSnapshot(array $snapshot): void + { + $movable = array_diff([...self::FROZEN, 'template_vmid'], ['started_at']); + + $this->forceFill(array_intersect_key($snapshot, array_flip($movable))); + + // Quietly, because the guard in booted() is what is being excepted here. + $this->saveQuietly(); + + // The version has moved, so anything already holding the old one is + // holding the wrong package — the proof-register row written straight + // after this reads planVersion for the version number it files under. + $this->unsetRelation('planVersion'); + } + public function isYearly(): bool { return $this->term === self::TERM_YEARLY; diff --git a/app/Observers/OrderObserver.php b/app/Observers/OrderObserver.php new file mode 100644 index 0000000..9bd281d --- /dev/null +++ b/app/Observers/OrderObserver.php @@ -0,0 +1,57 @@ +wasChanged('status') || $order->status !== 'paid') { + return; + } + + if ($order->type !== 'upgrade') { + return; + } + + try { + app(ApplyPlanChange::class)->forOrder($order); + } catch (Throwable $e) { + // Never allowed to fail the write that marked the order paid. That + // write is the record that money changed hands, and undoing it over + // a plan change would erase the payment and leave nothing to retry + // from. A change that did not land is loud in its own right: the + // order stays unconsumed and the contract still says the old + // package, both of which somebody can see. + Log::error('Failed to apply the plan change a paid order bought.', [ + 'order_id' => $order->id, 'plan' => $order->plan, 'error' => $e->getMessage(), + ]); + } + } +} diff --git a/app/Provisioning/Steps/Customer/ApplyStorageQuota.php b/app/Provisioning/Steps/Customer/ApplyStorageQuota.php new file mode 100644 index 0000000..3e62361 --- /dev/null +++ b/app/Provisioning/Steps/Customer/ApplyStorageQuota.php @@ -0,0 +1,64 @@ +instance($run); + + if ($instance === null) { + return StepResult::fail('no_instance'); + } + + $quota = (int) $instance->quota_gb; + + // No allowance recorded: setting "0 GB" would lock the customer out of + // their own files, which is a far worse answer than leaving what is + // there and letting somebody notice. + if ($quota <= 0) { + return StepResult::advance(); + } + + $pve = $this->pve->forHost($instance->host); + $occ = 'cd /opt/nextcloud && docker compose exec -T app php occ '; + + // Idempotent, like every other occ call in this pipeline: writing the + // same value again is a no-op and exits 0. + $this->guest($pve, $run, $occ.'config:app:set files default_quota --value='.escapeshellarg($quota.' GB')); + + return StepResult::advance(); + } +} diff --git a/app/Provisioning/Steps/Customer/ResizeVirtualMachine.php b/app/Provisioning/Steps/Customer/ResizeVirtualMachine.php new file mode 100644 index 0000000..a5f2ae1 --- /dev/null +++ b/app/Provisioning/Steps/Customer/ResizeVirtualMachine.php @@ -0,0 +1,162 @@ +instance($run); + + if ($instance === null) { + return StepResult::fail('no_instance'); + } + + $node = (string) $run->context('node'); + $vmid = (int) $run->context('vmid'); + $pve = $this->pve->forHost($instance->host); + + // setCloudInit() is the generic `PUT /nodes/{node}/qemu/{vmid}/config` + // despite its name — it writes whatever parameters it is given, and + // cloud-init keys are simply what the first caller happened to need. + // cores and memory are ordinary VM configuration and go the same way. + // Idempotent: writing the same values again changes nothing. + $pve->setCloudInit($node, $vmid, [ + 'cores' => (int) $instance->cores, + 'memory' => (int) $instance->ram_mb, + ]); + + $this->growDisk($run, $pve, $instance, $node, $vmid); + $this->settlePendingRestart($run, $pve, $instance, $node, $vmid); + + return StepResult::advance(); + } + + /** + * Grow the disk to what the contract now includes, and say so out loud when + * the contract asks for less than the machine already has. + */ + private function growDisk(ProvisioningRun $run, ProxmoxClient $pve, Instance $instance, string $node, int $vmid): void + { + $target = (int) ($this->plan($run)['disk_gb'] ?? 0); + $actual = (int) $instance->disk_gb; + + // No contract behind this run, or a package without a size: nothing to + // grow towards, and guessing at one would resize somebody's disk from a + // default. + if ($target <= 0) { + return; + } + + if ($target > $actual) { + // Absolute target, not '+…', so a retry cannot grow it twice. + $pve->resizeDisk($node, $vmid, 'scsi0', $target.'G'); + $instance->update(['disk_gb' => $target]); + + return; + } + + if ($target < $actual) { + $run->events()->create([ + 'step' => $this->key(), + 'attempt' => $run->attempt, + 'outcome' => 'info', + 'message' => "Festplatte bleibt bei {$actual} GB: ein kleineres Paket ({$target} GB) verkleinert " + .'keine Platte — das Kontingent des Kunden sinkt stattdessen.', + ]); + } + } + + /** + * Record — or clear — the restart this change is waiting on. + * + * Keyed on whether the machine is RUNNING rather than on reading back what + * Proxmox says the guest currently has. Without hotplug the answer is the + * same either way and this one needs no second API shape to be faked in + * tests; and where the two could differ, being pessimistic is the safe + * direction. A "restart pending" that turns out to have been unnecessary is + * a visible nuisance, while a resize that silently never happened is exactly + * the failure this step exists to stop. + */ + private function settlePendingRestart(ProvisioningRun $run, ProxmoxClient $pve, Instance $instance, string $node, int $vmid): void + { + $fromCores = (int) $run->context('plan_change.from_cores', $instance->cores); + $fromRam = (int) $run->context('plan_change.from_ram_mb', $instance->ram_mb); + + // Nothing about CPU or memory moved, so this change is waiting on + // nothing. An older pending restart is left exactly where it is — it is + // still pending, and clearing it here would hide it. + if ($fromCores === (int) $instance->cores && $fromRam === (int) $instance->ram_mb) { + return; + } + + $running = ($pve->vmStatus($node, $vmid)['status'] ?? '') === 'running'; + + if (! $running) { + // A stopped machine reads its configuration when it starts, so the + // new size is already true for it — and anything pending from an + // earlier change is settled by the same start. + if ($instance->restartIsPending()) { + $instance->update(['restart_required_since' => null]); + } + + return; + } + + // First pending change keeps its timestamp: how long somebody has been + // paying for cores they do not have is the interesting number, not when + // the most recent change was applied. + if (! $instance->restartIsPending()) { + $instance->update(['restart_required_since' => now()]); + } + + $run->events()->create([ + 'step' => $this->key(), + 'attempt' => $run->attempt, + 'outcome' => 'info', + 'message' => "Neue Ausstattung ({$instance->cores} vCPU, {$instance->ram_mb} MB RAM) ist in der VM-Konfiguration " + .'hinterlegt und wird beim nächsten Neustart wirksam. Die laufende Maschine wurde nicht neu gestartet.', + ]); + } +} diff --git a/app/Provisioning/Steps/Customer/SettlePlanServices.php b/app/Provisioning/Steps/Customer/SettlePlanServices.php new file mode 100644 index 0000000..af59f20 --- /dev/null +++ b/app/Provisioning/Steps/Customer/SettlePlanServices.php @@ -0,0 +1,113 @@ +instance($run); + + if ($instance === null) { + return StepResult::fail('no_instance'); + } + + // Every package, the same nightly window — see the class docblock for + // why this is read from one place rather than derived from the plan. + $schedule = '02:00'; + + if (! $instance->backups()->exists()) { + $jobId = $this->pve->forHost($instance->host) + ->createBackupJob((string) $run->context('node'), (int) $instance->vmid, $schedule); + + // Local row before anything else can fail, exactly as RegisterBackup + // does it, so a retry finds the job rather than creating a second. + $instance->backups()->firstOrCreate( + ['external_job_id' => $jobId], + ['schedule' => $schedule, 'status' => 'scheduled'], + ); + } + + $this->settleMonitoring($run, $instance); + + return StepResult::advance(); + } + + /** + * Monitoring is observability, not the product. + * + * The same judgement RegisterMonitoring makes during a build applies here + * with more force: a customer's plan change must not fail because Kuma is + * down. The gap is recorded on the run and the change goes through. + */ + private function settleMonitoring(ProvisioningRun $run, Instance $instance): void + { + $url = 'https://'.$instance->subdomain.'.'.ProvisioningSettings::dnsZone().'/status.php'; + + if ($instance->monitoringTargets()->where('url', $url)->exists()) { + return; + } + + try { + $targetId = $this->monitoring->registerTarget($instance->subdomain, $url); + } catch (Throwable $e) { + $run->events()->create([ + 'step' => $this->key(), + 'attempt' => $run->attempt, + 'outcome' => 'info', + 'message' => 'Monitoring übersprungen (Dienst nicht erreichbar): '.$e->getMessage(), + ]); + + return; + } + + $instance->monitoringTargets()->firstOrCreate( + ['external_id' => $targetId], + // 'unknown', not 'up': registering a check is not passing one. + ['url' => $url, 'status' => 'unknown'], + ); + } +} diff --git a/app/Services/Proxmox/ProxmoxClient.php b/app/Services/Proxmox/ProxmoxClient.php index bcc4c59..52e4405 100644 --- a/app/Services/Proxmox/ProxmoxClient.php +++ b/app/Services/Proxmox/ProxmoxClient.php @@ -31,7 +31,16 @@ interface ProxmoxClient /** Clone a template to a new VM; returns the task UPID. */ public function cloneVm(string $node, int $templateVmid, int $newVmid, string $name): string; - /** @param array $params */ + /** + * The generic `PUT /nodes/{node}/qemu/{vmid}/config`, despite the name. + * + * It writes whatever parameters it is given; cloud-init keys are simply what + * the first caller needed. `cores`, `memory` and the rest of a VM's ordinary + * configuration go through here too — see ResizeVirtualMachine, which uses + * it to apply a new plan's CPU and memory. + * + * @param array $params + */ public function setCloudInit(string $node, int $vmid, array $params): void; public function resizeDisk(string $node, int $vmid, string $disk, string $size): void; diff --git a/config/provisioning.php b/config/provisioning.php index e8ff5b6..d6123a4 100644 --- a/config/provisioning.php +++ b/config/provisioning.php @@ -54,6 +54,11 @@ return [ Customer\ConfigureNetwork::class, Customer\DeployApplicationStack::class, Customer\ConfigureNextcloud::class, + // The allowance the customer paid for, enforced on the machine. + // It was never applied anywhere before: quota_gb reached the + // instance row and stopped there, so every package delivered the + // whole disk and the figure on the price sheet was decoration. + Customer\ApplyStorageQuota::class, Customer\CreateCustomerAdmin::class, Customer\ConfigureDnsAndTls::class, Customer\RegisterBackup::class, @@ -79,6 +84,31 @@ return [ Customer\ConfigureDnsAndTls::class, Customer\ConfigureNextcloud::class, ], + + /* + | Making an existing instance match the package it has just been moved + | onto. Started by App\Actions\ApplyPlanChange once the contract, the + | proof register and the entitlements have already moved — this is the + | machine catching up, and nothing here decides whether a change is + | allowed. + | + | Same subject as `customer` and `address` (the instance's own purchase + | Order, NOT the upgrade order), so CustomerStep::order(), ::instance() + | and ::subscription() all resolve, so ReapplyInstanceAddress sees a run + | in flight and does not start a second one against the same machine, + | and so RunRunner's failure hook leaves a paid, running order alone. + | + | The two address steps are the `address` pipeline itself, reused rather + | than repeated: a downgrade onto a package without an own domain has to + | stop serving that domain, and that is exactly what those two do. + */ + 'plan-change' => [ + Customer\ResizeVirtualMachine::class, + Customer\ApplyStorageQuota::class, + Customer\ConfigureDnsAndTls::class, + Customer\ConfigureNextcloud::class, + Customer\SettlePlanServices::class, + ], ], // The one currency the catalogue is priced in. Plan prices carry no currency diff --git a/database/migrations/2026_07_29_240000_add_pending_restart_to_instances.php b/database/migrations/2026_07_29_240000_add_pending_restart_to_instances.php new file mode 100644 index 0000000..89a9dd9 --- /dev/null +++ b/database/migrations/2026_07_29_240000_add_pending_restart_to_instances.php @@ -0,0 +1,37 @@ +timestamp('restart_required_since')->nullable()->after('cores'); + }); + } + + public function down(): void + { + Schema::table('instances', function (Blueprint $table) { + $table->dropColumn('restart_required_since'); + }); + } +}; diff --git a/lang/de/admin.php b/lang/de/admin.php index 4e5a194..a0d67b7 100644 --- a/lang/de/admin.php +++ b/lang/de/admin.php @@ -134,6 +134,8 @@ return [ ], 'retry' => 'Wiederholen', + 'restart_pending' => 'Neustart offen', + 'restart_pending_hint' => 'Neue CPU/RAM sind hinterlegt und werden beim nächsten Neustart der VM wirksam.', 'run_retried' => 'Lauf wird erneut gestartet.', 'run_started' => 'Gestartet', 'run_activity' => 'Letzte Aktivität', diff --git a/lang/de/cloud.php b/lang/de/cloud.php index e254d92..765fa84 100644 --- a/lang/de/cloud.php +++ b/lang/de/cloud.php @@ -30,6 +30,8 @@ return [ 'storage' => 'Speicher', 'storage_growth' => 'Speicherverlauf', 'storage_growth_sub' => 'Belegter Speicher der letzten 12 Wochen.', + 'restart_pending_title' => 'Neustart erforderlich', + 'restart_pending_body' => 'Ihr neues Paket ist aktiv. Mehr Prozessorkerne und Arbeitsspeicher werden erst beim nächsten Neustart Ihrer Cloud wirksam — Ihre Cloud wurde dafür bewusst nicht von selbst neu gestartet.', 'restart' => 'Neu starten', 'snapshot' => 'Snapshot erstellen', 'logs' => 'Protokolle', diff --git a/lang/de/provisioning.php b/lang/de/provisioning.php index dcebac7..09d64bf 100644 --- a/lang/de/provisioning.php +++ b/lang/de/provisioning.php @@ -17,6 +17,9 @@ return [ 'register_monitoring' => 'Monitoring einrichten', 'run_acceptance_checks' => 'Abnahmeprüfung', 'complete_provisioning' => 'Bereitstellung abschließen', + 'resize_vm' => 'VM-Ausstattung anpassen', + 'apply_storage_quota' => 'Speicherkontingent setzen', + 'settle_plan_services' => 'Backup & Monitoring abgleichen', ], 'mail' => [ diff --git a/lang/en/admin.php b/lang/en/admin.php index af5e3f0..5ada413 100644 --- a/lang/en/admin.php +++ b/lang/en/admin.php @@ -134,6 +134,8 @@ return [ ], 'retry' => 'Retry', + 'restart_pending' => 'Restart pending', + 'restart_pending_hint' => 'New CPU/RAM are configured and take effect at the VM’s next restart.', 'run_retried' => 'Run is being retried.', 'run_started' => 'Started', 'run_activity' => 'Last activity', diff --git a/lang/en/cloud.php b/lang/en/cloud.php index c81daa2..bf22320 100644 --- a/lang/en/cloud.php +++ b/lang/en/cloud.php @@ -30,6 +30,8 @@ return [ 'storage' => 'Storage', 'storage_growth' => 'Storage over time', 'storage_growth_sub' => 'Used storage over the last 12 weeks.', + 'restart_pending_title' => 'Restart required', + 'restart_pending_body' => 'Your new plan is active. The extra CPU cores and memory only take effect the next time your cloud restarts — we deliberately did not restart it for you.', 'restart' => 'Restart', 'snapshot' => 'Create snapshot', 'logs' => 'Logs', diff --git a/lang/en/provisioning.php b/lang/en/provisioning.php index 7d4cd01..3207f14 100644 --- a/lang/en/provisioning.php +++ b/lang/en/provisioning.php @@ -17,6 +17,9 @@ return [ 'register_monitoring' => 'Register monitoring', 'run_acceptance_checks' => 'Acceptance checks', 'complete_provisioning' => 'Complete provisioning', + 'resize_vm' => 'Resize the VM', + 'apply_storage_quota' => 'Apply storage quota', + 'settle_plan_services' => 'Reconcile backup & monitoring', ], 'mail' => [ diff --git a/resources/views/livewire/admin/instances.blade.php b/resources/views/livewire/admin/instances.blade.php index 46ef0fc..a61384e 100644 --- a/resources/views/livewire/admin/instances.blade.php +++ b/resources/views/livewire/admin/instances.blade.php @@ -27,7 +27,17 @@ {{ $r['vmid'] }} {{ $r['plan'] }} {{ $r['quota'] }} - {{ $r['status_label'] }} + +
+ {{ $r['status_label'] }} + @if ($r['restart']) + + {{ __('admin.restart_pending') }} + + @endif +
+ @empty {{ __('admin.instances_empty') }} diff --git a/resources/views/livewire/cloud.blade.php b/resources/views/livewire/cloud.blade.php index 203e1d8..2ef79b0 100644 --- a/resources/views/livewire/cloud.blade.php +++ b/resources/views/livewire/cloud.blade.php @@ -36,6 +36,16 @@ @endif + @if ($restartPending) +
+ +
+

{{ __('cloud.restart_pending_title') }}

+

{{ __('cloud.restart_pending_body') }}

+
+
+ @endif +
@foreach ([ ['cloud.plan', $instance['plan']], diff --git a/routes/console.php b/routes/console.php index 67d0fb9..a32e74e 100644 --- a/routes/console.php +++ b/routes/console.php @@ -1,6 +1,11 @@ app(TickProvisioning::class)()) // Refresh the VPN peer state (handshakes, traffic) so the console does not go // stale while nobody has it open. The job itself runs on the provisioning // queue — that worker owns wg0. -Schedule::job(new \App\Provisioning\Jobs\SyncVpnPeers) +Schedule::job(new SyncVpnPeers) ->everyMinute() ->name('vpn-sync'); @@ -26,11 +31,11 @@ Schedule::job(new \App\Provisioning\Jobs\SyncVpnPeers) // provisioning queue, which is where the Proxmox credentials are usable. // Turns the monitoring column from a claim into a measurement. Without it the // console and the public status page report every instance healthy forever. -Schedule::job(new \App\Provisioning\Jobs\SyncMonitoringStatus) +Schedule::job(new SyncMonitoringStatus) ->everyFiveMinutes() ->withoutOverlapping(); -Schedule::job(new \App\Provisioning\Jobs\CollectInstanceTraffic) +Schedule::job(new CollectInstanceTraffic) ->everyFifteenMinutes() ->name('traffic-collect'); @@ -40,7 +45,7 @@ Schedule::job(new \App\Provisioning\Jobs\CollectInstanceTraffic) // made by hand in the dashboard. A week is far longer than the moment a // checkout takes to become a contract, and short enough that the table stays // a holding area rather than a second copy of Stripe's event log. -Schedule::call(fn () => \App\Models\StripePendingEvent::query() +Schedule::call(fn () => StripePendingEvent::query() ->where('created_at', '<', now()->subWeek()) ->delete()) ->daily() @@ -99,6 +104,20 @@ Schedule::command('clupilot:auto-update') // once, at onboarding — so every host read "offline" half an hour later, // permanently. Nothing was measuring host reachability at all, which is also // why it could not appear on the status page. -Schedule::job(new \App\Provisioning\Jobs\PingHosts) +Schedule::job(new PingHosts) ->everyMinute() ->name('host-ping'); + +// Carry out the downgrades whose term has run out. +// +// An upgrade lands the moment it is paid for; a downgrade waits, because +// somebody on a yearly contract bought a year. Nothing was waiting with it — the +// order sat in the cart forever and the customer stayed on the bigger package. +// +// Every quarter of an hour rather than nightly: a term ends at the second it +// ends, and a customer who has asked to pay less should not spend another +// afternoon on the old price. Nothing is taken away that they did not ask to +// give up, so there is no bad hour for it. +Schedule::command('clupilot:apply-due-plan-changes') + ->everyFifteenMinutes() + ->withoutOverlapping(); diff --git a/tests/Feature/Billing/ApplyPlanChangeTest.php b/tests/Feature/Billing/ApplyPlanChangeTest.php new file mode 100644 index 0000000..c9a5763 --- /dev/null +++ b/tests/Feature/Billing/ApplyPlanChangeTest.php @@ -0,0 +1,334 @@ +active()->create(['datacenter' => 'fsn', 'node' => 'pve']); + $order = Order::factory()->withSubscription()->create(['datacenter' => 'fsn', 'plan' => $plan]); + $subscription = $order->subscription; + + $instance = Instance::factory()->create(array_merge([ + 'order_id' => $order->id, + 'customer_id' => $order->customer_id, + 'host_id' => $host->id, + 'vmid' => 101, + 'guest_ip' => '10.20.0.7', + 'subdomain' => 'berger', + 'plan' => $plan, + 'quota_gb' => $subscription->quota_gb, + 'disk_gb' => $subscription->disk_gb, + 'ram_mb' => $subscription->ram_mb, + 'cores' => $subscription->cores, + 'status' => 'active', + 'route_written' => true, + 'routed_hostnames' => ['berger.'.ProvisioningSettings::dnsZone()], + 'cert_ok' => true, + ], $instanceAttributes)); + + $subscription->update(['instance_id' => $instance->id]); + + return ['host' => $host, 'order' => $order, 'subscription' => $subscription->fresh(), 'instance' => $instance]; +} + +/** What the shop writes when somebody picks another package. */ +function planChangeOrder(array $fixture, string $plan, string $type): Order +{ + return Order::create([ + 'customer_id' => $fixture['order']->customer_id, + 'plan' => $plan, + 'type' => $type, + 'amount_cents' => 1000, + 'currency' => 'EUR', + 'datacenter' => 'fsn', + 'status' => 'pending', + ]); +} + +/** The run the change started, whatever step it is on. */ +function planChangeRun(): ?ProvisioningRun +{ + return ProvisioningRun::query()->where('pipeline', 'plan-change')->latest('id')->first(); +} + +it('moves the contract onto the new package, records it once, and starts one run when the upgrade is paid', function () { + fakeServices(); + Queue::fake(); + + $fixture = planChangeFixture('team'); + $order = planChangeOrder($fixture, 'business', 'upgrade'); + $periodEnd = $fixture['subscription']->current_period_end; + + // The trigger: the order becoming paid, which is all a mocked payment is. + $order->update(['status' => 'paid']); + + $business = Subscription::snapshotFrom('business'); + $contract = $fixture['subscription']->fresh(); + + expect($contract->plan)->toBe('business') + ->and($contract->plan_version_id)->toBe($business['plan_version_id']) + ->and($contract->quota_gb)->toBe($business['quota_gb']) + ->and($contract->price_cents)->toBe($business['price_cents']) + // Term and billing period are the customer's, not the package's. + ->and($contract->term)->toBe(Subscription::TERM_MONTHLY) + ->and($contract->current_period_end->eq($periodEnd))->toBeTrue() + ->and($contract->started_at->eq($fixture['subscription']->started_at))->toBeTrue(); + + expect(SubscriptionRecord::query()->where('event', SubscriptionRecord::EVENT_UPGRADE)->count())->toBe(1) + ->and(ProvisioningRun::query()->where('pipeline', 'plan-change')->count())->toBe(1) + ->and($order->fresh()->status)->toBe('applied') + ->and($fixture['instance']->fresh()->plan)->toBe('business'); + + // The run hangs off the machine's own purchase order, like `address` does, + // so the steps can find this customer's contract and no second run can be + // started against the same machine beside it. + expect(planChangeRun()->subject_id)->toBe($fixture['order']->id); +}); + +it('changes nothing the second time the same order is applied', function () { + fakeServices(); + Queue::fake(); + + $fixture = planChangeFixture('team'); + $order = planChangeOrder($fixture, 'business', 'upgrade'); + $order->update(['status' => 'paid']); + + $versionBefore = $fixture['subscription']->fresh()->plan_version_id; + + // Straight back through the action, as a retried trigger or a second + // scheduler tick would come. + expect(app(ApplyPlanChange::class)->forOrder($order->fresh()))->toBeNull(); + + expect(SubscriptionRecord::query()->where('event', SubscriptionRecord::EVENT_UPGRADE)->count())->toBe(1) + ->and(ProvisioningRun::query()->where('pipeline', 'plan-change')->count())->toBe(1) + ->and($fixture['subscription']->fresh()->plan_version_id)->toBe($versionBefore); +}); + +it('holds a downgrade until the term is over, then applies it from the scheduler', function () { + fakeServices(); + Queue::fake(); + + $fixture = planChangeFixture('business'); + $order = planChangeOrder($fixture, 'team', 'downgrade'); + $end = $fixture['subscription']->current_period_end; + + // Mid-term: the customer bought this month, and a month is a month. + Carbon::setTestNow($end->copy()->subDays(3)); + $this->artisan('clupilot:apply-due-plan-changes')->assertSuccessful(); + + expect($fixture['subscription']->fresh()->plan)->toBe('business') + ->and($order->fresh()->status)->toBe('pending') + ->and(ProvisioningRun::query()->where('pipeline', 'plan-change')->count())->toBe(0) + ->and(SubscriptionRecord::query()->where('event', SubscriptionRecord::EVENT_DOWNGRADE)->count())->toBe(0); + + // Once the term has run out it lands, and running the command again does + // not land it a second time. + Carbon::setTestNow($end->copy()->addHour()); + $this->artisan('clupilot:apply-due-plan-changes')->assertSuccessful(); + $this->artisan('clupilot:apply-due-plan-changes')->assertSuccessful(); + + expect($fixture['subscription']->fresh()->plan)->toBe('team') + ->and($order->fresh()->status)->toBe('applied') + ->and(SubscriptionRecord::query()->where('event', SubscriptionRecord::EVENT_DOWNGRADE)->count())->toBe(1) + ->and(ProvisioningRun::query()->where('pipeline', 'plan-change')->count())->toBe(1); + + Carbon::setTestNow(); +}); + +it('stops serving a domain the smaller package cannot carry', function () { + $services = fakeServices(); + Queue::fake(); + + $domain = 'cloud.berger.at'; + $fixture = planChangeFixture('business', [ + 'custom_domain' => $domain, + 'domain_token' => 'tok', + 'domain_verified_at' => now(), + 'domain_cert_ok' => true, + 'routed_hostnames' => ['berger.'.ProvisioningSettings::dnsZone(), $domain], + ]); + $customer = $fixture['order']->customer; + $access = app(CustomDomainAccess::class); + + // Business carries an own domain; Start cannot have one at all. + expect($access->allowsCustomer($customer))->toBeTrue(); + + $order = planChangeOrder($fixture, 'start', 'downgrade'); + Carbon::setTestNow($fixture['subscription']->current_period_end->copy()->addHour()); + $this->artisan('clupilot:apply-due-plan-changes')->assertSuccessful(); + + expect($access->allowsCustomer($customer->fresh()))->toBeFalse(); + + // And the machine is told, by this run rather than by a second one racing + // it: the address steps are part of the plan-change pipeline. + expect(ProvisioningRun::query()->where('pipeline', 'address')->count())->toBe(0); + + $run = planChangeRun(); + app(ConfigureDnsAndTls::class)->execute($run); + app(ConfigureNextcloud::class)->execute($run); + + expect($services['traefik']->serves('berger', $domain))->toBeFalse() + ->and($services['pve']->guestRan('config:system:delete trusted_domains 2'))->toBeTrue() + ->and($order->fresh()->status)->toBe('applied'); + + Carbon::setTestNow(); +}); + +it('shrinks the quota on a downgrade and never the disk', function () { + $services = fakeServices(); + Queue::fake(); + + $fixture = planChangeFixture('business'); + $diskBefore = (int) $fixture['instance']->disk_gb; // 1050 GB, and it stays + $order = planChangeOrder($fixture, 'start', 'downgrade'); + + Carbon::setTestNow($fixture['subscription']->current_period_end->copy()->addHour()); + $this->artisan('clupilot:apply-due-plan-changes')->assertSuccessful(); + + $run = planChangeRun(); + app(ResizeVirtualMachine::class)->execute($run); + app(ApplyStorageQuota::class)->execute($run); + + $start = Subscription::snapshotFrom('start'); + $instance = $fixture['instance']->fresh(); + + // Proxmox grows a disk and cannot shrink one, so nothing asked it to — and + // the row still states the size the machine actually holds, because the + // host's storage accounting reads it. + expect($services['pve']->resizeCalls)->toBe([]) + ->and($instance->disk_gb)->toBe($diskBefore) + ->and($instance->disk_gb)->toBeGreaterThan((int) $start['disk_gb']); + + // What was sold does shrink, and Nextcloud is what enforces it. + expect($instance->quota_gb)->toBe($start['quota_gb']) + ->and($services['pve']->guestRan("config:app:set files default_quota --value='100 GB'"))->toBeTrue(); + + Carbon::setTestNow(); +}); + +it('grows the disk on an upgrade', function () { + $services = fakeServices(); + Queue::fake(); + + $fixture = planChangeFixture('team'); + planChangeOrder($fixture, 'business', 'upgrade')->update(['status' => 'paid']); + + app(ResizeVirtualMachine::class)->execute(planChangeRun()); + + expect($services['pve']->resizeCalls)->toBe(['101:scsi0:1050G']) + ->and($fixture['instance']->fresh()->disk_gb)->toBe(1050); +}); + +it('refuses an upgrade on a contract that is no longer active', function () { + fakeServices(); + Queue::fake(); + + $fixture = planChangeFixture('team'); + $fixture['subscription']->update(['status' => 'cancelled', 'cancelled_at' => now()]); + + $order = planChangeOrder($fixture, 'business', 'upgrade'); + $order->update(['status' => 'paid']); + + // Nothing at all: a customer who has left is not upgraded, billed or built. + expect($fixture['subscription']->fresh()->plan)->toBe('team') + ->and(SubscriptionRecord::query()->where('event', SubscriptionRecord::EVENT_UPGRADE)->count())->toBe(0) + ->and(ProvisioningRun::query()->where('pipeline', 'plan-change')->count())->toBe(0) + // Still the customer's to remove from the cart, rather than silently + // marked as though it had been carried out. + ->and($order->fresh()->status)->toBe('paid'); +}); + +it('never reboots a live machine, and says so where a human looks', function () { + $services = fakeServices(); + Queue::fake(); + + $fixture = planChangeFixture('team'); + $services['pve']->runningVmids = [101]; + + planChangeOrder($fixture, 'business', 'upgrade')->update(['status' => 'paid']); + + app(ResizeVirtualMachine::class)->execute($run = planChangeRun()); + + $instance = $fixture['instance']->fresh(); + + // The configuration carries the bigger machine… + expect($services['pve']->cloudInitParams[101]['cores'])->toBe(8) + ->and($services['pve']->cloudInitParams[101]['memory'])->toBe(16384) + // …the guest was left running, untouched: startVm appends to this list, + // so an unchanged list is a machine that was never stopped and started. + ->and($services['pve']->runningVmids)->toBe([101]) + // …and the gap is recorded rather than left for somebody to discover. + ->and($instance->restartIsPending())->toBeTrue() + ->and($run->events()->where('outcome', 'info')->where('step', 'resize_vm')->exists())->toBeTrue(); + + // The customer is told on the page that shows them their cloud. + $user = User::factory()->create(['email_verified_at' => now()]); + $fixture['order']->customer->update(['user_id' => $user->id, 'email' => $user->email]); + + Livewire::actingAs($user)->test(Cloud::class) + ->assertSee(__('cloud.restart_pending_title')); + + // And the console's instance list carries it beside the status. + Livewire::actingAs(admin(), 'operator')->test(Instances::class) + ->assertSee(__('admin.restart_pending')); +}); + +it('has nothing pending when the machine is stopped, because starting it applies the new size', function () { + $services = fakeServices(); + Queue::fake(); + + // Stopped, and carrying a pending restart from an earlier change. + $fixture = planChangeFixture('team', ['restart_required_since' => now()->subDay()]); + $services['pve']->runningVmids = []; + + planChangeOrder($fixture, 'business', 'upgrade')->update(['status' => 'paid']); + + app(ResizeVirtualMachine::class)->execute(planChangeRun()); + + expect($fixture['instance']->fresh()->restartIsPending())->toBeFalse(); +}); + +it('refuses to be moved onto a package the catalogue will not sell', function () { + fakeServices(); + Queue::fake(); + + $fixture = planChangeFixture('team'); + + expect(app(ApplyPlanChange::class)($fixture['subscription'], 'gibtsnicht'))->toBeNull() + ->and($fixture['subscription']->fresh()->plan)->toBe('team') + ->and(SubscriptionRecord::query()->whereIn('event', [ + SubscriptionRecord::EVENT_UPGRADE, SubscriptionRecord::EVENT_DOWNGRADE, + ])->count())->toBe(0); +}); diff --git a/tests/Feature/Provisioning/CustomerStepBaseTest.php b/tests/Feature/Provisioning/CustomerStepBaseTest.php index a33e37f..37cdc03 100644 --- a/tests/Feature/Provisioning/CustomerStepBaseTest.php +++ b/tests/Feature/Provisioning/CustomerStepBaseTest.php @@ -3,6 +3,8 @@ use App\Models\Instance; use App\Models\Order; use App\Models\ProvisioningRun; +use App\Provisioning\Steps\Customer\ApplyStorageQuota; +use App\Services\Billing\PlanCatalogue; use Tests\Support\Steps\ProbeCustomerStep; it('resolves order, instance and plan from the run', function () { @@ -23,11 +25,16 @@ it('resolves order, instance and plan from the run', function () { ->and($step->label())->toBe('provisioning.step.probe'); }); -it('registers the 15-step customer pipeline in config', function () { - expect(config('provisioning.pipelines.customer'))->toHaveCount(15); +it('builds every customer machine with the storage allowance that was sold', function () { + // A count alone only says the list is as long as it was. What matters is + // that the quota step is IN it: the allowance was applied nowhere for the + // whole life of the pipeline — quota_gb reached the instance row and + // stopped — so every package handed out the whole disk. + expect(config('provisioning.pipelines.customer'))->toHaveCount(16) + ->toContain(ApplyStorageQuota::class); }); it('takes the blueprint from the catalogue, which no longer lives in config', function () { expect(config('provisioning.plans'))->toBeNull() - ->and(app(App\Services\Billing\PlanCatalogue::class)->currentVersion('start')->template_vmid)->toBe(9000); + ->and(app(PlanCatalogue::class)->currentVersion('start')->template_vmid)->toBe(9000); });