feat(billing): the plan catalogue becomes three tables, and config stops selling

Plans lived in config/provisioning.php, where the owner cannot reach them and
where a plan has no history. They now live in plan_families / plan_versions /
plan_prices — a name that never moves, what the plan WAS at a point in time,
and a price per version and term with its own Stripe Price id.

- Availability is computed on every read (available_from <= now < until,
  half-open, UTC) plus a per-family sales kill switch. Nothing schedules a plan
  into or out of sale: a job that fails to run is a plan that silently
  misbehaves.
- Overlaps crash rather than resolve. currentVersion() uses sole(), and
  scheduling takes a lock on the family and rejects an overlapping window —
  two versions on sale at once would decide a customer's price by row order.
- A version is frozen from publication, not from first sale, and so is its
  price: a checkout is not instant, and an amount edited between the session
  opening and the webhook landing would contract someone at a price they were
  never quoted. Repricing publishes a new version, as Stripe requires anyway.
- Neither a published version, its price, nor a family with customers can be
  deleted, and a family key cannot be renamed. All of those would null the
  provenance off existing contracts.
- Subscriptions and orders record plan_version_id. A historical reference
  resolved by plan NAME hands back today's terms, which is the split-brain one
  level up. A checkout that carried its version is honoured even after the
  window closes — they paid for what they were shown.

Switched atomically and failing closed: config('provisioning.plans') and
plan_features are gone, and nothing falls back to them. A fallback would
resurrect a plan the owner had just switched off. The seed lives in the
migration, and PlanCatalogueTest pins what the config catalogue sold as the
shadow comparison. `php artisan plans:check` reports overlaps, gaps and
missing prices before a customer finds them.

Contract-backed displays, which were reading the live catalogue:
seat limits, the cloud card, the plan card, and admin MRR — the last three now
divide a yearly contract down via Subscription::monthlyPriceCents(), since all
three label the figure per month.

Two recovery gaps closed along the way: the order now commits before the
contract is opened (so a contract failure can never erase the record of a
payment), a Stripe retry repairs a missing contract and restarts a run stranded
with no_subscription, and TickProvisioning sweeps runs left pending by a crash
rather than only running/waiting ones.

402 tests green. Codex review clean after thirteen rounds. Verified in the
browser: portal, billing and console render unchanged off the new catalogue.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feat/portal-design
nexxo 2026-07-26 12:05:56 +02:00
parent 52b41bb0d5
commit 6387c747d0
29 changed files with 2025 additions and 118 deletions

View File

@ -36,7 +36,10 @@ class OpenSubscription
$start = now(); $start = now();
return Subscription::create(array_merge( return Subscription::create(array_merge(
Subscription::snapshotFrom($order->plan, $term), // The version the order carries, when the checkout recorded one:
// what the customer saw beats what happens to be on sale by the
// time their payment reaches us.
Subscription::snapshotFrom($order->plan, $term, $order->plan_version_id),
[ [
'customer_id' => $order->customer_id, 'customer_id' => $order->customer_id,
'order_id' => $order->id, 'order_id' => $order->id,

View File

@ -7,8 +7,11 @@ use App\Models\Order;
use App\Models\ProvisioningRun; use App\Models\ProvisioningRun;
use App\Models\Subscription; use App\Models\Subscription;
use App\Provisioning\Jobs\AdvanceRunJob; use App\Provisioning\Jobs\AdvanceRunJob;
use App\Services\Billing\PlanCatalogue;
use Illuminate\Database\UniqueConstraintViolationException; use Illuminate\Database\UniqueConstraintViolationException;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Throwable;
/** /**
* Turns a paid Stripe event into a customer + order + provisioning run. The * Turns a paid Stripe event into a customer + order + provisioning run. The
@ -24,20 +27,26 @@ class StartCustomerProvisioning
*/ */
public function fromStripeEvent(array $event): ?Order public function fromStripeEvent(array $event): ?Order
{ {
if (Order::query()->where('stripe_event_id', $event['id'])->exists()) { $existing = Order::query()->where('stripe_event_id', $event['id'])->first();
return null; // already processed
if ($existing !== null) {
$this->resume($existing);
return null;
} }
$customer = $this->resolveCustomer($event); $customer = $this->resolveCustomer($event);
$customer->ensureUser(); // portal login (also enables admin impersonation) $customer->ensureUser(); // portal login (also enables admin impersonation)
$openSubscription = $this->openSubscription;
try { try {
[$order, $run] = DB::transaction(function () use ($event, $customer, $openSubscription) { [$order, $run] = DB::transaction(function () use ($event, $customer) {
$order = Order::create([ $order = Order::create([
'customer_id' => $customer->id, 'customer_id' => $customer->id,
'plan' => $event['plan'], 'plan' => $event['plan'],
// Set once Stripe checkout carries it (phase 5). Null means
// "whatever is on sale when the webhook lands", which is
// what this did before the column existed.
'plan_version_id' => $event['plan_version_id'] ?? null,
'amount_cents' => $event['amount_cents'], 'amount_cents' => $event['amount_cents'],
'currency' => $event['currency'], 'currency' => $event['currency'],
'datacenter' => $event['datacenter'], 'datacenter' => $event['datacenter'],
@ -45,24 +54,6 @@ class StartCustomerProvisioning
'status' => 'paid', '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([ $run = ProvisioningRun::create([
'subject_type' => Order::class, 'subject_type' => Order::class,
'subject_id' => $order->id, 'subject_id' => $order->id,
@ -77,14 +68,139 @@ class StartCustomerProvisioning
return [$order, $run]; return [$order, $run];
}); });
} catch (UniqueConstraintViolationException) { } catch (UniqueConstraintViolationException) {
return null; // concurrent duplicate webhook // A concurrent delivery won the race. Both requests will answer
// Stripe with a 2xx, so this is the last look anyone takes at this
// payment — if the winner then dies before opening the contract, no
// retry is coming to fix it. So finish its work rather than just
// stepping aside. The unique index on subscriptions.order_id keeps
// both from opening one.
$winner = Order::query()->where('stripe_event_id', $event['id'])->first();
if ($winner !== null) {
$this->resume($winner);
} }
return null;
}
$this->openContract($order);
AdvanceRunJob::dispatch($run->uuid); // after commit AdvanceRunJob::dispatch($run->uuid); // after commit
return $order; return $order;
} }
/**
* Finish what a crash interrupted.
*
* The order commits before the contract is opened, so that a failure there
* can never erase the record of a payment but that leaves a window in
* which a paid order exists with no contract and nothing running. Stripe
* retries until it gets a 2xx, so a retry is exactly the chance to close
* that window. Simply returning "already processed" would strand a paying
* customer permanently, because no later webhook would ever look again.
*
* Safe to run on every duplicate: opening a contract is idempotent. A run
* that was merely never dispatched needs no nudge from here the scheduler
* tick sweeps pending runs. A run that already ran and FAILED for want of
* the contract does, because nothing sweeps failed runs.
*/
private function resume(Order $order): void
{
if ($order->subscription()->exists()) {
return;
}
$this->openContract($order);
if ($order->subscription()->exists()) {
$this->reviveRunStrandedWithoutAContract($order);
}
}
/**
* Restart a run that failed only because the contract was missing.
*
* Narrow on purpose. `no_subscription` is returned before anything is built
* the run never got past validating the order or reserving resources so
* there is nothing half-made to trip over. A run that failed for any other
* reason failed at something a contract does not fix, and restarting it
* would just repeat whatever went wrong.
*/
private function reviveRunStrandedWithoutAContract(Order $order): void
{
$run = $order->runs()->where('pipeline', 'customer')->latest('id')->first();
if ($run?->status !== ProvisioningRun::STATUS_FAILED
|| ! str_contains((string) $run->error, 'no_subscription')) {
return;
}
// onProvisioningFailed() marked the order failed on the way down.
$order->update(['status' => 'paid']);
$run->update([
'status' => ProvisioningRun::STATUS_PENDING,
'current_step' => 0,
'attempt' => 0,
'next_attempt_at' => null,
'error' => null,
]);
AdvanceRunJob::dispatch($run->uuid);
Log::info('Restarted a run that was stranded without a contract.', [
'order_id' => $order->id, 'run' => $run->uuid,
]);
}
/**
* Freeze what they bought, while the catalogue still says what they were
* shown.
*
* Deliberately AFTER the order is committed, and deliberately swallowing
* its failure. The order is the record that money changed hands: if opening
* the contract could roll it back, an unsellable plan, a missing price or
* any unforeseen fault would erase the evidence of a payment we have
* already taken and Stripe, seeing no 2xx, would retry a webhook that can
* never succeed. A missing contract is recoverable and loud: the run stops
* at ValidateOrder with `no_subscription`, in the operator's face.
*
* Two cases are expected to land here: a plan the catalogue cannot sell,
* and a payment in a currency it cannot price freezing a EUR price onto a
* CHF payment would put the contract and the payment in permanent
* disagreement.
*/
private function openContract(Order $order): void
{
try {
// A checkout that recorded its version is owed THAT version, even
// if the window has closed or the plan has been withdrawn since —
// they paid for what they were shown. Only a purchase with no
// recorded version has to ask what is on sale now.
$deliverable = $order->plan_version_id !== null
? app(PlanCatalogue::class)->isDeliverable($order->plan, $order->plan_version_id)
: Subscription::knowsPlan($order->plan);
$sellable = $deliverable
&& strtoupper((string) $order->currency) === Subscription::catalogueCurrency();
if (! $sellable) {
Log::warning('No contract opened: the catalogue cannot sell this order.', [
'order_id' => $order->id, 'plan' => $order->plan, 'currency' => $order->currency,
]);
return;
}
($this->openSubscription)($order);
} catch (Throwable $e) {
Log::error('Failed to open a contract for a paid order.', [
'order_id' => $order->id, 'plan' => $order->plan, 'error' => $e->getMessage(),
]);
}
}
/** /**
* Find or create the customer, race-safe against the unique email index so a * Find or create the customer, race-safe against the unique email index so a
* concurrent first-time purchase can't create two customers for one email. * concurrent first-time purchase can't create two customers for one email.

View File

@ -0,0 +1,99 @@
<?php
namespace App\Console\Commands;
use App\Models\PlanFamily;
use App\Models\PlanVersion;
use App\Models\Subscription;
use Illuminate\Console\Command;
/**
* Tells the owner whether the catalogue is in a state that can actually sell.
*
* Everything here is computed at read time, which means a broken window or a
* missing price does not announce itself until a customer hits it. This is the
* thing to run after editing plans before finding out from a failed checkout.
*/
class CheckPlanCatalogue extends Command
{
protected $signature = 'plans:check';
protected $description = 'Check the plan catalogue for overlaps, gaps and missing prices';
public function handle(): int
{
$currency = Subscription::catalogueCurrency();
$problems = [];
$now = now();
$families = PlanFamily::query()->with('versions.prices')->orderBy('tier')->get();
if ($families->isEmpty()) {
$this->error('The catalogue is empty. Nothing can be sold.');
return self::FAILURE;
}
foreach ($families as $family) {
$live = $family->versions->filter(fn (PlanVersion $v) => $v->isAvailableAt($now));
if ($live->count() > 1) {
$problems[] = "{$family->key}: {$live->count()} versions on sale at once (".
$live->pluck('version')->implode(', ').') — overlapping windows.';
}
if ($family->sales_enabled && $live->isEmpty()) {
$problems[] = "{$family->key}: on sale, but no version is available right now.";
}
foreach ($family->versions as $version) {
if (! $version->isPublished()) {
continue;
}
foreach ([Subscription::TERM_MONTHLY, Subscription::TERM_YEARLY] as $term) {
$priced = $version->prices
->first(fn ($p) => $p->term === $term && $p->currency === $currency);
if ($priced === null) {
$problems[] = "{$family->key} v{$version->version}: no {$term} price in {$currency}.";
}
}
}
}
// A contract that cannot say which version it was sold under has lost
// its provenance, which is the one thing the version table is for.
$orphaned = Subscription::query()->whereNull('plan_version_id')->count();
if ($orphaned > 0) {
$problems[] = "{$orphaned} subscription(s) have no plan version recorded.";
}
foreach ($families as $family) {
$state = $family->sales_enabled ? 'on sale' : 'withdrawn';
$version = $family->versions->first(fn (PlanVersion $v) => $v->isAvailableAt($now));
$this->line(sprintf(
' %-12s tier %d %-10s %s',
$family->key,
$family->tier,
$state,
$version !== null ? "v{$version->version}" : '—',
));
}
if ($problems === []) {
$this->newLine();
$this->info('Catalogue is consistent.');
return self::SUCCESS;
}
$this->newLine();
foreach ($problems as $problem) {
$this->error(' '.$problem);
}
return self::FAILURE;
}
}

View File

@ -7,15 +7,36 @@ use App\Provisioning\Jobs\AdvanceRunJob;
/** /**
* Scheduler tick (every minute): dispatch an advance job for every run that is * Scheduler tick (every minute): dispatch an advance job for every run that is
* running/waiting and due. Immediate dispatch on advance handles the fast path; * due. Immediate dispatch on advance handles the fast path; this catches
* this catches waiting/retrying runs and anything a crashed worker left behind. * waiting/retrying runs and anything a crashed worker left behind.
*
* PENDING counts as left behind. A run is created and dispatched in two steps,
* and a process that dies between them leaves a paid customer with a run that
* nothing will ever pick up. Re-dispatching one that is merely fresh is free
* the runner takes a per-run lock and the second job returns immediately.
*/ */
class TickProvisioning class TickProvisioning
{ {
/**
* How long a run may sit pending before it counts as stranded.
*
* Long enough that an ordinary queue backlog is not mistaken for a crash:
* sweeping a run whose first job is merely still queued would start a
* second chain of continuations alongside the first, and both would keep
* dispatching for the rest of the pipeline.
*/
private const STRANDED_AFTER_MINUTES = 5;
public function __invoke(): void public function __invoke(): void
{ {
ProvisioningRun::query() ProvisioningRun::query()
->where(function ($q) {
$q
->whereIn('status', [ProvisioningRun::STATUS_RUNNING, ProvisioningRun::STATUS_WAITING]) ->whereIn('status', [ProvisioningRun::STATUS_RUNNING, ProvisioningRun::STATUS_WAITING])
->orWhere(fn ($stranded) => $stranded
->where('status', ProvisioningRun::STATUS_PENDING)
->where('created_at', '<=', now()->subMinutes(self::STRANDED_AFTER_MINUTES)));
})
->where(function ($q) { ->where(function ($q) {
$q->whereNull('next_attempt_at')->orWhere('next_attempt_at', '<=', now()); $q->whereNull('next_attempt_at')->orWhere('next_attempt_at', '<=', now());
}) })

View File

@ -56,6 +56,9 @@ class StripeWebhookController extends Controller
'name' => $object['customer_details']['name'] ?? ($meta['name'] ?? null), 'name' => $object['customer_details']['name'] ?? ($meta['name'] ?? null),
'stripe_customer_id' => $object['customer'] ?? null, 'stripe_customer_id' => $object['customer'] ?? null,
'plan' => $meta['plan'] ?? 'start', 'plan' => $meta['plan'] ?? 'start',
// What the customer was actually shown. Absent on a session created
// before phase 5 put it there, and then the version on sale applies.
'plan_version_id' => isset($meta['plan_version_id']) ? (int) $meta['plan_version_id'] : null,
'datacenter' => $meta['datacenter'] ?? 'fsn', 'datacenter' => $meta['datacenter'] ?? 'fsn',
'amount_cents' => (int) ($object['amount_total'] ?? $object['amount'] ?? 0), 'amount_cents' => (int) ($object['amount_total'] ?? $object['amount'] ?? 0),
'currency' => strtoupper($object['currency'] ?? 'eur'), 'currency' => strtoupper($object['currency'] ?? 'eur'),

View File

@ -3,6 +3,8 @@
namespace App\Livewire\Admin; namespace App\Livewire\Admin;
use App\Models\Customer; use App\Models\Customer;
use App\Models\PlanFamily;
use App\Services\Billing\PlanCatalogue;
use Illuminate\Support\Number; use Illuminate\Support\Number;
use Livewire\Attributes\Layout; use Livewire\Attributes\Layout;
use Livewire\Component; use Livewire\Component;
@ -35,17 +37,26 @@ class Customers extends Component
public function render() public function render()
{ {
$locale = app()->getLocale(); $locale = app()->getLocale();
$plans = config('provisioning.plans'); $plans = app(PlanCatalogue::class)->sellable();
$customers = Customer::query() $customers = Customer::query()
->with('instances') ->with(['instances.subscription'])
->orderBy('name') ->orderBy('name')
->get(); ->get();
$rows = $customers->map(function (Customer $c) use ($plans, $locale) { $rows = $customers->map(function (Customer $c) use ($plans, $locale) {
$instance = $c->instances->sortByDesc('id')->first(); $instance = $c->instances->sortByDesc('id')->first();
$planKey = $instance->plan ?? null; $planKey = $instance->plan ?? null;
$priceCents = $planKey !== null ? ($plans[$planKey]['price_cents'] ?? 0) : 0; // What this customer actually pays, off their contract — not what
// the plan costs today. Otherwise a price rise inflates reported
// revenue for every grandfathered customer overnight.
//
// Per MONTH, whatever the term: a yearly contract stores the whole
// year, and adding that to a monthly column would report twelve
// times the revenue for anyone who paid up front.
$contract = $instance?->subscription;
$priceCents = (int) ($contract?->monthlyPriceCents()
?? ($planKey !== null ? ($plans[$planKey]['price_cents'] ?? 0) : 0));
return [ return [
'uuid' => $c->uuid, 'uuid' => $c->uuid,
@ -65,10 +76,13 @@ class Customers extends Component
]; ];
})->all(); })->all();
// Plan distribution for the doughnut — derived from live instances. // Plan distribution for the doughnut — derived from live instances, and
// keyed by what customers are actually ON, not by what is on sale. A
// withdrawn plan still has customers, and dropping them from the chart
// would quietly understate the estate.
$labels = []; $labels = [];
$counts = []; $counts = [];
foreach (array_keys($plans) as $planKey) { foreach (PlanFamily::query()->orderBy('tier')->pluck('key') as $planKey) {
$n = $customers->filter(fn (Customer $c) => $c->instances->contains('plan', $planKey))->count(); $n = $customers->filter(fn (Customer $c) => $c->instances->contains('plan', $planKey))->count();
if ($n > 0) { if ($n > 0) {
$labels[] = __('billing.plan.'.$planKey); $labels[] = __('billing.plan.'.$planKey);

View File

@ -5,6 +5,8 @@ namespace App\Livewire;
use App\Livewire\Concerns\ResolvesCustomer; use App\Livewire\Concerns\ResolvesCustomer;
use App\Models\Customer; use App\Models\Customer;
use App\Models\Order; use App\Models\Order;
use App\Models\Subscription;
use App\Services\Billing\PlanCatalogue;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Livewire\Attributes\Layout; use Livewire\Attributes\Layout;
use Livewire\Component; use Livewire\Component;
@ -27,7 +29,7 @@ class Billing extends Component
$instance = $customer->instances()->latest('id')->first(); $instance = $customer->instances()->latest('id')->first();
$currentPlan = $instance?->plan ?? 'start'; $currentPlan = $instance?->plan ?? 'start';
$plans = (array) config('provisioning.plans'); $plans = app(PlanCatalogue::class)->sellable();
$addons = (array) config('provisioning.addons'); $addons = (array) config('provisioning.addons');
[$plan, $amount, $addonKey] = match ($type) { [$plan, $amount, $addonKey] = match ($type) {
@ -40,7 +42,11 @@ class Billing extends Component
// Guard against invalid keys / non-upgrades. // Guard against invalid keys / non-upgrades.
$valid = match ($type) { $valid = match ($type) {
'upgrade' => isset($plans[$key]) && ($plans[$key]['price_cents'] ?? 0) > ($plans[$currentPlan]['price_cents'] ?? 0), // By rank, matching PlanChange and the cards on the page. Comparing
// prices would call a grandfathered plan an upgrade to a smaller
// one and charge for the privilege.
'upgrade' => isset($plans[$key])
&& (int) ($plans[$key]['tier'] ?? 0) > (int) ($instance?->subscription?->tier ?? $plans[$currentPlan]['tier'] ?? 0),
'storage' => true, 'storage' => true,
// Always available: running out of traffic is exactly when someone // Always available: running out of traffic is exactly when someone
// needs to be able to buy more, whatever plan they are on. // needs to be able to buy more, whatever plan they are on.
@ -101,24 +107,59 @@ class Billing extends Component
$this->dispatch('notify', message: __('billing.cart.removed')); $this->dispatch('notify', message: __('billing.cart.removed'));
} }
/**
* The terms to show as "your plan": the contract if there is one, and only
* otherwise the catalogue someone browsing before they have bought.
*
* @param array<string, mixed> $catalogue
* @return array<string, mixed>
*/
private function currentTerms(?Subscription $subscription, array $catalogue): array
{
if ($subscription === null) {
return $catalogue;
}
return [
'tier' => $subscription->tier,
// Per month: the card says "/ month", and a yearly contract stores
// the whole year.
'price_cents' => $subscription->monthlyPriceCents(),
'currency' => $subscription->currency,
'quota_gb' => $subscription->quota_gb,
'traffic_gb' => $subscription->traffic_gb,
'seats' => $subscription->seats,
'performance' => $subscription->performance,
'features' => $subscription->planVersion?->features ?? ($catalogue['features'] ?? []),
];
}
public function render() public function render()
{ {
$customer = $this->customer(); $customer = $this->customer();
$instance = $customer?->instances()->latest('id')->first(); $instance = $customer?->instances()->latest('id')->first();
$plans = (array) config('provisioning.plans'); $plans = app(PlanCatalogue::class)->sellable();
$currentKey = $instance?->plan ?? 'start'; $currentKey = $instance?->plan ?? 'start';
$currentPrice = (int) ($plans[$currentKey]['price_cents'] ?? 0);
// Rank, not price: a grandfathered plan can cost less than a smaller
// one does today, and offering that as an "upgrade" would charge a
// customer immediately for losing resources.
$currentTier = (int) ($instance?->subscription?->tier ?? $plans[$currentKey]['tier'] ?? 0);
$upgrades = collect($plans) $upgrades = collect($plans)
->filter(fn ($p, $k) => (int) ($p['price_cents'] ?? 0) > $currentPrice) ->filter(fn ($p) => (int) ($p['tier'] ?? 0) > $currentTier)
->keys()->all(); ->keys()->all();
return view('livewire.billing', [ return view('livewire.billing', [
'currentKey' => $currentKey, 'currentKey' => $currentKey,
'current' => $plans[$currentKey] ?? [], // What they HAVE comes from their contract; only what they could
// BUY comes from the shop. Reading this off the catalogue showed a
// customer today's price as though it were theirs, and dropped the
// card entirely once their plan stopped being sold.
'current' => $this->currentTerms($instance?->subscription, $plans[$currentKey] ?? []),
'instance' => $instance, 'instance' => $instance,
'plans' => $plans, 'plans' => $plans,
'features' => (array) config('provisioning.plan_features'), 'features' => collect($plans)->map(fn ($p) => $p['features'] ?? [])->all(),
'upgrades' => $upgrades, 'upgrades' => $upgrades,
'storage' => (array) config('provisioning.storage_addon'), 'storage' => (array) config('provisioning.storage_addon'),
'trafficAddon' => (array) config('provisioning.traffic.addon'), 'trafficAddon' => (array) config('provisioning.traffic.addon'),

View File

@ -26,10 +26,12 @@ class Cloud extends Component
->latest('id')->first(); ->latest('id')->first();
$maintenance = MaintenanceWindow::forInstance($shown)->first(); $maintenance = MaintenanceWindow::forInstance($shown)->first();
$plans = (array) config('provisioning.plans'); // The instance carries what was actually provisioned, and the contract
$planKey = $shown->plan ?? 'team'; // what was bought. Neither is the catalogue, which only describes what
$plan = $plans[$planKey] ?? []; // we would sell someone new — never what this customer already has.
$quota = (int) ($shown->quota_gb ?? ($plan['quota_gb'] ?? 500)); $contract = $shown?->subscription;
$planKey = $shown?->plan ?? 'team';
$quota = (int) ($shown?->quota_gb ?? $contract?->quota_gb ?? 500);
// Usage metering is not wired yet — scale the illustrative curve into the // Usage metering is not wired yet — scale the illustrative curve into the
// instance's quota so the chart and the "x / y GB" label never disagree. // instance's quota so the chart and the "x / y GB" label never disagree.
$curveMax = max($growth); $curveMax = max($growth);
@ -47,15 +49,17 @@ class Cloud extends Component
'status' => $shown->status ?? 'active', 'status' => $shown->status ?? 'active',
'plan' => __('cloud.plan_line', [ 'plan' => __('cloud.plan_line', [
'plan' => 'CluPilot Cloud '.__('billing.plan.'.$planKey), 'plan' => 'CluPilot Cloud '.__('billing.plan.'.$planKey),
'price' => (int) round(($plan['price_cents'] ?? 0) / 100), // The line ends in "/mo", so a yearly contract has to be
// divided down before it goes in.
'price' => (int) round(($contract?->monthlyPriceCents() ?? 0) / 100),
]), ]),
'location' => __('cloud.datacenter'), 'location' => __('cloud.datacenter'),
'version' => 'Nextcloud 31.0.4', 'version' => 'Nextcloud 31.0.4',
'php' => 'PHP 8.3 · MariaDB 11.4', 'php' => 'PHP 8.3 · MariaDB 11.4',
'storageUsed' => $used, 'storageUsed' => $used,
'storageQuota' => $quota, 'storageQuota' => $quota,
'seats' => __('billing.seats_count', ['count' => $plan['seats'] ?? 0]), 'seats' => __('billing.seats_count', ['count' => $contract?->seats ?? 0]),
'performance' => __('billing.perf.'.($plan['performance'] ?? 'standard')), 'performance' => __('billing.perf.'.($contract?->performance ?? 'standard')),
], ],
'storageChart' => [ 'storageChart' => [
'type' => 'line', 'type' => 'line',

View File

@ -175,9 +175,10 @@ class Users extends Component
// failed/deprovisioned record. // failed/deprovisioned record.
$instance = $customer->instances()->whereIn('status', ['active', 'cancellation_scheduled'])->latest('id')->first() $instance = $customer->instances()->whereIn('status', ['active', 'cancellation_scheduled'])->latest('id')->first()
?? $customer->instances()->latest('id')->first(); ?? $customer->instances()->latest('id')->first();
$plan = $instance->plan ?? 'start'; // From the contract: how many people a customer may invite is part of
// what they bought. Cutting a plan's seats in the catalogue must not
return (int) config("provisioning.plans.$plan.seats", 5); // lock users out of an existing customer's cloud.
return (int) ($instance?->subscription?->seats ?? 5);
} }
private function seat(string $uuid): ?Seat private function seat(string $uuid): ?Seat

View File

@ -17,13 +17,13 @@ class Order extends Model implements ProvisioningSubject
use HasFactory, HasUuid; use HasFactory, HasUuid;
protected $fillable = [ protected $fillable = [
'customer_id', 'plan', 'type', 'addon_key', 'amount_cents', 'currency', 'datacenter', 'customer_id', 'plan', 'plan_version_id', 'type', 'addon_key', 'amount_cents', 'currency',
'stripe_event_id', 'status', 'datacenter', 'stripe_event_id', 'status',
]; ];
protected function casts(): array protected function casts(): array
{ {
return ['amount_cents' => 'integer']; return ['amount_cents' => 'integer', 'plan_version_id' => 'integer'];
} }
/** /**

94
app/Models/PlanFamily.php Normal file
View File

@ -0,0 +1,94 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Carbon;
use RuntimeException;
/**
* A plan line the thing that has a name.
*
* "Team" stays Team through every price change and every resize. Orders,
* instances and contracts have referred to plans by `key` since day one, so the
* key is the one part of a family that must never move.
*/
class PlanFamily extends Model
{
use HasFactory;
use HasUuids;
protected $guarded = [];
protected static function booted(): void
{
static::updating(function (self $family) {
// Orders, instances and every subscription snapshot store this
// string. Renaming it does not rename those — it orphans them, and
// a checkout in flight for this family stops matching its own
// version. Display names are what the `name` column is for.
if ($family->isDirty('key')) {
throw new RuntimeException(
'A plan family key is permanent; existing orders and contracts refer to it by name. '.
'Change the display name instead.'
);
}
});
static::deleting(function (self $family) {
if ($family->versions()->whereNotNull('published_at')->exists()) {
throw new RuntimeException(
'A plan family with published versions cannot be deleted; customers are contracted to them. '.
'Switch sales off instead — it disappears from the shop and stays on record.'
);
}
// Only drafts left. The foreign key restricts, so they have to go
// explicitly — and nothing was ever promised on a draft.
$family->versions->each->delete();
});
}
protected function casts(): array
{
return [
'tier' => 'integer',
'sales_enabled' => 'boolean',
];
}
public function uniqueIds(): array
{
return ['uuid'];
}
public function versions(): HasMany
{
return $this->hasMany(PlanVersion::class);
}
/**
* The version on sale at a given moment, or null.
*
* Resolved with sole(): two overlapping windows are a mistake we want to
* hear about, not one we want silently resolved by whichever row the
* database happened to return first.
*
* @throws \Illuminate\Database\MultipleRecordsFoundException on overlap
*/
public function versionAt(?Carbon $at = null): ?PlanVersion
{
$query = $this->versions()->available($at);
return $query->count() === 0 ? null : $query->sole();
}
/** On sale right now: not killed by the switch, and a version is running. */
public function isSellableAt(?Carbon $at = null): bool
{
return $this->sales_enabled && $this->versionAt($at) !== null;
}
}

85
app/Models/PlanPrice.php Normal file
View File

@ -0,0 +1,85 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use RuntimeException;
/**
* What a version costs for one term.
*
* One row per version AND term, each with its own Stripe Price id a Stripe
* Price carries its own recurring interval, so monthly and yearly can never
* share one. Amounts are NET; what is actually charged depends on the
* customer's tax treatment.
*
* Frozen with its version. Repricing means publishing a new version, which is
* what Stripe requires anyway a Stripe Price is immutable, so a rise mints a
* new one and existing subscriptions keep the old.
*
* The reason is not tidiness. A checkout is not instant: the session opens, the
* customer types their card details, the webhook arrives. An amount edited in
* that gap would contract them at a price they were never quoted, while their
* payment carries the old one. Only `stripe_price_id` stays writable, because
* it is filled in after the fact.
*/
class PlanPrice extends Model
{
use HasFactory;
use HasUuids;
/** What publishing the version nails down. */
public const FROZEN = ['plan_version_id', 'term', 'amount_cents', 'currency'];
protected $guarded = [];
protected static function booted(): void
{
static::updating(function (self $price) {
// A draft is still a draft: nothing has been quoted to anyone.
if ($price->version?->isPublished() !== true) {
return;
}
$frozen = array_intersect(array_keys($price->getDirty()), self::FROZEN);
if ($frozen !== []) {
throw new RuntimeException(
'The price of a published plan version is fixed; tried to change: '.implode(', ', $frozen).
'. Publish a new version instead — someone may be at the checkout looking at this one.'
);
}
});
static::deleting(function (self $price) {
// Same loophole as editing. Remove the price of a version someone is
// checking out on, and their payment lands on a plan that can no
// longer be priced — the order is recorded, no contract opens, and
// they are left paid-for and unprovisioned.
if ($price->version?->isPublished() === true) {
throw new RuntimeException(
'The price of a published plan version cannot be deleted. '.
'Close the version\'s availability window instead.'
);
}
});
}
protected function casts(): array
{
return ['amount_cents' => 'integer'];
}
public function uniqueIds(): array
{
return ['uuid'];
}
public function version(): BelongsTo
{
return $this->belongsTo(PlanVersion::class, 'plan_version_id');
}
}

167
app/Models/PlanVersion.php Normal file
View File

@ -0,0 +1,167 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
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\Support\Carbon;
use RuntimeException;
/**
* What a plan WAS at a point in time.
*
* A customer buys a version, not a name. Once a version is published it is
* fixed: publication is a promise made in public, and it is made whether or not
* anyone has bought yet which is why the lock is on publication and not on
* the first sale. Selling different terms means publishing a new version, and
* the old one keeps describing what its customers are owed.
*
* The availability WINDOW is not frozen. Rescheduling when a version is sold
* changes nothing about what it promises, and the owner has to be able to move
* a launch date without rewriting the plan.
*/
class PlanVersion extends Model
{
use HasFactory;
use HasUuids;
/** Everything publication nails down. */
public const FROZEN = [
'plan_family_id', 'version', 'quota_gb', 'traffic_gb', 'seats', 'ram_mb',
'cores', 'disk_gb', 'performance', 'template_vmid', 'features', 'published_at',
];
protected $guarded = [];
protected static function booted(): void
{
static::updating(function (self $version) {
// A draft is still a draft: nothing has been promised to anyone.
if ($version->getOriginal('published_at') === null) {
return;
}
$frozen = array_intersect(array_keys($version->getDirty()), self::FROZEN);
if ($frozen !== []) {
throw new RuntimeException(
'A published plan version is immutable; tried to change: '.implode(', ', $frozen).
'. Publish a new version instead — the customers on this one bought these terms.'
);
}
});
static::deleting(function (self $version) {
// Deleting is the loophole that would undo the rest. Contracts and
// orders point here to record what was sold, and the foreign keys
// null out on delete — so removing a published version quietly
// erases the provenance of every customer on it, and can leave an
// in-flight checkout resolving against whatever replaced it.
if ($version->isPublished()) {
throw new RuntimeException(
'A published plan version cannot be deleted; customers are contracted to it. '.
'Close its availability window instead — it stops being sold and stays on record.'
);
}
});
}
protected function casts(): array
{
return [
'version' => 'integer',
'quota_gb' => 'integer',
'traffic_gb' => 'integer',
'seats' => 'integer',
'ram_mb' => 'integer',
'cores' => 'integer',
'disk_gb' => 'integer',
'template_vmid' => 'integer',
'features' => 'array',
'available_from' => 'datetime',
'available_until' => 'datetime',
'published_at' => 'datetime',
];
}
public function uniqueIds(): array
{
return ['uuid'];
}
public function family(): BelongsTo
{
return $this->belongsTo(PlanFamily::class, 'plan_family_id');
}
public function prices(): HasMany
{
return $this->hasMany(PlanPrice::class);
}
/**
* Published, and inside its window at `$at`.
*
* Half-open [from, until): `until` is the first moment the version is no
* longer sold, so a successor starting exactly then leaves neither a gap
* nor an overlap.
*/
public function scopeAvailable(Builder $query, ?Carbon $at = null): Builder
{
$at ??= now();
return $query
->whereNotNull('published_at')
->where('available_from', '<=', $at)
->where(fn (Builder $open) => $open
->whereNull('available_until')
->orWhere('available_until', '>', $at));
}
public function isAvailableAt(?Carbon $at = null): bool
{
$at ??= now();
return $this->published_at !== null
&& $this->available_from->lessThanOrEqualTo($at)
&& ($this->available_until === null || $this->available_until->greaterThan($at));
}
public function isPublished(): bool
{
return $this->published_at !== null;
}
public function priceFor(string $term, ?string $currency = null): ?PlanPrice
{
return $this->prices()
->where('term', $term)
->where('currency', $currency ?? Subscription::catalogueCurrency())
->first();
}
/**
* The capabilities, in the shape the rest of the app already speaks.
*
* @return array<string, mixed>
*/
public function capabilities(): array
{
return [
'tier' => (int) $this->family->tier,
'quota_gb' => $this->quota_gb,
'traffic_gb' => $this->traffic_gb,
'seats' => $this->seats,
'ram_mb' => $this->ram_mb,
'cores' => $this->cores,
'disk_gb' => $this->disk_gb,
'performance' => $this->performance,
'template_vmid' => $this->template_vmid,
'features' => $this->features ?? [],
];
}
}

View File

@ -2,6 +2,7 @@
namespace App\Models; namespace App\Models;
use App\Services\Billing\PlanCatalogue;
use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
@ -23,7 +24,7 @@ class Subscription extends Model
* new subscriptions, never to existing ones. * new subscriptions, never to existing ones.
*/ */
public const FROZEN = [ public const FROZEN = [
'plan', 'term', 'price_cents', 'currency', 'quota_gb', 'traffic_gb', 'plan', 'plan_version_id', 'term', 'price_cents', 'currency', 'quota_gb', 'traffic_gb',
'seats', 'ram_mb', 'cores', 'disk_gb', 'performance', 'tier', 'started_at', 'seats', 'ram_mb', 'cores', 'disk_gb', 'performance', 'tier', 'started_at',
]; ];
@ -78,6 +79,12 @@ class Subscription extends Model
return $this->belongsTo(Order::class); return $this->belongsTo(Order::class);
} }
/** Which version of the plan this contract was sold under. */
public function planVersion(): BelongsTo
{
return $this->belongsTo(PlanVersion::class, 'plan_version_id');
}
public function instance(): BelongsTo public function instance(): BelongsTo
{ {
return $this->belongsTo(Instance::class); return $this->belongsTo(Instance::class);
@ -86,7 +93,7 @@ class Subscription extends Model
/** Whether the catalogue still sells this plan at all. */ /** Whether the catalogue still sells this plan at all. */
public static function knowsPlan(string $plan): bool public static function knowsPlan(string $plan): bool
{ {
return (array) config("provisioning.plans.{$plan}") !== []; return app(PlanCatalogue::class)->isSellable($plan);
} }
/** The single currency every catalogue price is expressed in. */ /** The single currency every catalogue price is expressed in. */
@ -95,43 +102,55 @@ class Subscription extends Model
return strtoupper((string) config('provisioning.currency', 'EUR')); 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 * Take the version on sale right now and freeze it onto a new subscription.
*
* Reads the catalogue tables, never config: there is one source, and it is
* the one the owner can actually edit. An unknown, withdrawn or unpriced
* plan throws rather than returning something plausible a contract opened
* from a guess is worse than a purchase that visibly fails.
*/
public static function snapshotFrom(string $plan, string $term = self::TERM_MONTHLY, ?int $versionId = null): array
{ {
$catalogue = (array) config("provisioning.plans.{$plan}");
if ($catalogue === []) {
throw new RuntimeException("Unknown plan: {$plan}");
}
// A typo would otherwise be priced monthly while carrying an unknown // A typo would otherwise be priced monthly while carrying an unknown
// term — a subscription whose price and billing period disagree. // term — a subscription whose price and billing period disagree.
if (! in_array($term, [self::TERM_MONTHLY, self::TERM_YEARLY], true)) { if (! in_array($term, [self::TERM_MONTHLY, self::TERM_YEARLY], true)) {
throw new RuntimeException("Unknown term: {$term}"); throw new RuntimeException("Unknown term: {$term}");
} }
$monthly = (int) ($catalogue['price_cents'] ?? 0); // Honour the version the customer was actually shown, even if it has
// stopped being sold since — a checkout that completes just after a
// scheduled transition must not contract them to terms they never saw.
$version = $versionId !== null
? app(PlanCatalogue::class)->soldVersion($plan, $versionId)
: app(PlanCatalogue::class)->currentVersion($plan);
$price = $version->priceFor($term);
if ($price === null) {
throw new RuntimeException("Plan '{$plan}' is not sold on a {$term} term.");
}
return [ return [
'plan' => $plan, 'plan' => $plan,
// Which version this contract was sold under. A historical
// reference resolved by plan NAME would hand back today's terms.
'plan_version_id' => $version->id,
'term' => $term, 'term' => $term,
// A yearly term is twelve months of the same price. Any discount 'price_cents' => $price->amount_cents,
// belongs in the catalogue, not hidden in this conversion. 'currency' => $price->currency,
'price_cents' => $term === self::TERM_YEARLY ? $monthly * 12 : $monthly, 'quota_gb' => $version->quota_gb,
'currency' => self::catalogueCurrency(), 'traffic_gb' => $version->traffic_gb,
'quota_gb' => (int) ($catalogue['quota_gb'] ?? 0), 'seats' => $version->seats,
'traffic_gb' => (int) ($catalogue['traffic_gb'] ?? 0), 'ram_mb' => $version->ram_mb,
'seats' => (int) ($catalogue['seats'] ?? 0), 'cores' => $version->cores,
'ram_mb' => (int) ($catalogue['ram_mb'] ?? 0), 'disk_gb' => $version->disk_gb,
'cores' => (int) ($catalogue['cores'] ?? 0), 'performance' => $version->performance,
'disk_gb' => (int) ($catalogue['disk_gb'] ?? 0),
'performance' => $catalogue['performance'] ?? null,
// Copied so provisioning never has to ask the catalogue what to // Copied so provisioning never has to ask the catalogue what to
// clone. Not in FROZEN — see the migration for why. // clone. Not in FROZEN — see the migration for why.
'template_vmid' => isset($catalogue['template_vmid']) ? (int) $catalogue['template_vmid'] : null, 'template_vmid' => $version->template_vmid,
// Frozen too: which direction a later change goes must not depend // Frozen too: which direction a later change goes must not depend
// on where the plan sits in today's catalogue. // on where the plan sits in today's catalogue.
'tier' => (int) ($catalogue['tier'] ?? 0), 'tier' => (int) $version->family->tier,
]; ];
} }
@ -139,4 +158,19 @@ class Subscription extends Model
{ {
return $this->term === self::TERM_YEARLY; return $this->term === self::TERM_YEARLY;
} }
/**
* What this contract works out to per month.
*
* `price_cents` is the price of a TERM, and a yearly term holds the whole
* year. Everything that prints a monthly figure the plan card, the cloud
* page, the revenue column has to divide, and every one of them getting
* it right separately is not something to rely on.
*/
public function monthlyPriceCents(): int
{
return $this->isYearly()
? (int) round($this->price_cents / 12)
: (int) $this->price_cents;
}
} }

View File

@ -0,0 +1,315 @@
<?php
namespace App\Services\Billing;
use App\Models\PlanFamily;
use App\Models\PlanVersion;
use App\Models\Subscription;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use RuntimeException;
/**
* The one place the plan catalogue is read.
*
* There is deliberately **no fallback to config**. "Use the DB, and config if
* the DB has no row" would resurrect a plan the owner had just switched off,
* and would leave commerce reading one source while provisioning read another
* the exact split-brain this whole rebuild exists to close. An empty catalogue
* is an outage, and it should look like one.
*
* Availability is computed here, on every read. Nothing schedules a plan into
* or out of sale, because a job that fails to run is a plan that silently
* misbehaves, and "the launch didn't happen because a worker was down" is not
* something you can tell a customer.
*/
final class PlanCatalogue
{
/**
* Every plan on sale right now, keyed by family key, in the array shape the
* app has always used.
*
* @return array<string, array<string, mixed>>
*/
public function sellable(?Carbon $at = null): array
{
$at ??= now();
$currency = Subscription::catalogueCurrency();
return PlanFamily::query()
->where('sales_enabled', true)
->with(['versions' => fn ($q) => $q->available($at)->with('prices')])
->orderBy('tier')
->get()
->mapWithKeys(function (PlanFamily $family) use ($currency) {
// sole() semantics, kept here so a caller listing the shop hits
// the same loud failure as a caller resolving one plan.
if ($family->versions->count() > 1) {
throw new RuntimeException(
"Plan '{$family->key}' has {$family->versions->count()} versions on sale at once. ".
'Overlapping availability windows must be fixed before anything can be sold.'
);
}
$version = $family->versions->first();
if ($version === null) {
return [];
}
// Every supported term, or the plan is not shown at all. Listing
// one that is priced monthly but not yearly would let a
// customer pick it, pay, and land on a contract that cannot be
// opened — the shop and the checkout must agree on this.
$priced = $this->requiredPrices($version, $currency);
if ($priced === null) {
return [];
}
$monthly = $priced[Subscription::TERM_MONTHLY];
return [$family->key => array_merge($version->capabilities(), [
'name' => $family->name,
'price_cents' => $monthly->amount_cents,
'currency' => $monthly->currency,
'plan_version_id' => $version->id,
])];
})
->all();
}
/**
* The version a purchase of this family right now would be sold under.
*
* Fails closed and loudly: an unknown or unsold plan throws, and so does an
* overlap. Picking one of two overlapping versions would decide a customer's
* terms by row order.
*/
public function currentVersion(string $familyKey, ?Carbon $at = null): PlanVersion
{
$family = PlanFamily::query()->where('key', $familyKey)->first();
if ($family === null) {
throw new RuntimeException("Unknown plan: {$familyKey}");
}
if (! $family->sales_enabled) {
throw new RuntimeException("Plan '{$familyKey}' is not on sale.");
}
try {
$version = $family->versions()->available($at)->sole();
} catch (ModelNotFoundException) {
throw new RuntimeException("Plan '{$familyKey}' has no version on sale.");
}
$version->setRelation('family', $family);
return $version;
}
/**
* Whether a purchase of this family could actually be completed right now.
*
* Pricing is part of the question, not a detail left to the checkout. A
* version whose price row has been deleted is still inside its window, and
* answering "yes" here would send an already-paid webhook into a contract
* it cannot open.
*/
public function isSellable(string $familyKey, ?Carbon $at = null): bool
{
try {
$version = $this->currentVersion($familyKey, $at);
} catch (RuntimeException) {
return false;
}
return $this->requiredPrices($version, Subscription::catalogueCurrency()) !== null;
}
/**
* Every term we sell on, priced in this currency or null if any is
* missing.
*
* The single definition of "this version can actually be bought", so the
* shop, the checkout and the consistency command can never disagree about
* which plans are real.
*
* @return array<string, \App\Models\PlanPrice>|null
*/
private function requiredPrices(PlanVersion $version, string $currency): ?array
{
$found = [];
foreach ([Subscription::TERM_MONTHLY, Subscription::TERM_YEARLY] as $term) {
// relationLoaded() so a listing that eager-loaded prices does not
// fire a query per plan per term.
$price = $version->relationLoaded('prices')
? $version->prices->first(fn ($p) => $p->term === $term && $p->currency === $currency)
: $version->priceFor($term, $currency);
if ($price === null) {
return null;
}
$found[$term] = $price;
}
return $found;
}
/**
* The exact version a customer was sold, checked against the plan they
* bought.
*
* Used when the purchase carried its own version a checkout that started
* before a scheduled transition and finished after it. A version that has
* closed is still honoured, because the customer saw and paid for it; one
* that was never published is not, because nothing was ever promised.
*/
public function soldVersion(string $familyKey, int $versionId): PlanVersion
{
$version = $this->version($versionId);
if ($version->family->key !== $familyKey) {
throw new RuntimeException(
"Version {$versionId} belongs to '{$version->family->key}', not to '{$familyKey}'."
);
}
if (! $version->isPublished()) {
throw new RuntimeException("Version {$versionId} was never published.");
}
return $version;
}
/**
* Whether a contract can still be opened on a version a customer was quoted.
*
* Deliberately NOT the same question as "is this plan on sale". A checkout
* that began while the version was available is owed that version, even if
* the window has closed or the owner has withdrawn the plan since they
* paid for what they were shown. Only realness and pricing matter here.
*/
public function isDeliverable(string $familyKey, int $versionId): bool
{
try {
$version = $this->soldVersion($familyKey, $versionId);
} catch (RuntimeException|ModelNotFoundException) {
return false;
}
return $this->requiredPrices($version, Subscription::catalogueCurrency()) !== null;
}
/**
* A historical reference, resolved by version id never by family key.
*
* Looking a past contract up by name would hand back today's terms, which
* is the same mistake the snapshot exists to prevent, one level up.
*/
public function version(int $id): PlanVersion
{
return PlanVersion::query()->with('family')->findOrFail($id);
}
/**
* Move a version's availability window.
*
* Under a lock on the family, because two admins scheduling at the same
* moment would each see a clean check and both commit leaving two
* versions on sale and every read of that family throwing.
*/
public function schedule(PlanVersion $version, Carbon $from, ?Carbon $until = null): PlanVersion
{
if ($until !== null && $until->lessThanOrEqualTo($from)) {
throw new RuntimeException('A plan cannot stop being sold before it starts.');
}
return DB::transaction(function () use ($version, $from, $until) {
PlanFamily::query()->whereKey($version->plan_family_id)->lockForUpdate()->firstOrFail();
$clash = PlanVersion::query()
->where('plan_family_id', $version->plan_family_id)
->whereKeyNot($version->getKey())
// Only published versions can clash: a draft is not on sale, so
// its provisional window must not block the owner from
// rescheduling the version that customers can actually buy.
->whereNotNull('published_at')
// Half-open overlap: a starts before b ends AND b starts before
// a ends. A null end is "never ends".
->where(fn ($q) => $q
->whereNull('available_until')
->orWhere('available_until', '>', $from))
->when($until !== null, fn ($q) => $q->where('available_from', '<', $until))
->exists();
if ($clash) {
throw new RuntimeException(
'That window overlaps another version of this plan. Two versions on sale at once '.
'would leave the price a customer pays decided by row order.'
);
}
// Written by query, not by save(): a model handed to us may carry
// unsaved edits, and rescheduling a window must never be the thing
// that quietly persists a change to what the plan promises.
PlanVersion::query()->whereKey($version->getKey())->update([
'available_from' => $from,
'available_until' => $until,
'updated_at' => now(),
]);
return $version->refresh();
});
}
/**
* Publish a draft: lock its capabilities and put it on sale.
*
* Publication is the promise. From here the version describes what its
* customers are owed, and changing it would rewrite their contract.
*/
public function publish(PlanVersion $version, ?Carbon $from = null, ?Carbon $until = null): PlanVersion
{
// Re-read before deciding anything. A previous attempt that was rolled
// back leaves this object claiming a publication the database never
// kept, and the owner would then be told a draft they can still see is
// already published.
$version->refresh();
if ($version->isPublished()) {
throw new RuntimeException('That version is already published.');
}
$currency = Subscription::catalogueCurrency();
// Both terms, or neither. A version priced only yearly passes as
// sellable and then fails at the checkout of anyone who picks monthly —
// and once published its capabilities are frozen, so the mistake cannot
// simply be edited away.
foreach ([Subscription::TERM_MONTHLY, Subscription::TERM_YEARLY] as $term) {
$priced = $version->prices()->where('term', $term)->where('currency', $currency)->exists();
if (! $priced) {
throw new RuntimeException("A version cannot go on sale without a {$term} price in {$currency}.");
}
}
// Publication and scheduling together, or not at all. Publishing first
// and then failing the overlap check would leave the version frozen but
// unscheduled — and publish() refuses it from then on, so nothing short
// of a manual repair could rescue it.
return DB::transaction(function () use ($version, $from, $until) {
PlanVersion::query()->whereKey($version->getKey())->update([
'published_at' => now(),
'updated_at' => now(),
]);
return $this->schedule($version->refresh(), $from ?? now(), $until);
});
}
}

View File

@ -40,17 +40,15 @@ 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 * The allowance is the contract's, not the catalogue's: cutting a plan's
* catalogue: cutting a plan's traffic must not start throttling someone who * traffic must not start throttling someone who bought the larger one. An
* bought the larger allowance. Instances provisioned before contracts * instance with no contract has nothing to meter against and is treated as
* existed have none, and for those the catalogue is still the only answer * unmetered rather than as exhausted a machine we cannot price should not
* that fallback goes away once Phase 2 backfills them. * be throttled on a guess.
*/ */
public static function quotaGb(Instance $instance): int public static function quotaGb(Instance $instance): int
{ {
$plan = (int) ($instance->subscription?->traffic_gb $plan = (int) ($instance->subscription?->traffic_gb ?? 0);
?? config("provisioning.plans.{$instance->plan}.traffic_gb")
?? 0);
$addonGb = (int) config('provisioning.traffic.addon.gb', 1000); $addonGb = (int) config('provisioning.traffic.addon.gb', 1000);
return $plan + ($instance->traffic_addons ?? 0) * $addonGb; return $plan + ($instance->traffic_addons ?? 0) * $addonGb;

View File

@ -67,16 +67,21 @@ return [
// contract without the contract and the payment disagreeing forever. // contract without the contract and the payment disagreeing forever.
'currency' => env('CLUPILOT_CURRENCY', 'EUR'), '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 | The plan catalogue does NOT live here any more. It lives in
// ADMIN-ONLY. Customers compare seats, storage, performance class and | plan_families / plan_versions / plan_prices, because the owner has to be
// features — never raw vCPU/RAM (see docs/specs/2026-07-25-portal-d-*). | able to create and schedule plans from the console, and because a config
'plans' => [ | array cannot hold a version's history.
'start' => ['tier' => 1, 'traffic_gb' => 1000, 'quota_gb' => 100, 'disk_gb' => 120, 'ram_mb' => 4096, 'cores' => 2, 'seats' => 5, 'performance' => 'standard', 'price_cents' => 4900, 'template_vmid' => 9000], |
'team' => ['tier' => 2, 'traffic_gb' => 3000, 'quota_gb' => 500, 'disk_gb' => 540, 'ram_mb' => 8192, 'cores' => 4, 'seats' => 25, 'performance' => 'enhanced', 'price_cents' => 17900, 'template_vmid' => 9000], | Nothing falls back to a plans array here. A fallback would resurrect a
'business' => ['tier' => 3, 'traffic_gb' => 8000, 'quota_gb' => 1000, 'disk_gb' => 1050, 'ram_mb' => 16384, 'cores' => 8, 'seats' => 100, 'performance' => 'high', 'price_cents' => 39900, 'template_vmid' => 9000], | plan the owner had just switched off, and would let commerce read one
'enterprise' => ['tier' => 4, 'traffic_gb' => 20000, 'quota_gb' => 2000, 'disk_gb' => 2100, 'ram_mb' => 32768, 'cores' => 16, 'seats' => 500, 'performance' => 'dedicated', 'price_cents' => 79900, 'template_vmid' => 9000], | source while provisioning read another the split-brain this replaced.
], | See App\Services\Billing\PlanCatalogue.
|
| quota_gb/disk_gb/ram_mb/cores drive infrastructure placement and stay
| ADMIN-ONLY. Customers compare seats, storage, performance class and
| features never raw vCPU/RAM (see docs/specs/2026-07-25-portal-d-*).
*/
// Default branding applied when a customer has not set their own (resolved by // Default branding applied when a customer has not set their own (resolved by
// Customer::brandingResolved(); NULL customer fields fall back to these). // Customer::brandingResolved(); NULL customer fields fall back to these).
@ -125,14 +130,9 @@ return [
'collabora_pro' => ['price_cents' => 1900], 'collabora_pro' => ['price_cents' => 1900],
], ],
// Customer-facing feature bullets per plan (labels in lang/*/billing.php → // Feature bullets moved onto plan_versions.features, so that what a version
// billing.feature.<key>). Cumulative tiers: each plan lists its own bullets. // promises is frozen with the version that promised it. Labels still live in
'plan_features' => [ // lang/*/billing.php → billing.feature.<key>.
'start' => ['managed_updates', 'daily_backups', 'monitoring', 'subdomain'],
'team' => ['managed_updates', 'daily_backups', 'monitoring', 'subdomain', 'office', 'branding', 'priority_support'],
'business' => ['managed_updates', 'daily_backups', 'monitoring', 'custom_domain', 'office', 'branding', 'priority_support', 'extended_retention', 'audit_log'],
'enterprise' => ['managed_updates', 'daily_backups', 'monitoring', 'custom_domain', 'office', 'branding', 'premium_sla', 'extended_retention', 'audit_log', 'onboarding'],
],
'dns' => [ 'dns' => [
'provider' => 'hetzner', 'provider' => 'hetzner',

View File

@ -18,6 +18,19 @@ use Illuminate\Support\Str;
*/ */
return new class extends Migration return new class extends Migration
{ {
/**
* The catalogue as it stood when this migration was written. Inlined, not
* read from config: the plans array moved into its own tables one migration
* later, and a migration that reads config stops working the moment config
* moves on. These are the terms the orders being backfilled were sold under.
*/
private const CATALOGUE_AT_THE_TIME = [
'start' => ['tier' => 1, 'traffic_gb' => 1000, 'quota_gb' => 100, 'disk_gb' => 120, 'ram_mb' => 4096, 'cores' => 2, 'seats' => 5, 'performance' => 'standard', 'price_cents' => 4900, 'template_vmid' => 9000],
'team' => ['tier' => 2, 'traffic_gb' => 3000, 'quota_gb' => 500, 'disk_gb' => 540, 'ram_mb' => 8192, 'cores' => 4, 'seats' => 25, 'performance' => 'enhanced', 'price_cents' => 17900, 'template_vmid' => 9000],
'business' => ['tier' => 3, 'traffic_gb' => 8000, 'quota_gb' => 1000, 'disk_gb' => 1050, 'ram_mb' => 16384, 'cores' => 8, 'seats' => 100, 'performance' => 'high', 'price_cents' => 39900, 'template_vmid' => 9000],
'enterprise' => ['tier' => 4, 'traffic_gb' => 20000, 'quota_gb' => 2000, 'disk_gb' => 2100, 'ram_mb' => 32768, 'cores' => 16, 'seats' => 500, 'performance' => 'dedicated', 'price_cents' => 79900, 'template_vmid' => 9000],
];
public function up(): void public function up(): void
{ {
Schema::table('subscriptions', function (Blueprint $table) { Schema::table('subscriptions', function (Blueprint $table) {
@ -63,7 +76,7 @@ return new class extends Migration
*/ */
private function backfillContracts(): void private function backfillContracts(): void
{ {
$plans = (array) config('provisioning.plans'); $plans = self::CATALOGUE_AT_THE_TIME;
$currency = strtoupper((string) config('provisioning.currency', 'EUR')); $currency = strtoupper((string) config('provisioning.currency', 'EUR'));
$orders = DB::table('orders') $orders = DB::table('orders')

View File

@ -0,0 +1,217 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
/**
* The plan catalogue, moved out of config/provisioning.php and into three
* tables because one table cannot hold three different lifetimes.
*
* - `plan_families` is the thing that has a NAME. "Team" is still Team after
* five price changes, and everything that points at a plan by name points
* here.
* - `plan_versions` is what a plan WAS at a point in time. Frozen from the
* moment it is published, because a customer bought a version, not a name.
* - `plan_prices` is per version AND term, each with its own Stripe Price id:
* a Stripe Price carries its own recurring interval, so monthly and yearly
* can never share one.
*
* Availability is computed when read `available_from <= now < available_until`,
* half-open, UTC never flipped by a scheduled job. What does not run cannot
* fail to run, and a plan that goes on sale because a worker happened to be up
* is not a plan you can promise anyone.
*/
return new class extends Migration
{
/**
* Today's catalogue, inlined rather than read from config: this migration
* has to seed the same values in five years, whatever config says by then.
*/
private const SEED = [
'start' => ['tier' => 1, 'traffic_gb' => 1000, 'quota_gb' => 100, 'disk_gb' => 120, 'ram_mb' => 4096, 'cores' => 2, 'seats' => 5, 'performance' => 'standard', 'price_cents' => 4900, 'template_vmid' => 9000,
'features' => ['managed_updates', 'daily_backups', 'monitoring', 'subdomain']],
'team' => ['tier' => 2, 'traffic_gb' => 3000, 'quota_gb' => 500, 'disk_gb' => 540, 'ram_mb' => 8192, 'cores' => 4, 'seats' => 25, 'performance' => 'enhanced', 'price_cents' => 17900, 'template_vmid' => 9000,
'features' => ['managed_updates', 'daily_backups', 'monitoring', 'subdomain', 'office', 'branding', 'priority_support']],
'business' => ['tier' => 3, 'traffic_gb' => 8000, 'quota_gb' => 1000, 'disk_gb' => 1050, 'ram_mb' => 16384, 'cores' => 8, 'seats' => 100, 'performance' => 'high', 'price_cents' => 39900, 'template_vmid' => 9000,
'features' => ['managed_updates', 'daily_backups', 'monitoring', 'custom_domain', 'office', 'branding', 'priority_support', 'extended_retention', 'audit_log']],
'enterprise' => ['tier' => 4, 'traffic_gb' => 20000, 'quota_gb' => 2000, 'disk_gb' => 2100, 'ram_mb' => 32768, 'cores' => 16, 'seats' => 500, 'performance' => 'dedicated', 'price_cents' => 79900, 'template_vmid' => 9000,
'features' => ['managed_updates', 'daily_backups', 'monitoring', 'custom_domain', 'office', 'branding', 'premium_sla', 'extended_retention', 'audit_log', 'onboarding']],
];
public function up(): void
{
Schema::create('plan_families', function (Blueprint $table) {
$table->id();
$table->uuid()->unique();
// The stable handle. Orders, instances and contracts have referred
// to plans by this string since day one; it must not change.
$table->string('key')->unique();
$table->string('name');
// Rank, not price, decides whether a plan change is an upgrade — a
// grandfathered plan can cost less than a smaller one does today.
$table->unsignedTinyInteger('tier');
// The kill switch: stop selling this plan NOW, without editing a
// window and without waiting for a job. Existing customers keep
// their contract; the plan simply disappears from the shop.
$table->boolean('sales_enabled')->default(true);
$table->string('stripe_product_id')->nullable();
$table->timestamps();
$table->index('tier');
});
Schema::create('plan_versions', function (Blueprint $table) {
$table->id();
$table->uuid()->unique();
// Restrict, not cascade: a cascade would delete published versions
// without any model ever seeing it, and null the plan_version_id off
// every contract that pointed at them. Removing a family means
// clearing its drafts first, and a family with customers on it
// cannot be removed at all.
$table->foreignId('plan_family_id')->constrained()->restrictOnDelete();
$table->unsignedInteger('version');
// What the plan can do. Frozen from publication — see PlanVersion.
$table->unsignedBigInteger('quota_gb');
$table->unsignedBigInteger('traffic_gb');
$table->unsignedInteger('seats');
$table->unsignedInteger('ram_mb');
$table->unsignedInteger('cores');
$table->unsignedBigInteger('disk_gb');
$table->string('performance')->nullable();
$table->unsignedInteger('template_vmid')->nullable();
// Which feature bullets this version puts in front of a customer.
$table->json('features');
// Half-open [from, until): until is the first moment it is NO
// longer sold, so a successor starting exactly then leaves no gap
// and no overlap. Null means open-ended.
$table->timestamp('available_from');
$table->timestamp('available_until')->nullable();
// Publication, not the first sale, is what locks the capabilities:
// a plan that was announced has been promised to someone, whether
// or not anyone has bought it yet.
$table->timestamp('published_at')->nullable();
$table->timestamps();
$table->unique(['plan_family_id', 'version']);
$table->index(['plan_family_id', 'available_from']);
});
Schema::create('plan_prices', function (Blueprint $table) {
$table->id();
$table->uuid()->unique();
$table->foreignId('plan_version_id')->constrained()->cascadeOnDelete();
$table->string('term'); // monthly|yearly
$table->unsignedInteger('amount_cents'); // NET, per term
$table->char('currency', 3);
// A Stripe Price is immutable and carries its own interval, so this
// belongs to the row, not to the family.
$table->string('stripe_price_id')->nullable();
$table->timestamps();
$table->unique(['plan_version_id', 'term', 'currency']);
});
$this->seedFromTodaysCatalogue();
}
public function down(): void
{
Schema::dropIfExists('plan_prices');
Schema::dropIfExists('plan_versions');
Schema::dropIfExists('plan_families');
}
/**
* Version 1 of every plan we sell today, published and on sale from now,
* open-ended. Idempotent, so a re-run after a rollback adopts what is
* already there instead of duplicating it.
*/
private function seedFromTodaysCatalogue(): void
{
$currency = strtoupper((string) config('provisioning.currency', 'EUR'));
$now = now();
foreach (self::SEED as $key => $plan) {
$familyId = DB::table('plan_families')->where('key', $key)->value('id');
if ($familyId === null) {
$familyId = DB::table('plan_families')->insertGetId([
'uuid' => (string) Str::uuid(),
'key' => $key,
'name' => ucfirst($key),
'tier' => $plan['tier'],
'sales_enabled' => true,
'created_at' => $now,
'updated_at' => $now,
]);
}
$versionId = DB::table('plan_versions')
->where('plan_family_id', $familyId)->where('version', 1)->value('id');
if ($versionId === null) {
$versionId = DB::table('plan_versions')->insertGetId([
'uuid' => (string) Str::uuid(),
'plan_family_id' => $familyId,
'version' => 1,
'quota_gb' => $plan['quota_gb'],
'traffic_gb' => $plan['traffic_gb'],
'seats' => $plan['seats'],
'ram_mb' => $plan['ram_mb'],
'cores' => $plan['cores'],
'disk_gb' => $plan['disk_gb'],
'performance' => $plan['performance'],
'template_vmid' => $plan['template_vmid'],
'features' => json_encode($plan['features']),
// Backdated so a contract opened a moment ago still falls
// inside the window it was sold under.
'available_from' => $now->copy()->subYear(),
'available_until' => null,
'published_at' => $now->copy()->subYear(),
'created_at' => $now,
'updated_at' => $now,
]);
}
// Yearly is twelve months of the same price. Any discount is a
// decision the owner makes in the catalogue, never arithmetic
// hidden in a conversion.
foreach (['monthly' => $plan['price_cents'], 'yearly' => $plan['price_cents'] * 12] as $term => $amount) {
$exists = DB::table('plan_prices')
->where('plan_version_id', $versionId)
->where('term', $term)
->where('currency', $currency)
->exists();
if ($exists) {
continue;
}
DB::table('plan_prices')->insert([
'uuid' => (string) Str::uuid(),
'plan_version_id' => $versionId,
'term' => $term,
'amount_cents' => $amount,
'currency' => $currency,
'created_at' => $now,
'updated_at' => $now,
]);
}
}
}
};

View File

@ -0,0 +1,49 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* Records WHICH version a contract was sold under.
*
* The plan key is not enough and never was: "team" in 2026 and "team" in 2029
* are different promises. A historical reference resolved by family key would
* hand back today's terms, which is the same mistake the snapshot exists to
* prevent one level up. So every contract points at the exact version.
*
* The snapshot on the subscription stays authoritative for what the customer is
* owed; this is the provenance, not a lookup to read terms through.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('subscriptions', function (Blueprint $table) {
$table->foreignId('plan_version_id')->nullable()->after('plan')
->constrained()->nullOnDelete();
});
// Existing contracts were sold under version 1 — the only version there
// has ever been, seeded from the catalogue they were sold from.
$versions = DB::table('plan_versions')
->join('plan_families', 'plan_families.id', '=', 'plan_versions.plan_family_id')
->where('plan_versions.version', 1)
->pluck('plan_versions.id', 'plan_families.key');
foreach ($versions as $key => $versionId) {
DB::table('subscriptions')
->whereNull('plan_version_id')
->where('plan', $key)
->update(['plan_version_id' => $versionId]);
}
}
public function down(): void
{
Schema::table('subscriptions', function (Blueprint $table) {
$table->dropConstrainedForeignId('plan_version_id');
});
}
};

View File

@ -0,0 +1,36 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Which version the customer was actually looking at when they paid.
*
* A checkout is not instant: the session is created, the customer types their
* card details, the webhook arrives. If a scheduled version transition happens
* in that gap, resolving the plan by name at webhook time would contract them
* to terms they never saw a different price, a different quota while their
* payment was for the old ones.
*
* Stripe carries this through checkout metadata (phase 5). Until it does, the
* column stays null and the webhook resolves the version on sale, which is the
* behaviour it had anyway.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('orders', function (Blueprint $table) {
$table->foreignId('plan_version_id')->nullable()->after('plan')
->constrained()->nullOnDelete();
});
}
public function down(): void
{
Schema::table('orders', function (Blueprint $table) {
$table->dropConstrainedForeignId('plan_version_id');
});
}
};

View File

@ -0,0 +1,79 @@
<?php
use App\Livewire\Admin\Customers;
use App\Models\Customer;
use App\Models\Host;
use App\Models\Instance;
use App\Models\PlanFamily;
use App\Models\Subscription;
use App\Models\User;
use Livewire\Livewire;
/**
* The console reports what customers actually pay. Their contract is the only
* thing that knows it the catalogue describes what we would sell someone new.
*/
function customerOnPlan(string $plan, string $term = 'monthly'): Customer
{
$customer = Customer::factory()->create();
$instance = Instance::factory()->create([
'customer_id' => $customer->id,
'host_id' => Host::factory()->active()->create()->id,
'plan' => $plan,
'status' => 'active',
]);
Subscription::factory()->plan($plan, $term)->create([
'customer_id' => $customer->id,
'instance_id' => $instance->id,
]);
return $customer;
}
/** The console row for one customer — the factories leave others lying around. */
function mrrOf(Customer $customer): string
{
$admin = User::factory()->operator()->create();
$rows = Livewire::actingAs($admin)->test(Customers::class)->viewData('rows');
return collect($rows)->firstWhere('uuid', $customer->uuid)['mrr'];
}
it('reports a yearly contract as what it costs per month', function () {
$customer = customerOnPlan('team', 'yearly');
// The contract stores 12 × 179 €. Reporting that in a monthly column would
// show this customer as worth twelve times what they are.
expect(mrrOf($customer))->toContain('179')
->and(mrrOf($customer))->not->toContain('2.148');
});
it('reports what a grandfathered customer pays, not today\'s price', function () {
$customer = customerOnPlan('team');
Subscription::query()->where('customer_id', $customer->id)->update(['price_cents' => 9900]);
expect(mrrOf($customer))->toContain('99,00');
});
it('shows a yearly customer their monthly rate on the portal too', function () {
$customer = customerOnPlan('team', 'yearly');
$user = User::factory()->create(['email' => $customer->email]);
// Both pages label the figure per month, and the contract holds a year.
$card = Livewire::actingAs($user)->test(App\Livewire\Billing::class)->viewData('current');
$cloud = Livewire::actingAs($user)->test(App\Livewire\Cloud::class)->viewData('instance');
expect($card['price_cents'])->toBe(17900)
->and($cloud['plan'])->toContain('179');
});
it('keeps customers on a withdrawn plan in the distribution', function () {
customerOnPlan('team');
PlanFamily::query()->where('key', 'team')->update(['sales_enabled' => false]);
$admin = User::factory()->operator()->create();
$chart = Livewire::actingAs($admin)->test(Customers::class)->viewData('plansChart');
// They are still running, still paying, and still ours to count.
expect($chart['data']['labels'])->toContain(__('billing.plan.team'));
});

View File

@ -0,0 +1,340 @@
<?php
use App\Models\PlanFamily;
use App\Models\PlanPrice;
use App\Models\PlanVersion;
use App\Models\Subscription;
use App\Services\Billing\PlanCatalogue;
use Illuminate\Database\MultipleRecordsFoundException;
/**
* The catalogue moved out of config and into three tables. This is the shadow
* comparison the move was supposed to survive: the values the seeded catalogue
* serves are pinned literally, so a migration that quietly changes what we sell
* fails here rather than in a customer's invoice.
*/
/** What config/provisioning.php sold, before the catalogue became a table. */
const CATALOGUE_BEFORE_THE_MOVE = [
'start' => ['tier' => 1, 'traffic_gb' => 1000, 'quota_gb' => 100, 'disk_gb' => 120, 'ram_mb' => 4096, 'cores' => 2, 'seats' => 5, 'performance' => 'standard', 'price_cents' => 4900, 'template_vmid' => 9000],
'team' => ['tier' => 2, 'traffic_gb' => 3000, 'quota_gb' => 500, 'disk_gb' => 540, 'ram_mb' => 8192, 'cores' => 4, 'seats' => 25, 'performance' => 'enhanced', 'price_cents' => 17900, 'template_vmid' => 9000],
'business' => ['tier' => 3, 'traffic_gb' => 8000, 'quota_gb' => 1000, 'disk_gb' => 1050, 'ram_mb' => 16384, 'cores' => 8, 'seats' => 100, 'performance' => 'high', 'price_cents' => 39900, 'template_vmid' => 9000],
'enterprise' => ['tier' => 4, 'traffic_gb' => 20000, 'quota_gb' => 2000, 'disk_gb' => 2100, 'ram_mb' => 32768, 'cores' => 16, 'seats' => 500, 'performance' => 'dedicated', 'price_cents' => 79900, 'template_vmid' => 9000],
];
it('sells exactly what the config catalogue sold', function () {
$sellable = app(PlanCatalogue::class)->sellable();
expect(array_keys($sellable))->toBe(array_keys(CATALOGUE_BEFORE_THE_MOVE));
foreach (CATALOGUE_BEFORE_THE_MOVE as $key => $expected) {
foreach ($expected as $field => $value) {
expect($sellable[$key][$field])->toBe($value, "{$key}.{$field}");
}
}
});
it('freezes the same snapshot the config catalogue would have frozen', function () {
foreach (CATALOGUE_BEFORE_THE_MOVE as $key => $expected) {
$snapshot = Subscription::snapshotFrom($key);
expect($snapshot['price_cents'])->toBe($expected['price_cents'])
->and($snapshot['ram_mb'])->toBe($expected['ram_mb'])
->and($snapshot['cores'])->toBe($expected['cores'])
->and($snapshot['disk_gb'])->toBe($expected['disk_gb'])
->and($snapshot['quota_gb'])->toBe($expected['quota_gb'])
->and($snapshot['traffic_gb'])->toBe($expected['traffic_gb'])
->and($snapshot['seats'])->toBe($expected['seats'])
->and($snapshot['tier'])->toBe($expected['tier'])
->and($snapshot['template_vmid'])->toBe($expected['template_vmid'])
->and($snapshot['plan_version_id'])->not->toBeNull();
// Yearly is twelve months of the same price until the owner says
// otherwise — and it comes from its own row, not from arithmetic.
expect(Subscription::snapshotFrom($key, 'yearly')['price_cents'])
->toBe($expected['price_cents'] * 12);
}
});
it('hides a plan the shop cannot price for every term it sells', function () {
$catalogue = app(PlanCatalogue::class);
// Forced past the guard, the way a bad repair script would.
PlanPrice::query()->whereKey($catalogue->currentVersion('team')->priceFor('yearly')->id)->delete();
// The shop and the checkout have to agree, or a customer picks a plan,
// pays, and lands on a contract that cannot be opened.
expect($catalogue->isSellable('team'))->toBeFalse()
->and(array_keys($catalogue->sellable()))->not->toContain('team')
->and(array_keys($catalogue->sellable()))->toContain('start');
});
it('records which version a contract was sold under', function () {
$snapshot = Subscription::snapshotFrom('team');
$version = app(PlanCatalogue::class)->version($snapshot['plan_version_id']);
expect($version->family->key)->toBe('team')
->and($version->version)->toBe(1);
});
it('contracts a checkout to the version it was shown, not the one that replaced it', function () {
$catalogue = app(PlanCatalogue::class);
$shown = $catalogue->currentVersion('team');
$currency = Subscription::catalogueCurrency();
// The customer is looking at v1 when the owner's scheduled swap lands.
$catalogue->schedule($shown, $shown->available_from, now());
$successor = PlanVersion::query()->create([
...$shown->only(['plan_family_id', 'quota_gb', 'traffic_gb', 'seats', 'ram_mb', 'cores', 'disk_gb', 'performance', 'template_vmid']),
'version' => 2, 'features' => $shown->features, 'available_from' => now(),
]);
$successor->prices()->create(['term' => 'monthly', 'amount_cents' => 24900, 'currency' => $currency]);
$successor->prices()->create(['term' => 'yearly', 'amount_cents' => 298800, 'currency' => $currency]);
$catalogue->publish($successor, now());
// Their payment arrives after the swap. They pay what they were quoted.
$snapshot = Subscription::snapshotFrom('team', 'monthly', $shown->id);
expect($snapshot['price_cents'])->toBe(17900)
->and($snapshot['plan_version_id'])->toBe($shown->id);
// Someone arriving now gets the successor.
expect(Subscription::snapshotFrom('team')['price_cents'])->toBe(24900);
});
it('fixes the price of a published version, so a checkout cannot be repriced mid-flight', function () {
$price = app(PlanCatalogue::class)->currentVersion('team')->priceFor('monthly');
expect(fn () => $price->update(['amount_cents' => 24900]))
->toThrow(RuntimeException::class, 'fixed');
expect($price->fresh()->amount_cents)->toBe(17900);
// Stripe's id is filled in afterwards, so that stays writable. Re-read
// first: the refused edit is still sitting on the rejected object, and the
// guard would rightly stop it riding along on the next save.
$price->refresh()->update(['stripe_price_id' => 'price_123']);
expect($price->fresh()->stripe_price_id)->toBe('price_123')
->and($price->fresh()->amount_cents)->toBe(17900);
});
it('refuses to delete a published version or its price, but lets a draft go', function () {
$published = app(PlanCatalogue::class)->currentVersion('team');
expect(fn () => $published->delete())->toThrow(RuntimeException::class, 'cannot be deleted')
->and(fn () => $published->priceFor('monthly')->delete())->toThrow(RuntimeException::class, 'cannot be deleted');
// Contracts point at the version to record what was sold; deleting it would
// null that away and leave a customer contracted to nothing.
$subscription = Subscription::factory()->plan('team')->create();
expect($subscription->plan_version_id)->toBe($published->id);
$draft = PlanVersion::query()->create([
...$published->only(['plan_family_id', 'quota_gb', 'traffic_gb', 'seats', 'ram_mb', 'cores', 'disk_gb', 'performance', 'template_vmid']),
'version' => 9, 'features' => [], 'available_from' => now()->addYear(),
]);
// Nothing was ever promised on a draft, so it can simply go.
expect($draft->delete())->toBeTrue();
});
it('will not let a plan family be renamed or deleted out from under its customers', function () {
$family = PlanFamily::query()->where('key', 'team')->sole();
// Orders, instances and every snapshot store this string.
expect(fn () => $family->update(['key' => 'teams']))->toThrow(RuntimeException::class, 'permanent');
// Deleting would cascade past the version guard and null the provenance
// off every contract on it.
expect(fn () => $family->fresh()->delete())->toThrow(RuntimeException::class, 'cannot be deleted');
expect(PlanFamily::query()->where('key', 'team')->exists())->toBeTrue();
// The display name is what is meant to change.
$family->fresh()->update(['name' => 'Team Pro']);
expect(PlanFamily::query()->where('key', 'team')->sole()->name)->toBe('Team Pro');
});
it('lets a family that never sold anything be removed with its drafts', function () {
$family = PlanFamily::query()->create(['key' => 'trial', 'name' => 'Trial', 'tier' => 0]);
$draft = $family->versions()->create([
'version' => 1, 'quota_gb' => 10, 'traffic_gb' => 100, 'seats' => 1, 'ram_mb' => 1024,
'cores' => 1, 'disk_gb' => 20, 'performance' => 'standard', 'features' => [],
'available_from' => now(),
]);
expect($family->delete())->toBeTrue()
->and(PlanVersion::query()->whereKey($draft->id)->exists())->toBeFalse();
});
it('still honours a quoted version after the plan is withdrawn', function () {
$catalogue = app(PlanCatalogue::class);
$quoted = $catalogue->currentVersion('team');
// The owner pulls Team while a customer is at the checkout.
PlanFamily::query()->where('key', 'team')->update(['sales_enabled' => false]);
expect($catalogue->isSellable('team'))->toBeFalse()
// They saw it and paid for it, so it is still deliverable to them.
->and($catalogue->isDeliverable('team', $quoted->id))->toBeTrue()
->and(Subscription::snapshotFrom('team', 'monthly', $quoted->id)['price_cents'])->toBe(17900);
});
it('will not contract a customer to a version of a different plan', function () {
$startVersion = app(PlanCatalogue::class)->currentVersion('start');
expect(fn () => Subscription::snapshotFrom('team', 'monthly', $startVersion->id))
->toThrow(RuntimeException::class, 'belongs to');
});
it('refuses to sell a plan the owner has switched off', function () {
PlanFamily::query()->where('key', 'team')->update(['sales_enabled' => false]);
expect(app(PlanCatalogue::class)->isSellable('team'))->toBeFalse()
->and(array_keys(app(PlanCatalogue::class)->sellable()))->not->toContain('team');
// Fails closed rather than falling back to anything.
expect(fn () => Subscription::snapshotFrom('team'))->toThrow(RuntimeException::class);
});
it('does not sell a version before its window opens or after it closes', function () {
$catalogue = app(PlanCatalogue::class);
$version = $catalogue->currentVersion('team');
$catalogue->schedule($version, now()->addWeek(), now()->addMonth());
expect($catalogue->isSellable('team'))->toBeFalse()
->and($catalogue->isSellable('team', now()->addWeek()))->toBeTrue()
// Half-open: the closing moment is the first at which it is gone.
->and($catalogue->isSellable('team', now()->addMonth()))->toBeFalse()
->and($catalogue->isSellable('team', now()->addMonth()->subSecond()))->toBeTrue();
});
it('crashes instead of choosing between two versions on sale at once', function () {
$family = PlanFamily::query()->where('key', 'team')->sole();
$first = $family->versions()->sole();
// Forced past the guard, the way a bad migration or a direct edit would.
PlanVersion::query()->create([
...$first->only(['plan_family_id', 'quota_gb', 'traffic_gb', 'seats', 'ram_mb', 'cores', 'disk_gb', 'performance', 'template_vmid']),
'version' => 2,
'features' => $first->features,
'available_from' => now()->subDay(),
'available_until' => null,
'published_at' => now()->subDay(),
]);
expect(fn () => app(PlanCatalogue::class)->currentVersion('team'))
->toThrow(MultipleRecordsFoundException::class);
});
it('refuses a window that overlaps another version of the same plan', function () {
$catalogue = app(PlanCatalogue::class);
$family = PlanFamily::query()->where('key', 'team')->sole();
$first = $catalogue->currentVersion('team');
// Close the running version, then publish a successor after it.
$catalogue->schedule($first, $first->available_from, now()->addMonth());
$second = PlanVersion::query()->create([
...$first->only(['plan_family_id', 'quota_gb', 'traffic_gb', 'seats', 'ram_mb', 'cores', 'disk_gb', 'performance', 'template_vmid']),
'version' => 2,
'features' => $first->features,
'available_from' => now()->addMonth(),
]);
$second->prices()->create([
'term' => 'monthly', 'amount_cents' => 19900, 'currency' => Subscription::catalogueCurrency(),
]);
$second->prices()->create([
'term' => 'yearly', 'amount_cents' => 238800, 'currency' => Subscription::catalogueCurrency(),
]);
$catalogue->publish($second, now()->addMonth());
// Backdating the successor into its predecessor's window is refused.
expect(fn () => $catalogue->schedule($second, now()->addWeek()))
->toThrow(RuntimeException::class);
// Starting exactly where the predecessor stops is not an overlap.
$catalogue->schedule($second, now()->addMonth());
expect($catalogue->currentVersion('team', now()->addMonth())->version)->toBe(2);
});
it('locks a version once it is published, but still lets its window move', function () {
$version = app(PlanCatalogue::class)->currentVersion('team');
expect(fn () => $version->update(['ram_mb' => 4096]))->toThrow(RuntimeException::class);
// The refused edit is not smuggled through by the next write either.
app(PlanCatalogue::class)->schedule($version, $version->available_from, now()->addYear());
expect($version->fresh()->available_until)->not->toBeNull()
->and($version->fresh()->ram_mb)->toBe(8192);
});
it('lets an unpublished draft be edited freely', function () {
$family = PlanFamily::query()->where('key', 'team')->sole();
$draft = PlanVersion::query()->create([
'plan_family_id' => $family->id, 'version' => 2,
'quota_gb' => 600, 'traffic_gb' => 3000, 'seats' => 30, 'ram_mb' => 8192,
'cores' => 4, 'disk_gb' => 640, 'performance' => 'enhanced', 'template_vmid' => 9000,
'features' => ['monitoring'], 'available_from' => now()->addMonth(),
]);
$draft->update(['ram_mb' => 12288]);
expect($draft->fresh()->ram_mb)->toBe(12288);
});
it('will not publish a version that is not priced for every term', function () {
$family = PlanFamily::query()->where('key', 'team')->sole();
$draft = PlanVersion::query()->create([
'plan_family_id' => $family->id, 'version' => 3,
'quota_gb' => 600, 'traffic_gb' => 3000, 'seats' => 30, 'ram_mb' => 8192,
'cores' => 4, 'disk_gb' => 640, 'performance' => 'enhanced', 'template_vmid' => 9000,
'features' => [], 'available_from' => now(),
]);
expect(fn () => app(PlanCatalogue::class)->publish($draft))->toThrow(RuntimeException::class);
// Yearly alone is not enough: someone would pick monthly and hit a
// checkout that cannot price them.
$draft->prices()->create(['term' => 'yearly', 'amount_cents' => 214800, 'currency' => Subscription::catalogueCurrency()]);
expect(fn () => app(PlanCatalogue::class)->publish($draft))->toThrow(RuntimeException::class, 'monthly');
});
it('leaves a version unpublished when its window is refused', function () {
$catalogue = app(PlanCatalogue::class);
$family = PlanFamily::query()->where('key', 'team')->sole();
$currency = Subscription::catalogueCurrency();
$draft = PlanVersion::query()->create([
'plan_family_id' => $family->id, 'version' => 4,
'quota_gb' => 600, 'traffic_gb' => 3000, 'seats' => 30, 'ram_mb' => 8192,
'cores' => 4, 'disk_gb' => 640, 'performance' => 'enhanced', 'template_vmid' => 9000,
'features' => [], 'available_from' => now(),
]);
$draft->prices()->create(['term' => 'monthly', 'amount_cents' => 18900, 'currency' => $currency]);
$draft->prices()->create(['term' => 'yearly', 'amount_cents' => 226800, 'currency' => $currency]);
// Straight into the running version's window.
expect(fn () => $catalogue->publish($draft, now()))->toThrow(RuntimeException::class, 'overlaps');
// Still a draft, so the mistake is simply correctable — publishing first
// and failing after would have frozen it beyond repair.
expect($draft->fresh()->isPublished())->toBeFalse()
->and($draft->fresh()->available_from->toDateString())->toBe(now()->toDateString());
$current = $catalogue->currentVersion('team');
$catalogue->schedule($current, $current->available_from, now()->addWeek());
$catalogue->publish($draft, now()->addWeek());
expect($catalogue->currentVersion('team', now()->addWeek())->version)->toBe(4);
});
it('reports a consistent catalogue, and an inconsistent one', function () {
$this->artisan('plans:check')->assertSuccessful();
PlanVersion::query()->whereKey(app(PlanCatalogue::class)->currentVersion('team')->id)
->update(['available_until' => now()->subDay()]);
$this->artisan('plans:check')->assertFailed();
});

View File

@ -8,7 +8,21 @@ it('freezes what the customer bought against later price rises', function () {
$subscription = Subscription::factory()->plan('team')->create(); $subscription = Subscription::factory()->plan('team')->create();
$bookedPrice = $subscription->price_cents; $bookedPrice = $subscription->price_cents;
config()->set('provisioning.plans.team.price_cents', $bookedPrice * 2); // The owner doubles what Team costs, which means publishing a new version:
// the price of a published one is fixed, because someone may be standing at
// the checkout looking at it.
$catalogue = app(App\Services\Billing\PlanCatalogue::class);
$current = $catalogue->currentVersion('team');
$currency = Subscription::catalogueCurrency();
$catalogue->schedule($current, $current->available_from, now());
$dearer = App\Models\PlanVersion::query()->create([
...$current->only(['plan_family_id', 'quota_gb', 'traffic_gb', 'seats', 'ram_mb', 'cores', 'disk_gb', 'performance', 'template_vmid']),
'version' => 2, 'features' => $current->features, 'available_from' => now(),
]);
$dearer->prices()->create(['term' => 'monthly', 'amount_cents' => $bookedPrice * 2, 'currency' => $currency]);
$dearer->prices()->create(['term' => 'yearly', 'amount_cents' => $bookedPrice * 24, 'currency' => $currency]);
$catalogue->publish($dearer, now());
// The existing customer keeps their terms — that is the entire point. // The existing customer keeps their terms — that is the entire point.
expect($subscription->fresh()->price_cents)->toBe($bookedPrice) expect($subscription->fresh()->price_cents)->toBe($bookedPrice)

View File

@ -20,10 +20,15 @@ it('binds the cloud card to the real instance', function () {
$user = \App\Models\User::factory()->create(['email' => 'c@cloud.test']); $user = \App\Models\User::factory()->create(['email' => 'c@cloud.test']);
$customer = \App\Models\Customer::factory()->create(['email' => 'c@cloud.test', 'user_id' => $user->id, 'name' => 'Acme AG']); $customer = \App\Models\Customer::factory()->create(['email' => 'c@cloud.test', 'user_id' => $user->id, 'name' => 'Acme AG']);
$host = \App\Models\Host::factory()->active()->create(); $host = \App\Models\Host::factory()->active()->create();
\App\Models\Instance::factory()->create([ $instance = \App\Models\Instance::factory()->create([
'customer_id' => $customer->id, 'host_id' => $host->id, 'status' => 'active', 'customer_id' => $customer->id, 'host_id' => $host->id, 'status' => 'active',
'plan' => 'business', 'subdomain' => 'acme-ag', 'quota_gb' => 1000, 'plan' => 'business', 'subdomain' => 'acme-ag', 'quota_gb' => 1000,
]); ]);
// The card states what this customer bought, so there has to be a contract
// to state it from — as there is for every running machine in production.
\App\Models\Subscription::factory()->plan('business')->create([
'customer_id' => $customer->id, 'instance_id' => $instance->id,
]);
$this->actingAs($user)->get(route('cloud')) $this->actingAs($user)->get(route('cloud'))
->assertOk() ->assertOk()

View File

@ -24,6 +24,10 @@ it('resolves order, instance and plan from the run', function () {
}); });
it('registers the 15-step customer pipeline in config', function () { it('registers the 15-step customer pipeline in config', function () {
expect(config('provisioning.pipelines.customer'))->toHaveCount(15) expect(config('provisioning.pipelines.customer'))->toHaveCount(15);
->and(config('provisioning.plans.start.template_vmid'))->toBe(9000); });
it('takes the blueprint from the catalogue, which no longer lives in config', function () {
expect(config('provisioning.plans'))->toBeNull()
->and(app(App\Services\Billing\PlanCatalogue::class)->currentVersion('start')->template_vmid)->toBe(9000);
}); });

View File

@ -252,6 +252,117 @@ it('refuses to provision an order that has no contract', function () {
->and($result->reason)->toBe('no_subscription'); ->and($result->reason)->toBe('no_subscription');
}); });
it('finishes a contract a crash left unopened, on the next Stripe retry', function () {
Queue::fake();
$event = [
'id' => 'evt_resume',
'type' => 'checkout.session.completed',
'data' => ['object' => [
'id' => 'cs_resume',
'payment_status' => 'paid',
'customer_details' => ['email' => 'absturz@example.com'],
'amount_total' => 17900,
'currency' => 'eur',
'metadata' => ['plan' => 'team', 'datacenter' => 'fsn'],
]],
];
$this->postJson(route('webhooks.stripe'), $event)->assertOk();
// Simulate the crash window: the order and run committed, the contract
// never got written.
$order = Order::query()->where('stripe_event_id', 'cs_resume')->sole();
Subscription::query()->where('order_id', $order->id)->delete();
// Stripe retries until it gets a 2xx — and the retry repairs it.
$this->postJson(route('webhooks.stripe'), $event)->assertOk();
expect(Subscription::query()->where('order_id', $order->id)->sole()->plan)->toBe('team')
->and(Order::query()->where('stripe_event_id', 'cs_resume')->count())->toBe(1);
});
it('restarts a run that already failed for want of the contract', function () {
Queue::fake();
// The plan cannot be priced when the payment lands, so no contract opens.
$price = app(App\Services\Billing\PlanCatalogue::class)->currentVersion('team')->priceFor('monthly');
$amount = $price->amount_cents;
// Forced past the guard, the way a bad repair script would.
App\Models\PlanPrice::query()->whereKey($price->id)->delete();
$event = [
'id' => 'evt_strand',
'type' => 'checkout.session.completed',
'data' => ['object' => [
'id' => 'cs_strand',
'payment_status' => 'paid',
'customer_details' => ['email' => 'gestrandet@example.com'],
'amount_total' => 17900,
'currency' => 'eur',
'metadata' => ['plan' => 'team', 'datacenter' => 'fsn'],
]],
];
$this->postJson(route('webhooks.stripe'), $event)->assertOk();
$order = Order::query()->where('stripe_event_id', 'cs_strand')->sole();
$run = ProvisioningRun::query()->where('subject_id', $order->id)->sole();
// The run gets as far as validating and stops dead.
app(App\Provisioning\RunRunner::class)->advance($run);
expect($run->fresh()->status)->toBe('failed')
->and($run->fresh()->error)->toContain('no_subscription')
->and($order->fresh()->status)->toBe('failed');
// The owner restores the price. Nothing sweeps failed runs, so without the
// retry repairing it this customer would stay paid-for and unprovisioned.
app(App\Services\Billing\PlanCatalogue::class)->currentVersion('team')
->prices()->create(['term' => 'monthly', 'amount_cents' => $amount, 'currency' => 'EUR']);
$this->postJson(route('webhooks.stripe'), $event)->assertOk();
expect(Subscription::query()->where('order_id', $order->id)->sole()->plan)->toBe('team')
->and($run->fresh()->status)->toBe('pending')
->and($run->fresh()->error)->toBeNull()
->and($order->fresh()->status)->toBe('paid');
Queue::assertPushed(App\Provisioning\Jobs\AdvanceRunJob::class);
});
it('keeps the record of a payment even when no contract can be opened', function () {
Queue::fake();
// The monthly price row is gone — the plan looks live but cannot be sold.
// Forced past the guard, the way a bad repair script would.
App\Models\PlanPrice::query()
->whereKey(app(App\Services\Billing\PlanCatalogue::class)->currentVersion('team')->priceFor('monthly')->id)
->delete();
$this->postJson(route('webhooks.stripe'), [
'id' => 'evt_unpriced',
'type' => 'checkout.session.completed',
'data' => ['object' => [
'id' => 'cs_unpriced',
'payment_status' => 'paid',
'customer_details' => ['email' => 'ohnepreis@example.com'],
'amount_total' => 17900,
'currency' => 'eur',
'metadata' => ['plan' => 'team', 'datacenter' => 'fsn'],
]],
])->assertOk();
// The money is on record and Stripe is answered; the run stops visibly.
$order = Order::query()->where('stripe_event_id', 'cs_unpriced')->sole();
expect($order->amount_cents)->toBe(17900)
->and(Subscription::query()->where('order_id', $order->id)->exists())->toBeFalse();
$run = ProvisioningRun::query()->where('subject_id', $order->id)->sole();
expect(app(ValidateOrder::class)->execute($run)->reason)->toBe('no_subscription');
});
it('opens no contract for a payment in a currency the catalogue cannot express', function () { it('opens no contract for a payment in a currency the catalogue cannot express', function () {
Queue::fake(); Queue::fake();

View File

@ -5,6 +5,30 @@ use App\Models\ProvisioningRun;
use App\Provisioning\Jobs\AdvanceRunJob; use App\Provisioning\Jobs\AdvanceRunJob;
use Illuminate\Support\Facades\Queue; use Illuminate\Support\Facades\Queue;
it('picks up a run that was created but never dispatched', function () {
Queue::fake();
$fresh = ProvisioningRun::factory()->create([
'status' => ProvisioningRun::STATUS_PENDING,
'next_attempt_at' => null,
]);
$stranded = ProvisioningRun::factory()->create([
'status' => ProvisioningRun::STATUS_PENDING,
'next_attempt_at' => null,
'created_at' => now()->subHour(),
]);
(new TickProvisioning)();
// The stranded one: a process died between creating the run and dispatching
// its first job, and nothing else would ever look at it again.
Queue::assertPushed(AdvanceRunJob::class, fn ($job) => $job->runUuid === $stranded->uuid);
// The fresh one is left alone — its own job is simply still queued, and a
// second one would run a duplicate chain beside it for the whole pipeline.
Queue::assertNotPushed(AdvanceRunJob::class, fn ($job) => $job->runUuid === $fresh->uuid);
});
it('dispatches advance jobs only for due running or waiting runs', function () { it('dispatches advance jobs only for due running or waiting runs', function () {
Queue::fake(); Queue::fake();

View File

@ -25,7 +25,14 @@ function trafficSetup(array $overrides = []): array
'status' => 'active', 'status' => 'active',
], $overrides)); ], $overrides));
return [$pve, $customer, $instance]; // Every running machine answers to a contract, and the allowance is the
// contract's — so the fixture has to have one too.
App\Models\Subscription::factory()->plan($instance->plan)->create([
'customer_id' => $customer->id,
'instance_id' => $instance->id,
]);
return [$pve, $customer, $instance->fresh()];
} }
it('counts the difference between samples, not the raw counter', function () { it('counts the difference between samples, not the raw counter', function () {
@ -128,19 +135,32 @@ it('counts add-on packs towards the allowance', function () {
expect(TrafficMeter::quotaGb($instance->fresh()))->toBe(3000); expect(TrafficMeter::quotaGb($instance->fresh()))->toBe(3000);
}); });
it('keeps the allowance the customer bought when the catalogue is cut', function () { it('keeps the allowance the customer bought when a leaner version replaces it', function () {
[, , $instance] = trafficSetup(); [, , $instance] = trafficSetup();
App\Models\Subscription::factory()->create([ // The owner replaces the start plan with a version carrying half the
'customer_id' => $instance->customer_id, // traffic. New customers get 500 GB; this one bought 1000.
'instance_id' => $instance->id, $catalogue = app(App\Services\Billing\PlanCatalogue::class);
...App\Models\Subscription::snapshotFrom('start'), $current = $catalogue->currentVersion('start');
$catalogue->schedule($current, $current->available_from, now());
$leaner = App\Models\PlanVersion::query()->create([
...$current->only(['plan_family_id', 'quota_gb', 'seats', 'ram_mb', 'cores', 'disk_gb', 'performance', 'template_vmid']),
'version' => 2,
'traffic_gb' => 500,
'features' => $current->features,
'available_from' => now(),
]); ]);
$leaner->prices()->create([
'term' => 'monthly', 'amount_cents' => 4900, 'currency' => App\Models\Subscription::catalogueCurrency(),
]);
$leaner->prices()->create([
'term' => 'yearly', 'amount_cents' => 58800, 'currency' => App\Models\Subscription::catalogueCurrency(),
]);
$catalogue->publish($leaner, now());
// Half the traffic off the start plan, after this customer bought it. expect($catalogue->sellable()['start']['traffic_gb'])->toBe(500)
config()->set('provisioning.plans.start.traffic_gb', 500); ->and(TrafficMeter::quotaGb($instance->fresh()))->toBe(1000);
expect(TrafficMeter::quotaGb($instance->fresh()))->toBe(1000);
}); });
it('shows the customer what is left, and offers a top-up when it gets tight', function () { it('shows the customer what is left, and offers a top-up when it gets tight', function () {