Decide who may have their own domain, and decide it in one place
tests / pest (push) Failing after 9m46s Details
tests / assets (push) Successful in 21s Details
tests / release (push) Has been skipped Details

The domain page shipped with no access control at all: the `custom_domain`
plan feature was checked nowhere in the codebase, and every customer on
every package could point a domain at their instance. This is the owner's
rule, built as one authority that everything else asks.

  Start        impossible — not bookable, not upgradable, not offered
  Team         optional, via the `custom_domain` module
  Business     included, because the plan version carries the feature
  Enterprise   included, likewise

App\Services\Billing\CustomDomainAccess answers all of it: may this
contract use one, may it book the module, why not in words a customer can
be shown, what a downgrade would do to a domain they have, and — after a
change lands — making the state match. Read against the FROZEN plan
version, never today's catalogue, so a feature added to a package later
cannot reach into a contract signed before it.

Which packages cannot have one at all is an explicit list in
config/provisioning.php, beside the module's price. The catalogue cannot
answer it: plan versions model what a package HAS, and nothing models what
a package may BUY. Both available proxies are dishonest — "the lowest tier
on sale" hands the right to a grandfathered Start customer the day the
owner stops selling Start or adds something cheaper below it, and reading
it off `branding` would sell a domain to anyone allowed to upload a logo.
The config comment says so.

Enforced where it can be enforced today:

  - BookAddon refuses the module, with the sentence the customer is shown.
    GrantAddon now writes its synthetic order inside the transaction, so a
    refusal cannot leave a paid order for a module nobody got.
  - The portal drops the card where it cannot be booked and shows it as
    included where the package carries it — no price, no button.
  - Billing::purchase() re-checks, because a hidden button is markup.
  - DowngradeCheck reports the consequence beside the blockers, so the
    customer reads it above the button rather than after it: losing the
    domain (Team -> Start), or the choice (Business -> Team, where keeping
    it means booking the module).
  - PlanChange::settleCustomDomain() applies it once a change lands: the
    module stops being charged for and the instance goes back to its
    platform address. It clears the whole domain, not only the verified
    flag — clupilot:verify-domains re-reads every row that still has a
    domain and a token, and would switch it back on the next night.
  - The price sheet's own-domain row now reads dash / optional + price /
    included, from the same authority; the three cell states already
    existed and the view is unchanged.

STILL TO APPLY, ONCE THIS AND THE CUSTOM-DOMAIN BRANCH MEET — one line at
the top of App\Livewire\CustomDomain::mount(), which belongs to the other
session and is deliberately untouched here:

    abort_unless(app(\App\Services\Billing\CustomDomainAccess::class)->allowsCustomer($this->customer()), 403);

routes/web.php is left alone for the same reason. The mount() guard covers
the route and the component both; a middleware would only repeat it.

Follow-ups, named rather than built:

  - Nothing applies a plan change anywhere yet, so settleCustomDomain()
    has no caller. Whoever builds that must call it after the new contract
    opens, and must decide whether a booked module is carried onto the new
    contract — enforce() cancels the booking on the contract it is given.
  - Deactivation sets the state; it does not re-run provisioning. Traefik
    and Nextcloud's trusted_domains still name the old address until
    ConfigureDnsAndTls / ConfigureNextcloud run again.
  - App\Support\Navigation still shows the Domain tab to every customer;
    it can ask allowsCustomer() the same way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feat/plan-reopen
nexxo 2026-07-29 15:59:04 +02:00
parent 517606b4db
commit b3651a14be
13 changed files with 888 additions and 39 deletions

View File

@ -7,6 +7,7 @@ use App\Models\Subscription;
use App\Models\SubscriptionAddon; use App\Models\SubscriptionAddon;
use App\Models\SubscriptionRecord; use App\Models\SubscriptionRecord;
use App\Services\Billing\AddonCatalogue; use App\Services\Billing\AddonCatalogue;
use App\Services\Billing\CustomDomainAccess;
use Illuminate\Database\UniqueConstraintViolationException; use Illuminate\Database\UniqueConstraintViolationException;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use RuntimeException; use RuntimeException;
@ -40,6 +41,21 @@ class BookAddon
throw new RuntimeException('An add-on is booked at least once.'); 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 { try {
return $this->book($subscription, $addonKey, $quantity, $order, $price, $overrides); return $this->book($subscription, $addonKey, $quantity, $order, $price, $overrides);
} catch (UniqueConstraintViolationException) { } catch (UniqueConstraintViolationException) {

View File

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

View File

@ -4,6 +4,7 @@ namespace App\Http\Controllers;
use App\Models\Subscription; use App\Models\Subscription;
use App\Services\Billing\AddonCatalogue; use App\Services\Billing\AddonCatalogue;
use App\Services\Billing\CustomDomainAccess;
use App\Services\Billing\PlanCatalogue; use App\Services\Billing\PlanCatalogue;
use App\Support\ProvisioningSettings; use App\Support\ProvisioningSettings;
use Illuminate\Contracts\View\View; use Illuminate\Contracts\View\View;
@ -283,10 +284,13 @@ class LandingController extends Controller
foreach ($plans as $plan) { foreach ($plans as $plan) {
$cells[$plan['key']] = match (true) { $cells[$plan['key']] = match (true) {
in_array($key, $plan['keys'], true) => ['state' => 'included'], in_array($key, $plan['keys'], true) => ['state' => 'included'],
// Not carried, but on sale — the only honest third answer. // Not carried, but on sale TO THIS PACKAGE — the only
// A module we do not sell stays a dash, however easy it // honest third answer. A module we do not sell stays a
// would be to invent a price for it. // dash, however easy it would be to invent a price for it,
isset($modules[$key]) => ['state' => 'optional', 'price' => $this->modulePrice($modules[$key])], // 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'], default => ['state' => 'absent'],
}; };
} }
@ -297,6 +301,26 @@ class LandingController extends Controller
return $rows; 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. * Support as one row of levels.
* *

View File

@ -7,6 +7,7 @@ use App\Models\Customer;
use App\Models\Order; use App\Models\Order;
use App\Models\Subscription; use App\Models\Subscription;
use App\Services\Billing\AddonCatalogue; use App\Services\Billing\AddonCatalogue;
use App\Services\Billing\CustomDomainAccess;
use App\Services\Billing\DowngradeCheck; use App\Services\Billing\DowngradeCheck;
use App\Services\Billing\PlanCatalogue; use App\Services\Billing\PlanCatalogue;
use App\Services\Billing\TaxTreatment; use App\Services\Billing\TaxTreatment;
@ -45,6 +46,8 @@ class Billing extends Component
default => [null, 0, null], default => [null, 0, null],
}; };
$access = app(CustomDomainAccess::class);
// Guard against invalid keys / non-upgrades. // Guard against invalid keys / non-upgrades.
$valid = match ($type) { $valid = match ($type) {
// By rank, matching PlanChange and the cards on the page. Comparing // By rank, matching PlanChange and the cards on the page. Comparing
@ -58,12 +61,18 @@ class Billing extends Component
// markup is not enforced. // markup is not enforced.
'downgrade' => isset($plans[$key]) 'downgrade' => isset($plans[$key])
&& (int) ($plans[$key]['tier'] ?? 0) < (int) ($instance?->subscription?->tier ?? $plans[$currentPlan]['tier'] ?? 0) && (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, 'storage' => true,
// Always available: running out of traffic is exactly when someone // Always available: running out of traffic is exactly when someone
// needs to be able to buy more, whatever plan they are on. // needs to be able to buy more, whatever plan they are on.
'traffic' => true, '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, default => false,
}; };
if (! $valid || $plan === null) { 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() public function render()
{ {
$customer = $this->customer(); $customer = $this->customer();
@ -171,7 +215,10 @@ class Billing extends Component
$downgrades = collect($plans) $downgrades = collect($plans)
->filter(fn ($p) => (int) ($p['tier'] ?? 0) < $currentTier) ->filter(fn ($p) => (int) ($p['tier'] ?? 0) < $currentTier)
->sortByDesc(fn ($p) => (int) ($p['tier'] ?? 0)) ->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(); ->all();
$upgrades = collect($plans) $upgrades = collect($plans)
@ -199,9 +246,12 @@ class Billing extends Component
// Booked modules at the price they were booked at; everything else // Booked modules at the price they were booked at; everything else
// at today's. Reading them all off the catalogue would re-price // at today's. Reading them all off the catalogue would re-price
// half a customer's bill behind their back. // half a customer's bill behind their back.
'addons' => collect(app(AddonCatalogue::class)->forSubscription($instance?->subscription)) 'addons' => $this->offerable(
->except(AddonCatalogue::STORAGE) collect(app(AddonCatalogue::class)->forSubscription($instance?->subscription))
->all(), ->except(AddonCatalogue::STORAGE)
->all(),
$customer,
),
'totalMonthlyCents' => $instance?->subscription?->totalMonthlyCents(), 'totalMonthlyCents' => $instance?->subscription?->totalMonthlyCents(),
'pending' => $customer 'pending' => $customer
? $customer->orders()->where('status', 'pending')->latest('id')->get() ? $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 * 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 * 480 GB of a 500 GB plan cannot move to a 200 GB one, whatever their contract
* says the allowance is. * 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 final readonly class DowngradeCheck
{ {
@ -27,12 +34,18 @@ final readonly class DowngradeCheck
public bool $allowed, public bool $allowed,
/** @var array<int, array{key: string, current: string, limit: string}> */ /** @var array<int, array{key: string, current: string, limit: string}> */
public array $blockers, 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 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 = []; $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

@ -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 * 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 * anyway a goodwill move or a support decision. Never offered to the

View File

@ -134,7 +134,28 @@ return [
// without an own domain costs to give one — an unpriced feature can only // without an own domain costs to give one — an unpriced feature can only
// be shown as absent, and it was being shown that way to the two plans // be shown as absent, and it was being shown that way to the two plans
// we would most like to sell it to. // we would most like to sell it to.
'custom_domain' => ['price_cents' => 900], 'custom_domain' => [
'price_cents' => 900,
// The packages on which an own domain is not possible AT ALL: not
// bookable, not upgradable, not even offered. Named by plan key,
// and named HERE, because the catalogue cannot answer this. Plan
// versions model what a package HAS — its features and its size —
// and nothing in the catalogue models what a package may BUY, so
// the only catalogue-derived answers would be proxies, and both
// available ones are dishonest:
//
// - "the lowest tier on sale" quietly hands the right to a
// grandfathered Start customer the day the owner stops selling
// Start or adds a cheaper package underneath it.
// - reading it off `branding` ties two unrelated decisions
// together, so letting Start upload a logo would also sell it
// a domain.
//
// A rule this commercial belongs where the owner can see and
// change it, next to the price it gates. Read only by
// App\Services\Billing\CustomDomainAccess.
'unavailable_on' => ['start'],
],
], ],
// Feature bullets moved onto plan_versions.features, so that what a version // Feature bullets moved onto plan_versions.features, so that what a version
@ -144,7 +165,13 @@ return [
'dns' => [ 'dns' => [
'provider' => 'hetzner', 'provider' => 'hetzner',
'token' => env('HETZNER_DNS_TOKEN', ''), 'token' => env('HETZNER_DNS_TOKEN', ''),
'zone' => env('CLUPILOT_DNS_ZONE', 'clupilot.com'), // Customer instances live on their own registrable domain, NOT on the
// one that serves the portal and the console. A Nextcloud is
// third-party software that strangers sign in to; on the same
// registrable domain as the portal it shares cookie scope,
// document.domain and CAA with it. Same reasoning as
// googleusercontent.com or vercel.app.
'zone' => env('CLUPILOT_DNS_ZONE', 'clupilot.cloud'),
// Internal, WireGuard-only host names (fsn-01.node.…) — the vpn-dns // Internal, WireGuard-only host names (fsn-01.node.…) — the vpn-dns
// container's dnsmasq --hostsdir. NOT the public Hetzner zone above: // container's dnsmasq --hostsdir. NOT the public Hetzner zone above:

View File

@ -11,6 +11,13 @@ return [
'seats' => 'Sie haben :current Benutzer, dieses Paket erlaubt :limit. Entfernen Sie Zugänge unter „Benutzer“.', '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.', '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', 'per_month' => 'netto pro Monat',
'net_reverse_charge' => 'netto — Reverse Charge', '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_body' => 'Jederzeit erweiterbar — +:gb GB für :price pro Monat, monatlich kündbar.',
'storage_cta' => '+:gb GB buchen', 'storage_cta' => '+:gb GB buchen',
'addon_cta' => 'Hinzufügen', 'addon_cta' => 'Hinzufügen',
'addon_included' => 'In Ihrem Paket enthalten',
'total_with_addons' => 'Gesamt inkl. Module: :total', 'total_with_addons' => 'Gesamt inkl. Module: :total',
'addon_packs' => ':count Pakete', 'addon_packs' => ':count Pakete',
'addon_booked' => 'Gebucht — Preis fest', 'addon_booked' => 'Gebucht — Preis fest',
@ -101,6 +109,15 @@ return [
'enterprise' => 'Enterprise', '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' => [ 'addon' => [
'extra_backups' => ['name' => 'Tägliche Off-Site-Backups', 'desc' => 'Zusätzliche verschlüsselte Backups an einem zweiten Standort.'], '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.'], 'priority_support' => ['name' => 'Priority-Support', 'desc' => 'Bevorzugte Bearbeitung, Reaktionszeit unter 1 Stunde.'],

View File

@ -11,6 +11,13 @@ return [
'seats' => 'You have :current users, this package allows :limit. Remove access under "Users".', '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.', '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', 'per_month' => 'net per month',
'net_reverse_charge' => 'net — reverse charge', 'net_reverse_charge' => 'net — reverse charge',
@ -88,6 +95,7 @@ return [
'storage_body' => 'Expandable anytime — +:gb GB for :price per month, cancel monthly.', 'storage_body' => 'Expandable anytime — +:gb GB for :price per month, cancel monthly.',
'storage_cta' => 'Add :gb GB', 'storage_cta' => 'Add :gb GB',
'addon_cta' => 'Add', 'addon_cta' => 'Add',
'addon_included' => 'Included in your package',
'total_with_addons' => 'Total incl. modules: :total', 'total_with_addons' => 'Total incl. modules: :total',
'addon_packs' => ':count packs', 'addon_packs' => ':count packs',
'addon_booked' => 'Booked — price fixed', 'addon_booked' => 'Booked — price fixed',
@ -101,6 +109,15 @@ return [
'enterprise' => 'Enterprise', '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' => [ 'addon' => [
'extra_backups' => ['name' => 'Daily off-site backups', 'desc' => 'Additional encrypted backups at a second location.'], '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.'], 'priority_support' => ['name' => 'Priority support', 'desc' => 'Priority handling, response time under 1 hour.'],

View File

@ -179,6 +179,25 @@
</p> </p>
</div> </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) @if ($plan['check']->allowed)
<x-ui.button variant="secondary" class="mt-5 w-full" <x-ui.button variant="secondary" class="mt-5 w-full"
wire:click="purchase('downgrade', '{{ $key }}')" wire:click="purchase('downgrade', '{{ $key }}')"
@ -251,7 +270,14 @@
<p class="font-semibold text-ink">{{ __('billing.addon.'.$key.'.name') }}</p> <p class="font-semibold text-ink">{{ __('billing.addon.'.$key.'.name') }}</p>
</div> </div>
<p class="mt-2 flex-1 text-sm text-muted">{{ __('billing.addon.'.$key.'.desc') }}</p> <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. --}} {{-- Not "free", not struck through just the service. --}}
<p class="mt-3 text-sm font-medium text-muted">{{ __('billing.granted_plan') }}</p> <p class="mt-3 text-sm font-medium text-muted">{{ __('billing.granted_plan') }}</p>
@else @else
@ -276,7 +302,9 @@
<span class="size-1.5 rounded-pill bg-current" aria-hidden="true"></span> <span class="size-1.5 rounded-pill bg-current" aria-hidden="true"></span>
{{ __('billing.addon_booked') }} {{ __('billing.addon_booked') }}
</p> </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"> <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') }} {{ __('billing.addon_cta') }}
</x-ui.button> </x-ui.button>

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

@ -170,9 +170,9 @@ it('states what every package has once, above the table instead of as a row in i
}); });
it('offers what a plan lacks at the price the module catalogue would charge', function () { it('offers what a plan lacks at the price the module catalogue would charge', function () {
// The third cell state, and the reason it exists: start and team do not // The third cell state, and the reason it exists: team does not carry an
// carry an own domain, but we sell one — a dash there was the page telling // own domain, but we sell one — a dash there was the page telling a visitor
// a visitor no on a sale we would happily make. // no on a sale we would happily make.
$this->get('/')->assertOk()->assertSee("optional · 9\u{00A0}€", false); $this->get('/')->assertOk()->assertSee("optional · 9\u{00A0}€", false);
// And the figure follows the catalogue. Written into the template it would // And the figure follows the catalogue. Written into the template it would
@ -186,6 +186,24 @@ it('offers what a plan lacks at the price the module catalogue would charge', fu
->assertDontSee("optional · 9\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 () { 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 // 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 // offer for it. Being able to say "no" is what makes the "optional" cells