'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> $plans * @return array> */ 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> $plans * @param array $modules * @return array>}> */ 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 $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> $plans * @param array $modules * @return array{label: string, cells: array>}|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 */ 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 $modules * @return array */ 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> 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 $keys * @return array */ 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, ))); } }