fix(billing): a paid order opens a contract, and provisioning obeys it

The pipeline re-resolved config('provisioning.plans') by order.plan, so the
subscription snapshot protected a customer's price but not their machine:
shrinking a plan resized an existing customer's VM on its next run. Nothing
created a subscription either, so closing this meant opening the contract at
purchase and pointing provisioning at it.

- OpenSubscription freezes the catalogue onto a subscription when a checkout
  is paid; StartCustomerProvisioning calls it inside the order transaction.
- CustomerStep::plan() reads the frozen snapshot. ValidateOrder and
  ReserveResources fail closed with no_subscription rather than falling back
  to the catalogue, which is the bug itself.
- template_vmid joins the snapshot so a re-clone cannot pick up a blueprint
  published after the sale. Deliberately outside FROZEN: it is how we build
  the machine, not a term the customer is owed, and a dead template must be
  replaceable without cancelling a contract.
- TrafficMeter reads the allowance off the contract too — cutting a plan's
  traffic was otherwise enough to start throttling someone who bought more.
- The migration backfills contracts for orders that already bought something,
  reconstructed from what was actually delivered where an instance exists,
  and adopts an existing order-less contract instead of opening a second.
  Orders paid in a currency the catalogue cannot price get none, matching the
  checkout path.

price_cents stays the catalogue's NET price, which is what PlanChange
prorates against — not Order::amount_cents, which holds Stripe's GROSS total.
Reconciling the two belongs to the proof register and Stripe (phases 4/5).

Also pins STRIPE_WEBHOOK_SECRET blank in phpunit.xml: the operator's real
secret was reaching the suite from .env and rejecting every unsigned test
payload, which is why 7 webhook tests failed before any of this.

Verified in the browser: with team traffic cut from 3000 to 500 GB in the
catalogue, the customer's portal still shows 3 TB.

373 tests green. Codex review clean after three rounds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feat/portal-design
nexxo 2026-07-26 11:10:00 +02:00
parent 0dde76ad55
commit 52b41bb0d5
18 changed files with 710 additions and 12 deletions

View File

@ -0,0 +1,52 @@
<?php
namespace App\Actions;
use App\Models\Order;
use App\Models\Subscription;
/**
* Opens the contract a paid order bought.
*
* This is the moment the catalogue stops applying. Everything the customer is
* owed price, quotas, seats, the hardware behind the plan is copied onto
* the subscription here and never read from the catalogue again. Provisioning
* sizes the machine from this row, so an operator editing a plan afterwards
* cannot reach a customer who has already paid.
*
* `price_cents` is the catalogue's NET price, which is what PlanChange prorates
* against deliberately not `Order::amount_cents`, which holds the GROSS total
* Stripe actually charged. The two can legitimately differ (VAT, and later
* coupons), and reconciling them is not this action's job: the proof register
* records what was charged per event, and Stripe's invoice is the authority for
* the amount. Copying a gross total into this net field would silently corrupt
* every pro-rata calculation that reads it.
*/
class OpenSubscription
{
public function __invoke(Order $order, string $term = Subscription::TERM_MONTHLY): Subscription
{
// A retried webhook must not open a second contract for one purchase.
$existing = Subscription::query()->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',
],
));
}
}

View File

@ -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,

View File

@ -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);

View File

@ -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');

View File

@ -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),

View File

@ -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<string, mixed> */
/** 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<string, mixed>
*/
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',
]);
}
/**

View File

@ -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([

View File

@ -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');

View File

@ -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;

View File

@ -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

View File

@ -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<Order> */
@ -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);
}
});
}
}

View File

@ -0,0 +1,177 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
/**
* Gives provisioning a way to reach the contract, and the contract everything
* provisioning needs.
*
* Until now the pipeline re-read config/provisioning.php by the order's plan
* name, so the snapshot protected the customer's price but not their machine:
* shrinking a plan resized an existing customer's VM on the next run. The
* pipeline's subject is the order, so the order is how it finds the snapshot.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('subscriptions', function (Blueprint $table) {
// The order that opened this contract. Unique, because one purchase
// opens one contract — later upgrades and add-ons are their own
// orders against the same subscription, never new subscriptions.
$table->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(),
]);
}
}
};

View File

@ -30,6 +30,11 @@
<!-- Fixed test key: storing a VPN config must be exercised, and a
random one would make a decryption failure irreproducible. -->
<env name="VPN_CONFIG_KEY" value="Y2x1cGlsb3QtdGVzdC1vbmx5LXZwbi1rZXktMzJieXQ=" force="true"/>
<!-- Blank so the suite never picks up the operator's real webhook secret
from .env: with one set, every unsigned test payload is rejected as
an invalid signature. The two tests that DO exercise verification
set the secret themselves. -->
<env name="STRIPE_WEBHOOK_SECRET" value="" force="true"/>
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
<env name="BCRYPT_ROUNDS" value="4" force="true"/>
<env name="BROADCAST_CONNECTION" value="null" force="true"/>

View File

@ -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' => [],
]);

View File

@ -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,

View File

@ -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',

View File

@ -0,0 +1,305 @@
<?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');
});

View File

@ -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]);