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
parent
52b41bb0d5
commit
6387c747d0
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
->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) {
|
->where(function ($q) {
|
||||||
$q->whereNull('next_attempt_at')->orWhere('next_attempt_at', '<=', now());
|
$q->whereNull('next_attempt_at')->orWhere('next_attempt_at', '<=', now());
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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'),
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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'),
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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'];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 ?? [],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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')
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -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'));
|
||||||
|
});
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 () {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue