Fix-Runde 1/1: Enterprise-Schwelle folgt dem Katalog statt einer festen Zahl
Zwei Betreiber-Entscheidungen aus der Durchsicht:
1. Keine zweite, feste Untergrenze mehr im Fliesstext ("ab 500 GB") -- die
eigene Maschine beginnt dort, wo das groesste Paket endet, ihre Groesse ist
Hardware-Frage einer Angebotsanfrage, kein zweiter Wert auf dem Preisblatt.
2. Die Zahl in der Ueberschrift ("Mehr als :quota?") kommt jetzt aus $plans --
derselben Liste, die baseline() und comparison() schon lesen, statt fest im
Sprachtext zu stehen. Eine spaetere Umschaltung der Paketleiter (Task 9)
aendert sonst, was "am groessten" ist, und der Satz haette es nicht gemerkt.
max() auf einer leeren Plan-Liste (Katalog nicht lesbar) waere ein Fatal
gewesen -- enterprise() gibt in diesem Fall jetzt null zurueck, mit Test.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feat/neue-pakete
parent
4ad1828f27
commit
3df7434f7a
|
|
@ -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<int, array<string, mixed>> $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'] ?? []),
|
||||
|
|
|
|||
|
|
@ -1,10 +1,15 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
// 175 GB ist die Kontingentgrenze des größten noch verkauften Pakets
|
||||
// (Business). Darüber ist Enterprise keine Katalogzeile mehr, sondern
|
||||
// eine eigene Maschine — siehe LandingController::enterprise().
|
||||
'enterprise_title' => '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',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -1,10 +1,14 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
// 175 GB is the quota ceiling of the largest package still on sale
|
||||
// (Business). Above that, Enterprise is no longer a catalogue row but a
|
||||
// dedicated machine — see LandingController::enterprise().
|
||||
'enterprise_title' => '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',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue