Commit Graph

4 Commits (6b8412f0c394981c091fdba767fc2defe3627124)

Author SHA1 Message Date
nexxo f85f57152e Let the customer buy it themselves, and tab the integrations page
tests / pest (push) Failing after 21m20s Details
tests / assets (push) Successful in 20s Details
tests / release (push) Has been skipped Details
Every path on the site ended in "anfragen": a mailto: link and a promise
to answer the same working day. That is an afternoon of somebody's time
per customer, for a product whose whole point is that the machine does
the work — and it scales exactly as far as one inbox does.

Now: sign in, pick a package, pay, and provisioning starts by itself.
The pieces were already there — Stripe holds a Price per plan version
(the catalogue sync writes the ids), the webhook opens the contract, the
pipeline builds the VM — and what was missing was the checkout session
between them. CheckoutController opens one and gets out of the way; the
purchase becomes real on the webhook, which stays the only place allowed
to believe a payment happened. The success URL is a redirect target and
therefore a URL anybody can type, so it says "we are building it", not
"you have paid".

Nothing in the request may decide the price. The plan key arrives from a
form and everything else is re-derived from the catalogue, including the
plan VERSION, so a version published while the customer types their card
number cannot change what they were quoted.

Two cases that are not errors: no capacity is a parked order (already
built), and an existing customer is sent to the plan change rather than
sold a second, empty cloud that bills twice.

The site says what it can keep. "danach wissen Sie … wie Ihre Daten
sicher umziehen" was a promise made before seeing the thing it is about:
we do not know where a visitor's data is, what shape it is in, or whether
it can be moved at all. Migration is named as its own question, looked at
first and quoted afterwards.

The integrations page gets the settings page's treatment for the same
reason: it was one narrow column of six cards, so an operator connecting
DNS scrolled past Stripe to reach it and the raw .env editor sat at the
bottom of everything. Three tabs by what a section is FOR — services we
buy from outside, our own machines, the file underneath — the same axis
the page was rebuilt around when it stopped being split by storage
mechanism. The tab is in the query string; unlocking survives a switch,
because a confirmed password is a session fact and not a property of a
tab. The .env tab is Owner-only, like the editor it holds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 19:42:28 +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 ec8675861e Take the order, park it, and say when it will be delivered
tests / pest (push) Failing after 7m44s Details
tests / assets (push) Successful in 21s Details
tests / release (push) Has been skipped Details
A paid order that no host has room for used to FAIL at the reservation
step. The customer has paid by then, so that turned a sale into an
incident and left somebody holding money against nothing. The estate is
finite and booked thick, so "no host has room right now" is an ordinary
Tuesday — it means a machine has to be bought, which takes days.

So the order waits instead. poll(), not retry(): the wait is measured in
days and must not eat the run's attempt budget. It only fails after
PARK_DAYS (14) — long enough to buy, rack and onboard a server over a
weekend, short enough that a forgotten order surfaces instead of waiting
for ever.

Then say so, in the same words on both sides of the payment:

  - The price sheet marks each package "wird sofort ausgeliefert" or
    "Bereitstellung in 2-3 Werktagen", read from free capacity rather
    than written into the page.
  - The customer's own overview says their cloud is being prepared while
    it is parked, instead of a stepper standing still for two days under
    "wird eingerichtet" — which reads as broken.
  - The console front page shows the same per-package answer, so an
    operator can see what visitors are being promised.

And a capacity page for the decision that follows: who is waiting, what
the roomiest host still has, what the queue needs ("3x Start, 1x
Business" - the LARGEST parked package sets the minimum machine, because
an instance lives on one host), and what such a machine costs today from
the provider's live list. It orders nothing: buying a server is a
contract, and a bug in a capacity calculation must not sign one.

The operator can also say where each parked order goes. A pin is
honoured or it waits — never quietly redirected — because placing it
elsewhere answers a different question than the one they answered, and
they would find out from the finished instance.

Two things this shook out:

  - `current_step` is an INDEX into the pipeline, not the step's key, and
    it is cast to integer. `where('current_step', 'reserve_resources')`
    reads as correct and matches step 0 of EVERY pipeline instead. Both
    lookups go through HostCapacity::parkedRuns() now, which resolves the
    index and filters by pipeline.
  - The auction feed sends `hdd_hr` as a list, not a string. Casting it
    printed "Array" into the console and raised a warning that took the
    page down with it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 18:50:46 +02:00
nexxo 98d884727a Warn that a package cannot be placed, before somebody pays for it
tests / pest (push) Failing after 7m55s Details
tests / assets (push) Successful in 23s Details
tests / release (push) Has been skipped Details
The platform books thick: a package reserves its whole disk_gb — the sold
quota plus the machine's own overhead — on ONE host at placement. That is the
right choice for a small operator, because nothing can quietly overfill. It
has one consequence nobody was being told about: the catalogue goes on
offering a package no host has room for, and the first anyone hears of it is a
PAID order failing at the reservation step.

On the machine this was found on the numbers are stark. 2 x 512 GB NVMe in a
mirror is about 400 GB usable; Start reserves 120, Team 540, Business 1050,
Enterprise 2100. Three of the four packages on sale cannot be placed at all,
and the fourth fits three times.

So the console says so now, per package, with the shortfall — "Team needs 540
GB, the roomiest host has 260" — because "buy a server" is not the decision.
"Buy a server big enough for what I am selling" is.

Two things it deliberately does not do. It does not warn on an installation
with no host at all: that is a machine nobody has onboarded yet, it has its
own notice, and without the guard a fresh console greeted its operator with
one warning per package before they had done anything. And it does not take
the overview down when the catalogue cannot be read — a capacity warning does
not get to break the page somebody opens when something is already wrong.

The largest FREE HOST, never the sum: an instance goes on one machine, so two
hosts with 300 GB each cannot take a 540 GB package between them.

And when placement does fail, the log now names the shortfall instead of only
'no_capacity'.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 18:20:51 +02:00