Commit Graph

3 Commits (feat/plan-reopen)

Author SHA1 Message Date
nexxo b3651a14be Decide who may have their own domain, and decide it in one place
tests / pest (push) Failing after 9m46s Details
tests / assets (push) Successful in 21s Details
tests / release (push) Has been skipped Details
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
nexxo 602602864a Let a subscription or add-on be opened for free, with who and why on the row
A grant is an order without money, not a second code path: GrantSubscription
and GrantAddon create the same Order/Subscription/SubscriptionAddon rows a
paid purchase does, with stripe_subscription_id left null and price_cents set
to whatever the customer actually pays. OpenSubscription and BookAddon gained
an optional overrides parameter so the price and provenance (who granted it,
when, a note, an optional end date, and the catalogue price for legibility)
land on the same snapshot every real purchase goes through, not a duplicate.

New customers.grant_plan capability, Owner-only like secrets.manage — giving
away service is exactly the kind of blast radius that precedent reserves for
the Owner.
2026-07-29 12:41:06 +02:00
nexxo 7582df3de6 feat(billing): a proof register, and modules frozen at their booked price
Two things the contract could not answer on its own: what happened, and what
the customer's whole bill is.

`subscription_records` is append-only, one row per commercial event. Flat
columns for everything searched or relied on as evidence — event, customer,
subscription, plan family and version, term, net/tax/gross, currency, tax rate,
reverse charge, Stripe ids — PLUS a versioned JSON copy of the whole snapshot.
Not JSON alone: you cannot query it, and "the amount is in there somewhere" is
poor evidence. Copied, not joined, so a row still answers after the customer,
the plan or the version have gone.

The flat columns describe the TRANSACTION. Gross is what was actually taken;
net and tax are that gross split by the rate that applied on the day. A
discount lowers the taxable amount rather than creating negative VAT, and a
free checkout is recorded as free instead of as paid in full. What was agreed
sits beside it in the snapshot, so a charge that differs from the catalogue is
preserved as a question rather than reconciled away.

The register refuses to be rewritten, in bulk as well as one row at a time —
model events do not fire for `query()->update()`, which is exactly the shape a
careless data fix takes.

`subscription_addons` carries the owner's rule: a customer's total is their
subscription plus their modules, and all of it is frozen at what was agreed.
Price protection covering only the plan would let a module quietly double for
someone who booked it two years ago. A module they have NOT booked is a sale
still to be made, at today's price — so the catalogue is consulted only for
what is not in this table. Several bookings of one module are summed rather
than reduced to one arbitrary row, or the page and the bill would disagree.

Concurrency: one order books one module, enforced by a unique index rather than
a lookup two retries can both pass; cancellation is an atomic claim; and each
booking commits with its own register entry, so a failure cannot leave a sale
without evidence and a retry that finds the booking cannot skip the event.

Verified in the browser: with the module raised from 29,00 € to 59,00 € in the
catalogue, the customer who booked it still sees 29,00 € and a total of
208,00 €, while a new customer is quoted 59,00 €.

436 tests green. Codex review clean after seven rounds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 13:07:34 +02:00