CluPilotCloud/app/Livewire/Billing.php

491 lines
22 KiB
PHP

<?php
namespace App\Livewire;
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\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'));
}
#[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,
]);
}
}