active()->create(['datacenter' => 'fsn', 'node' => 'pve']); $order = Order::factory()->create(['datacenter' => 'fsn', 'plan' => $plan, 'status' => 'paid']); $subscription = app(OpenSubscription::class)($order); $run = ProvisioningRun::factory()->create([ 'subject_type' => Order::class, 'subject_id' => $order->id, 'pipeline' => 'customer', 'context' => [], ]); return compact('host', 'order', 'subscription', 'run'); } /** * The operator changes what a plan is, after somebody has already bought it. * * Publishing a successor and closing the old window is the ONLY way the catalogue * can move: a published version is immutable, precisely so that what a customer * bought keeps describing what they are owed. So it is also the only way this * regression can be constructed — and the tests below used to try it with * config('provisioning.plans.*') writes, against a key that has not existed since * the catalogue moved into plan_families/plan_versions/plan_prices. Every one of * those writes was a no-op, so each test proved only that the contract agrees with * itself. * * @param array $changes */ function publishSuccessor(string $plan, array $changes): PlanVersion { $catalogue = app(PlanCatalogue::class); $current = $catalogue->currentVersion($plan); $currency = Subscription::catalogueCurrency(); $price = $current->priceFor(Subscription::TERM_MONTHLY)->amount_cents; // Half-open windows: the old one ends at the instant the new one starts, so // there is neither a gap nor two versions on sale at once. $catalogue->schedule($current, $current->available_from, now()); $successor = PlanVersion::query()->create([ ...$current->only([ 'plan_family_id', 'quota_gb', 'traffic_gb', 'seats', 'ram_mb', 'cores', 'disk_gb', 'performance', 'template_vmid', ]), ...$changes, 'version' => $current->version + 1, 'features' => $current->features, 'available_from' => now(), ]); $successor->prices()->create(['term' => 'monthly', 'amount_cents' => $price, 'currency' => $currency]); $successor->prices()->create(['term' => 'yearly', 'amount_cents' => $price * 12, 'currency' => $currency]); return $catalogue->publish($successor, now()); } it('freezes the catalogue onto a subscription when the checkout is paid', function () { Queue::fake(); $this->postJson(route('webhooks.stripe'), [ 'id' => 'evt_snap', 'type' => 'checkout.session.completed', 'data' => ['object' => [ 'id' => 'cs_snap', 'payment_status' => 'paid', 'customer_details' => ['email' => 'kunde@example.com', 'name' => 'Kanzlei Berger'], 'amount_total' => 17900, 'currency' => 'eur', 'metadata' => ['plan' => 'team', 'datacenter' => 'fsn'], ]], ])->assertOk(); $order = Order::query()->where('stripe_event_id', 'cs_snap')->sole(); $subscription = Subscription::query()->where('order_id', $order->id)->sole(); expect($subscription->plan)->toBe('team') ->and($subscription->customer_id)->toBe($order->customer_id) ->and($subscription->ram_mb)->toBe(8192) ->and($subscription->disk_gb)->toBe(540) ->and($subscription->price_cents)->toBe(17900) ->and($subscription->tier)->toBe(2) ->and($subscription->status)->toBe('active') ->and($subscription->current_period_end->greaterThan($subscription->current_period_start))->toBeTrue(); }); it('opens exactly one contract when Stripe retries the webhook', function () { Queue::fake(); $event = [ 'id' => 'evt_twice', 'type' => 'checkout.session.completed', 'data' => ['object' => [ 'id' => 'cs_twice', 'payment_status' => 'paid', 'customer_details' => ['email' => 'zwei@example.com'], 'amount_total' => 4900, 'currency' => 'eur', 'metadata' => ['plan' => 'start', 'datacenter' => 'fsn'], ]], ]; $this->postJson(route('webhooks.stripe'), $event)->assertOk(); $this->postJson(route('webhooks.stripe'), $event)->assertOk(); expect(Subscription::query()->count())->toBe(1); Queue::assertPushed(AdvanceRunJob::class, 1); }); it('sizes the machine from the contract, not from a catalogue edited afterwards', function () { ['order' => $order, 'run' => $run] = paidOrderWithSubscription('team'); // The operator cuts the team plan back after this customer has paid for it. publishSuccessor('team', ['ram_mb' => 4096, 'cores' => 2, 'disk_gb' => 120, 'quota_gb' => 100]); // The premise, asserted and not assumed. This edit used to be four // config('provisioning.plans.team.*') writes — a key that has not existed // since the catalogue moved into the database — so the catalogue was never // touched at all and the test could not detect the regression it exists for. expect(Subscription::snapshotFrom('team')['ram_mb'])->toBe(4096) ->and(Subscription::snapshotFrom('team')['disk_gb'])->toBe(120); expect(app(ReserveResources::class)->execute($run)->type)->toBe('advance'); $instance = Instance::query()->where('order_id', $order->id)->sole(); expect($instance->ram_mb)->toBe(8192) ->and($instance->cores)->toBe(4) ->and($instance->disk_gb)->toBe(540) ->and($instance->quota_gb)->toBe(500); }); it('links the instance back to the contract it was provisioned for', function () { ['order' => $order, 'subscription' => $subscription, 'run' => $run] = paidOrderWithSubscription('team'); app(ReserveResources::class)->execute($run); $instance = Instance::query()->where('order_id', $order->id)->sole(); expect($subscription->fresh()->instance_id)->toBe($instance->id); }); it('clones the template the contract was sold with, not a later one', function () { ['host' => $host, 'order' => $order, 'run' => $run] = paidOrderWithSubscription('team'); $instance = Instance::factory()->create([ 'order_id' => $order->id, 'customer_id' => $order->customer_id, 'host_id' => $host->id, 'status' => 'provisioning', ]); $run->mergeContext(['instance_id' => $instance->id, 'host_id' => $host->id, 'node' => 'pve']); // A new blueprint is published after this customer bought — the real thing, // a successor version, because a published version's template is frozen with // it. The line here was a config write against a key that no longer exists. publishSuccessor('team', ['template_vmid' => 9999]); expect(Subscription::snapshotFrom('team')['template_vmid'])->toBe(9999); $cloned = null; $pve = Mockery::mock(ProxmoxClient::class); $pve->shouldReceive('forHost')->andReturnSelf(); $pve->shouldReceive('nextVmid')->andReturn(120); $pve->shouldReceive('vmExists')->andReturn(false); $pve->shouldReceive('cloneVm')->andReturnUsing(function ($node, $template, $vmid, $name) use (&$cloned) { $cloned = $template; return 'UPID:pve:clone'; }); $pve->shouldReceive('taskStatus')->andReturn(['status' => 'stopped', 'exitstatus' => 'OK']); app()->instance(ProxmoxClient::class, $pve); expect(app(CloneVirtualMachine::class)->execute($run->fresh())->type)->toBe('advance') ->and($cloned)->toBe(9000); }); it('backfills a contract for an order that was already paid before contracts existed', function () { // An order from before this migration: paid, machine running, no contract. $order = Order::factory()->create(['status' => 'active', 'plan' => 'team', 'created_at' => now()->subMonths(3)]); $instance = Instance::factory()->create([ 'order_id' => $order->id, 'customer_id' => $order->customer_id, // Sized from a catalogue that has since been cut back. 'quota_gb' => 750, 'disk_gb' => 800, 'ram_mb' => 16384, 'cores' => 6, ]); $migration = require database_path('migrations/2026_07_26_030000_link_subscriptions_to_orders.php'); (new ReflectionMethod($migration, 'backfillContracts'))->invoke($migration); $subscription = Subscription::query()->where('order_id', $order->id)->sole(); // Reconstructed from what was actually delivered, not from today's plan. expect($subscription->ram_mb)->toBe(16384) ->and($subscription->cores)->toBe(6) ->and($subscription->disk_gb)->toBe(800) ->and($subscription->quota_gb)->toBe(750) ->and($subscription->instance_id)->toBe($instance->id) ->and($subscription->status)->toBe('active') // The term is anchored on the purchase and rolled forward to the period // running now, so the next renewal lands on the right day. ->and($subscription->started_at->toDateString())->toBe(now()->subMonths(3)->toDateString()) ->and($subscription->current_period_end->isFuture())->toBeTrue(); // Running it twice must not open a second contract. (new ReflectionMethod($migration, 'backfillContracts'))->invoke($migration); expect(Subscription::query()->where('order_id', $order->id)->count())->toBe(1); }); it('backfills no contract for an order that never bought anything', function () { // Never paid: the cart leaves an order pending with no Stripe event, and a // run against it fails without any money having changed hands. $failed = Order::factory()->create(['status' => 'failed', 'plan' => 'team', 'stripe_event_id' => null]); $pending = Order::factory()->create(['status' => 'pending', 'plan' => 'team', 'stripe_event_id' => null]); $migration = require database_path('migrations/2026_07_26_030000_link_subscriptions_to_orders.php'); (new ReflectionMethod($migration, 'backfillContracts'))->invoke($migration); expect(Subscription::query()->whereIn('order_id', [$failed->id, $pending->id])->exists())->toBeFalse(); }); it('adopts a contract that already exists instead of opening a second one', function () { $order = Order::factory()->create(['status' => 'active', 'plan' => 'team']); $instance = Instance::factory()->create(['order_id' => $order->id, 'customer_id' => $order->customer_id]); // A contract from before anything set order_id — carrying terms that have // since been cut from the catalogue. $existing = Subscription::factory()->create([ 'customer_id' => $order->customer_id, 'instance_id' => $instance->id, 'order_id' => null, 'template_vmid' => null, ]); DB::table('subscriptions')->where('id', $existing->id)->update(['ram_mb' => 32768]); $migration = require database_path('migrations/2026_07_26_030000_link_subscriptions_to_orders.php'); (new ReflectionMethod($migration, 'backfillContracts'))->invoke($migration); expect(Subscription::query()->where('customer_id', $order->customer_id)->count())->toBe(1); $adopted = Subscription::query()->where('order_id', $order->id)->sole(); expect($adopted->id)->toBe($existing->id) ->and($adopted->ram_mb)->toBe(32768) // their real terms, not the catalogue's ->and($adopted->template_vmid)->toBe(9000); // only the missing piece filled in }); it('backfills no contract for a payment the catalogue cannot price', function () { $order = Order::factory()->create(['status' => 'active', 'plan' => 'team', 'currency' => 'CHF']); $migration = require database_path('migrations/2026_07_26_030000_link_subscriptions_to_orders.php'); (new ReflectionMethod($migration, 'backfillContracts'))->invoke($migration); expect(Subscription::query()->where('order_id', $order->id)->exists())->toBeFalse(); }); it('backfills a contract for a payment whose provisioning failed', function () { // Nothing refunds this order — onProvisioningFailed() just marks it failed — // so the customer is still owed what they paid for. $order = Order::factory()->create([ 'status' => 'failed', 'plan' => 'team', 'stripe_event_id' => 'cs_paid_then_failed', ]); $migration = require database_path('migrations/2026_07_26_030000_link_subscriptions_to_orders.php'); (new ReflectionMethod($migration, 'backfillContracts'))->invoke($migration); expect(Subscription::query()->where('order_id', $order->id)->sole()->plan)->toBe('team'); }); it('refuses to provision an order that has no contract', function () { $order = Order::factory()->create(['status' => 'paid', 'plan' => 'team']); $run = ProvisioningRun::factory()->create([ 'subject_type' => Order::class, 'subject_id' => $order->id, 'pipeline' => 'customer', 'context' => [], ]); $result = app(ValidateOrder::class)->execute($run); expect($result->type)->toBe('fail') ->and($result->reason)->toBe('no_subscription'); }); it('finishes a contract a crash left unopened, on the next Stripe retry', function () { Queue::fake(); $event = [ 'id' => 'evt_resume', 'type' => 'checkout.session.completed', 'data' => ['object' => [ 'id' => 'cs_resume', 'payment_status' => 'paid', 'customer_details' => ['email' => 'absturz@example.com'], 'amount_total' => 17900, 'currency' => 'eur', 'metadata' => ['plan' => 'team', 'datacenter' => 'fsn'], ]], ]; $this->postJson(route('webhooks.stripe'), $event)->assertOk(); // Simulate the crash window: the order and run committed, the contract // never got written. $order = Order::query()->where('stripe_event_id', 'cs_resume')->sole(); Subscription::query()->where('order_id', $order->id)->delete(); // Stripe retries until it gets a 2xx — and the retry repairs it. $this->postJson(route('webhooks.stripe'), $event)->assertOk(); expect(Subscription::query()->where('order_id', $order->id)->sole()->plan)->toBe('team') ->and(Order::query()->where('stripe_event_id', 'cs_resume')->count())->toBe(1); }); it('restarts a run that already failed for want of the contract', function () { Queue::fake(); // The plan cannot be priced when the payment lands, so no contract opens. $price = app(App\Services\Billing\PlanCatalogue::class)->currentVersion('team')->priceFor('monthly'); $amount = $price->amount_cents; // Forced past the guard, the way a bad repair script would. App\Models\PlanPrice::query()->whereKey($price->id)->delete(); $event = [ 'id' => 'evt_strand', 'type' => 'checkout.session.completed', 'data' => ['object' => [ 'id' => 'cs_strand', 'payment_status' => 'paid', 'customer_details' => ['email' => 'gestrandet@example.com'], 'amount_total' => 17900, 'currency' => 'eur', 'metadata' => ['plan' => 'team', 'datacenter' => 'fsn'], ]], ]; $this->postJson(route('webhooks.stripe'), $event)->assertOk(); $order = Order::query()->where('stripe_event_id', 'cs_strand')->sole(); $run = ProvisioningRun::query()->where('subject_id', $order->id)->sole(); // The run gets as far as validating and stops dead. app(App\Provisioning\RunRunner::class)->advance($run); expect($run->fresh()->status)->toBe('failed') ->and($run->fresh()->error)->toContain('no_subscription') ->and($order->fresh()->status)->toBe('failed'); // The owner restores the price. Nothing sweeps failed runs, so without the // retry repairing it this customer would stay paid-for and unprovisioned. app(App\Services\Billing\PlanCatalogue::class)->currentVersion('team') ->prices()->create(['term' => 'monthly', 'amount_cents' => $amount, 'currency' => 'EUR']); $this->postJson(route('webhooks.stripe'), $event)->assertOk(); expect(Subscription::query()->where('order_id', $order->id)->sole()->plan)->toBe('team') ->and($run->fresh()->status)->toBe('pending') ->and($run->fresh()->error)->toBeNull() ->and($order->fresh()->status)->toBe('paid'); Queue::assertPushed(App\Provisioning\Jobs\AdvanceRunJob::class); }); it('keeps the record of a payment even when no contract can be opened', function () { Queue::fake(); // The monthly price row is gone — the plan looks live but cannot be sold. // Forced past the guard, the way a bad repair script would. App\Models\PlanPrice::query() ->whereKey(app(App\Services\Billing\PlanCatalogue::class)->currentVersion('team')->priceFor('monthly')->id) ->delete(); $this->postJson(route('webhooks.stripe'), [ 'id' => 'evt_unpriced', 'type' => 'checkout.session.completed', 'data' => ['object' => [ 'id' => 'cs_unpriced', 'payment_status' => 'paid', 'customer_details' => ['email' => 'ohnepreis@example.com'], 'amount_total' => 17900, 'currency' => 'eur', 'metadata' => ['plan' => 'team', 'datacenter' => 'fsn'], ]], ])->assertOk(); // The money is on record and Stripe is answered; the run stops visibly. $order = Order::query()->where('stripe_event_id', 'cs_unpriced')->sole(); expect($order->amount_cents)->toBe(17900) ->and(Subscription::query()->where('order_id', $order->id)->exists())->toBeFalse(); $run = ProvisioningRun::query()->where('subject_id', $order->id)->sole(); expect(app(ValidateOrder::class)->execute($run)->reason)->toBe('no_subscription'); }); it('opens no contract for a payment in a currency the catalogue cannot express', function () { Queue::fake(); $this->postJson(route('webhooks.stripe'), [ 'id' => 'evt_chf', 'type' => 'checkout.session.completed', 'data' => ['object' => [ 'id' => 'cs_chf', 'payment_status' => 'paid', 'customer_details' => ['email' => 'schweiz@example.com'], 'amount_total' => 17900, 'currency' => 'chf', 'metadata' => ['plan' => 'team', 'datacenter' => 'fsn'], ]], ])->assertOk(); // Freezing a EUR price onto a CHF payment would leave the contract and the // payment disagreeing forever, so no contract is opened — but the payment // is still recorded, and the run stops visibly instead of building anything. $order = Order::query()->where('stripe_event_id', 'cs_chf')->sole(); expect($order->currency)->toBe('CHF') ->and(Subscription::query()->where('order_id', $order->id)->exists())->toBeFalse(); $run = ProvisioningRun::query()->where('subject_id', $order->id)->sole(); expect(app(ValidateOrder::class)->execute($run)->reason)->toBe('no_subscription'); }); it('leaves a paid order behind when the plan is unknown, instead of losing the payment', function () { Queue::fake(); $this->postJson(route('webhooks.stripe'), [ 'id' => 'evt_ghost', 'type' => 'checkout.session.completed', 'data' => ['object' => [ 'id' => 'cs_ghost', 'payment_status' => 'paid', 'customer_details' => ['email' => 'geist@example.com'], 'amount_total' => 4900, 'currency' => 'eur', 'metadata' => ['plan' => 'ghost', 'datacenter' => 'fsn'], ]], ])->assertOk(); // The order is recorded so the payment is traceable; the run fails visibly. $order = Order::query()->where('stripe_event_id', 'cs_ghost')->sole(); expect(Subscription::query()->where('order_id', $order->id)->exists())->toBeFalse(); $run = ProvisioningRun::query()->where('subject_id', $order->id)->sole(); expect(app(ValidateOrder::class)->execute($run)->type)->toBe('fail'); });