CluPilotCloud/tests/Feature/Provisioning/SubscriptionSnapshotTest.php

306 lines
13 KiB
PHP

<?php
use App\Actions\OpenSubscription;
use App\Models\Host;
use App\Models\Instance;
use App\Models\Order;
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\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');
}
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 shrinks the team plan after this customer has paid for it.
config()->set('provisioning.plans.team.ram_mb', 4096);
config()->set('provisioning.plans.team.cores', 2);
config()->set('provisioning.plans.team.disk_gb', 120);
config()->set('provisioning.plans.team.quota_gb', 100);
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.
config()->set('provisioning.plans.team.template_vmid', 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('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');
});