CluPilotCloud/app/Livewire/Checkout.php

154 lines
6.0 KiB
PHP

<?php
namespace App\Livewire;
use App\Models\Customer;
use App\Models\Subscription;
use App\Services\Billing\PlanCatalogue;
use App\Services\Billing\SetupFee;
use App\Services\Billing\TaxTreatment;
use App\Services\Provisioning\HostCapacity;
use App\Support\CompanyProfile;
use Illuminate\Support\Facades\Auth;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Url;
use Livewire\Component;
use Throwable;
/**
* The order, before it is placed.
*
* There was no such page. The order page posted straight to Stripe, so the last
* thing a customer saw in this product's own design was a grid of packages, and
* the first thing they saw after pressing was somebody else's payment form
* asking for a card. What they were buying, what it costs in total, what happens
* afterwards and what they were agreeing to were never stated in one place.
*
* This is that place: one package, one term, every figure that will be charged,
* and the terms acceptance directly above the button that spends money — which
* is also where it belongs and where the owner asked for it.
*
* ## It decides nothing
*
* Every figure here comes from the same three sources the checkout itself uses —
* the catalogue, TaxTreatment, SetupFee — and none of them is recomputed. A
* summary that works out its own total is a second answer waiting to disagree
* with the first, and the one that matters is the one Stripe is handed.
*/
#[Layout('layouts.portal-app')]
class Checkout extends Component
{
/** Which package. In the address, so this page can be reloaded and shared. */
#[Url]
public string $plan = '';
/** Monthly or yearly — carried from the choice on the order page. */
#[Url]
public string $term = Subscription::TERM_MONTHLY;
public function mount(): void
{
if (! in_array($this->term, [Subscription::TERM_MONTHLY, Subscription::TERM_YEARLY], true)) {
$this->term = Subscription::TERM_MONTHLY;
}
}
public function render()
{
$user = Auth::user();
$customer = $user === null ? null : Customer::query()->where('email', $user->email)->first();
// Someone who already has a contract does not buy a second one by
// accident: changing package is a plan change, which prorates and keeps
// their data. Same rule as the order page, checked again here because a
// link into this page skips that one.
$contract = $customer === null ? null : Subscription::query()
->where('customer_id', $customer->id)
->whereIn('status', ['active', 'past_due'])
->first();
$tax = TaxTreatment::for($customer);
// NOT called `plan` in the view: the public property of that name is a
// key, a string, and Livewire merges its properties into the view data —
// so a package array under the same name is shadowed by it and every
// `$plan['name']` in the template reads an offset off a string.
$package = $this->plan === '' ? null : $this->planOf($this->plan);
return view('livewire.checkout', [
'package' => $package,
'contract' => $contract,
'tax' => $tax,
'lines' => $package === null ? [] : $this->lines($package, $tax),
'vat' => rtrim(rtrim(number_format(CompanyProfile::taxRate(), 1, ',', '.'), '0'), ','),
]);
}
/**
* The chosen package as the shop describes it, or null when the key names
* nothing on sale — a URL is a string somebody can retype.
*
* @return array<string, mixed>|null
*/
private function planOf(string $key): ?array
{
try {
$sellable = app(PlanCatalogue::class)->sellable();
} catch (Throwable) {
// The catalogue fails loudly by design; a customer's page is not
// the place to argue about it. No package, one sentence.
return null;
}
if (! array_key_exists($key, $sellable)) {
return null;
}
$plan = $sellable[$key];
return $plan + [
'key' => $key,
'delivery' => app(HostCapacity::class)->deliveryFor((int) ($plan['disk_gb'] ?? 0)),
];
}
/**
* What will be charged, line by line, in the order an invoice states it.
*
* Net, tax and gross come from TaxTreatment rather than from a
* multiplication here: a business with a valid EU VAT number outside Austria
* pays no Austrian tax at all, and a summary that "adds 20 %" would quote
* that customer a total they will never be charged.
*
* @param array<string, mixed> $plan
* @return array<string, mixed>
*/
private function lines(array $plan, TaxTreatment $tax): array
{
$yearly = $this->term === Subscription::TERM_YEARLY;
$recurringNet = (int) ($yearly ? $plan['yearly_price_cents'] : $plan['price_cents']);
$recurringGross = $tax->chargeCents($recurringNet);
$setupGross = SetupFee::chargedCents($tax);
$setupNet = SetupFee::netCents();
return [
'currency' => (string) $plan['currency'],
'recurring_net' => $recurringNet,
'recurring_gross' => $recurringGross,
'setup_net' => $setupNet,
'setup_gross' => $setupGross,
// What leaves the account on the first charge: the term plus the
// one-off. Every figure afterwards is the term alone.
'today_net' => $recurringNet + $setupNet,
'today_gross' => $recurringGross + $setupGross,
'today_tax' => ($recurringGross + $setupGross) - ($recurringNet + $setupNet),
'yearly' => $yearly,
'free_months' => (int) ($plan['free_months'] ?? 0),
// Only on the yearly view, and only when there is something to
// compare against: twelve monthly charges at the monthly price.
'twelve_months_gross' => $tax->chargeCents((int) $plan['price_cents']) * 12,
'per_month_gross' => $yearly ? intdiv($recurringGross, 12) : $recurringGross,
];
}
}