Compare commits
No commits in common. "v1.3.73" and "feat/bootstrap" have entirely different histories.
v1.3.73
...
feat/boots
196
.env.example
196
.env.example
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
|
@ -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
314
CLAUDE.md
|
|
@ -1,314 +0,0 @@
|
|||
# CluPilot — verbindliche Regeln (Repo-Teil)
|
||||
|
||||
Die **Vollfassung der Regeln R1–R17 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.
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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'];
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
]);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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 Stripe’s 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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
]]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
]]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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';
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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.");
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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).");
|
||||
}
|
||||
}
|
||||
|
|
@ -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.");
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -2,9 +2,7 @@
|
|||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
|
||||
abstract class Controller
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
//
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
|
@ -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.'"',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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'));
|
||||
}
|
||||
}
|
||||
|
|
@ -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.');
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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)],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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'])
|
||||
: [],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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]]],
|
||||
],
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
Loading…
Reference in New Issue