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
parent
0dde76ad55
commit
52b41bb0d5
|
|
@ -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',
|
||||
],
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
@ -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"/>
|
||||
|
|
|
|||
|
|
@ -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' => [],
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
|
@ -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]);
|
||||
|
|
|
|||
Loading…
Reference in New Issue