CluPilotCloud/app/Livewire/Order.php

140 lines
6.3 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\Component;
use Throwable;
/**
* Choose a package and pay for it, without anybody writing an email.
*
* The website used to end in "anfragen" — a mailto: link and a promise to reply
* the same working day. That is an afternoon of somebody's time per customer,
* for a product whose whole point is that the machine does the work.
*
* The prices here are the catalogue's at the till, exactly as the public sheet
* shows them: a customer who was quoted 58,80 € must not meet 49 € on the page
* where they press the button, nor the other way round. This page differs from the
* public sheet in one respect, and it is allowed to because it knows who is
* reading it: a business in another member state with a verified VAT id is quoted
* — and charged — the bare net, because reverse charge means no VAT is owed to us
* at all. The delivery promise is the estate's own answer (HostCapacity), so
* nobody is told "sofort" while a server still has to be bought.
*
* Nothing is charged here. The button opens a Stripe hosted checkout; the
* purchase becomes real on the webhook, which is the only place allowed to
* believe a payment happened.
*/
// layouts.portal is the bare shell the SIGN-IN pages use: no navigation, no
// page padding. This page is reached signed in and behind `verified`, so it
// belongs in the app shell like every other portal page — Dashboard and Billing
// were already there. In the wrong one it rendered flush against the top and
// bottom of the window with no way back to anything.
#[Layout('layouts.portal-app')]
class Order extends Component
{
public function render()
{
$user = Auth::user();
$customer = $user === null ? null : Customer::query()->where('email', $user->email)->first();
// Somebody already on a contract is not buying a second cloud from
// this page — changing package is a plan change, which prorates and
// keeps their data where it is.
$contract = $customer === null ? null : Subscription::query()
->where('customer_id', $customer->id)
->whereIn('status', ['active', 'past_due'])
->first();
// Resolved once and used for every figure on the page, so the package
// price, the setup fee and the sentence about VAT underneath them cannot
// state three different treatments of one sale. It is the same call the
// checkout makes to pick the Stripe Price, so what is shown here is what
// will be taken.
$tax = TaxTreatment::for($customer);
return view('livewire.order', [
'plans' => $this->plans($tax),
'tax' => $tax,
'contract' => $contract,
'vat' => rtrim(rtrim(number_format(CompanyProfile::taxRate(), 1, ',', '.'), '0'), ','),
// At the till, like the package price above it and like the public
// sheet, which quotes "Einrichtung kostet einmalig 118,80 €". This page
// was quoting the NET figure under the same sentence, so the site said
// 118,80 and the page with the button on it said 99,00. Through the
// same treatment as the package, because a fee is a supply like any
// other and cannot be taxed differently from the thing it sets up.
'setup' => SetupFee::chargedCents($tax),
]);
}
/**
* The packages on sale, in the words and figures the public sheet uses —
* priced for whoever is reading them.
*
* `gross` is what this customer's card would be charged, formed by
* TaxTreatment and not by a multiplication of its own. It used to be the
* latter, which is a second source for a number the checkout also forms, and
* therefore a second answer waiting to happen.
*
* @return array<int, array<string, mixed>>
*/
private function plans(TaxTreatment $tax): array
{
try {
$sellable = app(PlanCatalogue::class)->sellable();
} catch (Throwable) {
// The catalogue fails loudly by design — an overlap must not decide
// a customer's terms by row order. A portal page is not the place
// to argue about it: no list, one sentence, and the operator's
// console is already shouting.
return [];
}
$capacity = app(HostCapacity::class);
$plans = [];
foreach ($sellable as $key => $plan) {
$net = (int) $plan['price_cents'];
$yearlyNet = (int) ($plan['yearly_price_cents'] ?? $net * 12);
$yearlyGross = $tax->chargeCents($yearlyNet);
$freeMonths = (int) ($plan['free_months'] ?? 0);
$plans[] = [
'key' => $key,
'name' => (string) $plan['name'],
'audience' => (string) ($plan['audience'] ?? ''),
'gross' => $tax->chargeCents($net),
'net' => $net,
// Paying for a year, in the three figures somebody compares:
// what leaves the account once, what that works out to per
// month, and what it would have cost month by month.
'yearly_gross' => $yearlyGross,
'yearly_net' => $yearlyNet,
// intdiv, so the headline figure never claims a tenth of a cent
// nobody is charged. The total above it is the amount taken.
'yearly_per_month' => intdiv($yearlyGross, 12),
'twelve_months_gross' => $tax->chargeCents($net) * 12,
'free_months' => $freeMonths,
'currency' => (string) $plan['currency'],
'quota_gb' => (int) $plan['quota_gb'],
'traffic_gb' => (int) $plan['traffic_gb'],
'seats' => (int) $plan['seats'],
'recommended' => (bool) ($plan['recommended'] ?? false),
'delivery' => $capacity->deliveryFor((int) ($plan['disk_gb'] ?? 0)),
];
}
return $plans;
}
}