CluPilotCloud/app/Http/Controllers/LandingController.php

582 lines
24 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\Provisioning\HostCapacity;
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();
return view('landing', [
'plans' => $plans,
'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'] ?? ''),
'price' => $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".
*/
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
{
return $this->money($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,
)));
}
}