7 Commits (b3651a14be91cddb8e9352c223d0079880d475a5)
| Author | SHA1 | Message | Date |
|---|---|---|---|
|
|
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>
|
|
|
|
0560ae743d |
feat(billing): Stripe owns the billing cycle, we own capability
A Product per plan family and a Price per priced row of a published version, plus the four subscribed webhook events that were arriving and being ignored. `stripe:sync-catalogue` mirrors the catalogue. Idempotent twice over: a stored id is skipped, and each call carries an idempotency key derived from our own row, so a crash between Stripe creating a Price and us recording its id gives back the same object rather than a second one. That matters more here than usual — a Stripe Price cannot be edited or deleted, so a duplicate is permanent. Drafts are not synced at all: a version that has promised nothing has no business in a price list. --dry-run shows what would be created. The webhook now handles invoice.paid, invoice.payment_failed, customer.subscription.updated and customer.subscription.deleted. It never re-derives an amount: the invoice is the authority for what was charged, and recomputing it from our catalogue would produce a second, disagreeing answer. A failed payment is recorded and nothing else — Stripe runs the dunning schedule, and cutting a customer off on the first failure would punish an expired card as though it were a refusal to pay. Only a cycle renewal moves the term on. Stripe also sends paid invoices for prorations and manual charges, and the checkout's own invoice — which is already in the register as the purchase, and would otherwise double every customer's first payment. Stripe does not guarantee delivery order, which is the source of most of the care here: - state changes are judged and written in one transaction with the row held, so two deliveries cannot both pass a check made on a stale copy; - an older event never overwrites a newer one, with a rank breaking ties between events sharing a second — their timestamps have one-second resolution, and a failed attempt and its successful retry routinely do; - a term is never shortened, and an ended contract is never revived; - an event for a contract that does not exist yet is HELD and replayed the moment it appears, rather than acknowledged and forgotten. A cancellation dropped that way would leave us serving someone who had already left. Held rows that never match are pruned after a week. Register entries are keyed by what they are about — the invoice, the attempt, the subscription — with a unique index doing the work, because a check two concurrent deliveries can both pass is not idempotency. PlanChange is now documented as the preview shown before someone confirms, not the invoice: Stripe's proration accounts for tax, existing credit and the exact second the change lands, and ours cannot. Not run against the live account — `stripe:sync-catalogue` creates objects that cannot be deleted, so that is the owner's call. The dry run lists 12. 457 tests green. Codex review clean after seven rounds. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
6119044669 |
fix(billing): no plan changes after cancellation, no unknown terms
tests / pest (push) Successful in 6m46s
Details
tests / assets (push) Successful in 19s
Details
tests / release (push) Has been skipped
Details
A cancelled subscription still reported an upgrade as allowed and priced it, so a caller trusting that would have provisioned and billed a customer who had already left. And any term string other than the two we support was silently priced monthly while keeping the unknown value — a subscription whose price and billing period disagree. Both are refused now. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
|
|
|
35324b986a |
fix(billing): no free upgrade in the last hours of a term
tests / pest (push) Successful in 6m48s
Details
tests / assets (push) Successful in 19s
Details
tests / release (push) Has been skipped
Details
Flooring the remaining time made an upgrade with under a day left cost nothing, and an upgrade requested after the period had ended cost nothing while being applied immediately — the bigger plan for free. Remaining time now rounds up while any service is left, and an expired period defers the change to the next term instead of pricing it at zero. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
|
|
|
52b1acbb80 |
fix(billing): a scheduled downgrade must be applicable, and never cost money
tests / pest (push) Successful in 6m49s
Details
tests / assets (push) Successful in 19s
Details
tests / release (push) Has been skipped
Details
Once the term is over, the downgrade has to be allowed — otherwise the job that is supposed to carry it out never can, and the change waits forever for a date that has already passed. And a grandfathered plan can be cheaper than the smaller plan costs today, which made the goodwill credit negative: an invoice for the privilege of downgrading. Clamped to zero. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
|
|
|
460fac01b1 |
fix(billing): upgrade or downgrade is decided by rank, not by price
tests / pest (push) Successful in 6m51s
Details
tests / assets (push) Successful in 20s
Details
tests / release (push) Has been skipped
Details
With grandfathered prices the comparison inverts: a business plan bought when it cost less than today's team plan would have treated a move to team as an upgrade — charged immediately, while the customer loses resources. The plan's rank is frozen with the rest of the snapshot and decides the direction; prices only decide the amount. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
|
|
|
ac250071cb |
feat(billing): immutable subscription snapshots and pro-rata plan changes
The plan catalogue describes what we sell today; a customer who signed up last year bought last year's terms. Every commercially relevant condition — price, quotas, seats, the hardware behind the plan — is now frozen onto a subscription at signup, and the model refuses to let any of it be rewritten afterwards. A price rise applies to new subscriptions and cannot reach back into an existing contract. PlanChange holds the two rules, computed against the frozen price rather than today's catalogue: - Upgrading is immediate and pro rata: the new plan for the days left in the paid term, minus what the old plan was worth over those same days. On the last day of a month that is one day's difference, not a month's. - Downgrading waits for the end of the term. A yearly customer bought a year and can move down when it is up; a month is a month. A mid-term downgrade is a goodwill decision, not a self-service button, and its credit covers only the unused part of the difference. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |