diff --git a/app/Actions/OpenSubscription.php b/app/Actions/OpenSubscription.php index 9ead25d..58c90cf 100644 --- a/app/Actions/OpenSubscription.php +++ b/app/Actions/OpenSubscription.php @@ -36,7 +36,10 @@ class OpenSubscription $start = now(); 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, 'order_id' => $order->id, diff --git a/app/Actions/StartCustomerProvisioning.php b/app/Actions/StartCustomerProvisioning.php index 1ee2e76..f5091d2 100644 --- a/app/Actions/StartCustomerProvisioning.php +++ b/app/Actions/StartCustomerProvisioning.php @@ -7,8 +7,11 @@ use App\Models\Order; use App\Models\ProvisioningRun; use App\Models\Subscription; use App\Provisioning\Jobs\AdvanceRunJob; +use App\Services\Billing\PlanCatalogue; use Illuminate\Database\UniqueConstraintViolationException; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Log; +use Throwable; /** * Turns a paid Stripe event into a customer + order + provisioning run. The @@ -24,20 +27,26 @@ class StartCustomerProvisioning */ public function fromStripeEvent(array $event): ?Order { - if (Order::query()->where('stripe_event_id', $event['id'])->exists()) { - return null; // already processed + $existing = Order::query()->where('stripe_event_id', $event['id'])->first(); + + if ($existing !== null) { + $this->resume($existing); + + return null; } $customer = $this->resolveCustomer($event); $customer->ensureUser(); // portal login (also enables admin impersonation) - $openSubscription = $this->openSubscription; - try { - [$order, $run] = DB::transaction(function () use ($event, $customer, $openSubscription) { + [$order, $run] = DB::transaction(function () use ($event, $customer) { $order = Order::create([ 'customer_id' => $customer->id, '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'], 'currency' => $event['currency'], 'datacenter' => $event['datacenter'], @@ -45,24 +54,6 @@ class StartCustomerProvisioning 'status' => 'paid', ]); - // Freeze what they bought, now, while the catalogue still says - // what they were shown. - // - // Two cases are deliberately left WITHOUT a contract: a plan the - // catalogue does not know, and a payment in a currency the - // catalogue cannot express — freezing a EUR price onto a CHF - // payment would put the contract and the payment in permanent - // disagreement. Both still record the order, so the payment is - // traceable, and the run then fails visibly at ValidateOrder. - // Throwing here instead would roll the order back and leave - // Stripe retrying a webhook that can never succeed. - $sellable = Subscription::knowsPlan($order->plan) - && strtoupper((string) $order->currency) === Subscription::catalogueCurrency(); - - if ($sellable) { - $openSubscription($order); - } - $run = ProvisioningRun::create([ 'subject_type' => Order::class, 'subject_id' => $order->id, @@ -77,14 +68,139 @@ class StartCustomerProvisioning return [$order, $run]; }); } 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 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 * concurrent first-time purchase can't create two customers for one email. diff --git a/app/Console/Commands/CheckPlanCatalogue.php b/app/Console/Commands/CheckPlanCatalogue.php new file mode 100644 index 0000000..5081021 --- /dev/null +++ b/app/Console/Commands/CheckPlanCatalogue.php @@ -0,0 +1,99 @@ +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; + } +} diff --git a/app/Console/TickProvisioning.php b/app/Console/TickProvisioning.php index 614a005..4e6aa0f 100644 --- a/app/Console/TickProvisioning.php +++ b/app/Console/TickProvisioning.php @@ -7,15 +7,36 @@ use App\Provisioning\Jobs\AdvanceRunJob; /** * 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; - * this catches waiting/retrying runs and anything a crashed worker left behind. + * due. Immediate dispatch on advance handles the fast path; this catches + * 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 { + /** + * 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 { ProvisioningRun::query() - ->whereIn('status', [ProvisioningRun::STATUS_RUNNING, ProvisioningRun::STATUS_WAITING]) + ->where(function ($q) { + $q + ->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) { $q->whereNull('next_attempt_at')->orWhere('next_attempt_at', '<=', now()); }) diff --git a/app/Http/Controllers/StripeWebhookController.php b/app/Http/Controllers/StripeWebhookController.php index 0622ff5..604a983 100644 --- a/app/Http/Controllers/StripeWebhookController.php +++ b/app/Http/Controllers/StripeWebhookController.php @@ -56,6 +56,9 @@ class StripeWebhookController extends Controller 'name' => $object['customer_details']['name'] ?? ($meta['name'] ?? null), 'stripe_customer_id' => $object['customer'] ?? null, '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', 'amount_cents' => (int) ($object['amount_total'] ?? $object['amount'] ?? 0), 'currency' => strtoupper($object['currency'] ?? 'eur'), diff --git a/app/Livewire/Admin/Customers.php b/app/Livewire/Admin/Customers.php index 57abfa1..106cd08 100644 --- a/app/Livewire/Admin/Customers.php +++ b/app/Livewire/Admin/Customers.php @@ -3,6 +3,8 @@ namespace App\Livewire\Admin; use App\Models\Customer; +use App\Models\PlanFamily; +use App\Services\Billing\PlanCatalogue; use Illuminate\Support\Number; use Livewire\Attributes\Layout; use Livewire\Component; @@ -35,17 +37,26 @@ class Customers extends Component public function render() { $locale = app()->getLocale(); - $plans = config('provisioning.plans'); + $plans = app(PlanCatalogue::class)->sellable(); $customers = Customer::query() - ->with('instances') + ->with(['instances.subscription']) ->orderBy('name') ->get(); $rows = $customers->map(function (Customer $c) use ($plans, $locale) { $instance = $c->instances->sortByDesc('id')->first(); $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 [ 'uuid' => $c->uuid, @@ -65,10 +76,13 @@ class Customers extends Component ]; })->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 = []; $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(); if ($n > 0) { $labels[] = __('billing.plan.'.$planKey); diff --git a/app/Livewire/Billing.php b/app/Livewire/Billing.php index 2f411ad..d85e0b5 100644 --- a/app/Livewire/Billing.php +++ b/app/Livewire/Billing.php @@ -5,6 +5,8 @@ namespace App\Livewire; use App\Livewire\Concerns\ResolvesCustomer; use App\Models\Customer; use App\Models\Order; +use App\Models\Subscription; +use App\Services\Billing\PlanCatalogue; use Illuminate\Support\Facades\DB; use Livewire\Attributes\Layout; use Livewire\Component; @@ -27,7 +29,7 @@ class Billing extends Component $instance = $customer->instances()->latest('id')->first(); $currentPlan = $instance?->plan ?? 'start'; - $plans = (array) config('provisioning.plans'); + $plans = app(PlanCatalogue::class)->sellable(); $addons = (array) config('provisioning.addons'); [$plan, $amount, $addonKey] = match ($type) { @@ -40,7 +42,11 @@ class Billing extends Component // Guard against invalid keys / non-upgrades. $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, // Always available: running out of traffic is exactly when someone // 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')); } + /** + * 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 $catalogue + * @return array + */ + 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() { $customer = $this->customer(); $instance = $customer?->instances()->latest('id')->first(); - $plans = (array) config('provisioning.plans'); + $plans = app(PlanCatalogue::class)->sellable(); $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) - ->filter(fn ($p, $k) => (int) ($p['price_cents'] ?? 0) > $currentPrice) + ->filter(fn ($p) => (int) ($p['tier'] ?? 0) > $currentTier) ->keys()->all(); return view('livewire.billing', [ '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, 'plans' => $plans, - 'features' => (array) config('provisioning.plan_features'), + 'features' => collect($plans)->map(fn ($p) => $p['features'] ?? [])->all(), 'upgrades' => $upgrades, 'storage' => (array) config('provisioning.storage_addon'), 'trafficAddon' => (array) config('provisioning.traffic.addon'), diff --git a/app/Livewire/Cloud.php b/app/Livewire/Cloud.php index b2f39f6..9a9df87 100644 --- a/app/Livewire/Cloud.php +++ b/app/Livewire/Cloud.php @@ -26,10 +26,12 @@ class Cloud extends Component ->latest('id')->first(); $maintenance = MaintenanceWindow::forInstance($shown)->first(); - $plans = (array) config('provisioning.plans'); - $planKey = $shown->plan ?? 'team'; - $plan = $plans[$planKey] ?? []; - $quota = (int) ($shown->quota_gb ?? ($plan['quota_gb'] ?? 500)); + // The instance carries what was actually provisioned, and the contract + // what was bought. Neither is the catalogue, which only describes what + // we would sell someone new — never what this customer already has. + $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 // instance's quota so the chart and the "x / y GB" label never disagree. $curveMax = max($growth); @@ -47,15 +49,17 @@ class Cloud extends Component 'status' => $shown->status ?? 'active', 'plan' => __('cloud.plan_line', [ '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'), 'version' => 'Nextcloud 31.0.4', 'php' => 'PHP 8.3 · MariaDB 11.4', 'storageUsed' => $used, 'storageQuota' => $quota, - 'seats' => __('billing.seats_count', ['count' => $plan['seats'] ?? 0]), - 'performance' => __('billing.perf.'.($plan['performance'] ?? 'standard')), + 'seats' => __('billing.seats_count', ['count' => $contract?->seats ?? 0]), + 'performance' => __('billing.perf.'.($contract?->performance ?? 'standard')), ], 'storageChart' => [ 'type' => 'line', diff --git a/app/Livewire/Users.php b/app/Livewire/Users.php index db7848d..dfc2c5c 100644 --- a/app/Livewire/Users.php +++ b/app/Livewire/Users.php @@ -175,9 +175,10 @@ class Users extends Component // failed/deprovisioned record. $instance = $customer->instances()->whereIn('status', ['active', 'cancellation_scheduled'])->latest('id')->first() ?? $customer->instances()->latest('id')->first(); - $plan = $instance->plan ?? 'start'; - - return (int) config("provisioning.plans.$plan.seats", 5); + // 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 + // lock users out of an existing customer's cloud. + return (int) ($instance?->subscription?->seats ?? 5); } private function seat(string $uuid): ?Seat diff --git a/app/Models/Order.php b/app/Models/Order.php index db4b645..3da17e1 100644 --- a/app/Models/Order.php +++ b/app/Models/Order.php @@ -17,13 +17,13 @@ class Order extends Model implements ProvisioningSubject use HasFactory, HasUuid; protected $fillable = [ - 'customer_id', 'plan', 'type', 'addon_key', 'amount_cents', 'currency', 'datacenter', - 'stripe_event_id', 'status', + 'customer_id', 'plan', 'plan_version_id', 'type', 'addon_key', 'amount_cents', 'currency', + 'datacenter', 'stripe_event_id', 'status', ]; protected function casts(): array { - return ['amount_cents' => 'integer']; + return ['amount_cents' => 'integer', 'plan_version_id' => 'integer']; } /** diff --git a/app/Models/PlanFamily.php b/app/Models/PlanFamily.php new file mode 100644 index 0000000..8866454 --- /dev/null +++ b/app/Models/PlanFamily.php @@ -0,0 +1,94 @@ +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; + } +} diff --git a/app/Models/PlanPrice.php b/app/Models/PlanPrice.php new file mode 100644 index 0000000..974ee4f --- /dev/null +++ b/app/Models/PlanPrice.php @@ -0,0 +1,85 @@ +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'); + } +} diff --git a/app/Models/PlanVersion.php b/app/Models/PlanVersion.php new file mode 100644 index 0000000..d554844 --- /dev/null +++ b/app/Models/PlanVersion.php @@ -0,0 +1,167 @@ +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 + */ + 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 ?? [], + ]; + } +} diff --git a/app/Models/Subscription.php b/app/Models/Subscription.php index 4be6deb..2383af6 100644 --- a/app/Models/Subscription.php +++ b/app/Models/Subscription.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Services\Billing\PlanCatalogue; use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -23,7 +24,7 @@ class Subscription extends Model * new subscriptions, never to existing ones. */ 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', ]; @@ -78,6 +79,12 @@ class Subscription extends Model 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 { return $this->belongsTo(Instance::class); @@ -86,7 +93,7 @@ class Subscription extends Model /** Whether the catalogue still sells this plan at all. */ 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. */ @@ -95,43 +102,55 @@ class Subscription extends Model 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 // term — a subscription whose price and billing period disagree. if (! in_array($term, [self::TERM_MONTHLY, self::TERM_YEARLY], true)) { 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 [ '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, - // A yearly term is twelve months of the same price. Any discount - // belongs in the catalogue, not hidden in this conversion. - 'price_cents' => $term === self::TERM_YEARLY ? $monthly * 12 : $monthly, - 'currency' => self::catalogueCurrency(), - 'quota_gb' => (int) ($catalogue['quota_gb'] ?? 0), - 'traffic_gb' => (int) ($catalogue['traffic_gb'] ?? 0), - 'seats' => (int) ($catalogue['seats'] ?? 0), - 'ram_mb' => (int) ($catalogue['ram_mb'] ?? 0), - 'cores' => (int) ($catalogue['cores'] ?? 0), - 'disk_gb' => (int) ($catalogue['disk_gb'] ?? 0), - 'performance' => $catalogue['performance'] ?? null, + 'price_cents' => $price->amount_cents, + 'currency' => $price->currency, + 'quota_gb' => $version->quota_gb, + 'traffic_gb' => $version->traffic_gb, + 'seats' => $version->seats, + 'ram_mb' => $version->ram_mb, + 'cores' => $version->cores, + 'disk_gb' => $version->disk_gb, + 'performance' => $version->performance, // Copied so provisioning never has to ask the catalogue what to // clone. Not in FROZEN — see the migration for why. - 'template_vmid' => isset($catalogue['template_vmid']) ? (int) $catalogue['template_vmid'] : null, + 'template_vmid' => $version->template_vmid, // Frozen too: which direction a later change goes must not depend // on where the plan sits in today's catalogue. - 'tier' => (int) ($catalogue['tier'] ?? 0), + 'tier' => (int) $version->family->tier, ]; } @@ -139,4 +158,19 @@ class Subscription extends Model { 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; + } } diff --git a/app/Services/Billing/PlanCatalogue.php b/app/Services/Billing/PlanCatalogue.php new file mode 100644 index 0000000..956bc38 --- /dev/null +++ b/app/Services/Billing/PlanCatalogue.php @@ -0,0 +1,315 @@ +> + */ + 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|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); + }); + } +} diff --git a/app/Services/Traffic/TrafficMeter.php b/app/Services/Traffic/TrafficMeter.php index ef98583..8ced736 100644 --- a/app/Services/Traffic/TrafficMeter.php +++ b/app/Services/Traffic/TrafficMeter.php @@ -40,17 +40,15 @@ final readonly class TrafficMeter /** * Plan allowance plus purchased add-on packs. * - * The allowance comes from the customer's contract, not from today's - * catalogue: cutting a plan's traffic must not start throttling someone who - * bought the larger allowance. Instances provisioned before contracts - * existed have none, and for those the catalogue is still the only answer — - * that fallback goes away once Phase 2 backfills them. + * The allowance is the contract's, not the catalogue's: cutting a plan's + * traffic must not start throttling someone who bought the larger one. An + * instance with no contract has nothing to meter against and is treated as + * unmetered rather than as exhausted — a machine we cannot price should not + * be throttled on a guess. */ public static function quotaGb(Instance $instance): int { - $plan = (int) ($instance->subscription?->traffic_gb - ?? config("provisioning.plans.{$instance->plan}.traffic_gb") - ?? 0); + $plan = (int) ($instance->subscription?->traffic_gb ?? 0); $addonGb = (int) config('provisioning.traffic.addon.gb', 1000); return $plan + ($instance->traffic_addons ?? 0) * $addonGb; diff --git a/config/provisioning.php b/config/provisioning.php index f4555ff..cc99b57 100644 --- a/config/provisioning.php +++ b/config/provisioning.php @@ -67,16 +67,21 @@ return [ // contract without the contract and the payment disagreeing forever. 'currency' => env('CLUPILOT_CURRENCY', 'EUR'), - // Plan → resource sizing + monthly price + Nextcloud blueprint template VMID. - // quota_gb/disk_gb/ram_mb/cores drive infrastructure placement and are - // ADMIN-ONLY. Customers compare seats, storage, performance class and - // features — never raw vCPU/RAM (see docs/specs/2026-07-25-portal-d-*). - 'plans' => [ - '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], - ], + /* + | The plan catalogue does NOT live here any more. It lives in + | plan_families / plan_versions / plan_prices, because the owner has to be + | able to create and schedule plans from the console, and because a config + | array cannot hold a version's history. + | + | Nothing falls back to a plans array here. A fallback would resurrect a + | plan the owner had just switched off, and would let commerce read one + | 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 // Customer::brandingResolved(); NULL customer fields fall back to these). @@ -125,14 +130,9 @@ return [ 'collabora_pro' => ['price_cents' => 1900], ], - // Customer-facing feature bullets per plan (labels in lang/*/billing.php → - // billing.feature.). Cumulative tiers: each plan lists its own bullets. - 'plan_features' => [ - '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'], - ], + // Feature bullets moved onto plan_versions.features, so that what a version + // promises is frozen with the version that promised it. Labels still live in + // lang/*/billing.php → billing.feature.. 'dns' => [ 'provider' => 'hetzner', diff --git a/database/migrations/2026_07_26_030000_link_subscriptions_to_orders.php b/database/migrations/2026_07_26_030000_link_subscriptions_to_orders.php index 040ea62..36fde47 100644 --- a/database/migrations/2026_07_26_030000_link_subscriptions_to_orders.php +++ b/database/migrations/2026_07_26_030000_link_subscriptions_to_orders.php @@ -18,6 +18,19 @@ use Illuminate\Support\Str; */ 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 { Schema::table('subscriptions', function (Blueprint $table) { @@ -63,7 +76,7 @@ return new class extends Migration */ private function backfillContracts(): void { - $plans = (array) config('provisioning.plans'); + $plans = self::CATALOGUE_AT_THE_TIME; $currency = strtoupper((string) config('provisioning.currency', 'EUR')); $orders = DB::table('orders') diff --git a/database/migrations/2026_07_26_040000_create_plan_catalogue_tables.php b/database/migrations/2026_07_26_040000_create_plan_catalogue_tables.php new file mode 100644 index 0000000..4d0059e --- /dev/null +++ b/database/migrations/2026_07_26_040000_create_plan_catalogue_tables.php @@ -0,0 +1,217 @@ + ['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, + ]); + } + } + } +}; diff --git a/database/migrations/2026_07_26_040001_link_subscriptions_to_plan_versions.php b/database/migrations/2026_07_26_040001_link_subscriptions_to_plan_versions.php new file mode 100644 index 0000000..e91d924 --- /dev/null +++ b/database/migrations/2026_07_26_040001_link_subscriptions_to_plan_versions.php @@ -0,0 +1,49 @@ +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'); + }); + } +}; diff --git a/database/migrations/2026_07_26_040002_add_plan_version_to_orders.php b/database/migrations/2026_07_26_040002_add_plan_version_to_orders.php new file mode 100644 index 0000000..9c3a337 --- /dev/null +++ b/database/migrations/2026_07_26_040002_add_plan_version_to_orders.php @@ -0,0 +1,36 @@ +foreignId('plan_version_id')->nullable()->after('plan') + ->constrained()->nullOnDelete(); + }); + } + + public function down(): void + { + Schema::table('orders', function (Blueprint $table) { + $table->dropConstrainedForeignId('plan_version_id'); + }); + } +}; diff --git a/tests/Feature/Admin/CustomerRevenueTest.php b/tests/Feature/Admin/CustomerRevenueTest.php new file mode 100644 index 0000000..78e1ef9 --- /dev/null +++ b/tests/Feature/Admin/CustomerRevenueTest.php @@ -0,0 +1,79 @@ +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')); +}); diff --git a/tests/Feature/Billing/PlanCatalogueTest.php b/tests/Feature/Billing/PlanCatalogueTest.php new file mode 100644 index 0000000..aa9f04f --- /dev/null +++ b/tests/Feature/Billing/PlanCatalogueTest.php @@ -0,0 +1,340 @@ + ['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(); +}); diff --git a/tests/Feature/Billing/PlanChangeTest.php b/tests/Feature/Billing/PlanChangeTest.php index 5e75af9..cf056c8 100644 --- a/tests/Feature/Billing/PlanChangeTest.php +++ b/tests/Feature/Billing/PlanChangeTest.php @@ -8,7 +8,21 @@ it('freezes what the customer bought against later price rises', function () { $subscription = Subscription::factory()->plan('team')->create(); $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. expect($subscription->fresh()->price_cents)->toBe($bookedPrice) diff --git a/tests/Feature/PortalTabsTest.php b/tests/Feature/PortalTabsTest.php index 801a63d..a98e76b 100644 --- a/tests/Feature/PortalTabsTest.php +++ b/tests/Feature/PortalTabsTest.php @@ -20,10 +20,15 @@ it('binds the cloud card to the real instance', function () { $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']); $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', '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')) ->assertOk() diff --git a/tests/Feature/Provisioning/CustomerStepBaseTest.php b/tests/Feature/Provisioning/CustomerStepBaseTest.php index 3cce1dc..a33e37f 100644 --- a/tests/Feature/Provisioning/CustomerStepBaseTest.php +++ b/tests/Feature/Provisioning/CustomerStepBaseTest.php @@ -24,6 +24,10 @@ it('resolves order, instance and plan from the run', function () { }); it('registers the 15-step customer pipeline in config', function () { - expect(config('provisioning.pipelines.customer'))->toHaveCount(15) - ->and(config('provisioning.plans.start.template_vmid'))->toBe(9000); + expect(config('provisioning.pipelines.customer'))->toHaveCount(15); +}); + +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); }); diff --git a/tests/Feature/Provisioning/SubscriptionSnapshotTest.php b/tests/Feature/Provisioning/SubscriptionSnapshotTest.php index 446e989..15383a6 100644 --- a/tests/Feature/Provisioning/SubscriptionSnapshotTest.php +++ b/tests/Feature/Provisioning/SubscriptionSnapshotTest.php @@ -252,6 +252,117 @@ it('refuses to provision an order that has no contract', function () { ->and($result->reason)->toBe('no_subscription'); }); +it('finishes a contract a crash left unopened, on the next Stripe retry', function () { + Queue::fake(); + + $event = [ + 'id' => 'evt_resume', + 'type' => 'checkout.session.completed', + 'data' => ['object' => [ + 'id' => 'cs_resume', + 'payment_status' => 'paid', + 'customer_details' => ['email' => 'absturz@example.com'], + 'amount_total' => 17900, + 'currency' => 'eur', + 'metadata' => ['plan' => 'team', 'datacenter' => 'fsn'], + ]], + ]; + + $this->postJson(route('webhooks.stripe'), $event)->assertOk(); + + // Simulate the crash window: the order and run committed, the contract + // never got written. + $order = Order::query()->where('stripe_event_id', 'cs_resume')->sole(); + Subscription::query()->where('order_id', $order->id)->delete(); + + // Stripe retries until it gets a 2xx — and the retry repairs it. + $this->postJson(route('webhooks.stripe'), $event)->assertOk(); + + expect(Subscription::query()->where('order_id', $order->id)->sole()->plan)->toBe('team') + ->and(Order::query()->where('stripe_event_id', 'cs_resume')->count())->toBe(1); +}); + +it('restarts a run that already failed for want of the contract', function () { + Queue::fake(); + + // The plan cannot be priced when the payment lands, so no contract opens. + $price = app(App\Services\Billing\PlanCatalogue::class)->currentVersion('team')->priceFor('monthly'); + $amount = $price->amount_cents; + // Forced past the guard, the way a bad repair script would. + App\Models\PlanPrice::query()->whereKey($price->id)->delete(); + + $event = [ + 'id' => 'evt_strand', + 'type' => 'checkout.session.completed', + 'data' => ['object' => [ + 'id' => 'cs_strand', + 'payment_status' => 'paid', + 'customer_details' => ['email' => 'gestrandet@example.com'], + 'amount_total' => 17900, + 'currency' => 'eur', + 'metadata' => ['plan' => 'team', 'datacenter' => 'fsn'], + ]], + ]; + + $this->postJson(route('webhooks.stripe'), $event)->assertOk(); + + $order = Order::query()->where('stripe_event_id', 'cs_strand')->sole(); + $run = ProvisioningRun::query()->where('subject_id', $order->id)->sole(); + + // The run gets as far as validating and stops dead. + app(App\Provisioning\RunRunner::class)->advance($run); + + expect($run->fresh()->status)->toBe('failed') + ->and($run->fresh()->error)->toContain('no_subscription') + ->and($order->fresh()->status)->toBe('failed'); + + // The owner restores the price. Nothing sweeps failed runs, so without the + // retry repairing it this customer would stay paid-for and unprovisioned. + app(App\Services\Billing\PlanCatalogue::class)->currentVersion('team') + ->prices()->create(['term' => 'monthly', 'amount_cents' => $amount, 'currency' => 'EUR']); + + $this->postJson(route('webhooks.stripe'), $event)->assertOk(); + + expect(Subscription::query()->where('order_id', $order->id)->sole()->plan)->toBe('team') + ->and($run->fresh()->status)->toBe('pending') + ->and($run->fresh()->error)->toBeNull() + ->and($order->fresh()->status)->toBe('paid'); + + Queue::assertPushed(App\Provisioning\Jobs\AdvanceRunJob::class); +}); + +it('keeps the record of a payment even when no contract can be opened', function () { + Queue::fake(); + + // The monthly price row is gone — the plan looks live but cannot be sold. + // Forced past the guard, the way a bad repair script would. + App\Models\PlanPrice::query() + ->whereKey(app(App\Services\Billing\PlanCatalogue::class)->currentVersion('team')->priceFor('monthly')->id) + ->delete(); + + $this->postJson(route('webhooks.stripe'), [ + 'id' => 'evt_unpriced', + 'type' => 'checkout.session.completed', + 'data' => ['object' => [ + 'id' => 'cs_unpriced', + 'payment_status' => 'paid', + 'customer_details' => ['email' => 'ohnepreis@example.com'], + 'amount_total' => 17900, + 'currency' => 'eur', + 'metadata' => ['plan' => 'team', 'datacenter' => 'fsn'], + ]], + ])->assertOk(); + + // The money is on record and Stripe is answered; the run stops visibly. + $order = Order::query()->where('stripe_event_id', 'cs_unpriced')->sole(); + + expect($order->amount_cents)->toBe(17900) + ->and(Subscription::query()->where('order_id', $order->id)->exists())->toBeFalse(); + + $run = ProvisioningRun::query()->where('subject_id', $order->id)->sole(); + expect(app(ValidateOrder::class)->execute($run)->reason)->toBe('no_subscription'); +}); + it('opens no contract for a payment in a currency the catalogue cannot express', function () { Queue::fake(); diff --git a/tests/Feature/Provisioning/TickProvisioningTest.php b/tests/Feature/Provisioning/TickProvisioningTest.php index 97e6f9c..ade4e83 100644 --- a/tests/Feature/Provisioning/TickProvisioningTest.php +++ b/tests/Feature/Provisioning/TickProvisioningTest.php @@ -5,6 +5,30 @@ use App\Models\ProvisioningRun; use App\Provisioning\Jobs\AdvanceRunJob; 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 () { Queue::fake(); diff --git a/tests/Feature/TrafficTest.php b/tests/Feature/TrafficTest.php index 0c16a57..d8a857e 100644 --- a/tests/Feature/TrafficTest.php +++ b/tests/Feature/TrafficTest.php @@ -25,7 +25,14 @@ function trafficSetup(array $overrides = []): array 'status' => 'active', ], $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 () { @@ -128,19 +135,32 @@ it('counts add-on packs towards the allowance', function () { expect(TrafficMeter::quotaGb($instance->fresh()))->toBe(3000); }); -it('keeps the allowance the customer bought when the catalogue is cut', function () { +it('keeps the allowance the customer bought when a leaner version replaces it', function () { [, , $instance] = trafficSetup(); - App\Models\Subscription::factory()->create([ - 'customer_id' => $instance->customer_id, - 'instance_id' => $instance->id, - ...App\Models\Subscription::snapshotFrom('start'), + // The owner replaces the start plan with a version carrying half the + // traffic. New customers get 500 GB; this one bought 1000. + $catalogue = app(App\Services\Billing\PlanCatalogue::class); + $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. - config()->set('provisioning.plans.start.traffic_gb', 500); - - expect(TrafficMeter::quotaGb($instance->fresh()))->toBe(1000); + expect($catalogue->sellable()['start']['traffic_gb'])->toBe(500) + ->and(TrafficMeter::quotaGb($instance->fresh()))->toBe(1000); }); it('shows the customer what is left, and offers a top-up when it gets tight', function () {