get('stripe.secret'))) { $this->addError('purchase', __('billing.stripe_not_configured')); return; } $customer = $this->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, $quantity) { 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; // Storage is the one thing sold in packs, and a blocked downgrade // can need several of them at once — a customer told they are 280 GB // over should not have to click the same button three times and // count. Everything else is one line whatever is asked for: a second // upgrade contradicts the first, and a second entitlement is a // second charge for one thing. $lines = $type === 'storage' ? max(1, min(self::MAX_STORAGE_PACKS, $quantity)) : 1; foreach (range(1, $lines) as $ignored) { 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 deliberately does NOT unbook a booked downgrade. It used // to, and that was the whole of what an upgrade click did: nothing in // this application marks a cart order paid, so the customer traded a // downgrade they had genuinely booked for a cart line that was never // fulfilled. The two really do contradict each other — a downgrade // left booked would come due months later and undo the bigger package // — and that contradiction is settled where the money is: ApplyPlanChange // clears the booking at the moment the upgrade actually lands on the // contract. Until then the booking stands, and the card that states the // customer's terms goes on saying so. if ($contract !== null && $type === 'downgrade') { $contract->bookPendingPlanChange((string) $plan); } return $replaced; }); if ($replaced > 0) { $this->dispatch('notify', message: __('billing.cart.plan_replaced')); } $this->dispatch('notify', message: __('billing.purchased')); } /** * Cancel a booked module, monthly, to the end of the term already paid for. * * The owner's rule, and the thing the portal could not do at all: "die * Addons können monatlich gekündigt werden" was true of * BookAddon::cancelAtPeriodEnd() and of nothing a customer could reach — * the method had no caller in the interface, so the only way out of a * recurring charge was to write to us. * * Every running booking of that module, not one of them. To a customer, * "Modul kündigen" means the module stops; a pack module where one of two * bookings kept billing would be a cancellation that did not cancel. * * Raised by ConfirmCancelAddon rather than called from the card (R23), and * re-resolved from the signed-in customer here rather than trusted from the * argument: this method is reachable by anybody who can post to * /livewire/update with any key at all, and a booking is only ever looked * for on contracts that belong to the person asking. */ #[On('addon-cancel-confirmed')] public function cancelAddon(string $key): void { $customer = $this->requireCustomer(); if ($customer === null) { return; } $bookings = $this->ownBookings($customer, $key) ->filter(fn (SubscriptionAddon $addon) => ! $addon->endsAtPeriodEnd()); if ($bookings->isEmpty()) { $this->dispatch('notify', message: __('billing.addon_cancel_none')); return; } $book = app(BookAddon::class); foreach ($bookings as $addon) { $book->cancelAtPeriodEnd($addon); } // Read back rather than assumed: cancelAtPeriodEnd() ends a module // outright when the contract has no term left to wait for, and telling // the customer it runs until a date that does not exist would be worse // than saying nothing. $endsAt = $this->ownBookings($customer, $key)->pluck('cancels_at')->filter()->min(); $this->dispatch('notify', message: $endsAt === null ? __('billing.addon_cancelled_now') : __('billing.addon_cancel_done', ['date' => $endsAt->local()->isoFormat('LL')])); } /** * Take that cancellation back, while the module is still running. * * No confirmation dialog: this restores what the customer had, charges * nothing extra and can itself be undone by cancelling again. R23 is about * actions with consequences, and the consequence of this one is that * nothing happens. */ public function resumeAddon(string $key): void { $customer = $this->requireCustomer(); if ($customer === null) { return; } $bookings = $this->ownBookings($customer, $key) ->filter(fn (SubscriptionAddon $addon) => $addon->endsAtPeriodEnd()); if ($bookings->isEmpty()) { $this->dispatch('notify', message: __('billing.addon_resume_none')); return; } $book = app(BookAddon::class); foreach ($bookings as $addon) { $book->undoCancelAtPeriodEnd($addon); } $this->dispatch('notify', message: __('billing.addon_resumed')); } /** * The customer's own running bookings of one module. * * Scoped through the contract to the signed-in customer, always. The key * arrives from a request and a request is not evidence of ownership — a * lookup by key alone would cancel somebody else's module for anyone who * could guess one. * * @return Collection */ private function ownBookings(Customer $customer, string $key): Collection { return SubscriptionAddon::query() ->whereHas('subscription', fn ($q) => $q->where('customer_id', $customer->id)) ->where('addon_key', $key) ->active() ->orderBy('id') ->get(); } #[On('order-removed')] public function orderRemoved(): void { $this->dispatch('notify', message: __('billing.cart.removed')); } /** * The first way out of a downgrade blocked by data: buy the room instead of * deleting the files. * * Raised by ConfirmBookStorage rather than called from the card, so the * customer confirms a recurring charge in this product's own dialog and not * in one the browser draws (R23). The work stays in purchase(), which * already knows what a storage line costs and how the cart is written — * duplicating that here is how the two would come to disagree. */ #[On('storage-packs-confirmed')] public function bookStoragePacks(int $packs = 1): void { $this->purchase('storage', null, $packs); } /** * The other way out: delete data, then ask again NOW. * * The blocker is measured, and the measurement is taken on the sampler's * rounds — so somebody who has just cleared forty gigabytes was being told * to wait until tomorrow to find out it had worked. This takes the reading * on demand and writes it into the same row the sampler fills, so every * reader of the fill level sees it and nothing needs a second notion of * "current". * * One `df` through the guest agent: a bounded read that cannot change * anything on the machine. The mutating work storage needs — growing a disk, * growing a filesystem, writing an occ setting — is not here and never will * be; that goes through a provisioning run, where it is retried and visible. * The cooldown is because this button is a button: a customer holding it * down must not turn into a queue of guest commands against their own cloud. */ public function remeasureStorage(): void { $customer = $this->requireCustomer(); if ($customer === null) { return; } $instance = $customer->instances()->latest('id')->first(); if ($instance === null || ! $instance->hasLiveMachine()) { $this->dispatch('notify', message: __('billing.storage_remeasure_unavailable')); return; } if (! Cache::add('storage-remeasure:'.$instance->uuid, true, now()->addSeconds(20))) { $this->dispatch('notify', message: __('billing.storage_remeasure_wait')); return; } $metric = app(DiskUsageProbe::class)->record($instance); // Null is "we could not look", which is a different sentence from "you // are fine" — a guest that did not answer must never read as an empty // one, because the next thing the customer would do is press a downgrade // button on the strength of it. $this->dispatch('notify', message: __($metric === null ? 'billing.storage_remeasure_unavailable' : 'billing.storage_remeasured')); } /** * 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, ?Instance $instance, array $catalogue): array { // What the customer may store is the package PLUS the packs they have // booked, and the card that states their terms has to say the figure // that is actually enforced on their machine. Printing `quota_gb` there // told a customer with two packs that they had 500 GB while Nextcloud // was giving them 700. $allowance = StorageAllowance::for($instance); // Nobody browsing has packs — a pack is booked onto a contract — so the // catalogue's own figure is the whole answer there, and falling back to // the allowance would print "0 GB" at somebody who has not bought yet. if ($subscription === null) { return $catalogue + [ 'storage_gb' => (int) ($catalogue['quota_gb'] ?? 0), 'storage_packs' => 0, 'storage_pack_gb' => 0, ]; } return [ 'storage_gb' => $allowance->totalGb(), 'storage_packs' => $allowance->packs, 'storage_pack_gb' => $allowance->packGb(), 'tier' => $subscription->tier, // Per month: the card says "/ month", and a yearly contract stores // the whole year. 'price_cents' => $subscription->monthlyPriceCents(), // Which term is running, and what leaves the account when it does. // The figure above is per month either way, so a yearly customer // read a number they never see on a statement and nothing said why. 'term' => (string) $subscription->term, 'term_total_cents' => (int) $subscription->price_cents, 'renews_at' => $subscription->current_period_end, // 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(); // The contract, resolved from the CUSTOMER rather than from the // instance: a paid order waiting for a machine has a subscription and no // instance yet (see ReserveResources, which parks it), and asking the // instance would report "no package" at somebody who has paid. $contract = $customer === null ? null : Subscription::query() ->where('customer_id', $customer->id) ->whereIn('status', ['active', 'past_due']) ->latest('id') ->first(); // Whether there is anything to show at all. Without this the page fell // back to 'start' for a customer who had bought nothing and rendered it // as "AKTUELLES PAKET Start — Status Aktiv": a contract nobody entered // into, on the page a customer opens to find out what they pay. Reported // straight after registering, and the dashboard said the opposite on the // same account. // An INSTANCE counts too, whatever the subscription rows say: a running // cloud is a contract by any reading a customer cares about, and older // installations have instances whose subscription row predates the // catalogue rebuild. $hasContract = $contract !== null || $instance !== null; $currentKey = $instance?->plan ?? $contract?->plan ?? (string) array_key_first($plans); // 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 ?? $contract?->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', [ 'hasContract' => $hasContract, '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 ?? $contract, $instance, $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)), // When the fill level a blocked downgrade is measured against was // actually read. Stated rather than assumed current: a customer who // has just deleted data has to be able to see that the number in // front of them predates the deletion, and that pressing "neu // messen" is what will change it. 'storageMeasuredAt' => $instance !== null ? InstanceMetric::latestDisk($instance)?->updated_at : null, ]); } }