CluPilotCloud/app/Services/Billing/CustomDomainAccess.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());
}
}