From b3651a14be91cddb8e9352c223d0079880d475a5 Mon Sep 17 00:00:00 2001 From: nexxo Date: Wed, 29 Jul 2026 15:59:04 +0200 Subject: [PATCH] Decide who may have their own domain, and decide it in one place MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- app/Actions/BookAddon.php | 16 + app/Actions/GrantAddon.php | 47 ++- app/Http/Controllers/LandingController.php | 32 +- app/Livewire/Billing.php | 62 ++- app/Services/Billing/CustomDomainAccess.php | 358 ++++++++++++++++++ app/Services/Billing/DowngradeCheck.php | 29 +- app/Services/Billing/PlanChange.php | 21 + config/provisioning.php | 31 +- lang/de/billing.php | 17 + lang/en/billing.php | 17 + resources/views/livewire/billing.blade.php | 32 +- .../Billing/CustomDomainAccessTest.php | 241 ++++++++++++ tests/Feature/LandingPriceSheetTest.php | 24 +- 13 files changed, 888 insertions(+), 39 deletions(-) create mode 100644 app/Services/Billing/CustomDomainAccess.php create mode 100644 tests/Feature/Billing/CustomDomainAccessTest.php diff --git a/app/Actions/BookAddon.php b/app/Actions/BookAddon.php index 6e86b57..917adae 100644 --- a/app/Actions/BookAddon.php +++ b/app/Actions/BookAddon.php @@ -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) { diff --git a/app/Actions/GrantAddon.php b/app/Actions/GrantAddon.php index 7af8826..edc8636 100644 --- a/app/Actions/GrantAddon.php +++ b/app/Actions/GrantAddon.php @@ -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, + ]); + }); } } diff --git a/app/Http/Controllers/LandingController.php b/app/Http/Controllers/LandingController.php index 8688cb8..30312c0 100644 --- a/app/Http/Controllers/LandingController.php +++ b/app/Http/Controllers/LandingController.php @@ -4,6 +4,7 @@ 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; @@ -283,10 +284,13 @@ class LandingController extends Controller foreach ($plans as $plan) { $cells[$plan['key']] = match (true) { in_array($key, $plan['keys'], true) => ['state' => 'included'], - // Not carried, but on sale — the only honest third answer. - // A module we do not sell stays a dash, however easy it - // would be to invent a price for it. - isset($modules[$key]) => ['state' => 'optional', 'price' => $this->modulePrice($modules[$key])], + // 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'], }; } @@ -297,6 +301,26 @@ class LandingController extends Controller 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 $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. * diff --git a/app/Livewire/Billing.php b/app/Livewire/Billing.php index f2ba019..b05ae91 100644 --- a/app/Livewire/Billing.php +++ b/app/Livewire/Billing.php @@ -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> $rows + * @return array> + */ + 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() diff --git a/app/Services/Billing/CustomDomainAccess.php b/app/Services/Billing/CustomDomainAccess.php new file mode 100644 index 0000000..fc11a03 --- /dev/null +++ b/app/Services/Billing/CustomDomainAccess.php @@ -0,0 +1,358 @@ +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 $features the version's feature keys + */ + public function includedInFeatures(array $features): bool + { + return in_array(self::FEATURE, $features, true); + } + + /** @param array $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 $targetFeatures the target version's feature keys + * @return array}> + */ + 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 + */ + 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()); + } +} diff --git a/app/Services/Billing/DowngradeCheck.php b/app/Services/Billing/DowngradeCheck.php index 9d333f3..406c9fa 100644 --- a/app/Services/Billing/DowngradeCheck.php +++ b/app/Services/Billing/DowngradeCheck.php @@ -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 */ public array $blockers, + /** @var array}> */ + public array $consequences = [], ) {} /** * @param array $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); } } diff --git a/app/Services/Billing/PlanChange.php b/app/Services/Billing/PlanChange.php index 7735fa0..6fc6fe6 100644 --- a/app/Services/Billing/PlanChange.php +++ b/app/Services/Billing/PlanChange.php @@ -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 diff --git a/config/provisioning.php b/config/provisioning.php index c391dc9..cbcb30b 100644 --- a/config/provisioning.php +++ b/config/provisioning.php @@ -134,7 +134,28 @@ return [ // without an own domain costs to give one — an unpriced feature can only // be shown as absent, and it was being shown that way to the two plans // we would most like to sell it to. - 'custom_domain' => ['price_cents' => 900], + '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 @@ -144,7 +165,13 @@ return [ 'dns' => [ 'provider' => 'hetzner', '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 // container's dnsmasq --hostsdir. NOT the public Hetzner zone above: diff --git a/lang/de/billing.php b/lang/de/billing.php index 9e6e042..d0d358e 100644 --- a/lang/de/billing.php +++ b/lang/de/billing.php @@ -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,6 +109,15 @@ 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.'], diff --git a/lang/en/billing.php b/lang/en/billing.php index 8a81c69..d8d8385 100644 --- a/lang/en/billing.php +++ b/lang/en/billing.php @@ -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,6 +109,15 @@ 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.'], diff --git a/resources/views/livewire/billing.blade.php b/resources/views/livewire/billing.blade.php index 588b8bc..9fd2f11 100644 --- a/resources/views/livewire/billing.blade.php +++ b/resources/views/livewire/billing.blade.php @@ -179,6 +179,25 @@

+ {{-- 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 !== []) +
+

+ {{ __('billing.downgrade_consequence_title') }} +

+
    + @foreach ($plan['check']->consequences as $consequence) +
  • + {{ __('billing.downgrade_consequence.'.$consequence['key'], $consequence['replace']) }} +
  • + @endforeach +
+
+ @endif + @if ($plan['check']->allowed) {{ __('billing.addon.'.$key.'.name') }}

{{ __('billing.addon.'.$key.'.desc') }}

- @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. --}} +

+ {{ __('billing.addon_included') }} +

+ @elseif ($addon['granted'] ?? false) {{-- Not "free", not struck through — just the service. --}}

{{ __('billing.granted_plan') }}

@else @@ -276,7 +302,9 @@ {{ __('billing.addon_booked') }}

- @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)) {{ __('billing.addon_cta') }} diff --git a/tests/Feature/Billing/CustomDomainAccessTest.php b/tests/Feature/Billing/CustomDomainAccessTest.php new file mode 100644 index 0000000..c63bacc --- /dev/null +++ b/tests/Feature/Billing/CustomDomainAccessTest.php @@ -0,0 +1,241 @@ +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']); +}); diff --git a/tests/Feature/LandingPriceSheetTest.php b/tests/Feature/LandingPriceSheetTest.php index 15699b8..e1e21a6 100644 --- a/tests/Feature/LandingPriceSheetTest.php +++ b/tests/Feature/LandingPriceSheetTest.php @@ -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 () { - // The third cell state, and the reason it exists: start and team do not - // carry an own domain, but we sell one — a dash there was the page telling - // a visitor no on a sale we would happily make. + // 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 @@ -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); }); +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('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