122 lines
5.1 KiB
PHP
122 lines
5.1 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.
|
|
*/
|
|
#[Layout('layouts.portal')]
|
|
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'];
|
|
|
|
$plans[] = [
|
|
'key' => $key,
|
|
'name' => (string) $plan['name'],
|
|
'audience' => (string) ($plan['audience'] ?? ''),
|
|
'gross' => $tax->chargeCents($net),
|
|
'net' => $net,
|
|
'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;
|
|
}
|
|
}
|