diff --git a/app/Http/Controllers/LandingController.php b/app/Http/Controllers/LandingController.php index 8299bca..8688cb8 100644 --- a/app/Http/Controllers/LandingController.php +++ b/app/Http/Controllers/LandingController.php @@ -2,7 +2,10 @@ namespace App\Http\Controllers; +use App\Models\Subscription; +use App\Services\Billing\AddonCatalogue; use App\Services\Billing\PlanCatalogue; +use App\Support\ProvisioningSettings; use Illuminate\Contracts\View\View; use Illuminate\Support\Facades\Log; use Throwable; @@ -22,15 +25,44 @@ use Throwable; * 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. */ + /** + * 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', - 'subdomain' => 'Adresse auf clupilot.cloud', + // Filled in from the configured zone by features(), 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 something else entirely. + 'subdomain' => 'Adresse auf :zone', 'custom_domain' => 'Eigene Domain', 'office' => 'Office im Browser', 'branding' => 'Ihr Logo & Ihre Farben', @@ -41,38 +73,355 @@ class LandingController extends Controller '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, - 'featureRows' => $this->featureRows($plans), + 'baseline' => $this->baseline($plans), + 'comparison' => $this->comparison($plans, $modules), + 'addons' => $this->addons($modules), ]); } /** - * Every feature any sellable plan carries, in the catalogue's narrative - * order — the row headings of the price sheet. - * - * Built from what is on sale rather than from the full list, so a feature - * no current plan offers does not appear as an empty row. + * The promises that hold for every package on sale. * * @param array> $plans - * @return array + * @return array> */ - private function featureRows(array $plans): array + private function baseline(array $plans): array { - $offered = array_merge(...array_column($plans, 'features')) ?: []; - return array_values(array_filter( - self::FEATURES, - fn (string $label) => in_array($label, $offered, true), + 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; + }, )); } /** - * @return array> empty when the catalogue cannot be read + * 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 — 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. + isset($modules[$key]) => ['state' => 'optional', 'price' => $this->modulePrice($modules[$key])], + default => ['state' => 'absent'], + }; + } + + $rows[] = ['label' => (string) $this->label($key), 'cells' => $cells]; + } + + return $rows; + } + + /** + * 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 { @@ -103,6 +452,12 @@ class LandingController extends Controller '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), ]; } @@ -110,6 +465,26 @@ class LandingController extends Controller 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' => '£']; @@ -135,6 +510,19 @@ class LandingController extends Controller 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 @@ -152,7 +540,7 @@ class LandingController extends Controller // 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) => self::FEATURES[$key] ?? null, + fn (string $key) => $this->label($key), $keys, ))); } diff --git a/config/provisioning.php b/config/provisioning.php index b3ef3b9..c391dc9 100644 --- a/config/provisioning.php +++ b/config/provisioning.php @@ -129,6 +129,12 @@ return [ 'extra_backups' => ['price_cents' => 500], 'priority_support' => ['price_cents' => 2900], 'collabora_pro' => ['price_cents' => 1900], + // The owner's commercial figure, to be adjusted once the first ones are + // sold. Listed here because the public sheet has to answer what a plan + // without an own domain costs to give one — an unpriced feature can only + // be shown as absent, and it was being shown that way to the two plans + // we would most like to sell it to. + 'custom_domain' => ['price_cents' => 900], ], // Feature bullets moved onto plan_versions.features, so that what a version diff --git a/lang/de/billing.php b/lang/de/billing.php index b611251..9e6e042 100644 --- a/lang/de/billing.php +++ b/lang/de/billing.php @@ -105,5 +105,6 @@ return [ 'extra_backups' => ['name' => 'Tägliche Off-Site-Backups', 'desc' => 'Zusätzliche verschlüsselte Backups an einem zweiten Standort.'], 'priority_support' => ['name' => 'Priority-Support', 'desc' => 'Bevorzugte Bearbeitung, Reaktionszeit unter 1 Stunde.'], 'collabora_pro' => ['name' => 'Collabora Online Pro', 'desc' => 'Erweiterte Office-Funktionen und mehr gleichzeitige Bearbeiter.'], + 'custom_domain' => ['name' => 'Eigene Domain', 'desc' => 'Ihre Cloud unter Ihrer eigenen Adresse, samt Zertifikat und Einrichtung.'], ], ]; diff --git a/lang/en/billing.php b/lang/en/billing.php index a2c6a65..8a81c69 100644 --- a/lang/en/billing.php +++ b/lang/en/billing.php @@ -105,5 +105,6 @@ return [ 'extra_backups' => ['name' => 'Daily off-site backups', 'desc' => 'Additional encrypted backups at a second location.'], 'priority_support' => ['name' => 'Priority support', 'desc' => 'Priority handling, response time under 1 hour.'], 'collabora_pro' => ['name' => 'Collabora Online Pro', 'desc' => 'Advanced office features and more concurrent editors.'], + 'custom_domain' => ['name' => 'Own domain', 'desc' => 'Your cloud under your own address, certificate and setup included.'], ], ]; diff --git a/resources/views/landing.blade.php b/resources/views/landing.blade.php index f01755b..8eeaf93 100644 --- a/resources/views/landing.blade.php +++ b/resources/views/landing.blade.php @@ -517,46 +517,124 @@ @endforeach -
- - Alle Merkmale im direkten Vergleich - - -
- - - - - - @foreach ($plans as $plan) - - @endforeach - - - - @foreach ($featureRows as $feature) - - + {{-- The baseline, said once and said first. Four of these were rows of + ticks running straight down every column of the table below, and + the same promises were repeated in a prose line under it — a + visitor read the comparison twice to learn the two places where it + compared nothing. The address is shown as the address, not as the + phrase "eigene Subdomain": what is worth knowing about it is its + shape. --}} +
+
+

In jedem Paket enthalten

+ ohne Aufpreis, ab dem ersten Tag +
+
+ @foreach ($baseline as $item) +
+ +
+ {{ $item['label'] }} + $item['mono'] ?? false, + ])>{{ $item['detail'] }} +
+
+ @endforeach +
+
+ + {{-- Only what differs, and three answers instead of two. A dash used to + mean both "you cannot have this" and "you can have this for nine + euros", which is how a page that sells five modules came to mention + none of them. --}} + @if (! empty($comparison)) +
+ + Was die Pakete unterscheidet + + + {{-- The table keeps its own scroll. Four columns of prose cells + do not fit a phone, and a page that scrolls sideways as a + whole loses its header and its margins with it. --}} +
+
Pakete und Merkmale im Vergleich
Merkmal{{ $plan['name'] }}
{{ $feature }}
+ + + + @foreach ($plans as $plan) - + @endforeach - @endforeach - -
Was die Pakete unterscheidet
Merkmal - @if (in_array($feature, $plan['features'], true)) - - @else - - @endif - {{ $plan['name'] }}
-
-
+ + + @foreach ($comparison as $row) + + {{ $row['label'] }} + @foreach ($plans as $plan) + @php($cell = $row['cells'][$plan['key']] ?? ['state' => 'absent']) + + @switch($cell['state']) + @case('included') + + inklusive + + @break -

- In jedem Paket: tägliche verschlüsselte Sicherung mit monatlichem Wiederherstellungstest · - Updates, Wartung und Überwachung · AV-Vertrag und TOM-Dokumentation · Support auf Deutsch am selben Werktag. -

+ @case('optional') + + optional · {{ $cell['price'] }} + + @break + + @case('level') + {{ $cell['text'] }} + @if ($cell['note']) + {{ $cell['note'] }} + @endif + @break + + @default + + @endswitch + + @endforeach + + @endforeach + + + +

+ „optional“ heißt: in diesem Paket nicht enthalten, aber jederzeit zubuchbar — netto pro Monat, monatlich kündbar. + „—“ heißt: in diesem Paket nicht verfügbar. +

+ + @endif + + {{-- What costs extra, with the price it costs. The modules were on sale + the whole time and the page named none of them; the owner's + complaint was simply that nothing on it said so. --}} + @if (! empty($addons)) +
+
+

Optional dazubuchbar

+ zu jedem Paket, monatlich kündbar +
+
+ @foreach ($addons as $addon) +
+ {{ $addon['name'] }} + {{ $addon['body'] }} + + +{{ $addon['price'] }}/Monat + +
+ @endforeach +
+
+ @endif @endif diff --git a/tests/Feature/LandingPriceSheetTest.php b/tests/Feature/LandingPriceSheetTest.php index 43af041..15699b8 100644 --- a/tests/Feature/LandingPriceSheetTest.php +++ b/tests/Feature/LandingPriceSheetTest.php @@ -13,8 +13,48 @@ use Illuminate\Support\Str; * the site advertised 249 € for a plan the checkout charged 399 € for. These * tests exist so that cannot come back — and so a catalogue that is broken * takes the price sheet down, never the whole front page. + * + * The sheet is three blocks — what every package has, what tells them apart, + * what costs extra — and the tests below are mostly about the seams between + * them: the same words mean different things in each block, so a promise made + * once above the table must not reappear as a row inside it, and a cell that + * says nothing must be distinguishable from one that says "nine euros". */ +/** + * The comparison table's own markup. + * + * A search across the whole page cannot tell the blocks apart — "Überwachung + * rund um die Uhr" is a promise above the table and a card bullet beside it, + * and the point of the rebuild is that it is no longer a row inside it. + */ +function priceSheetTable(string $html): string +{ + $section = substr($html, (int) strpos($html, 'id="preise"')); + $start = strpos($section, '') - $start); +} + +/** One row of that table, by its heading — the cells of a single feature. */ +function priceSheetRow(string $html, string $label): string +{ + $table = priceSheetTable($html); + $at = strpos($table, '>'.$label.''); + + if ($at === false) { + return ''; + } + + $start = (int) strrpos(substr($table, 0, $at), '', $at) - $start); +} + it('quotes the price the catalogue would actually charge', function () { $team = PlanFamily::query()->where('key', 'team')->firstOrFail(); $version = PlanVersion::query()->where('plan_family_id', $team->id)->firstOrFail(); @@ -113,6 +153,111 @@ it('shows the audience line the console saved, not a string compiled into the co ->assertDontSee('Für wachsende Teams'); }); +it('states what every package has once, above the table instead of as a row in it', function () { + // Four rows of ticks running straight down every column, and then the same + // promises again in a line under the table. Neither told a visitor anything + // about the choice they were there to make. + $content = $this->get('/')->assertOk()->getContent(); + + expect($content)->toContain('In jedem Paket enthalten') + ->and($content)->toContain('Überwachung rund um die Uhr') + // That the table was found at all: every "is not in the table" + // assertion in this file passes for free against an empty string. + ->and(priceSheetTable($content))->toContain('Eigene Domain') + ->and(priceSheetTable($content))->not->toContain('Überwachung rund um die Uhr') + // The prose line that used to repeat it under the table is gone with it. + ->and($content)->not->toContain('In jedem Paket:'); +}); + +it('offers what a plan lacks at the price the module catalogue would charge', function () { + // The third cell state, and the reason it exists: start and team do not + // carry an own domain, but we sell one — a dash there was the page telling + // a visitor no on a sale we would happily make. + $this->get('/')->assertOk()->assertSee("optional · 9\u{00A0}€", false); + + // And the figure follows the catalogue. Written into the template it would + // be right until the first time somebody changed the price and read the + // page as confirmation. + config()->set('provisioning.addons.custom_domain.price_cents', 1900); + + $this->get('/') + ->assertOk() + ->assertSee("optional · 19\u{00A0}€", false) + ->assertDontSee("optional · 9\u{00A0}€", false); +}); + +it('leaves a feature we do not sell as a dash, without inventing a price for it', function () { + // Longer retention is not on the module list, so start does not get an + // offer for it. Being able to say "no" is what makes the "optional" cells + // above worth anything. + $row = priceSheetRow($this->get('/')->assertOk()->getContent(), 'Verlängerte Aufbewahrung'); + + expect($row)->toContain('—') + ->and($row)->not->toContain('optional') + ->and($row)->not->toContain('€'); +}); + +it('shows one support level per plan, and never a dash for the best of them', function () { + // priority_support and premium_sla are steps on one ladder, and as two + // independent rows 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 —" beside team's tick. + $content = $this->get('/')->assertOk()->getContent(); + $row = priceSheetRow($content, 'Support'); + + expect($row)->toContain('Premium') + ->and($row)->toContain('Standard') + ->and($row)->not->toContain('—') + // The two booleans are gone from the table as separate rows. + ->and(priceSheetTable($content))->not->toContain('Vereinbarte Reaktionszeiten'); +}); + +it('shows the platform address in the shape a customer will type it, under the configured zone', function () { + // "Eigene CluPilot-Subdomain" as a table row was meaningless twice over: + // every instance gets one, and the phrase does not say what it looks like. + config()->set('provisioning.dns.zone', 'wolke.test'); + + $this->get('/') + ->assertOk() + ->assertSee('ihrefirma.wolke.test') + ->assertDontSee('ihrefirma.clupilot.com'); +}); + +it('names the modules it sells, with the prices the catalogue charges for them', function () { + // The complaint that started this: the shop sold five modules and the + // public page mentioned none of them. + $storage = (int) config('provisioning.storage_addon.gb'); + $money = fn (int $cents) => number_format($cents / 100, 0, ',', '.')."\u{00A0}€"; + + $page = $this->get('/')->assertOk(); + + $page->assertSee('Optional dazubuchbar'); + + foreach (['extra_backups', 'priority_support', 'collabora_pro', 'custom_domain'] as $key) { + $page->assertSee($money((int) config("provisioning.addons.{$key}.price_cents")), false); + } + + // The storage pack is sold by the pack, so the sheet says how big one is. + $page->assertSee($storage.' GB') + ->assertSee($money((int) config('provisioning.storage_addon.price_cents')), false); +}); + +it('keeps the sheet up when nothing is on sale on top of a package', function () { + // An installation that sells no modules is a configuration, not a failure. + // The block disappears; the cells that could have been an offer go back to + // being honest dashes. + config()->set('provisioning.addons', []); + config()->set('provisioning.storage_addon', []); + + $this->get('/') + ->assertOk() + ->assertDontSee('Optional dazubuchbar') + ->assertDontSee('optional · ', false) + // The comparison itself is untouched: an own domain still tells the + // packages apart, it just cannot be bought into the smaller ones. + ->assertSee('Eigene Domain'); +}); + it('drops a catalogue feature it has no wording for, rather than printing its key', function () { $version = PlanVersion::query() ->where('plan_family_id', PlanFamily::query()->where('key', 'team')->value('id'))