diff --git a/app/Http/Controllers/LandingController.php b/app/Http/Controllers/LandingController.php index eb4d961..d598e81 100644 --- a/app/Http/Controllers/LandingController.php +++ b/app/Http/Controllers/LandingController.php @@ -479,32 +479,52 @@ class LandingController extends Controller * The request-a-quote block that replaces Enterprise's column once the * family is no longer a priced catalogue entry. * - * Null while Enterprise is still in `$plans` — today's state, and the one - * every install starts in — so the sheet renders exactly as before this - * method existed. Read off `$plans` rather than queried again from - * PlanFamily: `$plans` already IS PlanCatalogue::sellable()'s answer for - * this request, and a second "is Enterprise on sale" question here could - * drift from it the moment either changed — the same split-brain this - * class exists to avoid. + * Null in two cases. While Enterprise is still in `$plans` — today's + * state, and the one every install starts in — the sheet renders exactly + * as before this method existed. And when `$plans` itself is empty: that + * is the catalogue-read-failed state the class docblock already covers, + * the sheet already says "Preise derzeit auf Anfrage" for it, and there + * is no "largest sellable package" to measure the heading against — + * inventing a size here (or worse, `max()` on an empty array) would be + * the same lie in a new place, not a fix for the empty one. * - * The copy is fixed rather than pulled from the catalogue on purpose: - * there is no price to quote. From 500 GB a customer gets a dedicated - * machine, and the shop would misprice it anyway — placeableIn() finds no - * host on the 388 GB a shared machine actually has to give away, so a - * checkout would take the payment and only then discover it cannot - * deliver. + * Read off `$plans` rather than queried again from PlanFamily or + * PlanVersion: `$plans` already IS PlanCatalogue::sellable()'s answer for + * this request, and a second "how big is the largest package" question + * here could drift from it the moment either changed — the same + * split-brain this class exists to avoid. + * + * The heading names the largest sellable quota rather than a number typed + * into the language file, for the same reason: a plan restructure changes + * what the largest package holds (the pricing rebuild this task is part + * of does exactly that to Business), and a fixed figure would go on + * quietly naming the wrong ceiling with nothing to catch it. There is + * deliberately no SECOND, lower threshold ("ab 500 GB") in the copy any + * more either — the dedicated machine starts exactly where the largest + * sellable package ends; how big any particular one becomes is a + * hardware question answered per quote, not a second number on the price + * sheet. + * + * The rest of the copy is fixed rather than pulled from the catalogue: + * there is no price to quote for a machine that is not built yet, and + * Enterprise cannot simply become a normal catalogue row again either — + * placeableIn() finds no host on the 388 GB a shared machine actually has + * to give away, so a checkout would take the payment and only then + * discover it cannot deliver. * * @param array> $plans * @return array{title: string, body: string, cta: string, href: string}|null */ private function enterprise(array $plans): ?array { - if (in_array('enterprise', array_column($plans, 'key'), true)) { + if ($plans === [] || in_array('enterprise', array_column($plans, 'key'), true)) { return null; } return [ - 'title' => __('landing.enterprise_title'), + 'title' => __('landing.enterprise_title', [ + 'quota' => $this->storage((int) max(array_column($plans, 'quota_gb'))), + ]), 'body' => __('landing.enterprise_body'), 'cta' => __('landing.enterprise_cta'), // No `contact` route exists (checked against routes/web.php), and @@ -574,6 +594,12 @@ class LandingController extends Controller 'price_twelve_months' => $this->money($this->gross((int) $plan['price_cents']) * 12, (string) $plan['currency']), 'free_months' => (int) ($plan['free_months'] ?? 0), 'storage' => $this->storage((int) $plan['quota_gb']), + // The raw figure beside its formatted twin, same reason as + // 'keys' beside 'features': enterprise() has to find the + // largest sellable quota to name in its heading, and a second + // catalogue read to get an unformatted number back out of a + // "500 GB" string would be worse than carrying the int along. + 'quota_gb' => (int) $plan['quota_gb'], 'traffic' => $this->storage((int) $plan['traffic_gb']), 'seats' => (int) $plan['seats'], 'features' => $this->features($plan['features'] ?? []), diff --git a/lang/de/landing.php b/lang/de/landing.php index 014e904..3b619bc 100644 --- a/lang/de/landing.php +++ b/lang/de/landing.php @@ -1,10 +1,15 @@ 'Mehr als 175 GB? Dann eine eigene Maschine.', - 'enterprise_body' => 'Ab 500 GB richten wir einen Server ein, auf dem nur Ihre Cloud läuft — Größe, Leistung und Sicherung nach Ihrem Bedarf. Wir rechnen Ihnen das durch.', + // :quota ist die Kontingentgrenze des größten gerade verkäuflichen + // Pakets, live aus $plans gelesen (LandingController::enterprise()) statt + // hier fest eingetragen — eine Umstellung der Paketleiter verschiebt + // sonst, was "am größten" ist, und der Satz würde still die falsche Zahl + // nennen. Keine zweite, feste Untergrenze mehr im Fließtext ("ab 500 GB"): + // die eigene Maschine beginnt dort, wo das größte Paket endet, und wie + // groß sie wird, ist eine Frage der gekauften Hardware, nicht des + // Preisblatts. + 'enterprise_title' => 'Mehr als :quota? Dann eine eigene Maschine.', + 'enterprise_body' => 'Wir richten Ihnen einen Server ein, auf dem nur Ihre Cloud läuft — Größe, Leistung und Sicherung nach Ihrem Bedarf. Wir rechnen Ihnen das durch.', 'enterprise_cta' => 'Angebot anfragen', ]; diff --git a/lang/en/landing.php b/lang/en/landing.php index 0adf56c..7441c9e 100644 --- a/lang/en/landing.php +++ b/lang/en/landing.php @@ -1,10 +1,14 @@ 'More than 175 GB? Then a dedicated machine.', - 'enterprise_body' => 'From 500 GB we set up a server that runs only your cloud — sized, powered and backed up to your needs. We work out the numbers with you.', + // :quota is the quota ceiling of the largest package currently on sale, + // read live off $plans (LandingController::enterprise()) rather than + // written in here — a catalogue restructure moves what "the largest" is, + // and a fixed figure would go on quietly naming the wrong one. No fixed + // second, lower threshold in the body either ("from 500 GB"): the + // dedicated machine starts exactly where the largest package ends, and + // how big it gets is a hardware question, not a price-sheet number. + 'enterprise_title' => 'More than :quota? Then a dedicated machine.', + 'enterprise_body' => 'We set up a server that runs only your cloud — sized, powered and backed up to your needs. We work out the numbers with you.', 'enterprise_cta' => 'Request a quote', ]; diff --git a/tests/Feature/LandingPriceSheetTest.php b/tests/Feature/LandingPriceSheetTest.php index 7e2ae02..094f69c 100644 --- a/tests/Feature/LandingPriceSheetTest.php +++ b/tests/Feature/LandingPriceSheetTest.php @@ -315,11 +315,14 @@ it('drops a catalogue feature it has no wording for, rather than printing its ke it('leaves Enterprise in the comparison table while it is still on sale', function () { // Today's state (sales_enabled defaults to true): nothing about this task - // changes the page until the owner actually flips the switch. + // changes the page until the owner actually flips the switch. Checked + // against the CTA text rather than the (now interpolated) heading — a + // heading built with no replacement never matches what the page actually + // renders anyway, sellable or not, and would prove nothing either way. $content = $this->get('/')->assertOk()->getContent(); expect(priceSheetTable($content))->toContain('>Enterprise<') - ->and($content)->not->toContain(__('landing.enterprise_title')); + ->and($content)->not->toContain(__('landing.enterprise_cta')); }); it('shows Enterprise as a request for a quote once it is taken off sale', function () { @@ -330,9 +333,55 @@ it('shows Enterprise as a request for a quote once it is taken off sale', functi $content = $this->get('/')->assertOk()->getContent(); - expect($content)->toContain(__('landing.enterprise_title')) + expect($content) + // Business (seeded at 1000 GB, which formats as "1 TB") is the + // largest family left once Enterprise drops out of $plans. + ->toContain(__('landing.enterprise_title', ['quota' => '1 TB'])) ->and($content)->toContain(__('landing.enterprise_cta')) // No price is left to compare it against once it drops out of // PlanCatalogue::sellable() — not a dash in a row, no column at all. ->and(priceSheetTable($content))->not->toContain('>Enterprise<'); }); + +it('names the largest sellable quota in the heading, not a figure typed into the language file', function () { + // The point of Change 2: a plan restructure moves what "the largest + // package" holds, and the heading has to move with it. Two different + // sizes in a row is the actual proof — a single value could just as + // easily be the coincidence of matching a number already sitting there. + App\Models\PlanFamily::query()->where('key', 'enterprise')->update(['sales_enabled' => false]); + $businessId = PlanFamily::query()->where('key', 'business')->value('id'); + + // Both above Team's own quota, so Business stays the largest family + // throughout — otherwise this would accidentally test Team's figure + // showing through instead of proving Business's tracks its own change. + DB::table('plan_versions')->where('plan_family_id', $businessId)->update(['quota_gb' => 555]); + $first = $this->get('/')->assertOk()->getContent(); + + DB::table('plan_versions')->where('plan_family_id', $businessId)->update(['quota_gb' => 777]); + $second = $this->get('/')->assertOk()->getContent(); + + expect($first)->toContain(__('landing.enterprise_title', ['quota' => '555 GB'])) + ->and($second)->toContain(__('landing.enterprise_title', ['quota' => '777 GB'])) + ->and($second)->not->toContain(__('landing.enterprise_title', ['quota' => '555 GB'])); +}); + +it('keeps the front page up instead of naming a size when no package is sellable at all', function () { + // The edge Change 2 introduced a way to break: max() on an empty list of + // quotas throws. Mirrors the existing "catalogue cannot be read" test + // above — two overlapping versions make PlanCatalogue::sellable() refuse + // to guess, $plans comes back empty, and there is no "largest package" + // left to name a heading against. + Log::spy(); + + $team = PlanFamily::query()->where('key', 'team')->firstOrFail(); + $original = PlanVersion::query()->where('plan_family_id', $team->id)->firstOrFail(); + + $clone = collect($original->getAttributes())->except('id')->all(); + $clone['uuid'] = (string) Str::uuid(); + $clone['version'] = $original->version + 1; + DB::table('plan_versions')->insert($clone); + + $this->get('/') + ->assertOk() + ->assertDontSee(__('landing.enterprise_cta')); +});