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' => [$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], }; // 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), '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, 'addon' => isset($addons[$key]), 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, $type, $plan, $addonKey, $amount, $datacenter) { Customer::query()->whereKey($customer->id)->lockForUpdate()->first(); $replaced = $type === 'upgrade' ? Order::query() ->where('customer_id', $customer->id) ->where('status', 'pending') ->where('type', 'upgrade') ->delete() : 0; Order::create([ 'customer_id' => $customer->id, 'plan' => $plan, 'type' => $type, 'addon_key' => $addonKey, 'amount_cents' => $amount, 'currency' => 'EUR', 'datacenter' => $datacenter, 'status' => 'pending', ]); return $replaced; }); if ($replaced > 0) { $this->dispatch('notify', message: __('billing.cart.plan_replaced')); } $this->dispatch('notify', message: __('billing.purchased')); } #[\Livewire\Attributes\On('order-removed')] public function orderRemoved(): void { $this->dispatch('notify', message: __('billing.cart.removed')); } /** * The terms to show as "your plan": the contract if there is one, and only * otherwise the catalogue — someone browsing before they have bought. * * @param array $catalogue * @return array */ private function currentTerms(?Subscription $subscription, array $catalogue): array { if ($subscription === null) { return $catalogue; } return [ 'tier' => $subscription->tier, // Per month: the card says "/ month", and a yearly contract stores // the whole year. 'price_cents' => $subscription->monthlyPriceCents(), 'currency' => $subscription->currency, 'quota_gb' => $subscription->quota_gb, 'traffic_gb' => $subscription->traffic_gb, 'seats' => $subscription->seats, 'performance' => $subscription->performance, 'features' => $subscription->planVersion?->features ?? ($catalogue['features'] ?? []), ]; } public function render() { $customer = $this->customer(); $instance = $customer?->instances()->latest('id')->first(); $plans = 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); $upgrades = collect($plans) ->filter(fn ($p) => (int) ($p['tier'] ?? 0) > $currentTier) ->keys()->all(); 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, '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' => \App\Services\Billing\TaxTreatment::for($customer), 'trafficMeter' => $instance !== null ? \App\Services\Traffic\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' => collect(app(AddonCatalogue::class)->forSubscription($instance?->subscription)) ->except(AddonCatalogue::STORAGE) ->all(), 'totalMonthlyCents' => $instance?->subscription?->totalMonthlyCents(), 'pending' => $customer ? $customer->orders()->where('status', 'pending')->latest('id')->get() : collect(), ]); } }