359 lines
14 KiB
PHP
359 lines
14 KiB
PHP
<?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());
|
|
}
|
|
}
|