Compare commits

...

235 Commits

Author SHA1 Message Date
nexxo 5c2e481985 fix(security): console and account checks now survive a Livewire action
tests / pest (push) Successful in 7m8s Details
tests / assets (push) Successful in 20s Details
tests / release (push) Successful in 4s Details
Livewire re-applies only the middleware on its persistent list when an action
posts to /livewire/update. Its defaults cover auth; ours were not on the list —
only RestrictAdminHost had been added. So EnsureAdmin and EnsureCustomerActive
guarded the PAGE and not the actions, and the page is not where the actions run:
a signed-in non-operator who could reach the admin host could drive console
components, and a suspended customer could keep driving the portal.

Found by Codex while reviewing the domain-separation design, and confirmed
against Livewire's own default list in vendor.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 20:04:34 +02:00
nexxo 5d64da1cb3 docs: hiding the site also hides the login page, and that has a consequence
tests / pest (push) Successful in 7m2s Details
tests / assets (push) Successful in 20s Details
tests / release (push) Successful in 3s Details
The gate exempts admin/* so the console keeps working, but /admin sends a guest
to /login and /login is not the console — so with the site hidden and no VPN
yet, an operator cannot sign in to flip the switch back. Found while bringing
up the live server.

The mechanism for it already exists and is the right one: TRUSTED_RANGES. What
does NOT work is exempting the login flow by hostname, which was the obvious
patch — a Host header is chosen by the caller, so one forged header would have
lifted the gate for every route, portal included. Codex caught that; the comment
now says why the narrow-looking option is the wrong one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 19:27:51 +02:00
nexxo 875fd11982 fix(deploy): an answer file with a space in a value killed the install
tests / pest (push) Successful in 7m2s Details
tests / assets (push) Successful in 19s Details
tests / release (push) Successful in 3s Details
The file is sourced, so ADMIN_NAME=Boban Blaskovic is read as a command and
dies with "Blaskovic: command not found" — naming neither the file nor the
variable — and set -e ends the install there. A display name with a space is
entirely ordinary; the example just happened to use "Administrator", one word,
so nobody hit it.

Found while installing on the live server.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 18:49:58 +02:00
nexxo 9352935b88 fix(nginx): the live hostnames were missing from the admin denylist
tests / pest (push) Successful in 6m56s Details
tests / assets (push) Successful in 21s Details
tests / release (push) Successful in 6s Details
The map listed only *.dev.clupilot.com, with the production names commented out
under "add the real public hostnames here before launch". Launching without
them removes the outer of the two layers that keep /admin off the public
internet — the ADMIN_HOSTS check in the app would still answer 404, but the
whole point of the pair is that neither is relied on alone.

admin.clupilot.com stays out of the list on purpose: the list names the hosts
that are PUBLIC, and the console is not one of them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 18:43:44 +02:00
nexxo 35307e64cc fix(deploy): the installer could never finish, twice over
tests / pest (push) Successful in 6m55s Details
tests / assets (push) Successful in 22s Details
tests / release (push) Successful in 5s Details
Found by running it on a fresh Debian 12 with systemd, no docker, no git and no
users — which is the first time it has ever been run anywhere.

`optional_env` ended in `[[ -n "$2" ]] && set_env …`. With an empty value the
function returns 1, and under `set -e` that ends the install, silently, with a
half-written root-owned .env. Absent is the NORMAL case for those variables —
they are the optional ones — so this killed every installation that did not
happen to supply a Hetzner token. An `if` returns 0 and does not.

Then it waited for `php -v` before migrating. PHP answers in seconds; the
entrypoint is still running `composer install` on a fresh checkout, which takes
minutes. So `artisan migrate` ran against a checkout with no vendor/ and died
on a missing autoload.php. It now waits for vendor/autoload.php, exactly as the
dependent containers already do, and says where to look if it never appears.

After both: exit 0, 59 migrations applied, the plan catalogue seeded and
consistent, the Owner account created with its role, and the portal answering
200 while the console redirects to login and the unconfigured Stripe webhook
fails closed with 400.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 17:42:26 +02:00
nexxo e9240c1324 feat(deploy): releases you can pin to, and a version that tells the truth
VERSION at the repository root is the release number and the only place it is
written down — a file rather than `git describe`, because a source archive, a
shallow CI checkout and a container built without .git have no tags to
describe. Cutting a release is editing that file and merging it; CI creates the
annotated v<VERSION> tag once green, only on the commit that raised the number,
and never moves it. Servers are pinned to these.

Two modes, and the difference is the point. RELEASE=v1.0.0 pins a server to an
immutable tag, checked out detached: it moves when someone decides it moves.
BRANCH=main follows the edge, where CI tags a commit only after it is pushed.
The mode is remembered, so re-running the updater on a pinned box does not
quietly walk it back onto main.

Going backwards is refused, by commit ancestry rather than by comparing version
strings — a tag can be cut from anywhere, and only ancestry says whether history
is going back. The schema has already moved forward by then, and the migrations
needed to reverse it are not in the older checkout at all.

What the console reports comes from a manifest written atomically after every
step succeeded, never from live git: git says what the files are, not whether
the deployment came up. So a failed update keeps reporting the version that is
actually serving — including its version number, not the newer one the checkout
has already moved to. A deployment pinned to the tag reads "1.0.0 (abc1234)";
anything else reads "1.0.0-dev (abc1234) · main", because every commit after the
tag still carries VERSION=1.0.0 and is not that release. Reporting it as one is
how a bug gets filed against the wrong code.

Codex reviewed the design before it was written and the result six times after.
Its findings, all real: the version had to come from the manifest too, not just
the commit; branch names need JSON escaping or the manifest silently vanishes; a
`case` glob does not anchor and accepted 1x.2y.3garbage; pinning to the commit a
server already sits on skipped recording the mode, and then skipped detaching;
a manifest that failed to write would never be repaired; an unparseable
timestamp would 500 every admin page; and an empty tag list read as a
connectivity failure.

466 tests green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 15:21:38 +02:00
nexxo c119e8d96e fix(deploy): say which branches exist instead of failing inside git
The installer clones BRANCH, which defaults to main — and the repository has
no main. Git's own "Remote branch not found" reads like a bad token or an
unreachable server, and the branch is the one thing here that is routinely
wrong. It now asks first and, when the branch is missing, prints the ones that
do exist and how to pass one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 14:48:49 +02:00
nexxo 4f3e11ae5e docs(deploy): the installer's Stripe steps match what phase 5 actually needs
It still named two webhook events; the application handles six, and the four
new ones are the billing cycle itself — renewals, failed payments, status
changes and endings. It also said nothing about stripe:sync-catalogue, without
which Stripe has no prices and nothing can be sold at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 14:46:25 +02:00
nexxo 8c3a79e258 fix(billing): hold only what cannot be matched, and let migrations roll back
Two findings from reviewing all five phases together rather than one at a time.

The webhook held every event a handler answered `null` to — but `null` also
means "already recorded" and "deliberately skipped", which is every checkout's
own invoice and every redelivered renewal. Their contract is right there, so
`replayHeldFor()` could never come back for them, and ordinary Stripe traffic
silted up the holding area until the weekly prune. It now holds only what it
genuinely cannot match — and if the contract appeared in the meantime, applies
the event instead of dropping it: the creation race is narrow, but losing a
cancellation to it would leave us serving someone who had left.

Also fixes a rollback that predates this work and blocks `migrate:fresh` on
MariaDB entirely: dropping the unique index on customers.user_id fails while
the foreign key added one migration earlier still depends on it. Verified by
building all 33 migrations from nothing on MariaDB, rolling every one of them
back, and building them again.

458 tests green. Codex clean on the full branch diff.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 13:57:11 +02:00
nexxo 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>
2026-07-26 13:36:28 +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
nexxo f6ddf45dc0 feat(admin): a console for creating, pricing and scheduling plans
The owner can now do from the console what only a config edit could do before:
create a plan line, draft a version, price it, publish it into a window, and
pull a plan out of the shop.

Two pages and one modal, because the catalogue has exactly two levels. The
plan list is a name, a rank and a kill switch. Everything that can be got
wrong — capabilities, prices, windows — lives on the versions, where the page
is built around the one rule that matters: a draft is freely editable, a
published version is not touchable at all. So drafting and publishing are
separate acts, and publishing says plainly that it is final.

The console refuses everything the catalogue refuses, on the form rather than
as a stack trace: an overlapping window, a window that ends before it starts,
a version with no price for a term we sell on, and — new here — a version with
no VM template, which would be bought and then fail provisioning every time.
Numbers are bounded on both sides, because a mistyped price otherwise
overflows the column and answers with a 500 instead of saying which field was
wrong. Performance class and features are picked from the keys we have labels
for; free text would be frozen at publication and shown to customers as a raw
translation key forever.

Concurrency, since two admins share one catalogue: draft numbers are allocated
under a lock on the family, publication is an atomic conditional claim, and
discarding a draft is one statement conditional on it still being a draft —
otherwise a draft published in the meantime could be deleted out from under
the customers now contracted to it.

`plans.manage` (Owner and Admin) guards the pages themselves, not only the
buttons, and the modal authorises itself — prices, drafts and unreleased plans
are not something to leave readable to anyone who types the URL.

Also fixes the shared checkbox, which ticked in the browser's own blue on
every page that used one: a native checkbox ignores text colour and needs
accent-color.

Verified in the browser: withdrawing a plan in the console removes it from the
customer's billing page immediately, and restoring it brings it back. The
public landing page carries no catalogue-driven plan list, so there is nothing
there to hide.

421 tests green. Codex review clean after six rounds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 12:41:51 +02:00
nexxo 6387c747d0 feat(billing): the plan catalogue becomes three tables, and config stops selling
Plans lived in config/provisioning.php, where the owner cannot reach them and
where a plan has no history. They now live in plan_families / plan_versions /
plan_prices — a name that never moves, what the plan WAS at a point in time,
and a price per version and term with its own Stripe Price id.

- Availability is computed on every read (available_from <= now < until,
  half-open, UTC) plus a per-family sales kill switch. Nothing schedules a plan
  into or out of sale: a job that fails to run is a plan that silently
  misbehaves.
- Overlaps crash rather than resolve. currentVersion() uses sole(), and
  scheduling takes a lock on the family and rejects an overlapping window —
  two versions on sale at once would decide a customer's price by row order.
- A version is frozen from publication, not from first sale, and so is its
  price: a checkout is not instant, and an amount edited between the session
  opening and the webhook landing would contract someone at a price they were
  never quoted. Repricing publishes a new version, as Stripe requires anyway.
- Neither a published version, its price, nor a family with customers can be
  deleted, and a family key cannot be renamed. All of those would null the
  provenance off existing contracts.
- Subscriptions and orders record plan_version_id. A historical reference
  resolved by plan NAME hands back today's terms, which is the split-brain one
  level up. A checkout that carried its version is honoured even after the
  window closes — they paid for what they were shown.

Switched atomically and failing closed: config('provisioning.plans') and
plan_features are gone, and nothing falls back to them. A fallback would
resurrect a plan the owner had just switched off. The seed lives in the
migration, and PlanCatalogueTest pins what the config catalogue sold as the
shadow comparison. `php artisan plans:check` reports overlaps, gaps and
missing prices before a customer finds them.

Contract-backed displays, which were reading the live catalogue:
seat limits, the cloud card, the plan card, and admin MRR — the last three now
divide a yearly contract down via Subscription::monthlyPriceCents(), since all
three label the figure per month.

Two recovery gaps closed along the way: the order now commits before the
contract is opened (so a contract failure can never erase the record of a
payment), a Stripe retry repairs a missing contract and restarts a run stranded
with no_subscription, and TickProvisioning sweeps runs left pending by a crash
rather than only running/waiting ones.

402 tests green. Codex review clean after thirteen rounds. Verified in the
browser: portal, billing and console render unchanged off the new catalogue.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 12:05:56 +02:00
nexxo 52b41bb0d5 fix(billing): a paid order opens a contract, and provisioning obeys it
The pipeline re-resolved config('provisioning.plans') by order.plan, so the
subscription snapshot protected a customer's price but not their machine:
shrinking a plan resized an existing customer's VM on its next run. Nothing
created a subscription either, so closing this meant opening the contract at
purchase and pointing provisioning at it.

- OpenSubscription freezes the catalogue onto a subscription when a checkout
  is paid; StartCustomerProvisioning calls it inside the order transaction.
- CustomerStep::plan() reads the frozen snapshot. ValidateOrder and
  ReserveResources fail closed with no_subscription rather than falling back
  to the catalogue, which is the bug itself.
- template_vmid joins the snapshot so a re-clone cannot pick up a blueprint
  published after the sale. Deliberately outside FROZEN: it is how we build
  the machine, not a term the customer is owed, and a dead template must be
  replaceable without cancelling a contract.
- TrafficMeter reads the allowance off the contract too — cutting a plan's
  traffic was otherwise enough to start throttling someone who bought more.
- The migration backfills contracts for orders that already bought something,
  reconstructed from what was actually delivered where an instance exists,
  and adopts an existing order-less contract instead of opening a second.
  Orders paid in a currency the catalogue cannot price get none, matching the
  checkout path.

price_cents stays the catalogue's NET price, which is what PlanChange
prorates against — not Order::amount_cents, which holds Stripe's GROSS total.
Reconciling the two belongs to the proof register and Stripe (phases 4/5).

Also pins STRIPE_WEBHOOK_SECRET blank in phpunit.xml: the operator's real
secret was reaching the suite from .env and rejecting every unsigned test
payload, which is why 7 webhook tests failed before any of this.

Verified in the browser: with team traffic cut from 3000 to 500 GB in the
catalogue, the customer's portal still shows 3 TB.

373 tests green. Codex review clean after three rounds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 11:10:00 +02:00
nexxo 0dde76ad55 docs: handoff for the plan catalogue rebuild
tests / pest (push) Successful in 6m57s Details
tests / assets (push) Successful in 19s Details
tests / release (push) Has been skipped Details
Design is decided and Codex-reviewed; none of it is built. Records the live
split-brain bug to fix first, the three-table catalogue, computed availability,
the proof register with frozen add-ons, the Stripe split, what already exists,
and the working rules that cost time this session.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 10:43:17 +02:00
nexxo 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>
2026-07-26 09:57:53 +02:00
nexxo 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>
2026-07-26 09:55:53 +02:00
nexxo 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>
2026-07-26 09:54:16 +02:00
nexxo 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>
2026-07-26 09:52:28 +02:00
nexxo 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>
2026-07-26 09:49:48 +02:00
nexxo c36ea17b39 fix(billing): normalise both sides of the VAT comparison; cast the timestamp
tests / pest (push) Successful in 6m45s Details
tests / assets (push) Successful in 22s Details
tests / release (push) Has been skipped Details
A verifier that returns the number in display form ("DE 811 907 980") would
have failed the comparison against the normalised current value and silently
switched a genuine reverse-charge customer back to domestic VAT. Both sides are
normalised now, and vat_id_verified_at is a real datetime rather than a string.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 09:36:07 +02:00
nexxo 5aef5e693c fix(billing): verification vouches for a value, not for a row
tests / pest (push) Successful in 6m52s Details
tests / assets (push) Successful in 20s Details
tests / release (push) Has been skipped Details
A timestamp alone said "some number was checked once": editing the field left
it intact, so a customer could swap a verified foreign VAT ID for any
plausible-looking one and keep zero-VAT pricing. The verified value is stored
and compared, which makes the rule self-enforcing — no writer has to remember
to clear a flag, and there are several writers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 09:34:40 +02:00
nexxo 42ceafb57f fix(billing): a self-declared VAT ID must not zero the tax
tests / pest (push) Successful in 7m13s Details
tests / assets (push) Successful in 22s Details
tests / release (push) Has been skipped Details
Any non-empty string starting with two characters other than AT switched the
customer to reverse charge — typing "XX123" was a 20 % discount. Reverse charge
now requires a VAT ID that is verified, belongs to an EU member state other than
ours, and looks like a VAT number at all. Unverified is the normal state and
means the domestic rate: over-collecting is correctable, under-collecting is a
tax liability.

Changing the number clears its verification. Verification itself (VIES) is not
built yet, so reverse charge stays off until someone confirms a number — which
is the safe direction to be wrong in.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 09:32:42 +02:00
nexxo 76510a59a3 fix(billing): VAT follows the customer, not a global setting
tests / pest (push) Successful in 7m28s Details
tests / assets (push) Successful in 21s Details
tests / release (push) Has been skipped Details
Codex was right that this could misstate real charges: an EU business with a
VAT ID registered in another country is billed under reverse charge, and we
were adding 20 % Austrian VAT to their total anyway. TaxTreatment resolves it
from the customer's VAT ID, and the whole page — cart, plan cards, add-on
cards — now states one treatment instead of contradicting itself.

Explicitly NOT handled: cross-border sales to private individuals, which are
taxed at the buyer's national rate under OSS. That needs a maintained rate
table and a tax adviser, not a guess, so those fall back to the domestic rate —
over-collecting rather than under-collecting.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 09:30:17 +02:00
nexxo ac3f429958 fix(billing): round the recurring figure the same way as the total
tests / pest (push) Successful in 7m33s Details
tests / assets (push) Successful in 21s Details
tests / release (push) Has been skipped Details
The note rounded VAT after aggregating while the total rounded per order, so
cent-level prices made the two disagree — and a customer who spots that stops
trusting every other number on the page.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 09:27:02 +02:00
nexxo 9fab251fb3 fix(billing): make the one-plan-change rule hold under two clicks
tests / pest (push) Successful in 7m28s Details
tests / assets (push) Successful in 21s Details
tests / release (push) Has been skipped Details
Both requests could finish their delete before either inserted, leaving exactly
the two pending upgrades the rule exists to prevent. Replacement and insert now
run in one transaction with the customer row locked.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 09:25:29 +02:00
nexxo a87a4f512d feat(billing): one plan change at a time, and every price says what it is
Two contradicting plan upgrades sat in the cart at 799 and 399 € — no checkout
could resolve which one the customer meant. Choosing another now replaces the
pending one and says so; add-ons still stack, because buying 200 GB as two packs
is a sensible thing to want.

Every price now states net or gross and how often. The cart shows net per line
with "pro Monat" or "einmalig", then subtotal, VAT and gross — and separates
the monthly recurring amount from a one-off traffic top-up sharing the same
cart, because those are two different commitments. The rate is configurable
(CLUPILOT_TAX_PERCENT, 20 % default) since it follows the seller's country.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 09:23:49 +02:00
nexxo 55b1a1468f fix(admin): the admin password is shown once, and means it
tests / pest (push) Successful in 7m37s Details
tests / assets (push) Successful in 22s Details
tests / release (push) Has been skipped Details
The handoff stayed in the cache for its full ten minutes, so any replayed
component request could fetch the plaintext again — "shown once" was a figure
of speech. It is consumed on the first read and the token dropped; the payload
reaches the view and nothing else.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 05:34:57 +02:00
nexxo d40113be8e fix(admin): never reset a stranger's VM, never leave the console spinning
Two from Codex:

- The job accepted any stored instance with a host. A closed one's VMID can have
  been reused on the same machine, and the reset would then land on another
  customer's VM. It now requires a live instance, and the action is not offered
  for anything else.
- An unreachable Proxmox or guest agent throws rather than returning an exit
  code, so nothing was ever written to the handoff and the modal polled forever.
  The throw is caught, and a failed() handler covers anything that still escapes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 05:32:53 +02:00
nexxo aae0457e19 feat(admin): administrator access to a customer's Nextcloud
Impersonation borrows the customer's portal session; this is access to their
installation, which is a different thing and now a different button.

Nextcloud has no passwordless admin jump, so this does the only honest thing it
offers: it resets OUR managed admin account inside that installation and hands
the credentials over once. The customer's own accounts are untouched, and the
next request sets a new password again.

- New capability instances.adminlogin, Owner and Admin only — stronger than
  impersonation, because it hands over control rather than a session.
- The operator's own password is required every time, rate-limited: taking over
  a customer's installation is not something an unattended browser should manage
  on its own.
- The reset runs on the provisioning worker (it owns the tunnel and the Proxmox
  credentials); the console polls a handoff token, so the credentials never
  enter a Livewire snapshot.
- A silent instance is reported as such instead of appearing to succeed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 05:30:59 +02:00
nexxo d26200c74b feat(billing): a cart you can actually read and change
tests / pest (push) Successful in 7m10s Details
tests / assets (push) Successful in 20s Details
tests / release (push) Has been skipped Details
"5 purchases pending" told nobody what they had ordered and offered no way to
change their mind. The billing page now lists each pending purchase by name,
what it costs, when it was added, and the total — with a remove button per row
behind a confirmation, like every other destructive action in the console.

The wording lives on the Order model rather than in the view, so the cart, the
invoice list and any later confirmation mail cannot each invent their own name
for the same row. Removal is scoped to the customer's own still-pending orders,
checked in the modal itself: modals are reachable without the page's guards.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 05:26:49 +02:00
nexxo a6d7594e5c test: do not reach for Redis from the provisioning queue
tests / pest (push) Successful in 7m4s Details
tests / assets (push) Successful in 20s Details
tests / release (push) Has been skipped Details
Jobs that pin themselves to the provisioning connection bypass
QUEUE_CONNECTION, so two tests opened a Redis connection — fine on a machine
running the stack, a RedisException in CI. A test run must not depend on
infrastructure being up.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 02:20:46 +02:00
nexxo 819872bb79 fix(ci): install dependencies by cloning, not through GitHub's API
tests / pest (push) Failing after 6m59s Details
tests / assets (push) Successful in 20s Details
tests / release (push) Has been skipped Details
The dist path is rate-limited for anonymous callers, and a partial failure left
a half-installed vendor/ behind — the suite then failed with 500s that had
nothing to do with the code. Source clones need no quota. A GitHub token would
let us go back to the faster path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 02:11:52 +02:00
nexxo 7af3e2957b test: do not depend on a built asset manifest
tests / pest (push) Failing after 7m22s Details
tests / assets (push) Successful in 22s Details
tests / release (push) Has been skipped Details
CI has no build, so every page rendering through @vite threw a view exception —
dozens of failures with one cause. withoutVite() makes the suite test the
application rather than the state of the asset pipeline; a broken build shows up
in the build job, which is where it belongs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 02:03:58 +02:00
nexxo 2c0884357c fix(pages): the update and placeholder pages must not need a build
tests / pest (push) Failing after 7m49s Details
tests / assets (push) Successful in 26s Details
tests / release (push) Has been skipped Details
CI caught it: both rendered through @vite, so a missing manifest threw a view
exception instead of a page. That is not a test problem — the update page is
shown precisely while the application is mid-deploy or freshly installed, which
is exactly when the manifest may be absent. Both carry their own small
stylesheet now and render with nothing built at all.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 01:56:23 +02:00
nexxo b55a127a00 fix(ci): drop the cache steps this runner cannot serve
tests / pest (push) Failing after 7m55s Details
tests / assets (push) Successful in 25s Details
tests / release (push) Has been skipped Details
actions/cache expects a cache service the runner matching Gitea 1.20 does not
speak, and it failed the job outright. It was an optimisation; the source
fallback that makes the install work is what mattered.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 01:45:11 +02:00
nexxo 7cd3ff33bd fix(ci): survive GitHub's anonymous rate limit
tests / pest (push) Failing after 1m11s Details
tests / assets (push) Failing after 16s Details
tests / release (push) Has been skipped Details
Composer's dist downloads come from GitHub, which throttles anonymous callers —
on a shared address that is a coin flip, and it took the run down after four
minutes of setup. Source clones are the fallback, and both toolchains now cache
their package directories so a repeat run barely touches the network.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 01:39:32 +02:00
nexxo 6367f4a9fd fix(ci): pin actions to versions the runner can execute
tests / pest (push) Failing after 1m0s Details
tests / assets (push) Successful in 52s Details
tests / release (push) Has been skipped Details
The floating tags moved to node24; act_runner 0.2.6 — the version that matches
Gitea 1.20 — supports node20 at most, so every job died before running a single
test. Pinned to the newest releases that still declare node20.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 01:34:37 +02:00
nexxo 53d305340f fix(vpn): a late revocation must not disconnect whoever holds the key now
tests / pest (push) Failing after 49s Details
tests / assets (push) Successful in 51s Details
tests / release (push) Has been skipped Details
Once a key is free, someone can legitimately create a new access with it. The
forced revocation looked the key up and would have deleted that person's access
and pulled their key off the hub. It now acts only on an adoption artifact — a
system peer with no owner and no creator — and leaves anything else alone.

Re-issuing also threw when VPN_CONFIG_KEY was missing, which is exactly the
situation the console tells people to fix by re-issuing. It now hands the new
config over once instead of storing it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 01:24:55 +02:00
nexxo 4f9ca27732 fix(vpn): a replaced key must not stall the reconciliation
Codex found that a sync landing between the key swap and its removal would see
the old key as unknown. Writing the test showed something worse than the
duplicate he predicted: the adoption cannot even happen — the address still
belongs to the live access — so the insert violated the unique index and took
the ENTIRE reconciliation down with it. One stale key would have stopped every
peer's state from updating.

The sync now recognises that case and queues the removal instead of adopting,
and a key with no row of its own is revoked with force: it can only ever be
removed, never kept, so the removal cannot be talked out of it by a row that
should not exist.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 01:23:15 +02:00
nexxo 556a560506 feat(vpn): explain a missing download, and offer a way out
An access created without storing its config had no download button and no
explanation — it looked broken. It now shows a dimmed button that says why
(the private key only exists on your device), and every staff access gets
"Re-issue": a new keypair, old key off the hub before the new one goes on,
so two peers never claim the same tunnel address at once. A stored config is
re-encrypted in step, or the owner would download a key the hub no longer
accepts.

Host peers are excluded: their key belongs to the machine's own wg0, and
swapping it here would cut the host off with nothing left to repair it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 01:20:04 +02:00
nexxo e5aea84539 docs(ci): wire the runner up to the point where only the token is missing
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 01:18:42 +02:00
nexxo 419a1a8552 fix(ci): keep the workflow and runner compatible with Gitea 1.20
github.* is the documented context alias and exists in 1.20; gitea.* came
later. The runner is pinned instead of :latest — the registration protocol
moves with the server, and a newer runner refuses to register against an older
Gitea.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 01:15:45 +02:00
nexxo 8117630a65 feat(ops): update page, automatic 419 recovery, CI workflow
Three things that bit us on every deploy:

- artisan down now renders a branded page that says an update is running and
  reloads itself, instead of Laravel's bare 503.
- A page left open across a deploy carries a Livewire snapshot and CSRF token
  the new code rejects — the user got "419 Page Expired" and a dead interface.
  A 419 from a /livewire/ request now reloads the page; the session is still
  valid, so that is all it takes. Hooked at the fetch layer rather than through
  Livewire's request hook, whose failure callback is not invoked for this case
  in the installed version — verified against a real 419 in the browser, not a
  simulated one.
- Vite no longer empties its output directory: wiping it is what left an open
  page without its stylesheet mid-deploy, which is the "design is completely
  broken" symptom. update.sh also rebuilds the caches with optimize:clear +
  optimize instead of leaving them half-warm.

Plus CI: .gitea/workflows/tests.yml (Pest + asset build, and a tested- tag only
on a green main) and an opt-in act_runner service under the ci profile.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 00:58:27 +02:00
nexxo ac86efd9c3 feat(deploy): unattended install from a single answers file
--env-file makes the whole run non-interactive, which is what a bare server
needs: install git, clone, run, done. The file also carries the operational
secrets (Hetzner DNS, Stripe, SMTP, the Proxmox key path); those are optional,
so a first install can happen before the Stripe account exists and the features
stay dark until the values are filled in.

The installer warns when the file is readable by more than its owner, and
clupilot.env is gitignored — it holds every secret an installation will need.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 00:40:33 +02:00
nexxo 062d337dd0 fix(portal): move notices into the header, so the page stops scrolling twice
The banners sat outside the h-screen shell, so the page scrolled and the content
scrolled — two scrollbars whenever a maintenance window was active.

Maintenance notices are now a bell with a count in the header, next to the user
menu that was otherwise the only thing up there. Impersonation stays visible as
a header chip rather than moving into the dropdown: it is a mode, not a notice,
and acting as someone else without noticing is how mistakes happen.

Also: the VPN status pill wrapped onto two lines in a narrow column.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 00:13:20 +02:00
nexxo 3fad319053 fix(hosts): re-read the host before deciding there is no DNS record
A registration finishing while the purge waited on the run locks wrote
dns_record_id after the purge had already loaded the host, so it skipped the
deletion and then deleted the row holding the only id.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 00:02:18 +02:00
nexxo 7b57ab21cb fix(hosts): retry a failed DNS registration before advancing without a name
The failure may be a lost response to a request that did create the record.
Advancing straight away stored no id, so PurgeHost could never remove it and the
host's management address stayed published. The upsert is idempotent, so a retry
recovers the id; only after the attempts run out does the onboarding continue
without a name.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 00:01:16 +02:00
nexxo 952e8e5d2f fix(hosts): number DNS names by the normalised label, not the raw code
Two legacy codes can normalise to the same label (eu_west and eu-west), and
separate counters then handed both of them eu-west-01 — a unique-constraint
failure inside the reservation, which blocks onboarding rather than being a DNS
problem the step can shrug off. Lock, counter and the in-use check all key off
the label now.

(The helper was also called label(), which HostStep already declares as the
step's display name — renamed.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 23:59:58 +02:00
nexxo e96f4c1c1a fix(datacenters): a code becomes a DNS label, so validate it as one
alpha_dash accepted eu_west, -edge and edge-, none of which are valid DNS
labels — every host in such a datacenter would have failed registration and,
because DNS is non-fatal, silently ended up without a name. The console now
requires a proper label, and the step normalises anything created before that
rule so existing rows still get a usable name.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 23:56:50 +02:00
nexxo 69043f9ffc fix(dns): deleting a record that is already gone is success
A lost response, or a retry after a later step failed, made every further
attempt a 404 — and the host impossible to purge.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 23:54:45 +02:00
nexxo 9f8659da32 fix(hosts): a failed DNS deletion must not orphan the record
Swallowing it and carrying on deleted the row that held the only reference to
that record, publishing the machine's management address for good. The purge now
fails instead — and is retried, since it re-reads the host and its other steps
are idempotent — so a broken DNS provider becomes a visible failed job rather
than a silent leak.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 23:53:14 +02:00
nexxo edf24257b5 fix(hosts): reserve the DNS name under the lock, never reuse it, clean it up
Codex was right that my never-reuse claim did not hold:

- The lock was released with only a candidate in hand, so two concurrent
  onboardings in one datacenter could pick the same name and overwrite each
  other's record. The name is now persisted on the host while still locked.
- The number was derived from the hosts that happen to exist, so removing the
  highest one handed that number straight back out — a cached name would then
  resolve to a different machine. The counter is stored per datacenter, floored
  by what is actually in use so a wiped settings store cannot reissue live names.
- PurgeHost deleted the row that carried the record id, leaving the machine's
  management address published with nothing left to clean it up.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 23:51:25 +02:00
nexxo 5db2f7fda7 feat(hosts): give each host a DNS name under the public zone
fsn-01.node.clupilot.com, numbered per datacenter and never reused — removing a
host must not renumber its neighbours onto its name, so the number is stored
rather than derived.

The record points at the host's WireGuard address, not its public IP: the name
exists so an operator can reach a host by name over the VPN, and publishing a
Proxmox host's public address would hand every scanner a target, which is the
one thing this network design avoids.

DNS is convenience, not a prerequisite: a failure logs an event and lets the
onboarding finish rather than stranding a host that is otherwise ready.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 23:49:18 +02:00
nexxo 7941935f57 fix(traffic): a sample without counters is not zero traffic
Defaulting missing netin/netout to zero reset the baseline, so the next real
sample added the entire cumulative counter again — a customer throttled for
traffic that was counted twice. Such a sample is skipped and logged now.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 23:44:02 +02:00
nexxo 2415dde288 fix(deploy,traffic): clear caches before restarts, hide the token, widen the lock
- update.sh restarted the long-lived services before clearing the caches, so
  they booted from the old cached config and kept it for the life of the
  process — the release's configuration never reached the workers.
- install.sh put the Gitea token in the clone URL, which is visible in the
  process list to every local user while the clone runs. It goes through a
  short-lived askpass helper now.
- The collector's uniqueness lock expired at exactly the collection interval,
  so a delayed queue could run two collectors on the same baseline and count
  the same delta twice — throttling a customer for traffic they never used.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 23:42:12 +02:00
nexxo 0135f114d3 fix(traffic): keep retrying the throttle release after a Proxmox blip
Tying the release to the creation of the new month's row meant a single failed
call was never retried: the row exists from then on, and the current period
looks unthrottled, so nothing could detect the stale NIC limit. A customer would
have stayed slow for a whole month they had paid for. Checked on every sample
now, with a test that makes the first release fail.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 23:40:33 +02:00
nexxo 25c4c5df5a fix(deploy): restart the worker after the hub key, diff against what was deployed
- The provisioning worker boots before install.sh writes
  CLUPILOT_WG_HUB_PUBKEY and holds the empty value for the life of its process,
  so every host onboarded afterwards would have got an empty PublicKey in its
  wg0.conf.
- A failed update left the checkout at the target, so the rerun compared a
  commit with itself and skipped exactly the dependency and image steps that had
  not finished. The comparison base is now the last commit actually deployed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 23:38:32 +02:00
nexxo 2b70c9226a fix(traffic): release the throttle at month end; close the Livewire bypass
Two from Codex:

- A throttled instance kept its NIC limit into the new month: the new period
  row starts unthrottled, so neither branch of enforce() fired and nothing ever
  took the limit off. A customer would have stayed slow through a month they
  had already paid for.
- PublicSiteGate exempted livewire/* wholesale. That endpoint is shared by the
  console and the portal, so a signed-in customer could keep driving portal
  components — billing included — while the portal was supposed to be offline.
  Operators pass the normal check anyway, which is all the console needed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 23:36:38 +02:00
nexxo c1e81808a7 feat(traffic): meter the monthly allowance, show it, throttle instead of blocking
Customers can now see what they have used and what is left, and the service
slows down rather than stopping when the allowance is gone — a slow Nextcloud
gets a top-up, a dead one gets a cancellation.

- instance_traffic keeps one row per instance per month. Proxmox counters are
  cumulative since the VM last started, so usage is the difference between two
  samples, and a counter that went backwards means a restart, not a refund.
- Outbound is what counts: inbound is free at our providers and egress is what
  Hetzner's 20 TB per server applies to.
- CollectInstanceTraffic samples every 15 minutes, warns once per threshold
  (80/95 %) rather than on every run, and at 100 % limits the VM's NIC via
  Proxmox — released again as soon as the customer tops up or the month rolls
  over.
- The dashboard gets a full-width band (it throttles the service, so it is not
  a tile among tiles) with the top-up offer right next to the warning.
- Bytes are formatted in SI units now: allowances are computed in SI, so
  dividing by 1024 made a 1000 GB plan read as "931 GB".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 23:33:47 +02:00
nexxo 4681b135db feat(deploy): run under a dedicated account instead of root
The installer still needs root for apt and the firewall, but it now creates a
clupilot service account that owns the checkout and runs Docker, maps the
container user to it (HOST_UID/GID), and update.sh refuses to run as root —
root-owned files in the checkout are files the application cannot write.

The account has no password login: docker group membership is root-equivalent
on the host, so it is reachable only via sudo -u or an SSH key.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 23:25:59 +02:00
nexxo 0219e3c987 fix(deploy): recreate the container, keep the root password secret, forget failures
- update.sh recreated the app container only at the very end, so an update that
  changes the PHP runtime would have installed dependencies and migrated inside
  the old one.
- install.sh randomised DB_PASSWORD but left DB_ROOT_PASSWORD at the value
  committed in .env.example — the same root credential on every installation,
  reachable from any container on the compose network.
- Settings cached the fallback after a database failure, so one blip could
  leave a hidden site publicly visible until someone cleared the cache. Only a
  successful read is cached now, proven by a test that drops the table and
  brings it back.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 23:25:06 +02:00
nexxo 773ef4bd5f fix(deploy): migrate behind maintenance mode, resume a failed update, install deps
Codex was right on all three, and the first was a design error on my part: the
checkout is bind-mounted into every container, so 'git merge' swaps the running
code instantly — 'migrations before traffic' was not achievable by ordering.
The updater now goes down → merge → dependencies → migrate → assets → restart →
up, trading a short deliberate outage for never serving code whose schema does
not exist yet. If a step fails the site STAYS down: coming back up with new code
on an old schema is worse than staying dark until someone looks.

It also records the last successfully deployed commit. A run that died halfway
left the checkout ahead, so the next run said 'already up to date' and skipped
the rest forever.

And it installs dependencies when a lockfile moved: vendor/ and node_modules/
live in the bind mount and shadow the image, so rebuilding the image never
updated them — migrations could run against stale packages.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 23:15:15 +02:00
nexxo 834abcec40 feat(deploy): installer and updater for a fresh server
install.sh sets up a bare Debian/Ubuntu server end to end: Docker, git,
WireGuard, clone, generated secrets, stack, migrations, hub keypair, the Owner
account and a closed firewall. Re-runnable: it keeps an existing .env and never
regenerates the hub key, which would disconnect every onboarded host.

update.sh pulls and applies. Migrations run before the new containers take
traffic, the image is rebuilt only when its definition changed, and the queue
workers are restarted — they are long-running processes that otherwise keep the
old classes in memory, which cost us an hour during development.

clupilot:create-admin creates or promotes an Owner, so re-running the installer
fixes a lost role instead of failing on a taken address.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 23:13:24 +02:00
nexxo e2b4cdbac4 feat(site): switch the public website and portal off from the console
While the product is still being built, the marketing site and the customer
portal should not be reachable — but they must stay reachable for us.

- A toggle in the console (site.manage, Owner/Admin) stored in a new
  app_settings table, because this has to be flippable without a deploy.
- Outsiders get a placeholder with 503 + noindex, not 200: a 200 invites search
  engines to index the placeholder as the site's content, which is far harder
  to undo than to prevent.
- Anyone on the management VPN, and any signed-in operator, sees the real site.
  The console, Livewire's endpoint, the Stripe webhook and the health check are
  always reachable — otherwise the switch could only be flipped once.
- robots.txt is generated by the app and follows the switch. It had to stop
  being a static file: nginx short-circuited it and Laravel's stock file said
  'Disallow:' with an empty value, so crawlers were never told anything.
- Settings reads fall back to the default when the table is unavailable. The
  gate reads one on every request, so a deploy running new code before migrate
  would otherwise answer the entire site with a 500.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 23:10:27 +02:00
nexxo 7f60f88542 fix(maintenance): make the window form usable
The action labels wrapped to two lines and crushed the column — now icon
buttons with tooltips, like every other admin table. The form column was too
narrow for its content: the datetime fields silently clipped their own value,
so they are stacked and full width now, with duration chips because typing an
end timestamp by hand is the fiddliest part of the form. Host rows show a real
selected state and a per-datacenter count, and the impact column says
"1 Host · 1 Kunde" instead of "Host(s) · Kunde(n)".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 23:05:33 +02:00
nexxo 7ef1b3e5c8 docs(wireguard): document the ownership model and config storage
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 22:55:14 +02:00
nexxo 17d7dc2ad9 fix(vpn): store the config inside the creation transaction
Encryption and the write ran after the transaction had committed and the peer
had been dispatched to the hub. A failure there left a live access whose config
was never stored — unrecoverable, and the operator would create a second one
not knowing why. Both now happen in the same insert; nothing is dispatched
unless it succeeded.

Re-verified in the browser afterwards: create with storage, wrong password
refused, right password reveals the config.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 22:53:30 +02:00
nexxo 4ffdafc614 fix(vpn): losing the operator roles closes the owner doors by itself
Ownership alone kept download, block and delete reachable for someone whose
roles were removed by any path other than revokeStaff() — a direct role edit,
say. Being staff is now part of ownership, so revocation closes these doors
without depending on whoever removed the role also remembering the tunnel.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 22:50:13 +02:00
nexxo e5f19f8db3 fix(vpn): count downloads in the database; repair the race test
The download counter was read-modify-written, so two concurrent retrievals
recorded one. It is an audit trail — under-reporting is the failure mode it
must not have.

The creation-race test broke when issuing moved into a transaction: its
simulated competitor writes inside our transaction, so the rollback removes
that row too. It now asserts what actually matters (the loser is told, and
creates nothing) and states what a single-connection sqlite test cannot show.
This test was red in the previous commit — my mistake for committing before
reading the run.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 22:48:38 +02:00
nexxo 02750a7c9a fix(vpn): lock the owner row while issuing an access
A revocation committing between the operator check and the insert would find no
peer to remove and still leave the revoked colleague with a brand-new tunnel.
Issuing now runs in a transaction that locks the owner row — the same row
revokeStaff() locks — so the two cannot interleave.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 22:46:23 +02:00
nexxo 3bd3e64a3d fix(vpn): pause polling while a private config is on screen
Every five-second poll re-rendered the page, putting the private key into
another HTTP response — the handoff kept it out of the snapshot but not out of
the responses. Traffic figures can wait the minute it takes to copy a key.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 22:44:31 +02:00
nexxo c2b18ecfbc fix(vpn): drop the previous config before showing a new access
Creating a second access with a supplied key skipped the config branch, so the
first access's private configuration stayed on screen under the new name — and
someone would have handed it out.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 22:42:38 +02:00
nexxo 7e17bc74b2 fix(vpn): give the QR code the specified four-module quiet zone
Two modules is below the minimum, and against a bordered card some readers fail
to locate the symbol at all.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 22:40:54 +02:00
nexxo 04ba9dd2a9 fix(vpn): an open modal stops serving the config once access is revoked
Once revealed, the plaintext sits in the handoff cache for ten minutes, and the
modal read it from the token alone. A former owner could keep pulling the key
out of an open modal after revocation — the exact window purgeSecret() exists to
close. Every request now re-checks the policy and drops the handoff when it no
longer holds.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 22:39:30 +02:00
nexxo db7b8a6ee3 fix(vpn): keep the navigation entry after the capability split
The sidebar still gated the VPN entry on vpn.manage, which the split deleted —
so the page would have vanished from the console for every role, Owner
included. Every operator can hold their own access, so the entry is shown to
all of them and the page itself shows only what the policy allows. Guarded by a
test across all five roles, which is the assertion my rewrite had dropped.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 22:37:35 +02:00
nexxo 371d517261 fix(vpn): dispatch the revocation removal after the transaction commits
A worker could pick the job up mid-transaction, still see the peer as active,
leave it on the hub — and never be asked again, because the committed state is
only a soft delete. A revoked colleague would keep their tunnel until some
later reconciliation noticed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 22:36:11 +02:00
nexxo 1112b4cdec fix(vpn): classify adopted peers as system, not staff; drop duplicate env line
Codex spotted the migration labelling sync-adopted peers as staff with no
owner, which breaks the invariant the same migration introduces. The live sync
had the identical hole — it never set kind at all, so every adopted peer landed
on the 'staff' default. Both now use system for a peer nobody is behind, and
promote it to host once the link is known.

.env.example carried ADMIN_HOSTS twice; phpdotenv keeps the first, so a fresh
checkout would have got the list without localhost and 404'd its own console.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 22:34:18 +02:00
nexxo 0d9b62eb50 feat(vpn): ownership, a Developer role, and password-gated config retrieval
Reworked after a design consultation with Codex, which pushed back on my first
proposal in three useful ways.

Ownership and rights:
- vpn.manage split into vpn.view.all and vpn.manage.all. Seeing an access is
  not managing it, and neither is holding its private key.
- Record-level rules live in VpnPeerPolicy, not in permissions: an access
  belongs to a person, and ownership is what grants sight of it. Every operator
  reaches the page, but sees only their own unless they may see all.
- Issuing an access is not self-service — it reaches the management network, so
  it needs vpn.manage.all even for oneself.
- New Developer role: sees everything, manages nothing. Writing code does not
  imply authority over other people's access.
- kind (staff|host|system) replaces a null user_id that had to mean two
  different things at once.

Config storage, opt-in per access:
- Downloading is owner-only — explicitly NOT for view.all or manage.all. An
  admin who needs access revokes this one and issues their own, which keeps the
  record of who holds what honest.
- The password is asked on EVERY retrieval, rate-limited. Laravel's
  password.confirm keeps a 15-minute session stamp, which would authorise
  unlimited later downloads from an unattended browser.
- Stored under VPN_CONFIG_KEY, not APP_KEY: a leaked application key must not
  also hand over the management network. Purged on revocation and when a staff
  member is revoked — console taken away, tunnel left open was the worst case.
- The plaintext never enters a Livewire snapshot (the page polls every five
  seconds); the component carries an opaque handle instead.

Also: copy button works without HTTPS again (navigator.clipboard only exists in
a secure context, so over http it silently did nothing), plus config download
as a file and a QR code for the mobile apps.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 22:31:54 +02:00
nexxo cad245c5df docs(wireguard): document the VPN console and the invariants behind it
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 22:02:46 +02:00
nexxo d1302e17bc fix(vpn): purging a host no longer leaves a phantom access behind
The FK only nulled host_id, so the adopted peer stayed enabled-but-absent: shown
as "wird angewendet" forever, and re-enabling it would have put the deleted
host's key and address back on the hub. PurgeHost now tombstones that row, which
hands it to the same revocation machinery as everything else.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 22:00:13 +02:00
nexxo c54b718566 fix(vpn): never let an access hijack a host key; name adopted peers correctly
- A host's wg_pubkey may not be in vpn_peers yet, so the duplicate check passed
  and an operator could create an access with it. ApplyVpnPeer would then run
  wg set with a freshly allocated address, rewriting that host's allowed-ip and
  cutting CluPilot's management tunnel to a live machine. Creation now rejects
  keys already held by a host, checked under the allocation lock.
- A sync landing between addPeer() and the step storing wg_pubkey adopted the
  peer as "unknown", and a later sync filled in host_id but left the name, so
  the page showed that host as unknown forever. The name is now filled in when
  the link becomes known — only for sync-adopted rows, so an access an operator
  named keeps its name.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 21:58:03 +02:00
nexxo 28e681b9df fix(vpn): lock every hub mutation; resolve duplicate keys inside the lock
- ConfigureWireguard and RemoveWireguardPeer mutate the hub too, and were not
  taking the lock, so a sync could still read the interface around them and
  write stale state. Both now hold wireguard:hub, which is what makes the
  read/mutation guarantee actually hold.
- The duplicate-key check ran before the allocation lock, so two concurrent
  creations with the same key both passed it and the loser hit the unique index
  as an unhandled 500. The check moved inside the lock, with the index kept as
  a caught backstop for any writer that does not take it — reproduced in a test
  by inserting a colliding row from within the allocation call.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 21:55:57 +02:00
nexxo 8eb9d7ad30 fix(vpn): serialize hub reads against hub mutations
A sync could read the hub snapshot just before ApplyVpnPeer removed a peer and
purged its tombstone, then adopt what it had seen as a fresh enabled access —
restoring a revoked one. Both jobs now hold Cache::lock('wireguard:hub') around
read-decide-mutate, so a reconciliation can never straddle a removal. Retry
removals are dispatched after the lock is released, because the sync queue
driver runs them inline and they take the same lock.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 21:53:13 +02:00
nexxo 9137eae6df fix(vpn): a stale enable job can no longer resurrect a deleted access
If an add job was retried after the access had been revoked and its tombstone
purged, it found no row and trusted its own enabled=true payload — re-adding a
key that no longer had a row anyone could revoke it with. A missing row can only
mean the access is gone, so the captured payload may now remove but never
enable.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 21:50:57 +02:00
nexxo 1b193ba4ea fix(vpn): join the host pipeline's allocation lock; re-authorize on every poll
Two more from Codex:

- Addresses come from one subnet but live in two tables (hosts.wg_ip and
  vpn_peers.allowed_ip), so a concurrent host onboarding and console creation
  could each see the same address as free and both insert — neither unique
  index sees the other. Creation now waits on Cache::lock('wireguard:allocate'),
  the same lock ConfigureWireguard already holds while reserving a host address.

- mount() runs once, so revoking vpn.manage left an open tab polling every five
  seconds and still receiving fresh peer state. Authorization now also runs on
  hydration, which is what the poll goes through.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 21:49:09 +02:00
nexxo 300ea60f19 fix(docker): build assets on start when the manifest is missing
With the Vite dev server off by default, a fresh checkout had no public/build
(gitignored, and the image does not build it), so every @vite page failed with
ViteManifestNotFoundException. The entrypoint now builds once when the manifest
is absent, non-fatally — a broken build must not wedge the container into a
restart loop where the logs are unreachable.

Proven by deleting public/build and restarting: assets rebuilt automatically,
page 200.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 21:45:27 +02:00
nexxo 08670ecbee fix(vpn): stop revoked accesses coming back and stale jobs undoing intent
Two lifecycle holes Codex found in the new VPN management:

1. Delete removed the row before the hub knew. If the removal job was delayed
   or failed, SyncVpnPeers saw a peer it did not recognise and adopted it back
   as a live access — silently restoring what an operator had just revoked, and
   with no row left to revoke it again. Deletion is now a soft-delete
   tombstone: the row survives until the hub confirms the peer is gone, the
   sync retries the removal instead of adopting, and the tombstone is purged
   only once the peer is really absent. It also keeps the key and the tunnel
   address reserved meanwhile, so neither is handed out twice.

2. ApplyVpnPeer applied the state captured at dispatch. A retried block job
   could therefore undo a later unblock, and nothing would repair it — the sync
   only observes state, it never re-applies intent. The job now resolves the
   current desired state from the row and treats its payload as a fallback for
   the case where the row is already gone.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 21:41:03 +02:00
nexxo 71ca0e1394 feat(admin): VPN access management with live peer state
The console can now create, block and remove VPN accesses, and shows who is
actually connected — traffic counters, source endpoint and last handshake.

Design notes:
- The web container has no wg0, so the page never talks to WireGuard. Live
  state is copied in by SyncVpnPeers on the provisioning queue (the only worker
  whose container owns the interface); the page polls the database and merely
  nudges that job, throttled so many open tabs cannot flood the queue.
- enabled (operator intent) and present (observed on the hub) are stored
  separately, so a sync can never quietly undo a block and drift stays visible
  as "wird angewendet".
- The sync adopts peers the host pipeline registered, naming them after their
  host — otherwise "who is on the VPN" would have blind spots.
- Keypairs are generated in PHP via libsodium rather than shelling out to
  wg genkey (no interface in this container, and it keeps generation testable).
  The private key is shown once and never stored.
- allocateIp() now also considers vpn_peers, which would otherwise be handed a
  tunnel address a host already holds.
- vpn.manage is a new capability held by Owner/Admin only, and it hides the nav
  entry too — a VPN access reaches the management network.

Verified end to end against the real hub, not just mocks: created an access in
the browser, connected with the generated config, watched the console flip to
"Verbunden" with real counters, then blocked it and confirmed the peer was
gone from wg0 and the client could no longer handshake.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 21:38:12 +02:00
nexxo a5a261fa37 test: pin APP_URL/ADMIN_HOSTS so the suite does not follow deployment env
Setting the real APP_URL made route() build https://app.dev.clupilot.com/...,
which ADMIN_HOSTS does not list, so 30 admin tests 404'd. The suite must not
depend on operator hostnames.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 21:24:42 +02:00
nexxo 3e82b51f89 fix(ui): make modal cancel buttons work; serve the app over the HTTPS domains
The cancel buttons were dead: wire-elements registers Livewire.on('closeModal'),
but the buttons used Alpine's $dispatch, which fires a DOM event. Livewire
bridges its own events TO the DOM, not back, so nothing ever received them.
Proven in the browser both ways — before: modal stays open after Abbrechen;
after: it closes.

Domain operation behind Zoraxy:
- trustProxies for FOR/PORT/PROTO so Laravel sees https (otherwise it builds
  http:// URLs into an https page). X_FORWARDED_HOST is deliberately NOT
  trusted — the console is gated on the request host, and trusting it would let
  anyone reach /admin through a public domain by forging the header.
- APP_URL + VITE_REVERB_* point at the dev domains (wss via ws.dev).
- Vite dev server is now opt-in (VITE_AUTOSTART): over https the browser cannot
  load assets from http://<ip>:5173. Built assets are the default, and the
  entrypoint clears a stale public/hot, which otherwise 404s every asset.

Verified in the browser over https: login, styled pages, zero console errors,
zero unencrypted requests, /admin reachable only on admin.dev.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 21:24:03 +02:00
nexxo 8edb2a69e5 docs(wireguard): bring the management hub up and prove it end-to-end
The hub lives in queue-provisioning (the only worker on the provisioning queue,
and LocalWireguardHub shells out to wg in-process). Generated the hub keypair,
wrote wg0.conf, and filled CLUPILOT_WG_ENDPOINT/HUB_PUBKEY.

Verified against the running stack rather than mocks: registered a stand-in
Proxmox peer through the app's own addPeer(), got a handshake, opened hub->peer
TCP over the tunnel (what the SSH steps actually need — a handshake alone does
not prove routed TCP), confirmed wg-quick save persisted the peer, then removed
it through removePeer(). wg0 returns after a container restart.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 21:05:11 +02:00
nexxo 7247edf9d8 feat(security): keep the operator console off the public hostnames
Only the marketing site and the customer portal are public; /admin and the
Proxmox hosts stay on the private network. nginx was a single server_name _
catch-all, so /admin was served on all four dev domains.

Two layers:
- nginx denies /admin on the known-public hostnames before PHP is reached
  (denylist, so an allowlist typo cannot lock the operators out).
- ADMIN_HOSTS is now populated, making the app layer a strict allowlist that
  also covers /livewire/update, which nginx cannot attribute to /admin.

Verified live against the running stack: /admin is 404 on www/app/api/ws and on
any unlisted host, 302->login on admin.dev / the private IP / localhost, while
/ and /dashboard stay reachable everywhere.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 21:01:02 +02:00
nexxo ba861ad579 fix(security): match ADMIN_HOSTS case-insensitively; share test helpers
DNS names are case-insensitive and Symfony's getHost() always returns lowercase,
so ADMIN_HOSTS=Admin.Example.com rejected every request and locked operators
out. Normalised in the config AND at the comparison, so the value is safe
whichever route it took in (env, cached config, runtime set).

operator()/admin() moved from RbacTest/HostManagementTest to tests/Pest.php:
they were only loaded when those files happened to be in the run, so a targeted
single-file run died on 'undefined function operator()'.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 20:56:26 +02:00
nexxo f5f4d2a8cd fix(security): close the Livewire bypass of the admin host restriction
Admin actions post to /livewire/update, which a path-scoped guard skips — an
operator session could drive admin components through a PUBLIC hostname despite
ADMIN_HOSTS. The restriction is now registered as Livewire-persistent middleware
and listed on the admin route group, so Livewire re-applies it from the
component snapshot.

Proven by replaying a snapshot taken from the real rendered page: identical
payload -> 404 on a public host, 200 on the allowed host (positive control, so
the test cannot pass for the wrong reason).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 20:50:22 +02:00
nexxo 1f42c05648 feat(security): admin console can be pinned to non-public hostnames
The Proxmox fleet and the operator console must never be publicly reachable. The
primary control is the reverse proxy, but nginx here is a catch-all
(server_name _), so /admin was served on EVERY hostname — a proxy
misconfiguration would expose it. ADMIN_HOSTS pins it; any other host gets 404
(not 403: a public domain must not disclose that a console exists).

Prepended to the  group instead of the admin route group on purpose: route
middleware is reordered by Laravel's priority list, which runs  first — a
guest would then be redirected to /login and learn the console is there. Covered
by a test for exactly that case. Empty ADMIN_HOSTS = unrestricted, so nobody is
locked out by upgrading.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 20:45:54 +02:00
nexxo db3ef1642b chore: drop stray vim swap file
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 20:25:40 +02:00
nexxo 46d821a047 fix(monitoring): cap monitoring retries at the run budget; dependency-free liveness probe
- MONITORING_ATTEMPTS is capped by the run's max_attempts, so a large value can
  no longer burn the retry budget and fail the very provisioning the degradation
  exists to protect (covered by a new test at the budget boundary)
- /health is now dependency-free (200 in ~2ms with Kuma absent, verified: the
  container healthcheck stays healthy); Kuma state is exclusively in /ready

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 20:19:32 +02:00
nexxo d1db23398d fix(kuma-bridge): a rejected login is not-ready (was falsely 'up'); clearer connect vs login errors
Verified with a reachable Kuma and deliberately wrong credentials:
/ready -> 503 'Kuma login rejected: Incorrect username or password.'

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 20:16:24 +02:00
nexxo 353df9f054 fix(kuma-bridge): detect Kuma outages and never trust the cached monitor list
Verified against a real Kuma by stopping it mid-test:
- get_monitors() reads the library's CACHED monitorList event, so it reported
  'up' long after Kuma died. Reachability now does a real HTTP round-trip plus a
  live socket check.
- the cached list could yield an id for a monitor that no longer exists; a match
  is now confirmed with a live get_monitor before it is returned, so CluPilot can
  never record a target that is never checked.
- liveness (/health, always 200 while serving) split from readiness (/ready, 503
  when Kuma is unreachable) — the container healthcheck must not restart a
  healthy bridge just because a dependency is down.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 20:13:34 +02:00
nexxo 82916167cf fix(monitoring): MONITORING_ATTEMPTS is now exact (off-by-one); accept int or dict monitor id
- attempt is 0-based, so compare one-based: ATTEMPTS=2 really means two tries
- bridge accepts add_monitor returning {monitorID}|{id}|int (v1.2.1 returns a
  dict — re-verified: first create on a fresh Kuma is HTTP 200 with the id)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 20:08:07 +02:00
nexxo 5301ce4309 feat(monitoring): Uptime Kuma support via a REST bridge + resilient monitoring policy
Kuma has no write REST API (monitor CRUD is Socket.IO only), so docker/kuma-bridge
translates the exact generic REST contract HttpMonitoringClient already speaks —
no PHP client change needed. Opt-in compose profile 'monitoring'.

Verified end-to-end against a real Uptime Kuma 1.x:
  create -> id, create again -> same id (idempotent), list, status, delete
  and PHP MonitoringClient -> bridge -> Kuma round-trip.
The e2e run caught a real bug: add_monitor takes 'maxretries', not 'retries'.

Monitoring is now observability, not a delivery gate:
- RegisterMonitoring retries MONITORING_ATTEMPTS times on an outage, then
  continues degraded with a visible 'info' event
- RunAcceptanceChecks no longer fails when monitoring isn't green (a fresh Kuma
  monitor legitimately reports 'pending' — confirmed in the e2e run); it records
  the gap instead. MONITORING_REQUIRED=true restores strict gating.
- docs/monitoring-uptime-kuma.md documents setup, failure behaviour, alternatives

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 20:04:58 +02:00
nexxo f06f00c428 chore(config): documented env placeholders + SSH keys from file paths
- .env.example gains a full, commented CluPilot operations block (Stripe, Hetzner
  DNS, Traefik, WireGuard hub, SSH identity, monitoring, prod mail) with a note
  that everything is optional for local dev
- config/provisioning.php: CLUPILOT_SSH_{PUBLIC,PRIVATE}_KEY_PATH read the key
  from a file (a multi-line PEM cannot live in .env), falling back to inline vars
- documents that Uptime Kuma's REST API is read-only (monitor CRUD is Socket.IO),
  so it needs a bridge rather than being drop-in for the generic REST client

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 19:37:25 +02:00
nexxo 32fa1028cc fix(portal): cloud status labels for all instance states; scale storage curve into the quota
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 19:16:16 +02:00
nexxo d05932105d feat(portal): bind the cloud card to the real instance (plan/seats/domain/quota) so the maintenance badge matches it
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 19:14:42 +02:00
nexxo ac3b3a4e11 fix(portal): scope the per-instance maintenance badge to that instance's host
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 19:12:33 +02:00
nexxo a321d963d7 fix(portal): no silent no-ops without a customer; maintenance visible per instance; maintenance form redesign
- ResolvesCustomer trait replaces 5 duplicated customer() lookups; portal actions
  now SAY why nothing happened (operator accounts have no Customer) instead of
  returning silently — the actual cause of 'dead buttons', '0/0 seats' and
  'settings not saving' when signed in as admin
- portal layout: explicit notice for a login without a linked customer
- /cloud: per-instance maintenance badge + window details; seat note 'owner = seat 1'
- maintenance form: sectioned card, hints, placeholder, styled datetime fields,
  grouped host picker with per-datacenter select-all + selection counter

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 19:10:44 +02:00
nexxo 0b2d762b88 fix(auth): scope registration throttle to its own route (per-IP), not all Fortify endpoints
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 18:41:30 +02:00
nexxo 35a86c413c fix(auth): throttle Fortify endpoints (registration had no limiter — signup-spam guard)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 18:38:44 +02:00
nexxo 406f753311 fix(admin): remove is_admin self-heal — RBAC is the only console boundary (no revocation bypass)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 18:35:14 +02:00
nexxo 22cbe9ac39 fix(auth/admin): atomic user+customer signup; Rule::in for country validation (comma-safe)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 18:33:09 +02:00
nexxo 6ca1e3fba5 fix(auth): create + link a Customer on public signup so the portal works pre-purchase
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 18:31:08 +02:00
nexxo 39a1f708a1 fix(auth): reject signup with an existing customer email (account-claim guard)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 18:29:35 +02:00
nexxo 507ab485a0 fix(admin): derive legacy datacenter location allowlist from DB, not client property
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 18:27:41 +02:00
nexxo 3a794b05dd fix(admin): promote only role-less legacy admins; allow editing datacenters with legacy free-form location
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 18:26:09 +02:00
nexxo fd7cba7403 fix(admin): authorize datacenter modal mounts; stale threshold uses step maxDuration
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 18:23:44 +02:00
nexxo 2b73a895c9 fix(admin): don't flag scheduled-backoff runs as stale; edit-datacenter country validation from config
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 18:21:22 +02:00
nexxo 8304f2e7dc fix(admin): self-heal legacy is_admin into Owner (no RBAC bypass); validate datacenter country server-side
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 18:19:24 +02:00
nexxo 88957f8f57 feat(auth): enterprise split login + signup (Fortify registration) via frontend-design
- login/register redesigned as a split brand-panel + clean form (Apple-ish
  enterprise look), cross-linked; brand panel uses accent gradient + tokens
- Fortify registration enabled; /register full-page Livewire + RegisterTest

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 18:16:27 +02:00
nexxo be99f413f7 feat(admin): provisioning liveness — per-run progress bar, last-activity, stale warning
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 18:11:17 +02:00
nexxo b50f97568f fix(admin): no admin lockout (is_admin fallback); centered modal width; datacenter edit modal + country dropdown
- isOperator()/EnsureAdmin/broadcast fall back to is_admin so a legacy admin is
  never locked out by a stale permission cache
- published modal: centered max-w-lg card (dynamic modalWidth class was purged
  by Tailwind → full-width)
- datacenter edit moved to a modal (no row-height jump); location is now a
  country dropdown (config/countries.php) instead of free text

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 18:09:17 +02:00
nexxo 87a81f5f40 fix(admin): read-only send-time guard (never drop mail on retry); document exactly-once residual for real-mail outbox
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 16:40:44 +02:00
nexxo e09965f918 fix(admin): in-flight claim (claimed_at) for exactly-once maintenance send; scope permission rollback
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 16:38:44 +02:00
nexxo 28221549d1 fix(admin): defer maintenance sent_at to real delivery; read-only send-time dedup (no lost mail)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 16:35:52 +02:00
nexxo 0cc5be5479 fix(admin): atomic send-time delivery claim so backlog duplicates ship exactly once
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 16:34:02 +02:00
nexxo 5b85806605 fix(admin): catch racing ledger insert and re-fetch (idempotent under concurrency)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 16:31:43 +02:00
nexxo 72731112f4 fix(admin): atomically claim maintenance notification retries under a row lock
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 16:30:17 +02:00
nexxo 8bb93137a3 refactor(admin): extract MaintenanceNotifier; atomic provisioning retry; catch-up cancellation for race-delivered announcements
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 16:28:02 +02:00
nexxo ae1f78f534 fix(admin): shown-once temp password for invited staff; suppress announcements for cancelled windows at send time
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 16:23:21 +02:00
nexxo 718e8568c1 fix(admin): require host on any maintenance save; cancel-notify only delivered announcements
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 16:19:44 +02:00
nexxo 62b4d85ef7 fix(admin): release notification claim on dispatch failure so retries aren't blocked
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 16:17:44 +02:00
nexxo ab5ba2b169 fix(admin): dedup pending maintenance notifications (updated_at claim + staleness threshold)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 16:16:15 +02:00
nexxo 493b81aadf fix(admin): confirm maintenance delivery via MessageSent (retryable until sent); guard resend/cancel on derived state; fix flaky non-unique host wg_ip
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 16:14:01 +02:00
nexxo 52fa7a34a2 feat(admin): email affected customers when maintenance is cancelled (ledger-guarded)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 16:07:21 +02:00
nexxo 6593dae946 fix(admin): retry path for missed maintenance emails; lock Owner role to serialize last-owner guard
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 16:04:31 +02:00
nexxo bc2e95ba18 fix(admin): block role escalation of non-staff; validate maintenance host ids atomically; rollback ledger on dispatch failure
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 16:01:45 +02:00
nexxo e5c74c6bdd fix(admin): reject customer email on operator account update; do not claim mail delivery on dispatch
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 15:58:24 +02:00
nexxo d68d8e1f25 fix(rbac/mail): migrate legacy is_admin users to Owner in-migration; send maintenance mail in customer locale
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 15:56:22 +02:00
nexxo eff8c08258 feat(admin): maintenance windows — schedule once, assign many hosts, notify
- maintenance_windows + host pivot + notification ledger; derived state (never
  stored); affected-customer + banner queries live off instances
- admin /maintenance: create draft/publish, multi-host select, impact counts,
  cancel; capability-gated (maintenance.manage)
- publish emails affected customers once (queued Mailable, ledger-idempotent)
- customer portal maintenance banner (upcoming <=72h + active) merged per window

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 15:53:50 +02:00
nexxo c5d60340b7 feat(admin): staff RBAC (spatie) + admin settings page
- 5 operator roles (Owner/Admin/Support/Billing/Read-only) seeded via migration
  with a capability catalogue; app checks capabilities via Gate, never role names
- every mutating admin action authorizes server-side (hosts/datacenters/customers/
  impersonate/provisioning); is_admin reads migrated to console.view / isOperator()
- admin /settings: own account + Owner-only staff invite/role/revoke with
  last-owner, self-role and customer-collision guards (transactional)
- sidebar 'zum Kundenportal' link replaced with Settings

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 15:47:42 +02:00
nexxo 4336c3bb3f feat(brand): CluPilot logo mark + SVG favicon across layouts
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 15:31:33 +02:00
nexxo 08f61b43f5 fix(ui): modal above sidebar (z-index); datacenter edit buttons stay right; toast slides from bottom; provisioning retry action
- modal wrapper z-10 -> z-[70] (was behind the z-40 sidebar); toasts slide up/down
- datacenter inline-edit colspan fix so save/cancel stay right-aligned
- admin provisioning: retry a failed run (table + panel), mirrors host-detail retry

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 15:29:37 +02:00
nexxo 99081284a4 fix(portal): delete old logo only after the branding update commits
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 15:04:27 +02:00
nexxo f6efa4f200 fix(portal): allow closure after failed provisioning; serialize last-owner guard
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 15:02:37 +02:00
nexxo 8bcb5ec268 fix(admin): enforce hosts.datacenter FK (restrictOnDelete) — no deactivation side-effect
The DB now refuses to orphan a host; datacenter delete pre-checks for a friendly
message and catches the constraint as the race backstop.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 15:00:38 +02:00
nexxo 249efa0553 fix: serialize datacenter delete (lock+deactivate+recheck); store logo before deleting old
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 14:58:07 +02:00
nexxo 01383e7e5e fix(portal): race-safe owner-seat initialization (firstOrCreate + catch)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 14:55:09 +02:00
nexxo 3bf0dfb548 fix: seat limit + suspend guard per Codex; harden factory email uniqueness
- seat limit follows the active/cancelling package, not a newer inactive record
- suspend/reactivate toggle refuses closed accounts (terminal); closed shown in list
- factory emails use a large unique numeric space (safeEmail pool could collide
  late in a full suite run)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 14:53:15 +02:00
nexxo cbbb523f9c fix(portal): cancel the active instance explicitly, not just the newest record
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 14:48:57 +02:00
nexxo c011a11b48 fix(portal): atomic seat-limit (row lock) + billing-day-anchored cancellation date
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 14:47:04 +02:00
nexxo 074b0c041b fix(portal): enforce customer lifecycle per Codex review
- EnsureCustomerActive middleware: suspended/closed customers lose portal
  access (admins + active impersonation exempt)
- cancellation: reject non-active instances; service-end anchored on the
  subscription start date, not calendar month-end

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 14:45:03 +02:00
nexxo 3b288486cd feat(portal): seats management — invite/role/revoke against plan limit
- seats table + model; Users page manages team members (owner auto-created)
- invite respects plan seat allowance + dedupe; role change + revoke guarded
  so the last owner can never be removed or demoted

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 14:40:18 +02:00
nexxo df10448e5d feat(portal): settings page — company profile, branding (logo+colors), cancel package, close account
- customers gain profile + branding + closed_at; instances gain cancellation
  fields; branding resolver (NULL -> CluPilot defaults) snapshotted into the
  provisioning run context
- cancel package: term-end, irreversible, typed-confirm modal (R5)
- close account: guarded (no active package), typed-confirm modal (R5)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 14:36:52 +02:00
nexxo ef110b06db feat(admin): host reserve edit + maintenance drain; customer suspend/reactivate
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 14:30:21 +02:00
nexxo d3d686e575 feat(admin): scalable host list (search/filter/health) + host detail redesign
- host list: search + datacenter/status filters, dense table with heartbeat
  health dot, instance count, capacity meter
- host detail: health hero (last_seen), storage/compute breakdown, technical
  facts, hosted-instances table
- Host model: usedPct() + healthState() helpers

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 14:27:53 +02:00
nexxo b44d25404d test: widen DatacenterFactory code space (fix faker unique() pool exhaustion)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 14:23:49 +02:00
nexxo 54d9a05235 feat(admin): datacenter edit+delete (guarded); MRR chart fills card; host-load as meters
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 14:22:15 +02:00
nexxo e3ab3cb501 feat(portal): service framing + gradient line charts + billing button fix
- plans gain seats + performance class; customer views show storage/seats/
  performance/features, no raw vCPU/RAM (admin keeps specs)
- billing: scoped wire:target + wire:key so one purchase no longer spins all
- chart island: line-colour->transparent gradient for filled line datasets;
  MRR / backups / invoices bar charts converted to gradient line

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 14:18:08 +02:00
nexxo a3423a2cee docs: Phase D spec — service framing, redesign & CRUD completeness
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 14:13:23 +02:00
nexxo 80348d6a3f fix: unique customer↔user link + reject inactive datacenters on host create
- customers.user_id unique so impersonation/billing can't cross customers
- HostCreate validates datacenter is active (exists rule + active=1)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 13:56:07 +02:00
nexxo b44e224bee fix: backfill datacenters on migrate; resolve billing customer by user link
- migration seeds fsn/hel + any host-referenced code so existing installs
  keep selectable datacenters after upgrade
- Billing::customer() resolves via user_id, email only as legacy fallback

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 13:53:06 +02:00
nexxo 4e86e135a6 fix(admin): harden impersonation + datacenter code per Codex review
- normalize datacenter code before uniqueness validation
- ensureUser: race-safe create + refuse linking admin accounts
- impersonate start/leave are POST (CSRF-protected)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 13:50:16 +02:00
nexxo 879697c6ea feat(admin): impersonate customer portal — session login + return banner
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 13:46:48 +02:00
nexxo 515f26234e feat(portal): billing page — current plan, upgrades, extra storage, add-ons
New /billing page + nav; plans gained price_cents, storage_addon + addons
catalogue in config. Purchases create a pending Order intent (fulfillment
mocked). Dashboard storage upsell links here. DE+EN. 6 tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 13:36:52 +02:00
nexxo eabcb98f91 feat(admin): compact redesign of /admin/provisioning current-run panel
Progress bar + X/total, highlighted current step, faint next step, failure
inline — replaces the long repetitive per-step 'done' list. Tactical-Terminal
tokens, DE+EN.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 13:31:49 +02:00
nexxo 7c1a153446 fix(ui): fixed sidebars (admin + portal) + storage banner shimmer
App-shell layout: sidebar is a full-height fixed column on lg, only main
scrolls. Re-added the left->right shimmer sweep on the dashboard storage
banner (reduced-motion safe, token colours).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 13:28:05 +02:00
nexxo bc278c8fa1 feat(admin): datacenters management (create + select)
datacenters table/model + admin CRUD (/admin/datacenters, nav). HostCreate
picks from active datacenters (validated); hosts show the datacenter code.
Seeded fsn/hel. 5 tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 13:25:17 +02:00
nexxo 5fb4e637fc docs: panel improvements (phase C) design spec
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 13:20:55 +02:00
nexxo d7c2edb1df fix(engine-b): idempotent local DNS record (firstOrCreate on record_id)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 13:08:13 +02:00
nexxo 96aceeeaa1 fix(engine-b): stop customer dashboard polling once provisioning failed
Show the failure state but only poll while pending/running/waiting.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 13:06:31 +02:00
nexxo 66e8a4bbf6 fix(engine-b): clean re-clone on lost task ref; idempotent firewall rules
- Clone recovery: once the lock clears but the task ref is lost, destroy the
  possibly-incomplete VM and re-clone instead of advancing onto it.
- applyFirewall clears existing rules before adding, so retries don't accumulate
  duplicate Proxmox firewall entries.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 13:04:48 +02:00
nexxo 9162446b1f fix(engine-b): acceptance gate verifies real health, not just breadcrumbs
- Nextcloud: parse occ status JSON (installed + not in maintenance).
- Admin: query occ user:info for the account.
- Monitoring: MonitoringClient::isHealthy checks the provider, not the local row.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 13:00:49 +02:00
nexxo 491800e09c fix(engine-b): grant VM.Backup to the Proxmox automation role
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 12:57:11 +02:00
nexxo 157496f0c5 fix(engine-b): write Traefik route to the serving host over SSH, not locally
TraefikWriter now targets the host that serves the traffic (DNS points at it):
SshTraefikWriter SSHes in and writes the file-provider YAML there. Interface
takes the traffic host + guest backend.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 12:55:24 +02:00
nexxo 483a79a822 fix(engine-b): treat unreachable cert endpoint as not-ready (poll, not retry-burn)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 12:52:37 +02:00
nexxo 03c4b6508f fix(engine-b): Proxmox agent exec repeated command fields; Traefik write throws on failure
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 12:50:57 +02:00
nexxo 8825f9abfe fix(engine-b): Proxmox config uses PUT; guest-agent exec is form-encoded
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 12:48:58 +02:00
nexxo 778bb7f117 fix(engine-b): always scrub credentials once delivered; idempotent monitoring lookup
- CompleteProvisioning scrubs admin_password whenever credentials_sent exists,
  so a crash between recording and scrubbing can't leave the password in context.
- HttpMonitoringClient looks up an existing monitor by URL before creating one,
  so a retry after a crashed POST doesn't create a duplicate external monitor.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 12:46:34 +02:00
nexxo 661b52dbe0 fix(engine-b): clone recovery waits for lock; register rows before breadcrumb
- Clone recovery polls until the VM lock clears instead of advancing onto a
  possibly-incomplete clone.
- RegisterBackup/RegisterMonitoring create the local row BEFORE the run-resource
  breadcrumb, so a crash between them can't leave the row permanently missing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 12:44:22 +02:00
nexxo 3c5a18b5d7 fix(engine-b): apply plan CPU/RAM; idempotent backup + monitoring registration
- ConfigureCloudInit sets cores/memory from the plan (not the template default).
- RegisterBackup uses a deterministic Proxmox job id (ignore-exists) + firstOrCreate.
- RegisterMonitoring is idempotent by URL + firstOrCreate — no duplicate schedules
  or monitors after a crash/retry.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 12:42:10 +02:00
nexxo 5cf1964d7d fix(engine-b): default-deny firewall, disk-based capacity, synchronous credential mail
- applyFirewall sets policy_in=DROP so only 80/443 are exposed.
- Capacity/placement account for disk_gb (the real VM allocation), not the
  smaller Nextcloud user quota — no systematic overcommit.
- CloudReady sent synchronously again; the step retries on mail failure with the
  password preserved (no lost credential in a failed queue job).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 12:39:15 +02:00
nexxo c79874ccfe fix(engine-b): keep admin password ciphertext in the queued mail; valid backup schedule
- CloudReady receives the ENCRYPTED password (decrypts only when rendering), so
  the queued payload never holds plaintext.
- RegisterBackup uses a valid Proxmox calendar expression (02:00).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 12:35:49 +02:00
nexxo b4cf94ee1a fix(engine-b): real backup/monitoring registration + OC_PASS scoping
- CreateCustomerAdmin puts OC_PASS on the docker invocation (survives the &&).
- RegisterBackup creates a real vzdump job via ProxmoxClient::createBackupJob.
- RegisterMonitoring registers via a new MonitoringClient service (interface +
  fake + http). Both persist the external id [E] before the breadcrumb, so
  acceptance reflects a real registration, not a fabricated row.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 12:33:45 +02:00
nexxo 64e3ca421d fix(engine-b): async stripe payments, capacity honesty, durable credential mail
- Accept checkout.session.async_payment_succeeded (async methods) and dedupe on
  the checkout session id, not the event id.
- committedGb still counts a failed instance while its VM exists (has vmid);
  releases quota only when no VM was created — no overcommit vs no leak.
- CloudReady is queued (durable). Credential delivery is documented at-least-once
  (a rare duplicate welcome email beats a lost credential; exactly-once = v1.1 outbox).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 12:29:47 +02:00
nexxo e0e4c4e18d fix(engine-b): run guest-agent commands through /bin/sh -c
The Proxmox guest agent execs a program directly; our compound commands
(cd/&&/pipes/redirects/env) need an explicit shell.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 12:25:24 +02:00
nexxo f43ade79b0 fix(engine-b): address Codex round 6 (crash-window idempotency)
- Clone: recover via vmExists() when the task ref was lost, instead of
  re-cloning the reserved vmid (which Proxmox rejects).
- Deploy: persist the DB password ENCRYPTED before compose up and reuse it on
  retry, so a crash can't regenerate a mismatching credential.
- Complete: gate credential delivery on a credentials_sent breadcrumb so a
  retry after a crash doesn't re-send the email.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 12:23:28 +02:00
nexxo e2b8f25e78 fix(engine-b): address Codex round 5 (capacity release, activate-last, guest-ip poll, email unique)
- Failed run releases the instance (status=failed) so its quota stops counting
  against host capacity.
- CompleteProvisioning activates instance+order only AFTER onboarding tasks +
  credential delivery succeed.
- ConfigureNetwork polls until the guest has an address (no wrong Traefik route).
- customers.email unique + race-safe customer resolution in the intake action.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 12:19:31 +02:00
nexxo c56aa14f9d fix(engine): don't reset started_at on poll so step deadlines accumulate
RunRunner::onPoll no longer resets started_at, so a poll step's own deadline
(WaitForGuestAgent 270s, ConfigureDnsAndTls cert 840s) actually fires instead
of resetting every poll. maxDuration raised above each own-deadline so the
step's fail wins over the generic timeout. Shared core fix (A + B).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 12:15:26 +02:00
nexxo fffcecb152 fix(engine-b): address Codex round 3 (webhook email/replay/rotation, db secret)
- Require a real customer email (no unknown@ fallback merging customers).
- Stripe signature: enforce 5-min replay tolerance + accept any rotating v1.
- DeployApplicationStack no longer persists the DB password in the run context.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 12:12:24 +02:00
nexxo e6bd6c9354 fix(engine-b): address Codex round 2 (fail-closed webhook, placement lock, clone/admin idempotency)
- Stripe webhook fails closed when the signing secret is missing (outside local/testing).
- ReserveResources places + creates the instance under a per-datacenter lock so
  concurrent orders can't overcommit a host.
- CloneVirtualMachine reserves the vmid + breadcrumb BEFORE cloning, so a crash
  can't mint a new vmid and orphan the first clone.
- CreateCustomerAdmin checks user existence and resets the password instead of
  re-running user:add on retry.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 12:09:21 +02:00
nexxo 42fa92cf20 fix(engine-b): address Codex (paid-only stripe, guest routing, absolute disk)
- Stripe: only checkout.session.completed with payment_status=paid (dedupes the
  paired payment_intent event and blocks unpaid async sessions).
- Capture the guest IP (ConfigureNetwork) and point Traefik at the VM, not the
  Proxmox host, so ACME/HTTP-01 can reach Nextcloud.
- Resize the disk to an absolute target (not '+…') so a retry can't double it.
(Codex #4 timeout was a false positive — started_at resets per step.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 12:05:31 +02:00
nexxo 3e19778046 chore(engine-b): provisioning step/mail i18n (DE+EN) + demo seed data
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 11:59:06 +02:00
nexxo a9fbb395f9 feat(engine-b): live provisioning progress (admin console + customer dashboard)
Admin /admin/provisioning bound to real runs+steps (poll+admin.runs). Embedded
CustomerProvisioning card shows the logged-in customer their own run live
(per-customer StepAdvanced channel, email-bridged authz). Shared BuildsRunSteps.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 11:57:35 +02:00
nexxo 65ba3d6588 feat(engine-b): Stripe webhook -> idempotent customer provisioning intake
Signed webhook (HMAC verify when secret set), CSRF-exempt route. Paid checkout
creates customer+order+run (stripe_event_id unique => duplicate webhooks start
one run) and dispatches. 5 tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 11:52:46 +02:00
nexxo 446da58061 feat(engine-b): 15-step customer pipeline + DNS/Traefik services
validate->reserve(placement)->clone->cloudinit->start->guestagent->network->
deploy->nextcloud->admin->dns/tls->backup->monitoring->acceptance->complete.
HetznerDnsClient + TraefikWriter (interface+fake+real), CloudReady notification,
hosts.node (set by RegisterCapacity). Secrets transient/encrypted, never
plaintext. 21 tests incl. mocked end-to-end + crash idempotency.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 11:50:26 +02:00
nexxo e5aeb3989e feat(engine-b): ProxmoxClient VM-lifecycle extension (interface + fake + http)
nextVmid/cloneVm/setCloudInit/resizeDisk/startVm/vmStatus/guestAgentPing/
guestExec/taskStatus/applyFirewall. Fake is deterministic with configurable
task/guest failure hooks; Http polls UPID + guest exec-status.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 11:41:48 +02:00
nexxo 770a6cf7cd feat(engine-b): CustomerStep base + shared resource trait + config
ManagesRunResources trait (host+customer), CustomerStep base (order/instance/
plan/guest helpers), config plans + 15-step customer pipeline + dns/traefik.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 11:39:56 +02:00
nexxo 406251dfde feat(engine-b): customer domain models + migrations
customers, orders (ProvisioningSubject, stripe_event_id unique), instances
(subdomain unique, encrypted nc_admin_ref), dns_records, backups,
monitoring_targets, onboarding_tasks. Host capacity: committedGb/availableGb +
datacenter placement. 7 tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 11:38:01 +02:00
nexxo b9742f61ee docs: customer pipeline (Subsystem B) implementation plan
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 11:35:28 +02:00
nexxo 5e6d6b11b6 docs: engine customer-pipeline spec (the missing Section 3, aligned to the built core)
Detailed build contract for Subsystem B (15 customer-provisioning steps),
written against the real Subsystem-A contracts: ProvisioningStep/StepResult
(advance/retry/poll/fail), RunResource idempotency breadcrumbs, ProvisioningRun
context, the config/provisioning.php pipeline registry, and the read-only
ProxmoxClient it must extend. Covers new models (customers/orders/instances/…),
ProxmoxClient VM-lifecycle additions, Hetzner-DNS + Traefik services, Stripe
idempotent intake, live progress binding to the existing admin/customer views,
build order and DoD. Unblocks B.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 11:27:28 +02:00
nexxo c7fb1ce56d fix(engine): dispatch run continuation after releasing the lock
Prevents a second worker from consuming the follow-up job and bailing on the
still-held run lock, which would stall the run until the next scheduler tick.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 11:09:24 +02:00
nexxo 294c9aef2f fix(engine): store wg_pubkey right after addPeer so failed peers are cleanable
Keeps the wg_peer resource gated on a verified handshake (idempotency) while
ensuring PurgeHost can always remove the hub peer, even on terminal failure.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 11:07:41 +02:00
nexxo 9e664654a2 fix(engine): record wg peer only after verified handshake; async host purge
- ConfigureWireguard checks setup command results and only records the wg_peer
  resource after the handshake verifies, so a transient setup failure re-runs
  the full setup instead of getting stuck on the idempotent path.
- Host removal now deactivates immediately and queues PurgeHost, which deletes
  runs under the runner lock (waiting out a long step) — no 15s LockTimeout error.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 11:06:13 +02:00
nexxo a6a41719bb fix(engine): long SSH command timeout; wg peer removal on privileged worker
- PhpseclibRemoteShell sets a command timeout (default 2000s) so apt
  full-upgrade/install don't hit phpseclib's ~10s default and fail.
- Host removal dispatches RemoveWireguardPeer to the provisioning queue, which
  runs in the worker that owns wg0 (the web container can't manage WireGuard).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 11:01:02 +02:00
nexxo 5979470dda fix(engine): install wireguard-tools + bring up wg0; lock-coordinate host removal
- Image installs wireguard-tools/iproute2; the provisioning worker brings up
  wg0 before queue:work so LocalWireguardHub can manage peers.
- ConfirmRemoveHost deletes each run under its run:<uuid> lock so an in-flight
  worker can't keep mutating the server or write to a deleted run.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 10:57:51 +02:00
nexxo 93191f5de9 fix(engine): fail on unresolvable pipeline step; make worker the WG hub
- RunRunner catches step-resolution errors (removed/renamed pipeline) and
  fails the run terminally instead of looping forever every tick.
- queue-provisioning worker gains NET_ADMIN + tun + persistent wireguard
  volume + published UDP port so LocalWireguardHub can manage wg0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 10:53:40 +02:00
nexxo 00b8897b9d fix(engine): compute default WireGuard hub IP from the CIDR network
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 10:49:43 +02:00
nexxo 8a392bfd06 fix(engine): parse WireGuard CIDR for address allocation (any prefix length)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 10:48:16 +02:00
nexxo b65fe69088 fix(engine): drop wg peer on host removal; clear reboot state on deadline fail
- Removing a host now removes its WireGuard hub peer so a freed wg_ip can't
  route to the removed server via a stale peer.
- RebootIntoPveKernel clears reboot_issued/deadline when it times out, so the
  manual retry action actually re-issues a reboot instead of failing instantly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 10:46:11 +02:00
nexxo 91e308efb0 fix(engine): one host per public IP (unique constraint + validation)
Prevents two onboarding runs from fighting over the same physical server.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 10:43:30 +02:00
nexxo 48ed5e7d34 fix(engine): fail if the WireGuard peer can't be persisted (wg-quick save)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 10:41:11 +02:00
nexxo f240401a42 fix(engine): honour configured WireGuard prefix length in wg0.conf
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 10:39:46 +02:00
nexxo 18d1ed2424 fix(engine): derive WireGuard hub IP from the configured subnet
hub_ip is now defined in config (default: subnet .1), so a custom
CLUPILOT_WG_SUBNET no longer pings the wrong handshake target.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 10:38:17 +02:00
nexxo ced7e6103d fix(engine): durable queueing for long provisioning steps
- Dedicated 'provisioning' queue connection (retry_after 2400) + worker
  (--timeout=2100); AdvanceRunJob tries=1, timeout=2100 on that queue.
- Run lock TTL raised to 2100s so a long step can't be run concurrently by a
  duplicate tick-dispatched job.
- StartHostOnboarding creates host+run in a transaction, dispatches after commit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 10:36:49 +02:00
nexxo fbe87d99d5 fix(engine): honour backoff timing + don't double-count Proxmox storage
- RunRunner returns early for a waiting run whose next_attempt_at is in the
  future, so stale/duplicate jobs can't bypass backoff.
- RegisterCapacity takes the largest VM-capable datastore instead of summing
  overlapping pools (local + local-lvm), preventing placement overcommit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 10:32:14 +02:00
nexxo 8a2a2ee695 fix(engine): reset step timer on retry so timed-out steps recover
A retried step (manual or after a timeout) now gets a fresh started_at, so
RunRunner no longer immediately re-detects the timeout and burns the retry
budget without executing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 10:29:11 +02:00
nexxo 62c4412623 fix(engine): address Codex round 2 (poll budget, host error state, wg race)
- StepResult::poll — polling steps (reboot) wait without consuming the retry
  budget; the step owns its deadline. Reboot maxDuration > deadline.
- Failed runs move a Host subject to 'error' via ProvisioningSubject hook
  (no host stuck 'onboarding').
- ConfigureWireguard allocates + reserves the wg_ip under a global lock;
  unique index on hosts.wg_ip as a backstop against duplicate addresses.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 10:26:37 +02:00
nexxo 65717bd3bd fix(engine): address Codex review (auth token bootstrap, tunnel recheck, ssh)
- CreateAutomationToken now bootstraps the pveum role/user/token over the
  authenticated SSH session (a fresh host has no API token yet); ProxmoxClient
  is read-only in A.
- ConfigureWireguard re-verifies the handshake on the idempotent replay path,
  never advancing over a dead tunnel.
- PhpseclibRemoteShell treats a missing SSH exit status as failure (255).
- connectWithKey verifies the pinned host key fingerprint on later logins.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 10:20:59 +02:00
nexxo 36a564d5c8 chore(seed): demo Proxmox hosts for the operator console (local/testing)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 10:15:12 +02:00
nexxo 8d12a40a42 feat(admin): host onboarding UI (add / live stepper / retry / remove)
Real hosts list, add-host form (StartHostOnboarding), host detail with live
progress stepper (Reverb + wire:poll fallback), retry failed run, remove via
wire-elements/modal (deregister only). DE+EN. 8 tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 10:12:41 +02:00
nexxo ac3d17cd6a feat(engine): 11-step host onboarding pipeline (SSH -> WG -> Proxmox)
Idempotent steps: validate, ssh-trust (deploy key + scrub password),
prepare base, wireguard (hub peer), install proxmox-ve, reboot-into-pve
(retry-poll), configure, automation token, verify api, register capacity,
complete. StartHostOnboarding action. 22 tests incl. mocked end-to-end +
crash idempotency.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 10:04:39 +02:00
nexxo c06ca0ae5d feat(engine): SSH / WireGuard / Proxmox service layer
RemoteShell (phpseclib + fake), WireguardHub (local wg + fake), ProxmoxClient
(REST + fake). Interfaces bound in AppServiceProvider; tests swap fakes via
fakeServices() helper.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 09:58:02 +02:00
nexxo 4f724d57c9 feat(engine): orchestrator core (state machine + tick + lock)
StepResult, ProvisioningStep contract, PipelineRegistry, RunRunner (per-run
lock, advance/retry/fail, backoff, timeout, append-only events + Reverb
StepAdvanced), AdvanceRunJob (provisioning queue), minutely Tick, admin.runs
channel. 21 tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 09:54:52 +02:00
nexxo 11cdcdd4da feat(engine): core provisioning data model + hosts
hosts, provisioning_runs (polymorphic), append-only provisioning_step_events,
run_resources (idempotency breadcrumbs). Models with encrypted api_token_ref,
json context helpers, UUID routing (R11).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 09:49:43 +02:00
nexxo 7ae81d8127 docs: host-onboarding implementation plan
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 09:46:58 +02:00
nexxo 772e9d35ff docs: host-onboarding (Subsystem A) design spec
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 09:44:11 +02:00
nexxo bf5813057b docs: provisioning engine v1.0 build handoff (standalone fleet + cluster-per-DC target)
Focused TDD build plan for the engine: topology decision baked in (Option 1
standalone fleet as the software model, cluster-per-datacenter as the growth
target — hosts get datacenter + nullable cluster fields, placement filters by
datacenter). Covers data model, DB-state-machine orchestrator, 15 customer steps,
new ProxmoxClient, Stripe idempotent intake, and wiring the existing admin/customer
progress views to real data via Reverb. References the state handoff for workflow.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 09:31:51 +02:00
nexxo 49b5b7a523 docs: state/continuation handoff (2026-07-25)
Full project state for a fresh session: env facts, stack, docker workflow, what's
built (landing + customer portal + admin console), verification workflow (pest /
R12 puppeteer / R15 codex), hard-won gotchas, open items, and the recommended next
block (provisioning engine v1.0).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 09:16:17 +02:00
nexxo b032d8808b feat(landing): public marketing homepage at / (+ legal placeholders)
Public www landing page (your design) as a self-contained Blade view at / — own
CSS/JS, system fonts (no CDN, R14), hero, marquee, typewriter, product mock with
scroll-zoom, security section, honest comparison, pricing gallery, FAQ, CTA. It is
intentionally outside the app token/component system (marketing page, not control
panel). Wired a sign-in link to route('login').

Robustness/a11y hardening from the Codex (R15) loop:
- reduced-motion: pin hero/reveal elements to their visible end state.
- <noscript> fallback so reveal content is visible without JS.
- login link kept visible on mobile (moved out of the hidden nav group).
- stats render their real values in HTML (correct without JS).
- footer legal links now resolve to real /legal/* routes (placeholder pages).
- reconciled contradictory data-migration pricing (add-on from €390).

48 Pest tests green; R12 browser: landing 0 console errors. Codex (R15) clean.

NOTE: the /legal/* pages (Impressum, Datenschutz, AGB) are placeholders — real
legal content must be supplied before this homepage goes public.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 08:59:39 +02:00
nexxo 1aa7535fc4 feat(admin): dark Tactical-Terminal operator console
Separate admin console at /admin, gated to is_admin users (EnsureAdmin middleware
+ migration/seed; a plain customer user is seeded to prove the gate). Distinct dark
aesthetic achieved purely by token scoping: .theme-admin overrides every CSS design
token to a dark graphite / signal-orange palette, so ALL shared components (button,
card, badge, table, stat, chart) render dark with zero new markup (R3). Chart island
now reads tokens from its own element, so charts are theme-aware.

Sections (each full-page class-based Livewire, English routes R13, localized DE/EN):
- Overview: fleet KPIs, fleet-growth line, host-load bars, MRR bars, active runs, alerts.
- Customers: table + plan doughnut. Instances: fleet table (vmid/host/storage).
- Hosts: capacity cards (storage/CPU bars). Provisioning: runs table + live stepper.
- Revenue: MRR/ARR/ARPU/churn KPIs, MRR line, plan doughnut, recent payments.
- Locale-aware month labels/currency (Carbon/Number).

18 new Pest tests (guest redirect / non-admin 403 / admin render per section) → 44
green. R12 browser: all six admin pages HTTP 200, ZERO console errors (Chart.js dark).
Codex (R15) — clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 08:35:03 +02:00
nexxo c4cff8f67c feat(portal): full sidebar — Cloud, Users, Backups, Invoices, Support
Each sidebar tab is now a full-page class-based Livewire component (R1/R2) with an
English route (R13), localized DE/EN (R16), Chart.js islands and token styling:
- Cloud: instance details + specs + storage-over-time line chart + actions.
- Users: user table (roles/groups/status badges) + users-by-group doughnut.
- Backups: 14-day size bar chart + backup history table + restore.
- Invoices: invoice table + next-charge card + monthly-spend bar chart (locale-
  aware month labels, Number::currency amounts).
- Support: contact cards + tickets table + FAQ accordion (Alpine).
- Sidebar links all tabs with routeIs() active state; global toast in app shell.
- All fixture dates/numbers locale-aware (Carbon isoFormat / Number::format).

12 new Pest tests (guard + render per tab) → 26 green. R12 browser: all six tabs
HTTP 200 with ZERO console errors (Chart.js clean). Codex (R15) — clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 08:08:33 +02:00
nexxo 499adaff00 feat(dashboard): rich overview with Chart.js (customer-portal template)
- Chart.js as an Alpine island (x-ui.chart): configs are PHP arrays; colours use
  token: strings resolved from CSS design tokens at runtime (R3). Respects
  reduced-motion; IBM Plex chart defaults.
- Tokens: soft --radius-xl (20px), --success-bright for dots/rings/charts.
  Staggered 'rise' reveal keyframe. Global toast in the app shell.
- Overview rebuilt to the customer template: storage doughnut ring, availability
  sparkline, KPI tiles, upsell, cloud card, interactive onboarding checklist
  (Alpine), backups, modules (Lucide icons — no emoji, R9), activity feed.
- Locale-aware fixture display: Carbon isoFormat dates + Number::format sizes so
  the EN dashboard is not mixed-language (R16).
- 14 Pest tests green; R12 browser: /dashboard 0 console errors with Chart.js;
  R7 no overflow at 375/768/1280. Reviewed with Codex (R15) — clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 07:59:19 +02:00
nexxo 2b08b072fe fix(docker): stop injecting .env as real env vars (test isolation)
env_file: .env exported the dev DB/cache config as real container env vars,
which overrode phpunit's forced test env — so the Pest suite ran RefreshDatabase
against the dev MariaDB and wiped it on every run. Laravel already reads .env
from the bind mount; only vite needs VITE_HMR_HOST/VITE_PORT at process level,
now injected explicitly. Tests now use sqlite :memory: and never touch dev data.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 01:28:51 +02:00
nexxo 254a7d46a0 feat(portal): Fortify auth + Login/2FA/Dashboard + component kit
Backend (Fortify):
- laravel/fortify with TOTP two-factor + recovery codes; User uses
  TwoFactorAuthenticatable; 2FA credentials hidden from serialization.
- Views off — pages are full-page class-based Livewire components (R1/R2);
  Fortify handles POST actions. Home redirect -> /dashboard. v1 scope: login +
  2FA only (no public register/reset/passkeys). Seeder gated to local/testing.

Component kit (Blade, token-based, a11y):
- button, input, checkbox, alert, card, badge, stat-tile, otp-input (Alpine,
  auto-advance/paste, -safe submit), progress-stepper, nav-item, icon
  (Lucide), plus layouts/portal-app app-shell (sidebar drawer + topbar + menu).

Screens (localized DE/EN, R16):
- Login (form -> login.store), Two-factor challenge (OTP + recovery fallback),
  Dashboard (KPI stat tiles, instance card, provisioning stepper fixtures,
  activity). Routes English (R13).

Tests + verification:
- Pest: 14 green (login ok/invalid/throttle, dashboard guard, component render).
- R12 browser (Puppeteer, prod assets): /, /login, /two-factor-challenge and
  the authenticated /dashboard all HTTP 200 with ZERO console errors; login
  flow verified end-to-end.
- Test isolation fixed (force test env over injected .env).
- Reviewed with Codex (R15): 4 rounds, all findings fixed, final pass clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 01:20:25 +02:00
nexxo 53c2a12d6d feat(portal): design foundation — Tailwind v3, tokens, self-hosted fonts
- Downgrade Tailwind v4 -> v3 (user decision): postcss.config.js,
  tailwind.config.js mapping framework-neutral CSS-var tokens onto utilities.
- portal-tokens.css: light enterprise palette, single orange accent, IBM Plex
  type scale, radius/shadow/motion/focus (design handoff §6). AA-safe accent
  text/fill tokens (accent-active/-press/-text) — #f97316 alone fails AA.
- Self-hosted IBM Plex Sans+Mono via @fontsource, Vite-bundled (R14, no CDN).
- app.css: v3 layers, base type, uniform :focus-visible, reduced-motion.
- layouts/portal.blade.php base layout; welcome page retokenised (guarded
  login CTA, DE/EN via lang/common) — no v4-only classes.
- Reviewed with Codex (R15): 5 rounds, all findings fixed, final pass clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 00:43:01 +02:00
456 changed files with 39122 additions and 602 deletions

BIN
..env.swp Normal file

Binary file not shown.

View File

@ -63,23 +63,142 @@ REVERB_SERVER_PORT=8080
# browser-side (baked into built assets): the address a browser can reach
VITE_REVERB_APP_KEY=change-me-app-key
VITE_REVERB_HOST=localhost
VITE_REVERB_PORT=8080
VITE_REVERB_SCHEME=http
VITE_REVERB_PORT=443
VITE_REVERB_SCHEME=https
VITE_APP_NAME=CluPilot
# Vite HMR host the browser connects to (dev). Set to the reachable VM address
# when developing against a remote host; localhost for local-only.
VITE_HMR_HOST=localhost
# ── Mail (dev → log) ─────────────────────────────────────────────────────
# ── Mail ─────────────────────────────────────────────────────────────────
# DEV: `log` writes mails to storage/logs (nothing is sent).
# PROD: switch to smtp and fill in the credentials below.
MAIL_MAILER=log
MAIL_FROM_ADDRESS=hello@clupilot.local
MAIL_FROM_NAME=CluPilot
# MAIL_MAILER=smtp
# MAIL_HOST=smtp.your-provider.tld
# MAIL_PORT=587
# MAIL_USERNAME=
# MAIL_PASSWORD=
# MAIL_SCHEME=tls # tls | smtps (465)
# ═════════════════════════════════════════════════════════════════════════
# CluPilot operations — fill these in to run real provisioning.
# Everything below is OPTIONAL for local dev: left blank, the services are
# no-ops/fakes and the app + test suite still run. Only real infrastructure
# work (host onboarding, customer provisioning) needs them.
# ═════════════════════════════════════════════════════════════════════════
# ── Stripe (payments) ────────────────────────────────────────────────────
# WEBHOOK_SECRET is READ BY CODE today (signature check on /webhooks/stripe).
# Outside local/testing a missing secret makes the webhook fail closed.
STRIPE_WEBHOOK_SECRET=
# Not read yet — needed once the Checkout flow is built (currently the portal
# only records a purchase intent):
STRIPE_KEY=
STRIPE_SECRET=
# ── DNS (Hetzner DNS API) ────────────────────────────────────────────────
# Creates <subdomain>.<zone> A-records for each customer instance.
HETZNER_DNS_TOKEN=
CLUPILOT_DNS_ZONE=clupilot.com
# ── Traefik (reverse proxy + TLS) ────────────────────────────────────────
# Directory ON THE PROXMOX HOST where CluPilot writes dynamic route files.
TRAEFIK_DYNAMIC_PATH=/etc/traefik/dynamic
# ── WireGuard hub (this CluPilot VM) ─────────────────────────────────────
# Hosts join this hub during onboarding; CluPilot reaches them over the tunnel.
CLUPILOT_WG_SUBNET=10.66.0.0/24
CLUPILOT_WG_HUB_IP=10.66.0.1
CLUPILOT_WG_ENDPOINT= # public host:port peers dial, e.g. vpn.clupilot.com:51820
CLUPILOT_WG_HUB_PUBKEY= # `wg pubkey < /etc/wireguard/privatekey`
CLUPILOT_WG_CONFIG_PATH=/etc/wireguard/wg0.conf
# ── SSH identity for host onboarding ─────────────────────────────────────
# Deployed to each fresh server after the one-time root password login.
# PREFERRED: point at files (a multi-line PEM cannot live in .env).
# ssh-keygen -t ed25519 -N '' -C clupilot -f storage/app/ssh/clupilot
CLUPILOT_SSH_PUBLIC_KEY_PATH=
CLUPILOT_SSH_PRIVATE_KEY_PATH=
# Alternative (single-line values only):
CLUPILOT_SSH_PUBLIC_KEY=
CLUPILOT_SSH_PRIVATE_KEY=
CLUPILOT_SSH_COMMAND_TIMEOUT=2000
# ── Monitoring ───────────────────────────────────────────────────────────
# The built-in client speaks a GENERIC REST API:
# GET/POST /monitors, GET/DELETE /monitors/{id}
# For Uptime Kuma use the bundled bridge below (Kuma's own REST API is
# read-only — monitor CRUD goes through Socket.IO):
# MONITORING_API_URL=http://kuma-bridge:8080
# Leave blank to disable monitoring entirely: provisioning records a stable
# breadcrumb and its own health checks (occ status, TLS, admin user) still
# gate acceptance.
MONITORING_API_URL=
MONITORING_API_TOKEN=
# ── Uptime Kuma (über die mitgelieferte Bridge) ───────────────────────────
# Kuma kann Monitore nur über Socket.IO anlegen, daher die Bridge:
# docker compose --profile monitoring up -d --build kuma-bridge
# Danach hier MONITORING_API_URL auf die Bridge zeigen lassen:
# MONITORING_API_URL=http://kuma-bridge:8080
# MONITORING_API_TOKEN ist gleichzeitig das Bridge-Token (frei wählbar, lang).
KUMA_URL=
KUMA_USERNAME=
KUMA_PASSWORD=
KUMA_TOTP=
# Monitoring-Verhalten bei Ausfall:
# MONITORING_REQUIRED=false -> Bereitstellung läuft weiter (Warn-Event), Standard
# MONITORING_REQUIRED=true -> Lauf schlägt fehl, wenn Monitoring nicht erreichbar
MONITORING_REQUIRED=false
MONITORING_ATTEMPTS=2
# ── Admin-Konsole: erlaubte Hostnamen ────────────────────────────────────
# Die Proxmox-Hosts UND das Admin-Dashboard dürfen nie öffentlich erreichbar
# sein. Primär regelt das dein Reverse Proxy (nur www/app/ws/api öffentlich);
# dies ist die zweite Verteidigungslinie IN der App: auf jedem anderen Host
# antwortet /admin mit 404 (nicht 403 — verrät nicht, dass es die Konsole gibt).
# Komma-getrennt. LEER = keine Einschränkung (aktueller Dev-Stand).
# Beispiel: # Schluessel fuer gespeicherte VPN-Konfigurationen (32 Byte, base64).
# Bewusst getrennt von APP_KEY. Leer = Speichern deaktiviert.
# Erzeugen: head -c 32 /dev/urandom | base64
VPN_CONFIG_KEY=
# Netze, die als "wir" gelten, solange die Website versteckt ist
# (WireGuard-Subnetz). Alles andere sieht die Platzhalterseite.
TRUSTED_RANGES=10.66.0.0/24,127.0.0.1
# Umsatzsteuersatz des Verkäufers in Prozent. Preise in der Konfiguration
# sind NETTO; die Oberfläche weist beides aus.
CLUPILOT_TAX_PERCENT=20
ADMIN_HOSTS=admin.dev.clupilot.com,10.10.90.185,localhost,127.0.0.1
# ── Nextcloud blueprint template ─────────────────────────────────────────
# NOT an env var: set the Proxmox template VMID per plan in
# config/provisioning.php → plans.*.template_vmid (default 9000).
# ── docker-compose knobs (host-side) ─────────────────────────────────────
HOST_UID=1000
HOST_GID=1000
APP_PORT=80
# Vite dev server (HMR). Off by default: over the HTTPS domains the
# browser cannot load assets from http://<ip>:5173. Set true only when
# working over http://10.10.90.185, then run: docker compose up -d app
VITE_AUTOSTART=false
VITE_PORT=5173
REVERB_HOST_PORT=8080
DB_HOST_PORT=3306
# ── CI (Gitea Actions) ──────────────────────────────────────────────────────
# Registrierungs-Token aus Gitea: Repo → Settings → Actions → Runners →
# "Create new runner". Danach: docker compose --profile ci up -d runner
GITEA_INSTANCE_URL=https://git.bave.dev
GITEA_RUNNER_TOKEN=
GITEA_RUNNER_NAME=clupilot-local
# Passend zur Server-Version halten (1.20 verträgt keinen aktuellen Runner).
GITEA_RUNNER_VERSION=0.2.6

118
.gitea/workflows/tests.yml Normal file
View File

@ -0,0 +1,118 @@
# CluPilot CI — runs on Gitea Actions (GitHub-Actions syntax, own runner).
#
# Deliberately not mirrored to GitHub: this repository describes how our
# infrastructure is provisioned, and the same workflow runs at home. If we ever
# move, this file goes along unchanged.
name: tests
on:
push:
branches: [main, 'feat/**']
pull_request:
jobs:
pest:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4.2.2
- name: Set up PHP
# Pinned: the floating v2 tag now requires node24, which the runner
# matching Gitea 1.20 cannot execute. 2.34.1 is the newest that still
# declares node20.
uses: shivammathur/setup-php@2.34.1
with:
php-version: '8.4'
extensions: mbstring, pdo_sqlite, sodium, redis, bcmath, gd, zip
coverage: none
- name: Install PHP dependencies
# Source clones, not dist archives: dist downloads go through GitHub's
# API, which throttles anonymous callers and left a half-installed
# vendor/ behind — the tests then failed with 500s that had nothing to
# do with the code. Cloning is slower and does not need anyone's quota.
# Swap back to --prefer-dist once a GitHub token is configured.
run: composer install --no-interaction --prefer-source --no-progress
- name: Prepare environment
run: |
cp .env.example .env
php artisan key:generate
- name: Tests
# phpunit.xml pins its own sqlite/array drivers, so no services needed.
run: ./vendor/bin/pest --colors=always
assets:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4.2.2
- uses: actions/setup-node@v4.1.0
with:
node-version: '22'
- name: Install JS dependencies
run: npm ci --no-fund --no-audit
# A build failure here is what used to surface as "the design is broken"
# only after deploying — catch it before it ships.
- name: Build assets
run: npm run build
release:
# Only a green run produces the tag the console offers as an update.
needs: [pest, assets]
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4.2.2
with:
fetch-depth: 0
- name: Tag this commit as tested
run: |
tag="tested-$(date -u +%Y%m%d-%H%M)-$(git rev-parse --short HEAD)"
git tag "$tag"
git push origin "$tag"
# A release is cut by editing VERSION and merging it — nothing else. The
# tag is created here, only after the suite is green, and only if it does
# not already exist. Never moved: servers are pinned to these, and a tag
# that changes underneath them means two machines claiming one version.
- name: Tag the release when VERSION changed
run: |
version="$(tr -d ' \n\r' < VERSION)"
# Anchored, because a `case` glob does not anchor: `[0-9]*.[0-9]*`
# happily accepts 1x.2y.3garbage and would tag it.
printf '%s' "$version" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$' \
|| { echo "VERSION is not MAJOR.MINOR.PATCH: '$version'" >&2; exit 1; }
# Only the commit that RAISED the version is the release. Without
# this, any later green push finds the tag missing and claims it —
# and the tag would then name code the bump never described. Runs
# overlap and finish out of order; that is enough for it to happen.
previous="$(git show 'HEAD^:VERSION' 2>/dev/null | tr -d ' \n\r' || true)"
if [ "$previous" = "$version" ]; then
echo "::notice::VERSION is unchanged ($version) — nothing to release."
exit 0
fi
tag="v${version}"
git fetch --tags --force origin
if existing="$(git rev-parse -q --verify "refs/tags/${tag}^{commit}")"; then
# Already released. The invariant is that the tag points at the
# commit VERSION was raised in — if a later commit still carries
# that number, that is fine, but the tag must not be re-pointed.
if [ "$existing" != "$(git rev-parse HEAD)" ]; then
echo "::notice::${tag} already exists at ${existing}; leaving it alone."
fi
exit 0
fi
git config user.name "CluPilot CI"
git config user.email "ci@clupilot.local"
git tag -a "$tag" -m "CluPilot ${version}"
git push origin "$tag"
echo "::notice::Released ${tag}"

1
.gitignore vendored
View File

@ -28,3 +28,4 @@ Thumbs.db
# Gitea push token (parent home dir; never track)
.env.gitea
clupilot.env

1
VERSION Normal file
View File

@ -0,0 +1 @@
1.0.0

View File

@ -0,0 +1,459 @@
<?php
namespace App\Actions;
use App\Models\StripePendingEvent;
use App\Models\Subscription;
use App\Models\SubscriptionRecord;
use Illuminate\Database\UniqueConstraintViolationException;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
/**
* What Stripe tells us about the billing cycle, applied to the contract.
*
* The division of labour: Stripe owns the money retries, dunning, off-session
* SCA, invoice numbering and we own capability, because Stripe does not know
* how big the VM should be. So these handlers move period boundaries and status,
* and write the register. They never re-derive an amount: the invoice is the
* authority for what was charged, and recomputing it here from our own
* catalogue would produce a second, disagreeing answer.
*
* Every handler is idempotent. Stripe retries a webhook until it gets a 2xx,
* and it also sends the same event to several endpoints so "already applied"
* is the normal case, not the exception.
*/
class ApplyStripeBillingEvent
{
/**
* Precedence when two events share a second highest wins.
*
* Ordered by how much the event settles: a payment that went through is the
* last word, a snapshot of the subscription is a running picture, and a
* failed attempt is the least final of the three, since a retry may already
* have succeeded.
*/
private const RANK_PAID = 3;
private const RANK_UPDATED = 2;
private const RANK_FAILED = 1;
public function __construct(private RecordCommercialEvent $record) {}
/**
* A renewal was paid. Move the period on and enter it in the register.
*
* @param array<string, mixed> $invoice
*/
public function invoicePaid(array $invoice, ?Carbon $eventAt = null): ?SubscriptionRecord
{
$subscription = $this->resolve($invoice['subscription'] ?? null);
if ($subscription === null) {
return null;
}
$reason = (string) ($invoice['billing_reason'] ?? '');
// The checkout's own invoice is not a renewal — it is the purchase, and
// OpenSubscription has already entered it. Recording it again here
// would double every customer's first payment in the register.
if ($reason === 'subscription_create') {
return null;
}
// Only a cycle renewal moves the term on. Stripe also sends paid
// invoices for prorations and for charges raised by hand, and those are
// money received without being the start of a new month — calling them
// renewals would push the period forward on a top-up.
$isRenewal = $reason === 'subscription_cycle';
$invoiceId = (string) ($invoice['id'] ?? '');
[$start, $end] = $this->period($invoice);
// A contract that has ended stays ended (mutateInOrder refuses one), so
// a final invoice arriving after the deletion cannot hand a departed
// customer their service back. The payment is still entered in the
// register below; it did happen.
if ($isRenewal && $start !== null && $end !== null) {
$this->mutateInOrder($subscription, $eventAt, self::RANK_PAID, fn (Subscription $fresh) => $end
// Never backwards: a renewal delayed behind the next one would
// otherwise shorten the term the customer has already paid for.
->greaterThanOrEqualTo($fresh->current_period_end)
// Period boundaries are not part of the frozen snapshot:
// what the customer is owed does not change, only which
// month it is.
? [
'current_period_start' => $start,
'current_period_end' => $end,
'status' => 'active',
'stripe_status' => 'active',
]
: null);
}
// One invoice, one entry — enforced by the unique index rather than by
// a check, because Stripe can deliver the same invoice twice at once
// and both deliveries would pass a check.
$event = $isRenewal ? SubscriptionRecord::EVENT_RENEWAL : SubscriptionRecord::EVENT_INVOICE_PAID;
return $this->recordOnce(
fn () => ($this->record)(
event: $event,
subscription: $subscription->refresh(),
netCents: $isRenewal ? $subscription->price_cents : 0,
stripe: [
'invoice' => $invoiceId ?: null,
'subscription' => $subscription->stripe_subscription_id,
],
// The exact kind, kept so a proration or a manual charge stays
// distinguishable from an ordinary renewal.
extra: ['billing_reason' => $reason ?: null],
// Stripe's total is what was actually taken, tax and all. Ours
// is what was agreed; the register states both.
chargedGrossCents: isset($invoice['amount_paid']) ? (int) $invoice['amount_paid'] : null,
eventKey: $invoiceId !== '' ? "{$event}:{$invoiceId}" : null,
)
);
}
/**
* A renewal failed. Recorded, and nothing else: Stripe runs the dunning
* schedule, and cutting a customer off on the first failed attempt would
* punish an expired card as though it were a refusal to pay.
*
* @param array<string, mixed> $invoice
*/
public function invoicePaymentFailed(array $invoice, ?Carbon $eventAt = null): ?SubscriptionRecord
{
$subscription = $this->resolve($invoice['subscription'] ?? null);
if ($subscription === null) {
return null;
}
$invoiceId = (string) ($invoice['id'] ?? '');
// Only if this is the newest word we have. A failed attempt delayed
// behind the successful retry would otherwise flip a customer who has
// since paid back to past_due.
$this->mutateInOrder($subscription, $eventAt, self::RANK_FAILED, fn () => ['stripe_status' => 'past_due']);
return $this->recordOnce(
fn () => ($this->record)(
event: SubscriptionRecord::EVENT_PAYMENT_FAILED,
subscription: $subscription->refresh(),
netCents: 0,
stripe: [
'invoice' => $invoiceId ?: null,
'subscription' => $subscription->stripe_subscription_id,
],
extra: ['attempt' => $invoice['attempt_count'] ?? null],
chargedGrossCents: 0,
// Per attempt, not per invoice: Stripe retries a failing
// payment on a schedule, and each attempt is its own event.
eventKey: $invoiceId !== ''
? 'payment_failed:'.$invoiceId.':'.($invoice['attempt_count'] ?? 0)
: null,
)
);
}
/**
* Stripe's own status changed. Theirs is the authority on whether the money
* is arriving; we copy it, and leave what the customer may USE to our own
* status, which only a cancellation or an operator changes.
*
* @param array<string, mixed> $object
*/
public function subscriptionUpdated(array $object, ?Carbon $eventAt = null): ?Subscription
{
$subscription = $this->resolve($object['id'] ?? null);
if ($subscription === null) {
return null;
}
[$start, $end] = $this->period($object);
// Ended is ended, whatever order events arrive in — and an older
// snapshot must never overwrite a newer one it was delivered behind.
$this->mutateInOrder($subscription, $eventAt, self::RANK_UPDATED, fn () => array_filter([
'stripe_status' => $object['status'] ?? null,
'current_period_start' => $start,
'current_period_end' => $end,
], fn ($value) => $value !== null));
return $subscription;
}
/**
* The subscription has ended at Stripe. That is the end of the contract,
* so it goes in the register a cancellation nobody recorded is exactly
* the kind of gap the register exists to prevent.
*
* @param array<string, mixed> $object
*/
public function subscriptionDeleted(array $object, ?Carbon $eventAt = null): ?SubscriptionRecord
{
$subscription = $this->resolve($object['id'] ?? null);
if ($subscription === null) {
return null;
}
$endedAt = isset($object['ended_at'])
? Carbon::createFromTimestamp((int) $object['ended_at'])
: now();
// The ending and its entry in the register commit together. Marking the
// contract cancelled first and failing before the record would leave a
// retry seeing "already cancelled" and returning — and the end of a
// contract would never be entered at all.
return $this->recordOnce(fn () => DB::transaction(function () use ($subscription, $object, $endedAt, $eventAt) {
// Held while we decide, so a renewal arriving at the same moment
// cannot reactivate the contract between this and the write.
$subscription = Subscription::query()->whereKey($subscription->getKey())->lockForUpdate()->firstOrFail();
// No ordering guard: an ending is final, so a late delivery of it
// is still correct. Only the running picture can go stale.
$subscription->update([
'status' => 'cancelled',
'stripe_status' => $object['status'] ?? 'canceled',
'cancelled_at' => $endedAt,
'stripe_event_at' => $eventAt,
]);
return ($this->record)(
event: SubscriptionRecord::EVENT_CANCELLATION,
subscription: $subscription->refresh(),
netCents: 0,
at: $endedAt,
stripe: ['subscription' => $subscription->stripe_subscription_id],
chargedGrossCents: 0,
// A contract ends once. Two deliveries arriving together would
// both pass a status check; only one can take this key.
eventKey: 'cancellation:'.$subscription->id,
);
}));
}
/**
* Whether this event is the newest word we have about the contract.
*
* Stripe does not guarantee delivery order, and the state here is a
* running picture rather than a log: applying a stale snapshot on top of a
* fresher one is how a paid-up customer ends up looking overdue. The
* register is unaffected each entry is keyed by what it is about, so a
* late arrival still lands once, in its proper place.
*/
/**
* Decide and write in one step, with the contract's row held.
*
* The ordering guard is only worth as much as its atomicity: two
* deliveries for the same contract can both read a stale copy, both pass
* the check, and the loser then overwrites the winner a renewal
* reactivating a contract a deletion had just ended, or an old failure
* burying a payment that went through. So the row is re-read under a lock
* and re-judged inside the transaction that writes it.
*
* `$changes` returns the columns to set, or null to decline.
*
* @param callable(Subscription): ?array<string, mixed> $changes
*/
private function mutateInOrder(Subscription $subscription, ?Carbon $eventAt, int $rank, callable $changes): void
{
DB::transaction(function () use ($subscription, $eventAt, $rank, $changes) {
$fresh = Subscription::query()->whereKey($subscription->getKey())->lockForUpdate()->first();
if ($fresh === null
|| $fresh->status === 'cancelled'
|| ! $this->appliesInOrder($fresh, $eventAt, $rank)) {
return;
}
$values = $changes($fresh);
if ($values === null) {
return;
}
if ($eventAt !== null) {
$values['stripe_event_at'] = $eventAt;
$values['stripe_event_rank'] = $rank;
}
$fresh->update($values);
});
$subscription->refresh();
}
private function appliesInOrder(Subscription $subscription, ?Carbon $eventAt, int $rank): bool
{
if ($eventAt === null || $subscription->stripe_event_at === null) {
return true; // nothing to compare against
}
if ($eventAt->greaterThan($subscription->stripe_event_at)) {
return true;
}
if ($eventAt->lessThan($subscription->stripe_event_at)) {
return false;
}
// Same second, which Stripe's one-second timestamps make common enough
// to matter: a failed attempt and the retry that succeeded can share
// one. Decide by what the event says about the money.
return $rank >= (int) ($subscription->stripe_event_rank ?? 0);
}
/**
* Write a register entry, unless one already exists for this invoice and
* event.
*
* The uniqueness lives in the database, on (stripe_invoice_id, event).
* Checking first and inserting afterwards leaves a window that Stripe
* delivering the same invoice twice at once walks straight through and a
* register that counts a payment twice is worse than one that is late.
*
* @param callable(): SubscriptionRecord $write
*/
private function recordOnce(callable $write): ?SubscriptionRecord
{
try {
return $write();
} catch (UniqueConstraintViolationException) {
return null; // already recorded by a concurrent delivery
}
}
/**
* Hold an event whose contract does not exist yet, so it is not lost.
*
* @param array<string, mixed> $event
*/
public function hold(array $event): void
{
$subscriptionId = $event['data']['object']['subscription']
?? $event['data']['object']['id']
?? null;
if (! is_string($subscriptionId) || ! is_string($event['id'] ?? null)) {
return;
}
// Only what we could not match. A handler returns null for several
// reasons — already recorded, or deliberately skipped, like every
// checkout's own invoice — and holding those would fill the table with
// rows that replayHeldFor() can never revisit, because their contract
// is right there. Nothing to wait for means nothing to hold.
if (Subscription::query()->where('stripe_subscription_id', $subscriptionId)->exists()) {
// Unless it appeared just now: the contract can be created between
// the handler missing it and this check, and the replay that would
// have collected it has then already been and gone. Apply it here
// instead of dropping it. Safe to repeat — the handlers are
// idempotent, so an event that was a no-op stays one.
$this->dispatch($event);
return;
}
StripePendingEvent::query()->updateOrCreate(
['stripe_event_id' => $event['id']],
[
'stripe_subscription_id' => $subscriptionId,
'type' => (string) ($event['type'] ?? ''),
'raised_at' => isset($event['created']) && is_numeric($event['created'])
? Carbon::createFromTimestamp((int) $event['created'])
: null,
'payload' => $event,
],
);
}
/**
* Replay everything held for a contract that has just appeared.
*
* In their original order, so the ordering guard sees them as Stripe raised
* them rather than as they happened to be stored.
*/
public function replayHeldFor(Subscription $subscription): int
{
if ($subscription->stripe_subscription_id === null) {
return 0;
}
$held = StripePendingEvent::query()
->where('stripe_subscription_id', $subscription->stripe_subscription_id)
->orderBy('raised_at')
->orderBy('id')
->get();
foreach ($held as $pending) {
$this->dispatch($pending->payload ?? []);
$pending->delete();
}
return $held->count();
}
/**
* Route one event to its handler. Returns false for a type we do not
* handle, so the caller can tell "not ours" from "nothing to do".
*
* @param array<string, mixed> $event
*/
public function dispatch(array $event): mixed
{
$object = $event['data']['object'] ?? [];
$raisedAt = isset($event['created']) && is_numeric($event['created'])
? Carbon::createFromTimestamp((int) $event['created'])
: null;
return match ($event['type'] ?? '') {
'invoice.paid' => $this->invoicePaid($object, $raisedAt),
'invoice.payment_failed' => $this->invoicePaymentFailed($object, $raisedAt),
'customer.subscription.updated' => $this->subscriptionUpdated($object, $raisedAt),
'customer.subscription.deleted' => $this->subscriptionDeleted($object, $raisedAt),
default => false,
};
}
/**
* Find the contract a Stripe event is about.
*
* An unknown id is logged and dropped rather than raised: Stripe delivers
* events for objects created by hand in the dashboard, and by other
* environments pointed at the same endpoint. Failing the webhook for those
* would have Stripe retry something that can never succeed.
*/
private function resolve(mixed $stripeSubscriptionId): ?Subscription
{
$id = is_string($stripeSubscriptionId) ? $stripeSubscriptionId : null;
if ($id === null) {
return null;
}
return Subscription::query()->where('stripe_subscription_id', $id)->first();
}
/**
* @param array<string, mixed> $object
* @return array{0: ?Carbon, 1: ?Carbon}
*/
private function period(array $object): array
{
$start = $object['period_start'] ?? $object['current_period_start'] ?? null;
$end = $object['period_end'] ?? $object['current_period_end'] ?? null;
return [
is_numeric($start) ? Carbon::createFromTimestamp((int) $start) : null,
is_numeric($end) ? Carbon::createFromTimestamp((int) $end) : null,
];
}
}

132
app/Actions/BookAddon.php Normal file
View File

@ -0,0 +1,132 @@
<?php
namespace App\Actions;
use App\Models\Order;
use App\Models\Subscription;
use App\Models\SubscriptionAddon;
use App\Models\SubscriptionRecord;
use App\Services\Billing\AddonCatalogue;
use Illuminate\Database\UniqueConstraintViolationException;
use Illuminate\Support\Facades\DB;
use RuntimeException;
/**
* Books a module onto a contract at today's price, and freezes it there.
*
* Booking is the moment the module's price stops moving for this customer, for
* exactly the reason the plan's price does: they agreed to a figure. What they
* have NOT booked stays on the live catalogue that is a sale still to be
* made, at whatever it costs now.
*/
class BookAddon
{
public function __construct(private RecordCommercialEvent $record) {}
public function __invoke(Subscription $subscription, string $addonKey, int $quantity = 1, ?Order $order = null): SubscriptionAddon
{
$price = app(AddonCatalogue::class)->priceCents($addonKey);
if ($price === null) {
throw new RuntimeException("Unknown add-on: {$addonKey}");
}
if ($quantity < 1) {
throw new RuntimeException('An add-on is booked at least once.');
}
try {
return $this->book($subscription, $addonKey, $quantity, $order, $price);
} catch (UniqueConstraintViolationException) {
// A concurrent retry won. The unique index on (order_id, addon_key)
// is what actually enforces "one order books one module" — the
// lookup below is only the fast path, and two transactions can both
// pass it.
return SubscriptionAddon::query()
->where('order_id', $order?->id)
->where('addon_key', $addonKey)
->firstOrFail();
}
}
private function book(Subscription $subscription, string $addonKey, int $quantity, ?Order $order, int $price): SubscriptionAddon
{
// The booking and its entry in the register commit together: if the
// record could fail afterwards, retrying the same order would find the
// add-on already there and skip the event for good.
return DB::transaction(function () use ($subscription, $addonKey, $quantity, $order, $price) {
// Idempotent against a retried webhook: one order books one module.
if ($order !== null) {
$existing = SubscriptionAddon::query()
->where('order_id', $order->id)
->where('addon_key', $addonKey)
->first();
if ($existing !== null) {
return $existing;
}
}
$addon = SubscriptionAddon::create([
'subscription_id' => $subscription->id,
'order_id' => $order?->id,
'addon_key' => $addonKey,
'price_cents' => $price,
'currency' => Subscription::catalogueCurrency(),
'quantity' => $quantity,
'booked_at' => now(),
]);
($this->record)(
event: SubscriptionRecord::EVENT_ADDON_BOOKED,
subscription: $subscription,
netCents: $addon->monthlyCents(),
extra: ['addon' => ['key' => $addonKey, 'quantity' => $quantity, 'price_cents' => $price]],
order: $order,
stripe: ['event' => $order?->stripe_event_id],
// What was actually charged for the module, on the same terms
// as a plan purchase: a discount or a free booking has to be
// reconcilable, not reconstructed from the catalogue.
chargedGrossCents: $order?->stripe_event_id !== null ? (int) $order->amount_cents : null,
);
return $addon;
});
}
/**
* Stop charging for a module, without losing what it cost.
*
* Cancelled, not deleted: what a customer was paying, and until when, is
* part of the same record as what they bought.
*/
public function cancel(SubscriptionAddon $addon): SubscriptionAddon
{
return DB::transaction(function () use ($addon) {
// Claim the cancellation conditionally, so two requests arriving
// together write one event between them. Checking `isActive()` on
// separate instances and updating afterwards lets both through, and
// the register would show a module cancelled twice.
$claimed = SubscriptionAddon::query()
->whereKey($addon->getKey())
->whereNull('cancelled_at')
->update(['cancelled_at' => now(), 'updated_at' => now()]);
if ($claimed === 0) {
return $addon->refresh();
}
$addon->refresh();
($this->record)(
event: SubscriptionRecord::EVENT_ADDON_CANCELLED,
subscription: $addon->subscription,
netCents: -$addon->monthlyCents(),
extra: ['addon' => ['key' => $addon->addon_key, 'quantity' => $addon->quantity]],
order: $addon->order,
);
return $addon;
});
}
}

View File

@ -0,0 +1,64 @@
<?php
namespace App\Actions\Fortify;
use App\Models\Customer;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
use Laravel\Fortify\Contracts\CreatesNewUsers;
class CreateNewUser implements CreatesNewUsers
{
use PasswordValidationRules;
/**
* Validate and create a newly registered user.
*
* @param array<string, string> $input
*
* @throws ValidationException
*/
public function create(array $input): User
{
Validator::make($input, [
'name' => ['required', 'string', 'max:255'],
'email' => [
'required',
'string',
'email',
'max:255',
Rule::unique(User::class),
// Never let public signup claim an existing (possibly not-yet-
// provisioned) customer's email — ensureUser() would later link
// that account and hand over the customer's portal.
Rule::unique(Customer::class, 'email'),
],
'password' => $this->passwordRules(),
])->validate();
// Create the user and its linked customer atomically — a public signup is
// a customer, and the portal (Billing::purchase, Settings, …) needs one.
// Either both exist or neither, so a failure never orphans a user.
return DB::transaction(function () use ($input) {
$user = User::create([
'name' => $input['name'],
'email' => $input['email'],
'password' => Hash::make($input['password']),
]);
Customer::query()->create([
'user_id' => $user->id,
'name' => $input['name'],
'email' => $input['email'],
'locale' => app()->getLocale(),
'status' => 'active',
]);
return $user;
});
}
}

View File

@ -0,0 +1,19 @@
<?php
namespace App\Actions\Fortify;
use Illuminate\Contracts\Validation\Rule;
use Illuminate\Validation\Rules\Password;
trait PasswordValidationRules
{
/**
* Get the validation rules used to validate passwords.
*
* @return array<int, Rule|array<mixed>|string>
*/
protected function passwordRules(): array
{
return ['required', 'string', Password::default(), 'confirmed'];
}
}

View File

@ -0,0 +1,32 @@
<?php
namespace App\Actions\Fortify;
use App\Models\User;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\ValidationException;
use Laravel\Fortify\Contracts\ResetsUserPasswords;
class ResetUserPassword implements ResetsUserPasswords
{
use PasswordValidationRules;
/**
* Validate and reset the user's forgotten password.
*
* @param array<string, string> $input
*
* @throws ValidationException
*/
public function reset(User $user, array $input): void
{
Validator::make($input, [
'password' => $this->passwordRules(),
])->validate();
$user->forceFill([
'password' => Hash::make($input['password']),
])->save();
}
}

View File

@ -0,0 +1,35 @@
<?php
namespace App\Actions\Fortify;
use App\Models\User;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\ValidationException;
use Laravel\Fortify\Contracts\UpdatesUserPasswords;
class UpdateUserPassword implements UpdatesUserPasswords
{
use PasswordValidationRules;
/**
* Validate and update the user's password.
*
* @param array<string, string> $input
*
* @throws ValidationException
*/
public function update(User $user, array $input): void
{
Validator::make($input, [
'current_password' => ['required', 'string', 'current_password:web'],
'password' => $this->passwordRules(),
], [
'current_password.current_password' => __('The provided password does not match your current password.'),
])->validateWithBag('updatePassword');
$user->forceFill([
'password' => Hash::make($input['password']),
])->save();
}
}

View File

@ -0,0 +1,61 @@
<?php
namespace App\Actions\Fortify;
use App\Models\User;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
use Laravel\Fortify\Contracts\UpdatesUserProfileInformation;
class UpdateUserProfileInformation implements UpdatesUserProfileInformation
{
/**
* Validate and update the given user's profile information.
*
* @param array<string, string> $input
*
* @throws ValidationException
*/
public function update(User $user, array $input): void
{
Validator::make($input, [
'name' => ['required', 'string', 'max:255'],
'email' => [
'required',
'string',
'email',
'max:255',
Rule::unique('users')->ignore($user->id),
],
])->validateWithBag('updateProfileInformation');
if ($input['email'] !== $user->email &&
$user instanceof MustVerifyEmail) {
$this->updateVerifiedUser($user, $input);
} else {
$user->forceFill([
'name' => $input['name'],
'email' => $input['email'],
])->save();
}
}
/**
* Update the given verified user's profile information.
*
* @param array<string, string> $input
*/
protected function updateVerifiedUser(User $user, array $input): void
{
$user->forceFill([
'name' => $input['name'],
'email' => $input['email'],
'email_verified_at' => null,
])->save();
$user->sendEmailVerificationNotification();
}
}

View File

@ -0,0 +1,96 @@
<?php
namespace App\Actions;
use App\Models\Order;
use App\Models\Subscription;
use App\Models\SubscriptionRecord;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
/**
* Opens the contract a paid order bought.
*
* This is the moment the catalogue stops applying. Everything the customer is
* owed price, quotas, seats, the hardware behind the plan is copied onto
* the subscription here and never read from the catalogue again. Provisioning
* sizes the machine from this row, so an operator editing a plan afterwards
* cannot reach a customer who has already paid.
*
* `price_cents` is the catalogue's NET price, which is what PlanChange prorates
* against deliberately not `Order::amount_cents`, which holds the GROSS total
* Stripe actually charged. The two can legitimately differ (VAT, and later
* coupons), and reconciling them is not this action's job: the proof register
* records what was charged per event, and Stripe's invoice is the authority for
* the amount. Copying a gross total into this net field would silently corrupt
* every pro-rata calculation that reads it.
*/
class OpenSubscription
{
public function __construct(private RecordCommercialEvent $record) {}
public function __invoke(Order $order, string $term = Subscription::TERM_MONTHLY): Subscription
{
// A retried webhook must not open a second contract for one purchase.
$existing = Subscription::query()->where('order_id', $order->id)->first();
if ($existing !== null) {
return $existing;
}
$start = now();
// The contract and its entry in the register commit together. If the
// record could fail after the subscription is in, the next webhook
// retry would find the contract, return early, and leave a paid sale
// permanently missing from the evidence.
return DB::transaction(fn () => $this->open($order, $term, $start));
}
private function open(Order $order, string $term, Carbon $start): Subscription
{
$subscription = Subscription::create(array_merge(
// The version the order carries, when the checkout recorded one:
// what the customer saw beats what happens to be on sale by the
// time their payment reaches us.
Subscription::snapshotFrom($order->plan, $term, $order->plan_version_id),
[
'customer_id' => $order->customer_id,
'order_id' => $order->id,
// Carried from the checkout: from here on, Stripe's events
// name this and nothing else.
'stripe_subscription_id' => $order->stripe_subscription_id,
'started_at' => $start,
'current_period_start' => $start,
'current_period_end' => $term === Subscription::TERM_YEARLY
? $start->copy()->addYear()
: $start->copy()->addMonth(),
'status' => 'active',
],
));
// The sale, entered in the register the moment it happens. Written from
// the contract that was just frozen, so the evidence and the contract
// cannot describe different purchases.
($this->record)(
event: SubscriptionRecord::EVENT_PURCHASE,
subscription: $subscription,
netCents: $subscription->price_cents,
at: $start,
stripe: ['event' => $order->stripe_event_id],
// The order carries what Stripe actually took — gross, including
// whatever tax or discount applied on the day. The contract price
// is what was agreed; this is what was paid, and the register has
// to be able to state both.
//
// Keyed on the Stripe id, not on the amount being non-zero: a fully
// discounted checkout charges zero, and reading that as "no amount
// recorded" would file a free sale as though it had been paid for
// in full. An order without a Stripe id was never charged through
// a checkout at all, and has nothing to report.
chargedGrossCents: $order->stripe_event_id !== null ? (int) $order->amount_cents : null,
);
return $subscription;
}
}

View File

@ -0,0 +1,146 @@
<?php
namespace App\Actions;
use App\Models\Order;
use App\Models\Subscription;
use App\Models\SubscriptionRecord;
use App\Services\Billing\TaxTreatment;
use Illuminate\Support\Carbon;
/**
* Writes one row into the proof register.
*
* Every commercial event goes through here so that the flat columns are filled
* the same way each time an evidence table where the amount means one thing
* in one row and another in the next is not evidence.
*
* Amounts are stated three ways on purpose. Net is what the catalogue and the
* contract deal in; gross is what was actually charged; the tax between them
* depends on the customer and on the day, and recomputing it later from a rate
* that has since changed would produce a different answer to the one on the
* invoice.
*/
class RecordCommercialEvent
{
/**
* @param array<string, mixed> $extra merged into the JSON snapshot
* @param array<string, string|null> $stripe event / invoice / subscription ids
*/
public function __invoke(
string $event,
Subscription $subscription,
int $netCents,
?Carbon $at = null,
array $extra = [],
array $stripe = [],
?int $chargedGrossCents = null,
?Order $order = null,
?string $eventKey = null,
): SubscriptionRecord {
$at ??= now();
$customer = $subscription->customer;
$tax = TaxTreatment::for($customer);
$agreedNet = $netCents;
$expectedGross = $tax->grossCents($agreedNet);
// The flat columns describe the TRANSACTION, not the contract: gross is
// what was taken from the customer, and net and tax are that gross
// split by the rate that applied on the day.
//
// Split, not subtracted from the agreed price. A discount lowers the
// taxable amount; it does not create negative VAT, and recording
// 14900 charged against a 179,00 contract as "minus 30,00 tax" would
// make the register wrong in exactly the case someone audits.
if ($chargedGrossCents !== null) {
$gross = $chargedGrossCents;
$net = (int) round($gross / (1 + $tax->rate));
} else {
$net = $agreedNet;
$gross = $expectedGross;
}
$version = $subscription->planVersion;
return SubscriptionRecord::create([
'event' => $event,
// What makes this event "the same one" if it arrives again. Unique
// where set, so a duplicate delivery collides in the database
// rather than in a check that two of them can both pass.
'event_key' => $eventKey,
'customer_id' => $subscription->customer_id,
'subscription_id' => $subscription->id,
// The order this event belongs to — a booked module has its own,
// and pointing it at the original plan purchase would file every
// add-on under the wrong transaction.
'order_id' => $order?->id ?? $subscription->order_id,
'plan_version_id' => $subscription->plan_version_id,
// Copied rather than joined: these have to still answer the question
// after the customer, the plan or the version have gone.
'customer_name' => $customer?->name,
'plan_key' => $subscription->plan,
'plan_version' => $version?->version,
'term' => $subscription->term,
'net_cents' => $net,
'tax_cents' => $gross - $net,
'gross_cents' => $gross,
'currency' => $subscription->currency,
'tax_rate' => round($tax->rate * 100, 2),
'reverse_charge' => $tax->reverseCharge,
'stripe_event_id' => $stripe['event'] ?? null,
'stripe_invoice_id' => $stripe['invoice'] ?? null,
'stripe_subscription_id' => $stripe['subscription'] ?? null,
'occurred_at' => $at,
'snapshot_version' => SubscriptionRecord::SNAPSHOT_VERSION,
// The long tail: everything nobody has thought to ask about yet.
// The flat columns above are what gets queried.
'snapshot' => array_merge([
'subscription' => $subscription->only(Subscription::FROZEN),
'plan' => [
'key' => $subscription->plan,
'version' => $version?->version,
'version_id' => $subscription->plan_version_id,
'family' => $version?->family?->name,
'capabilities' => $version?->capabilities(),
],
'period' => [
'start' => $subscription->current_period_start?->toIso8601String(),
'end' => $subscription->current_period_end?->toIso8601String(),
],
'addons' => $subscription->addons()->active()->get()
->map(fn ($addon) => [
'key' => $addon->addon_key,
'price_cents' => $addon->price_cents,
'quantity' => $addon->quantity,
'booked_at' => $addon->booked_at?->toIso8601String(),
])->all(),
'customer' => [
'name' => $customer?->name,
'email' => $customer?->email,
'vat_id' => $customer?->vat_id,
],
// Kept even when they agree. A charge that differs from the
// catalogue plus tax is exactly the thing someone will ask
// about later, and reconciling it silently would erase the
// question along with the answer.
'amounts' => [
// What was agreed, beside what was actually taken. The
// flat columns state the transaction; this states whether
// it came out at the contract price, which is the question
// someone asks a year later.
'agreed_net_cents' => $agreedNet,
'expected_gross_cents' => $expectedGross,
'charged_gross_cents' => $gross,
'matches_catalogue' => $gross === $expectedGross,
],
], $extra),
]);
}
}

View File

@ -0,0 +1,230 @@
<?php
namespace App\Actions;
use App\Models\Customer;
use App\Models\Order;
use App\Models\ProvisioningRun;
use App\Models\Subscription;
use App\Provisioning\Jobs\AdvanceRunJob;
use App\Services\Billing\PlanCatalogue;
use Illuminate\Database\UniqueConstraintViolationException;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Throwable;
/**
* Turns a paid Stripe event into a customer + order + provisioning run. The
* order's unique stripe_event_id is the idempotency key: a duplicate webhook
* never starts a second run.
*/
class StartCustomerProvisioning
{
public function __construct(
private OpenSubscription $openSubscription,
private ApplyStripeBillingEvent $billing,
) {}
/**
* @param array{id:string,email:string,name:?string,stripe_customer_id:?string,plan:string,datacenter:string,amount_cents:int,currency:string} $event
*/
public function fromStripeEvent(array $event): ?Order
{
$existing = Order::query()->where('stripe_event_id', $event['id'])->first();
if ($existing !== null) {
$this->resume($existing);
return null;
}
$customer = $this->resolveCustomer($event);
$customer->ensureUser(); // portal login (also enables admin impersonation)
try {
[$order, $run] = DB::transaction(function () use ($event, $customer) {
$order = Order::create([
'customer_id' => $customer->id,
'plan' => $event['plan'],
// Set once Stripe checkout carries it (phase 5). Null means
// "whatever is on sale when the webhook lands", which is
// what this did before the column existed.
'plan_version_id' => $event['plan_version_id'] ?? null,
'amount_cents' => $event['amount_cents'],
'currency' => $event['currency'],
'datacenter' => $event['datacenter'],
'stripe_event_id' => $event['id'],
'stripe_subscription_id' => $event['stripe_subscription_id'] ?? null,
'status' => 'paid',
]);
$run = ProvisioningRun::create([
'subject_type' => Order::class,
'subject_id' => $order->id,
'pipeline' => 'customer',
'status' => ProvisioningRun::STATUS_PENDING,
'current_step' => 0,
// Snapshot resolved branding so retries apply identical inputs
// (unset customer → CluPilot defaults, resolved once here).
'context' => ['branding' => $customer->brandingResolved()],
]);
return [$order, $run];
});
} catch (UniqueConstraintViolationException) {
// A concurrent delivery won the race. Both requests will answer
// Stripe with a 2xx, so this is the last look anyone takes at this
// payment — if the winner then dies before opening the contract, no
// retry is coming to fix it. So finish its work rather than just
// stepping aside. The unique index on subscriptions.order_id keeps
// both from opening one.
$winner = Order::query()->where('stripe_event_id', $event['id'])->first();
if ($winner !== null) {
$this->resume($winner);
}
return null;
}
$this->openContract($order);
AdvanceRunJob::dispatch($run->uuid); // after commit
return $order;
}
/**
* Finish what a crash interrupted.
*
* The order commits before the contract is opened, so that a failure there
* can never erase the record of a payment but that leaves a window in
* which a paid order exists with no contract and nothing running. Stripe
* retries until it gets a 2xx, so a retry is exactly the chance to close
* that window. Simply returning "already processed" would strand a paying
* customer permanently, because no later webhook would ever look again.
*
* Safe to run on every duplicate: opening a contract is idempotent. A run
* that was merely never dispatched needs no nudge from here the scheduler
* tick sweeps pending runs. A run that already ran and FAILED for want of
* the contract does, because nothing sweeps failed runs.
*/
private function resume(Order $order): void
{
if ($order->subscription()->exists()) {
return;
}
$this->openContract($order);
if ($order->subscription()->exists()) {
$this->reviveRunStrandedWithoutAContract($order);
}
}
/**
* Restart a run that failed only because the contract was missing.
*
* Narrow on purpose. `no_subscription` is returned before anything is built
* the run never got past validating the order or reserving resources so
* there is nothing half-made to trip over. A run that failed for any other
* reason failed at something a contract does not fix, and restarting it
* would just repeat whatever went wrong.
*/
private function reviveRunStrandedWithoutAContract(Order $order): void
{
$run = $order->runs()->where('pipeline', 'customer')->latest('id')->first();
if ($run?->status !== ProvisioningRun::STATUS_FAILED
|| ! str_contains((string) $run->error, 'no_subscription')) {
return;
}
// onProvisioningFailed() marked the order failed on the way down.
$order->update(['status' => 'paid']);
$run->update([
'status' => ProvisioningRun::STATUS_PENDING,
'current_step' => 0,
'attempt' => 0,
'next_attempt_at' => null,
'error' => null,
]);
AdvanceRunJob::dispatch($run->uuid);
Log::info('Restarted a run that was stranded without a contract.', [
'order_id' => $order->id, 'run' => $run->uuid,
]);
}
/**
* Freeze what they bought, while the catalogue still says what they were
* shown.
*
* Deliberately AFTER the order is committed, and deliberately swallowing
* its failure. The order is the record that money changed hands: if opening
* the contract could roll it back, an unsellable plan, a missing price or
* any unforeseen fault would erase the evidence of a payment we have
* already taken and Stripe, seeing no 2xx, would retry a webhook that can
* never succeed. A missing contract is recoverable and loud: the run stops
* at ValidateOrder with `no_subscription`, in the operator's face.
*
* Two cases are expected to land here: a plan the catalogue cannot sell,
* and a payment in a currency it cannot price freezing a EUR price onto a
* CHF payment would put the contract and the payment in permanent
* disagreement.
*/
private function openContract(Order $order): void
{
try {
// A checkout that recorded its version is owed THAT version, even
// if the window has closed or the plan has been withdrawn since —
// they paid for what they were shown. Only a purchase with no
// recorded version has to ask what is on sale now.
$deliverable = $order->plan_version_id !== null
? app(PlanCatalogue::class)->isDeliverable($order->plan, $order->plan_version_id)
: Subscription::knowsPlan($order->plan);
$sellable = $deliverable
&& strtoupper((string) $order->currency) === Subscription::catalogueCurrency();
if (! $sellable) {
Log::warning('No contract opened: the catalogue cannot sell this order.', [
'order_id' => $order->id, 'plan' => $order->plan, 'currency' => $order->currency,
]);
return;
}
$subscription = ($this->openSubscription)($order);
// Anything Stripe sent about this contract before it existed —
// a renewal, a failure, a cancellation — applied now, in the order
// they were raised.
$this->billing->replayHeldFor($subscription);
} catch (Throwable $e) {
Log::error('Failed to open a contract for a paid order.', [
'order_id' => $order->id, 'plan' => $order->plan, 'error' => $e->getMessage(),
]);
}
}
/**
* Find or create the customer, race-safe against the unique email index so a
* concurrent first-time purchase can't create two customers for one email.
*
* @param array{email:string,name:?string,stripe_customer_id:?string} $event
*/
private function resolveCustomer(array $event): Customer
{
try {
return Customer::query()->firstOrCreate(
['email' => $event['email']],
['name' => $event['name'] ?? $event['email'], 'stripe_customer_id' => $event['stripe_customer_id'] ?? null],
);
} catch (UniqueConstraintViolationException) {
return Customer::query()->where('email', $event['email'])->firstOrFail();
}
}
}

View File

@ -0,0 +1,49 @@
<?php
namespace App\Actions;
use App\Models\Host;
use App\Models\ProvisioningRun;
use App\Provisioning\Jobs\AdvanceRunJob;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
/**
* Registers a fresh host and kicks off its onboarding run. The one-time root
* password lives encrypted in the run context and is scrubbed after SSH trust
* is established.
*/
class StartHostOnboarding
{
/**
* @param array{name: string, datacenter: string, public_ip: string, root_password: string} $input
*/
public function run(array $input): Host
{
// Host + run are created atomically; a partial insert would otherwise
// leave a permanently pending host with no run.
[$host, $run] = DB::transaction(function () use ($input) {
$host = Host::create([
'name' => $input['name'],
'datacenter' => $input['datacenter'],
'public_ip' => $input['public_ip'],
'status' => 'pending',
]);
$run = ProvisioningRun::create([
'subject_type' => Host::class,
'subject_id' => $host->id,
'pipeline' => 'host',
'status' => ProvisioningRun::STATUS_PENDING,
'current_step' => 0,
'context' => ['root_password' => Crypt::encryptString($input['root_password'])],
]);
return [$host, $run];
});
AdvanceRunJob::dispatch($run->uuid); // after commit — the run definitely exists
return $host;
}
}

View File

@ -0,0 +1,99 @@
<?php
namespace App\Console\Commands;
use App\Models\PlanFamily;
use App\Models\PlanVersion;
use App\Models\Subscription;
use Illuminate\Console\Command;
/**
* Tells the owner whether the catalogue is in a state that can actually sell.
*
* Everything here is computed at read time, which means a broken window or a
* missing price does not announce itself until a customer hits it. This is the
* thing to run after editing plans before finding out from a failed checkout.
*/
class CheckPlanCatalogue extends Command
{
protected $signature = 'plans:check';
protected $description = 'Check the plan catalogue for overlaps, gaps and missing prices';
public function handle(): int
{
$currency = Subscription::catalogueCurrency();
$problems = [];
$now = now();
$families = PlanFamily::query()->with('versions.prices')->orderBy('tier')->get();
if ($families->isEmpty()) {
$this->error('The catalogue is empty. Nothing can be sold.');
return self::FAILURE;
}
foreach ($families as $family) {
$live = $family->versions->filter(fn (PlanVersion $v) => $v->isAvailableAt($now));
if ($live->count() > 1) {
$problems[] = "{$family->key}: {$live->count()} versions on sale at once (".
$live->pluck('version')->implode(', ').') — overlapping windows.';
}
if ($family->sales_enabled && $live->isEmpty()) {
$problems[] = "{$family->key}: on sale, but no version is available right now.";
}
foreach ($family->versions as $version) {
if (! $version->isPublished()) {
continue;
}
foreach ([Subscription::TERM_MONTHLY, Subscription::TERM_YEARLY] as $term) {
$priced = $version->prices
->first(fn ($p) => $p->term === $term && $p->currency === $currency);
if ($priced === null) {
$problems[] = "{$family->key} v{$version->version}: no {$term} price in {$currency}.";
}
}
}
}
// A contract that cannot say which version it was sold under has lost
// its provenance, which is the one thing the version table is for.
$orphaned = Subscription::query()->whereNull('plan_version_id')->count();
if ($orphaned > 0) {
$problems[] = "{$orphaned} subscription(s) have no plan version recorded.";
}
foreach ($families as $family) {
$state = $family->sales_enabled ? 'on sale' : 'withdrawn';
$version = $family->versions->first(fn (PlanVersion $v) => $v->isAvailableAt($now));
$this->line(sprintf(
' %-12s tier %d %-10s %s',
$family->key,
$family->tier,
$state,
$version !== null ? "v{$version->version}" : '—',
));
}
if ($problems === []) {
$this->newLine();
$this->info('Catalogue is consistent.');
return self::SUCCESS;
}
$this->newLine();
foreach ($problems as $problem) {
$this->error(' '.$problem);
}
return self::FAILURE;
}
}

View File

@ -0,0 +1,79 @@
<?php
namespace App\Console\Commands;
use App\Models\Customer;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rules\Password;
use Illuminate\Support\Facades\Validator;
use Spatie\Permission\PermissionRegistrar;
/**
* Creates (or promotes) an operator with the Owner role the account the
* installer sets up so a fresh server is usable immediately.
*
* Promotion is deliberate: running this twice with the same address should fix
* a lost role, not fail with "email already taken".
*/
class CreateAdmin extends Command
{
protected $signature = 'clupilot:create-admin
{--email= : Login address}
{--name= : Display name}
{--password= : Leave empty to be prompted}';
protected $description = 'Create or promote an operator account with the Owner role';
public function handle(): int
{
$email = $this->option('email') ?: $this->ask('Email');
$name = $this->option('name') ?: $this->ask('Name', 'Administrator');
$password = $this->option('password') ?: $this->secret('Password');
$validator = Validator::make(
['email' => $email, 'name' => $name, 'password' => $password],
[
'email' => 'required|email|max:255',
'name' => 'required|string|max:255',
'password' => ['required', Password::min(12)],
],
);
if ($validator->fails()) {
foreach ($validator->errors()->all() as $error) {
$this->error($error);
}
return self::FAILURE;
}
// An operator address that also belongs to a customer would block that
// customer from ever getting a portal login.
if (Customer::query()->where('email', $email)->exists()) {
$this->error('That address already belongs to a customer.');
return self::FAILURE;
}
$user = User::query()->firstOrNew(['email' => $email]);
$existed = $user->exists;
$user->fill([
'name' => $name,
'password' => Hash::make($password),
'is_admin' => true,
'email_verified_at' => $user->email_verified_at ?? now(),
])->save();
$user->syncRoles(['Owner']);
app(PermissionRegistrar::class)->forgetCachedPermissions();
$this->info($existed
? "Existing account {$email} promoted to Owner and password reset."
: "Owner account {$email} created.");
return self::SUCCESS;
}
}

View File

@ -0,0 +1,135 @@
<?php
namespace App\Console\Commands;
use App\Models\PlanFamily;
use App\Models\PlanPrice;
use App\Models\PlanVersion;
use App\Services\Stripe\StripeClient;
use Illuminate\Console\Command;
/**
* Mirrors our catalogue into Stripe: a Product per plan family, a Price per
* priced row.
*
* Stripe owns the recurring billing retries, dunning, off-session SCA,
* invoice numbering so it needs to know what it is billing for. It does not
* need to know how big the VM is, and it is not asked.
*
* Idempotent by construction: a row that already carries an id is skipped. That
* matters more here than usual, because a Stripe Price cannot be edited, so a
* second run that minted duplicates would leave two live prices for one plan
* and no way to tell which a customer is on.
*
* Only PUBLISHED versions are synced. A draft has promised nothing, and a
* Product for it would be a price list entry for something that may never
* exist.
*/
class SyncStripeCatalogue extends Command
{
protected $signature = 'stripe:sync-catalogue {--dry-run : Show what would be created without touching Stripe}';
protected $description = 'Create the Stripe products and prices for the plan catalogue';
public function handle(StripeClient $stripe): int
{
$dryRun = (bool) $this->option('dry-run');
if (! $dryRun && ! $stripe->isConfigured()) {
$this->error('Stripe is not configured (STRIPE_SECRET is empty). Nothing was created.');
return self::FAILURE;
}
$created = 0;
foreach (PlanFamily::query()->with('versions.prices')->orderBy('tier')->get() as $family) {
$published = $family->versions->filter(fn (PlanVersion $version) => $version->isPublished());
// A family whose versions are all drafts has promised nothing, so
// it has no business appearing in Stripe's price list yet.
if ($published->isEmpty()) {
continue;
}
$productId = $family->stripe_product_id;
if ($productId === null) {
$this->line(" product {$family->key}{$family->name}");
$created++;
if (! $dryRun) {
$productId = $stripe->createProduct(
$family->name,
['plan_family' => $family->key, 'plan_family_id' => (string) $family->id],
// Keyed on our row, so a crash between Stripe creating
// the product and us storing its id gives back the same
// product on the next run rather than a second one.
idempotencyKey: "clupilot-product-{$family->id}",
);
$family->update(['stripe_product_id' => $productId]);
}
}
foreach ($published as $version) {
foreach ($version->prices as $price) {
if ($price->stripe_price_id !== null) {
continue;
}
$this->line(sprintf(
' price %s v%d %s %d %s',
$family->key, $version->version, $price->term, $price->amount_cents, $price->currency,
));
$created++;
if ($dryRun || $productId === null) {
continue;
}
$priceId = $stripe->createPrice(
productId: $productId,
amountCents: $price->amount_cents,
currency: $price->currency,
interval: $price->term === 'yearly' ? 'year' : 'month',
metadata: [
'plan_family' => $family->key,
'plan_version' => (string) $version->version,
'plan_version_id' => (string) $version->id,
'plan_price_id' => (string) $price->id,
],
idempotencyKey: "clupilot-price-{$price->id}",
);
// Written straight through the query builder: stripe_price_id
// is not part of what publication froze, and the model would
// otherwise have to be re-read first.
PlanPrice::query()->whereKey($price->id)->update(['stripe_price_id' => $priceId]);
}
}
}
$this->newLine();
if ($created === 0) {
$this->info('Stripe is already in step with the catalogue.');
return self::SUCCESS;
}
$this->info($dryRun
? "{$created} object(s) would be created. Run without --dry-run to create them."
: "{$created} object(s) created in Stripe.");
return self::SUCCESS;
}
/** Versions whose prices are live in Stripe, for the status line. */
public static function syncedVersions(): int
{
return PlanVersion::query()
->whereNotNull('published_at')
->whereHas('prices', fn ($q) => $q->whereNotNull('stripe_price_id'))
->count();
}
}

View File

@ -0,0 +1,46 @@
<?php
namespace App\Console;
use App\Models\ProvisioningRun;
use App\Provisioning\Jobs\AdvanceRunJob;
/**
* Scheduler tick (every minute): dispatch an advance job for every run that is
* due. Immediate dispatch on advance handles the fast path; this catches
* waiting/retrying runs and anything a crashed worker left behind.
*
* PENDING counts as left behind. A run is created and dispatched in two steps,
* and a process that dies between them leaves a paid customer with a run that
* nothing will ever pick up. Re-dispatching one that is merely fresh is free
* the runner takes a per-run lock and the second job returns immediately.
*/
class TickProvisioning
{
/**
* How long a run may sit pending before it counts as stranded.
*
* Long enough that an ordinary queue backlog is not mistaken for a crash:
* sweeping a run whose first job is merely still queued would start a
* second chain of continuations alongside the first, and both would keep
* dispatching for the rest of the pipeline.
*/
private const STRANDED_AFTER_MINUTES = 5;
public function __invoke(): void
{
ProvisioningRun::query()
->where(function ($q) {
$q
->whereIn('status', [ProvisioningRun::STATUS_RUNNING, ProvisioningRun::STATUS_WAITING])
->orWhere(fn ($stranded) => $stranded
->where('status', ProvisioningRun::STATUS_PENDING)
->where('created_at', '<=', now()->subMinutes(self::STRANDED_AFTER_MINUTES)));
})
->where(function ($q) {
$q->whereNull('next_attempt_at')->orWhere('next_attempt_at', '<=', now());
})
->get()
->each(fn (ProvisioningRun $run) => AdvanceRunJob::dispatch($run->uuid));
}
}

View File

@ -2,7 +2,9 @@
namespace App\Http\Controllers;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
abstract class Controller
{
//
use AuthorizesRequests;
}

View File

@ -0,0 +1,41 @@
<?php
namespace App\Http\Controllers;
use App\Models\Customer;
use App\Models\User;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\Auth;
/**
* Admin impersonation: an admin can log in as a customer's portal account to
* inspect their portal during an incident, then return to the admin session.
*/
class ImpersonationController extends Controller
{
/** Admin-gated: become the customer. */
public function start(Customer $customer): RedirectResponse
{
$this->authorize('customers.impersonate');
$user = $customer->ensureUser();
session(['impersonator_id' => Auth::id()]);
Auth::login($user);
return redirect()->route('dashboard');
}
/** Return to the admin session (available while impersonating). */
public function leave(): RedirectResponse
{
$adminId = session()->pull('impersonator_id');
if ($adminId !== null && ($admin = User::query()->find($adminId)) !== null) {
Auth::login($admin);
return redirect()->route('admin.overview');
}
return redirect()->route('dashboard');
}
}

View File

@ -0,0 +1,137 @@
<?php
namespace App\Http\Controllers;
use App\Actions\ApplyStripeBillingEvent;
use App\Actions\StartCustomerProvisioning;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
/**
* Stripe webhook: verifies the signature (when a secret is configured) and turns
* a paid checkout into a provisioning run. Idempotent via the order's
* stripe_event_id Stripe retries a webhook until it gets a 2xx.
*/
class StripeWebhookController extends Controller
{
public function __invoke(
Request $request,
StartCustomerProvisioning $action,
ApplyStripeBillingEvent $billing,
): JsonResponse {
$payload = $request->getContent();
$secret = (string) config('services.stripe.webhook_secret');
if (blank($secret)) {
// Fail closed: an unconfigured secret must not authorize provisioning
// outside local/testing.
abort_unless(app()->environment('local', 'testing'), 400, 'Stripe webhook secret not configured');
} elseif (! $this->signatureValid($payload, (string) $request->header('Stripe-Signature'), $secret)) {
abort(400, 'invalid signature');
}
$event = json_decode($payload, true) ?: [];
$object = $event['data']['object'] ?? [];
$type = $event['type'] ?? '';
// The billing cycle is Stripe's: once a contract exists, they decide
// when it renews, when a payment failed, and when it has ended. Handled
// before the checkout branch because none of these are checkouts.
$applied = $billing->dispatch($event);
if ($applied !== false) {
if ($applied === null) {
// Either already applied, or about a contract that does not
// exist yet — a checkout takes a moment to become one, and
// Stripe does not deliver in order. Holding it costs a row and
// saves a cancellation we would otherwise never hear about
// again; a replay is a no-op if it was simply a duplicate.
$billing->hold($event);
}
// 2xx either way. Stripe retrying would not change the answer, and
// anything worth replaying is now held rather than dropped.
return response()->json(['handled' => $type, 'applied' => $applied !== null]);
}
// Paid triggers: a synchronous checkout (completed + paid) OR an async
// method clearing later (async_payment_succeeded). Ignore everything else,
// including the still-unpaid completed event for async methods.
$paid = ($type === 'checkout.session.completed' && ($object['payment_status'] ?? null) === 'paid')
|| $type === 'checkout.session.async_payment_succeeded';
if (! $paid) {
return response()->json(['ignored' => true]);
}
$meta = $object['metadata'] ?? [];
// A real email is required — never merge unrelated customers under a
// manufactured address or send credentials into the void.
$email = $object['customer_details']['email'] ?? $object['customer_email'] ?? ($meta['email'] ?? null);
if (blank($email)) {
return response()->json(['ignored' => 'no_customer_email']);
}
$action->fromStripeEvent([
// Deduplicate on the checkout session id (stable across the completed
// and async-succeeded events for one purchase), not the event id.
'id' => (string) ($object['id'] ?? $event['id'] ?? ''),
'email' => $email,
'name' => $object['customer_details']['name'] ?? ($meta['name'] ?? null),
'stripe_customer_id' => $object['customer'] ?? null,
// The handle every later billing event arrives with. Without it an
// invoice.paid cannot be matched to the contract it renews.
'stripe_subscription_id' => is_string($object['subscription'] ?? null) ? $object['subscription'] : null,
'plan' => $meta['plan'] ?? 'start',
// What the customer was actually shown. Absent on a session created
// before phase 5 put it there, and then the version on sale applies.
'plan_version_id' => isset($meta['plan_version_id']) ? (int) $meta['plan_version_id'] : null,
'datacenter' => $meta['datacenter'] ?? 'fsn',
'amount_cents' => (int) ($object['amount_total'] ?? $object['amount'] ?? 0),
'currency' => strtoupper($object['currency'] ?? 'eur'),
]);
return response()->json(['ok' => true]);
}
/** Stripe's default replay tolerance, in seconds. */
private const SIGNATURE_TOLERANCE = 300;
/** Verify Stripe's `t=…,v1=…` signature: HMAC-SHA256 of "{t}.{payload}". */
private function signatureValid(string $payload, string $header, string $secret): bool
{
if (blank($header)) {
return false;
}
$timestamp = null;
$signatures = [];
foreach (explode(',', $header) as $segment) {
[$key, $value] = array_pad(explode('=', $segment, 2), 2, '');
if ($key === 't') {
$timestamp = $value;
} elseif ($key === 'v1') {
$signatures[] = $value; // keep every v1 (secret rotation sends several)
}
}
if ($timestamp === null || $signatures === []) {
return false;
}
// Reject replays outside the tolerance window.
if (abs(time() - (int) $timestamp) > self::SIGNATURE_TOLERANCE) {
return false;
}
$expected = hash_hmac('sha256', $timestamp.'.'.$payload, $secret);
foreach ($signatures as $signature) {
if (hash_equals($expected, $signature)) {
return true;
}
}
return false;
}
}

View File

@ -0,0 +1,23 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class EnsureAdmin
{
/**
* Only operators with the console.view capability may reach the admin console.
* Legacy is_admin accounts are migrated to roles by the seed_roles_and_permissions
* migration and the seeder the gate never trusts the bare is_admin flag, so
* an RBAC revocation is never silently undone here.
*/
public function handle(Request $request, Closure $next): Response
{
abort_unless((bool) $request->user()?->can('console.view'), 403);
return $next($request);
}
}

View File

@ -0,0 +1,39 @@
<?php
namespace App\Http\Middleware;
use App\Models\Customer;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Symfony\Component\HttpFoundation\Response;
/**
* Enforce customer account lifecycle on portal requests: a suspended or closed
* customer must lose access. Admins are exempt, and an active impersonation
* session is exempt so operators can still inspect a suspended customer's portal.
*/
class EnsureCustomerActive
{
public function handle(Request $request, Closure $next): Response
{
$user = $request->user();
if ($user !== null && ! $user->isOperator() && ! $request->session()->has('impersonator_id')) {
$customer = Customer::query()->where('user_id', $user->id)->first()
?? Customer::query()->where('email', $user->email)->first();
if ($customer !== null && ($customer->status === 'suspended' || $customer->status === 'closed' || $customer->closed_at !== null)) {
Auth::logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
$key = $customer->closed_at !== null || $customer->status === 'closed' ? 'auth.account_closed' : 'auth.account_suspended';
return redirect()->route('login')->withErrors(['email' => __($key)]);
}
}
return $next($request);
}
}

View File

@ -0,0 +1,68 @@
<?php
namespace App\Http\Middleware;
use App\Support\Settings;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\IpUtils;
use Symfony\Component\HttpFoundation\Response;
/**
* Hides the marketing site and the customer portal while the product is still
* being built, without hiding it from us.
*
* Switched from the console (site.public). Anyone coming through the management
* VPN, and any signed-in operator, sees the real thing; everyone else crawlers
* included gets a placeholder.
*
* Answers 503 with Retry-After and X-Robots-Tag rather than 200: a 200 would
* invite search engines to index the placeholder as the site's content, and
* getting that out of an index again is much harder than keeping it out.
*/
class PublicSiteGate
{
/**
* Paths that must keep working regardless: the console itself (otherwise the
* switch could not be flipped back), Stripe's webhook, and the health check.
*
* Livewire is deliberately NOT in this list. Its endpoint is shared by the
* console and the portal, so exempting it would leave a signed-in customer
* able to drive portal components including billing while the portal is
* supposed to be offline. Operators pass the check below anyway, which is
* what the console actually needs.
*
* Nor is /login, and that has a consequence worth knowing before you hide
* the site: /admin sends a guest to /login, which is not the console, so
* SIGNING IN while hidden needs the request to come from a trusted range
* the management VPN, or an address put in TRUSTED_RANGES for the initial
* setup. Exempting the login flow by hostname instead would mean trusting a
* Host header, which the caller chooses, and one forged header would unhide
* the entire portal.
*/
private const ALWAYS_ALLOWED = ['admin', 'admin/*', 'webhooks/*', 'up', 'robots.txt'];
public function handle(Request $request, Closure $next): Response
{
if ($request->is(...self::ALWAYS_ALLOWED) || Settings::bool('site.public', true)) {
return $next($request);
}
if ($this->fromManagementNetwork($request) || $request->user()?->isOperator()) {
return $next($request);
}
return response()->view('coming-soon', [], 503)->withHeaders([
'Retry-After' => 3600,
'X-Robots-Tag' => 'noindex, nofollow, noarchive',
'Cache-Control' => 'no-store',
]);
}
private function fromManagementNetwork(Request $request): bool
{
$ranges = (array) config('admin_access.trusted_ranges', []);
return $ranges !== [] && IpUtils::checkIp((string) $request->ip(), $ranges);
}
}

View File

@ -0,0 +1,48 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* Serves the operator console only on the hostnames listed in ADMIN_HOSTS.
*
* Responds 404 (not 403) on any other host: a public domain must not even
* disclose that an admin console exists here. Empty list = no restriction, so
* this can be rolled out without locking anyone out.
*
* Host-based rather than IP-based on purpose: behind a reverse proxy without
* TrustProxies, request->ip() is the proxy's address, so an IP allowlist would
* be misleading. The real network-level control stays in the proxy.
*/
class RestrictAdminHost
{
public function handle(Request $request, Closure $next): Response
{
// Path-scoped and prepended to the `web` group rather than attached to
// the admin route group: route middleware is reordered by Laravel's
// priority list, which puts `auth` first — a guest would then be
// redirected to /login and thereby learn the console exists. Running
// ahead of the whole route stack makes the 404 deterministic.
if (! $request->is('admin', 'admin/*')) {
return $next($request);
}
// Lowercased here as well as in the config: DNS names are
// case-insensitive and getHost() always returns lowercase, so a
// capitalised ADMIN_HOSTS entry must not lock operators out — whatever
// route the value took into the config (env, cached config, runtime set).
$allowed = array_map(
fn ($host) => strtolower((string) $host),
(array) config('admin_access.hosts', []),
);
if ($allowed !== [] && ! in_array($request->getHost(), $allowed, true)) {
abort(404);
}
return $next($request);
}
}

View File

@ -0,0 +1,60 @@
<?php
namespace App\Livewire\Admin;
use App\Models\Datacenter;
use App\Models\Host;
use Illuminate\Database\QueryException;
use LivewireUI\Modal\ModalComponent;
/**
* Confirmation for deleting a datacenter (R5). Guarded: a datacenter that still
* has hosts cannot be deleted it would orphan host placement so the modal
* blocks and explains instead. Otherwise it is safe to hard-delete an unused code.
*/
class ConfirmDeleteDatacenter extends ModalComponent
{
public string $uuid;
public string $name = '';
public int $hostCount = 0;
public function mount(string $uuid): void
{
$this->authorize('datacenters.manage'); // modals are reachable without the route middleware
$dc = Datacenter::query()->where('uuid', $uuid)->withCount('hosts')->firstOrFail();
$this->uuid = $uuid;
$this->name = $dc->name;
$this->hostCount = $dc->hosts_count;
}
public function delete()
{
$this->authorize('datacenters.manage');
$dc = Datacenter::query()->where('uuid', $this->uuid)->first();
if ($dc === null) {
return $this->redirectRoute('admin.datacenters', navigate: true);
}
// The hosts.datacenter → datacenters.code foreign key (restrictOnDelete)
// is the source of truth: the DB refuses to orphan a host. We pre-check
// for a friendly message and catch the constraint as the race backstop.
if (Host::query()->where('datacenter', $dc->code)->exists()) {
return $this->redirectRoute('admin.datacenters', navigate: true);
}
try {
$dc->delete();
} catch (QueryException) {
// A host was created for this code concurrently — leave it intact.
}
return $this->redirectRoute('admin.datacenters', navigate: true);
}
public function render()
{
return view('livewire.admin.confirm-delete-datacenter');
}
}

View File

@ -0,0 +1,60 @@
<?php
namespace App\Livewire\Admin;
use App\Models\PlanVersion;
use App\Services\Billing\PlanCatalogue;
use LivewireUI\Modal\ModalComponent;
/**
* Confirmation for discarding an unpublished plan version.
*
* Only a draft can be discarded at all a published version is what customers
* are contracted to, and the model refuses to delete one. So this is a small
* destructive action, and the modal says which one it is rather than leaving
* "delete" next to a row that might be either.
*/
class ConfirmDeletePlanDraft extends ModalComponent
{
public string $uuid;
public int $version;
public string $plan = '';
public function mount(string $uuid): void
{
// Modals are reachable without the page's guards, so this is the real
// check, not a convenience one.
$this->authorize('plans.manage');
$version = PlanVersion::query()->with('family')->where('uuid', $uuid)->firstOrFail();
abort_if($version->isPublished(), 403);
$this->uuid = $uuid;
$this->version = $version->version;
$this->plan = $version->family->name;
}
public function delete(): void
{
$this->authorize('plans.manage');
$version = PlanVersion::query()->where('uuid', $this->uuid)->first();
// The catalogue decides, in one conditional statement. Checking here and
// deleting afterwards would leave a window in which the version is
// published between the two — and a published version must never go.
if ($version !== null && ! app(PlanCatalogue::class)->discardDraft($version)) {
$this->dispatch('notify', message: __('plans.discard_too_late'));
}
$this->dispatch('plan-draft-deleted');
$this->closeModal();
}
public function render()
{
return view('livewire.admin.confirm-delete-plan-draft');
}
}

View File

@ -0,0 +1,58 @@
<?php
namespace App\Livewire\Admin;
use App\Models\VpnPeer;
use App\Provisioning\Jobs\ApplyVpnPeer;
use LivewireUI\Modal\ModalComponent;
/**
* Confirmation for revoking a VPN access for good (R5). Deleting a peer that
* belongs to a host cuts CluPilot's own management path to that machine, so the
* modal says so plainly rather than hiding the option.
*/
class ConfirmDeleteVpnPeer extends ModalComponent
{
public string $uuid;
public string $name = '';
public ?string $hostName = null;
public function mount(string $uuid): void
{
// Modals are reachable without the route middleware, so this is the
// real guard, not a convenience check.
$peer = VpnPeer::query()->with('host')->where('uuid', $uuid)->firstOrFail();
$this->authorize('delete', $peer);
$this->uuid = $uuid;
$this->name = $peer->name;
$this->hostName = $peer->host?->name;
}
public function delete(): void
{
$peer = VpnPeer::query()->where('uuid', $this->uuid)->first();
if ($peer !== null) {
$this->authorize('delete', $peer);
// A tombstone that still carries a usable private key is a liability.
$peer->purgeSecret();
// Soft-delete, so the row survives as a tombstone until the hub has
// actually dropped the peer. A hard delete here would let the next
// sync adopt the still-present peer back as a live access —
// silently restoring what was just revoked. ApplyVpnPeer purges the
// tombstone once removal succeeded; SyncVpnPeers retries if not.
$peer->delete();
ApplyVpnPeer::dispatch($peer->public_key, null, false);
}
$this->dispatch('vpn-peer-deleted');
$this->closeModal();
}
public function render()
{
return view('livewire.admin.confirm-delete-vpn-peer');
}
}

View File

@ -0,0 +1,46 @@
<?php
namespace App\Livewire\Admin;
use App\Models\Host;
use App\Provisioning\Jobs\PurgeHost;
use LivewireUI\Modal\ModalComponent;
/**
* Confirmation for the destructive "remove host" action (R5). Deregisters the
* CluPilot record only it does not touch the physical server. The host is
* deactivated immediately and purged asynchronously so removal never blocks on
* (or races) an in-flight provisioning step.
*/
class ConfirmRemoveHost extends ModalComponent
{
public string $uuid;
public string $name = '';
public function mount(string $uuid): void
{
$this->uuid = $uuid;
$this->name = Host::query()->where('uuid', $uuid)->value('name') ?? '';
}
public function remove()
{
$this->authorize('hosts.manage');
$host = Host::query()->where('uuid', $this->uuid)->first();
if ($host !== null) {
// Deactivate now (out of placement, visibly removed), finalize on the
// provisioning worker which waits for the runner lock.
$host->update(['status' => 'disabled']);
PurgeHost::dispatch($host->uuid);
}
return $this->redirectRoute('admin.hosts', navigate: true);
}
public function render()
{
return view('livewire.admin.confirm-remove-host');
}
}

View File

@ -0,0 +1,112 @@
<?php
namespace App\Livewire\Admin;
use App\Models\Customer;
use App\Models\PlanFamily;
use App\Services\Billing\PlanCatalogue;
use Illuminate\Support\Number;
use Livewire\Attributes\Layout;
use Livewire\Component;
#[Layout('layouts.admin')]
class Customers extends Component
{
/** Suspend / reactivate a customer account (a guarded lifecycle action, not delete). */
public function toggleSuspend(string $uuid): void
{
$this->authorize('customers.manage');
$customer = Customer::query()->where('uuid', $uuid)->first();
if ($customer === null) {
return;
}
// A closed account is terminal — the suspend/reactivate toggle must not
// resurrect it to active while closed_at is still set.
if ($customer->closed_at !== null || $customer->status === 'closed') {
return;
}
$customer->update([
'status' => $customer->status === 'suspended' ? 'active' : 'suspended',
]);
$this->dispatch('notify', message: __('admin.customer_'.($customer->status === 'suspended' ? 'suspended' : 'reactivated')));
}
public function render()
{
$locale = app()->getLocale();
$plans = app(PlanCatalogue::class)->sellable();
$customers = Customer::query()
->with(['instances.subscription'])
->orderBy('name')
->get();
$rows = $customers->map(function (Customer $c) use ($plans, $locale) {
$instance = $c->instances->sortByDesc('id')->first();
$planKey = $instance->plan ?? null;
// What this customer actually pays, off their contract — not what
// the plan costs today. Otherwise a price rise inflates reported
// revenue for every grandfathered customer overnight.
//
// Per MONTH, whatever the term: a yearly contract stores the whole
// year, and adding that to a monthly column would report twelve
// times the revenue for anyone who paid up front.
$contract = $instance?->subscription;
$priceCents = (int) ($contract?->monthlyPriceCents()
?? ($planKey !== null ? ($plans[$planKey]['price_cents'] ?? 0) : 0));
return [
'uuid' => $c->uuid,
'name' => $c->name,
'plan' => $planKey !== null ? __('billing.plan.'.$planKey) : '—',
'mrr' => Number::currency($priceCents / 100, in: 'EUR', locale: $locale),
'instance' => $instance->subdomain ?? '—',
// Only an instance that exists can hand out an admin login.
'instance_uuid' => ($instance?->status === 'active') ? $instance->uuid : null,
'closed' => $c->closed_at !== null || $c->status === 'closed',
'suspended' => $c->status === 'suspended',
'status' => match (true) {
$c->closed_at !== null || $c->status === 'closed' => 'closed',
$c->status === 'suspended' => 'suspended',
default => $instance->status ?? $c->status ?? 'provisioning',
},
];
})->all();
// Plan distribution for the doughnut — derived from live instances, and
// keyed by what customers are actually ON, not by what is on sale. A
// withdrawn plan still has customers, and dropping them from the chart
// would quietly understate the estate.
$labels = [];
$counts = [];
foreach (PlanFamily::query()->orderBy('tier')->pluck('key') as $planKey) {
$n = $customers->filter(fn (Customer $c) => $c->instances->contains('plan', $planKey))->count();
if ($n > 0) {
$labels[] = __('billing.plan.'.$planKey);
$counts[] = $n;
}
}
return view('livewire.admin.customers', [
'rows' => $rows,
'plansChart' => [
'type' => 'doughnut',
'data' => [
'labels' => $labels,
'datasets' => [[
'data' => $counts,
'backgroundColor' => ['token:info', 'token:accent', 'token:success-bright', 'token:warning'],
'borderWidth' => 0,
]],
],
'options' => [
'cutout' => '62%',
'plugins' => ['legend' => ['position' => 'bottom', 'labels' => ['boxWidth' => 10, 'padding' => 12]]],
],
],
]);
}
}

View File

@ -0,0 +1,67 @@
<?php
namespace App\Livewire\Admin;
use App\Models\Datacenter;
use Illuminate\Validation\Rule;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Validate;
use Livewire\Component;
#[Layout('layouts.admin')]
class Datacenters extends Component
{
#[Validate(['required', 'string', 'max:8', 'regex:/^[a-z0-9]([a-z0-9-]{0,6}[a-z0-9])?$/', 'unique:datacenters,code'])]
public string $code = '';
#[Validate('required|string|max:255')]
public string $name = '';
// Country picked from config/countries.php (no manual code typos).
#[Validate('nullable|string|max:2')]
public string $location = '';
public function save(): void
{
$this->authorize('datacenters.manage');
// Normalize before validating so the unique check matches how the row is
// stored — otherwise `FSN` passes the rule but the lowercased insert collides.
$this->code = strtolower(trim($this->code));
// Country must be one of the curated list — a forged request can bypass
// the <select>, so enforce membership server-side (mirrors EditDatacenter).
$data = $this->validate([
// A datacenter code becomes a DNS label (fsn-01.node.clupilot.com),
// so it has to be one: lowercase, no underscores, no leading or
// trailing dash.
'code' => ['required', 'string', 'max:8', 'regex:/^[a-z0-9]([a-z0-9-]{0,6}[a-z0-9])?$/', 'unique:datacenters,code'],
'name' => 'required|string|max:255',
'location' => ['nullable', Rule::in(array_keys((array) config('countries')))],
]);
Datacenter::create([
'code' => $data['code'],
'name' => $data['name'],
'location' => $data['location'] ?: null,
'active' => true,
]);
$this->reset('code', 'name', 'location');
$this->dispatch('notify', message: __('datacenters.created'));
}
public function toggle(string $uuid): void
{
$this->authorize('datacenters.manage');
$datacenter = Datacenter::query()->where('uuid', $uuid)->first();
$datacenter?->update(['active' => ! $datacenter->active]);
}
public function render()
{
return view('livewire.admin.datacenters', [
'datacenters' => Datacenter::query()->withCount('hosts')->orderBy('name')->get(),
'countries' => config('countries'),
]);
}
}

View File

@ -0,0 +1,74 @@
<?php
namespace App\Livewire\Admin;
use App\Models\Datacenter;
use Illuminate\Validation\Rule;
use Livewire\Attributes\Validate;
use LivewireUI\Modal\ModalComponent;
/**
* Edit a datacenter's name and location in a modal (avoids the row-height jump
* of inline editing). The code is immutable it is referenced by hosts. Country
* is picked from a list so it can't be mistyped.
*/
class EditDatacenter extends ModalComponent
{
public string $uuid = '';
public string $code = '';
#[Validate('required|string|max:255')]
public string $name = '';
public string $location = '';
/** The location present when the modal opened — a pre-curation free-form value. */
public string $originalLocation = '';
public function mount(string $uuid): void
{
$this->authorize('datacenters.manage'); // modals are reachable without the route middleware
$dc = Datacenter::query()->where('uuid', $uuid)->firstOrFail();
$this->uuid = $uuid;
$this->code = $dc->code;
$this->name = $dc->name;
$this->location = (string) $dc->location;
$this->originalLocation = (string) $dc->location;
}
public function save()
{
$this->authorize('datacenters.manage');
// Accept the configured countries plus the record's own legacy value, so a
// datacenter created before the curated list can still be edited. The
// legacy value is read from the DB — never from the client-hydrated
// property, which could be forged to whitelist an arbitrary location.
$dc = Datacenter::query()->where('uuid', $this->uuid)->firstOrFail();
$allowed = array_keys((array) config('countries'));
if ($dc->location) {
$allowed[] = $dc->location;
}
$data = $this->validate([
'name' => 'required|string|max:255',
'location' => ['nullable', Rule::in($allowed)], // array form — a legacy value may contain commas
]);
Datacenter::query()->where('uuid', $this->uuid)->update([
'name' => $data['name'],
'location' => $data['location'] ?: null,
]);
$this->dispatch('notify', message: __('datacenters.updated'));
return $this->redirectRoute('admin.datacenters', navigate: true);
}
public function render()
{
return view('livewire.admin.edit-datacenter', [
'countries' => config('countries'),
]);
}
}

View File

@ -0,0 +1,46 @@
<?php
namespace App\Livewire\Admin;
use App\Actions\StartHostOnboarding;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Validate;
use Livewire\Component;
#[Layout('layouts.admin')]
class HostCreate extends Component
{
#[Validate('required|string|max:255')]
public string $name = '';
#[Validate('required|string|exists:datacenters,code,active,1')]
public string $datacenter = '';
public function mount(): void
{
$this->datacenter = (string) \App\Models\Datacenter::query()->active()->orderBy('name')->value('code');
}
#[Validate('required|ip|unique:hosts,public_ip')]
public string $public_ip = '';
#[Validate('required|string|min:8')]
public string $root_password = '';
public function save(StartHostOnboarding $action)
{
$this->authorize('hosts.manage');
$data = $this->validate();
$host = $action->run($data);
return $this->redirectRoute('admin.hosts.show', ['host' => $host->uuid], navigate: true);
}
public function render()
{
return view('livewire.admin.host-create', [
'datacenters' => \App\Models\Datacenter::query()->active()->orderBy('name')->get(),
]);
}
}

View File

@ -0,0 +1,117 @@
<?php
namespace App\Livewire\Admin;
use App\Models\Host;
use App\Models\ProvisioningRun;
use App\Provisioning\Jobs\AdvanceRunJob;
use Illuminate\Support\Collection;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Component;
#[Layout('layouts.admin')]
class HostDetail extends Component
{
public Host $host;
public function mount(Host $host): void
{
$this->host = $host;
}
/** Live refresh whenever any run advances (admins-only channel). */
#[On('echo-private:admin.runs,StepAdvanced')]
public function onStepAdvanced(): void
{
$this->host->refresh();
}
/** Adjust the capacity reserve (% of storage kept free for headroom). */
public function saveReserve(int $reserve): void
{
$this->authorize('hosts.manage');
$reserve = max(0, min(90, $reserve));
$this->host->update(['reserve_pct' => $reserve]);
$this->dispatch('notify', message: __('hosts.detail.reserve_saved'));
}
/**
* Drain / return a host: toggle between active and disabled. Disabled takes
* it out of placement (maintenance) without purging it distinct from the
* destructive "remove host". Never touches a host mid-onboarding.
*/
public function toggleMaintenance(): void
{
$this->authorize('hosts.manage');
if ($this->host->status === 'active') {
$this->host->update(['status' => 'disabled']);
} elseif ($this->host->status === 'disabled') {
$this->host->update(['status' => 'active']);
}
}
public function retry(): void
{
$this->authorize('hosts.manage');
$run = $this->currentRun();
if ($run !== null && $run->status === ProvisioningRun::STATUS_FAILED) {
$run->update([
'status' => ProvisioningRun::STATUS_RUNNING,
'attempt' => 0,
'next_attempt_at' => now(),
'started_at' => now(), // reset the step timer so it doesn't re-time-out instantly
'error' => null,
]);
$this->host->update(['status' => 'onboarding']);
AdvanceRunJob::dispatch($run->uuid);
}
}
private function currentRun(): ?ProvisioningRun
{
return $this->host->runs()->latest('id')->first();
}
/** @return array<int, array{label: string, state: string}> */
private function buildSteps(?ProvisioningRun $run): array
{
$pipeline = config('provisioning.pipelines.host', []);
$current = $run?->current_step ?? 0;
$status = $run?->status;
$steps = [];
foreach ($pipeline as $index => $class) {
if ($status === ProvisioningRun::STATUS_COMPLETED || $index < $current) {
$state = 'done';
} elseif ($index === $current) {
$state = $status === ProvisioningRun::STATUS_FAILED ? 'failed' : 'running';
} else {
$state = 'pending';
}
$steps[] = ['label' => __(app($class)->label()), 'state' => $state];
}
return $steps;
}
public function render()
{
$run = $this->currentRun();
/** @var Collection $events */
$events = $run
? $run->events()->latest('id')->limit(30)->get()
: collect();
return view('livewire.admin.host-detail', [
'run' => $run,
'steps' => $this->buildSteps($run),
'events' => $events,
'instances' => $this->host->instances()->latest('id')->get(),
'health' => $this->host->healthState(),
]);
}
}

View File

@ -0,0 +1,53 @@
<?php
namespace App\Livewire\Admin;
use App\Models\Datacenter;
use App\Models\Host;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Url;
use Livewire\Component;
#[Layout('layouts.admin')]
class Hosts extends Component
{
#[Url(as: 'q')]
public string $search = '';
#[Url]
public string $datacenter = '';
#[Url]
public string $status = '';
public function updated(): void
{
// no-op; keeps the query string in sync as filters change
}
public function clearFilters(): void
{
$this->reset('search', 'datacenter', 'status');
}
public function render()
{
$hosts = Host::query()
->withCount('instances')
->when($this->search !== '', function ($q) {
$term = '%'.$this->search.'%';
$q->where(fn ($w) => $w->where('name', 'like', $term)->orWhere('public_ip', 'like', $term));
})
->when($this->datacenter !== '', fn ($q) => $q->where('datacenter', $this->datacenter))
->when($this->status !== '', fn ($q) => $q->where('status', $this->status))
->orderBy('datacenter')->orderBy('name')
->get();
return view('livewire.admin.hosts', [
'hosts' => $hosts,
'datacenters' => Datacenter::query()->orderBy('name')->get(),
'statuses' => ['pending', 'onboarding', 'active', 'error', 'disabled'],
'total' => Host::query()->count(),
]);
}
}

View File

@ -0,0 +1,107 @@
<?php
namespace App\Livewire\Admin;
use App\Models\Instance;
use App\Provisioning\Jobs\IssueInstanceAdminAccess;
use App\Services\Wireguard\ConfigHandoff;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Str;
use LivewireUI\Modal\ModalComponent;
/**
* Administrator access to a customer's Nextcloud.
*
* Not impersonation: that borrows a portal session. This resets our managed
* admin account inside the customer's installation and hands the credentials
* over once the only thing stock Nextcloud offers, and honest about what it
* does rather than pretending to be a passwordless jump.
*
* The operator's own password is required, every time. Taking control of a
* customer's installation is not something an unattended browser should be able
* to do on its own.
*/
class InstanceAdminAccess extends ModalComponent
{
public string $uuid;
public string $subdomain = '';
public string $password = '';
/** Opaque handle; the credentials never enter the component snapshot. */
public ?string $token = null;
public bool $waiting = false;
public function mount(string $uuid): void
{
$this->authorize('instances.adminlogin');
$instance = Instance::query()->where('uuid', $uuid)->firstOrFail();
$this->uuid = $uuid;
$this->subdomain = $instance->subdomain;
}
public function hydrate(): void
{
$this->authorize('instances.adminlogin');
}
public function request(): void
{
$this->authorize('instances.adminlogin');
$this->resetErrorBag('password');
$key = 'instance-admin:'.auth()->id();
if (RateLimiter::tooManyAttempts($key, 5)) {
$this->addError('password', __('instances.too_many_attempts', ['seconds' => RateLimiter::availableIn($key)]));
return;
}
if (! Hash::check($this->password, auth()->user()->password)) {
RateLimiter::hit($key, 300);
$this->addError('password', __('instances.wrong_password'));
return;
}
RateLimiter::clear($key);
$this->reset('password');
// The reset runs on the provisioning worker — it owns the tunnel. The
// token is where it will leave the result.
$this->token = Str::random(40);
$this->waiting = true;
IssueInstanceAdminAccess::dispatch($this->uuid, $this->token, (int) auth()->id());
}
public function render()
{
$payload = null;
if ($this->token !== null) {
$raw = ConfigHandoff::get($this->token);
if ($raw !== null) {
// Consumed on the first read: leaving it in the cache would make
// "shown once" a figure of speech — any replayed component
// request could fetch the password again for ten minutes. The
// payload lives in a local variable, so it reaches the view and
// nothing else.
ConfigHandoff::forget($this->token);
$this->token = null;
$this->waiting = false;
$payload = json_decode($raw, true);
}
}
return view('livewire.admin.instance-admin-access', [
'credentials' => isset($payload['password']) ? $payload : null,
'failed' => isset($payload['error']) ? $payload['error'] : null,
]);
}
}

View File

@ -0,0 +1,25 @@
<?php
namespace App\Livewire\Admin;
use Livewire\Attributes\Layout;
use Livewire\Component;
#[Layout('layouts.admin')]
class Instances extends Component
{
public function render()
{
return view('livewire.admin.instances', [
'rows' => [
['sub' => 'kanzlei-berger', 'host' => 'pve-fsn-1', 'vmid' => 1042, 'storage' => '235 / 500 GB', 'version' => 'NC 31.0.4', 'status' => 'active'],
['sub' => 'stb-reiss', 'host' => 'pve-fsn-1', 'vmid' => 1051, 'storage' => '88 / 250 GB', 'version' => 'NC 31.0.4', 'status' => 'active'],
['sub' => 'notariat-kuelz', 'host' => 'pve-fsn-2', 'vmid' => 1067, 'storage' => '612 / 1000 GB', 'version' => 'NC 31.0.4', 'status' => 'active'],
['sub' => 'ordination-fux', 'host' => 'pve-fsn-2', 'vmid' => 1072, 'storage' => '2 / 100 GB', 'version' => 'NC 31.0.4', 'status' => 'provisioning'],
['sub' => 'buero-lang', 'host' => 'pve-fsn-3', 'vmid' => 1074, 'storage' => '—', 'version' => 'NC 31.0.4', 'status' => 'provisioning'],
['sub' => 'weingut-prantl', 'host' => 'pve-hel-1', 'vmid' => 1075, 'storage' => '4 / 100 GB', 'version' => 'NC 31.0.4', 'status' => 'provisioning'],
['sub' => 'praxis-sommer', 'host' => 'pve-fsn-3', 'vmid' => 1039, 'storage' => '41 / 100 GB', 'version' => 'NC 31.0.4', 'status' => 'suspended'],
],
]);
}
}

View File

@ -0,0 +1,248 @@
<?php
namespace App\Livewire\Admin;
use App\Models\Datacenter;
use App\Models\Host;
use App\Models\MaintenanceWindow;
use App\Services\Maintenance\MaintenanceNotifier;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Validate;
use Livewire\Component;
#[Layout('layouts.admin')]
class Maintenance extends Component
{
#[Validate('required|string|max:255')]
public string $title = '';
#[Validate('nullable|string|max:2000')]
public string $publicDescription = '';
#[Validate('nullable|string|max:2000')]
public string $internalNotes = '';
#[Validate('required|date')]
public string $startsAt = '';
#[Validate('required|date')]
public string $endsAt = '';
/** @var array<int> */
public array $hostIds = [];
/** Toggle every host of one datacenter (select-all / deselect-all). */
public function selectDatacenter(string $code): void
{
$this->authorize('maintenance.manage');
$ids = Host::query()->where('datacenter', $code)->pluck('id')->map(fn ($id) => (string) $id)->all();
$current = array_map('strval', $this->hostIds);
$this->hostIds = empty(array_diff($ids, $current))
? array_values(array_diff($current, $ids)) // all selected → clear them
: array_values(array_unique([...$current, ...$ids]));
}
/**
* Set the end from the start. Typing a full timestamp by hand is the
* fiddliest part of this form, and the end is almost always "start plus a
* round number of minutes".
*/
public function setDuration(int $minutes): void
{
$this->authorize('maintenance.manage');
$start = $this->parsed($this->startsAt);
if ($start === null) {
// No start yet: assume the next half hour, which is what someone
// scheduling a window in a hurry means anyway.
$start = now()->addMinutes(30 - (now()->minute % 30))->startOfMinute();
$this->startsAt = $start->format('Y-m-d\TH:i');
}
$this->endsAt = $start->copy()->addMinutes($minutes)->format('Y-m-d\TH:i');
}
/** Minutes between start and end, or null while either is unusable. */
public function durationMinutes(): ?int
{
$start = $this->parsed($this->startsAt);
$end = $this->parsed($this->endsAt);
if ($start === null || $end === null || $end->lessThanOrEqualTo($start)) {
return null;
}
return (int) $start->diffInMinutes($end);
}
private function parsed(string $value): ?\Illuminate\Support\Carbon
{
if (trim($value) === '') {
return null;
}
try {
return \Illuminate\Support\Carbon::parse($value);
} catch (\Throwable) {
return null; // half-typed input is normal while the field is live
}
}
public function saveDraft(): void
{
$this->authorize('maintenance.manage');
$this->persist('draft');
}
public function publish(): void
{
$this->authorize('maintenance.manage');
$window = $this->persist('scheduled');
if ($window !== null) {
app(MaintenanceNotifier::class)->announce($window);
}
}
private function persist(string $state): ?MaintenanceWindow
{
$data = $this->validate([
'title' => 'required|string|max:255',
'publicDescription' => 'nullable|string|max:2000',
'internalNotes' => 'nullable|string|max:2000',
'startsAt' => 'required|date',
'endsAt' => 'required|date',
'hostIds' => 'array',
'hostIds.*' => 'integer|exists:hosts,id',
]);
$starts = Carbon::parse($data['startsAt']);
$ends = Carbon::parse($data['endsAt']);
if ($ends->lessThanOrEqualTo($starts)) {
$this->addError('endsAt', __('maintenance.end_after_start'));
return null;
}
// A window always needs a host — otherwise a hostless draft can never be
// published (there is no edit-hosts action) and is stuck.
if (empty($this->hostIds)) {
$this->addError('hostIds', __('maintenance.need_host'));
return null;
}
if ($state === 'scheduled' && $ends->isPast()) {
$this->addError('endsAt', __('maintenance.end_future'));
return null;
}
// Create the window and attach hosts atomically so a bad id can't leave
// an orphaned scheduled window behind.
$window = DB::transaction(function () use ($data, $starts, $ends, $state) {
$window = MaintenanceWindow::create([
'title' => $data['title'],
'public_description' => $data['publicDescription'] ?: null,
'internal_notes' => $data['internalNotes'] ?: null,
'starts_at' => $starts,
'ends_at' => $ends,
'state' => $state,
'created_by' => auth()->id(),
'published_at' => $state === 'scheduled' ? now() : null,
]);
$window->hosts()->sync(array_map('intval', $data['hostIds'] ?? []));
return $window;
});
$this->reset('title', 'publicDescription', 'internalNotes', 'startsAt', 'endsAt', 'hostIds');
$this->dispatch('notify', message: __($state === 'scheduled' ? 'maintenance.published' : 'maintenance.draft_saved'));
return $window;
}
public function publishExisting(string $uuid): void
{
$this->authorize('maintenance.manage');
$window = MaintenanceWindow::query()->where('uuid', $uuid)->first();
if ($window === null || $window->state !== 'draft') {
return;
}
if ($window->hosts()->count() === 0 || $window->ends_at->isPast()) {
$this->dispatch('notify', message: __('maintenance.need_host'));
return;
}
$window->update(['state' => 'scheduled', 'published_at' => now()]);
app(MaintenanceNotifier::class)->announce($window);
$this->dispatch('notify', message: __('maintenance.published'));
}
/**
* Re-run announcements for a published window. Idempotent (ledger-guarded),
* so it only fills gaps the retry path when a transient queue outage left
* some affected customers un-notified.
*/
public function resend(string $uuid): void
{
$this->authorize('maintenance.manage');
$window = MaintenanceWindow::query()->where('uuid', $uuid)->first();
// Only announce for a window that has not yet ended (derived state).
if ($window === null || ! in_array($window->derivedState(), ['upcoming', 'active'], true)) {
return;
}
app(MaintenanceNotifier::class)->announce($window);
$this->dispatch('notify', message: __('maintenance.notified'));
}
public function cancel(string $uuid): void
{
$this->authorize('maintenance.manage');
$window = MaintenanceWindow::query()->where('uuid', $uuid)->first();
// Cannot cancel what is already cancelled or has already completed.
if ($window === null || in_array($window->derivedState(), ['cancelled', 'completed'], true)) {
return;
}
$wasPublished = $window->state === 'scheduled';
$window->update(['state' => 'cancelled', 'cancelled_at' => now()]);
// Customers who received an announcement are told it's cancelled. The
// MessageSending listener suppresses still-pending announcements, and the
// MessageSent listener catches the race (an announcement that delivers
// just as we cancel) by queuing a catch-up cancellation for it.
if ($wasPublished) {
app(MaintenanceNotifier::class)->notifyCancellation($window);
}
$this->dispatch('notify', message: __('maintenance.cancelled'));
}
public function render()
{
$windows = MaintenanceWindow::query()
->withCount('hosts')
->orderByDesc('starts_at')
->get()
->map(fn (MaintenanceWindow $w) => [
'uuid' => $w->uuid,
'title' => $w->title,
'starts_at' => $w->starts_at,
'ends_at' => $w->ends_at,
'state' => $w->derivedState(),
'hosts' => $w->hosts_count,
'affected' => $w->affectedCustomers()->count(),
'is_draft' => $w->state === 'draft',
'notifiable' => $w->state === 'scheduled' && in_array($w->derivedState(), ['upcoming', 'active'], true),
'cancellable' => in_array($w->derivedState(), ['draft', 'upcoming', 'active'], true),
]);
return view('livewire.admin.maintenance', [
'windows' => $windows,
'datacenters' => Datacenter::query()->orderBy('name')->get(),
'hosts' => Host::query()->orderBy('datacenter')->orderBy('name')->get(),
'durationMinutes' => $this->durationMinutes(),
]);
}
}

View File

@ -0,0 +1,82 @@
<?php
namespace App\Livewire\Admin;
use Illuminate\Support\Carbon;
use Illuminate\Support\Number;
use Livewire\Attributes\Layout;
use Livewire\Component;
#[Layout('layouts.admin')]
class Overview extends Component
{
public function render()
{
$locale = app()->getLocale();
$months = collect(range(11, 0))
->map(fn ($i) => Carbon::now()->locale($locale)->startOfMonth()->subMonths($i)->isoFormat('MMM'))
->all();
return view('livewire.admin.overview', [
'kpis' => [
['label' => __('admin.kpi.customers'), 'value' => '42', 'delta' => '+3', 'tone' => 'success'],
['label' => __('admin.kpi.instances'), 'value' => '39', 'sub' => __('admin.kpi.instances_sub'), 'tone' => 'success'],
['label' => __('admin.kpi.hosts'), 'value' => '4', 'sub' => __('admin.kpi.hosts_sub'), 'tone' => 'muted'],
['label' => __('admin.kpi.mrr'), 'value' => Number::currency(7842, in: 'EUR', locale: $locale), 'delta' => '+6,2 %', 'tone' => 'success'],
],
'fleetChart' => [
'type' => 'line',
'data' => [
'labels' => $months,
'datasets' => [[
'label' => __('admin.kpi.instances'),
'data' => [21, 23, 25, 27, 28, 30, 31, 33, 35, 36, 38, 39],
'borderColor' => 'token:accent',
'backgroundColor' => 'token:accent/0.12',
'fill' => true, 'tension' => 0.35, 'pointRadius' => 0, 'borderWidth' => 2,
]],
],
'options' => [
'scales' => ['x' => ['grid' => ['display' => false]], 'y' => ['grid' => ['color' => 'token:border']]],
'plugins' => ['legend' => ['display' => false]],
],
],
'revenueChart' => [
'type' => 'line',
'data' => [
'labels' => $months,
'datasets' => [[
'label' => 'MRR',
'data' => [4100, 4460, 4900, 5300, 5600, 6050, 6300, 6700, 7050, 7300, 7620, 7842],
'borderColor' => 'token:accent',
'fill' => true,
'tension' => 0.35,
'pointRadius' => 0,
'borderWidth' => 2,
]],
],
'options' => [
'scales' => ['x' => ['grid' => ['display' => false]], 'y' => ['beginAtZero' => true, 'grid' => ['color' => 'token:border']]],
'plugins' => ['legend' => ['display' => false]],
],
],
// Per-host storage load as capacity meters (a ranked comparison, not a
// trend — so meters, not a chart). level drives the meter colour.
'hostLoad' => [
['name' => 'pve-fsn-1', 'pct' => 72, 'level' => 'warn'],
['name' => 'pve-fsn-2', 'pct' => 64, 'level' => 'ok'],
['name' => 'pve-fsn-3', 'pct' => 81, 'level' => 'high'],
['name' => 'pve-hel-1', 'pct' => 38, 'level' => 'ok'],
],
'runs' => [
['customer' => 'Ordination Dr. Fux', 'step' => __('admin.run.deploy'), 'state' => 'running'],
['customer' => 'Architekturbüro Lang', 'step' => __('admin.run.dns'), 'state' => 'running'],
['customer' => 'Weingut Prantl', 'step' => __('admin.run.acceptance'), 'state' => 'running'],
],
'alerts' => [
['level' => 'warning', 'text' => __('admin.alert.host_load', ['host' => 'pve-fsn-3'])],
['level' => 'info', 'text' => __('admin.alert.cert_soon', ['n' => 3])],
],
]);
}
}

View File

@ -0,0 +1,243 @@
<?php
namespace App\Livewire\Admin;
use App\Models\PlanFamily;
use App\Models\PlanVersion;
use App\Models\Subscription;
use App\Services\Billing\PlanCatalogue;
use Illuminate\Support\Carbon;
use Illuminate\Validation\Rule;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Component;
use RuntimeException;
/**
* The versions of one plan: what it can do, what it costs, and when it sells.
*
* The page is built around the one rule that matters here a draft can be
* edited freely, a published version cannot be touched at all. So drafting and
* publishing are separate acts, and publishing says plainly that it is final.
*/
#[Layout('layouts.admin')]
class PlanVersions extends Component
{
public string $uuid;
/** The draft being written. */
public int $quotaGb = 100;
public int $trafficGb = 1000;
public int $seats = 5;
public int $ramMb = 4096;
public int $cores = 2;
public int $diskGb = 120;
public string $performance = 'standard';
public ?int $templateVmid = 9000;
/** @var array<int, string> */
public array $features = [];
public int $monthlyCents = 4900;
public int $yearlyCents = 58800;
/** Publishing: when it goes on sale, and optionally when it stops. */
public string $availableFrom = '';
public string $availableUntil = '';
public ?string $publishing = null;
public function mount(string $uuid): void
{
$this->authorize('plans.manage');
$family = PlanFamily::query()->where('uuid', $uuid)->firstOrFail();
$this->uuid = $uuid;
// Start the draft from what this plan is today, so the common case —
// "same plan, new price" — is a single edit rather than nine.
$latest = $family->versions()->orderByDesc('version')->first();
if ($latest !== null) {
$this->quotaGb = $latest->quota_gb;
$this->trafficGb = $latest->traffic_gb;
$this->seats = $latest->seats;
$this->ramMb = $latest->ram_mb;
$this->cores = $latest->cores;
$this->diskGb = $latest->disk_gb;
$this->performance = (string) ($latest->performance ?? 'standard');
$this->templateVmid = $latest->template_vmid;
$this->features = $latest->features ?? [];
$monthly = $latest->priceFor(Subscription::TERM_MONTHLY);
$yearly = $latest->priceFor(Subscription::TERM_YEARLY);
$this->monthlyCents = $monthly?->amount_cents ?? $this->monthlyCents;
$this->yearlyCents = $yearly?->amount_cents ?? $this->yearlyCents;
}
}
private function family(): PlanFamily
{
return PlanFamily::query()->where('uuid', $this->uuid)->firstOrFail();
}
/** Write a new draft. Nothing is promised until it is published. */
public function draft(): void
{
$this->authorize('plans.manage');
// Every bound is explicit, and generous rather than tight. A mistyped
// figure has to come back as a message on the field; without an upper
// limit it overflows the column instead and the owner gets a 500 with
// no idea which number was wrong.
$data = $this->validate([
'quotaGb' => 'required|integer|min:1|max:1000000',
'trafficGb' => 'required|integer|min:0|max:10000000',
'seats' => 'required|integer|min:1|max:100000',
'ramMb' => 'required|integer|min:512|max:4194304',
'cores' => 'required|integer|min:1|max:512',
// The disk has to hold the customer's quota plus the system itself.
'diskGb' => 'required|integer|min:1|max:1000000|gte:quotaGb',
// One of the classes we actually have a label for. Free text means
// a typo is published, frozen, and shown to customers forever as a
// raw translation key.
'performance' => ['required', Rule::in(array_keys((array) __('billing.perf')))],
// Required, not nullable: a version without a blueprint can be
// published and sold, and then fails provisioning every time.
'templateVmid' => 'required|integer|min:100|max:999999999',
'features' => 'array',
// Same reason as the performance class: a request can carry
// anything, and an unknown key is frozen at publication and shown
// to customers as a raw translation key.
'features.*' => ['string', Rule::in(array_keys((array) __('billing.feature')))],
// 9,999,999.99 in the catalogue currency — far past anything we
// would charge, far short of overflowing an unsigned int.
'monthlyCents' => 'required|integer|min:0|max:999999999',
'yearlyCents' => 'required|integer|min:0|max:999999999',
]);
$version = app(PlanCatalogue::class)->draft(
$this->family(),
[
'quota_gb' => $data['quotaGb'],
'traffic_gb' => $data['trafficGb'],
'seats' => $data['seats'],
'ram_mb' => $data['ramMb'],
'cores' => $data['cores'],
'disk_gb' => $data['diskGb'],
'performance' => $data['performance'],
'template_vmid' => $data['templateVmid'],
'features' => array_values($data['features']),
],
// Both terms, because publishing refuses a version that is not
// priced for each — better to find that out here than in front of
// a customer.
[
Subscription::TERM_MONTHLY => $data['monthlyCents'],
Subscription::TERM_YEARLY => $data['yearlyCents'],
],
);
$this->dispatch('notify', message: __('plans.draft_created', ['version' => $version->version]));
}
/** Open the publish form for one draft. */
public function choose(string $versionUuid): void
{
$this->publishing = $versionUuid;
$this->availableFrom = now()->format('Y-m-d\TH:i');
$this->availableUntil = '';
}
public function cancelPublish(): void
{
$this->reset('publishing', 'availableFrom', 'availableUntil');
}
/**
* Publish: fix the terms and put them on sale.
*
* Everything that can refuse this an unpriced version, a window that
* overlaps another refuses before anything is written, so a rejected
* publish leaves an editable draft rather than a stranded one.
*/
public function publish(): void
{
$this->authorize('plans.manage');
$this->validate([
'availableFrom' => 'required|date',
'availableUntil' => 'nullable|date|after:availableFrom',
]);
$version = PlanVersion::query()->where('uuid', $this->publishing)->firstOrFail();
abort_unless($version->plan_family_id === $this->family()->id, 404);
try {
app(PlanCatalogue::class)->publish(
$version,
Carbon::parse($this->availableFrom),
$this->availableUntil !== '' ? Carbon::parse($this->availableUntil) : null,
);
} catch (RuntimeException $e) {
$this->addError('availableFrom', $e->getMessage());
return;
}
$this->cancelPublish();
$this->dispatch('notify', message: __('plans.published', ['version' => $version->version]));
}
/**
* Close a running version's window the ordinary way to take a plan off
* sale for good, since a published version can never be deleted.
*/
public function close(string $versionUuid): void
{
$this->authorize('plans.manage');
$version = PlanVersion::query()->where('uuid', $versionUuid)->firstOrFail();
abort_unless($version->plan_family_id === $this->family()->id, 404);
try {
app(PlanCatalogue::class)->schedule($version, $version->available_from, now());
} catch (RuntimeException $e) {
$this->addError('availableFrom', $e->getMessage());
return;
}
$this->dispatch('notify', message: __('plans.closed', ['version' => $version->version]));
}
#[On('plan-draft-deleted')]
public function refresh(): void
{
// Livewire re-renders; the listener exists so the modal can say so.
}
public function render()
{
$family = $this->family();
$now = now();
return view('livewire.admin.plan-versions', [
'family' => $family,
'versions' => $family->versions()->with('prices')->orderByDesc('version')->get(),
'now' => $now,
'featureKeys' => array_keys((array) __('billing.feature')),
'performanceClasses' => (array) __('billing.perf'),
'currency' => Subscription::catalogueCurrency(),
]);
}
}

View File

@ -0,0 +1,116 @@
<?php
namespace App\Livewire\Admin;
use App\Models\PlanFamily;
use App\Services\Billing\PlanCatalogue;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Validate;
use Livewire\Component;
/**
* The plan lines we sell, and whether each is currently on sale.
*
* Deliberately shallow: a family is a name and a rank, and everything that can
* be got wrong capabilities, prices, windows lives one level down on the
* versions, where publishing makes it permanent.
*/
#[Layout('layouts.admin')]
class Plans extends Component
{
#[Validate(['required', 'string', 'max:32', 'regex:/^[a-z][a-z0-9_]*$/', 'unique:plan_families,key'])]
public string $key = '';
#[Validate('required|string|max:255')]
public string $name = '';
#[Validate('required|integer|min:0|max:255')]
public int $tier = 1;
public function mount(): void
{
// The page itself, not only its buttons. Hiding the nav entry and
// guarding the mutations still leaves the whole catalogue — prices,
// drafts, unreleased plans — readable to any operator who types the URL.
$this->authorize('plans.manage');
}
public function save(): void
{
$this->authorize('plans.manage');
$this->key = strtolower(trim($this->key));
$data = $this->validate([
// The key is permanent once created — orders, instances and every
// contract snapshot store it as a string — so it has to be a plain
// identifier, not something anyone will want to prettify later.
'key' => ['required', 'string', 'max:32', 'regex:/^[a-z][a-z0-9_]*$/', 'unique:plan_families,key'],
'name' => 'required|string|max:255',
'tier' => 'required|integer|min:0|max:255',
]);
PlanFamily::create([
'key' => $data['key'],
'name' => $data['name'],
'tier' => $data['tier'],
// Nothing to sell until a version is published, so it starts able
// to sell and simply has nothing available.
'sales_enabled' => true,
]);
$this->reset('key', 'name', 'tier');
$this->tier = 1;
$this->dispatch('notify', message: __('plans.created'));
}
/**
* The kill switch: stop selling this plan now, without touching a window.
*
* Existing customers keep their contract and their machine this only
* decides whether anyone new can buy it.
*/
public function toggleSales(string $uuid): void
{
$this->authorize('plans.manage');
$family = PlanFamily::query()->where('uuid', $uuid)->first();
$family?->update(['sales_enabled' => ! $family->sales_enabled]);
$this->dispatch('notify', message: __($family?->sales_enabled ? 'plans.now_selling' : 'plans.withdrawn'));
}
public function render()
{
$now = now();
$families = PlanFamily::query()
->with(['versions.prices'])
->withCount('versions')
->orderBy('tier')
->get()
->map(fn (PlanFamily $family) => [
'uuid' => $family->uuid,
'key' => $family->key,
'name' => $family->name,
'tier' => $family->tier,
'sales_enabled' => $family->sales_enabled,
'versions_count' => $family->versions_count,
'live' => $family->versions->first(fn ($v) => $v->isAvailableAt($now)),
// What is queued up next, so a scheduled launch is visible
// before a customer discovers it.
'next' => $family->versions
->filter(fn ($v) => $v->isPublished() && $v->available_from->greaterThan($now))
->sortBy('available_from')
->first(),
'drafts' => $family->versions->filter(fn ($v) => ! $v->isPublished())->count(),
]);
return view('livewire.admin.plans', [
'families' => $families,
// The shop's own answer, so this page cannot disagree with what a
// customer actually sees.
'sellable' => array_keys(app(PlanCatalogue::class)->sellable()),
]);
}
}

View File

@ -0,0 +1,160 @@
<?php
namespace App\Livewire\Admin;
use App\Livewire\Concerns\BuildsRunSteps;
use App\Models\Host;
use App\Models\Instance;
use App\Models\Order;
use App\Models\ProvisioningRun;
use App\Provisioning\Jobs\AdvanceRunJob;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Component;
#[Layout('layouts.admin')]
class Provisioning extends Component
{
use BuildsRunSteps;
#[On('echo-private:admin.runs,StepAdvanced')]
public function onStepAdvanced(): void
{
// A round-trip re-renders with fresh run state.
}
/** Retry a failed run from its current step (mirrors the host-detail retry). */
public function retry(string $uuid): void
{
$this->authorize('provisioning.retry');
// Atomically claim the run: only the request that transitions it out of
// FAILED proceeds, so two concurrent retries can't double-dispatch.
$claimed = ProvisioningRun::query()
->where('uuid', $uuid)
->where('status', ProvisioningRun::STATUS_FAILED)
->update([
'status' => ProvisioningRun::STATUS_RUNNING,
'attempt' => 0,
'next_attempt_at' => now(),
'started_at' => now(), // reset the step timer so it doesn't re-time-out instantly
'error' => null,
]);
if ($claimed === 0) {
return; // already retried by a concurrent request
}
$run = ProvisioningRun::query()->where('uuid', $uuid)->first();
if ($run === null) {
return;
}
// Move the subject out of its error state so the console reflects the retry.
$subject = $run->subject;
if ($subject instanceof Host) {
$subject->update(['status' => 'onboarding']);
} elseif ($subject instanceof Order) {
$subject->update(['status' => 'provisioning']);
Instance::query()->where('order_id', $subject->id)->where('status', 'failed')->update(['status' => 'provisioning']);
}
AdvanceRunJob::dispatch($run->uuid);
$this->dispatch('notify', message: __('admin.run_retried'));
}
/**
* A run that claims to be in progress but hasn't advanced in a while looks
* stuck unless it is in a legitimate scheduled backoff (next_attempt_at in
* the future, e.g. RunRunner's retry delay of up to 300 s).
*/
private function isStale(ProvisioningRun $run): bool
{
if (! in_array($run->status, ['running', 'waiting'], true)) {
return false;
}
if ($run->next_attempt_at !== null && $run->next_attempt_at->isFuture()) {
return false; // waiting out a scheduled retry/poll — not stuck
}
// Compare against the CURRENT step's own allowed duration — some steps
// (VM clone, Proxmox install) legitimately run for many minutes — plus a
// small grace. Only past its own deadline without advancing is it stuck.
$pipeline = config('provisioning.pipelines.'.$run->pipeline, []);
$class = $pipeline[$run->current_step] ?? null;
$maxSeconds = $class !== null ? app($class)->maxDuration() : 120;
return $run->updated_at !== null && $run->updated_at->lt(now()->subSeconds($maxSeconds + 30));
}
private function subjectLabel(ProvisioningRun $run): string
{
$subject = $run->subject;
if ($subject instanceof Order) {
return $subject->customer?->name ?? 'Order';
}
if ($subject instanceof Host) {
return $subject->name;
}
return class_basename($run->subject_type);
}
public function render()
{
$runs = ProvisioningRun::query()->latest('id')->limit(30)->get();
$active = $runs->first(fn (ProvisioningRun $r) => in_array(
$r->status, ['pending', 'running', 'waiting'], true
)) ?? $runs->first();
return view('livewire.admin.provisioning', [
'hasActive' => $active !== null && in_array($active->status, ['pending', 'running', 'waiting'], true),
'rows' => $runs->map(function (ProvisioningRun $r) {
$total = max(count(config('provisioning.pipelines.'.$r->pipeline, [])), 1);
$done = $r->status === ProvisioningRun::STATUS_COMPLETED ? $total : $r->current_step;
return [
'uuid' => $r->uuid,
'customer' => $this->subjectLabel($r),
'pipeline' => $r->pipeline,
'step' => $r->status === 'completed' ? '—' : $this->currentStepLabel($r),
'n' => ($r->current_step + 1).'/'.$total,
'percent' => (int) round($done / $total * 100),
'attempt' => $r->attempt,
'state' => $this->runState($r),
'failed' => $r->status === ProvisioningRun::STATUS_FAILED,
'activity' => $r->updated_at?->diffForHumans() ?? '—',
'stale' => $this->isStale($r),
];
})->all(),
'panel' => $active ? $this->panelFor($active) : null,
]);
}
/** Compact "current run" readout for the right column. */
private function panelFor(ProvisioningRun $run): array
{
$pipeline = config('provisioning.pipelines.'.$run->pipeline, []);
$total = max(count($pipeline), 1);
$current = min($run->current_step, $total - 1);
$completed = $run->status === ProvisioningRun::STATUS_COMPLETED;
$done = $completed ? $total : $current;
return [
'uuid' => $run->uuid,
'subject' => $this->subjectLabel($run),
'pipeline' => $run->pipeline,
'attempt' => $run->attempt,
'status' => $this->runState($run), // running|done|failed
'failed' => $run->status === ProvisioningRun::STATUS_FAILED,
'current' => $completed ? null : (isset($pipeline[$current]) ? __(app($pipeline[$current])->label()) : null),
'next' => isset($pipeline[$current + 1]) && ! $completed ? __(app($pipeline[$current + 1])->label()) : null,
'error' => $run->error,
'done' => $done,
'total' => $total,
'percent' => (int) round($done / $total * 100),
'activity' => $run->updated_at?->diffForHumans() ?? '—',
'started' => $run->started_at?->diffForHumans() ?? null,
'stale' => $this->isStale($run),
];
}
}

View File

@ -0,0 +1,64 @@
<?php
namespace App\Livewire\Admin;
use Illuminate\Support\Carbon;
use Illuminate\Support\Number;
use Livewire\Attributes\Layout;
use Livewire\Component;
#[Layout('layouts.admin')]
class Revenue extends Component
{
public function render()
{
$locale = app()->getLocale();
$eur = fn (int $v) => Number::currency($v, in: 'EUR', locale: $locale);
$months = collect(range(11, 0))
->map(fn ($i) => Carbon::now()->locale($locale)->startOfMonth()->subMonths($i)->isoFormat('MMM'))
->all();
return view('livewire.admin.revenue', [
'kpis' => [
['label' => __('admin.rev.mrr'), 'value' => $eur(7842), 'delta' => '+6,2 %'],
['label' => __('admin.rev.arr'), 'value' => $eur(94104), 'delta' => '+18 %'],
['label' => __('admin.rev.arpu'), 'value' => $eur(187), 'delta' => '+1,4 %'],
['label' => __('admin.rev.churn'), 'value' => '1,8 %', 'delta' => '0,3 %'],
],
'mrrChart' => [
'type' => 'line',
'data' => [
'labels' => $months,
'datasets' => [[
'label' => 'MRR',
'data' => [4100, 4460, 4900, 5300, 5600, 6050, 6300, 6700, 7050, 7300, 7620, 7842],
'borderColor' => 'token:accent', 'backgroundColor' => 'token:accent/0.12',
'fill' => true, 'tension' => 0.35, 'pointRadius' => 0, 'borderWidth' => 2,
]],
],
'options' => [
'scales' => ['x' => ['grid' => ['display' => false]], 'y' => ['grid' => ['color' => 'token:border']]],
'plugins' => ['legend' => ['display' => false]],
],
],
'planChart' => [
'type' => 'doughnut',
'data' => [
'labels' => ['Solo', 'Team', 'Enterprise'],
'datasets' => [[
'data' => [1659, 3366, 1796],
'backgroundColor' => ['token:info', 'token:accent', 'token:success-bright'],
'borderWidth' => 0,
]],
],
'options' => ['cutout' => '62%', 'plugins' => ['legend' => ['position' => 'bottom', 'labels' => ['boxWidth' => 10, 'padding' => 12]]]],
],
'payments' => [
['customer' => 'Notariat Külz', 'amount' => $eur(449), 'when' => '01.07.'],
['customer' => 'Kanzlei Berger', 'amount' => $eur(198), 'when' => '01.07.'],
['customer' => 'Steuerberatung Reiss', 'amount' => $eur(198), 'when' => '01.07.'],
['customer' => 'Ordination Dr. Fux', 'amount' => $eur(79), 'when' => '30.06.'],
],
]);
}
}

View File

@ -0,0 +1,267 @@
<?php
namespace App\Livewire\Admin;
use App\Models\Customer;
use App\Models\User;
use App\Models\VpnPeer;
use App\Support\Settings as AppSettings;
use App\Provisioning\Jobs\ApplyVpnPeer;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
use Spatie\Permission\Models\Role;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Validate;
use Livewire\Component;
/**
* Operator settings: the signed-in operator's own account plus (Owner-only)
* staff management. All staff mutations are capability-gated server-side and
* protect the last-Owner and self-role invariants transactionally.
*/
#[Layout('layouts.admin')]
class Settings extends Component
{
// My account
#[Validate('required|string|max:255')]
public string $name = '';
#[Validate('required|email|max:255')]
public string $email = '';
// Invite staff
#[Validate('required|string|max:255')]
public string $staffName = '';
#[Validate('required|email|max:255')]
public string $staffEmail = '';
#[Validate('required|in:Owner,Admin,Support,Billing,Read-only')]
public string $staffRole = 'Support';
/** Shown once after inviting, since email delivery is still mocked. */
public ?string $invitedEmail = null;
public ?string $invitedPassword = null;
public function mount(): void
{
$this->name = auth()->user()->name;
$this->email = auth()->user()->email;
}
/**
* Take the marketing site and the customer portal offline, or back online.
* The console keeps working either way otherwise this switch could only
* ever be flipped once.
*/
public function toggleSiteVisibility(): void
{
$this->authorize('site.manage');
$public = ! AppSettings::bool('site.public', true);
AppSettings::set('site.public', $public);
$this->dispatch('notify', message: $public
? __('admin_settings.site_now_public')
: __('admin_settings.site_now_hidden'));
}
public function saveAccount(): void
{
$user = auth()->user();
$data = $this->validate([
'name' => 'required|string|max:255',
'email' => 'required|email|max:255|unique:users,email,'.$user->id,
]);
// An operator email must never collide with a customer's — that would
// block the customer from ever obtaining a portal login (ensureUser).
if (Customer::query()->where('email', $data['email'])->exists()) {
$this->addError('email', __('admin_settings.is_customer'));
return;
}
$user->update($data);
$this->dispatch('notify', message: __('admin_settings.account_saved'));
}
public function inviteStaff(): void
{
$this->authorize('staff.manage');
$data = $this->validate([
'staffName' => 'required|string|max:255',
'staffEmail' => 'required|email|max:255|unique:users,email',
'staffRole' => 'required|in:Owner,Admin,Support,Billing,Read-only',
]);
// Only an Owner may create another Owner.
if ($data['staffRole'] === 'Owner' && ! auth()->user()->hasRole('Owner')) {
$this->addError('staffRole', __('admin_settings.owner_only'));
return;
}
// Never turn a customer's portal login into an operator.
if (Customer::query()->where('email', $data['staffEmail'])->exists()) {
$this->addError('staffEmail', __('admin_settings.is_customer'));
return;
}
// Email/password-setup delivery is still mocked, so generate a temporary
// password and surface it once to the Owner to share securely — the
// account is usable immediately (a proper invite link follows with mail).
$temp = Str::password(14);
$user = User::create([
'name' => $data['staffName'],
'email' => $data['staffEmail'],
'password' => Hash::make($temp),
'is_admin' => true,
]);
$user->assignRole($data['staffRole']);
$this->invitedEmail = $data['staffEmail'];
$this->invitedPassword = $temp;
$this->reset('staffName', 'staffEmail');
$this->staffRole = 'Support';
$this->dispatch('notify', message: __('admin_settings.staff_invited'));
}
public function setStaffRole(int $id, string $role): void
{
$this->authorize('staff.manage');
if (! in_array($role, User::OPERATOR_ROLES, true)) {
return;
}
$result = DB::transaction(function () use ($id, $role) {
// Serialize on the Owner role so the global owner count can't be
// raced to zero by concurrent demotions of different owners.
Role::query()->where('name', 'Owner')->lockForUpdate()->first();
$target = User::query()->whereKey($id)->lockForUpdate()->first();
if ($target === null) {
return 'gone';
}
if ($target->id === auth()->id()) {
return 'self';
}
// Only existing operators may be re-roled — never escalate a customer
// (or any non-staff) user into the console via a tampered id.
if (! $target->isOperator() || Customer::query()->where('email', $target->email)->exists()) {
return 'not_staff';
}
// Granting or revoking Owner is Owner-only.
if (($role === 'Owner' || $target->hasRole('Owner')) && ! auth()->user()->hasRole('Owner')) {
return 'owner_only';
}
// Never demote the last Owner.
if ($target->hasRole('Owner') && $role !== 'Owner' && $this->ownerCount() <= 1) {
return 'last_owner';
}
$target->syncRoles([$role]);
return 'ok';
});
$this->flash($result);
}
public function revokeStaff(int $id): void
{
$this->authorize('staff.manage');
$revokedPeers = [];
$result = DB::transaction(function () use ($id, &$revokedPeers) {
Role::query()->where('name', 'Owner')->lockForUpdate()->first();
$target = User::query()->whereKey($id)->lockForUpdate()->first();
if ($target === null || ! $target->isOperator()) {
return 'gone';
}
if ($target->id === auth()->id()) {
return 'self';
}
if ($target->hasRole('Owner') && $this->ownerCount() <= 1) {
return 'last_owner';
}
$target->syncRoles([]);
$target->update(['is_admin' => false]);
// Taking away the console but leaving the tunnel would be the worst
// of both: no access to the panel, still a route into the
// management network. Same revocation path as the VPN page uses.
foreach (VpnPeer::query()->where('user_id', $target->id)->get() as $peer) {
$peer->purgeSecret();
$peer->delete();
$revokedPeers[] = $peer->public_key;
}
return 'revoked';
});
// Dispatched only once the rows are committed: a worker picking the job
// up mid-transaction would still see the peer as active, leave it on the
// hub, and never be asked again — a revoked colleague would keep their
// tunnel until the next reconciliation happened to notice.
foreach ($revokedPeers as $publicKey) {
ApplyVpnPeer::dispatch($publicKey, null, false);
}
$this->flash($result);
}
private function flash(string $result): void
{
$msg = match ($result) {
'ok' => __('admin_settings.role_updated'),
'revoked' => __('admin_settings.staff_revoked'),
'self' => __('admin_settings.not_self'),
'owner_only' => __('admin_settings.owner_only'),
'last_owner' => __('admin_settings.last_owner'),
'not_staff' => __('admin_settings.not_staff'),
default => null,
};
if ($msg !== null) {
$this->dispatch('notify', message: $msg);
}
}
private function ownerCount(): int
{
return User::query()->whereHas('roles', fn ($q) => $q->where('name', 'Owner'))->count();
}
public function render()
{
$staff = User::query()
->whereHas('roles', fn ($q) => $q->whereIn('name', User::OPERATOR_ROLES))
->with('roles')
->orderBy('name')
->get()
->map(fn (User $u) => [
'id' => $u->id,
'name' => $u->name,
'email' => $u->email,
'role' => $u->roles->first()?->name ?? '—',
'self' => $u->id === auth()->id(),
]);
return view('livewire.admin.settings', [
'sitePublic' => AppSettings::bool('site.public', true),
'canManageSite' => auth()->user()?->can('site.manage') ?? false,
// Shown so nobody has to guess why they still see the real site.
'viewerOnVpn' => \Symfony\Component\HttpFoundation\IpUtils::checkIp(
(string) request()->ip(),
(array) config('admin_access.trusted_ranges', []),
),
'staff' => $staff,
'roles' => User::OPERATOR_ROLES,
'canManageStaff' => auth()->user()->can('staff.manage'),
'isOwner' => auth()->user()->hasRole('Owner'),
]);
}
}

357
app/Livewire/Admin/Vpn.php Normal file
View File

@ -0,0 +1,357 @@
<?php
namespace App\Livewire\Admin;
use App\Models\Host;
use App\Models\VpnPeer;
use App\Provisioning\Jobs\ApplyVpnPeer;
use App\Provisioning\Jobs\SyncVpnPeers;
use App\Models\User;
use App\Services\Wireguard\ConfigHandoff;
use App\Services\Wireguard\ConfigVault;
use App\Services\Wireguard\Keypair;
use App\Services\Wireguard\QrCode;
use App\Services\Wireguard\WireguardHub;
use Illuminate\Database\QueryException;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Component;
/**
* VPN access management. The console container has no wg0, so live state is
* read from the database and refreshed by SyncVpnPeers on the provisioning
* queue the poll below only nudges that job, it never talks to WireGuard.
*/
#[Layout('layouts.admin')]
class Vpn extends Component
{
public string $name = '';
/** Optional: a peer that generated its own key never hands us the private half. */
public string $publicKey = '';
/** Opaque handle for the freshly created config; see ConfigHandoff. */
public ?string $configToken = null;
public ?string $newConfigName = null;
/** Owner of the new access. Defaults to the operator creating it. */
public ?int $ownerId = null;
/** Keep the config so its owner can fetch it again behind their password. */
public bool $storeConfig = false;
public bool $showQr = false;
public function mount(): void
{
$this->authorize('viewAny', VpnPeer::class);
$this->ownerId = auth()->id();
}
/**
* mount() runs once; every later request including the five-second poll
* only hydrates. Without this, revoking vpn.manage would not take effect
* until the operator happened to reload the page, and the open tab would
* keep serving fresh peer state.
*/
public function hydrate(): void
{
$this->authorize('viewAny', VpnPeer::class);
}
public function create(): void
{
$this->authorize('create', VpnPeer::class);
$this->validate([
'name' => 'required|string|max:255',
'publicKey' => 'nullable|string|max:64',
// An access belongs to a person, and only to an operator: a customer
// account must never own a way into the management network.
'ownerId' => ['required', Rule::exists('users', 'id')],
]);
// Storing needs a key. Without one we would either write the credential
// in the clear or pretend we stored it — both worse than saying no.
if ($this->storeConfig && ! ConfigVault::available()) {
$this->addError('storeConfig', __('vpn.vault_unavailable'));
return;
}
$ownKey = trim($this->publicKey) !== '';
if ($this->storeConfig && $ownKey) {
$this->addError('storeConfig', __('vpn.cannot_store_foreign_key'));
return;
}
if ($ownKey && ! Keypair::isValidKey(trim($this->publicKey))) {
$this->addError('publicKey', __('vpn.invalid_key'));
return;
}
$keypair = $ownKey ? null : Keypair::generate();
$publicKey = $ownKey ? trim($this->publicKey) : $keypair->publicKey;
$hub = app(WireguardHub::class);
// Same lock the host pipeline holds while it reserves an address
// (ConfigureWireguard). Addresses come from one subnet but live in two
// tables, so neither unique index can catch the other's insert — the
// shared lock is what keeps a host and an access off the same tunnel IP.
try {
// Filled inside the transaction; only the handoff needs it afterwards.
$plainConfig = null;
$peer = Cache::lock('wireguard:allocate', 30)->block(10, function () use ($hub, $publicKey, $keypair, &$plainConfig) {
return DB::transaction(function () use ($hub, $publicKey, $keypair, &$plainConfig) {
// The owner row is locked for the whole insert, and revokeStaff()
// locks the same row: without that, a revocation could commit
// between the check and the insert, find no peer to remove, and
// leave the revoked colleague with a brand-new tunnel.
$owner = User::query()->whereKey($this->ownerId)->lockForUpdate()->first();
if ($owner === null || ! $owner->isOperator()) {
return 'owner_must_be_operator';
}
// Inside the lock: checking before it would let two concurrent
// requests both pass and the loser hit the unique index as a
// 500. withTrashed, because a revoked peer keeps its key until
// the hub confirms removal.
$existing = VpnPeer::withTrashed()->where('public_key', $publicKey)->first();
if ($existing !== null) {
return $existing->trashed() ? 'pending_removal' : 'duplicate_key';
}
// A host's key may not have been adopted into vpn_peers yet.
// Re-using it would make `wg set` rewrite that host's allowed-ip
// to the address allocated here — cutting the management tunnel
// to a live machine.
if (Host::query()->where('wg_pubkey', $publicKey)->exists()) {
return 'host_key';
}
$ip = $hub->allocateIp();
// Built and encrypted here so the stored config is part of the
// same insert. Doing it afterwards could leave a live access
// whose config was never stored — unrecoverable, and the
// operator would create a second one not knowing why.
$secret = null;
if ($keypair !== null) {
$plainConfig = $this->clientConfig($keypair, $ip);
if ($this->storeConfig) {
$secret = ConfigVault::encrypt($plainConfig);
}
}
return VpnPeer::create([
'name' => trim($this->name),
'kind' => VpnPeer::KIND_STAFF,
'user_id' => $this->ownerId,
'public_key' => $publicKey,
'allowed_ip' => $ip,
'config_secret' => $secret,
'enabled' => true,
'present' => false,
'created_by' => auth()->id(),
]);
});
});
} catch (QueryException) {
// Backstop: the unique index caught a writer that did not take this
// lock. Report it like any other duplicate instead of a 500.
$this->addError('publicKey', __('vpn.duplicate_key'));
return;
}
if (is_string($peer)) {
$field = $peer === 'owner_must_be_operator' ? 'ownerId' : 'publicKey';
$this->addError($field, __('vpn.'.$peer));
return;
}
ApplyVpnPeer::dispatch($peer->public_key, $peer->allowed_ip, true);
// Whatever was on screen belongs to the previous access. Leaving it up
// after creating another one would present the wrong private config
// under the new name — and someone would hand it out.
$this->dismissConfig();
// Only a key we generated can be turned into a ready-to-use config.
if ($plainConfig !== null) {
$this->configToken = ConfigHandoff::put($plainConfig);
$this->newConfigName = $peer->name;
}
$this->reset('name', 'publicKey', 'storeConfig');
$this->ownerId = auth()->id();
$this->dispatch('notify', message: __('vpn.created'));
}
public function toggle(string $uuid): void
{
// Looked up globally and then judged by the policy: filtering it out of
// the query here would turn "not allowed" into a button that silently
// does nothing, which is exactly how the portal used to lie to people.
$peer = VpnPeer::query()->where('uuid', $uuid)->first();
if ($peer === null) {
return;
}
$this->authorize('block', $peer);
$peer->update(['enabled' => ! $peer->enabled]);
ApplyVpnPeer::dispatch($peer->public_key, $peer->allowed_ip, $peer->enabled);
$this->dispatch('notify', message: $peer->enabled ? __('vpn.unblocked') : __('vpn.blocked'));
}
#[On('vpn-peer-deleted')]
public function peerDeleted(): void
{
$this->dispatch('notify', message: __('vpn.deleted'));
}
/**
* Replace an access's keypair.
*
* The way back for an access whose config was never stored: nobody can hand
* out a private key that no longer exists, so the honest option is a new
* one. The old key stops working the moment the hub is updated, which is
* also what makes this the right tool for a lost or leaked device.
*/
public function reissue(string $uuid): void
{
$peer = VpnPeer::query()->where('uuid', $uuid)->first();
if ($peer === null) {
return;
}
$this->authorize('update', $peer);
if ($peer->kind !== VpnPeer::KIND_STAFF) {
$this->dispatch('notify', message: __('vpn.reissue_staff_only'));
return;
}
$keypair = Keypair::generate();
$oldKey = $peer->public_key;
$config = $this->clientConfig($keypair, $peer->allowed_ip);
$peer->forceFill([
'public_key' => $keypair->publicKey,
'present' => false,
// Only re-stored when the vault can actually be used: without the key
// this would throw, and the console tells people to re-issue
// precisely when a stored config has become unreadable.
'config_secret' => $peer->hasStoredConfig() && ConfigVault::available()
? ConfigVault::encrypt($config)
: null,
])->save();
// Old key off the hub first, then the new one on: the other order would
// briefly leave two peers claiming the same tunnel address.
ApplyVpnPeer::dispatch($oldKey, null, false, force: true);
ApplyVpnPeer::dispatch($peer->public_key, $peer->allowed_ip, true);
$this->dismissConfig();
$this->configToken = ConfigHandoff::put($config);
$this->newConfigName = $peer->name;
$this->dispatch('notify', message: __('vpn.reissued'));
}
public function dismissConfig(): void
{
ConfigHandoff::forget($this->configToken);
$this->reset('configToken', 'newConfigName', 'showQr');
}
/**
* The list this operator is allowed to see: their own accesses, plus
* everything when they hold vpn.view.all. Filtering in the query as well as
* in the actions, so a forged uuid cannot reach a peer the page never showed.
*/
private function visiblePeers()
{
$query = VpnPeer::query();
if (! auth()->user()?->can('vpn.view.all')) {
$query->where('kind', VpnPeer::KIND_STAFF)->where('user_id', auth()->id());
}
return $query;
}
public function toggleQr(): void
{
$this->showQr = ! $this->showQr;
}
/**
* wg-quick takes the interface name from the filename, and Linux caps
* interface names at 15 characters so the label is slugged and cut rather
* than handing the operator a file that fails to come up.
*/
public function configFilename(): string
{
$slug = Str::slug((string) $this->newConfigName) ?: 'clupilot';
return Str::limit($slug, 15, '').'.conf';
}
/** Polled by the view; throttled so a room full of open tabs cannot flood the queue. */
public function refreshPeers(): void
{
if (Cache::add('vpn:sync-dispatched', true, 8)) {
SyncVpnPeers::dispatch();
}
}
private function clientConfig(Keypair $keypair, string $ip): string
{
$hub = app(WireguardHub::class);
return implode("\n", [
'[Interface]',
'PrivateKey = '.$keypair->privateKey,
'Address = '.$ip.'/32',
'',
'[Peer]',
'PublicKey = '.$hub->publicKey(),
'Endpoint = '.$hub->endpoint(),
'AllowedIPs = '.config('provisioning.wireguard.subnet', '10.66.0.0/24'),
'PersistentKeepalive = 25',
'',
]);
}
public function render()
{
$hub = app(WireguardHub::class);
return view('livewire.admin.vpn', [
'newConfig' => $config = ConfigHandoff::get($this->configToken),
'qrSvg' => $this->showQr && $config !== null ? QrCode::svg($config) : null,
'peers' => $this->visiblePeers()->with(['host', 'owner'])->orderByDesc('present')->orderBy('name')->get(),
'operators' => User::query()
->whereHas('roles', fn ($q) => $q->whereIn('name', User::OPERATOR_ROLES))
->orderBy('name')->get(['id', 'name', 'email']),
'canManage' => auth()->user()?->can('vpn.manage.all') ?? false,
'vaultAvailable' => ConfigVault::available(),
'hubEndpoint' => $hub->endpoint(),
'hubPublicKey' => $hub->publicKey(),
'lastSync' => VpnPeer::query()->max('observed_at'),
]);
}
}

View File

@ -0,0 +1,133 @@
<?php
namespace App\Livewire\Admin;
use App\Models\VpnPeer;
use App\Services\Wireguard\ConfigHandoff;
use App\Services\Wireguard\ConfigVault;
use App\Services\Wireguard\QrCode;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Str;
use LivewireUI\Modal\ModalComponent;
/**
* Retrieving a stored VPN config again, behind the owner's own password.
*
* The password is asked for on EVERY retrieval, not once per session: Laravel's
* password.confirm middleware keeps a 15-minute session stamp, which would
* authorise unlimited later downloads from an unattended browser not what
* "enter your password to download it" means.
*/
class VpnConfigAccess extends ModalComponent
{
public string $uuid;
public string $name = '';
public string $password = '';
/** Opaque handle; the plaintext never enters the component snapshot. */
public ?string $token = null;
public bool $showQr = false;
public function mount(string $uuid): void
{
$peer = VpnPeer::query()->where('uuid', $uuid)->firstOrFail();
$this->authorize('downloadConfig', $peer);
$this->uuid = $uuid;
$this->name = $peer->name;
}
/**
* Every later request re-checks: once revealed, the plaintext sits in the
* handoff cache for ten minutes, and an open modal would otherwise keep
* handing it out after the access was revoked or reassigned exactly the
* window purgeSecret() exists to close.
*/
public function hydrate(): void
{
$peer = VpnPeer::withTrashed()->where('uuid', $this->uuid)->first();
if ($peer === null || Gate::denies('downloadConfig', $peer)) {
ConfigHandoff::forget($this->token);
$this->token = null;
abort(403);
}
}
public function reveal(): void
{
// A retry must not still be showing the previous attempt's complaint.
$this->resetErrorBag('password');
$peer = VpnPeer::query()->where('uuid', $this->uuid)->firstOrFail();
$this->authorize('downloadConfig', $peer);
// Rate-limited per user, so a stolen session cannot grind the password.
$key = 'vpn-config:'.auth()->id();
if (RateLimiter::tooManyAttempts($key, 5)) {
$this->addError('password', __('vpn.too_many_attempts', ['seconds' => RateLimiter::availableIn($key)]));
return;
}
if (! Hash::check($this->password, auth()->user()->password)) {
RateLimiter::hit($key, 300);
Log::warning('VPN config download refused: wrong password', [
'user_id' => auth()->id(), 'peer' => $peer->uuid,
]);
$this->addError('password', __('vpn.wrong_password'));
return;
}
RateLimiter::clear($key);
$plaintext = ConfigVault::decrypt((string) $peer->config_secret);
if ($plaintext === null) {
// Wrong or rotated key, or a tampered record. Never guess.
$this->addError('password', __('vpn.config_unreadable'));
return;
}
// Counted in the database, not read-modify-written here: two concurrent
// retrievals would otherwise record one. This is an audit trail, so
// under-reporting is the one failure mode it must not have.
VpnPeer::query()->whereKey($peer->getKey())->update([
'last_downloaded_at' => now(),
'download_count' => DB::raw('download_count + 1'),
]);
Log::info('VPN config downloaded', ['user_id' => auth()->id(), 'peer' => $peer->uuid]);
$this->reset('password');
$this->token = ConfigHandoff::put($plaintext);
}
public function toggleQr(): void
{
$this->showQr = ! $this->showQr;
}
public function filename(): string
{
return Str::limit(Str::slug($this->name) ?: 'clupilot', 15, '').'.conf';
}
public function render()
{
$config = ConfigHandoff::get($this->token);
return view('livewire.admin.vpn-config-access', [
'config' => $config,
'qrSvg' => $config !== null && $this->showQr ? QrCode::svg($config) : null,
]);
}
}

View File

@ -0,0 +1,15 @@
<?php
namespace App\Livewire\Auth;
use Livewire\Attributes\Layout;
use Livewire\Component;
#[Layout('layouts.portal')]
class Login extends Component
{
public function render()
{
return view('livewire.auth.login');
}
}

View File

@ -0,0 +1,19 @@
<?php
namespace App\Livewire\Auth;
use Livewire\Attributes\Layout;
use Livewire\Component;
/**
* Full-page signup view (R1/R2). The POST is handled by Fortify's registration
* feature (register.store App\Actions\Fortify\CreateNewUser).
*/
#[Layout('layouts.portal')]
class Register extends Component
{
public function render()
{
return view('livewire.auth.register');
}
}

View File

@ -0,0 +1,15 @@
<?php
namespace App\Livewire\Auth;
use Livewire\Attributes\Layout;
use Livewire\Component;
#[Layout('layouts.portal')]
class TwoFactorChallenge extends Component
{
public function render()
{
return view('livewire.auth.two-factor-challenge');
}
}

58
app/Livewire/Backups.php Normal file
View File

@ -0,0 +1,58 @@
<?php
namespace App\Livewire;
use Illuminate\Support\Carbon;
use Illuminate\Support\Number;
use Livewire\Attributes\Layout;
use Livewire\Component;
#[Layout('layouts.portal-app')]
class Backups extends Component
{
public function render()
{
$locale = app()->getLocale();
$gb = fn (float $v) => Number::format($v, precision: 1, locale: $locale).' GB';
$date = fn (string $iso, string $fmt) => Carbon::parse($iso)->locale($locale)->isoFormat($fmt);
// 14 days of backup sizes for the bar chart.
$sizes = [18.1, 18.1, 18.2, 18.2, 18.2, 18.3, 18.3, 18.3, 18.3, 18.4, 18.4, 18.3, 18.4, 18.4];
$rows = [];
foreach (range(0, 6) as $i) {
$rows[] = [
'when' => $date(Carbon::parse('2026-07-24')->subDays($i)->toDateString(), 'dd, D. MMMM').', 03:1'.($i % 3 + 1),
'size' => $gb(18.4 - $i * 0.02),
'status' => 'ok',
];
}
return view('livewire.backups', [
'rows' => $rows,
'lastTest' => $date('2026-07-01', 'LL'),
'sizeChart' => [
'type' => 'line',
'data' => [
'labels' => array_map(fn ($i) => $date(Carbon::parse('2026-07-24')->subDays(13 - $i)->toDateString(), 'D.'), range(0, 13)),
'datasets' => [[
'label' => 'GB',
'data' => $sizes,
'borderColor' => 'token:accent',
'fill' => true,
'tension' => 0.35,
'pointRadius' => 0,
'borderWidth' => 2,
]],
],
'options' => [
'scales' => [
'x' => ['grid' => ['display' => false]],
'y' => ['beginAtZero' => false, 'grid' => ['color' => 'token:border']],
],
'plugins' => ['legend' => ['display' => false]],
],
],
]);
}
}

183
app/Livewire/Billing.php Normal file
View File

@ -0,0 +1,183 @@
<?php
namespace App\Livewire;
use App\Livewire\Concerns\ResolvesCustomer;
use App\Models\Customer;
use App\Models\Order;
use App\Models\Subscription;
use App\Services\Billing\AddonCatalogue;
use App\Services\Billing\PlanCatalogue;
use Illuminate\Support\Facades\DB;
use Livewire\Attributes\Layout;
use Livewire\Component;
#[Layout('layouts.portal-app')]
class Billing extends Component
{
use ResolvesCustomer;
/**
* Record a purchase intent (order). Fulfillment (Stripe checkout + resize) is
* mocked for now the order is created with status 'pending'.
*/
public function purchase(string $type, ?string $key = null): void
{
$customer = $this->requireCustomer();
if ($customer === null) {
return;
}
$instance = $customer->instances()->latest('id')->first();
$currentPlan = $instance?->plan ?? 'start';
$plans = app(PlanCatalogue::class)->sellable();
$addons = (array) config('provisioning.addons');
[$plan, $amount, $addonKey] = match ($type) {
'upgrade' => [$key, (int) ($plans[$key]['price_cents'] ?? 0), null],
'storage' => [$currentPlan, (int) config('provisioning.storage_addon.price_cents', 0), null],
'traffic' => [$currentPlan, (int) config('provisioning.traffic.addon.price_cents', 0), null],
'addon' => [$currentPlan, (int) ($addons[$key]['price_cents'] ?? 0), $key],
default => [null, 0, null],
};
// Guard against invalid keys / non-upgrades.
$valid = match ($type) {
// By rank, matching PlanChange and the cards on the page. Comparing
// prices would call a grandfathered plan an upgrade to a smaller
// one and charge for the privilege.
'upgrade' => isset($plans[$key])
&& (int) ($plans[$key]['tier'] ?? 0) > (int) ($instance?->subscription?->tier ?? $plans[$currentPlan]['tier'] ?? 0),
'storage' => true,
// Always available: running out of traffic is exactly when someone
// needs to be able to buy more, whatever plan they are on.
'traffic' => true,
'addon' => isset($addons[$key]),
default => false,
};
if (! $valid || $plan === null) {
return;
}
$datacenter = $instance?->host?->datacenter
?? $customer->orders()->latest('id')->value('datacenter')
?? 'fsn';
// A cart cannot hold two plan changes: they contradict each other and no
// checkout could resolve which one the customer meant. Choosing another
// replaces the pending one — add-ons still stack.
//
// Replacement and insert run together with the customer row locked:
// two clicks in flight could otherwise both delete and then both
// insert, leaving exactly the two upgrades this rule exists to prevent.
$replaced = DB::transaction(function () use ($customer, $type, $plan, $addonKey, $amount, $datacenter) {
Customer::query()->whereKey($customer->id)->lockForUpdate()->first();
$replaced = $type === 'upgrade'
? Order::query()
->where('customer_id', $customer->id)
->where('status', 'pending')
->where('type', 'upgrade')
->delete()
: 0;
Order::create([
'customer_id' => $customer->id,
'plan' => $plan,
'type' => $type,
'addon_key' => $addonKey,
'amount_cents' => $amount,
'currency' => 'EUR',
'datacenter' => $datacenter,
'status' => 'pending',
]);
return $replaced;
});
if ($replaced > 0) {
$this->dispatch('notify', message: __('billing.cart.plan_replaced'));
}
$this->dispatch('notify', message: __('billing.purchased'));
}
#[\Livewire\Attributes\On('order-removed')]
public function orderRemoved(): void
{
$this->dispatch('notify', message: __('billing.cart.removed'));
}
/**
* The terms to show as "your plan": the contract if there is one, and only
* otherwise the catalogue someone browsing before they have bought.
*
* @param array<string, mixed> $catalogue
* @return array<string, mixed>
*/
private function currentTerms(?Subscription $subscription, array $catalogue): array
{
if ($subscription === null) {
return $catalogue;
}
return [
'tier' => $subscription->tier,
// Per month: the card says "/ month", and a yearly contract stores
// the whole year.
'price_cents' => $subscription->monthlyPriceCents(),
'currency' => $subscription->currency,
'quota_gb' => $subscription->quota_gb,
'traffic_gb' => $subscription->traffic_gb,
'seats' => $subscription->seats,
'performance' => $subscription->performance,
'features' => $subscription->planVersion?->features ?? ($catalogue['features'] ?? []),
];
}
public function render()
{
$customer = $this->customer();
$instance = $customer?->instances()->latest('id')->first();
$plans = app(PlanCatalogue::class)->sellable();
$currentKey = $instance?->plan ?? 'start';
// Rank, not price: a grandfathered plan can cost less than a smaller
// one does today, and offering that as an "upgrade" would charge a
// customer immediately for losing resources.
$currentTier = (int) ($instance?->subscription?->tier ?? $plans[$currentKey]['tier'] ?? 0);
$upgrades = collect($plans)
->filter(fn ($p) => (int) ($p['tier'] ?? 0) > $currentTier)
->keys()->all();
return view('livewire.billing', [
'currentKey' => $currentKey,
// What they HAVE comes from their contract; only what they could
// BUY comes from the shop. Reading this off the catalogue showed a
// customer today's price as though it were theirs, and dropped the
// card entirely once their plan stopped being sold.
'current' => $this->currentTerms($instance?->subscription, $plans[$currentKey] ?? []),
'instance' => $instance,
'plans' => $plans,
'features' => collect($plans)->map(fn ($p) => $p['features'] ?? [])->all(),
'upgrades' => $upgrades,
'storage' => (array) config('provisioning.storage_addon'),
'trafficAddon' => (array) config('provisioning.traffic.addon'),
// Resolved once: the cart and the plan cards must not state
// different tax treatments on the same page.
'tax' => \App\Services\Billing\TaxTreatment::for($customer),
'trafficMeter' => $instance !== null ? \App\Services\Traffic\TrafficMeter::for($instance) : null,
// Booked modules at the price they were booked at; everything else
// at today's. Reading them all off the catalogue would re-price
// half a customer's bill behind their back.
'addons' => collect(app(AddonCatalogue::class)->forSubscription($instance?->subscription))
->except(AddonCatalogue::STORAGE)
->all(),
'totalMonthlyCents' => $instance?->subscription?->totalMonthlyCents(),
'pending' => $customer
? $customer->orders()->where('status', 'pending')->latest('id')->get()
: collect(),
]);
}
}

91
app/Livewire/Cloud.php Normal file
View File

@ -0,0 +1,91 @@
<?php
namespace App\Livewire;
use App\Livewire\Concerns\ResolvesCustomer;
use App\Models\MaintenanceWindow;
use Illuminate\Support\Number;
use Livewire\Attributes\Layout;
use Livewire\Component;
#[Layout('layouts.portal-app')]
class Cloud extends Component
{
use ResolvesCustomer;
public function render()
{
$locale = app()->getLocale();
$growth = [180, 188, 195, 199, 204, 210, 214, 219, 223, 228, 231, 235];
// The card is built from the customer's real service instance, so the
// maintenance badge below can be scoped to exactly that instance's host.
$customer = $this->customer();
$shown = $customer?->instances()
->whereIn('status', ['active', 'provisioning', 'cancellation_scheduled'])
->latest('id')->first();
$maintenance = MaintenanceWindow::forInstance($shown)->first();
// The instance carries what was actually provisioned, and the contract
// what was bought. Neither is the catalogue, which only describes what
// we would sell someone new — never what this customer already has.
$contract = $shown?->subscription;
$planKey = $shown?->plan ?? 'team';
$quota = (int) ($shown?->quota_gb ?? $contract?->quota_gb ?? 500);
// Usage metering is not wired yet — scale the illustrative curve into the
// instance's quota so the chart and the "x / y GB" label never disagree.
$curveMax = max($growth);
if ($curveMax > $quota) {
$growth = array_map(fn ($v) => (int) round($v / $curveMax * $quota), $growth);
}
$used = $growth[count($growth) - 1];
$domain = $shown?->custom_domain
?: ($shown?->subdomain ? $shown->subdomain.'.'.config('provisioning.dns.zone', 'clupilot.com') : 'cloud.example.com');
return view('livewire.cloud', [
'instance' => [
'name' => $customer ? __('cloud.instance_name', ['name' => $customer->name]) : __('cloud.title'),
'domain' => $domain,
'status' => $shown->status ?? 'active',
'plan' => __('cloud.plan_line', [
'plan' => 'CluPilot Cloud '.__('billing.plan.'.$planKey),
// The line ends in "/mo", so a yearly contract has to be
// divided down before it goes in.
'price' => (int) round(($contract?->monthlyPriceCents() ?? 0) / 100),
]),
'location' => __('cloud.datacenter'),
'version' => 'Nextcloud 31.0.4',
'php' => 'PHP 8.3 · MariaDB 11.4',
'storageUsed' => $used,
'storageQuota' => $quota,
'seats' => __('billing.seats_count', ['count' => $contract?->seats ?? 0]),
'performance' => __('billing.perf.'.($contract?->performance ?? 'standard')),
],
'storageChart' => [
'type' => 'line',
'data' => [
'labels' => ['', '', '', '', '', '', '', '', '', '', '', __('cloud.now')],
'datasets' => [[
'label' => 'GB',
'data' => $growth,
'borderColor' => 'token:accent',
'backgroundColor' => 'token:accent/0.10',
'fill' => true,
'tension' => 0.35,
'pointRadius' => 0,
'borderWidth' => 2,
]],
],
'options' => [
'scales' => [
'x' => ['grid' => ['display' => false]],
'y' => ['beginAtZero' => false, 'grid' => ['color' => 'token:border']],
],
'plugins' => ['legend' => ['display' => false]],
],
],
'storageLabel' => Number::format($used, locale: $locale).' / '.Number::format($quota, locale: $locale).' GB',
'maintenance' => $maintenance,
]);
}
}

View File

@ -0,0 +1,56 @@
<?php
namespace App\Livewire\Concerns;
use App\Models\ProvisioningRun;
/**
* Builds the {label, state} array a progress stepper renders from a run's
* pipeline + current step + status. Shared by admin and customer views.
*/
trait BuildsRunSteps
{
/** @return array<int, array{label: string, state: string}> */
protected function buildRunSteps(?ProvisioningRun $run): array
{
if ($run === null) {
return [];
}
$pipeline = config('provisioning.pipelines.'.$run->pipeline, []);
$current = $run->current_step;
$status = $run->status;
$steps = [];
foreach ($pipeline as $index => $class) {
if ($status === ProvisioningRun::STATUS_COMPLETED || $index < $current) {
$state = 'done';
} elseif ($index === $current) {
$state = $status === ProvisioningRun::STATUS_FAILED ? 'failed' : 'running';
} else {
$state = 'pending';
}
$steps[] = ['label' => __(app($class)->label()), 'state' => $state];
}
return $steps;
}
protected function currentStepLabel(ProvisioningRun $run): string
{
$pipeline = config('provisioning.pipelines.'.$run->pipeline, []);
$class = $pipeline[$run->current_step] ?? null;
return $class ? __(app($class)->label()) : '—';
}
protected function runState(ProvisioningRun $run): string
{
return match ($run->status) {
ProvisioningRun::STATUS_COMPLETED => 'done',
ProvisioningRun::STATUS_FAILED => 'failed',
default => 'running',
};
}
}

View File

@ -0,0 +1,39 @@
<?php
namespace App\Livewire\Concerns;
use App\Models\Customer;
/**
* Resolves the customer behind the signed-in portal user.
*
* Operator accounts (admins) have no Customer, so customer-scoped actions must
* NOT silently no-op that reads as a dead button. requireCustomer() surfaces
* an explicit message instead and returns null so the caller can bail.
*/
trait ResolvesCustomer
{
protected function customer(): ?Customer
{
$user = auth()->user();
if (! $user) {
return null;
}
// Prefer the explicit link; fall back to email for legacy unlinked accounts.
return Customer::query()->where('user_id', $user->id)->first()
?? Customer::query()->where('email', $user->email)->first();
}
/** Same as customer(), but tells the user why nothing happened. */
protected function requireCustomer(): ?Customer
{
$customer = $this->customer();
if ($customer === null) {
$this->dispatch('notify', message: __('dashboard.no_customer_action'));
}
return $customer;
}
}

View File

@ -0,0 +1,78 @@
<?php
namespace App\Livewire;
use App\Livewire\Concerns\ResolvesCustomer;
use App\Models\Instance;
use LivewireUI\Modal\ModalComponent;
/**
* Cancel the customer's package (R5). Per the founder's decision: effective at
* the end of the billing term, irreversible once confirmed; at term end the
* customer receives a finished data export, then the instance is deprovisioned
* (both mocked for now). Requires typing the instance subdomain to confirm.
*/
class ConfirmCancelPackage extends ModalComponent
{
use ResolvesCustomer;
public string $confirmName = '';
public function cancelPackage()
{
$customer = $this->customer();
// Target the ACTIVE package explicitly — a newer failed/deprovisioned
// record must not shadow an older active instance (v1: one active/customer).
$instance = $customer?->instances()->where('status', 'active')->latest('id')->first();
if ($instance === null) {
// Explain rather than silently closing: no customer behind this login
// (e.g. an operator account) or no active package to cancel.
$this->addError('confirmName', __($customer === null ? 'dashboard.no_customer_action' : 'settings.no_package'));
return null;
}
// Typed confirmation must match the instance subdomain.
if (trim($this->confirmName) !== (string) $instance->subdomain) {
$this->addError('confirmName', __('settings.cancel_mismatch'));
return;
}
$instance->update([
'status' => 'cancellation_scheduled',
'cancel_requested_at' => now(),
'service_ends_at' => $this->currentPeriodEnd($instance),
]);
return $this->redirectRoute('settings', navigate: true);
}
/**
* End of the current monthly billing period, anchored on the subscription
* start (the order date) rather than assuming calendar-month billing.
*/
private function currentPeriodEnd(Instance $instance): \Illuminate\Support\Carbon
{
$start = $instance->order?->created_at ?? $instance->created_at ?? now();
// Always add from the immutable start so the billing-day anchor is kept
// (chaining addMonth would drift a Jan-31 start to Feb-28 → Mar-28).
$months = 1;
while ($start->copy()->addMonthsNoOverflow($months)->lessThanOrEqualTo(now())) {
$months++;
}
return $start->copy()->addMonthsNoOverflow($months);
}
public function render()
{
$instance = $this->customer()?->instances()->where('status', 'active')->latest('id')->first();
return view('livewire.confirm-cancel-package', [
'subdomain' => $instance?->subdomain ?? '',
]);
}
}

View File

@ -0,0 +1,65 @@
<?php
namespace App\Livewire;
use App\Livewire\Concerns\ResolvesCustomer;
use App\Models\Customer;
use LivewireUI\Modal\ModalComponent;
/**
* Close the whole CluPilot account (R5). Guarded: only allowed when no active
* package remains an active subscription must be cancelled first. Sets
* closed_at; financial records are retained. Requires typing "LOESCHEN".
*/
class ConfirmCloseAccount extends ModalComponent
{
use ResolvesCustomer;
public string $confirmWord = '';
public function closeAccount()
{
$customer = $this->customer();
// No customer behind this login (e.g. an operator account) — say so
// instead of leaving the button looking broken.
if ($customer === null) {
$this->addError('confirmWord', __('dashboard.no_customer_action'));
return null;
}
if ($this->hasActivePackage($customer)) {
$this->addError('confirmWord', __('settings.close_blocked'));
return;
}
if (mb_strtoupper(trim($this->confirmWord)) !== __('settings.close_keyword')) {
$this->addError('confirmWord', __('settings.close_mismatch'));
return;
}
$customer->update(['closed_at' => now(), 'status' => 'closed']);
return $this->redirectRoute('settings', navigate: true);
}
private function hasActivePackage(Customer $customer): bool
{
// Only genuinely-live packages block closure — a failed/cancelled/
// deprovisioned instance does not retain a service.
return $customer->instances()
->whereIn('status', ['active', 'provisioning', 'cancellation_scheduled'])
->exists();
}
public function render()
{
$customer = $this->customer();
return view('livewire.confirm-close-account', [
'blocked' => $customer !== null && $this->hasActivePackage($customer),
]);
}
}

View File

@ -0,0 +1,73 @@
<?php
namespace App\Livewire;
use App\Livewire\Concerns\ResolvesCustomer;
use App\Models\Order;
use LivewireUI\Modal\ModalComponent;
/**
* Taking a not-yet-paid purchase back out of the cart.
*
* Scoped to the signed-in customer's own orders: modals are reachable without
* the page's own guards, so the ownership check belongs here, not in the view
* that happens to link to it.
*/
class ConfirmRemoveOrder extends ModalComponent
{
use ResolvesCustomer;
public string $uuid;
public string $label = '';
public int $amountCents = 0;
public function mount(string $uuid): void
{
$order = $this->order($uuid);
abort_if($order === null, 404);
$this->uuid = $uuid;
$this->label = $order->label();
$this->amountCents = $order->amount_cents;
}
public function remove(): void
{
$order = $this->order($this->uuid);
// Gone or already paid for: say so instead of pretending it worked.
if ($order === null) {
$this->dispatch('notify', message: __('billing.cart.gone'));
$this->closeModal();
return;
}
$order->delete();
$this->dispatch('order-removed');
$this->closeModal();
}
/** @return Order|null the customer's own, still-pending order */
private function order(string $uuid): ?Order
{
$customer = $this->customer();
if ($customer === null) {
return null;
}
return Order::query()
->where('customer_id', $customer->id)
->where('uuid', $uuid)
->where('status', 'pending')
->first();
}
public function render()
{
return view('livewire.confirm-remove-order');
}
}

View File

@ -0,0 +1,59 @@
<?php
namespace App\Livewire;
use App\Livewire\Concerns\BuildsRunSteps;
use App\Models\Customer;
use App\Models\Order;
use App\Models\ProvisioningRun;
use Livewire\Component;
/**
* Embedded live provisioning progress for the logged-in customer's order.
* Polls only itself so the dashboard's charts don't churn. Renders nothing once
* provisioning has completed.
*/
class CustomerProvisioning extends Component
{
use BuildsRunSteps;
private function currentRun(): ?ProvisioningRun
{
$user = auth()->user();
if ($user === null) {
return null;
}
$customer = Customer::query()->where('email', $user->email)->first();
if ($customer === null) {
return null;
}
return ProvisioningRun::query()
->where('subject_type', Order::class)
->whereIn('subject_id', $customer->orders()->select('id'))
->latest('id')
->first();
}
public function render()
{
$run = $this->currentRun();
// Show the card while provisioning is in flight OR terminally failed; but
// only POLL while it can still change (terminal failed must not poll forever).
$show = $run !== null && $run->status !== ProvisioningRun::STATUS_COMPLETED;
$polling = $run !== null && in_array($run->status, [
ProvisioningRun::STATUS_PENDING,
ProvisioningRun::STATUS_RUNNING,
ProvisioningRun::STATUS_WAITING,
], true);
return view('livewire.customer-provisioning', [
'active' => $show,
'polling' => $polling,
'failed' => $run?->status === ProvisioningRun::STATUS_FAILED,
'steps' => $show ? $this->buildRunSteps($run) : [],
]);
}
}

133
app/Livewire/Dashboard.php Normal file
View File

@ -0,0 +1,133 @@
<?php
namespace App\Livewire;
use App\Livewire\Concerns\ResolvesCustomer;
use App\Services\Traffic\TrafficMeter;
use Illuminate\Support\Carbon;
use Illuminate\Support\Number;
use Livewire\Attributes\Layout;
use Livewire\Component;
#[Layout('layouts.portal-app')]
class Dashboard extends Component
{
use ResolvesCustomer;
public function render()
{
// Real numbers where they exist: traffic is metered, unlike the
// fixtures below, and a customer needs to see it before it runs out.
$instance = $this->customer()?->instances()->latest('id')->first();
$traffic = $instance !== null ? TrafficMeter::for($instance) : null;
// Locale-aware date / number formatting for the fixture values below.
$locale = app()->getLocale();
$date = fn (string $iso, string $fmt) => Carbon::parse($iso)->locale($locale)->isoFormat($fmt);
$gb = fn (float $v) => Number::format($v, precision: 1, locale: $locale).' GB';
// Fixture data until the provisioning engine (separate handoff) supplies
// real instances / metrics. Tenant-specific values stand in for DB rows.
$storageUsed = 235;
$storageQuota = 500;
$storagePercent = (int) round($storageUsed / max(1, $storageQuota) * 100);
// ~30 days of availability, gently trending near 100%.
$availability = [99.95, 99.97, 99.96, 99.98, 99.99, 99.97, 99.98, 99.99, 99.99, 99.98,
99.97, 99.99, 100, 99.98, 99.99, 99.99, 99.97, 99.98, 99.99, 100,
99.98, 99.99, 99.99, 99.98, 100, 99.99, 99.98, 99.99, 100, 99.98];
return view('livewire.dashboard', [
'traffic' => $traffic,
'storageUsed' => $storageUsed,
'storageQuota' => $storageQuota,
'storagePercent' => $storagePercent,
'usersActive' => 8,
'usersQuota' => 20,
'lastBackup' => '03:12',
'availability' => Number::format(99.98, precision: 2, locale: $locale),
'storageChart' => [
'type' => 'doughnut',
'data' => [
'datasets' => [[
'data' => [$storagePercent, 100 - $storagePercent],
'backgroundColor' => ['token:accent', 'token:surface-2'],
'borderWidth' => 0,
]],
],
'options' => [
'cutout' => '76%',
'plugins' => [
'legend' => ['display' => false],
'tooltip' => ['enabled' => false],
],
],
],
'availabilityChart' => [
'type' => 'line',
'data' => [
'labels' => array_fill(0, count($availability), ''),
'datasets' => [[
'data' => $availability,
'borderColor' => 'token:success-bright',
'backgroundColor' => 'token:success-bright/0.12',
'fill' => true,
'tension' => 0.4,
'pointRadius' => 0,
'borderWidth' => 2,
]],
],
'options' => [
'scales' => [
'x' => ['display' => false],
'y' => ['display' => false, 'min' => 99.9, 'max' => 100],
],
'plugins' => [
'legend' => ['display' => false],
'tooltip' => ['enabled' => false],
],
],
],
'cloud' => [
'name' => 'Cloud der Kanzlei Berger',
'domain' => 'cloud.kanzlei-berger.at',
'package' => __('dashboard.cloud.plan_line', ['plan' => 'CluPilot Cloud Team', 'price' => 179]),
'since' => $date('2026-03-14', 'LL'),
'location' => __('dashboard.cloud.datacenter'),
'version' => 'Nextcloud 31.0.4',
],
'onboarding' => [
['label' => __('dashboard.onboarding.instance'), 'done' => true, 'date' => $date('2026-03-14', 'D. MMMM')],
['label' => __('dashboard.onboarding.domain'), 'done' => true, 'date' => $date('2026-03-15', 'D. MMMM')],
['label' => __('dashboard.onboarding.branding'), 'done' => true, 'date' => $date('2026-03-15', 'D. MMMM')],
['label' => __('dashboard.onboarding.users'), 'done' => true, 'date' => $date('2026-03-16', 'D. MMMM')],
['label' => __('dashboard.onboarding.migration'), 'done' => false, 'date' => null],
['label' => __('dashboard.onboarding.training'), 'done' => false, 'date' => null],
['label' => __('dashboard.onboarding.docs'), 'done' => false, 'date' => null],
],
'backups' => [
['when' => __('dashboard.today').', 03:12', 'size' => $gb(18.4)],
['when' => __('dashboard.yesterday').', 03:09', 'size' => $gb(18.4)],
['when' => $date('2026-07-21', 'D. MMMM').', 03:11', 'size' => $gb(18.3)],
],
'modules' => [
['icon' => 'cloud', 'name' => 'Nextcloud + Collabora', 'desc' => __('dashboard.modules.nextcloud'), 'status' => 'active'],
['icon' => 'shield-check', 'name' => 'Vaultwarden', 'desc' => __('dashboard.modules.vaultwarden'), 'status' => 'active'],
['icon' => 'receipt', 'name' => 'Paperless-ngx', 'desc' => __('dashboard.modules.paperless'), 'status' => 'available'],
['icon' => 'pen', 'name' => __('dashboard.modules.signature_name'), 'desc' => __('dashboard.modules.signature'), 'status' => 'soon'],
],
'activity' => [
['icon' => 'download', 'text' => __('dashboard.activity_items.backup_ok'), 'time' => '03:12'],
['icon' => 'shield-check', 'text' => __('dashboard.activity_items.security'), 'time' => '04:01'],
['icon' => 'users', 'text' => __('dashboard.activity_items.upload'), 'time' => '07:56'],
['icon' => 'shield-check', 'text' => __('dashboard.activity_items.cert'), 'time' => __('dashboard.yesterday').' 22:10'],
],
]);
}
}

58
app/Livewire/Invoices.php Normal file
View File

@ -0,0 +1,58 @@
<?php
namespace App\Livewire;
use Illuminate\Support\Carbon;
use Illuminate\Support\Number;
use Livewire\Attributes\Layout;
use Livewire\Component;
#[Layout('layouts.portal-app')]
class Invoices extends Component
{
public function render()
{
$locale = app()->getLocale();
$eur = fn (float $v) => Number::currency($v, in: 'EUR', locale: $locale);
$date = fn (string $iso) => Carbon::parse($iso)->locale($locale)->isoFormat('LL');
$months = collect(['2026-03-01', '2026-04-01', '2026-05-01', '2026-06-01', '2026-07-01'])
->map(fn ($m) => Carbon::parse($m)->locale($locale)->isoFormat('MMM'))->all();
$rows = [
['no' => 'CP-2026-0007', 'date' => $date('2026-07-01'), 'amount' => $eur(198), 'status' => 'paid'],
['no' => 'CP-2026-0006', 'date' => $date('2026-06-01'), 'amount' => $eur(198), 'status' => 'paid'],
['no' => 'CP-2026-0005', 'date' => $date('2026-05-01'), 'amount' => $eur(198), 'status' => 'paid'],
['no' => 'CP-2026-0004', 'date' => $date('2026-04-01'), 'amount' => $eur(179), 'status' => 'paid'],
['no' => 'CP-2026-0003', 'date' => $date('2026-03-15'), 'amount' => $eur(179), 'status' => 'paid'],
];
return view('livewire.invoices', [
'rows' => $rows,
'nextCharge' => $date('2026-08-01'),
'nextAmount' => $eur(198),
'spendChart' => [
'type' => 'line',
'data' => [
'labels' => $months,
'datasets' => [[
'label' => 'EUR',
'data' => [179, 179, 198, 198, 198],
'borderColor' => 'token:accent',
'fill' => true,
'tension' => 0.35,
'pointRadius' => 0,
'borderWidth' => 2,
]],
],
'options' => [
'scales' => [
'x' => ['grid' => ['display' => false]],
'y' => ['beginAtZero' => true, 'grid' => ['color' => 'token:border']],
],
'plugins' => ['legend' => ['display' => false]],
],
],
]);
}
}

162
app/Livewire/Settings.php Normal file
View File

@ -0,0 +1,162 @@
<?php
namespace App\Livewire;
use App\Livewire\Concerns\ResolvesCustomer;
use Illuminate\Support\Facades\Storage;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Validate;
use Livewire\Component;
use Livewire\WithFileUploads;
class Settings extends Component
{
use ResolvesCustomer, WithFileUploads;
// Company / billing profile
#[Validate('required|string|max:255')]
public string $companyName = '';
#[Validate('nullable|string|max:255')]
public string $contactName = '';
#[Validate('nullable|string|max:64')]
public string $phone = '';
#[Validate('nullable|string|max:64')]
public string $vatId = '';
#[Validate('nullable|string|max:2000')]
public string $billingAddress = '';
// Branding
#[Validate('nullable|string|max:255')]
public string $brandDisplayName = '';
#[Validate('nullable|regex:/^#[0-9a-fA-F]{6}$/')]
public string $brandPrimary = '';
#[Validate('nullable|regex:/^#[0-9a-fA-F]{6}$/')]
public string $brandAccent = '';
/** New logo upload (validated on save). */
public $logo = null;
public ?string $brandLogoPath = null;
public function mount(): void
{
$c = $this->customer();
if ($c === null) {
return;
}
$this->companyName = $c->name ?? '';
$this->contactName = $c->contact_name ?? '';
$this->phone = $c->phone ?? '';
$this->vatId = $c->vat_id ?? '';
$this->billingAddress = $c->billing_address ?? '';
$this->brandDisplayName = $c->brand_display_name ?? '';
$this->brandPrimary = $c->brand_primary_color ?? '';
$this->brandAccent = $c->brand_accent_color ?? '';
$this->brandLogoPath = $c->brand_logo_path;
}
public function saveProfile(): void
{
$c = $this->requireCustomer();
if ($c === null) {
return;
}
$this->validateOnly('companyName');
$data = $this->validate([
'companyName' => 'required|string|max:255',
'contactName' => 'nullable|string|max:255',
'phone' => 'nullable|string|max:64',
'vatId' => 'nullable|string|max:64',
'billingAddress' => 'nullable|string|max:2000',
]);
$c->update([
'name' => $data['companyName'],
'contact_name' => $data['contactName'] ?: null,
'phone' => $data['phone'] ?: null,
'vat_id' => $data['vatId'] ?: null,
'billing_address' => $data['billingAddress'] ?: null,
]);
$this->dispatch('notify', message: __('settings.profile_saved'));
}
public function saveBranding(): void
{
$c = $this->requireCustomer();
if ($c === null) {
return;
}
$this->validate([
'brandDisplayName' => 'nullable|string|max:255',
'brandPrimary' => 'nullable|regex:/^#[0-9a-fA-F]{6}$/',
'brandAccent' => 'nullable|regex:/^#[0-9a-fA-F]{6}$/',
'logo' => 'nullable|image|mimes:png,webp|max:2048',
]);
// Store the new upload, but keep the old file until the DB row that
// references it is updated — delete the old one only after that commits,
// so a failed update never orphans a file or dangles a reference.
$oldToDelete = null;
if ($this->logo !== null) {
$oldToDelete = $this->brandLogoPath;
$this->brandLogoPath = $this->logo->store('branding', 'public');
$this->logo = null;
}
$c->update([
'brand_display_name' => $this->brandDisplayName ?: null,
'brand_primary_color' => $this->brandPrimary ?: null,
'brand_accent_color' => $this->brandAccent ?: null,
'brand_logo_path' => $this->brandLogoPath,
]);
if ($oldToDelete !== null && $oldToDelete !== $this->brandLogoPath) {
Storage::disk('public')->delete($oldToDelete);
}
$this->dispatch('notify', message: __('settings.branding_saved'));
}
public function removeLogo(): void
{
$c = $this->requireCustomer();
if ($c === null) {
return;
}
if ($this->brandLogoPath !== null) {
Storage::disk('public')->delete($this->brandLogoPath);
}
$this->brandLogoPath = null;
$c->update(['brand_logo_path' => null]);
$this->dispatch('notify', message: __('settings.branding_saved'));
}
#[Layout('layouts.portal-app')]
public function render()
{
$c = $this->customer();
// Prefer the active/cancelling instance for the package section so the
// controls line up with what cancellation actually targets.
$active = $c?->instances()->where('status', 'active')->latest('id')->first();
$scheduled = $c?->instances()->where('status', 'cancellation_scheduled')->latest('id')->first();
$instance = $active ?? $scheduled ?? $c?->instances()->latest('id')->first();
return view('livewire.settings', [
'customer' => $c,
'instance' => $instance,
'branding' => $c?->brandingResolved(),
'logoUrl' => $this->brandLogoPath ? Storage::disk('public')->url($this->brandLogoPath) : null,
'hasActivePackage' => $active !== null,
'cancellationScheduled' => $active === null && $scheduled !== null,
]);
}
}

30
app/Livewire/Support.php Normal file
View File

@ -0,0 +1,30 @@
<?php
namespace App\Livewire;
use Illuminate\Support\Carbon;
use Livewire\Attributes\Layout;
use Livewire\Component;
#[Layout('layouts.portal-app')]
class Support extends Component
{
public function render()
{
$locale = app()->getLocale();
$date = fn (string $iso) => Carbon::parse($iso)->locale($locale)->isoFormat('LL');
return view('livewire.support', [
'tickets' => [
['subject' => __('support.ticket_migration'), 'ref' => '#4821', 'date' => $date('2026-07-22'), 'status' => 'open'],
['subject' => __('support.ticket_training'), 'ref' => '#4790', 'date' => $date('2026-07-18'), 'status' => 'progress'],
['subject' => __('support.ticket_smtp'), 'ref' => '#4712', 'date' => $date('2026-07-04'), 'status' => 'closed'],
],
'faqs' => [
['q' => __('support.faq_q1'), 'a' => __('support.faq_a1')],
['q' => __('support.faq_q2'), 'a' => __('support.faq_a2')],
['q' => __('support.faq_q3'), 'a' => __('support.faq_a3')],
],
]);
}
}

203
app/Livewire/Users.php Normal file
View File

@ -0,0 +1,203 @@
<?php
namespace App\Livewire;
use App\Livewire\Concerns\ResolvesCustomer;
use App\Models\Customer;
use App\Models\Seat;
use Illuminate\Database\UniqueConstraintViolationException;
use Illuminate\Support\Facades\DB;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Validate;
use Livewire\Component;
#[Layout('layouts.portal-app')]
class Users extends Component
{
use ResolvesCustomer;
#[Validate('required|email|max:255')]
public string $inviteEmail = '';
#[Validate('nullable|string|max:255')]
public string $inviteName = '';
#[Validate('required|in:admin,member,readonly')]
public string $inviteRole = 'member';
public function mount(): void
{
// Every customer starts with themselves as the owner seat. firstOrCreate
// keyed on (customer_id, email) is idempotent; the catch covers the
// concurrent-first-visit race against the unique index.
$customer = $this->customer();
if ($customer !== null && $customer->seats()->count() === 0) {
try {
$customer->seats()->firstOrCreate(
['email' => $customer->email],
['name' => $customer->name, 'role' => 'owner', 'status' => 'active', 'invited_at' => now()],
);
} catch (UniqueConstraintViolationException) {
// Another concurrent first visit created it — fine.
}
}
}
public function invite(): void
{
$customer = $this->requireCustomer();
if ($customer === null) {
return;
}
$data = $this->validate();
// Serialize the limit check with the insert so two concurrent invites for
// the last free seat can't both pass (lock the customer row).
$result = DB::transaction(function () use ($customer, $data) {
$locked = Customer::query()->whereKey($customer->id)->lockForUpdate()->first();
if ($locked->seats()->where('email', $data['inviteEmail'])->exists()) {
return 'duplicate';
}
if ($this->usedSeats($locked) >= $this->seatLimit($locked)) {
return 'limit';
}
$locked->seats()->create([
'email' => $data['inviteEmail'],
'name' => $data['inviteName'] ?: null,
'role' => $data['inviteRole'],
'status' => 'invited',
'invited_at' => now(),
]);
return 'ok';
});
if ($result === 'limit') {
$this->addError('inviteEmail', __('users.limit_reached'));
return;
}
if ($result === 'duplicate') {
$this->addError('inviteEmail', __('users.duplicate'));
return;
}
$this->reset('inviteEmail', 'inviteName', 'inviteRole');
$this->inviteRole = 'member';
$this->dispatch('notify', message: __('users.invited'));
}
public function setRole(string $uuid, string $role): void
{
if (! in_array($role, Seat::ROLES, true)) {
return;
}
$customer = $this->requireCustomer();
if ($customer === null) {
return;
}
// Lock the customer so a concurrent owner change can't race past the guard.
$ok = DB::transaction(function () use ($customer, $uuid, $role) {
Customer::query()->whereKey($customer->id)->lockForUpdate()->first();
$seat = $customer->seats()->where('uuid', $uuid)->first();
if ($seat === null) {
return true;
}
if ($seat->role === 'owner' && $role !== 'owner' && $customer->seats()->where('role', 'owner')->count() <= 1) {
return false; // would remove the last owner
}
$seat->update(['role' => $role]);
return true;
});
if (! $ok) {
$this->dispatch('notify', message: __('users.last_owner'));
}
}
public function revoke(string $uuid): void
{
$customer = $this->requireCustomer();
if ($customer === null) {
return;
}
$result = DB::transaction(function () use ($customer, $uuid) {
Customer::query()->whereKey($customer->id)->lockForUpdate()->first();
$seat = $customer->seats()->where('uuid', $uuid)->first();
if ($seat === null) {
return 'gone';
}
if ($seat->role === 'owner' && $customer->seats()->where('role', 'owner')->count() <= 1) {
return 'last_owner';
}
$seat->delete();
return 'ok';
});
if ($result === 'last_owner') {
$this->dispatch('notify', message: __('users.last_owner'));
} elseif ($result === 'ok') {
$this->dispatch('notify', message: __('users.revoked'));
}
}
public function resend(string $uuid): void
{
// Invite delivery is mocked for now.
if ($this->seat($uuid) !== null) {
$this->dispatch('notify', message: __('users.resent'));
}
}
private function ownerCount(): int
{
$customer = $this->customer();
return $customer ? $customer->seats()->where('role', 'owner')->count() : 0;
}
private function usedSeats(Customer $customer): int
{
return $customer->seats()->where('status', '!=', 'revoked')->count();
}
private function seatLimit(Customer $customer): int
{
// The entitlement follows the active (or cancelling) package, not a newer
// failed/deprovisioned record.
$instance = $customer->instances()->whereIn('status', ['active', 'cancellation_scheduled'])->latest('id')->first()
?? $customer->instances()->latest('id')->first();
// From the contract: how many people a customer may invite is part of
// what they bought. Cutting a plan's seats in the catalogue must not
// lock users out of an existing customer's cloud.
return (int) ($instance?->subscription?->seats ?? 5);
}
private function seat(string $uuid): ?Seat
{
$customer = $this->customer();
return $customer?->seats()->where('uuid', $uuid)->first();
}
public function render()
{
$customer = $this->customer();
$seats = $customer ? $customer->seats()->orderByRaw("role = 'owner' desc")->orderBy('email')->get() : collect();
return view('livewire.users', [
'seats' => $seats,
'used' => $customer ? $this->usedSeats($customer) : 0,
'limit' => $customer ? $this->seatLimit($customer) : 0,
'roles' => Seat::ROLES,
]);
}
}

View File

@ -0,0 +1,47 @@
<?php
namespace App\Mail;
use App\Models\Customer;
use App\Models\MaintenanceWindow;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Mail\Mailables\Headers;
use Illuminate\Queue\SerializesModels;
class MaintenanceAnnouncementMail extends Mailable implements ShouldQueue
{
use Queueable, SerializesModels;
/** The ledger row this mail confirms once actually delivered. */
public ?int $notificationId = null;
public function __construct(
public MaintenanceWindow $window,
public Customer $customer,
) {}
public function headers(): Headers
{
return new Headers(text: $this->notificationId ? ['X-CP-Notification' => (string) $this->notificationId] : []);
}
public function envelope(): Envelope
{
return new Envelope(subject: __('maintenance.mail_subject', ['title' => $this->window->title]));
}
public function content(): Content
{
return new Content(markdown: 'mail.maintenance-announcement', with: [
'title' => $this->window->title,
'description' => $this->window->public_description,
'startsAt' => $this->window->starts_at,
'endsAt' => $this->window->ends_at,
'name' => $this->customer->name,
]);
}
}

View File

@ -0,0 +1,44 @@
<?php
namespace App\Mail;
use App\Models\Customer;
use App\Models\MaintenanceWindow;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Mail\Mailables\Headers;
use Illuminate\Queue\SerializesModels;
class MaintenanceCancelledMail extends Mailable implements ShouldQueue
{
use Queueable, SerializesModels;
/** The ledger row this mail confirms once actually delivered. */
public ?int $notificationId = null;
public function __construct(
public MaintenanceWindow $window,
public Customer $customer,
) {}
public function headers(): Headers
{
return new Headers(text: $this->notificationId ? ['X-CP-Notification' => (string) $this->notificationId] : []);
}
public function envelope(): Envelope
{
return new Envelope(subject: __('maintenance.mail_cancel_subject', ['title' => $this->window->title]));
}
public function content(): Content
{
return new Content(markdown: 'mail.maintenance-cancelled', with: [
'title' => $this->window->title,
'name' => $this->customer->name,
]);
}
}

21
app/Models/Backup.php Normal file
View File

@ -0,0 +1,21 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Backup extends Model
{
protected $fillable = ['instance_id', 'external_job_id', 'schedule', 'last_ok_at', 'status'];
protected function casts(): array
{
return ['last_ok_at' => 'datetime'];
}
public function instance(): BelongsTo
{
return $this->belongsTo(Instance::class);
}
}

View File

@ -0,0 +1,41 @@
<?php
namespace App\Models\Builders;
use Illuminate\Database\Eloquent\Builder;
use RuntimeException;
/**
* A query builder that refuses to rewrite history.
*
* Model events (`updating`, `deleting`) only fire for instance operations, so a
* guard on the model alone still lets `Model::query()->where(...)->update(...)`
* through which is exactly the shape a careless data fix takes. For a table
* whose value IS that it cannot be edited, the guard has to sit where the bulk
* operations pass.
*
* Not a security boundary: anyone with database access can still do as they
* please. It is a guard against the application quietly doing it by accident,
* which is the realistic way a register loses its meaning.
*/
class AppendOnlyBuilder extends Builder
{
public function update(array $values)
{
throw new RuntimeException(
'The proof register is append-only. Record a correcting event instead of editing history.'
);
}
public function delete()
{
throw new RuntimeException(
'The proof register is append-only. A record that can be deleted is not evidence.'
);
}
public function forceDelete()
{
return $this->delete();
}
}

View File

@ -0,0 +1,26 @@
<?php
namespace App\Models\Concerns;
use Illuminate\Support\Str;
/**
* Assigns a random UUID on creation. Records that expose a `uuid` column use it
* as their route key (R11: URLs address records by UUID, not integer PK).
*/
trait HasUuid
{
protected static function bootHasUuid(): void
{
static::creating(function ($model) {
if (empty($model->uuid)) {
$model->uuid = (string) Str::uuid();
}
});
}
public function getRouteKeyName(): string
{
return 'uuid';
}
}

144
app/Models/Customer.php Normal file
View File

@ -0,0 +1,144 @@
<?php
namespace App\Models;
use App\Models\Concerns\HasUuid;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\UniqueConstraintViolationException;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
use RuntimeException;
class Customer extends Model
{
/** Comparable form: spaces and punctuation are cosmetic in a VAT number. */
public static function normaliseVatId(?string $value): string
{
return strtoupper(preg_replace('/[\s.-]+/', '', (string) $value) ?? '');
}
public function normalisedVatId(): string
{
return self::normaliseVatId($this->vat_id);
}
/**
* True only for the value that was actually checked. Binding it to the
* value rather than to a flag means editing the field cannot inherit the
* old confirmation, whichever code path does the editing.
*/
public function hasVerifiedVatId(): bool
{
// Both sides normalised: a verifier that stores the number in display
// form ("DE 811 907 980") must not invalidate a genuine confirmation.
return $this->vat_id_verified_at !== null
&& $this->normalisedVatId() !== ''
&& $this->normalisedVatId() === self::normaliseVatId($this->vat_id_verified_value);
}
/** @use HasFactory<\Database\Factories\CustomerFactory> */
use HasFactory, HasUuid;
protected $fillable = [
'user_id', 'name', 'contact_name', 'email', 'phone', 'vat_id', 'vat_id_verified_at', 'vat_id_verified_value', 'billing_address',
'locale', 'stripe_customer_id', 'status', 'closed_at',
'brand_display_name', 'brand_logo_path', 'brand_primary_color', 'brand_accent_color',
];
protected function casts(): array
{
return ['closed_at' => 'datetime', 'vat_id_verified_at' => 'datetime'];
}
public function seats(): HasMany
{
return $this->hasMany(Seat::class);
}
/**
* Resolve branding: customer values where set, else CluPilot defaults. Used
* for previews and snapshotted into the provisioning run so retries are
* deterministic. NULL is stored for "unset" defaults are never copied in.
*
* @return array{display_name:string,logo_path:?string,primary_color:string,accent_color:string,is_default:bool}
*/
public function brandingResolved(): array
{
$defaults = (array) config('provisioning.branding_defaults');
return [
'display_name' => $this->brand_display_name ?: ($defaults['display_name'] ?? 'CluPilot'),
'logo_path' => $this->brand_logo_path ?: ($defaults['logo_path'] ?? null),
'primary_color' => $this->brand_primary_color ?: ($defaults['primary_color'] ?? '#f97316'),
'accent_color' => $this->brand_accent_color ?: ($defaults['accent_color'] ?? '#c2560a'),
'is_default' => $this->brand_display_name === null
&& $this->brand_logo_path === null
&& $this->brand_primary_color === null
&& $this->brand_accent_color === null,
];
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function orders(): HasMany
{
return $this->hasMany(Order::class);
}
public function instances(): HasMany
{
return $this->hasMany(Instance::class);
}
/**
* Find or create the portal login account for this customer and link it.
* Race-safe against the unique email index, and never links an operator
* account that would carry admin authorization into an impersonated
* "customer" session.
*/
public function ensureUser(): User
{
if ($this->user) {
return $this->user;
}
if (($existing = User::query()->where('email', $this->email)->first()) !== null) {
$this->assertNotAdmin($existing);
$this->update(['user_id' => $existing->id]);
return $existing;
}
try {
$user = User::query()->create([
'email' => $this->email,
'name' => $this->name,
'password' => Hash::make(Str::random(40)),
'is_admin' => false,
]);
} catch (UniqueConstraintViolationException) {
// Concurrent first-time creation — re-fetch and link the winner.
$user = User::query()->where('email', $this->email)->firstOrFail();
$this->assertNotAdmin($user);
}
$this->update(['user_id' => $user->id]);
return $user->refresh();
}
private function assertNotAdmin(User $user): void
{
if ($user->is_admin || $user->isOperator()) {
throw new RuntimeException(
"Refusing to link operator account {$user->email} as a portal login for customer {$this->id}.",
);
}
}
}

31
app/Models/Datacenter.php Normal file
View File

@ -0,0 +1,31 @@
<?php
namespace App\Models;
use App\Models\Concerns\HasUuid;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Datacenter extends Model
{
/** @use HasFactory<\Database\Factories\DatacenterFactory> */
use HasFactory, HasUuid;
protected $fillable = ['code', 'name', 'location', 'active'];
protected function casts(): array
{
return ['active' => 'boolean'];
}
public function scopeActive(Builder $query): Builder
{
return $query->where('active', true);
}
public function hosts()
{
return $this->hasMany(Host::class, 'datacenter', 'code');
}
}

16
app/Models/DnsRecord.php Normal file
View File

@ -0,0 +1,16 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class DnsRecord extends Model
{
protected $fillable = ['instance_id', 'provider', 'record_id', 'fqdn', 'type', 'value'];
public function instance(): BelongsTo
{
return $this->belongsTo(Instance::class);
}
}

129
app/Models/Host.php Normal file
View File

@ -0,0 +1,129 @@
<?php
namespace App\Models;
use App\Models\Concerns\HasUuid;
use App\Provisioning\Contracts\ProvisioningSubject;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphMany;
class Host extends Model implements ProvisioningSubject
{
/** @use HasFactory<\Database\Factories\HostFactory> */
use HasFactory, HasUuid;
protected $fillable = [
'name', 'datacenter', 'cluster', 'public_ip', 'wg_ip', 'wg_pubkey',
'ssh_host_key', 'api_token_ref', 'total_gb', 'total_ram_mb', 'cpu_cores',
'cpu_weight', 'reserve_pct', 'pve_version', 'node', 'status', 'last_seen_at', 'dns_name', 'dns_record_id',
];
protected $hidden = ['api_token_ref'];
protected function casts(): array
{
return [
'api_token_ref' => 'encrypted',
'last_seen_at' => 'datetime',
'total_gb' => 'integer',
'total_ram_mb' => 'integer',
'cpu_cores' => 'integer',
'cpu_weight' => 'integer',
'reserve_pct' => 'integer',
];
}
/** Placement runs (polymorphic subject). */
public function runs(): MorphMany
{
return $this->morphMany(ProvisioningRun::class, 'subject');
}
/** Runner hook: a failed onboarding run moves the host to the error status. */
public function onProvisioningFailed(): void
{
$this->update(['status' => 'error']);
}
public function instances(): HasMany
{
return $this->hasMany(Instance::class);
}
public function maintenanceWindows(): BelongsToMany
{
return $this->belongsToMany(MaintenanceWindow::class);
}
/** Free committable storage: total minus reserve. */
public function freeGb(): int
{
if ($this->total_gb === null) {
return 0;
}
return (int) floor($this->total_gb * (100 - $this->reserve_pct) / 100);
}
/**
* Host storage already committed, counted as the VM disk allocation
* (disk_gb) since that is what is actually placed on the host not the
* (smaller) Nextcloud user quota. A failed instance still counts while its VM
* exists (has a vmid); a failure before any VM was created releases it.
*/
public function committedGb(): int
{
return (int) $this->instances()
->where(fn ($q) => $q->where('status', '!=', 'failed')->orWhereNotNull('vmid'))
->sum('disk_gb');
}
/** Storage still available for new instances. */
public function availableGb(): int
{
return max(0, $this->freeGb() - $this->committedGb());
}
/** Used-storage percentage (committed vs committable free), 0100. */
public function usedPct(): int
{
$free = $this->freeGb();
return $free > 0 ? min(100, (int) round($this->committedGb() / $free * 100)) : 0;
}
/**
* Heartbeat health from last_seen_at: online (≤5 min), stale (≤30 min),
* offline (older or never seen). Drives the health dot in the console.
*/
public function healthState(): string
{
if ($this->last_seen_at === null) {
return 'offline';
}
$minutes = $this->last_seen_at->diffInMinutes(now());
return match (true) {
$minutes <= 5 => 'online',
$minutes <= 30 => 'stale',
default => 'offline',
};
}
/**
* Placement (spec §1): first active host in the datacenter with enough free
* committable storage. Cluster is ignored in v1.0.
*/
public static function placeableIn(string $datacenter, int $quotaGb): ?self
{
return self::query()
->where('datacenter', $datacenter)
->where('status', 'active')
->orderBy('name')
->get()
->first(fn (self $host) => $host->availableGb() >= $quotaGb);
}
}

85
app/Models/Instance.php Normal file
View File

@ -0,0 +1,85 @@
<?php
namespace App\Models;
use App\Models\Concerns\HasUuid;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
class Instance extends Model
{
/** @use HasFactory<\Database\Factories\InstanceFactory> */
use HasFactory, HasUuid;
protected $fillable = [
'customer_id', 'order_id', 'host_id', 'vmid', 'guest_ip', 'plan', 'quota_gb', 'traffic_addons', 'disk_gb',
'ram_mb', 'cores', 'subdomain', 'custom_domain', 'nc_admin_ref',
'route_written', 'cert_ok', 'status', 'cancel_requested_at', 'service_ends_at',
];
protected $hidden = ['nc_admin_ref'];
protected function casts(): array
{
return [
'nc_admin_ref' => 'encrypted',
'route_written' => 'boolean',
'cert_ok' => 'boolean',
'vmid' => 'integer',
'traffic_addons' => 'integer',
'quota_gb' => 'integer',
'disk_gb' => 'integer',
'ram_mb' => 'integer',
'cores' => 'integer',
'cancel_requested_at' => 'datetime',
'service_ends_at' => 'datetime',
];
}
public function customer(): BelongsTo
{
return $this->belongsTo(Customer::class);
}
public function order(): BelongsTo
{
return $this->belongsTo(Order::class);
}
/**
* The contract this machine fulfils. The authority on what the customer is
* entitled to the catalogue only describes what we sell today.
*/
public function subscription(): HasOne
{
return $this->hasOne(Subscription::class);
}
public function host(): BelongsTo
{
return $this->belongsTo(Host::class);
}
public function dnsRecords(): HasMany
{
return $this->hasMany(DnsRecord::class);
}
public function backups(): HasMany
{
return $this->hasMany(Backup::class);
}
public function monitoringTargets(): HasMany
{
return $this->hasMany(MonitoringTarget::class);
}
public function onboardingTasks(): HasMany
{
return $this->hasMany(OnboardingTask::class);
}
}

View File

@ -0,0 +1,37 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class InstanceTraffic extends Model
{
protected $table = 'instance_traffic';
protected $guarded = [];
protected function casts(): array
{
return [
'rx_bytes' => 'integer',
'tx_bytes' => 'integer',
'last_netin' => 'integer',
'last_netout' => 'integer',
'notified_percent' => 'integer',
'throttled' => 'boolean',
'sampled_at' => 'datetime',
'throttled_at' => 'datetime',
];
}
public function instance(): BelongsTo
{
return $this->belongsTo(Instance::class);
}
public static function currentPeriod(): string
{
return now()->format('Y-m');
}
}

View File

@ -0,0 +1,26 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class MaintenanceNotification extends Model
{
protected $fillable = ['maintenance_window_id', 'customer_id', 'event', 'email', 'sent_at', 'claimed_at'];
protected function casts(): array
{
return ['sent_at' => 'datetime', 'claimed_at' => 'datetime'];
}
public function window(): BelongsTo
{
return $this->belongsTo(MaintenanceWindow::class, 'maintenance_window_id');
}
public function customer(): BelongsTo
{
return $this->belongsTo(Customer::class);
}
}

View File

@ -0,0 +1,130 @@
<?php
namespace App\Models;
use App\Models\Concerns\HasUuid;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Collection;
class MaintenanceWindow extends Model
{
/** @use HasFactory<\Database\Factories\MaintenanceWindowFactory> */
use HasFactory, HasUuid;
/** How far before the start the banner appears (Codex: 72 h). */
public const DISPLAY_HOURS = 72;
protected $fillable = [
'title', 'public_description', 'internal_notes', 'starts_at', 'ends_at',
'state', 'created_by', 'published_at', 'cancelled_at', 'cancellation_reason',
];
protected function casts(): array
{
return [
'starts_at' => 'datetime',
'ends_at' => 'datetime',
'published_at' => 'datetime',
'cancelled_at' => 'datetime',
];
}
public function hosts(): BelongsToMany
{
return $this->belongsToMany(Host::class);
}
public function deliveries(): HasMany
{
return $this->hasMany(MaintenanceNotification::class);
}
/** Derived lifecycle state — never stored (Codex). */
public function derivedState(): string
{
if ($this->state === 'cancelled') {
return 'cancelled';
}
if ($this->state === 'draft') {
return 'draft';
}
if (now()->lt($this->starts_at)) {
return 'upcoming';
}
if (now()->lte($this->ends_at)) {
return 'active';
}
return 'completed';
}
public function isPublished(): bool
{
return $this->state === 'scheduled';
}
/** Service-bearing customers on the assigned hosts (recomputed live). */
public function affectedCustomers(): Collection
{
$hostIds = $this->hosts()->pluck('hosts.id');
if ($hostIds->isEmpty()) {
return collect();
}
return Customer::query()
->whereHas('instances', fn (Builder $q) => $q
->whereIn('host_id', $hostIds)
->whereIn('status', ['active', 'provisioning', 'cancellation_scheduled']))
->get();
}
/**
* Scheduled windows within the display horizon that affect ONE instance's
* host for a per-instance badge, so a customer with several instances
* never sees another instance's maintenance on this card.
*/
public static function forInstance(?Instance $instance): Collection
{
if ($instance === null || $instance->host_id === null) {
return collect();
}
return self::query()
->where('state', 'scheduled')
->where('ends_at', '>=', now())
->where('starts_at', '<=', now()->addHours(self::DISPLAY_HOURS))
->whereHas('hosts', fn (Builder $q) => $q->whereKey($instance->host_id))
->orderBy('starts_at')
->get();
}
/**
* Scheduled windows to show a given customer right now: within the display
* horizon (72 h before start until the end) and touching one of the
* customer's service instances' hosts.
*/
public static function bannerFor(Customer $customer): Collection
{
$hostIds = Instance::query()
->where('customer_id', $customer->id)
->whereIn('status', ['active', 'provisioning', 'cancellation_scheduled'])
->whereNotNull('host_id')
->pluck('host_id')->unique();
if ($hostIds->isEmpty()) {
return collect();
}
return self::query()
->where('state', 'scheduled')
->where('ends_at', '>=', now())
->where('starts_at', '<=', now()->addHours(self::DISPLAY_HOURS))
->whereHas('hosts', fn (Builder $q) => $q->whereIn('hosts.id', $hostIds))
->orderBy('starts_at')
->get();
}
}

View File

@ -0,0 +1,16 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class MonitoringTarget extends Model
{
protected $fillable = ['instance_id', 'external_id', 'url', 'status'];
public function instance(): BelongsTo
{
return $this->belongsTo(Instance::class);
}
}

View File

@ -0,0 +1,21 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class OnboardingTask extends Model
{
protected $fillable = ['instance_id', 'key', 'done', 'done_at'];
protected function casts(): array
{
return ['done' => 'boolean', 'done_at' => 'datetime'];
}
public function instance(): BelongsTo
{
return $this->belongsTo(Instance::class);
}
}

109
app/Models/Order.php Normal file
View File

@ -0,0 +1,109 @@
<?php
namespace App\Models;
use App\Models\Concerns\HasUuid;
use App\Services\Billing\TaxTreatment;
use App\Provisioning\Contracts\ProvisioningSubject;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Database\Eloquent\Relations\MorphMany;
class Order extends Model implements ProvisioningSubject
{
/** @use HasFactory<\Database\Factories\OrderFactory> */
use HasFactory, HasUuid;
protected $fillable = [
'customer_id', 'plan', 'plan_version_id', 'type', 'addon_key', 'amount_cents', 'currency',
'datacenter', 'stripe_event_id', 'stripe_subscription_id', 'status',
];
protected function casts(): array
{
return ['amount_cents' => 'integer', 'plan_version_id' => 'integer'];
}
/**
* What this order actually buys, in words.
*
* Lives on the model rather than in the view: the cart, the invoice list and
* any future confirmation mail must not each invent their own wording for
* the same row.
*/
public function label(): string
{
return match ($this->type) {
'upgrade' => __('billing.cart.upgrade', ['plan' => ucfirst($this->plan)]),
'storage' => __('billing.cart.storage', ['gb' => (int) config('provisioning.storage_addon.gb', 100)]),
'traffic' => __('billing.cart.traffic', ['gb' => (int) config('provisioning.traffic.addon.gb', 1000)]),
'addon' => __('billing.addon.'.$this->addon_key.'.name'),
default => __('billing.cart.plan', ['plan' => ucfirst($this->plan)]),
};
}
/**
* Monthly or one-off.
*
* Traffic is bought for the month that is running out and does not renew;
* everything else changes the recurring bill.
*/
public function isRecurring(): bool
{
return $this->type !== 'traffic';
}
/**
* Net is what is stored; gross is what gets charged and how much VAT that
* is depends on the customer, not on a global setting.
*/
public function grossCents(): int
{
return $this->taxTreatment()->grossCents($this->amount_cents);
}
public function taxTreatment(): TaxTreatment
{
return TaxTreatment::for($this->customer);
}
/** Only a pending order is still the customer's to change. */
public function isRemovable(): bool
{
return $this->status === 'pending';
}
public function customer(): BelongsTo
{
return $this->belongsTo(Customer::class);
}
public function instance(): HasOne
{
return $this->hasOne(Instance::class);
}
/** The contract this order opened, if it was the purchase that opened one. */
public function subscription(): HasOne
{
return $this->hasOne(Subscription::class);
}
public function runs(): MorphMany
{
return $this->morphMany(ProvisioningRun::class, 'subject');
}
/**
* Runner hook: a failed provisioning run marks the order failed (no
* auto-refund) AND releases the reserved instance, so its quota stops
* counting against host capacity.
*/
public function onProvisioningFailed(): void
{
$this->update(['status' => 'failed']);
$this->instance()->update(['status' => 'failed']);
}
}

94
app/Models/PlanFamily.php Normal file
View File

@ -0,0 +1,94 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Carbon;
use RuntimeException;
/**
* A plan line the thing that has a name.
*
* "Team" stays Team through every price change and every resize. Orders,
* instances and contracts have referred to plans by `key` since day one, so the
* key is the one part of a family that must never move.
*/
class PlanFamily extends Model
{
use HasFactory;
use HasUuids;
protected $guarded = [];
protected static function booted(): void
{
static::updating(function (self $family) {
// Orders, instances and every subscription snapshot store this
// string. Renaming it does not rename those — it orphans them, and
// a checkout in flight for this family stops matching its own
// version. Display names are what the `name` column is for.
if ($family->isDirty('key')) {
throw new RuntimeException(
'A plan family key is permanent; existing orders and contracts refer to it by name. '.
'Change the display name instead.'
);
}
});
static::deleting(function (self $family) {
if ($family->versions()->whereNotNull('published_at')->exists()) {
throw new RuntimeException(
'A plan family with published versions cannot be deleted; customers are contracted to them. '.
'Switch sales off instead — it disappears from the shop and stays on record.'
);
}
// Only drafts left. The foreign key restricts, so they have to go
// explicitly — and nothing was ever promised on a draft.
$family->versions->each->delete();
});
}
protected function casts(): array
{
return [
'tier' => 'integer',
'sales_enabled' => 'boolean',
];
}
public function uniqueIds(): array
{
return ['uuid'];
}
public function versions(): HasMany
{
return $this->hasMany(PlanVersion::class);
}
/**
* The version on sale at a given moment, or null.
*
* Resolved with sole(): two overlapping windows are a mistake we want to
* hear about, not one we want silently resolved by whichever row the
* database happened to return first.
*
* @throws \Illuminate\Database\MultipleRecordsFoundException on overlap
*/
public function versionAt(?Carbon $at = null): ?PlanVersion
{
$query = $this->versions()->available($at);
return $query->count() === 0 ? null : $query->sole();
}
/** On sale right now: not killed by the switch, and a version is running. */
public function isSellableAt(?Carbon $at = null): bool
{
return $this->sales_enabled && $this->versionAt($at) !== null;
}
}

85
app/Models/PlanPrice.php Normal file
View File

@ -0,0 +1,85 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use RuntimeException;
/**
* What a version costs for one term.
*
* One row per version AND term, each with its own Stripe Price id a Stripe
* Price carries its own recurring interval, so monthly and yearly can never
* share one. Amounts are NET; what is actually charged depends on the
* customer's tax treatment.
*
* Frozen with its version. Repricing means publishing a new version, which is
* what Stripe requires anyway a Stripe Price is immutable, so a rise mints a
* new one and existing subscriptions keep the old.
*
* The reason is not tidiness. A checkout is not instant: the session opens, the
* customer types their card details, the webhook arrives. An amount edited in
* that gap would contract them at a price they were never quoted, while their
* payment carries the old one. Only `stripe_price_id` stays writable, because
* it is filled in after the fact.
*/
class PlanPrice extends Model
{
use HasFactory;
use HasUuids;
/** What publishing the version nails down. */
public const FROZEN = ['plan_version_id', 'term', 'amount_cents', 'currency'];
protected $guarded = [];
protected static function booted(): void
{
static::updating(function (self $price) {
// A draft is still a draft: nothing has been quoted to anyone.
if ($price->version?->isPublished() !== true) {
return;
}
$frozen = array_intersect(array_keys($price->getDirty()), self::FROZEN);
if ($frozen !== []) {
throw new RuntimeException(
'The price of a published plan version is fixed; tried to change: '.implode(', ', $frozen).
'. Publish a new version instead — someone may be at the checkout looking at this one.'
);
}
});
static::deleting(function (self $price) {
// Same loophole as editing. Remove the price of a version someone is
// checking out on, and their payment lands on a plan that can no
// longer be priced — the order is recorded, no contract opens, and
// they are left paid-for and unprovisioned.
if ($price->version?->isPublished() === true) {
throw new RuntimeException(
'The price of a published plan version cannot be deleted. '.
'Close the version\'s availability window instead.'
);
}
});
}
protected function casts(): array
{
return ['amount_cents' => 'integer'];
}
public function uniqueIds(): array
{
return ['uuid'];
}
public function version(): BelongsTo
{
return $this->belongsTo(PlanVersion::class, 'plan_version_id');
}
}

167
app/Models/PlanVersion.php Normal file
View File

@ -0,0 +1,167 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Carbon;
use RuntimeException;
/**
* What a plan WAS at a point in time.
*
* A customer buys a version, not a name. Once a version is published it is
* fixed: publication is a promise made in public, and it is made whether or not
* anyone has bought yet which is why the lock is on publication and not on
* the first sale. Selling different terms means publishing a new version, and
* the old one keeps describing what its customers are owed.
*
* The availability WINDOW is not frozen. Rescheduling when a version is sold
* changes nothing about what it promises, and the owner has to be able to move
* a launch date without rewriting the plan.
*/
class PlanVersion extends Model
{
use HasFactory;
use HasUuids;
/** Everything publication nails down. */
public const FROZEN = [
'plan_family_id', 'version', 'quota_gb', 'traffic_gb', 'seats', 'ram_mb',
'cores', 'disk_gb', 'performance', 'template_vmid', 'features', 'published_at',
];
protected $guarded = [];
protected static function booted(): void
{
static::updating(function (self $version) {
// A draft is still a draft: nothing has been promised to anyone.
if ($version->getOriginal('published_at') === null) {
return;
}
$frozen = array_intersect(array_keys($version->getDirty()), self::FROZEN);
if ($frozen !== []) {
throw new RuntimeException(
'A published plan version is immutable; tried to change: '.implode(', ', $frozen).
'. Publish a new version instead — the customers on this one bought these terms.'
);
}
});
static::deleting(function (self $version) {
// Deleting is the loophole that would undo the rest. Contracts and
// orders point here to record what was sold, and the foreign keys
// null out on delete — so removing a published version quietly
// erases the provenance of every customer on it, and can leave an
// in-flight checkout resolving against whatever replaced it.
if ($version->isPublished()) {
throw new RuntimeException(
'A published plan version cannot be deleted; customers are contracted to it. '.
'Close its availability window instead — it stops being sold and stays on record.'
);
}
});
}
protected function casts(): array
{
return [
'version' => 'integer',
'quota_gb' => 'integer',
'traffic_gb' => 'integer',
'seats' => 'integer',
'ram_mb' => 'integer',
'cores' => 'integer',
'disk_gb' => 'integer',
'template_vmid' => 'integer',
'features' => 'array',
'available_from' => 'datetime',
'available_until' => 'datetime',
'published_at' => 'datetime',
];
}
public function uniqueIds(): array
{
return ['uuid'];
}
public function family(): BelongsTo
{
return $this->belongsTo(PlanFamily::class, 'plan_family_id');
}
public function prices(): HasMany
{
return $this->hasMany(PlanPrice::class);
}
/**
* Published, and inside its window at `$at`.
*
* Half-open [from, until): `until` is the first moment the version is no
* longer sold, so a successor starting exactly then leaves neither a gap
* nor an overlap.
*/
public function scopeAvailable(Builder $query, ?Carbon $at = null): Builder
{
$at ??= now();
return $query
->whereNotNull('published_at')
->where('available_from', '<=', $at)
->where(fn (Builder $open) => $open
->whereNull('available_until')
->orWhere('available_until', '>', $at));
}
public function isAvailableAt(?Carbon $at = null): bool
{
$at ??= now();
return $this->published_at !== null
&& $this->available_from->lessThanOrEqualTo($at)
&& ($this->available_until === null || $this->available_until->greaterThan($at));
}
public function isPublished(): bool
{
return $this->published_at !== null;
}
public function priceFor(string $term, ?string $currency = null): ?PlanPrice
{
return $this->prices()
->where('term', $term)
->where('currency', $currency ?? Subscription::catalogueCurrency())
->first();
}
/**
* The capabilities, in the shape the rest of the app already speaks.
*
* @return array<string, mixed>
*/
public function capabilities(): array
{
return [
'tier' => (int) $this->family->tier,
'quota_gb' => $this->quota_gb,
'traffic_gb' => $this->traffic_gb,
'seats' => $this->seats,
'ram_mb' => $this->ram_mb,
'cores' => $this->cores,
'disk_gb' => $this->disk_gb,
'performance' => $this->performance,
'template_vmid' => $this->template_vmid,
'features' => $this->features ?? [],
];
}
}

View File

@ -0,0 +1,78 @@
<?php
namespace App\Models;
use App\Models\Concerns\HasUuid;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphTo;
class ProvisioningRun extends Model
{
/** @use HasFactory<\Database\Factories\ProvisioningRunFactory> */
use HasFactory, HasUuid;
public const STATUS_PENDING = 'pending';
public const STATUS_RUNNING = 'running';
public const STATUS_WAITING = 'waiting';
public const STATUS_PAUSED = 'paused';
public const STATUS_COMPLETED = 'completed';
public const STATUS_FAILED = 'failed';
protected $fillable = [
'subject_type', 'subject_id', 'pipeline', 'current_step', 'status',
'attempt', 'max_attempts', 'next_attempt_at', 'started_at', 'finished_at',
'error', 'context',
];
protected function casts(): array
{
return [
'context' => 'array',
'next_attempt_at' => 'datetime',
'started_at' => 'datetime',
'finished_at' => 'datetime',
'current_step' => 'integer',
'attempt' => 'integer',
'max_attempts' => 'integer',
];
}
public function subject(): MorphTo
{
return $this->morphTo();
}
public function events(): HasMany
{
return $this->hasMany(ProvisioningStepEvent::class, 'run_id');
}
public function resources(): HasMany
{
return $this->hasMany(RunResource::class, 'run_id');
}
/** Read a single key from the json context. */
public function context(string $key, mixed $default = null): mixed
{
return data_get($this->context, $key, $default);
}
/** Merge values into the json context and persist. */
public function mergeContext(array $values): void
{
$this->context = array_merge($this->context ?? [], $values);
$this->save();
}
/** Remove a key from the json context and persist (e.g. scrubbing a secret). */
public function forgetContext(string $key): void
{
$context = $this->context ?? [];
unset($context[$key]);
$this->context = $context;
$this->save();
}
}

View File

@ -0,0 +1,21 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* Append-only run event. Eloquent manages created_at only (no updated_at).
*/
class ProvisioningStepEvent extends Model
{
public const UPDATED_AT = null;
protected $fillable = ['run_id', 'step', 'attempt', 'outcome', 'message', 'external_ref'];
public function run(): BelongsTo
{
return $this->belongsTo(ProvisioningRun::class, 'run_id');
}
}

View File

@ -0,0 +1,27 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* Idempotency breadcrumb: an external resource created during a run (wg peer,
* pve token, vmid ). Persisted before the creating step advances.
*/
class RunResource extends Model
{
public const UPDATED_AT = null;
protected $fillable = ['run_id', 'host_id', 'kind', 'external_id'];
public function run(): BelongsTo
{
return $this->belongsTo(ProvisioningRun::class, 'run_id');
}
public function host(): BelongsTo
{
return $this->belongsTo(Host::class, 'host_id');
}
}

28
app/Models/Seat.php Normal file
View File

@ -0,0 +1,28 @@
<?php
namespace App\Models;
use App\Models\Concerns\HasUuid;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Seat extends Model
{
/** @use HasFactory<\Database\Factories\SeatFactory> */
use HasFactory, HasUuid;
public const ROLES = ['owner', 'admin', 'member', 'readonly'];
protected $fillable = ['customer_id', 'email', 'name', 'role', 'status', 'invited_at'];
protected function casts(): array
{
return ['invited_at' => 'datetime'];
}
public function customer(): BelongsTo
{
return $this->belongsTo(Customer::class);
}
}

View File

@ -0,0 +1,24 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
/**
* A billing event held until the contract it is about exists.
*
* See the migration for why: Stripe does not guarantee delivery order, and
* answering 2xx to an event we cannot match yet would drop it for good.
*/
class StripePendingEvent extends Model
{
protected $guarded = [];
protected function casts(): array
{
return [
'payload' => 'array',
'raised_at' => 'datetime',
];
}
}

204
app/Models/Subscription.php Normal file
View File

@ -0,0 +1,204 @@
<?php
namespace App\Models;
use App\Services\Billing\PlanCatalogue;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use RuntimeException;
class Subscription extends Model
{
use HasFactory;
use HasUuids;
public const TERM_MONTHLY = 'monthly';
public const TERM_YEARLY = 'yearly';
/**
* The conditions the customer is owed. Changing any of these after the fact
* would rewrite a contract, so the model refuses a price rise applies to
* new subscriptions, never to existing ones.
*/
public const FROZEN = [
'plan', 'plan_version_id', 'term', 'price_cents', 'currency', 'quota_gb', 'traffic_gb',
'seats', 'ram_mb', 'cores', 'disk_gb', 'performance', 'tier', 'started_at',
];
protected $guarded = [];
protected static function booted(): void
{
static::updating(function (self $subscription) {
$frozen = array_intersect(array_keys($subscription->getDirty()), self::FROZEN);
if ($frozen !== []) {
throw new RuntimeException(
'A subscription snapshot is immutable; tried to change: '.implode(', ', $frozen).
'. Cancel this subscription and start a new one instead.'
);
}
});
}
protected function casts(): array
{
return [
'price_cents' => 'integer',
'quota_gb' => 'integer',
'traffic_gb' => 'integer',
'seats' => 'integer',
'ram_mb' => 'integer',
'cores' => 'integer',
'disk_gb' => 'integer',
'template_vmid' => 'integer',
'tier' => 'integer',
'started_at' => 'datetime',
'current_period_start' => 'datetime',
'current_period_end' => 'datetime',
'pending_effective_at' => 'datetime',
'cancelled_at' => 'datetime',
'stripe_event_at' => 'datetime',
];
}
public function uniqueIds(): array
{
return ['uuid'];
}
public function customer(): BelongsTo
{
return $this->belongsTo(Customer::class);
}
public function order(): BelongsTo
{
return $this->belongsTo(Order::class);
}
/** Which version of the plan this contract was sold under. */
public function planVersion(): BelongsTo
{
return $this->belongsTo(PlanVersion::class, 'plan_version_id');
}
/** Modules booked onto this contract, each frozen at its booked price. */
public function addons(): HasMany
{
return $this->hasMany(SubscriptionAddon::class);
}
/** Every commercial event this contract has been through. */
public function records(): HasMany
{
return $this->hasMany(SubscriptionRecord::class);
}
public function instance(): BelongsTo
{
return $this->belongsTo(Instance::class);
}
/** Whether the catalogue still sells this plan at all. */
public static function knowsPlan(string $plan): bool
{
return app(PlanCatalogue::class)->isSellable($plan);
}
/** The single currency every catalogue price is expressed in. */
public static function catalogueCurrency(): string
{
return strtoupper((string) config('provisioning.currency', 'EUR'));
}
/**
* Take the version on sale right now and freeze it onto a new subscription.
*
* Reads the catalogue tables, never config: there is one source, and it is
* the one the owner can actually edit. An unknown, withdrawn or unpriced
* plan throws rather than returning something plausible a contract opened
* from a guess is worse than a purchase that visibly fails.
*/
public static function snapshotFrom(string $plan, string $term = self::TERM_MONTHLY, ?int $versionId = null): array
{
// A typo would otherwise be priced monthly while carrying an unknown
// term — a subscription whose price and billing period disagree.
if (! in_array($term, [self::TERM_MONTHLY, self::TERM_YEARLY], true)) {
throw new RuntimeException("Unknown term: {$term}");
}
// Honour the version the customer was actually shown, even if it has
// stopped being sold since — a checkout that completes just after a
// scheduled transition must not contract them to terms they never saw.
$version = $versionId !== null
? app(PlanCatalogue::class)->soldVersion($plan, $versionId)
: app(PlanCatalogue::class)->currentVersion($plan);
$price = $version->priceFor($term);
if ($price === null) {
throw new RuntimeException("Plan '{$plan}' is not sold on a {$term} term.");
}
return [
'plan' => $plan,
// Which version this contract was sold under. A historical
// reference resolved by plan NAME would hand back today's terms.
'plan_version_id' => $version->id,
'term' => $term,
'price_cents' => $price->amount_cents,
'currency' => $price->currency,
'quota_gb' => $version->quota_gb,
'traffic_gb' => $version->traffic_gb,
'seats' => $version->seats,
'ram_mb' => $version->ram_mb,
'cores' => $version->cores,
'disk_gb' => $version->disk_gb,
'performance' => $version->performance,
// Copied so provisioning never has to ask the catalogue what to
// clone. Not in FROZEN — see the migration for why.
'template_vmid' => $version->template_vmid,
// Frozen too: which direction a later change goes must not depend
// on where the plan sits in today's catalogue.
'tier' => (int) $version->family->tier,
];
}
public function isYearly(): bool
{
return $this->term === self::TERM_YEARLY;
}
/**
* What this contract works out to per month.
*
* `price_cents` is the price of a TERM, and a yearly term holds the whole
* year. Everything that prints a monthly figure the plan card, the cloud
* page, the revenue column has to divide, and every one of them getting
* it right separately is not something to rely on.
*/
public function monthlyPriceCents(): int
{
return $this->isYearly()
? (int) round($this->price_cents / 12)
: (int) $this->price_cents;
}
/**
* The whole monthly bill, net: the plan plus every module still booked.
*
* All of it frozen that is the owner's rule. A total assembled from the
* contract for the plan and from today's catalogue for the modules would
* protect half a customer's price and quietly re-price the other half.
*/
public function totalMonthlyCents(): int
{
return $this->monthlyPriceCents() + $this->addons
->where('cancelled_at', null)
->sum(fn (SubscriptionAddon $addon) => $addon->monthlyCents());
}
}

View File

@ -0,0 +1,84 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use RuntimeException;
/**
* One booked module, at the price it was booked at.
*
* Frozen for the same reason the subscription is: the customer agreed to a
* figure, and raising it afterwards would rewrite that agreement. Cancelling
* sets `cancelled_at` rather than deleting the row what someone was paying,
* and until when, is evidence.
*/
class SubscriptionAddon extends Model
{
use HasFactory;
use HasUuids;
/** What the customer agreed to. */
public const FROZEN = ['subscription_id', 'addon_key', 'price_cents', 'currency', 'quantity', 'booked_at'];
protected $guarded = [];
protected static function booted(): void
{
static::updating(function (self $addon) {
$frozen = array_intersect(array_keys($addon->getDirty()), self::FROZEN);
if ($frozen !== []) {
throw new RuntimeException(
'A booked module is frozen at its booked price; tried to change: '.implode(', ', $frozen).
'. Cancel it and book it again at today\'s price instead.'
);
}
});
}
protected function casts(): array
{
return [
'price_cents' => 'integer',
'quantity' => 'integer',
'booked_at' => 'datetime',
'cancelled_at' => 'datetime',
];
}
public function uniqueIds(): array
{
return ['uuid'];
}
public function subscription(): BelongsTo
{
return $this->belongsTo(Subscription::class);
}
public function order(): BelongsTo
{
return $this->belongsTo(Order::class);
}
public function scopeActive(Builder $query): Builder
{
return $query->whereNull('cancelled_at');
}
/** Net per month for this booking, packs included. */
public function monthlyCents(): int
{
return $this->price_cents * $this->quantity;
}
public function isActive(): bool
{
return $this->cancelled_at === null;
}
}

View File

@ -0,0 +1,118 @@
<?php
namespace App\Models;
use App\Models\Builders\AppendOnlyBuilder;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use RuntimeException;
/**
* One commercial event, written once and never again.
*
* A register that can be edited proves nothing. So the model refuses updates
* and deletes outright: a mistake is corrected by recording the correction,
* which is how ledgers have always worked and why they can be trusted.
*/
class SubscriptionRecord extends Model
{
use HasFactory;
use HasUuids;
public const EVENT_PURCHASE = 'purchase';
public const EVENT_UPGRADE = 'upgrade';
public const EVENT_DOWNGRADE = 'downgrade';
/** A term that renewed and was paid. Stripe's invoice says so. */
public const EVENT_RENEWAL = 'renewal';
/** A renewal Stripe could not collect. Their dunning takes it from here. */
public const EVENT_PAYMENT_FAILED = 'payment_failed';
/**
* A paid invoice that is not a cycle renewal a proration, or a charge
* raised by hand. Money received, so it belongs in the register, but it
* does not start a new term and must not move the period.
*/
public const EVENT_INVOICE_PAID = 'invoice_paid';
public const EVENT_ADDON_BOOKED = 'addon_booked';
public const EVENT_ADDON_CANCELLED = 'addon_cancelled';
public const EVENT_CANCELLATION = 'cancellation';
/** The shape of the `snapshot` column. Bump when it changes. */
public const SNAPSHOT_VERSION = 1;
protected $guarded = [];
protected static function booted(): void
{
static::updating(function () {
throw new RuntimeException(
'The proof register is append-only. Record a correcting event instead of editing history.'
);
});
static::deleting(function () {
throw new RuntimeException(
'The proof register is append-only. A record that can be deleted is not evidence.'
);
});
}
protected function casts(): array
{
return [
'net_cents' => 'integer',
'tax_cents' => 'integer',
'gross_cents' => 'integer',
'tax_rate' => 'decimal:2',
'reverse_charge' => 'boolean',
'plan_version' => 'integer',
'snapshot_version' => 'integer',
'snapshot' => 'array',
'occurred_at' => 'datetime',
];
}
public function uniqueIds(): array
{
return ['uuid'];
}
/**
* The model events above only fire for instance operations. A bulk
* `query()->update()` or `->delete()` would sail straight past them, so the
* builder refuses those too.
*/
public function newEloquentBuilder($query): AppendOnlyBuilder
{
return new AppendOnlyBuilder($query);
}
public function customer(): BelongsTo
{
return $this->belongsTo(Customer::class);
}
public function subscription(): BelongsTo
{
return $this->belongsTo(Subscription::class);
}
public function order(): BelongsTo
{
return $this->belongsTo(Order::class);
}
public function planVersion(): BelongsTo
{
return $this->belongsTo(PlanVersion::class, 'plan_version_id');
}
}

View File

@ -9,13 +9,24 @@ use Illuminate\Database\Eloquent\Attributes\Hidden;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Laravel\Fortify\TwoFactorAuthenticatable;
use Spatie\Permission\Traits\HasRoles;
#[Fillable(['name', 'email', 'password'])]
#[Hidden(['password', 'remember_token'])]
#[Fillable(['name', 'email', 'password', 'is_admin'])]
#[Hidden(['password', 'remember_token', 'two_factor_secret', 'two_factor_recovery_codes'])]
class User extends Authenticatable
{
/** @use HasFactory<UserFactory> */
use HasFactory, Notifiable;
use HasFactory, HasRoles, Notifiable, TwoFactorAuthenticatable;
/** The five operator roles that grant access to the admin console. */
public const OPERATOR_ROLES = ['Owner', 'Admin', 'Support', 'Billing', 'Read-only', 'Developer'];
/** True when this user holds an operator role (the RBAC console boundary). */
public function isOperator(): bool
{
return $this->hasAnyRole(self::OPERATOR_ROLES);
}
/**
* Get the attributes that should be cast.
@ -27,6 +38,7 @@ class User extends Authenticatable
return [
'email_verified_at' => 'datetime',
'password' => 'hashed',
'is_admin' => 'boolean',
];
}
}

111
app/Models/VpnPeer.php Normal file
View File

@ -0,0 +1,111 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
class VpnPeer extends Model
{
use HasFactory;
use HasUuids;
use SoftDeletes;
/** A tunnel is considered live if the last handshake is recent. WireGuard
* re-handshakes about every two minutes, so three is the first value that
* does not flap for an idle-but-connected peer. */
public const ONLINE_AFTER_MINUTES = 3;
/** A person's access. Requires an owner. */
public const KIND_STAFF = 'staff';
/** Registered by the host pipeline for a Proxmox host. Requires host_id. */
public const KIND_HOST = 'host';
/** Found on the interface without a match — adopted so it stays visible. */
public const KIND_SYSTEM = 'system';
protected $guarded = [];
protected function casts(): array
{
return [
'enabled' => 'boolean',
'present' => 'boolean',
'last_handshake_at' => 'datetime',
'secret_purged_at' => 'datetime',
'last_downloaded_at' => 'datetime',
'observed_at' => 'datetime',
'rx_bytes' => 'integer',
'tx_bytes' => 'integer',
];
}
public function uniqueIds(): array
{
return ['uuid'];
}
public function host(): BelongsTo
{
return $this->belongsTo(Host::class);
}
public function creator(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by');
}
/** The person this access belongs to (staff peers only). */
public function owner(): BelongsTo
{
return $this->belongsTo(User::class, 'user_id');
}
public function hasStoredConfig(): bool
{
return $this->config_secret !== null;
}
/**
* Overwrite the stored credential. Called on revocation: a tombstone that
* still carries a usable private key is a liability, not a record.
*/
public function purgeSecret(): void
{
if ($this->config_secret === null) {
return;
}
$this->forceFill(['config_secret' => null, 'secret_purged_at' => now()])->saveQuietly();
}
public function isOnline(): bool
{
return $this->enabled
&& $this->present
&& $this->last_handshake_at !== null
&& $this->last_handshake_at->gt(now()->subMinutes(self::ONLINE_AFTER_MINUTES));
}
/**
* blocked operator revoked it; it must not be on the hub
* pending desired and observed state disagree; a job is still applying it
* online configured and handshaking
* idle configured, but no recent handshake
*/
public function status(): string
{
if (! $this->enabled) {
return $this->present ? 'pending' : 'blocked';
}
if (! $this->present) {
return 'pending';
}
return $this->isOnline() ? 'online' : 'idle';
}
}

View File

@ -0,0 +1,49 @@
<?php
namespace App\Notifications;
use App\Models\Instance;
use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
use Illuminate\Support\Facades\Crypt;
/**
* Delivers the initial admin credentials once provisioning completes.
*
* Sent SYNCHRONOUSLY from CompleteProvisioning (not queued): the step only
* records the `credentials_sent` breadcrumb and scrubs the password AFTER the
* send returns, so a mail failure re-runs the step (password preserved) instead
* of losing the credential in a failed queue job. Delivery is at-least-once a
* crash in the tiny window after send but before the breadcrumb can re-send a
* duplicate welcome email, which is preferable to a lost credential.
*/
class CloudReady extends Notification
{
use Queueable;
public function __construct(
public Instance $instance,
public string $adminUser,
public string $adminPasswordEncrypted, // ciphertext — safe to serialize into the queue
) {}
/** @return array<int, string> */
public function via(object $notifiable): array
{
return ['mail'];
}
public function toMail(object $notifiable): MailMessage
{
$url = 'https://'.$this->instance->subdomain.'.'.config('provisioning.dns.zone');
return (new MailMessage)
->subject(__('provisioning.mail.ready_subject'))
->greeting(__('provisioning.mail.ready_greeting'))
->line(__('provisioning.mail.ready_line'))
->line(__('provisioning.mail.ready_user', ['user' => $this->adminUser]))
->line(__('provisioning.mail.ready_password', ['password' => Crypt::decryptString($this->adminPasswordEncrypted)]))
->action(__('provisioning.mail.ready_action'), $url);
}
}

View File

@ -0,0 +1,83 @@
<?php
namespace App\Policies;
use App\Models\User;
use App\Models\VpnPeer;
/**
* Who may see and do what with a VPN access.
*
* Ownership is record-level and lives here; permissions describe elevated
* capabilities (vpn.view.all, vpn.manage.all) and are checked through Gate.
* The two are kept apart on purpose: seeing an access is not managing it, and
* neither of them is holding its private key.
*
* There is no Gate::before super-admin shortcut in this application, so these
* rules are the whole story in particular downloadConfig() cannot be
* out-voted by a role.
*/
class VpnPeerPolicy
{
public function viewAny(User $user): bool
{
// Every operator may at least reach the page to find their own access.
return $user->isOperator();
}
public function view(User $user, VpnPeer $peer): bool
{
return $this->owns($user, $peer) || $user->can('vpn.view.all');
}
public function create(User $user): bool
{
// Not self-service: an access reaches the management network, so issuing
// one is an administrative act even for oneself.
return $user->can('vpn.manage.all');
}
/** Rename, reassign, change stored-config settings. */
public function update(User $user, VpnPeer $peer): bool
{
return $user->can('vpn.manage.all');
}
/** Block / unblock. Owners may switch their own access off and on again. */
public function block(User $user, VpnPeer $peer): bool
{
return $user->can('vpn.manage.all') || $this->owns($user, $peer);
}
public function delete(User $user, VpnPeer $peer): bool
{
return $user->can('vpn.manage.all') || $this->owns($user, $peer);
}
/**
* The private half of someone's credential. Owner only explicitly NOT
* vpn.view.all and NOT vpn.manage.all: an administrator who needs access
* revokes this one and issues themselves their own, which keeps the record
* of who holds what intact.
*/
public function downloadConfig(User $user, VpnPeer $peer): bool
{
return $this->owns($user, $peer)
&& $peer->config_secret !== null
&& ! $peer->trashed();
}
/**
* isOperator() is part of ownership on purpose: roles can be taken away by
* other paths than revokeStaff() a direct role edit, for instance. Losing
* the console must close these doors by itself, not rely on whoever removed
* the role also remembering the tunnel.
*/
private function owns(User $user, VpnPeer $peer): bool
{
return $user->isOperator()
&& $peer->kind === VpnPeer::KIND_STAFF
&& $peer->user_id !== null
&& $peer->user_id === $user->id;
}
}

View File

@ -2,7 +2,32 @@
namespace App\Providers;
use App\Provisioning\PipelineRegistry;
use App\Services\Dns\HetznerDnsClient;
use App\Services\Dns\HttpHetznerDnsClient;
use App\Services\Monitoring\HttpMonitoringClient;
use App\Services\Monitoring\MonitoringClient;
use App\Services\Proxmox\HttpProxmoxClient;
use App\Services\Proxmox\ProxmoxClient;
use App\Services\Stripe\HttpStripeClient;
use App\Services\Stripe\StripeClient;
use App\Services\Ssh\PhpseclibRemoteShell;
use App\Services\Ssh\RemoteShell;
use App\Services\Traefik\SshTraefikWriter;
use App\Services\Traefik\TraefikWriter;
use App\Services\Wireguard\LocalWireguardHub;
use App\Services\Wireguard\WireguardHub;
use App\Http\Middleware\EnsureAdmin;
use App\Http\Middleware\EnsureCustomerActive;
use App\Http\Middleware\RestrictAdminHost;
use App\Mail\MaintenanceCancelledMail;
use App\Models\MaintenanceNotification;
use App\Services\Maintenance\MaintenanceNotifier;
use Illuminate\Mail\Events\MessageSending;
use Illuminate\Mail\Events\MessageSent;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\ServiceProvider;
use Livewire\Livewire;
class AppServiceProvider extends ServiceProvider
{
@ -11,7 +36,18 @@ class AppServiceProvider extends ServiceProvider
*/
public function register(): void
{
//
$this->app->singleton(PipelineRegistry::class, fn () => new PipelineRegistry(
config('provisioning.pipelines', []),
));
// Real I/O implementations; tests swap in fakes via app()->instance().
$this->app->bind(RemoteShell::class, PhpseclibRemoteShell::class);
$this->app->bind(WireguardHub::class, LocalWireguardHub::class);
$this->app->bind(ProxmoxClient::class, HttpProxmoxClient::class);
$this->app->bind(HetznerDnsClient::class, HttpHetznerDnsClient::class);
$this->app->bind(TraefikWriter::class, SshTraefikWriter::class);
$this->app->bind(MonitoringClient::class, HttpMonitoringClient::class);
$this->app->bind(StripeClient::class, HttpStripeClient::class);
}
/**
@ -19,6 +55,79 @@ class AppServiceProvider extends ServiceProvider
*/
public function boot(): void
{
// Livewire posts every component action to /livewire/update, which a
// path-based guard would skip. Marking the host restriction persistent
// makes Livewire re-apply it from the component's original route, so an
// admin action cannot be driven through a public hostname.
// Livewire re-applies only the middleware on this list when an action
// posts to /livewire/update. Its own defaults cover `auth` — but not
// ours. Without these two, a signed-in NON-operator could drive console
// components, and a suspended customer could keep driving the portal:
// the page would never load for them, but the page is not where the
// actions run.
Livewire::addPersistentMiddleware([
RestrictAdminHost::class,
EnsureAdmin::class,
EnsureCustomerActive::class,
]);
// Send-time guard for maintenance mail (X-CP-Notification carries the
// ledger id). Deliberately READ-ONLY — it never marks the row sent or
// claimed, because returning false makes Laravel treat the send as a
// successful cancellation: pre-claiming here would silently DROP a mail
// whose transport later fails and retries. It only suppresses a send that
// is definitively pointless: the window was cancelled, or a sibling copy
// already delivered (sent_at set). sent_at is recorded on real delivery
// in MessageSent, so nothing is ever lost.
//
// Residual: two jobs whose MessageSending both fire before either's
// MessageSent (true simultaneous multi-worker send of a stale resend) can
// still both deliver. That cannot happen on the current single-worker
// log-mail setup; true exactly-once needs a transactional outbox + a
// dedicated delivery worker keyed on claimed_at, to be added with real mail.
Event::listen(MessageSending::class, function (MessageSending $event) {
$header = $event->message->getHeaders()->get('X-CP-Notification');
if ($header === null) {
return null;
}
$notification = MaintenanceNotification::query()->with('window')->find((int) $header->getBodyAsString());
if ($notification === null) {
return null;
}
if ($notification->sent_at !== null) {
return false; // already delivered — suppress a duplicate copy
}
if ($notification->event === 'announcement' && $notification->window?->state === 'cancelled') {
return false; // window cancelled meanwhile — do not deliver
}
return null;
});
// Stamp a maintenance-notification ledger row as delivered only once the
// mail is actually sent (the X-CP-Notification header carries the id).
// Until then sent_at stays null → the row is a retryable marker. If an
// announcement delivered for an already-cancelled window (the cancel race),
// queue a catch-up cancellation so that customer isn't left mis-informed.
Event::listen(MessageSent::class, function (MessageSent $event) {
$header = $event->message->getHeaders()->get('X-CP-Notification');
if ($header === null) {
return;
}
$notification = MaintenanceNotification::query()->with(['window', 'customer'])->find((int) $header->getBodyAsString());
if ($notification === null) {
return;
}
$notification->update(['sent_at' => now()]);
if ($notification->event === 'announcement' && $notification->window?->state === 'cancelled' && $notification->customer !== null) {
app(MaintenanceNotifier::class)->deliver(
$notification->window,
$notification->customer,
'cancelled',
new MaintenanceCancelledMail($notification->window, $notification->customer),
);
}
});
}
}

View File

@ -0,0 +1,60 @@
<?php
namespace App\Providers;
use App\Actions\Fortify\CreateNewUser;
use App\Actions\Fortify\ResetUserPassword;
use App\Actions\Fortify\UpdateUserPassword;
use App\Actions\Fortify\UpdateUserProfileInformation;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Str;
use Laravel\Fortify\Actions\RedirectIfTwoFactorAuthenticatable;
use Laravel\Fortify\Fortify;
class FortifyServiceProvider extends ServiceProvider
{
/**
* Register any application services.
*/
public function register(): void
{
//
}
/**
* Bootstrap any application services.
*/
public function boot(): void
{
Fortify::createUsersUsing(CreateNewUser::class);
Fortify::updateUserProfileInformationUsing(UpdateUserProfileInformation::class);
Fortify::updateUserPasswordsUsing(UpdateUserPassword::class);
Fortify::resetUserPasswordsUsing(ResetUserPassword::class);
Fortify::redirectUserForTwoFactorAuthenticationUsing(RedirectIfTwoFactorAuthenticatable::class);
RateLimiter::for('login', function (Request $request) {
$throttleKey = Str::transliterate(Str::lower($request->input(Fortify::username())).'|'.$request->ip());
return Limit::perMinute(5)->by($throttleKey);
});
RateLimiter::for('two-factor', function (Request $request) {
return Limit::perMinute(5)->by($request->session()->get('login.id'));
});
// Registration is public (no per-account key), so throttle per IP to blunt
// signup spam / CPU-exhaustion via repeated password hashing.
RateLimiter::for('registration', fn (Request $request) => Limit::perMinute(10)->by($request->ip()));
RateLimiter::for('passkeys', function (Request $request) {
$credentialId = $request->input('credential.id');
return Limit::perMinute(10)->by(
($credentialId ?: $request->session()->getId()).'|'.$request->ip()
);
});
}
}

View File

@ -0,0 +1,24 @@
<?php
namespace App\Provisioning\Contracts;
use App\Models\ProvisioningRun;
use App\Provisioning\StepResult;
/**
* One idempotent unit of a provisioning pipeline. Steps never call each other;
* they depend only on the run's state and the service interfaces they need.
*/
interface ProvisioningStep
{
/** Stable snake_case identifier used in events and idempotency checks. */
public function key(): string;
/** i18n key for the display label, e.g. hosts.step.<key>. */
public function label(): string;
/** Seconds this step may run before the runner treats it as timed out. */
public function maxDuration(): int;
public function execute(ProvisioningRun $run): StepResult;
}

View File

@ -0,0 +1,12 @@
<?php
namespace App\Provisioning\Contracts;
/**
* A polymorphic run subject (Host, Order ) that reacts when its run fails,
* so the runner can update domain state without knowing the concrete model.
*/
interface ProvisioningSubject
{
public function onProvisioningFailed(): void;
}

Some files were not shown because too many files have changed in this diff Show More