Commit Graph

34 Commits (feature/host-bootstrap)

Author SHA1 Message Date
nexxo 9da1358802 Stop charging VAT to the customers who owe us none
A verified EU business outside Austria gets reverse charge — rate 0, no VAT
line, the note on the invoice — and was still charged 214,80 € for a 179,00 €
package, because one Stripe Price carried the domestic gross for everybody.
There is no VAT line on their document to reclaim, so it was a flat 20 %
surcharge on exactly the customers who read their invoices, and the document
then stated the whole 214,80 € as net at 0 %, so they self-accounted their own
VAT on a base a fifth too large.

A Stripe Price can ask who is buying after all: there are two per sellable
thing now, the domestic gross and the bare net, both live on one Product, and
the checkout picks by TaxTreatment. The rule is Austria B2B 20 %, other EU B2B
without VAT — a domestic business still pays the gross, reclaims it as input
tax, and the price on the website is still the price charged for them.

- stripe:sync-catalogue mints and archives both halves of every pair, per plan
  and per module; `reverse_charge` on stripe_plan_prices/stripe_addon_prices
  says which is which, and joining it on subscriptions.stripe_price_id says
  which one a running contract is billed on. A rate change moves the gross
  Price and leaves the net one alone, because the net owes nothing to the rate.
- A status that changes after the sale converges: a verification (or a lapse)
  makes stripe:reprice-subscriptions move the contract onto the other Price
  with PRORATE_NONE, because the term is already paid for. The module items
  follow the same way.
- Downstream follows: the invoice total equals what was taken, the proof
  register expects the figure this customer is actually charged (so a correct
  reverse-charge sale is no longer flagged as a mismatch, and an overcharged
  one is), the setup fee obeys the same rule, and the booking page quotes what
  it will charge.
- StripeClient gained activatePrice(). Archived Prices were brought back in our
  own table and left inactive at Stripe, so a rate that moved and moved back
  pointed the catalogue at a Price no checkout could be opened for.

An unverified number is still charged the gross — an unchecked string must
never be a discount — and where the net Price is missing the checkout refuses
rather than reaching for the domestic one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 02:15:41 +02:00
nexxo 889b401faf Fix nine defects in the provisioning pipelines
The capacity park could not survive its own first poll. ReserveResources
polls every 120 s and declared a maxDuration of 60, and RunRunner measures
a step's budget from a started_at it deliberately does not reset on a poll
— so every re-entry was ruled timed out before the body ran, a timeout
consumes an attempt, and five of them failed a paid order in about six
minutes. The fourteen-day promise, the console's capacity queue and the
"go and buy a server" workflow were unreachable code. maxDuration is now
the whole park plus one poll interval, and a test drives a park through
the runner rather than calling execute() directly, which is why this was
invisible.

Every guest occ call ran as root. `docker compose exec` defaults to root
and the Nextcloud image's console.php exits 1 unless the caller owns
config/config.php, so all five call sites were failing on every instance.
There is now one builder, App\Support\NextcloudOcc, and a test refuses a
second: nothing in app/ may spell the invocation out by hand. deploy/
update.sh had learned this for our own container and nobody carried it
across.

RunAcceptanceChecks was terminal on the first no. certReachable() answers
false for a connect timeout as readily as for a missing certificate, so
one bad second ended a finished, certified Nextcloud as a failed order
with the instance released. The probes retry now and the run still fails
for good with the probe's own reason once the budget is spent; the two
facts a retry cannot change stay terminal.

The in-flight guard asked whether anything was running, not whether it
would do the work. App\Provisioning\WorkInFlight asks the second question,
reading the step lists the runner executes: a domain proven during a
restart is routed, and a storage pack booked during an address run is
delivered instead of charged monthly and forgotten.

The address pipeline ran its steps in the wrong order for the direction it
exists for. Nextcloud before the router in `address` and `plan-change`, as
in `customer`, because a run that got the certificate and then failed left
the customer's own domain serving an untrusted-domain error under a valid
certificate while the portal called it live. Safe in both directions; the
reasoning is written above the pipeline.

A stale guest_ip could never be corrected. ConfigureDnsAndTls compared the
hostnames but never the backend, and nothing re-read the address after the
build. It now records and compares `routed_backend`, and ConfigureNetwork
joins the `restart` pipeline — a cold boot is what moves a DHCP lease — and
asks for the address to be re-applied when the guest has actually moved.

Also: HetznerDnsClient::upsertRecord read only the first page of a zone
that pages at 100, so past a hundred records it created a second A record
for the same name and the cloud was up about half the time; the console's
retry left a live customer's order in `provisioning` for ever for any
maintenance pipeline; and a revived run's first pass burned an attempt on a
timeout that had already happened, because the clause written for it used
`??=`.

Seven test sites wrote config('provisioning.plans.*'), a key that has not
existed since the catalogue moved into the database. Every write was a
no-op, so each test proved something other than its name: the missing
template, the two snapshot regressions and the grandfathered-price scenario
are now actually constructed, ValidateOrder's reasons are asserted rather
than only its type, and the end-to-end run checks that the quota step
delivered something.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 01:34:55 +02:00
nexxo 8ca0d4d257 Say the same thing to the customer, the register and the bank
tests / pest (push) Failing after 8m5s Details
tests / assets (push) Successful in 25s Details
tests / release (push) Has been skipped Details
Five places where the product told somebody a figure that nothing behind
it produced.

**The invoice page showed five invoices nobody was ever issued.** A mock
that outlived its purpose: CP-2026-0003 to CP-2026-0007, a prefix
belonging to no configured series, all "paid", a spend curve from 179 to
198 € and a next charge of 198 € on 01.08.2026 — behind
['auth','verified','customer.active'], so every signed-in customer saw
it. Their real invoices existed in `invoices`, were rendered as PDFs and
mailed to them, and appeared nowhere in the portal at all. The page now
lists their own documents, newest first, with the numbers, dates and
totals off the frozen snapshot, a Storno marked as one, and a download.
The PDF route resolves the document against the signed-in customer in
the query — a URL anybody can type is not made private by not printing
it. No next charge and no chart: nothing here computes what Stripe will
take next cycle, and a figure nobody has worked out is worse than a
blank space.

**The setup fee was quoted everywhere and charged nowhere.** On the
price sheet, on the booking page, set by the operator in the console —
and CompanyProfile::setupFeeCents() had no reader outside the two
sentences that quoted it. It is charged now, as a second non-recurring
line on the same checkout session, which Stripe puts on the initial
invoice only. `orders.setup_fee_cents` records how much of the charge it
was, so the whole total still goes back on a withdrawal while the
register holds the PACKAGE's charge against the contract price and the
invoice prints the fee as a line of its own. The booking page was also
quoting the fee NET under the same sentence the website quotes GROSS —
99,00 against 118,80 — and now quotes what will be taken.

**An upgrade click cancelled a booked downgrade and delivered nothing.**
Nothing in this application marks a cart order paid, so the customer
traded a downgrade they had genuinely booked for a cart line that would
never be fulfilled. The booking now stands until the upgrade actually
lands on the contract, where ApplyPlanChange clears it. The fulfilment
seam is reachable for every type that can be paid — a module and a
storage pack book through BookAddon, which was the missing path from a
paid order to a SubscriptionAddon and left AddonPrices,
SyncStripeAddonItems and clupilot:end-cancelled-addons with no caller
behind a payment. A paid traffic pack logs an error rather than pretending:
the metered allowance is a standing count of packs while a top-up is sold
for one month, and which of the two is meant is not a guess to make here.
That no path to `paid` exists yet is now stated in both places rather
than in one.

**The proof register called correct charges wrong.** expected_gross_cents
used the CUSTOMER's rate, which is zero under reverse charge, against
the domestic gross Stripe actually took — so matches_catalogue was false
for every EU business sale charged exactly the advertised amount, and
for every renewal on a contract with a module, which was held against
`price_cents` alone. It compares against TaxTreatment::chargedCents()
and against the whole term now. Where no money moved, charged and the
flag are null instead of the expected figure agreeing with itself, and a
plan change records the terms with the pro-rata figures in the snapshot
rather than money that arrives again on Stripe's proration invoice.

**The seller's own VAT id was on the invoice, unlabelled**, between the
register number and the court. Labelled — and the footer's composition
moved out of the TCPDF method, because inside one the only way to read
what it says is to render a PDF and un-subset its fonts.

One test enshrined a figure the code does not produce: a reverse-charge
purchase recorded with no charged amount, asserting 17900/0/17900, where
OpenSubscription always passes the charged figure and a real one records
21480/0/21480. Another asserted that an upgrade click cancels a booked
downgrade.

Full suite: 1676 passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 01:30:24 +02:00
nexxo 8440266ed3 Stop charging for a service that has ended
Nothing in this application could cancel a Stripe subscription — there was
no such method on the client at all. So a customer who cancelled and a
consumer who withdrew both stayed subscribed, the card went on being
charged every month, and every one of those payments arrived as
invoice.paid, drew a real number out of the gapless Austrian series and
mailed the PDF, for a machine that had been switched off. Indefinitely.

StripeClient::cancelSubscription() closes the first half, with the timing
passed explicitly at the call site — CANCEL_AT_PERIOD_END for a
cancellation, because the customer keeps the term they paid for, and
CANCEL_IMMEDIATELY for a withdrawal, because that unwinds the contract and
the whole amount has just gone back. The immediate form states prorate and
invoice_now as false rather than inheriting them: a credit note of Stripe's
beside our own Storno would be the same money twice.

The second half is ApplyStripeBillingEvent::owesADocument(). Refusing every
invoice on a cancelled contract would lose the legitimate final one — the
cycle for a term the customer really did use, unpaid, dunned for three
weeks and settled after the contract ended. So the question is not when the
payment landed but what it is for: an invoice whose billed period STARTED
before the contract ended is documented, one whose period begins at or
after that moment is not. Neither is a contract marked cancelled with no
date, nor an invoice with no period, because a number handed out in error
can never be withdrawn while a missing document can still be issued. The
register entry is written either way — the money moved, and that is where an
operator finds what has to go back.

A cancellation also took its date from the order date plus MONTHS, whatever
the term said, so a yearly customer who cancelled in March lost the nine
months they had already paid for. It reads current_period_end now, which
Stripe keeps current for both terms; the month-walk survives only for a
machine with no contract behind it, where nothing records a term at all.
Stripe is asked first and our rows are written only if it agreed: a
cancellation we could not make effective is the defect above wearing a
"gekündigt" label on the settings page, and a cancellation has no deadline,
so being asked to try again costs nobody anything.

A withdrawal cancelled the OPENING invoice only, found by invoices.order_id
— which a renewal and a module document leave null on purpose. A storage
pack booked on day three and withdrawn from on day ten was therefore
neither cancelled nor refunded. Every charge inside the window is covered
now, one Storno per document and one refund per payment, each against the
payment that actually took it: a single refund of the total against the
opening PaymentIntent is what Stripe would have refused for exceeding it,
and one idempotency key for the whole withdrawal would have had Stripe
replay the first refund's answer for the second.

StartCustomerProvisioning::resume() opened a missing contract and returned,
and the only production call to IssueInvoice::forOrders() is after the order
commits — so a worker killed in between cost that customer their invoice for
good, silently, with nothing anywhere sweeping for it. resume() finishes that
work too, guarded by the invoice already filed against the order and by
invoices.sent_at, so however often Stripe redelivers it comes out as one
invoice, one number, one mail. The order confirmation stays on the first
pass alone: it has nothing to stamp.

subscriptions.cancel_requested_at is new because the billing half of the
application had no way to tell a cancelled contract from an untouched one.
Deliberately not a status: until the term runs out this is a paying
customer, which is the distinction EndInstanceService is built around, and
`cancelled_at` beside it still means the day it genuinely ended.

clupilot:verify-vat-ids is scheduled monthly on the first, and its docblock
no longer points at a note in routes/console.php that never existed. Reverse
charge rested on a one-off VIES answer, a withdrawn registration keeps
earning rate 0 for ever, and the unpaid VAT on those invoices is the
seller's — so the cadence is the width of that window, and the first of the
month is the rhythm the return is filed on.

Two tests that proved nothing are now about behaviour. SettingsTest asserted
service_ends_at was not null, a column the code had just written and which
the broken monthly arithmetic satisfied exactly as well as the right answer;
it states the yearly date, the contract's own record and the order to Stripe.
EndInstanceServiceTest travelled to whatever value the implementation had
computed, so it recomputed the code instead of checking it, and never looked
at billing at all; the boundary is a literal date now and the money stopping
is asserted beside the address coming down.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 00:52:19 +02:00
nexxo 2277dfe4cb Ask the EU register whether a VAT number is real
tests / pest (push) Failing after 8m24s Details
tests / assets (push) Successful in 26s Details
tests / release (push) Has been skipped Details
vat_id_verified_at was declared on the customer and read by TaxTreatment, and
set by no code anywhere. hasVerifiedVatId() therefore never returned true, so
reverse charge could not trigger for anybody - and every EU business customer was
invoiced Austrian VAT they cannot reclaim in their own country, which is not a
pass-through for them but a real cost until the invoice is corrected.

The check has THREE outcomes, and the third is why VatIdCheck exists. VIES is a
gateway onto twenty-seven national registers and any of them is regularly down.
Reading "I could not ask" as "not registered" would clear a verification that has
stood for months, move that customer onto the domestic rate, and change what
their next invoice says - because a register was asleep. Reading it as
"registered" would put reverse charge on an unchecked number, which is our
liability. So on UNAVAILABLE nothing at all is written.

Asked at the moment the number is entered, because that is when somebody is
looking at the field and can fix a typo. The save has already happened by then
and the check cannot undo it: a register that is down must not cost a customer
their address change.

Shape and membership are refused before a request is spent, and the fake refuses
on the same rule as the real verifier. A fake that answers where VIES would never
have been asked is not a simplification but a different product, and free text
normalises into a well-formed number more easily than it looks - "not a number"
becomes NOTANUMBER, which the shape rule alone reads as a Norwegian one.

clupilot:verify-vat-ids re-asks about the numbers reverse charge rests on, since
a registration can be withdrawn. Deliberately not scheduled: it queries a public
service with a concurrency limit on behalf of somebody else's tax position, and
the cadence is the owner's to set.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 22:42:29 +02:00
nexxo f02e86769b Charge the price the website shows, and hand a withdrawal back in full
tests / pest (push) Failing after 9m21s Details
tests / assets (push) Successful in 21s Details
tests / release (push) Has been skipped Details
Stripe was charging the catalogue's NET figure while the document added the
domestic rate on top: a customer quoted 214,80 paid 179,00 and was then invoiced
for VAT nobody had collected. Under para 11(12) UStG that VAT is owed to the tax
office whether or not it ever arrived, so every document issued created a
liability against revenue that did not contain it.

The Stripe Price now carries the GROSS figure. price_cents stays net - it is
frozen onto every contract and PlanChange prorates against it, so redefining it
would corrupt every pro-rata sum ever computed. Only the amount at the till
moved, and it is formed by the one call TaxTreatment already answers for the
price sheet and for the invoice, so displayed, charged and invoiced cannot drift
apart.

Stripe's automatic_tax is deliberately not used. TaxTreatment is the single tax
authority here, and a second rate computed by Stripe would take 19 % from a
German consumer while our document said 20 %.

A Stripe Price cannot be edited, so a changed figure means a new Price and the
old one archived - which stops it being SOLD and leaves every subscription
already on it billing the old amount for ever. stripe:reprice-subscriptions is
the second half, and a command of its own because it touches live money.

A withdrawing consumer is refunded in full, as the owner decided. That is more
generous than FAGG para 16 requires, and it makes the express-request consent the
statute hinges on irrelevant - so the gate is gone rather than left looking
load-bearing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 22:42:02 +02:00
nexxo 6d7c2bdf35 Ask whether they are a consumer, and let one change their mind
tests / pest (push) Failing after 8m35s Details
tests / assets (push) Successful in 22s Details
tests / release (push) Has been skipped Details
Three things the product owed its customers and did not have.

**Who is on the other side.** There was no consumer/business flag anywhere,
and `vat_id` was standing in for one — which it cannot: a business without a
VAT number is an ordinary small business, and a consumer with one does not
exist. It is asked at sign-up now, correctable in the portal, and NULL where
nobody has been asked. Unknown is read as CONSUMER everywhere it decides a
right, because that mistake costs us a refund while the other one takes a
statutory right away from somebody who has it.

Reverse charge asks the recorded type instead of the number: an explicit
consumer is charged the domestic rate whatever `vat_id` says — previously they
were not, and anyone could zero their own VAT by getting a number verified. An
unrecorded type still falls back to the verified number, so no contract that is
already running changes rate.

**The fourteen-day right of withdrawal** (FAGG, §312g BGB), for consumers only,
at every door: the window is stamped on the contract when it is concluded, the
customer exercises it from the portal, an operator records one that arrived by
telephone or post, and both go through one action that refuses a business
customer on the server rather than by hiding a card.

The money follows the paperwork rather than being computed beside it. The
invoice is cancelled by a Storno with its own gapless number — nothing is ever
edited or deleted — a new invoice states the pro-rata value of the service
actually delivered (FAGG §16, by days over the term paid for), and the refund
is exactly the difference between the two documents. Where the consumer never
expressly asked for the service to begin at once, they owe nothing and the whole
amount goes back. `StripeClient::refund()` is new, keyed so a retry cannot send
the money twice. The service ends through EndInstanceService and the
`cancellation_scheduled` machinery that was already there.

**The cancel button for modules.** BookAddon::cancelAtPeriodEnd() had no caller
in the interface at all, so a customer could book a recurring charge in two
clicks and had no way to stop it. It is on the module card now, with the date it
runs until and a way back while the cancellation is still pending — and putting
a module back settles nothing, because the term it was cancelled for was paid
for in advance.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 21:06:06 +02:00
nexxo 8f1630ba91 Bill a booked module every month, and put it on the invoice
A module was never an item on the Stripe subscription, so it was charged in
the month it was booked and never again. And the renewal document was written
from our own contract snapshot, so it would not have said so even if the money
had been taken. Two halves of one fault, closed together.

Modules are items now. stripe:sync-catalogue mirrors a Product and a Price per
module on both terms — a Stripe Price carries its own interval, and a booking
frozen at an older figure needs its own Price, so they are keyed by module,
amount, currency and interval in stripe_addon_prices. Booking adds an item with
always_invoice: prorated by days AND charged there and then, which is the
invoice the owner wants for a module booked mid-term. A second storage pack is
a quantity on one item, not a second item. Cancelling takes the item off with
no proration — no credit, and the next cycle is simply smaller — while the
module itself is kept until the period end on subscription_addons.cancels_at
and ended by clupilot:end-cancelled-addons.

Documents are built from Stripe's own invoice lines. Stripe is what charged the
customer; a document assembled from our figures would state a sum that is not
the one taken. So every paid Stripe invoice gets one document with one number,
idempotent on stripe_invoice_id — a cycle carrying package and every module
together, a mid-period booking carrying just its prorated line, an upgrade
carrying its proration. Only the wording is ours: each line is named from the
catalogue rather than printed as a Price id, and a line nobody can name stays on
the document under Stripe's own description and is logged. The checkout's own
invoice is still the one exception; that purchase already has a document.

Stripe being away never undoes a booking that has already reached the machine.
The failure is parked on the contract in stripe_addon_sync and swept by the
same clupilot:sync-stripe-subscriptions that retries a plan change, which finds
its work from the bookings themselves rather than from the marker. A granted
contract has no Stripe subscription and is not touched at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 20:22:02 +02:00
nexxo 35b312d5ef Invoice every renewal, and tell Stripe when the package changes
Two things Stripe was doing on its own, without us.

**A renewal produced no invoice and no mail.** InvoiceMail went out from
exactly one place — the first purchase — so a customer paying every month
for a year received one invoice, for month one. Stripe already says when
the money lands: invoice.paid with billing_reason subscription_cycle. What
was missing was the document.

A renewal has no Order behind it, and one was not invented for the
occasion: an Order is something a customer bought, and writing a purchase
nobody made would corrupt the record of what they have actually ordered.
IssueInvoice::forBilledPeriod() issues from the CONTRACT instead, at its
frozen net price, for the term Stripe billed. Idempotent against the Stripe
invoice id, which is unique in the database rather than checked in PHP —
and because the number is drawn inside the same transaction as the row, a
redelivery takes its number back with it and the series keeps its sequence.
Nothing in the paperwork can fail the webhook: issuing and mailing are both
caught and logged, exactly as confirmByMail() does.

Only a cycle renewal gets a document. The checkout's own invoice already
has one; an upgrade's proration was worked out against Stripe's boundaries
and would not match a document written from our snapshot; a charge raised
by hand is for something this catalogue cannot describe. All three stay in
the register, and are logged once as money no document of ours covers.

**After a plan change, Stripe went on billing the old price.** There was no
way to move a subscription onto another price at all, so a customer who
upgraded paid for the smaller package every month afterwards. The client
can now swap one, the item id it needs is learnt from Stripe's own events
(and asked for once, for contracts older than the column), and the
behaviour is chosen per direction: an upgrade settles immediately, so the
cycle invoice stays exactly the contract price the renewal document states;
a downgrade lands at the period boundary and settles nothing.

A granted contract has no Stripe subscription and is left untouched. A
change that reaches the machine and not Stripe is never rolled back — it is
parked on the contract with the error and the behaviour it needed, logged
as an error, and retried hourly by clupilot:sync-stripe-subscriptions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 19:35:15 +02:00
nexxo 5b63fdb86c Deliver the storage a customer actually buys
A booked storage pack was a row in `subscription_addons` and nothing else:
priced, frozen, charged every month, and delivered nowhere. Nextcloud's
quota came from `instances.quota_gb`, which is the package alone; the disk
was sized for the package alone; and on the one occasion a disk did grow —
a plan upgrade — the guest never saw it, because nothing stretched the
partition or the filesystem over the new space.

One authority for the allowance. StorageAllowance adds the package and the
booked packs, and everything that needs the figure asks it: the quota step,
the downgrade check, the portal, the console, the repair command. It is
DERIVED rather than stored — a column would have to be rewritten by booking,
cancelling, granting, a grant expiring and a plan change, and the day one of
those paths forgot it would be silently wrong in whichever direction costs
somebody money. `quota_applied_gb` keeps its own meaning: what the guest was
last actually told, which is how a machine with the allowance enforced is
told from one where the figure has only ever been a row in our database.

Buying it delivers it. BookAddon asks ApplyStorageAllowance for a run on
booking AND on cancellation; the new `storage` pipeline grows the virtual
disk to the allowance plus the package's own overhead (read off the package
— 20/40/50/100 GB on the four catalogue packages — never a ratio invented
here), then GrowGuestFilesystem makes the guest see it, then the quota is
applied. No cold boot is involved: the data disk is scsi0 and Proxmox's
resize on a running guest is a qemu block_resize, so the capacity reaches
the guest while it runs. The new step rescans, growparts and grows the
filesystem with the tool its type actually needs — ext2/3/4, xfs, btrfs —
and fails loudly on one it does not know rather than handing it to resize2fs
and hoping. Idempotent end to end: NOCHANGE from growpart is not a failure,
and every tool here exits 0 when there is nothing left to grow.

The same step now runs in the plan-change pipeline, where an upgrade used to
stop one step short of the customer.

A downgrade blocked by data gets a way out. The block stays — it is correct
— but the check now measures against the target package PLUS the packs the
customer already owns, and reports the numbers behind the refusal: what is
stored, what would be allowed, how much has to go, and how many packs would
cover it instead. The portal offers both routes: book exactly those packs
(confirmed in a modal, R23), or delete data and have the fill level measured
on demand rather than waiting for the nightly sampler. That reading is taken
by DiskUsageProbe, which CollectInstanceTraffic now uses too, so there is one
notion of "how full is it" and not two.

A plan change keeps booked packs. They were paid for separately and have
nothing to do with which package the customer is on; the new package's disk
and quota are sized with them included.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 19:13:10 +02:00
nexxo d76de5ada3 Restart a machine, enforce the quota that was sold, end a route that ended
Three places where the product said one thing and did another.

**Nothing could restart a virtual machine.** ProxmoxClient had startVm and no
way to stop anything, so `restart_required_since` — set by every plan change
that grows a running guest — could only ever be cleared by a resize step that
happened to find the machine stopped, which nothing in the product could
arrange. A paid upgrade's cores and RAM could stay unreached for the life of the
contract, and the cloud page's "Neu starten" was a toast.

There is now a `restart` pipeline: shutdown, start, wait for the agent, confirm.
A shutdown and a start rather than a reboot, because only a cold boot makes qemu
read the VM definition again — a reset would take the machine round and bring it
back exactly as small as it was. The shutdown is a REQUEST, bounded at ten
minutes, and there is no escalation: ProxmoxClient deliberately does not expose
Proxmox's forceStop, because the guest is a Nextcloud and cutting power to a
database mid-write to apply a CPU change trades an inconvenience for a restore
from backup. A guest that ignores ACPI for ten minutes fails the run instead, and
a person decides what happens to it.

`restart_required_since` is cleared by the last step, from what the RUNNING guest
reports (`cpus`/`maxmem`) — not by the button, and not by the run merely
finishing. A machine that comes back smaller than it was sold fails the run.

Started by App\Actions\RestartInstance, which re-checks authorisation itself
rather than trusting a hidden button: an operator holding the new
`instances.restart` may restart anything, a portal user their own machine and
nothing else. Refused while another run is in flight against the same order.
Confirmed in a modal on both sides (R23), addressed by uuid (R11).

**Existing instances had no storage quota.** ApplyStorageQuota reaches new and
changed machines; everything built before it kept the whole disk whatever it had
paid for, and nothing recorded which was which. `instances.quota_applied_gb` is
now what the guest was actually told, written by the step only after the occ call
succeeds — so "sold" and "enforced" are separate facts and can be compared.
`clupilot:apply-quotas` sweeps the estate: one `quota` run per instance through
the same step the pipelines use, skipping what it should and saying why, a no-op
once an allowance is enforced, and inert under --dry-run. Deliberately NOT
scheduled — a nightly sweep would quietly cover for the pipeline step the day it
stopped working, which is how the original hole survived this long.

**A Traefik router was never torn down.** TraefikWriter::remove() had no caller
anywhere. What was missing was the moment: ConfirmCancelPackage wrote a date into
`service_ends_at` and nothing ever went back to it, so every route this platform
ever wrote was written for good — pointing at a guest address the host is free to
reassign. `clupilot:end-due-services` (hourly) keeps that appointment through
App\Actions\EndInstanceService. "Ended" means cancellation_scheduled AND
`service_ends_at` passed, never the day a cancellation is scheduled: that
customer has paid to the end of the term and is working in it. The DNS record
goes with the route — it is in our zone, pointing at a host that serves other
people, which is the shape of every subdomain takeover there has ever been. The
customer's own domain is in their zone and is not ours to touch; the virtual
machine is left alone, because the cancellation flow promises a data export first
and destroying disks is not this change's decision.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 18:28:28 +02:00
nexxo 8f2252e81b Book a downgrade for a date that cannot move, and sell a module once
Two ways of charging a customer for something they did not get.

A downgrade was due when `subscriptions.current_period_end` said so, and
Stripe pushes that column forward on every renewal. So a downgrade booked in
March was deferred to the end of April by April's renewal, and to the end of
May by May's — each time by a whole term, and each time the customer was
billed again for the package they had asked to leave. The due date was being
re-derived from a moving target on every scheduler tick.

It is decided once now, at the moment the customer decides, and stamped onto
the contract: `pending_plan` and `pending_effective_at`, the two columns that
have been in the schema since the first migration and were written by nothing
at all. The shop stamps them, PlanChange reads them instead of the period end
when a move is already booked, and clupilot:apply-due-plan-changes finds
contracts by the stamped date rather than orders by a date it recomputes.

The order stays what it always was — the customer's own record of the request,
what the cart shows them and what they remove to change their mind — so it is
still consumed with the change, and removing it unbooks the change. What it no
longer does is decide when. A second downgrade replaces the first rather than
queueing behind it; an upgrade clears one, or it would come due months later
and undo the bigger package; a contract cancelled before the date takes the
booking with it; and a customer with no contract can no longer place a
downgrade at all, which was accepted before and could never be carried out.

A booked change is now visible where the contract is read: on the plan card in
the portal, because the cart entry disappears when it is paid for and the
booking does not, and on the customer row in the console, because that is
where an operator answers "what is this customer on".

The second defect is the same money in one step: BookAddon guarded duplicates
per ORDER — the unique index on (order_id, addon_key) — so two orders for the
same module on one contract both went through. A stale tab or a double click
bought priority support twice.

But storage is deliberately sold in packs, and AddonCatalogue sums the
quantities for exactly that reason, so "refuse the second" is only right for
some of them. Which is which is declared beside each price in
config/provisioning.php (`sold_as`), because it is a commercial decision about
each module and not something to infer from a key: storage is a quantity;
off-site backups are on or off, support is prioritised or it is not, Collabora
Pro is one licence, and a machine answers to one own domain. An undeclared
module counts as an entitlement — the cheaper of the two mistakes — and a test
refuses to let one ship undeclared.

Enforced in the action, where the portal, the console's grant screen and a
webhook all pass, with the sentence the customer is shown rather than a
developer's. A retried webhook for the SAME order still gets its one booking
back; that is one purchase arriving twice, not two purchases. The portal stops
offering what would be refused — booked, or already waiting in the cart — and
goes on offering the pack.

What is missing is on the Stripe side and is not invented here: nothing moves
the subscription's item onto the new Price, so the renewal after a plan change
still bills the old plan at Stripe. StripeClient only creates and archives
catalogue objects, and swapping a price also needs the subscription ITEM id,
which nothing stores.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 17:51:37 +02:00
nexxo 34874adec3 Apply a bought plan change instead of only pricing it
tests / pest (push) Failing after 8m6s Details
tests / assets (push) Successful in 21s Details
tests / release (push) Has been skipped Details
PlanChange could say what a move would cost and whether it was allowed, and
that was all it could do. Billing::purchase() wrote an upgrade order and
nothing ever consumed it: same snapshot, same machine, same quota. A customer
could pay for a bigger package and receive nothing.

ApplyPlanChange is now the single place a change lands — it moves the contract
onto the target's current version, writes one register row, settles the custom
domain, and starts a run that resizes the machine. Applying the same order
twice is a no-op, enforced by a unique event key rather than by a check two
callers could both pass.

Two things a plan change must not do, and now does not. A disk is never shrunk
— Proxmox cannot, so the QUOTA shrinks instead, which is what was sold anyway.
And a live machine is never rebooted as a side effect: cores and RAM are
written, and where they need a restart the instance says so where an operator
and the customer can both see it.

The storage allowance also joins the build pipeline. ApplyStorageQuota was
written for plan changes and ran only there, so a brand-new customer still got
no quota at all — quota_gb reached the instance row and stopped, and every
package delivered the whole disk. The test guarding that pipeline only counted
its steps, which is how a list missing the one step that makes a package's
storage real stayed green for its whole life. It now names the step.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 17:18:55 +02:00
nexxo 712803edd6 Serve the custom domain, not just announce it
A verified custom domain was reported as the customer's address by
Instance::address(), by the portal and by the credentials mail while
nothing on the platform routed it: the Traefik router's rule was
hard-coded to {subdomain}.{zone}. The address a customer was handed
answered nothing, and a withdrawn domain stayed in Nextcloud's
trusted_domains forever, because the only thing that ever wrote either
was the initial provisioning run.

- TraefikWriter::write() takes a LIST of hostnames under one stable
  router name — the platform address always, the verified domain as
  well when there is one. One file per instance, so a withdrawal is a
  rewrite rather than a second thing somebody has to remember.
- ConfigureDnsAndTls records the custom domain's certificate instead of
  waiting on it: it depends on an A record in the customer's own zone,
  which may never appear, and must never fail a run. The platform
  address keeps its 840s deadline. instances.domain_cert_ok tells
  "proven" apart from "answering", and the portal now says which.
- ConfigureNextcloud deletes trusted_domains 2 when there is no verified
  domain, so a withdrawn one stops being trusted.
- New `address` pipeline (those two steps) plus ReapplyInstanceAddress,
  which starts one against the order and refuses to start a second while
  any run is in flight. The route is rewritten when the hostname list
  differs from what the router carries — not on route_written, which
  would short-circuit exactly the case a re-apply exists for.
- Triggered where the address changes: verification flipping either way
  in clupilot:verify-domains, the customer's own domain page, and
  CustomDomainAccess::deactivate() on a package downgrade.
- A maintenance run no longer condemns its subject: an address run that
  failed used to mark the order failed and release the live instance
  with it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 16:44:52 +02:00
nexxo b3651a14be Decide who may have their own domain, and decide it in one place
tests / pest (push) Failing after 9m46s Details
tests / assets (push) Successful in 21s Details
tests / release (push) Has been skipped Details
The domain page shipped with no access control at all: the `custom_domain`
plan feature was checked nowhere in the codebase, and every customer on
every package could point a domain at their instance. This is the owner's
rule, built as one authority that everything else asks.

  Start        impossible — not bookable, not upgradable, not offered
  Team         optional, via the `custom_domain` module
  Business     included, because the plan version carries the feature
  Enterprise   included, likewise

App\Services\Billing\CustomDomainAccess answers all of it: may this
contract use one, may it book the module, why not in words a customer can
be shown, what a downgrade would do to a domain they have, and — after a
change lands — making the state match. Read against the FROZEN plan
version, never today's catalogue, so a feature added to a package later
cannot reach into a contract signed before it.

Which packages cannot have one at all is an explicit list in
config/provisioning.php, beside the module's price. The catalogue cannot
answer it: plan versions model what a package HAS, and nothing models what
a package may BUY. Both available proxies are dishonest — "the lowest tier
on sale" hands the right to a grandfathered Start customer the day the
owner stops selling Start or adds something cheaper below it, and reading
it off `branding` would sell a domain to anyone allowed to upload a logo.
The config comment says so.

Enforced where it can be enforced today:

  - BookAddon refuses the module, with the sentence the customer is shown.
    GrantAddon now writes its synthetic order inside the transaction, so a
    refusal cannot leave a paid order for a module nobody got.
  - The portal drops the card where it cannot be booked and shows it as
    included where the package carries it — no price, no button.
  - Billing::purchase() re-checks, because a hidden button is markup.
  - DowngradeCheck reports the consequence beside the blockers, so the
    customer reads it above the button rather than after it: losing the
    domain (Team -> Start), or the choice (Business -> Team, where keeping
    it means booking the module).
  - PlanChange::settleCustomDomain() applies it once a change lands: the
    module stops being charged for and the instance goes back to its
    platform address. It clears the whole domain, not only the verified
    flag — clupilot:verify-domains re-reads every row that still has a
    domain and a token, and would switch it back on the next night.
  - The price sheet's own-domain row now reads dash / optional + price /
    included, from the same authority; the three cell states already
    existed and the view is unchanged.

STILL TO APPLY, ONCE THIS AND THE CUSTOM-DOMAIN BRANCH MEET — one line at
the top of App\Livewire\CustomDomain::mount(), which belongs to the other
session and is deliberately untouched here:

    abort_unless(app(\App\Services\Billing\CustomDomainAccess::class)->allowsCustomer($this->customer()), 403);

routes/web.php is left alone for the same reason. The mount() guard covers
the route and the component both; a middleware would only repeat it.

Follow-ups, named rather than built:

  - Nothing applies a plan change anywhere yet, so settleCustomDomain()
    has no caller. Whoever builds that must call it after the new contract
    opens, and must decide whether a booked module is carried onto the new
    contract — enforce() cancels the booking on the contract it is given.
  - Deactivation sets the state; it does not re-run provisioning. Traefik
    and Nextcloud's trusted_domains still name the old address until
    ConfigureDnsAndTls / ConfigureNextcloud run again.
  - App\Support\Navigation still shows the Domain tab to every customer;
    it can ask allowsCustomer() the same way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 15:59:04 +02:00
nexxo 602602864a Let a subscription or add-on be opened for free, with who and why on the row
A grant is an order without money, not a second code path: GrantSubscription
and GrantAddon create the same Order/Subscription/SubscriptionAddon rows a
paid purchase does, with stripe_subscription_id left null and price_cents set
to whatever the customer actually pays. OpenSubscription and BookAddon gained
an optional overrides parameter so the price and provenance (who granted it,
when, a note, an optional end date, and the catalogue price for legibility)
land on the same snapshot every real purchase goes through, not a duplicate.

New customers.grant_plan capability, Owner-only like secrets.manage — giving
away service is exactly the kind of blast radius that precedent reserves for
the Owner.
2026-07-29 12:41:06 +02:00
nexxo 79564947d4 Actually issue the invoice when the money arrives
tests / pest (push) Failing after 7m24s Details
tests / assets (push) Successful in 20s Details
tests / release (push) Has been skipped Details
Everything was built — numbering, the frozen snapshot, the PDF, the mail, the
archive and its retention — and none of it was connected to anything. IssueInvoice
was called from nowhere but its own tests. So on a real server no invoice had
ever been created, the handover directory was empty, and rsync was correctly
copying nothing. Reported as "nothing gets copied", which it was.

One mail, not two. The invoice is issued the moment the payment lands, so an
order confirmation and an invoice mail would otherwise go out in the same second
for the same event — a defect rather than a preference. The invoice mail carries
the document and says everything the confirmation said, so it replaces it
whenever there is an invoice; the confirmation stays for when there cannot be
one.

Issuing is allowed to fail without taking the purchase with it. IssueInvoice
refuses while the company details are incomplete, and that refusal is correct —
an invoice without a registered name or a VAT number is not a valid invoice, and
issuing one consumes a number that can never be handed out again. The order
still stands, the machine is still built, and the invoice can be issued later.
Nothing here may throw at the webhook either: Stripe retries anything that is
not a 2xx, and a retry would re-enter provisioning and build a second machine
over a missing VAT number.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 03:25:56 +02:00
nexxo b1ab0f7f31 Put every mail in one design, and confirm an order when the money arrives
tests / pest (push) Failing after 7m48s Details
tests / assets (push) Successful in 25s Details
tests / release (push) Has been skipped Details
Four templates, one layout. Two of them — the maintenance announcement and its
cancellation — still wore Laravel's markdown component, which brings its own
logo, its own button and its own footer: the product was sending mail that
looked like two different companies, one of which was the framework.

New: an order confirmation, sent when the payment lands rather than when
provisioning finishes. Those are minutes apart at best and can be much longer,
and somebody who has just been charged and heard nothing assumes the worst
about both the charge and the product. It deliberately promises no time for the
cloud itself — provisioning takes what it takes, CloudReady announces the end,
and a promised minute that slips is worse than no promise. Queued after the
transaction, never inside it, and never allowed to fail the webhook: Stripe
retries anything that is not a 2xx, and a retry would re-enter provisioning
over a mail server having a bad minute.

The finished-cloud mail now names what was actually built — address, plan,
storage, location — because somebody who ordered a fortnight ago is checking it
against what they bought, and that is a comparison rather than a sentence. Still
no credential in it.

Two tests earn their place. One holds every template on the shared layout, so a
new one written in a hurry cannot copy-paste its way back to the framework's.
The other proves the finished-cloud mail carries no password — asserted against
the data the mail is given and the HTML it renders, not against the template's
source, because the first version of that test tripped over the word in a
comment and would have passed a template that leaked the value through a
variable. It tested the prose, not the mail.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 00:14:04 +02:00
nexxo 20329b176b Refuse a portal login for an address that already belongs to an operator
Customer::ensureUser() enforced uniqueness only within users, so a
customer provisioned (or registering publicly) with an operator's email
got a second, colliding identity for the same address — the exact thing
R21 exists to prevent. Checks Operator by address now, not by session
(the deleted assertNotAdmin() checked who was signed in, which could
never be the right question).

Public registration gets the same guard via a plain unique() validation
rule, reusing Laravel's stock "already taken" message rather than a
dedicated one — naming the collision specifically would tell a public
visitor an address belongs to staff, a worse leak than the generic
refusal every other collision here already gives them.

Impersonation answers 409 instead of crashing into it. The Stripe
webhook keeps recording the paid order (same principle as
openContract()'s own comment: the order is proof money changed hands)
and withholds only the colliding login, logged for an operator to
resolve by hand.

Verified the reverse direction Codex flagged as already covered: two
Admin\Settings actions and the clupilot:create-operator command all
already refuse an operator email that collides with a customer's, and
that check is sound today because every users row is created in
lockstep with a customers row of the same email (CreateNewUser and
ensureUser are the only two creation sites, both confirmed by grep) and
nothing in this app can change a user's email afterwards (Fortify's
updateProfileInformation feature is disabled, and no other code calls
that action).
2026-07-28 14:42:16 +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 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 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 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 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 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 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 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 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 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