CluPilotCloud/app/Services/Billing
nexxo b3651a14be
tests / pest (push) Failing after 9m46s Details
tests / assets (push) Successful in 21s Details
tests / release (push) Has been skipped Details
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>
2026-07-29 15:59:04 +02:00
..
AddonCatalogue.php Skip the invoice for a full gift, keep it out of revenue, hide its price 2026-07-29 12:41:32 +02:00
CustomDomainAccess.php Decide who may have their own domain, and decide it in one place 2026-07-29 15:59:04 +02:00
DowngradeCheck.php Decide who may have their own domain, and decide it in one place 2026-07-29 15:59:04 +02:00
InvoiceArchive.php Leave the archive readable by whoever collects from it 2026-07-29 03:48:03 +02:00
InvoiceDocument.php Render an invoice, with the arithmetic held to integer cents 2026-07-29 01:41:46 +02:00
InvoiceMath.php Print every line at full price and take the discount off once, in euros 2026-07-29 01:59:56 +02:00
InvoiceNumbers.php Lay the foundation for self-issued invoices: series, numbers, frozen documents 2026-07-29 00:57:06 +02:00
InvoiceRenderer.php Render an invoice, with the arithmetic held to integer cents 2026-07-29 01:41:46 +02:00
IssueInvoice.php Skip the invoice for a full gift, keep it out of revenue, hide its price 2026-07-29 12:41:32 +02:00
PlanCatalogue.php Paketversionen: Verkauf wieder aufnehmen und saubere Übergabe 2026-07-29 14:58:11 +02:00
PlanChange.php Decide who may have their own domain, and decide it in one place 2026-07-29 15:59:04 +02:00
SampleInvoice.php Print every line at full price and take the discount off once, in euros 2026-07-29 01:59:56 +02:00
TaxTreatment.php Decide the VAT rate in one place, before the two disagree 2026-07-29 02:03:29 +02:00