CluPilotCloud/tests/Feature/Provisioning/SubscriptionSnapshotTest.php

469 lines
20 KiB
PHP

<?php
use App\Actions\OpenSubscription;
use App\Models\Host;
use App\Models\Instance;
use App\Models\Order;
use App\Models\PlanVersion;
use App\Models\ProvisioningRun;
use App\Models\Subscription;
use App\Provisioning\Jobs\AdvanceRunJob;
use App\Provisioning\Steps\Customer\CloneVirtualMachine;
use App\Provisioning\Steps\Customer\ReserveResources;
use App\Provisioning\Steps\Customer\ValidateOrder;
use App\Services\Billing\PlanCatalogue;
use App\Services\Proxmox\ProxmoxClient;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Queue;
/**
* The catalogue describes what we sell today. A customer who has already paid
* bought today's terms and keeps them — so once an order is paid, nothing the
* operator changes in the catalogue may reach that customer, not their price
* and not the machine they were promised.
*/
/** A paid order with its contract already opened, plus a host to place it on. */
function paidOrderWithSubscription(string $plan = 'team'): array
{
$host = Host::factory()->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<string, mixed> $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');
});