diff --git a/app/Actions/OpenSubscription.php b/app/Actions/OpenSubscription.php new file mode 100644 index 0000000..9ead25d --- /dev/null +++ b/app/Actions/OpenSubscription.php @@ -0,0 +1,52 @@ +where('order_id', $order->id)->first(); + + if ($existing !== null) { + return $existing; + } + + $start = now(); + + return Subscription::create(array_merge( + Subscription::snapshotFrom($order->plan, $term), + [ + 'customer_id' => $order->customer_id, + 'order_id' => $order->id, + 'started_at' => $start, + 'current_period_start' => $start, + 'current_period_end' => $term === Subscription::TERM_YEARLY + ? $start->copy()->addYear() + : $start->copy()->addMonth(), + 'status' => 'active', + ], + )); + } +} diff --git a/app/Actions/StartCustomerProvisioning.php b/app/Actions/StartCustomerProvisioning.php index c1f7b01..1ee2e76 100644 --- a/app/Actions/StartCustomerProvisioning.php +++ b/app/Actions/StartCustomerProvisioning.php @@ -5,6 +5,7 @@ namespace App\Actions; use App\Models\Customer; use App\Models\Order; use App\Models\ProvisioningRun; +use App\Models\Subscription; use App\Provisioning\Jobs\AdvanceRunJob; use Illuminate\Database\UniqueConstraintViolationException; use Illuminate\Support\Facades\DB; @@ -16,6 +17,8 @@ use Illuminate\Support\Facades\DB; */ class StartCustomerProvisioning { + public function __construct(private OpenSubscription $openSubscription) {} + /** * @param array{id:string,email:string,name:?string,stripe_customer_id:?string,plan:string,datacenter:string,amount_cents:int,currency:string} $event */ @@ -28,8 +31,10 @@ class StartCustomerProvisioning $customer = $this->resolveCustomer($event); $customer->ensureUser(); // portal login (also enables admin impersonation) + $openSubscription = $this->openSubscription; + try { - [$order, $run] = DB::transaction(function () use ($event, $customer) { + [$order, $run] = DB::transaction(function () use ($event, $customer, $openSubscription) { $order = Order::create([ 'customer_id' => $customer->id, 'plan' => $event['plan'], @@ -40,6 +45,24 @@ class StartCustomerProvisioning 'status' => 'paid', ]); + // Freeze what they bought, now, while the catalogue still says + // what they were shown. + // + // Two cases are deliberately left WITHOUT a contract: a plan the + // catalogue does not know, and a payment in a currency the + // catalogue cannot express — freezing a EUR price onto a CHF + // payment would put the contract and the payment in permanent + // disagreement. Both still record the order, so the payment is + // traceable, and the run then fails visibly at ValidateOrder. + // Throwing here instead would roll the order back and leave + // Stripe retrying a webhook that can never succeed. + $sellable = Subscription::knowsPlan($order->plan) + && strtoupper((string) $order->currency) === Subscription::catalogueCurrency(); + + if ($sellable) { + $openSubscription($order); + } + $run = ProvisioningRun::create([ 'subject_type' => Order::class, 'subject_id' => $order->id, diff --git a/app/Models/Instance.php b/app/Models/Instance.php index 84fcd44..b6484fa 100644 --- a/app/Models/Instance.php +++ b/app/Models/Instance.php @@ -7,6 +7,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Eloquent\Relations\HasOne; class Instance extends Model { @@ -48,6 +49,15 @@ class Instance extends Model return $this->belongsTo(Order::class); } + /** + * The contract this machine fulfils. The authority on what the customer is + * entitled to — the catalogue only describes what we sell today. + */ + public function subscription(): HasOne + { + return $this->hasOne(Subscription::class); + } + public function host(): BelongsTo { return $this->belongsTo(Host::class); diff --git a/app/Models/Order.php b/app/Models/Order.php index e84e361..db4b645 100644 --- a/app/Models/Order.php +++ b/app/Models/Order.php @@ -85,6 +85,12 @@ class Order extends Model implements ProvisioningSubject return $this->hasOne(Instance::class); } + /** The contract this order opened, if it was the purchase that opened one. */ + public function subscription(): HasOne + { + return $this->hasOne(Subscription::class); + } + public function runs(): MorphMany { return $this->morphMany(ProvisioningRun::class, 'subject'); diff --git a/app/Models/Subscription.php b/app/Models/Subscription.php index 6d8d3b8..4be6deb 100644 --- a/app/Models/Subscription.php +++ b/app/Models/Subscription.php @@ -53,6 +53,7 @@ class Subscription extends Model 'ram_mb' => 'integer', 'cores' => 'integer', 'disk_gb' => 'integer', + 'template_vmid' => 'integer', 'tier' => 'integer', 'started_at' => 'datetime', 'current_period_start' => 'datetime', @@ -72,11 +73,28 @@ class Subscription extends Model return $this->belongsTo(Customer::class); } + public function order(): BelongsTo + { + return $this->belongsTo(Order::class); + } + public function instance(): BelongsTo { return $this->belongsTo(Instance::class); } + /** Whether the catalogue still sells this plan at all. */ + public static function knowsPlan(string $plan): bool + { + return (array) config("provisioning.plans.{$plan}") !== []; + } + + /** The single currency every catalogue price is expressed in. */ + public static function catalogueCurrency(): string + { + return strtoupper((string) config('provisioning.currency', 'EUR')); + } + /** Take today's catalogue entry and freeze it onto a new subscription. */ public static function snapshotFrom(string $plan, string $term = self::TERM_MONTHLY): array { @@ -100,7 +118,7 @@ class Subscription extends Model // A yearly term is twelve months of the same price. Any discount // belongs in the catalogue, not hidden in this conversion. 'price_cents' => $term === self::TERM_YEARLY ? $monthly * 12 : $monthly, - 'currency' => 'EUR', + 'currency' => self::catalogueCurrency(), 'quota_gb' => (int) ($catalogue['quota_gb'] ?? 0), 'traffic_gb' => (int) ($catalogue['traffic_gb'] ?? 0), 'seats' => (int) ($catalogue['seats'] ?? 0), @@ -108,6 +126,9 @@ class Subscription extends Model 'cores' => (int) ($catalogue['cores'] ?? 0), 'disk_gb' => (int) ($catalogue['disk_gb'] ?? 0), 'performance' => $catalogue['performance'] ?? null, + // Copied so provisioning never has to ask the catalogue what to + // clone. Not in FROZEN — see the migration for why. + 'template_vmid' => isset($catalogue['template_vmid']) ? (int) $catalogue['template_vmid'] : null, // Frozen too: which direction a later change goes must not depend // on where the plan sits in today's catalogue. 'tier' => (int) ($catalogue['tier'] ?? 0), diff --git a/app/Provisioning/Steps/Customer/CustomerStep.php b/app/Provisioning/Steps/Customer/CustomerStep.php index c105652..2a38490 100644 --- a/app/Provisioning/Steps/Customer/CustomerStep.php +++ b/app/Provisioning/Steps/Customer/CustomerStep.php @@ -5,6 +5,7 @@ namespace App\Provisioning\Steps\Customer; use App\Models\Instance; use App\Models\Order; use App\Models\ProvisioningRun; +use App\Models\Subscription; use App\Provisioning\Contracts\ProvisioningStep; use App\Provisioning\Steps\ManagesRunResources; use App\Services\Proxmox\ProxmoxClient; @@ -43,10 +44,35 @@ abstract class CustomerStep implements ProvisioningStep return $id ? Instance::query()->find($id) : null; } - /** @return array */ + /** The contract this run is carrying out, or null if none was opened. */ + protected function subscription(ProvisioningRun $run): ?Subscription + { + return Subscription::query()->where('order_id', $this->order($run)->id)->first(); + } + + /** + * What the customer actually bought — read from their frozen snapshot, never + * from config('provisioning.plans'). + * + * The catalogue says what we sell TODAY. Sizing a machine from it would mean + * that shrinking a plan resizes the VM of a customer who bought the larger + * one, on their next run. Empty when no contract exists, so ValidateOrder + * can fail the run closed instead of quietly falling back to the catalogue. + * + * @return array + */ protected function plan(ProvisioningRun $run): array { - return config('provisioning.plans.'.$this->order($run)->plan, []); + $subscription = $this->subscription($run); + + if ($subscription === null) { + return []; + } + + return $subscription->only([ + 'plan', 'term', 'price_cents', 'currency', 'quota_gb', 'traffic_gb', + 'seats', 'ram_mb', 'cores', 'disk_gb', 'performance', 'template_vmid', 'tier', + ]); } /** diff --git a/app/Provisioning/Steps/Customer/ReserveResources.php b/app/Provisioning/Steps/Customer/ReserveResources.php index 43039d1..43443f8 100644 --- a/app/Provisioning/Steps/Customer/ReserveResources.php +++ b/app/Provisioning/Steps/Customer/ReserveResources.php @@ -30,10 +30,19 @@ class ReserveResources extends CustomerStep $existing = Instance::query()->where('order_id', $order->id)->first(); if ($existing !== null) { $this->putContext($run, $existing); + $this->linkToSubscription($run, $existing); return StepResult::advance(); } + $subscription = $this->subscription($run); + + if ($subscription === null) { + return StepResult::fail('no_subscription'); + } + + // Sizes come from the contract, not the catalogue: this customer gets + // the machine they paid for even if the plan was edited since. $plan = $this->plan($run); // Place + create under a per-datacenter lock so concurrent reservations @@ -63,11 +72,26 @@ class ReserveResources extends CustomerStep } $this->putContext($run, $instance); + $this->linkToSubscription($run, $instance); $this->recordResource($run, $instance->host, 'instance_id', (string) $instance->id); return StepResult::advance(); } + /** + * Point the contract at the machine that fulfils it, so anything asking + * "what is this customer entitled to?" can get there from either end. + * instance_id is not part of the frozen snapshot, so this is allowed. + */ + private function linkToSubscription(ProvisioningRun $run, Instance $instance): void + { + $subscription = $this->subscription($run); + + if ($subscription !== null && $subscription->instance_id !== $instance->id) { + $subscription->update(['instance_id' => $instance->id]); + } + } + private function putContext(ProvisioningRun $run, Instance $instance): void { $run->mergeContext([ diff --git a/app/Provisioning/Steps/Customer/ValidateOrder.php b/app/Provisioning/Steps/Customer/ValidateOrder.php index 4fed5db..b7a376d 100644 --- a/app/Provisioning/Steps/Customer/ValidateOrder.php +++ b/app/Provisioning/Steps/Customer/ValidateOrder.php @@ -24,8 +24,11 @@ class ValidateOrder extends CustomerStep if (! in_array($order->status, ['paid', 'provisioning'], true)) { return StepResult::fail('order_not_paid'); } + // Fail closed. No contract means nothing frozen to build from, and + // sizing the machine from today's catalogue instead is exactly the bug + // the snapshot exists to prevent. if ($this->plan($run) === []) { - return StepResult::fail('unknown_plan'); + return StepResult::fail('no_subscription'); } if (blank($order->datacenter)) { return StepResult::fail('invalid_datacenter'); diff --git a/app/Services/Traffic/TrafficMeter.php b/app/Services/Traffic/TrafficMeter.php index 4815896..ef98583 100644 --- a/app/Services/Traffic/TrafficMeter.php +++ b/app/Services/Traffic/TrafficMeter.php @@ -37,10 +37,20 @@ final readonly class TrafficMeter ); } - /** Plan allowance plus purchased add-on packs. */ + /** + * Plan allowance plus purchased add-on packs. + * + * The allowance comes from the customer's contract, not from today's + * catalogue: cutting a plan's traffic must not start throttling someone who + * bought the larger allowance. Instances provisioned before contracts + * existed have none, and for those the catalogue is still the only answer — + * that fallback goes away once Phase 2 backfills them. + */ public static function quotaGb(Instance $instance): int { - $plan = (int) (config("provisioning.plans.{$instance->plan}.traffic_gb") ?? 0); + $plan = (int) ($instance->subscription?->traffic_gb + ?? config("provisioning.plans.{$instance->plan}.traffic_gb") + ?? 0); $addonGb = (int) config('provisioning.traffic.addon.gb', 1000); return $plan + ($instance->traffic_addons ?? 0) * $addonGb; diff --git a/config/provisioning.php b/config/provisioning.php index 2faca93..f4555ff 100644 --- a/config/provisioning.php +++ b/config/provisioning.php @@ -62,6 +62,11 @@ return [ ], ], + // The one currency the catalogue is priced in. Plan prices carry no currency + // of their own, so a payment in anything else cannot be frozen onto a + // contract without the contract and the payment disagreeing forever. + 'currency' => env('CLUPILOT_CURRENCY', 'EUR'), + // Plan → resource sizing + monthly price + Nextcloud blueprint template VMID. // quota_gb/disk_gb/ram_mb/cores drive infrastructure placement and are // ADMIN-ONLY. Customers compare seats, storage, performance class and diff --git a/database/factories/OrderFactory.php b/database/factories/OrderFactory.php index 8cd8ee4..48cfdee 100644 --- a/database/factories/OrderFactory.php +++ b/database/factories/OrderFactory.php @@ -2,8 +2,10 @@ namespace Database\Factories; +use App\Actions\OpenSubscription; use App\Models\Customer; use App\Models\Order; +use App\Models\Subscription; use Illuminate\Database\Eloquent\Factories\Factory; /** @extends Factory */ @@ -22,4 +24,18 @@ class OrderFactory extends Factory 'status' => 'paid', ]; } + + /** + * A purchase that opened a contract — what a paid order looks like in + * production, and what provisioning now requires. An unknown plan is left + * without one on purpose, so the fail-closed path stays testable. + */ + public function withSubscription(string $term = Subscription::TERM_MONTHLY): static + { + return $this->afterCreating(function (Order $order) use ($term) { + if (Subscription::knowsPlan($order->plan)) { + app(OpenSubscription::class)($order, $term); + } + }); + } } diff --git a/database/migrations/2026_07_26_030000_link_subscriptions_to_orders.php b/database/migrations/2026_07_26_030000_link_subscriptions_to_orders.php new file mode 100644 index 0000000..040ea62 --- /dev/null +++ b/database/migrations/2026_07_26_030000_link_subscriptions_to_orders.php @@ -0,0 +1,177 @@ +foreignId('order_id')->nullable()->unique()->after('customer_id') + ->constrained()->nullOnDelete(); + + // The blueprint the customer's VM is cloned from. Frozen here so the + // pipeline stops reading the live catalogue — but deliberately NOT + // in Subscription::FROZEN: this is how we build the machine, not a + // condition the customer is owed, and an operator has to be able to + // point a contract at a replacement template when the old one is + // gone, without cancelling the contract to do it. + $table->unsignedInteger('template_vmid')->nullable()->after('performance'); + }); + + $this->backfillContracts(); + } + + public function down(): void + { + Schema::table('subscriptions', function (Blueprint $table) { + $table->dropConstrainedForeignId('order_id'); + $table->dropColumn('template_vmid'); + }); + + // Backfilled contracts are deliberately left in place. Deleting a + // customer's contract to undo a schema change would destroy the record + // of what they are owed; a stale row is the lesser harm. + } + + /** + * Give every order that already bought something the contract it should + * always have had. + * + * Provisioning now fails closed without one, so an order that is paid or + * mid-flight when this deploys would otherwise stop dead at ValidateOrder. + * + * Everything is inlined rather than routed through Subscription — a + * migration has to keep working when the model moves on. + */ + private function backfillContracts(): void + { + $plans = (array) config('provisioning.plans'); + $currency = strtoupper((string) config('provisioning.currency', 'EUR')); + + $orders = DB::table('orders') + ->where('type', 'new') // only a purchase opens a contract + ->where(function ($query) { + $query + ->whereIn('status', ['paid', 'provisioning', 'active']) + // A failed run does not undo a payment: onProvisioningFailed() + // marks the order failed and refunds nothing, and from now on + // such an order keeps the contract opened before provisioning + // ever started. Backfill matches that — but only where the + // money is evidenced, since an order that was never paid can + // end up failed too. Only the paid webhook sets + // stripe_event_id; the cart leaves it null and pending. + ->orWhere(fn ($paid) => $paid + ->where('status', 'failed') + ->whereNotNull('stripe_event_id')); + }) + ->orderBy('id') + ->get(); + + foreach ($orders as $order) { + if (DB::table('subscriptions')->where('order_id', $order->id)->exists()) { + continue; + } + + $plan = $plans[$order->plan] ?? null; + + if ($plan === null) { + continue; // a plan we no longer sell: nothing left to reconstruct from + } + + // A payment in a currency the catalogue cannot express gets no + // contract, exactly as the checkout path refuses one — writing an + // EUR price against a CHF payment would bake in a disagreement. + if (strtoupper((string) $order->currency) !== $currency) { + continue; + } + + // What was actually delivered outranks what the catalogue says now: + // the catalogue may already have drifted from what this customer was + // sold, and the running machine is the honest record of their terms. + $instance = DB::table('instances')->where('order_id', $order->id)->first(); + + // A contract may already exist and simply have no order_id, because + // nothing set one before this migration. Adopt it rather than open a + // second: two active contracts for one purchase make "what is this + // customer owed?" unanswerable, and its frozen terms are the real + // ones — reconstructing them from the catalogue would be a guess. + $orphan = DB::table('subscriptions') + ->whereNull('order_id') + ->where('customer_id', $order->customer_id) + ->where('plan', $order->plan) + ->when( + $instance !== null, + fn ($q) => $q->where(fn ($m) => $m->where('instance_id', $instance->id)->orWhereNull('instance_id')), + fn ($q) => $q->whereNull('instance_id'), + ) + ->orderBy('id') + ->first(); + + if ($orphan !== null) { + DB::table('subscriptions')->where('id', $orphan->id)->update(array_filter([ + 'order_id' => $order->id, + // Fill only what is missing. The snapshot itself is left + // untouched: it is what this customer was actually sold. + 'instance_id' => $orphan->instance_id ?? $instance->id ?? null, + 'template_vmid' => $orphan->template_vmid + ?? (isset($plan['template_vmid']) ? (int) $plan['template_vmid'] : null), + 'updated_at' => now(), + ], fn ($value) => $value !== null)); + + continue; + } + + // Anchor the term on the purchase and roll it forward to the period + // that is running now, so the next renewal lands where it should. + $start = Carbon::parse($order->created_at); + $end = $start->copy()->addMonth(); + while ($end->isPast()) { + $end->addMonth(); + } + + DB::table('subscriptions')->insert([ + 'uuid' => (string) Str::uuid(), + 'customer_id' => $order->customer_id, + 'order_id' => $order->id, + 'instance_id' => $instance->id ?? null, + 'plan' => $order->plan, + 'tier' => (int) ($plan['tier'] ?? 0), + 'term' => 'monthly', + 'price_cents' => (int) ($plan['price_cents'] ?? 0), + 'currency' => $currency, + 'quota_gb' => (int) ($instance->quota_gb ?? $plan['quota_gb'] ?? 0), + 'traffic_gb' => (int) ($plan['traffic_gb'] ?? 0), + 'seats' => (int) ($plan['seats'] ?? 0), + 'ram_mb' => (int) ($instance->ram_mb ?? $plan['ram_mb'] ?? 0), + 'cores' => (int) ($instance->cores ?? $plan['cores'] ?? 0), + 'disk_gb' => (int) ($instance->disk_gb ?? $plan['disk_gb'] ?? 0), + 'performance' => $plan['performance'] ?? null, + 'template_vmid' => isset($plan['template_vmid']) ? (int) $plan['template_vmid'] : null, + 'started_at' => $start, + 'current_period_start' => $end->copy()->subMonth(), + 'current_period_end' => $end, + 'status' => 'active', + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + } +}; diff --git a/phpunit.xml b/phpunit.xml index 50f2621..e1bffaa 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -30,6 +30,11 @@ + + diff --git a/tests/Feature/Provisioning/CustomerProvisioningEndToEndTest.php b/tests/Feature/Provisioning/CustomerProvisioningEndToEndTest.php index 7ab87b0..d899641 100644 --- a/tests/Feature/Provisioning/CustomerProvisioningEndToEndTest.php +++ b/tests/Feature/Provisioning/CustomerProvisioningEndToEndTest.php @@ -20,7 +20,7 @@ it('provisions a paid order all the way to active (mocked)', function () { // Fake defaults: tasks stopped/OK, guest agent up, cert reachable. Host::factory()->active()->create(['datacenter' => 'fsn', 'node' => 'pve', 'total_gb' => 1000]); - $order = Order::factory()->create(['status' => 'paid', 'plan' => 'start', 'datacenter' => 'fsn']); + $order = Order::factory()->withSubscription()->create(['status' => 'paid', 'plan' => 'start', 'datacenter' => 'fsn']); $run = ProvisioningRun::factory()->create([ 'subject_type' => Order::class, 'subject_id' => $order->id, 'pipeline' => 'customer', 'context' => [], ]); @@ -68,7 +68,7 @@ it('does not duplicate external resources when a step re-runs after a crash', fu $s['pve']->guestScript('occ status', 0, '{"installed":true,"maintenance":false}'); Host::factory()->active()->create(['datacenter' => 'fsn', 'node' => 'pve', 'total_gb' => 1000]); - $order = Order::factory()->create(['status' => 'paid', 'plan' => 'start', 'datacenter' => 'fsn']); + $order = Order::factory()->withSubscription()->create(['status' => 'paid', 'plan' => 'start', 'datacenter' => 'fsn']); $run = ProvisioningRun::factory()->create([ 'subject_type' => Order::class, 'subject_id' => $order->id, 'pipeline' => 'customer', 'context' => [], ]); diff --git a/tests/Feature/Provisioning/CustomerStepBaseTest.php b/tests/Feature/Provisioning/CustomerStepBaseTest.php index 49ecb41..3cce1dc 100644 --- a/tests/Feature/Provisioning/CustomerStepBaseTest.php +++ b/tests/Feature/Provisioning/CustomerStepBaseTest.php @@ -6,7 +6,7 @@ use App\Models\ProvisioningRun; use Tests\Support\Steps\ProbeCustomerStep; it('resolves order, instance and plan from the run', function () { - $order = Order::factory()->create(['plan' => 'team']); + $order = Order::factory()->withSubscription()->create(['plan' => 'team']); $instance = Instance::factory()->create(); $run = ProvisioningRun::factory()->create([ 'subject_type' => Order::class, diff --git a/tests/Feature/Provisioning/CustomerStepsTest.php b/tests/Feature/Provisioning/CustomerStepsTest.php index 42f07f1..d925782 100644 --- a/tests/Feature/Provisioning/CustomerStepsTest.php +++ b/tests/Feature/Provisioning/CustomerStepsTest.php @@ -27,7 +27,7 @@ use Illuminate\Support\Facades\Notification; /** A run whose order is paid but nothing is reserved yet. */ function orderRun(array $attrs = [], array $context = []): ProvisioningRun { - $order = Order::factory()->create($attrs); + $order = Order::factory()->withSubscription()->create($attrs); return ProvisioningRun::factory()->create([ 'subject_type' => Order::class, @@ -41,7 +41,7 @@ function orderRun(array $attrs = [], array $context = []): ProvisioningRun function reservedRun(array $extraContext = [], array $instanceAttrs = []): array { $host = Host::factory()->active()->create(['datacenter' => 'fsn', 'node' => 'pve']); - $order = Order::factory()->create(['datacenter' => 'fsn', 'plan' => 'start']); + $order = Order::factory()->withSubscription()->create(['datacenter' => 'fsn', 'plan' => 'start']); $instance = Instance::factory()->create(array_merge([ 'order_id' => $order->id, 'customer_id' => $order->customer_id, 'host_id' => $host->id, 'vmid' => 101, 'status' => 'provisioning', diff --git a/tests/Feature/Provisioning/SubscriptionSnapshotTest.php b/tests/Feature/Provisioning/SubscriptionSnapshotTest.php new file mode 100644 index 0000000..446e989 --- /dev/null +++ b/tests/Feature/Provisioning/SubscriptionSnapshotTest.php @@ -0,0 +1,305 @@ +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'); +}); diff --git a/tests/Feature/TrafficTest.php b/tests/Feature/TrafficTest.php index 69476be..0c16a57 100644 --- a/tests/Feature/TrafficTest.php +++ b/tests/Feature/TrafficTest.php @@ -128,6 +128,21 @@ it('counts add-on packs towards the allowance', function () { expect(TrafficMeter::quotaGb($instance->fresh()))->toBe(3000); }); +it('keeps the allowance the customer bought when the catalogue is cut', function () { + [, , $instance] = trafficSetup(); + + App\Models\Subscription::factory()->create([ + 'customer_id' => $instance->customer_id, + 'instance_id' => $instance->id, + ...App\Models\Subscription::snapshotFrom('start'), + ]); + + // Half the traffic off the start plan, after this customer bought it. + config()->set('provisioning.plans.start.traffic_gb', 500); + + expect(TrafficMeter::quotaGb($instance->fresh()))->toBe(1000); +}); + it('shows the customer what is left, and offers a top-up when it gets tight', function () { [, $customer, $instance] = trafficSetup(); $user = User::factory()->create(['email' => $customer->email]);