Compare commits
No commits in common. "v1.3.33" and "feat/bootstrap" have entirely different histories.
v1.3.33
...
feat/boots
187
.env.example
187
.env.example
|
|
@ -38,7 +38,7 @@ REDIS_HOST=redis
|
||||||
REDIS_PASSWORD=null
|
REDIS_PASSWORD=null
|
||||||
REDIS_PORT=6379
|
REDIS_PORT=6379
|
||||||
|
|
||||||
SESSION_DRIVER=database
|
SESSION_DRIVER=redis
|
||||||
SESSION_LIFETIME=120
|
SESSION_LIFETIME=120
|
||||||
SESSION_ENCRYPT=false
|
SESSION_ENCRYPT=false
|
||||||
SESSION_PATH=/
|
SESSION_PATH=/
|
||||||
|
|
@ -63,198 +63,23 @@ REVERB_SERVER_PORT=8080
|
||||||
# browser-side (baked into built assets): the address a browser can reach
|
# browser-side (baked into built assets): the address a browser can reach
|
||||||
VITE_REVERB_APP_KEY=change-me-app-key
|
VITE_REVERB_APP_KEY=change-me-app-key
|
||||||
VITE_REVERB_HOST=localhost
|
VITE_REVERB_HOST=localhost
|
||||||
VITE_REVERB_PORT=443
|
VITE_REVERB_PORT=8080
|
||||||
VITE_REVERB_SCHEME=https
|
VITE_REVERB_SCHEME=http
|
||||||
VITE_APP_NAME=CluPilot
|
VITE_APP_NAME=CluPilot
|
||||||
|
|
||||||
# Vite HMR host the browser connects to (dev). Set to the reachable VM address
|
# Vite HMR host the browser connects to (dev). Set to the reachable VM address
|
||||||
# when developing against a remote host; localhost for local-only.
|
# when developing against a remote host; localhost for local-only.
|
||||||
VITE_HMR_HOST=localhost
|
VITE_HMR_HOST=localhost
|
||||||
|
|
||||||
# ── Mail ─────────────────────────────────────────────────────────────────
|
# ── Mail (dev → log) ─────────────────────────────────────────────────────
|
||||||
# DEV: `log` writes mails to storage/logs (nothing is sent).
|
|
||||||
# PROD: switch to smtp and fill in the credentials below.
|
|
||||||
MAIL_MAILER=log
|
MAIL_MAILER=log
|
||||||
MAIL_FROM_ADDRESS=hello@clupilot.local
|
MAIL_FROM_ADDRESS=hello@clupilot.local
|
||||||
MAIL_FROM_NAME=CluPilot
|
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_WEBHOOK_SECRET=
|
|
||||||
# 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).
|
|
||||||
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) ─────────────────────────────────────
|
# ── docker-compose knobs (host-side) ─────────────────────────────────────
|
||||||
HOST_UID=1000
|
HOST_UID=1000
|
||||||
HOST_GID=1000
|
HOST_GID=1000
|
||||||
# Behind a reverse proxy — which is every production install — these must stay
|
APP_PORT=80
|
||||||
# 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
|
|
||||||
VITE_PORT=5173
|
VITE_PORT=5173
|
||||||
REVERB_HOST_PORT=127.0.0.1:8081
|
REVERB_HOST_PORT=8080
|
||||||
DB_HOST_PORT=3306
|
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
|
||||||
.env.backup
|
.env.backup
|
||||||
.env.production
|
.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
|
.phpactor.json
|
||||||
.phpunit.result.cache
|
.phpunit.result.cache
|
||||||
/.codex
|
/.codex
|
||||||
|
|
@ -24,10 +20,6 @@
|
||||||
/public/storage
|
/public/storage
|
||||||
/storage/*.key
|
/storage/*.key
|
||||||
/storage/pail
|
/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
|
/vendor
|
||||||
_ide_helper.php
|
_ide_helper.php
|
||||||
Homestead.json
|
Homestead.json
|
||||||
|
|
@ -36,7 +28,3 @@ Thumbs.db
|
||||||
|
|
||||||
# Gitea push token (parent home dir; never track)
|
# Gitea push token (parent home dir; never track)
|
||||||
.env.gitea
|
.env.gitea
|
||||||
clupilot.env
|
|
||||||
|
|
||||||
# SDD-Arbeitsverzeichnis (Ledger, Briefs, Review-Pakete) — Kladde, nicht Quelltext.
|
|
||||||
.superpowers/
|
|
||||||
|
|
|
||||||
269
CLAUDE.md
269
CLAUDE.md
|
|
@ -1,269 +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.
|
|
||||||
|
|
@ -1,314 +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\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);
|
|
||||||
|
|
||||||
// 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,
|
|
||||||
// What this move is worth: the pro-rata difference for an
|
|
||||||
// upgrade, nothing for a downgrade. Deliberately NOT the order's
|
|
||||||
// amount_cents, which is the shop's headline price for the whole
|
|
||||||
// package — charging or recording that for a move made on the
|
|
||||||
// sixteenth would state a sum nobody agreed to.
|
|
||||||
netCents: $change->chargeCents,
|
|
||||||
at: $at,
|
|
||||||
extra: ['plan_change' => [
|
|
||||||
'from_plan' => $before['plan'],
|
|
||||||
'to_plan' => $targetPlan,
|
|
||||||
'remaining_days' => $change->remainingDays,
|
|
||||||
'term_days' => $change->termDays,
|
|
||||||
]],
|
|
||||||
// Nothing has been charged: payment is mocked repo-wide and no
|
|
||||||
// invoice exists for this move yet. 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,142 +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 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;
|
|
||||||
|
|
||||||
// Checked against the ORDER, because that is the subject every customer
|
|
||||||
// pipeline shares. A storage run beside an unfinished build would resize
|
|
||||||
// a disk the build is still writing; one beside a plan change would
|
|
||||||
// resize it twice from two different figures. Whatever is in flight
|
|
||||||
// reads the allowance itself when it reaches the quota step, so nothing
|
|
||||||
// is lost by standing aside.
|
|
||||||
if ($this->hasRunInFlight($order)) {
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function hasRunInFlight(Order $order): bool
|
|
||||||
{
|
|
||||||
return ProvisioningRun::query()
|
|
||||||
->where('subject_type', Order::class)
|
|
||||||
->where('subject_id', $order->id)
|
|
||||||
->whereIn('status', [
|
|
||||||
ProvisioningRun::STATUS_PENDING,
|
|
||||||
ProvisioningRun::STATUS_RUNNING,
|
|
||||||
ProvisioningRun::STATUS_WAITING,
|
|
||||||
ProvisioningRun::STATUS_PAUSED,
|
|
||||||
])
|
|
||||||
->exists();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,555 +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 single exception.
|
|
||||||
*
|
|
||||||
* 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.
|
|
||||||
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(),
|
|
||||||
netCents: $isRenewal ? $subscription->price_cents : 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,
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
$this->invoicePayment($subscription, $invoice, $start, $end);
|
|
||||||
|
|
||||||
return $record;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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,135 +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,
|
|
||||||
'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,297 +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;
|
|
||||||
}
|
|
||||||
|
|
||||||
return app(IssueInvoice::class)->forBilledPeriod($subscription, $from, $to, $stripeInvoiceId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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 NET, so the line amounts that come
|
|
||||||
* back are net and the document adds VAT on top: Stripe's total should be
|
|
||||||
* our gross. It may legitimately be our NET instead, on an account that does
|
|
||||||
* not collect tax on our behalf. A figure that is neither is one nobody can
|
|
||||||
* explain, and it is exactly what an operator needs to be told about.
|
|
||||||
*
|
|
||||||
* 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 || $total === $invoice->net_cents) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
Log::warning('An invoice was issued for a sum that is neither the net nor the gross Stripe reported.', [
|
|
||||||
'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,218 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Actions;
|
|
||||||
|
|
||||||
use App\Models\PlanPrice;
|
|
||||||
use App\Models\Subscription;
|
|
||||||
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) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @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.
|
|
||||||
*
|
|
||||||
* By plan VERSION and term, which is what a Stripe Price is: one per priced
|
|
||||||
* row, 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.
|
|
||||||
*/
|
|
||||||
private function targetPrice(Subscription $subscription): ?string
|
|
||||||
{
|
|
||||||
$priceId = PlanPrice::query()
|
|
||||||
->where('plan_version_id', $subscription->plan_version_id)
|
|
||||||
->where('term', $subscription->term)
|
|
||||||
->value('stripe_price_id');
|
|
||||||
|
|
||||||
return is_string($priceId) && $priceId !== '' ? $priceId : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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,124 +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 GROSS total
|
|
||||||
* Stripe actually charged. The two can legitimately differ (VAT, and later
|
|
||||||
* coupons), 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: the express request to begin at
|
|
||||||
// once is what makes a withdrawing consumer owe the pro-rata
|
|
||||||
// share, and it was given at the checkout, not here.
|
|
||||||
'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 — gross, 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.
|
|
||||||
//
|
|
||||||
// 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.
|
|
||||||
chargedGrossCents: $order->stripe_event_id !== null ? (int) $order->amount_cents : null,
|
|
||||||
);
|
|
||||||
|
|
||||||
return $subscription;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,132 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Actions;
|
|
||||||
|
|
||||||
use App\Models\Instance;
|
|
||||||
use App\Models\Order;
|
|
||||||
use App\Models\ProvisioningRun;
|
|
||||||
use App\Provisioning\Jobs\AdvanceRunJob;
|
|
||||||
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, which is this run's state or newer. Checked
|
|
||||||
// against the ORDER, because that is the subject both pipelines share:
|
|
||||||
// starting an address run beside an unfinished customer run would have
|
|
||||||
// two runs writing one router and one Nextcloud config.
|
|
||||||
if ($this->hasRunInFlight($order)) {
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
|
|
||||||
private function hasRunInFlight(Order $order): bool
|
|
||||||
{
|
|
||||||
return ProvisioningRun::query()
|
|
||||||
->where('subject_type', Order::class)
|
|
||||||
->where('subject_id', $order->id)
|
|
||||||
->whereIn('status', [
|
|
||||||
ProvisioningRun::STATUS_PENDING,
|
|
||||||
ProvisioningRun::STATUS_RUNNING,
|
|
||||||
ProvisioningRun::STATUS_WAITING,
|
|
||||||
ProvisioningRun::STATUS_PAUSED,
|
|
||||||
])
|
|
||||||
->exists();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,146 +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;
|
|
||||||
$expectedGross = $tax->grossCents($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 {
|
|
||||||
$net = $agreedNet;
|
|
||||||
$gross = $expectedGross;
|
|
||||||
}
|
|
||||||
|
|
||||||
$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,
|
|
||||||
'charged_gross_cents' => $gross,
|
|
||||||
'matches_catalogue' => $gross === $expectedGross,
|
|
||||||
],
|
|
||||||
], $extra),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,163 +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. A restart beside an unfinished build would stop the
|
|
||||||
// machine the build is still writing to; a restart beside a plan change
|
|
||||||
// would race the config PUT it is about to boot. Whatever is in flight
|
|
||||||
// gets to finish, and the customer can press the button again after.
|
|
||||||
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)
|
|
||||||
->whereIn('status', [
|
|
||||||
ProvisioningRun::STATUS_PENDING,
|
|
||||||
ProvisioningRun::STATUS_RUNNING,
|
|
||||||
ProvisioningRun::STATUS_WAITING,
|
|
||||||
ProvisioningRun::STATUS_PAUSED,
|
|
||||||
])
|
|
||||||
->exists();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,332 +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\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,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'],
|
|
||||||
'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. Null is
|
|
||||||
// the honest default and it is the EXPENSIVE one for us: a
|
|
||||||
// withdrawal without this consent on record refunds the
|
|
||||||
// whole amount, because we cannot charge for days the
|
|
||||||
// customer never agreed to start.
|
|
||||||
'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.
|
|
||||||
*
|
|
||||||
* 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 — which is a defect, not a preference.
|
|
||||||
* The invoice mail carries the document and says everything the
|
|
||||||
* confirmation said, so it replaces it whenever there is an invoice; the
|
|
||||||
* confirmation remains for the case where there cannot be one.
|
|
||||||
*
|
|
||||||
* 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
|
|
||||||
{
|
|
||||||
$address = $customer->email;
|
|
||||||
|
|
||||||
if (! is_string($address) || $address === '') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$invoice = null;
|
|
||||||
|
|
||||||
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.
|
|
||||||
$invoice = 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(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
Mail::to($address)->queue($invoice !== null
|
|
||||||
? new InvoiceMail($invoice, (string) $customer->name)
|
|
||||||
: new OrderConfirmationMail($order, (string) $customer->name));
|
|
||||||
} catch (Throwable $e) {
|
|
||||||
Log::warning('Could not queue the purchase confirmation', [
|
|
||||||
'order' => $order->id,
|
|
||||||
'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.
|
|
||||||
*/
|
|
||||||
private function resume(Order $order): void
|
|
||||||
{
|
|
||||||
if ($order->subscription()->exists()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->openContract($order);
|
|
||||||
|
|
||||||
if ($order->subscription()->exists()) {
|
|
||||||
$this->reviveRunStrandedWithoutAContract($order);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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,310 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Actions;
|
|
||||||
|
|
||||||
use App\Models\Subscription;
|
|
||||||
use App\Models\SubscriptionAddon;
|
|
||||||
use App\Services\Billing\AddonPrices;
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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,
|
|
||||||
);
|
|
||||||
|
|
||||||
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,390 +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\TaxTreatment;
|
|
||||||
use App\Services\Billing\WithdrawalRight;
|
|
||||||
use App\Services\Stripe\StripeClient;
|
|
||||||
use Illuminate\Support\Carbon;
|
|
||||||
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**: the invoice that was issued is CANCELLED by a Storno
|
|
||||||
* with its own number — never edited, never deleted — and if the consumer
|
|
||||||
* owes a pro-rata share, a new invoice states it. That is the established
|
|
||||||
* rule for correcting an issued document, and a withdrawal is the clearest
|
|
||||||
* case there is for it.
|
|
||||||
* 4. **The money**, which falls out of the paperwork rather than being
|
|
||||||
* computed beside it: what was invoiced, less what the new document says is
|
|
||||||
* still owed. Two sums that must agree cannot disagree if only one of them
|
|
||||||
* is ever calculated.
|
|
||||||
* 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.
|
|
||||||
*
|
|
||||||
* ## 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.
|
|
||||||
*
|
|
||||||
* @return int what actually went back to the customer, gross, in cents
|
|
||||||
*/
|
|
||||||
private function settle(Subscription $subscription, Carbon $at): int
|
|
||||||
{
|
|
||||||
try {
|
|
||||||
$original = $this->openingInvoice($subscription);
|
|
||||||
$owedNet = WithdrawalRight::owedNetCents($subscription, $at);
|
|
||||||
|
|
||||||
// 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 and nothing to correct, 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.
|
|
||||||
if ($original === null) {
|
|
||||||
$paid = (int) ($subscription->order?->amount_cents ?? 0);
|
|
||||||
$owedGross = $owedNet === 0 ? 0 : $this->grossFor($subscription, $owedNet);
|
|
||||||
|
|
||||||
return $this->sendBack($subscription, max(0, $paid - $owedGross), $at);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Taken back in full, with its own gapless number, pointing at the
|
|
||||||
// one it cancels. The original keeps its number for ever.
|
|
||||||
$this->invoices->cancelling($original);
|
|
||||||
|
|
||||||
// What the consumer owes for the days the cloud actually ran. Its
|
|
||||||
// own document, because the money is real and an amount kept with no
|
|
||||||
// invoice behind it is money we cannot account for.
|
|
||||||
$remainder = $owedNet > 0
|
|
||||||
? $this->invoices->forService(
|
|
||||||
customer: $original->customer,
|
|
||||||
lines: [[
|
|
||||||
'description' => __('withdrawal.invoice_line', [
|
|
||||||
'plan' => ucfirst((string) $subscription->plan),
|
|
||||||
]),
|
|
||||||
'details' => [__('withdrawal.invoice_detail', [
|
|
||||||
'days' => WithdrawalRight::deliveredDays($subscription, $at),
|
|
||||||
'term' => WithdrawalRight::termDays($subscription),
|
|
||||||
])],
|
|
||||||
'quantity_milli' => 1000,
|
|
||||||
'unit' => '',
|
|
||||||
'unit_net_cents' => $owedNet,
|
|
||||||
]],
|
|
||||||
currency: (string) $original->currency,
|
|
||||||
)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
// The refund IS the difference between the two documents. Computing
|
|
||||||
// it separately would produce a second figure that has to agree with
|
|
||||||
// the first, and one day would not.
|
|
||||||
return $this->sendBack(
|
|
||||||
$subscription,
|
|
||||||
max(0, (int) $original->gross_cents - (int) ($remainder?->gross_cents ?? 0)),
|
|
||||||
$at,
|
|
||||||
);
|
|
||||||
} catch (Throwable $e) {
|
|
||||||
$this->parkMoneyFailure($subscription, $e);
|
|
||||||
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Put the money back where it came from.
|
|
||||||
*
|
|
||||||
* Stripe refunds a PAYMENT, not a subscription and not an invoice, so the
|
|
||||||
* handle is whatever the checkout reported — a PaymentIntent if there was
|
|
||||||
* one, otherwise 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 sendBack(Subscription $subscription, int $grossCents, Carbon $at): int
|
|
||||||
{
|
|
||||||
$subscription->update(['withdrawal_refund_cents' => $grossCents]);
|
|
||||||
|
|
||||||
if ($grossCents <= 0) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
$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);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($reference === null) {
|
|
||||||
throw new RuntimeException(
|
|
||||||
'No Stripe payment is recorded for this contract, so the refund has to be sent by hand.'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
$refundId = $this->stripe->refund(
|
|
||||||
$reference,
|
|
||||||
$grossCents,
|
|
||||||
// Keyed on the contract, because a contract is withdrawn from once.
|
|
||||||
// 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.
|
|
||||||
idempotencyKey: 'clupilot-withdrawal-'.$subscription->uuid,
|
|
||||||
);
|
|
||||||
|
|
||||||
$subscription->update([
|
|
||||||
'withdrawal_refund_reference' => $refundId,
|
|
||||||
'withdrawal_refund_error' => null,
|
|
||||||
]);
|
|
||||||
|
|
||||||
Log::info('Refunded a withdrawal.', [
|
|
||||||
'subscription' => $subscription->uuid,
|
|
||||||
'refund' => $refundId,
|
|
||||||
'gross_cents' => $grossCents,
|
|
||||||
'withdrawn_at' => $at->toIso8601String(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return $grossCents;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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.
|
|
||||||
*/
|
|
||||||
private function endEverything(Subscription $subscription, Carbon $at): void
|
|
||||||
{
|
|
||||||
foreach (SubscriptionAddon::query()->where('subscription_id', $subscription->id)->active()->get() as $addon) {
|
|
||||||
$this->bookAddon->cancel($addon);
|
|
||||||
}
|
|
||||||
|
|
||||||
$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,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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 document issued for the purchase that opened this contract.
|
|
||||||
*
|
|
||||||
* Cancellations are excluded by construction — one is not an invoice that
|
|
||||||
* can be taken back — and so is anything that already has a Storno against
|
|
||||||
* it, which IssueInvoice::cancelling() refuses in its own right.
|
|
||||||
*/
|
|
||||||
private function openingInvoice(Subscription $subscription): ?Invoice
|
|
||||||
{
|
|
||||||
if ($subscription->order_id === null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Invoice::query()
|
|
||||||
->where('order_id', $subscription->order_id)
|
|
||||||
->whereNull('cancels_invoice_id')
|
|
||||||
->orderBy('id')
|
|
||||||
->first();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Gross for a net figure, at this customer's own treatment. */
|
|
||||||
private function grossFor(Subscription $subscription, int $netCents): int
|
|
||||||
{
|
|
||||||
return TaxTreatment::for($subscription->customer)->grossCents($netCents);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$subscription->update([
|
|
||||||
'withdrawal_refund_error' => mb_substr($e->getMessage(), 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,
|
|
||||||
'delivered_days' => WithdrawalRight::deliveredDays($subscription, $at),
|
|
||||||
'term_days' => WithdrawalRight::termDays($subscription),
|
|
||||||
'owed_net_cents' => WithdrawalRight::owedNetCents($subscription, $at),
|
|
||||||
'refunded_gross_cents' => $refundedGross,
|
|
||||||
'immediate_start_consent_at' => $subscription->immediate_start_consent_at?->toIso8601String(),
|
|
||||||
]],
|
|
||||||
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,173 +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);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function hasRunInFlight(Instance $instance): bool
|
|
||||||
{
|
|
||||||
return ProvisioningRun::query()
|
|
||||||
->where('subject_type', Order::class)
|
|
||||||
->where('subject_id', $instance->order_id)
|
|
||||||
->whereIn('status', [
|
|
||||||
ProvisioningRun::STATUS_PENDING,
|
|
||||||
ProvisioningRun::STATUS_RUNNING,
|
|
||||||
ProvisioningRun::STATUS_WAITING,
|
|
||||||
ProvisioningRun::STATUS_PAUSED,
|
|
||||||
])
|
|
||||||
->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,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,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,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,198 +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\Stripe\StripeClient;
|
|
||||||
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.
|
|
||||||
*
|
|
||||||
* Idempotent by construction: a row that already carries an id is skipped. That
|
|
||||||
* matters more here than usual, because a Stripe Price cannot be edited, so a
|
|
||||||
* second run that minted duplicates would leave two live prices for one plan
|
|
||||||
* and no way to tell which a customer is on.
|
|
||||||
*
|
|
||||||
* 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.
|
|
||||||
*
|
|
||||||
* **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;
|
|
||||||
}
|
|
||||||
|
|
||||||
$created = 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) {
|
|
||||||
$productId = $stripe->createProduct(
|
|
||||||
$family->name,
|
|
||||||
['plan_family' => $family->key, 'plan_family_id' => (string) $family->id],
|
|
||||||
// Keyed on our row, so a crash between Stripe creating
|
|
||||||
// the product and us storing its id gives back the same
|
|
||||||
// product on the next run rather than a second one.
|
|
||||||
idempotencyKey: "clupilot-product-{$family->id}",
|
|
||||||
);
|
|
||||||
$family->update(['stripe_product_id' => $productId]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($published as $version) {
|
|
||||||
foreach ($version->prices as $price) {
|
|
||||||
if ($price->stripe_price_id !== null) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->line(sprintf(
|
|
||||||
' price %s v%d %s %d %s',
|
|
||||||
$family->key, $version->version, $price->term, $price->amount_cents, $price->currency,
|
|
||||||
));
|
|
||||||
$created++;
|
|
||||||
|
|
||||||
if ($dryRun || $productId === null) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$priceId = $stripe->createPrice(
|
|
||||||
productId: $productId,
|
|
||||||
amountCents: $price->amount_cents,
|
|
||||||
currency: $price->currency,
|
|
||||||
interval: $price->term === 'yearly' ? 'year' : 'month',
|
|
||||||
metadata: [
|
|
||||||
'plan_family' => $family->key,
|
|
||||||
'plan_version' => (string) $version->version,
|
|
||||||
'plan_version_id' => (string) $version->id,
|
|
||||||
'plan_price_id' => (string) $price->id,
|
|
||||||
],
|
|
||||||
idempotencyKey: "clupilot-price-{$price->id}",
|
|
||||||
);
|
|
||||||
|
|
||||||
// Written straight through the query builder: stripe_price_id
|
|
||||||
// is not part of what publication froze, and the model would
|
|
||||||
// otherwise have to be re-read first.
|
|
||||||
PlanPrice::query()->whereKey($price->id)->update(['stripe_price_id' => $priceId]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$created += $this->syncModules($dryRun);
|
|
||||||
|
|
||||||
$this->newLine();
|
|
||||||
|
|
||||||
if ($created === 0) {
|
|
||||||
$this->info('Stripe is already in step with the catalogue.');
|
|
||||||
|
|
||||||
return self::SUCCESS;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->info($dryRun
|
|
||||||
? "{$created} object(s) would be created. Run without --dry-run to create them."
|
|
||||||
: "{$created} object(s) created in Stripe.");
|
|
||||||
|
|
||||||
return self::SUCCESS;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A Product and two Prices — monthly and yearly — for every module on sale.
|
|
||||||
*
|
|
||||||
* Both terms, 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 => $monthly, Subscription::TERM_YEARLY => $monthly * 12] as $term => $amount) {
|
|
||||||
$interval = $term === Subscription::TERM_YEARLY ? 'year' : 'month';
|
|
||||||
|
|
||||||
if ($prices->find($key, $amount, $currency, $interval) !== null) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->line(sprintf(' module %s %s %d %s', $key, $term, $amount, $currency));
|
|
||||||
$created++;
|
|
||||||
|
|
||||||
if ($dryRun) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$prices->ensure($key, $monthly, $currency, $term);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $created;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 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,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,144 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Http\Controllers;
|
|
||||||
|
|
||||||
use App\Models\Customer;
|
|
||||||
use App\Models\Subscription;
|
|
||||||
use App\Services\Billing\PlanCatalogue;
|
|
||||||
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): RedirectResponse
|
|
||||||
{
|
|
||||||
$data = $request->validate([
|
|
||||||
'plan' => ['required', 'string', 'max:64'],
|
|
||||||
'term' => ['nullable', 'in:'.Subscription::TERM_MONTHLY.','.Subscription::TERM_YEARLY],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$term = $data['term'] ?? Subscription::TERM_MONTHLY;
|
|
||||||
$user = $request->user();
|
|
||||||
|
|
||||||
// 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($user?->email)) {
|
|
||||||
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')]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// A price with no Stripe id was never synced. Sending the customer on
|
|
||||||
// would open a checkout for nothing.
|
|
||||||
if ($price === null || blank($price->stripe_price_id)) {
|
|
||||||
Log::error('Checkout for a plan that is not on sale in Stripe', [
|
|
||||||
'plan' => $data['plan'],
|
|
||||||
'term' => $term,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return back()->withErrors(['plan' => __('checkout.plan_gone')]);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
$url = $stripe->createCheckoutSession(
|
|
||||||
priceId: (string) $price->stripe_price_id,
|
|
||||||
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(),
|
|
||||||
],
|
|
||||||
customerEmail: $user?->email,
|
|
||||||
);
|
|
||||||
} 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 address already have a contract that is being billed? */
|
|
||||||
private function hasLiveContract(?string $email): bool
|
|
||||||
{
|
|
||||||
if ($email === null) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$customer = Customer::query()->where('email', $email)->first();
|
|
||||||
|
|
||||||
return $customer !== null && Subscription::query()
|
|
||||||
->where('customer_id', $customer->id)
|
|
||||||
->whereIn('status', ['active', 'past_due'])
|
|
||||||
->exists();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -2,9 +2,7 @@
|
||||||
|
|
||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
|
||||||
|
|
||||||
abstract class Controller
|
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,623 +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\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();
|
|
||||||
$setupNet = CompanyProfile::setupFeeCents();
|
|
||||||
|
|
||||||
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($this->gross($setupNet), 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']),
|
|
||||||
'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 with the domestic VAT put back on.
|
|
||||||
*
|
|
||||||
* The same rate an invoice would use, read from the same place
|
|
||||||
* (App\Services\Billing\TaxTreatment does too) — a percentage written
|
|
||||||
* into the marketing page would be the second source that makes the sheet
|
|
||||||
* and the invoice disagree.
|
|
||||||
*
|
|
||||||
* Cross-border reverse charge is deliberately not modelled here: it depends
|
|
||||||
* on a VERIFIED VAT id, which a visitor to a public page does not have. The
|
|
||||||
* page quotes the domestic price and the invoice applies the treatment.
|
|
||||||
*/
|
|
||||||
private function gross(int $netCents): int
|
|
||||||
{
|
|
||||||
return (int) round($netCents * (1 + CompanyProfile::taxRate() / 100));
|
|
||||||
}
|
|
||||||
|
|
||||||
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,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,150 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Http\Controllers;
|
|
||||||
|
|
||||||
use App\Actions\ApplyStripeBillingEvent;
|
|
||||||
use App\Actions\StartCustomerProvisioning;
|
|
||||||
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 = (string) config('services.stripe.webhook_secret');
|
|
||||||
|
|
||||||
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, having been told they would then
|
|
||||||
// owe the pro-rata share of whatever had been delivered (FAGG §16).
|
|
||||||
// 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.
|
|
||||||
'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),
|
|
||||||
'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,97 +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->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',
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
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,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,143 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Livewire\Admin;
|
|
||||||
|
|
||||||
use App\Mail\OperatorMessageMail;
|
|
||||||
use App\Models\Customer;
|
|
||||||
use App\Models\SentMail;
|
|
||||||
use App\Models\SupportRequest;
|
|
||||||
use Illuminate\Support\Facades\Mail;
|
|
||||||
use Livewire\Attributes\Layout;
|
|
||||||
use Livewire\Attributes\Validate;
|
|
||||||
use Livewire\Component;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One customer, everything about them, and a box to write to them.
|
|
||||||
*
|
|
||||||
* The console had a customer LIST and nothing behind it: an operator answering
|
|
||||||
* a question had the name in one window, the mail client in another, and no
|
|
||||||
* record anywhere afterwards that an answer had been given at all. So the
|
|
||||||
* conversation happened outside the system that knows who the customer is.
|
|
||||||
*
|
|
||||||
* This page is the other half. What they bought, what they asked, what we sent
|
|
||||||
* them — and a field to write the next message, which goes out as mail from the
|
|
||||||
* support mailbox and is written into the register on its way.
|
|
||||||
*
|
|
||||||
* Reading their INCOMING mail is not here yet. That needs the mailbox itself
|
|
||||||
* polled over IMAP, which is a mail-server credential and a dependency; until
|
|
||||||
* then the requests a customer files through the portal appear here in full,
|
|
||||||
* and a mail they write instead still has to be read in the mail client.
|
|
||||||
*/
|
|
||||||
#[Layout('layouts.admin')]
|
|
||||||
class CustomerDetail extends Component
|
|
||||||
{
|
|
||||||
public Customer $customer;
|
|
||||||
|
|
||||||
/** What the operator is writing. Empty is the normal state of this page. */
|
|
||||||
#[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 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();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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 = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
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, 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'])
|
|
||||||
);
|
|
||||||
|
|
||||||
// The listener has already written a row for the delivery — it fires on
|
|
||||||
// every mail this application sends. This adds what only the console
|
|
||||||
// knows: that a person wrote it, and what they wrote.
|
|
||||||
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', [
|
|
||||||
'requests' => $this->customer->supportRequests()
|
|
||||||
->with('instance')
|
|
||||||
->latest('id')
|
|
||||||
->get(),
|
|
||||||
// The whole conversation as it left here, newest first. Capped:
|
|
||||||
// this is a page, not an export, and a customer of three years has
|
|
||||||
// a few hundred rows nobody scrolls.
|
|
||||||
'mails' => SentMail::query()
|
|
||||||
->where('customer_id', $this->customer->id)
|
|
||||||
->latest('sent_at')
|
|
||||||
->limit(50)
|
|
||||||
->get(),
|
|
||||||
'instance' => $this->customer->instances()->latest('id')->first(),
|
|
||||||
'subscription' => $this->customer->subscriptions()->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,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,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),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,46 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Livewire\Admin;
|
|
||||||
|
|
||||||
use App\Actions\StartHostOnboarding;
|
|
||||||
use Livewire\Attributes\Layout;
|
|
||||||
use Livewire\Attributes\Validate;
|
|
||||||
use Livewire\Component;
|
|
||||||
|
|
||||||
#[Layout('layouts.admin')]
|
|
||||||
class HostCreate extends Component
|
|
||||||
{
|
|
||||||
#[Validate('required|string|max:255')]
|
|
||||||
public string $name = '';
|
|
||||||
|
|
||||||
#[Validate('required|string|exists:datacenters,code,active,1')]
|
|
||||||
public string $datacenter = '';
|
|
||||||
|
|
||||||
public function mount(): void
|
|
||||||
{
|
|
||||||
$this->datacenter = (string) \App\Models\Datacenter::query()->active()->orderBy('name')->value('code');
|
|
||||||
}
|
|
||||||
|
|
||||||
#[Validate('required|ip|unique:hosts,public_ip')]
|
|
||||||
public string $public_ip = '';
|
|
||||||
|
|
||||||
#[Validate('required|string|min:8')]
|
|
||||||
public string $root_password = '';
|
|
||||||
|
|
||||||
public function save(StartHostOnboarding $action)
|
|
||||||
{
|
|
||||||
$this->authorize('hosts.manage');
|
|
||||||
$data = $this->validate();
|
|
||||||
|
|
||||||
$host = $action->run($data);
|
|
||||||
|
|
||||||
return $this->redirectRoute('admin.hosts.show', ['host' => $host->uuid], navigate: true);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function render()
|
|
||||||
{
|
|
||||||
return view('livewire.admin.host-create', [
|
|
||||||
'datacenters' => \App\Models\Datacenter::query()->active()->orderBy('name')->get(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,117 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Livewire\Admin;
|
|
||||||
|
|
||||||
use App\Models\Host;
|
|
||||||
use App\Models\ProvisioningRun;
|
|
||||||
use App\Provisioning\Jobs\AdvanceRunJob;
|
|
||||||
use Illuminate\Support\Collection;
|
|
||||||
use Livewire\Attributes\Layout;
|
|
||||||
use Livewire\Attributes\On;
|
|
||||||
use Livewire\Component;
|
|
||||||
|
|
||||||
#[Layout('layouts.admin')]
|
|
||||||
class HostDetail extends Component
|
|
||||||
{
|
|
||||||
public Host $host;
|
|
||||||
|
|
||||||
public function mount(Host $host): void
|
|
||||||
{
|
|
||||||
$this->host = $host;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Live refresh whenever any run advances (admins-only channel). */
|
|
||||||
#[On('echo-private:admin.runs,StepAdvanced')]
|
|
||||||
public function onStepAdvanced(): void
|
|
||||||
{
|
|
||||||
$this->host->refresh();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Adjust the capacity reserve (% of storage kept free for headroom). */
|
|
||||||
public function saveReserve(int $reserve): void
|
|
||||||
{
|
|
||||||
$this->authorize('hosts.manage');
|
|
||||||
$reserve = max(0, min(90, $reserve));
|
|
||||||
$this->host->update(['reserve_pct' => $reserve]);
|
|
||||||
$this->dispatch('notify', message: __('hosts.detail.reserve_saved'));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Drain / return a host: toggle between active and disabled. Disabled takes
|
|
||||||
* it out of placement (maintenance) without purging it — distinct from the
|
|
||||||
* destructive "remove host". Never touches a host mid-onboarding.
|
|
||||||
*/
|
|
||||||
public function toggleMaintenance(): void
|
|
||||||
{
|
|
||||||
$this->authorize('hosts.manage');
|
|
||||||
if ($this->host->status === 'active') {
|
|
||||||
$this->host->update(['status' => 'disabled']);
|
|
||||||
} elseif ($this->host->status === 'disabled') {
|
|
||||||
$this->host->update(['status' => 'active']);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public function retry(): void
|
|
||||||
{
|
|
||||||
$this->authorize('hosts.manage');
|
|
||||||
$run = $this->currentRun();
|
|
||||||
|
|
||||||
if ($run !== null && $run->status === ProvisioningRun::STATUS_FAILED) {
|
|
||||||
$run->update([
|
|
||||||
'status' => ProvisioningRun::STATUS_RUNNING,
|
|
||||||
'attempt' => 0,
|
|
||||||
'next_attempt_at' => now(),
|
|
||||||
'started_at' => now(), // reset the step timer so it doesn't re-time-out instantly
|
|
||||||
'error' => null,
|
|
||||||
]);
|
|
||||||
$this->host->update(['status' => 'onboarding']);
|
|
||||||
AdvanceRunJob::dispatch($run->uuid);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private function currentRun(): ?ProvisioningRun
|
|
||||||
{
|
|
||||||
return $this->host->runs()->latest('id')->first();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @return array<int, array{label: string, state: string}> */
|
|
||||||
private function buildSteps(?ProvisioningRun $run): array
|
|
||||||
{
|
|
||||||
$pipeline = config('provisioning.pipelines.host', []);
|
|
||||||
$current = $run?->current_step ?? 0;
|
|
||||||
$status = $run?->status;
|
|
||||||
|
|
||||||
$steps = [];
|
|
||||||
foreach ($pipeline as $index => $class) {
|
|
||||||
if ($status === ProvisioningRun::STATUS_COMPLETED || $index < $current) {
|
|
||||||
$state = 'done';
|
|
||||||
} elseif ($index === $current) {
|
|
||||||
$state = $status === ProvisioningRun::STATUS_FAILED ? 'failed' : 'running';
|
|
||||||
} else {
|
|
||||||
$state = 'pending';
|
|
||||||
}
|
|
||||||
|
|
||||||
$steps[] = ['label' => __(app($class)->label()), 'state' => $state];
|
|
||||||
}
|
|
||||||
|
|
||||||
return $steps;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function render()
|
|
||||||
{
|
|
||||||
$run = $this->currentRun();
|
|
||||||
|
|
||||||
/** @var Collection $events */
|
|
||||||
$events = $run
|
|
||||||
? $run->events()->latest('id')->limit(30)->get()
|
|
||||||
: collect();
|
|
||||||
|
|
||||||
return view('livewire.admin.host-detail', [
|
|
||||||
'run' => $run,
|
|
||||||
'steps' => $this->buildSteps($run),
|
|
||||||
'events' => $events,
|
|
||||||
'instances' => $this->host->instances()->latest('id')->get(),
|
|
||||||
'health' => $this->host->healthState(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,58 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Livewire\Admin;
|
|
||||||
|
|
||||||
use App\Models\Datacenter;
|
|
||||||
use App\Models\Host;
|
|
||||||
use Livewire\Attributes\Layout;
|
|
||||||
use Livewire\Attributes\Url;
|
|
||||||
use Livewire\Component;
|
|
||||||
|
|
||||||
#[Layout('layouts.admin')]
|
|
||||||
class Hosts extends Component
|
|
||||||
{
|
|
||||||
#[Url(as: 'q')]
|
|
||||||
public string $search = '';
|
|
||||||
|
|
||||||
#[Url]
|
|
||||||
public string $datacenter = '';
|
|
||||||
|
|
||||||
#[Url]
|
|
||||||
public string $status = '';
|
|
||||||
|
|
||||||
public function updated(): void
|
|
||||||
{
|
|
||||||
// no-op; keeps the query string in sync as filters change
|
|
||||||
}
|
|
||||||
|
|
||||||
public function clearFilters(): void
|
|
||||||
{
|
|
||||||
$this->reset('search', 'datacenter', 'status');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function render()
|
|
||||||
{
|
|
||||||
$hosts = Host::query()
|
|
||||||
->withCount('instances')
|
|
||||||
// The VPN column reads SyncVpnPeers' synced row, not a live hub
|
|
||||||
// call — the console container has no wg0 (see VpnPeer::vpnPeer()
|
|
||||||
// and SyncVpnPeers' docblock). One eager-loaded query for the
|
|
||||||
// whole page, not one per host.
|
|
||||||
->with('vpnPeer')
|
|
||||||
->when($this->search !== '', function ($q) {
|
|
||||||
$term = '%'.$this->search.'%';
|
|
||||||
$q->where(fn ($w) => $w->where('name', 'like', $term)->orWhere('public_ip', 'like', $term));
|
|
||||||
})
|
|
||||||
->when($this->datacenter !== '', fn ($q) => $q->where('datacenter', $this->datacenter))
|
|
||||||
->when($this->status !== '', fn ($q) => $q->where('status', $this->status))
|
|
||||||
->orderBy('datacenter')->orderBy('name')
|
|
||||||
->get();
|
|
||||||
|
|
||||||
return view('livewire.admin.hosts', [
|
|
||||||
'hosts' => $hosts,
|
|
||||||
'datacenters' => Datacenter::query()->orderBy('name')->get(),
|
|
||||||
'statuses' => ['pending', 'onboarding', 'active', 'error', 'disabled'],
|
|
||||||
'total' => Host::query()->count(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,194 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Livewire\Admin;
|
|
||||||
|
|
||||||
use App\Models\Incident;
|
|
||||||
use App\Models\IncidentUpdate;
|
|
||||||
use App\Services\Status\ServiceHealth;
|
|
||||||
use App\Support\LocalTime;
|
|
||||||
use Illuminate\Support\Facades\Auth;
|
|
||||||
use Illuminate\Support\Facades\DB;
|
|
||||||
use Livewire\Attributes\Layout;
|
|
||||||
use Livewire\Attributes\On;
|
|
||||||
use Livewire\Attributes\Validate;
|
|
||||||
use Livewire\Component;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Writing down a disruption while it is happening.
|
|
||||||
*
|
|
||||||
* The public page can only be as good as what somebody types here, so the form
|
|
||||||
* is deliberately short: a title, what it touches, how bad it is, and the first
|
|
||||||
* sentence. Everything else is a further update, which is also the only way to
|
|
||||||
* change what the record says — an update is a statement made at a time, and
|
|
||||||
* editing it afterwards is precisely what this record exists to prevent.
|
|
||||||
*
|
|
||||||
* Nothing is public until it is published. An operator two minutes into an
|
|
||||||
* outage should be able to write carefully rather than publish a sentence they
|
|
||||||
* then have to correct in front of everybody.
|
|
||||||
*/
|
|
||||||
#[Layout('layouts.admin')]
|
|
||||||
class Incidents extends Component
|
|
||||||
{
|
|
||||||
#[Validate('required|string|max:160')]
|
|
||||||
public string $title = '';
|
|
||||||
|
|
||||||
#[Validate('required|in:degraded,down,maintenance')]
|
|
||||||
public string $impact = Incident::IMPACT_DEGRADED;
|
|
||||||
|
|
||||||
/** @var array<int, string> */
|
|
||||||
#[Validate('required|array|min:1')]
|
|
||||||
public array $components = [];
|
|
||||||
|
|
||||||
#[Validate('required|date')]
|
|
||||||
public string $startedAt = '';
|
|
||||||
|
|
||||||
#[Validate('required|string|max:2000')]
|
|
||||||
public string $firstUpdate = '';
|
|
||||||
|
|
||||||
/** The incident an update is being written for, by uuid. */
|
|
||||||
public ?string $updatingUuid = null;
|
|
||||||
|
|
||||||
#[Validate('nullable|in:investigating,identified,monitoring,resolved')]
|
|
||||||
public string $updateStage = 'identified';
|
|
||||||
|
|
||||||
#[Validate('nullable|string|max:2000')]
|
|
||||||
public string $updateBody = '';
|
|
||||||
|
|
||||||
public function mount(): void
|
|
||||||
{
|
|
||||||
// Now, in the operator's own time — they are reporting something that
|
|
||||||
// is happening, not filing a historical record (R19).
|
|
||||||
$this->startedAt = LocalTime::toField(now());
|
|
||||||
}
|
|
||||||
|
|
||||||
public function create(): void
|
|
||||||
{
|
|
||||||
$this->authorize('maintenance.manage');
|
|
||||||
|
|
||||||
$this->validateOnly('title');
|
|
||||||
$this->validateOnly('impact');
|
|
||||||
$this->validateOnly('components');
|
|
||||||
$this->validateOnly('startedAt');
|
|
||||||
$this->validateOnly('firstUpdate');
|
|
||||||
|
|
||||||
// One transaction: an incident with no first update is a headline with
|
|
||||||
// nothing under it, and that is what a reader would see if the second
|
|
||||||
// insert failed.
|
|
||||||
DB::transaction(function () {
|
|
||||||
$incident = Incident::query()->create([
|
|
||||||
'title' => $this->title,
|
|
||||||
'impact' => $this->impact,
|
|
||||||
'components' => array_values(array_intersect(ServiceHealth::COMPONENTS, $this->components)),
|
|
||||||
'started_at' => LocalTime::fromField($this->startedAt),
|
|
||||||
'published_at' => now(),
|
|
||||||
'created_by' => Auth::guard('operator')->user()?->email,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// The first entry is always "wird untersucht" — that is what a
|
|
||||||
// first report is. The form says so, so nobody looks for a stage
|
|
||||||
// selector that is not there.
|
|
||||||
$incident->updates()->create([
|
|
||||||
'status' => 'investigating',
|
|
||||||
'body' => $this->firstUpdate,
|
|
||||||
'created_by' => Auth::guard('operator')->user()?->email,
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
$this->reset(['title', 'impact', 'components', 'firstUpdate']);
|
|
||||||
$this->startedAt = LocalTime::toField(now());
|
|
||||||
$this->dispatch('notify', message: __('admin_incidents.created'));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Open the update box for one incident. */
|
|
||||||
public function startUpdate(string $uuid): void
|
|
||||||
{
|
|
||||||
$this->authorize('maintenance.manage');
|
|
||||||
$this->updatingUuid = $uuid;
|
|
||||||
$this->updateStage = 'identified';
|
|
||||||
$this->updateBody = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
public function cancelUpdate(): void
|
|
||||||
{
|
|
||||||
$this->reset(['updatingUuid', 'updateStage', 'updateBody']);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function postUpdate(): void
|
|
||||||
{
|
|
||||||
$this->authorize('maintenance.manage');
|
|
||||||
|
|
||||||
$this->validate([
|
|
||||||
'updateStage' => 'required|in:investigating,identified,monitoring,resolved',
|
|
||||||
'updateBody' => 'required|string|max:2000',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$incident = Incident::query()->where('uuid', $this->updatingUuid)->firstOrFail();
|
|
||||||
|
|
||||||
DB::transaction(function () use ($incident) {
|
|
||||||
$incident->updates()->create([
|
|
||||||
'status' => $this->updateStage,
|
|
||||||
'body' => $this->updateBody,
|
|
||||||
'created_by' => Auth::guard('operator')->user()?->email,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// "Resolved" is the update AND the closing of the incident. Two
|
|
||||||
// separate actions is how a status page ends up with a resolved
|
|
||||||
// note under an incident that still reports as ongoing.
|
|
||||||
if ($this->updateStage === 'resolved' && ! $incident->isResolved()) {
|
|
||||||
$incident->forceFill(['resolved_at' => now()])->save();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
$this->cancelUpdate();
|
|
||||||
$this->dispatch('notify', message: __('admin_incidents.update_posted'));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Remove an incident entirely — the whole record, updates and all.
|
|
||||||
*
|
|
||||||
* Deliberately different from editing an update, which there is still no
|
|
||||||
* way to do: an update is a statement made at a time, and rewriting one
|
|
||||||
* afterwards is what this record exists to prevent. Removing the WHOLE
|
|
||||||
* incident is a different act. It is how a test entry, or one published by
|
|
||||||
* mistake against the wrong service, is taken back — and taking it back
|
|
||||||
* leaves nothing half-standing behind.
|
|
||||||
*
|
|
||||||
* Owner-level, and confirmed in a modal (R23), because on a page whose
|
|
||||||
* whole value is that it is believed, deleting the record is the one action
|
|
||||||
* that cannot be undone by writing more.
|
|
||||||
*/
|
|
||||||
#[On('incident-delete-confirmed')]
|
|
||||||
public function delete(string $uuid): void
|
|
||||||
{
|
|
||||||
$this->authorize('maintenance.manage');
|
|
||||||
|
|
||||||
Incident::query()->where('uuid', $uuid)->first()?->delete();
|
|
||||||
|
|
||||||
if ($this->updatingUuid === $uuid) {
|
|
||||||
$this->cancelUpdate();
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->dispatch('notify', message: __('admin_incidents.deleted'));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @return array<int, string> */
|
|
||||||
public function componentOptions(): array
|
|
||||||
{
|
|
||||||
return ServiceHealth::COMPONENTS;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function render()
|
|
||||||
{
|
|
||||||
$this->authorize('maintenance.manage');
|
|
||||||
|
|
||||||
return view('livewire.admin.incidents', [
|
|
||||||
'ongoing' => Incident::query()->ongoing()->with('updates')->orderByDesc('started_at')->get(),
|
|
||||||
'resolved' => Incident::query()
|
|
||||||
->whereNotNull('resolved_at')
|
|
||||||
->with('updates')
|
|
||||||
->orderByDesc('started_at')
|
|
||||||
->limit(25)
|
|
||||||
->get(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,114 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Livewire\Admin;
|
|
||||||
|
|
||||||
use App\Livewire\Concerns\ResolvesOperator;
|
|
||||||
use App\Models\Instance;
|
|
||||||
use App\Provisioning\Jobs\IssueInstanceAdminAccess;
|
|
||||||
use App\Services\Wireguard\ConfigHandoff;
|
|
||||||
use Illuminate\Support\Facades\Hash;
|
|
||||||
use Illuminate\Support\Facades\RateLimiter;
|
|
||||||
use Illuminate\Support\Str;
|
|
||||||
use LivewireUI\Modal\ModalComponent;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Administrator access to a customer's Nextcloud.
|
|
||||||
*
|
|
||||||
* Not impersonation: that borrows a portal session. This resets our managed
|
|
||||||
* admin account inside the customer's installation and hands the credentials
|
|
||||||
* over once — the only thing stock Nextcloud offers, and honest about what it
|
|
||||||
* does rather than pretending to be a passwordless jump.
|
|
||||||
*
|
|
||||||
* The operator's own password is required, every time. Taking control of a
|
|
||||||
* customer's installation is not something an unattended browser should be able
|
|
||||||
* to do on its own.
|
|
||||||
*/
|
|
||||||
class InstanceAdminAccess extends ModalComponent
|
|
||||||
{
|
|
||||||
use ResolvesOperator;
|
|
||||||
|
|
||||||
public string $uuid;
|
|
||||||
|
|
||||||
public string $subdomain = '';
|
|
||||||
|
|
||||||
public string $password = '';
|
|
||||||
|
|
||||||
/** Opaque handle; the credentials never enter the component snapshot. */
|
|
||||||
public ?string $token = null;
|
|
||||||
|
|
||||||
public bool $waiting = false;
|
|
||||||
|
|
||||||
public function mount(string $uuid): void
|
|
||||||
{
|
|
||||||
$this->authorize('instances.adminlogin');
|
|
||||||
|
|
||||||
$instance = Instance::query()->where('uuid', $uuid)->firstOrFail();
|
|
||||||
$this->uuid = $uuid;
|
|
||||||
$this->subdomain = $instance->subdomain;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function hydrate(): void
|
|
||||||
{
|
|
||||||
$this->authorize('instances.adminlogin');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function request(): void
|
|
||||||
{
|
|
||||||
$this->authorize('instances.adminlogin');
|
|
||||||
$this->resetErrorBag('password');
|
|
||||||
|
|
||||||
if (! $operator = $this->currentOperator()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$key = 'instance-admin:'.$operator->id;
|
|
||||||
if (RateLimiter::tooManyAttempts($key, 5)) {
|
|
||||||
$this->addError('password', __('instances.too_many_attempts', ['seconds' => RateLimiter::availableIn($key)]));
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! Hash::check($this->password, $operator->password)) {
|
|
||||||
RateLimiter::hit($key, 300);
|
|
||||||
$this->addError('password', __('instances.wrong_password'));
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
RateLimiter::clear($key);
|
|
||||||
$this->reset('password');
|
|
||||||
|
|
||||||
// The reset runs on the provisioning worker — it owns the tunnel. The
|
|
||||||
// token is where it will leave the result.
|
|
||||||
$this->token = Str::random(40);
|
|
||||||
$this->waiting = true;
|
|
||||||
|
|
||||||
IssueInstanceAdminAccess::dispatch($this->uuid, $this->token, $operator->id);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function render()
|
|
||||||
{
|
|
||||||
$payload = null;
|
|
||||||
|
|
||||||
if ($this->token !== null) {
|
|
||||||
$raw = ConfigHandoff::get($this->token);
|
|
||||||
|
|
||||||
if ($raw !== null) {
|
|
||||||
// Consumed on the first read: leaving it in the cache would make
|
|
||||||
// "shown once" a figure of speech — any replayed component
|
|
||||||
// request could fetch the password again for ten minutes. The
|
|
||||||
// payload lives in a local variable, so it reaches the view and
|
|
||||||
// nothing else.
|
|
||||||
ConfigHandoff::forget($this->token);
|
|
||||||
$this->token = null;
|
|
||||||
$this->waiting = false;
|
|
||||||
$payload = json_decode($raw, true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return view('livewire.admin.instance-admin-access', [
|
|
||||||
'credentials' => isset($payload['password']) ? $payload : null,
|
|
||||||
'failed' => isset($payload['error']) ? $payload['error'] : null,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,113 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Livewire\Admin;
|
|
||||||
|
|
||||||
use App\Actions\RestartInstance;
|
|
||||||
use App\Models\Instance;
|
|
||||||
use App\Services\Billing\StorageAllowance;
|
|
||||||
use Illuminate\Auth\Access\AuthorizationException;
|
|
||||||
use Illuminate\Support\Facades\Gate;
|
|
||||||
use Illuminate\Support\Facades\Lang;
|
|
||||||
use Livewire\Attributes\Layout;
|
|
||||||
use Livewire\Attributes\On;
|
|
||||||
use Livewire\Component;
|
|
||||||
use Livewire\WithPagination;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Every instance on the estate, from the instances table.
|
|
||||||
*
|
|
||||||
* This page used to list seven invented instances on four invented hosts,
|
|
||||||
* complete with a Nextcloud version column. The version is not recorded
|
|
||||||
* anywhere, so the column is gone rather than filled in with something
|
|
||||||
* plausible — and the storage column now shows the quota that was actually
|
|
||||||
* sold, not a made-up "used of total", because used disk is not collected.
|
|
||||||
*/
|
|
||||||
#[Layout('layouts.admin')]
|
|
||||||
class Instances extends Component
|
|
||||||
{
|
|
||||||
use WithPagination;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Restart one instance, once ConfirmRestartInstance has said so.
|
|
||||||
*
|
|
||||||
* The capability is checked twice on purpose, and neither check is the
|
|
||||||
* decoration of the other. This one keeps an operator without
|
|
||||||
* `instances.restart` from driving the console component at all; the one
|
|
||||||
* inside RestartInstance is what actually guards the machine, because that
|
|
||||||
* is the door a hand-written POST to /livewire/update arrives at. Losing
|
|
||||||
* either would still leave a customer's cloud reachable by somebody who was
|
|
||||||
* only ever meant to read the list.
|
|
||||||
*/
|
|
||||||
#[On('instance-restart-confirmed')]
|
|
||||||
public function restart(string $uuid): void
|
|
||||||
{
|
|
||||||
$this->authorize('instances.restart');
|
|
||||||
|
|
||||||
$instance = Instance::query()->where('uuid', $uuid)->first();
|
|
||||||
|
|
||||||
if ($instance === null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
$run = app(RestartInstance::class)($instance);
|
|
||||||
} catch (AuthorizationException) {
|
|
||||||
$this->dispatch('notify', message: __('admin.restart_denied'));
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Null is every ordinary refusal — no live machine, or a run already in
|
|
||||||
// flight against this order. Said out loud, because an operator who
|
|
||||||
// pressed a button and saw nothing will press it again.
|
|
||||||
$this->dispatch('notify', message: __($run === null ? 'admin.restart_busy' : 'admin.restart_started'));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function render()
|
|
||||||
{
|
|
||||||
$instances = Instance::query()
|
|
||||||
->with(['customer', 'host'])
|
|
||||||
->orderByDesc('id')
|
|
||||||
->paginate(25);
|
|
||||||
|
|
||||||
return view('livewire.admin.instances', [
|
|
||||||
'instances' => $instances,
|
|
||||||
// Whether to draw the action column at all. An operator who may not
|
|
||||||
// restart anything should not be reading a column of buttons that
|
|
||||||
// answer them 403.
|
|
||||||
'canRestart' => Gate::allows('instances.restart'),
|
|
||||||
'rows' => $instances->getCollection()->map(fn (Instance $i) => [
|
|
||||||
// R11: a row's action is addressed by uuid, never the numeric id.
|
|
||||||
'uuid' => $i->uuid,
|
|
||||||
// Only a machine remote work can actually reach. A reservation
|
|
||||||
// with no VM, a failed build and an ended service all have
|
|
||||||
// nothing to restart — see Instance::hasLiveMachine().
|
|
||||||
'live' => $i->hasLiveMachine(),
|
|
||||||
// The operator list shows what is actually served — an unverified
|
|
||||||
// custom domain is a plan, not an address.
|
|
||||||
'address' => $i->domainIsVerified() ? $i->custom_domain : $i->subdomain,
|
|
||||||
'customer' => $i->customer?->name ?? '—',
|
|
||||||
'host' => $i->host?->name ?? '—',
|
|
||||||
'vmid' => $i->vmid ?? '—',
|
|
||||||
'plan' => $i->plan !== null ? __('billing.plan.'.$i->plan) : '—',
|
|
||||||
// The whole allowance, packs included — the same figure the
|
|
||||||
// customer's own page states and the same one Nextcloud is
|
|
||||||
// told. An operator reading the package alone here would be
|
|
||||||
// looking at a smaller number than the machine enforces and
|
|
||||||
// would have no way of telling.
|
|
||||||
'quota' => ($owed = StorageAllowance::for($i)->totalGb()) > 0 ? $owed.' GB' : '—',
|
|
||||||
// A machine running on less CPU or RAM than it has been sold —
|
|
||||||
// see Instance::restartIsPending(). Shown beside the status
|
|
||||||
// rather than as one, because the instance is genuinely active
|
|
||||||
// and the operator needs both facts at once.
|
|
||||||
'restart' => $i->restartIsPending(),
|
|
||||||
'status' => $status = $i->status ?? 'provisioning',
|
|
||||||
// A status the lifecycle adds later must show as itself, never
|
|
||||||
// as "admin.status.whatever" in front of the owner.
|
|
||||||
'status_label' => Lang::has('admin.status.'.$status)
|
|
||||||
? __('admin.status.'.$status)
|
|
||||||
: $status,
|
|
||||||
])->all(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,444 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Livewire\Admin;
|
|
||||||
|
|
||||||
use App\Livewire\Concerns\ConfirmsPassword;
|
|
||||||
use App\Services\Deployment\UpdateChannel;
|
|
||||||
use App\Services\Env\EnvFileEditor;
|
|
||||||
use App\Services\Env\InvalidEnvContentException;
|
|
||||||
use App\Services\Secrets\SecretVault;
|
|
||||||
use App\Support\ProvisioningSettings;
|
|
||||||
use App\Support\Settings;
|
|
||||||
use Illuminate\Support\Facades\Artisan;
|
|
||||||
use Illuminate\Support\Facades\Auth;
|
|
||||||
use Illuminate\Support\Facades\Gate;
|
|
||||||
use Livewire\Attributes\Layout;
|
|
||||||
use Livewire\Attributes\On;
|
|
||||||
use Livewire\Attributes\Url;
|
|
||||||
use Livewire\Component;
|
|
||||||
use Throwable;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Connected services, one page, grouped by what they are for — Zahlungen,
|
|
||||||
* DNS, Monitoring, VPN/WireGuard, SSH-Identität — not by which of the two
|
|
||||||
* mechanisms below happens to hold a given value.
|
|
||||||
*
|
|
||||||
* Replaces Admin\Secrets and Admin\Infrastructure, which is what they were:
|
|
||||||
* one console area split across two pages by storage mechanism, reported as
|
|
||||||
* exactly the wrong axis to split on. An operator setting up an integration
|
|
||||||
* should not have to know whether a given field happens to be encrypted.
|
|
||||||
*
|
|
||||||
* Both mechanisms are unchanged underneath — this is a presentation change,
|
|
||||||
* not a data migration:
|
|
||||||
*
|
|
||||||
* - SecretVault: a curated, encrypted key/value store for credentials that
|
|
||||||
* actually stop the business when they leak or expire. Gated by
|
|
||||||
* `secrets.manage` (Owner only) AND a recently confirmed password — the
|
|
||||||
* realistic threat is an unlocked machine, not a stranger. A stored value
|
|
||||||
* is write-only: the page only ever shows an outline of it.
|
|
||||||
* - App\Support\Settings: plain deployment configuration. Gated by
|
|
||||||
* `hosts.manage` (Owner + Admin) alone — none of it opens anything by
|
|
||||||
* itself. Shows its value in full, because there is nothing to protect.
|
|
||||||
*
|
|
||||||
* mount() is reachable with EITHER capability, because the two halves are no
|
|
||||||
* longer two pages an operator's role happens to see one, both, or neither
|
|
||||||
* of — they are sections of the SAME page now. Each section, and each save
|
|
||||||
* action, still checks its OWN capability (guardSecrets()/guardInfra())
|
|
||||||
* server-side; an Admin who can reach this page for the DNS zone still gets
|
|
||||||
* 403 the moment they try to touch a vault entry.
|
|
||||||
*
|
|
||||||
* The .env editor (Part B) shares the secrets lock: it needs `secrets.manage`
|
|
||||||
* and the same confirmed password, because it is the one place on this page
|
|
||||||
* that can reach every credential the vault deliberately keeps write-only —
|
|
||||||
* see EnvFileEditor for the safeguards that make that survivable.
|
|
||||||
*
|
|
||||||
* TABBED, like Admin\Settings and for the same reason: this was one narrow
|
|
||||||
* column of six cards, and an operator connecting DNS scrolled past Stripe to
|
|
||||||
* get to it. The split is by what a section is FOR — outside services we buy
|
|
||||||
* from, our own machines, and the raw file underneath — which is the same axis
|
|
||||||
* the page was rebuilt around when it stopped being split by storage mechanism.
|
|
||||||
*
|
|
||||||
* The open tab is in the query string, so a reload or a bookmark lands where
|
|
||||||
* the operator was. The .env tab in particular is worth linking to directly,
|
|
||||||
* and the unlock survives the switch because the confirmation is a session
|
|
||||||
* fact, not a property of a tab.
|
|
||||||
*/
|
|
||||||
#[Layout('layouts.admin')]
|
|
||||||
class Integrations extends Component
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* The tabs, in order. The list IS the schema: it validates the query
|
|
||||||
* string, builds the bar and decides what renders.
|
|
||||||
*
|
|
||||||
* `services` is what we buy from outside (Stripe, Hetzner DNS, the
|
|
||||||
* monitoring bridge), `platform` is how we reach our own machines (the
|
|
||||||
* WireGuard hub, the SSH identity), `env` is the file underneath all of it.
|
|
||||||
*/
|
|
||||||
public const TABS = ['services', 'platform', 'env'];
|
|
||||||
|
|
||||||
#[Url(except: 'services')]
|
|
||||||
public string $tab = 'services';
|
|
||||||
|
|
||||||
use ConfirmsPassword {
|
|
||||||
forgetPasswordConfirmation as private lockAgain;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Vault entries — same shape ConfirmSaveSecret/ConfirmForgetSecret expect
|
|
||||||
// and dispatch back into (secret-save-confirmed / secret-forget-confirmed).
|
|
||||||
public array $entered = [];
|
|
||||||
|
|
||||||
public ?array $check = null;
|
|
||||||
|
|
||||||
// Plain settings — App\Support\ProvisioningSettings' full list.
|
|
||||||
public string $dnsZone = '';
|
|
||||||
|
|
||||||
public string $wgEndpoint = '';
|
|
||||||
|
|
||||||
public string $wgHubPubkey = '';
|
|
||||||
|
|
||||||
public string $traefikDynamicPath = '';
|
|
||||||
|
|
||||||
public string $sshPublicKey = '';
|
|
||||||
|
|
||||||
public string $monitoringUrl = '';
|
|
||||||
|
|
||||||
// .env editor.
|
|
||||||
public string $envContent = '';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Loaded lazily, on the first render after unlocking — never in mount(),
|
|
||||||
* so a merely-reachable page never pulls the whole credential file into
|
|
||||||
* component state for an operator who has not confirmed anything yet.
|
|
||||||
*/
|
|
||||||
public bool $envLoaded = false;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* True from the moment saveEnv() successfully asks the agent to restart
|
|
||||||
* the workers, until render() observes the request is no longer pending.
|
|
||||||
*
|
|
||||||
* Drives the small inline "wird neu gestartet" indicator on this page —
|
|
||||||
* not the full-screen maintenance overlay in layouts/admin.blade.php,
|
|
||||||
* which is for an actual deployment. A worker restart is four containers
|
|
||||||
* coming back in a few seconds, this page (and the console around it)
|
|
||||||
* never goes offline for it, and `app` — the container answering this
|
|
||||||
* very poll — is never among them. wire:poll on this page's own
|
|
||||||
* component is therefore enough to watch it finish; nothing here needs
|
|
||||||
* the client-side watcher an in-flight deployment does.
|
|
||||||
*/
|
|
||||||
public bool $envRestartWatching = false;
|
|
||||||
|
|
||||||
protected function confirmationGuard(): string
|
|
||||||
{
|
|
||||||
return 'operator';
|
|
||||||
}
|
|
||||||
|
|
||||||
public function mount(): void
|
|
||||||
{
|
|
||||||
abort_unless(Gate::any(['hosts.manage', 'secrets.manage']), 403);
|
|
||||||
|
|
||||||
// A tab name out of the URL is a string a stranger typed.
|
|
||||||
if (! in_array($this->tab, self::TABS, true)) {
|
|
||||||
$this->tab = self::TABS[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
// The raw file is Owner-only. Landing on the first tab says that
|
|
||||||
// without a blank page; the editor itself checks again anyway.
|
|
||||||
if ($this->tab === 'env' && ! Gate::allows('secrets.manage')) {
|
|
||||||
$this->tab = self::TABS[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Gate::allows('hosts.manage')) {
|
|
||||||
$this->dnsZone = ProvisioningSettings::dnsZone();
|
|
||||||
$this->wgEndpoint = ProvisioningSettings::wgEndpoint();
|
|
||||||
$this->wgHubPubkey = ProvisioningSettings::wgHubPublicKey();
|
|
||||||
$this->traefikDynamicPath = ProvisioningSettings::traefikDynamicPath();
|
|
||||||
$this->sshPublicKey = ProvisioningSettings::sshPublicKey();
|
|
||||||
$this->monitoringUrl = ProvisioningSettings::monitoringUrl();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Plain settings — App\Support\Settings, hosts.manage, no password. ----
|
|
||||||
|
|
||||||
public function saveInfra(): void
|
|
||||||
{
|
|
||||||
$this->guardInfra();
|
|
||||||
|
|
||||||
$data = $this->validate([
|
|
||||||
'dnsZone' => ['nullable', 'string', 'max:255'],
|
|
||||||
'wgEndpoint' => ['nullable', 'string', 'max:255'],
|
|
||||||
'wgHubPubkey' => ['nullable', 'string', 'max:255'],
|
|
||||||
'traefikDynamicPath' => ['nullable', 'string', 'max:255'],
|
|
||||||
'sshPublicKey' => ['nullable', 'string', 'max:1000'],
|
|
||||||
'monitoringUrl' => ['nullable', 'url', 'max:255'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
Settings::set('provisioning.dns_zone', trim((string) $data['dnsZone']));
|
|
||||||
Settings::set('provisioning.wg_endpoint', trim((string) $data['wgEndpoint']));
|
|
||||||
Settings::set('provisioning.wg_hub_pubkey', trim((string) $data['wgHubPubkey']));
|
|
||||||
Settings::set('provisioning.traefik_dynamic_path', trim((string) $data['traefikDynamicPath']));
|
|
||||||
Settings::set('provisioning.ssh_public_key', trim((string) $data['sshPublicKey']));
|
|
||||||
Settings::set('monitoring.api_url', trim((string) $data['monitoringUrl']));
|
|
||||||
|
|
||||||
$this->dispatch('notify', message: __('integrations.settings_saved'));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Vault entries — SecretVault, secrets.manage + confirmed password. ----
|
|
||||||
|
|
||||||
public function save(string $key): void
|
|
||||||
{
|
|
||||||
$this->guardSecrets();
|
|
||||||
|
|
||||||
$field = self::field($key);
|
|
||||||
$value = trim((string) ($this->entered[$field] ?? ''));
|
|
||||||
|
|
||||||
if ($value === '') {
|
|
||||||
$this->addError('entered.'.$field, __('secrets.empty'));
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
app(SecretVault::class)->put($key, $value, Auth::guard('operator')->user());
|
|
||||||
} catch (Throwable $e) {
|
|
||||||
$this->addError('entered.'.$field, $e->getMessage());
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->entered[$field] = '';
|
|
||||||
$this->check = null;
|
|
||||||
$this->dispatch('notify', message: __('secrets.saved'));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** ConfirmSaveSecret dispatches this back (R23) — see that class. */
|
|
||||||
#[On('secret-save-confirmed')]
|
|
||||||
public function onSaveConfirmed(string $key): void
|
|
||||||
{
|
|
||||||
$this->save($key);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function forget(string $key): void
|
|
||||||
{
|
|
||||||
$this->guardSecrets();
|
|
||||||
|
|
||||||
app(SecretVault::class)->forget($key);
|
|
||||||
$this->check = null;
|
|
||||||
$this->dispatch('notify', message: __('secrets.removed'));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** ConfirmForgetSecret dispatches this back (R23) — see that class. */
|
|
||||||
#[On('secret-forget-confirmed')]
|
|
||||||
public function onForgetConfirmed(string $key): void
|
|
||||||
{
|
|
||||||
$this->forget($key);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test(string $key): void
|
|
||||||
{
|
|
||||||
$this->guardSecrets();
|
|
||||||
|
|
||||||
$checker = SecretVault::REGISTRY[$key]['check'] ?? null;
|
|
||||||
abort_if($checker === null, 404);
|
|
||||||
|
|
||||||
$candidate = trim((string) ($this->entered[self::field($key)] ?? '')) ?: null;
|
|
||||||
$this->check = app($checker)->run($candidate);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The dotless form key for a registry key (a dot means nesting to Livewire). */
|
|
||||||
public static function field(string $key): string
|
|
||||||
{
|
|
||||||
return str_replace('.', '_', $key);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- The .env editor — Part B. secrets.manage + confirmed password. ----
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate, back up, write — then do the two things saving this file used
|
|
||||||
* to leave as homework: clear the config cache (this container can do
|
|
||||||
* that itself, no host privileges needed) and ask the host-side agent to
|
|
||||||
* restart queue, queue-provisioning, scheduler and reverb (UpdateChannel
|
|
||||||
* — it cannot be done from in here; see that class for why).
|
|
||||||
*
|
|
||||||
* EnvFileEditor does the write itself and the actual refusing; this only
|
|
||||||
* translates its one exception into a form error instead of a 500.
|
|
||||||
*/
|
|
||||||
public function saveEnv(): void
|
|
||||||
{
|
|
||||||
$this->guardSecrets();
|
|
||||||
|
|
||||||
try {
|
|
||||||
$backup = app(EnvFileEditor::class)->write($this->envContent);
|
|
||||||
} catch (InvalidEnvContentException $e) {
|
|
||||||
$this->addError('envContent', $e->invalidLine !== null
|
|
||||||
? __('integrations.env_invalid', ['line' => $e->invalidLine])
|
|
||||||
: __('integrations.env_empty'));
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Unconditional, and deliberately not gated on
|
|
||||||
// app()->configurationIsCached() first: Illuminate's own ConfigClear
|
|
||||||
// command is already exactly that guard — it deletes the cache file
|
|
||||||
// if one exists and does nothing if there is none, so adding a check
|
|
||||||
// here would only save a single is_file() call in the common case at
|
|
||||||
// the cost of a second source of truth to keep in sync with it. This
|
|
||||||
// dev machine has no cache right now (checked: no
|
|
||||||
// bootstrap/cache/config.php), but deploy/update.sh and
|
|
||||||
// deploy/install-agent.sh both run `config:cache` as standard
|
|
||||||
// practice, so a real installation usually does — and while one is
|
|
||||||
// active, Laravel skips loading .env on every request AT ALL
|
|
||||||
// (LoadEnvironmentVariables bootstraps straight from the cached
|
|
||||||
// values instead). Left uncleared there, the value just written would
|
|
||||||
// stay invisible — not merely to the four workers below, to this
|
|
||||||
// application too — until the cache was rebuilt by hand.
|
|
||||||
Artisan::call('config:clear');
|
|
||||||
|
|
||||||
$channel = app(UpdateChannel::class);
|
|
||||||
$agentAlive = $channel->state()['agent_seen'];
|
|
||||||
$by = Auth::guard('operator')->user()?->email ?? 'console';
|
|
||||||
|
|
||||||
// '' means there was no previous file to protect — EnvFileEditor's
|
|
||||||
// own docblock explains why that is not an error.
|
|
||||||
$backupName = $backup !== '' ? basename($backup) : __('integrations.env_no_previous_file');
|
|
||||||
|
|
||||||
if (! $agentAlive) {
|
|
||||||
// Honest, not silent: the values ARE saved, but nothing is going
|
|
||||||
// to pick them up on its own. Pretending otherwise here is worse
|
|
||||||
// than the manual-restart card this replaces — see
|
|
||||||
// admin_settings.update_no_agent for the same shape on updates.
|
|
||||||
$this->envRestartWatching = false;
|
|
||||||
$this->dispatch('notify', message: __('integrations.env_saved_no_agent', ['backup' => $backupName]));
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $channel->requestRestart($by)) {
|
|
||||||
// The single request slot is already taken by something else
|
|
||||||
// (a check, an update, or another restart just asked for) — rare,
|
|
||||||
// but real, and not something to paper over with a message that
|
|
||||||
// says "restarting" when nothing was actually queued.
|
|
||||||
$this->envRestartWatching = false;
|
|
||||||
$this->dispatch('notify', message: __('integrations.env_saved_restart_busy', ['backup' => $backupName]));
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->envRestartWatching = true;
|
|
||||||
$this->dispatch('notify', message: __('integrations.env_saved_restarting', ['backup' => $backupName]));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** ConfirmSaveEnv dispatches this back (R23) — see that class. */
|
|
||||||
#[On('env-save-confirmed')]
|
|
||||||
public function onEnvSaveConfirmed(): void
|
|
||||||
{
|
|
||||||
$this->saveEnv();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Overrides ConfirmsPassword's own method (aliased above to lockAgain) to
|
|
||||||
* also drop the Stripe check result — the render()-side check just below
|
|
||||||
* handles $envContent/$envLoaded, and handles it for BOTH ways a session
|
|
||||||
* can end up locked: this explicit click, and the confirmation window
|
|
||||||
* simply expiring with nobody clicking anything. A clear here alone would
|
|
||||||
* only ever cover the first.
|
|
||||||
*/
|
|
||||||
public function forgetPasswordConfirmation(): void
|
|
||||||
{
|
|
||||||
$this->lockAgain();
|
|
||||||
|
|
||||||
$this->check = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function guardInfra(): void
|
|
||||||
{
|
|
||||||
$this->authorize('hosts.manage');
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Capability AND a recently confirmed password, on every vault/.env action. */
|
|
||||||
private function guardSecrets(): void
|
|
||||||
{
|
|
||||||
$this->authorize('secrets.manage');
|
|
||||||
abort_unless($this->passwordRecentlyConfirmed(), 403);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function render()
|
|
||||||
{
|
|
||||||
$vault = app(SecretVault::class);
|
|
||||||
$canSecrets = Gate::allows('secrets.manage');
|
|
||||||
$canInfra = Gate::allows('hosts.manage');
|
|
||||||
$unlocked = $this->passwordRecentlyConfirmed();
|
|
||||||
|
|
||||||
// Loaded here rather than in mount(): a page that is merely reachable
|
|
||||||
// must not already hold the whole credential file in component state
|
|
||||||
// for an operator who has not confirmed a password this session.
|
|
||||||
if ($canSecrets && $unlocked && ! $this->envLoaded) {
|
|
||||||
$this->envContent = app(EnvFileEditor::class)->read();
|
|
||||||
$this->envLoaded = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// The mirror case, and the one forgetPasswordConfirmation() alone
|
|
||||||
// does not cover: the confirmation window can also expire on its
|
|
||||||
// own, with nobody clicking "Wieder sperren" — $unlocked simply goes
|
|
||||||
// false on whatever the next render happens to be. Without this, the
|
|
||||||
// textarea disappears from the page but $envContent — the ENTIRE
|
|
||||||
// credential file — stays sitting in the component snapshot, reachable
|
|
||||||
// to anyone with the browser this was left open on (Codex review,
|
|
||||||
// P1). Checked on every render, not only the explicit lock action.
|
|
||||||
if (! $unlocked && $this->envLoaded) {
|
|
||||||
$this->envContent = '';
|
|
||||||
$this->envLoaded = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$restart = app(UpdateChannel::class)->state();
|
|
||||||
|
|
||||||
// The transition the small inline indicator exists to show: a restart
|
|
||||||
// THIS component asked for is no longer pending. Read off
|
|
||||||
// 'requested_at' rather than 'restarting': the latter is gated on
|
|
||||||
// agent_seen (see UpdateChannel::state()), so if the agent went
|
|
||||||
// quiet in the few seconds this was being waited on, 'restarting'
|
|
||||||
// would already read false EVEN THOUGH THE REQUEST FILE IS STILL
|
|
||||||
// SITTING THERE, unread — and this would announce "restarted"
|
|
||||||
// for a restart that never ran. 'requested_at' answers the only
|
|
||||||
// question that matters here — is a request still on disk — with no
|
|
||||||
// opinion about the agent either way. Fires once — the instant it
|
|
||||||
// does, envRestartWatching drops so the next poll (or the one after,
|
|
||||||
// on a slower host with no on-demand wake) does not dispatch it
|
|
||||||
// again. wire:poll on the .env section is what keeps calling
|
|
||||||
// render() while this is being waited on; see $envRestartWatching's
|
|
||||||
// own docblock for why that is enough and the full-page deployment
|
|
||||||
// overlay is not needed here.
|
|
||||||
if ($this->envRestartWatching && $restart['requested_at'] === null) {
|
|
||||||
$this->envRestartWatching = false;
|
|
||||||
$this->dispatch('notify', message: __('integrations.env_restart_done'));
|
|
||||||
}
|
|
||||||
|
|
||||||
return view('livewire.admin.integrations', [
|
|
||||||
// Only the tabs this operator can open. A tab that renders nothing
|
|
||||||
// is worse than one that is not there.
|
|
||||||
'tabs' => array_values(array_filter(
|
|
||||||
self::TABS,
|
|
||||||
fn (string $tab) => $tab !== 'env' || $canSecrets,
|
|
||||||
)),
|
|
||||||
'canSecrets' => $canSecrets,
|
|
||||||
'canInfra' => $canInfra,
|
|
||||||
'unlocked' => $unlocked,
|
|
||||||
'usable' => $vault->isUsable(),
|
|
||||||
'restart' => $restart,
|
|
||||||
'entries' => collect(SecretVault::REGISTRY)
|
|
||||||
->map(fn (array $meta, string $key) => [
|
|
||||||
'key' => $key,
|
|
||||||
'field' => self::field($key),
|
|
||||||
'label' => __($meta['label']),
|
|
||||||
'envKey' => $meta['env_key'],
|
|
||||||
'testable' => isset($meta['check']),
|
|
||||||
'source' => $vault->source($key),
|
|
||||||
'outline' => $unlocked ? $vault->outline($key) : null,
|
|
||||||
'updated_at' => $unlocked ? $vault->updatedAt($key) : null,
|
|
||||||
// The SSH identity is a multi-line PEM key: a single-line
|
|
||||||
// password field would mangle it on paste.
|
|
||||||
'multiline' => $key === 'ssh.private_key',
|
|
||||||
])
|
|
||||||
->keyBy('key'),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,80 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Livewire\Admin;
|
|
||||||
|
|
||||||
use App\Models\Invoice;
|
|
||||||
use Livewire\Attributes\Layout;
|
|
||||||
use Livewire\Attributes\Url;
|
|
||||||
use Livewire\Component;
|
|
||||||
use Livewire\WithPagination;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Every document that has been issued, newest first.
|
|
||||||
*
|
|
||||||
* Read-only by design, and that is the feature. An issued invoice cannot be
|
|
||||||
* edited or deleted here or anywhere else — a wrong one is corrected by issuing
|
|
||||||
* a cancellation and a new document, which is the only lawful way to correct
|
|
||||||
* one. There is no edit button to explain the absence of.
|
|
||||||
*/
|
|
||||||
#[Layout('layouts.admin')]
|
|
||||||
class Invoices extends Component
|
|
||||||
{
|
|
||||||
use WithPagination;
|
|
||||||
|
|
||||||
#[Url]
|
|
||||||
public string $search = '';
|
|
||||||
|
|
||||||
#[Url]
|
|
||||||
public string $year = '';
|
|
||||||
|
|
||||||
public function mount(): void
|
|
||||||
{
|
|
||||||
$this->authorize('site.manage');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function updatedSearch(): void
|
|
||||||
{
|
|
||||||
$this->resetPage();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function updatedYear(): void
|
|
||||||
{
|
|
||||||
$this->resetPage();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function render()
|
|
||||||
{
|
|
||||||
$invoices = Invoice::query()
|
|
||||||
->with(['customer', 'series'])
|
|
||||||
->when($this->search !== '', function ($q) {
|
|
||||||
$term = '%'.$this->search.'%';
|
|
||||||
$q->where(fn ($w) => $w->where('number', 'like', $term)
|
|
||||||
->orWhereHas('customer', fn ($c) => $c->where('name', 'like', $term)));
|
|
||||||
})
|
|
||||||
->when($this->year !== '', fn ($q) => $q->whereYear('issued_on', (int) $this->year))
|
|
||||||
->orderByDesc('issued_on')
|
|
||||||
->orderByDesc('id')
|
|
||||||
->paginate(25);
|
|
||||||
|
|
||||||
return view('livewire.admin.invoices', [
|
|
||||||
'invoices' => $invoices,
|
|
||||||
// Straight from the rows rather than a fixed range: a list of years
|
|
||||||
// with nothing in them is a filter that mostly returns nothing.
|
|
||||||
//
|
|
||||||
// Derived in PHP rather than with YEAR() in raw SQL. That function
|
|
||||||
// is MySQL's; SQLite has no such thing, and a query that only runs
|
|
||||||
// on one engine turns the test database into a different product
|
|
||||||
// from the real one.
|
|
||||||
'years' => Invoice::query()
|
|
||||||
->orderByDesc('issued_on')
|
|
||||||
->pluck('issued_on')
|
|
||||||
// ->local() even for a year (R19): on New Year's night the UTC
|
|
||||||
// year is still the previous one, so the filter would offer a
|
|
||||||
// year the operator is no longer in.
|
|
||||||
->map(fn ($date) => $date?->local()->format('Y'))
|
|
||||||
->filter()
|
|
||||||
->unique()
|
|
||||||
->values(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,201 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Livewire\Admin;
|
|
||||||
|
|
||||||
use App\Livewire\Concerns\ConfirmsPassword;
|
|
||||||
use App\Models\Mailbox;
|
|
||||||
use App\Services\Mail\MailboxTester;
|
|
||||||
use App\Services\Mail\MailPurpose;
|
|
||||||
use App\Services\Secrets\SecretCipher;
|
|
||||||
use App\Support\Settings;
|
|
||||||
use Livewire\Attributes\Layout;
|
|
||||||
use Livewire\Component;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The sending addresses, and which kind of mail leaves from which.
|
|
||||||
*
|
|
||||||
* The server sits at the top because there is one of it; the mailboxes are a
|
|
||||||
* list because there are several; the mapping is last because it only makes
|
|
||||||
* sense once both exist. The test-send button lives with the mailboxes: it
|
|
||||||
* proves one specific mailbox can actually send, which is the only thing
|
|
||||||
* that makes the rest of this page more than a form.
|
|
||||||
*/
|
|
||||||
#[Layout('layouts.admin')]
|
|
||||||
class Mail extends Component
|
|
||||||
{
|
|
||||||
use ConfirmsPassword;
|
|
||||||
|
|
||||||
public string $host = '';
|
|
||||||
|
|
||||||
public int|string $port = 587;
|
|
||||||
|
|
||||||
public string $encryption = 'tls';
|
|
||||||
|
|
||||||
/** @var array<string, string> purpose => mailbox key */
|
|
||||||
public array $purposes = [];
|
|
||||||
|
|
||||||
public string $testRecipient = '';
|
|
||||||
|
|
||||||
/** @var array{ok: bool, error: ?string}|null */
|
|
||||||
public ?array $testResult = null;
|
|
||||||
|
|
||||||
public ?string $testedKey = null;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether credentials can be stored at all on this installation.
|
|
||||||
*
|
|
||||||
* Public so the page can say it rather than throwing on the first password
|
|
||||||
* a mailbox tries to decrypt — the same courtesy the secrets page already
|
|
||||||
* extends. A missing SECRETS_KEY is a setup state, not an error.
|
|
||||||
*/
|
|
||||||
public bool $usable = true;
|
|
||||||
|
|
||||||
protected function confirmationGuard(): string
|
|
||||||
{
|
|
||||||
return 'operator';
|
|
||||||
}
|
|
||||||
|
|
||||||
public function mount(): void
|
|
||||||
{
|
|
||||||
$this->authorize('mail.manage');
|
|
||||||
|
|
||||||
$this->host = (string) Settings::get('mail.host', '');
|
|
||||||
$this->port = (int) Settings::get('mail.port', 587);
|
|
||||||
$this->encryption = (string) Settings::get('mail.encryption', 'tls');
|
|
||||||
|
|
||||||
foreach (MailPurpose::ALL as $purpose) {
|
|
||||||
$this->purposes[$purpose] = (string) Settings::get(MailPurpose::settingKey($purpose), '');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public function saveServer(): void
|
|
||||||
{
|
|
||||||
$this->authorize('mail.manage');
|
|
||||||
|
|
||||||
// $this->host is the platform's outbound relay for every purpose
|
|
||||||
// mailbox at once — pointing it at an attacker's server intercepts
|
|
||||||
// everything CluPilot sends. The capability decides who may open this
|
|
||||||
// page; a recent password decides whether THIS session may repoint
|
|
||||||
// it, the same second gate Admin\Integrations' vault entries use and
|
|
||||||
// for the same reason: the realistic threat is an unlocked machine, not a
|
|
||||||
// stranger. savePurposes() and test() stay on the capability alone —
|
|
||||||
// this is the "changing an address" split's one exception.
|
|
||||||
abort_unless($this->passwordRecentlyConfirmed(), 403);
|
|
||||||
|
|
||||||
// Rules on the ACTION, not on the property: a #[Validate] attribute on
|
|
||||||
// a Livewire property applies class-wide, and savePurposes() below
|
|
||||||
// would drag these along.
|
|
||||||
$this->validate([
|
|
||||||
'host' => ['required', 'string', 'max:255'],
|
|
||||||
'port' => ['required', 'integer', 'min:1', 'max:65535'],
|
|
||||||
'encryption' => ['required', 'in:tls,ssl,none'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Codex R15#6, P2: last_verified_at on EVERY mailbox proves a test
|
|
||||||
// against THIS server config specifically — the shared-server mirror
|
|
||||||
// of EditMailbox::save()'s guard on a single mailbox's own address/
|
|
||||||
// username/authenticates. Compared against the STORED values, not
|
|
||||||
// just "did the operator touch the field": re-opening the card and
|
|
||||||
// saving without editing anything must not wipe a legitimate
|
|
||||||
// verification. Read before Settings::set() overwrites them below,
|
|
||||||
// and deliberately not merged with EditMailbox's own comparison — that
|
|
||||||
// one diffs a single loaded model's in-memory attributes, this one
|
|
||||||
// diffs persisted Settings against incoming scalars for a config that
|
|
||||||
// has no single row to load at all; forcing one comparison function
|
|
||||||
// over both shapes would add an abstraction with nothing genuinely
|
|
||||||
// shared to justify it. What IS shared is the actual clear itself:
|
|
||||||
// both this method and EditMailbox::save() delegate to a Mailbox::
|
|
||||||
// invalidate*() method rather than touching the column directly.
|
|
||||||
$serverChanged = $this->host !== (string) Settings::get('mail.host', '')
|
|
||||||
|| (int) $this->port !== (int) Settings::get('mail.port', 587)
|
|
||||||
|| $this->encryption !== (string) Settings::get('mail.encryption', 'tls');
|
|
||||||
|
|
||||||
Settings::set('mail.host', $this->host);
|
|
||||||
Settings::set('mail.port', (int) $this->port);
|
|
||||||
Settings::set('mail.encryption', $this->encryption);
|
|
||||||
|
|
||||||
if ($serverChanged) {
|
|
||||||
Mailbox::invalidateAllVerifications();
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->dispatch('notify', message: __('mail_settings.server_saved'));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function savePurposes(): void
|
|
||||||
{
|
|
||||||
$this->authorize('mail.manage');
|
|
||||||
|
|
||||||
// Reachable by posting straight to /livewire/update, past whatever
|
|
||||||
// the <select> in the view would ever submit — so "is a nonempty
|
|
||||||
// string" is not enough. Every purpose's key must name a mailbox
|
|
||||||
// that actually exists; system's additionally must be ACTIVE, because
|
|
||||||
// MailboxResolver::for() falls every unmapped (or inactive) OTHER
|
|
||||||
// purpose through to system, and there is nothing left to fall back
|
|
||||||
// to when system itself is the broken one.
|
|
||||||
$activeKeys = Mailbox::query()->where('active', true)->pluck('key')->all();
|
|
||||||
$allKeys = Mailbox::query()->pluck('key')->all();
|
|
||||||
|
|
||||||
$rules = [
|
|
||||||
// system is the fallback, so it is the one that may not be empty
|
|
||||||
// — there is nothing left to fall back to.
|
|
||||||
'purposes.system' => ['required', 'string', function ($attribute, $value, $fail) use ($activeKeys) {
|
|
||||||
if ($value !== '' && ! in_array($value, $activeKeys, true)) {
|
|
||||||
$fail(__('mail_settings.system_inactive'));
|
|
||||||
}
|
|
||||||
}],
|
|
||||||
];
|
|
||||||
|
|
||||||
foreach (MailPurpose::ALL as $purpose) {
|
|
||||||
if ($purpose === MailPurpose::SYSTEM) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Blank is a legitimate choice for every OTHER purpose —
|
|
||||||
// MailboxResolver already falls an unmapped (or inactive)
|
|
||||||
// purpose through to system on its own, so only a key that names
|
|
||||||
// NO mailbox at all (a deleted row, or a tampered payload) is
|
|
||||||
// refused here.
|
|
||||||
$rules["purposes.{$purpose}"] = ['nullable', 'string', function ($attribute, $value, $fail) use ($allKeys) {
|
|
||||||
if ($value !== '' && ! in_array($value, $allKeys, true)) {
|
|
||||||
$fail(__('mail_settings.purpose_unknown_mailbox'));
|
|
||||||
}
|
|
||||||
}];
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->validate($rules, [
|
|
||||||
'purposes.system.required' => __('mail_settings.system_required'),
|
|
||||||
]);
|
|
||||||
|
|
||||||
foreach (MailPurpose::ALL as $purpose) {
|
|
||||||
Settings::set(MailPurpose::settingKey($purpose), $this->purposes[$purpose] ?? '');
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->dispatch('notify', message: __('mail_settings.purposes_saved'));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test(string $uuid): void
|
|
||||||
{
|
|
||||||
$this->authorize('mail.manage');
|
|
||||||
|
|
||||||
$this->validate(
|
|
||||||
['testRecipient' => ['required', 'email']],
|
|
||||||
['testRecipient.required' => __('mail_settings.test_recipient_required')],
|
|
||||||
);
|
|
||||||
|
|
||||||
$box = Mailbox::query()->where('uuid', $uuid)->firstOrFail();
|
|
||||||
|
|
||||||
$this->testedKey = $box->key;
|
|
||||||
$this->testResult = app(MailboxTester::class)->run($box, $this->testRecipient);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function render()
|
|
||||||
{
|
|
||||||
$this->usable = app(SecretCipher::class)->isUsable();
|
|
||||||
|
|
||||||
return view('livewire.admin.mail', [
|
|
||||||
'mailboxes' => Mailbox::query()->orderBy('key')->get(),
|
|
||||||
'purposeList' => MailPurpose::ALL,
|
|
||||||
'passwordConfirmed' => $this->passwordRecentlyConfirmed(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,56 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Livewire\Admin;
|
|
||||||
|
|
||||||
use App\Models\SentMail;
|
|
||||||
use Livewire\Attributes\Layout;
|
|
||||||
use Livewire\Attributes\Url;
|
|
||||||
use Livewire\Component;
|
|
||||||
use Livewire\WithPagination;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Everything this installation has sent, newest first.
|
|
||||||
*
|
|
||||||
* "Hat der Kunde die Zugangsdaten je bekommen, und wann?" used to be answered
|
|
||||||
* on the mail server — a different machine, a different program, and nothing an
|
|
||||||
* operator can put in front of somebody who says nothing ever arrived.
|
|
||||||
*
|
|
||||||
* A row means the transport accepted the message. What the receiving server did
|
|
||||||
* with it afterwards is not ours to know, and a column claiming otherwise would
|
|
||||||
* be worse than none.
|
|
||||||
*/
|
|
||||||
#[Layout('layouts.admin')]
|
|
||||||
class MailLog extends Component
|
|
||||||
{
|
|
||||||
use WithPagination;
|
|
||||||
|
|
||||||
#[Url]
|
|
||||||
public string $search = '';
|
|
||||||
|
|
||||||
public function mount(): void
|
|
||||||
{
|
|
||||||
$this->authorize('customers.manage');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function updatedSearch(): void
|
|
||||||
{
|
|
||||||
$this->resetPage();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function render()
|
|
||||||
{
|
|
||||||
$this->authorize('customers.manage');
|
|
||||||
|
|
||||||
$mails = SentMail::query()
|
|
||||||
->with('customer')
|
|
||||||
->when($this->search !== '', function ($q) {
|
|
||||||
$term = '%'.$this->search.'%';
|
|
||||||
$q->where(fn ($w) => $w->where('to', 'like', $term)->orWhere('subject', 'like', $term));
|
|
||||||
})
|
|
||||||
->latest('sent_at')
|
|
||||||
->latest('id')
|
|
||||||
->paginate(30);
|
|
||||||
|
|
||||||
return view('livewire.admin.mail-log', ['mails' => $mails]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,243 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Livewire\Admin;
|
|
||||||
|
|
||||||
use App\Support\LocalTime;
|
|
||||||
use App\Models\Datacenter;
|
|
||||||
use App\Models\Host;
|
|
||||||
use App\Models\MaintenanceWindow;
|
|
||||||
use App\Services\Maintenance\MaintenanceNotifier;
|
|
||||||
use Illuminate\Support\Carbon;
|
|
||||||
use Illuminate\Support\Facades\Auth;
|
|
||||||
use Illuminate\Support\Facades\DB;
|
|
||||||
use Livewire\Attributes\Layout;
|
|
||||||
use Livewire\Attributes\Validate;
|
|
||||||
use Livewire\Component;
|
|
||||||
|
|
||||||
#[Layout('layouts.admin')]
|
|
||||||
class Maintenance extends Component
|
|
||||||
{
|
|
||||||
#[Validate('required|string|max:255')]
|
|
||||||
public string $title = '';
|
|
||||||
|
|
||||||
#[Validate('nullable|string|max:2000')]
|
|
||||||
public string $publicDescription = '';
|
|
||||||
|
|
||||||
#[Validate('nullable|string|max:2000')]
|
|
||||||
public string $internalNotes = '';
|
|
||||||
|
|
||||||
#[Validate('required|date')]
|
|
||||||
public string $startsAt = '';
|
|
||||||
|
|
||||||
#[Validate('required|date')]
|
|
||||||
public string $endsAt = '';
|
|
||||||
|
|
||||||
/** @var array<int> */
|
|
||||||
public array $hostIds = [];
|
|
||||||
|
|
||||||
/** Toggle every host of one datacenter (select-all / deselect-all). */
|
|
||||||
public function selectDatacenter(string $code): void
|
|
||||||
{
|
|
||||||
$this->authorize('maintenance.manage');
|
|
||||||
$ids = Host::query()->where('datacenter', $code)->pluck('id')->map(fn ($id) => (string) $id)->all();
|
|
||||||
$current = array_map('strval', $this->hostIds);
|
|
||||||
|
|
||||||
$this->hostIds = empty(array_diff($ids, $current))
|
|
||||||
? array_values(array_diff($current, $ids)) // all selected → clear them
|
|
||||||
: array_values(array_unique([...$current, ...$ids]));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set the end from the start. Typing a full timestamp by hand is the
|
|
||||||
* fiddliest part of this form, and the end is almost always "start plus a
|
|
||||||
* round number of minutes".
|
|
||||||
*/
|
|
||||||
public function setDuration(int $minutes): void
|
|
||||||
{
|
|
||||||
$this->authorize('maintenance.manage');
|
|
||||||
|
|
||||||
$start = $this->parsed($this->startsAt);
|
|
||||||
if ($start === null) {
|
|
||||||
// No start yet: assume the next half hour, which is what someone
|
|
||||||
// scheduling a window in a hurry means anyway.
|
|
||||||
$start = now()->addMinutes(30 - (now()->minute % 30))->startOfMinute();
|
|
||||||
$this->startsAt = LocalTime::toField($start);
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->endsAt = LocalTime::toField($start->copy()->addMinutes($minutes));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Minutes between start and end, or null while either is unusable. */
|
|
||||||
public function durationMinutes(): ?int
|
|
||||||
{
|
|
||||||
$start = $this->parsed($this->startsAt);
|
|
||||||
$end = $this->parsed($this->endsAt);
|
|
||||||
|
|
||||||
if ($start === null || $end === null || $end->lessThanOrEqualTo($start)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (int) $start->diffInMinutes($end);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** What the operator typed, as UTC. Half-typed input is normal here. */
|
|
||||||
private function parsed(string $value): ?\Illuminate\Support\Carbon
|
|
||||||
{
|
|
||||||
return LocalTime::fromField($value);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function saveDraft(): void
|
|
||||||
{
|
|
||||||
$this->authorize('maintenance.manage');
|
|
||||||
$this->persist('draft');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function publish(): void
|
|
||||||
{
|
|
||||||
$this->authorize('maintenance.manage');
|
|
||||||
$window = $this->persist('scheduled');
|
|
||||||
if ($window !== null) {
|
|
||||||
app(MaintenanceNotifier::class)->announce($window);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private function persist(string $state): ?MaintenanceWindow
|
|
||||||
{
|
|
||||||
$data = $this->validate([
|
|
||||||
'title' => 'required|string|max:255',
|
|
||||||
'publicDescription' => 'nullable|string|max:2000',
|
|
||||||
'internalNotes' => 'nullable|string|max:2000',
|
|
||||||
'startsAt' => 'required|date',
|
|
||||||
'endsAt' => 'required|date',
|
|
||||||
'hostIds' => 'array',
|
|
||||||
'hostIds.*' => 'integer|exists:hosts,id',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$starts = LocalTime::fromField($data['startsAt']);
|
|
||||||
$ends = LocalTime::fromField($data['endsAt']);
|
|
||||||
|
|
||||||
if ($ends->lessThanOrEqualTo($starts)) {
|
|
||||||
$this->addError('endsAt', __('maintenance.end_after_start'));
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
// A window always needs a host — otherwise a hostless draft can never be
|
|
||||||
// published (there is no edit-hosts action) and is stuck.
|
|
||||||
if (empty($this->hostIds)) {
|
|
||||||
$this->addError('hostIds', __('maintenance.need_host'));
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if ($state === 'scheduled' && $ends->isPast()) {
|
|
||||||
$this->addError('endsAt', __('maintenance.end_future'));
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create the window and attach hosts atomically so a bad id can't leave
|
|
||||||
// an orphaned scheduled window behind.
|
|
||||||
$window = DB::transaction(function () use ($data, $starts, $ends, $state) {
|
|
||||||
$window = MaintenanceWindow::create([
|
|
||||||
'title' => $data['title'],
|
|
||||||
'public_description' => $data['publicDescription'] ?: null,
|
|
||||||
'internal_notes' => $data['internalNotes'] ?: null,
|
|
||||||
'starts_at' => $starts,
|
|
||||||
'ends_at' => $ends,
|
|
||||||
'state' => $state,
|
|
||||||
'created_by' => Auth::guard('operator')->id(),
|
|
||||||
'published_at' => $state === 'scheduled' ? now() : null,
|
|
||||||
]);
|
|
||||||
$window->hosts()->sync(array_map('intval', $data['hostIds'] ?? []));
|
|
||||||
|
|
||||||
return $window;
|
|
||||||
});
|
|
||||||
|
|
||||||
$this->reset('title', 'publicDescription', 'internalNotes', 'startsAt', 'endsAt', 'hostIds');
|
|
||||||
$this->dispatch('notify', message: __($state === 'scheduled' ? 'maintenance.published' : 'maintenance.draft_saved'));
|
|
||||||
|
|
||||||
return $window;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function publishExisting(string $uuid): void
|
|
||||||
{
|
|
||||||
$this->authorize('maintenance.manage');
|
|
||||||
$window = MaintenanceWindow::query()->where('uuid', $uuid)->first();
|
|
||||||
if ($window === null || $window->state !== 'draft') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if ($window->hosts()->count() === 0 || $window->ends_at->isPast()) {
|
|
||||||
$this->dispatch('notify', message: __('maintenance.need_host'));
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
$window->update(['state' => 'scheduled', 'published_at' => now()]);
|
|
||||||
app(MaintenanceNotifier::class)->announce($window);
|
|
||||||
$this->dispatch('notify', message: __('maintenance.published'));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Re-run announcements for a published window. Idempotent (ledger-guarded),
|
|
||||||
* so it only fills gaps — the retry path when a transient queue outage left
|
|
||||||
* some affected customers un-notified.
|
|
||||||
*/
|
|
||||||
public function resend(string $uuid): void
|
|
||||||
{
|
|
||||||
$this->authorize('maintenance.manage');
|
|
||||||
$window = MaintenanceWindow::query()->where('uuid', $uuid)->first();
|
|
||||||
// Only announce for a window that has not yet ended (derived state).
|
|
||||||
if ($window === null || ! in_array($window->derivedState(), ['upcoming', 'active'], true)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
app(MaintenanceNotifier::class)->announce($window);
|
|
||||||
$this->dispatch('notify', message: __('maintenance.notified'));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function cancel(string $uuid): void
|
|
||||||
{
|
|
||||||
$this->authorize('maintenance.manage');
|
|
||||||
$window = MaintenanceWindow::query()->where('uuid', $uuid)->first();
|
|
||||||
// Cannot cancel what is already cancelled or has already completed.
|
|
||||||
if ($window === null || in_array($window->derivedState(), ['cancelled', 'completed'], true)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
$wasPublished = $window->state === 'scheduled';
|
|
||||||
$window->update(['state' => 'cancelled', 'cancelled_at' => now()]);
|
|
||||||
|
|
||||||
// Customers who received an announcement are told it's cancelled. The
|
|
||||||
// MessageSending listener suppresses still-pending announcements, and the
|
|
||||||
// MessageSent listener catches the race (an announcement that delivers
|
|
||||||
// just as we cancel) by queuing a catch-up cancellation for it.
|
|
||||||
if ($wasPublished) {
|
|
||||||
app(MaintenanceNotifier::class)->notifyCancellation($window);
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->dispatch('notify', message: __('maintenance.cancelled'));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function render()
|
|
||||||
{
|
|
||||||
$windows = MaintenanceWindow::query()
|
|
||||||
->withCount('hosts')
|
|
||||||
->orderByDesc('starts_at')
|
|
||||||
->get()
|
|
||||||
->map(fn (MaintenanceWindow $w) => [
|
|
||||||
'uuid' => $w->uuid,
|
|
||||||
'title' => $w->title,
|
|
||||||
'starts_at' => $w->starts_at,
|
|
||||||
'ends_at' => $w->ends_at,
|
|
||||||
'state' => $w->derivedState(),
|
|
||||||
'hosts' => $w->hosts_count,
|
|
||||||
'affected' => $w->affectedCustomers()->count(),
|
|
||||||
'is_draft' => $w->state === 'draft',
|
|
||||||
'notifiable' => $w->state === 'scheduled' && in_array($w->derivedState(), ['upcoming', 'active'], true),
|
|
||||||
'cancellable' => in_array($w->derivedState(), ['draft', 'upcoming', 'active'], true),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return view('livewire.admin.maintenance', [
|
|
||||||
'windows' => $windows,
|
|
||||||
'datacenters' => Datacenter::query()->orderBy('name')->get(),
|
|
||||||
'hosts' => Host::query()->orderBy('datacenter')->orderBy('name')->get(),
|
|
||||||
'durationMinutes' => $this->durationMinutes(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,216 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Livewire\Admin;
|
|
||||||
|
|
||||||
use App\Models\Customer;
|
|
||||||
use App\Models\InvoiceSeries;
|
|
||||||
use App\Models\Subscription;
|
|
||||||
use App\Services\Billing\InvoiceMath;
|
|
||||||
use App\Services\Billing\IssueInvoice;
|
|
||||||
use App\Services\Billing\TaxTreatment;
|
|
||||||
use App\Support\CompanyProfile;
|
|
||||||
use Livewire\Attributes\Layout;
|
|
||||||
use Livewire\Attributes\Url;
|
|
||||||
use Livewire\Component;
|
|
||||||
use Throwable;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* An invoice for work that was done, typed by the person who did it.
|
|
||||||
*
|
|
||||||
* Not everything we are paid for comes off a price list. Somebody asks whether
|
|
||||||
* their data can be moved into Nextcloud; we look at it, we say what it will
|
|
||||||
* cost, we do it. That is a real invoice with no order and no contract behind
|
|
||||||
* it, and until now the only way to produce one was to write it somewhere else
|
|
||||||
* — which puts it outside the series, and a numbered series with a document
|
|
||||||
* missing from it is worth nothing at an audit.
|
|
||||||
*
|
|
||||||
* So it goes through the same door as every other invoice: the same series and
|
|
||||||
* therefore the same consecutive number, the same tax treatment (a business in
|
|
||||||
* another member state gets its reverse charge here too), the same immutable
|
|
||||||
* snapshot, the same archive jobs.
|
|
||||||
*
|
|
||||||
* This page only writes what the operator typed. It invents no price and
|
|
||||||
* applies no rule of its own — what is on the lines is what was agreed on the
|
|
||||||
* phone. Everything it shows before the button is a preview of what the
|
|
||||||
* document will say, computed by InvoiceMath, so nobody has to issue one to
|
|
||||||
* find out what it adds up to.
|
|
||||||
*/
|
|
||||||
#[Layout('layouts.admin')]
|
|
||||||
class NewInvoice extends Component
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Who it is for. The uuid, because that is what a URL and a select carry.
|
|
||||||
*
|
|
||||||
* In the query string so a link can arrive with the recipient already
|
|
||||||
* chosen — from a customer's own page, or from the support request that
|
|
||||||
* asked for the work — and so a reload does not lose them.
|
|
||||||
*/
|
|
||||||
#[Url(as: 'customer')]
|
|
||||||
public string $customerUuid = '';
|
|
||||||
|
|
||||||
/** Which series draws the number. Empty means the active invoice series. */
|
|
||||||
public string $seriesId = '';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The lines, as a person types them.
|
|
||||||
*
|
|
||||||
* Amounts are held as strings in euro rather than integers in cents: this
|
|
||||||
* is a form, and asking somebody to type 12000 for a hundred and twenty
|
|
||||||
* euro is how an invoice ends up a hundred times too large. Converted once,
|
|
||||||
* on the way out.
|
|
||||||
*
|
|
||||||
* @var array<int, array{description: string, detail: string, quantity: string, unit: string, price: string}>
|
|
||||||
*/
|
|
||||||
public array $lines = [];
|
|
||||||
|
|
||||||
public function mount(): void
|
|
||||||
{
|
|
||||||
$this->authorize('site.manage');
|
|
||||||
|
|
||||||
$this->addLine();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function addLine(): void
|
|
||||||
{
|
|
||||||
$this->lines[] = [
|
|
||||||
'description' => '',
|
|
||||||
'detail' => '',
|
|
||||||
'quantity' => '1',
|
|
||||||
'unit' => '',
|
|
||||||
'price' => '',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Remove a line, but never the last one.
|
|
||||||
*
|
|
||||||
* An invoice with no lines is not a draft state worth having — the page
|
|
||||||
* would show a total of nothing and a button that refuses.
|
|
||||||
*/
|
|
||||||
public function removeLine(int $index): void
|
|
||||||
{
|
|
||||||
if (count($this->lines) <= 1) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
unset($this->lines[$index]);
|
|
||||||
$this->lines = array_values($this->lines);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Issue it.
|
|
||||||
*
|
|
||||||
* There is no draft and no edit afterwards, deliberately: an issued invoice
|
|
||||||
* cannot lawfully be changed, and a "save as draft" that draws a number
|
|
||||||
* would be the same thing with a friendlier name. The number is taken at
|
|
||||||
* this click and not before.
|
|
||||||
*/
|
|
||||||
public function issue(IssueInvoice $issuer): void
|
|
||||||
{
|
|
||||||
$this->authorize('site.manage');
|
|
||||||
|
|
||||||
$data = $this->validate([
|
|
||||||
'customerUuid' => 'required|string',
|
|
||||||
'seriesId' => 'nullable|string',
|
|
||||||
'lines' => 'required|array|min:1',
|
|
||||||
'lines.*.description' => 'required|string|max:255',
|
|
||||||
'lines.*.detail' => 'nullable|string|max:255',
|
|
||||||
// Down to a thousandth, because hours are billed in quarters and
|
|
||||||
// 0.25 has to survive the trip.
|
|
||||||
'lines.*.quantity' => 'required|numeric|min:0.001|max:100000',
|
|
||||||
'lines.*.unit' => 'nullable|string|max:32',
|
|
||||||
// Negative allowed: a credit line on an ordinary invoice is how a
|
|
||||||
// goodwill deduction is shown without a second document.
|
|
||||||
'lines.*.price' => 'required|numeric|min:-1000000|max:1000000',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$customer = Customer::query()->where('uuid', $data['customerUuid'])->first();
|
|
||||||
|
|
||||||
if ($customer === null) {
|
|
||||||
$this->addError('customerUuid', __('new_invoice.no_customer'));
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$series = $data['seriesId'] === ''
|
|
||||||
? null
|
|
||||||
: InvoiceSeries::query()->whereKey($data['seriesId'])->first();
|
|
||||||
|
|
||||||
try {
|
|
||||||
$invoice = $issuer->forService(
|
|
||||||
customer: $customer,
|
|
||||||
lines: $this->linesForIssue(),
|
|
||||||
// The currency the catalogue is priced in, not a second
|
|
||||||
// setting: a service invoice in another currency than the
|
|
||||||
// packages would be a second answer to a question that already
|
|
||||||
// has one.
|
|
||||||
currency: Subscription::catalogueCurrency(),
|
|
||||||
series: $series,
|
|
||||||
);
|
|
||||||
} catch (Throwable $e) {
|
|
||||||
// The company profile being incomplete is the usual one, and it is
|
|
||||||
// a refusal by design: an invoice without a registered name, an
|
|
||||||
// address or a VAT number is not a valid invoice, and issuing it
|
|
||||||
// would burn a number that can never be reused.
|
|
||||||
$this->addError('lines', $e->getMessage());
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->redirectRoute('admin.invoices', navigate: true);
|
|
||||||
|
|
||||||
session()->flash('status', __('new_invoice.issued', ['number' => $invoice->number]));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The lines in the shape IssueInvoice wants them.
|
|
||||||
*
|
|
||||||
* round(), not a cast: (int) (12.95 * 100) is 1294 on a binary float, and
|
|
||||||
* an invoice a cent short of what was typed is the kind of error nobody
|
|
||||||
* finds until a customer does.
|
|
||||||
*
|
|
||||||
* @return array<int, array<string, mixed>>
|
|
||||||
*/
|
|
||||||
private function linesForIssue(): array
|
|
||||||
{
|
|
||||||
return array_map(fn (array $line) => [
|
|
||||||
'description' => trim($line['description']),
|
|
||||||
'details' => array_filter([trim((string) $line['detail'])]),
|
|
||||||
'quantity_milli' => (int) round(((float) $line['quantity']) * 1000),
|
|
||||||
'unit' => trim((string) $line['unit']),
|
|
||||||
'unit_net_cents' => (int) round(((float) $line['price']) * 100),
|
|
||||||
], $this->lines);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function render()
|
|
||||||
{
|
|
||||||
$this->authorize('site.manage');
|
|
||||||
|
|
||||||
$customer = $this->customerUuid === ''
|
|
||||||
? null
|
|
||||||
: Customer::query()->where('uuid', $this->customerUuid)->first();
|
|
||||||
|
|
||||||
// The same treatment the document will carry, shown before it is
|
|
||||||
// issued: an operator writing an invoice for a business in another
|
|
||||||
// member state should see "reverse charge, 0 %" on the page, not
|
|
||||||
// discover it on the PDF.
|
|
||||||
$treatment = TaxTreatment::for($customer);
|
|
||||||
$rate = (int) round($treatment->rate * 10000);
|
|
||||||
|
|
||||||
return view('livewire.admin.new-invoice', [
|
|
||||||
'customers' => Customer::query()
|
|
||||||
->whereNull('closed_at')
|
|
||||||
->orderBy('name')
|
|
||||||
->get(['id', 'uuid', 'name', 'email']),
|
|
||||||
'series' => InvoiceSeries::query()->where('kind', 'invoice')->orderBy('name')->get(),
|
|
||||||
'customer' => $customer,
|
|
||||||
'treatment' => $treatment,
|
|
||||||
'totals' => InvoiceMath::totals($this->linesForIssue(), null, $rate),
|
|
||||||
'currency' => Subscription::catalogueCurrency(),
|
|
||||||
// Said on the page rather than only thrown at the click: the
|
|
||||||
// company profile is somebody else's tab, and finding out at the
|
|
||||||
// last step costs the whole form.
|
|
||||||
'missing' => CompanyProfile::missingForInvoicing(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,383 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Livewire\Admin;
|
|
||||||
|
|
||||||
use App\Livewire\Concerns\BuildsRunSteps;
|
|
||||||
use App\Models\Customer;
|
|
||||||
use App\Models\Host;
|
|
||||||
use App\Services\Provisioning\HostCapacity;
|
|
||||||
use App\Models\Instance;
|
|
||||||
use App\Models\MonitoringTarget;
|
|
||||||
use App\Models\ProvisioningRun;
|
|
||||||
use App\Models\Subscription;
|
|
||||||
use Illuminate\Support\Carbon;
|
|
||||||
use Illuminate\Support\Number;
|
|
||||||
use Livewire\Attributes\Layout;
|
|
||||||
use Livewire\Component;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The console's front page — every figure on it comes from the database.
|
|
||||||
*
|
|
||||||
* It used to be invented: forty-two customers, €7,842 of monthly revenue, hosts
|
|
||||||
* named pve-fsn-1..3, a twelve-month growth curve. It looked like an operating
|
|
||||||
* business and reported nothing. Anything without a real source has been
|
|
||||||
* removed rather than approximated — the revenue trend line especially, because
|
|
||||||
* there is no revenue history to draw it from, and a plausible line is worse
|
|
||||||
* than no line.
|
|
||||||
*/
|
|
||||||
#[Layout('layouts.admin')]
|
|
||||||
class Overview extends Component
|
|
||||||
{
|
|
||||||
// current_step is an index into the pipeline, not a name. The provisioning
|
|
||||||
// page already owns the translation from one to the other; sharing it keeps
|
|
||||||
// the two pages from naming the same step differently.
|
|
||||||
use BuildsRunSteps;
|
|
||||||
|
|
||||||
/** A host is presumed missing once it has not checked in for this long. */
|
|
||||||
private const HOST_SILENT_AFTER_MINUTES = 30;
|
|
||||||
|
|
||||||
public function render()
|
|
||||||
{
|
|
||||||
$locale = app()->getLocale();
|
|
||||||
|
|
||||||
$customersOpen = Customer::query()->where('status', '!=', 'closed')->whereNull('closed_at')->count();
|
|
||||||
$customersSuspended = Customer::query()->where('status', 'suspended')->count();
|
|
||||||
|
|
||||||
$instancesByStatus = Instance::query()
|
|
||||||
->selectRaw('status, count(*) as n')
|
|
||||||
->groupBy('status')
|
|
||||||
->pluck('n', 'status');
|
|
||||||
|
|
||||||
$hostsByStatus = Host::query()
|
|
||||||
->selectRaw('status, count(*) as n')
|
|
||||||
->groupBy('status')
|
|
||||||
->pluck('n', 'status');
|
|
||||||
|
|
||||||
return view('livewire.admin.overview', [
|
|
||||||
'kpis' => [
|
|
||||||
[
|
|
||||||
'label' => __('admin.kpi.customers'),
|
|
||||||
'value' => (string) $customersOpen,
|
|
||||||
'sub' => $customersSuspended > 0
|
|
||||||
? __('admin.kpi.customers_suspended', ['n' => $customersSuspended])
|
|
||||||
: null,
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'label' => __('admin.kpi.instances'),
|
|
||||||
'value' => (string) ($instancesByStatus['active'] ?? 0),
|
|
||||||
'sub' => __('admin.kpi.instances_of', ['n' => $instancesByStatus->sum()]),
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'label' => __('admin.kpi.hosts'),
|
|
||||||
'value' => (string) ($hostsByStatus['active'] ?? 0),
|
|
||||||
'sub' => __('admin.kpi.hosts_of', ['n' => $hostsByStatus->sum()]),
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'label' => __('admin.kpi.mrr'),
|
|
||||||
'value' => $this->monthlyRevenue($locale),
|
|
||||||
'sub' => __('admin.kpi.mrr_sub'),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
'newInstances' => $this->newInstancesPerMonth($locale),
|
|
||||||
'hostLoad' => $this->hostLoad(),
|
|
||||||
'runs' => $this->openRuns(),
|
|
||||||
'delivery' => $this->delivery(),
|
|
||||||
'notices' => $notices = $this->notices(),
|
|
||||||
'noticeCount' => count($notices),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* What the estate bills per month, net.
|
|
||||||
*
|
|
||||||
* Off the contracts, not the catalogue: a price rise must not inflate
|
|
||||||
* reported revenue for every grandfathered customer overnight. Add-ons
|
|
||||||
* included and yearly terms divided — totalMonthlyCents() owns both rules.
|
|
||||||
*
|
|
||||||
* Grouped by currency and never summed across them. Adding francs to euros
|
|
||||||
* produces a number that is wrong in a way nobody notices.
|
|
||||||
*/
|
|
||||||
private function monthlyRevenue(string $locale): string
|
|
||||||
{
|
|
||||||
$byCurrency = [];
|
|
||||||
|
|
||||||
Subscription::query()
|
|
||||||
->where('status', 'active')
|
|
||||||
->with('addons')
|
|
||||||
->chunkById(200, function ($subscriptions) use (&$byCurrency) {
|
|
||||||
foreach ($subscriptions as $subscription) {
|
|
||||||
$currency = strtoupper((string) $subscription->currency);
|
|
||||||
$byCurrency[$currency] = ($byCurrency[$currency] ?? 0) + $subscription->totalMonthlyCents();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if ($byCurrency === []) {
|
|
||||||
return Number::currency(0, in: Subscription::catalogueCurrency(), locale: $locale);
|
|
||||||
}
|
|
||||||
|
|
||||||
return collect($byCurrency)
|
|
||||||
->map(fn (int $cents, string $currency) => Number::currency($cents / 100, in: $currency, locale: $locale))
|
|
||||||
->implode(' · ');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Instances created per month over the last twelve.
|
|
||||||
*
|
|
||||||
* Deliberately "created", not "in operation": reconstructing how many were
|
|
||||||
* running at the end of some past month would need a history nobody keeps,
|
|
||||||
* and the honest version of that chart is this one with a truthful label.
|
|
||||||
*
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
private function newInstancesPerMonth(string $locale): array
|
|
||||||
{
|
|
||||||
$start = Carbon::now()->startOfMonth()->subMonths(11);
|
|
||||||
|
|
||||||
$counts = Instance::query()
|
|
||||||
->where('created_at', '>=', $start)
|
|
||||||
->get(['created_at'])
|
|
||||||
->countBy(fn (Instance $i) => $i->created_at->local()->format('Y-m'));
|
|
||||||
|
|
||||||
$labels = [];
|
|
||||||
$data = [];
|
|
||||||
|
|
||||||
for ($i = 0; $i < 12; $i++) {
|
|
||||||
$month = $start->copy()->addMonths($i);
|
|
||||||
$labels[] = $month->local()->locale($locale)->isoFormat('MMM');
|
|
||||||
$data[] = (int) ($counts[$month->local()->format('Y-m')] ?? 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
'empty' => array_sum($data) === 0,
|
|
||||||
'config' => [
|
|
||||||
'type' => 'bar',
|
|
||||||
'data' => [
|
|
||||||
'labels' => $labels,
|
|
||||||
'datasets' => [[
|
|
||||||
'label' => __('admin.new_instances'),
|
|
||||||
'data' => $data,
|
|
||||||
'backgroundColor' => 'token:accent',
|
|
||||||
'borderRadius' => 3,
|
|
||||||
'maxBarThickness' => 26,
|
|
||||||
]],
|
|
||||||
],
|
|
||||||
'options' => [
|
|
||||||
'scales' => [
|
|
||||||
'x' => ['grid' => ['display' => false]],
|
|
||||||
// Whole instances only — a y-axis offering "1.5 instances"
|
|
||||||
// is the giveaway that a chart was never looked at.
|
|
||||||
'y' => ['beginAtZero' => true, 'ticks' => ['precision' => 0], 'grid' => ['color' => 'token:border']],
|
|
||||||
],
|
|
||||||
'plugins' => ['legend' => ['display' => false]],
|
|
||||||
],
|
|
||||||
],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Storage committed on each host, against the capacity placement may use.
|
|
||||||
*
|
|
||||||
* Deliberately the model's own committedGb()/freeGb()/usedPct() rather than
|
|
||||||
* a quicker sum here. Placement counts the VM disk allocation, ignores a
|
|
||||||
* failed instance that never got a VM, and subtracts the host's reserve —
|
|
||||||
* a dashboard doing its own arithmetic would show a host as comfortable
|
|
||||||
* while placement was already refusing to put anything on it, and the
|
|
||||||
* disagreement would only surface when an order failed.
|
|
||||||
*
|
|
||||||
* @return array<int, array<string, mixed>>
|
|
||||||
*/
|
|
||||||
private function hostLoad(): array
|
|
||||||
{
|
|
||||||
return Host::query()
|
|
||||||
// Preloaded so listing hosts is one query, not one per host.
|
|
||||||
->withSum(['instances as committed_disk_gb' => fn ($q) => $q->occupyingHost()], 'disk_gb')
|
|
||||||
->orderBy('name')
|
|
||||||
->get()
|
|
||||||
->map(function (Host $host) {
|
|
||||||
$usable = $host->freeGb();
|
|
||||||
$committed = $host->committedGb();
|
|
||||||
// Same ratio usedPct() states, computed from the two values
|
|
||||||
// already in hand rather than asking the host to sum again.
|
|
||||||
$pct = $usable > 0 ? min(100, (int) round($committed / $usable * 100)) : 0;
|
|
||||||
|
|
||||||
return [
|
|
||||||
'name' => $host->name,
|
|
||||||
'pct' => $pct,
|
|
||||||
'detail' => $usable > 0 ? "{$committed} / {$usable} GB" : __('admin.host_no_capacity'),
|
|
||||||
'level' => match (true) {
|
|
||||||
$usable === 0 => 'unknown',
|
|
||||||
$pct >= 90 => 'high',
|
|
||||||
$pct >= 75 => 'warn',
|
|
||||||
default => 'ok',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
})
|
|
||||||
->all();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* What each package can be promised today, and who is waiting.
|
|
||||||
*
|
|
||||||
* The same question the price sheet and the customer's own overview ask,
|
|
||||||
* answered from the same place — an operator must be able to see on the
|
|
||||||
* front page what a visitor is being told, without opening the shop in
|
|
||||||
* another tab. A package with no host big enough is not unsellable: the
|
|
||||||
* order is taken, parked, and rolled out once a machine arrives, which is
|
|
||||||
* exactly what "bereitgestellt in 2-3 Werktagen" means.
|
|
||||||
*
|
|
||||||
* @return array{plans: array<int, array{name: string, needs: int, state: string}>, queued: int}
|
|
||||||
*/
|
|
||||||
private function delivery(): array
|
|
||||||
{
|
|
||||||
$capacity = app(HostCapacity::class);
|
|
||||||
|
|
||||||
try {
|
|
||||||
$sellable = app(\App\Services\Billing\PlanCatalogue::class)->sellable();
|
|
||||||
} catch (\Throwable) {
|
|
||||||
// A catalogue that cannot be read has its own alarm. It does not
|
|
||||||
// get to take the console's front page down with it.
|
|
||||||
$sellable = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
$plans = [];
|
|
||||||
|
|
||||||
foreach ($sellable as $key => $plan) {
|
|
||||||
$needs = (int) ($plan['disk_gb'] ?? 0);
|
|
||||||
|
|
||||||
$plans[] = [
|
|
||||||
'name' => (string) ($plan['name'] ?? $key),
|
|
||||||
'needs' => $needs,
|
|
||||||
'state' => $capacity->deliveryFor($needs),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
return ['plans' => $plans, 'queued' => $capacity->queueDemand()['count']];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Provisioning runs that have not finished.
|
|
||||||
*
|
|
||||||
* @return array<int, array<string, mixed>>
|
|
||||||
*/
|
|
||||||
private function openRuns(): array
|
|
||||||
{
|
|
||||||
return ProvisioningRun::query()
|
|
||||||
->whereIn('status', [
|
|
||||||
ProvisioningRun::STATUS_PENDING,
|
|
||||||
ProvisioningRun::STATUS_RUNNING,
|
|
||||||
ProvisioningRun::STATUS_WAITING,
|
|
||||||
ProvisioningRun::STATUS_PAUSED,
|
|
||||||
])
|
|
||||||
// Nested through the morph, so naming the customer does not cost a
|
|
||||||
// query per run.
|
|
||||||
->with(['subject' => fn ($morph) => $morph->morphWith([Instance::class => ['customer']])])
|
|
||||||
->latest('id')
|
|
||||||
->limit(6)
|
|
||||||
->get()
|
|
||||||
->map(fn (ProvisioningRun $run) => [
|
|
||||||
'subject' => $this->describeSubject($run),
|
|
||||||
'step' => $this->currentStepLabel($run),
|
|
||||||
'status' => $run->status,
|
|
||||||
])
|
|
||||||
->all();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Everything currently wrong, from the records that know it.
|
|
||||||
*
|
|
||||||
* There is no invented alert here. If this list is empty, nothing in the
|
|
||||||
* database is reporting a problem — which is a statement worth being able
|
|
||||||
* to trust.
|
|
||||||
*
|
|
||||||
* @return array<int, array<string, string>>
|
|
||||||
*/
|
|
||||||
private function notices(): array
|
|
||||||
{
|
|
||||||
$notices = [];
|
|
||||||
|
|
||||||
$failedRuns = ProvisioningRun::query()->where('status', ProvisioningRun::STATUS_FAILED)->count();
|
|
||||||
if ($failedRuns > 0) {
|
|
||||||
$notices[] = [
|
|
||||||
'level' => 'warning',
|
|
||||||
'text' => __('admin.notice.failed_runs', ['n' => $failedRuns]),
|
|
||||||
// Every notice carries the page that shows the thing it is
|
|
||||||
// about. A warning an operator cannot act on from where they
|
|
||||||
// read it is a warning they go looking for by hand, and the
|
|
||||||
// count in the header was not even a link to the list.
|
|
||||||
'route' => 'admin.provisioning',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (Host::query()->where('status', 'error')->pluck('name') as $name) {
|
|
||||||
$notices[] = [
|
|
||||||
'level' => 'warning',
|
|
||||||
'text' => __('admin.notice.host_error', ['host' => $name]),
|
|
||||||
'route' => 'admin.hosts',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
$silent = Host::query()
|
|
||||||
->where('status', 'active')
|
|
||||||
->where(fn ($q) => $q
|
|
||||||
->whereNull('last_seen_at')
|
|
||||||
->orWhere('last_seen_at', '<', Carbon::now()->subMinutes(self::HOST_SILENT_AFTER_MINUTES)))
|
|
||||||
->pluck('name');
|
|
||||||
foreach ($silent as $name) {
|
|
||||||
$notices[] = [
|
|
||||||
'level' => 'warning',
|
|
||||||
'text' => __('admin.notice.host_silent', [
|
|
||||||
'host' => $name, 'minutes' => self::HOST_SILENT_AFTER_MINUTES,
|
|
||||||
]),
|
|
||||||
'route' => 'admin.hosts',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Distinct instances, not targets: one instance can be watched by
|
|
||||||
// several checks, and counting checks would report three outages where
|
|
||||||
// one machine is down. And only verdicts the sync job has refreshed —
|
|
||||||
// an unchecked row is not a healthy one, nor a failing one.
|
|
||||||
$down = MonitoringTarget::query()
|
|
||||||
->where('status', 'down')
|
|
||||||
->where('checked_at', '>=', Carbon::now()->subMinutes(20))
|
|
||||||
->distinct()
|
|
||||||
->count('instance_id');
|
|
||||||
if ($down > 0) {
|
|
||||||
$notices[] = [
|
|
||||||
'level' => 'warning',
|
|
||||||
'text' => __('admin.notice.monitoring_down', ['n' => $down]),
|
|
||||||
'route' => 'admin.instances',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Capacity, before an order finds out.
|
|
||||||
//
|
|
||||||
// The platform books thick: a package reserves its whole `disk_gb` on
|
|
||||||
// one host at placement. So the catalogue can go on offering a package
|
|
||||||
// no host has room for, and the first anyone hears of it is a PAID
|
|
||||||
// order failing at the reservation step — after the money.
|
|
||||||
//
|
|
||||||
// Listed per package with the shortfall, because "buy a server" is not
|
|
||||||
// the decision; "buy a server big enough for the package I am selling"
|
|
||||||
// is.
|
|
||||||
foreach (app(HostCapacity::class)->unplaceablePlans() as $plan) {
|
|
||||||
$notices[] = ['level' => 'warning', 'route' => 'admin.capacity', 'text' => __('admin.notice.no_capacity', [
|
|
||||||
'plan' => $plan['name'],
|
|
||||||
'needs' => $plan['needs'],
|
|
||||||
'largest' => $plan['largest'],
|
|
||||||
])];
|
|
||||||
}
|
|
||||||
|
|
||||||
return $notices;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function describeSubject(ProvisioningRun $run): string
|
|
||||||
{
|
|
||||||
$subject = $run->subject;
|
|
||||||
|
|
||||||
return match (true) {
|
|
||||||
$subject instanceof Instance => $subject->customer?->name ?? $subject->subdomain ?? '—',
|
|
||||||
$subject instanceof Host => $subject->name,
|
|
||||||
default => class_basename($run->subject_type ?? '').' #'.$run->subject_id,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,322 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Livewire\Admin;
|
|
||||||
|
|
||||||
use App\Models\PlanFamily;
|
|
||||||
use App\Models\PlanVersion;
|
|
||||||
use App\Models\Subscription;
|
|
||||||
use App\Services\Billing\PlanCatalogue;
|
|
||||||
use App\Support\LocalTime;
|
|
||||||
use App\Support\Money;
|
|
||||||
use Illuminate\Validation\Rule;
|
|
||||||
use Livewire\Attributes\Layout;
|
|
||||||
use Livewire\Attributes\On;
|
|
||||||
use Livewire\Component;
|
|
||||||
use RuntimeException;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The versions of one plan: what it can do, what it costs, and when it sells.
|
|
||||||
*
|
|
||||||
* The page is built around the one rule that matters here — a draft can be
|
|
||||||
* edited freely, a published version cannot be touched at all. So drafting and
|
|
||||||
* publishing are separate acts, and publishing says plainly that it is final.
|
|
||||||
*/
|
|
||||||
#[Layout('layouts.admin')]
|
|
||||||
class PlanVersions extends Component
|
|
||||||
{
|
|
||||||
public string $uuid;
|
|
||||||
|
|
||||||
/** The draft being written. */
|
|
||||||
public int $quotaGb = 100;
|
|
||||||
|
|
||||||
public int $trafficGb = 1000;
|
|
||||||
|
|
||||||
public int $seats = 5;
|
|
||||||
|
|
||||||
public int $ramMb = 4096;
|
|
||||||
|
|
||||||
public int $cores = 2;
|
|
||||||
|
|
||||||
public int $diskGb = 120;
|
|
||||||
|
|
||||||
public string $performance = 'standard';
|
|
||||||
|
|
||||||
public ?int $templateVmid = 9000;
|
|
||||||
|
|
||||||
/** @var array<int, string> */
|
|
||||||
public array $features = [];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* In EUROS, as typed. The catalogue stores cents; asking the owner to type
|
|
||||||
* them too turned €799 into "79900", where one slipped digit is a factor of
|
|
||||||
* ten on an invoice. Money::toCents does the conversion, once.
|
|
||||||
*/
|
|
||||||
public string $monthlyPrice = '49,00';
|
|
||||||
|
|
||||||
public string $yearlyPrice = '588,00';
|
|
||||||
|
|
||||||
/** Publishing: when it goes on sale, and optionally when it stops. */
|
|
||||||
public string $availableFrom = '';
|
|
||||||
|
|
||||||
public string $availableUntil = '';
|
|
||||||
|
|
||||||
public ?string $publishing = null;
|
|
||||||
|
|
||||||
public function mount(string $uuid): void
|
|
||||||
{
|
|
||||||
$this->authorize('plans.manage');
|
|
||||||
|
|
||||||
$family = PlanFamily::query()->where('uuid', $uuid)->firstOrFail();
|
|
||||||
$this->uuid = $uuid;
|
|
||||||
|
|
||||||
// Start the draft from what this plan is today, so the common case —
|
|
||||||
// "same plan, new price" — is a single edit rather than nine.
|
|
||||||
$latest = $family->versions()->orderByDesc('version')->first();
|
|
||||||
|
|
||||||
if ($latest !== null) {
|
|
||||||
$this->quotaGb = $latest->quota_gb;
|
|
||||||
$this->trafficGb = $latest->traffic_gb;
|
|
||||||
$this->seats = $latest->seats;
|
|
||||||
$this->ramMb = $latest->ram_mb;
|
|
||||||
$this->cores = $latest->cores;
|
|
||||||
$this->diskGb = $latest->disk_gb;
|
|
||||||
$this->performance = (string) ($latest->performance ?? 'standard');
|
|
||||||
$this->templateVmid = $latest->template_vmid;
|
|
||||||
$this->features = $latest->features ?? [];
|
|
||||||
|
|
||||||
$monthly = $latest->priceFor(Subscription::TERM_MONTHLY);
|
|
||||||
$yearly = $latest->priceFor(Subscription::TERM_YEARLY);
|
|
||||||
$this->monthlyPrice = Money::fromCents($monthly?->amount_cents ?? 4900);
|
|
||||||
$this->yearlyPrice = Money::fromCents($yearly?->amount_cents ?? 58800);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private function family(): PlanFamily
|
|
||||||
{
|
|
||||||
return PlanFamily::query()->where('uuid', $this->uuid)->firstOrFail();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Write a new draft. Nothing is promised until it is published. */
|
|
||||||
public function draft(): void
|
|
||||||
{
|
|
||||||
$this->authorize('plans.manage');
|
|
||||||
|
|
||||||
// Every bound is explicit, and generous rather than tight. A mistyped
|
|
||||||
// figure has to come back as a message on the field; without an upper
|
|
||||||
// limit it overflows the column instead and the owner gets a 500 with
|
|
||||||
// no idea which number was wrong.
|
|
||||||
$data = $this->validate([
|
|
||||||
'quotaGb' => 'required|integer|min:1|max:1000000',
|
|
||||||
'trafficGb' => 'required|integer|min:0|max:10000000',
|
|
||||||
'seats' => 'required|integer|min:1|max:100000',
|
|
||||||
'ramMb' => 'required|integer|min:512|max:4194304',
|
|
||||||
'cores' => 'required|integer|min:1|max:512',
|
|
||||||
// The disk has to hold the customer's quota plus the system itself.
|
|
||||||
'diskGb' => 'required|integer|min:1|max:1000000|gte:quotaGb',
|
|
||||||
// One of the classes we actually have a label for. Free text means
|
|
||||||
// a typo is published, frozen, and shown to customers forever as a
|
|
||||||
// raw translation key.
|
|
||||||
'performance' => ['required', Rule::in(array_keys((array) __('billing.perf')))],
|
|
||||||
// Required, not nullable: a version without a blueprint can be
|
|
||||||
// published and sold, and then fails provisioning every time.
|
|
||||||
'templateVmid' => 'required|integer|min:100|max:999999999',
|
|
||||||
'features' => 'array',
|
|
||||||
// Same reason as the performance class: a request can carry
|
|
||||||
// anything, and an unknown key is frozen at publication and shown
|
|
||||||
// to customers as a raw translation key.
|
|
||||||
'features.*' => ['string', Rule::in(array_keys((array) __('billing.feature')))],
|
|
||||||
// Euros with at most two decimals, either separator. The upper
|
|
||||||
// bound lives in Money's pattern: nine whole digits is far past
|
|
||||||
// anything we would charge and far short of overflowing the column.
|
|
||||||
// Checked as a rule, not afterwards: a price fault has to be
|
|
||||||
// reported alongside every other fault on the form, and a check
|
|
||||||
// that runs after validate() never runs at all once some other
|
|
||||||
// field has already thrown.
|
|
||||||
'monthlyPrice' => ['required', 'string', $price = function (string $attribute, mixed $value, callable $fail) {
|
|
||||||
if (Money::toCents((string) $value) === null) {
|
|
||||||
$fail(__('plans.price_invalid'));
|
|
||||||
}
|
|
||||||
}],
|
|
||||||
'yearlyPrice' => ['required', 'string', $price],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$monthlyCents = Money::toCents($data['monthlyPrice']);
|
|
||||||
$yearlyCents = Money::toCents($data['yearlyPrice']);
|
|
||||||
|
|
||||||
$version = app(PlanCatalogue::class)->draft(
|
|
||||||
$this->family(),
|
|
||||||
[
|
|
||||||
'quota_gb' => $data['quotaGb'],
|
|
||||||
'traffic_gb' => $data['trafficGb'],
|
|
||||||
'seats' => $data['seats'],
|
|
||||||
'ram_mb' => $data['ramMb'],
|
|
||||||
'cores' => $data['cores'],
|
|
||||||
'disk_gb' => $data['diskGb'],
|
|
||||||
'performance' => $data['performance'],
|
|
||||||
'template_vmid' => $data['templateVmid'],
|
|
||||||
'features' => array_values($data['features']),
|
|
||||||
],
|
|
||||||
// Both terms, because publishing refuses a version that is not
|
|
||||||
// priced for each — better to find that out here than in front of
|
|
||||||
// a customer.
|
|
||||||
[
|
|
||||||
Subscription::TERM_MONTHLY => $monthlyCents,
|
|
||||||
Subscription::TERM_YEARLY => $yearlyCents,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
$this->dispatch('notify', message: __('plans.draft_created', ['version' => $version->version]));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Open the publish form for one draft. */
|
|
||||||
public function choose(string $versionUuid): void
|
|
||||||
{
|
|
||||||
$this->publishing = $versionUuid;
|
|
||||||
$this->availableFrom = LocalTime::toField(now());
|
|
||||||
$this->availableUntil = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
public function cancelPublish(): void
|
|
||||||
{
|
|
||||||
$this->reset('publishing', 'availableFrom', 'availableUntil');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Publish: fix the terms and put them on sale.
|
|
||||||
*
|
|
||||||
* Everything that can refuse this — an unpriced version, a window that
|
|
||||||
* overlaps another — refuses before anything is written, so a rejected
|
|
||||||
* publish leaves an editable draft rather than a stranded one.
|
|
||||||
*/
|
|
||||||
public function publish(): void
|
|
||||||
{
|
|
||||||
$this->authorize('plans.manage');
|
|
||||||
|
|
||||||
$this->validate([
|
|
||||||
'availableFrom' => 'required|date',
|
|
||||||
'availableUntil' => 'nullable|date|after:availableFrom',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$version = PlanVersion::query()->where('uuid', $this->publishing)->firstOrFail();
|
|
||||||
abort_unless($version->plan_family_id === $this->family()->id, 404);
|
|
||||||
|
|
||||||
try {
|
|
||||||
app(PlanCatalogue::class)->publish(
|
|
||||||
$version,
|
|
||||||
LocalTime::fromField($this->availableFrom),
|
|
||||||
LocalTime::fromField($this->availableUntil),
|
|
||||||
);
|
|
||||||
} catch (RuntimeException $e) {
|
|
||||||
$this->addError('availableFrom', $e->getMessage());
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->cancelPublish();
|
|
||||||
$this->dispatch('notify', message: __('plans.published', ['version' => $version->version]));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Close a running version's window — the ordinary way to take a plan off
|
|
||||||
* sale for good, since a published version can never be deleted.
|
|
||||||
*/
|
|
||||||
public function close(string $versionUuid): void
|
|
||||||
{
|
|
||||||
$this->authorize('plans.manage');
|
|
||||||
|
|
||||||
$version = PlanVersion::query()->where('uuid', $versionUuid)->firstOrFail();
|
|
||||||
abort_unless($version->plan_family_id === $this->family()->id, 404);
|
|
||||||
|
|
||||||
try {
|
|
||||||
app(PlanCatalogue::class)->schedule($version, $version->available_from, now());
|
|
||||||
} catch (RuntimeException $e) {
|
|
||||||
$this->addError('availableFrom', $e->getMessage());
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->dispatch('notify', message: __('plans.closed', ['version' => $version->version]));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Put a closed version back on sale — the way out of a closed window.
|
|
||||||
*
|
|
||||||
* The likely refusal here is that a newer version has taken over, and the
|
|
||||||
* catalogue says so in English, at the length a log entry deserves. Which
|
|
||||||
* of its two refusals it was is read off the version itself rather than out
|
|
||||||
* of the message text: a message is prose, and prose changes.
|
|
||||||
*/
|
|
||||||
public function reopen(string $versionUuid): void
|
|
||||||
{
|
|
||||||
$this->authorize('plans.manage');
|
|
||||||
|
|
||||||
$version = PlanVersion::query()->where('uuid', $versionUuid)->firstOrFail();
|
|
||||||
abort_unless($version->plan_family_id === $this->family()->id, 404);
|
|
||||||
|
|
||||||
try {
|
|
||||||
app(PlanCatalogue::class)->reopen($version);
|
|
||||||
} catch (RuntimeException) {
|
|
||||||
$this->addError('availableFrom', __(
|
|
||||||
$version->isPublished() ? 'plans.reopen_blocked' : 'plans.reopen_draft'
|
|
||||||
));
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->dispatch('notify', message: __('plans.reopened', ['version' => $version->version]));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[On('plan-draft-deleted')]
|
|
||||||
public function refresh(): void
|
|
||||||
{
|
|
||||||
// Livewire re-renders; the listener exists so the modal can say so.
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The version that publishing at the chosen moment would take off sale.
|
|
||||||
*
|
|
||||||
* Announced on the form, because publishing now ends a running version's
|
|
||||||
* sale instead of being refused, and the owner should not have to work that
|
|
||||||
* out from the version list afterwards. Asked of the catalogue rather than
|
|
||||||
* worked out here, so the announcement and the act cannot name different
|
|
||||||
* versions.
|
|
||||||
*
|
|
||||||
* @return array{version: int, at: string}|null
|
|
||||||
*/
|
|
||||||
private function handover(PlanFamily $family): ?array
|
|
||||||
{
|
|
||||||
if ($this->publishing === null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$version = $family->versions()->where('uuid', $this->publishing)->first();
|
|
||||||
$from = LocalTime::fromField($this->availableFrom);
|
|
||||||
|
|
||||||
if ($version === null || $from === null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$predecessor = app(PlanCatalogue::class)->predecessorAt($version, $from);
|
|
||||||
|
|
||||||
return $predecessor === null ? null : [
|
|
||||||
'version' => $predecessor->version,
|
|
||||||
'at' => $from->local()->isoFormat('L LT'),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
public function render()
|
|
||||||
{
|
|
||||||
$family = $this->family();
|
|
||||||
$now = now();
|
|
||||||
|
|
||||||
return view('livewire.admin.plan-versions', [
|
|
||||||
'family' => $family,
|
|
||||||
'versions' => $family->versions()->with('prices')->orderByDesc('version')->get(),
|
|
||||||
'now' => $now,
|
|
||||||
'handover' => $this->handover($family),
|
|
||||||
'featureKeys' => array_keys((array) __('billing.feature')),
|
|
||||||
'performanceClasses' => (array) __('billing.perf'),
|
|
||||||
'currency' => Subscription::catalogueCurrency(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,117 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Livewire\Admin;
|
|
||||||
|
|
||||||
use App\Models\PlanFamily;
|
|
||||||
use App\Services\Billing\PlanCatalogue;
|
|
||||||
use Livewire\Attributes\Layout;
|
|
||||||
use Livewire\Attributes\Validate;
|
|
||||||
use Livewire\Component;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The plan lines we sell, and whether each is currently on sale.
|
|
||||||
*
|
|
||||||
* Deliberately shallow: a family is a name and a rank, and everything that can
|
|
||||||
* be got wrong — capabilities, prices, windows — lives one level down on the
|
|
||||||
* versions, where publishing makes it permanent.
|
|
||||||
*/
|
|
||||||
#[Layout('layouts.admin')]
|
|
||||||
class Plans extends Component
|
|
||||||
{
|
|
||||||
#[Validate(['required', 'string', 'max:32', 'regex:/^[a-z][a-z0-9_]*$/', 'unique:plan_families,key'])]
|
|
||||||
public string $key = '';
|
|
||||||
|
|
||||||
#[Validate('required|string|max:255')]
|
|
||||||
public string $name = '';
|
|
||||||
|
|
||||||
#[Validate('required|integer|min:0|max:255')]
|
|
||||||
public int $tier = 1;
|
|
||||||
|
|
||||||
public function mount(): void
|
|
||||||
{
|
|
||||||
// The page itself, not only its buttons. Hiding the nav entry and
|
|
||||||
// guarding the mutations still leaves the whole catalogue — prices,
|
|
||||||
// drafts, unreleased plans — readable to any operator who types the URL.
|
|
||||||
$this->authorize('plans.manage');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function save(): void
|
|
||||||
{
|
|
||||||
$this->authorize('plans.manage');
|
|
||||||
|
|
||||||
$this->key = strtolower(trim($this->key));
|
|
||||||
|
|
||||||
$data = $this->validate([
|
|
||||||
// The key is permanent once created — orders, instances and every
|
|
||||||
// contract snapshot store it as a string — so it has to be a plain
|
|
||||||
// identifier, not something anyone will want to prettify later.
|
|
||||||
'key' => ['required', 'string', 'max:32', 'regex:/^[a-z][a-z0-9_]*$/', 'unique:plan_families,key'],
|
|
||||||
'name' => 'required|string|max:255',
|
|
||||||
'tier' => 'required|integer|min:0|max:255',
|
|
||||||
]);
|
|
||||||
|
|
||||||
PlanFamily::create([
|
|
||||||
'key' => $data['key'],
|
|
||||||
'name' => $data['name'],
|
|
||||||
'tier' => $data['tier'],
|
|
||||||
// Nothing to sell until a version is published, so it starts able
|
|
||||||
// to sell and simply has nothing available.
|
|
||||||
'sales_enabled' => true,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->reset('key', 'name', 'tier');
|
|
||||||
$this->tier = 1;
|
|
||||||
$this->dispatch('notify', message: __('plans.created'));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The kill switch: stop selling this plan now, without touching a window.
|
|
||||||
*
|
|
||||||
* Existing customers keep their contract and their machine — this only
|
|
||||||
* decides whether anyone new can buy it.
|
|
||||||
*/
|
|
||||||
public function toggleSales(string $uuid): void
|
|
||||||
{
|
|
||||||
$this->authorize('plans.manage');
|
|
||||||
|
|
||||||
$family = PlanFamily::query()->where('uuid', $uuid)->first();
|
|
||||||
$family?->update(['sales_enabled' => ! $family->sales_enabled]);
|
|
||||||
|
|
||||||
$this->dispatch('notify', message: __($family?->sales_enabled ? 'plans.now_selling' : 'plans.withdrawn'));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function render()
|
|
||||||
{
|
|
||||||
$now = now();
|
|
||||||
|
|
||||||
$families = PlanFamily::query()
|
|
||||||
->with(['versions.prices'])
|
|
||||||
->withCount('versions')
|
|
||||||
->orderBy('tier')
|
|
||||||
->get()
|
|
||||||
->map(fn (PlanFamily $family) => [
|
|
||||||
'uuid' => $family->uuid,
|
|
||||||
'key' => $family->key,
|
|
||||||
'name' => $family->name,
|
|
||||||
'tier' => $family->tier,
|
|
||||||
'sales_enabled' => $family->sales_enabled,
|
|
||||||
'is_recommended' => $family->is_recommended,
|
|
||||||
'versions_count' => $family->versions_count,
|
|
||||||
'live' => $family->versions->first(fn ($v) => $v->isAvailableAt($now)),
|
|
||||||
// What is queued up next, so a scheduled launch is visible
|
|
||||||
// before a customer discovers it.
|
|
||||||
'next' => $family->versions
|
|
||||||
->filter(fn ($v) => $v->isPublished() && $v->available_from->greaterThan($now))
|
|
||||||
->sortBy('available_from')
|
|
||||||
->first(),
|
|
||||||
'drafts' => $family->versions->filter(fn ($v) => ! $v->isPublished())->count(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return view('livewire.admin.plans', [
|
|
||||||
'families' => $families,
|
|
||||||
// The shop's own answer, so this page cannot disagree with what a
|
|
||||||
// customer actually sees.
|
|
||||||
'sellable' => array_keys(app(PlanCatalogue::class)->sellable()),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,160 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Livewire\Admin;
|
|
||||||
|
|
||||||
use App\Livewire\Concerns\BuildsRunSteps;
|
|
||||||
use App\Models\Host;
|
|
||||||
use App\Models\Instance;
|
|
||||||
use App\Models\Order;
|
|
||||||
use App\Models\ProvisioningRun;
|
|
||||||
use App\Provisioning\Jobs\AdvanceRunJob;
|
|
||||||
use Livewire\Attributes\Layout;
|
|
||||||
use Livewire\Attributes\On;
|
|
||||||
use Livewire\Component;
|
|
||||||
|
|
||||||
#[Layout('layouts.admin')]
|
|
||||||
class Provisioning extends Component
|
|
||||||
{
|
|
||||||
use BuildsRunSteps;
|
|
||||||
|
|
||||||
#[On('echo-private:admin.runs,StepAdvanced')]
|
|
||||||
public function onStepAdvanced(): void
|
|
||||||
{
|
|
||||||
// A round-trip re-renders with fresh run state.
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Retry a failed run from its current step (mirrors the host-detail retry). */
|
|
||||||
public function retry(string $uuid): void
|
|
||||||
{
|
|
||||||
$this->authorize('provisioning.retry');
|
|
||||||
// Atomically claim the run: only the request that transitions it out of
|
|
||||||
// FAILED proceeds, so two concurrent retries can't double-dispatch.
|
|
||||||
$claimed = ProvisioningRun::query()
|
|
||||||
->where('uuid', $uuid)
|
|
||||||
->where('status', ProvisioningRun::STATUS_FAILED)
|
|
||||||
->update([
|
|
||||||
'status' => ProvisioningRun::STATUS_RUNNING,
|
|
||||||
'attempt' => 0,
|
|
||||||
'next_attempt_at' => now(),
|
|
||||||
'started_at' => now(), // reset the step timer so it doesn't re-time-out instantly
|
|
||||||
'error' => null,
|
|
||||||
]);
|
|
||||||
if ($claimed === 0) {
|
|
||||||
return; // already retried by a concurrent request
|
|
||||||
}
|
|
||||||
$run = ProvisioningRun::query()->where('uuid', $uuid)->first();
|
|
||||||
if ($run === null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Move the subject out of its error state so the console reflects the retry.
|
|
||||||
$subject = $run->subject;
|
|
||||||
if ($subject instanceof Host) {
|
|
||||||
$subject->update(['status' => 'onboarding']);
|
|
||||||
} elseif ($subject instanceof Order) {
|
|
||||||
$subject->update(['status' => 'provisioning']);
|
|
||||||
Instance::query()->where('order_id', $subject->id)->where('status', 'failed')->update(['status' => 'provisioning']);
|
|
||||||
}
|
|
||||||
|
|
||||||
AdvanceRunJob::dispatch($run->uuid);
|
|
||||||
$this->dispatch('notify', message: __('admin.run_retried'));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A run that claims to be in progress but hasn't advanced in a while looks
|
|
||||||
* stuck — unless it is in a legitimate scheduled backoff (next_attempt_at in
|
|
||||||
* the future, e.g. RunRunner's retry delay of up to 300 s).
|
|
||||||
*/
|
|
||||||
private function isStale(ProvisioningRun $run): bool
|
|
||||||
{
|
|
||||||
if (! in_array($run->status, ['running', 'waiting'], true)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if ($run->next_attempt_at !== null && $run->next_attempt_at->isFuture()) {
|
|
||||||
return false; // waiting out a scheduled retry/poll — not stuck
|
|
||||||
}
|
|
||||||
|
|
||||||
// Compare against the CURRENT step's own allowed duration — some steps
|
|
||||||
// (VM clone, Proxmox install) legitimately run for many minutes — plus a
|
|
||||||
// small grace. Only past its own deadline without advancing is it stuck.
|
|
||||||
$pipeline = config('provisioning.pipelines.'.$run->pipeline, []);
|
|
||||||
$class = $pipeline[$run->current_step] ?? null;
|
|
||||||
$maxSeconds = $class !== null ? app($class)->maxDuration() : 120;
|
|
||||||
|
|
||||||
return $run->updated_at !== null && $run->updated_at->lt(now()->subSeconds($maxSeconds + 30));
|
|
||||||
}
|
|
||||||
|
|
||||||
private function subjectLabel(ProvisioningRun $run): string
|
|
||||||
{
|
|
||||||
$subject = $run->subject;
|
|
||||||
|
|
||||||
if ($subject instanceof Order) {
|
|
||||||
return $subject->customer?->name ?? 'Order';
|
|
||||||
}
|
|
||||||
if ($subject instanceof Host) {
|
|
||||||
return $subject->name;
|
|
||||||
}
|
|
||||||
|
|
||||||
return class_basename($run->subject_type);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function render()
|
|
||||||
{
|
|
||||||
$runs = ProvisioningRun::query()->latest('id')->limit(30)->get();
|
|
||||||
|
|
||||||
$active = $runs->first(fn (ProvisioningRun $r) => in_array(
|
|
||||||
$r->status, ['pending', 'running', 'waiting'], true
|
|
||||||
)) ?? $runs->first();
|
|
||||||
|
|
||||||
return view('livewire.admin.provisioning', [
|
|
||||||
'hasActive' => $active !== null && in_array($active->status, ['pending', 'running', 'waiting'], true),
|
|
||||||
'rows' => $runs->map(function (ProvisioningRun $r) {
|
|
||||||
$total = max(count(config('provisioning.pipelines.'.$r->pipeline, [])), 1);
|
|
||||||
$done = $r->status === ProvisioningRun::STATUS_COMPLETED ? $total : $r->current_step;
|
|
||||||
|
|
||||||
return [
|
|
||||||
'uuid' => $r->uuid,
|
|
||||||
'customer' => $this->subjectLabel($r),
|
|
||||||
'pipeline' => $r->pipeline,
|
|
||||||
'step' => $r->status === 'completed' ? '—' : $this->currentStepLabel($r),
|
|
||||||
'n' => ($r->current_step + 1).'/'.$total,
|
|
||||||
'percent' => (int) round($done / $total * 100),
|
|
||||||
'attempt' => $r->attempt,
|
|
||||||
'state' => $this->runState($r),
|
|
||||||
'failed' => $r->status === ProvisioningRun::STATUS_FAILED,
|
|
||||||
'activity' => $r->updated_at?->diffForHumans() ?? '—',
|
|
||||||
'stale' => $this->isStale($r),
|
|
||||||
];
|
|
||||||
})->all(),
|
|
||||||
'panel' => $active ? $this->panelFor($active) : null,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Compact "current run" readout for the right column. */
|
|
||||||
private function panelFor(ProvisioningRun $run): array
|
|
||||||
{
|
|
||||||
$pipeline = config('provisioning.pipelines.'.$run->pipeline, []);
|
|
||||||
$total = max(count($pipeline), 1);
|
|
||||||
$current = min($run->current_step, $total - 1);
|
|
||||||
$completed = $run->status === ProvisioningRun::STATUS_COMPLETED;
|
|
||||||
$done = $completed ? $total : $current;
|
|
||||||
|
|
||||||
return [
|
|
||||||
'uuid' => $run->uuid,
|
|
||||||
'subject' => $this->subjectLabel($run),
|
|
||||||
'pipeline' => $run->pipeline,
|
|
||||||
'attempt' => $run->attempt,
|
|
||||||
'status' => $this->runState($run), // running|done|failed
|
|
||||||
'failed' => $run->status === ProvisioningRun::STATUS_FAILED,
|
|
||||||
'current' => $completed ? null : (isset($pipeline[$current]) ? __(app($pipeline[$current])->label()) : null),
|
|
||||||
'next' => isset($pipeline[$current + 1]) && ! $completed ? __(app($pipeline[$current + 1])->label()) : null,
|
|
||||||
'error' => $run->error,
|
|
||||||
'done' => $done,
|
|
||||||
'total' => $total,
|
|
||||||
'percent' => (int) round($done / $total * 100),
|
|
||||||
'activity' => $run->updated_at?->diffForHumans() ?? '—',
|
|
||||||
'started' => $run->started_at?->diffForHumans() ?? null,
|
|
||||||
'stale' => $this->isStale($run),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,106 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Livewire\Admin;
|
|
||||||
|
|
||||||
use App\Actions\WithdrawContract;
|
|
||||||
use App\Models\Customer;
|
|
||||||
use App\Models\Subscription;
|
|
||||||
use App\Services\Billing\CustomDomainAccess;
|
|
||||||
use App\Services\Billing\WithdrawalRight;
|
|
||||||
use LivewireUI\Modal\ModalComponent;
|
|
||||||
use RuntimeException;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A withdrawal that arrived by telephone or by post, typed in by an operator.
|
|
||||||
*
|
|
||||||
* The law does not require a consumer to use our form. A sentence on the phone,
|
|
||||||
* a letter, an email — any unambiguous declaration inside the fourteen days
|
|
||||||
* ends the contract, and if the only way to record one were the portal button,
|
|
||||||
* the ones that came in every other way would be handled by hand in Stripe and
|
|
||||||
* never appear in the register at all.
|
|
||||||
*
|
|
||||||
* So it goes through the SAME action as the portal, with the channel recorded
|
|
||||||
* and the operator's name on it. Nothing is decided here that is not decided
|
|
||||||
* there: a business customer is refused, an expired window is refused, and the
|
|
||||||
* pro-rata share is worked out by the same arithmetic. An operator cannot
|
|
||||||
* override any of it, which is the point — a withdrawal an operator could
|
|
||||||
* approve out of the window would be a refund with no legal basis and no
|
|
||||||
* document that could justify it.
|
|
||||||
*
|
|
||||||
* `customers.manage` rather than `customers.grant_plan`: this is not a gift an
|
|
||||||
* operator decides to make, it is a declaration the customer has already made
|
|
||||||
* and somebody is writing down. Support answers the telephone, and Support holds
|
|
||||||
* `customers.manage`.
|
|
||||||
*/
|
|
||||||
class RecordWithdrawal extends ModalComponent
|
|
||||||
{
|
|
||||||
public string $customerUuid = '';
|
|
||||||
|
|
||||||
public string $customerName = '';
|
|
||||||
|
|
||||||
/** Why it cannot be recorded, in the customer's own words. Null when it can. */
|
|
||||||
public ?string $refusal = null;
|
|
||||||
|
|
||||||
public function mount(string $uuid): void
|
|
||||||
{
|
|
||||||
// Modals are reachable without the route middleware, so the permission
|
|
||||||
// is checked here as well as on the page that opens this one.
|
|
||||||
$this->authorize('customers.manage');
|
|
||||||
|
|
||||||
$customer = Customer::query()->where('uuid', $uuid)->firstOrFail();
|
|
||||||
|
|
||||||
$this->customerUuid = $uuid;
|
|
||||||
$this->customerName = (string) $customer->name;
|
|
||||||
$this->refusal = WithdrawalRight::for($this->contractOf($customer))->refusal;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function record(): void
|
|
||||||
{
|
|
||||||
$this->authorize('customers.manage');
|
|
||||||
|
|
||||||
$customer = Customer::query()->where('uuid', $this->customerUuid)->firstOrFail();
|
|
||||||
$contract = $this->contractOf($customer);
|
|
||||||
|
|
||||||
if ($contract === null) {
|
|
||||||
$this->refusal = __('withdrawal.refusal_no_contract');
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
app(WithdrawContract::class)(
|
|
||||||
$contract,
|
|
||||||
WithdrawContract::CHANNEL_OPERATOR,
|
|
||||||
// Who took it. Part of the evidence that a declaration was made
|
|
||||||
// at all — a refund with nobody's name against it is the one an
|
|
||||||
// auditor asks about.
|
|
||||||
auth('operator')->user(),
|
|
||||||
);
|
|
||||||
} catch (RuntimeException $e) {
|
|
||||||
$this->refusal = $e->getMessage();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->dispatch('notify', message: __('withdrawal.recorded', ['name' => $this->customerName]));
|
|
||||||
$this->closeModal();
|
|
||||||
}
|
|
||||||
|
|
||||||
private function contractOf(Customer $customer): ?Subscription
|
|
||||||
{
|
|
||||||
return app(CustomDomainAccess::class)->contractOf($customer);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function render()
|
|
||||||
{
|
|
||||||
$customer = Customer::query()->where('uuid', $this->customerUuid)->first();
|
|
||||||
$contract = $customer === null ? null : $this->contractOf($customer);
|
|
||||||
|
|
||||||
return view('livewire.admin.record-withdrawal', [
|
|
||||||
'right' => WithdrawalRight::for($contract),
|
|
||||||
'owedNetCents' => $contract === null ? 0 : WithdrawalRight::owedNetCents($contract),
|
|
||||||
'deliveredDays' => $contract === null ? 0 : WithdrawalRight::deliveredDays($contract),
|
|
||||||
'termDays' => $contract === null ? 0 : WithdrawalRight::termDays($contract),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue