Merge branch 'feat/plan-reopen'

# Conflicts:
#	app/Http/Controllers/LandingController.php
feature/betriebsmodus
nexxo 2026-07-29 16:20:07 +02:00
commit 6ef9b510ce
23 changed files with 2070 additions and 150 deletions

View File

@ -7,6 +7,7 @@ use App\Models\Subscription;
use App\Models\SubscriptionAddon;
use App\Models\SubscriptionRecord;
use App\Services\Billing\AddonCatalogue;
use App\Services\Billing\CustomDomainAccess;
use Illuminate\Database\UniqueConstraintViolationException;
use Illuminate\Support\Facades\DB;
use RuntimeException;
@ -40,6 +41,21 @@ class BookAddon
throw new RuntimeException('An add-on is booked at least once.');
}
// Not every module is for sale to everyone. The own domain is possible
// on some packages, included in others and impossible on the smallest,
// and the portal already leaves it out where it cannot be booked — but
// a hidden button is markup, and a rule enforced only in markup is not
// enforced. A stale tab, a second window or anything that can name this
// action arrives here too, so it fails closed, with the sentence the
// customer would be shown rather than a developer's.
$refusal = $addonKey === CustomDomainAccess::ADDON
? app(CustomDomainAccess::class)->bookingRefusal($subscription)
: null;
if ($refusal !== null) {
throw new RuntimeException($refusal);
}
try {
return $this->book($subscription, $addonKey, $quantity, $order, $price, $overrides);
} catch (UniqueConstraintViolationException) {

View File

@ -8,6 +8,7 @@ use App\Models\Subscription;
use App\Models\SubscriptionAddon;
use App\Services\Billing\AddonCatalogue;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use RuntimeException;
/**
@ -45,26 +46,32 @@ class GrantAddon
// already carries the right one.
$datacenter = $subscription->order?->datacenter ?? 'fsn';
$order = Order::create([
'customer_id' => $subscription->customer_id,
'plan' => $subscription->plan,
'type' => 'addon',
'addon_key' => $addonKey,
'amount_cents' => $priceCents,
'currency' => Subscription::catalogueCurrency(),
'datacenter' => $datacenter,
'stripe_event_id' => null,
'stripe_subscription_id' => null,
'status' => 'paid',
]);
// The order and the booking together, or neither. Booking can refuse —
// not every module is for sale on every package — and an order left
// behind on its own is a paid purchase of nothing: it shows up in the
// customer's orders and on their invoice, for a module they never got.
return DB::transaction(function () use ($subscription, $grantedBy, $addonKey, $priceCents, $quantity, $note, $until, $cataloguePrice, $datacenter) {
$order = Order::create([
'customer_id' => $subscription->customer_id,
'plan' => $subscription->plan,
'type' => 'addon',
'addon_key' => $addonKey,
'amount_cents' => $priceCents,
'currency' => Subscription::catalogueCurrency(),
'datacenter' => $datacenter,
'stripe_event_id' => null,
'stripe_subscription_id' => null,
'status' => 'paid',
]);
return ($this->bookAddon)($subscription, $addonKey, $quantity, $order, [
'price_cents' => $priceCents,
'granted_by' => $grantedBy->id,
'granted_at' => now(),
'grant_note' => $note,
'granted_until' => $until,
'catalogue_price_cents' => $cataloguePrice,
]);
return ($this->bookAddon)($subscription, $addonKey, $quantity, $order, [
'price_cents' => $priceCents,
'granted_by' => $grantedBy->id,
'granted_at' => now(),
'grant_note' => $note,
'granted_until' => $until,
'catalogue_price_cents' => $cataloguePrice,
]);
});
}
}

View File

@ -2,6 +2,9 @@
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\Support\ProvisioningSettings;
use Illuminate\Contracts\View\View;
@ -23,27 +26,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
{
/** Presentation that belongs to the marketing page, not to the catalogue. */
private const COPY = [
'start' => ['audience' => 'Für kleine Teams', 'note' => 'Einstieg für Büros, die heute noch Dateien per Mail schicken.'],
'team' => ['audience' => 'Für wachsende Teams', 'note' => 'Der Regelfall — Einschulung des gesamten Teams inklusive.'],
'business' => ['audience' => 'Für dokumentenlastige Betriebe', 'note' => 'Mehr Speicher, längere Aufbewahrung, bevorzugte Reaktionszeit.'],
'enterprise' => ['audience' => 'Individuell für Ihr Unternehmen', 'note' => 'Umfang, Einrichtung und Reaktionszeiten nach Vereinbarung.'],
];
/** 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',
// Filled in from the configured zone by 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.
// 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',
@ -55,42 +75,378 @@ 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<int, array<string, mixed>> $plans
* @return array<int, string>
* @return array<int, array<string, mixed>>
*/
private function featureRows(array $plans): array
private function baseline(array $plans): array
{
$offered = array_merge(...array_column($plans, 'features')) ?: [];
// Through label(), like the plans themselves. Compared against the raw
// constant this stopped matching the moment one label gained a
// placeholder, and the row for it would simply have vanished from the
// comparison table — with nothing anywhere saying why.
return array_values(array_filter(
array_map(fn (string $key) => $this->label($key), array_keys(self::FEATURES)),
fn (?string $label) => $label !== null && 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<int, array<string, mixed>> 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<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
{
@ -110,13 +466,23 @@ class LandingController extends Controller
$plans[] = [
'key' => $key,
'name' => $plan['name'],
'audience' => self::COPY[$key]['audience'] ?? '',
'note' => self::COPY[$key]['note'] ?? '',
// 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),
];
}
@ -135,9 +501,13 @@ class LandingController extends Controller
{
$label = self::FEATURES[$key] ?? null;
return $label === null
? null
: str_replace(':zone', ProvisioningSettings::dnsZone(), $label);
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. */
@ -165,6 +535,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

View File

@ -7,17 +7,25 @@ use App\Services\Billing\PlanCatalogue;
use LivewireUI\Modal\ModalComponent;
/**
* The marketing presentation of one plan line starting with whether it
* carries the "recommended" mark.
* The marketing presentation of one plan line: who it is for, the sentence
* under its name on the price sheet, and whether it carries the
* "recommended" mark.
*
* Lives on the family, not a version: a version is what gets published and
* superseded, but "which one plan we point a visitor towards" is a stance
* about the product line, not a capability that changes with a price.
* These were a hardcoded array in LandingController, keyed on exactly four
* plan keys a plan created under any other key rendered on the public page
* with no copy at all. They live on the family, not a version: a version is
* what gets published and superseded, but "who this is for" and "which one
* plan we point a visitor towards" are a stance about the product line, not a
* capability that changes with a price.
*/
class EditPlanFamily extends ModalComponent
{
public string $uuid = '';
public string $audience = '';
public string $note = '';
public bool $recommended = false;
public function mount(string $uuid): void
@ -26,6 +34,8 @@ class EditPlanFamily extends ModalComponent
$family = PlanFamily::query()->where('uuid', $uuid)->firstOrFail();
$this->uuid = $uuid;
$this->audience = (string) $family->audience;
$this->note = (string) $family->note;
$this->recommended = (bool) $family->is_recommended;
}
@ -34,11 +44,18 @@ class EditPlanFamily extends ModalComponent
$this->authorize('plans.manage');
$data = $this->validate([
'audience' => 'nullable|string|max:255',
'note' => 'nullable|string|max:500',
'recommended' => 'boolean',
]);
$family = PlanFamily::query()->where('uuid', $this->uuid)->firstOrFail();
PlanFamily::query()->whereKey($family->getKey())->update([
'audience' => $data['audience'] !== '' ? $data['audience'] : null,
'note' => $data['note'] !== '' ? $data['note'] : null,
]);
// The singular invariant — only one family recommended at a time —
// lives in the catalogue service, not here: two operators recommending
// two different plans at the same moment must not both win.

View File

@ -2,13 +2,12 @@
namespace App\Livewire\Admin;
use App\Support\LocalTime;
use App\Models\PlanFamily;
use App\Models\PlanVersion;
use App\Models\Subscription;
use App\Services\Billing\PlanCatalogue;
use App\Support\LocalTime;
use App\Support\Money;
use Illuminate\Support\Carbon;
use Illuminate\Validation\Rule;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
@ -239,12 +238,72 @@ class PlanVersions extends Component
$this->dispatch('notify', message: __('plans.closed', ['version' => $version->version]));
}
/**
* Put a closed version back on sale the way out of a closed window.
*
* The likely refusal here is that a newer version has taken over, and the
* catalogue says so in English, at the length a log entry deserves. Which
* of its two refusals it was is read off the version itself rather than out
* of the message text: a message is prose, and prose changes.
*/
public function reopen(string $versionUuid): void
{
$this->authorize('plans.manage');
$version = PlanVersion::query()->where('uuid', $versionUuid)->firstOrFail();
abort_unless($version->plan_family_id === $this->family()->id, 404);
try {
app(PlanCatalogue::class)->reopen($version);
} catch (RuntimeException) {
$this->addError('availableFrom', __(
$version->isPublished() ? 'plans.reopen_blocked' : 'plans.reopen_draft'
));
return;
}
$this->dispatch('notify', message: __('plans.reopened', ['version' => $version->version]));
}
#[On('plan-draft-deleted')]
public function refresh(): void
{
// Livewire re-renders; the listener exists so the modal can say so.
}
/**
* The version that publishing at the chosen moment would take off sale.
*
* Announced on the form, because publishing now ends a running version's
* sale instead of being refused, and the owner should not have to work that
* out from the version list afterwards. Asked of the catalogue rather than
* worked out here, so the announcement and the act cannot name different
* versions.
*
* @return array{version: int, at: string}|null
*/
private function handover(PlanFamily $family): ?array
{
if ($this->publishing === null) {
return null;
}
$version = $family->versions()->where('uuid', $this->publishing)->first();
$from = LocalTime::fromField($this->availableFrom);
if ($version === null || $from === null) {
return null;
}
$predecessor = app(PlanCatalogue::class)->predecessorAt($version, $from);
return $predecessor === null ? null : [
'version' => $predecessor->version,
'at' => $from->local()->isoFormat('L LT'),
];
}
public function render()
{
$family = $this->family();
@ -254,6 +313,7 @@ class PlanVersions extends Component
'family' => $family,
'versions' => $family->versions()->with('prices')->orderByDesc('version')->get(),
'now' => $now,
'handover' => $this->handover($family),
'featureKeys' => array_keys((array) __('billing.feature')),
'performanceClasses' => (array) __('billing.perf'),
'currency' => Subscription::catalogueCurrency(),

View File

@ -7,6 +7,7 @@ use App\Models\Customer;
use App\Models\Order;
use App\Models\Subscription;
use App\Services\Billing\AddonCatalogue;
use App\Services\Billing\CustomDomainAccess;
use App\Services\Billing\DowngradeCheck;
use App\Services\Billing\PlanCatalogue;
use App\Services\Billing\TaxTreatment;
@ -45,6 +46,8 @@ class Billing extends Component
default => [null, 0, null],
};
$access = app(CustomDomainAccess::class);
// Guard against invalid keys / non-upgrades.
$valid = match ($type) {
// By rank, matching PlanChange and the cards on the page. Comparing
@ -58,12 +61,18 @@ class Billing extends Component
// markup is not enforced.
'downgrade' => isset($plans[$key])
&& (int) ($plans[$key]['tier'] ?? 0) < (int) ($instance?->subscription?->tier ?? $plans[$currentPlan]['tier'] ?? 0)
&& DowngradeCheck::for($customer, $instance, $plans[$key])->allowed,
&& DowngradeCheck::for($customer, $instance, $plans[$key], $key)->allowed,
'storage' => true,
// Always available: running out of traffic is exactly when someone
// needs to be able to buy more, whatever plan they are on.
'traffic' => true,
'addon' => isset($addons[$key]),
// The module list leaves the own domain out where it cannot be
// booked, and this refuses it there for the same reason the
// downgrade branch re-checks: the button is markup, and markup is
// not a limit. BookAddon refuses it a third time, at the moment the
// money would actually buy something.
'addon' => isset($addons[$key])
&& ($key !== CustomDomainAccess::ADDON || $access->bookable($access->contractOf($customer))),
default => false,
};
if (! $valid || $plan === null) {
@ -152,6 +161,41 @@ class Billing extends Component
];
}
/**
* The module cards, minus the ones this customer cannot be sold.
*
* Two things the shop was getting wrong about the own domain, and both of
* them were offers: a package that already includes it was invited to buy
* it again, and a package that cannot have one at all was invited to buy
* something we would then refuse. An offer that would be refused is worse
* than no offer the customer clicks it, and finds out from an error
* message what they should have been told by the page.
*
* A module already booked keeps its card whatever the package says, because
* it is still on the bill and hiding it would hide the charge.
*
* @param array<string, array<string, mixed>> $rows
* @return array<string, array<string, mixed>>
*/
private function offerable(array $rows, ?Customer $customer): array
{
$access = app(CustomDomainAccess::class);
$key = CustomDomainAccess::ADDON;
if (! array_key_exists($key, $rows)) {
return $rows;
}
$contract = $access->contractOf($customer);
$rows[$key]['included'] = $access->includedInPlan($contract);
if (! $rows[$key]['included'] && ! $rows[$key]['booked'] && ! $access->bookable($contract)) {
unset($rows[$key]);
}
return $rows;
}
public function render()
{
$customer = $this->customer();
@ -171,7 +215,10 @@ class Billing extends Component
$downgrades = collect($plans)
->filter(fn ($p) => (int) ($p['tier'] ?? 0) < $currentTier)
->sortByDesc(fn ($p) => (int) ($p['tier'] ?? 0))
->map(fn ($p, $k) => $p + ['check' => DowngradeCheck::for($customer, $instance, $p)])
// With the key, not only the terms: what a move costs a customer
// depends on which package they are moving to, and an entry that
// does not know its own name cannot be asked about it.
->map(fn ($p, $k) => $p + ['check' => DowngradeCheck::for($customer, $instance, $p, $k)])
->all();
$upgrades = collect($plans)
@ -199,9 +246,12 @@ class Billing extends Component
// Booked modules at the price they were booked at; everything else
// at today's. Reading them all off the catalogue would re-price
// half a customer's bill behind their back.
'addons' => collect(app(AddonCatalogue::class)->forSubscription($instance?->subscription))
->except(AddonCatalogue::STORAGE)
->all(),
'addons' => $this->offerable(
collect(app(AddonCatalogue::class)->forSubscription($instance?->subscription))
->except(AddonCatalogue::STORAGE)
->all(),
$customer,
),
'totalMonthlyCents' => $instance?->subscription?->totalMonthlyCents(),
'pending' => $customer
? $customer->orders()->where('status', 'pending')->latest('id')->get()

View File

@ -0,0 +1,358 @@
<?php
namespace App\Services\Billing;
use App\Actions\BookAddon;
use App\Models\Customer;
use App\Models\Instance;
use App\Models\PlanFamily;
use App\Models\Subscription;
use App\Support\ProvisioningSettings;
use Illuminate\Support\Number;
/**
* Whether a customer may have their own domain asked and answered once.
*
* The owner's rule has three steps, and until now not one of them existed
* anywhere in the code: `/domain` shipped with no access control at all, and
* the `custom_domain` plan feature was checked nowhere, so every customer on
* every package could point a domain at their instance.
*
* - **Start**: impossible. Not bookable, not upgradable, not even offered.
* - **Team**: optional. The `custom_domain` module buys it.
* - **Business and Enterprise**: included, because the plan version carries
* the `custom_domain` feature. The module buys them nothing.
*
* Everything asks this class the booking action, the portal's module list,
* the downgrade warning, the plan change that carries it out, and the public
* price sheet. Nothing re-derives it. A rule written out in five places is five
* rules, and the fifth is the one nobody remembers to change.
*
* Read against the FROZEN plan version, never against today's catalogue: what a
* customer is owed is what they bought, and a feature added to or taken off a
* plan later must not reach into a contract that was signed before it.
*/
final class CustomDomainAccess
{
/** The catalogue feature a package carries when an own domain is part of it. */
public const FEATURE = 'custom_domain';
/** The module that buys one where the package does not include it. */
public const ADDON = 'custom_domain';
/**
* The customer's live contract — the same resolution the console's grant
* screen uses.
*
* Deliberately not read through the instance: `subscriptions.instance_id`
* is written during provisioning, so a customer whose machine is still
* being built has a package but no link, and answering "no contract" there
* would take a right away from someone who has paid for it.
*/
public function contractOf(?Customer $customer): ?Subscription
{
if ($customer === null) {
return null;
}
return Subscription::query()
->where('customer_id', $customer->id)
->where('status', 'active')
->latest('id')
->first();
}
/** The guard every page and route asks: may this customer use one right now? */
public function allowsCustomer(?Customer $customer): bool
{
return $this->allows($this->contractOf($customer));
}
/**
* May this contract present a custom domain right now?
*
* Included by the package, or bought as a module and a module only counts
* where the package could have booked it. Without that second half a module
* booked on Team would survive a move to Start and keep serving a domain
* the customer has just been told they cannot have; enforce() cancels such
* a booking, and this is the same sentence read the other way round.
*/
public function allows(?Subscription $subscription): bool
{
if ($subscription === null) {
return false;
}
return $this->includedInPlan($subscription)
|| ($this->bookable($subscription) && $this->hasBooked($subscription));
}
/** Does the package itself carry it, so nothing needs booking? */
public function includedInPlan(?Subscription $subscription): bool
{
return $subscription !== null && $this->includedInFeatures($this->planFeatures($subscription));
}
/**
* May this contract book the module?
*
* Only where a domain is possible and the package does not already include
* it which is Team today. Never Start, and pointless on Business and
* Enterprise, who would be paying nine euros a month for something they
* already have.
*/
public function bookable(?Subscription $subscription): bool
{
return $subscription !== null
&& $this->bookableOnPlan((string) $subscription->plan, $this->planFeatures($subscription));
}
/** Is the module already booked and still running on this contract? */
public function hasBooked(?Subscription $subscription): bool
{
return $subscription !== null
&& $subscription->addons()->active()->where('addon_key', self::ADDON)->exists();
}
/**
* The same two questions for a package rather than a contract, because the
* public price sheet has no customer to ask about.
*
* @param array<int, string> $features the version's feature keys
*/
public function includedInFeatures(array $features): bool
{
return in_array(self::FEATURE, $features, true);
}
/** @param array<int, string> $features */
public function bookableOnPlan(string $planKey, array $features): bool
{
return $this->possibleOn($planKey) && ! $this->includedInFeatures($features);
}
/**
* Is an own domain possible on this package at all?
*
* The list of packages where it is not lives in config/provisioning.php,
* beside the module's price see the comment there for why the catalogue
* cannot answer this and a derived rule would be a guess.
*/
public function possibleOn(string $planKey): bool
{
$excluded = (array) config('provisioning.addons.'.self::ADDON.'.unavailable_on', []);
return ! in_array($planKey, $excluded, true);
}
/**
* Why the customer may not use one, in words they can be shown.
*
* Null when they may. Every caller that would otherwise write its own
* sentence the guard on the domain page, a support answer takes it from
* here, so the customer is never told two different things.
*/
public function refusal(?Subscription $subscription): ?string
{
if ($this->allows($subscription)) {
return null;
}
if ($subscription === null) {
return __('billing.custom_domain.no_contract');
}
if (! $this->possibleOn((string) $subscription->plan)) {
return __('billing.custom_domain.not_in_plan', ['plan' => $this->planName((string) $subscription->plan)]);
}
return __('billing.custom_domain.needs_addon', ['price' => $this->modulePrice()]);
}
/** Why the module may not be booked. Null when it may. */
public function bookingRefusal(?Subscription $subscription): ?string
{
if ($subscription === null) {
return __('billing.custom_domain.no_contract');
}
if ($this->includedInPlan($subscription)) {
return __('billing.custom_domain.already_included');
}
if (! $this->possibleOn((string) $subscription->plan)) {
return __('billing.custom_domain.not_in_plan', ['plan' => $this->planName((string) $subscription->plan)]);
}
return null;
}
/**
* What moving to another package would do to a domain the customer HAS, so
* they are asked before they confirm rather than told afterwards.
*
* Two answers, and they are different decisions: on a package where a
* domain is impossible it simply goes, and on one where it is a module the
* customer chooses book it and nothing changes for them, leave it and the
* domain is switched off with the change.
*
* Nothing is reported when nothing happens: no domain set, none in use, the
* target includes it anyway, or the module is already booked and stays
* valid on the target.
*
* @param array<int, string> $targetFeatures the target version's feature keys
* @return array<int, array{key: string, replace: array<string, string>}>
*/
public function consequencesOfMovingTo(?Subscription $current, ?Instance $instance, string $targetPlan, array $targetFeatures): array
{
if ($instance === null || blank($instance->custom_domain) || ! $this->allows($current)) {
return [];
}
if ($this->includedInFeatures($targetFeatures)) {
return [];
}
if (! $this->possibleOn($targetPlan)) {
return [[
'key' => 'custom_domain_lost',
'replace' => [
'domain' => (string) $instance->custom_domain,
'plan' => $this->planName($targetPlan),
// The address it falls back to, so "abgeschaltet" is a
// change of address rather than a threat of darkness.
'address' => $instance->subdomain.'.'.ProvisioningSettings::dnsZone(),
],
]];
}
// Already paying for the module, and it stays valid where they are
// going: for them this move changes nothing about the domain.
if ($this->hasBooked($current)) {
return [];
}
return [[
'key' => 'custom_domain_addon',
'replace' => [
'domain' => (string) $instance->custom_domain,
'plan' => $this->planName($targetPlan),
'price' => $this->modulePrice(),
],
]];
}
/**
* Make the state match the rule, after a change has landed.
*
* Called by PlanChange when a plan change takes effect. Two things happen,
* in this order: the module stops being charged for, and the domain stops
* being presented. Charging first, because a customer who has lost a
* feature and is still billed for it has a complaint we would deserve.
*
* Returns whether anything was actually taken away.
*/
public function enforce(Subscription $subscription): bool
{
if ($this->allows($subscription)) {
return false;
}
$changed = false;
// Resolved lazily rather than through the constructor: BookAddon asks
// this class whether a booking is allowed, and two constructors
// pointing at each other cannot both be built.
$bookAddon = app(BookAddon::class);
foreach ($subscription->addons()->active()->where('addon_key', self::ADDON)->get() as $addon) {
$bookAddon->cancel($addon);
$changed = true;
}
return $this->deactivate($this->instanceOf($subscription)) || $changed;
}
/**
* Take the domain off an instance, so it answers on its platform address
* again.
*
* The whole domain, not merely its verification flag. The nightly re-check
* (clupilot:verify-domains) reads every instance that still has a domain
* AND a token, and it would put the verification straight back the next
* night the domain would switch itself on again days after the customer
* was told it was gone. Clearing the field is also exactly what the
* customer's own "remove" does on the domain page, so what they see
* afterwards is a state this product already knows how to draw.
*
* Neither the verification nor the serving logic is reimplemented here:
* Instance::address() already falls back to the subdomain the moment the
* domain is gone, and that is the whole of the fallback in this codebase.
*/
public function deactivate(?Instance $instance): bool
{
if ($instance === null || blank($instance->custom_domain)) {
return false;
}
$instance->forceFill([
'custom_domain' => null,
'domain_token' => null,
'domain_verified_at' => null,
'domain_checked_at' => null,
'domain_error' => null,
'domain_failures' => 0,
])->save();
return true;
}
/**
* The machine this contract pays for.
*
* The link when provisioning has written it, and otherwise the customer's
* newest instance the same fallback the portal makes for contracts opened
* before that link existed.
*/
private function instanceOf(Subscription $subscription): ?Instance
{
return $subscription->instance
?? $subscription->customer?->instances()->latest('id')->first();
}
/**
* What this contract's package promises from the version it was sold
* under, not from the catalogue.
*
* The empty fallback is for a contract whose version row has gone (a
* discarded draft nulls the reference); such a package promises nothing
* this class can stand on, and inventing today's answer for it would hand
* out a right the contract may never have carried.
*
* @return array<int, string>
*/
private function planFeatures(Subscription $subscription): array
{
return array_values((array) ($subscription->planVersion?->features ?? []));
}
/**
* A package by the name the owner gave it, never by its key.
*
* The key is a handle that has not changed since day one; the name is what
* the customer sees on the price sheet, and renaming a package must not
* leave one sentence in the product still calling it something else.
*/
private function planName(string $planKey): string
{
return (string) (PlanFamily::query()->where('key', $planKey)->value('name') ?? ucfirst($planKey));
}
/** Today's price for the module, in the same shape the portal prints it. */
private function modulePrice(): string
{
$cents = (int) app(AddonCatalogue::class)->priceCents(self::ADDON);
return Number::currency($cents / 100, in: Subscription::catalogueCurrency(), locale: app()->getLocale());
}
}

View File

@ -20,6 +20,13 @@ use App\Support\Bytes;
* Checked against what is MEASURED, not what was sold: a customer sitting on
* 480 GB of a 500 GB plan cannot move to a 200 GB one, whatever their contract
* says the allowance is.
*
* Blockers are not the only thing worth saying beforehand. A downgrade can be
* perfectly possible and still take something away the customer's own domain
* and being told that afterwards is the same failure as a greyed-out button
* with no reason on it. So a consequence is reported alongside, in the same
* shape and in the same place, and it does not make the move impossible: it is
* the sentence somebody reads before they agree to it.
*/
final readonly class DowngradeCheck
{
@ -27,12 +34,18 @@ final readonly class DowngradeCheck
public bool $allowed,
/** @var array<int, array{key: string, current: string, limit: string}> */
public array $blockers,
/** @var array<int, array{key: string, replace: array<string, string>}> */
public array $consequences = [],
) {}
/**
* @param array<string, mixed> $target the catalogue entry being moved to
* @param ?string $targetPlan its plan key null when a caller is checking raw
* limits rather than a package, and then the
* consequences that depend on which package it is
* cannot be computed and are not guessed at
*/
public static function for(Customer $customer, ?Instance $instance, array $target): self
public static function for(Customer $customer, ?Instance $instance, array $target, ?string $targetPlan = null): self
{
$blockers = [];
@ -65,6 +78,18 @@ final readonly class DowngradeCheck
];
}
return new self($blockers === [], $blockers);
// Asked, never re-derived: which packages may carry an own domain is
// CustomDomainAccess's question, and this class only reports what it
// answers about the move being considered.
$access = app(CustomDomainAccess::class);
$consequences = $targetPlan === null ? [] : $access->consequencesOfMovingTo(
current: $access->contractOf($customer),
instance: $instance,
targetPlan: $targetPlan,
targetFeatures: array_values((array) ($target['features'] ?? [])),
);
return new self($blockers === [], $blockers, $consequences);
}
}

View File

@ -3,6 +3,7 @@
namespace App\Services\Billing;
use App\Models\PlanFamily;
use App\Models\PlanPrice;
use App\Models\PlanVersion;
use App\Models\Subscription;
use Illuminate\Database\Eloquent\ModelNotFoundException;
@ -78,7 +79,9 @@ final class PlanCatalogue
// Marketing presentation, not a capability: it lives on the
// family (see the migration), and every reader of the shop
// gets it from the same place rather than each keeping its
// own copy of which plan to point a visitor towards.
// own copy of who a plan is for.
'audience' => $family->audience,
'note' => $family->note,
'recommended' => (bool) $family->is_recommended,
])];
})
@ -142,7 +145,7 @@ final class PlanCatalogue
* shop, the checkout and the consistency command can never disagree about
* which plans are real.
*
* @return array<string, \App\Models\PlanPrice>|null
* @return array<string, PlanPrice>|null
*/
private function requiredPrices(PlanVersion $version, string $currency): ?array
{
@ -326,6 +329,66 @@ final class PlanCatalogue
});
}
/**
* Put a published version back on open-ended sale.
*
* The inverse of closing a window, and the reason it has to exist: closing
* was one-way. An owner who took a version off sale before its replacement
* was ready had no way back, and the plan stayed dark until someone edited
* the table by hand.
*
* Expressed through schedule() rather than as its own update, so it passes
* the same family lock and the same overlap check as every other window
* change. That check is the point, not an obstacle: a version whose
* successor is already selling must not come back and leave two on sale at
* once.
*/
public function reopen(PlanVersion $version): PlanVersion
{
// Re-read first: a stale object could carry a start date the owner has
// since moved, and reopening would then quietly restore the old one.
$version->refresh();
if (! $version->isPublished()) {
throw new RuntimeException(
'That version was never published, so it was never on sale and there is nothing to reopen. '.
'Publish it instead.'
);
}
return $this->schedule($version, $version->available_from, null);
}
/**
* The other published version of this family that is on sale at `$at`.
*
* Public because the console announces a handover before the owner commits
* to it, and an announcement computed from its own idea of "currently
* running" would sooner or later name a different version than the one
* publish() actually closes.
*
* Null when two are running at once: that is the data fault schedule()
* exists to prevent, and picking one of them to close would decide by row
* order which of the owner's plans quietly stops selling.
*/
public function predecessorAt(PlanVersion $version, Carbon $at): ?PlanVersion
{
$running = PlanVersion::query()
->where('plan_family_id', $version->plan_family_id)
->whereKeyNot($version->getKey())
->whereNotNull('published_at')
// Running at `$at`, not merely clashing with it. A version that
// only starts later clashes too, but closing it at `$at` would end
// it before it began — that one still belongs to the overlap check.
->where('available_from', '<=', $at)
->where(fn ($q) => $q
->whereNull('available_until')
->orWhere('available_until', '>', $at))
->get();
return $running->count() === 1 ? $running->first() : null;
}
/**
* Mark exactly one plan family as recommended, clearing every other one.
*
@ -385,11 +448,21 @@ final class PlanCatalogue
}
}
// Publication and scheduling together, or not at all. Publishing first
// and then failing the overlap check would leave the version frozen but
// unscheduled — and publish() refuses it from then on, so nothing short
// of a manual repair could rescue it.
// Publication, the handover and the new window together, or not at all.
// Publishing first and then failing the overlap check would leave the
// version frozen but unscheduled — and publish() refuses it from then
// on, so nothing short of a manual repair could rescue it. The same
// goes the other way: a predecessor closed for a successor that never
// arrived is a plan taken off sale by an action that failed.
return DB::transaction(function () use ($version, $from, $until) {
$from ??= now();
// The lock schedule() would take anyway, taken here instead: it has
// to be held from before the predecessor is chosen until after the
// successor's window is written, or a publish running alongside
// this one could hand the same version over twice.
PlanFamily::query()->whereKey($version->plan_family_id)->lockForUpdate()->firstOrFail();
// Claim the publication conditionally, so of two simultaneous
// attempts exactly one proceeds. Unconditional, the second would
// sail past and overwrite the window the first had just set.
@ -402,7 +475,27 @@ final class PlanCatalogue
throw new RuntimeException('That version is already published.');
}
return $this->schedule($version->refresh(), $from ?? now(), $until);
$predecessor = $this->predecessorAt($version, $from);
if ($predecessor !== null) {
// A handover, not an overlap: the version that was running
// stops at the moment its successor starts, which is neither a
// gap nor two versions on sale. Refusing here — as this used to
// — forced the owner to close the running version first, and a
// replacement that then failed to publish left the plan off
// sale with no way back. That dead end is what this removes.
//
// Written by query for the same reason schedule() is: the
// version being handed over is published and immutable, and
// save() would offer every attribute on the object to that
// guard.
PlanVersion::query()->whereKey($predecessor->getKey())->update([
'available_until' => $from,
'updated_at' => now(),
]);
}
return $this->schedule($version->refresh(), $from, $until);
});
}
}

View File

@ -128,6 +128,27 @@ final readonly class PlanChange
);
}
/**
* Carry the custom domain over or take it away once a change has landed.
*
* The preview above says what a move would cost; this is the one thing that
* has to happen after it. A contract that no longer allows a custom domain
* loses it: the module stops being charged for and the instance goes back
* to its platform address. A contract that still allows one because the
* new package includes it, or because the customer booked the module before
* moving is left alone, which is exactly what "keeping it" means.
*
* The rule itself is not here. CustomDomainAccess owns it; this is the
* moment it is applied, sitting beside the preview so nobody changes what a
* plan change costs without seeing what it does.
*
* Returns whether anything was taken away.
*/
public static function settleCustomDomain(Subscription $subscription): bool
{
return app(CustomDomainAccess::class)->enforce($subscription);
}
/**
* What a mid-term downgrade would be worth, for the cases where we grant one
* anyway a goodwill move or a support decision. Never offered to the

View File

@ -0,0 +1,53 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* The plan's marketing copy, moved onto the family it describes.
*
* `audience` and `note` were a hardcoded array in LandingController, keyed on
* exactly the four family keys the seed migration created a plan created
* under any other key rendered on the public page with no copy at all. They
* belong on the family for the same reason the recommendation mark does (see
* the previous migration): "who this plan is for" does not change with a
* price, so it must not be frozen per version.
*
* Backfilled from the controller's own strings in the same statement that
* adds the columns, so the public page prints exactly what it printed a
* moment ago, sourced from the console instead of from code.
*/
return new class extends Migration
{
/** The exact copy LandingController::COPY held before this migration. */
private const COPY = [
'start' => ['audience' => 'Für kleine Teams', 'note' => 'Einstieg für Büros, die heute noch Dateien per Mail schicken.'],
'team' => ['audience' => 'Für wachsende Teams', 'note' => 'Der Regelfall — Einschulung des gesamten Teams inklusive.'],
'business' => ['audience' => 'Für dokumentenlastige Betriebe', 'note' => 'Mehr Speicher, längere Aufbewahrung, bevorzugte Reaktionszeit.'],
'enterprise' => ['audience' => 'Individuell für Ihr Unternehmen', 'note' => 'Umfang, Einrichtung und Reaktionszeiten nach Vereinbarung.'],
];
public function up(): void
{
Schema::table('plan_families', function (Blueprint $table) {
$table->string('audience', 255)->nullable()->after('is_recommended');
$table->string('note', 500)->nullable()->after('audience');
});
foreach (self::COPY as $key => $copy) {
DB::table('plan_families')->where('key', $key)->update([
'audience' => $copy['audience'],
'note' => $copy['note'],
]);
}
}
public function down(): void
{
Schema::table('plan_families', function (Blueprint $table) {
$table->dropColumn(['audience', 'note']);
});
}
};

View File

@ -11,6 +11,13 @@ return [
'seats' => 'Sie haben :current Benutzer, dieses Paket erlaubt :limit. Entfernen Sie Zugänge unter „Benutzer“.',
'storage' => 'Sie belegen :current, dieses Paket bietet :limit. Löschen Sie Daten oder leeren Sie den Papierkorb.',
],
// Kein Hindernis, sondern eine Folge: der Wechsel ist möglich, nimmt aber
// etwas weg. Steht deshalb über dem Knopf, nicht statt seiner.
'downgrade_consequence_title' => 'Das ändert sich mit dem Wechsel',
'downgrade_consequence' => [
'custom_domain_lost' => 'Ihre eigene Domain :domain wird mit dem Wechsel abgeschaltet — im Paket :plan ist keine eigene Domain möglich. Ihre Cloud ist danach wieder unter :address erreichbar.',
'custom_domain_addon' => 'Im Paket :plan ist eine eigene Domain nicht enthalten. Sie behalten :domain, wenn Sie das Modul „Eigene Domain“ für :price pro Monat buchen — ohne dieses Modul wird die Domain mit dem Wechsel abgeschaltet.',
],
'per_month' => 'netto pro Monat',
'net_reverse_charge' => 'netto — Reverse Charge',
@ -88,6 +95,7 @@ return [
'storage_body' => 'Jederzeit erweiterbar — +:gb GB für :price pro Monat, monatlich kündbar.',
'storage_cta' => '+:gb GB buchen',
'addon_cta' => 'Hinzufügen',
'addon_included' => 'In Ihrem Paket enthalten',
'total_with_addons' => 'Gesamt inkl. Module: :total',
'addon_packs' => ':count Pakete',
'addon_booked' => 'Gebucht — Preis fest',
@ -101,9 +109,19 @@ return [
'enterprise' => 'Enterprise',
],
// Warum eine eigene Domain gerade nicht geht — in den Worten, die der Kunde
// zu sehen bekommt, egal ob die Seite, die Buchung oder der Support fragt.
'custom_domain' => [
'no_contract' => 'Für dieses Konto besteht kein laufender Vertrag.',
'not_in_plan' => 'Im Paket :plan ist keine eigene Domain möglich. Mit einem größeren Paket können Sie Ihre Cloud unter Ihrer eigenen Adresse betreiben.',
'already_included' => 'Eine eigene Domain ist in Ihrem Paket bereits enthalten — dieses Modul brauchen Sie nicht.',
'needs_addon' => 'Eine eigene Domain gehört nicht zu Ihrem Paket. Buchen Sie das Modul „Eigene Domain“ für :price pro Monat unter „Abrechnung“.',
],
'addon' => [
'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.'],
],
];

View File

@ -36,6 +36,11 @@ return [
'recommended_badge' => 'Empfohlen',
'recommended_label' => 'Als empfohlen markieren',
'recommended_hint' => 'Nur ein Paket kann empfohlen sein — die Markierung wandert von einem anderen Paket hierher.',
'audience' => 'Zielgruppe',
'audience_placeholder' => 'z. B. Für wachsende Teams',
'audience_hint' => 'Kurzer Satz unter dem Paketnamen auf der Website.',
'note' => 'Anmerkung',
'note_hint' => 'Freitext zu diesem Paket — wird derzeit nicht auf der Website angezeigt.',
'save' => 'Speichern',
'back' => 'Zurück zu den Paketen',
@ -62,6 +67,11 @@ return [
'available_until_hint' => 'Leer lassen für unbefristet.',
'close_now' => 'Verkauf beenden',
'closed' => 'Version :version wird nicht mehr verkauft.',
'reopen' => 'Wieder verkaufen',
'reopened' => 'Version :version ist wieder im Verkauf.',
'reopen_blocked' => 'Diese Version kann nicht zurück in den Verkauf: Für dieses Paket läuft bereits eine neuere Version. Beenden oder verschieben Sie zuerst deren Verkaufszeitraum. Zwei Versionen gleichzeitig im Verkauf würden den Preis, den ein Kunde zahlt, von der Zeilenreihenfolge abhängig machen.',
'reopen_draft' => 'Diese Version wurde nie veröffentlicht und war damit nie im Verkauf. Veröffentlichen Sie sie stattdessen.',
'handover_note' => 'Version :version ist derzeit im Verkauf und endet mit dieser Veröffentlichung am :at. Keine Lücke: ab genau diesem Zeitpunkt verkauft die neue Version.',
'cancel' => 'Abbrechen',
'discard' => 'Verwerfen',

View File

@ -11,6 +11,13 @@ return [
'seats' => 'You have :current users, this package allows :limit. Remove access under "Users".',
'storage' => 'You are using :current, this package offers :limit. Delete data or empty the trash.',
],
// Not an obstacle but a consequence: the move is possible, it just takes
// something away. Shown above the button, not instead of it.
'downgrade_consequence_title' => 'What this move changes',
'downgrade_consequence' => [
'custom_domain_lost' => 'Your own domain :domain is switched off with this move — the :plan package cannot have one. Your cloud is then reachable at :address again.',
'custom_domain_addon' => 'The :plan package does not include an own domain. You keep :domain by booking the "Own domain" module for :price per month — without it the domain is switched off with this move.',
],
'per_month' => 'net per month',
'net_reverse_charge' => 'net — reverse charge',
@ -88,6 +95,7 @@ return [
'storage_body' => 'Expandable anytime — +:gb GB for :price per month, cancel monthly.',
'storage_cta' => 'Add :gb GB',
'addon_cta' => 'Add',
'addon_included' => 'Included in your package',
'total_with_addons' => 'Total incl. modules: :total',
'addon_packs' => ':count packs',
'addon_booked' => 'Booked — price fixed',
@ -101,9 +109,19 @@ return [
'enterprise' => 'Enterprise',
],
// Why an own domain is not possible right now — in the words the customer
// is shown, whether the page, the booking or support is asking.
'custom_domain' => [
'no_contract' => 'There is no running contract for this account.',
'not_in_plan' => 'The :plan package cannot have an own domain. A larger package lets you run your cloud under your own address.',
'already_included' => 'An own domain is already part of your package — you do not need this module.',
'needs_addon' => 'An own domain is not part of your package. Book the "Own domain" module for :price per month under "Billing".',
],
'addon' => [
'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.'],
],
];

View File

@ -36,6 +36,11 @@ return [
'recommended_badge' => 'Recommended',
'recommended_label' => 'Mark as recommended',
'recommended_hint' => 'Only one plan can be recommended — the mark moves here from whichever plan carried it before.',
'audience' => 'Audience',
'audience_placeholder' => 'e.g. For growing teams',
'audience_hint' => 'Short line under the plan name on the website.',
'note' => 'Note',
'note_hint' => 'Free text about this plan — not currently shown on the website.',
'save' => 'Save',
'back' => 'Back to plans',
@ -62,6 +67,11 @@ return [
'available_until_hint' => 'Leave empty for open-ended.',
'close_now' => 'Stop selling',
'closed' => 'Version :version is no longer sold.',
'reopen' => 'Sell again',
'reopened' => 'Version :version is on sale again.',
'reopen_blocked' => 'This version cannot go back on sale: a newer version of this plan is already running. Stop or reschedule that one first. Two versions on sale at once would leave the price a customer pays decided by row order.',
'reopen_draft' => 'This version was never published, so it was never on sale. Publish it instead.',
'handover_note' => 'Version :version is on sale now and ends on :at when this one is published. No gap: from exactly that moment the new version sells.',
'cancel' => 'Cancel',
'discard' => 'Discard',

View File

@ -471,7 +471,12 @@
<span class="rounded-pill bg-accent-active px-2 py-0.5 font-mono text-[10px] font-medium uppercase tracking-[0.07em] text-on-accent">Empfohlen</span>
@endif
</div>
<p class="mt-1 text-sm text-muted">{{ $plan['audience'] }}</p>
{{-- A plan can exist before its copy is written the empty
string renders nothing rather than an empty paragraph
with margin above it and nothing in it. --}}
@if ($plan['audience'])
<p class="mt-1 text-sm text-muted">{{ $plan['audience'] }}</p>
@endif
<p class="mt-6 flex items-baseline gap-1.5">
<span class="text-[2.4rem] font-bold leading-none tabular-nums tracking-[-0.04em] text-ink">{{ $plan['price'] }}</span>
@ -512,46 +517,124 @@
@endforeach
</div>
<details class="faq rv group mt-6 overflow-hidden rounded-xl border border-line bg-surface shadow-xs">
<summary class="flex min-h-14 items-center gap-4 px-6 py-4 text-sm font-medium text-ink transition-colors hover:bg-surface-hover">
Alle Merkmale im direkten Vergleich
<x-ui.icon name="chevron-down" class="ml-auto size-4 shrink-0 text-muted transition-transform duration-200 group-open:rotate-180" />
</summary>
<div class="overflow-x-auto border-t border-line">
<table class="w-full min-w-[680px] border-collapse text-left text-sm">
<caption class="sr-only">Pakete und Merkmale im Vergleich</caption>
<thead>
<tr class="border-b border-line">
<th scope="col" class="px-6 py-4"><span class="sr-only">Merkmal</span></th>
@foreach ($plans as $plan)
<th scope="col" class="px-6 py-4 font-semibold text-ink">{{ $plan['name'] }}</th>
@endforeach
</tr>
</thead>
<tbody>
@foreach ($featureRows as $feature)
<tr class="border-b border-line last:border-0">
<th scope="row" class="px-6 py-3 font-medium text-muted">{{ $feature }}</th>
{{-- 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. --}}
<div class="rv mt-12 overflow-hidden rounded-xl border border-line bg-surface shadow-xs">
<div class="flex flex-wrap items-baseline gap-x-3 gap-y-1 border-b border-line px-7 py-5">
<h3 class="text-lg font-bold tracking-[-0.02em] text-ink">In jedem Paket enthalten</h3>
<span class="text-sm text-muted">ohne Aufpreis, ab dem ersten Tag</span>
</div>
<div class="grid sm:grid-cols-2">
@foreach ($baseline as $item)
<div class="flex gap-3.5 border-b border-line px-7 py-5 sm:odd:border-r">
<x-ui.icon name="{{ $item['icon'] }}" class="mt-0.5 size-4 text-accent-text" />
<div class="min-w-0">
<b class="block text-sm font-semibold text-ink">{{ $item['label'] }}</b>
<span @class([
'mt-1 block text-xs leading-relaxed text-muted',
'font-mono' => $item['mono'] ?? false,
])>{{ $item['detail'] }}</span>
</div>
</div>
@endforeach
</div>
</div>
{{-- 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))
<details class="faq rv group mt-4 overflow-hidden rounded-xl border border-line bg-surface shadow-xs">
<summary class="flex min-h-14 items-center gap-4 px-6 py-4 text-sm font-medium text-ink transition-colors hover:bg-surface-hover">
Was die Pakete unterscheidet
<x-ui.icon name="chevron-down" class="ml-auto size-4 shrink-0 text-muted transition-transform duration-200 group-open:rotate-180" />
</summary>
{{-- 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. --}}
<div class="overflow-x-auto border-t border-line">
<table class="w-full min-w-[720px] border-collapse text-left text-sm">
<caption class="sr-only">Was die Pakete unterscheidet</caption>
<thead>
<tr class="border-b border-line">
<th scope="col" class="px-6 py-4"><span class="sr-only">Merkmal</span></th>
@foreach ($plans as $plan)
<td class="px-6 py-3">
@if (in_array($feature, $plan['features'], true))
<x-ui.icon name="check" class="size-4 text-success" aria-label="enthalten" />
@else
<span class="text-faint" aria-label="nicht enthalten"></span>
@endif
</td>
<th scope="col" class="px-6 py-4 font-semibold text-ink">{{ $plan['name'] }}</th>
@endforeach
</tr>
@endforeach
</tbody>
</table>
</div>
</details>
</thead>
<tbody>
@foreach ($comparison as $row)
<tr class="border-b border-line last:border-0">
<th scope="row" class="px-6 py-3.5 font-medium text-muted">{{ $row['label'] }}</th>
@foreach ($plans as $plan)
@php($cell = $row['cells'][$plan['key']] ?? ['state' => 'absent'])
<td class="px-6 py-3.5 align-top">
@switch($cell['state'])
@case('included')
<span class="inline-flex items-center gap-2 text-body">
<x-ui.icon name="check" class="size-4 text-success" />inklusive
</span>
@break
<p class="rv mt-8 text-center text-sm leading-relaxed text-muted">
<b class="font-semibold text-ink">In jedem Paket:</b> tägliche verschlüsselte Sicherung mit monatlichem Wiederherstellungstest ·
Updates, Wartung und Überwachung · AV-Vertrag und TOM-Dokumentation · Support auf Deutsch am selben Werktag.
</p>
@case('optional')
<span class="inline-flex items-center gap-1.5 rounded-pill border border-accent-border bg-accent-subtle px-2.5 py-1 text-xs font-medium text-accent-text">
<x-ui.icon name="plus" class="size-3.5" />optional · {{ $cell['price'] }}
</span>
@break
@case('level')
<span class="block font-medium text-ink">{{ $cell['text'] }}</span>
@if ($cell['note'])
<span class="mt-0.5 block text-xs text-muted">{{ $cell['note'] }}</span>
@endif
@break
@default
<span class="text-faint" aria-label="nicht enthalten"></span>
@endswitch
</td>
@endforeach
</tr>
@endforeach
</tbody>
</table>
</div>
<p class="border-t border-line px-6 py-4 text-xs leading-relaxed text-muted">
„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.
</p>
</details>
@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))
<div class="rv d1 mt-4 overflow-hidden rounded-xl border border-line bg-surface shadow-xs">
<div class="flex flex-wrap items-baseline gap-x-3 gap-y-1 border-b border-line px-7 py-5">
<h3 class="text-lg font-bold tracking-[-0.02em] text-ink">Optional dazubuchbar</h3>
<span class="text-sm text-muted">zu jedem Paket, monatlich kündbar</span>
</div>
<div class="grid gap-3 p-6 sm:grid-cols-2 lg:grid-cols-3">
@foreach ($addons as $addon)
<div class="flex flex-col rounded-lg border border-line bg-surface-2 px-4 py-3.5">
<b class="text-sm font-semibold text-ink">{{ $addon['name'] }}</b>
<span class="mt-1.5 flex-1 text-xs leading-relaxed text-muted">{{ $addon['body'] }}</span>
<span class="mt-3.5 flex items-baseline gap-1.5 font-mono text-sm tabular-nums text-ink">
+{{ $addon['price'] }}<span class="text-xs text-muted">/Monat</span>
</span>
</div>
@endforeach
</div>
</div>
@endif
@endif
</section>

View File

@ -4,7 +4,24 @@
<span class="rounded bg-surface-2 px-2 py-0.5 font-mono text-xs text-muted">{{ $family->name }}</span>
</div>
<div class="mt-4 rounded-lg border border-line bg-surface-2 p-4">
<div class="mt-4 space-y-4">
<div>
<label class="text-sm font-medium text-body" for="pf-audience">{{ __('plans.audience') }}</label>
<input id="pf-audience" type="text" wire:model="audience" placeholder="{{ __('plans.audience_placeholder') }}"
class="mt-1.5 w-full rounded-md border border-line-strong bg-surface px-3 py-2 text-sm text-ink" />
<p class="mt-1 text-xs text-muted">{{ __('plans.audience_hint') }}</p>
@error('audience')<p class="mt-1 text-xs text-danger">{{ $message }}</p>@enderror
</div>
<div>
<label class="text-sm font-medium text-body" for="pf-note">{{ __('plans.note') }}</label>
<textarea id="pf-note" wire:model="note" rows="3"
class="mt-1.5 w-full rounded-md border border-line-strong bg-surface px-3 py-2 text-sm text-ink"></textarea>
<p class="mt-1 text-xs text-muted">{{ __('plans.note_hint') }}</p>
@error('note')<p class="mt-1 text-xs text-danger">{{ $message }}</p>@enderror
</div>
</div>
<div class="mt-5 rounded-lg border border-line bg-surface-2 p-4">
<x-ui.checkbox name="recommended" wire:model="recommended" :label="__('plans.recommended_label')" />
<p class="mt-1.5 pl-7 text-xs text-muted">{{ __('plans.recommended_hint') }}</p>
</div>

View File

@ -22,6 +22,16 @@
<div class="grid grid-cols-1 gap-5 xl:grid-cols-[1fr_380px] xl:items-start">
{{-- Versions --}}
<div class="space-y-4 animate-rise [animation-delay:60ms]">
@error('availableFrom')
@if ($publishing === null)
{{-- A refused row action has no form of its own to report
into: with the publish form closed, the message would
otherwise land in an error bag nothing renders, and the
button would look like it had simply done nothing. --}}
<div class="rounded-lg border border-danger bg-danger-bg p-4 text-sm text-body">{{ $message }}</div>
@endif
@enderror
@forelse ($versions as $version)
@php
// The window being open is not the same as the plan being
@ -67,10 +77,21 @@
x-on:click="Livewire.dispatch('openModal', { component: 'admin.confirm-delete-plan-draft', arguments: { uuid: '{{ $version->uuid }}' } })">
{{ __('plans.discard') }}
</x-ui.button>
@elseif ($windowOpen)
<x-ui.button variant="secondary" size="sm" wire:click="close('{{ $version->uuid }}')">
{{ __('plans.close_now') }}
</x-ui.button>
@else
@if ($windowOpen)
<x-ui.button variant="secondary" size="sm" wire:click="close('{{ $version->uuid }}')">
{{ __('plans.close_now') }}
</x-ui.button>
@endif
{{-- Every published version with an end date can
go back on open-ended sale, whether that end
is already past or still ahead. Closing used
to be one-way. --}}
@if ($version->available_until !== null)
<x-ui.button variant="secondary" size="sm" wire:click="reopen('{{ $version->uuid }}')">
{{ __('plans.reopen') }}
</x-ui.button>
@endif
@endif
</div>
</div>
@ -79,6 +100,14 @@
@if ($publishing === $version->uuid)
<div class="mt-4 rounded-lg border border-line-strong bg-surface-2 p-4">
<p class="text-sm text-body">{{ __('plans.publish_warning') }}</p>
@if ($handover !== null)
{{-- Publishing here ends another version's sale,
so say which one and when, before the button
is pressed rather than in the list after. --}}
<p class="mt-3 rounded border border-warning-border bg-warning-bg px-3 py-2 text-sm text-body">
{{ __('plans.handover_note', ['version' => $handover['version'], 'at' => $handover['at']]) }}
</p>
@endif
<div class="mt-3 grid grid-cols-1 gap-3 sm:grid-cols-2">
<x-ui.input name="availableFrom" type="datetime-local"
:label="__('plans.available_from')" wire:model="availableFrom" />

View File

@ -179,6 +179,25 @@
</p>
</div>
{{-- What the move would take away, above the button that
makes it being told afterwards that an own domain
has been switched off is the same failure as a
greyed-out button with no reason on it. --}}
@if ($plan['check']->consequences !== [])
<div class="mt-5 rounded border border-accent-border bg-accent-subtle p-3">
<p class="flex items-center gap-1.5 text-xs font-semibold text-accent-text">
<x-ui.icon name="alert-triangle" class="size-4" />{{ __('billing.downgrade_consequence_title') }}
</p>
<ul class="mt-2 space-y-1.5">
@foreach ($plan['check']->consequences as $consequence)
<li class="text-xs leading-relaxed text-ink">
{{ __('billing.downgrade_consequence.'.$consequence['key'], $consequence['replace']) }}
</li>
@endforeach
</ul>
</div>
@endif
@if ($plan['check']->allowed)
<x-ui.button variant="secondary" class="mt-5 w-full"
wire:click="purchase('downgrade', '{{ $key }}')"
@ -251,7 +270,14 @@
<p class="font-semibold text-ink">{{ __('billing.addon.'.$key.'.name') }}</p>
</div>
<p class="mt-2 flex-1 text-sm text-muted">{{ __('billing.addon.'.$key.'.desc') }}</p>
@if ($addon['granted'] ?? false)
@if ($addon['included'] ?? false)
{{-- Part of the package they already pay for. Neither a
price nor a button belongs here: both would be selling
a customer something they have. --}}
<p class="mt-3 inline-flex items-center gap-1.5 rounded-pill border border-success-border bg-success-bg px-2.5 py-0.5 text-xs font-medium text-success">
<x-ui.icon name="check" class="size-4" />{{ __('billing.addon_included') }}
</p>
@elseif ($addon['granted'] ?? false)
{{-- Not "free", not struck through just the service. --}}
<p class="mt-3 text-sm font-medium text-muted">{{ __('billing.granted_plan') }}</p>
@else
@ -276,7 +302,9 @@
<span class="size-1.5 rounded-pill bg-current" aria-hidden="true"></span>
{{ __('billing.addon_booked') }}
</p>
@else
{{-- A module the package includes has no button either: the
badge above is the whole of what this card has to say. --}}
@elseif (! ($addon['included'] ?? false))
<x-ui.button variant="secondary" size="sm" class="mt-3 w-full" wire:click="purchase('addon', '{{ $key }}')" wire:target="purchase('addon', '{{ $key }}')" wire:loading.attr="disabled">
{{ __('billing.addon_cta') }}
</x-ui.button>

View File

@ -1,16 +1,17 @@
<?php
use App\Support\LocalTime;
use App\Livewire\Admin\ConfirmDeletePlanDraft;
use App\Livewire\Admin\EditPlanFamily;
use App\Livewire\Admin\PlanVersions;
use App\Livewire\Admin\Plans;
use App\Livewire\Admin\PlanVersions;
use App\Models\Operator;
use App\Models\PlanFamily;
use App\Models\PlanVersion;
use App\Models\Subscription;
use App\Services\Billing\PlanCatalogue;
use App\Support\LocalTime;
use Livewire\Livewire;
use Spatie\Permission\PermissionRegistrar;
/**
* The console is where the owner creates and schedules plans. Everything the
@ -151,32 +152,106 @@ it('refuses a disk smaller than the storage it has to hold', function () {
->assertHasErrors('diskGb');
});
it('publishes a draft into a window, and refuses one that overlaps', function () {
it('publishes a draft straight over the running version, announcing the handover first', function () {
$page = Livewire::actingAs(owner(), 'operator')->test(PlanVersions::class, ['uuid' => teamFamily()->uuid]);
$page->set('monthlyPrice', '199,00')->call('draft');
$draft = teamFamily()->versions()->where('version', 2)->sole();
$live = app(PlanCatalogue::class)->currentVersion('team');
$at = now()->addHour();
// Publishing ends another version's sale, so the form has to say which one
// and when — before the button is pressed, not in the list afterwards.
$page->call('choose', $draft->uuid)
->set('availableFrom', LocalTime::toField($at))
->assertSee(__('plans.handover_note', [
'version' => $live->version,
'at' => $at->copy()->local()->isoFormat('L LT'),
]));
// No closing the running version first: that was the trap. A replacement
// that then failed to publish left the plan off sale with no way back.
$page->call('publish')->assertHasNoErrors();
expect($draft->fresh()->isPublished())->toBeTrue()
->and($live->fresh()->available_until->eq($draft->fresh()->available_from))->toBeTrue()
->and(app(PlanCatalogue::class)->currentVersion('team')->version)->toBe(1)
->and(app(PlanCatalogue::class)->currentVersion('team', now()->addHours(2))->version)->toBe(2);
});
it('puts a closed version back on sale from the version list', function () {
$page = Livewire::actingAs(owner(), 'operator')->test(PlanVersions::class, ['uuid' => teamFamily()->uuid]);
$live = app(PlanCatalogue::class)->currentVersion('team');
$page->call('close', $live->uuid);
expect(app(PlanCatalogue::class)->isSellable('team'))->toBeFalse();
// Closing was one-way until now, so the way back has to be on the page.
$page->assertSee(__('plans.reopen'))
->call('reopen', $live->uuid)
->assertHasNoErrors();
expect(app(PlanCatalogue::class)->isSellable('team'))->toBeTrue()
->and(app(PlanCatalogue::class)->currentVersion('team')->version)->toBe($live->version)
->and($live->fresh()->available_until)->toBeNull();
});
it('refuses to reopen a version whose successor is selling, in the console\'s own words', function () {
$page = Livewire::actingAs(owner(), 'operator')->test(PlanVersions::class, ['uuid' => teamFamily()->uuid]);
$page->set('monthlyPrice', '199,00')->call('draft');
$draft = teamFamily()->versions()->where('version', 2)->sole();
$live = app(PlanCatalogue::class)->currentVersion('team');
// Straight into the running version's window: refused, and said so on the
// form rather than thrown.
$page->call('choose', $draft->uuid)
->set('availableFrom', LocalTime::toField(now()))
->call('publish')
->assertHasErrors('availableFrom');
expect($draft->fresh()->isPublished())->toBeFalse();
// Close the running one first, then the successor lands cleanly.
$live = app(PlanCatalogue::class)->currentVersion('team');
$page->call('close', $live->uuid);
$page->call('choose', $draft->uuid)
->set('availableFrom', LocalTime::toField(now()->addMinute()))
->call('publish')
->assertHasNoErrors();
expect($draft->fresh()->isPublished())->toBeTrue()
->and(app(PlanCatalogue::class)->currentVersion('team', now()->addHour())->version)->toBe(2);
$page->call('reopen', $live->uuid)
->assertHasErrors('availableFrom')
->assertSee(__('plans.reopen_blocked'))
// The catalogue explains itself in English, at the length a log entry
// deserves. That sentence must not reach a German console.
->assertDontSee('overlaps');
expect($live->fresh()->available_until)->not->toBeNull()
->and(app(PlanCatalogue::class)->currentVersion('team')->version)->toBe(2);
});
it('will not reopen a draft, and says why instead of doing nothing', function () {
$page = Livewire::actingAs(owner(), 'operator')->test(PlanVersions::class, ['uuid' => teamFamily()->uuid]);
$page->call('draft');
$draft = teamFamily()->versions()->where('version', 2)->sole();
// The list offers no such button for a draft, but a request can carry
// anything, and "was never on sale" is a different refusal from "something
// newer is selling".
$page->call('reopen', $draft->uuid)
->assertHasErrors('availableFrom')
->assertSee(__('plans.reopen_draft'));
expect($draft->fresh()->isPublished())->toBeFalse()
->and($draft->fresh()->available_until)->toBeNull();
});
it('re-checks the capability on reopen rather than trusting the mount', function () {
$user = owner();
$live = app(PlanCatalogue::class)->currentVersion('team');
app(PlanCatalogue::class)->schedule($live, $live->available_from, now());
$page = Livewire::actingAs($user, 'operator')->test(PlanVersions::class, ['uuid' => teamFamily()->uuid]);
// The page is open and legitimate, and then the account is demoted.
$user->syncRoles(['Support']);
app(PermissionRegistrar::class)->forgetCachedPermissions();
$page->call('reopen', $live->uuid)->assertForbidden();
expect($live->fresh()->available_until)->not->toBeNull()
->and(app(PlanCatalogue::class)->isSellable('team'))->toBeFalse();
});
it('will not publish a window that ends before it starts', function () {
@ -282,6 +357,17 @@ it('opens the modal already showing the current mark, and can clear it entirely'
expect($team->fresh()->is_recommended)->toBeFalse();
});
it('saves the audience line and note beside the plan', function () {
Livewire::actingAs(owner(), 'operator')->test(EditPlanFamily::class, ['uuid' => teamFamily()->uuid])
->set('audience', 'Für Redaktionen')
->set('note', 'Testnotiz')
->call('save');
expect(teamFamily())
->audience->toBe('Für Redaktionen')
->note->toBe('Testnotiz');
});
it('closes the marketing modal to operators without the capability', function () {
// Modals are reachable without the page's guards — same reason
// ConfirmDeletePlanDraft checks this on itself.

View File

@ -0,0 +1,241 @@
<?php
use App\Actions\BookAddon;
use App\Actions\GrantAddon;
use App\Livewire\Billing;
use App\Models\Customer;
use App\Models\Instance;
use App\Models\Order;
use App\Models\Subscription;
use App\Models\SubscriptionAddon;
use App\Models\User;
use App\Services\Billing\CustomDomainAccess;
use App\Services\Billing\DowngradeCheck;
use App\Services\Billing\PlanCatalogue;
use App\Services\Billing\PlanChange;
use Livewire\Livewire;
/**
* Who may have their own domain.
*
* The domain page shipped with no access control at all: the `custom_domain`
* plan feature was checked nowhere, and every customer on every package could
* point a domain at their instance. These tests are the owner's rule, stated as
* behaviour impossible on the entry package, a module on Team, part of the
* package on Business and Enterprise and, as much as anything, they are about
* what happens on the way DOWN, which is where a right silently outliving the
* package that granted it does its damage.
*/
function domainCustomer(string $plan, array $instanceAttributes = []): array
{
$customer = Customer::factory()->create();
$user = User::factory()->create(['email' => $customer->email, 'email_verified_at' => now()]);
$customer->update(['user_id' => $user->id]);
$instance = Instance::factory()->create([
'customer_id' => $customer->id,
'plan' => $plan,
'status' => 'active',
// Per package: subdomains are unique, and one test walks two packages.
'subdomain' => 'berger-'.$plan,
...$instanceAttributes,
]);
$subscription = Subscription::factory()->plan($plan)->create([
'customer_id' => $customer->id,
'instance_id' => $instance->id,
]);
return [$customer, $user, $instance, $subscription];
}
/** An instance whose owner has proved the domain is theirs. */
function verifiedDomain(string $domain = 'cloud.berger.at'): array
{
return ['custom_domain' => $domain, 'domain_token' => 'tok123', 'domain_verified_at' => now()];
}
it('does not sell an own domain to the entry package, at any of the three doors', function () {
[$customer, $user, , $subscription] = domainCustomer('start');
// The action, which is the only door that actually books anything.
expect(fn () => app(BookAddon::class)($subscription, CustomDomainAccess::ADDON))
->toThrow(RuntimeException::class, __('billing.custom_domain.not_in_plan', ['plan' => 'Start']));
// The page, which must not offer what the action would refuse.
$page = Livewire::actingAs($user)->test(Billing::class);
expect($page->viewData('addons'))->not->toHaveKey(CustomDomainAccess::ADDON);
$page->assertDontSee("purchase('addon', 'custom_domain')", false);
// And the component method behind the button, because a hidden button is
// markup: a stale tab can still call it.
Livewire::actingAs($user)->test(Billing::class)->call('purchase', 'addon', CustomDomainAccess::ADDON);
expect(Order::query()->where('customer_id', $customer->id)->where('addon_key', CustomDomainAccess::ADDON)->exists())
->toBeFalse();
});
it('leaves no paid order behind when a grant of the module is refused', function () {
// The console gives a module away through the same booking action, on a
// synthetic order it writes first. A refusal after that would leave a paid
// purchase of nothing on the customer's account — and on their invoice.
[$customer, , , $subscription] = domainCustomer('start');
expect(fn () => app(GrantAddon::class)($subscription, admin(), CustomDomainAccess::ADDON, 0))
->toThrow(RuntimeException::class);
expect(Order::query()->where('customer_id', $customer->id)->where('type', 'addon')->exists())->toBeFalse();
});
it('gives Team the domain only once the module is booked', function () {
[, , , $subscription] = domainCustomer('team');
$access = app(CustomDomainAccess::class);
// Bookable, and not yet booked — which is not the same as allowed.
expect($access->bookable($subscription))->toBeTrue()
->and($access->allows($subscription))->toBeFalse()
->and($access->refusal($subscription))->not->toBeNull();
app(BookAddon::class)($subscription, CustomDomainAccess::ADDON);
expect($access->allows($subscription))->toBeTrue()
->and($access->refusal($subscription))->toBeNull();
});
it('gives Business and Enterprise the domain with the package, and does not sell it to them twice', function () {
$access = app(CustomDomainAccess::class);
foreach (['business', 'enterprise'] as $plan) {
[, $user, , $subscription] = domainCustomer($plan);
expect($access->allows($subscription))->toBeTrue()
->and($access->hasBooked($subscription))->toBeFalse()
->and($access->bookable($subscription))->toBeFalse();
expect(fn () => app(BookAddon::class)($subscription, CustomDomainAccess::ADDON))
->toThrow(RuntimeException::class, __('billing.custom_domain.already_included'));
// The card stays — it says what they have — but it is not a sale.
$page = Livewire::actingAs($user)->test(Billing::class);
expect($page->viewData('addons')[CustomDomainAccess::ADDON]['included'])->toBeTrue();
$page->assertSee(__('billing.addon_included'))
->assertDontSee("purchase('addon', 'custom_domain')", false);
}
});
it('switches the domain off when the package it moved to cannot have one', function () {
// Team → Start. The domain and the module both came with the package the
// customer has just left.
[$customer, , $instance] = domainCustomer('team', verifiedDomain());
$start = Subscription::factory()->plan('start')->create([
'customer_id' => $customer->id,
'instance_id' => $instance->id,
]);
// The module rides along with the change: nothing cancels a booking when a
// package changes, so settling has to be the thing that notices.
SubscriptionAddon::create([
'subscription_id' => $start->id,
'addon_key' => CustomDomainAccess::ADDON,
'price_cents' => 900,
'currency' => 'EUR',
'quantity' => 1,
'booked_at' => now(),
]);
expect(PlanChange::settleCustomDomain($start))->toBeTrue();
$instance->refresh();
expect($instance->domainIsVerified())->toBeFalse()
// Back to the address the platform issued it, which is the whole of the
// fallback: nothing here reimplements how an instance is served.
->and($instance->address('clupilot.com'))->toBe('berger-team.clupilot.com')
->and($instance->custom_domain)->toBeNull()
// And it stops being charged for. A feature withdrawn while the module
// is still on the bill is the complaint we would deserve.
->and($start->addons()->active()->count())->toBe(0);
});
it('asks a Business customer before the move, and abides by their answer', function () {
[$customer, $user, $instance] = domainCustomer('business', verifiedDomain());
$team = app(PlanCatalogue::class)->sellable()['team'];
$check = DowngradeCheck::for($customer, $instance, $team, 'team');
// Possible, and still worth saying out loud beforehand.
expect($check->allowed)->toBeTrue()
->and($check->consequences)->toHaveCount(1)
->and($check->consequences[0]['key'])->toBe('custom_domain_addon')
->and($check->consequences[0]['replace']['domain'])->toBe('cloud.berger.at');
// And it is on the card, above the button that would do it — not in a
// message afterwards.
Livewire::actingAs($user)->test(Billing::class)
->assertSee(__('billing.downgrade_consequence_title'))
->assertSee('cloud.berger.at');
// Declining: the Team contract carries no module, and the domain goes.
$teamContract = Subscription::factory()->plan('team')->create([
'customer_id' => $customer->id,
'instance_id' => $instance->id,
]);
expect(PlanChange::settleCustomDomain($teamContract))->toBeTrue()
->and($instance->fresh()->domainIsVerified())->toBeFalse();
});
it('leaves everything alone when the customer keeps the domain by booking the module', function () {
[$customer, , $instance] = domainCustomer('business', verifiedDomain());
// The module is booked onto the contract they are moving TO — on Business
// it would be refused as something they already have.
$teamContract = Subscription::factory()->plan('team')->create([
'customer_id' => $customer->id,
'instance_id' => $instance->id,
]);
app(BookAddon::class)($teamContract, CustomDomainAccess::ADDON);
expect(PlanChange::settleCustomDomain($teamContract))->toBeFalse()
->and($instance->fresh()->domainIsVerified())->toBeTrue()
->and($instance->fresh()->address('clupilot.com'))->toBe('cloud.berger.at');
// And with the module booked there is nothing left to warn about.
$team = app(PlanCatalogue::class)->sellable()['team'];
expect(DowngradeCheck::for($customer, $instance, $team, 'team')->consequences)->toBe([]);
});
it('says nothing about a domain the customer has not got', function () {
// The warning is about a concrete address being switched off. Without one
// there is nothing to decide, and a paragraph of consequences on every
// downgrade card teaches people to skip them.
[$customer, , $instance] = domainCustomer('business');
$team = app(PlanCatalogue::class)->sellable()['team'];
expect(DowngradeCheck::for($customer, $instance, $team, 'team')->consequences)->toBe([]);
});
it('keeps the rule in one place, where it can only be changed once', function () {
// The gap this closed was the same rule living in no place at all. The
// opposite failure — a copy in the shop, a copy on the price sheet, a copy
// in the downgrade warning — is the one that ships a customer three
// different answers, so the config key that says where a domain is
// impossible is read by exactly one class.
$readers = [];
$files = new RecursiveIteratorIterator(new RecursiveDirectoryIterator(app_path()));
foreach ($files as $file) {
if ($file->isFile() && $file->getExtension() === 'php'
&& str_contains((string) file_get_contents($file->getPathname()), 'unavailable_on')) {
$readers[] = str_replace(base_path().'/', '', $file->getPathname());
}
}
expect($readers)->toBe(['app/Services/Billing/CustomDomainAccess.php']);
});

View File

@ -363,33 +363,154 @@ it('will not publish a version that is not priced for every term', function () {
expect(fn () => app(PlanCatalogue::class)->publish($draft))->toThrow(RuntimeException::class, 'monthly');
});
it('leaves a version unpublished when its window is refused', function () {
it('leaves the version unpublished and its predecessor untouched when the window is still refused', function () {
$catalogue = app(PlanCatalogue::class);
$family = PlanFamily::query()->where('key', 'team')->sole();
$currency = Subscription::catalogueCurrency();
// v1 runs until next month, when the already-published v2 takes over.
$v1 = $catalogue->currentVersion('team');
$catalogue->schedule($v1, $v1->available_from, now()->addMonth());
$closesAt = $v1->fresh()->available_until->toDateTimeString();
$v2 = PlanVersion::query()->create([
...$v1->only(['plan_family_id', 'quota_gb', 'traffic_gb', 'seats', 'ram_mb', 'cores', 'disk_gb', 'performance', 'template_vmid']),
'version' => 2, 'features' => $v1->features, 'available_from' => now()->addMonth(),
]);
$v2->prices()->create(['term' => 'monthly', 'amount_cents' => 18900, 'currency' => $currency]);
$v2->prices()->create(['term' => 'yearly', 'amount_cents' => 226800, 'currency' => $currency]);
$catalogue->publish($v2, now()->addMonth());
$draft = PlanVersion::query()->create([
'plan_family_id' => $family->id, 'version' => 4,
'quota_gb' => 600, 'traffic_gb' => 3000, 'seats' => 30, 'ram_mb' => 8192,
'cores' => 4, 'disk_gb' => 640, 'performance' => 'enhanced', 'template_vmid' => 9000,
'features' => [], 'available_from' => now(),
]);
$draft->prices()->create(['term' => 'monthly', 'amount_cents' => 18900, 'currency' => $currency]);
$draft->prices()->create(['term' => 'yearly', 'amount_cents' => 226800, 'currency' => $currency]);
$draft->prices()->create(['term' => 'monthly', 'amount_cents' => 19900, 'currency' => $currency]);
$draft->prices()->create(['term' => 'yearly', 'amount_cents' => 238800, 'currency' => $currency]);
// Straight into the running version's window.
// Open-ended from now: v1 would be handed over, but v2 is still in the way,
// and a handover is not licence to run over a scheduled successor.
expect(fn () => $catalogue->publish($draft, now()))->toThrow(RuntimeException::class, 'overlaps');
// Still a draft, so the mistake is simply correctable — publishing first
// and failing after would have frozen it beyond repair.
// and failing after would have frozen it beyond repair. And v1 keeps the
// window it had: a predecessor closed for a successor that never arrived
// would be a plan taken off sale by an action that failed.
expect($draft->fresh()->isPublished())->toBeFalse()
->and($draft->fresh()->available_from->toDateString())->toBe(now()->toDateString());
->and($draft->fresh()->available_from->toDateString())->toBe(now()->toDateString())
->and($v1->fresh()->available_until->toDateTimeString())->toBe($closesAt);
$current = $catalogue->currentVersion('team');
$catalogue->schedule($current, $current->available_from, now()->addWeek());
$catalogue->publish($draft, now()->addWeek());
// A window that fits between the two is accepted, and takes v1 over then.
$catalogue->publish($draft, now()->addWeek(), now()->addMonth());
expect($catalogue->currentVersion('team', now()->addWeek())->version)->toBe(4);
expect($catalogue->currentVersion('team', now()->addWeek())->version)->toBe(4)
->and($v1->fresh()->available_until->eq($draft->fresh()->available_from))->toBeTrue();
});
it('hands the running version over to its replacement instead of refusing to publish it', function () {
$catalogue = app(PlanCatalogue::class);
$family = PlanFamily::query()->where('key', 'team')->sole();
$running = $catalogue->currentVersion('team');
$at = now()->addWeek();
$successor = $catalogue->draft($family, [
'quota_gb' => 600, 'traffic_gb' => 3000, 'seats' => 30, 'ram_mb' => 8192,
'cores' => 4, 'disk_gb' => 640, 'performance' => 'enhanced', 'template_vmid' => 9000,
'features' => [],
], ['monthly' => 18900, 'yearly' => 226800]);
// No closing the old one first: that used to be mandatory, and a
// replacement that then failed to publish left the plan off sale for good.
$catalogue->publish($successor, $at);
// The predecessor stops exactly where the successor starts.
expect($running->fresh()->available_until->eq($successor->fresh()->available_from))->toBeTrue();
// currentVersion() uses sole(), so each of these also proves that exactly
// one version is on sale at that moment — and there is no gap at the seam.
expect($catalogue->currentVersion('team', $at->copy()->subSecond())->version)->toBe($running->version)
->and($catalogue->currentVersion('team', $at)->version)->toBe($successor->version)
->and($catalogue->isSellable('team', $at->copy()->subSecond()))->toBeTrue()
->and($catalogue->isSellable('team', $at))->toBeTrue()
->and($catalogue->sellable($at)['team']['price_cents'])->toBe(18900);
});
it('leaves a version that had already ended out of the handover', function () {
$catalogue = app(PlanCatalogue::class);
$family = PlanFamily::query()->where('key', 'team')->sole();
// v1 stops in a minute, and nothing replaces it for a week.
$ended = $catalogue->currentVersion('team');
$catalogue->schedule($ended, $ended->available_from, now()->addMinute());
$endedAt = $ended->fresh()->available_until->toDateTimeString();
$successor = $catalogue->draft($family, [
'quota_gb' => 600, 'traffic_gb' => 3000, 'seats' => 30, 'ram_mb' => 8192,
'cores' => 4, 'disk_gb' => 640, 'performance' => 'enhanced', 'template_vmid' => 9000,
'features' => [],
], ['monthly' => 18900, 'yearly' => 226800]);
$catalogue->publish($successor, now()->addWeek());
// The handover closes what is running, not what is over. Moving this one's
// end date forward would rewrite when it actually stopped selling.
expect($ended->fresh()->available_until->toDateTimeString())->toBe($endedAt);
});
it('puts a closed version back on sale', function () {
$catalogue = app(PlanCatalogue::class);
$version = $catalogue->currentVersion('team');
$catalogue->schedule($version, $version->available_from, now());
expect($catalogue->isSellable('team'))->toBeFalse();
$catalogue->reopen($version);
// Open-ended again, not merely extended: the plan sells now and keeps
// selling until someone decides otherwise.
expect($catalogue->isSellable('team'))->toBeTrue()
->and($catalogue->isSellable('team', now()->addYear()))->toBeTrue()
->and($catalogue->sellable()['team']['price_cents'])->toBe(17900)
->and($catalogue->currentVersion('team')->version)->toBe($version->version);
});
it('refuses to reopen a version whose successor is already selling', function () {
$catalogue = app(PlanCatalogue::class);
$family = PlanFamily::query()->where('key', 'team')->sole();
$first = $catalogue->currentVersion('team');
$second = $catalogue->draft($family, [
'quota_gb' => 600, 'traffic_gb' => 3000, 'seats' => 30, 'ram_mb' => 8192,
'cores' => 4, 'disk_gb' => 640, 'performance' => 'enhanced', 'template_vmid' => 9000,
'features' => [],
], ['monthly' => 18900, 'yearly' => 226800]);
$catalogue->publish($second, now());
// Reopening would put both on sale, and every read of the family would
// throw rather than pick one.
expect(fn () => $catalogue->reopen($first))->toThrow(RuntimeException::class, 'overlaps');
expect($catalogue->currentVersion('team')->version)->toBe($second->version);
});
it('refuses to reopen a version that was never published', function () {
$catalogue = app(PlanCatalogue::class);
$family = PlanFamily::query()->where('key', 'team')->sole();
$draft = $catalogue->draft($family, [
'quota_gb' => 600, 'traffic_gb' => 3000, 'seats' => 30, 'ram_mb' => 8192,
'cores' => 4, 'disk_gb' => 640, 'performance' => 'enhanced', 'template_vmid' => 9000,
'features' => [],
], ['monthly' => 18900, 'yearly' => 226800]);
// Nothing was ever on sale, so there is nothing to put back.
expect(fn () => $catalogue->reopen($draft))->toThrow(RuntimeException::class, 'never published');
expect($draft->fresh()->isPublished())->toBeFalse();
});
it('reports a consistent catalogue, and an inconsistent one', function () {

View File

@ -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, '<table');
if ($start === false) {
return '';
}
return substr($section, $start, (int) strpos($section, '</table>') - $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.'</th>');
if ($at === false) {
return '';
}
$start = (int) strrpos(substr($table, 0, $at), '<tr');
return substr($table, $start, (int) strpos($table, '</tr>', $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();
@ -100,6 +140,142 @@ it('marks the plan the console recommends, and only that one', function () {
->and(substr_count($content, 'Empfohlen'))->toBe(1);
});
it('shows the audience line the console saved, not a string compiled into the controller', function () {
// The point of this migration: the console is the one place this
// sentence is written, same as the price.
DB::table('plan_families')
->where('key', 'team')
->update(['audience' => 'Für Redaktionen und Kanzleien']);
$this->get('/')
->assertOk()
->assertSee('Für Redaktionen und Kanzleien')
->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: team does 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('states the own-domain rule package by package: impossible, optional, included', function () {
// The owner's rule, on the page that has to say it: an own domain is
// impossible on the entry package, a module on the one above it, and part
// of the two above that. Quoting nine euros in the first column was the
// sheet making an offer the shop would then refuse — and the price sheet is
// where somebody decides which package to buy in the first place.
$row = priceSheetRow($this->get('/')->assertOk()->getContent(), 'Eigene Domain');
$cells = array_slice(explode('<td', $row), 1);
expect($cells)->toHaveCount(4)
->and($cells[0])->toContain('—')
->and($cells[0])->not->toContain('optional')
->and($cells[0])->not->toContain('€')
->and($cells[1])->toContain("optional · 9\u{00A0}€")
->and($cells[2])->toContain('inklusive')
->and($cells[3])->toContain('inklusive');
});
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'))