Commit Graph

9 Commits (v1.3.33)

Author SHA1 Message Date
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
Claude 935c9ae6ac Let people change their own password, and a location be switched off while editing it
tests / pest (push) Successful in 7m29s Details
tests / assets (push) Successful in 19s Details
tests / release (push) Successful in 4s Details
Three gaps, all of the "half-built" kind.

Nobody could change a password. Fortify's updatePasswords feature was switched
off, so an account created with a generated password kept it until someone
opened a shell on the server — including the owner's own. Both settings pages
have the form now, sharing one concern, because two copies of a password rule is
how one of them ends up weaker. It goes through Fortify's own action rather than
hashing here, and the current password is required: an unlocked machine should
not be two keystrokes away from locking its owner out.

The datacenter edit form had no active switch. The column and the scope existed,
and the list page has a toggle — but the form you open to change the thing did
not offer the one lifecycle action a location actually needs. Switching one off
now says what it does NOT do: existing hosts keep running. Once, on the actual
transition, and as the only message — the generic "saved" toast would otherwise
replace it, and an already-inactive location would have announced its own
deactivation every time its name was edited.

The staff table's actions cell is empty for your own row, because you cannot
revoke yourself. With a one-person team the column was therefore always blank
and read as broken rather than as a rule.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 07:49:07 +02:00
nexxo a321d963d7 fix(portal): no silent no-ops without a customer; maintenance visible per instance; maintenance form redesign
- ResolvesCustomer trait replaces 5 duplicated customer() lookups; portal actions
  now SAY why nothing happened (operator accounts have no Customer) instead of
  returning silently — the actual cause of 'dead buttons', '0/0 seats' and
  'settings not saving' when signed in as admin
- portal layout: explicit notice for a login without a linked customer
- /cloud: per-instance maintenance badge + window details; seat note 'owner = seat 1'
- maintenance form: sectioned card, hints, placeholder, styled datetime fields,
  grouped host picker with per-datacenter select-all + selection counter

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 19:10:44 +02:00
nexxo 99081284a4 fix(portal): delete old logo only after the branding update commits
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 15:04:27 +02:00
nexxo 249efa0553 fix: serialize datacenter delete (lock+deactivate+recheck); store logo before deleting old
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 14:58:07 +02:00
nexxo cbbb523f9c fix(portal): cancel the active instance explicitly, not just the newest record
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 14:48:57 +02:00
nexxo 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