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); });