633 lines
27 KiB
PHP
633 lines
27 KiB
PHP
<?php
|
|
|
|
namespace App\Http\Controllers;
|
|
|
|
use App\Models\Subscription;
|
|
use App\Services\Billing\AddonCatalogue;
|
|
use App\Services\Billing\CustomDomainAccess;
|
|
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 App\Support\ProvisioningSettings;
|
|
use Illuminate\Contracts\View\View;
|
|
use Illuminate\Support\Facades\Log;
|
|
use Throwable;
|
|
|
|
/**
|
|
* The public price sheet.
|
|
*
|
|
* Prices, storage and seat counts are read from the catalogue rather than
|
|
* written into the page. They were hard-coded here once, and the page and the
|
|
* catalogue had already drifted apart on three of four plans — a visitor was
|
|
* quoted 249 € for a plan that charged 399 € at checkout. The marketing site is
|
|
* a reader of the catalogue like every other caller.
|
|
*
|
|
* The catalogue fails loudly by design: an empty or overlapping catalogue is an
|
|
* outage for commerce, not something to paper over. But a public website is not
|
|
* commerce — a mistyped availability window must not take the company's front
|
|
* page down with it. So the failure is caught HERE and only here: the page
|
|
* still renders, the price sheet is replaced by "on request", and nobody is
|
|
* shown a number the checkout would not honour.
|
|
*
|
|
* The sheet is three blocks, not one matrix. A single grid of ticks was being
|
|
* asked to carry three different kinds of sentence at once, and it made all
|
|
* three unreadable: what every package has (a tick straight down every column,
|
|
* which is no information at all), what tells the packages apart, and what
|
|
* costs extra. Worse, two of the columns were lying by construction — support
|
|
* was modelled as two independent booleans, so the top plan rendered a dash
|
|
* where the middle one had a tick, and the platform address was modelled as if
|
|
* an own domain replaced it, when provisioning issues both. Each block now
|
|
* makes one kind of statement: BASELINE what everyone gets, the comparison
|
|
* table what differs, ADDONS what is for sale on top.
|
|
*/
|
|
class LandingController extends Controller
|
|
{
|
|
/**
|
|
* Catalogue feature keys, in the words a customer uses.
|
|
*
|
|
* Deliberately separate from the operator-facing labels in
|
|
* lang/*\/billing.php's `feature` array, which name the same keys for the
|
|
* console's checkboxes. The two are different registers for different
|
|
* readers — "Protokollierung der Zugriffe" here versus "Audit-Log" there —
|
|
* and features are catalogue-wide, not per-plan, so there is nowhere on a
|
|
* plan family or version to hang an editable customer-facing label
|
|
* without re-introducing a shared feature table. That table
|
|
* (`plan_features`) existed once and was deliberately removed in the
|
|
* catalogue rebuild that closed the pricing split-brain; bringing it back
|
|
* for wording alone is a bigger, separate decision.
|
|
*/
|
|
private const FEATURES = [
|
|
'managed_updates' => 'Updates & Wartung',
|
|
'daily_backups' => 'Tägliche Sicherung',
|
|
'monitoring' => 'Überwachung rund um die Uhr',
|
|
// Filled in from the configured zone by zone(), reached through
|
|
// label(), because a hard-coded domain here is the same
|
|
// two-sources-of-truth mistake as a hard-coded price — and it shipped
|
|
// naming clupilot.cloud, a domain the company does not own, while
|
|
// provisioning was issuing addresses under whatever CLUPILOT_DNS_ZONE
|
|
// said.
|
|
'subdomain' => 'Adresse auf :zone',
|
|
'custom_domain' => 'Eigene Domain',
|
|
'office' => 'Office im Browser',
|
|
'branding' => 'Ihr Logo & Ihre Farben',
|
|
'priority_support' => 'Bevorzugter Support',
|
|
'premium_sla' => 'Vereinbarte Reaktionszeiten',
|
|
'extended_retention' => 'Verlängerte Aufbewahrung',
|
|
'audit_log' => 'Protokollierung der Zugriffe',
|
|
'onboarding' => 'Begleitete Einführung',
|
|
];
|
|
|
|
/**
|
|
* What every package carries, said once above the table.
|
|
*
|
|
* Each of these was a row of ticks running straight down all four columns
|
|
* — a comparison that compares nothing — and the same promises were then
|
|
* repeated in a prose line under the table. `key` names the catalogue
|
|
* feature the claim stands on: it keeps the row out of the comparison
|
|
* table, and, unless the claim is `issued`, the whole item is dropped
|
|
* should a plan ever stop carrying it. The page must not promise for
|
|
* "jedes Paket" what one package does not have.
|
|
*
|
|
* The last two have no key because the catalogue does not model them. They
|
|
* are the promises the prose line under the table made, kept word for word
|
|
* rather than reworded on the way in.
|
|
*/
|
|
private const BASELINE = [
|
|
[
|
|
'icon' => 'refresh',
|
|
'label' => 'Updates & Wartung',
|
|
'detail' => 'Betriebssystem und Anwendung, eingespielt außerhalb Ihrer Arbeitszeit.',
|
|
'key' => 'managed_updates',
|
|
],
|
|
[
|
|
'icon' => 'database',
|
|
'label' => 'Tägliche verschlüsselte Sicherung',
|
|
'detail' => 'Mit monatlichem Wiederherstellungstest, protokolliert.',
|
|
'key' => 'daily_backups',
|
|
],
|
|
[
|
|
'icon' => 'activity',
|
|
'label' => 'Überwachung rund um die Uhr',
|
|
'detail' => 'Erreichbarkeit, Speicher und Dienste — Alarm, bevor Sie es merken.',
|
|
'key' => 'monitoring',
|
|
],
|
|
[
|
|
'icon' => 'globe',
|
|
'label' => 'Ihre Adresse ab der ersten Minute',
|
|
// The shape, not the word for it. "Eigene Subdomain" tells a
|
|
// customer nothing; the address they will actually type does.
|
|
'detail' => 'ihrefirma.:zone',
|
|
'mono' => true,
|
|
// Issued, not sold: ConfigureDnsAndTls gives every instance its
|
|
// platform address whatever `subdomain` says on the plan version
|
|
// that was frozen years ago — business and enterprise do not carry
|
|
// the flag and get the address anyway. The key is named so the row
|
|
// leaves the comparison table; the promise does not hang on it.
|
|
'key' => 'subdomain',
|
|
'issued' => true,
|
|
],
|
|
[
|
|
'icon' => 'file-text',
|
|
'label' => 'AV-Vertrag & TOM-Dokumentation',
|
|
'detail' => 'Schriftlich, für Ihre eigene Dokumentation und Prüfung.',
|
|
],
|
|
[
|
|
'icon' => 'life-buoy',
|
|
'label' => 'Support auf Deutsch',
|
|
'detail' => 'Antwort am selben Werktag, ohne Warteschleife.',
|
|
],
|
|
];
|
|
|
|
/**
|
|
* The support level a plan carries, strongest first.
|
|
*
|
|
* One axis, not two. `priority_support` and `premium_sla` are steps on the
|
|
* same ladder, and as independent tick boxes they made the top plan look
|
|
* worse than the middle one: enterprise carries the SLA and not the
|
|
* priority flag, so it rendered "Bevorzugter Support —" while team
|
|
* rendered a tick. No plan carrying either is not an absence — standard
|
|
* support is a real level, and the baseline block above says what it is.
|
|
*/
|
|
private const SUPPORT = [
|
|
'premium_sla' => ['level' => 'Premium', 'note' => 'mit zugesagter Reaktionszeit'],
|
|
'priority_support' => ['level' => 'Bevorzugt', 'note' => 'Anfragen werden vorgezogen'],
|
|
];
|
|
|
|
private const SUPPORT_STANDARD = ['level' => 'Standard', 'note' => 'Antwort am selben Werktag'];
|
|
|
|
/**
|
|
* The modules we sell on top of a package, in the words a customer uses.
|
|
*
|
|
* Same split as FEATURES and for the same reason: lang/*\/billing.php names
|
|
* these for the portal's booking cards ("Collabora Online Pro"), which is
|
|
* the register of somebody who already bought. A visitor deciding has not
|
|
* heard of Collabora. The prices are never written here — they come from
|
|
* AddonCatalogue, so the sheet and the booking page cannot disagree.
|
|
*/
|
|
private const ADDONS = [
|
|
'extra_backups' => [
|
|
'name' => 'Zweiter Sicherungsort',
|
|
'body' => 'Eine zusätzliche verschlüsselte Kopie Ihrer Daten, getrennt vom ersten Ort gelagert.',
|
|
],
|
|
'priority_support' => [
|
|
'name' => 'Bevorzugter Support',
|
|
'body' => 'Ihre Anfragen werden vorgezogen — Reaktion innerhalb einer Stunde statt am selben Werktag.',
|
|
],
|
|
'collabora_pro' => [
|
|
'name' => 'Office mit vollem Funktionsumfang',
|
|
'body' => 'Mehr gleichzeitige Bearbeiter und die erweiterten Funktionen der Office-Integration.',
|
|
],
|
|
'custom_domain' => [
|
|
'name' => 'Eigene Domain',
|
|
'body' => 'Ihre Cloud unter Ihrer eigenen Adresse, samt Zertifikat und Einrichtung.',
|
|
],
|
|
AddonCatalogue::STORAGE => [
|
|
'name' => 'Zusatzspeicher',
|
|
'body' => 'Ein Paket mit :size — jederzeit ergänzbar, ohne Umzug Ihrer Daten.',
|
|
],
|
|
];
|
|
|
|
public function __invoke(): View
|
|
{
|
|
$plans = $this->plans();
|
|
|
|
// Read once and handed to both blocks that quote a module price, so the
|
|
// "optional" cell in the table and the module list underneath it cannot
|
|
// print two different figures for the same thing.
|
|
$modules = $this->modulePrices();
|
|
|
|
$tax = CompanyProfile::taxRate();
|
|
// Through SetupFee, which is also what the checkout charges. The sentence
|
|
// on this page named a figure that nothing collected for months; now the
|
|
// quote and the charge come out of one place, so a change to either the fee
|
|
// or the rate cannot move one without moving the other.
|
|
$setupNet = SetupFee::netCents();
|
|
|
|
return view('landing', [
|
|
'plans' => $plans,
|
|
// What the page has to say about tax and about the one-off fee, in
|
|
// one place. Both are console-editable figures, and both were
|
|
// previously sentences on the page: "Netto pro Monat" and "zzgl.
|
|
// einmaliger Einrichtung" — the second of which never named a
|
|
// number at all, so a visitor learned only that there was one.
|
|
'vat' => [
|
|
'rate' => $tax,
|
|
'label' => rtrim(rtrim(number_format($tax, 1, ',', '.'), '0'), ','),
|
|
],
|
|
'setup' => $setupNet === 0 ? null : [
|
|
'gross' => $this->money(SetupFee::chargedCents(TaxTreatment::domestic()), Subscription::catalogueCurrency()),
|
|
'net' => $this->money($setupNet, Subscription::catalogueCurrency()),
|
|
],
|
|
'baseline' => $this->baseline($plans),
|
|
'comparison' => $this->comparison($plans, $modules),
|
|
'addons' => $this->addons($modules),
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* The promises that hold for every package on sale.
|
|
*
|
|
* @param array<int, array<string, mixed>> $plans
|
|
* @return array<int, array<string, mixed>>
|
|
*/
|
|
private function baseline(array $plans): array
|
|
{
|
|
return array_values(array_filter(
|
|
array_map(fn (array $item) => [...$item, 'detail' => $this->zone($item['detail'])], self::BASELINE),
|
|
function (array $item) use ($plans) {
|
|
$key = $item['key'] ?? null;
|
|
|
|
if ($key === null || ($item['issued'] ?? false)) {
|
|
return true;
|
|
}
|
|
|
|
// "In jedem Paket" is a claim about all of them, so one plan
|
|
// without the feature retires the claim rather than weakening
|
|
// it to a footnote nobody reads.
|
|
foreach ($plans as $plan) {
|
|
if (! in_array($key, $plan['keys'], true)) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
},
|
|
));
|
|
}
|
|
|
|
/**
|
|
* The rows on which the packages actually differ, each cell in one of three
|
|
* states: carried, buyable, or neither.
|
|
*
|
|
* Two states could not tell "you do not get this" apart from "you can have
|
|
* it for nine euros", so the sheet said the first about four things we
|
|
* would happily have sold — which is how the page came to mention none of
|
|
* the modules at all.
|
|
*
|
|
* Built from what is on sale rather than from the full feature list, so a
|
|
* feature no current plan offers does not appear as an empty row.
|
|
*
|
|
* @param array<int, array<string, mixed>> $plans
|
|
* @param array<string, int> $modules
|
|
* @return array<int, array{label: string, cells: array<string, array<string, mixed>>}>
|
|
*/
|
|
private function comparison(array $plans, array $modules): array
|
|
{
|
|
$baselineKeys = array_column(self::BASELINE, 'key');
|
|
$rows = [];
|
|
$supportPlaced = false;
|
|
|
|
foreach (array_keys(self::FEATURES) as $key) {
|
|
if (in_array($key, $baselineKeys, true)) {
|
|
continue;
|
|
}
|
|
|
|
// The support row takes the place of the first of the two keys it
|
|
// replaces, so the sheet keeps the catalogue's narrative order
|
|
// rather than growing an appendix at the bottom.
|
|
if (isset(self::SUPPORT[$key])) {
|
|
$row = $supportPlaced ? null : $this->supportRow($plans, $modules);
|
|
$supportPlaced = true;
|
|
|
|
if ($row !== null) {
|
|
$rows[] = $row;
|
|
}
|
|
|
|
continue;
|
|
}
|
|
|
|
$offered = array_filter($plans, fn (array $plan) => in_array($key, $plan['keys'], true));
|
|
|
|
if ($offered === []) {
|
|
continue;
|
|
}
|
|
|
|
$cells = [];
|
|
|
|
foreach ($plans as $plan) {
|
|
$cells[$plan['key']] = match (true) {
|
|
in_array($key, $plan['keys'], true) => ['state' => 'included'],
|
|
// Not carried, but on sale TO THIS PACKAGE — the only
|
|
// honest third answer. A module we do not sell stays a
|
|
// dash, however easy it would be to invent a price for it,
|
|
// and so does one this package may not book: an own domain
|
|
// is impossible on the entry package, so quoting nine euros
|
|
// there would be selling something we would then refuse.
|
|
isset($modules[$key]) && $this->bookableOn($key, $plan) => ['state' => 'optional', 'price' => $this->modulePrice($modules[$key])],
|
|
default => ['state' => 'absent'],
|
|
};
|
|
}
|
|
|
|
$rows[] = ['label' => (string) $this->label($key), 'cells' => $cells];
|
|
}
|
|
|
|
return $rows;
|
|
}
|
|
|
|
/**
|
|
* Whether a package could actually book this module.
|
|
*
|
|
* Only the own domain has such a rule today, and the rule is not restated
|
|
* here: CustomDomainAccess owns it, and the sheet asks. A page that decided
|
|
* for itself which packages may buy a domain would be a second answer to a
|
|
* question the shop already answers, and the two would part company the
|
|
* first time the owner changed one of them.
|
|
*
|
|
* @param array<string, mixed> $plan
|
|
*/
|
|
private function bookableOn(string $key, array $plan): bool
|
|
{
|
|
if ($key !== CustomDomainAccess::ADDON) {
|
|
return true;
|
|
}
|
|
|
|
return app(CustomDomainAccess::class)->bookableOnPlan((string) $plan['key'], $plan['keys']);
|
|
}
|
|
|
|
/**
|
|
* Support as one row of levels.
|
|
*
|
|
* Omitted entirely when no plan carries either key: four columns all
|
|
* reading "Standard" is the row of identical ticks this rebuild removed,
|
|
* and the baseline block has already said what standard support is.
|
|
*
|
|
* @param array<int, array<string, mixed>> $plans
|
|
* @param array<string, int> $modules
|
|
* @return array{label: string, cells: array<string, array<string, mixed>>}|null
|
|
*/
|
|
private function supportRow(array $plans, array $modules): ?array
|
|
{
|
|
$cells = [];
|
|
$differs = false;
|
|
|
|
foreach ($plans as $plan) {
|
|
$carried = self::SUPPORT_STANDARD;
|
|
|
|
// Strongest first: a plan carrying both is shown at the level it
|
|
// actually answers at, not at whichever key was checked first.
|
|
foreach (self::SUPPORT as $key => $level) {
|
|
if (in_array($key, $plan['keys'], true)) {
|
|
$carried = $level;
|
|
$differs = true;
|
|
|
|
break;
|
|
}
|
|
}
|
|
|
|
$cells[$plan['key']] = [
|
|
'state' => 'level',
|
|
'text' => $carried['level'],
|
|
// The upgrade displaces the level's own qualifier: a customer
|
|
// reading the standard cell is better served by what the next
|
|
// step costs than by a restatement of the line above.
|
|
'note' => $carried === self::SUPPORT_STANDARD && isset($modules['priority_support'])
|
|
? 'bevorzugt ab '.$this->modulePrice($modules['priority_support'])
|
|
: $carried['note'],
|
|
];
|
|
}
|
|
|
|
return $differs ? ['label' => 'Support', 'cells' => $cells] : null;
|
|
}
|
|
|
|
/**
|
|
* Today's price for every module actually on sale, in cents.
|
|
*
|
|
* Swallowed like the plan catalogue and for the same reason (see the class
|
|
* docblock): an empty or unreadable module list costs the page a block, not
|
|
* the page. Every caller then reads a plain array, so a module that is not
|
|
* in it is simply not for sale — which is exactly what the dash in the
|
|
* table means.
|
|
*
|
|
* @return array<string, int>
|
|
*/
|
|
private function modulePrices(): array
|
|
{
|
|
try {
|
|
$catalogue = app(AddonCatalogue::class);
|
|
$keys = [...array_keys((array) config('provisioning.addons', [])), AddonCatalogue::STORAGE];
|
|
$prices = [];
|
|
|
|
foreach ($keys as $key) {
|
|
$cents = $catalogue->priceCents((string) $key);
|
|
|
|
// Nothing is sold for nothing. An entry without a price reads
|
|
// back as 0 cents, and "optional · 0 €" on a public price sheet
|
|
// is an offer somebody would hold us to.
|
|
if ($cents === null || $cents <= 0) {
|
|
continue;
|
|
}
|
|
|
|
// The storage pack is sold by the pack, so a pack of no size is
|
|
// not a product either.
|
|
if ($key === AddonCatalogue::STORAGE && (int) config('provisioning.storage_addon.gb', 0) <= 0) {
|
|
continue;
|
|
}
|
|
|
|
$prices[(string) $key] = $cents;
|
|
}
|
|
|
|
return $prices;
|
|
} catch (Throwable $e) {
|
|
Log::error('Landing page could not read the add-on catalogue', ['exception' => $e]);
|
|
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The modules, in the order the catalogue lists them, with today's prices.
|
|
*
|
|
* @param array<string, int> $modules
|
|
* @return array<int, array{name: string, body: string, price: string}>
|
|
*/
|
|
private function addons(array $modules): array
|
|
{
|
|
$rows = [];
|
|
|
|
foreach ($modules as $key => $cents) {
|
|
$copy = self::ADDONS[$key] ?? null;
|
|
|
|
// Same rule as an unknown feature key: a module nobody has written
|
|
// up is left out rather than advertised as "collabora_pro".
|
|
if ($copy === null) {
|
|
continue;
|
|
}
|
|
|
|
$rows[] = [
|
|
'name' => $copy['name'],
|
|
'body' => str_replace(
|
|
':size',
|
|
$this->storage((int) config('provisioning.storage_addon.gb', 0)),
|
|
$copy['body'],
|
|
),
|
|
'price' => $this->modulePrice($cents),
|
|
];
|
|
}
|
|
|
|
return $rows;
|
|
}
|
|
|
|
/**
|
|
* @return array<int, array<string, mixed>> empty when the catalogue cannot be read
|
|
*/
|
|
private function plans(): array
|
|
{
|
|
try {
|
|
$sellable = app(PlanCatalogue::class)->sellable();
|
|
} catch (Throwable $e) {
|
|
// Deliberately swallowed: see the class docblock. Logged at error
|
|
// level because a shop that cannot list its plans is not selling.
|
|
Log::error('Landing page could not read the plan catalogue', ['exception' => $e]);
|
|
|
|
return [];
|
|
}
|
|
|
|
$plans = [];
|
|
$capacity = app(HostCapacity::class);
|
|
|
|
foreach ($sellable as $key => $plan) {
|
|
$plans[] = [
|
|
'key' => $key,
|
|
'name' => $plan['name'],
|
|
// How soon this package can actually be delivered, read from
|
|
// the estate rather than from a sentence somebody typed. Where
|
|
// a host has room it rolls out on its own within the hour;
|
|
// where none has, a machine has to be bought first — and
|
|
// saying "sofort" in that case is a promise the very next
|
|
// customer catches us breaking.
|
|
'delivery' => $capacity->deliveryFor((int) ($plan['disk_gb'] ?? 0)),
|
|
// Console-edited, from the family the plan belongs to — a
|
|
// family that has none yet (created but not written up) gets
|
|
// an empty string rather than a missing array key, and the
|
|
// template renders that sensibly instead of an empty gap.
|
|
'audience' => (string) ($plan['audience'] ?? ''),
|
|
'note' => (string) ($plan['note'] ?? ''),
|
|
// GROSS, because a private customer must never be quoted a
|
|
// figure that grows at checkout. A business reads the net one
|
|
// underneath and gets the VAT back anyway; a consumer does not,
|
|
// and for them the net figure was simply the wrong number.
|
|
'price' => $this->money($this->gross((int) $plan['price_cents']), (string) $plan['currency']),
|
|
'price_net' => $this->money((int) $plan['price_cents'], (string) $plan['currency']),
|
|
'storage' => $this->storage((int) $plan['quota_gb']),
|
|
'traffic' => $this->storage((int) $plan['traffic_gb']),
|
|
'seats' => (int) $plan['seats'],
|
|
'features' => $this->features($plan['features'] ?? []),
|
|
// The raw keys alongside the wording. Everything that has to
|
|
// decide something — which row this plan fills, which support
|
|
// level it answers at — asks the key; only the card bullets
|
|
// read the words. Deciding on the words matched until the
|
|
// first label gained a placeholder, and then quietly stopped.
|
|
'keys' => array_values((array) ($plan['features'] ?? [])),
|
|
'recommended' => (bool) ($plan['recommended'] ?? false),
|
|
];
|
|
}
|
|
|
|
return $plans;
|
|
}
|
|
|
|
/**
|
|
* One feature, in the words a customer uses.
|
|
*
|
|
* The address the platform hands out is a setting, not a constant — a
|
|
* server can be installed against any zone — so the sentence naming it is
|
|
* assembled rather than written down twice.
|
|
*/
|
|
private function label(string $key): ?string
|
|
{
|
|
$label = self::FEATURES[$key] ?? null;
|
|
|
|
return $label === null ? null : $this->zone($label);
|
|
}
|
|
|
|
/** The configured zone, wherever a sentence names it. */
|
|
private function zone(string $text): string
|
|
{
|
|
return str_replace(':zone', ProvisioningSettings::dnsZone(), $text);
|
|
}
|
|
|
|
/** Currencies we have a symbol for; anything else prints its ISO code. */
|
|
private const SYMBOLS = ['EUR' => '€', 'CHF' => 'CHF', 'USD' => '$', 'GBP' => '£'];
|
|
|
|
/**
|
|
* A price as the sheet prints it, currency included.
|
|
*
|
|
* The symbol comes from the catalogue rather than from the template: the
|
|
* currency is configurable (CLUPILOT_CURRENCY), and a page that says "€"
|
|
* while the checkout charges francs is the same two-sources-of-truth
|
|
* mistake as a hard-coded amount, only harder to notice.
|
|
*
|
|
* Whole units when the amount is whole — a price sheet reads "179", not
|
|
* "179,00".
|
|
*/
|
|
/**
|
|
* A net figure as the ordinary customer is charged it.
|
|
*
|
|
* Handed straight to TaxTreatment rather than worked out here. It is the same
|
|
* call the domestic Stripe Price is formed with, so the figure on this page and
|
|
* the figure on the card statement cannot drift apart. It used to be a
|
|
* multiplication of its own, which is a second source of the same number and
|
|
* therefore a second answer waiting to happen.
|
|
*
|
|
* This page is read by visitors nobody has met, so the domestic gross is the
|
|
* only honest figure to put on it. A business in another member state with a
|
|
* verified VAT id is charged less — the bare net, no VAT owed to us at all —
|
|
* and learns that on the pages that know who they are, not here, where
|
|
* advertising it would quote every private visitor a price they cannot have.
|
|
*/
|
|
private function gross(int $netCents): int
|
|
{
|
|
return TaxTreatment::advertisedCents($netCents);
|
|
}
|
|
|
|
private function money(int $cents, string $currency): string
|
|
{
|
|
$amount = $cents % 100 === 0
|
|
? number_format($cents / 100, 0, ',', '.')
|
|
: number_format($cents / 100, 2, ',', '.');
|
|
|
|
// Non-breaking: in a narrow table column "49 €" otherwise wraps, and the
|
|
// currency ends up on a line of its own under the number.
|
|
return $amount."\u{00A0}".(self::SYMBOLS[strtoupper($currency)] ?? strtoupper($currency));
|
|
}
|
|
|
|
/**
|
|
* A module price.
|
|
*
|
|
* A plan price carries its own currency from the catalogue; a module does
|
|
* not, and cannot — config holds one net amount. The one currency the
|
|
* catalogue is priced in is therefore the honest answer, and it is the same
|
|
* one the booking page charges in.
|
|
*/
|
|
private function modulePrice(int $cents): string
|
|
{
|
|
// Gross, like every other figure on this page. A sheet that quotes the
|
|
// package with VAT and the module without it is a sheet where the two
|
|
// numbers cannot be added up.
|
|
return $this->money($this->gross($cents), Subscription::catalogueCurrency());
|
|
}
|
|
|
|
private function storage(int $gb): string
|
|
{
|
|
return $gb >= 1000 && $gb % 1000 === 0
|
|
? ($gb / 1000).' TB'
|
|
: $gb.' GB';
|
|
}
|
|
|
|
/**
|
|
* @param array<int, string> $keys
|
|
* @return array<int, string>
|
|
*/
|
|
private function features(array $keys): array
|
|
{
|
|
// Unknown keys are dropped rather than printed raw: a feature added to
|
|
// the catalogue without a translation would otherwise appear on the
|
|
// public page as "premium_sla".
|
|
return array_values(array_filter(array_map(
|
|
fn (string $key) => $this->label($key),
|
|
$keys,
|
|
)));
|
|
}
|
|
}
|