requireCustomer(); if ($customer === null) { return; } $instance = $customer->instances()->latest('id')->first(); $currentPlan = $instance?->plan ?? 'start'; $plans = app(PlanCatalogue::class)->sellable(); $addons = (array) config('provisioning.addons'); [$plan, $amount, $addonKey] = match ($type) { 'upgrade', 'downgrade' => [$key, (int) ($plans[$key]['price_cents'] ?? 0), null], 'storage' => [$currentPlan, (int) config('provisioning.storage_addon.price_cents', 0), null], 'traffic' => [$currentPlan, (int) config('provisioning.traffic.addon.price_cents', 0), null], 'addon' => [$currentPlan, (int) ($addons[$key]['price_cents'] ?? 0), $key], default => [null, 0, null], }; $access = app(CustomDomainAccess::class); $contract = $access->contractOf($customer); // A module the customer already has — booked, or sitting in the cart // waiting to be paid for — cannot be sold to them again. Answered out // loud rather than folded into $valid below, because silence is the // worst of the possible answers: a click that does nothing cannot be // told from a broken button, so the customer clicks it again, which is // how the second order gets placed in the first place. if ($type === 'addon' && is_string($key)) { $refusal = app(AddonCatalogue::class)->duplicateRefusal($contract, $key) ?? $this->cartRefusal($customer, $key); if ($refusal !== null) { $this->dispatch('notify', message: $refusal); return; } } // Guard against invalid keys / non-upgrades. $valid = match ($type) { // 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), // Re-checked here and not only in the view: the button can be // hidden and the action still called — a stale tab, a second // window, anyone with the component name. A limit enforced only in // markup is not enforced. // // A contract is required, and not as a formality: a downgrade is // booked ONTO the contract, with the date it comes due, and there // is nothing to book it onto for a customer who has none. Before, // such an order was accepted and could never be carried out. 'downgrade' => isset($plans[$key]) && $contract !== null && (int) ($plans[$key]['tier'] ?? 0) < (int) ($instance?->subscription?->tier ?? $plans[$currentPlan]['tier'] ?? 0) && DowngradeCheck::for($customer, $instance, $plans[$key], $key)->allowed, 'storage' => true, // Always available: running out of traffic is exactly when someone // needs to be able to buy more, whatever plan they are on. 'traffic' => true, // The module list leaves the own domain out where it cannot be // booked, and this refuses it there for the same reason the // downgrade branch re-checks: the button is markup, and markup is // not a limit. BookAddon refuses it a third time, at the moment the // money would actually buy something. 'addon' => isset($addons[$key]) && ($key !== CustomDomainAccess::ADDON || $access->bookable($contract)), default => false, }; if (! $valid || $plan === null) { return; } $datacenter = $instance?->host?->datacenter ?? $customer->orders()->latest('id')->value('datacenter') ?? 'fsn'; // A cart cannot hold two plan changes: they contradict each other and no // checkout could resolve which one the customer meant. Choosing another // replaces the pending one — add-ons still stack. // // Replacement and insert run together with the customer row locked: // two clicks in flight could otherwise both delete and then both // insert, leaving exactly the two upgrades this rule exists to prevent. $replaced = DB::transaction(function () use ($customer, $contract, $type, $plan, $addonKey, $amount, $datacenter) { Customer::query()->whereKey($customer->id)->lockForUpdate()->first(); // Up and down are the same kind of change and contradict each // other just as much, so either replaces a pending one of both. $replaced = in_array($type, ['upgrade', 'downgrade'], true) ? Order::query() ->where('customer_id', $customer->id) ->where('status', 'pending') ->whereIn('type', ['upgrade', 'downgrade']) ->delete() : 0; Order::create([ 'customer_id' => $customer->id, 'plan' => $plan, 'type' => $type, 'addon_key' => $addonKey, 'amount_cents' => $amount, 'currency' => 'EUR', 'datacenter' => $datacenter, 'status' => 'pending', ]); // The contract carries the booking, and it is booked HERE — at the // moment the customer decides — with the period end as it stands // right now. Reading that date again later would be reading a // moving target: Stripe pushes it forward on every renewal. // // An upgrade clears a booked downgrade instead of standing beside // it, for the same reason the order above was just deleted: the two // contradict each other, and a downgrade left booked would come // due months later and quietly undo the bigger package the customer // had moved to in the meantime. if ($contract !== null) { match ($type) { 'downgrade' => $contract->bookPendingPlanChange((string) $plan), 'upgrade' => $contract->clearPendingPlanChange(), default => null, }; } return $replaced; }); if ($replaced > 0) { $this->dispatch('notify', message: __('billing.cart.plan_replaced')); } $this->dispatch('notify', message: __('billing.purchased')); } #[On('order-removed')] public function orderRemoved(): void { $this->dispatch('notify', message: __('billing.cart.removed')); } /** * Why a module cannot go into the cart a second time. Null when it can. * * The contract answers "already booked"; this answers the half hour before * that, when the purchase is placed and not yet paid for. Both are the same * mistake to the customer — being charged twice for one thing — and a * double click or a stale tab produces exactly this one. * * Quantities are not asked about: two storage packs in one cart are two * hundred gigabytes, and that is a sale, not an accident. */ private function cartRefusal(Customer $customer, string $key): ?string { $catalogue = app(AddonCatalogue::class); if (! $catalogue->isEntitlement($key)) { return null; } $inCart = $customer->orders() ->where('status', 'pending') ->where('type', 'addon') ->where('addon_key', $key) ->exists(); return $inCart ? __('billing.addon_in_cart', ['module' => $catalogue->name($key)]) : null; } /** * The terms to show as "your plan": the contract if there is one, and only * otherwise the catalogue — someone browsing before they have bought. * * @param array $catalogue * @return array */ private function currentTerms(?Subscription $subscription, array $catalogue): array { if ($subscription === null) { return $catalogue; } return [ 'tier' => $subscription->tier, // Per month: the card says "/ month", and a yearly contract stores // the whole year. 'price_cents' => $subscription->monthlyPriceCents(), // The portal shows a granted package without a price at all — not // "free", not struck through — so a later conversion to paid does // not read as a negotiation. 'granted' => $subscription->isGranted(), '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'] ?? []), ]; } /** * The module cards, minus the ones this customer cannot be sold. * * Two things the shop was getting wrong about the own domain, and both of * them were offers: a package that already includes it was invited to buy * it again, and a package that cannot have one at all was invited to buy * something we would then refuse. An offer that would be refused is worse * than no offer — the customer clicks it, and finds out from an error * message what they should have been told by the page. * * A module already booked keeps its card whatever the package says, because * it is still on the bill and hiding it would hide the charge. * * A module already in the CART keeps its card too, and loses its button: * the purchase is placed and waiting to be paid for, and offering it again * is how one double click turns into two charges for one thing. * * @param array> $rows * @param Collection $pending * @return array> */ private function offerable(array $rows, ?Customer $customer, Collection $pending): array { $inCart = $pending->where('type', 'addon')->pluck('addon_key')->filter()->all(); foreach ($rows as $key => $row) { $rows[$key]['in_cart'] = in_array($key, $inCart, true); } $access = app(CustomDomainAccess::class); $key = CustomDomainAccess::ADDON; if (! array_key_exists($key, $rows)) { return $rows; } $contract = $access->contractOf($customer); $rows[$key]['included'] = $access->includedInPlan($contract); if (! $rows[$key]['included'] && ! $rows[$key]['booked'] && ! $access->bookable($contract)) { unset($rows[$key]); } return $rows; } /** * The booked change, in the words and the wall-clock date the customer * reads. Null when nothing is booked. * * @return array{plan: string, at: string}|null */ private function pendingChange(?Subscription $contract): ?array { if ($contract === null || ! $contract->hasPendingPlanChange()) { return null; } return [ 'plan' => __('billing.plan.'.$contract->pending_plan), 'at' => $contract->pending_effective_at->local()->isoFormat('LL'), ]; } public function render() { $customer = $this->customer(); $instance = $customer?->instances()->latest('id')->first(); $plans = app(PlanCatalogue::class)->sellable(); $currentKey = $instance?->plan ?? 'start'; // 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); // Smaller plans, each with the reason it cannot be taken today. Shown // WITH the reason rather than hidden: a customer who cannot downgrade // needs to know what to delete, and a plan that silently disappears // reads as "not offered any more". $downgrades = collect($plans) ->filter(fn ($p) => (int) ($p['tier'] ?? 0) < $currentTier) ->sortByDesc(fn ($p) => (int) ($p['tier'] ?? 0)) // With the key, not only the terms: what a move costs a customer // depends on which package they are moving to, and an entry that // does not know its own name cannot be asked about it. ->map(fn ($p, $k) => $p + ['check' => DowngradeCheck::for($customer, $instance, $p, $k)]) ->all(); $upgrades = collect($plans) ->filter(fn ($p) => (int) ($p['tier'] ?? 0) > $currentTier) ->keys()->all(); $pending = $customer ? $customer->orders()->where('status', 'pending')->latest('id')->get() : collect(); return view('livewire.billing', [ 'currentKey' => $currentKey, // What they HAVE comes from their contract; only what they could // BUY comes from the shop. Reading this off the catalogue showed a // customer today's price as though it were theirs, and dropped the // card entirely once their plan stopped being sold. 'current' => $this->currentTerms($instance?->subscription, $plans[$currentKey] ?? []), 'instance' => $instance, 'plans' => $plans, 'features' => collect($plans)->map(fn ($p) => $p['features'] ?? [])->all(), 'upgrades' => $upgrades, 'downgrades' => $downgrades, 'storage' => (array) config('provisioning.storage_addon'), 'trafficAddon' => (array) config('provisioning.traffic.addon'), // Resolved once: the cart and the plan cards must not state // different tax treatments on the same page. 'tax' => TaxTreatment::for($customer), 'trafficMeter' => $instance !== null ? TrafficMeter::for($instance) : null, // Booked modules at the price they were booked at; everything else // at today's. Reading them all off the catalogue would re-price // half a customer's bill behind their back. 'addons' => $this->offerable( collect(app(AddonCatalogue::class)->forSubscription($instance?->subscription)) ->except(AddonCatalogue::STORAGE) ->all(), $customer, $pending, ), 'totalMonthlyCents' => $instance?->subscription?->totalMonthlyCents(), 'pending' => $pending, // The change this customer has already booked, so the page they // booked it on says so. A contract quietly due to shrink in three // months is exactly the thing a customer should not have to // remember on their own. 'pendingChange' => $this->pendingChange(app(CustomDomainAccess::class)->contractOf($customer)), ]); } }