Compare commits

..

No commits in common. "v1.3.73" and "feat/bootstrap" have entirely different histories.

1114 changed files with 615 additions and 149652 deletions

View File

@ -38,7 +38,7 @@ REDIS_HOST=redis
REDIS_PASSWORD=null
REDIS_PORT=6379
SESSION_DRIVER=database
SESSION_DRIVER=redis
SESSION_LIFETIME=120
SESSION_ENCRYPT=false
SESSION_PATH=/
@ -63,207 +63,23 @@ REVERB_SERVER_PORT=8080
# browser-side (baked into built assets): the address a browser can reach
VITE_REVERB_APP_KEY=change-me-app-key
VITE_REVERB_HOST=localhost
VITE_REVERB_PORT=443
VITE_REVERB_SCHEME=https
VITE_REVERB_PORT=8080
VITE_REVERB_SCHEME=http
VITE_APP_NAME=CluPilot
# Vite HMR host the browser connects to (dev). Set to the reachable VM address
# when developing against a remote host; localhost for local-only.
VITE_HMR_HOST=localhost
# ── Mail ─────────────────────────────────────────────────────────────────
# DEV: `log` writes mails to storage/logs (nothing is sent).
# PROD: switch to smtp and fill in the credentials below.
# ── Mail (dev → log) ─────────────────────────────────────────────────────
MAIL_MAILER=log
MAIL_FROM_ADDRESS=hello@clupilot.local
MAIL_FROM_NAME=CluPilot
# MAIL_MAILER=smtp
# MAIL_HOST=smtp.your-provider.tld
# MAIL_PORT=587
# MAIL_USERNAME=
# MAIL_PASSWORD=
# MAIL_SCHEME=smtp # smtp | smtps (465)
# ═════════════════════════════════════════════════════════════════════════
# CluPilot operations — fill these in to run real provisioning.
# Everything below is OPTIONAL for local dev: left blank, the services are
# no-ops/fakes and the app + test suite still run. Only real infrastructure
# work (host onboarding, customer provisioning) needs them.
# ═════════════════════════════════════════════════════════════════════════
# ── Stripe (payments) ────────────────────────────────────────────────────
# WEBHOOK_SECRET is READ BY CODE today (signature check on /webhooks/stripe).
# Outside local/testing a missing secret makes the webhook fail closed.
# Stripe hands out a SEPARATE signing secret per operating mode (test/live) —
# fill in the one matching each. Which one applies follows platform.mode
# (App\Support\StripeWebhookSecret), not this file.
STRIPE_WEBHOOK_SECRET=
STRIPE_WEBHOOK_SECRET_TEST=
# Not read yet — needed once the Checkout flow is built (currently the portal
# only records a purchase intent):
STRIPE_KEY=
STRIPE_SECRET=
# ── DNS (Hetzner DNS API) ────────────────────────────────────────────────
# Creates <subdomain>.<zone> A-records for each customer instance.
HETZNER_DNS_TOKEN=
CLUPILOT_DNS_ZONE=clupilot.cloud
# Host names (fsn-01.node.…) are NOT published here — that would leak the
# internal WireGuard subnet. They go into this directory instead, which the
# vpn-dns container reads (dnsmasq --hostsdir) over the dns-hosts volume; see
# docker-compose.yml. Only change if that volume is mounted somewhere else.
CLUPILOT_DNS_HOSTS_DIR=/etc/clupilot/dns-hosts
# ── Traefik (reverse proxy + TLS) ────────────────────────────────────────
# Directory ON THE PROXMOX HOST where CluPilot writes dynamic route files.
TRAEFIK_DYNAMIC_PATH=/etc/traefik/dynamic
# ── WireGuard hub (this CluPilot VM) ─────────────────────────────────────
# Hosts join this hub during onboarding; CluPilot reaches them over the tunnel.
CLUPILOT_WG_SUBNET=10.66.0.0/24
CLUPILOT_WG_HUB_IP=10.66.0.1
CLUPILOT_WG_ENDPOINT= # public host:port peers dial, e.g. vpn.clupilot.com:51820
CLUPILOT_WG_HUB_PUBKEY= # `wg pubkey < /etc/wireguard/privatekey`
CLUPILOT_WG_CONFIG_PATH=/etc/wireguard/wg0.conf
# ── SSH identity for host onboarding ─────────────────────────────────────
# Deployed to each fresh server after the one-time root password login.
# PREFERRED: point at files (a multi-line PEM cannot live in .env).
# ssh-keygen -t ed25519 -N '' -C clupilot -f storage/app/ssh/clupilot
CLUPILOT_SSH_PUBLIC_KEY_PATH=
CLUPILOT_SSH_PRIVATE_KEY_PATH=
# Alternative (single-line values only):
CLUPILOT_SSH_PUBLIC_KEY=
CLUPILOT_SSH_PRIVATE_KEY=
CLUPILOT_SSH_COMMAND_TIMEOUT=2000
# ── Monitoring ───────────────────────────────────────────────────────────
# The built-in client speaks a GENERIC REST API:
# GET/POST /monitors, GET/DELETE /monitors/{id}
# For Uptime Kuma use the bundled bridge below (Kuma's own REST API is
# read-only — monitor CRUD goes through Socket.IO):
# MONITORING_API_URL=http://kuma-bridge:8080
# Leave blank to disable monitoring entirely: provisioning records a stable
# breadcrumb and its own health checks (occ status, TLS, admin user) still
# gate acceptance.
MONITORING_API_URL=
MONITORING_API_TOKEN=
# ── Uptime Kuma (über die mitgelieferte Bridge) ───────────────────────────
# Kuma kann Monitore nur über Socket.IO anlegen, daher die Bridge:
# docker compose --profile monitoring up -d --build kuma-bridge
# Danach hier MONITORING_API_URL auf die Bridge zeigen lassen:
# MONITORING_API_URL=http://kuma-bridge:8080
# MONITORING_API_TOKEN ist gleichzeitig das Bridge-Token (frei wählbar, lang).
KUMA_URL=
KUMA_USERNAME=
KUMA_PASSWORD=
KUMA_TOTP=
# Monitoring-Verhalten bei Ausfall:
# MONITORING_REQUIRED=false -> Bereitstellung läuft weiter (Warn-Event), Standard
# MONITORING_REQUIRED=true -> Lauf schlägt fehl, wenn Monitoring nicht erreichbar
MONITORING_REQUIRED=false
MONITORING_ATTEMPTS=2
# ── VPN-Konfigurationen ──────────────────────────────────────────────────
# Schlüssel für hier gespeicherte WireGuard-Konfigurationen (32 Byte, base64).
# Bewusst getrennt von APP_KEY. Leer = Speichern deaktiviert.
# Erzeugen: head -c 32 /dev/urandom | base64
VPN_CONFIG_KEY=
# ── Zugangsdaten der Konsole (Stripe, DNS, Monitoring, Postfächer) ────────
# Eigener Schlüssel, ebenfalls getrennt von APP_KEY — der wird routinemäßig
# gewechselt und würde sonst jedes gespeicherte Geheimnis unlesbar machen.
# LEER = die Konsole speichert gar keine Zugangsdaten und sagt das auch.
# Erzeugen: head -c 32 /dev/urandom | base64
SECRETS_KEY=
# Netze, die als "wir" gelten, solange die Website versteckt ist
# (WireGuard-Subnetz). Alles andere sieht die Platzhalterseite.
TRUSTED_RANGES=10.66.0.0/24,127.0.0.1
# Umsatzsteuersatz des Verkäufers in Prozent. Preise in der Konfiguration
# sind NETTO; die Oberfläche weist beides aus.
CLUPILOT_TAX_PERCENT=20
# ── Wo die Konsole antwortet ─────────────────────────────────────────────
#
# ADMIN_HOSTS: Hostnamen, unter denen die Konsole erreichbar ist. Der ERSTE
# ist der kanonische — für ihn werden URLs erzeugt; die weiteren sind
# Ausweichadressen, über die jemand hereinkommt, der ausgesperrt ist.
#
# ADMIN_HOST_EXCLUSIVE: darf auf diesen Namen NUR die Konsole antworten?
#
# true Die Konsole liegt an der WURZEL ihres Hostnamens
# (https://admin.…/), und Portal wie Website antworten dort mit 404.
# Dann gehört hier ausschliesslich der Konsolen-Name hinein — eine
# IP oder localhost mit aufzunehmen schaltet Portal und Website auf
# genau dieser Adresse ab. Das ist die Betriebsform.
#
# false Die Konsole liegt unter /admin auf jedem gelisteten Namen, und
# diese Namen bedienen weiterhin auch Portal und Website. Nur für
# eine Maschine ohne eigene DNS-Namen sinnvoll.
#
# Ohne Eintrag gilt false — dann zeigt https://admin.…/ das PORTAL, nicht die
# Konsole, und das sieht wie ein Fehler aus, obwohl es die Vorgabe ist.
#
# APP_HOST: Hostname des Kundenportals, z. B. app.clupilot.com. Gesetzt, ist
# JEDE Portal-Route an diesen Host gebunden und existiert nirgends sonst —
# www.clupilot.com/dashboard ist dann ein 404, kein funktionierender Aufruf.
# Leer = Portal antwortet ueberall (Vorgabe, und was jede dev-Maschine ueber
# eine blanke IP braucht).
# Hostname fuer Dateien zum Herunterladen: AGB, AV, TOM (oeffentlich) und das
# Bootstrap-Archiv (nur mit gueltigem Einmal-Code). Leer = das Archiv bleibt
# auf dem Portal-Hostnamen.
FILES_HOST=
APP_HOST=
# SITE_HOST: Hostname der oeffentlichen Website. Gesetzt, antwortet die
# Startseite NUR dort — jeder andere Host (das Portal, eine blanke IP) zeigt
# unter "/" die Anmeldung bzw. das Dashboard. Leer heisst: Startseite ueberall,
# Mehrere Namen kommagetrennt, der ERSTE ist der kanonische — er liefert aus,
# alle weiteren leiten dauerhaft dorthin um (Pfad und Query bleiben erhalten):
# SITE_HOST=www.clupilot.com,clupilot.com
# und dann liefert app.clupilot.com die Website aus. Gebunden sind Startseite,
# robots.txt und die Legal-Seiten — dadurch erzeugt route('legal.impressum')
# auch aus einer Mail heraus eine www-Adresse.
SITE_HOST=
ADMIN_HOSTS=admin.dev.clupilot.com
ADMIN_HOST_EXCLUSIVE=true
# ── Nextcloud blueprint template ─────────────────────────────────────────
# NOT an env var: set the Proxmox template VMID per plan in
# config/provisioning.php → plans.*.template_vmid (default 9000).
# ── docker-compose knobs (host-side) ─────────────────────────────────────
HOST_UID=1000
HOST_GID=1000
# Behind a reverse proxy — which is every production install — these must stay
# on loopback: Docker publishes ports ahead of UFW, so anything on 0.0.0.0 is
# reachable from the internet regardless of the firewall, and reaching a backend
# directly skips every hostname and address rule the proxy enforces.
# For local development without a proxy, set APP_PORT=80.
APP_PORT=127.0.0.1:8080
# Vite dev server (HMR). Off by default: over the HTTPS domains the
# browser cannot load assets from http://<ip>:5173. Set true only when
# working over http://10.10.90.185, then run: docker compose up -d app
VITE_AUTOSTART=false
APP_PORT=80
VITE_PORT=5173
REVERB_HOST_PORT=127.0.0.1:8081
REVERB_HOST_PORT=8080
DB_HOST_PORT=3306
# ── CI (Gitea Actions) ──────────────────────────────────────────────────────
# Registrierungs-Token aus Gitea: Repo → Settings → Actions → Runners →
# "Create new runner". Danach: docker compose --profile ci up -d runner
GITEA_INSTANCE_URL=https://git.bave.dev
GITEA_RUNNER_TOKEN=
GITEA_RUNNER_NAME=clupilot-local
# Passend zur Server-Version halten (1.20 verträgt keinen aktuellen Runner).
GITEA_RUNNER_VERSION=0.2.6
# Zone in der Zeiten ANGEZEIGT werden. Gespeichert wird immer UTC.
APP_DISPLAY_TIMEZONE=Europe/Vienna

View File

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

12
.gitignore vendored
View File

@ -3,10 +3,6 @@
.env
.env.backup
.env.production
# EnvFileEditor's own backups (App\Services\Env\EnvFileEditor::backup()) — a
# timestamped copy beside .env before every write from the console's raw
# editor. Never pruned automatically; see the warning on that page.
.env.bak-*
.phpactor.json
.phpunit.result.cache
/.codex
@ -24,10 +20,6 @@
/public/storage
/storage/*.key
/storage/pail
# Exported invoice PDFs — runtime output, and on a real server this is a mount
# point. Committing it would put customer documents into the repository and
# make a deploy fight the archive for the directory.
/storage/archive
/vendor
_ide_helper.php
Homestead.json
@ -36,7 +28,3 @@ Thumbs.db
# Gitea push token (parent home dir; never track)
.env.gitea
clupilot.env
# SDD-Arbeitsverzeichnis (Ledger, Briefs, Review-Pakete) — Kladde, nicht Quelltext.
.superpowers/

314
CLAUDE.md
View File

@ -1,314 +0,0 @@
# CluPilot — verbindliche Regeln (Repo-Teil)
Die **Vollfassung der Regeln R1R17 liegt beim Nutzer**, nicht im Repo. Die
Kurzfassung steht in `docs/handoffs/2026-07-25-clupilot-state-handoff.md` §9.
Bei Konflikt: **STOP & fragen.**
Diese Datei hält die Regeln fest, die aus konkreten Fehlern im laufenden Betrieb
entstanden sind — sie sind nicht verhandelbar und werden per Test erzwungen.
---
## R18 — Icon-Größe und Zeilenumbruch
**Ein Icon steht neben seinem Text, nie darüber, und nie größer als bestellt.**
Verboten:
1. **Icon zwingt den Text auf eine zweite Zeile.** Ein Navigationseintrag, ein
Button, ein Tabellen-Action ist **einzeilig**. Zwei Zeilen sind nur erlaubt,
wenn der Text selbst bewusst zweizeilig gesetzt ist (Label + Unterzeile) —
dann steht das Icon links davon, nicht darüber.
2. **Icon größer als die Zeile, in der es sitzt.** Standard ist `size-5` (20px)
in der Navigation, `size-4` (16px) in Buttons, Tabellen und Fließtext.
Größer nur, wenn es als eigenständiges Element gemeint ist (Leerzustand,
Statusplakette).
### Warum das zweimal schiefging
- **Zeilenumbruch.** Tailwinds Preflight setzt `svg { display: block }`. Ein
Icon in einem *inline*-Elternteil schiebt den folgenden Text damit auf die
nächste Zeile. Genau so wurde aus dem Eintrag „Zugangsdaten" ein doppelt so
hoher Kasten mit Schloss oben und Wort darunter.
- **Größe.** `.size-4` und `.size-5` haben **dieselbe Spezifität**, also
entscheidet die Reihenfolge im Stylesheet — und Tailwind gibt `.size-4` *vor*
`.size-5` aus. Eine Komponente, die `size-5` bedingungslos mitmergt, überstimmt
damit **jedes** `class="size-4"` am Aufrufort. Alle so geschriebenen Icons
liefen still auf 20px.
### Wie es jetzt gebaut ist
- `resources/views/components/ui/icon.blade.php` setzt seine Standardgröße
**nur**, wenn der Aufrufort keine `size-`/`w-`/`h-`-Klasse mitgibt, und
rendert `inline-block shrink-0 align-middle` statt des Preflight-`block`.
- `resources/views/components/ui/nav-item.blade.php` legt Label und Icon in eine
eigene Flex-Zeile, damit ein Icon auch im falschen Slot daneben landet.
- Icons in `<x-ui.nav-item>` gehören in `<x-slot:icon>`, nicht in den
Default-Slot.
### Erzwungen durch
`tests/Feature/IconLayoutTest.php` — Größe des Aufruforts gewinnt, Icon bleibt
`inline-block`, Nav-Eintrag bleibt einzeilig aus beiden Slots, und kein
Blade-File im Repo darf ein Icon am Icon-Slot vorbeischmuggeln.
---
## R19 — Zeitzone: gespeichert in UTC, angezeigt auf der Wanduhr
**Jede Zeit, die ein Mensch liest, geht vorher durch `->local()`.**
Verboten:
1. **Einen gespeicherten Zeitstempel direkt formatieren.** `$model->created_at->isoFormat(…)`
liefert UTC. Richtig ist `$model->created_at->local()->isoFormat(…)`.
2. **`->timezone(config('app.timezone'))`.** Das ist die *Speicher*zone und bleibt
UTC — der Aufruf sieht aus wie eine Umrechnung und ist keine.
3. **Ein `datetime-local`-Feld nur in eine Richtung behandeln.** Rein und raus
gehören zusammen: `LocalTime::toField()` und `LocalTime::fromField()`.
Ein Feld hat keine Zeitzone; es sind die Ziffern, die jemand auf der eigenen
Uhr abliest.
Ausgenommen: `diffForHumans()` ist relativ und in jeder Zone gleich.
### Warum das durchgerutscht ist
Die Konsole kündigte eine Aktualisierung „spätestens um 15:21" an, während die
Uhr 17:21 zeigte. Zwei der vierzehn betroffenen Ansichten sahen sogar behandelt
aus — sie riefen `->timezone(config('app.timezone'))`, was sich liest wie „in
Ortszeit umrechnen" und, weil diese Zone UTC ist, nichts tut. Eine Attrappe ist
schlimmer als gar kein Aufruf: sie hält den Nächsten vom Nachsehen ab.
Schlimmer als die Anzeigen waren zwei **Formulare**: Wartungsfenster und
Paketversionen füllten ihre Felder mit UTC und lasen sie als UTC zurück. Ein
eingetragenes „21:00" wurde zu 23:00 Ortszeit.
Und die Tests deckten es nicht auf, weil sie den erwarteten Wert **mit demselben
falschen Aufruf** bildeten. Ein Test, der die Implementierung nachrechnet, prüft
nichts.
### Wie es jetzt gebaut ist
- `config('app.display_timezone')` (`APP_DISPLAY_TIMEZONE`, Vorgabe
`Europe/Vienna`) getrennt von `app.timezone`, das UTC bleibt.
- `->local()` als Carbon-Makro in `AppServiceProvider`. Es **kopiert** vor dem
Umstellen: `Illuminate\Support\Carbon` ist mutabel, sonst schriebe das bloße
Anzeigen das Modellattribut um. Beide Klassen bekommen denselben Rumpf —
Carbon führt Makros in *einer* globalen Tabelle, die zweite Registrierung
ersetzt die erste für alle.
- `App\Support\LocalTime` hält beide Feldrichtungen nebeneinander, damit niemand
eine ändert, ohne die andere zu sehen.
### Erzwungen durch
`tests/Feature/DisplayTimezoneTest.php` — kein Blade und kein Livewire-Bauteil
darf absolut formatieren ohne `->local()`, die UTC-Attrappe ist verboten,
Speicherzone bleibt UTC, Sommer- **und** Winterzeit werden geprüft, `->local()`
verändert das Original nicht, und der Feld-Round-Trip kommt als derselbe
Zeitpunkt zurück.
---
## R20 — Bearbeiten passiert im Modal, nie in der Zeile
**Sobald etwas Eingabefelder hat, geht ein Modal auf.**
Verboten:
1. **Inline-Bearbeitung in einer Tabellenzeile.** Kein `<input>`, kein
`<textarea>` in einem `<td>`. Die Zeile wächst, die Spalten daneben springen,
und eine halb im Bearbeitungsmodus stehende Tabelle liest sich wie ein
Darstellungsfehler, nicht wie ein Formular.
2. **Ein Bearbeiten-Knopf, der eine Methode am Seiten-Bauteil aufruft.** Er
schickt `openModal` — alles andere ist der Inline-Editor unter neuem Namen.
Nicht betroffen: Formulare, die *die Seite sind* — Anlegen-Formulare, die
Einstellungsseite, die Einladen-Zeile über einer Tabelle. Die bearbeiten keinen
bestehenden Datensatz an Ort und Stelle.
Ausnahmen, die kein Modal brauchen: ein einzelnes `<select>` oder eine
Checkbox in der Zeile (Rolle umstellen, aktiv schalten). Ein Klick, ein Wert,
keine Höhenänderung.
### Warum das aufgeschrieben wurde
Das Projekt hatte das Modal längst — `EditDatacenter`, mit genau dieser
Begründung im Kopfkommentar („avoids the row-height jump of inline editing").
Die Benutzertabelle hat es einfach nicht benutzt, und ich habe die
Bearbeitung inline gebaut, obwohl das Muster danebenlag.
### Wie es jetzt gebaut ist
- `App\Livewire\EditSeat` als `ModalComponent`, geöffnet über
`$dispatch('openModal', { component: 'edit-seat', arguments: { uuid } })`.
- Ein Modal ist **ohne** die Route-Middleware der Seite erreichbar. Es löst
deshalb den Kunden selbst auf und liest den Datensatz neu, statt einer vom
Browser hydrierten Eigenschaft zu glauben.
### Erzwungen durch
`tests/Feature/EditInModalTest.php` — kein Seiten-Blade darf ein Eingabefeld in
einem `<td>` wachsen lassen, und die Benutzertabelle muss `edit-seat` per
`openModal` öffnen.
---
## R21 — Konsole und Portal teilen keine Identität
**Betreiber und Kunden sind zwei Personengruppen, nicht zwei Zustände einer.**
Verboten:
1. **Eine Rolle oder Berechtigung am `User`.** Alle siebzehn Berechtigungen sind
Konsolen-Berechtigungen; sie liegen am `operator`-Guard. Ein `users`-Datensatz
mit Konsolenzugang ist die Vermischung in klein.
2. **Eine Auth-Route, die beide Seiten bedient.** `RestrictAdminHost::SHARED`
führt nur noch `livewire/*` und `up`.
3. **Eine Anmeldeansicht für beide.** Das Portal hat Fortify, die Konsole hat
`App\Livewire\Auth\OperatorLogin`.
### Warum das aufgeschrieben wurde
Die Konsole lieferte die Anmeldeseite des Portals aus — mit „Kein Konto?
Registrieren" darauf. `register` stand nicht in `SHARED` und konnte dort auch
nicht stehen, weil eine Konsole keine Selbstregistrierung hat. Der Link führte
also zwangsläufig in eine 404. Das war kein Anzeigefehler, sondern die Identität
zweier Personengruppen in einer Tabelle.
### Erzwungen durch
`tests/Feature/IdentitySeparationTest.php`
---
## R22 — Der Aufwand richtet sich nach der Aufgabe, nicht nach dem Verfahren
**Prüfen ist ein Mittel, kein Selbstzweck. Wer für eine Tabellenumstellung
zwölf Stunden und zehn Prüfrunden braucht, hat die Aufgabe nicht verstanden.**
Verbindliche Obergrenzen:
1. **Eine Prüfrunde, eine Fix-Runde, ein Re-Review über den Fix-Diff.** Danach
wird ein offener Befund **geparkt** — mit schriftlicher Begründung — und
nicht weitergeschliffen. Ein zweiter Re-Review desselben Befundes ist
verboten.
2. **Codex/R15: höchstens zwei Runden ohne P1.** Danach gehen die Restbefunde
als Folgepunkte in den Merge Request. Bis zur Nullmeldung zu schleifen ist
kein Qualitätsmerkmal, sondern fehlende Priorisierung.
3. **Ein Review sieht nur den Diff.** Dateien, die seit dem letzten grünen
Testlauf nicht angefasst wurden, werden nicht erneut geprüft — weder von
einem Prüfer noch beim Aufräumen. Was unverändert ist, war beim letzten
grünen Lauf schon in Ordnung.
4. **Mutationstests nur an der Grenze**, an der ein Fehler Daten verliert,
Rechte ausweitet oder Zugangsdaten preisgibt. Nicht flächendeckend.
5. **Aufgaben ohne Entwurfsentscheidung bekommen keinen eigenen Review.** Eine
Sprachdatei, eine Umbenennung, ein Feld mehr: bauen, Tests, weiter.
6. **Unabhängige Arbeit läuft gleichzeitig.** Analysen (Review, Codex,
Testlauf) blockieren einander nicht und werden parallel gestartet.
### Warum das aufgeschrieben wurde
Die Betreiber-Identität — eine Tabelle, ein Guard, eine Anmeldeseite — hat
zehn Codex-Runden und mehrere Re-Reviews gebraucht. Die Funde waren echt, aber
die Reihenfolge war falsch: Sicherheitsgrenzen zuerst, Randfälle in den
Folgepunkt. Der Nutzer wartet auf ein Ergebnis, nicht auf ein Verfahren.
---
## R23 — Bestätigung passiert im Modal, nie im nativen Browser-Dialog
**Eine Aktion mit Folgen wird im Design dieses Produkts bestätigt — nicht in
einem Fenster, das der Browser zeichnet.**
Verboten:
1. **`wire:confirm`.** Das Attribut ruft `window.confirm()` auf, bevor die
Anfrage beim Server ankommt — eine Systemmeldung mit dem Hostnamen darin,
kein Bestandteil der Oberfläche, die dieses Produkt zeichnet, nicht
gestaltbar und nur so übersetzt, wie der Browser gerade eingestellt ist.
2. **`confirm(` aus eigenem JavaScript.** Derselbe native Dialog, nur ohne die
Livewire-Beschriftung. Ein Skript, das ihn direkt aufruft, hat dasselbe
Problem unter anderem Namen.
### Warum das aufgeschrieben wurde
Die Konsole fragte vor dem Übernehmen eines Zahlungs-Schlüssels „Auf
admin.clupilot.com wird Folgendes angezeigt: Diesen Schlüssel wirklich
übernehmen?" — ein Systemfenster mit der Domain darin, mitten im sonst
durchgestalteten Betreiber-Bereich. Sechs Stellen in beiden Bereichen taten
dasselbe: ein Schlüssel speichern oder entfernen, ein VPN-Zugang neu
ausgestellt, Zwei-Faktor entfernt (Konsole UND Portal, zwei getrennte
Identitäten nach R21), ein Benutzerzugang entzogen.
### Wie es jetzt gebaut ist
- Sechs eigene Bestätigungs-Modals nach dem Muster von `ConfirmRemoveHost` und
den anderen bestehenden Lösch-Bestätigungen — `App\Livewire\Admin\
ConfirmSaveSecret`, `ConfirmForgetSecret`, `ConfirmReissueVpnPeer`,
`ConfirmDisableTwoFactor` in der Konsole; `App\Livewire\
ConfirmDisableTwoFactor` und `ConfirmRevokeSeat` im Portal. Geöffnet über
`$dispatch('openModal', { component: '…', arguments: { … } })`, derselbe
Mechanismus, dieselbe Knopf-Reihenfolge (Abbrechen sekundär, Bestätigen
benannt statt „OK").
- Das Modal mutiert nichts selbst. Der eigentliche Vorgang (`save()`,
`forget()`, `reissue()`, `disableTwoFactor()`, `revoke()`) bleibt
unverändert auf der Seiten-Komponente stehen, mit ihren eigenen
`guard()`-/Passwort-Prüfungen. Der Bestätigen-Knopf im Modal löst nur ein
Event aus (z. B. `secret-save-confirmed`), das die Seiten-Komponente per
`#[On(...)]` auffängt und an die bestehende Methode weiterreicht — das hält
die Berechtigungsprüfung an der einen Stelle, an der sie schon stand, statt
sie im Modal zu verdoppeln.
- Der bisherige Bestätigungssatz bleibt wortgleich als `_body` stehen; nur der
neue `_title` (und bei den Zwei-Faktor-Aktionen kein weiterer Text) kam
hinzu, `_confirm` heißt jetzt wie überall sonst im Repo die kurze
Knopfbeschriftung statt eines ganzen Satzes.
### Erzwungen durch
`tests/Feature/ConfirmInModalTest.php` — kein Blade-File im Repo darf
`wire:confirm` enthalten, und kein JavaScript darf `confirm(` aufrufen.
---
## R24 — Ein Modal wird nie höher als der Bildschirm
**Kopf und Fuß stehen fest, gescrollt wird nur die Mitte.**
Verboten:
1. **Ein Modal, das mit seinem Inhalt wächst.** Es läuft unten aus dem Fenster
heraus und nimmt seinen eigenen Speichern-Knopf mit — erreichbar nur, indem
man die Seite *hinter* dem Overlay scrollt, auf dem Telefon also gar nicht.
2. **Titel oder Knopfzeile im Scrollbereich.** Was man lesen muss, um zu wissen,
wo man ist, und was man erreichen muss, um fertig zu werden, gehören beide
außerhalb.
3. **Eine Blade-Direktive in der Attributliste eines Komponenten-Tags**
(`<x-ui.modal @if(…) wire:poll @endif>`). Sie landet im Attribut-Beutel und
zerlegt die View — dieselbe Falle wie `@disabled` an einer Komponente. Solche
Attribute gehören auf ein eigenes Element im Inhalt.
### Wie es gebaut ist
- `resources/views/vendor/wire-elements-modal/modal.blade.php` begrenzt das
Panel **einmal für alle Modals**: `max-h-[calc(100dvh-3rem)]`, Spalte,
`overflow-hidden`. `dvh` statt `vh`, weil am Telefon die Browserleiste zu `vh`
zählt — genau der Fall, in dem der letzte Zentimeter zählt.
- `resources/views/components/ui/modal.blade.php` ist die andere Hälfte:
`header`-Slot (oder `title`/`subtitle`), scrollender Rumpf, `footer`-Slot.
`min-h-0` am Rumpf ist die Zeile, die es wirken lässt — ein Flex-Kind wird
ohne sie nicht kleiner als sein Inhalt, und `overflow` greift nie.
- Ein Knopf im Fuß, der ein Formular im Rumpf abschickt, benutzt
`type="submit" form="…"`. Das ist der Preis für einen Fuß, der nicht
wegscrollt — und ein HTML-Attribut, kein Trick.
### Gilt für
Jedes Modal, in dem ein **Eingabefeld** steckt (`<form`, `<input`, `<textarea`,
`<select`, `x-ui.input`). Eine zweizeilige Rückfrage mit einem Knopf hat nichts,
was festbleiben müsste, und wird nicht umgebaut.
### Erzwungen durch
`tests/Feature/ModalHeightTest.php` — das Panel bleibt begrenzt, jedes Modal mit
einem Feld benutzt `<x-ui.modal>`, und keine Blade-Direktive steht in der
Attributliste eines Komponenten-Tags.

View File

@ -1 +0,0 @@
1.3.73

View File

@ -1,348 +0,0 @@
<?php
namespace App\Actions;
use App\Models\Instance;
use App\Models\Order;
use App\Models\ProvisioningRun;
use App\Models\Subscription;
use App\Models\SubscriptionRecord;
use App\Provisioning\Jobs\AdvanceRunJob;
use App\Services\Billing\CustomDomainAccess;
use App\Services\Billing\PlanChange;
use App\Services\Billing\TaxTreatment;
use App\Services\Stripe\StripeClient;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Throwable;
/**
* The single place a plan change actually happens.
*
* PlanChange is the preview: what a move would cost and whether it is allowed
* yet. Nothing carried one out. A customer could buy an upgrade, see it sit in
* the cart as a paid order, and keep the same contract, the same machine and the
* same quota forever the order was created and never consumed by anything.
*
* This is the consumer. It does the four things a plan change is, in one place
* so they cannot be done by halves:
*
* - the CONTRACT moves onto the target package's current sellable version, so
* the snapshot every step, bill and entitlement check reads says what the
* customer is now on;
* - the REGISTER gets one row, because a change of terms is a commercial event
* and the register is the evidence of what was agreed;
* - the ENTITLEMENTS that hang off the package are settled today that is the
* own domain, which a smaller package can take away;
* - the MACHINE is resized, through a provisioning run like every other piece
* of remote work in this application, so it is retried, logged and visible in
* the console instead of happening in a web request;
* - STRIPE is moved onto the new price, because it owns the billing cycle and
* would otherwise keep charging for the package the customer has left. Last,
* outside the transaction, and unable to fail the change see below.
*
* Idempotent from both ends. A contract already on the target version is a
* no-op rather than an error the order is consumed and nothing else moves
* and the register row carries the order's own event key, so a duplicate write
* collides in the database rather than in a check that two callers could both
* pass.
*/
class ApplyPlanChange
{
public function __construct(private RecordCommercialEvent $record) {}
/**
* Carry out the change an order bought.
*
* The contract is resolved through CustomDomainAccess::contractOf() rather
* than re-queried here: it is already the one place that answers "which
* contract is this customer on", and a second copy of that query is the one
* that would disagree the day the rule changes.
*/
public function forOrder(Order $order, ?Carbon $at = null): ?ProvisioningRun
{
$subscription = app(CustomDomainAccess::class)->contractOf($order->customer);
if ($subscription === null) {
Log::warning('No contract to move: this plan change has no live subscription behind it.', [
'order_id' => $order->id, 'plan' => $order->plan,
]);
return null;
}
return $this($subscription, (string) $order->plan, $order, $at);
}
/**
* @return ProvisioningRun|null the resize run, or null when the change was
* refused, was a no-op, or there is no machine
* to resize
*/
public function __invoke(Subscription $subscription, string $targetPlan, ?Order $order = null, ?Carbon $at = null): ?ProvisioningRun
{
$at = $at?->copy() ?? now();
try {
// The version on sale RIGHT NOW, which is what someone moving today
// is being sold. Throws for a plan that has been withdrawn or is not
// priced on this term, and that is the correct outcome: a contract
// moved onto a guess is worse than a change that visibly fails.
$target = Subscription::snapshotFrom($targetPlan, $subscription->term);
} catch (Throwable $e) {
Log::error('Cannot move a contract onto a package the catalogue will not sell.', [
'subscription' => $subscription->uuid, 'plan' => $targetPlan, 'error' => $e->getMessage(),
]);
return null;
}
// Already there. Not an error — a retried trigger, a command run twice,
// an operator pressing the same thing — but the order is consumed so it
// stops asking.
if ((string) $subscription->plan === $targetPlan
&& (int) $subscription->plan_version_id === (int) $target['plan_version_id']) {
$this->consume($order);
return null;
}
// The gate, and the only one. An upgrade with the period already over,
// a downgrade before the term the customer paid for has run out, and any
// move on a contract that is no longer active all stop here — see
// PlanChange::evaluate() for why each of them is refused.
$change = PlanChange::evaluate($subscription, $targetPlan, $at);
if (! $change->allowedNow) {
return null;
}
$instance = $this->instanceOf($subscription);
// Read BEFORE the snapshot moves: the resize step has to know what the
// machine had, to tell a change of CPU or RAM from a change that only
// touched storage or price.
$before = [
'plan' => (string) $subscription->plan,
'cores' => (int) ($instance?->cores ?? $subscription->cores),
'ram_mb' => (int) ($instance?->ram_mb ?? $subscription->ram_mb),
];
$run = DB::transaction(function () use ($subscription, $target, $targetPlan, $change, $order, $instance, $before, $at) {
$subscription->applyPlanSnapshot($target);
// A booked change is spent the moment the contract moves — whether it
// was this one arriving on its date, or a move in the other direction
// overtaking it. This is where the shop used to do it: an upgrade CLICK
// cleared a booked downgrade, in the cart, before a cent had been paid,
// and since nothing marks a cart order paid the customer lost the
// booking and got nothing in exchange. The contradiction is real —
// a downgrade left booked would come due months later and quietly undo
// the bigger package — but it is only real once the bigger package is
// actually theirs, which is here.
//
// Cleared AFTER PlanChange::evaluate() above, which reads the booked
// due date to decide whether a downgrade is allowed yet.
$subscription->clearPendingPlanChange();
// The register row is written from the contract that has just been
// moved, so the evidence and the contract cannot describe different
// packages — the same order OpenSubscription writes a purchase in.
($this->record)(
event: $change->isUpgrade
? SubscriptionRecord::EVENT_UPGRADE
: SubscriptionRecord::EVENT_DOWNGRADE,
subscription: $subscription,
// Zero, because a plan change moves TERMS and no money at all.
// Stripe settles the difference on a proration invoice of its
// own (PRORATE_IMMEDIATELY below), and that invoice arrives here
// as its own EVENT_INVOICE_PAID row carrying the real figure. An
// amount on this row as well counted one upgrade's money twice in
// any sum over the register.
//
// What the move is WORTH is not lost — it goes into the snapshot
// below, which is where the question "what were they quoted?"
// belongs. Deliberately NOT the order's amount_cents either:
// that is the shop's headline price for the whole package, and
// recording it for a move made on the sixteenth would state a sum
// nobody agreed to.
netCents: 0,
at: $at,
extra: ['plan_change' => [
'from_plan' => $before['plan'],
'to_plan' => $targetPlan,
'remaining_days' => $change->remainingDays,
'term_days' => $change->termDays,
// The preview's pro-rata difference, and what the till would
// take from THIS customer for it. Both, because PlanChange
// prorates the NET catalogue figures while the Stripe Price it
// prorates against is the one that customer is billed on — so
// for anybody who is charged VAT the net figure alone reads as
// a fifth less than the proration invoice that follows, and
// somebody reconciling the two needs the number to compare.
// For a reverse-charge business the two are equal, and saying
// so is what stops a reader looking for a missing fifth.
'prorated_net_cents' => $change->chargeCents,
'prorated_charge_cents' => TaxTreatment::for($subscription->customer)
->chargeCents($change->chargeCents),
]],
// Nothing has been charged AT THIS EVENT. Left null rather than
// reported as zero, which the register would read as a free sale.
chargedGrossCents: null,
order: $order,
// One row per order, enforced by the unique index rather than by
// a check two concurrent triggers could both pass.
eventKey: $order !== null ? 'plan-change:order:'.$order->id : null,
);
$this->resizeInstance($instance, $target, $targetPlan);
$this->consume($order);
return $this->startResize($instance, $targetPlan, $before, $change->isUpgrade, $order);
});
// After the run exists, never before. settleCustomDomain() withdraws a
// domain the new package no longer carries, and withdrawing one asks
// ReapplyInstanceAddress for a run to stop serving it — which finds this
// run already in flight against the same order and stands aside, because
// this pipeline carries the address steps itself. The other way round
// there would be two runs writing one router.
PlanChange::settleCustomDomain($subscription);
// Stripe bills the next term, so it has to be told which package that
// is — without this a customer who upgraded goes on paying for the one
// they left, every month, for as long as the contract runs.
//
// After the transaction and never inside it. The change has already
// landed on the contract and on the machine, and an unreachable or
// unconfigured Stripe must not roll any of that back: the action never
// throws, and parks what it could not do on the contract for the hourly
// sweep to pick up. A contract with no Stripe subscription behind it —
// a granted package — is left entirely alone.
//
// Resolved from the container here rather than injected, so a test that
// swaps the client in after this action exists still gets its fake.
app(MoveStripeSubscriptionPrice::class)(
$subscription,
$change->isUpgrade ? StripeClient::PRORATE_IMMEDIATELY : StripeClient::PRORATE_NONE,
);
if ($run !== null) {
AdvanceRunJob::dispatch($run->uuid); // after commit
Log::info('Applying a plan change.', [
'subscription' => $subscription->uuid,
'from' => $before['plan'],
'to' => $targetPlan,
'upgrade' => $change->isUpgrade,
'run' => $run->uuid,
]);
}
return $run;
}
/**
* The order is spent. Anything but `pending` takes it out of the customer's
* cart, and `applied` says which way it went it was never paid in the sense
* an invoice means, and calling it that would put a charge in the record that
* never happened.
*/
private function consume(?Order $order): void
{
if ($order !== null && $order->status !== 'applied') {
$order->update(['status' => 'applied']);
}
}
/**
* Bring the machine's own row in line with the package it now serves.
*
* Everything except the disk. A disk cannot be shrunk Proxmox grows one
* online and has no way back so a downgrade leaves the guest exactly as
* large as it already is, and writing the smaller figure here would make this
* row claim a size the machine does not have. Two things read it and both
* would be wrong: the host's storage accounting, which would think it had
* space it does not, and the next resize, which would ask Proxmox to shrink.
*
* What actually shrinks is the QUOTA, which Nextcloud enforces and which is
* what was sold see the plan-change pipeline's quota step. The disk is
* infrastructure; the quota is the product. The contract's own `disk_gb` still
* records what the smaller package includes, so nothing has been lost: this
* column is what the machine HAS, and that is all it has ever meant.
*
* @param array<string, mixed> $target
*/
private function resizeInstance(?Instance $instance, array $target, string $targetPlan): void
{
$instance?->update([
'plan' => $targetPlan,
'quota_gb' => $target['quota_gb'],
'ram_mb' => $target['ram_mb'],
'cores' => $target['cores'],
]);
}
/**
* Start the run that makes the machine match the package.
*
* Subject is the instance's own purchase order, exactly as the `address`
* pipeline does it not the upgrade order. That is what CustomerStep::order()
* and ::subscription() resolve from, so the steps read this customer's
* contract rather than nothing; it is what ReapplyInstanceAddress checks for
* a run already in flight, so two runs cannot write one router; and it keeps
* RunRunner from marking a paid, running customer's order failed because a
* resize could not be applied (see ProvisioningSubject::provisioningPipeline).
*
* @param array{plan: string, cores: int, ram_mb: int} $before
*/
private function startResize(?Instance $instance, string $targetPlan, array $before, bool $isUpgrade, ?Order $order): ?ProvisioningRun
{
// A contract with no machine — one still being built, one whose build
// failed, a package granted before provisioning ran — has nothing to
// resize. The contract has still moved, and the machine will be built
// from it whenever it is built.
if ($instance === null || ! $instance->hasLiveMachine()) {
return null;
}
return ProvisioningRun::create([
'subject_type' => Order::class,
'subject_id' => $instance->order_id,
'pipeline' => 'plan-change',
'status' => ProvisioningRun::STATUS_PENDING,
'current_step' => 0,
'context' => [
'instance_id' => $instance->id,
'host_id' => $instance->host_id,
'node' => $instance->host?->node,
'vmid' => $instance->vmid,
'subdomain' => $instance->subdomain,
// What the machine had before, so the resize step can tell which
// of its changes need the guest to come back round before they
// are true. Kept on the run rather than recomputed: by the time
// the step executes, the instance row already says the new size.
'plan_change' => [
'order_id' => $order?->id,
'from_plan' => $before['plan'],
'to_plan' => $targetPlan,
'from_cores' => $before['cores'],
'from_ram_mb' => $before['ram_mb'],
'upgrade' => $isUpgrade,
],
],
]);
}
/**
* The machine this contract pays for: the link when provisioning has written
* it, and otherwise the customer's newest instance the same fallback
* CustomDomainAccess makes, for the same reason.
*/
private function instanceOf(Subscription $subscription): ?Instance
{
return $subscription->instance
?? $subscription->customer?->instances()->latest('id')->first();
}
}

View File

@ -1,131 +0,0 @@
<?php
namespace App\Actions;
use App\Models\Instance;
use App\Models\Order;
use App\Models\ProvisioningRun;
use App\Models\Subscription;
use App\Provisioning\Jobs\AdvanceRunJob;
use App\Provisioning\WorkInFlight;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
/**
* Deliver the storage a customer has just bought or take back what they have
* just cancelled.
*
* A booked pack was a row in `subscription_addons` and nothing else. It was
* priced, frozen, charged for every month and had no effect whatsoever on the
* machine: the disk stayed the size the package made it, the filesystem inside
* stayed the size the disk had been, and Nextcloud went on enforcing the
* package's own allowance. This is the missing half the moment a booking
* becomes space.
*
* It does not do the work. Growing a disk, growing a guest's filesystem and
* writing an occ setting are remote work on a live machine, so they go through
* the same run machinery as everything else see the `storage` pipeline where
* they are retried, logged and visible in the console instead of happening
* inside whichever web request happened to book the pack.
*
* Called from the one place a pack becomes real or stops being real:
* App\Actions\BookAddon, on booking and on cancellation. Cancelling matters as
* much as booking the allowance goes back down, and Nextcloud has to be told,
* or the customer would keep the storage they have stopped paying for.
*/
class ApplyStorageAllowance
{
/** Start a storage run for the machine this contract pays for. */
public function __invoke(?Subscription $subscription): ?ProvisioningRun
{
if ($subscription === null) {
return null;
}
// The link when provisioning has written it, and otherwise the
// customer's newest instance — the same fallback ApplyPlanChange and
// CustomDomainAccess make, for the same reason.
return $this->forInstance(
$subscription->instance ?? $subscription->customer?->instances()->latest('id')->first()
);
}
/**
* Start a storage run for this instance, or return null when there is
* nothing to start.
*
* Null is the ordinary answer and not a failure: a machine still being
* built, one whose VM has gone, one whose service has ended, or one that
* already has a run in flight all deliver the allowance from that run
* instead every pipeline that touches storage reads the allowance when it
* gets there, which is this state or newer.
*/
public function forInstance(?Instance $instance): ?ProvisioningRun
{
if ($instance === null || ! $instance->hasLiveMachine()) {
return null;
}
// The same lock ReapplyInstanceAddress and RestartInstance take, for the
// same reason: two bookings can land in one instant — a double click, a
// webhook delivered twice, an operator granting a pack while the
// customer buys one — and two runs against one machine would resize the
// same disk and race each other's occ calls. Nobody waits for it: a run
// that lost the race has nothing to add, because the one that won reads
// the same allowance.
$lock = Cache::lock('instance-storage:'.$instance->uuid, 30);
if (! $lock->get()) {
return null;
}
try {
return $this->start($instance);
} finally {
$lock->release();
}
}
private function start(Instance $instance): ?ProvisioningRun
{
$order = $instance->order;
// Stand aside only for a run that will deliver the whole allowance: the
// disk, the filesystem inside it and the figure Nextcloud enforces. A
// plan change does all three and there is nothing to add. A `quota` run
// does only the last of them, and letting it stand for the booking would
// enforce a quota over a filesystem that never grew — space the machine
// does not have. `address` and `restart` do none of them, and standing
// aside for one of those was how a pack could be charged monthly and
// never delivered. See App\Provisioning\WorkInFlight.
if (app(WorkInFlight::class)->covers($order, 'storage')) {
return null;
}
$run = ProvisioningRun::create([
'subject_type' => Order::class,
'subject_id' => $order->id,
'pipeline' => 'storage',
'status' => ProvisioningRun::STATUS_PENDING,
'current_step' => 0,
// Everything the three steps read: instance_id is how CustomerStep
// finds the machine, node and vmid are how it reaches inside it.
'context' => [
'instance_id' => $instance->id,
'host_id' => $instance->host_id,
'node' => $instance->host?->node,
'vmid' => $instance->vmid,
'subdomain' => $instance->subdomain,
],
]);
AdvanceRunJob::dispatch($run->uuid);
Log::info('Delivering a changed storage allowance.', [
'instance' => $instance->uuid,
'run' => $run->uuid,
]);
return $run;
}
}

View File

@ -1,641 +0,0 @@
<?php
namespace App\Actions;
use App\Models\StripePendingEvent;
use App\Models\Subscription;
use App\Models\SubscriptionRecord;
use Illuminate\Database\UniqueConstraintViolationException;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Throwable;
/**
* What Stripe tells us about the billing cycle, applied to the contract.
*
* The division of labour: Stripe owns the money retries, dunning, off-session
* SCA and we own capability, because Stripe does not know how big the VM
* should be. So these handlers move period boundaries and status, and write the
* register. They never re-derive an amount: the invoice is the authority for
* what was charged, and recomputing it here from our own catalogue would produce
* a second, disagreeing answer.
*
* The one thing Stripe does NOT own is the document. Their invoice numbering is
* theirs and is not a lawful Austrian invoice series; ours is, and it is gapless
* because it has to be. So EVERY invoice Stripe reports as paid produces a
* document of our own and one mail carrying it see App\Actions\IssueStripeInvoice,
* and invoicePaid() below for the two exceptions: the checkout's own invoice,
* which the purchase already has a document for, and a charge for a term that
* begins after the contract ended, which owes nobody anything see
* owesADocument().
*
* Every handler is idempotent. Stripe retries a webhook until it gets a 2xx,
* and it also sends the same event to several endpoints so "already applied"
* is the normal case, not the exception.
*/
class ApplyStripeBillingEvent
{
/**
* Precedence when two events share a second highest wins.
*
* Ordered by how much the event settles: a payment that went through is the
* last word, a snapshot of the subscription is a running picture, and a
* failed attempt is the least final of the three, since a retry may already
* have succeeded.
*/
private const RANK_PAID = 3;
private const RANK_UPDATED = 2;
private const RANK_FAILED = 1;
public function __construct(
private RecordCommercialEvent $record,
private IssueStripeInvoice $issueStripeInvoice,
) {}
/**
* A renewal was paid. Move the period on and enter it in the register.
*
* @param array<string, mixed> $invoice
*/
public function invoicePaid(array $invoice, ?Carbon $eventAt = null): ?SubscriptionRecord
{
$subscription = $this->resolve($invoice['subscription'] ?? null);
if ($subscription === null) {
return null;
}
$reason = (string) ($invoice['billing_reason'] ?? '');
// The checkout's own invoice is not a renewal — it is the purchase, and
// OpenSubscription has already entered it. Recording it again here
// would double every customer's first payment in the register.
if ($reason === 'subscription_create') {
return null;
}
// Only a cycle renewal moves the term on. Stripe also sends paid
// invoices for prorations and for charges raised by hand, and those are
// money received without being the start of a new month — calling them
// renewals would push the period forward on a top-up.
$isRenewal = $reason === 'subscription_cycle';
$invoiceId = (string) ($invoice['id'] ?? '');
[$start, $end] = $this->period($invoice);
// A contract that has ended stays ended (mutateInOrder refuses one), so
// a final invoice arriving after the deletion cannot hand a departed
// customer their service back. The payment is still entered in the
// register below; it did happen. Whether it also owes a DOCUMENT is a
// separate question with a separate answer — see owesADocument().
if ($isRenewal && $start !== null && $end !== null) {
$this->mutateInOrder($subscription, $eventAt, self::RANK_PAID, fn (Subscription $fresh) => $end
// Never backwards: a renewal delayed behind the next one would
// otherwise shorten the term the customer has already paid for.
->greaterThanOrEqualTo($fresh->current_period_end)
// Period boundaries are not part of the frozen snapshot:
// what the customer is owed does not change, only which
// month it is.
? [
'current_period_start' => $start,
'current_period_end' => $end,
'status' => 'active',
'stripe_status' => 'active',
]
: null);
}
// One invoice, one entry — enforced by the unique index rather than by
// a check, because Stripe can deliver the same invoice twice at once
// and both deliveries would pass a check.
$event = $isRenewal ? SubscriptionRecord::EVENT_RENEWAL : SubscriptionRecord::EVENT_INVOICE_PAID;
$record = $this->recordOnce(
fn () => ($this->record)(
event: $event,
subscription: $subscription->refresh(),
// The whole term, modules included. A cycle invoice bills the
// package item AND one item per booked module, so what Stripe
// took is the gross of all of them — held against `price_cents`
// alone, every renewal on a contract with a single module was
// filed as charged at the wrong amount. Nothing is agreed for a
// proration or a hand-raised charge: what those are worth is
// whatever Stripe worked out, and it arrives as the charged
// figure below.
netCents: $isRenewal ? $subscription->termNetCents() : 0,
stripe: [
'invoice' => $invoiceId ?: null,
'subscription' => $subscription->stripe_subscription_id,
],
// The exact kind, kept so a proration or a manual charge stays
// distinguishable from an ordinary renewal.
extra: ['billing_reason' => $reason ?: null],
// Stripe's total is what was actually taken, tax and all. Ours
// is what was agreed; the register states both.
chargedGrossCents: isset($invoice['amount_paid']) ? (int) $invoice['amount_paid'] : null,
eventKey: $invoiceId !== '' ? "{$event}:{$invoiceId}" : null,
)
);
if ($this->owesADocument($subscription->refresh(), $invoice, $start)) {
$this->invoicePayment($subscription, $invoice, $start, $end);
}
return $record;
}
/**
* Whether this charge is one the customer is still owed a document for.
*
* Nothing in this application could cancel a Stripe subscription, so a
* customer who cancelled and a customer who withdrew both stayed subscribed
* and every month Stripe took another payment, `invoice.paid` arrived here,
* and a real invoice was drawn out of the gapless Austrian series and mailed,
* for a service that no longer existed. Cancelling the subscription is the
* first half of the fix; this is the second, because a subscription somebody
* failed to cancel, or one cancelled by hand in Stripe's dashboard, must not
* be able to mint numbers here either.
*
* ## The rule, and why it is not simply "refuse a cancelled contract"
*
* A perfectly legitimate FINAL invoice can arrive after the contract has
* ended, and refusing every one of them would lose a document the customer is
* legally owed. The commonest case: the cycle invoice for the term they were
* in went unpaid, Stripe's dunning ran for three weeks, the contract ended in
* the middle of it, and then the customer paid. That money is for a term they
* really did have a running cloud in.
*
* So the question is not WHEN the payment landed Stripe's dunning makes that
* arbitrary but WHAT it is for:
*
* the period this invoice bills STARTED before the contract ended
* a term the customer was actually in. Document it.
* the period begins at or after the moment the contract ended
* a term that does not exist. No document, no number, and an error in
* the log, because a charge for a service that has ended means Stripe
* was never stopped and somebody has to send that money back.
*
* The boundary case a period starting exactly at `cancelled_at` is the
* first month the customer does NOT have, so it is refused.
*
* A contract marked cancelled with no date on it, or an invoice carrying no
* period, cannot be judged by that rule at all, and both are refused. The
* asymmetry is deliberate and it is the same one IssueInvoice is built on: a
* missing document is recoverable an operator issues it from the console
* while a number handed out in error can never be withdrawn, only corrected by
* a cancellation document plus a new one.
*
* The register entry is written either way, above. The money moved; refusing
* to record that would hide it, and the register is where an operator finds
* the payments that need sending back.
*
* @param array<string, mixed> $invoice
*/
private function owesADocument(Subscription $subscription, array $invoice, ?Carbon $periodStart): bool
{
if (! $subscription->hasEnded()) {
return true;
}
$endedAt = $subscription->cancelled_at;
if ($endedAt !== null && $periodStart !== null && $periodStart->lessThan($endedAt)) {
return true;
}
Log::error('Stripe charged for a contract that has ended, so no invoice was issued and no number was used. This money has to go back.', [
'subscription' => $subscription->uuid,
'stripe_subscription' => $subscription->stripe_subscription_id,
'stripe_invoice' => $invoice['id'] ?? null,
'billing_reason' => $invoice['billing_reason'] ?? null,
'amount_paid' => $invoice['amount_paid'] ?? null,
'contract_ended_at' => $endedAt?->toIso8601String(),
'billed_period_start' => $periodStart?->toIso8601String(),
]);
return false;
}
/**
* The document this payment owes the customer, and the one mail that carries
* it.
*
* **Every paid invoice, not only a renewal.** The rule is one document per
* charge, so a proration, a module booked in the middle of a term and a
* charge raised by hand in Stripe's dashboard each get one built from
* Stripe's own lines, which is what actually took the money. The single
* exception is the checkout's own invoice, which returns before this line:
* that purchase already has a document, and a second would put two invoices
* on one sale.
*
* Outside recordOnce() on purpose. The register entry and the invoice are
* two different obligations, and tying the second to the first succeeding
* would mean that a delivery which recorded the payment but could not issue
* the document an incomplete company profile, a mail server that was down
* could never be finished: every retry would find the register entry
* already there, return, and the customer would never get an invoice. The
* issuing is idempotent on its own, keyed on the Stripe invoice id.
*
* Caught here as well as inside the action. The action guards what it does;
* this guards the rest, because the rule is that no paperwork of ours may
* ever cost Stripe a 2xx a retried webhook is a second attempt at
* everything, and one of those things moves the customer's term.
*
* @param array<string, mixed> $invoice
*/
private function invoicePayment(Subscription $subscription, array $invoice, ?Carbon $start, ?Carbon $end): void
{
try {
($this->issueStripeInvoice)($subscription->refresh(), $invoice, $start, $end);
} catch (Throwable $e) {
Log::error('Failed to invoice a Stripe payment that went through.', [
'subscription' => $subscription->uuid,
'stripe_invoice' => $invoice['id'] ?? null,
'error' => $e->getMessage(),
]);
}
}
/**
* A renewal failed. Recorded, and nothing else: Stripe runs the dunning
* schedule, and cutting a customer off on the first failed attempt would
* punish an expired card as though it were a refusal to pay.
*
* @param array<string, mixed> $invoice
*/
public function invoicePaymentFailed(array $invoice, ?Carbon $eventAt = null): ?SubscriptionRecord
{
$subscription = $this->resolve($invoice['subscription'] ?? null);
if ($subscription === null) {
return null;
}
$invoiceId = (string) ($invoice['id'] ?? '');
// Only if this is the newest word we have. A failed attempt delayed
// behind the successful retry would otherwise flip a customer who has
// since paid back to past_due.
$this->mutateInOrder($subscription, $eventAt, self::RANK_FAILED, fn () => ['stripe_status' => 'past_due']);
return $this->recordOnce(
fn () => ($this->record)(
event: SubscriptionRecord::EVENT_PAYMENT_FAILED,
subscription: $subscription->refresh(),
netCents: 0,
stripe: [
'invoice' => $invoiceId ?: null,
'subscription' => $subscription->stripe_subscription_id,
],
extra: ['attempt' => $invoice['attempt_count'] ?? null],
chargedGrossCents: 0,
// Per attempt, not per invoice: Stripe retries a failing
// payment on a schedule, and each attempt is its own event.
eventKey: $invoiceId !== ''
? 'payment_failed:'.$invoiceId.':'.($invoice['attempt_count'] ?? 0)
: null,
)
);
}
/**
* Stripe's own status changed. Theirs is the authority on whether the money
* is arriving; we copy it, and leave what the customer may USE to our own
* status, which only a cancellation or an operator changes.
*
* @param array<string, mixed> $object
*/
public function subscriptionUpdated(array $object, ?Carbon $eventAt = null): ?Subscription
{
$subscription = $this->resolve($object['id'] ?? null);
if ($subscription === null) {
return null;
}
[$start, $end] = $this->period($object);
$this->rememberItem($subscription, $object);
// Ended is ended, whatever order events arrive in — and an older
// snapshot must never overwrite a newer one it was delivered behind.
$this->mutateInOrder($subscription, $eventAt, self::RANK_UPDATED, fn () => array_filter([
'stripe_status' => $object['status'] ?? null,
'current_period_start' => $start,
'current_period_end' => $end,
], fn ($value) => $value !== null));
return $subscription;
}
/**
* Keep the id of the item this subscription bills through.
*
* Stripe names it only on the subscription object, and moving a contract
* onto another price is impossible without it the price hangs off the
* item, not off the subscription. Learnt here because this event arrives on
* its own for every status change and every renewal, so the ordinary running
* of a contract fills it in without anyone having to ask Stripe.
*
* Written outside the ordering guard on purpose: this is not part of the
* running picture. An item id does not go stale, does not change when the
* price on it does, and a stale event carries exactly the same one.
*
* @param array<string, mixed> $object
*/
private function rememberItem(Subscription $subscription, array $object): void
{
if ($subscription->stripe_item_id !== null) {
return;
}
$itemId = $object['items']['data'][0]['id'] ?? null;
// One item, because one contract bills one package. A subscription with
// several would need a rule for which of them the package is, and there
// is nothing here to build one from — better to have none than to swap
// the price on whichever happened to be first.
if (is_string($itemId) && $itemId !== '' && count($object['items']['data'] ?? []) === 1) {
$subscription->update(['stripe_item_id' => $itemId]);
}
}
/**
* The subscription has ended at Stripe. That is the end of the contract,
* so it goes in the register a cancellation nobody recorded is exactly
* the kind of gap the register exists to prevent.
*
* @param array<string, mixed> $object
*/
public function subscriptionDeleted(array $object, ?Carbon $eventAt = null): ?SubscriptionRecord
{
$subscription = $this->resolve($object['id'] ?? null);
if ($subscription === null) {
return null;
}
$endedAt = isset($object['ended_at'])
? Carbon::createFromTimestamp((int) $object['ended_at'])
: now();
// The ending and its entry in the register commit together. Marking the
// contract cancelled first and failing before the record would leave a
// retry seeing "already cancelled" and returning — and the end of a
// contract would never be entered at all.
return $this->recordOnce(fn () => DB::transaction(function () use ($subscription, $object, $endedAt, $eventAt) {
// Held while we decide, so a renewal arriving at the same moment
// cannot reactivate the contract between this and the write.
$subscription = Subscription::query()->whereKey($subscription->getKey())->lockForUpdate()->firstOrFail();
// No ordering guard: an ending is final, so a late delivery of it
// is still correct. Only the running picture can go stale.
//
// A booked plan change goes with the contract it was booked on. It
// could never be carried out — clupilot:apply-due-plan-changes only
// touches active contracts — but leaving the stamp behind would have
// the portal and the console go on announcing that a package which
// no longer exists is about to shrink.
$subscription->update([
'status' => 'cancelled',
'stripe_status' => $object['status'] ?? 'canceled',
'cancelled_at' => $endedAt,
'stripe_event_at' => $eventAt,
'pending_plan' => null,
'pending_effective_at' => null,
]);
return ($this->record)(
event: SubscriptionRecord::EVENT_CANCELLATION,
subscription: $subscription->refresh(),
netCents: 0,
at: $endedAt,
stripe: ['subscription' => $subscription->stripe_subscription_id],
chargedGrossCents: 0,
// A contract ends once. Two deliveries arriving together would
// both pass a status check; only one can take this key.
eventKey: 'cancellation:'.$subscription->id,
);
}));
}
/**
* Whether this event is the newest word we have about the contract.
*
* Stripe does not guarantee delivery order, and the state here is a
* running picture rather than a log: applying a stale snapshot on top of a
* fresher one is how a paid-up customer ends up looking overdue. The
* register is unaffected each entry is keyed by what it is about, so a
* late arrival still lands once, in its proper place.
*/
/**
* Decide and write in one step, with the contract's row held.
*
* The ordering guard is only worth as much as its atomicity: two
* deliveries for the same contract can both read a stale copy, both pass
* the check, and the loser then overwrites the winner a renewal
* reactivating a contract a deletion had just ended, or an old failure
* burying a payment that went through. So the row is re-read under a lock
* and re-judged inside the transaction that writes it.
*
* `$changes` returns the columns to set, or null to decline.
*
* @param callable(Subscription): ?array<string, mixed> $changes
*/
private function mutateInOrder(Subscription $subscription, ?Carbon $eventAt, int $rank, callable $changes): void
{
DB::transaction(function () use ($subscription, $eventAt, $rank, $changes) {
$fresh = Subscription::query()->whereKey($subscription->getKey())->lockForUpdate()->first();
if ($fresh === null
|| $fresh->status === 'cancelled'
|| ! $this->appliesInOrder($fresh, $eventAt, $rank)) {
return;
}
$values = $changes($fresh);
if ($values === null) {
return;
}
if ($eventAt !== null) {
$values['stripe_event_at'] = $eventAt;
$values['stripe_event_rank'] = $rank;
}
$fresh->update($values);
});
$subscription->refresh();
}
private function appliesInOrder(Subscription $subscription, ?Carbon $eventAt, int $rank): bool
{
if ($eventAt === null || $subscription->stripe_event_at === null) {
return true; // nothing to compare against
}
if ($eventAt->greaterThan($subscription->stripe_event_at)) {
return true;
}
if ($eventAt->lessThan($subscription->stripe_event_at)) {
return false;
}
// Same second, which Stripe's one-second timestamps make common enough
// to matter: a failed attempt and the retry that succeeded can share
// one. Decide by what the event says about the money.
return $rank >= (int) ($subscription->stripe_event_rank ?? 0);
}
/**
* Write a register entry, unless one already exists for this invoice and
* event.
*
* The uniqueness lives in the database, on (stripe_invoice_id, event).
* Checking first and inserting afterwards leaves a window that Stripe
* delivering the same invoice twice at once walks straight through and a
* register that counts a payment twice is worse than one that is late.
*
* @param callable(): SubscriptionRecord $write
*/
private function recordOnce(callable $write): ?SubscriptionRecord
{
try {
return $write();
} catch (UniqueConstraintViolationException) {
return null; // already recorded by a concurrent delivery
}
}
/**
* Hold an event whose contract does not exist yet, so it is not lost.
*
* @param array<string, mixed> $event
*/
public function hold(array $event): void
{
$subscriptionId = $event['data']['object']['subscription']
?? $event['data']['object']['id']
?? null;
if (! is_string($subscriptionId) || ! is_string($event['id'] ?? null)) {
return;
}
// Only what we could not match. A handler returns null for several
// reasons — already recorded, or deliberately skipped, like every
// checkout's own invoice — and holding those would fill the table with
// rows that replayHeldFor() can never revisit, because their contract
// is right there. Nothing to wait for means nothing to hold.
if (Subscription::query()->where('stripe_subscription_id', $subscriptionId)->exists()) {
// Unless it appeared just now: the contract can be created between
// the handler missing it and this check, and the replay that would
// have collected it has then already been and gone. Apply it here
// instead of dropping it. Safe to repeat — the handlers are
// idempotent, so an event that was a no-op stays one.
$this->dispatch($event);
return;
}
StripePendingEvent::query()->updateOrCreate(
['stripe_event_id' => $event['id']],
[
'stripe_subscription_id' => $subscriptionId,
'type' => (string) ($event['type'] ?? ''),
'raised_at' => isset($event['created']) && is_numeric($event['created'])
? Carbon::createFromTimestamp((int) $event['created'])
: null,
'payload' => $event,
],
);
}
/**
* Replay everything held for a contract that has just appeared.
*
* In their original order, so the ordering guard sees them as Stripe raised
* them rather than as they happened to be stored.
*/
public function replayHeldFor(Subscription $subscription): int
{
if ($subscription->stripe_subscription_id === null) {
return 0;
}
$held = StripePendingEvent::query()
->where('stripe_subscription_id', $subscription->stripe_subscription_id)
->orderBy('raised_at')
->orderBy('id')
->get();
foreach ($held as $pending) {
$this->dispatch($pending->payload ?? []);
$pending->delete();
}
return $held->count();
}
/**
* Route one event to its handler. Returns false for a type we do not
* handle, so the caller can tell "not ours" from "nothing to do".
*
* @param array<string, mixed> $event
*/
public function dispatch(array $event): mixed
{
$object = $event['data']['object'] ?? [];
$raisedAt = isset($event['created']) && is_numeric($event['created'])
? Carbon::createFromTimestamp((int) $event['created'])
: null;
return match ($event['type'] ?? '') {
'invoice.paid' => $this->invoicePaid($object, $raisedAt),
'invoice.payment_failed' => $this->invoicePaymentFailed($object, $raisedAt),
'customer.subscription.updated' => $this->subscriptionUpdated($object, $raisedAt),
'customer.subscription.deleted' => $this->subscriptionDeleted($object, $raisedAt),
default => false,
};
}
/**
* Find the contract a Stripe event is about.
*
* An unknown id is logged and dropped rather than raised: Stripe delivers
* events for objects created by hand in the dashboard, and by other
* environments pointed at the same endpoint. Failing the webhook for those
* would have Stripe retry something that can never succeed.
*/
private function resolve(mixed $stripeSubscriptionId): ?Subscription
{
$id = is_string($stripeSubscriptionId) ? $stripeSubscriptionId : null;
if ($id === null) {
return null;
}
return Subscription::query()->where('stripe_subscription_id', $id)->first();
}
/**
* @param array<string, mixed> $object
* @return array{0: ?Carbon, 1: ?Carbon}
*/
private function period(array $object): array
{
$start = $object['period_start'] ?? $object['current_period_start'] ?? null;
$end = $object['period_end'] ?? $object['current_period_end'] ?? null;
return [
is_numeric($start) ? Carbon::createFromTimestamp((int) $start) : null,
is_numeric($end) ? Carbon::createFromTimestamp((int) $end) : null,
];
}
}

View File

@ -1,362 +0,0 @@
<?php
namespace App\Actions;
use App\Models\Order;
use App\Models\Subscription;
use App\Models\SubscriptionAddon;
use App\Models\SubscriptionRecord;
use App\Services\Billing\AddonCatalogue;
use App\Services\Billing\CustomDomainAccess;
use Illuminate\Database\UniqueConstraintViolationException;
use Illuminate\Support\Facades\DB;
use RuntimeException;
/**
* Books a module onto a contract at today's price, and freezes it there.
*
* Booking is the moment the module's price stops moving for this customer, for
* exactly the reason the plan's price does: they agreed to a figure. What they
* have NOT booked stays on the live catalogue that is a sale still to be
* made, at whatever it costs now.
*
* It is also the one place that decides a module may not be booked at all. Two
* rules live here rather than in the pages that call it, because a rule enforced
* in markup is not enforced: not every package may have an own domain, and not
* every module may be held twice. Both fail closed, with the sentence the
* customer would be shown rather than a developer's, so whatever refuses
* the portal, the console's grant screen, a webhook says the same thing.
*
* It is also where a booking stops being only a row. Storage packs are the one
* module that changes what the machine has to be, and they were sold for months
* without anything anywhere acting on them so booking or cancelling one asks
* ApplyStorageAllowance for the run that makes the disk, the guest's filesystem
* and Nextcloud's quota match what the customer now pays for. Every route into a
* booking goes through here the shop, a webhook, an operator's grant which
* is why the delivery hangs off this action and not off any one of them.
*
* `$overrides` exists for GrantAddon: a granted module is booked through this
* same action, with its price replaced by what the customer actually pays and
* its provenance stamped on the row. Empty for every ordinary booking. A grant
* is a booking like any other, so it is refused on the same terms giving away
* a module the customer is already paying for would bill them for both.
*
* It is also where a booking becomes MONEY, every month, and not only in the
* month it was made. A module is an item on the Stripe subscription, so booking
* one adds it and cancelling one takes it off see
* App\Actions\SyncStripeAddonItems, which is asked to bring the two into step
* after every change here. Like the storage delivery beside it, it never fails a
* booking: what it could not tell Stripe is parked on the contract and retried.
*/
class BookAddon
{
public function __construct(private RecordCommercialEvent $record) {}
/** @param array<string, mixed> $overrides */
public function __invoke(Subscription $subscription, string $addonKey, int $quantity = 1, ?Order $order = null, array $overrides = []): SubscriptionAddon
{
$price = app(AddonCatalogue::class)->priceCents($addonKey);
if ($price === null) {
throw new RuntimeException("Unknown add-on: {$addonKey}");
}
if ($quantity < 1) {
throw new RuntimeException('An add-on is booked at least once.');
}
// Not every module is for sale to everyone. The own domain is possible
// on some packages, included in others and impossible on the smallest,
// and the portal already leaves it out where it cannot be booked — but
// a hidden button is markup, and a rule enforced only in markup is not
// enforced. A stale tab, a second window or anything that can name this
// action arrives here too, so it fails closed, with the sentence the
// customer would be shown rather than a developer's.
$refusal = $addonKey === CustomDomainAccess::ADDON
? app(CustomDomainAccess::class)->bookingRefusal($subscription)
: null;
if ($refusal !== null) {
throw new RuntimeException($refusal);
}
try {
$addon = $this->book($subscription, $addonKey, $quantity, $order, $price, $overrides);
} catch (UniqueConstraintViolationException) {
// A concurrent retry won. The unique index on (order_id, addon_key)
// is what actually enforces "one order books one module" — the
// lookup below is only the fast path, and two transactions can both
// pass it.
$addon = SubscriptionAddon::query()
->where('order_id', $order?->id)
->where('addon_key', $addonKey)
->firstOrFail();
}
$this->deliverStorage($subscription, $addonKey);
$this->billThroughStripe($subscription);
return $addon;
}
/** @param array<string, mixed> $overrides */
private function book(Subscription $subscription, string $addonKey, int $quantity, ?Order $order, int $price, array $overrides = []): SubscriptionAddon
{
// The booking and its entry in the register commit together: if the
// record could fail afterwards, retrying the same order would find the
// add-on already there and skip the event for good.
return DB::transaction(function () use ($subscription, $addonKey, $quantity, $order, $price, $overrides) {
$catalogue = app(AddonCatalogue::class);
// Held while we look and write, for the same reason the shop holds
// the customer row: two clicks in flight would otherwise both find
// nothing booked and both book, which is precisely the double
// charge the check below exists to prevent. Only for a module of
// which there may be one — a second storage pack is meant to
// succeed, and serialising those would buy nothing.
if ($catalogue->isEntitlement($addonKey)) {
Subscription::query()->whereKey($subscription->getKey())->lockForUpdate()->first();
}
// Idempotent against a retried webhook: one order books one module.
if ($order !== null) {
$existing = SubscriptionAddon::query()
->where('order_id', $order->id)
->where('addon_key', $addonKey)
->first();
if ($existing !== null) {
return $existing;
}
}
// Asked AFTER the retry above, so a webhook delivered twice still
// gets its one booking back rather than an error: that is the same
// order arriving again, not a second purchase. What is refused here
// is a SECOND order for a module the contract already carries — a
// stale tab, a double click, an operator granting something the
// customer has bought. The unique index on (order_id, addon_key)
// never saw those: two orders are two different rows.
$refusal = $catalogue->duplicateRefusal($subscription, $addonKey);
if ($refusal !== null) {
throw new RuntimeException($refusal);
}
$addon = SubscriptionAddon::create(array_merge([
'subscription_id' => $subscription->id,
'order_id' => $order?->id,
'addon_key' => $addonKey,
'price_cents' => $price,
'currency' => Subscription::catalogueCurrency(),
'quantity' => $quantity,
'booked_at' => now(),
], $overrides));
($this->record)(
event: SubscriptionRecord::EVENT_ADDON_BOOKED,
subscription: $subscription,
netCents: $addon->monthlyCents(),
extra: ['addon' => ['key' => $addonKey, 'quantity' => $quantity, 'price_cents' => $addon->price_cents]],
order: $order,
stripe: ['event' => $order?->stripe_event_id],
// What was actually charged for the module, on the same terms
// as a plan purchase: a discount or a free booking has to be
// reconcilable, not reconstructed from the catalogue.
chargedGrossCents: $order?->stripe_event_id !== null ? (int) $order->amount_cents : null,
);
return $addon;
});
}
/**
* Book the end of a module for the end of the term it is paid up to.
*
* The owner's rule for a module is the same as for a package: what has been
* paid for is kept. A customer cancelling on the tenth keeps the module
* until the cycle turns and gets no credit for the days they did not use
* so the row stays active, `cancels_at` holds the appointment, and
* clupilot:end-cancelled-addons keeps it.
*
* Stripe is told at once all the same. Its item is only ever about what the
* NEXT cycle bills the term in progress was paid in advance and taking
* the item off with no proration moves no money while making sure the next
* invoice has no such line. Waiting for the boundary instead would be a race
* against Stripe raising that very invoice; see SyncStripeAddonItems.
*
* Ends the module outright when there is no term to wait for a contract
* with no period end, or one that is already past. There is nothing to keep.
*/
public function cancelAtPeriodEnd(SubscriptionAddon $addon): SubscriptionAddon
{
$subscription = $addon->subscription;
$endsOn = $subscription?->current_period_end;
if ($endsOn === null || $endsOn->isPast()) {
return $this->cancel($addon);
}
// Claimed conditionally, like the cancellation below: two requests
// arriving together would otherwise both write an appointment, and the
// second would move a date the customer had already been told.
$claimed = SubscriptionAddon::query()
->whereKey($addon->getKey())
->whereNull('cancelled_at')
->whereNull('cancels_at')
->update(['cancels_at' => $endsOn, 'updated_at' => now()]);
$addon->refresh();
if ($claimed === 0) {
return $addon;
}
$this->billThroughStripe($subscription);
return $addon;
}
/**
* Take back a cancellation that has not landed yet.
*
* The customer changed their mind again, which is allowed right up to the
* moment the appointment is kept: until then the module is still running and
* still theirs, and nothing has been undone that would have to be rebuilt.
* After it `cancelled_at` set this refuses, because the module has
* genuinely stopped and putting it back is a new booking at today's price.
*
* Claimed conditionally like the cancellation it undoes, so two clicks write
* one answer between them.
*
* **Nothing is charged for it.** The term this module was cancelled for was
* paid for in advance and the cancellation raised no credit that is the
* owner's rule so putting the item back must not raise a charge either.
* Left to its own devices the reconciler would add it with
* `always_invoice`, which is right for a module being bought in the middle
* of a term and wrong here: the customer would be billed a second time for
* days they have already paid for. So the reconciler is told, in the one
* place where the difference is known, that this addition is a restoration
* and not a sale.
*/
public function undoCancelAtPeriodEnd(SubscriptionAddon $addon): SubscriptionAddon
{
$claimed = SubscriptionAddon::query()
->whereKey($addon->getKey())
->whereNull('cancelled_at')
->whereNotNull('cancels_at')
->update(['cancels_at' => null, 'updated_at' => now()]);
$addon->refresh();
if ($claimed === 0) {
return $addon;
}
$subscription = $addon->subscription;
if ($subscription !== null) {
app(SyncStripeAddonItems::class)($subscription->refresh(), chargeForAdditions: false);
}
return $addon;
}
/**
* Stop charging for a module now, without losing what it cost.
*
* Cancelled, not deleted: what a customer was paying, and until when, is
* part of the same record as what they bought.
*
* This is the immediate end, and it is not what a customer's own
* cancellation does see cancelAtPeriodEnd() above. It is what happens when
* a module stops being POSSIBLE rather than wanted: a downgrade onto a
* package that cannot have an own domain takes the module with it, and
* leaving the customer billed for a feature that has already gone is a
* complaint we would deserve. It is also the appointment being kept, once
* cancelAtPeriodEnd()'s date has passed.
*/
public function cancel(SubscriptionAddon $addon): SubscriptionAddon
{
$cancelled = DB::transaction(function () use ($addon) {
// Claim the cancellation conditionally, so two requests arriving
// together write one event between them. Checking `isActive()` on
// separate instances and updating afterwards lets both through, and
// the register would show a module cancelled twice.
$claimed = SubscriptionAddon::query()
->whereKey($addon->getKey())
->whereNull('cancelled_at')
->update(['cancelled_at' => now(), 'updated_at' => now()]);
if ($claimed === 0) {
return $addon->refresh();
}
$addon->refresh();
($this->record)(
event: SubscriptionRecord::EVENT_ADDON_CANCELLED,
subscription: $addon->subscription,
netCents: -$addon->monthlyCents(),
extra: ['addon' => ['key' => $addon->addon_key, 'quantity' => $addon->quantity]],
order: $addon->order,
);
return $addon;
});
$this->deliverStorage($cancelled->subscription, (string) $cancelled->addon_key);
$this->billThroughStripe($cancelled->subscription);
return $cancelled;
}
/**
* Make a change to the storage packs real on the machine.
*
* Storage is the one module whose booking changes what the machine has to
* BE: a pack is a hundred gigabytes the disk, the guest's filesystem and
* Nextcloud's quota all have to grow to, and cancelling one is the same
* sentence read backwards. Every other module is a licence, a support tier
* or a flag nothing on the machine moves when one is booked.
*
* Deliberately AFTER the transaction has committed, never inside it. The run
* this starts is picked up by a queue worker in another process, and a
* worker that arrived first would read a contract whose booking had not been
* written yet and would then deliver the allowance as it was a moment ago.
*
* Nothing here fails a booking. ApplyStorageAllowance returns null for every
* ordinary "not now" no machine, one still being built, a run already in
* flight and that run applies the same allowance when it gets there.
*/
private function deliverStorage(?Subscription $subscription, string $addonKey): void
{
if ($addonKey !== AddonCatalogue::STORAGE) {
return;
}
app(ApplyStorageAllowance::class)($subscription);
}
/**
* Put what the contract now holds in front of Stripe.
*
* After the transaction for the same reason the storage delivery is: the
* reconciler reads the bookings back out of the database, and one running
* inside the transaction that wrote them would settle on the state as it was
* a moment ago.
*
* Nothing here fails a booking either. The module has been booked and, if it
* was storage, already delivered to the machine; Stripe being unreachable
* parks the difference on the contract and clupilot:sync-stripe-subscriptions
* finishes it.
*/
private function billThroughStripe(?Subscription $subscription): void
{
if ($subscription === null) {
return;
}
app(SyncStripeAddonItems::class)($subscription->refresh());
}
}

View File

@ -1,136 +0,0 @@
<?php
namespace App\Actions;
use App\Models\Instance;
use App\Services\Dns\HetznerDnsClient;
use App\Services\Traefik\TraefikWriter;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Log;
/**
* Take an instance's address down when its service has genuinely ended.
*
* `TraefikWriter::remove()` had no caller anywhere in the application. Every
* router this platform ever wrote was written for good: the file stayed on the
* serving host, the hostname kept resolving, and it kept being routed to a guest
* address that the host is free to hand to somebody else's VM later. That is not
* a tidiness problem it is one customer's hostname pointing at another
* customer's Nextcloud, with a valid certificate on it.
*
* ## What "ended" means here
*
* Exactly one thing: the instance is `cancellation_scheduled` AND its
* `service_ends_at` has passed. Not the moment somebody cancels a customer who
* schedules a cancellation on the second of the month has paid to the end of the
* month and must keep working through it, and pulling their address that
* afternoon would be a serious fault, worse than the leak this closes. Not a
* `failed` build either: those never got as far as a router, and the ones that
* did are being retried from the console. A `cancellation_scheduled` row without
* a `service_ends_at` at all is left alone on purpose that is a broken record
* rather than an expired one, and guessing an end date for it would invent the
* very moment this action exists to respect.
*
* ## The DNS record goes with the route
*
* Deliberately, and for the same reason the route does. The A record for
* `{subdomain}.{zone}` is in OUR zone and points at the serving host's public
* address a host that goes on serving other customers. Left published it is a
* name we own, resolving to a live machine, with nothing behind it that belongs
* to the person it is named after: the shape of every subdomain-takeover there
* has ever been. It also keeps the customer's old address looking alive, and it
* would collide the day the same subdomain is issued again. Removing the route
* without the record would leave the worse half standing.
*
* The CUSTOM domain is the opposite case and nothing is done to it. Its A record
* lives in the customer's own zone, which we have never had access to and never
* will; it is theirs, it stops reaching us the moment the router goes, and it is
* for them to point somewhere else.
*
* The virtual machine is NOT touched. The founder's cancellation flow promises a
* finished data export before deprovisioning, and destroying somebody's disks is
* not a decision this action gets to make on the way past. It takes the address
* away which is what was leaking and leaves the machine for whatever carries
* out that flow.
*/
class EndInstanceService
{
public function __construct(
private TraefikWriter $traefik,
private HetznerDnsClient $dns,
) {}
/**
* Has this instance's paid service actually run out?
*
* The rule lives here rather than in the command's query so that anything
* else that ever ends an instance asks the same question a second copy of
* "is it over yet" is how one caller ends up a month out from the other.
*/
public function hasEnded(Instance $instance, ?Carbon $at = null): bool
{
return $instance->status === 'cancellation_scheduled'
&& $instance->service_ends_at !== null
&& $instance->service_ends_at->lessThanOrEqualTo($at ?? now());
}
/**
* Tear the address down and mark the instance ended.
*
* Re-entrant: removing a router file that is not there is `rm -f`, a record
* id that has already been deleted is deleted again for nothing, and an
* instance already marked `ended` is returned false straight away. A DNS
* failure is deliberately not swallowed the record id survives on its row,
* so a retry can finish the job, and a provider that is permanently broken
* becomes a visible failure rather than a silent leak.
*
* @return bool whether this call was the one that ended it
*/
public function __invoke(Instance $instance): bool
{
if (! $this->hasEnded($instance)) {
return false;
}
$host = $instance->host;
if ($host !== null) {
// The host that actually serves the traffic — DNS points at it, and
// it is where ConfigureDnsAndTls wrote the file in the first place.
$this->traefik->remove($host->wg_ip ?? $host->public_ip, $instance->subdomain);
} else {
// No host left to reach: the router file went with the machine it
// lived on. Said out loud rather than passed over, because it means
// this teardown is only doing half of what it usually does.
Log::warning('Ending an instance whose host is gone — no router to remove.', [
'instance' => $instance->uuid,
]);
}
foreach ($instance->dnsRecords()->get() as $record) {
$this->dns->deleteRecord($record->record_id);
$record->delete();
}
$instance->update([
'status' => 'ended',
// The address state has to go with the address. Left as it was, a
// later run would read `route_written` plus a matching hostname list
// and decide there was nothing to write — so an instance that was
// ever revived would be announced and routed nowhere.
'route_written' => false,
'routed_hostnames' => null,
'routed_backend' => null,
'cert_ok' => false,
'domain_cert_ok' => false,
]);
Log::info('Instance service ended; address withdrawn.', [
'instance' => $instance->uuid,
'subdomain' => $instance->subdomain,
'service_ended_at' => $instance->service_ends_at?->toIso8601String(),
]);
return true;
}
}

View File

@ -1,81 +0,0 @@
<?php
namespace App\Actions\Fortify;
use App\Models\Customer;
use App\Models\Operator;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
use Laravel\Fortify\Contracts\CreatesNewUsers;
class CreateNewUser implements CreatesNewUsers
{
use PasswordValidationRules;
/**
* Validate and create a newly registered user.
*
* @param array<string, string> $input
*
* @throws ValidationException
*/
public function create(array $input): User
{
Validator::make($input, [
'name' => ['required', 'string', 'max:255'],
'email' => [
'required',
'string',
'email',
'max:255',
Rule::unique(User::class),
// Never let public signup claim an existing (possibly not-yet-
// provisioned) customer's email — ensureUser() would later link
// that account and hand over the customer's portal.
Rule::unique(Customer::class, 'email'),
// Nor an operator's — R21: one address must not represent both
// identities. No dedicated message: Laravel's stock "already
// taken" line (translated in both languages already) is what
// the two rules above fall back to as well, and telling a
// public visitor specifically that an address belongs to
// STAFF would be a worse leak than the generic refusal every
// other collision here already gives them.
Rule::unique(Operator::class, 'email'),
],
// Asked, never inferred. Everything downstream turns on it — the
// fourteen-day right of withdrawal, the wording of the invoice,
// whether reverse charge can apply — and a VAT number cannot stand
// in for it: plenty of businesses have none, and no consumer has
// one. Required, because the alternative is a field that is empty
// for most customers and therefore answers nothing.
'customer_type' => ['required', Rule::in(Customer::TYPES)],
'password' => $this->passwordRules(),
])->validate();
// Create the user and its linked customer atomically — a public signup is
// a customer, and the portal (Billing::purchase, Settings, …) needs one.
// Either both exist or neither, so a failure never orphans a user.
return DB::transaction(function () use ($input) {
$user = User::create([
'name' => $input['name'],
'email' => $input['email'],
'password' => Hash::make($input['password']),
]);
Customer::query()->create([
'user_id' => $user->id,
'name' => $input['name'],
'email' => $input['email'],
'customer_type' => $input['customer_type'],
'locale' => app()->getLocale(),
'status' => 'active',
]);
return $user;
});
}
}

View File

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

View File

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

View File

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

View File

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

View File

@ -1,77 +0,0 @@
<?php
namespace App\Actions;
use App\Models\Operator;
use App\Models\Order;
use App\Models\Subscription;
use App\Models\SubscriptionAddon;
use App\Services\Billing\AddonCatalogue;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use RuntimeException;
/**
* Gives a customer a module without a payment "ein Plugin schenken".
*
* Books through the same BookAddon a paid order uses, on a synthetic Order
* (no Stripe id, price set to what the customer actually pays) so the module
* ends up frozen on `subscription_addons` exactly like a real booking would.
*/
class GrantAddon
{
public function __construct(private BookAddon $bookAddon) {}
public function __invoke(
Subscription $subscription,
Operator $grantedBy,
string $addonKey,
int $priceCents,
int $quantity = 1,
?string $note = null,
?Carbon $until = null,
): SubscriptionAddon {
$cataloguePrice = app(AddonCatalogue::class)->priceCents($addonKey);
if ($cataloguePrice === null) {
throw new RuntimeException("Unknown add-on: {$addonKey}");
}
if ($priceCents < 0 || $priceCents > $cataloguePrice) {
throw new RuntimeException('A grant cannot charge more than the catalogue price.');
}
// Vestigial for a module — nothing is provisioned at a datacenter for
// it — but the column is not nullable, and the contract's own order
// already carries the right one.
$datacenter = $subscription->order?->datacenter ?? 'fsn';
// The order and the booking together, or neither. Booking can refuse —
// not every module is for sale on every package — and an order left
// behind on its own is a paid purchase of nothing: it shows up in the
// customer's orders and on their invoice, for a module they never got.
return DB::transaction(function () use ($subscription, $grantedBy, $addonKey, $priceCents, $quantity, $note, $until, $cataloguePrice, $datacenter) {
$order = Order::create([
'customer_id' => $subscription->customer_id,
'plan' => $subscription->plan,
'type' => 'addon',
'addon_key' => $addonKey,
'amount_cents' => $priceCents,
'currency' => Subscription::catalogueCurrency(),
'datacenter' => $datacenter,
'stripe_event_id' => null,
'stripe_subscription_id' => null,
'status' => 'paid',
]);
return ($this->bookAddon)($subscription, $addonKey, $quantity, $order, [
'price_cents' => $priceCents,
'granted_by' => $grantedBy->id,
'granted_at' => now(),
'grant_note' => $note,
'granted_until' => $until,
'catalogue_price_cents' => $cataloguePrice,
]);
});
}
}

View File

@ -1,116 +0,0 @@
<?php
namespace App\Actions;
use App\Models\Customer;
use App\Models\Operator;
use App\Models\Order;
use App\Models\ProvisioningRun;
use App\Models\Subscription;
use App\Provisioning\Jobs\AdvanceRunJob;
use App\Services\Billing\PlanCatalogue;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use RuntimeException;
/**
* Gives a customer a package without a payment.
*
* A grant is an order without money, not a second code path: this creates the
* exact Order ProvisioningRun Subscription rows a Stripe purchase does
* same pipeline, same OpenSubscription with `stripe_subscription_id` left
* null and `price_cents` set to whatever the customer actually pays (0 for a
* full gift, less than the catalogue price for a discount). Provisioning,
* DowngradeCheck, seats, quotas, cancellation: all of it reads an ordinary
* Subscription row afterwards and never learns this one was free.
*/
class GrantSubscription
{
public function __construct(private OpenSubscription $openSubscription) {}
/**
* @param array{quota_gb?:int,seats?:int,traffic_gb?:int} $bonus Beyond what the plan tier already includes.
*/
public function __invoke(
Customer $customer,
Operator $grantedBy,
string $plan,
string $term,
string $datacenter,
int $priceCents,
array $bonus = [],
?string $note = null,
?Carbon $until = null,
): Subscription {
if (! Subscription::knowsPlan($plan)) {
throw new RuntimeException("Plan '{$plan}' is not sellable, so it cannot be granted either.");
}
$version = app(PlanCatalogue::class)->currentVersion($plan);
$cataloguePrice = $version->priceFor($term);
if ($cataloguePrice === null) {
throw new RuntimeException("Plan '{$plan}' is not sold on a {$term} term.");
}
// A "grant" that charges more than the plan actually costs is not a
// grant — that path already exists, and it goes through Stripe.
if ($priceCents < 0 || $priceCents > $cataloguePrice->amount_cents) {
throw new RuntimeException('A grant cannot charge more than the catalogue price.');
}
[$run, $subscription] = DB::transaction(function () use (
$customer, $grantedBy, $plan, $term, $datacenter, $priceCents, $bonus, $note, $until, $cataloguePrice,
) {
$order = Order::create([
'customer_id' => $customer->id,
'plan' => $plan,
'type' => 'new',
'amount_cents' => $priceCents,
'currency' => Subscription::catalogueCurrency(),
'datacenter' => $datacenter,
// Never Stripe's: this purchase never went through Stripe at
// all, which is the whole point, and is also what tells
// IssueInvoice and the revenue figures this row apart from an
// ordinary (possibly also zero-priced) checkout.
'stripe_event_id' => null,
'stripe_subscription_id' => null,
'status' => 'paid',
]);
$run = ProvisioningRun::create([
'subject_type' => Order::class,
'subject_id' => $order->id,
'pipeline' => 'customer',
'status' => ProvisioningRun::STATUS_PENDING,
'current_step' => 0,
'context' => ['branding' => $customer->brandingResolved()],
]);
$overrides = array_filter([
'price_cents' => $priceCents,
'quota_gb' => $bonus['quota_gb'] ?? null,
'seats' => $bonus['seats'] ?? null,
'traffic_gb' => $bonus['traffic_gb'] ?? null,
], fn ($value) => $value !== null);
$overrides += [
'granted_by' => $grantedBy->id,
'granted_at' => now(),
'grant_note' => $note,
'granted_until' => $until,
'catalogue_price_cents' => $cataloguePrice->amount_cents,
];
$subscription = ($this->openSubscription)($order, $term, $overrides);
return [$run, $subscription];
});
// After the commit, never inside it — a worker can pick the run up
// before the transaction lands (see StartCustomerProvisioning).
AdvanceRunJob::dispatch($run->uuid);
return $subscription;
}
}

View File

@ -1,315 +0,0 @@
<?php
namespace App\Actions;
use App\Mail\InvoiceMail;
use App\Models\Invoice;
use App\Models\Subscription;
use App\Services\Billing\IssueInvoice;
use App\Services\Stripe\StripeClient;
use Illuminate\Database\UniqueConstraintViolationException;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
use Throwable;
/**
* The document a paid Stripe invoice owes the customer, and the one mail that
* carries it.
*
* **One document per charge.** That is the owner's rule and it is a legal one,
* not a preference: every amount taken from a customer needs its own invoice
* with its own number. Two months of package, a storage module booked in the
* middle of month three, and the cycle that follows are four charges and four
* documents the fourth being the one that carries package and storage
* together, because they were charged together.
*
* This used to issue for `subscription_cycle` alone, and everything else a
* proration, a module, a charge raised by hand was money received that no
* paper of ours covered. It was written down as a decision and it was the wrong
* one: Stripe charged for those, so they need documents.
*
* **Never allowed to fail the webhook.** Stripe retries anything that is not a
* 2xx, and there is nothing a retry could fix about an incomplete company
* profile or a mail server that is down it would simply arrive again, fail
* again, and eventually be given up on, taking the register entry's retry with
* it. So everything here is caught and logged. The payment is recorded whatever
* happens to the paperwork; a missing document is recoverable, a lost payment is
* not.
*
* **Idempotent against the Stripe invoice.** An invoice number comes out of a
* gapless series and can never be handed out twice, so the guard is the unique
* `invoices.stripe_invoice_id` rather than a check two deliveries arriving
* together would both pass a check. The number is drawn inside the same
* transaction the row is written in, so a collision takes the number back with
* it and the series keeps its sequence.
*
* Issuing and mailing are separate steps on purpose. `sent_at` records that the
* mail was handed to the mailer, so a delivery arriving after the document was
* written but before the mail went out finishes the job instead of skipping it
* and one that arrives afterwards does neither twice.
*/
class IssueStripeInvoice
{
/**
* @param array<string, mixed> $stripeInvoice the invoice as Stripe sent it
* @param Carbon|null $from the term this invoice billed, for the fallback below
* @return Invoice|null the document, or null when there is not one and could not be one
*/
public function __invoke(
Subscription $subscription,
array $stripeInvoice,
?Carbon $from = null,
?Carbon $to = null,
): ?Invoice {
$stripeInvoiceId = (string) ($stripeInvoice['id'] ?? '');
if ($stripeInvoiceId === '') {
// Without it there is no way to tell a redelivery from a second
// charge, and guessing wrong costs an invoice number.
Log::warning('Not issuing a document: the Stripe invoice carries no id, so issuing it could not be made idempotent.', [
'subscription' => $subscription->uuid,
]);
return null;
}
$invoice = $this->issue($subscription, $stripeInvoiceId, $stripeInvoice, $from, $to);
if ($invoice === null || $invoice->sent_at !== null) {
return $invoice;
}
$this->send($subscription, $invoice);
return $invoice;
}
/** @param array<string, mixed> $stripeInvoice */
private function issue(
Subscription $subscription,
string $stripeInvoiceId,
array $stripeInvoice,
?Carbon $from,
?Carbon $to,
): ?Invoice {
$existing = Invoice::query()->where('stripe_invoice_id', $stripeInvoiceId)->first();
if ($existing !== null) {
return $existing;
}
try {
$lines = $this->lines($stripeInvoiceId, $stripeInvoice);
} catch (Throwable $e) {
// Deliberately no document at all rather than a partial one. The
// total would still be right and the reader would have no way to see
// what they had paid for, which is the kind of invoice that gets
// rejected at an audit. A later redelivery or a replay can still
// issue it.
Log::error('Could not read the lines of a paid Stripe invoice, so no document was issued for it.', [
'subscription' => $subscription->uuid,
'stripe_invoice' => $stripeInvoiceId,
'exception' => $e->getMessage(),
]);
return null;
}
try {
if ($lines === []) {
return $this->fromContract($subscription, $stripeInvoiceId, $stripeInvoice, $from, $to);
}
$unnamed = [];
$invoice = app(IssueInvoice::class)->forStripeInvoice(
subscription: $subscription,
stripeInvoiceId: $stripeInvoiceId,
lines: $lines,
currency: is_string($stripeInvoice['currency'] ?? null) ? $stripeInvoice['currency'] : null,
unnamed: $unnamed,
);
$this->reportUnnamed($subscription, $stripeInvoiceId, $unnamed);
$this->reportMismatch($invoice, $stripeInvoice);
return $invoice;
} catch (UniqueConstraintViolationException) {
// A concurrent delivery won the race. Its document is the one to
// send; this one's invoice number went back into the series when the
// transaction rolled back.
return Invoice::query()->where('stripe_invoice_id', $stripeInvoiceId)->first();
} catch (Throwable $e) {
// Refuses while the company details are incomplete, which is the
// correct outcome — see IssueInvoice. The payment itself stands: it
// is in the register, and the document can be issued once the
// details are there.
Log::warning('Could not issue a document for this paid Stripe invoice.', [
'subscription' => $subscription->uuid,
'stripe_invoice' => $stripeInvoiceId,
'exception' => $e->getMessage(),
]);
return null;
}
}
/**
* Every line Stripe charged for, complete.
*
* The event carries them already. It does not always carry ALL of them a
* contract with enough modules on it passes the number Stripe puts in a
* webhook and `has_more` is how it says so. A document missing a charged
* line is worse than an extra API call, so the complete list is fetched
* whenever Stripe admits to holding one.
*
* @param array<string, mixed> $stripeInvoice
* @return array<int, array<string, mixed>>
*/
private function lines(string $stripeInvoiceId, array $stripeInvoice): array
{
$lines = (array) ($stripeInvoice['lines']['data'] ?? []);
if (($stripeInvoice['lines']['has_more'] ?? false) !== true) {
return $lines;
}
return app(StripeClient::class)->invoiceLines($stripeInvoiceId);
}
/**
* The document for a cycle invoice that arrived without any line detail.
*
* The contract's own frozen price is an honest description of the term that
* was billed, and better than leaving a paid renewal with no paper at all.
* Only for a cycle: a proration or a charge raised by hand describes
* something the contract cannot answer for, and a line invented for those
* would be a guess printed on a document nobody can correct afterwards.
*
* @param array<string, mixed> $stripeInvoice
*/
private function fromContract(
Subscription $subscription,
string $stripeInvoiceId,
array $stripeInvoice,
?Carbon $from,
?Carbon $to,
): ?Invoice {
$isCycle = (string) ($stripeInvoice['billing_reason'] ?? '') === 'subscription_cycle';
if (! $isCycle || $from === null || $to === null) {
Log::warning('A paid Stripe invoice carried no lines, so no document could be built from it.', [
'subscription' => $subscription->uuid,
'stripe_invoice' => $stripeInvoiceId,
'billing_reason' => $stripeInvoice['billing_reason'] ?? null,
]);
return null;
}
// The total Stripe took, where it said so. A document has to add up to
// the money, and the contract's own price at today's rate is only the
// next best answer — it would miss a cycle billed before a rate change.
$total = $stripeInvoice['total'] ?? $stripeInvoice['amount_paid'] ?? null;
return app(IssueInvoice::class)->forBilledPeriod(
$subscription,
$from,
$to,
$stripeInvoiceId,
chargedCents: is_numeric($total) ? (int) $total : null,
);
}
/**
* Say out loud that a line was charged which the catalogue could not name.
*
* The line is on the document either way, carrying whatever description
* Stripe sent. This is the trail for whoever has to work out what was sold
* a Price created by hand in Stripe's dashboard, or a module removed from
* the catalogue while somebody was still paying for it.
*
* @param array<int, string> $unnamed
*/
private function reportUnnamed(Subscription $subscription, string $stripeInvoiceId, array $unnamed): void
{
if ($unnamed === []) {
return;
}
Log::warning('A charged line was put on an invoice under Stripes own description, because this catalogue could not name it.', [
'subscription' => $subscription->uuid,
'stripe_invoice' => $stripeInvoiceId,
'prices' => $unnamed,
]);
}
/**
* Say out loud when the document and the money do not agree.
*
* Our catalogue is pushed to Stripe as GROSS, so the line amounts that come
* back are what the customer paid and the document divides that sum rather
* than adding a rate to it. Stripe's total and our gross must therefore be
* the SAME NUMBER, to the cent, for every customer including a
* reverse-charge one, whose whole charged amount is stated as net at 0 %.
*
* This used to accept our net as an alternative, on the grounds that an
* account might not collect tax for us. That tolerance is what let the
* defect run: 179,00 taken against a document for 214,80 matched the
* "net" branch and was never reported. Anything but equality is now worth a
* warning, and the likeliest cause is a Price that stripe:sync-catalogue has
* replaced with nobody having run stripe:reprice-subscriptions.
*
* Never an exception. The document is issued and the payment is recorded;
* raising here would only cost Stripe its 2xx and change nothing.
*
* @param array<string, mixed> $stripeInvoice
*/
private function reportMismatch(Invoice $invoice, array $stripeInvoice): void
{
$total = $stripeInvoice['total'] ?? $stripeInvoice['amount_paid'] ?? null;
if (! is_numeric($total)) {
return;
}
$total = (int) $total;
if ($total === $invoice->gross_cents) {
return;
}
Log::warning('An invoice was issued for a sum Stripe did not charge.', [
'invoice' => $invoice->number,
'stripe_invoice' => $invoice->stripe_invoice_id,
'stripe_total' => $total,
'net' => $invoice->net_cents,
'gross' => $invoice->gross_cents,
]);
}
private function send(Subscription $subscription, Invoice $invoice): void
{
$customer = $subscription->customer;
$address = $customer?->email;
if (! is_string($address) || $address === '') {
return;
}
try {
Mail::to($address)->queue(new InvoiceMail($invoice, (string) $customer->name));
// Only once it is actually with the mailer. Stamped so a redelivery
// does not send the same invoice a second time, and left unstamped
// on a failure so that one still can.
$invoice->update(['sent_at' => now()]);
} catch (Throwable $e) {
Log::warning('Could not queue the invoice mail for this payment.', [
'invoice' => $invoice->number,
'exception' => $e->getMessage(),
]);
}
}
}

View File

@ -1,224 +0,0 @@
<?php
namespace App\Actions;
use App\Models\Subscription;
use App\Services\Billing\PlanPrices;
use App\Services\Stripe\StripeClient;
use Illuminate\Support\Facades\Log;
use RuntimeException;
use Throwable;
/**
* Telling Stripe that the contract now bills for a different package.
*
* A plan change moved the contract, the entitlements and the machine, and left
* Stripe billing the old price so a customer who upgraded in March went on
* paying for the smaller package every month afterwards, and one who downgraded
* went on paying for the bigger one. This is what closes that.
*
* **Proration, per direction, chosen here and nowhere else.**
*
* - An **upgrade** is settled immediately (`always_invoice`). PlanChange's own
* docblock already says Stripe's proration invoice is the authority for what
* an upgrade costs, and that is only true if there IS one the alternative,
* letting the proration ride along on the next cycle invoice, would make the
* cycle charge bigger than the renewal document we issue from the contract's
* frozen price, and a document that does not match the money taken is not a
* document. Immediate also matches what the customer was shown: they get the
* bigger package now and pay for the days that are left.
* - A **downgrade** settles nothing (`none`). It lands exactly at the period
* end, so there is nothing left of the term to prorate; anything other than
* `none` at a boundary risks a stray credit line for a fraction of a day.
* From the next cycle Stripe simply bills the smaller price, which is what
* the renewal document will say.
*
* Between them the register and Stripe cannot claim different money: the upgrade
* row records what was AGREED (with nothing charged), Stripe's proration invoice
* comes back through the webhook as an `invoice_paid` row recording what was
* TAKEN, and the cycle invoice stays exactly the contract price on both sides.
*
* **Never throws.** The change has already been applied to the contract and to
* the machine by the time this runs, and Stripe being unreachable, unconfigured
* or out of step with the catalogue must not undo any of that. What it must not
* do either is disappear: the failure is parked on the contract in
* `stripe_price_sync`, logged as an error, and retried hourly by
* clupilot:sync-stripe-subscriptions.
*/
class MoveStripeSubscriptionPrice
{
public function __construct(
private StripeClient $stripe,
private PlanPrices $prices,
) {}
/**
* @param string $behaviour one of StripeClient::PRORATE_*
* @return bool whether Stripe now agrees with the contract
*/
public function __invoke(Subscription $subscription, string $behaviour): bool
{
// A granted package was never sold through Stripe — GrantSubscription
// leaves stripe_subscription_id null on purpose — so there is nothing
// there to move and nothing has gone wrong. Answered before anything
// else so that no call is made and no failure is recorded.
if ($subscription->stripe_subscription_id === null) {
return true;
}
$priceId = null;
try {
$priceId = $this->targetPrice($subscription);
if ($priceId === null) {
throw new RuntimeException(
'The catalogue has no Stripe price for this plan version on this term. Run stripe:sync-catalogue.'
);
}
// Already billing it. A retry of a swap that in fact went through,
// or a second change back onto the price Stripe is on: either way
// asking again would raise a proration for a move that is not one.
if ($subscription->stripe_price_id === $priceId) {
$this->settled($subscription, $priceId);
return true;
}
if (! $this->stripe->isConfigured()) {
throw new RuntimeException('Stripe is not configured (STRIPE_SECRET is empty).');
}
$itemId = $this->itemId($subscription);
if ($itemId === null) {
throw new RuntimeException(
'Stripe reports no item on this subscription, so there is nothing to put another price on.'
);
}
$this->stripe->updateSubscriptionPrice(
$subscription->stripe_subscription_id,
$itemId,
$priceId,
$behaviour,
);
$this->settled($subscription, $priceId);
return true;
} catch (Throwable $e) {
$this->park($subscription, $priceId, $behaviour, $e);
return false;
}
}
/**
* Try a parked swap again.
*
* The target price is worked out afresh from the contract rather than read
* back from the parked record: another change may have landed in between,
* and the contract is the authority on which package the customer is on.
* The BEHAVIOUR is the one that was chosen at the time, because it belongs
* to the direction the move went, which cannot be read off the contract
* once it has moved.
*
* A retried upgrade prorates from the moment Stripe finally hears about it,
* not from the moment the change landed here so a long outage charges the
* customer for slightly fewer days than they had the bigger package. In
* their favour, and preferable to the alternative of back-dating a charge.
*/
public function retry(Subscription $subscription): bool
{
$parked = (array) ($subscription->stripe_price_sync ?? []);
return $this($subscription, (string) ($parked['behaviour'] ?? StripeClient::PRORATE_NONE));
}
/**
* The Stripe Price for the package the contract is on now, for the customer
* who is paying for it.
*
* By plan VERSION and term, which is what a Stripe Price is: one per priced
* row and per tax treatment, written by stripe:sync-catalogue. Resolving it by
* plan name would hand back today's terms for a contract that is
* grandfathered on older ones.
*
* The treatment is part of the answer, which is what makes a verification
* converge: a business whose VAT id is confirmed the week after they bought
* belongs on the net Price from that moment, and one whose registration has
* lapsed belongs back on the gross one. Neither is a change of package, so
* nothing here looks like one the move is made with PRORATE_NONE by the
* sweep that finds it, because the term is already paid for.
*/
private function targetPrice(Subscription $subscription): ?string
{
return $this->prices->liveForSubscription($subscription);
}
/**
* The item the subscription bills through.
*
* Stored when we have it a `customer.subscription.updated` event carries
* it and ApplyStripeBillingEvent keeps it. A contract older than that column
* has none, so Stripe is asked once and the answer is kept: the item id
* survives a price swap, so this happens at most once per contract.
*/
private function itemId(Subscription $subscription): ?string
{
if ($subscription->stripe_item_id !== null) {
return $subscription->stripe_item_id;
}
$itemId = $this->stripe->subscriptionItemId((string) $subscription->stripe_subscription_id);
if ($itemId !== null) {
$subscription->update(['stripe_item_id' => $itemId]);
}
return $itemId;
}
/** Stripe and the contract agree; nothing is outstanding. */
private function settled(Subscription $subscription, string $priceId): void
{
$subscription->update([
'stripe_price_id' => $priceId,
'stripe_price_synced_at' => now(),
'stripe_price_sync' => null,
]);
}
/**
* The change landed here and did not reach Stripe.
*
* Recorded on the contract rather than only logged: a log line scrolls away,
* and what this describes is a customer being charged for a package they are
* no longer on. The row is what the retry sweep reads and what an operator
* can be shown.
*/
private function park(Subscription $subscription, ?string $priceId, string $behaviour, Throwable $e): void
{
$parked = (array) ($subscription->stripe_price_sync ?? []);
$attempts = (int) ($parked['attempts'] ?? 0) + 1;
Log::error('A plan change landed but did not reach Stripe: this contract is still being billed at the old price.', [
'subscription' => $subscription->uuid,
'plan' => $subscription->plan,
'stripe_subscription' => $subscription->stripe_subscription_id,
'stripe_price' => $priceId,
'attempts' => $attempts,
'error' => $e->getMessage(),
]);
$subscription->update(['stripe_price_sync' => [
'price' => $priceId,
'behaviour' => $behaviour,
'error' => mb_substr($e->getMessage(), 0, 250),
'failed_at' => now()->toIso8601String(),
'attempts' => $attempts,
]]);
}
}

View File

@ -1,134 +0,0 @@
<?php
namespace App\Actions;
use App\Models\Order;
use App\Models\Subscription;
use App\Models\SubscriptionRecord;
use App\Services\Billing\WithdrawalRight;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
/**
* Opens the contract a paid order bought.
*
* This is the moment the catalogue stops applying. Everything the customer is
* owed price, quotas, seats, the hardware behind the plan is copied onto
* the subscription here and never read from the catalogue again. Provisioning
* sizes the machine from this row, so an operator editing a plan afterwards
* cannot reach a customer who has already paid.
*
* `price_cents` is the catalogue's NET price, which is what PlanChange prorates
* against deliberately not `Order::amount_cents`, which holds the total Stripe
* actually charged, whichever of a package's two Prices this customer was sold on.
* The two can legitimately differ (VAT, and later coupons) and for a
* reverse-charge business they legitimately agree, because no VAT is owed to us at
* all and reconciling them is not this action's job: the proof register
* records what was charged per event, and Stripe's invoice is the authority for
* the amount. Copying a gross total into this net field would silently corrupt
* every pro-rata calculation that reads it.
*
* `$overrides` exists for exactly one caller, GrantSubscription: a grant opens
* a contract through this same action rather than a second one, but needs to
* set what the customer actually pays (0, or less than the catalogue price)
* and stamp who granted it. Passed straight through to Subscription::create()
* on top of the catalogue snapshot empty for every ordinary purchase, which
* is the whole reason this stays a parameter and not a new method.
*/
class OpenSubscription
{
public function __construct(private RecordCommercialEvent $record) {}
/** @param array<string, mixed> $overrides */
public function __invoke(Order $order, string $term = Subscription::TERM_MONTHLY, array $overrides = []): Subscription
{
// A retried webhook must not open a second contract for one purchase.
$existing = Subscription::query()->where('order_id', $order->id)->first();
if ($existing !== null) {
return $existing;
}
$start = now();
// The contract and its entry in the register commit together. If the
// record could fail after the subscription is in, the next webhook
// retry would find the contract, return early, and leave a paid sale
// permanently missing from the evidence.
return DB::transaction(fn () => $this->open($order, $term, $start, $overrides));
}
/** @param array<string, mixed> $overrides */
private function open(Order $order, string $term, Carbon $start, array $overrides = []): Subscription
{
$subscription = Subscription::create(array_merge(
// The version the order carries, when the checkout recorded one:
// what the customer saw beats what happens to be on sale by the
// time their payment reaches us.
Subscription::snapshotFrom($order->plan, $term, $order->plan_version_id),
[
'customer_id' => $order->customer_id,
'order_id' => $order->id,
// Carried from the checkout: from here on, Stripe's events
// name this and nothing else.
'stripe_subscription_id' => $order->stripe_subscription_id,
'started_at' => $start,
'current_period_start' => $start,
'current_period_end' => $term === Subscription::TERM_YEARLY
? $start->copy()->addYear()
: $start->copy()->addMonth(),
// The fourteen days a consumer may withdraw in, stamped at the
// moment the contract is concluded rather than derived on every
// read. A statutory deadline is a date the customer was told,
// and a date that is recomputed is a date that can move.
//
// Stamped for a business customer too. Whether the right exists
// is WithdrawalRight's question and it asks the customer, not
// this column — and a customer can correct their recorded type
// afterwards, at which point the window has to have been running
// all along rather than starting from the correction.
'withdrawal_ends_at' => $start->copy()->addDays(WithdrawalRight::WINDOW_DAYS),
// Carried from the purchase, where it was given. Nothing reads
// it to decide anything any more — a withdrawing consumer gets
// the whole amount back — so this is a record of what was agreed
// at the checkout and not a gate. See Order's own cast.
'immediate_start_consent_at' => $order->immediate_start_consent_at,
'status' => 'active',
],
// Last, so a grant's price and provenance win over the catalogue
// snapshot above rather than the other way round.
$overrides,
));
// The sale, entered in the register the moment it happens. Written from
// the contract that was just frozen, so the evidence and the contract
// cannot describe different purchases.
($this->record)(
event: SubscriptionRecord::EVENT_PURCHASE,
subscription: $subscription,
netCents: $subscription->price_cents,
at: $start,
stripe: ['event' => $order->stripe_event_id],
// The order carries what Stripe actually took, including whatever tax
// or discount applied on the day. The contract price is what was
// agreed; this is what was paid, and the register has to be able to
// state both — which for a reverse-charge business is the same figure,
// because their Price carries the bare net.
//
// Keyed on the Stripe id, not on the amount being non-zero: a fully
// discounted checkout charges zero, and reading that as "no amount
// recorded" would file a free sale as though it had been paid for
// in full. An order without a Stripe id was never charged through
// a checkout at all, and has nothing to report.
// packageChargedCents(), not the whole total: a first purchase can
// carry the one-off setup fee on the same Stripe session, and that fee
// buys the setting-up rather than the package. Held against the
// contract price it would report every sale with a fee on it as charged
// at the wrong amount, which is precisely what the register's flag is
// for and precisely why it must not fire on a correct sale.
chargedGrossCents: $order->stripe_event_id !== null ? $order->packageChargedCents() : null,
);
return $subscription;
}
}

View File

@ -1,121 +0,0 @@
<?php
namespace App\Actions;
use App\Models\Instance;
use App\Models\Order;
use App\Models\ProvisioningRun;
use App\Provisioning\Jobs\AdvanceRunJob;
use App\Provisioning\WorkInFlight;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
/**
* Make an instance's address true again the router, the certificate and the
* hostname Nextcloud answers to without touching anything else about it.
*
* A custom domain used to be announced and never served: the portal, the
* credentials mail and Instance::address() all reported it the moment its TXT
* proof appeared, while the only thing that ever wrote a route was the initial
* provisioning run, and it wrote the platform subdomain. This is the missing
* half the moment a proven domain becomes an address, and the moment a
* withdrawn one stops being one.
*
* Called from the three places the address can change: the nightly proof check
* when verification flips, the customer's own domain page, and a package change
* that takes the right to a domain away. Deliberately not called from anywhere
* that merely LOOKS at the domain a re-apply is remote work on a live
* machine, so it happens on a change and not on a schedule.
*/
class ReapplyInstanceAddress
{
/**
* Start an address run for this instance, or return null when there is
* nothing to start.
*
* Null is the ordinary answer, not a failure: an instance still being
* built, one whose machine no longer exists, or one that already has a run
* in flight all have their address applied by that run instead.
*/
public function __invoke(?Instance $instance): ?ProvisioningRun
{
if ($instance === null || ! $this->isReapplyable($instance)) {
return null;
}
// The lock closes the window between asking whether a run exists and
// creating one. Two requests can reach this in the same instant — the
// customer's own "check now" and the nightly command are the obvious
// pair — and two runs against one machine would write the same router
// twice and race each other's occ calls. Nobody waits for the lock: a
// re-apply that lost the race has nothing to add, because the run that
// won reads the same domain state it would have.
$lock = Cache::lock('instance-address:'.$instance->uuid, 30);
if (! $lock->get()) {
return null;
}
try {
return $this->start($instance);
} finally {
$lock->release();
}
}
private function start(Instance $instance): ?ProvisioningRun
{
$order = $instance->order;
// A run already under way applies whatever the domain state says when it
// gets to the step — but only if it HAS the step. Asked of the two steps
// this pipeline consists of rather than of the mere existence of a run:
// `customer` and `plan-change` both carry them and there is nothing to
// add, while `restart`, `storage` and `quota` carry neither, and standing
// aside for one of those was how a domain proven during a restart stayed
// unrouted for good. See App\Provisioning\WorkInFlight.
if (app(WorkInFlight::class)->covers($order, 'address')) {
return null;
}
$run = ProvisioningRun::create([
'subject_type' => Order::class,
'subject_id' => $order->id,
'pipeline' => 'address',
'status' => ProvisioningRun::STATUS_PENDING,
'current_step' => 0,
// Everything the two steps read. instance_id is how CustomerStep
// finds the machine; node and vmid are how it reaches inside it.
'context' => [
'instance_id' => $instance->id,
'host_id' => $instance->host_id,
'node' => $instance->host?->node,
'vmid' => $instance->vmid,
'subdomain' => $instance->subdomain,
],
]);
AdvanceRunJob::dispatch($run->uuid);
Log::info('Re-applying an instance address.', [
'instance' => $instance->uuid,
'domain' => $instance->custom_domain,
'verified' => $instance->domainIsVerified(),
'run' => $run->uuid,
]);
return $run;
}
/**
* Is there a machine here whose address can be re-applied at all?
*
* The question is not this action's alone a plan change asks exactly the
* same one before it starts a run against a live machine so the rule sits
* on the model and both read it there. See Instance::hasLiveMachine().
*/
private function isReapplyable(Instance $instance): bool
{
return $instance->hasLiveMachine();
}
}

View File

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

View File

@ -1,160 +0,0 @@
<?php
namespace App\Actions;
use App\Models\Customer;
use App\Models\Instance;
use App\Models\Order;
use App\Models\ProvisioningRun;
use App\Provisioning\Jobs\AdvanceRunJob;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
/**
* Take a customer's machine round once properly.
*
* The product could not restart anything at all. ProxmoxClient had startVm and
* nothing else, the cloud page's "Neu starten" was a toast, and
* `restart_required_since` set by every plan change that touches CPU or memory
* on a running guest could only ever be cleared by a resize step that happened
* to find the machine stopped. Nothing could stop it. So a paid upgrade's cores
* and RAM were written into the VM's definition and could stay unreached for the
* life of the contract.
*
* This is the missing trigger. It does not do the work: a restart is remote work
* on a live machine, so it goes through the same run machinery as everything
* else see the `restart` pipeline where it is retried, logged, and visible
* in the console instead of happening inside a web request.
*/
class RestartInstance
{
/**
* May whoever is signed in RIGHT NOW restart this machine?
*
* Asked here and not only where the button is drawn. A Livewire action is
* reachable by anyone who can POST to /livewire/update with a component
* snapshot hiding a button hides a button, it does not close a door so
* the rule has to be re-checked at the point the run is created.
*
* Two identities, two answers (R21): an operator holding `instances.restart`
* may restart any machine on the estate, because that is what the console is
* for; a portal user may restart their own and nobody else's. Both guards
* are read, and either may say yes the two sessions coexist in one browser
* by design (see config/auth.php), so asking only the console guard would
* refuse an operator their own cloud, and asking only the portal guard would
* refuse every operator everything.
*
* The customer is resolved from the signed-in user rather than taken from
* the caller: a caller that passes the customer is a caller that can pass
* the wrong one.
*/
public function allows(Instance $instance): bool
{
$operator = Auth::guard('operator')->user();
if ($operator !== null && $operator->isActive() && $operator->can('instances.restart')) {
return true;
}
$customer = Customer::forUser(Auth::guard('web')->user());
return $customer !== null && (int) $instance->customer_id === (int) $customer->id;
}
/**
* Start a restart run for this instance, or return null when there is
* nothing to start.
*
* Null is the ordinary "no" and not a failure a machine that is still
* being built, one whose VM has gone, one whose service has ended, or one
* that already has a run in flight all have a better reason to be left
* alone than a restart has to interrupt them. A refusal on AUTHORISATION is
* the one case that throws, because it is not an ordinary outcome and the
* caller must not be able to mistake it for one.
*
* @throws AuthorizationException
*/
public function __invoke(Instance $instance): ?ProvisioningRun
{
if (! $this->allows($instance)) {
throw new AuthorizationException;
}
if (! $instance->hasLiveMachine()) {
return null;
}
// The same lock ReapplyInstanceAddress takes, for the same reason: two
// requests can reach this in one instant — the customer pressing the
// button while an operator presses it for them is the obvious pair —
// and two restart runs against one machine would have the second one
// shutting the guest down while the first waits for it to come back.
// Nobody waits for the lock: a restart that lost the race has nothing
// to add, because the run that won does exactly the same thing.
$lock = Cache::lock('instance-restart:'.$instance->uuid, 30);
if (! $lock->get()) {
return null;
}
try {
return $this->start($instance);
} finally {
$lock->release();
}
}
private function start(Instance $instance): ?ProvisioningRun
{
$order = $instance->order;
// Checked against the ORDER, because that is the subject every customer
// pipeline shares. ANY run, and deliberately not the narrower "does it do
// this work?" question ReapplyInstanceAddress and ApplyStorageAllowance
// ask: taking the guest away underneath a run is harmful to every one of
// them, and unlike a proven domain or a booked pack a restart is somebody
// pressing a button, so it can be pressed again once the run is done
// rather than needing to be remembered for them.
if ($this->hasRunInFlight($order)) {
return null;
}
$run = ProvisioningRun::create([
'subject_type' => Order::class,
'subject_id' => $order->id,
'pipeline' => 'restart',
'status' => ProvisioningRun::STATUS_PENDING,
'current_step' => 0,
// Everything the four steps read: instance_id is how CustomerStep
// finds the machine, node and vmid are how it reaches it.
'context' => [
'instance_id' => $instance->id,
'host_id' => $instance->host_id,
'node' => $instance->host?->node,
'vmid' => $instance->vmid,
'subdomain' => $instance->subdomain,
],
]);
AdvanceRunJob::dispatch($run->uuid);
Log::info('Restarting a customer instance.', [
'instance' => $instance->uuid,
'restart_pending_since' => $instance->restart_required_since?->toIso8601String(),
'run' => $run->uuid,
]);
return $run;
}
private function hasRunInFlight(Order $order): bool
{
return ProvisioningRun::query()
->where('subject_type', Order::class)
->where('subject_id', $order->id)
->inFlight()
->exists();
}
}

View File

@ -1,436 +0,0 @@
<?php
namespace App\Actions;
use App\Exceptions\IdentityCollisionException;
use App\Mail\InvoiceMail;
use App\Mail\OrderConfirmationMail;
use App\Models\Customer;
use App\Models\Invoice;
use App\Models\Order;
use App\Models\ProvisioningRun;
use App\Models\Subscription;
use App\Provisioning\Jobs\AdvanceRunJob;
use App\Services\Billing\IssueInvoice;
use App\Services\Billing\PlanCatalogue;
use Illuminate\Database\UniqueConstraintViolationException;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
use Throwable;
/**
* Turns a paid Stripe event into a customer + order + provisioning run. The
* order's unique stripe_event_id is the idempotency key: a duplicate webhook
* never starts a second run.
*/
class StartCustomerProvisioning
{
public function __construct(
private OpenSubscription $openSubscription,
private ApplyStripeBillingEvent $billing,
) {}
/**
* @param array{id:string,email:string,name:?string,stripe_customer_id:?string,plan:string,datacenter:string,amount_cents:int,setup_fee_cents?:int,currency:string,immediate_start_consent?:bool} $event
*/
public function fromStripeEvent(array $event): ?Order
{
$existing = Order::query()->where('stripe_event_id', $event['id'])->first();
if ($existing !== null) {
$this->resume($existing);
return null;
}
$customer = $this->resolveCustomer($event);
try {
$customer->ensureUser(); // portal login (also enables admin impersonation)
} catch (IdentityCollisionException) {
// Same principle as openContract() below: the order is the record
// that money changed hands, so a paid order still gets recorded
// and provisioned. Only the colliding portal LOGIN is withheld —
// R21 — until an operator resolves the address conflict by hand
// (rename the operator, or the customer). Until then this
// customer has no self-service sign-in and cannot be
// impersonated either; both call ensureUser() the same way.
Log::error('Portal login withheld: this customer email already belongs to an operator account.', [
'customer_id' => $customer->id, 'email' => $customer->email,
]);
}
try {
[$order, $run] = DB::transaction(function () use ($event, $customer) {
$order = Order::create([
'customer_id' => $customer->id,
'plan' => $event['plan'],
// Set once Stripe checkout carries it (phase 5). Null means
// "whatever is on sale when the webhook lands", which is
// what this did before the column existed.
'plan_version_id' => $event['plan_version_id'] ?? null,
'amount_cents' => $event['amount_cents'],
// How much of `amount_cents` was the one-off setup fee. Kept
// beside the total rather than subtracted from it: a withdrawal
// sends back everything that was taken and the confirmation
// mail states everything that was taken, while the register and
// the invoice need the package and the fee apart — see
// Order::packageChargedCents().
'setup_fee_cents' => (int) ($event['setup_fee_cents'] ?? 0),
'currency' => $event['currency'],
'datacenter' => $event['datacenter'],
'stripe_event_id' => $event['id'],
'stripe_subscription_id' => $event['stripe_subscription_id'] ?? null,
// Kept so a withdrawal has something to refund against; see
// App\Actions\WithdrawContract.
'stripe_payment_intent_id' => $event['stripe_payment_intent_id'] ?? null,
'stripe_invoice_id' => $event['stripe_invoice_id'] ?? null,
// Stamped only where the consumer actually gave it. Nothing
// turns on it any more: a withdrawal refunds the whole
// amount whether it is there or not, because the owner has
// decided not to charge for the days the cloud ran. Kept as
// a record of what the customer was shown and ticked.
'immediate_start_consent_at' => ($event['immediate_start_consent'] ?? false) === true ? now() : null,
'status' => 'paid',
]);
$run = ProvisioningRun::create([
'subject_type' => Order::class,
'subject_id' => $order->id,
'pipeline' => 'customer',
'status' => ProvisioningRun::STATUS_PENDING,
'current_step' => 0,
// Snapshot resolved branding so retries apply identical inputs
// (unset customer → CluPilot defaults, resolved once here).
'context' => ['branding' => $customer->brandingResolved()],
]);
return [$order, $run];
});
} catch (UniqueConstraintViolationException) {
// A concurrent delivery won the race. Both requests will answer
// Stripe with a 2xx, so this is the last look anyone takes at this
// payment — if the winner then dies before opening the contract, no
// retry is coming to fix it. So finish its work rather than just
// stepping aside. The unique index on subscriptions.order_id keeps
// both from opening one.
$winner = Order::query()->where('stripe_event_id', $event['id'])->first();
if ($winner !== null) {
$this->resume($winner);
}
return null;
}
$this->openContract($order);
AdvanceRunJob::dispatch($run->uuid); // after commit
$this->confirmByMail($order, $customer);
return $order;
}
/**
* Invoice the payment, and tell the customer once.
*
* Both halves are idempotent and live in invoiceOnce() and mailInvoiceOnce()
* below, because this is not the only caller any more: resume() has to be able
* to finish exactly this work when a crash cut it off, and two copies of "have
* we invoiced this yet" would be two answers.
*
* Sent here 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. CloudReady announces the end; this announces the beginning.
*
* After the transaction, never inside it: a queued job can be picked up by
* a worker before the commit lands, and it would then read an order that
* does not exist yet.
*
* Never allowed to fail the webhook. Stripe retries anything that is not a
* 2xx, and a retry here would re-enter provisioning a second machine
* built over a mail server or a missing VAT number.
*/
private function confirmByMail(Order $order, Customer $customer): void
{
$invoice = $this->invoiceOnce($order, $customer);
if ($invoice !== null) {
$this->mailInvoiceOnce($invoice, $customer);
return;
}
$address = $customer->email;
if (! is_string($address) || $address === '') {
return;
}
// No invoice, and there could not be one: an incomplete company profile,
// or a purchase that was a full gift. The confirmation says what it can.
//
// Sent from HERE only, never from resume(). It has nothing to stamp — an
// order carries no `sent_at` — so a redelivery could not tell that it had
// already gone out, and Stripe redelivers until it gets a 2xx. The invoice
// mail is the one a crash must not lose, and that one is stamped.
try {
Mail::to($address)->queue(new OrderConfirmationMail($order, (string) $customer->name));
} catch (Throwable $e) {
Log::warning('Could not queue the purchase confirmation', [
'order' => $order->id,
'exception' => $e->getMessage(),
]);
}
}
/**
* The document this purchase owes, issued exactly once however often we are
* asked.
*
* The guard is the invoice already filed against this order, because an
* invoice number comes out of a gapless series and can never be handed out
* twice. Cancellations are excluded from the search: a Storno carries the same
* `order_id` as the document it takes back, so counting one would leave a
* withdrawn purchase looking as though it had been invoiced by its own
* cancellation.
*
* Not a database-level guard, and it cannot be one `invoices.order_id` is
* legitimately shared by an invoice and its Storno, so it can never be unique.
* What this closes is the case that actually happens: Stripe redelivering the
* same event, one delivery after another.
*/
private function invoiceOnce(Order $order, Customer $customer): ?Invoice
{
$existing = Invoice::query()
->where('order_id', $order->id)
->whereNull('cancels_invoice_id')
->orderBy('id')
->first();
if ($existing !== null) {
return $existing;
}
try {
// Refuses while the company details are incomplete, which is the
// correct outcome — 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 purchase is unaffected:
// the order stands, the machine gets built, and the invoice can be
// issued from the console once the details are there.
return app(IssueInvoice::class)->forOrders($customer, collect([$order]));
} catch (Throwable $e) {
Log::warning('Could not issue an invoice for this order', [
'order' => $order->id,
'exception' => $e->getMessage(),
]);
return null;
}
}
/**
* Hand the invoice to the mailer, once.
*
* `sent_at` is the stamp, the same one App\Actions\IssueStripeInvoice uses,
* and it is written only after the mailer has accepted the message so a
* redelivery arriving after the document was written but before the mail went
* out finishes the job, and one arriving afterwards does neither twice.
*
* ONE mail, not two. The invoice is issued at the moment the money lands, so
* an order confirmation and an invoice mail would otherwise go out in the same
* second for the same event. The invoice mail says everything the confirmation
* said and carries the document, so it replaces it whenever there is one.
*/
private function mailInvoiceOnce(Invoice $invoice, Customer $customer): void
{
$address = $customer->email;
if (! is_string($address) || $address === '' || $invoice->sent_at !== null) {
return;
}
try {
Mail::to($address)->queue(new InvoiceMail($invoice, (string) $customer->name));
$invoice->update(['sent_at' => now()]);
} catch (Throwable $e) {
Log::warning('Could not queue the invoice mail for this purchase', [
'order' => $invoice->order_id,
'invoice' => $invoice->number,
'exception' => $e->getMessage(),
]);
}
}
/**
* Finish what a crash interrupted.
*
* The order commits before the contract is opened, so that a failure there
* can never erase the record of a payment but that leaves a window in
* which a paid order exists with no contract and nothing running. Stripe
* retries until it gets a 2xx, so a retry is exactly the chance to close
* that window. Simply returning "already processed" would strand a paying
* customer permanently, because no later webhook would ever look again.
*
* Safe to run on every duplicate: opening a contract is idempotent. A run
* that was merely never dispatched needs no nudge from here the scheduler
* tick sweeps pending runs. A run that already ran and FAILED for want of
* the contract does, because nothing sweeps failed runs.
*
* ## The invoice is the other half, and it used to be the half that was lost
*
* This returned as soon as a contract existed, and the only production call to
* IssueInvoice::forOrders() is in confirmByMail() which runs AFTER the
* order commits. A worker killed in between left a paid purchase with no
* invoice, for good and in silence: the next redelivery found the contract
* already open, returned here, and nothing anywhere sweeps for an uninvoiced
* order. So the invoice and its mail are finished here too, on every
* redelivery, and the guards in invoiceOnce()/mailInvoiceOnce() are what make
* "however many times Stripe asks" come out as one invoice, one number and
* one mail.
*/
private function resume(Order $order): void
{
if (! $order->subscription()->exists()) {
$this->openContract($order);
if ($order->subscription()->exists()) {
$this->reviveRunStrandedWithoutAContract($order);
}
}
$customer = $order->customer;
if ($customer === null) {
return;
}
$invoice = $this->invoiceOnce($order, $customer);
if ($invoice !== null) {
$this->mailInvoiceOnce($invoice, $customer);
}
}
/**
* Restart a run that failed only because the contract was missing.
*
* Narrow on purpose. `no_subscription` is returned before anything is built
* the run never got past validating the order or reserving resources so
* there is nothing half-made to trip over. A run that failed for any other
* reason failed at something a contract does not fix, and restarting it
* would just repeat whatever went wrong.
*/
private function reviveRunStrandedWithoutAContract(Order $order): void
{
$run = $order->runs()->where('pipeline', 'customer')->latest('id')->first();
if ($run?->status !== ProvisioningRun::STATUS_FAILED
|| ! str_contains((string) $run->error, 'no_subscription')) {
return;
}
// onProvisioningFailed() marked the order failed on the way down.
$order->update(['status' => 'paid']);
$run->update([
'status' => ProvisioningRun::STATUS_PENDING,
'current_step' => 0,
'attempt' => 0,
'next_attempt_at' => null,
'error' => null,
]);
AdvanceRunJob::dispatch($run->uuid);
Log::info('Restarted a run that was stranded without a contract.', [
'order_id' => $order->id, 'run' => $run->uuid,
]);
}
/**
* Freeze what they bought, while the catalogue still says what they were
* shown.
*
* Deliberately AFTER the order is committed, and deliberately swallowing
* its failure. The order is the record that money changed hands: if opening
* the contract could roll it back, an unsellable plan, a missing price or
* any unforeseen fault would erase the evidence of a payment we have
* already taken and Stripe, seeing no 2xx, would retry a webhook that can
* never succeed. A missing contract is recoverable and loud: the run stops
* at ValidateOrder with `no_subscription`, in the operator's face.
*
* Two cases are expected to land here: a plan the catalogue cannot sell,
* and a payment in a currency it cannot price freezing a EUR price onto a
* CHF payment would put the contract and the payment in permanent
* disagreement.
*/
private function openContract(Order $order): void
{
try {
// A checkout that recorded its version is owed THAT version, even
// if the window has closed or the plan has been withdrawn since —
// they paid for what they were shown. Only a purchase with no
// recorded version has to ask what is on sale now.
$deliverable = $order->plan_version_id !== null
? app(PlanCatalogue::class)->isDeliverable($order->plan, $order->plan_version_id)
: Subscription::knowsPlan($order->plan);
$sellable = $deliverable
&& strtoupper((string) $order->currency) === Subscription::catalogueCurrency();
if (! $sellable) {
Log::warning('No contract opened: the catalogue cannot sell this order.', [
'order_id' => $order->id, 'plan' => $order->plan, 'currency' => $order->currency,
]);
return;
}
$subscription = ($this->openSubscription)($order);
// Anything Stripe sent about this contract before it existed —
// a renewal, a failure, a cancellation — applied now, in the order
// they were raised.
$this->billing->replayHeldFor($subscription);
} catch (Throwable $e) {
Log::error('Failed to open a contract for a paid order.', [
'order_id' => $order->id, 'plan' => $order->plan, 'error' => $e->getMessage(),
]);
}
}
/**
* Find or create the customer, race-safe against the unique email index so a
* concurrent first-time purchase can't create two customers for one email.
*
* `customer_type` is deliberately NOT set here, and that is a decision
* rather than an omission. Nobody in this code path has asked the question:
* a Stripe event carries a name, an address and a payment, and none of those
* says whether the person buying is a consumer or a business. Writing either
* answer would be inventing one so the column stays NULL, which every
* reader treats as a consumer, which is the protective answer. Ordinarily
* there is nothing to invent: the buyer signed up first and was asked then
* (App\Actions\Fortify\CreateNewUser). This branch is the customer whose
* Stripe email differs from the one they registered with, and the operator
* or the customer can record the answer afterwards in the portal settings.
*
* @param array{email:string,name:?string,stripe_customer_id:?string} $event
*/
private function resolveCustomer(array $event): Customer
{
try {
return Customer::query()->firstOrCreate(
['email' => $event['email']],
['name' => $event['name'] ?? $event['email'], 'stripe_customer_id' => $event['stripe_customer_id'] ?? null],
);
} catch (UniqueConstraintViolationException) {
return Customer::query()->where('email', $event['email'])->firstOrFail();
}
}
}

View File

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

View File

@ -1,421 +0,0 @@
<?php
namespace App\Actions;
use App\Models\Subscription;
use App\Models\SubscriptionAddon;
use App\Services\Billing\AddonPrices;
use App\Services\Billing\TaxTreatment;
use App\Services\Stripe\StripeClient;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
use RuntimeException;
use Throwable;
/**
* Telling Stripe which modules a contract is paying for.
*
* A booked module was a row here and a line on the customer's first invoice, and
* nothing at all on the Stripe subscription so it was charged once, in the
* month it was booked, and never again. This is what closes that: every module
* still running becomes an item on the subscription, and Stripe bills it beside
* the package on the same invoice, every cycle, for as long as the customer
* keeps it.
*
* **It reconciles rather than replays.** Not "booking adds an item, cancelling
* removes one": the desired state is derivable from the bookings themselves —
* which modules are running, at which frozen price, how many of each and the
* state Stripe is in is stamped on those same rows. So this can be run at any
* moment, from a booking, from a cancellation or from the hourly sweep, and it
* settles on the same answer. That is also what makes the retry honest: a
* booking that could not reach Stripe leaves an active row with no item id, and
* there is nothing else to remember.
*
* **Proration, per direction, chosen here and nowhere else.**
*
* - **Booking** and taking more of a pack settles immediately
* (`always_invoice`). The owner has decided a mid-period booking is prorated
* by days, and `always_invoice` is the behaviour that does exactly that AND
* charges it there and then: Stripe raises its own invoice for the days that
* are left and takes the money. That invoice comes back as `invoice.paid`
* and is the fourth document in the owner's example. The alternative,
* `create_prorations`, would park the amount as a credit line on the next
* cycle invoice, and the customer would get one document a month later
* carrying two different things.
* - **Cancelling** and taking fewer of a pack settles nothing (`none`).
* The customer keeps the module to the end of the term they have already paid
* for and gets no credit, so there is nothing to settle; the item comes off
* now and the next cycle is simply smaller.
*
* **Why the item comes off at cancellation rather than at the period end.**
* Stripe can cancel a SUBSCRIPTION at a period end; an item has no such thing.
* Waiting for the boundary and removing it then is a race against Stripe's own
* invoice generation, which happens at that same instant lose it and the
* customer is charged a full month for a module they cancelled, which is the one
* mistake that costs real money. Removing it now with `none` moves no money at
* all: the current term is already paid for, no credit is raised, and the next
* cycle invoice simply has no such line. What the customer KEEPS until the
* period end is the module itself, and that is held on the booking as
* `cancels_at` and ended by clupilot:end-cancelled-addons.
*
* **Never throws.** The booking has already been made and delivered to the
* machine by the time this runs a storage pack is a bigger disk before Stripe
* hears a word about it and Stripe being unreachable must not undo any of
* that. The failure is parked on the contract in `stripe_addon_sync`, logged,
* and retried by clupilot:sync-stripe-subscriptions beside the plan swaps.
*/
class SyncStripeAddonItems
{
public function __construct(
private StripeClient $stripe,
private AddonPrices $prices,
) {}
/**
* @param bool $chargeForAdditions false when an item is being PUT BACK rather than
* bought: a cancellation taken back inside the term
* restores something the customer has already paid
* for, and charging the proration again would bill
* them twice for the same days. See
* App\Actions\BookAddon::undoCancelAtPeriodEnd().
* @return bool whether Stripe now bills exactly the modules this contract holds
*/
public function __invoke(Subscription $subscription, bool $chargeForAdditions = true): bool
{
// A granted package was never sold through Stripe — GrantSubscription
// leaves stripe_subscription_id null on purpose — so there is nothing
// there to put an item on and nothing has gone wrong. Answered before
// anything else, so no call is made and no failure is recorded.
if ($subscription->stripe_subscription_id === null) {
$this->settled($subscription);
return true;
}
try {
foreach ($this->groups($subscription) as $group) {
$this->reconcile($subscription, $group, $chargeForAdditions);
}
$this->settled($subscription);
return true;
} catch (Throwable $e) {
$this->park($subscription, $e);
return false;
}
}
/**
* Move every module item this contract carries onto the Price that charges
* today's figure for it, to the customer who is paying for it.
*
* Two reasons an item is on the wrong Price, and __invoke() above finds
* neither of them: the item exists and the quantity is right, so it has
* nothing to reconcile. This is the deliberate second pass, run by
* stripe:reprice-subscriptions.
*
* The first is a change of figure the catalogue moved from pushing NET
* amounts to Stripe to pushing the amount actually charged, and a Stripe Price
* cannot be edited, so every running module was on a Price that takes too
* little. The second is a change of CUSTOMER: a business whose VAT id is
* verified after they booked owes no VAT on the module either, and belongs on
* its net Price from then on the reverse too, when a registration lapses.
*
* PRORATE_NONE throughout. The customer has already paid for the term they
* are in, at whatever was taken then; charging the difference for days
* already served would be a bill nobody agreed to. From the next cycle
* Stripe simply takes the right amount.
*
* The module's own frozen `price_cents` is untouched, because that is what
* the customer agreed to pay net and it is what every pro-rata sum reads.
*
* @return array<int, array{addon: string, from: ?string, to: ?string}> what moved, for the command's output
*/
public function reprice(Subscription $subscription, bool $dryRun = false): array
{
if ($subscription->stripe_subscription_id === null) {
return [];
}
$moved = [];
foreach ($this->groups($subscription) as $group) {
$billable = $group->filter(
fn (SubscriptionAddon $addon) => $addon->cancelled_at === null && $addon->cancels_at === null
);
$stamped = $group->filter(fn (SubscriptionAddon $addon) => $addon->stripe_item_id !== null);
$itemId = $stamped->first()?->stripe_item_id;
$first = $billable->first();
// Nothing Stripe is billing, or nothing it should be: __invoke()
// owns both of those cases and would undo whatever this did.
if ($itemId === null || $first === null || (int) $first->price_cents <= 0) {
continue;
}
$target = $this->prices->liveFor(
(string) $first->addon_key,
(int) $first->price_cents,
(string) $first->currency,
(string) $subscription->term,
TaxTreatment::for($subscription->customer),
);
$current = $stamped->first()?->stripe_price_id;
if ($target !== null && $target === $current) {
continue;
}
$moved[] = ['addon' => (string) $first->addon_key, 'from' => $current, 'to' => $target];
if ($dryRun) {
continue;
}
$this->guardConfigured();
// Minted here where the sweep left a figure the catalogue no longer
// pre-creates — a grandfathered booking, or a discounted grant.
$target ??= $this->prices->ensure(
(string) $first->addon_key,
(int) $first->price_cents,
(string) $first->currency,
(string) $subscription->term,
TaxTreatment::for($subscription->customer),
);
if ($target === null) {
// Nothing to bill for it after all — a module given away. Taken
// back out of the report rather than announced as a move.
array_pop($moved);
continue;
}
$moved[count($moved) - 1]['to'] = $target;
$this->stripe->updateSubscriptionPrice(
(string) $subscription->stripe_subscription_id,
$itemId,
$target,
StripeClient::PRORATE_NONE,
);
$this->stamp($group, $itemId, $target);
}
return $moved;
}
/**
* The bookings that matter, grouped into the items they should be.
*
* By module AND by frozen price, because that pair is what a Stripe Price
* is: two packs booked at the same money are one item with a quantity of
* two, and a third booked after the catalogue moved is a second item at the
* price that customer agreed to. Grouping by module alone would have to pick
* one of two prices and charge everybody that.
*
* Cancelled bookings are in here as long as they still carry an item id
* that is the only record of what Stripe was last told, and without it the
* quantity could never be brought back down.
*
* @return Collection<string, Collection<int, SubscriptionAddon>>
*/
private function groups(Subscription $subscription): Collection
{
return SubscriptionAddon::query()
->where('subscription_id', $subscription->id)
->where(fn ($q) => $q->whereNull('cancelled_at')->orWhereNotNull('stripe_item_id'))
->orderBy('id')
->get()
->groupBy(fn (SubscriptionAddon $addon) => $addon->addon_key.'@'.$addon->price_cents);
}
/**
* Bring one module's item into step with what the contract holds.
*
* @param Collection<int, SubscriptionAddon> $group
*/
private function reconcile(Subscription $subscription, Collection $group, bool $chargeForAdditions = true): void
{
// What Stripe should bill: every booking still running and not already
// on its way out. A module with `cancels_at` set is deliberately absent
// — the customer keeps it, nobody is billed for it again.
$billable = $group->filter(
fn (SubscriptionAddon $addon) => $addon->cancelled_at === null && $addon->cancels_at === null
);
// What Stripe was last told, read off the rows that carry the item id.
$stamped = $group->filter(fn (SubscriptionAddon $addon) => $addon->stripe_item_id !== null);
$desired = (int) $billable->sum('quantity');
$known = (int) $stamped->sum('quantity');
$itemId = $stamped->first()?->stripe_item_id;
$first = $group->first();
// A module given away costs nothing, so there is nothing for Stripe to
// bill — and a zero-amount item would put a line saying so on every
// invoice the customer ever gets. Treated exactly like a module that is
// no longer held: if one was ever billed, it comes off.
if ($desired === 0 || (int) $first->price_cents <= 0) {
if ($itemId !== null) {
$this->guardConfigured();
$this->stripe->removeSubscriptionItem($itemId, StripeClient::PRORATE_NONE);
}
$this->stamp($group, null, null);
return;
}
// Stripe already bills exactly this. Only the bookings are restamped —
// one may have been cancelled while another was booked in the same
// breath, which leaves the quantity right and the rows out of date.
if ($itemId !== null && $known === $desired) {
$this->stamp($group, $itemId, $stamped->first()?->stripe_price_id);
return;
}
// Asked for only now that something is going to be said to Stripe: an
// unconfigured account must fail here, where it is parked and retried,
// rather than while resolving a Price nobody is going to use.
$this->guardConfigured();
$priceId = $this->prices->ensure(
(string) $first->addon_key,
(int) $first->price_cents,
(string) $first->currency,
(string) $subscription->term,
// Whoever holds the contract decides which of the module's Prices its
// item bills through: the domestic gross, or the bare net where the
// customer is a verified business in another member state. A module is
// a supply like the package beside it and cannot be taxed differently
// from it on the same invoice.
TaxTreatment::for($subscription->customer),
);
if ($priceId === null) {
$this->stamp($group, null, null);
return;
}
if ($itemId === null) {
$itemId = $this->stripe->addSubscriptionItem(
(string) $subscription->stripe_subscription_id,
$priceId,
$desired,
$chargeForAdditions ? StripeClient::PRORATE_IMMEDIATELY : StripeClient::PRORATE_NONE,
// Keyed on the contract, the module and how many of it: a retry
// after a timeout that in fact went through replays Stripe's
// first answer instead of adding the module a second time.
idempotencyKey: sprintf(
'clupilot-addon-item-%d-%s-%d-%d',
$subscription->id,
$first->addon_key,
$first->price_cents,
$desired,
),
);
} else {
$this->stripe->updateSubscriptionItemQuantity(
$itemId,
$desired,
// More of a pack is bought now and paid for the days that are
// left; fewer is a cancellation, which earns no credit. A
// restoration is neither and settles nothing.
$desired > $known && $chargeForAdditions
? StripeClient::PRORATE_IMMEDIATELY
: StripeClient::PRORATE_NONE,
);
}
$this->stamp($group, $itemId, $priceId);
}
/**
* Write down what Stripe now bills, on the bookings it bills.
*
* Every running booking in the group carries the item id, so a second pack
* booked tomorrow can see that it is joining an item rather than starting
* one. A booking that has stopped gives its id back, because the quantity
* has already been taken down by it and counting it again would take the
* same pack off twice.
*
* Through the query builder: none of these columns is part of what the
* booking froze, and the model would otherwise have to be re-read first.
*
* @param Collection<int, SubscriptionAddon> $group
*/
private function stamp(Collection $group, ?string $itemId, ?string $priceId): void
{
foreach ($group as $addon) {
$billed = $itemId !== null && $addon->cancelled_at === null && $addon->cancels_at === null;
$values = [
'stripe_item_id' => $billed ? $itemId : null,
'stripe_price_id' => $billed ? $priceId : null,
];
if ($addon->stripe_item_id === $values['stripe_item_id']
&& $addon->stripe_price_id === $values['stripe_price_id']) {
continue;
}
SubscriptionAddon::query()->whereKey($addon->getKey())->update($values + ['updated_at' => now()]);
$addon->forceFill($values)->syncOriginal();
}
}
private function guardConfigured(): void
{
if (! $this->stripe->isConfigured()) {
throw new RuntimeException('Stripe is not configured (STRIPE_SECRET is empty).');
}
}
/** Stripe bills exactly what the contract holds; nothing is outstanding. */
private function settled(Subscription $subscription): void
{
if ($subscription->stripe_addon_sync === null) {
return;
}
$subscription->update(['stripe_addon_sync' => null]);
}
/**
* A module was booked or cancelled here and Stripe was not told.
*
* On the contract rather than only in a log line, for the same reason as
* `stripe_price_sync` beside it: what this describes is a customer holding a
* module nobody is billing them for or being billed for one they gave up
* and a log line scrolls away.
*/
private function park(Subscription $subscription, Throwable $e): void
{
$parked = (array) ($subscription->stripe_addon_sync ?? []);
$attempts = (int) ($parked['attempts'] ?? 0) + 1;
Log::error('A module booking landed but did not reach Stripe: this contract is not billed for what it holds.', [
'subscription' => $subscription->uuid,
'stripe_subscription' => $subscription->stripe_subscription_id,
'attempts' => $attempts,
'error' => $e->getMessage(),
]);
$subscription->update(['stripe_addon_sync' => [
'error' => mb_substr($e->getMessage(), 0, 250),
'failed_at' => now()->toIso8601String(),
'attempts' => $attempts,
]]);
}
}

View File

@ -1,84 +0,0 @@
<?php
namespace App\Actions;
use App\Models\Customer;
use App\Services\Tax\VatIdCheck;
use App\Services\Tax\VatIdVerifier;
/**
* Ask the register about a customer's VAT number and record what it said.
*
* The one place a check becomes a stored verification, so the portal form, the
* console and the nightly re-check cannot disagree about what counts. It decides
* nothing about tax: TaxTreatment reads the stored verification and applies the
* reverse-charge rule. This only answers "is the number real".
*
* Deliberately not gated on the customer's recorded type. Whether a verified
* number earns reverse charge is TaxTreatment's question it already refuses a
* customer who has recorded themselves as a private person and a number that
* is registered is registered whoever holds it. Splitting that decision across
* two files is how the two would drift.
*/
class VerifyCustomerVatId
{
public function __construct(private VatIdVerifier $verifier) {}
/**
* Returns what the register said, so the caller can tell the customer.
*
* Null when there is no number to check at all not an outcome, an absence,
* and a caller showing "invalid" for an empty field would be wrong.
*/
public function __invoke(Customer $customer): ?VatIdCheck
{
$vatId = $customer->normalisedVatId();
if ($vatId === '') {
// Removing the number removes the verification with it. Otherwise the
// stamp would sit on the record with nothing to compare against, and
// the next number typed in would look pre-verified for one save.
$this->clear($customer);
return null;
}
$check = $this->verifier->check($vatId);
// The whole reason VatIdCheck has three outcomes. An outage tells us
// nothing, so nothing is written: a verification that has been standing
// for months must not be cleared — and a customer's invoices changed —
// because a national register was down for the night.
if ($check->isUnavailable()) {
return $check;
}
if ($check->isInvalid()) {
$this->clear($customer);
return $check;
}
// Bound to the value that was checked, not to a flag. Customer::
// hasVerifiedVatId() compares the two, so editing the number afterwards
// cannot inherit this confirmation whichever code path does the editing.
$customer->forceFill([
'vat_id_verified_at' => now(),
'vat_id_verified_value' => $vatId,
])->save();
return $check;
}
private function clear(Customer $customer): void
{
if ($customer->vat_id_verified_at === null && $customer->vat_id_verified_value === null) {
return;
}
$customer->forceFill([
'vat_id_verified_at' => null,
'vat_id_verified_value' => null,
])->save();
}
}

View File

@ -1,548 +0,0 @@
<?php
namespace App\Actions;
use App\Models\Instance;
use App\Models\Invoice;
use App\Models\Operator;
use App\Models\Subscription;
use App\Models\SubscriptionAddon;
use App\Models\SubscriptionRecord;
use App\Services\Billing\IssueInvoice;
use App\Services\Billing\WithdrawalRight;
use App\Services\Stripe\StripeClient;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
use RuntimeException;
use Throwable;
/**
* A consumer changes their mind inside fourteen days, and everything that
* follows from it.
*
* One action, because a withdrawal is one event with four consequences and
* three of them are irreversible. Split across a controller, a command and a
* page, they would come apart: a service ended with no credit note, a refund
* sent for a contract still running, a document issued twice by two clicks.
*
* In order, and the order is the design:
*
* 1. **The right is checked**, by WithdrawalRight and nowhere else. A business
* customer is refused here, on the server, whatever the page did or did not
* show every door into this action is the same door.
* 2. **The withdrawal is claimed**, conditionally, on the contract. Two clicks
* in flight write one withdrawal between them; the loser is told it has
* already happened rather than sending a second refund.
* 3. **The paperwork**: EVERY invoice raised inside the window is CANCELLED by a
* Storno with its own number never edited, never deleted. That is the
* established rule for correcting an issued document, and a withdrawal is the
* clearest case there is for it. Every one of them, and not just the opening
* purchase: a module booked on day three and a renewal that fell inside the
* fourteen days are charges too, and they used to be left standing because
* they carry no `order_id` see chargesInsideTheWindow().
* 4. **The money**, which falls out of the paperwork rather than being
* computed beside it: the whole of what each cancelled document said, sent
* back against the payment that actually took it. FAGG §16 would let us keep
* the pro-rata value of the days the cloud ran, and the owner has decided not
* to a withdrawing consumer gets everything back. Two sums that must agree
* cannot disagree if only one of them is ever calculated, and here there is
* only the one: the Stornos'.
* 5. **The service ends**, through App\Actions\EndInstanceService and the
* `cancellation_scheduled` machinery it already reads the address comes
* down, the DNS record goes, the instance is marked ended. No second way
* for an instance to stop being served. Stripe is told to stop billing in
* the same breath, immediately: a withdrawal unwinds the contract, so there
* is no paid-up term to run out and no next cycle to raise.
*
* ## What happens when the money side fails
*
* It is caught, written onto the contract and logged as an error and the
* withdrawal still stands. The customer's declaration is what ends the
* contract; a payment provider being unreachable does not un-declare it, and
* leaving the service running because a refund timed out would charge them for
* days they have withdrawn from. `withdrawal_refund_error` is where an operator
* finds the ones that need a hand.
*/
class WithdrawContract
{
/** Recorded by the customer themselves, in the portal. */
public const CHANNEL_PORTAL = 'portal';
/** Taken by telephone or post and typed in by an operator. */
public const CHANNEL_OPERATOR = 'operator';
public function __construct(
private EndInstanceService $endService,
private BookAddon $bookAddon,
private IssueInvoice $invoices,
private StripeClient $stripe,
private RecordCommercialEvent $record,
) {}
/**
* @param string $channel one of the CHANNEL_* constants
* @param Operator|null $by the operator who took the declaration, when it did not come from the portal
*
* @throws RuntimeException with the sentence the customer is shown, when the right does not apply
*/
public function __invoke(
Subscription $subscription,
string $channel = self::CHANNEL_PORTAL,
?Operator $by = null,
?Carbon $at = null,
): Subscription {
$at ??= now();
$right = WithdrawalRight::for($subscription);
if (! $right->open) {
// The refusal is the customer's own sentence, not a developer's:
// whatever refuses — the portal, the console, a stale tab — says
// the same thing, in the same words.
throw new RuntimeException((string) ($right->refusal ?? __('withdrawal.refusal_expired')));
}
// The one atomic gate. Everything below happens exactly once because
// exactly one caller can turn this column over.
$claimed = Subscription::query()
->whereKey($subscription->getKey())
->whereNull('withdrawn_at')
->update([
'withdrawn_at' => $at,
'withdrawal_channel' => $channel,
'withdrawal_recorded_by' => $by?->id,
'updated_at' => now(),
]);
$subscription->refresh();
if ($claimed === 0) {
throw new RuntimeException(__('withdrawal.refusal_already'));
}
$refunded = $this->settle($subscription, $at);
$this->endEverything($subscription, $at);
$this->enterInRegister($subscription, $refunded, $at);
return $subscription->refresh();
}
/**
* The paperwork and the money, for every charge inside the window.
*
* One Storno per document and one refund per payment, because that is what
* each of the two things IS: a Storno mirrors exactly one invoice and carries
* its number, and a Stripe refund goes against exactly one payment. A single
* refund for the whole sum, sent against the opening payment, would be
* refused by Stripe for exceeding it which is the trap the old one-invoice
* version was hiding behind.
*
* Each charge is settled in its own try, so a module invoice whose payment
* cannot be reached does not stop the opening purchase from being refunded.
* What actually went back is totalled from the refunds that succeeded, never
* from what was intended a contract claiming money moved that did not is
* the one state nobody may discover three weeks later.
*
* @return int what actually went back to the customer, gross, in cents
*/
private function settle(Subscription $subscription, Carbon $at): int
{
$charges = $this->chargesInsideTheWindow($subscription, $at);
$refunded = 0;
$references = [];
$failed = false;
if ($charges->isEmpty()) {
// No document was ever issued for this purchase — the company
// details were incomplete when it was made, or it was a grant. There
// is nothing to cancel, so the refund is worked out from what was
// charged instead. Stated here rather than silently skipped: a
// withdrawal without paperwork is a withdrawal an accountant will
// ask about.
try {
$gross = max(0, $subscription->order?->chargedCents() ?? 0);
$reference = $this->refund(
$subscription,
$gross,
$this->paymentBehind(null, $subscription),
'clupilot-withdrawal-'.$subscription->uuid,
$at,
);
if ($reference !== null) {
$refunded += $gross;
$references[] = $reference;
}
} catch (Throwable $e) {
$this->parkMoneyFailure($subscription, $e);
$failed = true;
}
}
foreach ($charges as $invoice) {
try {
// Taken back in full, with its own gapless number, pointing at
// the one it cancels. The original keeps its number for ever.
$storno = $this->invoices->cancelling($invoice);
// The refund IS what the cancellation took back — never a second
// figure computed beside it, which would have to agree with the
// document and one day would not.
$gross = max(0, -(int) $storno->gross_cents);
$reference = $this->refund(
$subscription,
$gross,
$this->paymentBehind($invoice, $subscription),
// Keyed per DOCUMENT and not per contract. One key for the
// whole withdrawal would have Stripe replay the first
// refund's answer for the second, and the second charge
// would silently never be sent back at all.
'clupilot-withdrawal-'.$subscription->uuid.'-invoice-'.$invoice->id,
$at,
);
if ($reference !== null) {
$refunded += $gross;
$references[] = $reference;
}
} catch (Throwable $e) {
$this->parkMoneyFailure($subscription, $e);
$failed = true;
}
}
$subscription->update([
'withdrawal_refund_cents' => $refunded,
// Every reference, because there is a refund per charge and an
// operator reconciling this against Stripe needs all of them.
'withdrawal_refund_reference' => $references === []
? null
: mb_substr(implode(',', $references), 0, 250),
// Cleared only when nothing went wrong. parkMoneyFailure() has
// already written the reason otherwise, and clearing it here would
// erase the one note saying this withdrawal needs a hand.
...($failed ? [] : ['withdrawal_refund_error' => null]),
]);
return $refunded;
}
/**
* Every charge this contract raised inside the withdrawal window.
*
* `invoices.order_id` alone was the defect: only the opening purchase carries
* one. A renewal and a Stripe-invoice document are written against the
* CONTRACT and leave `order_id` null on purpose see
* IssueInvoice::forBilledPeriod() and forStripeInvoice(), which say why so a
* customer who booked a storage pack on day three and withdrew on day ten had
* that pack neither cancelled nor refunded. The owner's rule is that a
* withdrawing consumer gets EVERYTHING back, so every document has to be in
* this list.
*
* Both links, therefore, and both are needed: the opening invoice has an
* `order_id` and no `subscription_id`, and everything after it is the other
* way round.
*
* Cancellations are excluded by construction one is not an invoice that can
* be taken back and so is anything that already carries a Storno.
* IssueInvoice::cancelling() refuses the latter in its own right; excluding it
* here as well means a second withdrawal attempt finds nothing to do rather
* than raising an exception that would be parked as a money failure.
*
* Bounded at the moment of withdrawal rather than at fourteen days. It is the
* same set a withdrawal can only be declared inside the window, so every
* document this contract has is inside it and the bound that matters is
* "before the customer changed their mind", which is what `$at` is.
*
* @return Collection<int, Invoice>
*/
private function chargesInsideTheWindow(Subscription $subscription, Carbon $at): Collection
{
return Invoice::query()
->where(function ($query) use ($subscription) {
$query->where('subscription_id', $subscription->id);
if ($subscription->order_id !== null) {
$query->orWhere('order_id', $subscription->order_id);
}
})
->whereNull('cancels_invoice_id')
->whereNotIn(
'id',
Invoice::query()->whereNotNull('cancels_invoice_id')->select('cancels_invoice_id')
)
->where('created_at', '<=', $at)
->orderBy('id')
->get();
}
/**
* The Stripe payment a charge was settled by.
*
* Stripe refunds a PAYMENT, not a subscription and not an invoice, and each
* charge inside the window was a different payment: the checkout, a renewal,
* a module prorated in the middle of a term. So every document is sent back
* against the money that actually paid for it.
*
* The opening purchase is the one document with no Stripe invoice of its own
* it is written from the ORDER, which is where the checkout's PaymentIntent
* was recorded, falling back to the invoice that charged the card, resolved
* through Stripe once. A contract with neither was never paid for through
* Stripe at all (a grant, a contract opened by hand), and there is nothing to
* send back.
*/
private function paymentBehind(?Invoice $invoice, Subscription $subscription): ?string
{
if ($invoice?->stripe_invoice_id !== null) {
return $this->stripe->invoicePaymentReference((string) $invoice->stripe_invoice_id);
}
$order = $subscription->order;
$reference = $order?->stripe_payment_intent_id;
if ($reference === null && $order?->stripe_invoice_id !== null) {
$reference = $this->stripe->invoicePaymentReference((string) $order->stripe_invoice_id);
}
return $reference;
}
/**
* Put one charge's money back where it came from.
*
* @return string|null Stripe's reference for the refund, or null when there
* was nothing to send back
*/
private function refund(
Subscription $subscription,
int $grossCents,
?string $reference,
string $idempotencyKey,
Carbon $at,
): ?string {
if ($grossCents <= 0) {
return null;
}
if ($reference === null) {
throw new RuntimeException(
'No Stripe payment is recorded for this charge, so the refund has to be sent by hand.'
);
}
// A retry after a timeout that in fact went through replays Stripe's
// first answer rather than sending the money a second time — which is
// the single most expensive mistake this file could make.
$refundId = $this->stripe->refund($reference, $grossCents, idempotencyKey: $idempotencyKey);
Log::info('Refunded a withdrawal.', [
'subscription' => $subscription->uuid,
'refund' => $refundId,
'gross_cents' => $grossCents,
'withdrawn_at' => $at->toIso8601String(),
]);
return $refundId;
}
/**
* A withdrawn contract ends: the modules stop, the address comes down and
* the contract is closed.
*
* Through the machinery that already does this, never beside it. The
* instance is put into `cancellation_scheduled` with its service ending NOW
* which is what a withdrawal means, unlike a cancellation, where the
* customer keeps the term they paid for and EndInstanceService is asked
* the same question it is asked by the nightly sweep.
*
* The modules are ended outright rather than at the period end, for the same
* reason: there is no term left to keep. The customer has withdrawn from the
* contract the modules hang off, and BookAddon::cancel() takes them off
* Stripe and out of the register.
*
* The order of the two Stripe calls is the reason the subscription is stopped
* LAST and not first. BookAddon::cancel() takes each module's item off the
* subscription, and an item cannot be removed from a subscription that no
* longer exists so cancelling the whole thing first would make every one of
* those removals fail and park a sync error on a contract that is already
* over.
*/
private function endEverything(Subscription $subscription, Carbon $at): void
{
foreach (SubscriptionAddon::query()->where('subscription_id', $subscription->id)->active()->get() as $addon) {
$this->bookAddon->cancel($addon);
}
$this->stopBilling($subscription);
$instance = $this->instanceOf($subscription);
if ($instance !== null && $instance->status !== 'ended') {
$instance->update([
'status' => 'cancellation_scheduled',
'cancel_requested_at' => $at,
// Now, not the end of the term. A withdrawal unwinds the
// contract; there is no paid-up period to run out.
'service_ends_at' => $at,
]);
($this->endService)($instance->refresh());
}
$subscription->update([
'status' => 'cancelled',
'cancelled_at' => $at,
]);
}
/**
* Tell Stripe to stop, now.
*
* The call nothing in this application used to make. A withdrawal marked the
* contract cancelled, ended the service and sent the money back, and the
* subscription at Stripe went on charging the card every month for a
* machine that had been switched off, with `invoice.paid` arriving here each
* time and drawing a real invoice number for it.
*
* Immediately and not at the period end, which is the whole difference
* between this and a customer's own cancellation: a withdrawal unwinds the
* contract from the beginning, the entire amount has just gone back, and
* there is no paid-up term left for the customer to use up.
*
* A failure does not un-declare the withdrawal same principle as the refund,
* for the same reason but it is the most urgent thing on this contract, so
* it is said on the contract as well as in the log. Written after settle(),
* so the note cannot be cleared by a refund that succeeded.
*/
private function stopBilling(Subscription $subscription): void
{
// Nothing to stop, and nothing has gone wrong. A granted package was
// never sold through Stripe — GrantSubscription leaves the id null on
// purpose — and neither was a contract opened by hand.
if ($subscription->stripe_subscription_id === null) {
return;
}
try {
$this->stripe->cancelSubscription(
(string) $subscription->stripe_subscription_id,
StripeClient::CANCEL_IMMEDIATELY,
// Keyed on the contract, because a contract is withdrawn from
// once. A retry after a timeout replays Stripe's first answer.
idempotencyKey: 'clupilot-withdrawal-stop-'.$subscription->uuid,
);
} catch (Throwable $e) {
Log::error('A withdrawal was declared and Stripe was not told to stop charging for it.', [
'subscription' => $subscription->uuid,
'stripe_subscription' => $subscription->stripe_subscription_id,
'error' => $e->getMessage(),
]);
$this->note($subscription, 'Stripe was not told to stop: '.$e->getMessage());
}
}
/**
* The machine this contract pays for.
*
* `subscriptions.instance_id` is the link, and it is written when resources
* are reserved so a contract withdrawn from before the build got that far
* has none. The order and then the customer are the fallbacks, in that
* order, because an order belongs to exactly one purchase and a customer can
* have more than one instance behind them.
*/
private function instanceOf(Subscription $subscription): ?Instance
{
if ($subscription->instance !== null) {
return $subscription->instance;
}
if ($subscription->order_id !== null) {
$byOrder = Instance::query()->where('order_id', $subscription->order_id)->latest('id')->first();
if ($byOrder !== null) {
return $byOrder;
}
}
return Instance::query()
->where('customer_id', $subscription->customer_id)
->whereIn('status', ['active', 'provisioning', 'cancellation_scheduled'])
->latest('id')
->first();
}
/**
* The refund did not go out. Said on the contract, not only in a log.
*
* What this describes is a customer who has withdrawn, whose service has
* stopped, and who has not had their money back the one state nobody may
* discover from a log file three weeks later.
*/
private function parkMoneyFailure(Subscription $subscription, Throwable $e): void
{
Log::error('A withdrawal was declared but the money did not go back.', [
'subscription' => $subscription->uuid,
'customer' => $subscription->customer_id,
'error' => $e->getMessage(),
]);
$this->note($subscription, $e->getMessage());
}
/**
* Write a sentence an operator has to read onto the contract itself.
*
* Appended rather than overwritten, because a withdrawal can go wrong in more
* than one way at once a module invoice whose payment could not be reached
* AND an order to stop billing that Stripe refused and an operator who read
* only the last of them would send the money back and leave the customer
* subscribed.
*/
private function note(Subscription $subscription, string $sentence): void
{
$existing = trim((string) $subscription->withdrawal_refund_error);
$subscription->update([
'withdrawal_refund_error' => mb_substr(
$existing === '' ? $sentence : $existing.' | '.$sentence,
0,
250,
),
]);
}
/**
* The entry that says this happened, in the register that cannot be edited.
*
* Negative, because it is revenue leaving. The gross is what actually went
* back, so a withdrawal that could not be refunded records a zero and the
* `withdrawal_refund_error` beside it says why rather than claiming money
* moved that did not.
*/
private function enterInRegister(Subscription $subscription, int $refundedGross, Carbon $at): void
{
($this->record)(
event: SubscriptionRecord::EVENT_WITHDRAWAL,
subscription: $subscription->refresh(),
netCents: -(int) $subscription->price_cents,
at: $at,
extra: ['withdrawal' => [
'channel' => $subscription->withdrawal_channel,
// Nothing is charged for them — the refund is the whole amount —
// but how long the service actually ran is a fact about this
// contract, and the register is where facts about it are kept.
'delivered_days' => WithdrawalRight::deliveredDays($subscription, $at),
'term_days' => WithdrawalRight::termDays($subscription),
'refunded_gross_cents' => $refundedGross,
]],
chargedGrossCents: -$refundedGross,
eventKey: 'withdrawal:'.$subscription->uuid,
);
}
}

View File

@ -1,118 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Actions\ApplyPlanChange;
use App\Models\Order;
use App\Models\Subscription;
use Illuminate\Console\Command;
use Throwable;
/**
* Carries out the plan changes whose moment has come.
*
* An upgrade lands when it is paid for see App\Observers\OrderObserver. A
* downgrade cannot: someone on a yearly contract bought a year, so moving them
* down in month three would take away what they have already paid for. It waits
* for the end of the term, and nothing was waiting with it. This is what does.
*
* **How a pending downgrade is known.** By the contract, which carries the
* package it is moving to and the date that move comes due `pending_plan` and
* `pending_effective_at`, stamped by the shop the moment the customer books it.
*
* This used to be read off the pending Order instead, with the due date worked
* out from `subscriptions.current_period_end` on every tick. That date is not a
* fact about the booking, it is a fact about the billing cycle, and Stripe moves
* it forward on every renewal: a downgrade booked in March was pushed to the end
* of April by April's renewal, then to the end of May by May's, and the customer
* went on paying for the package they had asked to leave. A due date has to be
* decided once, at the moment the customer decides so it is stamped then, and
* only read afterwards.
*
* The order is still the customer's own record of the request it is what the
* cart shows them and what they remove to change their mind so it is consumed
* here alongside the contract, and removing it clears the stamp (see
* App\Livewire\ConfirmRemoveOrder). What it no longer does is decide WHEN.
*
* **Safe to run as often as the scheduler likes.** A contract is picked up only
* while its stamped date has passed and it is still active, and the stamp is
* cleared the moment the change has landed so a second tick finds nothing
* left to do rather than doing it again.
*/
class ApplyDuePlanChanges extends Command
{
protected $signature = 'clupilot:apply-due-plan-changes';
protected $description = 'Apply scheduled downgrades whose term has run out';
public function handle(ApplyPlanChange $apply): int
{
$applied = 0;
$due = 0;
// Cancelled contracts are left where they are, and not merely because
// ApplyPlanChange would refuse them: a customer who has left is not
// moved onto a smaller package on their way out, and the booking they
// made goes with the contract it was made on.
$contracts = Subscription::query()
->whereNotNull('pending_plan')
->whereNotNull('pending_effective_at')
->where('pending_effective_at', '<=', now())
->where('status', 'active')
->with('customer')
->orderBy('id')
->cursor();
foreach ($contracts as $subscription) {
$due++;
$plan = (string) $subscription->pending_plan;
try {
$apply($subscription, $plan, $this->orderFor($subscription, $plan));
// Read back rather than assumed: a package the catalogue has
// since withdrawn cannot be moved onto, and ApplyPlanChange
// says so by logging and leaving the contract alone. Clearing
// the stamp there would drop the customer's request silently,
// so it stays booked and this says so on every tick.
$subscription->refresh();
if ((string) $subscription->plan !== $plan) {
$this->warn("Contract {$subscription->uuid}: still on {$subscription->plan}, {$plan} was not applied.");
continue;
}
$subscription->clearPendingPlanChange();
$applied++;
} catch (Throwable $e) {
// One customer's withdrawn package must not stop everybody
// else's downgrade from landing.
$this->warn("Contract {$subscription->uuid}: {$e->getMessage()}");
}
}
$this->info("{$due} downgrade(s) due, {$applied} applied.");
return self::SUCCESS;
}
/**
* The cart entry this booking came from, so it is consumed with the change
* and the register row files under it.
*
* Null is a perfectly ordinary answer an operator can book a change
* without a purchase, and the contract is the authority on what was agreed
* either way.
*/
private function orderFor(Subscription $subscription, string $plan): ?Order
{
return Order::query()
->where('customer_id', $subscription->customer_id)
->where('type', 'downgrade')
->where('status', 'pending')
->where('plan', $plan)
->orderBy('id')
->first();
}
}

View File

@ -1,175 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Models\Instance;
use App\Models\Order;
use App\Models\ProvisioningRun;
use App\Provisioning\Jobs\AdvanceRunJob;
use App\Services\Billing\StorageAllowance;
use Illuminate\Console\Command;
/**
* Give the existing estate the storage allowance it was already sold.
*
* ApplyStorageQuota now runs in the `customer` and `plan-change` pipelines, so
* everything built or changed from here on is enforced on the machine. Every
* instance from before that is not: `quota_gb` reached the row and stopped
* there, Nextcloud was never told, and those customers still have the whole disk
* whatever they are paying for. Nothing in the product could reach them, because
* the two pipelines only run on a build or a change and these machines are
* having neither.
*
* This is the sweep, and it is a REPAIR, not a routine. It is deliberately NOT
* in the scheduler:
*
* - the hole it closes is finite and closes for good. Once an instance's
* allowance is enforced and recorded, both pipelines keep it that way;
* - a nightly sweep would be a second authority writing the same value onto
* live machines on a timer, and the day the step in the pipeline silently
* stopped working the sweep would quietly cover for it the failure would
* never surface, which is how the original hole survived as long as it did;
* - a repair the owner runs is a repair the owner can read the output of. This
* one prints what it did and what it did not, and refuses to touch anything
* at all under --dry-run.
*
* The work itself goes through the `quota` pipeline rather than reaching into
* guests here: one run per instance, retried, logged and visible in the console
* like every other piece of remote work, and running the exact same step the
* other two pipelines run so a second implementation cannot drift from it.
*/
class ApplyStorageQuotas extends Command
{
protected $signature = 'clupilot:apply-quotas
{--dry-run : list what would happen and change nothing}
{--instance= : one instance uuid, for a single repair}';
protected $description = 'Apply the sold storage allowance to instances whose Nextcloud was never told about it';
public function handle(): int
{
$dryRun = (bool) $this->option('dry-run');
$started = 0;
/** @var array<string, int> reason => count */
$skipped = [];
$query = Instance::query()->with(['order', 'host']);
if ($uuid = $this->option('instance')) {
$query->where('uuid', $uuid);
}
foreach ($query->cursor() as $instance) {
$reason = $this->reasonToSkip($instance);
if ($reason !== null) {
$skipped[$reason] = ($skipped[$reason] ?? 0) + 1;
continue;
}
$started++;
// The whole allowance, packs included — the figure the run will
// actually write. Printing `quota_gb` would report the package while
// the sweep applied something larger.
$owed = StorageAllowance::for($instance)->totalGb();
$this->line(($dryRun ? '[dry-run] ' : '')
."{$instance->subdomain}: {$owed} GB "
.'('.($instance->quota_applied_gb === null ? 'noch nie gesetzt' : "zuletzt {$instance->quota_applied_gb} GB").')');
if (! $dryRun) {
$this->startQuotaRun($instance);
}
}
foreach ($skipped as $reason => $count) {
$this->line("übersprungen ({$reason}): {$count}");
}
$this->info($dryRun
? "Probelauf: {$started} Instanz(en) bekämen ihr Kontingent, ".array_sum($skipped).' übersprungen. Nichts wurde geändert.'
: "{$started} Kontingent-Lauf/Läufe gestartet, ".array_sum($skipped).' übersprungen.');
return self::SUCCESS;
}
/**
* Why this instance is being left alone, or null when it is not.
*
* The reason is the report a sweep that says "skipped 34" and nothing else
* is a sweep nobody can act on, because "34 machines have no allowance
* enforced" and "34 machines are already fine" look identical from here.
*/
private function reasonToSkip(Instance $instance): ?string
{
// Nothing remote work can reach: a reservation with no VM, a failed
// build, an ended service. The same rule every other maintenance action
// asks — see Instance::hasLiveMachine().
if (! $instance->hasLiveMachine()) {
return 'keine erreichbare Maschine';
}
// No allowance on the row at all. Writing "0 GB" would lock the customer
// out of their own files, which is a far worse answer than leaving what
// is there and letting somebody notice — the same decision the step
// itself makes, made here too so the machine is not visited for nothing.
if ((int) $instance->quota_gb <= 0) {
return 'kein Kontingent hinterlegt';
}
if ($instance->quotaIsEnforced()) {
return 'bereits gesetzt';
}
// Whatever is in flight against this order gets to finish. A quota run
// beside an unfinished build would race the same occ calls, and the
// build applies the quota itself anyway.
if ($this->hasRunInFlight($instance)) {
return 'anderer Lauf aktiv';
}
return null;
}
private function startQuotaRun(Instance $instance): void
{
$run = ProvisioningRun::create([
// The instance's own purchase order, as every customer pipeline
// uses — that is what CustomerStep::order() and ::instance()
// resolve from, and what the in-flight check above looks at.
'subject_type' => Order::class,
'subject_id' => $instance->order_id,
'pipeline' => 'quota',
'status' => ProvisioningRun::STATUS_PENDING,
'current_step' => 0,
'context' => [
'instance_id' => $instance->id,
'host_id' => $instance->host_id,
'node' => $instance->host?->node,
'vmid' => $instance->vmid,
'subdomain' => $instance->subdomain,
],
]);
AdvanceRunJob::dispatch($run->uuid);
}
/**
* ANY run, deliberately unlike the narrower "will it do this work?" question
* App\Provisioning\WorkInFlight answers for the two actions that must not lose
* a change they were asked to make. This is a repair sweep: it runs again, it
* skips instances whose quota is already enforced, and an instance passed over
* today is picked up on the next pass. Nothing is forgotten by waiting.
*/
private function hasRunInFlight(Instance $instance): bool
{
return ProvisioningRun::query()
->where('subject_type', Order::class)
->where('subject_id', $instance->order_id)
->inFlight()
->exists();
}
}

View File

@ -1,63 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Jobs\ArchiveInvoice;
use App\Models\Invoice;
use App\Models\ExportTarget;
use Illuminate\Console\Command;
/**
* The sweep behind the copy-on-issue.
*
* Copying at issue is right and it is not enough. An invoice issued while the
* office connection was down never reaches the archive, the queue eventually
* gives up, and nothing about the invoice tells anybody nobody opens an
* archive to check whether last Tuesday is in it. This picks up whatever is
* still missing, however long ago it was issued.
*
* Deliberately re-queues rather than writing here: the job already knows how to
* write one, and two code paths that both write an invoice into an archive is
* one more than the number that can be kept correct.
*/
class ArchiveUnexportedInvoices extends Command
{
protected $signature = 'clupilot:archive-invoices {--limit=200}';
protected $description = 'Queue a copy of every invoice that is not on the archive yet';
public function handle(): int
{
$targets = ExportTarget::query()->where('active', true)->get();
if ($targets->isEmpty()) {
$this->line('No export target configured — nothing to do.');
return self::SUCCESS;
}
$queued = 0;
foreach ($targets as $target) {
// Per destination, because "arrived" is a fact about a pair. An
// invoice on the office NAS and not on the off-site box is exactly
// the case this exists to catch.
$pending = Invoice::query()
->whereDoesntHave('exports', fn ($q) => $q
->where('export_target_id', $target->id)
->whereNotNull('exported_at'))
->orderBy('id')
->limit((int) $this->option('limit'))
->get();
foreach ($pending as $invoice) {
ArchiveInvoice::dispatch($invoice, $target);
$queued++;
}
}
$this->line("Queued {$queued} copy/copies for {$targets->count()} destination(s).");
return self::SUCCESS;
}
}

View File

@ -1,52 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Services\Deployment\UpdateChannel;
use App\Services\Deployment\UpdateWindow;
use Illuminate\Console\Command;
/**
* Takes an available update, inside the window, if the owner asked for that.
*
* It leaves exactly the same request the button leaves same file, same agent,
* same run. There is no second path into a deployment, because a second path is
* a second set of rules to keep in step with the first.
*
* Nothing here checks whether an update already ran today. It does not need to:
* an installation that is current has nothing available, so an operator who
* pressed the button at four finds the five o'clock window with nothing to do.
*/
class AutoUpdate extends Command
{
protected $signature = 'clupilot:auto-update {--force : ignore the window, for testing the wiring}';
protected $description = 'Install an available release during the configured update window';
public function handle(UpdateWindow $window, UpdateChannel $channel): int
{
if (! $window->enabled()) {
return self::SUCCESS;
}
if (! $this->option('force') && ! $window->isOpen()) {
return self::SUCCESS;
}
$state = $channel->state();
// The agent has to be alive, there has to be something to install, and
// nothing may be running. All three come from the same state the button
// is disabled by, so the automation cannot do what an operator is
// prevented from doing by hand.
if (! $state['agent_seen'] || ! $state['available'] || $state['running']) {
return self::SUCCESS;
}
if ($channel->request(__('admin_settings.update_by_schedule'))) {
$this->info('Requested an update inside the window.');
}
return self::SUCCESS;
}
}

View File

@ -1,36 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Services\Proxy\CertificateSweep;
use Illuminate\Console\Command;
/**
* Täglich: welche Namen bedient diese Installation, und wie lange gelten ihre
* Zertifikate noch.
*
* Nach Zeitplan und nicht nur auf Knopfdruck, weil die Frage, auf die es
* ankommt, nicht „ist es gerade gültig" lautet, sondern „läuft die Erneuerung".
* Die beantwortet nur eine Messung, die auch dann läuft, wenn niemand hinsieht.
*/
class CheckCertificates extends Command
{
protected $signature = 'clupilot:check-certificates';
protected $description = 'Syncs the known hostnames and measures their certificates';
public function handle(CertificateSweep $sweep): int
{
$added = $sweep->sync();
$result = $sweep->check();
$this->info(sprintf(
'%d hostname(s) added, %d checked, %d without a valid certificate',
$added,
$result['checked'],
$result['failing'],
));
return self::SUCCESS;
}
}

View File

@ -1,75 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Models\DnsRecord;
use App\Models\Instance;
use App\Models\MonitoringTarget;
use App\Support\ProvisioningSettings;
use Illuminate\Console\Command;
/**
* What changing the instance zone would touch, before anything is touched.
*
* The zone is one setting, and changing it silently re-addresses every
* instance: `subdomain` holds only the label, and every caller composes
* `label.zone` at read time. So the moment the setting changes, the console,
* the portal, the acceptance checks and the mails all name an address that
* does not resolve while the DNS records, the certificates and Nextcloud's
* trusted_domains still hold the old one.
*
* Harmless with no instances, which is exactly why it is worth doing now. This
* says which it is.
*/
class CheckDnsZone extends Command
{
protected $signature = 'clupilot:check-zone {zone? : the zone you are considering}';
protected $description = 'Report what a change of the instance DNS zone would affect';
public function handle(): int
{
$current = ProvisioningSettings::dnsZone();
$target = (string) ($this->argument('zone') ?? '');
$this->line('');
$this->line(" Current zone: <options=bold>{$current}</>");
$this->line(' Proposed zone: '.($target !== '' ? "<options=bold>{$target}</>" : '<comment>none given</comment>'));
$this->line('');
$instances = Instance::query()
->whereIn('status', ['active', 'provisioning', 'cancellation_scheduled'])
->get();
if ($instances->isEmpty()) {
$this->info(' No instances in service. The zone can be changed with nothing to migrate.');
$this->line('');
return self::SUCCESS;
}
$this->warn(" {$instances->count()} instance(s) are addressed under the current zone.");
$this->line('');
$this->line(' Each of these carries the old name in places this setting does NOT reach:');
$this->line('');
$records = DnsRecord::query()->whereIn('instance_id', $instances->pluck('id'))->count();
$targets = MonitoringTarget::query()->whereIn('instance_id', $instances->pluck('id'))->count();
$verified = $instances->filter(fn (Instance $i) => $i->domainIsVerified())->count();
$this->table(['What', 'Count', 'Consequence of changing the zone'], [
['DNS records (dns_records.fqdn)', $records, 'still point the OLD name at the host'],
['Monitoring targets', $targets, 'keep watching the old URL and report down'],
['TLS certificates', $instances->count(), 'issued for the old name; the new one has none'],
['Nextcloud trusted_domains', $instances->count(), 'rejects the new name until rewritten'],
['Instances on their own domain', $verified, 'unaffected — they are served at custom_domain'],
]);
$this->line('');
$this->line(' A change now is a migration with an outage, not a setting. Either do it');
$this->line(' before the first customer, or plan it as a maintenance window.');
$this->line('');
return self::SUCCESS;
}
}

View File

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

View File

@ -1,145 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Http\Middleware\RestrictConsoleNetwork;
use App\Support\Settings;
use Illuminate\Console\Command;
/**
* The way back in when the console has locked its owner out.
*
* The allowlist is managed in the console which is fine until the address you
* manage it from changes, and then the page that would fix the problem is the
* page the problem blocks. Every gate needs a door that does not depend on
* itself, and on a server that door is a shell.
*/
class ConsoleAccess extends Command
{
protected $signature = 'clupilot:console-access
{action=show : show|allow|deny|open|close|caddy}
{value? : an address or CIDR, for allow and deny}';
protected $description = 'Show or change who may reach the operator console';
public function handle(): int
{
$action = (string) $this->argument('action');
$value = trim((string) $this->argument('value'));
return match ($action) {
'show' => $this->show(),
'allow' => $this->allow($value),
'deny' => $this->deny($value),
'open' => $this->setRestricted(false),
'close' => $this->setRestricted(true),
'caddy' => $this->caddy(),
default => $this->refuse("Unknown action: {$action}"),
};
}
/**
* The allowlist as a Caddy matcher, for the reverse proxy to import.
*
* The proxy has its own allowlist, hard-coded, and it runs FIRST so
* everything the owner adds in the console has no effect whatsoever, and
* the console's own access page is a decoration. Worse, when the owner's
* address changes they are turned away by the proxy before the application
* they could have fixed it in is ever reached.
*
* Emitting the matcher from the same list the console manages makes the
* console the single authority. The agent on the host writes this out and
* reloads the proxy.
*/
private function caddy(): int
{
// Always the allowlist — never 0.0.0.0/0, not even when the console's
// own restriction is switched off.
//
// The owner's rule is absolute: the console is reachable over the
// management VPN, or from an address they have listed, and from nowhere
// else. Letting the console's "open" switch also open the PROXY would
// put the console on the public internet with one click, which is
// exactly the state that rule exists to prevent. Switching it off
// relaxes the application's own check; the proxy keeps its list.
$ranges = RestrictConsoleNetwork::allowedRanges();
// Never empty: an empty remote_ip matcher matches NOTHING in Caddy, and
// the console would become unreachable from anywhere at all — including
// from the place someone would fix it. Loopback always survives, so a
// shell on the box is always a way back.
if ($ranges === []) {
$ranges = ['127.0.0.1', '::1'];
}
$this->line('# Generated from the console allowlist — do not edit by hand.');
$this->line('# Regenerated by deploy/update-agent.sh; edit it in the console.');
$this->line('@allowed remote_ip '.implode(' ', $ranges));
return self::SUCCESS;
}
private function show(): int
{
$this->line(' restricted : '.(RestrictConsoleNetwork::isRestricted() ? 'yes' : 'no — anyone reaching the hostname gets in'));
$this->line(' always : '.implode(', ', (array) config('admin_access.trusted_ranges', [])).' (VPN, not removable)');
$own = (array) Settings::get('console.allowed_ips', []);
$this->line(' additional : '.($own === [] ? '(none)' : implode(', ', $own)));
return self::SUCCESS;
}
private function allow(string $value): int
{
if ($value === '') {
return $this->refuse('Give an address or range: clupilot:console-access allow 203.0.113.7');
}
// An entry that matches nothing would be stored, reported as success,
// and leave whoever is recovering still locked out.
if (! RestrictConsoleNetwork::isNetwork($value)) {
return $this->refuse("Not an address or a range: {$value}");
}
$list = (array) Settings::get('console.allowed_ips', []);
if (! in_array($value, $list, true)) {
$list[] = $value;
Settings::set('console.allowed_ips', array_values($list));
}
$this->info("{$value} may now reach the console.");
return $this->show();
}
private function deny(string $value): int
{
Settings::set('console.allowed_ips', array_values(array_filter(
(array) Settings::get('console.allowed_ips', []),
fn ($entry) => $entry !== $value,
)));
$this->info("{$value} removed.");
return $this->show();
}
private function setRestricted(bool $on): int
{
// No lock-out check here on purpose: this command IS the recovery path,
// and it is only reachable by someone who already has the server.
Settings::set('console.network_restricted', $on);
$this->info($on ? 'Console restricted to the VPN and the listed addresses.' : 'Restriction lifted.');
return $this->show();
}
private function refuse(string $message): int
{
$this->error($message);
return self::FAILURE;
}
}

View File

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

View File

@ -1,75 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Actions\BookAddon;
use App\Models\SubscriptionAddon;
use Illuminate\Console\Command;
use Throwable;
/**
* End the modules whose cancellation date has arrived.
*
* A module is cancelled monthly and the cancellation lands at the end of the
* term the customer has already paid for the same rule a package downgrade
* follows, and for the same reason: what has been paid for is kept. So
* BookAddon::cancelAtPeriodEnd() writes an appointment on the booking and leaves
* the module running. This keeps it.
*
* Stripe has already been told at the moment of cancellation; there is no item
* left to remove here. What ends now is the module itself the entitlement, and
* for a storage pack the space on the machine and the entry in the proof
* register that says the customer stopped paying for it.
*
* Safe to run as often as the scheduler likes: BookAddon::cancel() claims the
* cancellation conditionally, so a second tick over a module that has already
* ended writes nothing and records nothing.
*/
class EndCancelledAddons extends Command
{
protected $signature = 'clupilot:end-cancelled-addons
{--dry-run : list what would end and change nothing}';
protected $description = 'End the booked modules whose cancellation date has passed';
public function handle(BookAddon $bookAddon): int
{
$dryRun = (bool) $this->option('dry-run');
$ended = 0;
$due = SubscriptionAddon::query()
->whereNull('cancelled_at')
->whereNotNull('cancels_at')
->where('cancels_at', '<=', now())
->with('subscription')
->orderBy('id')
->get();
foreach ($due as $addon) {
$this->line(sprintf(
' %s on contract %s',
(string) $addon->addon_key,
(string) ($addon->subscription?->uuid ?? '—'),
));
if ($dryRun) {
continue;
}
try {
$bookAddon->cancel($addon);
$ended++;
} catch (Throwable $e) {
// One customer's module must not stop everybody else's from
// ending. It stays due and the next tick tries again.
$this->warn(" {$addon->uuid}: {$e->getMessage()}");
}
}
$this->info($dryRun
? "{$due->count()} module(s) due to end. Run without --dry-run to end them."
: "{$due->count()} module(s) due, {$ended} ended.");
return self::SUCCESS;
}
}

View File

@ -1,74 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Actions\EndInstanceService;
use App\Models\Instance;
use Illuminate\Console\Command;
/**
* End the services whose cancellation date has arrived.
*
* ConfirmCancelPackage schedules an ending status `cancellation_scheduled`,
* `service_ends_at` at the close of the paid period and nothing in the
* application ever kept the appointment. That is why `TraefikWriter::remove()`
* had no caller: there was no moment at which an instance ended, so there was
* never a moment at which its router came down. Cancelled instances kept their
* hostname, their certificate and their route to a guest address the host may
* later hand to a different customer.
*
* This is the appointment. It ends nothing early: EndInstanceService::hasEnded()
* is the whole rule and a `cancellation_scheduled` instance whose date is still
* in the future is left exactly alone see that class for why that distinction
* is the important one here.
*
* Hourly. The end of a paid term is a moment, and a sweep that ran once a night
* would hand out up to a day of service nobody decided to give while running
* it every minute would mean a provider's bad five seconds became a failed job
* sixty times over. Nothing here is taken away that the customer did not
* themselves ask to give up, on a date they were shown, so there is no bad hour
* for it.
*/
class EndDueServices extends Command
{
protected $signature = 'clupilot:end-due-services
{--dry-run : list what would end and change nothing}';
protected $description = 'Withdraw the address of every instance whose cancellation date has passed';
public function handle(EndInstanceService $end): int
{
$dryRun = (bool) $this->option('dry-run');
$ended = 0;
// Narrowed in the query for the sake of the database, decided by
// hasEnded() for the sake of the rule: the query is an index, not a
// second opinion.
$due = Instance::query()
->with(['host', 'dnsRecords'])
->where('status', 'cancellation_scheduled')
->whereNotNull('service_ends_at')
->where('service_ends_at', '<=', now())
->cursor();
foreach ($due as $instance) {
if (! $end->hasEnded($instance)) {
continue;
}
$this->line(($dryRun ? '[dry-run] ' : '')
."{$instance->subdomain}: Laufzeit endete am "
.$instance->service_ends_at->local()->isoFormat('LLL'));
if ($dryRun || $end($instance)) {
$ended++;
}
}
$this->info($dryRun
? "Probelauf: {$ended} Dienst(e) würden beendet. Nichts wurde geändert."
: "{$ended} Dienst(e) beendet.");
return self::SUCCESS;
}
}

View File

@ -1,29 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Services\Mail\IngestInboundMail;
use Illuminate\Console\Command;
/**
* Read the support mailbox into the console.
*
* Runs on the scheduler. A mail server has no way to tell us a message arrived
* IMAP is a mailbox you look in so this is polling, and the interval is the
* honest answer to "how long until I see it".
*/
class FetchInboundMail extends Command
{
protected $signature = 'clupilot:fetch-mail {--limit=50 : How many messages to take in one run}';
protected $description = 'Fetch new customer mail from the support mailbox';
public function handle(IngestInboundMail $ingest): int
{
$taken = $ingest((int) $this->option('limit'));
$this->info($taken === 0 ? 'Nothing new.' : $taken.' new message(s).');
return self::SUCCESS;
}
}

View File

@ -1,38 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Models\ProxyHost;
use Illuminate\Console\Command;
/**
* Gibt die Hostnamen aus, die der Reverse Proxy bedienen soll eine je Zeile,
* mit ihrem Zweck.
*
* NUR die Liste, nicht die fertige Caddy-Konfiguration. Das ist der ganze Punkt.
*
* `install-agent.sh` begründet für die sudoers-Freigabe: „a grant is only worth
* anything if the holder cannot change what it grants." Dürfte diese Anwendung
* Caddy-Blöcke schreiben, die ein root-Helfer ungeprüft einbaut, hätte sie über
* den Umweg der Konfiguration alles, was der Proxy kann Weiterleitungen
* irgendwohin, Dateiauslieferung aus jedem Verzeichnis. Sie gibt deshalb NAMEN
* aus, und die Vorlage darum herum steht im root-eigenen Helfer, wo diese
* Anwendung sie nicht anfassen kann.
*
* Format je Zeile: `<hostname> <purpose>`.
*/
class ProxyHostList extends Command
{
protected $signature = 'clupilot:proxy-hosts';
protected $description = 'Lists the hostnames the reverse proxy should serve, one per line';
public function handle(): int
{
foreach (ProxyHost::query()->orderBy('hostname')->get() as $host) {
$this->line($host->hostname.' '.$host->purpose);
}
return self::SUCCESS;
}
}

View File

@ -1,206 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Mail\DormantAccountWarningMail;
use App\Models\User;
use App\Models\UserDevice;
use Illuminate\Console\Command;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
use Throwable;
/**
* A confirmed account that has never had a package, removed after a year.
*
* The other sweep (PruneUnverifiedAccounts) is about registrations nobody
* confirmed and takes five days. This one is about the opposite case: somebody
* confirmed their address, looked around, never bought anything, and never came
* back. A year later there is a login, an address and a password hash on file
* for no purpose at all which is a data-protection question, not tidiness.
*
* ## What "no package" means, and why it is drawn this wide
*
* No instance, no contract, no order, no invoice ever, not merely "not now".
* A customer who cancelled last month has invoices, and invoices have to be kept
* for seven years (§ 132 BAO). Their login is part of how they reach those
* invoices, so nothing with commercial history is touched here. Both links
* between `users` and `customers` count, by id and by address, for the same
* reason as in the other sweep.
*
* ## The clock is reset by signing in
*
* Measured from the LATEST of the registration and the last time any device was
* seen (`user_devices.last_seen_at`) so a person who signs in once a year
* keeps their account, which is what the terms promise. There is no
* `last_login_at` on users, and the device rows already carry exactly this.
*
* ## Nobody is deleted without being told
*
* A warning goes out a fortnight before, once, and `dormant_warned_at` records
* it. An account is only removed when that warning is on file and old enough
* so a run that has never warned anybody deletes nobody, and a mail server that
* was down that night delays the deletion instead of skipping the warning.
*/
class PruneDormantAccounts extends Command
{
/** How long an account with no package is kept. */
public const AFTER_DAYS = 365;
/** How long before that the warning goes out. */
public const WARN_DAYS_BEFORE = 14;
protected $signature = 'clupilot:prune-dormant {--dry-run : Only say what would happen}';
protected $description = 'Warn, then delete confirmed accounts that never had a package';
public function handle(): int
{
$dry = (bool) $this->option('dry-run');
$warned = $this->sendWarnings($dry);
$removed = $this->remove($dry);
$this->info($dry
? "would warn {$warned}, would remove {$removed}."
: "{$warned} warned, {$removed} removed.");
return self::SUCCESS;
}
/**
* The fortnight's notice.
*
* Not called warn(): Command::warn() is the framework's "print a warning
* line" and overriding it with something private breaks the class outright.
*/
private function sendWarnings(bool $dry): int
{
$due = $this->dormant()
->whereNull('dormant_warned_at')
->get()
->filter(fn (User $user) => $this->dormantSince($user)
->lte(now()->subDays(self::AFTER_DAYS - self::WARN_DAYS_BEFORE)));
foreach ($due as $user) {
if ($dry) {
$this->line('would warn: '.$user->email);
continue;
}
try {
Mail::to($user->email)->queue(new DormantAccountWarningMail($user, self::WARN_DAYS_BEFORE));
} catch (Throwable $e) {
// Not stamped: the stamp is what permits the deletion, and
// stamping a mail that never went out would delete an account
// whose owner was never told.
Log::warning('Could not warn a dormant account', [
'email' => $user->email,
'exception' => $e->getMessage(),
]);
continue;
}
$user->forceFill(['dormant_warned_at' => now()])->save();
}
return $due->count();
}
/** The deletion itself, a fortnight after the warning at the earliest. */
private function remove(bool $dry): int
{
$due = $this->dormant()
->whereNotNull('dormant_warned_at')
->where('dormant_warned_at', '<=', now()->subDays(self::WARN_DAYS_BEFORE))
->get()
->filter(fn (User $user) => $this->dormantSince($user)
->lte(now()->subDays(self::AFTER_DAYS)));
foreach ($due as $user) {
if ($dry) {
$this->line('would remove: '.$user->email.' (dormant since '.$this->dormantSince($user)->toDateString().')');
continue;
}
// Logged individually rather than as a count: an address that
// disappears is the kind of thing somebody asks about later, and a
// number in a log answers nothing.
Log::info('Removing a dormant account', [
'email' => $user->email,
'registered_at' => $user->created_at?->toIso8601String(),
'warned_at' => $user->dormant_warned_at?->toIso8601String(),
]);
$user->delete();
}
return $due->count();
}
/**
* Confirmed, and with no customer record behind it at all.
*
* Drawn at the customer record rather than at "has no live package on
* record", and deliberately wider than it has to be:
*
* - A customer record means somebody in the business knows this person
* an operator entered them, or a checkout created them. Removing the
* login of a record we keep is half a deletion.
* - Anything commercial (an order, a contract, an instance, an invoice)
* hangs off a customer record, and invoices have to be kept for seven
* years (§ 132 BAO). Skipping every customer record skips all of that
* without having to enumerate it and without a new table sneaking past
* the enumeration later.
*
* What is left is exactly the case this exists for: somebody registered,
* confirmed their address, never bought anything, and never came back.
* Registration alone creates no customer record.
*
* Both links count, by id and by address see Customer::emailTaken for why
* one can exist without the other.
*
* @return \Illuminate\Database\Eloquent\Builder<User>
*/
private function dormant()
{
return User::query()
->whereNotNull('email_verified_at')
// Old enough to be worth looking at. The precise cut-off is applied
// per user afterwards, because it depends on the devices — and this
// one is <=, not <: a timestamp exactly at the threshold is stored to
// the second, and a stricter pre-filter would drop the very account
// the per-user check was about to catch.
->where('created_at', '<=', now()->subDays(self::AFTER_DAYS - self::WARN_DAYS_BEFORE))
->whereNotExists(fn ($q) => $q->selectRaw('1')->from('customers')
->whereColumn('customers.user_id', 'users.id'))
->whereNotExists(fn ($q) => $q->selectRaw('1')->from('customers')
->whereColumn('customers.email', 'users.email'));
}
/**
* Since when this account has been doing nothing.
*
* The later of the registration and the last time any of its devices was
* seen: signing in resets the clock, which is what the terms say.
*/
private function dormantSince(User $user): Carbon
{
// Scoped by guard as well as by id: operator 1 is not customer 1 (R21),
// and there is no devices() relation on User to hide that behind.
$lastSeen = UserDevice::query()
->where('guard', 'web')
->where('authenticatable_id', $user->id)
->max('last_seen_at');
$since = $user->created_at ?? now();
return $lastSeen === null
? $since
: Carbon::parse($lastSeen)->max($since);
}
}

View File

@ -1,129 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Models\ExportTarget;
use Illuminate\Console\Command;
use Illuminate\Support\Carbon;
/**
* Empty a handover directory of folders nobody needs any more.
*
* Only where a destination asks for it keep_days is null by default, because
* a retention rule nobody configured is a deletion nobody expected.
*
* Safe in a way deleting usually is not: the invoice lives in the database as a
* frozen document and its PDF is rendered from that on demand, so anything
* removed here can be produced again and re-exported. The directory is a
* handover point, not the record.
*
* Only ever removes folders whose NAME is a date it can parse and that are old
* enough. Anything else in there a stray file, a directory somebody made by
* hand, a folder from a different layout is left alone. A prune that deletes
* what it does not recognise is how an archive loses something nobody was
* watching.
*/
class PruneExportFolders extends Command
{
protected $signature = 'clupilot:prune-exports {--dry-run}';
protected $description = 'Remove dated folders older than a destination keeps them';
public function handle(): int
{
$targets = ExportTarget::query()
->whereNotNull('keep_days')
->where('keep_days', '>', 0)
// Only where this machine can see the files. An SFTP destination is
// somebody else's disk with somebody else's retention on it.
->where('driver', ExportTarget::LOCAL)
->get();
foreach ($targets as $target) {
$this->prune($target);
}
return self::SUCCESS;
}
private function prune(ExportTarget $target): void
{
$root = rtrim($target->path, '/');
if (! is_dir($root)) {
// The mount is gone. Saying nothing and doing nothing is right:
// this is a cleanup, and a cleanup must never be the thing that
// reports a broken mount — nor act on whatever is underneath it.
$this->warn("{$target->name}: path is not there, skipping ({$root})");
return;
}
$cutoff = now()->subDays((int) $target->keep_days)->startOfDay();
$removed = 0;
foreach ((array) scandir($root) as $entry) {
if ($entry === '.' || $entry === '..' || ! is_dir($root.'/'.$entry)) {
continue;
}
$date = $this->dateFrom($entry);
// Not a dated folder: left alone, deliberately. A prune that
// deletes what it does not recognise is how an archive loses
// something nobody was watching.
if ($date === null || $date->gte($cutoff)) {
continue;
}
if ($this->option('dry-run')) {
$this->line("would remove {$root}/{$entry}");
$removed++;
continue;
}
$this->removeTree($root.'/'.$entry);
$removed++;
}
$this->line("{$target->name}: {$removed} folder(s) older than {$target->keep_days} days.");
}
/**
* Only the exact shapes this application writes.
*
* Matched with a pattern BEFORE parsing, not by asking Carbon and catching
* what it does with nonsense: under strict mode createFromFormat throws
* rather than returning false, so a folder somebody made by hand would end
* the whole prune instead of being skipped. The pattern also says plainly
* which two shapes are recognised, which is the thing a reader wants to
* know here.
*/
private function dateFrom(string $name): ?Carbon
{
if (preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $name, $m)) {
return Carbon::createFromFormat('Y-m-d', $name)?->startOfDay();
}
if (preg_match('/^\d{4}$/', $name)) {
return Carbon::createFromFormat('Y-m-d', $name.'-12-31')?->startOfDay();
}
return null;
}
private function removeTree(string $directory): void
{
foreach ((array) scandir($directory) as $entry) {
if ($entry === '.' || $entry === '..') {
continue;
}
$path = $directory.'/'.$entry;
is_dir($path) ? $this->removeTree($path) : @unlink($path);
}
@rmdir($directory);
}
}

View File

@ -1,72 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Models\Host;
use App\Services\Dns\HetznerDnsClient;
use Illuminate\Console\Command;
use Throwable;
/**
* Cleans up host DNS records this app published in the PUBLIC Hetzner zone
* before RegisterHostDns moved to the internal vpn-dns hostsdir a host's
* WireGuard address in public DNS hands every scanner the internal subnet.
*
* The ids are whatever is still sitting in hosts.dns_record_id (PurgeHost
* already clears this column for any host removed normally; what remains
* belongs to hosts that are still active).
*
* Never deletes anything on its own: this runs against a live DNS account, a
* migration is the wrong place to make that call unattended, and a stale id
* could by now point at a record this app no longer owns. Without --force
* this only reports what it would remove.
*/
class PruneHostDns extends Command
{
protected $signature = 'clupilot:prune-host-dns {--force : Actually delete the records}';
protected $description = 'List (and, with --force, delete) public Hetzner DNS records this app published for a host';
public function handle(HetznerDnsClient $dns): int
{
$hosts = Host::query()->whereNotNull('dns_record_id')->orderBy('name')->get();
if ($hosts->isEmpty()) {
$this->info('No host carries a public dns_record_id. Nothing to do.');
return self::SUCCESS;
}
$force = (bool) $this->option('force');
$failed = 0;
foreach ($hosts as $host) {
$label = $host->name !== '' && $host->name !== null ? $host->name : $host->uuid;
$recordId = $host->dns_record_id;
if (! $force) {
$this->line(" would delete {$label} record={$recordId}");
continue;
}
try {
$dns->deleteRecord($recordId);
$host->update(['dns_record_id' => null]);
$this->info(" deleted {$label} record={$recordId}");
} catch (Throwable $e) {
$failed++;
$this->error(" failed {$label} record={$recordId}: {$e->getMessage()}");
}
}
if (! $force) {
$this->newLine();
$this->comment(sprintf('Dry run — %d record(s) found, nothing changed. Re-run with --force to delete.', $hosts->count()));
return self::SUCCESS;
}
return $failed === 0 ? self::SUCCESS : self::FAILURE;
}
}

View File

@ -1,82 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Models\Customer;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
/**
* Registrations nobody ever confirmed, removed.
*
* An abandoned attempt used to stay in `users` for ever, and it holds the
* address hostage while it does: the unique index means the same person cannot
* register again with the address they meant. "Abmelden und neu registrieren"
* was therefore advice that could not work signing out frees nothing.
*
* Five days, because that is long enough for somebody who registered on a
* Friday, found the mail in a spam folder on Monday, and clicked it and short
* enough that a typo does not block the correct address for a month.
*
* What it will NOT touch, and this is the important half:
*
* - a confirmed account, whatever else is true of it;
* - an account with a customer record behind it. A paid checkout creates the
* login through the webhook, and such a user can sit unconfirmed while
* already having a contract. Deleting that is data loss, not housekeeping.
*/
class PruneUnverifiedAccounts extends Command
{
/** How long an unconfirmed registration is kept. */
public const AFTER_DAYS = 5;
protected $signature = 'clupilot:prune-unverified {--dry-run : Only say what would go}';
protected $description = 'Delete registrations that were never confirmed';
public function handle(): int
{
$stale = User::query()
->whereNull('email_verified_at')
->where('created_at', '<', now()->subDays(self::AFTER_DAYS))
// Not a join: the two tables are linked by user_id OR by address
// (see Customer::emailTaken — a row can predate or outlive the
// other), and either one means somebody is a customer.
->whereNotExists(fn ($q) => $q->selectRaw('1')->from('customers')
->whereColumn('customers.user_id', 'users.id'))
->whereNotExists(fn ($q) => $q->selectRaw('1')->from('customers')
->whereColumn('customers.email', 'users.email'))
->get();
if ($stale->isEmpty()) {
$this->info('Nothing to remove.');
return self::SUCCESS;
}
if ($this->option('dry-run')) {
foreach ($stale as $user) {
$this->line('would remove: '.$user->email.' (registered '.$user->created_at->toDateString().')');
}
return self::SUCCESS;
}
foreach ($stale as $user) {
// Logged individually rather than as a count: an address that
// disappears is the kind of thing somebody asks about later, and a
// number in a log answers nothing.
Log::info('Removing an unconfirmed registration', [
'email' => $user->email,
'registered_at' => $user->created_at?->toIso8601String(),
]);
$user->delete();
}
$this->info($stale->count().' unconfirmed registration(s) removed.');
return self::SUCCESS;
}
}

View File

@ -1,85 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Models\DpaVersion;
use App\Services\Legal\DpaRenderer;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Storage;
/**
* Render the processing agreement and its annex, store them, put them in force.
*
* The documents are generated from the repository (resources/views/legal/dpa),
* so a change to the wording is a change somebody can review in a diff and the
* company name, the sub-processors and the deletion deadlines come from the same
* places the rest of the application reads them, which is what stops the
* agreement drifting away from what the software does.
*
* The console's upload form still exists and is unchanged: a lawyer's revision
* arrives as a PDF and becomes a version like any other. This command is for the
* version this application maintains itself.
*/
class PublishProcessingAgreement extends Command
{
protected $signature = 'clupilot:publish-dpa
{version : What the document calls itself, e.g. 1.0}
{--draft : Store it without putting it in force}';
protected $description = 'Render the processing agreement and its annex, and put them in force';
public function handle(DpaRenderer $renderer): int
{
$version = (string) $this->argument('version');
if (DpaVersion::query()->where('version', $version)->exists()) {
$this->error("Version {$version} already exists. Pick another name — two documents under one name makes every acceptance ambiguous.");
return self::FAILURE;
}
$agreementPath = 'dpa/av-vertrag-'.$this->slug($version).'.pdf';
$measuresPath = 'dpa/tom-'.$this->slug($version).'.pdf';
// Visibility "public" is about the FILE MODE, not about the web: this
// disk's root is outside the document root and nothing here becomes
// reachable by URL. What it changes is 0755/0644 instead of 0700/0600 —
// and that is the difference between a document the web process can read
// and one it cannot.
//
// It matters because this command is normally run as root (an artisan
// call in a container is root; PHP-FPM is www-data). The first run left
// a directory only root could enter, the route's exists() check answered
// false, and every link on the page 404'd with nothing in any log to say
// why.
Storage::disk('local')->put($agreementPath, $renderer->agreement($version), 'public');
Storage::disk('local')->put($measuresPath, $renderer->measures($version), 'public');
$row = DpaVersion::query()->create([
'version' => $version,
'agreement_path' => $agreementPath,
'measures_path' => $measuresPath,
// Publishing is the point of the command; --draft is for looking at
// it first. Every customer who accepted an earlier version is
// outstanding again from this moment.
'published_at' => $this->option('draft') ? null : now(),
]);
foreach ([$agreementPath, $measuresPath] as $path) {
if (Storage::disk('local')->getVisibility($path) !== 'public') {
$this->warn("{$path} is not readable by the web process. Run this as the user PHP runs as, or fix the mode — the page will 404 otherwise.");
}
}
$this->info(($this->option('draft') ? 'Drafted' : 'Published').' version '.$row->version.'.');
$this->line(' '.$agreementPath.' ('.number_format(strlen(Storage::disk('local')->get($agreementPath)) / 1024, 0).' KB)');
$this->line(' '.$measuresPath.' ('.number_format(strlen(Storage::disk('local')->get($measuresPath)) / 1024, 0).' KB)');
return self::SUCCESS;
}
private function slug(string $version): string
{
return preg_replace('/[^a-z0-9.-]+/i', '-', $version) ?: 'version';
}
}

View File

@ -1,152 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Actions\MoveStripeSubscriptionPrice;
use App\Actions\SyncStripeAddonItems;
use App\Models\Subscription;
use App\Services\Billing\PlanPrices;
use App\Services\Stripe\StripeClient;
use Illuminate\Console\Command;
/**
* Move contracts that are still billing on a superseded Stripe Price.
*
* stripe:sync-catalogue mints the Price that charges today's figure and archives
* the one it replaces and that is all it does. Archiving a Price in Stripe
* stops it being SOLD; every subscription already on it goes on being billed at
* the old amount for ever. This is the second half, and it is a command of its
* own because it touches live contracts and money, which is a decision an owner
* makes deliberately rather than a side effect of syncing a price list.
*
* Run it after every stripe:sync-catalogue that reported a change:
*
* php artisan stripe:sync-catalogue --dry-run
* php artisan stripe:sync-catalogue
* php artisan stripe:reprice-subscriptions --dry-run
* php artisan stripe:reprice-subscriptions
*
* **Nothing is prorated.** The customer has already paid for the term they are
* in, at whatever was taken at the time; charging the difference for days already
* served would be a bill nobody agreed to, and Stripe's own default would raise
* exactly that. From the next cycle the right amount is taken.
*
* **Safe to re-run.** Both halves ask whether the contract is already on the
* right Price and do nothing when it is, so a second run is free and a run
* interrupted halfway finishes on the next attempt. The package half never
* throws MoveStripeSubscriptionPrice parks its failures on the contract and
* clupilot:sync-stripe-subscriptions retries them hourly.
*
* **Two reasons a contract moves, not one.** The first is a change of FIGURE, as
* above. The second is a change of CUSTOMER: a business whose VAT id is verified
* the week after they bought must be billed the net Price from then on, because
* reverse charge means they owe no VAT to us at all and one whose registration
* has lapsed must go back onto the gross one. Neither is a change of package, so
* nothing else in the system would ever notice, and both converge here on the next
* run. PlanPrices::liveForSubscription() answers "which Price does this contract
* belong on?" for both, which is why this command has no resolver of its own: two
* places working that out is how they come to disagree.
*
* The contract's own `price_cents` is not touched by any of this. It is the
* catalogue's NET figure, it is frozen, and PlanChange prorates against it.
*/
class RepriceStripeSubscriptions extends Command
{
protected $signature = 'stripe:reprice-subscriptions
{--dry-run : Show which contracts would move without touching Stripe}
{--limit=500 : How many contracts to look at in one run}';
protected $description = 'Move live contracts onto the Stripe prices the catalogue now sells';
public function handle(
StripeClient $stripe,
MoveStripeSubscriptionPrice $move,
SyncStripeAddonItems $modules,
PlanPrices $prices,
): int {
$dryRun = (bool) $this->option('dry-run');
if (! $dryRun && ! $stripe->isConfigured()) {
$this->error('Stripe is not configured (STRIPE_SECRET is empty). Nothing was moved.');
return self::FAILURE;
}
// Active contracts Stripe actually bills. A cancelled one is not billed
// again, and a granted one has no Stripe subscription at all — moving
// either would be housekeeping on something that charges nobody.
$contracts = Subscription::query()
->whereNotNull('stripe_subscription_id')
->where('status', 'active')
->orderBy('id')
->limit((int) $this->option('limit'))
->get();
$packages = 0;
$items = 0;
$failed = 0;
foreach ($contracts as $contract) {
// Whether the catalogue prices this contract's package at all, asked
// separately from WHICH Price it should be on. A version that was never
// priced is a contract this command has nothing to say about; one that
// is priced but has no Stripe Price for this customer's treatment is a
// contract that must move, and the move is what mints it.
$priced = $prices->rowFor($contract) !== null;
$target = $priced ? $prices->liveForSubscription($contract) : null;
if ($priced && $contract->stripe_price_id !== $target) {
$this->line(sprintf(
' package %s %s → %s',
$contract->uuid,
$contract->stripe_price_id ?? '(unknown)',
// Null where the catalogue has no Price for what this customer
// should be charged. Said out loud rather than skipped: the move
// below parks the reason on the contract and the run warns about
// it, and the cure is one command.
$target ?? '(not synced)',
));
$packages++;
// PRORATE_NONE: the term in progress is paid for. See the class
// comment — this must never raise a charge for days already
// served at the old figure.
if (! $dryRun && ! $move($contract, StripeClient::PRORATE_NONE)) {
$failed++;
}
}
foreach ($modules->reprice($contract, $dryRun) as $item) {
$this->line(sprintf(
' module %s %s %s → %s',
$contract->uuid,
$item['addon'],
$item['from'] ?? '(none)',
$item['to'] ?? '(to be created)',
));
$items++;
}
}
$this->newLine();
if ($packages === 0 && $items === 0) {
$this->info('Every live contract is already on the price the catalogue sells.');
return self::SUCCESS;
}
$this->info($dryRun
? "{$packages} package(s) and {$items} module item(s) would move. Run without --dry-run to move them."
: "{$packages} package(s) and {$items} module item(s) moved.");
if ($failed > 0) {
// Parked on the contract by the action and retried hourly by
// clupilot:sync-stripe-subscriptions, so this is a warning and not a
// failure — but somebody watching the output has to be told.
$this->warn("{$failed} package move(s) did not reach Stripe and are parked for the hourly retry.");
}
return self::SUCCESS;
}
}

View File

@ -1,78 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Models\StatusDay;
use App\Services\Status\ServiceHealth;
use Illuminate\Console\Command;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
/**
* Writes one sample of every public component into today's row.
*
* The ninety-day bar on the status page is the one figure a reader checks
* against their own memory, so it has to come from something that was actually
* recorded at the time. There is no way to reconstruct it afterwards: the
* monitoring table holds the LAST verdict per instance, not a history, and by
* the time anybody asks about last Tuesday that verdict is about today.
*
* Runs on the scheduler beside the monitoring sync it depends on. A missed run
* costs one sample out of roughly three hundred in a day; a missed DAY leaves
* no row at all, and the page draws that as "not recorded" rather than
* inventing a colour for it.
*/
class SampleServiceStatus extends Command
{
protected $signature = 'clupilot:sample-status';
protected $description = 'Record the current state of every public status component into the daily history';
public function handle(ServiceHealth $health): int
{
// The wall clock, not UTC: the bar is labelled with days a reader
// recognises, and a sample taken at 01:30 local belongs to the day they
// would call it (R19).
$day = Carbon::now()->local()->toDateString();
foreach ($health->components() as $component) {
$state = $component['state'];
// One statement, so two schedulers racing on the same minute cannot
// read-modify-write over each other. The counters are increments,
// never assignments — a sample is a thing that happened, and a
// later run must not be able to undo it.
DB::table('status_days')->upsert(
[[
'day' => $day,
'component' => $component['key'],
'samples' => 1,
'operational' => $state === 'operational' ? 1 : 0,
'degraded' => $state === 'degraded' ? 1 : 0,
'down' => $state === 'down' ? 1 : 0,
'unknown' => $state === 'unknown' ? 1 : 0,
'created_at' => now(),
'updated_at' => now(),
]],
['day', 'component'],
[
'samples' => DB::raw('samples + 1'),
'operational' => DB::raw('operational + '.($state === 'operational' ? 1 : 0)),
'degraded' => DB::raw('degraded + '.($state === 'degraded' ? 1 : 0)),
'down' => DB::raw('down + '.($state === 'down' ? 1 : 0)),
'unknown' => DB::raw('unknown + '.($state === 'unknown' ? 1 : 0)),
'updated_at' => DB::raw('CURRENT_TIMESTAMP'),
],
);
}
// Beyond the window the page can draw, the rows are only weight. Kept a
// fortnight longer than the ninety days shown so a change of window
// does not immediately hit an empty table.
StatusDay::query()
->where('day', '<', Carbon::now()->local()->subDays(104)->toDateString())
->delete();
return self::SUCCESS;
}
}

View File

@ -1,156 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Models\PlanFamily;
use App\Models\StripeAddonPrice;
use App\Models\StripePlanPrice;
use App\Services\Stripe\StripeClient;
use Illuminate\Console\Command;
/**
* The Prices at our Products that no row of ours knows.
*
* App\Services\Billing\AdoptStripePrice takes over an orphan it can prove is
* ours. What it will not take over a Price carrying none of our metadata, or
* one that charges in a way we never mint it names in the log and leaves,
* because adopting a stranger's Price is worse than minting a second one. That
* was the right call and it left an operator with a log line and no way to act
* on it.
*
* **What this lists is wider than that log line.** Every active RECURRING Price
* at one of our Products that no row of ours knows adoptable or not. The only
* question asked below is whether a register row holds the id; nothing here
* re-asks AdoptStripePrice whether it would have taken the Price over.
*
* Recurring, because activePricesFor() sends `type: recurring` as well as
* `active: true`. That filter can hide somebody else's one-time Price from the
* list; it can never hide one of ours. createPrice() always sends
* `recurring[interval]`, and it is the only call that mints a Price at one of
* our Products at all the setup fee is priced inline at the checkout, under an
* ad-hoc Product of its own. So the filter errs toward listing too little, never
* toward missing an orphan of ours.
*
* **So stripe:sync-catalogue runs first.** Until it has, an orphan listed here
* may be one the next sync would simply have adopted, and --archive on that one
* is worse than it looks. Archiving it makes it invisible to the very step that
* would have rescued it: activePricesFor() asks Stripe for ACTIVE prices only,
* so adoption finds nothing and the sync falls through to createPrice() under
* the same idempotency key the crashed run used, because the key is built from
* what is being sold and everything the call puts on the wire (see
* App\Services\Stripe\IdempotencyKey), and nothing about either has changed. For
* twenty-four hours Stripe answers a repeated key by replaying the response it
* stored rather than doing anything: the archived Price's id, in the body it had
* while it was still live. The register writes that id down as the live Price
* for the row, and a checkout on it is refused.
*
* Without --archive this reports and touches nothing. With it, the orphans are
* archived at Stripe, which is harmless for everything THIS codebase sells, for
* the reason it always is here: Stripe goes on billing every subscription
* already on an archived Price, it only stops being SOLD and nothing this
* codebase sells is sold on a Price no row knows. That reasoning reaches no
* further than this codebase. A Price an operator wired up by hand a
* SUBSCRIPTION payment link built in Stripe's own dashboard against one of our
* Products is exactly the Price carrying none of our metadata described above,
* and --archive withdraws it too. A one-time link is the other side of the
* recurring filter: neither listed nor archived, and so neither protected nor
* disturbed by this command.
*/
class SweepOrphanStripePrices extends Command
{
protected $signature = 'stripe:sweep-orphan-prices
{--archive : Stop selling the orphans instead of only listing them}
{--dry-run : Show what would be archived without touching Stripe}';
protected $description = 'List the Stripe prices at our products that no row knows, and optionally archive them — run stripe:sync-catalogue first';
public function handle(StripeClient $stripe): int
{
$dryRun = (bool) $this->option('dry-run');
$archive = (bool) $this->option('archive');
// Checked on EVERY path, unlike stripe:sync-catalogue's --dry-run,
// which touches nothing because everything it compares is already in
// our own tables. This command has no local-only mode: the report —
// the whole point of it, dry-run or not — is activePricesFor(), a
// live call. Skipping this check under --dry-run would not skip the
// call, only the graceful error in front of it.
if (! $stripe->isConfigured()) {
$this->error('Stripe is not configured (STRIPE_SECRET is empty). Nothing was read.');
return self::FAILURE;
}
$known = StripeAddonPrice::query()->pluck('stripe_price_id')
->merge(StripePlanPrice::query()->pluck('stripe_price_id'))
->filter()
->flip();
$found = 0;
foreach ($this->products() as $productId) {
foreach ($stripe->activePricesFor($productId) as $price) {
if ($known->has($price['id'])) {
continue;
}
$found++;
$this->line(sprintf(
' orphan %s %d %s %s product %s %s',
$price['id'],
$price['unit_amount'],
$price['currency'],
$price['interval'],
$productId,
$price['metadata'] === [] ? 'no metadata' : json_encode($price['metadata']),
));
if ($archive && ! $dryRun) {
$stripe->archivePrice($price['id']);
}
}
}
$this->newLine();
if ($found === 0) {
$this->info('Every active price at our products is accounted for.');
return self::SUCCESS;
}
// Said on every path that found something, including after --archive
// has already run: the only question asked above is whether a row holds
// the id, so an orphan in this list may be one stripe:sync-catalogue
// would have adopted. Archiving that one is the failure this class's
// docblock describes, and an operator who has just done it needs to
// know as much as one who is about to.
$this->warn(' Run stripe:sync-catalogue first — an orphan listed here may be one the next sync would adopt.');
$this->info(match (true) {
$dryRun && $archive => "{$found} orphan(s) would be archived. Run without --dry-run to archive them.",
$archive => "{$found} orphan(s) archived. Existing subscriptions on them keep billing.",
default => "{$found} orphan(s) found. Once the sync has run, --archive stops selling what is still listed.",
});
return self::SUCCESS;
}
/**
* Every Stripe Product this platform owns: one per plan family, one per
* module. Read from our own rows rather than from Stripe, because a Product
* we do not know is not one whose Prices we can judge.
*
* @return array<int, string>
*/
private function products(): array
{
return PlanFamily::query()->whereNotNull('stripe_product_id')->pluck('stripe_product_id')
->merge(StripeAddonPrice::query()->pluck('stripe_product_id'))
->filter()
->unique()
->values()
->all();
}
}

View File

@ -1,428 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Models\PlanFamily;
use App\Models\PlanPrice;
use App\Models\PlanVersion;
use App\Models\Subscription;
use App\Services\Billing\AddonCatalogue;
use App\Services\Billing\AddonPrices;
use App\Services\Billing\AdoptStripePrice;
use App\Services\Billing\AdoptStripeProduct;
use App\Services\Billing\PlanPrices;
use App\Services\Billing\TaxTreatment;
use App\Services\Stripe\StripeClient;
use App\Support\OperatingMode;
use App\Support\StripeCatalogueMode;
use Illuminate\Console\Command;
/**
* Mirrors our catalogue into Stripe: a Product per plan family, a Price per
* priced row and the same for every module we sell.
*
* Stripe owns the recurring billing retries, dunning, off-session SCA,
* invoice numbering so it needs to know what it is billing for. It does not
* need to know how big the VM is, and it is not asked.
*
* **It pushes TWO Prices for everything it sells.** The catalogue's `amount_cents`
* is net and stays net it is frozen onto every contract and PlanChange prorates
* against it and what Stripe is asked to take from it depends on who is buying:
*
* - the DOMESTIC GROSS, which is the figure on the website and what everybody
* who is charged VAT pays, private and business alike. Pushing the net was the
* original defect: a customer quoted 214,80 paid 179,00 and was then
* invoiced 179,00 plus 35,80 VAT that nobody had collected;
* - the BARE NET, for a business in another member state whose VAT id is
* verified. Reverse charge means no VAT is owed to us at all, so the net is
* the whole of what they owe. Charging them the gross was a flat 20 % surcharge
* with no VAT line on the document for them to reclaim.
*
* Which of the two a checkout uses is TaxTreatment's decision see
* App\Services\Billing\PlanPrices, which owns the plan half of the mirror, and
* App\Services\Billing\AddonPrices, which owns the module half.
*
* Stripe's `automatic_tax` is deliberately NOT used. TaxTreatment is the single
* tax authority here; a second rate computed by Stripe would charge a German
* consumer 19 % while our document said 20 %.
*
* Idempotent by construction, and safe to re-run after a rate change: a row
* whose live Price already charges the right figure is skipped, and one that
* does not gets a NEW Price with the old one archived. A rate change moves the
* gross Price of each pair and leaves the net one untouched, because the net does
* not depend on the rate. That matters more here than usual, because a Stripe
* Price cannot be edited, so a run that minted duplicates would leave two live
* prices for one plan at one figure and no way to tell which a customer is on.
*
* It does not move anybody. Archiving a Price stops it being SOLD and leaves
* every subscription already on it exactly where it was moving those is
* stripe:reprice-subscriptions, which is a separate decision because it touches
* live contracts.
*
* Only PUBLISHED versions are synced. A draft has promised nothing, and a
* Product for it would be a price list entry for something that may never
* exist.
*
* **A run belongs to ONE Stripe account, and it says which.** Products and
* Prices are account-bound, and the operating mode picks the key so the mode
* a run worked in is recorded (App\Support\StripeCatalogueMode) and a run into
* a catalogue that belongs to the other account is refused rather than allowed
* to skip its way to "already in step". Without that, switching to live left
* every stored id pointing at test objects while the readiness page went on
* reporting a synced catalogue, and the first real order met "No such price".
*
* **Modules were missing from this entirely**, which is why a booked module was
* charged in the month it was booked and never again: nothing in Stripe existed
* to put on the subscription. They are pushed at today's catalogue price on both
* terms a Stripe Price carries its own interval, so a module on a yearly
* contract needs a yearly one. What this run cannot cover is a customer
* grandfathered on an older figure or holding a discounted grant; those mint
* their own Price at the moment they are billed. See App\Services\Billing\AddonPrices.
*/
class SyncStripeCatalogue extends Command
{
protected $signature = 'stripe:sync-catalogue {--dry-run : Show what would be created without touching Stripe}';
protected $description = 'Create the Stripe products and prices for the plan catalogue';
public function handle(StripeClient $stripe): int
{
$dryRun = (bool) $this->option('dry-run');
if (! $dryRun && ! $stripe->isConfigured()) {
$this->error('Stripe is not configured (STRIPE_SECRET is empty). Nothing was created.');
return self::FAILURE;
}
if (StripeCatalogueMode::hasStoredObjects() && StripeCatalogueMode::belongsToAnotherMode()) {
$this->error($this->staleCatalogueMessage());
// Auch der Trockenlauf endet hier, und mit FAILURE: er zählt, was
// FEHLT, und in diesem Zustand fehlt nichts — jede Zeile trägt
// schon eine ID, nur eine aus dem falschen Konto. „0 object(s)
// would be created" wäre die beruhigende Antwort auf die
// gefährliche Lage.
return self::FAILURE;
}
// VOR der Anlegeschleife, nicht danach. Hier ist bereits bewiesen, dass
// entweder nichts liegt oder das Vorhandene dem aktiven Konto gehört —
// der Zeitpunkt trägt die Aussage also genauso gut. Am Ende von
// handle() trug er sie NICHT: createProduct() und ensure() werfen
// ungefangen, und ein Lauf, der nach dem ersten angelegten Objekt
// stirbt, hinterließ IDs ohne Herkunft. Der nächste Lauf hielt das für
// ein fremdes Konto und trug dem Betreiber auf, einen Katalog zu leeren,
// an dem laufende Verträge abgerechnet werden — statt schlicht
// wiederaufzusetzen, wofür die Idempotenzschlüssel unten gebaut sind.
if (! $dryRun) {
if (StripeCatalogueMode::recorded() === null && StripeCatalogueMode::hasStoredObjects()) {
$this->line(' note taking up a catalogue whose account was never recorded; nothing is replaced.');
}
StripeCatalogueMode::record();
}
// Beide Seiten setzen hier an, und beide werden gebraucht.
// Die Modus-Sperre oben steht zuerst, weil sie VERWEIGERT: sie darf
// nicht hinter etwas laufen, das schon Zustand aufgebaut hat. Die
// Zähler darunter müssen nur vor der Anlegeschleife stehen, und das
// tun sie hier auch.
// Resolved once, as singletons: each counts across the WHOLE run, and a
// count taken before ensure()/the adoption call runs can never itself
// distinguish "created" from "adopted" — see AdoptStripePrice.
$adoptPrices = app(AdoptStripePrice::class);
$adoptProducts = app(AdoptStripeProduct::class);
$adoptedPricesBefore = $adoptPrices->adoptions;
// Duplicates accumulate on the SAME singleton across the whole process,
// not only this run — without this, a second handle() in one process
// would print a duplicate a previous run already reported.
$duplicatesBefore = count($adoptProducts->duplicates);
$created = 0;
// Counted here, locally, rather than on AdoptStripeProduct itself: the
// very same class also adopts a MODULE's Product, from
// AddonPrices::product() inside syncModules() below, and a module's
// Product is never counted as an intent at all — unlike a family's,
// which gets its own "product …" line and $created++ right where this
// is incremented. A single counter on the class could not tell the two
// apart; counting only at the one call site that also counts the
// intent avoids the question entirely.
$adoptedFamilyProducts = 0;
foreach (PlanFamily::query()->with('versions.prices')->orderBy('tier')->get() as $family) {
$published = $family->versions->filter(fn (PlanVersion $version) => $version->isPublished());
// A family whose versions are all drafts has promised nothing, so
// it has no business appearing in Stripe's price list yet.
if ($published->isEmpty()) {
continue;
}
$productId = $family->stripe_product_id;
if ($productId === null) {
$this->line(" product {$family->key}{$family->name}");
$created++;
if (! $dryRun) {
$metadata = ['plan_family' => $family->key, 'plan_family_id' => (string) $family->id];
// Asked BEFORE minting, for the same reason the Price side
// asks: a run that died between Stripe's create and our
// update left a Product this row does not know, and the key
// below stops protecting it after twenty-four hours.
// plan_family_id is what proves such a Product is this
// family's.
$productId = $adoptProducts($metadata, ['plan_family_id']);
if ($productId !== null) {
// The intent was counted three lines up, so — unlike a
// module's Product — an adoption here may honestly be
// subtracted back out of it below.
$adoptedFamilyProducts++;
}
$productId ??= $stripe->createProduct(
$family->name,
$metadata,
// Covers a RETRY, for twenty-four hours, and nothing
// beyond them. What stops a second Product for one
// family is the adoption step above.
idempotencyKey: "clupilot-product-{$family->id}",
);
$family->update(['stripe_product_id' => $productId]);
}
}
foreach ($published as $version) {
foreach ($version->prices as $price) {
$created += $this->syncPrice($family, $version, $price, $productId, $dryRun);
}
}
}
$created += $this->syncModules($dryRun);
$this->newLine();
// $adopted may only subtract what $created actually counted as an
// intent. Every price is one, family or module — syncPrice() and
// syncModules() both increment $created before knowing whether ensure()
// will mint or adopt, so the full price-adoption delta is honest. A
// family's Product is one too, counted above, so $adoptedFamilyProducts
// is honest for the same reason. A module's Product is NOT one — it
// prints no line of its own and increments nothing in syncModules(),
// however AdoptStripeProduct answers it — so its adoption stays out of
// both figures entirely. Subtracting it would report fewer objects
// created than Stripe actually gained.
$adopted = ($adoptPrices->adoptions - $adoptedPricesBefore) + $adoptedFamilyProducts;
$minted = max(0, $created - $adopted);
// Sliced to what THIS run added — the list lives on the same
// process-wide singleton as the count above, so without the same
// before/after care a second handle() in one process would print a
// duplicate a previous run already reported.
foreach (array_slice($adoptProducts->duplicates, $duplicatesBefore) as $duplicate) {
// Named here as well as in the log: an operator running the sweep
// reads this, and a second Product for one family is something only
// a person can resolve — we deliberately do not deactivate it.
$this->warn(" duplicate product {$duplicate} — left active, a product's prices become unsellable if it is deactivated");
}
if ($created === 0) {
$this->info('Stripe is already in step with the catalogue.');
return self::SUCCESS;
}
// "or adopted" on the dry run, and only there. A dry run counts intents
// without ever calling ensure(), so it cannot know which of them Stripe
// already holds a Price for — and a dry run is the first thing an
// operator runs after an interrupted one, which is exactly when some of
// these will be adopted rather than minted. The live line below has
// both figures because by then the adoption step has answered.
$this->info($dryRun
? "{$created} object(s) would be created or adopted. Run without --dry-run to create them."
: "{$minted} object(s) created, {$adopted} adopted in Stripe.");
return self::SUCCESS;
}
/**
* Why a run is refused outright rather than left to skip its way through.
*
* A Stripe Product and a Stripe Price belong to the ACCOUNT that issued
* them, and a test account and a live account are two accounts. This
* command skips every row that already carries an id (PlanPrices::inStep()
* asks the register, not Stripe), so a run after the switch would report
* "already in step" and change nothing while recording that the
* catalogue now belongs to the account it never touched.
*
* The registers have to go too, not only the two columns: inStep() takes
* a registered row as proof on its own for the reverse-charge half, so a
* cleared pointer with the register left behind would leave exactly that
* half pointing at the old account, and nothing would say so.
*
* Only reached where the OTHER account is established fact. A catalogue
* whose account was never recorded is a different state with a different
* cure see StripeCatalogueMode::belongsToAnotherMode(). Telling that
* operator to clear anything would be telling them to delete a catalogue
* their live contracts bill on.
*/
private function staleCatalogueMessage(): string
{
return sprintf(
'The stored catalogue was created in the [%s] account; this installation is now in [%s] mode. '
.'Stripe objects belong to the account that issued them, and this command skips every row that '
.'already carries an id — so it cannot repair this by running again. Clear '
.'plan_families.stripe_product_id, plan_prices.stripe_price_id and the stripe_plan_prices / '
.'stripe_addon_prices registers first, then run it. Nothing was created.',
StripeCatalogueMode::recorded()?->value,
OperatingMode::current()->value,
);
}
/**
* The Stripe Prices one priced catalogue row is sold on, brought into step.
*
* Both treatments, always: the domestic gross AND the bare net a
* reverse-charge business is charged. Unconditionally, rather than only when
* such a customer exists the Price has to be there BEFORE the checkout that
* needs it, and a verified business meeting a refused checkout is worse than
* an unused Price sitting in Stripe.
*
* What "in step" means, and the find-or-mint-or-replace that follows from it,
* belongs to PlanPrices: it is the same question the checkout and the plan
* swap ask, and asking it in two places is how they come to disagree. All
* this adds is the reporting, because an operator running a sync has to be
* able to see which half of which pair moved.
*
* Archiving stops a Price being offered and leaves every subscription on it
* untouched, which is exactly what is wanted: those are moved deliberately,
* by stripe:reprice-subscriptions.
*
* @return int how many objects this row changed, for the run's own count
*/
private function syncPrice(
PlanFamily $family,
PlanVersion $version,
PlanPrice $price,
?string $productId,
bool $dryRun,
): int {
$prices = app(PlanPrices::class);
$changed = 0;
foreach ($this->treatments() as $label => $treatment) {
if ($prices->inStep($price, $treatment)) {
continue;
}
$this->line(sprintf(
' price %s v%d %s %s %d %s net → %d %s charged',
$family->key, $version->version, $price->term, $label,
$price->amount_cents, $price->currency,
PlanPrices::chargedCents($price, $treatment), $price->currency,
));
$changed++;
// Nothing to create against: the Product for this family is minted
// by the caller, and on a dry run it does not exist yet either.
if ($dryRun || $productId === null) {
continue;
}
$prices->ensure($price, $treatment);
}
return $changed;
}
/**
* A Product and four Prices for every module on sale: monthly and yearly,
* each at the domestic gross and at the bare net.
*
* Both terms and both treatments, unconditionally, rather than only the ones
* somebody has bought a contract on: the Price has to exist BEFORE the booking
* that needs it, and a customer on a yearly package booking their first module
* would otherwise discover the gap at the moment their money was due.
*
* @return int how many objects were created, for the run's own count
*/
private function syncModules(bool $dryRun): int
{
$catalogue = app(AddonCatalogue::class);
$prices = app(AddonPrices::class);
$currency = Subscription::catalogueCurrency();
$created = 0;
$keys = array_merge(array_keys((array) config('provisioning.addons')), [AddonCatalogue::STORAGE]);
foreach ($keys as $key) {
$monthly = $catalogue->priceCents($key);
// Nothing to bill and nothing to mirror. A module priced at zero is
// either a placeholder or included, and an item at no money would
// put a line saying so on every invoice a customer ever gets.
if ($monthly === null || $monthly <= 0) {
continue;
}
foreach ([Subscription::TERM_MONTHLY, Subscription::TERM_YEARLY] as $term) {
foreach ($this->treatments() as $label => $treatment) {
// Asked of the CHARGED figure, so a rate change is seen as a
// Price that has to be replaced rather than as one that is
// already there.
if ($prices->liveFor($key, $monthly, $currency, $term, $treatment) !== null) {
continue;
}
$this->line(sprintf(
' module %s %s %s %d %s net → %d %s charged',
$key, $term, $label,
AddonPrices::termNetCents($monthly, $term), $currency,
AddonPrices::chargedCents($monthly, $term, $treatment), $currency,
));
$created++;
if ($dryRun) {
continue;
}
$prices->ensure($key, $monthly, $currency, $term, $treatment);
}
}
}
return $created;
}
/**
* The two customers everything on sale is mirrored for, labelled for the
* output so an operator reading a run can see which of a pair moved.
*
* Built from TaxTreatment rather than from a rate written down here: it is the
* single tax authority, and a second list of treatments is how the catalogue
* would come to sell a figure no invoice agrees with.
*
* @return array<string, TaxTreatment>
*/
private function treatments(): array
{
return [
'domestic' => TaxTreatment::domestic(),
'reverse-charge' => TaxTreatment::reverseCharge(),
];
}
/** Versions whose prices are live in Stripe, for the status line. */
public static function syncedVersions(): int
{
return PlanVersion::query()
->whereNotNull('published_at')
->whereHas('prices', fn ($q) => $q->whereNotNull('stripe_price_id'))
->count();
}
}

View File

@ -1,137 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Actions\MoveStripeSubscriptionPrice;
use App\Actions\SyncStripeAddonItems;
use App\Models\Subscription;
use App\Models\SubscriptionAddon;
use Illuminate\Console\Command;
/**
* The sweep behind the plan change and behind the module booking.
*
* Moving Stripe onto the new price at the moment the change lands is right and
* it is not enough. Stripe can be unreachable, the key can have been rotated,
* the catalogue can be a sync behind and the change has already happened to
* the contract and to the machine by then, so it is not rolled back. What is
* left is a customer being charged for a package they are no longer on, and
* nothing about the contract would ever say so again.
*
* ApplyPlanChange parks exactly that on the contract. This picks it up, however
* long ago it happened.
*
* A module booking is the same failure wearing different clothes: the pack is
* already a bigger disk, the entitlement is already switched on, and if Stripe
* never heard about it the customer holds a module nobody is billing them for.
* So it is swept from here too rather than from a second command one sweep,
* one schedule entry, one place to look when Stripe has been away.
*
* Deliberately hands the work back to the actions rather than doing it here:
* they own which price, which item and which proration behaviour, and two code
* paths that both move a subscription is one more than the number that can be
* kept in agreement.
*/
class SyncStripeSubscriptions extends Command
{
protected $signature = 'clupilot:sync-stripe-subscriptions {--limit=100}';
protected $description = 'Retry the plan changes and module bookings that landed here but never reached Stripe';
public function handle(MoveStripeSubscriptionPrice $move, SyncStripeAddonItems $modules): int
{
$status = $this->sweepPlans($move);
$this->sweepModules($modules);
return $status;
}
private function sweepPlans(MoveStripeSubscriptionPrice $move): int
{
// Active contracts only. A cancelled one is not billed again, so moving
// it onto another price would be housekeeping on something Stripe has
// already finished with — and an upgrade's proration raised against a
// contract that has ended would charge a departed customer.
$parked = Subscription::query()
->whereNotNull('stripe_price_sync')
->whereNotNull('stripe_subscription_id')
->where('status', 'active')
->orderBy('id')
->limit((int) $this->option('limit'))
->get();
$moved = 0;
foreach ($parked as $subscription) {
if ($move->retry($subscription)) {
$moved++;
continue;
}
// The action has already logged why and counted the attempt. Said
// again here because somebody is watching this command's output
// and not the log.
$this->warn(sprintf(
'Contract %s: still billed at the old price — %s',
$subscription->uuid,
(string) ($subscription->fresh()?->stripe_price_sync['error'] ?? 'unknown'),
));
}
$this->info("{$parked->count()} contract(s) out of step with Stripe, {$moved} moved.");
return self::SUCCESS;
}
/**
* Contracts whose modules Stripe is not billing.
*
* Found from the BOOKINGS rather than from a parked marker, because the
* bookings are the authority: a module still running with no item id was
* never put on the subscription, and one that has stopped while still
* carrying an item id is being billed after it ended. `stripe_addon_sync` is
* the record of what went wrong and how often it is not where the work
* lives, so a contract whose failure was never written down (a worker killed
* mid-booking) is still found here.
*/
private function sweepModules(SyncStripeAddonItems $modules): void
{
$outstanding = SubscriptionAddon::query()
->where(fn ($q) => $q
->where(fn ($active) => $active->whereNull('cancelled_at')->whereNull('cancels_at')->whereNull('stripe_item_id'))
->orWhere(fn ($gone) => $gone->whereNotNull('stripe_item_id')
->where(fn ($ended) => $ended->whereNotNull('cancelled_at')->orWhereNotNull('cancels_at'))))
->select('subscription_id')
->distinct();
// Active contracts that Stripe actually bills. A granted contract has no
// Stripe subscription at all and its bookings will never carry an item
// id — sweeping those would retry, and log, something that is not wrong.
$contracts = Subscription::query()
->whereIn('id', $outstanding)
->whereNotNull('stripe_subscription_id')
->where('status', 'active')
->orderBy('id')
->limit((int) $this->option('limit'))
->get();
$settled = 0;
foreach ($contracts as $subscription) {
if ($modules($subscription)) {
$settled++;
continue;
}
$this->warn(sprintf(
'Contract %s: modules not billed by Stripe — %s',
$subscription->uuid,
(string) ($subscription->fresh()?->stripe_addon_sync['error'] ?? 'unknown'),
));
}
$this->info("{$contracts->count()} contract(s) with modules out of step, {$settled} settled.");
}
}

View File

@ -1,101 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Actions\ReapplyInstanceAddress;
use App\Models\Instance;
use App\Services\Domains\DomainVerifier;
use Illuminate\Console\Command;
/**
* Re-reads every custom domain's proof, nightly.
*
* Asked for in exactly these words: the token must be checked again and again,
* so that nobody puts it in, passes the check, and takes it straight back out.
* At night, because a domain that is withdrawn is a domain that stops being
* served, and that is not a thing to do to somebody at eleven in the morning.
*
* Three consecutive misses before a verified domain is withdrawn. One failed
* lookup is a nameserver having a bad minute; taking a working Nextcloud
* offline over a hiccup would be far worse than the hole it closes.
*/
class VerifyCustomDomains extends Command
{
protected $signature = 'clupilot:verify-domains {--instance= : one instance uuid, for checking on demand}';
protected $description = 'Re-read the DNS proof for every custom domain and withdraw the ones that lost it';
public function handle(DomainVerifier $verifier, ReapplyInstanceAddress $reapply): int
{
$query = Instance::query()->whereNotNull('custom_domain')->whereNotNull('domain_token');
if ($uuid = $this->option('instance')) {
$query->where('uuid', $uuid);
}
$checked = 0;
$withdrawn = 0;
foreach ($query->cursor() as $instance) {
$checked++;
$present = $verifier->proofPresent($instance);
// What was true before this check, so the CHANGE can be acted on
// rather than the state. Re-applying an address is remote work on a
// live machine; doing it nightly for every domain that is simply
// still fine would be a hundred pointless runs a night.
$wasVerified = $instance->domainIsVerified();
if ($present) {
$instance->forceFill([
'domain_verified_at' => $instance->domain_verified_at ?? now(),
'domain_checked_at' => now(),
'domain_error' => null,
'domain_failures' => 0,
])->save();
// A domain that has just become provable is a domain nothing is
// serving yet: the router was written without it and Nextcloud
// does not trust it. This is where it becomes an address.
if (! $wasVerified) {
$reapply($instance);
}
continue;
}
$failures = $instance->domain_failures + 1;
// Withdrawal clears verified_at, which is the single flag every
// consumer reads. Nothing else is deleted: the domain and its token
// stay on the row, so the customer sees what is wrong and can put
// the record back rather than starting over.
$withdraw = $instance->domain_verified_at !== null
&& $failures >= DomainVerifier::FAILURES_BEFORE_WITHDRAWAL;
$instance->forceFill([
'domain_checked_at' => now(),
'domain_error' => 'missing_txt',
'domain_failures' => $failures,
'domain_verified_at' => $withdraw ? null : $instance->domain_verified_at,
'domain_cert_ok' => $withdraw ? false : $instance->domain_cert_ok,
])->save();
if ($withdraw) {
$withdrawn++;
// The other half of the flip, and the one that actually takes
// the domain away: the router stops carrying it and Nextcloud
// stops trusting it. Clearing verified_at alone only stops us
// TELLING people about it.
$reapply($instance);
$this->warn("Withdrew {$instance->custom_domain}: proof missing on {$failures} consecutive checks.");
}
}
$this->info("Checked {$checked} domain(s), withdrew {$withdrawn}.");
return self::SUCCESS;
}
}

View File

@ -1,118 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Actions\VerifyCustomerVatId;
use App\Models\Customer;
use Illuminate\Console\Command;
/**
* Re-ask the register about the VAT numbers we are relying on.
*
* A number is not verified once and for ever. A company is wound up, a
* registration is withdrawn, a member state removes it and from that moment we
* are zero-rating an invoice against a number that no longer exists, which is our
* liability and not the customer's. So the ones reverse charge actually rests on
* are asked again.
*
* It also picks up the numbers nobody has checked yet: every number entered
* before this check existed is unverified, so those customers are on the domestic
* rate today whether or not they are entitled to reverse charge.
*
* ## Scheduled monthly, and why that is not the owner's decision to inherit
*
* This used to say it was deliberately NOT scheduled, and to point at a note in
* routes/console.php that did not exist so an owner reading the schedule could
* not learn the command was there at all, and reverse charge went on resting on a
* one-off answer for ever.
*
* It is scheduled now, because the exposure is the SELLER's. A registration that
* is withdrawn keeps earning rate 0 on every invoice we issue afterwards, and the
* unpaid VAT on those is ours to make good, not the customer's so the cadence is
* not a preference but the width of the window in which that can happen unnoticed.
* Monthly, on the first, because that is the rhythm the VAT return is filed on: a
* number that lapsed in March is caught before the March return is prepared.
*
* The public service's concurrency limit is answered by `--limit` rather than by
* not running: the query takes the longest-unchecked first, so a customer base
* larger than one run rotates through it instead of starving. `--dry-run` and
* `--customer` are for the by-hand run before a return.
*/
class VerifyVatIds extends Command
{
protected $signature = 'clupilot:verify-vat-ids
{--dry-run : Show what would be asked without touching the register or any record}
{--customer= : One customer, by uuid}
{--limit=200 : How many to look at in one run}';
protected $description = 'Check customer VAT numbers against the EU VIES register';
public function handle(VerifyCustomerVatId $verify): int
{
$customers = Customer::query()
->whereNotNull('vat_id')
->where('vat_id', '!=', '')
->when($this->option('customer'), fn ($q, $uuid) => $q->where('uuid', $uuid))
// Longest unchecked first, so a short run covers the ones we know
// least about rather than the ones checked yesterday. A plain
// ascending sort does that on its own — never-checked rows are NULL
// and both engines sort those first — which is why there is no raw
// SQL here: MariaDB and SQLite would need different expressions for
// it, and a query that only runs on one of them makes the test
// database a different product from the live one.
->orderBy('vat_id_verified_at')
->limit((int) $this->option('limit'))
->get();
if ($customers->isEmpty()) {
$this->info('No VAT numbers on record.');
return self::SUCCESS;
}
$counts = ['verified' => 0, 'rejected' => 0, 'unchecked' => 0];
foreach ($customers as $customer) {
if ($this->option('dry-run')) {
$this->line(sprintf('would ask: %s (%s)', $customer->normalisedVatId(), $customer->name));
continue;
}
$check = $verify($customer);
match (true) {
$check === null => null,
$check->isValid() => $counts['verified']++,
$check->isInvalid() => $counts['rejected']++,
default => $counts['unchecked']++,
};
if ($check?->isInvalid()) {
// Named individually, because this one needs a person: an invoice
// already issued at 0 % against a number the register now rejects
// has to be looked at, and a count in a summary hides that.
$this->warn(sprintf(
'not registered: %s (%s) — reverse charge no longer applies',
$customer->normalisedVatId(),
$customer->name,
));
}
}
if ($this->option('dry-run')) {
$this->info($customers->count().' number(s) would be asked about.');
return self::SUCCESS;
}
$this->info(sprintf(
'%d confirmed, %d rejected, %d could not be checked (register unavailable — nothing changed for those).',
$counts['verified'],
$counts['rejected'],
$counts['unchecked'],
));
return self::SUCCESS;
}
}

View File

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

View File

@ -1,21 +0,0 @@
<?php
namespace App\Exceptions;
use RuntimeException;
/**
* Refused: this address already belongs to the other identity table.
*
* R21 operators and customers are two groups of people, never one row that
* could pass for both. Keyed on the address itself, not on who happens to be
* signed in when the check runs (see the deleted Customer::assertNotAdmin(),
* which checked the caller's session and mis-fired for exactly that reason).
*/
class IdentityCollisionException extends RuntimeException
{
public function __construct(public readonly string $email)
{
parent::__construct("Refusing to create or link a portal login for {$email}: that address already belongs to an operator (R21).");
}
}

View File

@ -1,20 +0,0 @@
<?php
namespace App\Exceptions;
use RuntimeException;
/**
* Der aktive Betriebsmodus hat keinen Stripe-Schlüssel.
*
* Eine eigene Klasse, damit Aufrufer und Tests genau diesen Fall greifen können
* statt „irgendetwas ging schief" — und damit die Kasse ihn in einen Satz
* übersetzen kann, den ein Kunde lesen kann.
*/
class StripeNotConfigured extends RuntimeException
{
public static function forMode(string $mode): self
{
return new self("No Stripe secret is stored for the [{$mode}] operating mode.");
}
}

View File

@ -1,133 +0,0 @@
<?php
namespace App\Http\Controllers;
use App\Support\HostEnrolment;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\Process\Process;
/**
* Liefert das Bootstrap-Skript als Archiv aus.
*
* Das ist der einzige Weg, auf dem das Skript auf eine nackte Maschine kommt:
* ein Rettungssystem hat nichts als curl. Es ist eine STATISCHE Datei und kein
* Endpunkt, der Auskunft gibt Spec §5 bleibt damit unangetastet. Es steht
* nichts Vertrauliches darin; alle Geheimnisse reist der Betreiber in der
* Befehlszeile mit, nicht im Archiv.
*
* Als Archiv und nicht als eine Datei, weil `curl … | sh` keine `lib/` haben
* kann und der Skript-Plan getrennte Dateien verlangt: `network.sh` und
* `template.sh` sollen einzeln nachlesbar und wiederholbar bleiben, wenn auf
* einer Maschine um drei Uhr morgens etwas klemmt.
*
* Gebaut wird beim ersten Abruf und danach nur, wenn sich unter `deploy/bootstrap`
* etwas geändert hat. Ein Archiv, das im Repo läge, wäre die Fassung von dem
* Tag, an dem jemand zuletzt daran gedacht hat, es neu zu bauen.
*/
class BootstrapArchiveController extends Controller
{
public function __invoke(Request $request): BinaryFileResponse
{
// Der Ausweis ist der Einmal-Code, der ohnehin schon in der kopierten
// Zeile steht. Kein zweites Geheimnis: ein eigener Download-Token liefe
// nie ab, stünde für immer in Shell-Historien, und er reiste in
// derselben Zeile wie der WireGuard-Schlüssel — er schützte also das
// Unwichtigste mit derselben Aussetzung wie das Wichtigste.
//
// `resolve()` und nicht `claim()`: der Code wird hier NICHT verbraucht.
// Er wird nach dem Herunterladen noch für jede Fortschrittsmeldung und
// zuletzt für die Registrierung gebraucht.
//
// 404 und nicht 403, dieselbe Regel wie bei der Konsole: ein Fremder
// soll nicht erfahren, dass es hier etwas gibt.
$code = (string) ($request->query('code') ?? $request->header('X-CluPilot-Code', ''));
if (HostEnrolment::resolve($code) === null) {
abort(404);
}
$archive = storage_path('app/bootstrap.tar.gz');
if ($this->isStale($archive)) {
$this->build($archive);
}
return response()
->file($archive, [
'Content-Type' => 'application/gzip',
'Content-Disposition' => 'attachment; filename="bootstrap.tar.gz"',
// Nicht in den Index. Die Adresse ist ohne Code wertlos, aber
// eine Suchmaschine, die sie kennt, verrät immerhin, dass es
// sie gibt.
'X-Robots-Tag' => 'noindex, nofollow',
]);
}
/**
* Neuer als alles, was drinsteckt?
*
* Über die Änderungszeit und nicht über einen Hash: das Verzeichnis ist
* klein, der Vergleich läuft bei jedem Abruf, und ein Hash über alle Dateien
* kostete bei jedem Abruf mehr als das Packen gelegentlich kostet.
*/
private function isStale(string $archive): bool
{
if (! is_file($archive)) {
return true;
}
$built = filemtime($archive);
foreach ($this->sourceFiles() as $file) {
if (filemtime($file) > $built) {
return true;
}
}
return false;
}
/** @return list<string> */
private function sourceFiles(): array
{
$root = base_path('deploy/bootstrap');
if (! is_dir($root)) {
return [];
}
$files = [];
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($root, \FilesystemIterator::SKIP_DOTS)
);
foreach ($iterator as $file) {
if ($file->isFile()) {
$files[] = $file->getPathname();
}
}
return $files;
}
/**
* Mit GNU tar und nicht mit PharData: `phar.readonly` steht in diesem
* Container auf 1, und das Ausführbar-Bit soll erhalten bleiben.
*
* Der oberste Eintrag heißt `bootstrap/`, damit die kopierte Befehlszeile
* mit `tar xz -C /opt/clupilot` genau `/opt/clupilot/bootstrap` ergibt
* der Ort, an dem das Skript sich selbst und seine Bibliothek erwartet.
*/
private function build(string $archive): void
{
@mkdir(dirname($archive), 0755, true);
$process = new Process(
['tar', 'czf', $archive, '-C', base_path('deploy'), 'bootstrap'],
timeout: 60,
);
$process->mustRun();
}
}

View File

@ -1,209 +0,0 @@
<?php
namespace App\Http\Controllers;
use App\Models\Customer;
use App\Models\Subscription;
use App\Services\Billing\PlanCatalogue;
use App\Services\Billing\PlanPrices;
use App\Services\Billing\SetupFee;
use App\Services\Billing\TaxTreatment;
use App\Services\Provisioning\HostCapacity;
use App\Services\Stripe\StripeClient;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Throwable;
/**
* Buying a package, without anyone having to write an email about it.
*
* The site used to end every path in "anfragen": a mailto: link and a promise
* to answer the same working day. That is a person's afternoon per customer,
* for a product whose entire point is that the machine does the work and it
* scales exactly as far as the operator's inbox does.
*
* So: a signed-in customer picks a package, pays, and provisioning starts by
* itself. Where no host has room the order is parked rather than refused (see
* ReserveResources) that is the ONE case that still needs a human, and it
* needs them to buy a server, not to answer a mail.
*
* This class does not take money and does not create a contract. It opens a
* Stripe hosted checkout and gets out of the way; the purchase becomes real
* when Stripe says so, on the webhook, which is the only place that may believe
* a payment happened. A "thank you" page proves nothing the customer can open
* it by typing the URL.
*/
class CheckoutController extends Controller
{
/**
* Send the customer to Stripe for one package.
*
* Everything here is re-derived from the catalogue: the plan key arrives
* from a form, and a form field is a string somebody can retype. The price
* charged is the one the catalogue holds for the version on sale right now,
* never a figure carried in the request.
*/
public function start(
Request $request,
StripeClient $stripe,
PlanCatalogue $catalogue,
PlanPrices $planPrices,
): RedirectResponse {
$data = $request->validate([
'plan' => ['required', 'string', 'max:64'],
'term' => ['nullable', 'in:'.Subscription::TERM_MONTHLY.','.Subscription::TERM_YEARLY],
// Acceptance of the terms, which is where everything about this
// sale is regulated: the delivery, the price, the fourteen-day
// withdrawal right, and the consumer's express request that we
// begin inside that window (FAGG §16, §356 BGB) — a cloud is built
// the moment the payment lands, so that request cannot be avoided
// and is stated on the checkbox as well as in the terms.
//
// Required. An order cannot be submitted without it: the box is the
// declaration, and the browser only disables the button — this is
// the line that actually refuses.
'terms_accepted' => ['accepted'],
], [
'terms_accepted.accepted' => __('checkout.terms_required'),
]);
$term = $data['term'] ?? Subscription::TERM_MONTHLY;
$user = $request->user();
// Read once and handed to everything below that has to know who is
// buying. Which of a package's two Stripe Prices this session uses is
// decided from it, and a second lookup is a second chance to decide it
// differently.
$customer = $user === null
? null
: Customer::query()->where('email', $user->email)->first();
// Somebody who already has a running contract is not buying a second
// one by accident: changing package is a plan change, which prorates
// and keeps their data where it is. A second checkout would build them
// a second, empty cloud and bill for both.
if ($this->hasLiveContract($customer)) {
return redirect()->route('billing')->with('status', __('checkout.already_customer'));
}
if (! $stripe->isConfigured()) {
// Nothing to send them to. Better a plain sentence than a redirect
// into an error page on somebody else's domain.
Log::error('Checkout attempted while Stripe is not configured.');
return back()->withErrors(['plan' => __('checkout.unavailable')]);
}
try {
$version = $catalogue->currentVersion($data['plan']);
$price = $version->prices->firstWhere('term', $term);
} catch (Throwable $e) {
// An unknown, withdrawn or overlapping plan. The catalogue throws
// by design; a shop must not guess which of two versions was meant.
Log::warning('Checkout for an unavailable plan', ['plan' => $data['plan'], 'exception' => $e]);
return back()->withErrors(['plan' => __('checkout.plan_gone')]);
}
// WHO is buying decides WHICH of the package's two Stripe Prices this
// session uses: the domestic gross for everybody who is charged VAT, the
// bare net for a business in another member state whose VAT id is
// verified, because reverse charge means no VAT is owed to us at all.
// Decided by TaxTreatment and read from PlanPrices — nothing about the
// customer is examined here, or there would be two answers to one
// question and no way to tell which an invoice was written against.
$treatment = TaxTreatment::for($customer);
$priceId = $price === null ? null : $planPrices->liveFor($price, $treatment);
// A Price Stripe has never been told about. Sending the customer on would
// open a checkout for nothing — and for a reverse-charge business, refusing
// is the only safe answer even though a Price does exist: the domestic one
// would take a fifth more than they owe, which is the overcharge this whole
// rule exists to end. A refused checkout is loud, one command cures it, and
// the log line names the command.
if ($priceId === null) {
Log::error('Checkout for a plan that is not on sale in Stripe. Run stripe:sync-catalogue.', [
'plan' => $data['plan'],
'term' => $term,
'reverse_charge' => $treatment->reverseCharge,
]);
return back()->withErrors(['plan' => __('checkout.plan_gone')]);
}
// The one-off setup fee as this customer is charged it, or null where the
// operator has set none. Advertised on the price sheet and on the booking
// page for months and charged to nobody; it rides along as a second,
// non-recurring line on this session, which Stripe puts on the initial
// invoice only. Subject to the same rule as the package: a reverse-charge
// business pays the bare net of it.
$setup = SetupFee::checkoutLine((string) $price->currency, $treatment);
try {
$url = $stripe->createCheckoutSession(
priceId: $priceId,
successUrl: route('checkout.done'),
cancelUrl: route('order'),
// Exactly the keys StripeWebhookController reads back. The
// VERSION goes along, so the contract is opened on what the
// customer was shown even if a new version is published while
// they are typing their card number.
metadata: [
'plan' => $version->family->key,
'plan_version_id' => (string) $version->id,
'datacenter' => app(HostCapacity::class)->preferredDatacenter(),
// Exactly '1', because StripeWebhookController compares
// against exactly that: a consent that can be produced by a
// typo is not a consent. It travels through Stripe rather
// than being stamped here — the contract only exists once
// the payment does, and a consent recorded for a checkout
// somebody abandoned would be a consent to nothing.
//
// The KEY keeps its old name deliberately. The form field it
// came from is called terms_accepted now, but a checkout
// session opened before a deploy is paid after it, and the
// webhook would find nothing under a new name. What it
// records is unchanged: the customer declared, at this
// moment, that we may start at once.
'immediate_start' => '1',
// What part of Stripe's amount_total is the setup fee, so the
// webhook can tell the two apart again: the package's charge
// is what the register holds against the contract price, and
// the fee is a line of its own on the first invoice.
'setup_fee_cents' => (string) ($setup['amount_cents'] ?? 0),
],
customerEmail: $user?->email,
oneOff: $setup,
);
} catch (Throwable $e) {
Log::error('Stripe would not open a checkout session', ['exception' => $e]);
return back()->withErrors(['plan' => __('checkout.unavailable')]);
}
return redirect()->away($url);
}
/**
* Where Stripe sends them back to.
*
* Deliberately says "we are building it", not "you have paid": this page is
* a redirect target, and a redirect target is a URL anybody can type. What
* the customer actually sees underneath is the provisioning card on their
* dashboard, which is driven by a run that only the webhook can start.
*/
public function done(): RedirectResponse
{
return redirect()->route('dashboard')->with('status', __('checkout.thanks'));
}
/** Does this customer already have a contract that is being billed? */
private function hasLiveContract(?Customer $customer): bool
{
return $customer !== null && Subscription::query()
->where('customer_id', $customer->id)
->whereIn('status', ['active', 'past_due'])
->exists();
}
}

View File

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

View File

@ -1,116 +0,0 @@
<?php
namespace App\Http\Controllers;
use App\Exceptions\IdentityCollisionException;
use App\Models\Customer;
use App\Models\Operator;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\URL;
/**
* Looking at a customer's portal as that customer.
*
* Not a shared cookie. Once the console has a hostname of its own, a session
* cookie set on the console host never reaches the portal host SESSION_DOMAIN
* is null and cookies are host-bound. So the handover is a signed, single-use
* link: it works regardless of cookie scope, it expires, and it leaves a record
* of who looked at whom.
*/
class ImpersonationController extends Controller
{
/** Sixty seconds is a handover, not a credential. */
private const WINDOW = 60;
public function start(Customer $customer): RedirectResponse
{
$this->authorize('customers.impersonate');
$operator = Auth::guard('operator')->user();
// impersonate.enter carries no domain of its own — a portal route,
// reached wherever the portal already answers. Its SIGNATURE is
// computed over the full absolute URL: UrlGenerator::signedRoute()
// hashes the generated URL string, host included, and
// hasCorrectSignature() re-hashes $request->url() — the ACTUAL request
// URL — on the way back. Left alone, "the generated URL" means
// whichever host issued THIS request: the console's, once it has an
// exclusive hostname of its own. RestrictAdminHost then 404s every
// non-console route reached through the console's exclusive host,
// including this one — so the link would die before the customer ever
// saw it, the exact cross-host failure this controller exists to fix.
//
// Forced to the portal's own configured root instead, derived from
// config('app.url') rather than hardcoded, and restored immediately
// after: nothing else generated later in this request should inherit
// the override.
$portalRoot = rtrim((string) config('app.url'), '/');
URL::forceRootUrl($portalRoot);
try {
$signed = URL::temporarySignedRoute(
'impersonate.enter',
now()->addSeconds(self::WINDOW),
// Addressed by CUSTOMER uuid, not user: `users` has no uuid
// column, and naming the integer key in a URL is what R11
// forbids. The portal end resolves the account the same way
// this end would have.
['customer' => $customer->uuid, 'operator' => $operator->uuid],
);
} finally {
URL::forceRootUrl(null);
}
return redirect()->away($signed);
}
/** The portal end of the handover. Signed, and good exactly once. */
public function enter(Request $request, string $customer, string $operator): RedirectResponse
{
abort_unless($request->hasValidSignature(), 403);
// Single use: the signature's own hash is the key, and it lives exactly
// as long as the link could. Redis, not the database — the marker is as
// short-lived as the link and should not need tidying up.
$marker = 'impersonate:'.hash('sha256', (string) $request->query('signature'));
abort_unless(Cache::add($marker, true, self::WINDOW), 403);
// Stale data, not a normal outcome: a customer row sharing its email
// with an operator should never have been possible to reach this far
// (see Customer::ensureUser() and CreateNewUser, R21) — refuse rather
// than hand the operator's own address a portal session.
try {
$target = Customer::where('uuid', $customer)->firstOrFail()->ensureUser();
} catch (IdentityCollisionException) {
abort(409);
}
$who = Operator::where('uuid', $operator)->firstOrFail();
Auth::guard('web')->login($target);
session(['impersonator_id' => $who->id]);
return redirect()->route('dashboard');
}
/** Back out of the customer's portal. The operator session was never touched. */
public function leave(): RedirectResponse
{
session()->forget('impersonator_id');
Auth::guard('web')->logout();
// By NAME, not AdminArea::home(): this route is reached on the
// PORTAL's host, and home() is a bare path ('/' in exclusive mode) —
// resolved against whatever host the request is already on, which is
// the portal's, not the console's. admin.overview is domain-bound to
// the console in exclusive mode, so route() generates the correct
// cross-host, absolute URL back to it; in shared mode it resolves
// against the current host same as home() would, so nothing changes
// there.
return redirect()->route('admin.overview');
}
}

View File

@ -1,647 +0,0 @@
<?php
namespace App\Http\Controllers;
use App\Models\Subscription;
use App\Services\Billing\AddonCatalogue;
use App\Services\Billing\CustomDomainAccess;
use App\Services\Billing\PlanCatalogue;
use App\Services\Billing\SetupFee;
use App\Services\Billing\TaxTreatment;
use App\Services\Provisioning\HostCapacity;
use App\Support\CompanyProfile;
use App\Support\ProvisioningSettings;
use Illuminate\Contracts\View\View;
use Illuminate\Support\Facades\Log;
use Throwable;
/**
* The public price sheet.
*
* Prices, storage and seat counts are read from the catalogue rather than
* written into the page. They were hard-coded here once, and the page and the
* catalogue had already drifted apart on three of four plans a visitor was
* quoted 249 for a plan that charged 399 at checkout. The marketing site is
* a reader of the catalogue like every other caller.
*
* The catalogue fails loudly by design: an empty or overlapping catalogue is an
* outage for commerce, not something to paper over. But a public website is not
* commerce a mistyped availability window must not take the company's front
* page down with it. So the failure is caught HERE and only here: the page
* still renders, the price sheet is replaced by "on request", and nobody is
* shown a number the checkout would not honour.
*
* The sheet is three blocks, not one matrix. A single grid of ticks was being
* asked to carry three different kinds of sentence at once, and it made all
* three unreadable: what every package has (a tick straight down every column,
* which is no information at all), what tells the packages apart, and what
* costs extra. Worse, two of the columns were lying by construction support
* was modelled as two independent booleans, so the top plan rendered a dash
* where the middle one had a tick, and the platform address was modelled as if
* an own domain replaced it, when provisioning issues both. Each block now
* makes one kind of statement: BASELINE what everyone gets, the comparison
* table what differs, ADDONS what is for sale on top.
*/
class LandingController extends Controller
{
/**
* Catalogue feature keys, in the words a customer uses.
*
* Deliberately separate from the operator-facing labels in
* lang/*\/billing.php's `feature` array, which name the same keys for the
* console's checkboxes. The two are different registers for different
* readers "Protokollierung der Zugriffe" here versus "Audit-Log" there
* and features are catalogue-wide, not per-plan, so there is nowhere on a
* plan family or version to hang an editable customer-facing label
* without re-introducing a shared feature table. That table
* (`plan_features`) existed once and was deliberately removed in the
* catalogue rebuild that closed the pricing split-brain; bringing it back
* for wording alone is a bigger, separate decision.
*/
private const FEATURES = [
'managed_updates' => 'Updates & Wartung',
'daily_backups' => 'Tägliche Sicherung',
'monitoring' => 'Überwachung rund um die Uhr',
// Filled in from the configured zone by zone(), reached through
// label(), because a hard-coded domain here is the same
// two-sources-of-truth mistake as a hard-coded price — and it shipped
// naming clupilot.cloud, a domain the company does not own, while
// provisioning was issuing addresses under whatever CLUPILOT_DNS_ZONE
// said.
'subdomain' => 'Adresse auf :zone',
'custom_domain' => 'Eigene Domain',
'office' => 'Office im Browser',
'branding' => 'Ihr Logo & Ihre Farben',
'priority_support' => 'Bevorzugter Support',
'premium_sla' => 'Vereinbarte Reaktionszeiten',
'extended_retention' => 'Verlängerte Aufbewahrung',
'audit_log' => 'Protokollierung der Zugriffe',
'onboarding' => 'Begleitete Einführung',
];
/**
* What every package carries, said once above the table.
*
* Each of these was a row of ticks running straight down all four columns
* a comparison that compares nothing and the same promises were then
* repeated in a prose line under the table. `key` names the catalogue
* feature the claim stands on: it keeps the row out of the comparison
* table, and, unless the claim is `issued`, the whole item is dropped
* should a plan ever stop carrying it. The page must not promise for
* "jedes Paket" what one package does not have.
*
* The last two have no key because the catalogue does not model them. They
* are the promises the prose line under the table made, kept word for word
* rather than reworded on the way in.
*/
private const BASELINE = [
[
'icon' => 'refresh',
'label' => 'Updates & Wartung',
'detail' => 'Betriebssystem und Anwendung, eingespielt außerhalb Ihrer Arbeitszeit.',
'key' => 'managed_updates',
],
[
'icon' => 'database',
'label' => 'Tägliche verschlüsselte Sicherung',
'detail' => 'Mit monatlichem Wiederherstellungstest, protokolliert.',
'key' => 'daily_backups',
],
[
'icon' => 'activity',
'label' => 'Überwachung rund um die Uhr',
'detail' => 'Erreichbarkeit, Speicher und Dienste — Alarm, bevor Sie es merken.',
'key' => 'monitoring',
],
[
'icon' => 'globe',
'label' => 'Ihre Adresse ab der ersten Minute',
// The shape, not the word for it. "Eigene Subdomain" tells a
// customer nothing; the address they will actually type does.
'detail' => 'ihrefirma.:zone',
'mono' => true,
// Issued, not sold: ConfigureDnsAndTls gives every instance its
// platform address whatever `subdomain` says on the plan version
// that was frozen years ago — business and enterprise do not carry
// the flag and get the address anyway. The key is named so the row
// leaves the comparison table; the promise does not hang on it.
'key' => 'subdomain',
'issued' => true,
],
[
'icon' => 'file-text',
'label' => 'AV-Vertrag & TOM-Dokumentation',
'detail' => 'Schriftlich, für Ihre eigene Dokumentation und Prüfung.',
],
[
'icon' => 'life-buoy',
'label' => 'Support auf Deutsch',
'detail' => 'Antwort am selben Werktag, ohne Warteschleife.',
],
];
/**
* The support level a plan carries, strongest first.
*
* One axis, not two. `priority_support` and `premium_sla` are steps on the
* same ladder, and as independent tick boxes they made the top plan look
* worse than the middle one: enterprise carries the SLA and not the
* priority flag, so it rendered "Bevorzugter Support —" while team
* rendered a tick. No plan carrying either is not an absence standard
* support is a real level, and the baseline block above says what it is.
*/
private const SUPPORT = [
'premium_sla' => ['level' => 'Premium', 'note' => 'mit zugesagter Reaktionszeit'],
'priority_support' => ['level' => 'Bevorzugt', 'note' => 'Anfragen werden vorgezogen'],
];
private const SUPPORT_STANDARD = ['level' => 'Standard', 'note' => 'Antwort am selben Werktag'];
/**
* The modules we sell on top of a package, in the words a customer uses.
*
* Same split as FEATURES and for the same reason: lang/*\/billing.php names
* these for the portal's booking cards ("Collabora Online Pro"), which is
* the register of somebody who already bought. A visitor deciding has not
* heard of Collabora. The prices are never written here they come from
* AddonCatalogue, so the sheet and the booking page cannot disagree.
*/
private const ADDONS = [
'extra_backups' => [
'name' => 'Zweiter Sicherungsort',
'body' => 'Eine zusätzliche verschlüsselte Kopie Ihrer Daten, getrennt vom ersten Ort gelagert.',
],
'priority_support' => [
'name' => 'Bevorzugter Support',
'body' => 'Ihre Anfragen werden vorgezogen — Reaktion innerhalb einer Stunde statt am selben Werktag.',
],
'collabora_pro' => [
'name' => 'Office mit vollem Funktionsumfang',
'body' => 'Mehr gleichzeitige Bearbeiter und die erweiterten Funktionen der Office-Integration.',
],
'custom_domain' => [
'name' => 'Eigene Domain',
'body' => 'Ihre Cloud unter Ihrer eigenen Adresse, samt Zertifikat und Einrichtung.',
],
AddonCatalogue::STORAGE => [
'name' => 'Zusatzspeicher',
'body' => 'Ein Paket mit :size — jederzeit ergänzbar, ohne Umzug Ihrer Daten.',
],
];
public function __invoke(): View
{
$plans = $this->plans();
// Read once and handed to both blocks that quote a module price, so the
// "optional" cell in the table and the module list underneath it cannot
// print two different figures for the same thing.
$modules = $this->modulePrices();
$tax = CompanyProfile::taxRate();
// Through SetupFee, which is also what the checkout charges. The sentence
// on this page named a figure that nothing collected for months; now the
// quote and the charge come out of one place, so a change to either the fee
// or the rate cannot move one without moving the other.
$setupNet = SetupFee::netCents();
return view('landing', [
'plans' => $plans,
// What the page has to say about tax and about the one-off fee, in
// one place. Both are console-editable figures, and both were
// previously sentences on the page: "Netto pro Monat" and "zzgl.
// einmaliger Einrichtung" — the second of which never named a
// number at all, so a visitor learned only that there was one.
'vat' => [
'rate' => $tax,
'label' => rtrim(rtrim(number_format($tax, 1, ',', '.'), '0'), ','),
],
'setup' => $setupNet === 0 ? null : [
'gross' => $this->money(SetupFee::chargedCents(TaxTreatment::domestic()), Subscription::catalogueCurrency()),
'net' => $this->money($setupNet, Subscription::catalogueCurrency()),
],
'baseline' => $this->baseline($plans),
'comparison' => $this->comparison($plans, $modules),
'addons' => $this->addons($modules),
]);
}
/**
* The promises that hold for every package on sale.
*
* @param array<int, array<string, mixed>> $plans
* @return array<int, array<string, mixed>>
*/
private function baseline(array $plans): array
{
return array_values(array_filter(
array_map(fn (array $item) => [...$item, 'detail' => $this->zone($item['detail'])], self::BASELINE),
function (array $item) use ($plans) {
$key = $item['key'] ?? null;
if ($key === null || ($item['issued'] ?? false)) {
return true;
}
// "In jedem Paket" is a claim about all of them, so one plan
// without the feature retires the claim rather than weakening
// it to a footnote nobody reads.
foreach ($plans as $plan) {
if (! in_array($key, $plan['keys'], true)) {
return false;
}
}
return true;
},
));
}
/**
* The rows on which the packages actually differ, each cell in one of three
* states: carried, buyable, or neither.
*
* Two states could not tell "you do not get this" apart from "you can have
* it for nine euros", so the sheet said the first about four things we
* would happily have sold which is how the page came to mention none of
* the modules at all.
*
* Built from what is on sale rather than from the full feature list, so a
* feature no current plan offers does not appear as an empty row.
*
* @param array<int, array<string, mixed>> $plans
* @param array<string, int> $modules
* @return array<int, array{label: string, cells: array<string, array<string, mixed>>}>
*/
private function comparison(array $plans, array $modules): array
{
$baselineKeys = array_column(self::BASELINE, 'key');
$rows = [];
$supportPlaced = false;
foreach (array_keys(self::FEATURES) as $key) {
if (in_array($key, $baselineKeys, true)) {
continue;
}
// The support row takes the place of the first of the two keys it
// replaces, so the sheet keeps the catalogue's narrative order
// rather than growing an appendix at the bottom.
if (isset(self::SUPPORT[$key])) {
$row = $supportPlaced ? null : $this->supportRow($plans, $modules);
$supportPlaced = true;
if ($row !== null) {
$rows[] = $row;
}
continue;
}
$offered = array_filter($plans, fn (array $plan) => in_array($key, $plan['keys'], true));
if ($offered === []) {
continue;
}
$cells = [];
foreach ($plans as $plan) {
$cells[$plan['key']] = match (true) {
in_array($key, $plan['keys'], true) => ['state' => 'included'],
// Not carried, but on sale TO THIS PACKAGE — the only
// honest third answer. A module we do not sell stays a
// dash, however easy it would be to invent a price for it,
// and so does one this package may not book: an own domain
// is impossible on the entry package, so quoting nine euros
// there would be selling something we would then refuse.
isset($modules[$key]) && $this->bookableOn($key, $plan) => ['state' => 'optional', 'price' => $this->modulePrice($modules[$key])],
default => ['state' => 'absent'],
};
}
$rows[] = ['label' => (string) $this->label($key), 'cells' => $cells];
}
return $rows;
}
/**
* Whether a package could actually book this module.
*
* Only the own domain has such a rule today, and the rule is not restated
* here: CustomDomainAccess owns it, and the sheet asks. A page that decided
* for itself which packages may buy a domain would be a second answer to a
* question the shop already answers, and the two would part company the
* first time the owner changed one of them.
*
* @param array<string, mixed> $plan
*/
private function bookableOn(string $key, array $plan): bool
{
if ($key !== CustomDomainAccess::ADDON) {
return true;
}
return app(CustomDomainAccess::class)->bookableOnPlan((string) $plan['key'], $plan['keys']);
}
/**
* Support as one row of levels.
*
* Omitted entirely when no plan carries either key: four columns all
* reading "Standard" is the row of identical ticks this rebuild removed,
* and the baseline block has already said what standard support is.
*
* @param array<int, array<string, mixed>> $plans
* @param array<string, int> $modules
* @return array{label: string, cells: array<string, array<string, mixed>>}|null
*/
private function supportRow(array $plans, array $modules): ?array
{
$cells = [];
$differs = false;
foreach ($plans as $plan) {
$carried = self::SUPPORT_STANDARD;
// Strongest first: a plan carrying both is shown at the level it
// actually answers at, not at whichever key was checked first.
foreach (self::SUPPORT as $key => $level) {
if (in_array($key, $plan['keys'], true)) {
$carried = $level;
$differs = true;
break;
}
}
$cells[$plan['key']] = [
'state' => 'level',
'text' => $carried['level'],
// The upgrade displaces the level's own qualifier: a customer
// reading the standard cell is better served by what the next
// step costs than by a restatement of the line above.
'note' => $carried === self::SUPPORT_STANDARD && isset($modules['priority_support'])
? 'bevorzugt ab '.$this->modulePrice($modules['priority_support'])
: $carried['note'],
];
}
return $differs ? ['label' => 'Support', 'cells' => $cells] : null;
}
/**
* Today's price for every module actually on sale, in cents.
*
* Swallowed like the plan catalogue and for the same reason (see the class
* docblock): an empty or unreadable module list costs the page a block, not
* the page. Every caller then reads a plain array, so a module that is not
* in it is simply not for sale which is exactly what the dash in the
* table means.
*
* @return array<string, int>
*/
private function modulePrices(): array
{
try {
$catalogue = app(AddonCatalogue::class);
$keys = [...array_keys((array) config('provisioning.addons', [])), AddonCatalogue::STORAGE];
$prices = [];
foreach ($keys as $key) {
$cents = $catalogue->priceCents((string) $key);
// Nothing is sold for nothing. An entry without a price reads
// back as 0 cents, and "optional · 0 €" on a public price sheet
// is an offer somebody would hold us to.
if ($cents === null || $cents <= 0) {
continue;
}
// The storage pack is sold by the pack, so a pack of no size is
// not a product either.
if ($key === AddonCatalogue::STORAGE && (int) config('provisioning.storage_addon.gb', 0) <= 0) {
continue;
}
$prices[(string) $key] = $cents;
}
return $prices;
} catch (Throwable $e) {
Log::error('Landing page could not read the add-on catalogue', ['exception' => $e]);
return [];
}
}
/**
* The modules, in the order the catalogue lists them, with today's prices.
*
* @param array<string, int> $modules
* @return array<int, array{name: string, body: string, price: string}>
*/
private function addons(array $modules): array
{
$rows = [];
foreach ($modules as $key => $cents) {
$copy = self::ADDONS[$key] ?? null;
// Same rule as an unknown feature key: a module nobody has written
// up is left out rather than advertised as "collabora_pro".
if ($copy === null) {
continue;
}
$rows[] = [
'name' => $copy['name'],
'body' => str_replace(
':size',
$this->storage((int) config('provisioning.storage_addon.gb', 0)),
$copy['body'],
),
'price' => $this->modulePrice($cents),
];
}
return $rows;
}
/**
* @return array<int, array<string, mixed>> empty when the catalogue cannot be read
*/
private function plans(): array
{
try {
$sellable = app(PlanCatalogue::class)->sellable();
} catch (Throwable $e) {
// Deliberately swallowed: see the class docblock. Logged at error
// level because a shop that cannot list its plans is not selling.
Log::error('Landing page could not read the plan catalogue', ['exception' => $e]);
return [];
}
$plans = [];
$capacity = app(HostCapacity::class);
foreach ($sellable as $key => $plan) {
$plans[] = [
'key' => $key,
'name' => $plan['name'],
// How soon this package can actually be delivered, read from
// the estate rather than from a sentence somebody typed. Where
// a host has room it rolls out on its own within the hour;
// where none has, a machine has to be bought first — and
// saying "sofort" in that case is a promise the very next
// customer catches us breaking.
'delivery' => $capacity->deliveryFor((int) ($plan['disk_gb'] ?? 0)),
// Console-edited, from the family the plan belongs to — a
// family that has none yet (created but not written up) gets
// an empty string rather than a missing array key, and the
// template renders that sensibly instead of an empty gap.
'audience' => (string) ($plan['audience'] ?? ''),
'note' => (string) ($plan['note'] ?? ''),
// GROSS, because a private customer must never be quoted a
// figure that grows at checkout. A business reads the net one
// underneath and gets the VAT back anyway; a consumer does not,
// and for them the net figure was simply the wrong number.
'price' => $this->money($this->gross((int) $plan['price_cents']), (string) $plan['currency']),
'price_net' => $this->money((int) $plan['price_cents'], (string) $plan['currency']),
// Paying for a year, in the same three figures the order page
// shows: what leaves the account once, what that works out to
// per month, and what twelve monthly payments would have been.
// A sheet that quotes only the monthly price cannot mention a
// saving, and the saving is the reason to offer the term.
'price_yearly' => $this->money(
$yearlyGross = $this->gross((int) ($plan['yearly_price_cents'] ?? 0)),
(string) $plan['currency'],
),
'price_yearly_net' => $this->money((int) ($plan['yearly_price_cents'] ?? 0), (string) $plan['currency']),
// intdiv, so the headline never claims a tenth of a cent nobody
// is charged; the total beside it is the amount actually taken.
'price_yearly_monthly' => $this->money(intdiv($yearlyGross, 12), (string) $plan['currency']),
'price_twelve_months' => $this->money($this->gross((int) $plan['price_cents']) * 12, (string) $plan['currency']),
'free_months' => (int) ($plan['free_months'] ?? 0),
'storage' => $this->storage((int) $plan['quota_gb']),
'traffic' => $this->storage((int) $plan['traffic_gb']),
'seats' => (int) $plan['seats'],
'features' => $this->features($plan['features'] ?? []),
// The raw keys alongside the wording. Everything that has to
// decide something — which row this plan fills, which support
// level it answers at — asks the key; only the card bullets
// read the words. Deciding on the words matched until the
// first label gained a placeholder, and then quietly stopped.
'keys' => array_values((array) ($plan['features'] ?? [])),
'recommended' => (bool) ($plan['recommended'] ?? false),
];
}
return $plans;
}
/**
* One feature, in the words a customer uses.
*
* The address the platform hands out is a setting, not a constant a
* server can be installed against any zone so the sentence naming it is
* assembled rather than written down twice.
*/
private function label(string $key): ?string
{
$label = self::FEATURES[$key] ?? null;
return $label === null ? null : $this->zone($label);
}
/** The configured zone, wherever a sentence names it. */
private function zone(string $text): string
{
return str_replace(':zone', ProvisioningSettings::dnsZone(), $text);
}
/** Currencies we have a symbol for; anything else prints its ISO code. */
private const SYMBOLS = ['EUR' => '€', 'CHF' => 'CHF', 'USD' => '$', 'GBP' => '£'];
/**
* A price as the sheet prints it, currency included.
*
* The symbol comes from the catalogue rather than from the template: the
* currency is configurable (CLUPILOT_CURRENCY), and a page that says ""
* while the checkout charges francs is the same two-sources-of-truth
* mistake as a hard-coded amount, only harder to notice.
*
* Whole units when the amount is whole a price sheet reads "179", not
* "179,00".
*/
/**
* A net figure as the ordinary customer is charged it.
*
* Handed straight to TaxTreatment rather than worked out here. It is the same
* call the domestic Stripe Price is formed with, so the figure on this page and
* the figure on the card statement cannot drift apart. It used to be a
* multiplication of its own, which is a second source of the same number and
* therefore a second answer waiting to happen.
*
* This page is read by visitors nobody has met, so the domestic gross is the
* only honest figure to put on it. A business in another member state with a
* verified VAT id is charged less the bare net, no VAT owed to us at all
* and learns that on the pages that know who they are, not here, where
* advertising it would quote every private visitor a price they cannot have.
*/
private function gross(int $netCents): int
{
return TaxTreatment::advertisedCents($netCents);
}
private function money(int $cents, string $currency): string
{
$amount = $cents % 100 === 0
? number_format($cents / 100, 0, ',', '.')
: number_format($cents / 100, 2, ',', '.');
// Non-breaking: in a narrow table column "49 €" otherwise wraps, and the
// currency ends up on a line of its own under the number.
return $amount."\u{00A0}".(self::SYMBOLS[strtoupper($currency)] ?? strtoupper($currency));
}
/**
* A module price.
*
* A plan price carries its own currency from the catalogue; a module does
* not, and cannot config holds one net amount. The one currency the
* catalogue is priced in is therefore the honest answer, and it is the same
* one the booking page charges in.
*/
private function modulePrice(int $cents): string
{
// Gross, like every other figure on this page. A sheet that quotes the
// package with VAT and the module without it is a sheet where the two
// numbers cannot be added up.
return $this->money($this->gross($cents), Subscription::catalogueCurrency());
}
private function storage(int $gb): string
{
return $gb >= 1000 && $gb % 1000 === 0
? ($gb / 1000).' TB'
: $gb.' GB';
}
/**
* @param array<int, string> $keys
* @return array<int, string>
*/
private function features(array $keys): array
{
// Unknown keys are dropped rather than printed raw: a feature added to
// the catalogue without a translation would otherwise appear on the
// public page as "premium_sla".
return array_values(array_filter(array_map(
fn (string $key) => $this->label($key),
$keys,
)));
}
}

View File

@ -1,68 +0,0 @@
<?php
namespace App\Http\Controllers;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
/**
* Dateien, die jeder herunterladen darf: AGB, Auftragsverarbeitungsvertrag,
* technische und organisatorische Maßnahmen, Preisblätter.
*
* Sie liegen in `storage/app/files/public/` und werden ausgeliefert, wie sie
* sind. Kein Verwaltungsbereich, keine Datenbanktabelle: eine Datei ablegen
* genügt, und was nicht dort liegt, gibt es nicht.
*
* ---------------------------------------------------------------------------
* Warum eine eigene Adresse
* ---------------------------------------------------------------------------
*
* Diese Dateien stehen in Verträgen und auf Rechnungen. Eine Adresse, die beim
* nächsten Umbau des Portals wandert, macht aus jedem dieser Verweise einen
* toten Link und zwar rückwirkend, in Dokumenten, die längst verschickt sind.
* Ein eigener Hostname ist die einzige Zusage, die sich einhalten lässt.
*
* ---------------------------------------------------------------------------
* Versionierte Dateinamen
* ---------------------------------------------------------------------------
*
* `agb-2026-01.pdf`, nicht `agb.pdf`. Eine Fassung, auf die ein Vertrag
* verweist, darf sich unter derselben Adresse NIE ändern sonst zeigt der
* Verweis in einem Vertrag vom Januar plötzlich auf Bedingungen vom Juli, und
* niemand kann mehr sagen, was vereinbart war. Neue Fassung heißt neue Datei.
*
* Erzwungen wird das hier nicht eine Regel, die eine Datei mit dem falschen
* Namen abweist, hilft niemandem, der eine hochladen will. Sie steht hier, weil
* dies die Stelle ist, an der jemand nachsieht.
*/
class PublicFileController extends Controller
{
private const ROOT = 'app/files/public';
public function __invoke(string $file): BinaryFileResponse
{
// NUR der Dateiname, ohne Pfad. `basename()` macht aus
// `../../.env` ein `.env`, und das liegt in diesem Verzeichnis nicht —
// damit ist der Pfad-Ausbruch beantwortet, bevor er anfängt. Ein
// Verzeichnisbaum unter `public/` gibt es absichtlich nicht: er wäre
// die zweite Gelegenheit für denselben Fehler.
$name = basename($file);
// Ein Punktdatei-Name kommt hier nicht durch. Sie sind in diesem
// Verzeichnis nicht vorgesehen, und ihr Vorkommen wäre ein Versehen.
if ($name === '' || str_starts_with($name, '.')) {
abort(404);
}
$path = storage_path(self::ROOT.'/'.$name);
if (! is_file($path)) {
abort(404);
}
// Inline, nicht als Anhang: eine AGB, die der Browser anzeigt, wird
// gelesen; eine, die er herunterlädt, wird weggeklickt.
return response()->file($path, [
'Content-Disposition' => 'inline; filename="'.$name.'"',
]);
}
}

View File

@ -1,192 +0,0 @@
<?php
namespace App\Http\Controllers;
use App\Models\Incident;
use App\Models\MaintenanceWindow;
use App\Models\StatusDay;
use App\Services\Status\ServiceHealth;
use Illuminate\Contracts\View\View;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
/**
* The public service status page.
*
* It answers three questions, and they are different questions: what is
* happening now, what has happened, and what is about to happen. The page used
* to answer only the first, which is the version of a status page that is of no
* use the morning after "Alle Dienste in Betrieb" is true and says nothing to
* somebody who was locked out the evening before.
*
* The measurement itself lives in ServiceHealth, shared with the sampler that
* writes the history, so the banner and the bars cannot disagree.
*
* Aggregate only: counts, never customer names, instance addresses or host
* names. This page is world-readable and the estate is not public information.
*/
class StatusController extends Controller
{
/** How much history the bars show. Ninety days is the convention. */
private const HISTORY_DAYS = 90;
/** How far back the written record goes on this page. */
private const INCIDENT_DAYS = 90;
public function __invoke(ServiceHealth $health): View
{
$components = $health->components();
// An ongoing incident overrides the measurement. Monitoring can be
// perfectly happy while something is broken in a way it does not look
// at, and in that situation the operator's word beats the probe's.
$ongoing = Incident::query()
->published()->ongoing()
->with('updates')
->orderByDesc('started_at')
->get();
$components = $this->applyIncidents($components, $ongoing);
return view('status', [
'components' => $components,
'overall' => $health->overall($components),
'history' => $this->history(),
'ongoing' => $ongoing,
'past' => $this->past(),
'maintenance' => $this->maintenance(),
'checkedAt' => Carbon::now(),
'historyDays' => self::HISTORY_DAYS,
]);
}
/**
* Let an open incident set the state of the components it names.
*
* Only downwards. An incident cannot make a component look better than the
* probes found it that direction is how a status page ends up being used
* to hide something.
*
* @param array<int, array<string, mixed>> $components
* @param Collection<int, Incident> $ongoing
* @return array<int, array<string, mixed>>
*/
private function applyIncidents(array $components, Collection $ongoing): array
{
$rank = ['operational' => 0, 'unknown' => 1, 'degraded' => 2, 'down' => 3];
foreach ($components as $i => $component) {
foreach ($ongoing as $incident) {
if (! in_array($component['key'], $incident->components ?? [], true)) {
continue;
}
// Maintenance is announced work, not a fault. It shows on the
// component without claiming anything is broken.
$state = $incident->impact === Incident::IMPACT_MAINTENANCE ? 'degraded' : $incident->impact;
if (($rank[$state] ?? 0) > ($rank[$component['state']] ?? 0)) {
$components[$i]['state'] = $state;
$components[$i]['detail'] = null;
}
$components[$i]['incident'] = $incident;
}
}
return $components;
}
/**
* The daily bars, one array per component, oldest first.
*
* Every day in the window appears, including the ones nobody measured
* they come back as null and are drawn as a gap. Filling them in from the
* days around them would be inventing the only number on this page a reader
* can check against their own memory.
*
* @return array<string, array{days: array<int, array<string, mixed>>, uptime: float|null}>
*/
private function history(): array
{
$from = Carbon::now()->local()->startOfDay()->subDays(self::HISTORY_DAYS - 1);
$rows = StatusDay::query()
->where('day', '>=', $from->toDateString())
->get()
->groupBy('component');
$history = [];
foreach (ServiceHealth::COMPONENTS as $component) {
$byDay = ($rows[$component] ?? collect())->keyBy(fn (StatusDay $d) => $d->day->toDateString());
$days = [];
$measured = 0;
$up = 0;
for ($i = 0; $i < self::HISTORY_DAYS; $i++) {
$date = $from->copy()->addDays($i);
$row = $byDay[$date->toDateString()] ?? null;
$days[] = [
'date' => $date,
'state' => $row?->worst(),
'uptime' => $row?->uptimePercent(),
];
if ($row !== null) {
$measured += $row->samples - $row->unknown;
$up += $row->operational + $row->degraded;
}
}
$history[$component] = [
'days' => $days,
// Over the whole window, not the mean of the daily figures: a
// day with four samples must not weigh the same as a day with
// three hundred.
'uptime' => $measured > 0 ? round($up / $measured * 100, 2) : null,
];
}
return $history;
}
/**
* Resolved incidents, newest first, grouped by the day they started.
*
* @return Collection<string, Collection<int, Incident>>
*/
private function past(): Collection
{
return Incident::query()
->published()
->whereNotNull('resolved_at')
->where('started_at', '>=', Carbon::now()->subDays(self::INCIDENT_DAYS))
->with('updates')
->orderByDesc('started_at')
->get()
->groupBy(fn (Incident $incident) => $incident->started_at->local()->toDateString());
}
/**
* Announced work that has not finished yet.
*
* Taken from the maintenance windows the console already keeps rather than
* from a second list somebody has to remember to fill in. Published ones
* only: a draft is a plan, not an announcement.
*
* @return Collection<int, MaintenanceWindow>
*/
private function maintenance(): Collection
{
return MaintenanceWindow::query()
->whereNotNull('published_at')
->whereNull('cancelled_at')
->where('state', '!=', 'draft')
->where('ends_at', '>=', Carbon::now())
->orderBy('starts_at')
->get();
}
}

View File

@ -1,167 +0,0 @@
<?php
namespace App\Http\Controllers;
use App\Actions\ApplyStripeBillingEvent;
use App\Actions\StartCustomerProvisioning;
use App\Support\StripeWebhookSecret;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
/**
* Stripe webhook: verifies the signature (when a secret is configured) and turns
* a paid checkout into a provisioning run. Idempotent via the order's
* stripe_event_id Stripe retries a webhook until it gets a 2xx.
*/
class StripeWebhookController extends Controller
{
public function __invoke(
Request $request,
StartCustomerProvisioning $action,
ApplyStripeBillingEvent $billing,
): JsonResponse {
$payload = $request->getContent();
$secret = StripeWebhookSecret::current();
if (blank($secret)) {
// Fail closed: an unconfigured secret must not authorize provisioning
// outside local/testing.
abort_unless(app()->environment('local', 'testing'), 400, 'Stripe webhook secret not configured');
} elseif (! $this->signatureValid($payload, (string) $request->header('Stripe-Signature'), $secret)) {
abort(400, 'invalid signature');
}
$event = json_decode($payload, true) ?: [];
$object = $event['data']['object'] ?? [];
$type = $event['type'] ?? '';
// The billing cycle is Stripe's: once a contract exists, they decide
// when it renews, when a payment failed, and when it has ended. Handled
// before the checkout branch because none of these are checkouts.
$applied = $billing->dispatch($event);
if ($applied !== false) {
if ($applied === null) {
// Either already applied, or about a contract that does not
// exist yet — a checkout takes a moment to become one, and
// Stripe does not deliver in order. Holding it costs a row and
// saves a cancellation we would otherwise never hear about
// again; a replay is a no-op if it was simply a duplicate.
$billing->hold($event);
}
// 2xx either way. Stripe retrying would not change the answer, and
// anything worth replaying is now held rather than dropped.
return response()->json(['handled' => $type, 'applied' => $applied !== null]);
}
// Paid triggers: a synchronous checkout (completed + paid) OR an async
// method clearing later (async_payment_succeeded). Ignore everything else,
// including the still-unpaid completed event for async methods.
$paid = ($type === 'checkout.session.completed' && ($object['payment_status'] ?? null) === 'paid')
|| $type === 'checkout.session.async_payment_succeeded';
if (! $paid) {
return response()->json(['ignored' => true]);
}
$meta = $object['metadata'] ?? [];
// A real email is required — never merge unrelated customers under a
// manufactured address or send credentials into the void.
$email = $object['customer_details']['email'] ?? $object['customer_email'] ?? ($meta['email'] ?? null);
if (blank($email)) {
return response()->json(['ignored' => 'no_customer_email']);
}
$action->fromStripeEvent([
// Deduplicate on the checkout session id (stable across the completed
// and async-succeeded events for one purchase), not the event id.
'id' => (string) ($object['id'] ?? $event['id'] ?? ''),
'email' => $email,
'name' => $object['customer_details']['name'] ?? ($meta['name'] ?? null),
'stripe_customer_id' => $object['customer'] ?? null,
// The handle every later billing event arrives with. Without it an
// invoice.paid cannot be matched to the contract it renews.
'stripe_subscription_id' => is_string($object['subscription'] ?? null) ? $object['subscription'] : null,
// What a refund would have to be issued against, kept at the only
// moment Stripe tells us. A subscription checkout charges through
// an invoice and usually reports no PaymentIntent at all, so both
// are taken and App\Actions\WithdrawContract uses whichever it has.
'stripe_payment_intent_id' => is_string($object['payment_intent'] ?? null) ? $object['payment_intent'] : null,
'stripe_invoice_id' => is_string($object['invoice'] ?? null) ? $object['invoice'] : null,
// The consumer's express request that the service begin inside the
// fourteen-day withdrawal window. Set on the session metadata by the
// checkout; anything other than the exact string is a no, because a
// consent that can be produced by a typo is not a consent.
//
// Nothing decides anything on it now — a withdrawal refunds the
// whole amount either way — so it is carried as a record of what was
// agreed rather than as a gate. See App\Models\Order.
'immediate_start_consent' => ($meta['immediate_start'] ?? null) === '1',
'plan' => $meta['plan'] ?? 'start',
// What the customer was actually shown. Absent on a session created
// before phase 5 put it there, and then the version on sale applies.
'plan_version_id' => isset($meta['plan_version_id']) ? (int) $meta['plan_version_id'] : null,
'datacenter' => $meta['datacenter'] ?? 'fsn',
'amount_cents' => (int) ($object['amount_total'] ?? $object['amount'] ?? 0),
// How much of that total was the one-off setup fee, which rode along
// as a second line item on the session. From OUR metadata rather than
// from Stripe's line items: this is the figure we asked for, we asked
// for it once, and reading it back from the amounts would mean
// guessing which line was which.
//
// Clamped to the total. A negative or oversized value could only come
// from a session that was not ours, and the package's charge is derived
// by subtracting this — a wild figure there would put a negative price
// on the contract's own line.
'setup_fee_cents' => max(0, min(
(int) ($object['amount_total'] ?? $object['amount'] ?? 0),
(int) ($meta['setup_fee_cents'] ?? 0),
)),
'currency' => strtoupper($object['currency'] ?? 'eur'),
]);
return response()->json(['ok' => true]);
}
/** Stripe's default replay tolerance, in seconds. */
private const SIGNATURE_TOLERANCE = 300;
/** Verify Stripe's `t=…,v1=…` signature: HMAC-SHA256 of "{t}.{payload}". */
private function signatureValid(string $payload, string $header, string $secret): bool
{
if (blank($header)) {
return false;
}
$timestamp = null;
$signatures = [];
foreach (explode(',', $header) as $segment) {
[$key, $value] = array_pad(explode('=', $segment, 2), 2, '');
if ($key === 't') {
$timestamp = $value;
} elseif ($key === 'v1') {
$signatures[] = $value; // keep every v1 (secret rotation sends several)
}
}
if ($timestamp === null || $signatures === []) {
return false;
}
// Reject replays outside the tolerance window.
if (abs(time() - (int) $timestamp) > self::SIGNATURE_TOLERANCE) {
return false;
}
$expected = hash_hmac('sha256', $timestamp.'.'.$payload, $secret);
foreach ($signatures as $signature) {
if (hash_equals($expected, $signature)) {
return true;
}
}
return false;
}
}

View File

@ -1,26 +0,0 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Symfony\Component\HttpFoundation\Response;
class EnsureAdmin
{
/**
* Only operators with the console.view capability may reach the admin
* console. Checked on the operator guard explicitly, never on the default
* (`web`) guard's user a portal account can no longer hold a role at all,
* so trusting the default guard here would just mean nobody ever gets in.
*/
public function handle(Request $request, Closure $next): Response
{
$operator = Auth::guard('operator')->user();
abort_unless($operator !== null && $operator->isActive() && $operator->can('console.view'), 403);
return $next($request);
}
}

View File

@ -1,54 +0,0 @@
<?php
namespace App\Http\Middleware;
use App\Models\Customer;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Symfony\Component\HttpFoundation\Response;
/**
* Enforce customer account lifecycle on portal requests: a suspended or closed
* customer must lose access. An active impersonation session is exempt so
* operators can still inspect a suspended customer's portal. There is no
* operator special case here any more: a `web`-guard user can never be an
* operator, so the only thing left to exempt is an impersonation session.
*/
class EnsureCustomerActive
{
public function handle(Request $request, Closure $next): Response
{
$user = $request->user();
if ($user !== null && ! $request->session()->has('impersonator_id')) {
$customer = Customer::query()->where('user_id', $user->id)->first()
?? Customer::query()->where('email', $user->email)->first();
if ($customer !== null && ($customer->status === 'suspended' || $customer->status === 'closed' || $customer->closed_at !== null)) {
Auth::logout();
// NOT session()->invalidate(): where the console and the portal
// share a host (shared/fallback mode — this VM's own mode), both
// guards keep their login key in the SAME session. invalidate()
// is flush() + migrate(true) — it discards every attribute in the
// session, not just the 'web' guard's, so it would silently sign
// an operator elsewhere in that same browser out of the console
// too. regenerate() issues a new session id (no fixation risk)
// without touching the other attributes, so the 'operator'
// guard's own login key — untouched by the Auth::logout() call
// above, which only logs out the default ('web') guard —
// survives untouched. Same fix as routes/admin.php's own
// /logout route, mirrored the other way round.
$request->session()->regenerate();
$request->session()->regenerateToken();
$key = $customer->closed_at !== null || $customer->status === 'closed' ? 'auth.account_closed' : 'auth.account_suspended';
return redirect()->route('login')->withErrors(['email' => __($key)]);
}
}
return $next($request);
}
}

View File

@ -1,125 +0,0 @@
<?php
namespace App\Http\Middleware;
use App\Support\AdminArea;
use App\Support\Settings;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Symfony\Component\HttpFoundation\IpUtils;
use Symfony\Component\HttpFoundation\Response;
/**
* Hides the marketing site and the customer portal while the product is still
* being built, without hiding it from us.
*
* Switched from the console (site.public). Anyone coming through the management
* VPN, and any signed-in operator, sees the real thing; everyone else crawlers
* included gets a placeholder.
*
* Answers 503 with Retry-After and X-Robots-Tag rather than 200: a 200 would
* invite search engines to index the placeholder as the site's content, and
* getting that out of an index again is much harder than keeping it out.
*/
class PublicSiteGate
{
/**
* Paths that must keep working regardless: the console itself (otherwise the
* switch could not be flipped back), Stripe's webhook, and the health check.
*
* Livewire is deliberately NOT in this list. Its endpoint is shared by the
* console and the portal, so exempting it would leave a signed-in customer
* able to drive portal components including billing while the portal is
* supposed to be offline. Operators pass the check below anyway, which is
* what the console actually needs.
*
* Nor is /login, and that has a consequence worth knowing before you hide
* the site: /admin sends a guest to /login, which is not the console, so
* SIGNING IN while hidden needs the request to come from a trusted range
* the management VPN, or an address put in TRUSTED_RANGES for the initial
* setup. Exempting the login flow by hostname instead would mean trusting a
* Host header, which the caller chooses, and one forged header would unhide
* the entire portal.
*/
private const ALWAYS_ALLOWED = ['webhooks/*', 'up', 'robots.txt'];
public function handle(Request $request, Closure $next): Response
{
// The console is exempt wherever it currently lives. This used to be
// the literal paths 'admin' and 'admin/*', which stops matching the
// moment the console moves to the root of its own hostname — and the
// result would be 503 on the sign-in page of the console, which is the
// only place the switch can be turned back on. Asking AdminArea keeps
// the exemption attached to the console rather than to a path that
// happened to be true once.
if (AdminArea::isConsole($request) || $request->is(...self::ALWAYS_ALLOWED) || Settings::bool('site.public', true)) {
return $next($request);
}
if ($this->isDownload($request)) {
return $next($request);
}
if ($this->fromManagementNetwork($request) || $this->isActiveOperator()) {
return $next($request);
}
return response()->view('coming-soon', [], 503)->withHeaders([
'Retry-After' => 3600,
'X-Robots-Tag' => 'noindex, nofollow, noarchive',
'Cache-Control' => 'no-store',
]);
}
/**
* Die Datei-Domain bleibt erreichbar, auch solange die Website verborgen ist.
*
* Sonst bricht jede Host-Übernahme, und zwar unsichtbar: ein Server im
* Rettungssystem holt das Archiv mit `curl … | tar xz`, bekäme hier die
* 503-Platzhalterseite und schöbe sie in `tar`. Der Betreiber sähe einen
* Entpackfehler auf einer Maschine, an die er nur noch über die
* Anbieterkonsole kommt und nichts davon deutete auf einen Schalter in
* der Konsole hin. Er ist per Definition weder in einem verwalteten Netz
* noch angemeldet: das ist ja gerade der Zustand, den er verlassen will.
*
* Über den ROUTENNAMEN, nicht über den Pfad: die Route existiert nur, wenn
* die Domain gebunden hat. Der Docblock oben warnt zu Recht davor, per
* Hostname auszunehmen dort ginge es aber um das gesamte Portal, und ein
* gefälschter Host-Header hätte es aufgedeckt. Hier stehen zwei Routen
* dahinter: die Rechtsdokumente, die ohnehin öffentlich sind, und das
* Archiv, das ohne gültigen Einmal-Code mit 404 antwortet. Wer den Header
* fälscht, gewinnt damit nichts, was er nicht auch nach dem Start hätte.
*/
private function isDownload(Request $request): bool
{
return in_array($request->route()?->getName(), ['bootstrap.archive', 'files.public'], true);
}
private function fromManagementNetwork(Request $request): bool
{
$ranges = (array) config('admin_access.trusted_ranges', []);
return $ranges !== [] && IpUtils::checkIp((string) $request->ip(), $ranges);
}
/**
* Same standard as EnsureAdmin: an active operator holding a console role.
* A bare `check()` stays true for a disabled operator's live session
* disabling someone did not sign them out, so it never stopped this gate
* from waving them through indefinitely.
*
* Deliberately checked, not enforced by logging out: this gate only
* decides what ONE request sees, and destroying the session would ripple
* into whatever else that session is doing (a concurrent console tab,
* mid-task) a much bigger side effect than a visibility check exists to
* have. EnsureAdmin sets the precedent: it also 403s a disabled operator
* per request without touching their session.
*/
private function isActiveOperator(): bool
{
$operator = Auth::guard('operator')->user();
return $operator !== null && $operator->isActive() && $operator->can('console.view');
}
}

View File

@ -1,56 +0,0 @@
<?php
namespace App\Http\Middleware;
use App\Support\AdminArea;
use App\Support\Settings;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Symfony\Component\HttpFoundation\Response;
/**
* Two-factor for operators, when the owner has made it compulsory.
*
* Off by default. ENROLMENT (admin.two-factor-setup) is exempt, because it is
* where the operator sets two-factor up gating it would leave nobody able
* to satisfy the requirement. Nothing else is.
*
* This used to exempt the whole of admin.settings, on the theory that it was
* "the page where two-factor is set up". That component also carries staff
* management, site visibility, network restrictions, account changes, and
* the two-factor policy switch itself so an unenrolled operator got
* unrestricted access to all of that, not just enrolment (Codex R15, P1b).
* Enrolment has its own route and component for exactly this reason: one
* page to exempt is one decision, not eight actions each needing to
* remember to check.
*/
class RequireOperatorTwoFactor
{
public function handle(Request $request, Closure $next): Response
{
if (! Settings::bool('console.require_2fa', false)) {
return $next($request);
}
$operator = Auth::guard('operator')->user();
if ($operator === null || $operator->two_factor_confirmed_at !== null) {
return $next($request);
}
// The two pages that must stay reachable: where two-factor is
// enrolled, and signing out. Both through AdminArea::routeIs(), not
// a bare $request->routeIs() — a recovery hostname's routes are
// named admin.viaN.*, and a bare check matches only the canonical
// name, which would leave an unenrolled operator unable to sign out
// on exactly the host that exists because the canonical one is not
// working.
if (AdminArea::routeIs('admin.two-factor-setup') || AdminArea::routeIs('admin.logout')) {
return $next($request);
}
return redirect()->route('admin.two-factor-setup')
->with('status', __('admin_settings.two_factor_required'));
}
}

View File

@ -1,103 +0,0 @@
<?php
namespace App\Http\Middleware;
use App\Support\AdminArea;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* The console answers only on its own hostname and that hostname answers
* only the console.
*
* Both halves matter. The first keeps the console off the public domains. The
* second is what actually separates the two products: binding the console
* routes to a hostname does not stop the CUSTOMER routes from answering there
* as well, because they are registered without a hostname. Without this,
* app.clupilot.com and admin.clupilot.com would still serve each other's pages
* wherever the paths did not collide.
*
* 404 in both directions, never 403: a stranger must not learn that a console
* lives here, and an operator on the wrong host should see the same nothing a
* stranger does.
*
* A handful of endpoints are shared by both sides and must answer on either
* host Livewire's own endpoints and the authentication actions. They are
* listed explicitly below, because the failure mode of getting that list wrong
* is "the console is broken" rather than "the rule is wrong", and a list you
* can read is easier to correct than a rule you have to infer.
*/
class RestrictAdminHost
{
/**
* Endpoints both the console and the portal need, on whichever host the
* caller is currently on.
*
* `login`, `logout` and `two-factor-challenge` used to be here, from when
* one sign-in page served both. They are gone: the console has its own at
* `admin.login`, and leaving the portal's here is what put a registration
* link in front of an operator with a 404 behind it, because `register`
* was never in this list and could not be, since the console has no
* registration by design.
*
* Livewire's component endpoint is guarded separately and more strictly:
* the console's own middleware is registered as persistent, so an action
* posted to /livewire/update is re-checked against the component's real
* route. Letting the path through here does not let anything through there.
*/
private const SHARED = [
'livewire/*',
'up',
// The console's own live-provisioning feed authorizes here (see
// routes/channels.php, admin.runs) — a route Laravel registers
// without any admin.* name, so isConsoleRoute() alone would not
// exempt it, and in exclusive mode it would 404 on the console host
// exactly like a stranger's request would.
'broadcasting/auth',
];
public function handle(Request $request, Closure $next): Response
{
// Not host-bound (development, a fresh checkout): the console keeps its
// /admin prefix on any host and nothing is separated. Upgrading must
// not lock anyone out of a system that was working.
if (! AdminArea::isHostBound()) {
return $next($request);
}
$isConsoleRoute = $this->isConsoleRoute($request);
$onConsoleHost = AdminArea::covers($request->getHost());
// The console, reached through a hostname that is not the console's.
if ($isConsoleRoute && ! $onConsoleHost) {
abort(404);
}
// The portal or the public site, reached through the console's
// hostname. Only once exclusivity is switched on: a machine that lists
// its own IP in ADMIN_HOSTS so the console is reachable without DNS
// still has to serve the portal from that same address.
if (AdminArea::isExclusive() && $onConsoleHost && ! $isConsoleRoute && ! $request->is(...self::SHARED)) {
abort(404);
}
return $next($request);
}
/**
* Console routes are the ones named `admin.*`.
*
* By name rather than by path: the path is exactly what changes between
* host-bound and fallback mode, and a rule written against it would be
* wrong in one of them.
*/
private function isConsoleRoute(Request $request): bool
{
$name = $request->route()?->getName();
// Covers admin.* and the alternate-host registrations (admin.viaN.*),
// which are the same console reached by a recovery hostname.
return $name !== null && str_starts_with($name, 'admin.');
}
}

View File

@ -1,134 +0,0 @@
<?php
namespace App\Http\Middleware;
use App\Support\AdminArea;
use App\Support\Settings;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\IpUtils;
use Symfony\Component\HttpFoundation\Response;
/**
* Who may reach the operator console, by network address.
*
* RestrictAdminHost answers "under which NAME does the console respond" and a
* Host header is chosen by the caller, so it can never answer "who is asking".
* This one can: the client address behind a trusted proxy is not something the
* client gets to pick.
*
* The management VPN always counts. Beyond that the owner keeps a list an
* office line, a home connection so being away from the VPN does not mean
* being locked out. That list lives in the console rather than in a proxy
* config file, because the person who needs to change it is the person sitting
* in the console.
*
* 404, never 403: a stranger must not learn that a console lives here.
*
* This is not a substitute for a firewall. It runs after PHP has started, so it
* bounds who can USE the console, not who can make the server work. Where a
* network-level ACL is also possible, both belong there.
*/
class RestrictConsoleNetwork
{
public function handle(Request $request, Closure $next): Response
{
// Scoped to the console, wherever the console currently is. Asking
// AdminArea rather than testing the path: once the console moves to the
// root of its own hostname there is no `/admin` left to match, and a
// guard that silently stops matching is worse than no guard.
if (! AdminArea::isConsole($request)) {
return $next($request);
}
if (! self::isRestricted()) {
return $next($request);
}
if (! self::allows((string) $request->ip())) {
abort(404);
}
return $next($request);
}
/** Whether the owner has switched the restriction on at all. */
public static function isRestricted(): bool
{
return Settings::bool('console.network_restricted', false);
}
/**
* Every network that may reach the console.
*
* The VPN is always in it and is not removable it is the one path that
* exists independently of whatever the owner has typed into the list, and
* without it a bad entry would leave nobody able to fix the entry.
*
* @return array<int, string>
*/
public static function allowedRanges(): array
{
$vpn = (array) config('admin_access.trusted_ranges', []);
$own = (array) Settings::get('console.allowed_ips', []);
return array_values(array_unique(array_filter(array_merge(
$vpn,
array_map('trim', array_map('strval', $own)),
))));
}
public static function allows(string $ip): bool
{
return self::covers($ip, self::allowedRanges());
}
/**
* Would this address still get in, given that list?
*
* Split out so the "you are about to lock yourself out" decision can be
* made and tested without a request: it is the one check whose failure
* mode is that nobody can reach the page that would fix it.
*
* @param array<int, string> $extra the owner's list AFTER the change
*/
public static function wouldStillAllow(string $ip, array $extra): bool
{
$vpn = (array) config('admin_access.trusted_ranges', []);
return self::covers($ip, array_merge($vpn, $extra));
}
/**
* Whether a value is an address or a CIDR range at all.
*
* Shared by the console and the recovery command on purpose: an entry that
* matches nothing is stored happily and reports success, and the operator
* then switches the restriction on believing they are covered. That is the
* exact situation the recovery command exists to get out of.
*/
public static function isNetwork(string $value): bool
{
[$address, $prefix] = array_pad(explode('/', $value, 2), 2, null);
if (filter_var($address, FILTER_VALIDATE_IP) === false) {
return false;
}
if ($prefix === null) {
return true;
}
$max = str_contains((string) $address, ':') ? 128 : 32;
return ctype_digit((string) $prefix) && (int) $prefix >= 0 && (int) $prefix <= $max;
}
/** @param array<int, string> $ranges */
private static function covers(string $ip, array $ranges): bool
{
$ranges = array_values(array_filter($ranges));
return $ranges !== [] && $ip !== '' && IpUtils::checkIp($ip, $ranges);
}
}

View File

@ -1,74 +0,0 @@
<?php
namespace App\Http\Middleware;
use App\Models\LoginSession;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Symfony\Component\HttpFoundation\Response;
/**
* Keeps a session row pointing at the session it describes.
*
* Laravel rotates the session id on every sign-in and on every regenerate().
* A row that stored the id once stops matching within a request or two and
* the list would then show sessions that cannot be ended and hide the one the
* reader is sitting in. The uuid inside the payload is what survives; the id is
* refreshed from it here.
*
* Also the only thing that keeps "last seen" honest. Without it the list
* reports the moment somebody signed in, which for a browser left open for a
* fortnight is the least useful timestamp available.
*
* Written at most once a minute. This runs on every authenticated request, and
* a write per request would turn a page of thumbnails into forty updates of the
* same row.
*/
class TouchLoginSession
{
private const THROTTLE_SECONDS = 60;
public function handle(Request $request, Closure $next): Response
{
$response = $next($request);
if (! $request->hasSession()) {
return $response;
}
$uuid = $request->session()->get(LoginSession::KEY);
if (! is_string($uuid) || $uuid === '') {
return $response;
}
$current = $request->session()->getId();
// A bare update rather than a model round-trip: this is on the path of
// every authenticated request, and the row is not needed, only moved.
DB::table('login_sessions')
->where('uuid', $uuid)
->where(function ($q) use ($current) {
// A CHANGED id is never throttled. Both sign-in paths raise the
// Login event before calling session()->regenerate(), so the row
// is written against an id that is dead a moment later. Held
// back by the throttle, the row would point at nothing for the
// first minute of every session — the one it describes missing
// from the list, and "end this one" deleting a session that no
// longer exists.
$q->where('session_id', '!=', $current)
->orWhereNull('session_id')
->orWhereNull('last_seen_at')
->orWhere('last_seen_at', '<=', now()->subSeconds(self::THROTTLE_SECONDS));
})
->update([
'session_id' => $current,
'ip_address' => $request->ip(),
'last_seen_at' => now(),
'updated_at' => now(),
]);
return $response;
}
}

View File

@ -1,70 +0,0 @@
<?php
namespace App\Jobs;
use App\Models\ExportTarget;
use App\Models\Invoice;
use App\Models\InvoiceExport;
use App\Services\Billing\InvoiceArchive;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Throwable;
/**
* Put one invoice on ONE destination, and keep trying if that one is away.
*
* One job per pair rather than one per invoice: the reason for having a second
* destination is that the first can fail, and a job that writes to both would
* retry the one that worked every time the other did not and would report a
* single outcome for two different things.
*
* Queued rather than done during the request that issued the invoice: a network
* mount can block for its whole timeout, and an invoice must not fail to be
* issued because a NAS in an office is rebooting.
*/
class ArchiveInvoice implements ShouldQueue
{
use Queueable;
public int $tries = 5;
/** A destination that is gone is usually gone for a while. */
public array $backoff = [60, 300, 900, 3600];
public function __construct(public Invoice $invoice, public ExportTarget $target) {}
public function handle(InvoiceArchive $archive): void
{
if (! $this->target->active) {
return;
}
$record = InvoiceExport::query()->firstOrNew([
'invoice_id' => $this->invoice->id,
'export_target_id' => $this->target->id,
]);
// Already there. A retry after a timeout that in fact succeeded must
// not write it a second time.
if ($record->exists && $record->exported_at !== null) {
return;
}
$record->attempts = (int) $record->attempts + 1;
$record->save();
$archive->store($this->invoice, $this->target);
$record->forceFill(['exported_at' => now(), 'error' => null])->save();
}
public function failed(?Throwable $e): void
{
// Against the pair, not the invoice: "on the office NAS, not on the
// off-site box" is the thing somebody needs to be told.
InvoiceExport::query()->updateOrCreate(
['invoice_id' => $this->invoice->id, 'export_target_id' => $this->target->id],
['error' => mb_substr((string) $e?->getMessage(), 0, 250)],
);
}
}

View File

@ -1,89 +0,0 @@
<?php
namespace App\Listeners;
use App\Models\Customer;
use App\Models\SentMail;
use Illuminate\Mail\Events\MessageSent;
use Illuminate\Support\Facades\Log;
use Throwable;
/**
* Write every outgoing mail into the register.
*
* On MessageSent rather than MessageSending: a row here means the transport
* accepted the message. Recording the intention instead would fill the register
* with mails that never left, which is worse than no register an operator
* would show a customer a line proving something was sent that was not.
*
* One listener for every mailable in the application, rather than a call in
* each of them. A register that has to be remembered at each call site is a
* register with holes in it, and the holes are exactly the mails nobody thought
* about.
*/
class RecordSentMail
{
/**
* Mails that are not worth a row.
*
* The verification and reset mails go to an address that has not been
* confirmed to belong to anybody yet, and both are sent in bulk by anyone
* who can type an address into a form. Recording them would let a stranger
* write lines into the operator's register at will.
*/
private const IGNORED = [
\App\Mail\VerifyEmailMail::class,
\App\Mail\ResetPasswordMail::class,
];
public function handle(MessageSent $event): void
{
try {
$this->record($event);
} catch (Throwable $e) {
// A register that cannot be written must never be the reason a mail
// fails. The send already happened by the time this runs, and
// throwing here would turn a delivered message into a failed job
// that gets delivered a second time on retry.
Log::error('Could not record a sent mail', ['exception' => $e]);
}
}
private function record(MessageSent $event): void
{
$mailable = $event->data['__laravel_mailable'] ?? null;
if ($mailable !== null && in_array($mailable, self::IGNORED, true)) {
return;
}
$message = $event->message;
$to = collect($message->getTo())->map(fn ($address) => $address->getAddress())->all();
if ($to === []) {
return;
}
// One row per recipient. A mail to three addresses is three deliveries,
// and an operator asking "what did this customer get" must not have to
// read someone else's row to find out.
foreach ($to as $address) {
SentMail::create([
'customer_id' => $this->customerFor($address)?->id,
'to' => $address,
'subject' => (string) $message->getSubject(),
'mailable' => $mailable,
// Set by the console when an operator writes one themselves;
// see Admin\CustomerMessage, which fills the body in afterwards.
'from_operator' => false,
'sent_at' => now(),
]);
}
}
/** Whose mail this was, where the address belongs to somebody we know. */
private function customerFor(string $address): ?Customer
{
return Customer::query()->where('email', $address)->first();
}
}

View File

@ -1,66 +0,0 @@
<?php
namespace App\Listeners;
use App\Mail\NewDeviceSignInMail;
use App\Services\Devices\DeviceRecognition;
use Illuminate\Auth\Events\Login;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
use Throwable;
/**
* One listener for both ways in.
*
* The console signs in through App\Livewire\Auth\OperatorLogin and the portal
* through Fortify two code paths that share nothing. Both raise Laravel's
* Login event, and it carries the guard, which is the one thing that tells
* operators and customers apart (R21). Hooking the event rather than the two
* controllers means neither can be changed in a way that quietly stops
* recording sign-ins.
*
* Nothing here may prevent a sign-in. Recognising a device is a safeguard, and
* a safeguard that locks the owner out when it breaks has stopped being one
* so a failure is logged and swallowed. This is a deliberate exception to
* failing loudly, and the reason is written here so nobody removes it as an
* oversight.
*/
class RecordSignInDevice
{
public function __construct(
private readonly DeviceRecognition $devices,
private readonly Request $request,
) {}
public function handle(Login $event): void
{
try {
$outcome = $this->devices->recognise($event->user, $event->guard, $this->request);
$this->devices->attachSession($outcome->device, $this->request);
if (! $outcome->shouldWarn) {
return;
}
$address = $event->user->getAttribute('email');
if (! is_string($address) || $address === '') {
return;
}
// Queued: a sign-in must not wait on a mail server, and this one
// runs while somebody is looking at a spinner on the login button.
Mail::to($address)->queue(new NewDeviceSignInMail(
name: (string) ($event->user->getAttribute('name') ?? ''),
device: $outcome->device,
guard: $event->guard,
));
} catch (Throwable $e) {
Log::warning('Could not record the device for a sign-in', [
'guard' => $event->guard,
'exception' => $e->getMessage(),
]);
}
}
}

View File

@ -1,112 +0,0 @@
<?php
namespace App\Livewire\Admin;
use App\Models\Host;
use App\Services\Provisioning\HostCapacity;
use App\Services\Provisioning\ServerMarket;
use Livewire\Attributes\Layout;
use Livewire\Component;
/**
* Who is waiting for a machine, and what machine would clear them.
*
* A paid order that cannot be placed is parked rather than failed the
* customer has paid, and "no host has room" is a purchasing decision, not a
* fault. This is where that decision is taken: what is queued, what the
* roomiest host still has, what the queue needs, and what such a machine costs
* today.
*
* Nothing here buys anything. Ordering a server is a contract; a bug in a
* capacity calculation must not be able to sign one.
*/
#[Layout('layouts.admin')]
class Capacity extends Component
{
/**
* Look again now.
*
* The runner retries every couple of minutes on its own. This is for the
* operator who has just finished onboarding a host and does not want to
* wait for the tick to see the queue drain.
*/
public function recheck(): void
{
$this->authorize('hosts.manage');
// Through the same query that FINDS them, so "look again" can never
// mean a different set of runs than the table above it is showing.
app(HostCapacity::class)->parkedRuns()->update(['next_attempt_at' => now()]);
$this->dispatch('notify', message: __('capacity.rechecked'));
}
/**
* Decide which parked order goes on which host.
*
* The queue is read by somebody who knows things the placement rule does
* not that this customer belongs beside that one, that a machine is about
* to be taken out of service, that the big order should have the new box
* and the small ones can share. The rule stays in charge when nobody says
* otherwise; an empty choice hands it back.
*
* Only the preference is written. The reservation still happens in the
* provisioning step, under the placement lock, and still checks the host
* has room a console that created the instance itself would be a second
* placement path with no lock and its own arithmetic.
*/
public function pin(int $runId, string $hostId): void
{
$this->authorize('hosts.manage');
// Only a run that is actually parked. Writing a host preference onto a
// run that has moved on would sit in its context for ever and take
// effect on a re-run months later, on a host chosen for a queue that no
// longer exists.
$run = app(HostCapacity::class)->parkedRuns()->whereKey($runId)->first();
if ($run === null) {
return;
}
if ($hostId === '') {
$run->forgetContext('preferred_host_id');
$this->dispatch('notify', message: __('capacity.pin_cleared'));
return;
}
$host = Host::query()->where('status', 'active')->find((int) $hostId);
if ($host === null) {
return;
}
$run->mergeContext(['preferred_host_id' => $host->id]);
// Taken up now rather than at the next tick: an operator who has just
// said where it goes is watching for it to go there.
$run->forceFill(['next_attempt_at' => now()])->save();
$this->dispatch('notify', message: __('capacity.pinned', ['host' => $host->name]));
}
public function render()
{
$this->authorize('hosts.manage');
$capacity = app(HostCapacity::class);
$demand = $capacity->queueDemand();
return view('livewire.admin.capacity', [
'parked' => $capacity->parked(),
'demand' => $demand,
'unplaceable' => $capacity->unplaceablePlans(),
'hosts' => Host::query()->where('status', 'active')->orderBy('name')->get(),
// Only when something is actually waiting. A shopping list on an
// idle estate is an invitation to spend money for no reason.
'offers' => $demand['count'] > 0
? app(ServerMarket::class)->offers($demand['largest'])
: [],
]);
}
}

View File

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

View File

@ -1,40 +0,0 @@
<?php
namespace App\Livewire\Admin;
use App\Models\Incident;
use LivewireUI\Modal\ModalComponent;
/**
* "Really remove this incident?"
*
* In the product's own dialog, not the browser's (R23). The modal mutates
* nothing itself: it dispatches, and Admin\Incidents which already holds the
* capability check does the work. Duplicating the authorisation here would
* mean two places to keep right.
*/
class ConfirmDeleteIncident extends ModalComponent
{
public string $uuid = '';
public string $title = '';
public function mount(string $uuid): void
{
$incident = Incident::query()->where('uuid', $uuid)->firstOrFail();
$this->uuid = $incident->uuid;
$this->title = $incident->title;
}
public function confirm(): void
{
$this->dispatch('incident-delete-confirmed', uuid: $this->uuid);
$this->closeModal();
}
public function render()
{
return view('livewire.admin.confirm-delete-incident');
}
}

View File

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

View File

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

View File

@ -1,24 +0,0 @@
<?php
namespace App\Livewire\Admin;
use LivewireUI\Modal\ModalComponent;
/**
* Confirmation before an operator's own two-factor login is removed (R23).
* No mutation here TwoFactorSetup::disableTwoFactor() still runs its own
* requireConfirmedPassword() check, unchanged.
*/
class ConfirmDisableTwoFactor extends ModalComponent
{
public function confirm(): void
{
$this->dispatch('two-factor-disable-confirmed');
$this->closeModal();
}
public function render()
{
return view('livewire.admin.confirm-disable-two-factor');
}
}

View File

@ -1,24 +0,0 @@
<?php
namespace App\Livewire\Admin;
use LivewireUI\Modal\ModalComponent;
/**
* Confirmation before an operator signs every other browser out (R23). The
* console's own, because operators and customers are two identities (R21) and
* one shared modal would raise an event both sides listen for.
*/
class ConfirmEndOtherSessions extends ModalComponent
{
public function confirm(): void
{
$this->dispatch('sessions-end-others-confirmed');
$this->closeModal();
}
public function render()
{
return view('livewire.admin.confirm-end-other-sessions');
}
}

View File

@ -1,39 +0,0 @@
<?php
namespace App\Livewire\Admin;
use App\Services\Secrets\SecretVault;
use LivewireUI\Modal\ModalComponent;
/**
* Confirmation before a stored override is removed (R23), reverting the key
* to whatever the server file provides. No mutation here it dispatches
* back to Secrets, whose forget() is unchanged.
*/
class ConfirmForgetSecret extends ModalComponent
{
public string $key;
public string $label = '';
public function mount(string $key): void
{
$this->authorize('secrets.manage');
abort_if(! array_key_exists($key, SecretVault::REGISTRY), 404);
$this->key = $key;
$this->label = __(SecretVault::REGISTRY[$key]['label']);
}
public function confirm(): void
{
$this->authorize('secrets.manage');
$this->dispatch('secret-forget-confirmed', key: $this->key);
$this->closeModal();
}
public function render()
{
return view('livewire.admin.confirm-forget-secret');
}
}

View File

@ -1,45 +0,0 @@
<?php
namespace App\Livewire\Admin;
use App\Models\DpaVersion;
use LivewireUI\Modal\ModalComponent;
/**
* Confirmation before a processing agreement goes into force (R23).
*
* Publishing is not a save: acceptance is per version, so from this moment every
* customer who had accepted the previous one is outstanding again. That is the
* correct behaviour and an expensive one to trigger by a misplaced click.
*
* Mutates nothing itself it dispatches back to ProcessingAgreements, whose
* publish() keeps the permission check it already had.
*/
class ConfirmPublishDpa extends ModalComponent
{
public string $uuid;
public string $version = '';
public function mount(string $uuid): void
{
$this->authorize('dpa.manage');
$version = DpaVersion::query()->where('uuid', $uuid)->firstOrFail();
$this->uuid = $uuid;
$this->version = $version->version;
}
public function confirm(): void
{
$this->authorize('dpa.manage');
$this->dispatch('dpa-publish-confirmed', uuid: $this->uuid);
$this->closeModal();
}
public function render()
{
return view('livewire.admin.confirm-publish-dpa');
}
}

View File

@ -1,39 +0,0 @@
<?php
namespace App\Livewire\Admin;
use App\Models\VpnPeer;
use LivewireUI\Modal\ModalComponent;
/**
* Confirmation before a staff access gets a fresh keypair (R23). The reissue
* itself still runs on Vpn::reissue(), unchanged this only gates it and,
* once confirmed, dispatches back so the page can show the new config.
*/
class ConfirmReissueVpnPeer extends ModalComponent
{
public string $uuid;
public string $name = '';
public function mount(string $uuid): void
{
$peer = VpnPeer::query()->where('uuid', $uuid)->firstOrFail();
$this->authorize('update', $peer);
abort_if($peer->kind !== VpnPeer::KIND_STAFF, 404);
$this->uuid = $uuid;
$this->name = $peer->name;
}
public function confirm(): void
{
$this->dispatch('vpn-peer-reissue-confirmed', uuid: $this->uuid);
$this->closeModal();
}
public function render()
{
return view('livewire.admin.confirm-reissue-vpn-peer');
}
}

View File

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

View File

@ -1,48 +0,0 @@
<?php
namespace App\Livewire\Admin;
use App\Models\Instance;
use LivewireUI\Modal\ModalComponent;
/**
* Confirmation before an operator restarts a customer's machine (R23).
*
* The customer is not asked and may be working in it, so the address is spelled
* out in the question an operator confirming a restart should be reading the
* hostname of the cloud that is about to go away, not the position of a row in a
* paginated table.
*
* No mutation here. Instances::restart() keeps the work and both authorisation
* checks it already had.
*/
class ConfirmRestartInstance extends ModalComponent
{
public string $uuid;
public string $address = '';
public function mount(string $uuid): void
{
// A modal is reachable without the page's own route middleware (R20), so
// it resolves the record itself rather than trusting a property the
// browser hydrated.
$instance = Instance::query()->where('uuid', $uuid)->firstOrFail();
$this->uuid = $uuid;
$this->address = $instance->domainIsVerified()
? (string) $instance->custom_domain
: (string) $instance->subdomain;
}
public function proceed(): void
{
$this->dispatch('instance-restart-confirmed', uuid: $this->uuid);
$this->closeModal();
}
public function render()
{
return view('livewire.admin.confirm-restart-instance');
}
}

View File

@ -1,35 +0,0 @@
<?php
namespace App\Livewire\Admin;
use LivewireUI\Modal\ModalComponent;
/**
* Confirmation before .env the file that holds every credential in the
* system is overwritten (R23).
*
* The content being written lives only in Integrations' own $envContent; this
* modal never sees it and cannot mutate anything itself. Confirming
* dispatches back to the page component, whose saveEnv() keeps its own
* guardSecrets() (secrets.manage + a recently confirmed password) unchanged
* same pattern as ConfirmSaveSecret/ConfirmForgetSecret.
*/
class ConfirmSaveEnv extends ModalComponent
{
public function mount(): void
{
$this->authorize('secrets.manage');
}
public function confirm(): void
{
$this->authorize('secrets.manage');
$this->dispatch('env-save-confirmed');
$this->closeModal();
}
public function render()
{
return view('livewire.admin.confirm-save-env');
}
}

View File

@ -1,42 +0,0 @@
<?php
namespace App\Livewire\Admin;
use App\Services\Secrets\SecretVault;
use LivewireUI\Modal\ModalComponent;
/**
* Confirmation before a credential takes effect (R23).
*
* The value being saved lives only in Secrets' own (deferred) `entered`
* property this modal never sees it and cannot mutate anything itself.
* Confirming dispatches back to the page component, whose save() keeps its
* own guard() (capability + recent password) unchanged.
*/
class ConfirmSaveSecret extends ModalComponent
{
public string $key;
public string $label = '';
public function mount(string $key): void
{
$this->authorize('secrets.manage');
abort_if(! array_key_exists($key, SecretVault::REGISTRY), 404);
$this->key = $key;
$this->label = __(SecretVault::REGISTRY[$key]['label']);
}
public function confirm(): void
{
$this->authorize('secrets.manage');
$this->dispatch('secret-save-confirmed', key: $this->key);
$this->closeModal();
}
public function render()
{
return view('livewire.admin.confirm-save-secret');
}
}

View File

@ -1,46 +0,0 @@
<?php
namespace App\Livewire\Admin;
use App\Support\OperatingMode;
use LivewireUI\Modal\ModalComponent;
/**
* Confirmation before the operating mode changes (R23).
*
* Wortgleiches Muster zu ConfirmSaveSecret: dieses Modal mutiert nichts selbst
* es ruft `OperatingMode::set()` nirgends auf. Bestätigen löst nur ein
* Ereignis aus, das Integrations::switchMode() auffängt; deren eigene Sperre
* (secrets.manage + kürzlich bestätigtes Passwort) bleibt die einzige Stelle,
* die tatsächlich etwas schreibt. Ein Modus, der hier gefälscht hereinkäme,
* muss trotzdem switchMode()s eigenes tryFrom() und guardSecrets() passieren.
*/
class ConfirmSwitchMode extends ModalComponent
{
public string $mode;
public function mount(string $mode): void
{
$this->authorize('secrets.manage');
// Ein Wert aus dem Aufrufort dieses Modals — dieselbe Prüfung, die
// switchMode() gleich noch einmal macht: ein unbekannter Modus 404t
// hier, statt eine Bestätigung für etwas zu zeigen, das nachher
// ohnehin still nichts tut.
abort_if(OperatingMode::tryFrom($mode) === null, 404);
$this->mode = $mode;
}
public function confirm(): void
{
$this->authorize('secrets.manage');
$this->dispatch('mode-switch-confirmed', mode: $this->mode);
$this->closeModal();
}
public function render()
{
return view('livewire.admin.confirm-switch-mode');
}
}

View File

@ -1,240 +0,0 @@
<?php
namespace App\Livewire\Admin;
use App\Mail\OperatorMessageMail;
use App\Models\Customer;
use App\Models\InboundMail;
use App\Models\Instance;
use App\Models\Invoice;
use App\Models\MailTemplate;
use App\Models\Order;
use App\Models\SentMail;
use App\Models\Subscription;
use App\Models\SupportRequest;
use App\Services\Mail\MailTemplateRenderer;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Mail;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Url;
use Livewire\Attributes\Validate;
use Livewire\Component;
/**
* One customer: who they are, what they bought, what they paid, what was said.
*
* The first version of this page was a compose box with three lists under it,
* and the reported complaint was exactly right it showed nothing ABOUT the
* customer. An operator on the phone needs the address, the package, whether
* the last invoice was paid and what was already said to them, and none of that
* was here.
*
* TABBED, like Settings and Integrations, for the same reason: this is four
* different questions, and stacking them makes a page nobody reads to the
* bottom. Writing is the LAST tab, because it is the thing you do after
* reading and it is where the templates come in, so the same answer is not
* retyped differently for every customer.
*
* The open tab is in the query string: a reload, a bookmark and the back button
* all land where the operator was, and a link can point straight at the tab
* that matters ("…/customers/x?tab=billing").
*/
#[Layout('layouts.admin')]
class CustomerDetail extends Component
{
/**
* The tabs, in order. The list IS the schema: it validates the query
* string, builds the bar and decides what renders.
*/
public const TABS = ['overview', 'package', 'billing', 'messages', 'compose'];
public Customer $customer;
/**
* Which tab is open, in the address bar.
*
* `history: true`, so each tab is a step the back button can take and no
* `except`, so the parameter is there from the first render rather than
* appearing only once somebody leaves the default tab. Hiding it on the
* default read as "the tab is not in the URL", which is exactly how it was
* reported: a link to this page could not say which tab it meant unless the
* tab happened not to be the first one.
*/
#[Url(history: true)]
public string $tab = 'overview';
// ---- Writing to them ----
#[Validate('required|string|max:200')]
public string $subject = '';
#[Validate('required|string|min:3|max:5000')]
public string $body = '';
/**
* The request being answered, if this message is an answer to one.
*
* Answering marks it answered which is the whole reason to answer from
* here rather than from a mail client, where the request stays open for
* ever because nothing ever told it otherwise.
*/
public ?string $answering = null;
public function mount(string $uuid): void
{
$this->authorize('customers.manage');
$this->customer = Customer::query()->where('uuid', $uuid)->firstOrFail();
if (! in_array($this->tab, self::TABS, true)) {
$this->tab = self::TABS[0];
}
}
/**
* Put a template into the fields, filled in for THIS customer.
*
* Inserted, never sent: the placeholders come out filled and the last two
* sentences are still the operator's to write. What goes out is what they
* saw and edited.
*/
public function useTemplate(string $uuid): void
{
$this->authorize('customers.manage');
$template = MailTemplate::query()->where('uuid', $uuid)->where('active', true)->first();
if ($template === null) {
return;
}
$filled = app(MailTemplateRenderer::class)->render(
$template,
$this->customer,
Auth::guard('operator')->user(),
);
// The subject is only taken when the operator has not written one — a
// template that overwrites what somebody typed is a template that eats
// work. The body is replaced, because that is what "use this template"
// means and the field is empty in the case that matters.
if (trim($this->subject) === '') {
$this->subject = $filled['subject'];
}
$this->body = $filled['body'];
$this->tab = 'compose';
}
/** Start an answer to one request: subject prefilled, the request remembered. */
public function answer(string $uuid): void
{
$this->authorize('customers.manage');
$request = $this->customer->supportRequests()->where('uuid', $uuid)->first();
if ($request === null) {
return;
}
$this->answering = $request->uuid;
$this->subject = __('customer_message.re', ['subject' => $request->subject]);
$this->body = '';
$this->tab = 'compose';
}
public function cancelAnswer(): void
{
$this->answering = null;
$this->subject = '';
$this->body = '';
}
/**
* Send it, record it, and close the request it answers.
*
* The register row is written here rather than left to the MessageSent
* listener alone, because this is the one mail whose BODY is worth keeping:
* an operator typed it, and "what exactly did I write to them in March" is a
* question the mail server's log cannot answer either.
*/
public function send(): void
{
$this->authorize('customers.manage');
$data = $this->validate();
Mail::to($this->customer->email)->send(
new OperatorMessageMail($this->customer, $data['subject'], $data['body'])
);
SentMail::query()
->where('customer_id', $this->customer->id)
->where('to', $this->customer->email)
->latest('id')
->limit(1)
->update(['from_operator' => true, 'body' => $data['body']]);
if ($this->answering !== null) {
$this->customer->supportRequests()
->where('uuid', $this->answering)
->update(['status' => 'answered', 'answered_at' => now()]);
}
$this->cancelAnswer();
$this->dispatch('notify', message: __('customer_message.sent'));
}
public function render()
{
$this->authorize('customers.manage');
return view('livewire.admin.customer-detail', [
'tabs' => self::TABS,
'subscription' => $this->subscription(),
'instance' => Instance::query()
->where('customer_id', $this->customer->id)
->latest('id')
->first(),
'orders' => Order::query()
->where('customer_id', $this->customer->id)
->latest('id')
->limit(20)
->get(),
'invoices' => Invoice::query()
->where('customer_id', $this->customer->id)
->orderByDesc('issued_on')
->orderByDesc('id')
->limit(20)
->get(),
'requests' => $this->customer->supportRequests()->latest('id')->get(),
'inbound' => InboundMail::query()
->where('customer_id', $this->customer->id)
->latest('received_at')
->limit(30)
->get(),
'mails' => SentMail::query()
->where('customer_id', $this->customer->id)
->latest('sent_at')
->limit(30)
->get(),
'templates' => MailTemplate::query()->usable()->get(),
// Counted for the tab bar, so an operator sees where the unread
// things are before opening anything.
'openRequests' => $this->customer->supportRequests()
->whereNotIn('status', SupportRequest::CLOSED)
->count(),
]);
}
/** The contract that is being billed, or the most recent one there was. */
private function subscription(): ?Subscription
{
return Subscription::query()
->where('customer_id', $this->customer->id)
->with('addons')
->orderByRaw("status = 'active' DESC")
->latest('id')
->first();
}
}

View File

@ -1,140 +0,0 @@
<?php
namespace App\Livewire\Admin;
use App\Models\Customer;
use App\Models\PlanFamily;
use App\Services\Billing\PlanCatalogue;
use App\Services\Billing\WithdrawalRight;
use Illuminate\Support\Number;
use Livewire\Attributes\Layout;
use Livewire\Component;
#[Layout('layouts.admin')]
class Customers extends Component
{
/** Suspend / reactivate a customer account (a guarded lifecycle action, not delete). */
public function toggleSuspend(string $uuid): void
{
$this->authorize('customers.manage');
$customer = Customer::query()->where('uuid', $uuid)->first();
if ($customer === null) {
return;
}
// A closed account is terminal — the suspend/reactivate toggle must not
// resurrect it to active while closed_at is still set.
if ($customer->closed_at !== null || $customer->status === 'closed') {
return;
}
$customer->update([
'status' => $customer->status === 'suspended' ? 'active' : 'suspended',
]);
$this->dispatch('notify', message: __('admin.customer_'.($customer->status === 'suspended' ? 'suspended' : 'reactivated')));
}
public function render()
{
$locale = app()->getLocale();
$plans = app(PlanCatalogue::class)->sellable();
$customers = Customer::query()
->with(['instances.subscription'])
->orderBy('name')
->get();
$rows = $customers->map(function (Customer $c) use ($plans, $locale) {
$instance = $c->instances->sortByDesc('id')->first();
$planKey = $instance->plan ?? null;
// What this customer actually pays, off their contract — not what
// the plan costs today. Otherwise a price rise inflates reported
// revenue for every grandfathered customer overnight.
//
// Per MONTH, whatever the term: a yearly contract stores the whole
// year, and adding that to a monthly column would report twelve
// times the revenue for anyone who paid up front.
$contract = $instance?->subscription;
// The customer is already loaded on this row, so hand it to the
// contract rather than letting WithdrawalRight fetch it again —
// that is one query per customer on a page that lists all of them.
$contract?->setRelation('customer', $c);
$priceCents = (int) ($contract?->monthlyPriceCents()
?? ($planKey !== null ? ($plans[$planKey]['price_cents'] ?? 0) : 0));
return [
'uuid' => $c->uuid,
'name' => $c->name,
'plan' => $planKey !== null ? __('billing.plan.'.$planKey) : '—',
// A gift or a discount, not a sale — flagged here rather than
// inferred from the price, which a genuinely cheap plan could
// also show.
'granted' => (bool) $contract?->isGranted(),
// What this package is about to become. An operator answering a
// question about a customer's plan has to be able to see that
// it is booked to shrink at the end of the term, or they will
// quote today's package for a bill that is already scheduled to
// change. The MRR beside it is still today's, which is correct:
// nothing has moved yet.
'pending_change' => $contract?->hasPendingPlanChange()
? [
'plan' => __('billing.plan.'.$contract->pending_plan),
'at' => $contract->pending_effective_at->local()->isoFormat('LL'),
]
: null,
'mrr' => Number::currency($priceCents / 100, in: 'EUR', locale: $locale),
'instance' => $instance->subdomain ?? '—',
// Only an instance that exists can hand out an admin login.
'instance_uuid' => ($instance?->status === 'active') ? $instance->uuid : null,
// Whether a withdrawal taken by telephone or post could still be
// recorded for this customer. Only a hint for the button: the
// refusal that counts is made again inside WithdrawContract, so
// a stale list cannot let one through.
'withdrawal_open' => WithdrawalRight::for($contract)->open,
'closed' => $c->closed_at !== null || $c->status === 'closed',
'suspended' => $c->status === 'suspended',
'status' => match (true) {
$c->closed_at !== null || $c->status === 'closed' => 'closed',
$c->status === 'suspended' => 'suspended',
default => $instance->status ?? $c->status ?? 'provisioning',
},
];
})->all();
// Plan distribution for the doughnut — derived from live instances, and
// keyed by what customers are actually ON, not by what is on sale. A
// withdrawn plan still has customers, and dropping them from the chart
// would quietly understate the estate.
$labels = [];
$counts = [];
foreach (PlanFamily::query()->orderBy('tier')->pluck('key') as $planKey) {
$n = $customers->filter(fn (Customer $c) => $c->instances->contains('plan', $planKey))->count();
if ($n > 0) {
$labels[] = __('billing.plan.'.$planKey);
$counts[] = $n;
}
}
return view('livewire.admin.customers', [
'rows' => $rows,
'plansChart' => [
'type' => 'doughnut',
'data' => [
'labels' => $labels,
'datasets' => [[
'data' => $counts,
'backgroundColor' => ['token:info', 'token:accent', 'token:success-bright', 'token:warning'],
'borderWidth' => 0,
]],
],
'options' => [
'cutout' => '62%',
'plugins' => ['legend' => ['position' => 'bottom', 'labels' => ['boxWidth' => 10, 'padding' => 12]]],
],
],
]);
}
}

View File

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

View File

@ -1,229 +0,0 @@
<?php
namespace App\Livewire\Admin;
use App\Models\Customer;
use App\Models\Operator;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\Rule;
use LivewireUI\Modal\ModalComponent;
/**
* Correct a customer's own details.
*
* The console could show them and not touch them, so a moved office or a new
* contact person meant either impersonating the customer or editing the database
* by hand. Both are worse than a form.
*
* R20: a modal, not the row. And a modal is reachable WITHOUT the page's route
* middleware, so it authorises again and reads the record itself rather than
* trusting anything the browser hydrated.
*
* ## What is not here, and why
*
* `status` suspending or reactivating is its own action on the customer list,
* with its own consequences for access; a lifecycle switch hidden among address
* fields is one somebody flips by accident.
*
* `stripe_customer_id` Stripe's own identifier for them. Typing a different
* one does not move a contract; it points our records at somebody else's
* payments.
*
* The brand fields the customer sets those for themselves in the portal, and
* an operator overwriting somebody's logo is not a correction.
*/
class EditCustomer extends ModalComponent
{
/** The two languages this application actually has. Empty = as configured. */
public const LOCALES = ['de', 'en'];
public string $uuid = '';
public string $name = '';
public string $contactName = '';
public string $email = '';
public string $phone = '';
public string $customerType = '';
public string $vatId = '';
/**
* The address, field by field the same four the customer's own settings
* page writes. One free-text block here and four fields there would mean an
* operator's correction being silently reverted the next time the customer
* saved their profile: their fields still held the old values.
*/
public string $billingStreet = '';
public string $billingPostcode = '';
public string $billingCity = '';
public string $billingCountry = '';
public string $locale = '';
/** The address the record had when the modal opened, to notice a change. */
public string $originalEmail = '';
public string $originalVatId = '';
public function mount(string $uuid): void
{
$this->authorize('customers.manage');
$customer = Customer::query()->where('uuid', $uuid)->firstOrFail();
$this->uuid = $uuid;
$this->name = (string) $customer->name;
$this->contactName = (string) $customer->contact_name;
$this->email = (string) $customer->email;
$this->phone = (string) $customer->phone;
$this->customerType = (string) $customer->customer_type;
$this->vatId = (string) $customer->vat_id;
$this->billingStreet = (string) $customer->billing_street;
$this->billingPostcode = (string) $customer->billing_postcode;
$this->billingCity = (string) $customer->billing_city;
$this->billingCountry = (string) $customer->billing_country;
$this->locale = (string) $customer->locale;
$this->originalEmail = (string) $customer->email;
$this->originalVatId = (string) $customer->vat_id;
}
public function save()
{
$this->authorize('customers.manage');
// Read again, never trusted from the hydrated property: everything below
// is decided against the STORED record.
$customer = Customer::query()->where('uuid', $this->uuid)->firstOrFail();
$data = $this->validate([
'name' => 'required|string|max:255',
'contactName' => 'nullable|string|max:255',
'email' => [
'required', 'email', 'max:255',
// Among customers, ignoring this one. Two customers on one
// address would make every lookup that matches by address —
// inbound mail, the portal user, the register — ambiguous.
Rule::unique('customers', 'email')->ignore($customer->id),
],
'phone' => 'nullable|string|max:64',
// Only one of the two real answers may be stored; an empty string
// leaves the record as it was. See below for why it is never
// cleared back to "nobody asked".
'customerType' => ['nullable', Rule::in(Customer::TYPES)],
'vatId' => 'nullable|string|max:64',
'billingStreet' => 'nullable|string|max:255',
'billingPostcode' => 'nullable|string|max:32',
'billingCity' => 'nullable|string|max:255',
'billingCountry' => 'nullable|string|max:255',
'locale' => ['nullable', Rule::in(self::LOCALES)],
]);
$email = strtolower(trim($data['email']));
$emailChanged = $email !== strtolower($customer->email);
// R21: an address belonging to an operator may never also belong to a
// customer. Checked here rather than left to a unique index, because the
// two identities live in different tables and no index spans them.
if ($emailChanged && Operator::query()->where('email', $email)->exists()) {
$this->addError('email', __('edit_customer.email_is_operator'));
return null;
}
// A `users` row can outlive or predate a matching customer — see
// Customer::emailTaken(). One belonging to somebody else must not be
// walked into.
if ($emailChanged && User::query()->where('email', $email)->whereKeyNot($customer->user_id ?? 0)->exists()) {
$this->addError('email', __('edit_customer.email_taken'));
return null;
}
DB::transaction(function () use ($customer, $data, $email, $emailChanged) {
$customer->update([
'name' => trim($data['name']),
'contact_name' => trim((string) $data['contactName']) ?: null,
'email' => $email,
'phone' => trim((string) $data['phone']) ?: null,
// Never cleared back to "unrecorded". Once somebody has
// answered, the answer stands until it is replaced by the other
// one — a form saved with the field blank must not quietly undo
// a recorded consumer into an unknown, and that answer decides
// whether a withdrawal right exists.
'customer_type' => $data['customerType'] ?: $customer->customer_type,
// The verification is bound to the VALUE (see
// Customer::hasVerifiedVatId), so a new number is unverified the
// moment it is stored. `vat_id_verified_value` is deliberately
// left alone: it is the record of what was checked, and clearing
// it would destroy the evidence rather than the claim.
'vat_id' => trim((string) $data['vatId']) ?: null,
'billing_street' => trim((string) $data['billingStreet']) ?: null,
'billing_postcode' => trim((string) $data['billingPostcode']) ?: null,
'billing_city' => trim((string) $data['billingCity']) ?: null,
'billing_country' => trim((string) $data['billingCountry']) ?: null,
// The composed block, from the same composer the portal uses —
// one shape for one field, whichever side writes it. An address
// that was never split keeps the block it had.
'billing_address' => \App\Livewire\Settings::composeAddress($data) ?: $customer->billing_address,
'locale' => $data['locale'] ?: null,
]);
if (! $emailChanged || $customer->user_id === null) {
return;
}
// The portal login moves with it, or the customer is locked out of
// an account that no longer matches their record. And the new
// address is unconfirmed by definition — nobody has shown they can
// read it — so it goes back to unverified, which is what Laravel's
// own profile update does for the same reason. No mail is sent from
// here: the portal asks them to confirm on their next sign-in, and a
// mail an operator did not knowingly send is a surprise.
User::query()->whereKey($customer->user_id)->update([
'email' => $email,
'email_verified_at' => null,
]);
});
$this->dispatch('notify', message: $this->message($emailChanged, $data));
return $this->redirectRoute('admin.customer', ['uuid' => $this->uuid], navigate: true);
}
/**
* One message, and the consequence if there is one.
*
* Both go through the same toast and the second would replace the first, so
* the sentence that matters has to be the only one.
*
* @param array<string, mixed> $data
*/
private function message(bool $emailChanged, array $data): string
{
if ($emailChanged) {
return __('edit_customer.saved_email_changed');
}
if (trim((string) $data['vatId']) !== trim($this->originalVatId)) {
return __('edit_customer.saved_vat_unverified');
}
return __('edit_customer.saved');
}
public function render()
{
return view('livewire.admin.edit-customer', [
'types' => Customer::TYPES,
'locales' => self::LOCALES,
]);
}
}

View File

@ -1,200 +0,0 @@
<?php
namespace App\Livewire\Admin;
use App\Models\Datacenter;
use App\Models\Host;
use App\Models\Order;
use Illuminate\Validation\Rule;
use Livewire\Attributes\Validate;
use LivewireUI\Modal\ModalComponent;
/**
* Edit a datacenter in a modal (avoids the row-height jump of inline editing).
* Country is picked from a list so it can't be mistyped.
*
* The code is editable, but only while nothing depends on it.
*
* It was fully immutable, which made a typo permanent. It cannot be freely
* mutable either, because it is not only a foreign key: hosts.datacenter
* references it (and MariaDB refuses the update outright, there being no ON
* UPDATE clause), every past order stores it as plain text, each host's DNS
* name was minted from it (fsn-01 a record that exists at the provider and
* does not move), the machine's own hostname was set from it while it was being
* built, and the per-code counter that hands out those numbers is keyed by it.
* Renaming a code with hosts in it is therefore not a rename; it is a datacenter
* called hel full of machines called fsn-01.
*
* So: free while nothing references it which is the case that matters, a
* typo noticed shortly after creating one and locked with the reason named
* once anything does.
*/
class EditDatacenter extends ModalComponent
{
public string $uuid = '';
public string $code = '';
#[Validate('required|string|max:255')]
public string $name = '';
/**
* Which building, not which city fsn-dc-15.
*
* The provider's own label, free text on purpose: it is theirs to change,
* and nothing here may key off it. Only so that picking "fsn" twice for
* redundancy is a decision somebody can actually make.
*/
#[Validate('nullable|string|max:255')]
public string $facility = '';
public string $location = '';
/** The location present when the modal opened — a pre-curation free-form value. */
public string $originalLocation = '';
/**
* Whether new hosts and orders may still be placed here.
*
* The column and the scope existed; the form did not offer it, so a
* datacenter could be created and never switched off again the one
* lifecycle action a location actually needs.
*/
public bool $active = true;
/** What references this code, and therefore what stops it being renamed. */
public int $hostCount = 0;
public int $orderCount = 0;
public function mount(string $uuid): void
{
$this->authorize('datacenters.manage'); // modals are reachable without the route middleware
$dc = Datacenter::query()->where('uuid', $uuid)->firstOrFail();
$this->uuid = $uuid;
$this->code = $dc->code;
$this->name = $dc->name;
$this->facility = (string) $dc->facility;
$this->location = (string) $dc->location;
$this->originalLocation = (string) $dc->location;
$this->active = (bool) $dc->active;
$this->hostCount = $this->hostsIn($dc->code);
$this->orderCount = $this->ordersIn($dc->code);
}
/**
* May this code still be changed?
*
* Always answered from the database, never from a hydrated property: the
* lock is an authorisation decision, and a forged request that flipped it
* would take the DNS names of every host in this datacenter with it.
*/
public function codeIsFree(string $code): bool
{
return $this->hostsIn($code) === 0 && $this->ordersIn($code) === 0;
}
private function hostsIn(string $code): int
{
return Host::query()->where('datacenter', $code)->count();
}
private function ordersIn(string $code): int
{
return Order::query()->where('datacenter', $code)->count();
}
public function save()
{
$this->authorize('datacenters.manage');
// Accept the configured countries plus the record's own legacy value, so a
// datacenter created before the curated list can still be edited. The
// legacy value is read from the DB — never from the client-hydrated
// property, which could be forged to whitelist an arbitrary location.
$dc = Datacenter::query()->where('uuid', $this->uuid)->firstOrFail();
$allowed = array_keys((array) config('countries'));
if ($dc->location) {
$allowed[] = $dc->location;
}
// Recomputed here, never taken from the property the browser sent back:
// the lock is what protects every host's DNS name in this datacenter
// from being orphaned, and a forged request must not be able to lift it.
$codeIsFree = $this->codeIsFree($dc->code);
if ($codeIsFree) {
// Normalised before validating so the unique check matches how the
// row is stored — otherwise `FSN` passes the rule and the update
// then collides with an existing lowercased row (mirrors Datacenters).
$this->code = strtolower(trim($this->code));
} else {
// Whatever came back from the browser is discarded. Silently, and
// deliberately: the field was never offered, so there is nothing to
// report to an honest client, and a dishonest one gets no purchase.
$this->code = $dc->code;
}
$rules = [
'name' => 'required|string|max:255',
'facility' => 'nullable|string|max:255',
'location' => ['nullable', Rule::in($allowed)], // array form — a legacy value may contain commas
'active' => 'boolean',
];
if ($codeIsFree) {
// The same rule the create form uses: a code becomes a DNS label
// (fsn-01.node.clupilot.com), so it has to be one. Ignoring its own
// row, or saving without touching the code would collide with itself.
$rules['code'] = [
'required', 'string', 'max:8',
'regex:/^[a-z0-9]([a-z0-9-]{0,6}[a-z0-9])?$/',
Rule::unique('datacenters', 'code')->ignore($dc->id),
];
}
$data = $this->validate($rules);
// Only on the transition, and compared against what is STORED: an
// already-inactive location would otherwise announce that it had just
// been switched off every time its name was edited.
$justDeactivated = $dc->active && ! $data['active'];
$hostsLeftRunning = $justDeactivated ? $dc->hosts()->count() : 0;
Datacenter::query()->where('uuid', $this->uuid)->update([
// Only where it was actually offered. $data has no 'code' key at
// all when the field was locked, and writing $this->code back would
// be the same value anyway — but only by luck, and this is the line
// that would quietly stop being true if the lock ever moved.
...($codeIsFree ? ['code' => $data['code']] : []),
'name' => $data['name'],
'facility' => $data['facility'] ?: null,
'location' => $data['location'] ?: null,
'active' => $data['active'],
]);
// One message, not two. Both go through the same toast, and the second
// replaces the first — so the generic "saved" would swallow the only
// sentence that says the hosts there are still running.
$this->dispatch('notify', message: $hostsLeftRunning > 0
? __('datacenters.deactivated_with_hosts', ['n' => $hostsLeftRunning])
: __('datacenters.updated'));
return $this->redirectRoute('admin.datacenters', navigate: true);
}
public function render()
{
// Read again rather than shown from the mounted properties: a host can
// be onboarded into this datacenter while the modal sits open, and an
// input offered on the strength of a stale count would be refused on
// save with nothing on screen explaining why.
$stored = Datacenter::query()->where('uuid', $this->uuid)->value('code') ?? $this->code;
$this->hostCount = $this->hostsIn($stored);
$this->orderCount = $this->ordersIn($stored);
return view('livewire.admin.edit-datacenter', [
'countries' => config('countries'),
'codeFree' => $this->hostCount === 0 && $this->orderCount === 0,
]);
}
}

View File

@ -1,138 +0,0 @@
<?php
namespace App\Livewire\Admin;
use App\Models\ExportTarget;
use App\Services\Secrets\SecretCipher;
use LivewireUI\Modal\ModalComponent;
/**
* Adding or changing one destination.
*
* The password never lives here. It goes into the secret vault under a key
* derived from the target, and only that key is stored on the row a
* credential in a settings table is a credential in every database dump. An
* empty password field on an existing target means "leave it alone", not
* "clear it": the field cannot show what is stored, so treating blank as a
* deletion would wipe the credential every time somebody renamed the target.
*/
class EditExportTarget extends ModalComponent
{
public string $uuid = '';
public string $name = '';
public string $driver = ExportTarget::LOCAL;
public string $path = '';
/** 'year' for an archive somebody keeps, 'day' for a handover point. */
public string $layout = ExportTarget::BY_YEAR;
/** Empty means keep for ever, which is the default and the safe one. */
public ?int $keepDays = null;
public string $host = '';
public ?int $port = 22;
public string $username = '';
public string $password = '';
public bool $hasPassword = false;
public bool $active = true;
public function mount(?string $uuid = null): void
{
$this->authorize('site.manage'); // modals bypass the route middleware
if ($uuid === null) {
return;
}
$target = ExportTarget::query()->where('uuid', $uuid)->firstOrFail();
$this->uuid = $uuid;
$this->name = $target->name;
$this->driver = $target->driver;
$this->path = $target->path;
$this->layout = $target->layout;
$this->keepDays = $target->keep_days;
$this->host = (string) $target->host;
$this->port = $target->port ?: 22;
$this->username = (string) $target->username;
$this->active = (bool) $target->active;
$this->hasPassword = trim((string) $target->secret_key) !== '';
}
public function save()
{
$this->authorize('site.manage');
$rules = [
'name' => 'required|string|max:120',
'driver' => 'required|in:local,sftp',
'path' => 'required|string|max:255',
'layout' => 'required|in:year,day',
// Nothing, or a real number of days. Zero would read as "delete
// immediately" to somebody who typed it meaning "never".
'keepDays' => 'nullable|integer|min:1|max:3650',
'active' => 'boolean',
];
if ($this->driver === ExportTarget::SFTP) {
$rules += [
'host' => 'required|string|max:190',
'port' => 'required|integer|min:1|max:65535',
'username' => 'required|string|max:120',
// Required only when there is nothing stored yet. Blank on an
// existing target means "unchanged" — the field cannot show
// what is stored, so treating blank as a deletion would wipe
// the credential on every unrelated edit.
'password' => $this->hasPassword ? 'nullable|string|max:255' : 'required|string|max:255',
];
}
$data = $this->validate($rules);
$target = $this->uuid !== ''
? ExportTarget::query()->where('uuid', $this->uuid)->firstOrFail()
: new ExportTarget;
$target->fill([
'name' => $data['name'],
'driver' => $data['driver'],
'path' => rtrim($data['path'], '/') ?: '/',
'layout' => $data['layout'],
'keep_days' => $data['keepDays'] ?: null,
'host' => $this->driver === ExportTarget::SFTP ? $this->host : null,
'port' => $this->driver === ExportTarget::SFTP ? $this->port : null,
'username' => $this->driver === ExportTarget::SFTP ? $this->username : null,
'active' => $data['active'] ?? true,
]);
$target->save();
// Encrypted onto the row rather than into the vault. The vault keeps a
// registry of fixed, documented credentials and refuses anything else,
// which is right for it — a per-destination password is not one of
// those, and forcing it in would make the registry meaningless. Same
// cipher, same key, so it is no more readable in a dump than the rest.
if ($this->driver === ExportTarget::SFTP && $this->password !== '') {
$target->forceFill([
'secret_key' => app(SecretCipher::class)->encrypt($this->password),
])->save();
}
$this->dispatch('notify', message: __('finance.targets_saved'));
return $this->redirectRoute('admin.finance', navigate: true);
}
public function render()
{
return view('livewire.admin.edit-export-target');
}
}

View File

@ -1,123 +0,0 @@
<?php
namespace App\Livewire\Admin;
use App\Models\InvoiceSeries;
use Illuminate\Validation\Rule;
use LivewireUI\Modal\ModalComponent;
/**
* Editing a Rechnungskreis, with the parts that cannot move held still.
*
* The same shape as EditDatacenter and for the same reason: some fields stop
* being editable once something depends on them. Here the something is a
* document in somebody's accounts.
*
* The prefix is part of every number already issued. Changing it from RE to AR
* does not rename anything it leaves a series whose documents say RE-2026-
* and whose next document says AR-2026-, with no record that they belong
* together. Free while the series is empty, which is the case that matters: a
* prefix chosen wrongly on the day the shop opens.
*
* The counter may be raised but never lowered. Lowering it re-issues a number
* that is already printed on a document somebody holds, and gapless-and-
* ascending is a legal requirement rather than a preference.
*/
class EditInvoiceSeries extends ModalComponent
{
public string $uuid = '';
public string $name = '';
public string $prefix = '';
public int $digits = 4;
public bool $yearlyReset = true;
public int $nextNumber = 1;
public bool $active = true;
/** How many documents this series has already issued. */
public int $issued = 0;
/** The lowest number this counter may still be set to. */
public int $floor = 1;
public function mount(string $uuid): void
{
$this->authorize('site.manage'); // modals are reachable without the route middleware
$series = InvoiceSeries::query()->where('uuid', $uuid)->firstOrFail();
$this->uuid = $uuid;
$this->name = $series->name;
$this->prefix = $series->prefix;
$this->digits = (int) $series->digits;
$this->yearlyReset = (bool) $series->yearly_reset;
$this->nextNumber = (int) $series->next_number;
$this->active = (bool) $series->active;
$this->issued = $series->invoices()->count();
$this->floor = (int) $series->next_number;
}
public function save()
{
$this->authorize('site.manage');
$series = InvoiceSeries::query()->where('uuid', $this->uuid)->firstOrFail();
// Recomputed from the database, never read back from the properties the
// browser returns: these two rules are what keep already-issued numbers
// from being re-used, and a forged field must not lift either.
$issued = $series->invoices()->count();
$floor = (int) $series->next_number;
$rules = [
'name' => 'required|string|max:120',
'digits' => 'required|integer|min:3|max:10',
'active' => 'boolean',
'nextNumber' => ['required', 'integer', 'min:'.$floor],
];
if ($issued === 0) {
$rules['prefix'] = [
'required', 'string', 'max:12', 'regex:/^[A-Z0-9-]+$/',
Rule::unique('invoice_series', 'prefix')->ignore($series->id),
];
// Only while nothing has been issued: switching an established
// series between yearly and continuous puts a gap in one of them.
$rules['yearlyReset'] = 'boolean';
}
if ($issued === 0) {
$this->prefix = strtoupper(trim($this->prefix));
} else {
$this->prefix = $series->prefix;
$this->yearlyReset = (bool) $series->yearly_reset;
}
$data = $this->validate($rules);
$series->update([
'name' => $data['name'],
'digits' => $data['digits'],
'next_number' => $data['nextNumber'],
'active' => $data['active'],
...($issued === 0 ? [
'prefix' => $data['prefix'],
'yearly_reset' => $data['yearlyReset'],
] : []),
]);
$this->dispatch('notify', message: __('finance.series_saved'));
return $this->redirectRoute('admin.finance', navigate: true);
}
public function render()
{
return view('livewire.admin.edit-invoice-series');
}
}

View File

@ -1,73 +0,0 @@
<?php
namespace App\Livewire\Admin;
use App\Models\MailTemplate;
use App\Services\Mail\MailTemplateRenderer;
use Livewire\Attributes\Validate;
use LivewireUI\Modal\ModalComponent;
/**
* Edit one template in a modal.
*
* R20: a textarea growing inside a table row pushes every column beside it and
* reads like a rendering fault rather than a form. A modal is also the only
* place a five-line body has room.
*
* A modal is reachable WITHOUT the page's route middleware, so it authorises
* again and reads the record itself rather than trusting a property the browser
* hydrated.
*/
class EditMailTemplate extends ModalComponent
{
public string $uuid = '';
#[Validate('required|string|max:255')]
public string $name = '';
#[Validate('required|string|max:255')]
public string $subject = '';
#[Validate('required|string|max:5000')]
public string $body = '';
public bool $active = true;
public function mount(string $uuid): void
{
$this->authorize('customers.manage');
$template = MailTemplate::query()->where('uuid', $uuid)->firstOrFail();
$this->uuid = $uuid;
$this->name = $template->name;
$this->subject = $template->subject;
$this->body = $template->body;
$this->active = (bool) $template->active;
}
public function save()
{
$this->authorize('customers.manage');
$data = $this->validate();
MailTemplate::query()->where('uuid', $this->uuid)->update([
'name' => trim($data['name']),
'subject' => trim($data['subject']),
'body' => $data['body'],
'active' => $this->active,
]);
$this->dispatch('notify', message: __('templates.saved'));
return $this->redirectRoute('admin.templates', navigate: true);
}
public function render()
{
return view('livewire.admin.edit-mail-template', [
'placeholders' => MailTemplateRenderer::PLACEHOLDERS,
]);
}
}

View File

@ -1,82 +0,0 @@
<?php
namespace App\Livewire\Admin;
use App\Models\PlanFamily;
use App\Services\Billing\PlanCatalogue;
use LivewireUI\Modal\ModalComponent;
/**
* The marketing presentation of one plan line: who it is for, the sentence
* under its name on the price sheet, and whether it carries the
* "recommended" mark.
*
* These were a hardcoded array in LandingController, keyed on exactly four
* plan keys a plan created under any other key rendered on the public page
* with no copy at all. They live on the family, not a version: a version is
* what gets published and superseded, but "who this is for" and "which one
* plan we point a visitor towards" are a stance about the product line, not a
* capability that changes with a price.
*/
class EditPlanFamily extends ModalComponent
{
public string $uuid = '';
public string $audience = '';
public string $note = '';
public bool $recommended = false;
public function mount(string $uuid): void
{
$this->authorize('plans.manage'); // modals bypass the route middleware
$family = PlanFamily::query()->where('uuid', $uuid)->firstOrFail();
$this->uuid = $uuid;
$this->audience = (string) $family->audience;
$this->note = (string) $family->note;
$this->recommended = (bool) $family->is_recommended;
}
public function save()
{
$this->authorize('plans.manage');
$data = $this->validate([
'audience' => 'nullable|string|max:255',
'note' => 'nullable|string|max:500',
'recommended' => 'boolean',
]);
$family = PlanFamily::query()->where('uuid', $this->uuid)->firstOrFail();
PlanFamily::query()->whereKey($family->getKey())->update([
'audience' => $data['audience'] !== '' ? $data['audience'] : null,
'note' => $data['note'] !== '' ? $data['note'] : null,
]);
// The singular invariant — only one family recommended at a time —
// lives in the catalogue service, not here: two operators recommending
// two different plans at the same moment must not both win.
if ($data['recommended']) {
app(PlanCatalogue::class)->recommend($family);
} elseif ($family->is_recommended) {
PlanFamily::query()->whereKey($family->getKey())->update(['is_recommended' => false]);
}
$this->dispatch('notify', message: __('plans.marketing_saved'));
return $this->redirectRoute('admin.plans', navigate: true);
}
public function render()
{
return view('livewire.admin.edit-plan-family', [
// Read again rather than from the mounted properties: the header
// shows the plan's name, and that can change under an operator's
// own hands while a second tab has this modal open.
'family' => PlanFamily::query()->where('uuid', $this->uuid)->firstOrFail(),
]);
}
}

View File

@ -1,224 +0,0 @@
<?php
namespace App\Livewire\Admin;
use App\Models\ExportTarget;
use App\Models\Invoice;
use App\Models\InvoiceExport;
use App\Models\InvoiceSeries;
use App\Services\Deployment\UpdateChannel;
use App\Support\CompanyProfile;
use App\Support\Settings;
use Illuminate\Support\Facades\Storage;
use Livewire\Attributes\Layout;
use Livewire\Component;
use Livewire\WithFileUploads;
/**
* Everything an invoice needs before one can be issued.
*
* Its own tab rather than a section of Settings: the company's registered
* details, the VAT rate and the Rechnungskreise are not preferences, they are
* what appears on a legal document, and burying them under "Einstellungen"
* beside the site-visibility switch invites somebody to change one in passing.
*
* None of it reaches an invoice that already exists. Every value is copied into
* the invoice when its number is assigned, so an address corrected here is
* correct from the next document onwards and cannot rewrite an old one.
*/
#[Layout('layouts.admin')]
class Finance extends Component
{
use WithFileUploads;
/** @var array<string, mixed> */
public array $company = [];
public float $taxRate = 20.0;
/**
* The one-off setup fee, in euro, as a person types it.
*
* Held in euro rather than cents because this is a form field, and asking
* an operator to enter 9900 for ninety-nine euro is how a fee ends up a
* hundred times too large. Converted on the way in and out.
*/
public string $setupFee = '0';
/**
* A freshly minted collection key, shown once and never stored.
*
* Held in the component for the length of one page view. The private half
* exists to be copied into a NAS and nowhere else writing it into the
* database "for convenience" would put a working credential in every backup
* of that database.
*
* @var array<string, string>|null
*/
public ?array $archiveKey = null;
/** True between asking the host for a key and the host answering. */
public bool $waitingForKey = false;
/** New logo upload, validated on save. */
public $logo = null;
public string $logoPath = '';
public function mount(): void
{
$this->authorize('site.manage');
$this->company = CompanyProfile::all();
$this->logoPath = (string) ($this->company['logo_path'] ?? '');
$this->taxRate = CompanyProfile::taxRate();
$this->setupFee = number_format(CompanyProfile::setupFeeCents() / 100, 2, '.', '');
}
public function saveCompany(): void
{
$this->authorize('site.manage');
$data = $this->validate([
'company.name' => 'required|string|max:190',
'company.address' => 'required|string|max:190',
'company.postcode' => 'required|string|max:20',
'company.city' => 'required|string|max:120',
'company.country' => 'required|string|max:120',
'company.phone' => 'nullable|string|max:60',
'company.email' => 'nullable|email|max:190',
'company.website' => 'nullable|string|max:190',
'company.register_number' => 'nullable|string|max:60',
'company.register_court' => 'nullable|string|max:120',
'company.vat_id' => 'required|string|max:40',
'company.bank_name' => 'nullable|string|max:120',
'company.iban' => 'nullable|string|max:42',
'company.bic' => 'nullable|string|max:15',
'company.payment_days' => 'required|integer|min:0|max:180',
'company.payment_terms' => 'nullable|string|max:500',
// PNG or WEBP only, and small. A PDF embeds whatever it is given,
// and a four-megabyte photograph would be embedded in every invoice
// ever rendered from then on.
'logo' => 'nullable|image|mimes:png,webp|max:1024',
'taxRate' => 'required|numeric|min:0|max:100',
'setupFee' => 'required|numeric|min:0|max:100000',
]);
if ($this->logo !== null) {
$previous = $this->logoPath;
$this->logoPath = $this->logo->store('company', 'public');
$this->company['logo_path'] = $this->logoPath;
// The old file goes only once the new one is stored. The other
// order loses the logo entirely if the upload fails, and every
// invoice rendered afterwards is missing it.
if ($previous !== '' && $previous !== $this->logoPath) {
Storage::disk('public')->delete($previous);
}
$this->logo = null;
}
CompanyProfile::put($data['company']);
Settings::set('company.tax_rate', (float) $data['taxRate']);
// round(), not a cast: (int) (99.95 * 100) is 9994 on a binary float,
// and a fee one cent short of what was typed is the kind of thing
// nobody finds until a customer does.
Settings::set('company.setup_fee_cents', (int) round(((float) $data['setupFee']) * 100));
$this->dispatch('notify', message: __('finance.company_saved'));
}
/**
* Ask the host to mint an SSH key that may only read this archive.
*
* Everything an operator otherwise does by hand across three machines
* generate a keypair, install the public half restricted to one directory,
* carry the private half to the NAS. None of it can happen here: the panel
* is www-data in a container and the keys, the home directory and rrsync
* are all on the host. So it asks, and the agent does it.
*/
public function createPullAccess(string $uuid): void
{
$this->authorize('site.manage');
$target = ExportTarget::query()->where('uuid', $uuid)->firstOrFail();
if ($target->driver !== ExportTarget::LOCAL) {
// Only a directory on this host has a home directory and an
// authorized_keys to put anything in. A destination somewhere else
// is somebody else's machine.
$this->dispatch('notify', message: __('finance.pull_local_only'));
return;
}
$accepted = app(UpdateChannel::class)->requestArchiveKey(
(string) (auth('operator')->user()?->email ?? ''),
$target->path,
'clupilot-archiv-'.$target->uuid,
);
$this->archiveKey = null;
$this->waitingForKey = $accepted;
$this->dispatch('notify', message: __($accepted
? 'finance.pull_requested'
: 'admin_settings.update_already_requested'));
}
/** Polled while waiting. Takes the key the moment the host has left one. */
public function collectArchiveKey(): void
{
$this->authorize('site.manage');
if (! $this->waitingForKey) {
return;
}
$key = app(UpdateChannel::class)->takeArchiveKey();
if ($key !== null) {
$this->archiveKey = $key;
$this->waitingForKey = false;
}
}
public function removeLogo(): void
{
$this->authorize('site.manage');
if ($this->logoPath !== '') {
Storage::disk('public')->delete($this->logoPath);
}
$this->logoPath = '';
$this->company['logo_path'] = '';
CompanyProfile::put(['logo_path' => '']);
$this->dispatch('notify', message: __('finance.logo_removed'));
}
public function render()
{
return view('livewire.admin.finance', [
'series' => InvoiceSeries::query()->orderBy('kind')->get(),
// What is still missing before an invoice may be issued at all. An
// invoice without a name, an address or a VAT number is not a valid
// invoice here, and issuing one is worse than refusing to.
'missing' => CompanyProfile::missingForInvoicing(),
// Counted from the join table, never by asking the destinations.
// A stat() on a network mount that has gone away blocks for the
// mount's whole timeout, and this is a page an operator opens when
// something is already wrong.
'targets' => ExportTarget::query()->orderBy('name')->get()->map(fn (ExportTarget $t) => [
'model' => $t,
'pending' => Invoice::query()->whereDoesntHave('exports', fn ($q) => $q
->where('export_target_id', $t->id)->whereNotNull('exported_at'))->count(),
'failed' => InvoiceExport::query()->where('export_target_id', $t->id)
->whereNotNull('error')->whereNull('exported_at')->count(),
]),
]);
}
}

View File

@ -1,293 +0,0 @@
<?php
namespace App\Livewire\Admin;
use App\Actions\GrantAddon;
use App\Actions\GrantSubscription;
use App\Models\Customer;
use App\Models\Datacenter;
use App\Models\Subscription;
use App\Models\SubscriptionAddon;
use App\Services\Billing\AddonCatalogue;
use App\Services\Billing\PlanCatalogue;
use App\Support\Money;
use Illuminate\Support\Carbon;
use Illuminate\Validation\Rule;
use LivewireUI\Modal\ModalComponent;
use RuntimeException;
use Throwable;
/**
* Gives a customer a package, a module, or a discount without a payment.
*
* Reuses GrantSubscription / GrantAddon for the actual mutation: this
* component is only the form. What it grants goes through the exact Order
* Subscription/SubscriptionAddon rows a Stripe purchase would, so everything
* downstream (provisioning, quotas, cancellation, revenue) reads it the same
* way as a sale.
*/
class GrantPlan extends ModalComponent
{
public string $customerUuid = '';
public string $customerName = '';
/** Which form is on screen: a whole package, or a single module. */
public string $kind = 'package';
public bool $hasSubscription = false;
// Package fields.
public string $plan = '';
public string $term = Subscription::TERM_MONTHLY;
public string $datacenter = '';
public string $bonusQuotaGb = '';
public string $bonusSeats = '';
public string $bonusTrafficGb = '';
// Add-on fields.
public ?int $subscriptionId = null;
public string $addonKey = '';
public int $quantity = 1;
// Shared.
public string $priceEuros = '0';
public string $note = '';
/** A plain date — "until when", not "until what time". */
public string $until = '';
public function mount(string $uuid): void
{
$this->authorize('customers.grant_plan'); // modals are reachable without the route middleware
$customer = Customer::query()->where('uuid', $uuid)->firstOrFail();
$this->customerUuid = $uuid;
$this->customerName = (string) $customer->name;
$this->datacenter = (string) (Datacenter::query()->active()->orderBy('code')->value('code') ?? '');
$subscription = Subscription::query()
->where('customer_id', $customer->id)
->where('status', 'active')
->latest('id')
->first();
$this->hasSubscription = $subscription !== null;
$this->subscriptionId = $subscription?->id;
// A customer with nothing yet can only be given a package — there is
// no contract to hang a module off.
$this->kind = $this->hasSubscription ? 'addon' : 'package';
}
/** Today's price for whatever is picked, so a discount reads as one. */
public function cataloguePriceCents(): ?int
{
try {
if ($this->kind === 'package' && $this->plan !== '') {
return app(PlanCatalogue::class)->currentVersion($this->plan)->priceFor($this->term)?->amount_cents;
}
if ($this->kind === 'addon' && $this->addonKey !== '') {
return app(AddonCatalogue::class)->priceCents($this->addonKey);
}
} catch (Throwable) {
return null;
}
return null;
}
public function grant(): void
{
$this->authorize('customers.grant_plan');
$operator = auth('operator')->user();
abort_if($operator === null, 403);
$customer = Customer::query()->where('uuid', $this->customerUuid)->firstOrFail();
if ($this->kind === 'package') {
$this->grantPackage($customer, $operator);
} else {
$this->grantAddon($customer, $operator);
}
}
private function grantPackage(Customer $customer, $operator): void
{
$priceRule = function (string $attribute, mixed $value, \Closure $fail): void {
if (Money::toCents((string) $value) === null) {
$fail(__('admin.grant.price_invalid'));
}
};
$data = $this->validate([
'plan' => ['required', Rule::in(array_keys(app(PlanCatalogue::class)->sellable()))],
'term' => ['required', Rule::in([Subscription::TERM_MONTHLY, Subscription::TERM_YEARLY])],
'datacenter' => ['required', Rule::exists('datacenters', 'code')->where('active', true)],
'priceEuros' => ['required', 'string', $priceRule],
'bonusQuotaGb' => 'nullable|integer|min:0|max:1000000',
'bonusSeats' => 'nullable|integer|min:0|max:100000',
'bonusTrafficGb' => 'nullable|integer|min:0|max:10000000',
'note' => 'nullable|string|max:1000',
]);
try {
app(GrantSubscription::class)(
customer: $customer,
grantedBy: $operator,
plan: $data['plan'],
term: $data['term'],
datacenter: $data['datacenter'],
priceCents: Money::toCents($data['priceEuros']),
bonus: array_filter([
'quota_gb' => $data['bonusQuotaGb'] !== null && $data['bonusQuotaGb'] !== '' ? (int) $data['bonusQuotaGb'] : null,
'seats' => $data['bonusSeats'] !== null && $data['bonusSeats'] !== '' ? (int) $data['bonusSeats'] : null,
'traffic_gb' => $data['bonusTrafficGb'] !== null && $data['bonusTrafficGb'] !== '' ? (int) $data['bonusTrafficGb'] : null,
], fn ($value) => $value !== null),
note: $data['note'] !== null && $data['note'] !== '' ? $data['note'] : null,
until: $this->parseUntil($this->until),
);
} catch (RuntimeException $e) {
$this->addError('priceEuros', $e->getMessage());
return;
}
$this->dispatch('notify', message: __('admin.grant.granted'));
$this->closeModal();
}
private function grantAddon(Customer $customer, $operator): void
{
$priceRule = function (string $attribute, mixed $value, \Closure $fail): void {
if (Money::toCents((string) $value) === null) {
$fail(__('admin.grant.price_invalid'));
}
};
$data = $this->validate([
'addonKey' => ['required', Rule::in(array_keys((array) config('provisioning.addons')))],
'quantity' => 'required|integer|min:1|max:100',
'priceEuros' => ['required', 'string', $priceRule],
'note' => 'nullable|string|max:1000',
]);
// Re-resolved and re-checked, never trusted from the hydrated
// property: a forged request must not be able to book a module onto
// someone else's contract.
$subscription = Subscription::query()
->where('customer_id', $customer->id)
->whereKey($this->subscriptionId)
->first();
if ($subscription === null) {
$this->addError('addonKey', __('admin.grant.no_subscription'));
return;
}
try {
app(GrantAddon::class)(
subscription: $subscription,
grantedBy: $operator,
addonKey: $data['addonKey'],
priceCents: Money::toCents($data['priceEuros']),
quantity: $data['quantity'],
note: $data['note'] !== null && $data['note'] !== '' ? $data['note'] : null,
until: $this->parseUntil($this->until),
);
} catch (RuntimeException $e) {
$this->addError('priceEuros', $e->getMessage());
return;
}
$this->dispatch('notify', message: __('admin.grant.granted'));
$this->closeModal();
}
/**
* A date, read in the operator's own zone the same pairing LocalTime
* keeps for a datetime-local field, minus the time of day this form does
* not ask for. "Until 15 August" means through the end of that day where
* the operator is sitting, not UTC midnight.
*/
private function parseUntil(string $value): ?Carbon
{
if (trim($value) === '') {
return null;
}
try {
return Carbon::parse($value, config('app.display_timezone'))->endOfDay()->utc();
} catch (Throwable) {
return null;
}
}
/** @return array<int, array<string, mixed>> */
private function existingGrants(int $customerId): array
{
$fromSubscriptions = Subscription::query()
->where('customer_id', $customerId)
->whereNotNull('granted_at')
->with('grantedBy')
->get()
->map(fn (Subscription $s) => [
'kind' => 'package',
'label' => __('billing.plan.'.$s->plan),
'price_cents' => $s->price_cents,
'catalogue_price_cents' => $s->catalogue_price_cents,
'granted_by' => $s->grantedBy?->name ?? '—',
'granted_at' => $s->granted_at,
'granted_until' => $s->granted_until,
'note' => $s->grant_note,
]);
$fromAddons = SubscriptionAddon::query()
->whereHas('subscription', fn ($q) => $q->where('customer_id', $customerId))
->whereNotNull('granted_at')
->with('grantedBy')
->get()
->map(fn (SubscriptionAddon $a) => [
'kind' => 'addon',
'label' => __('billing.addon.'.$a->addon_key.'.name'),
'price_cents' => $a->price_cents,
'catalogue_price_cents' => $a->catalogue_price_cents,
'granted_by' => $a->grantedBy?->name ?? '—',
'granted_at' => $a->granted_at,
'granted_until' => $a->granted_until,
'note' => $a->grant_note,
]);
return $fromSubscriptions->concat($fromAddons)
->sortByDesc('granted_at')
->values()
->all();
}
public function render()
{
// Read again rather than off the mounted properties: a second grant
// can have happened while this modal sat open (EditDatacenter's
// pattern), and the existing-grants list has to show it.
$customer = Customer::query()->where('uuid', $this->customerUuid)->firstOrFail();
return view('livewire.admin.grant-plan', [
'plans' => app(PlanCatalogue::class)->sellable(),
'addons' => (array) config('provisioning.addons'),
'datacenters' => Datacenter::query()->active()->orderBy('code')->pluck('name', 'code'),
'cataloguePriceCents' => $this->cataloguePriceCents(),
'grants' => $this->existingGrants($customer->id),
]);
}
}

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