<?php

namespace App\Livewire;

use App\Actions\BookAddon;
use App\Livewire\Concerns\ResolvesCustomer;
use App\Models\Customer;
use App\Models\Instance;
use App\Models\InstanceMetric;
use App\Models\Order;
use App\Models\Subscription;
use App\Models\SubscriptionAddon;
use App\Services\Billing\AddonCatalogue;
use App\Services\Billing\CustomDomainAccess;
use App\Services\Billing\DowngradeCheck;
use App\Services\Billing\PlanCatalogue;
use App\Services\Billing\StorageAllowance;
use App\Services\Billing\TaxTreatment;
use App\Services\Provisioning\DiskUsageProbe;
use App\Services\Secrets\SecretVault;
use App\Services\Traffic\TrafficMeter;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Component;

#[Layout('layouts.portal-app')]
class Billing extends Component
{
    use ResolvesCustomer;

    /**
     * Notbremse gegen eine unsinnige Zahl aus dem Formular. Die kaufmännische
     * Grenze steht in AddonCatalogue::maxQuantity() und wird von BookAddon
     * gehalten — hier wird nur verhindert, dass jemand für etwas bezahlt, das
     * die Buchung danach ablehnt.
     */
    private const MAX_STORAGE_PACKS = 50;

    /**
     * Record a purchase intent (order), at status `pending`.
     *
     * **This does not take money, and nothing in this application yet does for a
     * cart order.** Said plainly because it is the one thing about this page a
     * reader would otherwise assume: the first purchase of a package goes through
     * a real Stripe checkout (App\Http\Controllers\CheckoutController), and
     * everything sold from HERE — a plan change, a module, a storage pack, a
     * traffic top-up — is only written down. There is no second checkout, and no
     * code path in this repository sets a cart order to `paid`.
     *
     * What DOES happen once something marks one paid — an operator, a seeder, the
     * second checkout when it is built — is App\Observers\OrderObserver, which is
     * the seam that completes every type of order. The customer is told the same
     * thing this docblock says: "Kauf vorgemerkt — wir schalten ihn nach der
     * Zahlung frei", beside a cart headed "Wird nach Zahlung aktiviert".
     */
    public function purchase(string $type, ?string $key = null, int $quantity = 1): void
    {
        // Vor allem anderen: ohne Schlüssel des aktiven Modus entsteht kein
        // Auftrag. Nach der Zahlung wäre es zu spät, und ein halb angelegter
        // Auftrag ohne Stripe-Sitzung ist genau die Leiche, die niemand findet.
        if (blank(app(SecretVault::class)->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;
            }
        }

        // Speicher, an der Verfügbarkeit UND an der kaufmännischen Grenze:
        // derselbe Grund wie oben bei einem Entitlement, aus demselben Satz
        // (AddonCatalogue::availabilityRefusal()/quantityRefusal(), die auch
        // BookAddon spricht). Der Knopf, der hierher führt, ist bedingungslos
        // gerendert — ohne diese Prüfung entstünde eine Bestellzeile ($lines
        // unten floort sonst auf mindestens 1), die nach der Zahlung nur noch
        // BookAddon ablehnen könnte, während der Auftrag selbst `paid` stehen
        // bliebe (OrderObserver fängt den Fehlschlag nur ab und loggt ihn).
        // Verfügbarkeit zuerst geprüft, weil sie die schärfere Aussage ist:
        // "gibt es für Ihr Paket nicht" statt "ist gerade voll". Ein
        // Teil-Kontingent wird weiter befüllt (unten, $lines) — hier wird nur
        // der Fall abgefangen, in dem gar nichts mehr geht.
        if ($type === 'storage') {
            $refusal = app(AddonCatalogue::class)->availabilityRefusal($contract, AddonCatalogue::STORAGE)
                ?? app(AddonCatalogue::class)->quantityRefusal($contract, AddonCatalogue::STORAGE, 1);

            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, app(AddonCatalogue::class)->bookableQuantity($contract, AddonCatalogue::STORAGE), $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<int, SubscriptionAddon>
     */
    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<string, mixed>  $catalogue
     * @return array<string, mixed>
     */
    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<string, array<string, mixed>>  $rows
     * @param  Collection<int, Order>  $pending
     * @return array<string, array<string, mixed>>
     */
    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', [
            // Ob eine Abbuchung offen ist. Bis zum 31.7.2026 stand dazu
            // nichts im Portal: invoicePaymentFailed() setzt `past_due` und
            // schweigt, und der Kunde erfuhr davon erst, wenn seine Cloud
            // stand.
            'pastDue' => $customer !== null && $customer->subscriptions()
                ->where('stripe_status', 'past_due')
                ->exists(),
            // Die zwei Bauteile darunter brauchen einen Stripe-Kunden: ohne
            // ihn gibt es weder ein Zahlungsmittel zu tauschen noch eine
            // Rechnung zu begleichen. Wer noch nie an der Kasse war, hat
            // keinen — und bekommt hier folgerichtig auch keine leere Karte
            // gezeigt, sondern gar keine.
            'hasStripeCustomer' => $customer !== null && filled($customer->stripe_customer_id),
            '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'),
            // Null while the button may still sell a pack, the sentence
            // BookAddon would refuse with once it may not — the same two calls
            // purchase() makes before writing an order, so the card and the
            // action never disagree about whether one more block is possible.
            // Availability first: Enterprise never had a cap to reach, so
            // quantityRefusal alone stayed null and the button would have kept
            // offering a module the package does not carry at all.
            'storageLimitNote' => app(AddonCatalogue::class)->availabilityRefusal($instance?->subscription ?? $contract, AddonCatalogue::STORAGE)
                ?? app(AddonCatalogue::class)->quantityRefusal($instance?->subscription ?? $contract, AddonCatalogue::STORAGE, 1),
            '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,
        ]);
    }
}
