262 lines
11 KiB
PHP
262 lines
11 KiB
PHP
<?php
|
|
|
|
namespace App\Livewire;
|
|
|
|
use App\Livewire\Concerns\ResolvesCustomer;
|
|
use App\Models\Customer;
|
|
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\TaxTreatment;
|
|
use App\Services\Traffic\TrafficMeter;
|
|
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;
|
|
|
|
/**
|
|
* 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): 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);
|
|
|
|
// 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.
|
|
'downgrade' => isset($plans[$key])
|
|
&& (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($access->contractOf($customer))),
|
|
default => false,
|
|
};
|
|
if (! $valid || $plan === null) {
|
|
return;
|
|
}
|
|
|
|
$datacenter = $instance?->host?->datacenter
|
|
?? $customer->orders()->latest('id')->value('datacenter')
|
|
?? 'fsn';
|
|
|
|
// A cart cannot hold two plan changes: they contradict each other and no
|
|
// checkout could resolve which one the customer meant. Choosing another
|
|
// replaces the pending one — add-ons still stack.
|
|
//
|
|
// Replacement and insert run together with the customer row locked:
|
|
// two clicks in flight could otherwise both delete and then both
|
|
// insert, leaving exactly the two upgrades this rule exists to prevent.
|
|
$replaced = DB::transaction(function () use ($customer, $type, $plan, $addonKey, $amount, $datacenter) {
|
|
Customer::query()->whereKey($customer->id)->lockForUpdate()->first();
|
|
|
|
// 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;
|
|
|
|
Order::create([
|
|
'customer_id' => $customer->id,
|
|
'plan' => $plan,
|
|
'type' => $type,
|
|
'addon_key' => $addonKey,
|
|
'amount_cents' => $amount,
|
|
'currency' => 'EUR',
|
|
'datacenter' => $datacenter,
|
|
'status' => 'pending',
|
|
]);
|
|
|
|
return $replaced;
|
|
});
|
|
|
|
if ($replaced > 0) {
|
|
$this->dispatch('notify', message: __('billing.cart.plan_replaced'));
|
|
}
|
|
|
|
$this->dispatch('notify', message: __('billing.purchased'));
|
|
}
|
|
|
|
#[On('order-removed')]
|
|
public function orderRemoved(): void
|
|
{
|
|
$this->dispatch('notify', message: __('billing.cart.removed'));
|
|
}
|
|
|
|
/**
|
|
* The terms to show as "your plan": the contract if there is one, and only
|
|
* otherwise the catalogue — someone browsing before they have bought.
|
|
*
|
|
* @param array<string, mixed> $catalogue
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function currentTerms(?Subscription $subscription, array $catalogue): array
|
|
{
|
|
if ($subscription === null) {
|
|
return $catalogue;
|
|
}
|
|
|
|
return [
|
|
'tier' => $subscription->tier,
|
|
// Per month: the card says "/ month", and a yearly contract stores
|
|
// the whole year.
|
|
'price_cents' => $subscription->monthlyPriceCents(),
|
|
// 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.
|
|
*
|
|
* @param array<string, array<string, mixed>> $rows
|
|
* @return array<string, array<string, mixed>>
|
|
*/
|
|
private function offerable(array $rows, ?Customer $customer): array
|
|
{
|
|
$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;
|
|
}
|
|
|
|
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();
|
|
|
|
return view('livewire.billing', [
|
|
'currentKey' => $currentKey,
|
|
// What they HAVE comes from their contract; only what they could
|
|
// BUY comes from the shop. Reading this off the catalogue showed a
|
|
// customer today's price as though it were theirs, and dropped the
|
|
// card entirely once their plan stopped being sold.
|
|
'current' => $this->currentTerms($instance?->subscription, $plans[$currentKey] ?? []),
|
|
'instance' => $instance,
|
|
'plans' => $plans,
|
|
'features' => collect($plans)->map(fn ($p) => $p['features'] ?? [])->all(),
|
|
'upgrades' => $upgrades,
|
|
'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,
|
|
),
|
|
'totalMonthlyCents' => $instance?->subscription?->totalMonthlyCents(),
|
|
'pending' => $customer
|
|
? $customer->orders()->where('status', 'pending')->latest('id')->get()
|
|
: collect(),
|
|
]);
|
|
}
|
|
}
|