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
|