Commit Graph

11 Commits (main)

Author SHA1 Message Date
nexxo 5883989552 Give settings the shape a settings page actually has
Four attempts, and this is what was wrong with the last one: full-width
panels stacked down the page, each carrying its own title INSIDE it. Every
group had two frames — the title bar and the border — so the page read as
a column of long boxes with nothing saying where one topic ended and the
next began, and a phone number got a field a thousand pixels wide.

The shape now:

- **A section list on the left**, sticky, so the sections stay reachable
  however far a form runs. Below `lg` it becomes a scrolling strip of
  chips, because a vertical list on a telephone is four rows of nothing.
  Under it, who is signed in — on a shared office machine that is a real
  question, and a settings page is where it gets asked.
- **A content column of 820px**, not the whole 1240 shell.
- **The heading OUTSIDE the card it belongs to.** A section is a heading, a
  sentence, and then a card of rows. That single move is what stops them
  reading as boxes: the border now frames the fields, not the topic.
- **What cannot be undone is last, in a card whose border says so** — the
  deletion deadlines and the close button together, at the foot of the
  contract section, in danger colours.

The rows are unchanged (label left, control right) and so is every binding;
what changed is the frame around them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 17:59:16 +02:00
nexxo 2a97d76b2d Rebuild the settings page out of panels and rows
**The withdrawal was invisible in two different ways.** It rendered only
while the fourteen days were still running, so the moment they expired the
whole block vanished — indistinguishable from a feature nobody built. And
the account it was looked for on has no contract at all: no package, no
subscription, nothing to withdraw from, so there was never anything for it
to be about.

It is now a row for every CONSUMER who has a contract, open or not: while
it runs, the deadline and the button; afterwards, the sentence saying why
it is over. A business never sees it — WithdrawalRight answers that, and
Settings::withdraw() refuses them again on the server. And an account with
no package says so and offers the packages, instead of a dead sentence in
a box.

**The page is built out of two pieces now.** x-ui.panel is a group: a card
with a header that names it. x-ui.row is a setting inside it: what it is
on the left, the control on the right, dividers between rows. That is the
shape every settings page worth copying uses, and it is the shape that
uses the width — a 280px label column with the control beside it fills the
line, where a stack of full-width inputs leaves two thirds of it empty and
a grid of cards of different heights walks down the screen as a staircase.

All four tabs are rebuilt on it, so the page reads as one designed thing
rather than four pages that happen to share a tab bar. Below `sm` the rows
stack, because on a telephone a label belongs above its field.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 17:38:57 +02:00
nexxo 1056dddc62 Take the address by field, fix the customer type, ask why they leave
**The billing address was one textarea**, so what landed in it was
whatever somebody typed: a postcode on the street line, a city with no
postcode, no country at all. It is four fields now — street, postcode,
town, country — in the customer's settings and in the console's customer
editor, which had its own free-text copy of the same field and would have
had an operator's correction silently reverted the next time the customer
saved.

`customers.billing_address` is neither dropped nor parsed: guessing which
line of an existing block is the street would put a postcode where a
street belongs on a document nobody can correct afterwards. It becomes the
COMPOSED form, rewritten from the fields on every save, so IssueInvoice
and everything else that reads it keep working, and a record nobody has
re-saved keeps the block it always had.

**The customer type is fixed once it is on record.** It decides the
fourteen-day right of withdrawal, and a business able to set itself to
"Privatperson" on this page is a business able to withdraw from a contract
it may not withdraw from. Registration asks the question; this page
reports the answer, and saveProfile refuses to answer it a second time —
in the component, not by hiding a radio, because a form that hides a
control has never stopped anybody who can post to /livewire/update. A
record created before the question existed may still be given one, once.

The panel is a fact rather than a form now, which is also what was odd
about it, and the sentence about who has the right of withdrawal is gone.

**Cancelling asks why.** A reason from a fixed list, required, with a note
beside it that "Sonstiges" cannot go without — the one fact worth having
about a departure, asked at the only moment the customer is looking at the
question. It lands on the contract (`cancel_reason`, `cancel_reason_note`).

And where the fourteen days are still running, the dialogue offers the
WITHDRAWAL as a way out of itself rather than as one more reason inside
it: withdrawing ends the service the same day and sends the whole amount
back, cancelling keeps the term that was paid for, and somebody entitled
to the first should not lose it by taking the second unasked. A business
is never offered it — WithdrawalRight answers that, not the template.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 16:41:32 +02:00
nexxo 1d4e98eb06 Rebuild the customer's settings page as four tabs
It was one column 768 pixels wide inside a 1240-pixel shell — a third of
the window empty beside it — holding everything a customer might ever
change: the company address, their password, two-factor, the devices they
are signed in on, the branding of their cloud, the contract, and the
button that closes the account. Two thousand pixels of scroll, and the way
to a particular thing was knowing how far down it lived.

Four tabs, by what somebody came to do: their details, security, the
appearance of their cloud, the contract. The same tab bar the console
uses, so one vocabulary holds on both sides of the login, and the choice
lives in the query string — `#[Url(history: true)]`, no `except`, so the
parameter is there from the first render and a link to this page can name
the part it means. An unknown tab out of the address falls back to the
first rather than rendering a settings page with no section on it.

The width is used now. Inside a tab the short cards sit side by side
instead of each taking a full row to hold three lines: the customer type
beside the company form (it is one question, and beside the VAT field it
was mistaken for it), the password beside two-factor, the logo beside the
colours, the contract beside the closing of the account.

Also: the subtitle listed the old sections by name.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 16:25:10 +02:00
nexxo fed4acf31c Accept terms instead of a start date, and fix the sender no server accepts
**The test mail was rejected: "553 5.7.1 Sender address rejected: not
owned by user no-reply@…".** Every purpose mailbox here has its own SMTP
account, and a mail server lets an account send only from the address it
owns. Mail::to() hands back a pending mail bound to the DEFAULT mailer and
sendNow() then ignores the mailer the mailable asked for, so a mail
addressed support@ went out over the no-reply@ login. It sends through the
mailable's own mailer now, and the cloud-ready preview — the one mailable
with no mailbox at all — takes the provisioning mailbox like the real
notification does.

Writing the test for that found the same bug in production code:
**InvoiceMail and OrderConfirmationMail set their From to billing@ and
never named a mailer**, so both went out over mail.default's no-reply@
login. On this installation no customer had ever received an invoice mail
or an order confirmation; they rendered perfectly, queued without
complaint and were refused at the door. MailSenderOwnershipTest now scans
for the mismatch: a mail that takes a mailbox From must use that
mailbox's mailer.

**The box on the order page accepts the terms now**, not an immediate
start. It used to carry the whole FAGG §16 sentence, which read like a
choice between "now" and "in fourteen days" — and there is no second
option. The terms are what regulate the sale, so they had to exist:
resources/views/legal/terms.blade.php replaces the placeholder with
fourteen sections written from what this software actually does — the
delivery, the capacity queue, the full refund on withdrawal, the
cancellation at period end, the deletion deadlines. The company data
comes from CompanyProfile, so the page and the invoices cannot drift. No
availability figure and no liability cap has been invented.

No order goes through without it: the button is unusable until the box is
ticked and says why, and CheckoutController still refuses server-side —
the browser half refuses nothing. The request field is `terms_accepted`;
the Stripe metadata key stays `immediate_start`, because a session opened
before a deploy is paid after it and the webhook would find nothing under
a new name.

**"Wird der Account nach fünf Tagen gelöscht?"** Only an unconfirmed one.
That was the whole of what we said, so the answer looked like yes. The
second rule now exists and is stated: PruneDormantAccounts removes a
confirmed account after a year when it never had a package — no customer
record at all, which is where every order, contract and seven-year invoice
hangs. A fortnight's notice goes out first, once, and `dormant_warned_at`
is what permits the deletion: an account whose warning never went out is
never removed. Signing in resets the clock, measured off the device rows
because users has no last_login_at.

Both deadlines are said in the portal settings, on the verification page,
and in the terms — each reading the number off the command that enforces
it.

Also: the wordmark scan matched any element whose text merely BEGINS with
the company name, which a paragraph of terms does. It looks for the lockup
form now (no whitespace after the tag), which is what it was always about.
The seven checkout tests in the parallel session's files were posting the
old field name and now post the new one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 15:22:35 +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 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 973fcb3f2d Replace native confirm() dialogs with the app's own modal pattern
tests / pest (push) Failing after 7m14s Details
tests / assets (push) Successful in 21s Details
tests / release (push) Has been skipped Details
2026-07-28 19:34:27 +02:00
Claude f6b9181ed8 Give customers two-factor, and stop every button on the settings page reacting at once
tests / pest (push) Successful in 7m55s Details
tests / assets (push) Successful in 20s Details
tests / release (push) Successful in 6s Details
Clicking Save made every button on the console's settings page appear to fire.
Seven `wire:loading.attr="disabled"` had no `wire:target`, and without one
Livewire applies the loading state to ANY request on the component — so one save
put all of them into their disabled state simultaneously. Each names its own
action now.

Two-factor for customers. Fortify's endpoints already existed; only the screen
was missing. Setting up requires re-entering the password first, and every
action re-checks that server-side rather than relying on the button not being on
screen — a Livewire action is reachable by anyone who can post to
/livewire/update. The confirmation marker is Laravel's own session key, so it
and the framework's password.confirm middleware mean the same thing rather than
drifting apart.

The secret never enters a Livewire property. Component state travels to the
browser and back in the snapshot; the QR image is derived from the secret, the
secret is not in it — and there is a test that says so.

The status page moves to the ROOT of its hostname: status.clupilot.com/status
says the same word twice. That needed the domain-bound `/` registered BEFORE the
landing page, because Laravel takes the first match and the landing route is
host-agnostic — so the status host would have served the marketing site.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 08:48:34 +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