601 lines
26 KiB
PHP
601 lines
26 KiB
PHP
<?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\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;
|
|
|
|
/**
|
|
* The most storage packs one click may put in the cart.
|
|
*
|
|
* A ceiling rather than a rule: the blocked-downgrade card offers exactly
|
|
* the number that would clear the blocker, and that number is computed from
|
|
* a measurement. This exists because the method is reachable from anywhere
|
|
* that can POST to /livewire/update with any argument at all, and a cart
|
|
* with nine thousand lines in it is a page nobody can use again.
|
|
*/
|
|
private const MAX_STORAGE_PACKS = 50;
|
|
|
|
/**
|
|
* Record a purchase intent (order). Fulfillment (Stripe checkout + resize) is
|
|
* mocked for now — the order is created with status 'pending'.
|
|
*/
|
|
public function purchase(string $type, ?string $key = null, int $quantity = 1): void
|
|
{
|
|
$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 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'));
|
|
}
|
|
|
|
/**
|
|
* 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(),
|
|
// 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();
|
|
$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, $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,
|
|
]);
|
|
}
|
|
}
|