Decide who may have their own domain, and decide it in one place
The domain page shipped with no access control at all: the `custom_domain`
plan feature was checked nowhere in the codebase, and every customer on
every package could point a domain at their instance. This is the owner's
rule, built as one authority that everything else asks.
Start impossible — not bookable, not upgradable, not offered
Team optional, via the `custom_domain` module
Business included, because the plan version carries the feature
Enterprise included, likewise
App\Services\Billing\CustomDomainAccess answers all of it: may this
contract use one, may it book the module, why not in words a customer can
be shown, what a downgrade would do to a domain they have, and — after a
change lands — making the state match. Read against the FROZEN plan
version, never today's catalogue, so a feature added to a package later
cannot reach into a contract signed before it.
Which packages cannot have one at all is an explicit list in
config/provisioning.php, beside the module's price. The catalogue cannot
answer it: plan versions model what a package HAS, and nothing models what
a package may BUY. Both available proxies are dishonest — "the lowest tier
on sale" hands the right to a grandfathered Start customer the day the
owner stops selling Start or adds something cheaper below it, and reading
it off `branding` would sell a domain to anyone allowed to upload a logo.
The config comment says so.
Enforced where it can be enforced today:
- BookAddon refuses the module, with the sentence the customer is shown.
GrantAddon now writes its synthetic order inside the transaction, so a
refusal cannot leave a paid order for a module nobody got.
- The portal drops the card where it cannot be booked and shows it as
included where the package carries it — no price, no button.
- Billing::purchase() re-checks, because a hidden button is markup.
- DowngradeCheck reports the consequence beside the blockers, so the
customer reads it above the button rather than after it: losing the
domain (Team -> Start), or the choice (Business -> Team, where keeping
it means booking the module).
- PlanChange::settleCustomDomain() applies it once a change lands: the
module stops being charged for and the instance goes back to its
platform address. It clears the whole domain, not only the verified
flag — clupilot:verify-domains re-reads every row that still has a
domain and a token, and would switch it back on the next night.
- The price sheet's own-domain row now reads dash / optional + price /
included, from the same authority; the three cell states already
existed and the view is unchanged.
STILL TO APPLY, ONCE THIS AND THE CUSTOM-DOMAIN BRANCH MEET — one line at
the top of App\Livewire\CustomDomain::mount(), which belongs to the other
session and is deliberately untouched here:
abort_unless(app(\App\Services\Billing\CustomDomainAccess::class)->allowsCustomer($this->customer()), 403);
routes/web.php is left alone for the same reason. The mount() guard covers
the route and the component both; a middleware would only repeat it.
Follow-ups, named rather than built:
- Nothing applies a plan change anywhere yet, so settleCustomDomain()
has no caller. Whoever builds that must call it after the new contract
opens, and must decide whether a booked module is carried onto the new
contract — enforce() cancels the booking on the contract it is given.
- Deactivation sets the state; it does not re-run provisioning. Traefik
and Nextcloud's trusted_domains still name the old address until
ConfigureDnsAndTls / ConfigureNextcloud run again.
- App\Support\Navigation still shows the Domain tab to every customer;
it can ask allowsCustomer() the same way.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feat/plan-reopen
parent
517606b4db
commit
b3651a14be
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,6 +46,11 @@ class GrantAddon
|
|||
// already carries the right one.
|
||||
$datacenter = $subscription->order?->datacenter ?? 'fsn';
|
||||
|
||||
// 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,
|
||||
|
|
@ -66,5 +72,6 @@ class GrantAddon
|
|||
'granted_until' => $until,
|
||||
'catalogue_price_cents' => $cataloguePrice,
|
||||
]);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string, mixed> $plan
|
||||
*/
|
||||
private function bookableOn(string $key, array $plan): bool
|
||||
{
|
||||
if ($key !== CustomDomainAccess::ADDON) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return app(CustomDomainAccess::class)->bookableOnPlan((string) $plan['key'], $plan['keys']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Support as one row of levels.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ use App\Models\Customer;
|
|||
use App\Models\Order;
|
||||
use App\Models\Subscription;
|
||||
use App\Services\Billing\AddonCatalogue;
|
||||
use App\Services\Billing\CustomDomainAccess;
|
||||
use App\Services\Billing\DowngradeCheck;
|
||||
use App\Services\Billing\PlanCatalogue;
|
||||
use App\Services\Billing\TaxTreatment;
|
||||
|
|
@ -45,6 +46,8 @@ class Billing extends Component
|
|||
default => [null, 0, null],
|
||||
};
|
||||
|
||||
$access = app(CustomDomainAccess::class);
|
||||
|
||||
// Guard against invalid keys / non-upgrades.
|
||||
$valid = match ($type) {
|
||||
// By rank, matching PlanChange and the cards on the page. Comparing
|
||||
|
|
@ -58,12 +61,18 @@ class Billing extends Component
|
|||
// markup is not enforced.
|
||||
'downgrade' => isset($plans[$key])
|
||||
&& (int) ($plans[$key]['tier'] ?? 0) < (int) ($instance?->subscription?->tier ?? $plans[$currentPlan]['tier'] ?? 0)
|
||||
&& DowngradeCheck::for($customer, $instance, $plans[$key])->allowed,
|
||||
&& DowngradeCheck::for($customer, $instance, $plans[$key], $key)->allowed,
|
||||
'storage' => true,
|
||||
// Always available: running out of traffic is exactly when someone
|
||||
// needs to be able to buy more, whatever plan they are on.
|
||||
'traffic' => true,
|
||||
'addon' => isset($addons[$key]),
|
||||
// The module list leaves the own domain out where it cannot be
|
||||
// booked, and this refuses it there for the same reason the
|
||||
// downgrade branch re-checks: the button is markup, and markup is
|
||||
// not a limit. BookAddon refuses it a third time, at the moment the
|
||||
// money would actually buy something.
|
||||
'addon' => isset($addons[$key])
|
||||
&& ($key !== CustomDomainAccess::ADDON || $access->bookable($access->contractOf($customer))),
|
||||
default => false,
|
||||
};
|
||||
if (! $valid || $plan === null) {
|
||||
|
|
@ -152,6 +161,41 @@ class Billing extends Component
|
|||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* The module cards, minus the ones this customer cannot be sold.
|
||||
*
|
||||
* Two things the shop was getting wrong about the own domain, and both of
|
||||
* them were offers: a package that already includes it was invited to buy
|
||||
* it again, and a package that cannot have one at all was invited to buy
|
||||
* something we would then refuse. An offer that would be refused is worse
|
||||
* than no offer — the customer clicks it, and finds out from an error
|
||||
* message what they should have been told by the page.
|
||||
*
|
||||
* A module already booked keeps its card whatever the package says, because
|
||||
* it is still on the bill and hiding it would hide the charge.
|
||||
*
|
||||
* @param array<string, array<string, mixed>> $rows
|
||||
* @return array<string, array<string, mixed>>
|
||||
*/
|
||||
private function offerable(array $rows, ?Customer $customer): array
|
||||
{
|
||||
$access = app(CustomDomainAccess::class);
|
||||
$key = CustomDomainAccess::ADDON;
|
||||
|
||||
if (! array_key_exists($key, $rows)) {
|
||||
return $rows;
|
||||
}
|
||||
|
||||
$contract = $access->contractOf($customer);
|
||||
$rows[$key]['included'] = $access->includedInPlan($contract);
|
||||
|
||||
if (! $rows[$key]['included'] && ! $rows[$key]['booked'] && ! $access->bookable($contract)) {
|
||||
unset($rows[$key]);
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
$customer = $this->customer();
|
||||
|
|
@ -171,7 +215,10 @@ class Billing extends Component
|
|||
$downgrades = collect($plans)
|
||||
->filter(fn ($p) => (int) ($p['tier'] ?? 0) < $currentTier)
|
||||
->sortByDesc(fn ($p) => (int) ($p['tier'] ?? 0))
|
||||
->map(fn ($p, $k) => $p + ['check' => DowngradeCheck::for($customer, $instance, $p)])
|
||||
// With the key, not only the terms: what a move costs a customer
|
||||
// depends on which package they are moving to, and an entry that
|
||||
// does not know its own name cannot be asked about it.
|
||||
->map(fn ($p, $k) => $p + ['check' => DowngradeCheck::for($customer, $instance, $p, $k)])
|
||||
->all();
|
||||
|
||||
$upgrades = collect($plans)
|
||||
|
|
@ -199,9 +246,12 @@ class Billing extends Component
|
|||
// Booked modules at the price they were booked at; everything else
|
||||
// at today's. Reading them all off the catalogue would re-price
|
||||
// half a customer's bill behind their back.
|
||||
'addons' => collect(app(AddonCatalogue::class)->forSubscription($instance?->subscription))
|
||||
'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()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,358 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Billing;
|
||||
|
||||
use App\Actions\BookAddon;
|
||||
use App\Models\Customer;
|
||||
use App\Models\Instance;
|
||||
use App\Models\PlanFamily;
|
||||
use App\Models\Subscription;
|
||||
use App\Support\ProvisioningSettings;
|
||||
use Illuminate\Support\Number;
|
||||
|
||||
/**
|
||||
* Whether a customer may have their own domain — asked and answered once.
|
||||
*
|
||||
* The owner's rule has three steps, and until now not one of them existed
|
||||
* anywhere in the code: `/domain` shipped with no access control at all, and
|
||||
* the `custom_domain` plan feature was checked nowhere, so every customer on
|
||||
* every package could point a domain at their instance.
|
||||
*
|
||||
* - **Start**: impossible. Not bookable, not upgradable, not even offered.
|
||||
* - **Team**: optional. The `custom_domain` module buys it.
|
||||
* - **Business and Enterprise**: included, because the plan version carries
|
||||
* the `custom_domain` feature. The module buys them nothing.
|
||||
*
|
||||
* Everything asks this class — the booking action, the portal's module list,
|
||||
* the downgrade warning, the plan change that carries it out, and the public
|
||||
* price sheet. Nothing re-derives it. A rule written out in five places is five
|
||||
* rules, and the fifth is the one nobody remembers to change.
|
||||
*
|
||||
* Read against the FROZEN plan version, never against today's catalogue: what a
|
||||
* customer is owed is what they bought, and a feature added to or taken off a
|
||||
* plan later must not reach into a contract that was signed before it.
|
||||
*/
|
||||
final class CustomDomainAccess
|
||||
{
|
||||
/** The catalogue feature a package carries when an own domain is part of it. */
|
||||
public const FEATURE = 'custom_domain';
|
||||
|
||||
/** The module that buys one where the package does not include it. */
|
||||
public const ADDON = 'custom_domain';
|
||||
|
||||
/**
|
||||
* The customer's live contract — the same resolution the console's grant
|
||||
* screen uses.
|
||||
*
|
||||
* Deliberately not read through the instance: `subscriptions.instance_id`
|
||||
* is written during provisioning, so a customer whose machine is still
|
||||
* being built has a package but no link, and answering "no contract" there
|
||||
* would take a right away from someone who has paid for it.
|
||||
*/
|
||||
public function contractOf(?Customer $customer): ?Subscription
|
||||
{
|
||||
if ($customer === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Subscription::query()
|
||||
->where('customer_id', $customer->id)
|
||||
->where('status', 'active')
|
||||
->latest('id')
|
||||
->first();
|
||||
}
|
||||
|
||||
/** The guard every page and route asks: may this customer use one right now? */
|
||||
public function allowsCustomer(?Customer $customer): bool
|
||||
{
|
||||
return $this->allows($this->contractOf($customer));
|
||||
}
|
||||
|
||||
/**
|
||||
* May this contract present a custom domain right now?
|
||||
*
|
||||
* Included by the package, or bought as a module — and a module only counts
|
||||
* where the package could have booked it. Without that second half a module
|
||||
* booked on Team would survive a move to Start and keep serving a domain
|
||||
* the customer has just been told they cannot have; enforce() cancels such
|
||||
* a booking, and this is the same sentence read the other way round.
|
||||
*/
|
||||
public function allows(?Subscription $subscription): bool
|
||||
{
|
||||
if ($subscription === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->includedInPlan($subscription)
|
||||
|| ($this->bookable($subscription) && $this->hasBooked($subscription));
|
||||
}
|
||||
|
||||
/** Does the package itself carry it, so nothing needs booking? */
|
||||
public function includedInPlan(?Subscription $subscription): bool
|
||||
{
|
||||
return $subscription !== null && $this->includedInFeatures($this->planFeatures($subscription));
|
||||
}
|
||||
|
||||
/**
|
||||
* May this contract book the module?
|
||||
*
|
||||
* Only where a domain is possible and the package does not already include
|
||||
* it — which is Team today. Never Start, and pointless on Business and
|
||||
* Enterprise, who would be paying nine euros a month for something they
|
||||
* already have.
|
||||
*/
|
||||
public function bookable(?Subscription $subscription): bool
|
||||
{
|
||||
return $subscription !== null
|
||||
&& $this->bookableOnPlan((string) $subscription->plan, $this->planFeatures($subscription));
|
||||
}
|
||||
|
||||
/** Is the module already booked and still running on this contract? */
|
||||
public function hasBooked(?Subscription $subscription): bool
|
||||
{
|
||||
return $subscription !== null
|
||||
&& $subscription->addons()->active()->where('addon_key', self::ADDON)->exists();
|
||||
}
|
||||
|
||||
/**
|
||||
* The same two questions for a package rather than a contract, because the
|
||||
* public price sheet has no customer to ask about.
|
||||
*
|
||||
* @param array<int, string> $features the version's feature keys
|
||||
*/
|
||||
public function includedInFeatures(array $features): bool
|
||||
{
|
||||
return in_array(self::FEATURE, $features, true);
|
||||
}
|
||||
|
||||
/** @param array<int, string> $features */
|
||||
public function bookableOnPlan(string $planKey, array $features): bool
|
||||
{
|
||||
return $this->possibleOn($planKey) && ! $this->includedInFeatures($features);
|
||||
}
|
||||
|
||||
/**
|
||||
* Is an own domain possible on this package at all?
|
||||
*
|
||||
* The list of packages where it is not lives in config/provisioning.php,
|
||||
* beside the module's price — see the comment there for why the catalogue
|
||||
* cannot answer this and a derived rule would be a guess.
|
||||
*/
|
||||
public function possibleOn(string $planKey): bool
|
||||
{
|
||||
$excluded = (array) config('provisioning.addons.'.self::ADDON.'.unavailable_on', []);
|
||||
|
||||
return ! in_array($planKey, $excluded, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Why the customer may not use one, in words they can be shown.
|
||||
*
|
||||
* Null when they may. Every caller that would otherwise write its own
|
||||
* sentence — the guard on the domain page, a support answer — takes it from
|
||||
* here, so the customer is never told two different things.
|
||||
*/
|
||||
public function refusal(?Subscription $subscription): ?string
|
||||
{
|
||||
if ($this->allows($subscription)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($subscription === null) {
|
||||
return __('billing.custom_domain.no_contract');
|
||||
}
|
||||
|
||||
if (! $this->possibleOn((string) $subscription->plan)) {
|
||||
return __('billing.custom_domain.not_in_plan', ['plan' => $this->planName((string) $subscription->plan)]);
|
||||
}
|
||||
|
||||
return __('billing.custom_domain.needs_addon', ['price' => $this->modulePrice()]);
|
||||
}
|
||||
|
||||
/** Why the module may not be booked. Null when it may. */
|
||||
public function bookingRefusal(?Subscription $subscription): ?string
|
||||
{
|
||||
if ($subscription === null) {
|
||||
return __('billing.custom_domain.no_contract');
|
||||
}
|
||||
|
||||
if ($this->includedInPlan($subscription)) {
|
||||
return __('billing.custom_domain.already_included');
|
||||
}
|
||||
|
||||
if (! $this->possibleOn((string) $subscription->plan)) {
|
||||
return __('billing.custom_domain.not_in_plan', ['plan' => $this->planName((string) $subscription->plan)]);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* What moving to another package would do to a domain the customer HAS, so
|
||||
* they are asked before they confirm rather than told afterwards.
|
||||
*
|
||||
* Two answers, and they are different decisions: on a package where a
|
||||
* domain is impossible it simply goes, and on one where it is a module the
|
||||
* customer chooses — book it and nothing changes for them, leave it and the
|
||||
* domain is switched off with the change.
|
||||
*
|
||||
* Nothing is reported when nothing happens: no domain set, none in use, the
|
||||
* target includes it anyway, or the module is already booked and stays
|
||||
* valid on the target.
|
||||
*
|
||||
* @param array<int, string> $targetFeatures the target version's feature keys
|
||||
* @return array<int, array{key: string, replace: array<string, string>}>
|
||||
*/
|
||||
public function consequencesOfMovingTo(?Subscription $current, ?Instance $instance, string $targetPlan, array $targetFeatures): array
|
||||
{
|
||||
if ($instance === null || blank($instance->custom_domain) || ! $this->allows($current)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if ($this->includedInFeatures($targetFeatures)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (! $this->possibleOn($targetPlan)) {
|
||||
return [[
|
||||
'key' => 'custom_domain_lost',
|
||||
'replace' => [
|
||||
'domain' => (string) $instance->custom_domain,
|
||||
'plan' => $this->planName($targetPlan),
|
||||
// The address it falls back to, so "abgeschaltet" is a
|
||||
// change of address rather than a threat of darkness.
|
||||
'address' => $instance->subdomain.'.'.ProvisioningSettings::dnsZone(),
|
||||
],
|
||||
]];
|
||||
}
|
||||
|
||||
// Already paying for the module, and it stays valid where they are
|
||||
// going: for them this move changes nothing about the domain.
|
||||
if ($this->hasBooked($current)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [[
|
||||
'key' => 'custom_domain_addon',
|
||||
'replace' => [
|
||||
'domain' => (string) $instance->custom_domain,
|
||||
'plan' => $this->planName($targetPlan),
|
||||
'price' => $this->modulePrice(),
|
||||
],
|
||||
]];
|
||||
}
|
||||
|
||||
/**
|
||||
* Make the state match the rule, after a change has landed.
|
||||
*
|
||||
* Called by PlanChange when a plan change takes effect. Two things happen,
|
||||
* in this order: the module stops being charged for, and the domain stops
|
||||
* being presented. Charging first, because a customer who has lost a
|
||||
* feature and is still billed for it has a complaint we would deserve.
|
||||
*
|
||||
* Returns whether anything was actually taken away.
|
||||
*/
|
||||
public function enforce(Subscription $subscription): bool
|
||||
{
|
||||
if ($this->allows($subscription)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$changed = false;
|
||||
|
||||
// Resolved lazily rather than through the constructor: BookAddon asks
|
||||
// this class whether a booking is allowed, and two constructors
|
||||
// pointing at each other cannot both be built.
|
||||
$bookAddon = app(BookAddon::class);
|
||||
|
||||
foreach ($subscription->addons()->active()->where('addon_key', self::ADDON)->get() as $addon) {
|
||||
$bookAddon->cancel($addon);
|
||||
$changed = true;
|
||||
}
|
||||
|
||||
return $this->deactivate($this->instanceOf($subscription)) || $changed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Take the domain off an instance, so it answers on its platform address
|
||||
* again.
|
||||
*
|
||||
* The whole domain, not merely its verification flag. The nightly re-check
|
||||
* (clupilot:verify-domains) reads every instance that still has a domain
|
||||
* AND a token, and it would put the verification straight back the next
|
||||
* night — the domain would switch itself on again days after the customer
|
||||
* was told it was gone. Clearing the field is also exactly what the
|
||||
* customer's own "remove" does on the domain page, so what they see
|
||||
* afterwards is a state this product already knows how to draw.
|
||||
*
|
||||
* Neither the verification nor the serving logic is reimplemented here:
|
||||
* Instance::address() already falls back to the subdomain the moment the
|
||||
* domain is gone, and that is the whole of the fallback in this codebase.
|
||||
*/
|
||||
public function deactivate(?Instance $instance): bool
|
||||
{
|
||||
if ($instance === null || blank($instance->custom_domain)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$instance->forceFill([
|
||||
'custom_domain' => null,
|
||||
'domain_token' => null,
|
||||
'domain_verified_at' => null,
|
||||
'domain_checked_at' => null,
|
||||
'domain_error' => null,
|
||||
'domain_failures' => 0,
|
||||
])->save();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* The machine this contract pays for.
|
||||
*
|
||||
* The link when provisioning has written it, and otherwise the customer's
|
||||
* newest instance — the same fallback the portal makes for contracts opened
|
||||
* before that link existed.
|
||||
*/
|
||||
private function instanceOf(Subscription $subscription): ?Instance
|
||||
{
|
||||
return $subscription->instance
|
||||
?? $subscription->customer?->instances()->latest('id')->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* What this contract's package promises — from the version it was sold
|
||||
* under, not from the catalogue.
|
||||
*
|
||||
* The empty fallback is for a contract whose version row has gone (a
|
||||
* discarded draft nulls the reference); such a package promises nothing
|
||||
* this class can stand on, and inventing today's answer for it would hand
|
||||
* out a right the contract may never have carried.
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function planFeatures(Subscription $subscription): array
|
||||
{
|
||||
return array_values((array) ($subscription->planVersion?->features ?? []));
|
||||
}
|
||||
|
||||
/**
|
||||
* A package by the name the owner gave it, never by its key.
|
||||
*
|
||||
* The key is a handle that has not changed since day one; the name is what
|
||||
* the customer sees on the price sheet, and renaming a package must not
|
||||
* leave one sentence in the product still calling it something else.
|
||||
*/
|
||||
private function planName(string $planKey): string
|
||||
{
|
||||
return (string) (PlanFamily::query()->where('key', $planKey)->value('name') ?? ucfirst($planKey));
|
||||
}
|
||||
|
||||
/** Today's price for the module, in the same shape the portal prints it. */
|
||||
private function modulePrice(): string
|
||||
{
|
||||
$cents = (int) app(AddonCatalogue::class)->priceCents(self::ADDON);
|
||||
|
||||
return Number::currency($cents / 100, in: Subscription::catalogueCurrency(), locale: app()->getLocale());
|
||||
}
|
||||
}
|
||||
|
|
@ -20,6 +20,13 @@ use App\Support\Bytes;
|
|||
* Checked against what is MEASURED, not what was sold: a customer sitting on
|
||||
* 480 GB of a 500 GB plan cannot move to a 200 GB one, whatever their contract
|
||||
* says the allowance is.
|
||||
*
|
||||
* Blockers are not the only thing worth saying beforehand. A downgrade can be
|
||||
* perfectly possible and still take something away — the customer's own domain
|
||||
* — and being told that afterwards is the same failure as a greyed-out button
|
||||
* with no reason on it. So a consequence is reported alongside, in the same
|
||||
* shape and in the same place, and it does not make the move impossible: it is
|
||||
* the sentence somebody reads before they agree to it.
|
||||
*/
|
||||
final readonly class DowngradeCheck
|
||||
{
|
||||
|
|
@ -27,12 +34,18 @@ final readonly class DowngradeCheck
|
|||
public bool $allowed,
|
||||
/** @var array<int, array{key: string, current: string, limit: string}> */
|
||||
public array $blockers,
|
||||
/** @var array<int, array{key: string, replace: array<string, string>}> */
|
||||
public array $consequences = [],
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $target the catalogue entry being moved to
|
||||
* @param ?string $targetPlan its plan key — null when a caller is checking raw
|
||||
* limits rather than a package, and then the
|
||||
* consequences that depend on which package it is
|
||||
* cannot be computed and are not guessed at
|
||||
*/
|
||||
public static function for(Customer $customer, ?Instance $instance, array $target): self
|
||||
public static function for(Customer $customer, ?Instance $instance, array $target, ?string $targetPlan = null): self
|
||||
{
|
||||
$blockers = [];
|
||||
|
||||
|
|
@ -65,6 +78,18 @@ final readonly class DowngradeCheck
|
|||
];
|
||||
}
|
||||
|
||||
return new self($blockers === [], $blockers);
|
||||
// Asked, never re-derived: which packages may carry an own domain is
|
||||
// CustomDomainAccess's question, and this class only reports what it
|
||||
// answers about the move being considered.
|
||||
$access = app(CustomDomainAccess::class);
|
||||
|
||||
$consequences = $targetPlan === null ? [] : $access->consequencesOfMovingTo(
|
||||
current: $access->contractOf($customer),
|
||||
instance: $instance,
|
||||
targetPlan: $targetPlan,
|
||||
targetFeatures: array_values((array) ($target['features'] ?? [])),
|
||||
);
|
||||
|
||||
return new self($blockers === [], $blockers, $consequences);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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.'],
|
||||
|
|
|
|||
|
|
@ -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.'],
|
||||
|
|
|
|||
|
|
@ -179,6 +179,25 @@
|
|||
</p>
|
||||
</div>
|
||||
|
||||
{{-- What the move would take away, above the button that
|
||||
makes it — being told afterwards that an own domain
|
||||
has been switched off is the same failure as a
|
||||
greyed-out button with no reason on it. --}}
|
||||
@if ($plan['check']->consequences !== [])
|
||||
<div class="mt-5 rounded border border-accent-border bg-accent-subtle p-3">
|
||||
<p class="flex items-center gap-1.5 text-xs font-semibold text-accent-text">
|
||||
<x-ui.icon name="alert-triangle" class="size-4" />{{ __('billing.downgrade_consequence_title') }}
|
||||
</p>
|
||||
<ul class="mt-2 space-y-1.5">
|
||||
@foreach ($plan['check']->consequences as $consequence)
|
||||
<li class="text-xs leading-relaxed text-ink">
|
||||
{{ __('billing.downgrade_consequence.'.$consequence['key'], $consequence['replace']) }}
|
||||
</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($plan['check']->allowed)
|
||||
<x-ui.button variant="secondary" class="mt-5 w-full"
|
||||
wire:click="purchase('downgrade', '{{ $key }}')"
|
||||
|
|
@ -251,7 +270,14 @@
|
|||
<p class="font-semibold text-ink">{{ __('billing.addon.'.$key.'.name') }}</p>
|
||||
</div>
|
||||
<p class="mt-2 flex-1 text-sm text-muted">{{ __('billing.addon.'.$key.'.desc') }}</p>
|
||||
@if ($addon['granted'] ?? false)
|
||||
@if ($addon['included'] ?? false)
|
||||
{{-- Part of the package they already pay for. Neither a
|
||||
price nor a button belongs here: both would be selling
|
||||
a customer something they have. --}}
|
||||
<p class="mt-3 inline-flex items-center gap-1.5 rounded-pill border border-success-border bg-success-bg px-2.5 py-0.5 text-xs font-medium text-success">
|
||||
<x-ui.icon name="check" class="size-4" />{{ __('billing.addon_included') }}
|
||||
</p>
|
||||
@elseif ($addon['granted'] ?? false)
|
||||
{{-- Not "free", not struck through — just the service. --}}
|
||||
<p class="mt-3 text-sm font-medium text-muted">{{ __('billing.granted_plan') }}</p>
|
||||
@else
|
||||
|
|
@ -276,7 +302,9 @@
|
|||
<span class="size-1.5 rounded-pill bg-current" aria-hidden="true"></span>
|
||||
{{ __('billing.addon_booked') }}
|
||||
</p>
|
||||
@else
|
||||
{{-- A module the package includes has no button either: the
|
||||
badge above is the whole of what this card has to say. --}}
|
||||
@elseif (! ($addon['included'] ?? false))
|
||||
<x-ui.button variant="secondary" size="sm" class="mt-3 w-full" wire:click="purchase('addon', '{{ $key }}')" wire:target="purchase('addon', '{{ $key }}')" wire:loading.attr="disabled">
|
||||
{{ __('billing.addon_cta') }}
|
||||
</x-ui.button>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,241 @@
|
|||
<?php
|
||||
|
||||
use App\Actions\BookAddon;
|
||||
use App\Actions\GrantAddon;
|
||||
use App\Livewire\Billing;
|
||||
use App\Models\Customer;
|
||||
use App\Models\Instance;
|
||||
use App\Models\Order;
|
||||
use App\Models\Subscription;
|
||||
use App\Models\SubscriptionAddon;
|
||||
use App\Models\User;
|
||||
use App\Services\Billing\CustomDomainAccess;
|
||||
use App\Services\Billing\DowngradeCheck;
|
||||
use App\Services\Billing\PlanCatalogue;
|
||||
use App\Services\Billing\PlanChange;
|
||||
use Livewire\Livewire;
|
||||
|
||||
/**
|
||||
* Who may have their own domain.
|
||||
*
|
||||
* The domain page shipped with no access control at all: the `custom_domain`
|
||||
* plan feature was checked nowhere, and every customer on every package could
|
||||
* point a domain at their instance. These tests are the owner's rule, stated as
|
||||
* behaviour — impossible on the entry package, a module on Team, part of the
|
||||
* package on Business and Enterprise — and, as much as anything, they are about
|
||||
* what happens on the way DOWN, which is where a right silently outliving the
|
||||
* package that granted it does its damage.
|
||||
*/
|
||||
function domainCustomer(string $plan, array $instanceAttributes = []): array
|
||||
{
|
||||
$customer = Customer::factory()->create();
|
||||
$user = User::factory()->create(['email' => $customer->email, 'email_verified_at' => now()]);
|
||||
$customer->update(['user_id' => $user->id]);
|
||||
|
||||
$instance = Instance::factory()->create([
|
||||
'customer_id' => $customer->id,
|
||||
'plan' => $plan,
|
||||
'status' => 'active',
|
||||
// Per package: subdomains are unique, and one test walks two packages.
|
||||
'subdomain' => 'berger-'.$plan,
|
||||
...$instanceAttributes,
|
||||
]);
|
||||
|
||||
$subscription = Subscription::factory()->plan($plan)->create([
|
||||
'customer_id' => $customer->id,
|
||||
'instance_id' => $instance->id,
|
||||
]);
|
||||
|
||||
return [$customer, $user, $instance, $subscription];
|
||||
}
|
||||
|
||||
/** An instance whose owner has proved the domain is theirs. */
|
||||
function verifiedDomain(string $domain = 'cloud.berger.at'): array
|
||||
{
|
||||
return ['custom_domain' => $domain, 'domain_token' => 'tok123', 'domain_verified_at' => now()];
|
||||
}
|
||||
|
||||
it('does not sell an own domain to the entry package, at any of the three doors', function () {
|
||||
[$customer, $user, , $subscription] = domainCustomer('start');
|
||||
|
||||
// The action, which is the only door that actually books anything.
|
||||
expect(fn () => app(BookAddon::class)($subscription, CustomDomainAccess::ADDON))
|
||||
->toThrow(RuntimeException::class, __('billing.custom_domain.not_in_plan', ['plan' => 'Start']));
|
||||
|
||||
// The page, which must not offer what the action would refuse.
|
||||
$page = Livewire::actingAs($user)->test(Billing::class);
|
||||
|
||||
expect($page->viewData('addons'))->not->toHaveKey(CustomDomainAccess::ADDON);
|
||||
$page->assertDontSee("purchase('addon', 'custom_domain')", false);
|
||||
|
||||
// And the component method behind the button, because a hidden button is
|
||||
// markup: a stale tab can still call it.
|
||||
Livewire::actingAs($user)->test(Billing::class)->call('purchase', 'addon', CustomDomainAccess::ADDON);
|
||||
|
||||
expect(Order::query()->where('customer_id', $customer->id)->where('addon_key', CustomDomainAccess::ADDON)->exists())
|
||||
->toBeFalse();
|
||||
});
|
||||
|
||||
it('leaves no paid order behind when a grant of the module is refused', function () {
|
||||
// The console gives a module away through the same booking action, on a
|
||||
// synthetic order it writes first. A refusal after that would leave a paid
|
||||
// purchase of nothing on the customer's account — and on their invoice.
|
||||
[$customer, , , $subscription] = domainCustomer('start');
|
||||
|
||||
expect(fn () => app(GrantAddon::class)($subscription, admin(), CustomDomainAccess::ADDON, 0))
|
||||
->toThrow(RuntimeException::class);
|
||||
|
||||
expect(Order::query()->where('customer_id', $customer->id)->where('type', 'addon')->exists())->toBeFalse();
|
||||
});
|
||||
|
||||
it('gives Team the domain only once the module is booked', function () {
|
||||
[, , , $subscription] = domainCustomer('team');
|
||||
$access = app(CustomDomainAccess::class);
|
||||
|
||||
// Bookable, and not yet booked — which is not the same as allowed.
|
||||
expect($access->bookable($subscription))->toBeTrue()
|
||||
->and($access->allows($subscription))->toBeFalse()
|
||||
->and($access->refusal($subscription))->not->toBeNull();
|
||||
|
||||
app(BookAddon::class)($subscription, CustomDomainAccess::ADDON);
|
||||
|
||||
expect($access->allows($subscription))->toBeTrue()
|
||||
->and($access->refusal($subscription))->toBeNull();
|
||||
});
|
||||
|
||||
it('gives Business and Enterprise the domain with the package, and does not sell it to them twice', function () {
|
||||
$access = app(CustomDomainAccess::class);
|
||||
|
||||
foreach (['business', 'enterprise'] as $plan) {
|
||||
[, $user, , $subscription] = domainCustomer($plan);
|
||||
|
||||
expect($access->allows($subscription))->toBeTrue()
|
||||
->and($access->hasBooked($subscription))->toBeFalse()
|
||||
->and($access->bookable($subscription))->toBeFalse();
|
||||
|
||||
expect(fn () => app(BookAddon::class)($subscription, CustomDomainAccess::ADDON))
|
||||
->toThrow(RuntimeException::class, __('billing.custom_domain.already_included'));
|
||||
|
||||
// The card stays — it says what they have — but it is not a sale.
|
||||
$page = Livewire::actingAs($user)->test(Billing::class);
|
||||
|
||||
expect($page->viewData('addons')[CustomDomainAccess::ADDON]['included'])->toBeTrue();
|
||||
$page->assertSee(__('billing.addon_included'))
|
||||
->assertDontSee("purchase('addon', 'custom_domain')", false);
|
||||
}
|
||||
});
|
||||
|
||||
it('switches the domain off when the package it moved to cannot have one', function () {
|
||||
// Team → Start. The domain and the module both came with the package the
|
||||
// customer has just left.
|
||||
[$customer, , $instance] = domainCustomer('team', verifiedDomain());
|
||||
|
||||
$start = Subscription::factory()->plan('start')->create([
|
||||
'customer_id' => $customer->id,
|
||||
'instance_id' => $instance->id,
|
||||
]);
|
||||
|
||||
// The module rides along with the change: nothing cancels a booking when a
|
||||
// package changes, so settling has to be the thing that notices.
|
||||
SubscriptionAddon::create([
|
||||
'subscription_id' => $start->id,
|
||||
'addon_key' => CustomDomainAccess::ADDON,
|
||||
'price_cents' => 900,
|
||||
'currency' => 'EUR',
|
||||
'quantity' => 1,
|
||||
'booked_at' => now(),
|
||||
]);
|
||||
|
||||
expect(PlanChange::settleCustomDomain($start))->toBeTrue();
|
||||
|
||||
$instance->refresh();
|
||||
|
||||
expect($instance->domainIsVerified())->toBeFalse()
|
||||
// Back to the address the platform issued it, which is the whole of the
|
||||
// fallback: nothing here reimplements how an instance is served.
|
||||
->and($instance->address('clupilot.com'))->toBe('berger-team.clupilot.com')
|
||||
->and($instance->custom_domain)->toBeNull()
|
||||
// And it stops being charged for. A feature withdrawn while the module
|
||||
// is still on the bill is the complaint we would deserve.
|
||||
->and($start->addons()->active()->count())->toBe(0);
|
||||
});
|
||||
|
||||
it('asks a Business customer before the move, and abides by their answer', function () {
|
||||
[$customer, $user, $instance] = domainCustomer('business', verifiedDomain());
|
||||
|
||||
$team = app(PlanCatalogue::class)->sellable()['team'];
|
||||
$check = DowngradeCheck::for($customer, $instance, $team, 'team');
|
||||
|
||||
// Possible, and still worth saying out loud beforehand.
|
||||
expect($check->allowed)->toBeTrue()
|
||||
->and($check->consequences)->toHaveCount(1)
|
||||
->and($check->consequences[0]['key'])->toBe('custom_domain_addon')
|
||||
->and($check->consequences[0]['replace']['domain'])->toBe('cloud.berger.at');
|
||||
|
||||
// And it is on the card, above the button that would do it — not in a
|
||||
// message afterwards.
|
||||
Livewire::actingAs($user)->test(Billing::class)
|
||||
->assertSee(__('billing.downgrade_consequence_title'))
|
||||
->assertSee('cloud.berger.at');
|
||||
|
||||
// Declining: the Team contract carries no module, and the domain goes.
|
||||
$teamContract = Subscription::factory()->plan('team')->create([
|
||||
'customer_id' => $customer->id,
|
||||
'instance_id' => $instance->id,
|
||||
]);
|
||||
|
||||
expect(PlanChange::settleCustomDomain($teamContract))->toBeTrue()
|
||||
->and($instance->fresh()->domainIsVerified())->toBeFalse();
|
||||
});
|
||||
|
||||
it('leaves everything alone when the customer keeps the domain by booking the module', function () {
|
||||
[$customer, , $instance] = domainCustomer('business', verifiedDomain());
|
||||
|
||||
// The module is booked onto the contract they are moving TO — on Business
|
||||
// it would be refused as something they already have.
|
||||
$teamContract = Subscription::factory()->plan('team')->create([
|
||||
'customer_id' => $customer->id,
|
||||
'instance_id' => $instance->id,
|
||||
]);
|
||||
app(BookAddon::class)($teamContract, CustomDomainAccess::ADDON);
|
||||
|
||||
expect(PlanChange::settleCustomDomain($teamContract))->toBeFalse()
|
||||
->and($instance->fresh()->domainIsVerified())->toBeTrue()
|
||||
->and($instance->fresh()->address('clupilot.com'))->toBe('cloud.berger.at');
|
||||
|
||||
// And with the module booked there is nothing left to warn about.
|
||||
$team = app(PlanCatalogue::class)->sellable()['team'];
|
||||
|
||||
expect(DowngradeCheck::for($customer, $instance, $team, 'team')->consequences)->toBe([]);
|
||||
});
|
||||
|
||||
it('says nothing about a domain the customer has not got', function () {
|
||||
// The warning is about a concrete address being switched off. Without one
|
||||
// there is nothing to decide, and a paragraph of consequences on every
|
||||
// downgrade card teaches people to skip them.
|
||||
[$customer, , $instance] = domainCustomer('business');
|
||||
|
||||
$team = app(PlanCatalogue::class)->sellable()['team'];
|
||||
|
||||
expect(DowngradeCheck::for($customer, $instance, $team, 'team')->consequences)->toBe([]);
|
||||
});
|
||||
|
||||
it('keeps the rule in one place, where it can only be changed once', function () {
|
||||
// The gap this closed was the same rule living in no place at all. The
|
||||
// opposite failure — a copy in the shop, a copy on the price sheet, a copy
|
||||
// in the downgrade warning — is the one that ships a customer three
|
||||
// different answers, so the config key that says where a domain is
|
||||
// impossible is read by exactly one class.
|
||||
$readers = [];
|
||||
|
||||
$files = new RecursiveIteratorIterator(new RecursiveDirectoryIterator(app_path()));
|
||||
|
||||
foreach ($files as $file) {
|
||||
if ($file->isFile() && $file->getExtension() === 'php'
|
||||
&& str_contains((string) file_get_contents($file->getPathname()), 'unavailable_on')) {
|
||||
$readers[] = str_replace(base_path().'/', '', $file->getPathname());
|
||||
}
|
||||
}
|
||||
|
||||
expect($readers)->toBe(['app/Services/Billing/CustomDomainAccess.php']);
|
||||
});
|
||||
|
|
@ -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('<td', $row), 1);
|
||||
|
||||
expect($cells)->toHaveCount(4)
|
||||
->and($cells[0])->toContain('—')
|
||||
->and($cells[0])->not->toContain('optional')
|
||||
->and($cells[0])->not->toContain('€')
|
||||
->and($cells[1])->toContain("optional · 9\u{00A0}€")
|
||||
->and($cells[2])->toContain('inklusive')
|
||||
->and($cells[3])->toContain('inklusive');
|
||||
});
|
||||
|
||||
it('leaves a feature we do not sell as a dash, without inventing a price for it', function () {
|
||||
// Longer retention is not on the module list, so start does not get an
|
||||
// offer for it. Being able to say "no" is what makes the "optional" cells
|
||||
|
|
|
|||
Loading…
Reference in New Issue