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. * * Clearing the row is only half of it, though. A domain that was verified * is a domain the proxy is routing and Nextcloud is trusting, and neither * of those reads this table — so the address is re-applied, which is what * actually stops the customer's old domain from answering. Without it a * downgrade would take the domain off every screen while the machine * carried on serving it. */ public function deactivate(?Instance $instance): bool { if ($instance === null || blank($instance->custom_domain)) { return false; } $wasVerified = $instance->domainIsVerified(); $instance->forceFill([ 'custom_domain' => null, 'domain_token' => null, 'domain_verified_at' => null, 'domain_cert_ok' => false, 'domain_checked_at' => null, 'domain_error' => null, 'domain_failures' => 0, ])->save(); if ($wasVerified) { app(ReapplyInstanceAddress::class)($instance); } 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()); } }