417 lines
18 KiB
PHP
417 lines
18 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('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');
|
|
});
|