Compare commits

..

40 Commits

Author SHA1 Message Date
002867850d Phase6c: Optional Company contact name
All checks were successful
Build and Push / build (push) Successful in 1m38s
2026-05-25 13:28:56 +02:00
eea027b3b0 Phase6c: Optional Company contact name
All checks were successful
Build and Push / build (push) Successful in 1m38s
2026-05-25 13:14:36 +02:00
522246e386 Phase6c: Optional Company contact name
All checks were successful
Build and Push / build (push) Successful in 1m40s
2026-05-25 12:54:12 +02:00
b3131f7710 Phase6: Customer Billing details
All checks were successful
Build and Push / build (push) Successful in 1m43s
2026-05-25 12:15:48 +02:00
fadfdd3435 Phase6: Customer Billing details
All checks were successful
Build and Push / build (push) Successful in 1m46s
2026-05-25 11:47:14 +02:00
427c7c6204 Phase5: Automate bill creation
All checks were successful
Build and Push / build (push) Successful in 1m43s
2026-05-25 10:41:51 +02:00
6a8ad7b4be Phase4: Stripe
All checks were successful
Build and Push / build (push) Successful in 1m40s
2026-05-25 00:14:20 +02:00
875ade4351 Phase4: Stripe
All checks were successful
Build and Push / build (push) Successful in 1m40s
2026-05-24 23:59:05 +02:00
2a0bb10531 Phase4: Stripe
Some checks failed
Build and Push / build (push) Failing after 56s
2026-05-24 23:54:49 +02:00
262250564a Phase4: Stripe
Some checks failed
Build and Push / build (push) Failing after 53s
2026-05-24 23:48:39 +02:00
a680d6de9f Phase4: Stripe
Some checks failed
Build and Push / build (push) Failing after 38s
2026-05-24 23:37:48 +02:00
4a5ae0bb8b Phase3: Billing Customerpage/Mailings
All checks were successful
Build and Push / build (push) Successful in 1m37s
2026-05-24 22:21:26 +02:00
c21b48c704 Phase3: Billing Customerpage/Mailings
All checks were successful
Build and Push / build (push) Successful in 1m33s
2026-05-24 21:47:37 +02:00
cf190e5ac5 Phase3: Billing Customerpage/Mailings
Some checks failed
Build and Push / build (push) Failing after 46s
2026-05-24 21:44:10 +02:00
a3b080f542 Phase2.5: Skill SetUp Process
All checks were successful
Build and Push / build (push) Successful in 1m41s
2026-05-24 18:35:36 +02:00
229bfea263 Phase2.5: Skill SetUp Process
All checks were successful
Build and Push / build (push) Successful in 1m39s
2026-05-24 17:51:09 +02:00
49b085e59e Phase2.5: Skill SetUp Process
All checks were successful
Build and Push / build (push) Successful in 1m39s
2026-05-24 17:25:08 +02:00
cd15b391ac Phase2: Invoicecomputation/AdminpricingUI/Ainvoicemgnt
All checks were successful
Build and Push / build (push) Successful in 1m34s
2026-05-24 16:38:41 +02:00
11d7dbb06e Phase2: Invoicecomputation/AdminpricingUI/Ainvoicemgnt
All checks were successful
Build and Push / build (push) Successful in 1m36s
2026-05-24 14:48:40 +02:00
d41f0b6ec9 Phase2: Invoicecomputation/AdminpricingUI/Ainvoicemgnt
Some checks failed
Build and Push / build (push) Failing after 53s
2026-05-24 14:40:15 +02:00
03f8dd9afe Phase2: Invoicecomputation/AdminpricingUI/Ainvoicemgnt
Some checks failed
Build and Push / build (push) Failing after 47s
2026-05-24 14:25:00 +02:00
d4fcc33bc1 Phase2: Invoicecomputation/AdminpricingUI/Ainvoicemgnt
Some checks failed
Build and Push / build (push) Failing after 45s
2026-05-24 14:12:26 +02:00
cdc2210eaf Phase2: Invoicecomputation/AdminpricingUI/Ainvoicemgnt
Some checks failed
Build and Push / build (push) Failing after 45s
2026-05-24 14:01:33 +02:00
6bf9caa53a Lock @react-pdf/renderer for Phase 2 billing
Some checks failed
Build and Push / build (push) Failing after 1m23s
2026-05-24 13:56:53 +02:00
c8ed27157f Phase2: Invoicecomputation/AdminpricingUI/Ainvoicemgnt
Some checks failed
Build and Push / build (push) Failing after 28s
2026-05-24 13:51:38 +02:00
6baca1a459 Phase1: Schema + skill event tracking
All checks were successful
Build and Push / build (push) Successful in 1m35s
2026-05-24 00:21:29 +02:00
faf49119ea Phase1: Schema + skill event tracking
All checks were successful
Build and Push / build (push) Successful in 1m27s
2026-05-23 23:50:42 +02:00
ce70fe8480 Phase1: Schema + skill event tracking
Some checks failed
Build and Push / build (push) Failing after 38s
2026-05-23 23:45:04 +02:00
55571b1e59 Threema UX: static file middleware fix, *AIAGENT display, info banner
All checks were successful
Build and Push / build (push) Successful in 1m26s
2026-05-17 17:29:25 +02:00
c0ff22394c Threema QR: on-demand modal + auto-open on first add
All checks were successful
Build and Push / build (push) Successful in 1m29s
2026-05-17 17:13:44 +02:00
395d2f43cc Threema: customer-friendly texts + QR setup component
All checks were successful
Build and Push / build (push) Successful in 1m27s
2026-05-17 16:50:23 +02:00
6f42b56ad5 Threema package: relay-managed channel users + provisioning endpoints
All checks were successful
Build and Push / build (push) Successful in 1m26s
2026-05-17 11:10:40 +02:00
85c4302f7a Threema Gateway
All checks were successful
Build and Push / build (push) Successful in 1m30s
2026-05-16 22:00:27 +02:00
726151d90b Add new TTS/STT Logic
Some checks failed
Build and Push / build (push) Failing after 45s
2026-05-16 19:55:51 +02:00
a13af83655 Adjust skills
All checks were successful
Build and Push / build (push) Successful in 1m31s
2026-05-11 21:25:09 +02:00
b58bdadad4 feat(openclaw): per-tenant tag override + platform default ConfigMap (tag-only)
All checks were successful
Build and Push / build (push) Successful in 1m52s
2026-05-10 21:15:53 +02:00
d375a099f0 Limit by tenant and org
All checks were successful
Build and Push / build (push) Successful in 1m26s
2026-05-02 23:43:02 +02:00
666dd64580 Budget setting and all dollar to chf
All checks were successful
Build and Push / build (push) Successful in 1m33s
2026-05-02 23:25:24 +02:00
188bef2ece Budget setting and all dollar to chf
All checks were successful
Build and Push / build (push) Successful in 1m28s
2026-05-02 23:16:14 +02:00
57258bca92 Budget setting and all dollar to chf
All checks were successful
Build and Push / build (push) Successful in 1m31s
2026-05-02 22:59:51 +02:00
103 changed files with 15123 additions and 401 deletions

View File

@@ -17,3 +17,7 @@ LITELLM_MASTER_KEY=
# Portal Database (CloudNativePG)
DATABASE_URL=postgresql://portal:${PORTAL_DB_PASSWORD}@portal-db-rw.pieced-system.svc:5432/portal
# Threema relay (central pieced-threema-gateway)
THREEMA_RELAY_URL=http://pieced-threema-gateway.threema-gateway.svc:8080
THREEMA_RELAY_ADMIN_TOKEN=__from_openbao__

134
README.md
View File

@@ -1,100 +1,54 @@
# PieCed Portal
# PieCed Portal — Billing Phase 1 patch (suspend-via-admin fix)
Customer self-service portal for the PieCed IT multi-tenant OpenClaw platform.
Single-file fix on top of the Phase 1 v2 drop.
## Stack
## What it fixes
| Layer | Choice |
|-------|--------|
| Framework | Next.js 15 LTS (App Router, standalone output, Turbopack) |
| Auth | NextAuth v5 + ZITADEL OIDC (CODE flow) |
| Tenant mgmt | Direct K8s API → `PiecedTenant` CRs (Option A) |
| Usage data | LiteLLM `/team/info` + `/global/spend/logs` |
| i18n | next-intl 4.x (en/de) |
| Styling | Tailwind CSS 4 |
| Deployment | Container in `pieced-system`, exposed at `app.pieced.ch` |
The admin panel's suspend/resume button hits
`/api/admin/tenants/[name]/suspend` (a different route from the
customer-side `/api/tenants/[name]/suspend`). The v2 drop only
hooked the customer route — admin suspends were going to K8s
without producing a row in `tenant_suspension_events`.
## Setup
This patch adds the same `recordSuspensionEvent` hook to the
admin route. No other code paths affected; no schema changes.
### 1. ZITADEL Application
In ZITADEL console (`auth.pieced.ch`), project "OpenClaw Platform":
1. Create Application → **PieCed Portal** → Web → Authentication Method: **CODE**
2. Redirect URI: `https://app.pieced.ch/api/auth/callback/zitadel`
3. Post-logout URI: `https://app.pieced.ch/login`
4. Note Client ID and Client Secret
### 2. OpenBao Secrets
```bash
bao kv put pieced/portal/oidc \
client_id="<from step 1>" \
client_secret="<from step 1>" \
nextauth_secret="$(openssl rand -base64 32)"
```
### 3. Build & Push
```bash
docker build -t registry.c5ai.ch/pieced/pieced-portal:0.1.0 .
docker push registry.c5ai.ch/pieced/pieced-portal:0.1.0
```
Update image tag in `pieced-gitops/apps/portal/deployment.yaml`, push, ArgoCD syncs.
### 4. DNS
Ensure `app.pieced.ch` A record → MetalLB ingress IP (or ExternalDNS handles it).
## Local Development
```bash
cp .env.example .env.local
# Fill in values — K8s client uses ~/.kube/config locally
npm install
npm run dev
```
## Project Structure
## Files
```
src/
├── app/
│ ├── api/
│ │ ├── auth/[...nextauth]/route.ts # NextAuth handler
│ │ ├── tenants/route.ts # Tenant CRUD (K8s API)
│ │ └── usage/route.ts # Usage stub
│ ├── [locale]/
│ │ ├── layout.tsx # Locale layout + NavShell
│ │ ├── page.tsx # Redirect → /dashboard
│ │ ├── login/page.tsx # ZITADEL sign-in
│ │ ├── dashboard/page.tsx # Customer dashboard
│ │ └── admin/page.tsx # Platform admin tenant list
│ ├── layout.tsx # Root layout
│ └── globals.css # Tailwind 4 theme
├── components/
│ ├── layout/nav-shell.tsx # Header + navigation
│ └── ui/ # Reusable UI components
├── i18n/
│ ├── routing.ts # next-intl 4.x routing config
│ ├── navigation.ts # Localized Link, redirect, etc.
│ └── request.ts # Server-side i18n config
├── lib/
│ ├── auth.ts # NextAuth v5 + ZITADEL config
│ ├── k8s.ts # K8s client for PiecedTenant CRs
│ ├── litellm.ts # LiteLLM API client
│ └── session.ts # Session helpers
├── messages/
│ ├── en.json
│ └── de.json
└── types/index.ts # Shared TypeScript types
src/app/api/admin/tenants/[name]/suspend/route.ts MODIFIED
```
## Session Roadmap
## Deploy
- **6.1** ← This session: scaffold, auth, basic pages
- **6.2**: Instance management, package config, usage display
- **6.3**: Onboarding flow (create ZITADEL org → PiecedTenant CR)
- **6.4**: Workspace editor (SOUL.md, AGENTS.md, TOOLS.md)
- **6.5**: Admin panel (tenant lifecycle, billing overview)
Extract over your `pieced-portal/` tree, rebuild, redeploy as
usual. After the new image is running, verify:
1. Suspend any test tenant from the `/admin` panel.
2. Check the events table:
```bash
kubectl -n pieced-system exec -it portal-db-1 -- psql -U postgres -d portal -c \
"SELECT * FROM tenant_suspension_events ORDER BY id DESC LIMIT 5;"
```
Expect a fresh `suspended` row for the tenant you just toggled.
3. Resume → expect a `resumed` row.
## Why I missed this
Both routes share the same shape (PATCH/POST that sets
`spec.suspend`), but they differ on:
- URL path (`/api/admin/tenants/...` vs `/api/tenants/...`)
- Method (POST vs PATCH)
- Authorization (platform-only vs owner+platform)
- Caller (admin panel vs customer cancel button)
When I grepped for the suspend hook target I matched on the
customer endpoint and didn't audit cross-cutting admin
duplicates. I've since checked every site that calls
`patchTenantSpec`, `createTenant`, or `deleteTenant` — this was
the only missed billing-relevant one. Other `patchTenantSpec`
sites are confirmed non-billing (openClawImage, channelUsers).

70
deploy/README-threema.md Normal file
View File

@@ -0,0 +1,70 @@
# Wiring Threema relay into the portal
Drop-in files in this archive:
```
src/lib/packages.ts # add 'threema' to catalog + customProvisioning flag
src/lib/threema-relay.ts # new — admin API client
src/app/api/tenants/[name]/threema/route.ts # new — POST provision / DELETE deprovision
src/app/api/tenants/[name]/threema/routes/route.ts # new — atomic add/remove of a single Threema ID
src/components/channel-users/channel-users.tsx # branch threema through relay-managed endpoint
src/components/packages/package-card.tsx # handle customProvisioning enable/disable
deploy/patch-i18n-threema.mjs # idempotent i18n key injection
```
## Manual steps after dropping in
1. `.env` (and `.env.example`) — add:
```
THREEMA_RELAY_URL=http://pieced-threema-gateway.threema-gateway.svc:8080
THREEMA_RELAY_ADMIN_TOKEN=__from_openbao__
```
The portal pod's OpenBao client should also read `secret/data/threema-gateway/admin` and surface `token` as this env var (existing ESO pattern in the portal's Helm chart).
2. Patch the message files (one-time):
```bash
node deploy/patch-i18n-threema.mjs
```
3. Re-export `CHANNEL_PACKAGE_IDS` is unchanged in source; verify
`tenants/[name]/page.tsx` still derives the enabled-channels list
from it — it should now include `threema` automatically once a
tenant has it in `spec.packages`.
4. Type-check:
```bash
npx tsc --noEmit
```
## Flow summary
### Enabling Threema for a tenant
1. Customer toggles the threema package card.
2. PackageCard sees `customProvisioning: true` → POSTs `/api/tenants/<name>/threema`.
3. Handler calls relay `POST /admin/tokens` → gets `{token, hmacSecret}`.
4. Handler writes them to OpenBao at `secret/data/tenants/tenant-<name>/threema-relay`.
5. PackageCard then PATCHes `tenant.spec.packages` to include `threema`.
6. Operator reconciles: ExternalSecret syncs OpenBao → Secret; OpenClaw pod restarts with `THREEMA_RELAY_*` env vars; plugin registers `threema` channel.
### Customer adds a Threema ID
1. UI calls `POST /api/tenants/<name>/threema/routes` with `{threemaId}`.
2. Handler calls relay `POST /admin/routes` (uniqueness enforced at PK).
3. On 201 or 409-from-same-tenant: handler patches K8s `spec.channelUsers.threema`.
4. On 409-from-other-tenant: 409 to client with explanation.
5. On K8s patch failure after relay success: handler compensates by `DELETE /admin/routes/...` at the relay.
### Customer removes a Threema ID
1. UI calls `DELETE /api/tenants/<name>/threema/routes?threemaId=...`.
2. Handler patches K8s `spec.channelUsers.threema` to drop the ID.
3. Handler calls relay `DELETE /admin/routes/...` (404 = idempotent OK).
4. If relay drop fails: K8s already updated, surface warning but treat as success — relay deletes are idempotent on retry.
### Disabling Threema for a tenant
1. Customer disables the threema card.
2. PackageCard DELETEs `/api/tenants/<name>/threema`.
3. Handler calls relay `DELETE /admin/tokens/<name>` (cascades to all routes for this tenant).
4. Handler deletes OpenBao secret at `secret/data/tenants/tenant-<name>/threema-relay`.
5. PackageCard then PATCHes `tenant.spec.packages` to drop `threema`.
6. Operator reconciles: ExternalSecret targets a missing OpenBao path → Secret deleted → OpenClaw pod restarts without `threema` channel.
There's a small window (between step 4 and the operator's reconcile) where the pod still thinks it has a relay token but the relay has revoked it. Outbound during that window returns 401 from the relay; inbound is blackholed at the relay because routes are gone. Both are graceful failures.

View File

@@ -0,0 +1,130 @@
#!/usr/bin/env node
/**
* Run: node deploy/patch-i18n-threema.mjs
*
* Idempotently injects (or overwrites) customer-facing Threema texts:
* - packages.threema.{description, instructions, disclaimer}
* - channelUsers.threemaIdHelp
* - channelUsers.threemaSetup.{title, step1, step2, step3, qrAlt}
*
* Replaces the earlier version of this script entirely. The new texts:
* - Drop "Gateway account" jargon (customers don't know it)
* - Drop asterisk-prefix references (customers don't see / type it)
* - Tell the customer to add their OWN Threema ID, not someone else's
* - Disclose that Threema charges per message via the gateway
* - Walk through the QR-scan + add-your-ID flow explicitly
*
* Re-running is safe — keys are set, not merged, so this is the
* source of truth for the values it touches.
*/
import { readFileSync, writeFileSync } from "fs";
const i18n = {
en: {
pkg: {
description:
"Send and receive messages through Threema. Each inbound and outbound message uses the shared PieCed messaging service and incurs a per-message charge from Threema — a third-party cost, separate from your PieCed subscription.",
instructions:
"1. Enable this package.\n2. Open Threema on your phone, scan the QR code shown under Authorized Users → threema, and accept the contact.\n3. Add your own Threema ID under Authorized Users → threema so the assistant recognises your messages.\n4. Send a message from Threema to start chatting with the assistant.",
disclaimer:
"Messages between Threema and PieCed are end-to-end encrypted up to PieCed's messaging service, where they are decrypted to be routed to your assistant. Each message sent or received is counted toward Threema's per-message billing — see your plan for current rates.",
},
channelHelp:
"Enter your own Threema ID — the 8 characters shown in your Threema app under Settings → My Threema ID. Once added, you'll be able to chat with the assistant directly from Threema.",
setup: {
title: "Add the assistant to your Threema",
step1: "Open Threema on your phone.",
step2: "Tap the scan icon and scan this QR code to add the assistant as a contact.",
step3: "Then add your own Threema ID below.",
qrAlt: "QR code to add {gateway} as a Threema contact",
bannerTitle: "Set up Threema",
bannerBody: "Open Threema on your phone and scan our QR code to add the assistant as a contact. Then add your own Threema ID below.",
bannerButton: "Show QR code",
},
},
de: {
pkg: {
description:
"Senden und empfangen Sie Nachrichten über Threema. Jede eingehende und ausgehende Nachricht läuft über den gemeinsamen PieCed-Messaging-Dienst und verursacht eine Gebühr pro Nachricht bei Threema — eine Drittanbieter-Kostenposition, unabhängig von Ihrem PieCed-Abonnement.",
instructions:
"1. Aktivieren Sie dieses Paket.\n2. Öffnen Sie Threema auf Ihrem Telefon, scannen Sie den QR-Code unter Autorisierte Benutzer → threema und akzeptieren Sie den Kontakt.\n3. Tragen Sie Ihre eigene Threema-ID unter Autorisierte Benutzer → threema ein, damit der Assistent Ihre Nachrichten erkennt.\n4. Schreiben Sie eine Nachricht aus Threema, um das Gespräch zu beginnen.",
disclaimer:
"Nachrichten zwischen Threema und PieCed werden Ende-zu-Ende verschlüsselt bis zum PieCed-Messaging-Dienst, wo sie entschlüsselt und an Ihren Assistenten weitergeleitet werden. Jede gesendete oder empfangene Nachricht wird gemäss Threema-Tarif pro Nachricht abgerechnet — die aktuellen Preise finden Sie in Ihrem Plan.",
},
channelHelp:
"Geben Sie Ihre eigene Threema-ID ein — die 8 Zeichen, die in Ihrer Threema-App unter Einstellungen → Meine Threema-ID angezeigt werden. Anschliessend können Sie direkt aus Threema mit dem Assistenten chatten.",
setup: {
title: "Assistenten zu Threema hinzufügen",
step1: "Öffnen Sie Threema auf Ihrem Telefon.",
step2: "Tippen Sie auf das Scan-Symbol und scannen Sie diesen QR-Code, um den Assistenten als Kontakt hinzuzufügen.",
step3: "Fügen Sie anschliessend unten Ihre eigene Threema-ID hinzu.",
qrAlt: "QR-Code, um {gateway} als Threema-Kontakt hinzuzufügen",
bannerTitle: "Threema einrichten",
bannerBody: "Öffnen Sie Threema auf Ihrem Telefon und scannen Sie unseren QR-Code, um den Assistenten als Kontakt hinzuzufügen. Geben Sie anschliessend unten Ihre eigene Threema-ID ein.",
bannerButton: "QR-Code anzeigen",
},
},
fr: {
pkg: {
description:
"Envoyez et recevez des messages via Threema. Chaque message entrant ou sortant transite par le service de messagerie PieCed partagé et entraîne des frais par message facturés par Threema — un coût tiers, distinct de votre abonnement PieCed.",
instructions:
"1. Activez ce package.\n2. Ouvrez Threema sur votre téléphone, scannez le QR code affiché dans Utilisateurs autorisés → threema, puis acceptez le contact.\n3. Ajoutez votre propre identifiant Threema sous Utilisateurs autorisés → threema afin que l'assistant reconnaisse vos messages.\n4. Envoyez un message depuis Threema pour commencer la conversation.",
disclaimer:
"Les messages entre Threema et PieCed sont chiffrés de bout en bout jusqu'au service de messagerie PieCed, où ils sont déchiffrés pour être acheminés vers votre assistant. Chaque message envoyé ou reçu est facturé par Threema selon son tarif par message — consultez votre plan pour les tarifs en vigueur.",
},
channelHelp:
"Saisissez votre propre identifiant Threema — les 8 caractères affichés dans votre application Threema sous Réglages → Mon identifiant Threema. Une fois ajouté, vous pourrez discuter directement avec l'assistant depuis Threema.",
setup: {
title: "Ajouter l'assistant à Threema",
step1: "Ouvrez Threema sur votre téléphone.",
step2: "Appuyez sur l'icône de scan et scannez ce QR code pour ajouter l'assistant comme contact.",
step3: "Puis ajoutez votre propre identifiant Threema ci-dessous.",
qrAlt: "QR code pour ajouter {gateway} comme contact Threema",
bannerTitle: "Configurer Threema",
bannerBody: "Ouvrez Threema sur votre téléphone et scannez notre QR code pour ajouter l'assistant comme contact. Saisissez ensuite votre propre identifiant Threema ci-dessous.",
bannerButton: "Afficher le QR code",
},
},
it: {
pkg: {
description:
"Invia e ricevi messaggi tramite Threema. Ogni messaggio in entrata e in uscita passa attraverso il servizio di messaggistica condiviso di PieCed e comporta un addebito per messaggio da parte di Threema — un costo di terzi, separato dall'abbonamento PieCed.",
instructions:
"1. Attiva questo pacchetto.\n2. Apri Threema sul tuo telefono, scansiona il QR code mostrato in Utenti autorizzati → threema e accetta il contatto.\n3. Aggiungi il tuo ID Threema sotto Utenti autorizzati → threema affinché l'assistente riconosca i tuoi messaggi.\n4. Invia un messaggio da Threema per iniziare la conversazione.",
disclaimer:
"I messaggi tra Threema e PieCed sono cifrati end-to-end fino al servizio di messaggistica PieCed, dove vengono decifrati per essere inoltrati al tuo assistente. Ogni messaggio inviato o ricevuto viene addebitato da Threema secondo la sua tariffa per messaggio — consulta il tuo piano per i prezzi attuali.",
},
channelHelp:
"Inserisci il tuo ID Threema — gli 8 caratteri mostrati nella tua app Threema sotto Impostazioni → Il mio ID Threema. Una volta aggiunto, potrai conversare con l'assistente direttamente da Threema.",
setup: {
title: "Aggiungi l'assistente a Threema",
step1: "Apri Threema sul tuo telefono.",
step2: "Tocca l'icona di scansione e scansiona questo QR code per aggiungere l'assistente ai contatti.",
step3: "Quindi aggiungi il tuo ID Threema qui sotto.",
qrAlt: "QR code per aggiungere {gateway} come contatto Threema",
bannerTitle: "Configura Threema",
bannerBody: "Apri Threema sul tuo telefono e scansiona il nostro QR code per aggiungere l'assistente ai contatti. Inserisci poi il tuo ID Threema qui sotto.",
bannerButton: "Mostra QR code",
},
},
};
for (const [lang, entries] of Object.entries(i18n)) {
const path = `src/messages/${lang}.json`;
const json = JSON.parse(readFileSync(path, "utf8"));
json.packages = json.packages ?? {};
json.packages.threema = {
description: entries.pkg.description,
instructions: entries.pkg.instructions,
disclaimer: entries.pkg.disclaimer,
};
json.channelUsers = json.channelUsers ?? {};
json.channelUsers.threemaIdHelp = entries.channelHelp;
json.channelUsers.threemaSetup = entries.setup;
writeFileSync(path, JSON.stringify(json, null, 2) + "\n");
console.log(`Patched ${path}`);
}

View File

@@ -5,7 +5,11 @@ const withNextIntl = createNextIntlPlugin();
/** @type {import('next').NextConfig} */
const nextConfig = {
output: "standalone",
serverExternalPackages: ["pg"],
// pg uses native node bindings, @react-pdf/renderer pulls in
// fontkit / pdfkit which don't play nicely with webpack bundling.
// Both are pure server-side concerns; mark external so Next ships
// them as Node modules rather than bundling.
serverExternalPackages: ["pg", "@react-pdf/renderer"],
};
export default withNextIntl(nextConfig);

587
package-lock.json generated
View File

@@ -9,6 +9,7 @@
"version": "0.1.0",
"dependencies": {
"@kubernetes/client-node": "^1.4.0",
"@react-pdf/renderer": "^4.4.0",
"@types/nodemailer": "^8.0.0",
"@types/pg": "^8.20.0",
"next": "^15.5.15",
@@ -18,6 +19,7 @@
"pg": "^8.20.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"stripe": "^22.1.1",
"zod": "^3.24.0"
},
"devDependencies": {
@@ -73,6 +75,15 @@
}
}
},
"node_modules/@babel/runtime": {
"version": "7.29.2",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz",
"integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@emnapi/core": {
"version": "1.9.2",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz",
@@ -1089,6 +1100,30 @@
"node": ">= 10"
}
},
"node_modules/@noble/ciphers": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz",
"integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==",
"license": "MIT",
"engines": {
"node": "^14.21.3 || >=16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@noble/hashes": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
"integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
"license": "MIT",
"engines": {
"node": "^14.21.3 || >=16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@@ -1453,6 +1488,183 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/@react-pdf/fns": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@react-pdf/fns/-/fns-3.1.3.tgz",
"integrity": "sha512-0I7pApDr1/RLAKbizuLy/IHTEa93LSPy/bEwYniboC3Xqnp6Od8xFJKbKEzGw2wh/5zKFFwl00g4t9RwgIMc3w==",
"license": "MIT"
},
"node_modules/@react-pdf/font": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/@react-pdf/font/-/font-4.0.8.tgz",
"integrity": "sha512-deNd+emtZAJho1IlzKL9bRoLAGv/6oXOIKO2oZfs4RuXUrK1onLHbJO7e2YoVLPFP/sQxisRTnzdJFtd35iKwA==",
"license": "MIT",
"dependencies": {
"@react-pdf/pdfkit": "^5.1.1",
"@react-pdf/types": "^2.11.1",
"fontkit": "^2.0.2",
"is-url": "^1.2.4"
}
},
"node_modules/@react-pdf/image": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@react-pdf/image/-/image-3.1.0.tgz",
"integrity": "sha512-ks7Ry8v711r8NvKWSELehj0BXBNPRihSnWsM09nDD8Ur175zbWBCK217LLwQMKDNYDVpkZaipdoJPom1LGaE9g==",
"license": "MIT",
"dependencies": {
"@react-pdf/svg": "^1.1.0",
"jay-peg": "^1.1.1",
"png-js": "^2.0.0"
}
},
"node_modules/@react-pdf/layout": {
"version": "4.6.1",
"resolved": "https://registry.npmjs.org/@react-pdf/layout/-/layout-4.6.1.tgz",
"integrity": "sha512-gN6PmWoEffvlIkifLfEhMsVucRywVMyH3rnxdyOVOhGy0nWJKKGpHyPc4plbDdpP6EfZ0r8prHXujDSkIG2nSA==",
"license": "MIT",
"dependencies": {
"@react-pdf/fns": "3.1.3",
"@react-pdf/image": "^3.1.0",
"@react-pdf/primitives": "^4.3.0",
"@react-pdf/stylesheet": "^6.2.1",
"@react-pdf/textkit": "^6.3.0",
"@react-pdf/types": "^2.11.1",
"emoji-regex-xs": "^1.0.0",
"queue": "^6.0.1",
"yoga-layout": "^3.2.1"
}
},
"node_modules/@react-pdf/pdfkit": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/@react-pdf/pdfkit/-/pdfkit-5.1.1.tgz",
"integrity": "sha512-wNcdSsNlNYyGHGAgIdt453egBF7fiF9UxpRlklUfVvu8OWCrUppG9xiUrPLVoKiqWet5tMi0w6LmuFUJuYqjEg==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.20.13",
"@noble/ciphers": "^1.0.0",
"@noble/hashes": "^1.6.0",
"browserify-zlib": "^0.2.0",
"fontkit": "^2.0.2",
"jay-peg": "^1.1.1",
"js-md5": "^0.8.3",
"linebreak": "^1.1.0",
"png-js": "^2.0.0",
"vite-compatible-readable-stream": "^3.6.1"
}
},
"node_modules/@react-pdf/primitives": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/@react-pdf/primitives/-/primitives-4.3.0.tgz",
"integrity": "sha512-nYXoZ36pvwNzbc54+DbL8RCn15jU7woJ9D/svnh5tpUXekJ+CbI4mZLo6boSv24CvJgychOu6h7gxX03B4ps0A==",
"license": "MIT"
},
"node_modules/@react-pdf/reconciler": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@react-pdf/reconciler/-/reconciler-2.0.0.tgz",
"integrity": "sha512-7zaPRujpbHSmCpIrZ+b9HSTJHthcVZzX0Wx7RzvQGsGBUbHP4p6s5itXrAIOuQuPvDepoHGNOvf6xUuMVvdoyw==",
"license": "MIT",
"dependencies": {
"object-assign": "^4.1.1",
"scheduler": "0.25.0-rc-603e6108-20241029"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@react-pdf/reconciler/node_modules/scheduler": {
"version": "0.25.0-rc-603e6108-20241029",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.25.0-rc-603e6108-20241029.tgz",
"integrity": "sha512-pFwF6H1XrSdYYNLfOcGlM28/j8CGLu8IvdrxqhjWULe2bPcKiKW4CV+OWqR/9fT52mywx65l7ysNkjLKBda7eA==",
"license": "MIT"
},
"node_modules/@react-pdf/render": {
"version": "4.5.1",
"resolved": "https://registry.npmjs.org/@react-pdf/render/-/render-4.5.1.tgz",
"integrity": "sha512-IW/N4HWJWtioBXCf7n02IR24VJJ8gbdS3jGypf+vW/rSErEx3/URRzh9UK6Ma8Fpog9+T/W6GE2NHJ5AAKHhVA==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.20.13",
"@react-pdf/fns": "3.1.3",
"@react-pdf/primitives": "^4.3.0",
"@react-pdf/textkit": "^6.3.0",
"@react-pdf/types": "^2.11.1",
"abs-svg-path": "^0.1.1",
"color-string": "^2.1.4",
"normalize-svg-path": "^1.1.0",
"parse-svg-path": "^0.1.2",
"svg-arc-to-cubic-bezier": "^3.2.0"
}
},
"node_modules/@react-pdf/renderer": {
"version": "4.5.1",
"resolved": "https://registry.npmjs.org/@react-pdf/renderer/-/renderer-4.5.1.tgz",
"integrity": "sha512-5r1VQrE6FRLXX5wWUxwZzM24E2BJMo6g8AQWuS8WyPs9ugu5yMnb2g8/RpPYka/Z6J+RUEWc32wty2NoUJF42Q==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.20.13",
"@react-pdf/fns": "3.1.3",
"@react-pdf/font": "^4.0.8",
"@react-pdf/layout": "^4.6.1",
"@react-pdf/pdfkit": "^5.1.1",
"@react-pdf/primitives": "^4.3.0",
"@react-pdf/reconciler": "^2.0.0",
"@react-pdf/render": "^4.5.1",
"@react-pdf/types": "^2.11.1",
"events": "^3.3.0",
"object-assign": "^4.1.1",
"prop-types": "^15.6.2",
"queue": "^6.0.1"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@react-pdf/stylesheet": {
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/@react-pdf/stylesheet/-/stylesheet-6.2.1.tgz",
"integrity": "sha512-2+UEk+7e+z8baaWi2l5kPLWmwtJeOI+T5wW9GGeN3iDH7vd3kbTqOpN1yt9mmfNVZFxQsnDHpznFb5v5UF983A==",
"license": "MIT",
"dependencies": {
"@react-pdf/fns": "3.1.3",
"@react-pdf/types": "^2.11.1",
"color-string": "^2.1.4",
"hsl-to-hex": "^1.0.0",
"media-engine": "^1.0.3",
"postcss-value-parser": "^4.1.0"
}
},
"node_modules/@react-pdf/svg": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@react-pdf/svg/-/svg-1.1.0.tgz",
"integrity": "sha512-cTIHXiz9x1HrbfqzfxfZP3FRdDwUXG77QWF6Fb5MP/lV3ONxR+g0Z3hwtBatCS9HeGBQCpxX/Lzb8wHE+co1PA==",
"license": "MIT",
"dependencies": {
"@react-pdf/primitives": "^4.3.0"
}
},
"node_modules/@react-pdf/textkit": {
"version": "6.3.0",
"resolved": "https://registry.npmjs.org/@react-pdf/textkit/-/textkit-6.3.0.tgz",
"integrity": "sha512-v6+V8nAcVwm7s2s1jIG2MD3Iw//x/k+XrH1foWOELBE4b32pyDgKyPXN/6KJE0dnX7+fVy27uctLNCLNMvzKzQ==",
"license": "MIT",
"dependencies": {
"@react-pdf/fns": "3.1.3",
"bidi-js": "^1.0.2",
"hyphen": "^1.6.4",
"unicode-properties": "^1.4.1"
}
},
"node_modules/@react-pdf/types": {
"version": "2.11.1",
"resolved": "https://registry.npmjs.org/@react-pdf/types/-/types-2.11.1.tgz",
"integrity": "sha512-i9xQgfaDU9QoeNnbp6rltXCWg1huEh195rpOuN8cE4BZ2FuLdQrsIcb2dhFF9aOxXf+XBA6LOSpIW051MDD/bw==",
"license": "MIT",
"dependencies": {
"@react-pdf/font": "^4.0.8",
"@react-pdf/primitives": "^4.3.0",
"@react-pdf/stylesheet": "^6.2.1"
}
},
"node_modules/@rtsao/scc": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
@@ -2617,6 +2829,12 @@
"win32"
]
},
"node_modules/abs-svg-path": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/abs-svg-path/-/abs-svg-path-0.1.1.tgz",
"integrity": "sha512-d8XPSGjfyzlXC3Xx891DJRyZfqk5JU0BJrDQcsWomFIV1/BIzPW5HDH5iDdWpqWaav0YVIEzT1RHTwWr0FFshA==",
"license": "MIT"
},
"node_modules/acorn": {
"version": "8.16.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
@@ -3029,6 +3247,35 @@
"bare-path": "^3.0.0"
}
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/bidi-js": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
"integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==",
"license": "MIT",
"dependencies": {
"require-from-string": "^2.0.2"
}
},
"node_modules/brace-expansion": {
"version": "1.1.13",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz",
@@ -3053,6 +3300,24 @@
"node": ">=8"
}
},
"node_modules/brotli": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.3.tgz",
"integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==",
"license": "MIT",
"dependencies": {
"base64-js": "^1.1.2"
}
},
"node_modules/browserify-zlib": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/browserify-zlib/-/browserify-zlib-0.2.0.tgz",
"integrity": "sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA==",
"license": "MIT",
"dependencies": {
"pako": "~1.0.5"
}
},
"node_modules/call-bind": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz",
@@ -3155,6 +3420,15 @@
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
"license": "MIT"
},
"node_modules/clone": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz",
"integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==",
"license": "MIT",
"engines": {
"node": ">=0.8"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -3175,6 +3449,27 @@
"dev": true,
"license": "MIT"
},
"node_modules/color-string": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/color-string/-/color-string-2.1.4.tgz",
"integrity": "sha512-Bb6Cq8oq0IjDOe8wJmi4JeNn763Xs9cfrBcaylK1tPypWzyoy2G3l90v9k64kjphl/ZJjPIShFztenRomi8WTg==",
"license": "MIT",
"dependencies": {
"color-name": "^2.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/color-string/node_modules/color-name": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-2.1.0.tgz",
"integrity": "sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg==",
"license": "MIT",
"engines": {
"node": ">=12.20"
}
},
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
@@ -3355,6 +3650,12 @@
"node": ">=8"
}
},
"node_modules/dfa": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/dfa/-/dfa-1.2.0.tgz",
"integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==",
"license": "MIT"
},
"node_modules/doctrine": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz",
@@ -3389,6 +3690,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/emoji-regex-xs": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex-xs/-/emoji-regex-xs-1.0.0.tgz",
"integrity": "sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg==",
"license": "MIT"
},
"node_modules/end-of-stream": {
"version": "1.4.5",
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
@@ -4006,6 +4313,15 @@
"node": ">=0.10.0"
}
},
"node_modules/events": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
"integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
"license": "MIT",
"engines": {
"node": ">=0.8.x"
}
},
"node_modules/events-universal": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz",
@@ -4019,7 +4335,6 @@
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"dev": true,
"license": "MIT"
},
"node_modules/fast-fifo": {
@@ -4082,6 +4397,12 @@
"reusify": "^1.0.4"
}
},
"node_modules/fflate": {
"version": "0.8.3",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
"license": "MIT"
},
"node_modules/file-entry-cache": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
@@ -4146,6 +4467,23 @@
"dev": true,
"license": "ISC"
},
"node_modules/fontkit": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/fontkit/-/fontkit-2.0.4.tgz",
"integrity": "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==",
"license": "MIT",
"dependencies": {
"@swc/helpers": "^0.5.12",
"brotli": "^1.3.2",
"clone": "^2.1.2",
"dfa": "^1.2.0",
"fast-deep-equal": "^3.1.3",
"restructure": "^3.0.0",
"tiny-inflate": "^1.0.3",
"unicode-properties": "^1.4.0",
"unicode-trie": "^2.0.0"
}
},
"node_modules/for-each": {
"version": "0.3.5",
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
@@ -4458,6 +4796,27 @@
"node": ">=14"
}
},
"node_modules/hsl-to-hex": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/hsl-to-hex/-/hsl-to-hex-1.0.0.tgz",
"integrity": "sha512-K6GVpucS5wFf44X0h2bLVRDsycgJmf9FF2elg+CrqD8GcFU8c6vYhgXn8NjUkFCwj+xDFb70qgLbTUm6sxwPmA==",
"license": "MIT",
"dependencies": {
"hsl-to-rgb-for-reals": "^1.1.0"
}
},
"node_modules/hsl-to-rgb-for-reals": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/hsl-to-rgb-for-reals/-/hsl-to-rgb-for-reals-1.1.1.tgz",
"integrity": "sha512-LgOWAkrN0rFaQpfdWBQlv/VhkOxb5AsBjk6NQVx4yEzWS923T07X0M1Y0VNko2H52HeSpZrZNNMJ0aFqsdVzQg==",
"license": "ISC"
},
"node_modules/hyphen": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/hyphen/-/hyphen-1.14.1.tgz",
"integrity": "sha512-kvL8xYl5QMTh+LwohVN72ciOxC0OEV79IPdJSTwEXok9y9QHebXGdFgrED4sWfiax/ODx++CAMk3hMy4XPJPOw==",
"license": "ISC"
},
"node_modules/icu-minify": {
"version": "4.9.0",
"resolved": "https://registry.npmjs.org/icu-minify/-/icu-minify-4.9.0.tgz",
@@ -4510,6 +4869,12 @@
"node": ">=0.8.19"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/internal-slot": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz",
@@ -4899,6 +5264,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-url": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/is-url/-/is-url-1.2.4.tgz",
"integrity": "sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==",
"license": "MIT"
},
"node_modules/is-weakmap": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz",
@@ -4986,6 +5357,15 @@
"node": ">= 0.4"
}
},
"node_modules/jay-peg": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/jay-peg/-/jay-peg-1.1.1.tgz",
"integrity": "sha512-D62KEuBxz/ip2gQKOEhk/mx14o7eiFRaU+VNNSP4MOiIkwb/D6B3G1Mfas7C/Fit8EsSV2/IWjZElx/Gs6A4ww==",
"license": "MIT",
"dependencies": {
"restructure": "^3.0.0"
}
},
"node_modules/jiti": {
"version": "2.6.1",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
@@ -5005,11 +5385,16 @@
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/js-md5": {
"version": "0.8.3",
"resolved": "https://registry.npmjs.org/js-md5/-/js-md5-0.8.3.tgz",
"integrity": "sha512-qR0HB5uP6wCuRMrWPTrkMaev7MJZwJuuw4fnwAzRgP4J4/F8RwtodOKpGp4XpqsLBFzzgqIO42efFAyz2Et6KQ==",
"license": "MIT"
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"dev": true,
"license": "MIT"
},
"node_modules/js-yaml": {
@@ -5406,6 +5791,25 @@
"url": "https://opencollective.com/parcel"
}
},
"node_modules/linebreak": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/linebreak/-/linebreak-1.1.0.tgz",
"integrity": "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==",
"license": "MIT",
"dependencies": {
"base64-js": "0.0.8",
"unicode-trie": "^2.0.0"
}
},
"node_modules/linebreak/node_modules/base64-js": {
"version": "0.0.8",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-0.0.8.tgz",
"integrity": "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/locate-path": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
@@ -5433,7 +5837,6 @@
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"js-tokens": "^3.0.0 || ^4.0.0"
@@ -5461,6 +5864,12 @@
"node": ">= 0.4"
}
},
"node_modules/media-engine": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/media-engine/-/media-engine-1.0.3.tgz",
"integrity": "sha512-aa5tG6sDoK+k70B9iEX1NeyfT8ObCKhNDs6lJVpwF6r8vhUfuKMslIcirq6HIUYuuUYLefcEQOn9bSBOvawtwg==",
"license": "MIT"
},
"node_modules/merge2": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
@@ -5844,6 +6253,15 @@
"node": ">=6.0.0"
}
},
"node_modules/normalize-svg-path": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/normalize-svg-path/-/normalize-svg-path-1.1.0.tgz",
"integrity": "sha512-r9KHKG2UUeB5LoTouwDzBy2VxXlHsiM6fyLQvnJa0S5hrhzqElH/CH7TUGhT1fVvIYBIKf3OpY4YJ4CK+iaqHg==",
"license": "MIT",
"dependencies": {
"svg-arc-to-cubic-bezier": "^3.0.0"
}
},
"node_modules/oauth4webapi": {
"version": "3.8.5",
"resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.5.tgz",
@@ -5857,7 +6275,6 @@
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -6066,6 +6483,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/pako": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
"license": "(MIT AND Zlib)"
},
"node_modules/parent-module": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@@ -6079,6 +6502,12 @@
"node": ">=6"
}
},
"node_modules/parse-svg-path": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/parse-svg-path/-/parse-svg-path-0.1.2.tgz",
"integrity": "sha512-JyPSBnkTJ0AI8GGJLfMXvKq42cj5c006fnLz6fXy6zfoVjJizi8BNTpu8on8ziI1cKy9d9DGNuY17Ce7wuejpQ==",
"license": "MIT"
},
"node_modules/path-exists": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
@@ -6214,6 +6643,14 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/png-js": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/png-js/-/png-js-2.0.0.tgz",
"integrity": "sha512-GdzJuUMc6ZSpxFJWVxtOH1bzYHym+TOnveqUjb+VJIbZWbZzyiRGFiKhbiielfpYbgMlhHVhsJ0FTazfuRFkMA==",
"dependencies": {
"fflate": "^0.8.2"
}
},
"node_modules/po-parser": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/po-parser/-/po-parser-2.1.1.tgz",
@@ -6259,6 +6696,12 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/postcss-value-parser": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
"license": "MIT"
},
"node_modules/postgres-array": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
@@ -6331,7 +6774,6 @@
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
"dev": true,
"license": "MIT",
"dependencies": {
"loose-envify": "^1.4.0",
@@ -6359,6 +6801,15 @@
"node": ">=6"
}
},
"node_modules/queue": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/queue/-/queue-6.0.2.tgz",
"integrity": "sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==",
"license": "MIT",
"dependencies": {
"inherits": "~2.0.3"
}
},
"node_modules/queue-microtask": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
@@ -6405,7 +6856,6 @@
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"dev": true,
"license": "MIT"
},
"node_modules/reflect.getprototypeof": {
@@ -6452,6 +6902,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/require-from-string": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/resolve": {
"version": "2.0.0-next.6",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.6.tgz",
@@ -6496,6 +6955,12 @@
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
}
},
"node_modules/restructure": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/restructure/-/restructure-3.0.2.tgz",
"integrity": "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==",
"license": "MIT"
},
"node_modules/reusify": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
@@ -6557,6 +7022,26 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/safe-push-apply": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz",
@@ -6901,6 +7386,15 @@
"text-decoder": "^1.1.0"
}
},
"node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.2.0"
}
},
"node_modules/string.prototype.includes": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz",
@@ -7037,6 +7531,23 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/stripe": {
"version": "22.1.1",
"resolved": "https://registry.npmjs.org/stripe/-/stripe-22.1.1.tgz",
"integrity": "sha512-cmodIYP27tBkJ8G7DuGgWw0PFuemlFZbuF3Wwr1TrjFjUa3T7NIgCe6TVwX8BO2ynu+xtTuDGfHafNDCPt9lXA==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@types/node": ">=18"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
}
}
},
"node_modules/styled-jsx": {
"version": "5.1.6",
"resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz",
@@ -7086,6 +7597,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/svg-arc-to-cubic-bezier": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/svg-arc-to-cubic-bezier/-/svg-arc-to-cubic-bezier-3.2.0.tgz",
"integrity": "sha512-djbJ/vZKZO+gPoSDThGNpKDO+o+bAeA4XQKovvkNCqnIS2t+S4qnLAGQhyyrulhCFRl1WWzAp0wUDV8PpTVU3g==",
"license": "ISC"
},
"node_modules/tailwindcss": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz",
@@ -7151,6 +7668,12 @@
"b4a": "^1.6.4"
}
},
"node_modules/tiny-inflate": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz",
"integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==",
"license": "MIT"
},
"node_modules/tinyglobby": {
"version": "0.2.16",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
@@ -7380,6 +7903,32 @@
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"license": "MIT"
},
"node_modules/unicode-properties": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/unicode-properties/-/unicode-properties-1.4.1.tgz",
"integrity": "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==",
"license": "MIT",
"dependencies": {
"base64-js": "^1.3.0",
"unicode-trie": "^2.0.0"
}
},
"node_modules/unicode-trie": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz",
"integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==",
"license": "MIT",
"dependencies": {
"pako": "^0.2.5",
"tiny-inflate": "^1.0.0"
}
},
"node_modules/unicode-trie/node_modules/pako": {
"version": "0.2.9",
"resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz",
"integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==",
"license": "MIT"
},
"node_modules/unrs-resolver": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.11.1.tgz",
@@ -7446,6 +7995,26 @@
"react": "^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0"
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
"node_modules/vite-compatible-readable-stream": {
"version": "3.6.1",
"resolved": "https://registry.npmjs.org/vite-compatible-readable-stream/-/vite-compatible-readable-stream-3.6.1.tgz",
"integrity": "sha512-t20zYkrSf868+j/p31cRIGN28Phrjm3nRSLR2fyc2tiWi4cZGVdv68yNlwnIINTkMTmPoMiSlc0OadaO7DXZaQ==",
"license": "MIT",
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
@@ -7626,6 +8195,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/yoga-layout": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/yoga-layout/-/yoga-layout-3.2.1.tgz",
"integrity": "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==",
"license": "MIT"
},
"node_modules/zod": {
"version": "3.25.76",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",

View File

@@ -11,6 +11,7 @@
},
"dependencies": {
"@kubernetes/client-node": "^1.4.0",
"@react-pdf/renderer": "^4.4.0",
"@types/nodemailer": "^8.0.0",
"@types/pg": "^8.20.0",
"next": "^15.5.15",
@@ -20,6 +21,7 @@
"pg": "^8.20.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"stripe": "^22.1.1",
"zod": "^3.24.0"
},
"devDependencies": {

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

View File

@@ -0,0 +1,71 @@
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { getSessionUser } from "@/lib/session";
import { listTenants } from "@/lib/k8s";
import { getOrgBilling } from "@/lib/db";
import { BackLink } from "@/components/ui/back-link";
import { GenerateForm } from "@/components/admin/billing/generate-form";
/**
* /admin/billing/generate — testing tool to compute & commit an
* invoice for a given (org, period).
*
* Workflow:
* 1. Admin picks org + year/month + locale (default auto-detected
* from country).
* 2. "Preview" runs computeInvoiceDraft (dryRun) — shows lines,
* totals, warnings.
* 3. "Commit" persists + renders the PDF.
*
* The org dropdown is hydrated server-side here so the page loads
* with the list pre-populated. Per-org billing status (address
* present / open balance) is fetched on demand from /api/admin/
* billing/orgs since it can change as admin edits.
*/
export default async function AdminBillingGeneratePage() {
const user = await getSessionUser();
if (!user) redirect("/login");
if (!user.isPlatform) redirect("/dashboard");
const t = await getTranslations("adminBilling");
// Build initial org list from tenant labels.
const tenants = await listTenants();
const orgMap = new Map<string, string[]>();
for (const t of tenants) {
const oid = t.metadata.labels?.["pieced.ch/zitadel-org-id"];
if (!oid) continue;
if (!orgMap.has(oid)) orgMap.set(oid, []);
orgMap.get(oid)!.push(t.metadata.name);
}
// Hydrate company name + country in parallel.
const orgList = await Promise.all(
[...orgMap.entries()].map(async ([orgId, tenantNames]) => {
const billing = await getOrgBilling(orgId).catch(() => null);
return {
zitadelOrgId: orgId,
tenantNames,
companyName: billing?.companyName ?? null,
country: billing?.country ?? null,
hasBillingAddress: !!billing,
};
})
);
orgList.sort((a, b) =>
(a.companyName ?? a.zitadelOrgId).localeCompare(
b.companyName ?? b.zitadelOrgId
)
);
return (
<main className="max-w-4xl mx-auto px-6 py-8">
<BackLink href="/admin/billing" label={t("backToBilling")} />
<div className="mb-8 animate-in">
<h1 className="font-display text-2xl font-semibold accent-rule">
{t("generateTitle")}
</h1>
<p className="text-sm text-text-secondary mt-3">{t("generatePageDesc")}</p>
</div>
<GenerateForm orgs={orgList} />
</main>
);
}

View File

@@ -0,0 +1,35 @@
import { notFound, redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { getSessionUser } from "@/lib/session";
import { getInvoiceDetail } from "@/lib/db";
import { BackLink } from "@/components/ui/back-link";
import { InvoiceDetailView } from "@/components/admin/billing/invoice-detail-view";
/**
* /admin/billing/invoices/[id] — full detail of one invoice.
*
* Server-renders the static body (header, lines, totals, billing
* snapshot); the action bar (mark-paid, delete, PDF download) is
* a client component for the interactive bits.
*/
export default async function AdminInvoiceDetailPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const user = await getSessionUser();
if (!user) redirect("/login");
if (!user.isPlatform) redirect("/dashboard");
const t = await getTranslations("adminBilling");
const { id } = await params;
const detail = await getInvoiceDetail(id);
if (!detail) notFound();
return (
<main className="max-w-4xl mx-auto px-6 py-8">
<BackLink href="/admin/billing/invoices" label={t("backToInvoices")} />
<InvoiceDetailView detail={detail} />
</main>
);
}

View File

@@ -0,0 +1,39 @@
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { getSessionUser } from "@/lib/session";
import { listInvoices, syncOverdueInvoices } from "@/lib/db";
import { BackLink } from "@/components/ui/back-link";
import { InvoicesTable } from "@/components/admin/billing/invoices-table";
/**
* /admin/billing/invoices — list of all issued invoices, filterable
* by status and month. Click a row to drill into detail.
*
* Server-renders the initial table with no filters applied (showing
* the most recent 200). Client filters trigger a fetch with query
* params and re-render in place.
*/
export default async function AdminInvoicesListPage() {
const user = await getSessionUser();
if (!user) redirect("/login");
if (!user.isPlatform) redirect("/dashboard");
const t = await getTranslations("adminBilling");
await syncOverdueInvoices().catch((e) =>
console.error("syncOverdueInvoices failed:", e)
);
const invoices = await listInvoices({ limit: 200 });
return (
<main className="max-w-5xl mx-auto px-6 py-8">
<BackLink href="/admin/billing" label={t("backToBilling")} />
<div className="mb-8 animate-in">
<h1 className="font-display text-2xl font-semibold accent-rule">
{t("invoicesTitle")}
</h1>
<p className="text-sm text-text-secondary mt-3">{t("invoicesPageDesc")}</p>
</div>
<InvoicesTable initialInvoices={invoices} />
</main>
);
}

View File

@@ -0,0 +1,128 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { getSessionUser } from "@/lib/session";
import { getOrgOpenBalances, syncOverdueInvoices } from "@/lib/db";
import { Card } from "@/components/ui/card";
/**
* /admin/billing — landing page with sub-section links and a
* quick overview of orgs in arrears.
*
* Sub-pages:
* - /admin/billing/pricing — platform + skill prices
* - /admin/billing/generate — manual invoice generator (testing)
* - /admin/billing/invoices — invoice list/detail
*
* The Phase 2 customer-side /billing landing page is added in
* Phase 3.
*/
export default async function AdminBillingPage() {
const user = await getSessionUser();
if (!user) redirect("/login");
if (!user.isPlatform) redirect("/dashboard");
const t = await getTranslations("adminBilling");
// Sweep open invoices past due → 'overdue' so the counters below
// reflect reality without needing a cron.
await syncOverdueInvoices().catch((e) =>
console.error("syncOverdueInvoices failed:", e)
);
const balances = await getOrgOpenBalances().catch(() => []);
const totalOpen = balances.reduce((acc, b) => acc + b.totalOpenChf, 0);
const totalOverdue = balances.reduce((acc, b) => acc + b.overdueCount, 0);
return (
<main className="max-w-5xl mx-auto px-6 py-8">
<div className="mb-8 animate-in">
<h1 className="font-display text-2xl font-semibold accent-rule">
{t("title")}
</h1>
<p className="text-sm text-text-secondary mt-3">{t("subtitle")}</p>
</div>
{/* Stats strip */}
<div className="grid grid-cols-3 gap-4 mb-8 animate-in animate-in-delay-1">
<Card>
<div className="text-xs text-text-muted">{t("totalOpenBalance")}</div>
<div className="text-2xl font-semibold mt-1">
CHF {totalOpen.toFixed(2)}
</div>
</Card>
<Card>
<div className="text-xs text-text-muted">{t("orgsWithBalance")}</div>
<div className="text-2xl font-semibold mt-1">{balances.length}</div>
</Card>
<Card>
<div className="text-xs text-text-muted">{t("overdueInvoices")}</div>
<div className="text-2xl font-semibold mt-1">
{totalOverdue > 0 ? (
<span className="text-error">{totalOverdue}</span>
) : (
totalOverdue
)}
</div>
</Card>
</div>
{/* Sub-tool cards */}
<div className="grid grid-cols-3 gap-4 mb-8 animate-in animate-in-delay-2">
<Link href="/admin/billing/pricing">
<Card interactive>
<div className="font-semibold mb-1">{t("pricingTitle")}</div>
<div className="text-sm text-text-muted">{t("pricingDesc")}</div>
</Card>
</Link>
<Link href="/admin/billing/generate">
<Card interactive>
<div className="font-semibold mb-1">{t("generateTitle")}</div>
<div className="text-sm text-text-muted">{t("generateDesc")}</div>
</Card>
</Link>
<Link href="/admin/billing/invoices">
<Card interactive>
<div className="font-semibold mb-1">{t("invoicesTitle")}</div>
<div className="text-sm text-text-muted">{t("invoicesDesc")}</div>
</Card>
</Link>
</div>
{/* Orgs with open balance */}
{balances.length > 0 && (
<div className="animate-in animate-in-delay-3">
<h2 className="text-lg font-semibold mb-3">{t("balancesTitle")}</h2>
<Card>
<table className="w-full text-sm">
<thead className="text-xs text-text-muted text-left">
<tr>
<th className="pb-2">{t("orgIdCol")}</th>
<th className="pb-2 text-right">{t("openCountCol")}</th>
<th className="pb-2 text-right">{t("overdueCountCol")}</th>
<th className="pb-2 text-right">{t("totalOpenCol")}</th>
</tr>
</thead>
<tbody>
{balances.map((b) => (
<tr key={b.zitadelOrgId} className="border-t border-border">
<td className="py-2 font-mono text-xs">{b.zitadelOrgId}</td>
<td className="py-2 text-right">{b.openCount}</td>
<td className="py-2 text-right">
{b.overdueCount > 0 ? (
<span className="text-error">{b.overdueCount}</span>
) : (
<span className="text-text-muted">0</span>
)}
</td>
<td className="py-2 text-right">
CHF {b.totalOpenChf.toFixed(2)}
</td>
</tr>
))}
</tbody>
</table>
</Card>
</div>
)}
</main>
);
}

View File

@@ -0,0 +1,55 @@
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { getSessionUser } from "@/lib/session";
import { getPlatformPricing, listSkillPricing } from "@/lib/db";
import { PACKAGE_CATALOG } from "@/lib/packages";
import { BackLink } from "@/components/ui/back-link";
import { PricingEditor } from "@/components/admin/billing/pricing-editor";
/**
* /admin/billing/pricing — edit platform-wide pricing config
* (monthly fee, setup fee, Threema per-message, VAT rate for
* CH/LI) and per-skill daily prices.
*
* Single-row platform_pricing semantics: one global pricing
* config applies to every tenant. No per-tenant overrides in
* v1.
*/
export default async function AdminBillingPricingPage() {
const user = await getSessionUser();
if (!user) redirect("/login");
if (!user.isPlatform) redirect("/dashboard");
const t = await getTranslations("adminBilling");
const [pricing, skillPricing] = await Promise.all([
getPlatformPricing(),
listSkillPricing(),
]);
// Surface every package in the catalog so admin can price any of
// them — UI defaults the picker to skill-kind entries but doesn't
// hard-block other kinds (a future scenario where a non-skill
// package gets a per-day price shouldn't need a code change).
const catalog = Object.values(PACKAGE_CATALOG).map((p) => ({
id: p.id,
name: p.name,
category: p.category,
}));
return (
<main className="max-w-4xl mx-auto px-6 py-8">
<BackLink href="/admin/billing" label={t("backToBilling")} />
<div className="mb-8 animate-in">
<h1 className="font-display text-2xl font-semibold accent-rule">
{t("pricingTitle")}
</h1>
<p className="text-sm text-text-secondary mt-3">{t("pricingPageDesc")}</p>
</div>
<PricingEditor
initialPricing={pricing}
initialSkillPricing={skillPricing}
catalog={catalog}
/>
</main>
);
}

View File

@@ -0,0 +1,44 @@
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { getSessionUser } from "@/lib/session";
import {
getLastSuccessfulCronRuns,
listRecentCronRuns,
} from "@/lib/db";
import { CronControls } from "@/components/admin/cron/cron-controls";
/**
* /admin/cron — automation dashboard.
*
* Shows:
* - Last successful run of each kind, with relative time
* - Two "Run now" buttons (admin-triggered manual sweeps)
* - Recent runs table (last 30)
*
* Platform-admin gated server-side.
*/
export default async function AdminCronPage() {
const user = await getSessionUser();
if (!user || !user.isPlatform) redirect("/login");
const t = await getTranslations("adminCron");
const [recent, lastSuccess] = await Promise.all([
listRecentCronRuns(30),
getLastSuccessfulCronRuns(),
]);
return (
<main className="max-w-5xl mx-auto px-6 py-8">
<div className="mb-8 animate-in">
<h1 className="font-display text-2xl font-semibold accent-rule">
{t("title")}
</h1>
<p className="text-sm text-text-secondary mt-3">{t("subtitle")}</p>
</div>
<CronControls
initialRecent={recent}
initialLastSuccess={lastSuccess}
/>
</main>
);
}

View File

@@ -0,0 +1,71 @@
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { getSessionUser } from "@/lib/session";
import { listTenants, getOpenClawDefaults } from "@/lib/k8s";
import { OpenClawAdminPanel } from "@/components/admin/openclaw-admin-panel";
/**
* /admin/openclaw — platform-default OpenClaw image + per-tenant
* overrides table.
*
* Two sections:
* 1. Default — readable from `pieced-openclaw-config` ConfigMap.
* Editable via the same form. Empty fields show as "(unset)"
* and the operator falls back to its built-in default in that
* case (intentionally invisible to the portal — the binary's
* baked version moves with releases and we don't want the UI
* to claim a misleading "current default").
* 2. Tenant table — every tenant in the cluster with its current
* override (or "follows default"). Clicking a row opens a small
* inline editor.
*
* Authorization is gated server-side: `user.isPlatform` only. Any
* other user gets redirected to /dashboard.
*/
export default async function OpenClawAdminPage() {
const user = await getSessionUser();
if (!user) redirect("/login");
if (!user.isPlatform) redirect("/dashboard");
const t = await getTranslations("openclawAdmin");
// Parallel fetch — defaults and tenants are independent.
const [defaults, tenants] = await Promise.all([
getOpenClawDefaults(),
listTenants(),
]);
// Sort tenants: overridden first (more interesting to review),
// then alphabetically by display name. Helps the admin spot which
// tenants are off the platform default at a glance.
const sorted = [...tenants].sort((a, b) => {
const aOverride = a.spec.openClawImage ? 1 : 0;
const bOverride = b.spec.openClawImage ? 1 : 0;
if (aOverride !== bOverride) return bOverride - aOverride;
return (a.spec.displayName || a.metadata.name).localeCompare(
b.spec.displayName || b.metadata.name
);
});
return (
<main className="max-w-5xl mx-auto px-6 py-8">
<div className="mb-8 animate-in">
<h1 className="font-display text-2xl font-semibold accent-rule">
{t("title")}
</h1>
<p className="text-sm text-text-secondary mt-3">{t("subtitle")}</p>
</div>
<OpenClawAdminPanel
initialDefaults={defaults}
tenants={sorted.map((tn) => ({
name: tn.metadata.name,
displayName: tn.spec.displayName || tn.metadata.name,
phase: tn.status?.phase ?? "Unknown",
override: tn.spec.openClawImage?.tag
? { tag: tn.spec.openClawImage.tag }
: null,
}))}
/>
</main>
);
}

View File

@@ -2,6 +2,7 @@ import { getSessionUser } from "@/lib/session";
import { getTranslations } from "next-intl/server";
import { redirect } from "next/navigation";
import { listTenants } from "@/lib/k8s";
import { countPendingSkillActivationRequests } from "@/lib/db";
import { AdminPanel } from "@/components/admin/admin-panel";
export default async function AdminPage() {
@@ -19,15 +20,61 @@ export default async function AdminPage() {
}
const tenants = await listTenants();
// Phase 2.5: badge counter for the skill-activation admin queue.
// Cheap COUNT(*) on a partial-indexed status='pending' column —
// bounded by request volume and never expected to be high.
const pendingSkillCount = await countPendingSkillActivationRequests().catch(
() => 0
);
return (
<div>
<div className="mb-8 animate-in">
<div className="mb-8 animate-in flex items-end justify-between gap-4 flex-wrap">
<div>
<h1 className="font-display text-2xl font-semibold accent-rule mb-2">
{t("title")}
</h1>
<p className="text-text-secondary text-sm mt-4">{t("subtitle")}</p>
</div>
{/* Sub-tools: links to other admin pages. Plain links rather
than nav-shell entries — these are platform-team utilities,
not main navigation. */}
<div className="flex items-center gap-2">
<a
href="/admin/skills/pending"
className={`text-sm px-4 py-2 rounded-lg border transition-colors flex items-center gap-2 ${
pendingSkillCount > 0
? "border-warning text-warning hover:bg-warning/10"
: "border-border text-text-secondary hover:text-text-primary hover:border-text-secondary"
}`}
>
<span>{t("skillsQueueTool")}</span>
{pendingSkillCount > 0 && (
<span className="text-xs px-1.5 py-0.5 rounded bg-warning text-surface-0 font-semibold">
{pendingSkillCount}
</span>
)}
</a>
<a
href="/admin/billing"
className="text-sm px-4 py-2 rounded-lg border border-border text-text-secondary hover:text-text-primary hover:border-text-secondary transition-colors"
>
{t("billingTool")}
</a>
<a
href="/admin/cron"
className="text-sm px-4 py-2 rounded-lg border border-border text-text-secondary hover:text-text-primary hover:border-text-secondary transition-colors"
>
{t("cronTool")}
</a>
<a
href="/admin/openclaw"
className="text-sm px-4 py-2 rounded-lg border border-border text-text-secondary hover:text-text-primary hover:border-text-secondary transition-colors"
>
{t("openclawTool")}
</a>
</div>
</div>
<div className="animate-in animate-in-delay-1">
<AdminPanel initialTenants={tenants} />

View File

@@ -0,0 +1,59 @@
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { getSessionUser } from "@/lib/session";
import { listPendingSkillActivationRequests, getOrgBilling } from "@/lib/db";
import { getPackageDef } from "@/lib/packages";
import { BackLink } from "@/components/ui/back-link";
import { PendingSkillRequests } from "@/components/admin/skills/pending-skill-requests";
/**
* /admin/skills/pending — admin queue for manual-setup skill
* activation requests. Each row shows tenant, skill, requester
* info, and offers Approve / Reject actions.
*
* Server-renders the initial list. Approval/rejection trigger a
* client-side fetch + router.refresh() so the row disappears and
* the count updates without a hard reload.
*/
export default async function AdminPendingSkillRequestsPage() {
const user = await getSessionUser();
if (!user) redirect("/login");
if (!user.isPlatform) redirect("/dashboard");
const t = await getTranslations("adminSkills");
const pending = await listPendingSkillActivationRequests();
// Hydrate display fields: skill name from catalog, org company name
// from billing. Skill name fallback to skillId for off-catalog
// entries (shouldn't happen but defensive). Company name is
// looked up lazily per row; dedup'd via a Map so we don't issue
// duplicate getOrgBilling calls for the same org.
const seenOrg = new Map<string, string | null>();
const rows = await Promise.all(
pending.map(async (r) => {
if (!seenOrg.has(r.zitadelOrgId)) {
const billing = await getOrgBilling(r.zitadelOrgId).catch(() => null);
seenOrg.set(r.zitadelOrgId, billing?.companyName ?? null);
}
const def = getPackageDef(r.skillId);
return {
...r,
skillName: def?.name ?? r.skillId,
companyName: seenOrg.get(r.zitadelOrgId) ?? null,
};
})
);
return (
<main className="max-w-5xl mx-auto px-6 py-8">
<BackLink href="/admin" label={t("backToAdmin")} />
<div className="mb-8 animate-in">
<h1 className="font-display text-2xl font-semibold accent-rule">
{t("title")}
</h1>
<p className="text-sm text-text-secondary mt-3">{t("subtitle")}</p>
</div>
<PendingSkillRequests initialRows={rows} />
</main>
);
}

View File

@@ -0,0 +1,35 @@
import { redirect, notFound } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { getSessionUser } from "@/lib/session";
import { getInvoiceByNumberForOrg } from "@/lib/db";
import { BackLink } from "@/components/ui/back-link";
import { CustomerInvoiceDetail } from "@/components/billing/customer-invoice-detail";
/**
* /billing/[invoiceNumber] — single-invoice view.
*
* Lookup is by the human-readable invoice number (the YYYY-NNNNN
* format printed on the PDF and in the issuance email). Org
* filter is enforced in the DB query — a customer trying another
* org's number gets 404, not 403, to avoid leaking the existence
* of other orgs' invoices.
*/
export default async function CustomerInvoiceDetailPage({
params,
}: {
params: Promise<{ invoiceNumber: string; locale: string }>;
}) {
const user = await getSessionUser();
if (!user) redirect("/login");
const { invoiceNumber } = await params;
const t = await getTranslations("customerBilling");
const detail = await getInvoiceByNumberForOrg(invoiceNumber, user.orgId);
if (!detail) notFound();
return (
<main className="max-w-3xl mx-auto px-6 py-8">
<BackLink href="/billing" label={t("backToBilling")} />
<CustomerInvoiceDetail invoice={detail.invoice} lines={detail.lines} />
</main>
);
}

View File

@@ -0,0 +1,65 @@
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { getSessionUser } from "@/lib/session";
import { listInvoices, syncOverdueInvoices } from "@/lib/db";
import { CustomerInvoiceList } from "@/components/billing/customer-invoice-list";
import { RunningTotalWidget } from "@/components/billing/running-total-widget";
/**
* /billing — customer's billing home.
*
* Shows two things:
* 1. RunningTotalWidget — current calendar month's accruing cost
* (or the already-issued invoice for the current month, if
* that ran early).
* 2. CustomerInvoiceList — every issued invoice for this org,
* newest first. Status is reflected with a colored badge.
*
* Anyone signed in can view this. The data is org-scoped; even
* non-owner team members see the same view. Phase 4 will add a
* "settings.payByInvoice" toggle visibility-gated to owners only.
*/
export default async function CustomerBillingPage() {
const user = await getSessionUser();
if (!user) redirect("/login");
const t = await getTranslations("customerBilling");
// Sync overdue status before listing — cheap, idempotent.
try {
await syncOverdueInvoices();
} catch (e) {
console.warn("syncOverdueInvoices failed in /billing:", e);
}
const invoices = await listInvoices({
zitadelOrgId: user.orgId,
limit: 200,
});
return (
<main className="max-w-5xl mx-auto px-6 py-8">
<div className="mb-8 animate-in">
<h1 className="font-display text-2xl font-semibold accent-rule">
{t("title")}
</h1>
<p className="text-sm text-text-secondary mt-3">{t("subtitle")}</p>
</div>
<section className="mb-8 animate-in animate-in-delay-1">
<h2 className="text-xs font-semibold uppercase tracking-wider text-text-muted mb-3">
{t("currentPeriodHeading")}
</h2>
{/* Phase 6: pass the owner flag so the no-config CTA shows
the right call-to-action vs the right hint. */}
<RunningTotalWidget isOwner={user.roles.includes("owner")} />
</section>
<section className="animate-in animate-in-delay-2">
<h2 className="text-xs font-semibold uppercase tracking-wider text-text-muted mb-3">
{t("historyHeading")}
</h2>
<CustomerInvoiceList invoices={invoices} />
</section>
</main>
);
}

View File

@@ -76,6 +76,7 @@ export default async function NewInstancePage() {
userName={user.name}
userEmail={user.email}
hasOrgBilling={hasOrgBilling}
existingOrgBilling={orgBilling}
/>
</div>
</div>

View File

@@ -317,6 +317,7 @@ export default async function DashboardPage() {
userName={user.name}
userEmail={user.email}
hasOrgBilling={hasOrgBilling}
existingOrgBilling={orgBilling}
/>
</div>
</div>

View File

@@ -1,30 +1,31 @@
import { getTranslations } from "next-intl/server";
import { redirect, notFound } from "next/navigation";
import { getSessionUser, canMutate } from "@/lib/session";
import { getTranslations } from "next-intl/server";
import { getSessionUser } from "@/lib/session";
import { getOrgBilling } from "@/lib/db";
import { BillingSettingsForm } from "@/components/settings/billing-settings-form";
import { BillingSettingsForm } from "@/components/settings/billing-form";
/**
* /settings/billing — view and edit org-scoped billing (Bug 34/35).
* /settings/billing — customer-side billing details management.
*
* Server-side fetches the existing record (if any) and passes it to
* the client form. The form posts to PUT /api/billing on submit.
* Owner-only by visibility: non-owner members get a 404 (same
* response as if the page didn't exist). The link to this page
* is also hidden from non-owners on /billing and elsewhere, but
* the page itself enforces too — a non-owner who learns the URL
* still gets 404, not 403, so the page's existence doesn't leak.
*
* Access: same gate as the API — owners and platform admins. `user`
* role redirects to /settings (which also wouldn't list billing for
* them). 403 here would be friendlier than redirect, but the most
* likely cause of a `user` landing on this URL is sharing a bookmark
* with their owner — silent redirect is gentle.
* First-time visitors see an empty form. Subsequent visits see
* the current values, editable. Save creates or updates via the
* shared upsert path; the row's existence drives whether the
* monthly issuance cron will pick this org up.
*/
export default async function BillingSettingsPage() {
const user = await getSessionUser();
if (!user) redirect("/login");
if (!canMutate(user)) {
redirect("/settings");
}
const t = await getTranslations("settingsBilling");
// Non-owners get a 404 — see comment above.
if (!user.roles.includes("owner")) notFound();
const billing = await getOrgBilling(user.orgId);
const t = await getTranslations("settingsBilling");
const existing = await getOrgBilling(user.orgId);
return (
<main className="max-w-3xl mx-auto px-6 py-8">
@@ -32,16 +33,16 @@ export default async function BillingSettingsPage() {
<h1 className="font-display text-2xl font-semibold accent-rule">
{t("title")}
</h1>
<p className="text-sm text-text-secondary mt-3">{t("subtitle")}</p>
<p className="text-sm text-text-secondary mt-3">
{user.isPersonal ? t("subtitlePersonal") : t("subtitle")}
</p>
</div>
<div className="animate-in animate-in-delay-1">
<BillingSettingsForm
initial={billing}
initial={existing}
isPersonal={user.isPersonal}
orgName={user.orgName}
userName={user.name}
userEmail={user.email}
/>
</div>
</main>
);
}

View File

@@ -3,7 +3,11 @@ import { getTranslations, getFormatter } from "next-intl/server";
import { redirect, notFound } from "next/navigation";
import { getTenant } from "@/lib/k8s";
import { canUserSeeTenant } from "@/lib/visibility";
import { getPendingResumeRequestForTenant } from "@/lib/db";
import {
getPendingResumeRequestForTenant,
listSkillActivationRequestsForTenant,
listSkillPricing,
} from "@/lib/db";
import { StatusBadge } from "@/components/ui/status-badge";
import { WarningBadge } from "@/components/ui/warning-badge";
import { UsageDisplay } from "@/components/dashboard/usage-display";
@@ -13,8 +17,15 @@ import { ChannelUsers } from "@/components/channel-users/channel-users";
import { AssignedUsersPanel } from "@/components/tenants/assigned-users-panel";
import { SubscriptionToggle } from "@/components/tenants/subscription-toggle";
import { formatDateTime, formatRelative } from "@/lib/format";
import { CHANNEL_PACKAGE_IDS } from "@/lib/packages";
const CHANNEL_PACKAGES = ["telegram", "discord", "email"];
// CHANNEL_PACKAGES used to be a hardcoded literal here
// (`["telegram", "discord", "email"]`). It now derives from the
// portal-side catalog so adding a new channel anywhere only requires
// editing src/lib/packages.ts. The `email` channel was dropped as
// part of the Phase A package-model rework — IMAP/SMTP is now the
// `mail` skill instead.
const CHANNEL_PACKAGES = CHANNEL_PACKAGE_IDS;
export default async function TenantDetailPage({
params,
@@ -75,6 +86,17 @@ export default async function TenantDetailPage({
);
const channelUsers = tenant.spec.channelUsers || {};
// Phase 2.5: surface pending and most-recently-rejected skill
// activation requests so PackageCard can render the inline
// "Manual review pending" / "Activation rejected" states.
// Pricing drives the cost-disclosure dialog before enable.
// Both fetches are best-effort — an empty list is the safe
// fallback if the DB call fails (cards just show normal toggles).
const [activationRequests, skillPricing] = await Promise.all([
listSkillActivationRequestsForTenant(name).catch(() => []),
listSkillPricing().catch(() => []),
]);
// Bug 19 fix: every viewer (customer or admin) passes the tenant
// name to UsageDisplay. The /api/usage route resolves team+alias
// from the tenant CR's status and applies the visibility check, so
@@ -199,7 +221,7 @@ export default async function TenantDetailPage({
<h2 className="text-xs font-semibold uppercase tracking-wider text-text-muted mb-3">
{t("usage")}
</h2>
<UsageDisplay tenant={name} />
<UsageDisplay tenant={name} canEditBudget={canEdit} />
</section>
{/* Packages */}
@@ -212,6 +234,8 @@ export default async function TenantDetailPage({
enabledPackages={enabledPackages}
conditions={tenant.status?.conditions}
canEdit={canEdit}
activationRequests={activationRequests}
skillPricing={skillPricing}
/>
</section>

View File

@@ -0,0 +1,70 @@
import { NextResponse } from "next/server";
import { requirePlatformRole } from "@/lib/session";
import { listTenants } from "@/lib/k8s";
import { backfillTenantBillingLifecycle } from "@/lib/db";
import { safeError } from "@/lib/errors";
/**
* POST /api/admin/billing/backfill
*
* One-off bootstrap that reads every live PiecedTenant CR and
* mirrors it into the Phase 1 billing tables:
* - tenant_billing_lifecycle.created_at ← CR's creationTimestamp
* - tenant_skill_events: one 'enabled' event per package in
* spec.packages, anchored at the CR's creationTimestamp
* - tenant_suspension_events: one 'suspended' event if the CR is
* currently suspended (anchored at status.suspendedAt)
*
* Idempotent — re-running is safe. The helper only inserts rows
* for tenants that have no lifecycle row / no events yet; running
* twice produces zero additional rows.
*
* Authorization: platform role only. The body of the request is
* ignored.
*
* Response: counts of rows inserted, mostly for sanity-checking
* (expect non-zero on first run, zero on subsequent runs).
*
* Phase 2 will surface this behind an admin UI button.
*/
export async function POST() {
try {
await requirePlatformRole();
} catch {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
try {
const tenants = await listTenants();
const result = await backfillTenantBillingLifecycle(
tenants.map((t) => ({
name: t.metadata.name,
// Tenants without the org label exist as a pre-Slice-3
// artifact; we still record them but with 'unknown' as the
// org id, which surfaces them in admin reports for manual
// labelling. Per-org billing computation skips rows with
// org id = 'unknown'.
zitadelOrgId:
t.metadata.labels?.["pieced.ch/zitadel-org-id"] ?? "unknown",
createdAt: t.metadata.creationTimestamp
? new Date(t.metadata.creationTimestamp)
: new Date(),
packages: t.spec.packages ?? [],
suspendedAt: t.status?.suspendedAt
? new Date(t.status.suspendedAt)
: null,
}))
);
return NextResponse.json({
message: "Backfill complete.",
tenantsExamined: tenants.length,
...result,
});
} catch (e: any) {
console.error("Backfill failed:", e);
return NextResponse.json(
{ error: safeError(e, "Backfill failed") },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,66 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { requirePlatformRole } from "@/lib/session";
import { generateInvoice } from "@/lib/billing";
import { safeError } from "@/lib/errors";
/**
* POST /api/admin/billing/generate
*
* Compute (and optionally commit) an invoice for an (org, year,
* month). Platform-only — this is the testing/admin tool.
*
* Body:
* {
* zitadelOrgId: string,
* year: number (e.g. 2026),
* month: number (1-12),
* locale?: 'de' | 'en' | 'fr' | 'it', // default: from country
* dryRun?: boolean // default: false
* }
*
* Response on success:
* {
* draft: InvoiceDraft, // line breakdown + warnings
* invoice: Invoice | null, // null when dryRun=true
* }
*
* If an invoice for that (org, period) already exists, returns
* 409 with a clear message. Use the delete endpoint first to
* regenerate.
*/
const bodySchema = z.object({
zitadelOrgId: z.string().min(1),
year: z.number().int().min(2020).max(2100),
month: z.number().int().min(1).max(12),
locale: z.enum(["de", "en", "fr", "it"]).optional(),
dryRun: z.boolean().optional().default(false),
});
export async function POST(request: Request) {
try {
await requirePlatformRole();
} catch {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const body = await request.json().catch(() => ({}));
const parsed = bodySchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: "Invalid request", details: parsed.error.flatten() },
{ status: 400 }
);
}
try {
const result = await generateInvoice(parsed.data);
return NextResponse.json(result);
} catch (e: any) {
console.error("Invoice generation failed:", e);
const msg = safeError(e, "Generation failed");
// Specific 409 for the "already exists" case so the UI can
// show a "delete first" link.
const status = /already exists/i.test(msg) ? 409 : 500;
return NextResponse.json({ error: msg }, { status });
}
}

View File

@@ -0,0 +1,81 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { requirePlatformRole, getSessionUser } from "@/lib/session";
import { markInvoicePaid } from "@/lib/db";
import { safeError } from "@/lib/errors";
/**
* POST /api/admin/billing/invoices/[id]/mark-paid
*
* Manually mark an open/overdue invoice as paid. Used for the
* "pay by invoice" flow where the customer transfers money to
* the bank account printed on the PDF and the admin reconciles
* by hand.
*
* Body (all optional):
* {
* paidAt?: ISO timestamp, // defaults to now
* note?: string // free-form, stored in paid_method_detail
* }
*
* paid_by is set to the admin user's id automatically.
* Idempotent: trying to mark an already-paid invoice returns 409.
*
* Phase 4 will introduce a parallel auto-paid path triggered by
* Stripe webhooks; for Phase 2 this is the only way to flip the
* status.
*/
const bodySchema = z.object({
paidAt: z.string().datetime().optional(),
note: z.string().max(500).optional(),
});
export async function POST(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
let user;
try {
await requirePlatformRole();
user = await getSessionUser();
} catch {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { id } = await params;
const body = await request.json().catch(() => ({}));
const parsed = bodySchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: "Invalid request", details: parsed.error.flatten() },
{ status: 400 }
);
}
try {
const detail = parsed.data.note
? `${user.id}: ${parsed.data.note}`
: user.id;
const invoice = await markInvoicePaid(id, {
paidBy: "manual",
paidMethodDetail: detail,
paidAt: parsed.data.paidAt ? new Date(parsed.data.paidAt) : undefined,
});
if (!invoice) {
// Either not found or status not in {open, overdue}.
return NextResponse.json(
{ error: "Invoice not found, or already paid/void." },
{ status: 409 }
);
}
return NextResponse.json(invoice);
} catch (e) {
console.error("Failed to mark invoice paid:", e);
return NextResponse.json(
{ error: safeError(e, "Mark-paid failed") },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,48 @@
import { NextResponse } from "next/server";
import { requirePlatformRole } from "@/lib/session";
import { getInvoicePdf } from "@/lib/db";
/**
* GET /api/admin/billing/invoices/[id]/pdf
*
* Streams the stored PDF bytes for an invoice. The bytea column is
* read once and returned as an octet stream; no on-the-fly
* re-rendering — PDFs are immutable once issued.
*
* Phase 3 will add a parallel customer-facing route at
* /api/billing/invoices/[id]/pdf with org-scoped authorization.
*/
export async function GET(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
await requirePlatformRole();
} catch {
return new NextResponse("Forbidden", { status: 403 });
}
const { id } = await params;
const pdf = await getInvoicePdf(id);
if (!pdf) {
return new NextResponse("Not found", { status: 404 });
}
// Web `Response`'s BodyInit accepts BufferSource, which IS satisfied
// by a Uint8Array. But the pg-returned Buffer types as
// `Uint8Array<ArrayBufferLike>` (the @types/node 22+ generic form),
// and lib.dom's BufferSource only accepts `Uint8Array<ArrayBuffer>` —
// the narrower concrete form. The variance kills assignability,
// even though Buffer extends Uint8Array at runtime.
//
// `Uint8Array.from(buf)` allocates a fresh typed array; the result
// is `Uint8Array<ArrayBuffer>` (concrete generic), which BodyInit
// accepts. Copy cost is trivial at PDF sizes.
const body = Uint8Array.from(pdf.data);
return new NextResponse(body, {
status: 200,
headers: {
"Content-Type": "application/pdf",
"Content-Disposition": `inline; filename="${pdf.filename}"`,
"Cache-Control": "private, max-age=0, must-revalidate",
},
});
}

View File

@@ -0,0 +1,55 @@
import { NextResponse } from "next/server";
import { requirePlatformRole } from "@/lib/session";
import { deleteInvoice, getInvoiceDetail } from "@/lib/db";
import { safeError } from "@/lib/errors";
/**
* GET /api/admin/billing/invoices/[id]
* Detail view: invoice + lines.
*
* DELETE /api/admin/billing/invoices/[id]
* Hard delete (testing tool). Invoice number is consumed — gaps
* in the sequence are intentional and documented. Reminders
* (and their PDFs) cascade-delete via the FK.
*/
export async function GET(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
await requirePlatformRole();
} catch {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const { id } = await params;
const detail = await getInvoiceDetail(id);
if (!detail) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
return NextResponse.json(detail);
}
export async function DELETE(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
await requirePlatformRole();
} catch {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const { id } = await params;
try {
const ok = await deleteInvoice(id);
if (!ok) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
return NextResponse.json({ message: "Deleted." });
} catch (e) {
console.error("Failed to delete invoice:", e);
return NextResponse.json(
{ error: safeError(e, "Delete failed") },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,44 @@
import { NextResponse } from "next/server";
import { requirePlatformRole } from "@/lib/session";
import { listInvoices, syncOverdueInvoices } from "@/lib/db";
import type { InvoiceStatus } from "@/types";
/**
* GET /api/admin/billing/invoices
*
* List invoices for admin. Optional filters:
* ?status=open|paid|overdue|void|uncollectible
* ?orgId=...
* ?month=YYYY-MM
* ?limit=200
*
* Refreshes overdue status on each call (cheap UPDATE), so the
* admin list always reflects the latest due-date math without
* needing a cron.
*/
export async function GET(request: Request) {
try {
await requirePlatformRole();
} catch {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
await syncOverdueInvoices().catch((e) =>
console.error("syncOverdueInvoices failed:", e)
);
const { searchParams } = new URL(request.url);
const status = searchParams.get("status") as InvoiceStatus | null;
const orgId = searchParams.get("orgId");
const month = searchParams.get("month");
const limitParam = searchParams.get("limit");
const limit = limitParam ? Math.max(1, Math.min(1000, parseInt(limitParam, 10))) : 200;
const invoices = await listInvoices({
status: status ?? undefined,
zitadelOrgId: orgId ?? undefined,
periodMonth: month ?? undefined,
limit,
});
return NextResponse.json(invoices);
}

View File

@@ -0,0 +1,80 @@
import { NextResponse } from "next/server";
import { requirePlatformRole } from "@/lib/session";
import { listTenants } from "@/lib/k8s";
import { getOrgBilling, getOrgOpenBalances } from "@/lib/db";
/**
* GET /api/admin/billing/orgs
*
* Returns the orgs known to the platform via tenant labels, with
* their billing-address-on-file status and open balance summary.
* Powers the generate form's org dropdown and the billing landing
* page's open-balance table.
*
* Each entry:
* {
* zitadelOrgId: string,
* tenantCount: number,
* hasBillingAddress: boolean,
* companyName: string | null,
* openCount: number,
* overdueCount: number,
* totalOpenChf: number
* }
*/
export async function GET() {
try {
await requirePlatformRole();
} catch {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
// Org membership is derived from tenant labels — there's no
// separate "orgs" table on the portal. listTenants reads from
// K8s, which is the source of truth.
const tenants = await listTenants();
const orgIdToTenants = new Map<string, string[]>();
for (const t of tenants) {
const oid = t.metadata.labels?.["pieced.ch/zitadel-org-id"];
if (!oid) continue;
if (!orgIdToTenants.has(oid)) orgIdToTenants.set(oid, []);
orgIdToTenants.get(oid)!.push(t.metadata.name);
}
const balances = await getOrgOpenBalances();
const balanceMap = new Map(balances.map((b) => [b.zitadelOrgId, b]));
// Hydrate billing-address presence + company name per org.
const results = await Promise.all(
[...orgIdToTenants.entries()].map(async ([orgId, tenantNames]) => {
const billing = await getOrgBilling(orgId).catch(() => null);
const bal = balanceMap.get(orgId);
return {
zitadelOrgId: orgId,
tenantCount: tenantNames.length,
tenantNames,
hasBillingAddress: !!billing,
companyName: billing?.companyName ?? null,
country: billing?.country ?? null,
openCount: bal?.openCount ?? 0,
overdueCount: bal?.overdueCount ?? 0,
totalOpenChf: bal?.totalOpenChf ?? 0,
};
})
);
// Sort: orgs with overdue first, then open, then by name.
results.sort((a, b) => {
if (a.overdueCount !== b.overdueCount) {
return b.overdueCount - a.overdueCount;
}
if (a.openCount !== b.openCount) {
return b.openCount - a.openCount;
}
return (a.companyName ?? a.zitadelOrgId).localeCompare(
b.companyName ?? b.zitadelOrgId
);
});
return NextResponse.json(results);
}

View File

@@ -0,0 +1,59 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { requirePlatformRole } from "@/lib/session";
import { getPlatformPricing, updatePlatformPricing } from "@/lib/db";
import { safeError } from "@/lib/errors";
/**
* GET /api/admin/billing/pricing
* Returns the single-row platform pricing config.
*
* PUT /api/admin/billing/pricing
* Updates one or more pricing fields. Missing fields are left
* unchanged.
*
* Both endpoints are platform-role only.
*/
const updateSchema = z.object({
tenantMonthlyFeeChf: z.number().min(0).max(99_999_999).optional(),
tenantSetupFeeChf: z.number().min(0).max(99_999_999).optional(),
threemaMessageChf: z.number().min(0).max(1000).optional(),
vatRateChli: z.number().min(0).max(100).optional(),
});
export async function GET() {
try {
await requirePlatformRole();
} catch {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const pricing = await getPlatformPricing();
return NextResponse.json(pricing);
}
export async function PUT(request: Request) {
try {
await requirePlatformRole();
} catch {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const body = await request.json().catch(() => ({}));
const parsed = updateSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: "Invalid pricing payload", details: parsed.error.flatten() },
{ status: 400 }
);
}
try {
const updated = await updatePlatformPricing(parsed.data);
return NextResponse.json(updated);
} catch (e) {
console.error("Failed to update platform pricing:", e);
return NextResponse.json(
{ error: safeError(e, "Update failed") },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,33 @@
import { NextResponse } from "next/server";
import { requirePlatformRole } from "@/lib/session";
import { removeSkillPricing } from "@/lib/db";
import { safeError } from "@/lib/errors";
/**
* DELETE /api/admin/billing/skill-pricing/[skill]
* Remove pricing for a skill. Toggle events continue to be
* recorded; the skill simply becomes free starting from the next
* generated invoice. Historical invoices already issued are
* unaffected (they carry frozen line amounts).
*/
export async function DELETE(
_request: Request,
{ params }: { params: Promise<{ skill: string }> }
) {
try {
await requirePlatformRole();
} catch {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const { skill } = await params;
try {
await removeSkillPricing(skill);
return NextResponse.json({ message: "Removed." });
} catch (e) {
console.error("Failed to remove skill pricing:", e);
return NextResponse.json(
{ error: safeError(e, "Remove failed") },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,80 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { requirePlatformRole } from "@/lib/session";
import { listSkillPricing, setSkillPricing } from "@/lib/db";
import { getPackageDef } from "@/lib/packages";
import { safeError } from "@/lib/errors";
/**
* GET /api/admin/billing/skill-pricing
* List all configured skill prices.
*
* PUT /api/admin/billing/skill-pricing
* Upsert a daily price for a single skill. Body:
* { skillId: string, dailyPriceChf: number }
*
* Both endpoints are platform-only.
*
* Note on skillId validation: we accept any package id that exists
* in PACKAGE_CATALOG. The PIN to "skills only" is enforced at the
* UI layer, not here, so admins can price a non-skill package in
* an emergency without code changes.
*/
const upsertSchema = z.object({
skillId: z.string().min(1).max(100),
dailyPriceChf: z.number().min(0).max(1_000_000),
// Optional with default 0 so existing API callers keep working.
// Setup fee fires once per (tenant, skill); see billing.ts.
setupFeeChf: z.number().min(0).max(1_000_000).optional().default(0),
});
export async function GET() {
try {
await requirePlatformRole();
} catch {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const rows = await listSkillPricing();
return NextResponse.json(rows);
}
export async function PUT(request: Request) {
try {
await requirePlatformRole();
} catch {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const body = await request.json().catch(() => ({}));
const parsed = upsertSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: "Invalid payload", details: parsed.error.flatten() },
{ status: 400 }
);
}
// Validate the skill id exists in PACKAGE_CATALOG. Returns null
// for unknown ids; we reject those rather than persist a row that
// would never match a real toggle event.
const pkg = getPackageDef(parsed.data.skillId);
if (!pkg) {
return NextResponse.json(
{ error: `Unknown package id: ${parsed.data.skillId}` },
{ status: 400 }
);
}
try {
const row = await setSkillPricing(
parsed.data.skillId,
parsed.data.dailyPriceChf,
parsed.data.setupFeeChf
);
return NextResponse.json(row);
} catch (e) {
console.error("Failed to upsert skill pricing:", e);
return NextResponse.json(
{ error: safeError(e, "Upsert failed") },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,68 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { getSessionUser, requirePlatformRole } from "@/lib/session";
import { runMonthlyIssuance } from "@/lib/cron";
import { safeError } from "@/lib/errors";
/**
* POST /api/admin/cron/issue-monthly
*
* Admin-side manual trigger for the issuance sweep — same business
* logic as /api/cron/issue-monthly, different auth (session-based
* platform role check) and the option to override the target
* year/month from the request body.
*
* Body (all optional):
* { year?: number, month?: number }
*
* Default target is the previous local month — matching what the
* automated cron would do. Override is useful for catching up after
* a failed run or re-billing a past month after fixing data.
*/
const bodySchema = z.object({
year: z.number().int().min(2000).max(3000).optional(),
month: z.number().int().min(1).max(12).optional(),
});
export async function POST(request: Request) {
let user;
try {
await requirePlatformRole();
user = await getSessionUser();
} catch {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const body = await request.json().catch(() => ({}));
const parsed = bodySchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: "Invalid request", details: parsed.error.flatten() },
{ status: 400 }
);
}
if (
(parsed.data.year && !parsed.data.month) ||
(parsed.data.month && !parsed.data.year)
) {
return NextResponse.json(
{ error: "year and month must both be provided, or neither" },
{ status: 400 }
);
}
try {
const { runId, summary } = await runMonthlyIssuance({
triggeredBy: user.id,
year: parsed.data.year,
month: parsed.data.month,
});
return NextResponse.json({ runId, ...summary });
} catch (e) {
return NextResponse.json(
{ error: safeError(e, "Issuance sweep failed.") },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,27 @@
import { NextResponse } from "next/server";
import { requirePlatformRole } from "@/lib/session";
import {
getLastSuccessfulCronRuns,
listRecentCronRuns,
} from "@/lib/db";
/**
* GET /api/admin/cron/runs
*
* Returns recent cron run history plus per-kind "last successful"
* summary for the admin /admin/cron dashboard.
*
* Response: { recent: CronRun[]; lastSuccess: { monthlyIssue, reminders } }
*/
export async function GET() {
try {
await requirePlatformRole();
} catch {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const [recent, lastSuccess] = await Promise.all([
listRecentCronRuns(30),
getLastSuccessfulCronRuns(),
]);
return NextResponse.json({ recent, lastSuccess });
}

View File

@@ -0,0 +1,34 @@
import { NextResponse } from "next/server";
import { getSessionUser, requirePlatformRole } from "@/lib/session";
import { runReminderSweep } from "@/lib/cron";
import { safeError } from "@/lib/errors";
/**
* POST /api/admin/cron/send-reminders
*
* Admin-side manual trigger for the reminder sweep. Same logic
* as the machine path; session-based platform-role auth.
*/
export async function POST() {
let user;
try {
await requirePlatformRole();
user = await getSessionUser();
} catch {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
try {
const { runId, summary } = await runReminderSweep({
triggeredBy: user.id,
});
return NextResponse.json({ runId, ...summary });
} catch (e) {
return NextResponse.json(
{ error: safeError(e, "Reminder sweep failed.") },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,75 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { getSessionUser } from "@/lib/session";
import { getOpenClawDefaults, setOpenClawDefaults } from "@/lib/k8s";
import { safeError } from "@/lib/errors";
/**
* Platform-wide default OpenClaw image tag (admin-only).
*
* GET — read the current default tag from the
* `pieced-openclaw-config` ConfigMap. Can be empty string if no
* default is configured; the operator uses its built-in fallback
* in that case.
*
* PATCH — update the tag. Send "" to clear. The operator watches
* this ConfigMap and re-enqueues all tenants without a per-tenant
* override on change, so existing tenants roll forward to the new
* default automatically. Tenants WITH an override are unaffected.
*
* Tag-only by design — see operator notes.
*/
const patchSchema = z.object({
defaultTag: z.string().trim().max(256),
});
export async function GET() {
const user = await getSessionUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
if (!user.isPlatform) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
try {
return NextResponse.json(await getOpenClawDefaults());
} catch (e: any) {
console.error("Failed to read openclaw defaults:", e);
return NextResponse.json(
{ error: safeError(e, "Failed to read defaults") },
{ status: 500 }
);
}
}
export async function PATCH(req: NextRequest) {
const user = await getSessionUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
if (!user.isPlatform) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const body = await req.json().catch(() => null);
const parsed = patchSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: "Invalid input", details: parsed.error.flatten() },
{ status: 400 }
);
}
try {
const next = await setOpenClawDefaults({
defaultTag: parsed.data.defaultTag,
});
return NextResponse.json(next);
} catch (e: any) {
console.error("Failed to update openclaw defaults:", e);
return NextResponse.json(
{ error: safeError(e, "Failed to update defaults") },
{ status: 500 }
);
}
}

View File

@@ -4,6 +4,9 @@ import {
getTenantRequestById,
updateTenantRequestStatus,
clearEncryptedSecrets,
recordTenantCreated,
recordSkillEvents,
recordSuspensionEvent,
} from "@/lib/db";
import { createTenant, patchTenantSpec, setTenantAnnotation } from "@/lib/k8s";
import { sendApprovalEmail, sendResumeApprovalEmail } from "@/lib/email";
@@ -85,6 +88,23 @@ export async function POST(
}
try {
await patchTenantSpec(tenantRequest.tenantName, { suspend: false });
// Billing — Phase 1: record the resume so monthly proration
// counts the suspended segment correctly. Best-effort; if
// logging fails, the approval still succeeds.
try {
await recordSuspensionEvent(
tenantRequest.tenantName,
tenantRequest.zitadelOrgId,
"resumed"
);
} catch (e) {
console.error(
"billing: failed to record resumed suspension event:",
e
);
}
// Clear the annotation that pauses the operator's 60-day TTL.
// Best-effort — annotation cleanup is also done by the operator
// when it sees suspend=false on the next reconcile (it clears
@@ -199,6 +219,35 @@ export async function POST(
}
);
// Billing — Phase 1: record the tenant's creation and initial
// package state. Anchored at "now" rather than the CR's
// creationTimestamp because we don't get the timestamp back from
// createTenant — the few-millisecond skew vs the CR's actual
// creationTimestamp is irrelevant for monthly billing.
//
// Best-effort: tracking failures must never block provisioning.
// The backfill helper can repair any gaps later if needed.
const billingAnchor = new Date();
try {
await recordTenantCreated(
tenantName,
tenantRequest.zitadelOrgId,
billingAnchor
);
await recordSkillEvents(
tenantName,
tenantRequest.zitadelOrgId,
packages,
[],
billingAnchor
);
} catch (e) {
console.error(
"billing: failed to record tenant creation / initial skill events:",
e
);
}
// Step 5: Update request status — clear admin notes on re-approval
const updated = await updateTenantRequestStatus(id, "provisioning", {
adminNotes: isReApproval ? null : adminNotes,

View File

@@ -0,0 +1,155 @@
import { NextResponse } from "next/server";
import { getSessionUser, requirePlatformRole } from "@/lib/session";
import {
getSkillActivationRequestById,
recordSkillEvents,
updateSkillActivationRequestStatus,
} from "@/lib/db";
import { getTenant, patchTenantSpec } from "@/lib/k8s";
import { getPackageDef } from "@/lib/packages";
import { listOrgUsers } from "@/lib/zitadel";
import { sendSkillActivationApprovalEmail } from "@/lib/email";
import { safeError } from "@/lib/errors";
/**
* POST /api/admin/skills/pending/[id]/approve
*
* Atomic-ish approval. Ordering:
* 1. Load + sanity-check the request (must be pending).
* 2. Patch the tenant CR to include the skill in spec.packages.
* 3. Record the skill_event (kind=enabled) for billing.
* 4. Flip the request row to 'approved'.
* 5. Best-effort approval email to the requester.
*
* Step 2 is the irreversible one — if it succeeds but step 4 fails
* we end up with a skill enabled in K8s but a still-pending request
* row. That's a manual cleanup task; we log loudly so admin notices
* via the queue page (the request would reappear there).
*
* The request must be in 'pending' status. Approving an already-
* approved/rejected request returns 409.
*
* Body (optional): { adminNotes?: string }
*/
export async function POST(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
let admin;
try {
await requirePlatformRole();
admin = await getSessionUser();
} catch {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
if (!admin) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { id } = await params;
const body = await request.json().catch(() => ({}));
const adminNotes =
typeof body.adminNotes === "string" && body.adminNotes.length <= 1000
? body.adminNotes
: null;
// 1. Load + sanity-check.
const req = await getSkillActivationRequestById(id);
if (!req) {
return NextResponse.json({ error: "Request not found" }, { status: 404 });
}
if (req.status !== "pending") {
return NextResponse.json(
{ error: `Request is already ${req.status}` },
{ status: 409 }
);
}
// 2. Patch the tenant CR — add the skill if not already present.
// Defensive: if the tenant was deleted or the skill was somehow
// added by another path, we still proceed without duplicate.
let tenant;
try {
tenant = await getTenant(req.tenantName);
} catch (e) {
return NextResponse.json(
{ error: `Tenant ${req.tenantName} not found: ${safeError(e, "")}` },
{ status: 404 }
);
}
if (!tenant) {
return NextResponse.json(
{ error: `Tenant ${req.tenantName} not found` },
{ status: 404 }
);
}
const currentPackages = new Set<string>(tenant.spec.packages ?? []);
const alreadyEnabled = currentPackages.has(req.skillId);
if (!alreadyEnabled) {
currentPackages.add(req.skillId);
try {
await patchTenantSpec(req.tenantName, {
packages: [...currentPackages],
});
} catch (e) {
return NextResponse.json(
{ error: `Failed to enable skill on tenant: ${safeError(e, "")}` },
{ status: 500 }
);
}
}
// 3. Record skill event (only if we actually added it — re-adding
// would skew the day-count). Best-effort.
if (!alreadyEnabled) {
try {
await recordSkillEvents(req.tenantName, req.zitadelOrgId, [req.skillId], []);
} catch (e) {
console.error(
`Failed to record skill_event after approve (request ${id}):`,
e
);
}
}
// 4. Flip request to approved.
const updated = await updateSkillActivationRequestStatus(id, "approved", {
reviewedBy: admin.id,
adminNotes,
});
if (!updated) {
// Race: another admin tab flipped it between our read and now.
// The K8s patch already happened so we don't roll back; log so
// the human notices.
console.error(
`Request ${id} was no longer pending when we tried to mark approved; K8s patch already applied.`
);
return NextResponse.json(
{
error:
"Request status changed during approval; the skill may have been enabled. Check the queue.",
},
{ status: 409 }
);
}
// 5. Email the requester (best-effort). Look up their email via
// ZITADEL since we only stored the userId on the request.
try {
const orgUsers = await listOrgUsers(req.zitadelOrgId);
const requester = orgUsers.find((u) => u.userId === req.zitadelUserId);
if (requester?.email) {
const def = getPackageDef(req.skillId);
await sendSkillActivationApprovalEmail({
to: requester.email,
contactName: requester.displayName || requester.email,
skillName: def?.name ?? req.skillId,
tenantName: req.tenantName,
});
}
} catch (e) {
console.error(`Failed to send approval email for request ${id}:`, e);
}
return NextResponse.json(updated);
}

View File

@@ -0,0 +1,129 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { getSessionUser, requirePlatformRole } from "@/lib/session";
import {
getSkillActivationRequestById,
updateSkillActivationRequestStatus,
} from "@/lib/db";
import { getPackageDef } from "@/lib/packages";
import { listOrgUsers } from "@/lib/zitadel";
import { sendSkillActivationRejectionEmail } from "@/lib/email";
import { deletePackageSecrets } from "@/lib/openbao";
/**
* POST /api/admin/skills/pending/[id]/reject
*
* Reject a pending activation request with a required reason that
* is shown to the customer (mirroring the tenant-request rejection
* flow). The skill is NOT added to the tenant spec — it was never
* there in the first place — so the customer's enable attempt is
* effectively cancelled. They can try again from their tenant
* settings after seeing the reason (a new pending row will be
* created by their next toggle).
*
* Body:
* {
* reason: string (1..1000 chars, required),
* adminNotes?: string (optional, not shown to customer)
* }
*/
const bodySchema = z.object({
reason: z.string().min(1).max(1000),
adminNotes: z.string().max(1000).optional(),
});
export async function POST(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
let admin;
try {
await requirePlatformRole();
admin = await getSessionUser();
} catch {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
if (!admin) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { id } = await params;
const body = await request.json().catch(() => ({}));
const parsed = bodySchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: "Invalid request", details: parsed.error.flatten() },
{ status: 400 }
);
}
const req = await getSkillActivationRequestById(id);
if (!req) {
return NextResponse.json({ error: "Request not found" }, { status: 404 });
}
if (req.status !== "pending") {
return NextResponse.json(
{ error: `Request is already ${req.status}` },
{ status: 409 }
);
}
const updated = await updateSkillActivationRequestStatus(id, "rejected", {
reviewedBy: admin.id,
rejectionReason: parsed.data.reason,
adminNotes: parsed.data.adminNotes ?? null,
});
if (!updated) {
return NextResponse.json(
{ error: "Request status changed during rejection." },
{ status: 409 }
);
}
// Cleanup: if the package needed customer-provided secrets, the
// user submitted them BEFORE the gate fired (handleSubmitSecrets
// in PackageCard writes to OpenBao then PATCHes). Those secrets
// are now orphaned — the package never made it into spec, won't
// be re-attempted unless the user retries with fresh credentials.
// Best-effort delete: keep the OpenBao path clean, avoid stale
// creds lurking. Idempotent (404 is fine). Failure is logged but
// not propagated — the rejection itself already succeeded.
//
// We deliberately skip customProvisioning packages here. Those
// mint platform-side credentials via a dedicated endpoint and
// need symmetric deprovisioning (POST /[pkg.id] → DELETE
// /[pkg.id]). Calling deletePackageSecrets wouldn't revoke them
// — admin handles that path manually if the rejected request had
// already minted resources.
const def = getPackageDef(req.skillId);
if (def?.requiresSecrets && !def.customProvisioning) {
try {
await deletePackageSecrets(req.tenantName, req.skillId);
} catch (e) {
console.error(
`Failed to delete orphan secrets for ${req.tenantName}/${req.skillId} after reject:`,
e
);
}
}
// Email the requester with the reason — best-effort.
try {
const orgUsers = await listOrgUsers(req.zitadelOrgId);
const requester = orgUsers.find((u) => u.userId === req.zitadelUserId);
if (requester?.email) {
const def = getPackageDef(req.skillId);
await sendSkillActivationRejectionEmail({
to: requester.email,
contactName: requester.displayName || requester.email,
skillName: def?.name ?? req.skillId,
tenantName: req.tenantName,
reason: parsed.data.reason,
});
}
} catch (e) {
console.error(`Failed to send rejection email for request ${id}:`, e);
}
return NextResponse.json(updated);
}

View File

@@ -0,0 +1,22 @@
import { NextResponse } from "next/server";
import { requirePlatformRole } from "@/lib/session";
import { listPendingSkillActivationRequests } from "@/lib/db";
/**
* GET /api/admin/skills/pending
*
* List all pending skill-activation requests across all tenants
* and orgs. Powers the admin queue at /admin/skills/pending.
*
* Platform-role only. Returns up to 500 rows oldest-first so the
* queue UI shows the oldest requests at the top (FIFO).
*/
export async function GET() {
try {
await requirePlatformRole();
} catch {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const rows = await listPendingSkillActivationRequests();
return NextResponse.json(rows);
}

View File

@@ -4,6 +4,7 @@ import { getTenant, deleteTenant } from "@/lib/k8s";
import {
markTenantRequestDeletedByTenantName,
removeAllAssignmentsForTenant,
recordTenantDeleted,
} from "@/lib/db";
import { safeError } from "@/lib/errors";
@@ -49,6 +50,15 @@ export async function POST(
console.error("Failed to clean up tenant assignments:", e)
);
// Billing — Phase 1: stamp deletion timestamp on the lifecycle
// row so the final invoice covering the deletion month can
// prorate correctly. Idempotent at the DB layer; a missing
// lifecycle row (e.g. pre-Phase-1 tenants that haven't been
// backfilled yet) makes this a no-op.
await recordTenantDeleted(name).catch((e) =>
console.error("billing: failed to stamp tenant deletion:", e)
);
return NextResponse.json({
message: "Tenant deletion initiated. The operator will clean up all resources.",
});

View File

@@ -0,0 +1,78 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { getSessionUser } from "@/lib/session";
import { getTenant, patchTenantSpec } from "@/lib/k8s";
import { safeError } from "@/lib/errors";
/**
* Per-tenant OpenClaw image override (admin-only).
*
* Why admin-only: customers cannot pick OpenClaw versions. This
* exists so the platform team can A/B-test new releases on specific
* tenants without rolling them out fleet-wide. The endpoint enforces
* `user.isPlatform`; even owners of the tenant's org cannot use it.
*
* PATCH body shapes:
* - { tag: "2026.4.22" } → use this tag
* - { tag: "" } or empty body → clear override (revert to platform
* default)
*
* Tag-only by design — see operator notes for rationale.
*/
const patchSchema = z.object({
tag: z.string().trim().max(256).optional(),
});
export async function PATCH(
req: NextRequest,
{ params }: { params: Promise<{ name: string }> }
) {
const user = await getSessionUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
if (!user.isPlatform) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const { name } = await params;
const tenant = await getTenant(name);
if (!tenant) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
const body = await req.json().catch(() => null);
const parsed = patchSchema.safeParse(body ?? {});
if (!parsed.success) {
return NextResponse.json(
{ error: "Invalid input", details: parsed.error.flatten() },
{ status: 400 }
);
}
const tag = parsed.data.tag ?? "";
const isClearing = tag === "";
// Merge-patch semantics: openClawImage: null removes the field
// from the spec; openClawImage: { tag } sets it.
const spec: any = isClearing
? { openClawImage: null }
: { openClawImage: { tag } };
try {
const updated = await patchTenantSpec(name, spec);
return NextResponse.json({
message: isClearing
? "Override cleared; tenant follows platform default."
: "Override set.",
openClawImage: updated.spec.openClawImage ?? null,
});
} catch (e: any) {
console.error("Failed to set tenant openclaw image:", e);
return NextResponse.json(
{ error: safeError(e, "Failed to update tenant image") },
{ status: 500 }
);
}
}

View File

@@ -1,6 +1,7 @@
import { NextResponse } from "next/server";
import { requirePlatformRole } from "@/lib/session";
import { getTenant, patchTenantSpec } from "@/lib/k8s";
import { recordSuspensionEvent } from "@/lib/db";
import { safeError } from "@/lib/errors";
/**
@@ -29,6 +30,32 @@ export async function POST(
try {
const updated = await patchTenantSpec(name, { suspend });
// Billing — Phase 1: record the transition. Mirrors the same
// hook in the customer-side suspend route so admin actions
// also produce events. Best-effort; logging failures don't
// block the response.
try {
const orgId =
tenant.metadata.labels?.["pieced.ch/zitadel-org-id"] ?? null;
if (orgId) {
await recordSuspensionEvent(
name,
orgId,
suspend ? "suspended" : "resumed"
);
} else {
console.warn(
`billing: tenant ${name} has no zitadel-org-id label; suspension event not recorded`
);
}
} catch (e) {
console.error(
`billing: failed to record suspension event for ${name}:`,
e
);
}
return NextResponse.json({
message: suspend ? "Tenant suspended." : "Tenant resumed.",
tenant: updated,

View File

@@ -0,0 +1,75 @@
import { NextResponse } from "next/server";
import { getSessionUser } from "@/lib/session";
import { computeInvoiceDraft } from "@/lib/billing";
import { listInvoices } from "@/lib/db";
/**
* GET /api/billing/current
*
* Running total for the current calendar month — what the
* customer will be billed if no further activity happens. Uses
* the same compute pipeline as the final invoice (LiteLLM spend,
* Threema usage, skill day-counting, proration) so the number
* the customer sees matches what they'll eventually receive
* within the limits of intra-month drift.
*
* If an invoice has ALREADY been issued for the current month
* (e.g. cron ran early, admin manually generated), we return
* that issued invoice instead — no point showing a draft that
* duplicates a real invoice.
*
* Returns:
* { issued: Invoice } // current-month invoice exists
* { draft: InvoiceDraft } // still accruing
* { error: ... } // org missing billing config
*
* Cost: 1 LiteLLM HTTP call + 1 Threema HTTP call + a handful of
* DB queries per skill. Sub-second typically. No caching; called
* on demand from the customer billing page.
*/
export async function GET() {
const user = await getSessionUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// Resolve current calendar month from UTC. Billing is UTC-day
// based throughout (see billing.ts iterDays comment), so the
// running total inherits that same semantics.
const now = new Date();
const year = now.getUTCFullYear();
const month = now.getUTCMonth() + 1; // 1-12
const periodMonth = `${year}-${String(month).padStart(2, "0")}`;
// 1. Has the current month already been invoiced?
const existing = await listInvoices({
zitadelOrgId: user.orgId,
periodMonth,
limit: 1,
});
if (existing.length > 0) {
return NextResponse.json({ issued: existing[0] });
}
// 2. Otherwise compute the draft. Falls through to error if the
// org doesn't have a billing config yet (no Address on file).
try {
const draft = await computeInvoiceDraft({
zitadelOrgId: user.orgId,
year,
month,
});
return NextResponse.json({ draft });
} catch (e: any) {
// Most likely: org_billing row missing. We surface a 200 with a
// soft error code rather than 500 — the customer-side widget
// displays a helpful "complete your billing details" message
// instead of a stack trace.
return NextResponse.json(
{
error: e?.message ?? "Could not compute running total.",
code: e?.code ?? "COMPUTE_FAILED",
},
{ status: 200 }
);
}
}

View File

@@ -0,0 +1,105 @@
import { NextResponse } from "next/server";
import { getSessionUser } from "@/lib/session";
import {
getInvoiceByNumberForOrg,
getOrgBilling,
} from "@/lib/db";
import {
createCheckoutSessionForInvoice,
ensureStripeCustomerForOrg,
} from "@/lib/stripe";
import { safeError } from "@/lib/errors";
/**
* POST /api/billing/invoices/[invoiceNumber]/pay
*
* Initiates a Stripe Checkout Session for an open invoice. Returns
* `{ url }` — the browser is expected to navigate to that URL,
* where Stripe hosts the payment UI.
*
* Authorization: caller must belong to the invoice's org (the DB
* query enforces this — wrong-org returns 404, indistinguishable
* from a non-existent invoice).
*
* Preconditions enforced server-side:
* - Invoice exists for caller's org
* - Invoice status is 'open' or 'overdue' (paid/void/draft/uncollectible
* all reject — already-paid invoices in particular must not
* create a second Checkout Session, even though Stripe would
* deduplicate the actual charge)
*
* The Stripe Customer for the org is lazily ensured here — first
* card click on an org creates the customer; subsequent clicks
* reuse the persisted stripe_customer_id.
*/
export async function POST(
_request: Request,
{ params }: { params: Promise<{ invoiceNumber: string }> }
) {
const user = await getSessionUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { invoiceNumber } = await params;
const detail = await getInvoiceByNumberForOrg(invoiceNumber, user.orgId);
if (!detail) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
const inv = detail.invoice;
if (inv.status !== "open" && inv.status !== "overdue") {
return NextResponse.json(
{
error:
inv.status === "paid"
? "This invoice has already been paid."
: `This invoice cannot be paid online (status: ${inv.status}).`,
},
{ status: 409 }
);
}
// We need org_billing for the customer creation address. The
// invoice has a SNAPSHOT but that's frozen at issue time; for
// creating/updating the Stripe customer we want the current
// address (which may have been corrected since the invoice).
// Snapshot is still authoritative on the invoice PDF and total.
const orgBilling = await getOrgBilling(user.orgId);
if (!orgBilling) {
return NextResponse.json(
{ error: "Billing details are not configured for your organization." },
{ status: 400 }
);
}
try {
const customerId = await ensureStripeCustomerForOrg({
zitadelOrgId: user.orgId,
companyName: orgBilling.companyName,
billingEmail: orgBilling.billingEmail,
address: {
line1: orgBilling.streetAddress,
postalCode: orgBilling.postalCode,
city: orgBilling.city,
country: orgBilling.country,
},
});
const baseUrl =
process.env.APP_BASE_URL ?? "https://app.pieced.ch";
const { url } = await createCheckoutSessionForInvoice({
invoice: inv,
customerId,
baseUrl,
});
return NextResponse.json({ url });
} catch (e) {
console.error(
`Failed to create Checkout Session for invoice ${invoiceNumber}:`,
e
);
return NextResponse.json(
{ error: safeError(e, "Failed to start card payment.") },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,43 @@
import { NextResponse } from "next/server";
import { getSessionUser } from "@/lib/session";
import { getInvoiceByNumberForOrg, getInvoicePdf } from "@/lib/db";
/**
* GET /api/billing/invoices/[invoiceNumber]/pdf
*
* Customer-facing PDF download. Same Uint8Array.from() variance
* fix as the admin route — see /api/admin/billing/invoices/[id]/pdf
* for the rationale.
*
* Authorization: looks up the invoice by number with org scope
* baked into the query, then re-fetches the PDF blob by id. A
* customer can't probe another org's invoice numbers — they get
* 404 either way.
*/
export async function GET(
_request: Request,
{ params }: { params: Promise<{ invoiceNumber: string }> }
) {
const user = await getSessionUser();
if (!user) {
return new NextResponse("Unauthorized", { status: 401 });
}
const { invoiceNumber } = await params;
const detail = await getInvoiceByNumberForOrg(invoiceNumber, user.orgId);
if (!detail) {
return new NextResponse("Not found", { status: 404 });
}
const pdf = await getInvoicePdf(detail.invoice.id);
if (!pdf) {
return new NextResponse("PDF not available", { status: 404 });
}
const body = Uint8Array.from(pdf.data);
return new NextResponse(body, {
status: 200,
headers: {
"Content-Type": "application/pdf",
"Content-Disposition": `inline; filename="${pdf.filename}"`,
"Cache-Control": "private, max-age=0, must-revalidate",
},
});
}

View File

@@ -0,0 +1,27 @@
import { NextResponse } from "next/server";
import { getSessionUser } from "@/lib/session";
import { getInvoiceByNumberForOrg } from "@/lib/db";
/**
* GET /api/billing/invoices/[invoiceNumber]
*
* Customer-scoped detail lookup by invoice number (the human-
* readable YYYY-NNNNN format the customer sees on the PDF). The
* org filter is part of the DB query — a customer probing another
* org's invoice number gets the same 404 as a non-existent one.
*/
export async function GET(
_request: Request,
{ params }: { params: Promise<{ invoiceNumber: string }> }
) {
const user = await getSessionUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { invoiceNumber } = await params;
const detail = await getInvoiceByNumberForOrg(invoiceNumber, user.orgId);
if (!detail) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
return NextResponse.json(detail);
}

View File

@@ -0,0 +1,39 @@
import { NextResponse } from "next/server";
import { getSessionUser } from "@/lib/session";
import { listInvoices, syncOverdueInvoices } from "@/lib/db";
/**
* GET /api/billing/invoices
*
* Customer-scoped list of invoices for the caller's org. Returns
* a flat array of Invoice headers (no line items — those are
* fetched separately by /[invoiceNumber]).
*
* Status filter is implicit: we return every invoice the
* customer's org has, all statuses (issued/paid/overdue/void)
* because the customer wants a single billing-history view.
*
* Before returning we run syncOverdueInvoices() so the displayed
* status reflects the current date — issued invoices past their
* due_at flip to 'overdue'. Cheap, idempotent, and avoids needing
* a separate cron for this transition.
*/
export async function GET() {
const user = await getSessionUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// Personal accounts have an org too — they share the same shape;
// their invoices show up under that synthetic org id.
try {
await syncOverdueInvoices();
} catch (e) {
// Non-fatal — display stale status rather than 500.
console.warn("syncOverdueInvoices failed in /api/billing/invoices:", e);
}
const invoices = await listInvoices({
zitadelOrgId: user.orgId,
limit: 200,
});
return NextResponse.json(invoices);
}

View File

@@ -0,0 +1,42 @@
import { NextResponse } from "next/server";
import { runMonthlyIssuance, verifyCronBearer } from "@/lib/cron";
import { safeError } from "@/lib/errors";
/**
* POST /api/cron/issue-monthly
*
* Machine entry point for the monthly issuance sweep. Authentication
* is the shared bearer token in CRON_BEARER_TOKEN, injected from
* OpenBao via the portal-cron K8s Secret. The K8s CronJob sends:
*
* curl -X POST -H "Authorization: Bearer $CRON_BEARER_TOKEN" \
* https://app.pieced.ch/api/cron/issue-monthly
*
* The sweep targets the calendar month that ended just before
* "now" in Europe/Zurich. Running it on June 1st at 00:30 Swiss
* time bills May; running it on July 5th bills June; etc. The
* uniqueness constraint on (org, period_start) makes re-runs
* harmless — already-issued orgs are counted as skipped.
*
* Returns the summary {success, failure, skipped} JSON. The
* CronJob doesn't look at the response body (just the status
* code) but having a useful one helps debugging via curl.
*/
export const dynamic = "force-dynamic";
export async function POST(request: Request) {
if (!verifyCronBearer(request.headers.get("authorization"))) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
try {
const { runId, summary } = await runMonthlyIssuance({
triggeredBy: "cron",
});
return NextResponse.json({ runId, ...summary });
} catch (e) {
return NextResponse.json(
{ error: safeError(e, "Issuance sweep failed.") },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,33 @@
import { NextResponse } from "next/server";
import { runReminderSweep, verifyCronBearer } from "@/lib/cron";
import { safeError } from "@/lib/errors";
/**
* POST /api/cron/send-reminders
*
* Machine entry point for the daily reminder sweep. Same auth
* (bearer token in CRON_BEARER_TOKEN) and the same response
* contract as /api/cron/issue-monthly.
*
* Schedule: 09:00 Europe/Zurich daily. Picks invoices that are
* past their due date and haven't received the corresponding
* reminder level yet; sends one email per invoice per run.
*/
export const dynamic = "force-dynamic";
export async function POST(request: Request) {
if (!verifyCronBearer(request.headers.get("authorization"))) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
try {
const { runId, summary } = await runReminderSweep({
triggeredBy: "cron",
});
return NextResponse.json({ runId, ...summary });
} catch (e) {
return NextResponse.json(
{ error: safeError(e, "Reminder sweep failed.") },
{ status: 500 }
);
}
}

View File

@@ -252,11 +252,24 @@ export async function POST(request: Request) {
}
}
// For follow-up instances, prefer the on-file company name and contact
// details; the user can't change those by re-typing them in the wizard.
// The audit copy of company name on this request stays inherited
// from the first request in the org — it's a historical snapshot
// of the company name at the time the request was created, and
// org_billing is now the canonical source for current values.
//
// Phase 6 fix4: contactName and contactEmail are NOT inherited.
// They identify whoever submitted THIS specific request (drives
// admin display, support ticket routing, and email greetings).
// The previous "prior?.contactName ?? user.name" pattern locked
// the contact to whoever first onboarded the org, which broke for
// any subsequent submission by a different user — admin saw the
// wrong name, support emails went to the wrong person, and the
// actual submitter had no way to correct it because the wizard
// doesn't expose a contact-name input. The fix is simply to use
// the current session user every time.
const companyName = prior?.companyName ?? user.orgName;
const contactName = prior?.contactName ?? user.name;
const contactEmail = prior?.contactEmail ?? user.email;
const contactName = user.name;
const contactEmail = user.email;
// Bug 35: org-scoped billing.
//

View File

@@ -0,0 +1,90 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { getSessionUser } from "@/lib/session";
import { getOrgBilling, upsertOrgBilling } from "@/lib/db";
/**
* GET /api/settings/billing — read the caller's org_billing row.
* Returns null if the org hasn't configured billing yet — the
* form renders empty and the PUT will create on first save.
*
* PUT /api/settings/billing — upsert the row.
*
* Authorization: caller must have role "owner" in their org.
* Non-owners get 403 (they shouldn't have reached the page UI
* anyway, which hides the link, but the API enforces too — a
* non-owner who hits this directly with curl gets refused).
*
* Personal accounts are inherently their own owner (single-user
* org), so user.roles.includes("owner") returns true and they
* can manage their own billing.
*/
const upsertSchema = z.object({
companyName: z.string().trim().min(1).max(200),
// Phase 6 fix: optional "z.Hd." / "Attn:" line. Personal accounts
// never send this (the UI hides the field); orgs may set or leave
// it empty.
contactName: z.string().trim().max(200).optional().nullable(),
streetAddress: z.string().trim().min(1).max(200),
postalCode: z.string().trim().min(1).max(20),
city: z.string().trim().min(1).max(100),
// ISO 3166-1 alpha-2. We normalise to uppercase server-side.
country: z
.string()
.trim()
.length(2)
.regex(/^[A-Za-z]{2}$/, "Use a 2-letter ISO country code (CH, DE, …)"),
vatNumber: z.string().trim().max(40).optional().nullable(),
billingEmail: z.string().trim().email().max(200),
notes: z.string().trim().max(2000).optional().nullable(),
});
function requireOwner(user: { roles: string[] } | null) {
if (!user) return false;
return user.roles.includes("owner");
}
export async function GET() {
const user = await getSessionUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
if (!requireOwner(user as any)) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const billing = await getOrgBilling(user.orgId);
return NextResponse.json({ billing });
}
export async function PUT(request: Request) {
const user = await getSessionUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
if (!requireOwner(user as any)) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const body = await request.json().catch(() => ({}));
const parsed = upsertSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: "Invalid request", details: parsed.error.flatten() },
{ status: 400 }
);
}
const data = parsed.data;
const billing = await upsertOrgBilling({
zitadelOrgId: user.orgId,
companyName: data.companyName,
contactName: data.contactName ?? null,
streetAddress: data.streetAddress,
postalCode: data.postalCode,
city: data.city,
country: data.country.toUpperCase(),
vatNumber: data.vatNumber ?? null,
billingEmail: data.billingEmail,
notes: data.notes ?? null,
});
return NextResponse.json({ billing });
}

View File

@@ -0,0 +1,23 @@
import { NextResponse } from "next/server";
import { getSessionUser } from "@/lib/session";
import { listSkillPricing } from "@/lib/db";
/**
* GET /api/skills/pricing
*
* Returns the platform-wide skill pricing (daily price + setup fee
* per skill) for display in the customer's cost-disclosure dialog
* before they enable a priced skill. Any logged-in user can read
* this — pricing isn't org-specific and is effectively public
* information for anyone who'd be considering activation.
*
* Empty array means no skill is currently priced.
*/
export async function GET() {
const user = await getSessionUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const rows = await listSkillPricing();
return NextResponse.json(rows);
}

View File

@@ -0,0 +1,74 @@
import { NextResponse } from "next/server";
import { getSessionUser } from "@/lib/session";
import {
getSkillActivationRequestById,
updateSkillActivationRequestStatus,
} from "@/lib/db";
import { getPackageDef } from "@/lib/packages";
import { deletePackageSecrets } from "@/lib/openbao";
/**
* POST /api/skills/requests/[id]/withdraw
*
* The owner of a pending activation request can cancel it. This
* doesn't touch K8s (the skill was never enabled) — it just flips
* the row to 'withdrawn' so the user's UI clears the pending
* state and they can try a different skill or retry later.
*
* Authorization: only the original requester OR a platform admin
* can withdraw a request. We deliberately don't allow other org
* members to cancel each other's requests in v1 — the partial
* unique index would let one user repeatedly cancel another's
* pending request.
*/
export async function POST(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const user = await getSessionUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { id } = await params;
const req = await getSkillActivationRequestById(id);
if (!req) {
return NextResponse.json({ error: "Request not found" }, { status: 404 });
}
if (!user.isPlatform && req.zitadelUserId !== user.id) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
if (req.status !== "pending") {
return NextResponse.json(
{ error: `Request is already ${req.status}` },
{ status: 409 }
);
}
const updated = await updateSkillActivationRequestStatus(id, "withdrawn", {
reviewedBy: user.id,
});
if (!updated) {
return NextResponse.json(
{ error: "Request status changed during withdraw." },
{ status: 409 }
);
}
// Cleanup: same logic as reject — the user submitted secrets
// before the gate fired, and those are now orphaned in OpenBao.
// Best-effort delete; failure logged but not propagated. Skip
// customProvisioning packages (their deprovisioning is a
// separate, dedicated endpoint).
const def = getPackageDef(req.skillId);
if (def?.requiresSecrets && !def.customProvisioning) {
try {
await deletePackageSecrets(req.tenantName, req.skillId);
} catch (e) {
console.error(
`Failed to delete orphan secrets for ${req.tenantName}/${req.skillId} after withdraw:`,
e
);
}
}
return NextResponse.json(updated);
}

View File

@@ -0,0 +1,40 @@
import { NextResponse } from "next/server";
import { getSessionUser } from "@/lib/session";
import { listSkillActivationRequestsForTenant } from "@/lib/db";
import { canUserSeeTenant } from "@/lib/visibility";
import { getTenant } from "@/lib/k8s";
/**
* GET /api/skills/requests?tenant=<name>
*
* Returns pending and most-recent-rejected skill activation
* requests for the named tenant. Used by the tenant settings page
* to render the "Manual review pending" or "Activation rejected"
* inline states on PackageCard.
*
* Authorization: the caller must be able to see the tenant (owner
* of its org, assigned user, or platform admin).
*/
export async function GET(request: Request) {
const user = await getSessionUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const tenantName = searchParams.get("tenant");
if (!tenantName) {
return NextResponse.json(
{ error: "Missing tenant parameter" },
{ status: 400 }
);
}
const tenant = await getTenant(tenantName).catch(() => null);
if (!tenant) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
if (!canUserSeeTenant(user, tenant)) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const requests = await listSkillActivationRequestsForTenant(tenantName);
return NextResponse.json(requests);
}

View File

@@ -0,0 +1,232 @@
import { NextResponse } from "next/server";
import type Stripe from "stripe";
import { getStripeClient, getWebhookSecret } from "@/lib/stripe";
import {
markInvoicePaid,
markStripeEventProcessed,
setInvoiceStripePaymentIntent,
tryRecordStripeEvent,
} from "@/lib/db";
/**
* POST /api/stripe/webhook
*
* Receives signed events from Stripe. The lifecycle:
*
* 1. Read RAW body (request.text(), NOT request.json() — Stripe's
* HMAC is computed over the raw bytes and any JSON re-parse
* will subtly mangle whitespace or property ordering and the
* signature will fail).
* 2. Verify signature against the configured webhook secret. If
* verification fails → 400. An attacker forging webhook calls
* could otherwise mark our invoices paid.
* 3. Idempotency: INSERT the event id into stripe_events. If the
* INSERT conflicts (duplicate delivery, which is normal — Stripe
* retries failed deliveries for up to 72h), return 200 immediately
* so Stripe doesn't keep retrying.
* 4. Process the event based on type. Currently we care about:
* - checkout.session.completed → flip invoice to paid
* - charge.refunded → log; void/credit handling is Phase 7
* - payment_intent.payment_failed → log only; the failure is
* already shown to the user on
* the Stripe page, no action.
* Unknown event types are ack'd with 200 (we may have other
* events enabled at the Stripe end that we don't yet care about,
* and 200 + log is cheaper than 404 + Stripe retries).
* 5. Stamp processed_at on success.
*
* Return contract: 2xx ack → Stripe stops retrying. Any non-2xx →
* Stripe retries with exponential backoff up to 72h. We aim for
* 200 on every reachable path (verified, deduplicated, or processed),
* and only 400 for signature failures (those would never succeed
* on retry anyway, so retrying is wasted effort).
*
* Performance: handlers run synchronously here because PieCed's
* event volume is tiny. If/when that changes, the obvious refactor
* is to enqueue (Phase 7) and ack first — but at v1 the inline
* model is simpler to reason about and harder to lose events with.
*/
// Next.js: explicitly disable static optimization; this route MUST
// run on every request and must not be cached.
export const dynamic = "force-dynamic";
export async function POST(request: Request) {
// 1. Raw body — Stripe verifies the signature over these exact bytes.
const rawBody = await request.text();
const signature = request.headers.get("stripe-signature");
if (!signature) {
return new NextResponse("Missing stripe-signature header", {
status: 400,
});
}
// 2. Verify signature.
let event: Stripe.Event;
try {
const stripe = getStripeClient();
const secret = getWebhookSecret();
event = stripe.webhooks.constructEvent(rawBody, signature, secret);
} catch (err) {
console.error("Stripe webhook signature verification failed:", err);
// 400 — never retry. The webhook configuration is wrong on
// either end (rotated secret, wrong endpoint, etc.); retries
// won't fix it.
return new NextResponse("Invalid signature", { status: 400 });
}
// 3. Idempotency. INSERT event.id → fail-fast on duplicate.
let firstDelivery: boolean;
try {
firstDelivery = await tryRecordStripeEvent(
event.id,
event.type,
event
);
} catch (err) {
console.error(
`Failed to record stripe event ${event.id} (${event.type}):`,
err
);
// 5xx so Stripe retries — this is a DB hiccup, not a logic error.
return new NextResponse("DB error", { status: 500 });
}
if (!firstDelivery) {
// Already processed; ack happily.
return new NextResponse("Duplicate delivery; acknowledged.", {
status: 200,
});
}
// 4. Process. Each handler is responsible for being safe to run
// exactly once (we already deduplicated by event.id above).
try {
switch (event.type) {
case "checkout.session.completed":
await handleCheckoutCompleted(
event.data.object as Stripe.Checkout.Session
);
break;
case "charge.refunded":
await handleChargeRefunded(event.data.object as Stripe.Charge);
break;
case "payment_intent.payment_failed":
await handlePaymentFailed(
event.data.object as Stripe.PaymentIntent
);
break;
default:
// Unknown event — log so we notice if Stripe starts sending
// something we should handle, but ack so we don't accumulate
// retries forever.
console.log(
`Stripe webhook: ignoring event type ${event.type} (id ${event.id})`
);
}
} catch (err) {
console.error(
`Stripe webhook handler failed for ${event.type} (id ${event.id}):`,
err
);
// 5xx → Stripe retries. The handler is idempotent because the
// stripe_events row already exists, so on the next attempt we'd
// short-circuit at step 3. To actually retry the work we'd need
// to DELETE the stripe_events row first; for v1 we don't bother
// and let a human investigate the logs.
return new NextResponse("Handler error", { status: 500 });
}
// 5. Mark processed.
try {
await markStripeEventProcessed(event.id);
} catch (err) {
// Non-fatal — the event was already processed, this is just the
// bookkeeping flag. Log and move on.
console.error(
`Failed to mark stripe event ${event.id} processed:`,
err
);
}
return new NextResponse("OK", { status: 200 });
}
// ---------------------------------------------------------------------------
// Handlers
// ---------------------------------------------------------------------------
async function handleCheckoutCompleted(
session: Stripe.Checkout.Session
): Promise<void> {
// Defensive: paid sessions are what we want; sessions can also
// complete in "unpaid" state (rare for mode=payment, more common
// for async/delayed methods like SEPA). Only flip the invoice
// when payment actually cleared.
if (session.payment_status !== "paid") {
console.log(
`Checkout session ${session.id} completed but payment_status=${session.payment_status}; waiting for downstream events.`
);
return;
}
const invoiceId =
session.metadata?.invoice_id ?? session.client_reference_id ?? null;
if (!invoiceId) {
console.error(
`Checkout session ${session.id} completed without invoice_id metadata; cannot link to invoice.`
);
return;
}
const paymentIntentId =
typeof session.payment_intent === "string"
? session.payment_intent
: session.payment_intent?.id;
// Persist the PaymentIntent id on the invoice for traceability +
// future refund correlation.
if (paymentIntentId) {
await setInvoiceStripePaymentIntent(invoiceId, paymentIntentId);
}
// Flip status. markInvoicePaid is idempotent — re-running on an
// already-paid invoice returns null and we log + skip.
const updated = await markInvoicePaid(invoiceId, {
paidBy: "stripe",
paidMethodDetail: paymentIntentId
? `Stripe Checkout (${paymentIntentId})`
: "Stripe Checkout",
paidAt: session.created ? new Date(session.created * 1000) : undefined,
});
if (!updated) {
// Already paid or void/draft — fine, nothing to do.
console.log(
`Invoice ${invoiceId} was not in a payable state when Stripe webhook arrived (likely already paid).`
);
return;
}
console.log(
`Invoice ${invoiceId} marked paid via Stripe (session ${session.id}, intent ${paymentIntentId}).`
);
}
async function handleChargeRefunded(charge: Stripe.Charge): Promise<void> {
// v1 scope: log only. Refunds always go through Stripe → admin
// initiates them in the dashboard. Updating our invoice status
// to 'void' or partial-credit needs more product thinking
// (partial refunds? credit notes? VAT corrections?). Phase 7.
console.log(
`Charge ${charge.id} refunded (amount ${charge.amount_refunded} ${charge.currency}); no portal-side state change.`
);
}
async function handlePaymentFailed(
intent: Stripe.PaymentIntent
): Promise<void> {
// The Stripe-hosted page already shows the failure to the user.
// We log here for support visibility and to surface in Workbench.
// No invoice state change — it stays 'open' until paid.
console.log(
`PaymentIntent ${intent.id} failed: ${
intent.last_payment_error?.message ?? "(no message)"
}`
);
}

View File

@@ -0,0 +1,126 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { getSessionUser, canMutate } from "@/lib/session";
import { getTenant } from "@/lib/k8s";
import { canUserSeeTenant } from "@/lib/visibility";
import { findKeyByAlias, updateKeyBudget } from "@/lib/litellm";
import { safeError } from "@/lib/errors";
/**
* Update the per-tenant budget — operates on the LiteLLM virtual
* key, NOT on the team.
*
* Why per-key
* -----------
* Each tenant in an org has its own virtual key
* (`key_alias = tenant.metadata.name`); the team that owns those
* keys is org-scoped and shared across all the org's tenants. A
* budget on the team would cap the whole org; a budget on the key
* caps just this one tenant. Customers landing on the tenant detail
* page reasonably expect "edit budget" to mean "the budget of THIS
* tenant" — so we put it on the key.
*
* The team-level (org-wide) budget is a separate control that lives
* in /settings (not yet implemented) — the two coexist: LiteLLM
* applies whichever cap is hit first.
*
* Schema:
* - maxBudget: number > 0 (set a cap), or null (remove the cap).
* - budgetDuration: one of "30d", "1mo", "1y", or null (lifetime).
*
* Authorization: owners and platform admins.
*/
const patchSchema = z.object({
// > 0 because LiteLLM rejects 0 and a zero cap would lock the key
// out instantly. Upper bound 1M as a typo guard.
maxBudget: z.number().positive().max(1_000_000).nullable(),
budgetDuration: z.enum(["30d", "1mo", "1y"]).nullable(),
});
export async function PATCH(
req: NextRequest,
{ params }: { params: Promise<{ name: string }> }
) {
const user = await getSessionUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
if (!canMutate(user)) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const { name } = await params;
const tenant = await getTenant(name);
if (!tenant) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
if (!(await canUserSeeTenant(user, tenant))) {
// Don't leak existence — same 404 a non-visible tenant gets.
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
const teamId = tenant.status?.litellmTeamId;
if (!teamId) {
return NextResponse.json(
{
error:
"Tenant has no LiteLLM team yet. Please wait until provisioning completes.",
},
{ status: 409 }
);
}
const body = await req.json().catch(() => null);
const parsed = patchSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: "Invalid input", details: parsed.error.flatten() },
{ status: 400 }
);
}
// Defensive: removing the cap should null out the duration too —
// a reset cadence on an unlimited budget is meaningless and would
// confuse LiteLLM's bookkeeping.
const maxBudget = parsed.data.maxBudget;
const budgetDuration =
maxBudget === null ? null : parsed.data.budgetDuration;
// Look up the key by alias (= tenant name). The token returned is
// what /key/update wants in the `key` field.
let keyInfo;
try {
keyInfo = await findKeyByAlias(teamId, name);
} catch (e: any) {
console.error("Failed to look up tenant key:", e);
return NextResponse.json(
{ error: safeError(e, "Failed to look up tenant key") },
{ status: 500 }
);
}
if (!keyInfo) {
return NextResponse.json(
{
error:
"Tenant has no virtual key yet. Please wait until provisioning completes.",
},
{ status: 409 }
);
}
try {
await updateKeyBudget(keyInfo.token, { maxBudget, budgetDuration });
return NextResponse.json({
message: maxBudget === null ? "Budget removed." : "Budget updated.",
maxBudget,
budgetDuration,
});
} catch (e: any) {
console.error("Failed to update key budget:", e);
return NextResponse.json(
{ error: safeError(e, "Failed to update budget") },
{ status: 500 }
);
}
}

View File

@@ -3,6 +3,12 @@ import { getSessionUser, canMutate } from "@/lib/session";
import { canUserSeeTenant } from "@/lib/visibility";
import { getTenant, patchTenantSpec } from "@/lib/k8s";
import { getPackageDef } from "@/lib/packages";
import {
createSkillActivationRequest,
getOrgBilling,
recordSkillEvents,
} from "@/lib/db";
import { sendSkillActivationAdminNotification } from "@/lib/email";
import { safeError } from "@/lib/errors";
const ALLOWED_WORKSPACE_FILES = ["SOUL.md", "AGENTS.md", "TOOLS.md"];
@@ -68,6 +74,17 @@ export async function PATCH(
const specPatch: Record<string, any> = {};
// Track manual-setup gate activations created during this PATCH.
// We push to the K8s spec only the non-gated skills; the gated
// ones live in skill_activation_requests until admin approves
// and adds them via the admin endpoint. Platform admins bypass
// the gate (direct enable from /admin still applies immediately).
let gatedRequests: Array<{
skillId: string;
requestId: string;
skillName: string;
}> = [];
// ── Validate packages against catalog ──
if (body.packages !== undefined) {
if (!Array.isArray(body.packages) || body.packages.length > 10) {
@@ -84,7 +101,63 @@ export async function PATCH(
);
}
}
specPatch.packages = body.packages;
// Compute the to-be-added set against the existing spec.
const existingPackages = new Set<string>(existing.spec.packages ?? []);
const desiredPackages: string[] = body.packages;
const newlyAdded = desiredPackages.filter(
(p) => !existingPackages.has(p)
);
// Manual-setup gate. Customer adds get routed to the queue;
// platform admins go straight through.
if (!user.isPlatform && newlyAdded.length > 0) {
const orgIdForGate =
existing.metadata.labels?.["pieced.ch/zitadel-org-id"];
if (!orgIdForGate) {
// Defensive: every customer-visible tenant should have the
// org label. Without it we can't attribute the request.
return NextResponse.json(
{ error: "Tenant missing org binding; contact support." },
{ status: 500 }
);
}
const gatedSet = new Set<string>();
for (const skillId of newlyAdded) {
const def = getPackageDef(skillId);
if (!def?.requiresManualSetup) continue;
gatedSet.add(skillId);
try {
const req = await createSkillActivationRequest({
tenantName: name,
zitadelOrgId: orgIdForGate,
zitadelUserId: user.id,
skillId,
});
gatedRequests.push({
skillId,
requestId: req.id,
skillName: def.name,
});
} catch (e: any) {
if (e?.code === "REQUEST_ALREADY_PENDING") {
// Idempotent: a pending row already exists; just keep
// the skill out of the K8s spec and surface it as
// gated without creating a duplicate.
gatedRequests.push({
skillId,
requestId: "",
skillName: def.name,
});
} else {
throw e;
}
}
}
// Strip gated skills from the desired spec — they must not
// reach K8s until approved.
specPatch.packages = desiredPackages.filter((p) => !gatedSet.has(p));
} else {
specPatch.packages = desiredPackages;
}
}
// ── Validate workspaceFiles ──
@@ -187,7 +260,93 @@ export async function PATCH(
}
const updated = await patchTenantSpec(name, specPatch);
return NextResponse.json(updated);
// Billing — Phase 1: if packages changed, record enable/disable
// events. The diff is computed against the patched CR (the
// returned state) rather than `existing` so the events match
// what K8s actually committed. Best-effort: a logging failure
// never poisons the PATCH response — drift would be reconciled
// on the next backfill or by the next normal toggle.
//
// Note on races: two concurrent PATCHes could each see the
// same `existing` and both succeed at the K8s layer (last write
// wins for spec.packages, which is replaced wholesale). The
// events from the losing PATCH would then describe a transition
// that no longer reflects reality. Acceptable trade-off for v1
// — the toggle UI sends one request at a time and races would
// only matter for adjacent same-day toggles, which the billing
// computation collapses to a single billable day anyway.
if (specPatch.packages !== undefined) {
try {
const orgId =
existing.metadata.labels?.["pieced.ch/zitadel-org-id"] ?? null;
if (orgId) {
const oldSet = new Set<string>(existing.spec.packages ?? []);
const newSet = new Set<string>(updated.spec.packages ?? []);
const added = [...newSet].filter((x) => !oldSet.has(x));
const removed = [...oldSet].filter((x) => !newSet.has(x));
if (added.length > 0 || removed.length > 0) {
await recordSkillEvents(name, orgId, added, removed);
}
} else {
// A tenant without the org label is a pre-Slice-3 artifact
// — we can't attribute its skill events to any org. Log
// and skip rather than guess.
console.warn(
`billing: tenant ${name} has no zitadel-org-id label; skill events not recorded`
);
}
} catch (e) {
console.error(
`billing: failed to record skill events for ${name}:`,
e
);
}
}
// Phase 2.5: notify admin of newly created activation requests.
// Best-effort — email failure must not poison the PATCH response.
// requestId === "" means an existing-pending row was reused, so
// skip the email in that case (admin already knows).
if (gatedRequests.length > 0) {
const orgIdForEmail =
existing.metadata.labels?.["pieced.ch/zitadel-org-id"] ?? null;
const companyName = orgIdForEmail
? await getOrgBilling(orgIdForEmail)
.then((b) => b?.companyName ?? null)
.catch(() => null)
: null;
for (const g of gatedRequests) {
if (!g.requestId) continue;
try {
await sendSkillActivationAdminNotification({
tenantName: name,
skillId: g.skillId,
skillName: g.skillName,
requesterEmail: user.email,
requesterName: user.name,
companyName,
});
} catch (e) {
console.error(
`Failed to send admin notification for skill activation request:`,
e
);
}
}
}
return NextResponse.json({
...updated,
// Phase 2.5: tells the client which requested-to-enable skills
// didn't actually land in the spec because they're awaiting
// admin approval. UI uses this to render the "pending review"
// state on those skill cards.
pendingActivationRequests: gatedRequests.map((g) => ({
skillId: g.skillId,
skillName: g.skillName,
})),
});
} catch (e: any) {
return NextResponse.json(
{ error: safeError(e, "Failed to update tenant") },

View File

@@ -3,6 +3,7 @@ import { z } from "zod";
import { getSessionUser, canMutate } from "@/lib/session";
import { getTenant, patchTenantSpec, setTenantAnnotation } from "@/lib/k8s";
import { canUserSeeTenant } from "@/lib/visibility";
import { recordSuspensionEvent } from "@/lib/db";
import { safeError } from "@/lib/errors";
const patchSchema = z.object({
@@ -101,6 +102,33 @@ export async function PATCH(
try {
await patchTenantSpec(name, { suspend });
// Billing — Phase 1: record the transition so monthly proration
// can exclude suspended days from the fixed fee. The portal
// commands this transition; the operator's status.suspendedAt
// lags by a reconcile cycle (seconds), which is irrelevant for
// monthly billing. Best-effort: a logging failure never blocks
// the suspend/resume itself.
try {
const orgId =
tenant.metadata.labels?.["pieced.ch/zitadel-org-id"] ?? null;
if (orgId) {
await recordSuspensionEvent(
name,
orgId,
suspend ? "suspended" : "resumed"
);
} else {
console.warn(
`billing: tenant ${name} has no zitadel-org-id label; suspension event not recorded`
);
}
} catch (e) {
console.error(
`billing: failed to record suspension event for ${name}:`,
e
);
}
// On admin-side resume, also clear the pending-resume-request
// annotation if it exists. Belt-and-suspenders: the admin-approve
// endpoint already clears it on its happy path, but a platform

View File

@@ -0,0 +1,168 @@
import { NextRequest, NextResponse } from "next/server";
import { getSessionUser, canMutate } from "@/lib/session";
import { getTenant } from "@/lib/k8s";
import {
writePackageSecrets,
deletePackageSecrets,
} from "@/lib/openbao";
import { mintToken, revokeToken } from "@/lib/threema-relay";
import { safeError } from "@/lib/errors";
/**
* Threema package provisioning — special-cased because the credentials
* are platform-issued (relay mints them), not customer-supplied.
*
* POST /api/tenants/:name/threema
* - Mints a per-tenant bearer + HMAC secret from the central relay.
* - Writes both to OpenBao under
* secret/data/tenants/<tenant-{name}>/threema-relay so the
* operator's ExternalSecret can sync them into the tenant
* namespace alongside other channel secrets.
* - Returns 200 on success. The caller (PackageCard) then PATCHes
* tenant.spec.packages to add "threema".
*
* DELETE /api/tenants/:name/threema
* - Revokes the per-tenant token at the relay (cascades to all
* routes — the relay's tokens.deleteToken also deletes routes).
* - Deletes the OpenBao secret so the ExternalSecret/operator can
* converge cleanly.
* - Returns 200 on success even if no token existed (idempotent).
*
* Failure semantics
* -----------------
* On POST: if minting succeeds but the OpenBao write fails, we attempt
* to revoke the just-minted token before returning the error. That way
* the relay doesn't keep an orphan token row that nothing can use.
* Best-effort cleanup; if the revoke also fails, the relay admin can
* use DELETE /admin/tokens/<name> manually.
*
* On DELETE: we revoke FIRST, then delete OpenBao. If revoke fails we
* return the error and stop — leaving OpenBao alone means the pod's
* still-mounted secret keeps working in the brief window between
* "customer hits disable" and "operator reconciles spec without threema",
* which is more graceful than yanking the secret out from under a
* running pod.
*/
const VAULT_SUFFIX = "threema-relay";
export async function POST(
_req: NextRequest,
{ params }: { params: Promise<{ name: string }> },
) {
const user = await getSessionUser();
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
if (!canMutate(user))
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
const { name } = await params;
try {
const tenant = await getTenant(name);
if (!tenant)
return NextResponse.json({ error: "Not found" }, { status: 404 });
if (
!user.isPlatform &&
tenant.metadata.labels?.["pieced.ch/zitadel-org-id"] !== user.orgId
) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const minted = await mintToken(name);
if (!minted.ok) {
return NextResponse.json(
{ error: `Relay mint failed: ${minted.message}` },
{ status: minted.kind === "http" ? 502 : 503 },
);
}
try {
await writePackageSecrets(`tenant-${name}`, VAULT_SUFFIX, {
token: minted.token,
"hmac-secret": minted.hmacSecret,
});
} catch (e) {
// Compensate: revoke the just-minted token so the relay doesn't
// hold an orphan. Best-effort — log and continue surfacing the
// original error.
const revoke = await revokeToken(name);
if (!revoke.ok) {
console.error(
`[threema/provision] Compensating revoke failed for ${name}: ${revoke.message}`,
);
}
return NextResponse.json(
{ error: `OpenBao write failed: ${safeError(e, "secret store unavailable")}` },
{ status: 503 },
);
}
return NextResponse.json({ ok: true });
} catch (e) {
console.error("[threema/provision]", e);
return NextResponse.json(
{ error: safeError(e, "Provisioning failed") },
{ status: 500 },
);
}
}
export async function DELETE(
_req: NextRequest,
{ params }: { params: Promise<{ name: string }> },
) {
const user = await getSessionUser();
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
if (!canMutate(user))
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
const { name } = await params;
try {
const tenant = await getTenant(name);
if (!tenant)
return NextResponse.json({ error: "Not found" }, { status: 404 });
if (
!user.isPlatform &&
tenant.metadata.labels?.["pieced.ch/zitadel-org-id"] !== user.orgId
) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const revoke = await revokeToken(name);
// 404 from relay = nothing to revoke = idempotent success.
if (!revoke.ok && !(revoke.kind === "http" && revoke.status === 404)) {
return NextResponse.json(
{ error: `Relay revoke failed: ${revoke.message}` },
{ status: revoke.kind === "http" ? 502 : 503 },
);
}
// Delete the OpenBao secret. Idempotent — deletePackageSecrets
// tolerates 404.
try {
await deletePackageSecrets(`tenant-${name}`, VAULT_SUFFIX);
} catch (e) {
// Already revoked at the relay — surface the openbao failure
// but keep the partial-success state visible.
return NextResponse.json(
{
error: `Token revoked, but OpenBao delete failed: ${safeError(e, "secret store unavailable")}`,
partial: true,
},
{ status: 503 },
);
}
return NextResponse.json({
ok: true,
deletedRoutes: revoke.ok ? revoke.deletedRoutes : 0,
});
} catch (e) {
console.error("[threema/deprovision]", e);
return NextResponse.json(
{ error: safeError(e, "Deprovisioning failed") },
{ status: 500 },
);
}
}

View File

@@ -0,0 +1,217 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { getSessionUser, canMutate } from "@/lib/session";
import { getTenant, patchTenantSpec } from "@/lib/k8s";
import {
createRoute,
deleteRoute,
isRouteConflictForOtherTenant,
isRouteConflictForSameTenant,
listRoutes,
} from "@/lib/threema-relay";
import { safeError } from "@/lib/errors";
/**
* Threema route management — keeps three places in sync:
*
* 1. Relay DB (`routes` table) — source of truth for uniqueness
* 2. K8s spec.channelUsers.threema — what the operator sees
* 3. Customer UI — derived from (2)
*
* Add (POST) order: relay first (to claim uniqueness atomically), then
* K8s. On K8s failure we compensate by deleting the relay route.
*
* Remove (DELETE) order: K8s first (UI shows it gone immediately, which
* is the customer-facing semantic that matters), then relay. On relay
* failure we DO NOT rollback K8s — the customer wanted it gone, and
* the relay's stale route will be cleaned up on the next retry (deletes
* are idempotent at the relay).
*
* Read-modify-write race: patchTenantSpec uses K8s merge-patch on
* spec.channelUsers.threema, which REPLACES the entire array. We GET
* the latest array, mutate it, then PATCH. Two concurrent adds from the
* same customer's tabs can lose one of them. Acceptable at pilot scale
* (single-digit customers, low concurrency); revisit with SSA + field
* managers if it ever bites.
*/
const ROUTE_BODY = z.object({
threemaId: z
.string()
.regex(/^[A-Z0-9]{8}$/, "Threema ID must be 8 uppercase alphanumeric chars (no asterisk)"),
});
// ---- helpers --------------------------------------------------------------
async function loadTenantOrError(name: string, user: Awaited<ReturnType<typeof getSessionUser>>) {
if (!user) return { error: NextResponse.json({ error: "Unauthorized" }, { status: 401 }) };
if (!canMutate(user))
return { error: NextResponse.json({ error: "Forbidden" }, { status: 403 }) };
const tenant = await getTenant(name);
if (!tenant)
return { error: NextResponse.json({ error: "Not found" }, { status: 404 }) };
if (
!user.isPlatform &&
tenant.metadata.labels?.["pieced.ch/zitadel-org-id"] !== user.orgId
) {
return { error: NextResponse.json({ error: "Forbidden" }, { status: 403 }) };
}
return { tenant };
}
function currentThreemaIds(tenantSpec: any): string[] {
const cu = tenantSpec?.channelUsers ?? {};
const ids = cu.threema;
return Array.isArray(ids) ? ids.filter((x) => typeof x === "string") : [];
}
// ---- GET ------------------------------------------------------------------
export async function GET(
_req: NextRequest,
{ params }: { params: Promise<{ name: string }> },
) {
const user = await getSessionUser();
const { name } = await params;
const loaded = await loadTenantOrError(name, user);
if (loaded.error) return loaded.error;
const res = await listRoutes(name);
if (!res.ok) {
return NextResponse.json(
{ error: `Relay list failed: ${res.message}` },
{ status: res.kind === "http" ? 502 : 503 },
);
}
return NextResponse.json({ routes: res.routes });
}
// ---- POST -----------------------------------------------------------------
export async function POST(
req: NextRequest,
{ params }: { params: Promise<{ name: string }> },
) {
const user = await getSessionUser();
const { name } = await params;
const loaded = await loadTenantOrError(name, user);
if (loaded.error) return loaded.error;
const tenant = loaded.tenant;
const body = await req.json().catch(() => null);
const parsed = ROUTE_BODY.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: "Invalid threemaId", details: parsed.error.flatten() },
{ status: 400 },
);
}
const threemaId = parsed.data.threemaId;
// Step 1: claim at the relay. Uniqueness is enforced here.
const claim = await createRoute(name, threemaId);
if (!claim.ok) {
if (isRouteConflictForOtherTenant(claim, name)) {
return NextResponse.json(
{ error: "This Threema ID is already registered to another tenant" },
{ status: 409 },
);
}
if (!isRouteConflictForSameTenant(claim, name)) {
// Genuine non-409 failure
return NextResponse.json(
{ error: `Relay create failed: ${claim.message}` },
{ status: claim.kind === "http" ? claim.status : 503 },
);
}
// Idempotent self-claim — continue to step 2 to ensure K8s mirrors it.
}
// Step 2: add to K8s spec.channelUsers.threema (idempotent).
const existing = currentThreemaIds(tenant!.spec);
if (!existing.includes(threemaId)) {
const next = [...existing, threemaId];
try {
await patchTenantSpec(name, {
channelUsers: {
...(tenant!.spec?.channelUsers ?? {}),
threema: next,
} as Record<string, string[]>,
});
} catch (e) {
// Compensate: drop the relay route so we don't leave an orphan.
const compensate = await deleteRoute(name, threemaId);
if (!compensate.ok) {
console.error(
`[threema/routes] Compensating route delete failed for ${name}/${threemaId}: ${compensate.message}`,
);
}
return NextResponse.json(
{ error: `K8s patch failed: ${safeError(e, "patch failed")}` },
{ status: 500 },
);
}
}
return NextResponse.json({ ok: true, threemaId });
}
// ---- DELETE ---------------------------------------------------------------
export async function DELETE(
req: NextRequest,
{ params }: { params: Promise<{ name: string }> },
) {
const user = await getSessionUser();
const { name } = await params;
const loaded = await loadTenantOrError(name, user);
if (loaded.error) return loaded.error;
const tenant = loaded.tenant;
// threemaId arrives as ?threemaId=... since DELETE bodies are uneven across clients.
const threemaId = new URL(req.url).searchParams.get("threemaId") ?? "";
const parsed = ROUTE_BODY.safeParse({ threemaId });
if (!parsed.success) {
return NextResponse.json(
{ error: "Invalid threemaId", details: parsed.error.flatten() },
{ status: 400 },
);
}
// Step 1: drop from K8s (idempotent).
const existing = currentThreemaIds(tenant!.spec);
if (existing.includes(parsed.data.threemaId)) {
const next = existing.filter((id) => id !== parsed.data.threemaId);
try {
await patchTenantSpec(name, {
channelUsers: {
...(tenant!.spec?.channelUsers ?? {}),
threema: next,
} as Record<string, string[]>,
});
} catch (e) {
return NextResponse.json(
{ error: `K8s patch failed: ${safeError(e, "patch failed")}` },
{ status: 500 },
);
}
}
// Step 2: drop at relay (also idempotent — 404 is fine).
const dropped = await deleteRoute(name, parsed.data.threemaId);
if (!dropped.ok && !(dropped.kind === "http" && dropped.status === 404)) {
// K8s is already updated; surface but don't rollback. Next time the
// user toggles, both will converge.
return NextResponse.json(
{
ok: true,
threemaId: parsed.data.threemaId,
warning: `Removed from K8s but relay drop failed: ${dropped.message}`,
},
{ status: 200 },
);
}
return NextResponse.json({ ok: true, threemaId: parsed.data.threemaId });
}

View File

@@ -2,7 +2,11 @@ import { NextRequest, NextResponse } from "next/server";
import { getSessionUser } from "@/lib/session";
import { listTenants } from "@/lib/k8s";
import { listVisibleTenants } from "@/lib/visibility";
import { getTeamInfo, getTeamSpendLogsV2 } from "@/lib/litellm";
import {
getTeamInfo,
getTeamSpendLogsV2,
findKeyByAlias,
} from "@/lib/litellm";
import { safeError } from "@/lib/errors";
/**
@@ -126,6 +130,16 @@ export async function GET(req: NextRequest) {
try {
const teamInfo = await getTeamInfo(teamId);
// Per-tenant budget lives on the virtual key, not the team
// (Feature 7 fix). When the request is scoped to a specific
// tenant (keyAlias provided), look up the key so we can return
// the per-tenant cap. Tolerate failure — older LiteLLM builds
// or short-lived race conditions during provisioning shouldn't
// 500 the whole usage page; we degrade to "no key info".
const keyInfo = keyAlias
? await findKeyByAlias(teamId, keyAlias).catch(() => null)
: null;
// Page through results — server-side filtered by key_alias when
// provided. Pagination still needed because LiteLLM caps
// page_size at 100, and a busy tenant can easily exceed that in
@@ -191,16 +205,37 @@ export async function GET(req: NextRequest) {
totalSpend,
requestCount: allRequests.length,
},
// Budget is always team-level (= company budget). Spend reported
// here is the team total, not the per-key total — the customer
// wants to see "how much of our company budget is left", not
// just "how much has this one tenant cost".
budget: {
// Budget reporting (Feature 7).
//
// When the caller scopes to a specific tenant (keyAlias set),
// we report THAT tenant's per-key budget — that's what the
// tenant detail page renders, and what the customer expects
// when they see "Budget" on a tenant's page.
//
// When unscoped (admin / org-wide view), we fall back to the
// team budget — that's the org-wide cap, conceptually different
// but the only thing meaningful at that scope.
//
// The two cases display the same way; the editor button gates
// on whether we know which tenant we're on (= keyAlias set).
budget: keyAlias && keyInfo
? {
maxBudget: keyInfo.maxBudget,
spend: keyInfo.spend,
remaining:
keyInfo.maxBudget !== null
? keyInfo.maxBudget - keyInfo.spend
: null,
budgetDuration: keyInfo.budgetDuration,
}
: {
maxBudget: teamInfo?.team_info?.max_budget ?? null,
spend: teamInfo?.team_info?.spend ?? 0,
remaining: teamInfo?.team_info?.max_budget
? teamInfo.team_info.max_budget - (teamInfo.team_info.spend ?? 0)
? teamInfo.team_info.max_budget -
(teamInfo.team_info.spend ?? 0)
: null,
budgetDuration: teamInfo?.team_info?.budget_duration ?? null,
},
rateLimits: {
rpm: teamInfo?.team_info?.rpm_limit ?? null,

View File

@@ -0,0 +1,345 @@
"use client";
import { useState, Fragment } from "react";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { Card, CardHeader } from "@/components/ui/card";
import type { InvoiceDraft } from "@/types";
interface OrgEntry {
zitadelOrgId: string;
tenantNames: string[];
companyName: string | null;
country: string | null;
hasBillingAddress: boolean;
}
interface Props {
orgs: OrgEntry[];
}
const LOCALE_OPTIONS = [
{ value: "de", label: "Deutsch" },
{ value: "en", label: "English" },
{ value: "fr", label: "Français" },
{ value: "it", label: "Italiano" },
];
/**
* Two-step flow: preview (dryRun) → commit.
*
* Preview displays the InvoiceDraft (lines, subtotal, VAT, total)
* plus any warnings. Admin reviews and either commits or aborts.
* Commit re-runs the generator without dryRun and redirects to the
* persisted invoice's detail page.
*/
export function GenerateForm({ orgs }: Props) {
const t = useTranslations("adminBilling");
const router = useRouter();
// Default to previous calendar month — that's the typical "bill
// for last month" use case.
const now = new Date();
const prevMonth = new Date(now.getFullYear(), now.getMonth() - 1, 1);
const [orgId, setOrgId] = useState(orgs[0]?.zitadelOrgId ?? "");
const [year, setYear] = useState(String(prevMonth.getFullYear()));
const [month, setMonth] = useState(String(prevMonth.getMonth() + 1));
const [locale, setLocale] = useState<string>("");
const [draft, setDraft] = useState<InvoiceDraft | null>(null);
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
const selectedOrg = orgs.find((o) => o.zitadelOrgId === orgId);
// Auto-detect default locale from country if admin hasn't picked
// one. Same logic as billing.ts's defaultLocaleForCountry.
const effectiveLocale =
locale ||
(() => {
const c = (selectedOrg?.country || "").toUpperCase();
if (["CH", "LI", "AT", "DE"].includes(c)) return "de";
if (["FR", "BE", "LU"].includes(c)) return "fr";
if (c === "IT") return "it";
return "en";
})();
const preview = async () => {
setError("");
setDraft(null);
setBusy(true);
try {
const res = await fetch("/api/admin/billing/generate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
zitadelOrgId: orgId,
year: Number(year),
month: Number(month),
locale: effectiveLocale,
dryRun: true,
}),
});
const j = await res.json();
if (!res.ok) throw new Error(j.error || `HTTP ${res.status}`);
setDraft(j.draft);
} catch (e: any) {
setError(e.message);
} finally {
setBusy(false);
}
};
const commit = async () => {
if (!draft) return;
if (!confirm(t("confirmGenerate"))) return;
setError("");
setBusy(true);
try {
const res = await fetch("/api/admin/billing/generate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
zitadelOrgId: orgId,
year: Number(year),
month: Number(month),
locale: effectiveLocale,
dryRun: false,
}),
});
const j = await res.json();
if (!res.ok) throw new Error(j.error || `HTTP ${res.status}`);
// Navigate to the new invoice's detail page.
if (j.invoice?.id) {
router.push(`/admin/billing/invoices/${j.invoice.id}`);
}
} catch (e: any) {
setError(e.message);
setBusy(false);
}
};
return (
<div className="space-y-6">
<Card>
<CardHeader>{t("generateFormTitle")}</CardHeader>
{orgs.length === 0 ? (
<p className="text-sm text-text-muted italic">{t("noOrgsToGenerate")}</p>
) : (
<div className="space-y-4">
<label className="block">
<span className="text-sm text-text-secondary">{t("orgLabel")}</span>
<select
value={orgId}
onChange={(e) => {
setOrgId(e.target.value);
setDraft(null);
}}
className="mt-1 w-full px-3 py-2 rounded-md border border-border bg-surface-2 text-sm"
>
{orgs.map((o) => (
<option key={o.zitadelOrgId} value={o.zitadelOrgId}>
{o.companyName ?? o.zitadelOrgId}
{!o.hasBillingAddress ? `${t("noBillingAddrTag")}` : ""}
{` (${o.tenantNames.length} ${t("tenantsLabel")})`}
</option>
))}
</select>
{selectedOrg && !selectedOrg.hasBillingAddress && (
<p className="text-xs text-error mt-1">
{t("noBillingAddrWarning")}
</p>
)}
</label>
<div className="grid grid-cols-3 gap-3">
<label className="block">
<span className="text-sm text-text-secondary">{t("yearLabel")}</span>
<input
type="number"
min="2020"
max="2100"
value={year}
onChange={(e) => {
setYear(e.target.value);
setDraft(null);
}}
className="mt-1 w-full px-3 py-2 rounded-md border border-border bg-surface-2 text-sm"
/>
</label>
<label className="block">
<span className="text-sm text-text-secondary">{t("monthLabel")}</span>
<select
value={month}
onChange={(e) => {
setMonth(e.target.value);
setDraft(null);
}}
className="mt-1 w-full px-3 py-2 rounded-md border border-border bg-surface-2 text-sm"
>
{Array.from({ length: 12 }, (_, i) => i + 1).map((m) => (
<option key={m} value={m}>
{String(m).padStart(2, "0")}
</option>
))}
</select>
</label>
<label className="block">
<span className="text-sm text-text-secondary">
{t("localeLabel")}
</span>
<select
value={locale}
onChange={(e) => {
setLocale(e.target.value);
setDraft(null);
}}
className="mt-1 w-full px-3 py-2 rounded-md border border-border bg-surface-2 text-sm"
>
<option value="">
{t("localeAuto")} ({effectiveLocale})
</option>
{LOCALE_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</label>
</div>
<div className="flex items-center gap-3 pt-2">
<button
onClick={preview}
disabled={busy || !selectedOrg?.hasBillingAddress}
className="px-4 py-2 rounded-md border border-border text-sm disabled:opacity-50"
>
{busy && !draft ? t("computing") : t("previewBtn")}
</button>
{draft && (
<button
onClick={commit}
disabled={busy}
className="px-4 py-2 rounded-md bg-accent text-white text-sm disabled:opacity-50"
>
{busy ? t("saving") : t("commitBtn")}
</button>
)}
{error && (
<span className="text-sm text-error">{error}</span>
)}
</div>
</div>
)}
</Card>
{draft && <DraftPreview draft={draft} />}
</div>
);
}
function DraftPreview({ draft }: { draft: InvoiceDraft }) {
const t = useTranslations("adminBilling");
// Group lines by tenant for the preview (matches PDF layout).
const linesByTenant = new Map<string | null, typeof draft.lines>();
for (const ln of draft.lines) {
const key = ln.tenantName;
if (!linesByTenant.has(key)) linesByTenant.set(key, []);
linesByTenant.get(key)!.push(ln);
}
const tenantOrder = [...linesByTenant.keys()].sort((a, b) => {
if (a === null) return 1;
if (b === null) return -1;
return a.localeCompare(b);
});
return (
<Card>
<CardHeader>
{t("previewTitle")} {draft.periodStart} {draft.periodEnd}
</CardHeader>
{draft.warnings.length > 0 && (
<div className="mb-4 p-3 rounded-md border border-warning bg-warning/10 text-sm space-y-1">
<div className="font-semibold text-warning">{t("warningsTitle")}</div>
{draft.warnings.map((w, i) => (
<div key={i} className="text-text-secondary"> {w}</div>
))}
</div>
)}
<table className="w-full text-sm">
<thead className="text-xs text-text-muted text-left">
<tr>
<th className="pb-2">{t("descCol")}</th>
<th className="pb-2 text-right">{t("qtyCol")}</th>
<th className="pb-2 text-right">{t("unitPriceCol")}</th>
<th className="pb-2 text-right">{t("amountCol")}</th>
</tr>
</thead>
<tbody>
{tenantOrder.map((tenantKey) => {
const lines = linesByTenant.get(tenantKey)!;
return (
<Fragment key={tenantKey ?? "_org"}>
{tenantKey && (
<tr className="border-t border-border">
<td colSpan={4} className="py-1.5 pt-3">
<span className="text-xs font-semibold text-accent">
{tenantKey}
</span>
</td>
</tr>
)}
{lines.map((ln, i) => (
<tr
key={`${tenantKey}-${i}`}
className="border-t border-border"
>
<td className="py-1.5">
<div>{ln.description}</div>
<div className="text-xs text-text-muted font-mono">
{ln.kind}
</div>
</td>
<td className="py-1.5 text-right">
{ln.quantity}
{ln.unitLabel ? ` ${ln.unitLabel}` : ""}
</td>
<td className="py-1.5 text-right font-mono text-xs">
{ln.unitPriceChf.toFixed(4)}
</td>
<td className="py-1.5 text-right">
{ln.amountChf.toFixed(2)}
</td>
</tr>
))}
</Fragment>
);
})}
{draft.lines.length === 0 && (
<tr>
<td colSpan={4} className="py-4 text-center text-text-muted italic">
{t("noLinesGenerated")}
</td>
</tr>
)}
</tbody>
</table>
<div className="mt-4 pt-3 border-t border-border space-y-1 text-sm">
<div className="flex justify-between">
<span className="text-text-muted">{t("subtotal")}</span>
<span>CHF {draft.subtotalChf.toFixed(2)}</span>
</div>
<div className="flex justify-between">
<span className="text-text-muted">
{t("vat")} ({draft.vatRate.toFixed(2)}%)
</span>
<span>CHF {draft.vatAmountChf.toFixed(2)}</span>
</div>
<div className="flex justify-between pt-1 border-t border-border font-semibold">
<span>{t("total")}</span>
<span>CHF {draft.totalChf.toFixed(2)}</span>
</div>
</div>
</Card>
);
}

View File

@@ -0,0 +1,307 @@
"use client";
import { useState, Fragment } from "react";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { Card, CardHeader } from "@/components/ui/card";
import type { InvoiceDetail, InvoiceStatus } from "@/types";
interface Props {
detail: InvoiceDetail;
}
/**
* Renders the invoice header (status, totals, action bar) then
* line items grouped by tenant, then billing snapshot. Actions are
* mark-paid (POST), delete (DELETE), PDF download (link to /pdf).
*
* On successful action we router.refresh() — the server-side page
* re-renders against the new DB state. For delete we navigate
* away first.
*/
export function InvoiceDetailView({ detail }: Props) {
const t = useTranslations("adminBilling");
const router = useRouter();
const { invoice, lines } = detail;
const [busyAction, setBusyAction] = useState<null | "mark-paid" | "delete">(
null
);
const [actionError, setActionError] = useState("");
const [noteInput, setNoteInput] = useState("");
const [noteOpen, setNoteOpen] = useState(false);
const markPaid = async () => {
setActionError("");
setBusyAction("mark-paid");
try {
const res = await fetch(
`/api/admin/billing/invoices/${invoice.id}/mark-paid`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ note: noteInput || undefined }),
}
);
const j = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(j.error || `HTTP ${res.status}`);
setNoteOpen(false);
setNoteInput("");
router.refresh();
} catch (e: any) {
setActionError(e.message);
} finally {
setBusyAction(null);
}
};
const deleteInvoice = async () => {
if (!confirm(t("confirmDeleteInvoice", { num: invoice.invoiceNumber })))
return;
setActionError("");
setBusyAction("delete");
try {
const res = await fetch(`/api/admin/billing/invoices/${invoice.id}`, {
method: "DELETE",
});
if (!res.ok) {
const j = await res.json().catch(() => ({}));
throw new Error(j.error || `HTTP ${res.status}`);
}
router.push("/admin/billing/invoices");
} catch (e: any) {
setActionError(e.message);
setBusyAction(null);
}
};
// Group lines by tenant for display (matches PDF layout).
const linesByTenant = new Map<string | null, typeof lines>();
for (const ln of lines) {
const k = ln.tenantName;
if (!linesByTenant.has(k)) linesByTenant.set(k, []);
linesByTenant.get(k)!.push(ln);
}
const tenantOrder = [...linesByTenant.keys()].sort((a, b) => {
if (a === null) return 1;
if (b === null) return -1;
return a.localeCompare(b);
});
return (
<div className="space-y-4 animate-in">
<div className="flex items-end justify-between flex-wrap gap-3">
<div>
<h1 className="font-display text-2xl font-semibold accent-rule">
{invoice.invoiceNumber}
</h1>
<div className="flex items-center gap-3 mt-3 text-sm">
<StatusPill status={invoice.status} />
<span className="text-text-muted">
{invoice.periodStart} {invoice.periodEnd}
</span>
<span className="text-text-muted">·</span>
<span className="text-text-muted">
{t("dueOnLabel")}: {invoice.dueAt}
</span>
<span className="text-text-muted">·</span>
<span className="text-text-muted font-mono text-xs">
{invoice.locale}
</span>
</div>
</div>
<div className="text-right">
<div className="text-xs text-text-muted">{t("totalLabel")}</div>
<div className="text-2xl font-semibold font-mono">
CHF {invoice.totalChf.toFixed(2)}
</div>
</div>
</div>
{/* Action bar */}
<Card>
<div className="flex flex-wrap items-center gap-3">
{invoice.hasPdf && (
<a
href={`/api/admin/billing/invoices/${invoice.id}/pdf`}
target="_blank"
rel="noopener noreferrer"
className="px-4 py-2 rounded-md border border-border text-sm hover:bg-surface-2"
>
{t("downloadPdfBtn")}
</a>
)}
{(invoice.status === "open" || invoice.status === "overdue") && (
<>
{!noteOpen ? (
<button
onClick={() => setNoteOpen(true)}
disabled={busyAction !== null}
className="px-4 py-2 rounded-md bg-accent text-white text-sm disabled:opacity-50"
>
{t("markPaidBtn")}
</button>
) : (
<div className="flex items-center gap-2 flex-grow">
<input
type="text"
placeholder={t("paidNotePlaceholder")}
value={noteInput}
onChange={(e) => setNoteInput(e.target.value)}
className="flex-grow px-3 py-1.5 rounded-md border border-border bg-surface-2 text-sm"
autoFocus
/>
<button
onClick={markPaid}
disabled={busyAction !== null}
className="px-3 py-1.5 rounded-md bg-accent text-white text-sm disabled:opacity-50"
>
{busyAction === "mark-paid" ? t("saving") : t("confirm")}
</button>
<button
onClick={() => {
setNoteOpen(false);
setNoteInput("");
}}
className="px-3 py-1.5 rounded-md border border-border text-sm"
>
{t("cancel")}
</button>
</div>
)}
</>
)}
<button
onClick={deleteInvoice}
disabled={busyAction !== null}
className="ml-auto px-4 py-2 rounded-md border border-error text-error text-sm disabled:opacity-50 hover:bg-error/10"
title={t("deleteHint")}
>
{busyAction === "delete" ? t("deleting") : t("deleteBtn")}
</button>
</div>
{actionError && (
<div className="mt-3 text-sm text-error">{actionError}</div>
)}
{invoice.paidAt && (
<div className="mt-3 text-xs text-text-muted">
{t("paidOnLabel")}: {invoice.paidAt} · {invoice.paidBy} ·{" "}
{invoice.paidMethodDetail}
</div>
)}
</Card>
{/* Lines */}
<Card>
<CardHeader>{t("lineItemsTitle")}</CardHeader>
<table className="w-full text-sm">
<thead className="text-xs text-text-muted text-left">
<tr>
<th className="pb-2">{t("descCol")}</th>
<th className="pb-2 text-right">{t("qtyCol")}</th>
<th className="pb-2 text-right">{t("unitPriceCol")}</th>
<th className="pb-2 text-right">{t("amountCol")}</th>
</tr>
</thead>
<tbody>
{tenantOrder.map((tenantKey) => {
const tenantLines = linesByTenant.get(tenantKey)!;
return (
<Fragment key={tenantKey ?? "_org"}>
{tenantKey && (
<tr>
<td colSpan={4} className="pt-3 pb-1">
<span className="text-xs font-semibold text-accent">
{tenantKey}
</span>
</td>
</tr>
)}
{tenantLines.map((ln) => (
<tr key={ln.id} className="border-t border-border">
<td className="py-1.5">
<div>{ln.description}</div>
<div className="text-xs text-text-muted font-mono">
{ln.kind}
</div>
</td>
<td className="py-1.5 text-right">
{ln.quantity}
{ln.unitLabel ? ` ${ln.unitLabel}` : ""}
</td>
<td className="py-1.5 text-right font-mono text-xs">
{ln.unitPriceChf.toFixed(4)}
</td>
<td className="py-1.5 text-right">
{ln.amountChf.toFixed(2)}
</td>
</tr>
))}
</Fragment>
);
})}
</tbody>
</table>
<div className="mt-4 pt-3 border-t border-border space-y-1 text-sm">
<div className="flex justify-between">
<span className="text-text-muted">{t("subtotal")}</span>
<span>CHF {invoice.subtotalChf.toFixed(2)}</span>
</div>
<div className="flex justify-between">
<span className="text-text-muted">
{t("vat")} ({invoice.vatRate.toFixed(2)}%)
</span>
<span>CHF {invoice.vatAmountChf.toFixed(2)}</span>
</div>
<div className="flex justify-between pt-1 border-t border-border font-semibold">
<span>{t("total")}</span>
<span>CHF {invoice.totalChf.toFixed(2)}</span>
</div>
</div>
</Card>
{/* Billing snapshot */}
<Card>
<CardHeader>{t("billToSnapshotTitle")}</CardHeader>
<div className="text-sm space-y-1">
<div className="font-semibold">
{invoice.billingSnapshot.companyName}
</div>
<div>{invoice.billingSnapshot.streetAddress}</div>
<div>
{invoice.billingSnapshot.postalCode}{" "}
{invoice.billingSnapshot.city}
</div>
<div>{invoice.billingSnapshot.country}</div>
{invoice.billingSnapshot.vatNumber && (
<div className="text-text-muted">
VAT: {invoice.billingSnapshot.vatNumber}
</div>
)}
<div className="text-text-muted">
{invoice.billingSnapshot.billingEmail}
</div>
</div>
</Card>
</div>
);
}
function StatusPill({ status }: { status: InvoiceStatus }) {
const t = useTranslations("adminBilling");
const color =
status === "paid"
? "bg-success/15 text-success"
: status === "overdue"
? "bg-error/15 text-error"
: status === "void" || status === "uncollectible"
? "bg-text-muted/15 text-text-muted"
: "bg-accent/15 text-accent";
return (
<span
className={`inline-block px-2 py-0.5 rounded text-xs font-medium ${color}`}
>
{t(`status_${status}`)}
</span>
);
}

View File

@@ -0,0 +1,183 @@
"use client";
import { useState, useEffect } from "react";
import Link from "next/link";
import { useTranslations } from "next-intl";
import { Card } from "@/components/ui/card";
import type { Invoice, InvoiceStatus } from "@/types";
interface Props {
initialInvoices: Invoice[];
}
const STATUS_FILTERS: (InvoiceStatus | "all")[] = [
"all",
"open",
"overdue",
"paid",
"void",
];
/**
* Filterable invoice list. Filters live in URL-less local state
* (simpler than syncing to query string for a v1 admin tool); a
* page refresh resets.
*
* Re-fetching strategy: when filters change, hit the API directly
* rather than router.refresh() so we don't bounce the user through
* a full page render.
*/
export function InvoicesTable({ initialInvoices }: Props) {
const t = useTranslations("adminBilling");
const [statusFilter, setStatusFilter] = useState<InvoiceStatus | "all">("all");
const [monthFilter, setMonthFilter] = useState("");
const [invoices, setInvoices] = useState(initialInvoices);
const [busy, setBusy] = useState(false);
useEffect(() => {
// Effect runs after initial render too; skip refetch on mount
// when filters are at their defaults — the server already
// gave us the right initial set.
if (statusFilter === "all" && monthFilter === "") return;
let cancelled = false;
setBusy(true);
const params = new URLSearchParams();
if (statusFilter !== "all") params.set("status", statusFilter);
if (monthFilter) params.set("month", monthFilter);
fetch(`/api/admin/billing/invoices?${params}`)
.then((r) => r.json())
.then((data) => {
if (!cancelled) setInvoices(data);
})
.catch((e) => console.error("Failed to load invoices:", e))
.finally(() => {
if (!cancelled) setBusy(false);
});
return () => {
cancelled = true;
};
}, [statusFilter, monthFilter]);
return (
<div className="space-y-4">
<Card>
<div className="flex flex-wrap items-end gap-4">
<label className="block">
<span className="text-xs text-text-muted">{t("statusFilterLabel")}</span>
<select
value={statusFilter}
onChange={(e) =>
setStatusFilter(e.target.value as InvoiceStatus | "all")
}
className="mt-1 px-3 py-1.5 rounded-md border border-border bg-surface-2 text-sm"
>
{STATUS_FILTERS.map((s) => (
<option key={s} value={s}>
{s === "all" ? t("allStatuses") : t(`status_${s}`)}
</option>
))}
</select>
</label>
<label className="block">
<span className="text-xs text-text-muted">{t("monthFilterLabel")}</span>
<input
type="month"
value={monthFilter}
onChange={(e) => setMonthFilter(e.target.value)}
className="mt-1 px-3 py-1.5 rounded-md border border-border bg-surface-2 text-sm"
/>
</label>
{monthFilter && (
<button
onClick={() => setMonthFilter("")}
className="text-xs text-text-muted hover:underline"
>
{t("clearFilter")}
</button>
)}
{busy && (
<span className="text-xs text-text-muted ml-auto">
{t("loading")}
</span>
)}
</div>
</Card>
<Card>
{invoices.length === 0 ? (
<p className="text-sm text-text-muted italic text-center py-6">
{t("noInvoicesFound")}
</p>
) : (
<table className="w-full text-sm">
<thead className="text-xs text-text-muted text-left">
<tr>
<th className="pb-2">{t("invoiceNumberCol")}</th>
<th className="pb-2">{t("orgCol")}</th>
<th className="pb-2">{t("periodCol")}</th>
<th className="pb-2">{t("statusCol")}</th>
<th className="pb-2 text-right">{t("totalCol")}</th>
<th className="pb-2 text-right">{t("dueCol")}</th>
</tr>
</thead>
<tbody>
{invoices.map((inv) => (
<tr
key={inv.id}
className="border-t border-border hover:bg-surface-2 cursor-pointer"
>
<td className="py-2">
<Link
href={`/admin/billing/invoices/${inv.id}`}
className="font-mono text-xs hover:underline"
>
{inv.invoiceNumber}
</Link>
</td>
<td className="py-2">
<div className="text-xs">
{inv.billingSnapshot.companyName || (
<span className="font-mono">{inv.zitadelOrgId}</span>
)}
</div>
</td>
<td className="py-2 text-xs font-mono">
{inv.periodStart.slice(0, 7)}
</td>
<td className="py-2">
<StatusPill status={inv.status} />
</td>
<td className="py-2 text-right">
CHF {inv.totalChf.toFixed(2)}
</td>
<td className="py-2 text-right text-xs text-text-muted">
{inv.dueAt}
</td>
</tr>
))}
</tbody>
</table>
)}
</Card>
</div>
);
}
function StatusPill({ status }: { status: InvoiceStatus }) {
const t = useTranslations("adminBilling");
const color =
status === "paid"
? "bg-success/15 text-success"
: status === "overdue"
? "bg-error/15 text-error"
: status === "void" || status === "uncollectible"
? "bg-text-muted/15 text-text-muted"
: "bg-accent/15 text-accent";
return (
<span
className={`inline-block px-2 py-0.5 rounded text-xs font-medium ${color}`}
>
{t(`status_${status}`)}
</span>
);
}

View File

@@ -0,0 +1,491 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { Card, CardHeader } from "@/components/ui/card";
import type { PlatformPricing, SkillPricing } from "@/types";
interface CatalogEntry {
id: string;
name: string;
category: string;
}
interface Props {
initialPricing: PlatformPricing;
initialSkillPricing: SkillPricing[];
catalog: CatalogEntry[];
}
/**
* Two-card layout:
* 1. Platform pricing form (4 inputs, save = PUT to /pricing).
* 2. Skill pricing table — list of priced skills, "Add skill"
* picker below.
*
* No optimistic updates — every save round-trips and we
* router.refresh() afterwards so the server-side render stays
* the source of truth.
*/
export function PricingEditor({
initialPricing,
initialSkillPricing,
catalog,
}: Props) {
const t = useTranslations("adminBilling");
const tPackages = useTranslations("packages");
const router = useRouter();
// -- Platform pricing form ----------------------------------------------
const [monthly, setMonthly] = useState(
String(initialPricing.tenantMonthlyFeeChf)
);
const [setup, setSetup] = useState(String(initialPricing.tenantSetupFeeChf));
const [threema, setThreema] = useState(
String(initialPricing.threemaMessageChf)
);
const [vat, setVat] = useState(String(initialPricing.vatRateChli));
const [savingPricing, setSavingPricing] = useState(false);
const [pricingError, setPricingError] = useState("");
const [pricingSaved, setPricingSaved] = useState(false);
const savePricing = async (e: React.FormEvent) => {
e.preventDefault();
setSavingPricing(true);
setPricingError("");
setPricingSaved(false);
try {
const res = await fetch("/api/admin/billing/pricing", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
tenantMonthlyFeeChf: Number(monthly),
tenantSetupFeeChf: Number(setup),
threemaMessageChf: Number(threema),
vatRateChli: Number(vat),
}),
});
if (!res.ok) {
const j = await res.json().catch(() => ({}));
throw new Error(j.error || `HTTP ${res.status}`);
}
setPricingSaved(true);
router.refresh();
} catch (e: any) {
setPricingError(e.message);
} finally {
setSavingPricing(false);
}
};
// -- Package pricing ----------------------------------------------------
// Server is authoritative — we don't keep an editable local copy of the
// table; instead each action posts to the API and we router.refresh().
//
// Naming carry-over: the underlying DB table is `skill_pricing` and the
// column is `skill_id`, dating from when only skills were priced. The
// model now applies to any PackageDef in the catalog regardless of
// category — core, channel, or skill. The state variable names below
// (newSkill*, addingSkill, etc.) retain the legacy "skill" prefix
// because renaming the entire surface for purely cosmetic reasons
// would create churn for no functional gain. Treat "skill" here as
// shorthand for "priced package".
const [newSkillId, setNewSkillId] = useState(catalog[0]?.id ?? "");
const [newSkillPrice, setNewSkillPrice] = useState("0.10");
const [newSkillSetupFee, setNewSkillSetupFee] = useState("0");
const [addingSkill, setAddingSkill] = useState(false);
const [skillError, setSkillError] = useState("");
// Core upsert — used by both the "add new skill" form and the inline
// editors on existing rows. Kept event-free so callers can invoke it
// without synthesizing a fake form event. Both `dailyPriceChf` and
// `setupFeeChf` are written together because the API does a full
// upsert; partial updates would silently zero the other field.
const upsertSkillPrice = async (
skillId: string,
dailyPriceChf: number,
setupFeeChf: number
) => {
setAddingSkill(true);
setSkillError("");
try {
const res = await fetch("/api/admin/billing/skill-pricing", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ skillId, dailyPriceChf, setupFeeChf }),
});
if (!res.ok) {
const j = await res.json().catch(() => ({}));
throw new Error(j.error || `HTTP ${res.status}`);
}
router.refresh();
} catch (e: any) {
setSkillError(e.message);
} finally {
setAddingSkill(false);
}
};
const onAddNewSkill = (e: React.FormEvent) => {
e.preventDefault();
if (!newSkillId) return;
void upsertSkillPrice(
newSkillId,
Number(newSkillPrice),
Number(newSkillSetupFee)
);
};
const deleteSkill = async (skillId: string) => {
if (!confirm(t("confirmDeleteSkillPrice", { skill: skillId }))) return;
setSkillError("");
try {
const res = await fetch(
`/api/admin/billing/skill-pricing/${encodeURIComponent(skillId)}`,
{ method: "DELETE" }
);
if (!res.ok) {
const j = await res.json().catch(() => ({}));
throw new Error(j.error || `HTTP ${res.status}`);
}
router.refresh();
} catch (e: any) {
setSkillError(e.message);
}
};
// Pricing applies to any catalog entry regardless of category. Grouped
// dropdown sorts options by category for visual scanning — core,
// channel, and skill in a single picker.
const skillCatalogOptions = [...catalog].sort((a, b) => {
const order = { core: 0, channel: 1, skill: 2 } as Record<string, number>;
const ca = order[a.category] ?? 99;
const cb = order[b.category] ?? 99;
if (ca !== cb) return ca - cb;
return a.name.localeCompare(b.name);
});
const catalogIndex = new Map(catalog.map((c) => [c.id, c]));
const pricedIds = new Set(initialSkillPricing.map((s) => s.skillId));
return (
<div className="space-y-6">
<Card>
<CardHeader>{t("platformPricingTitle")}</CardHeader>
<form onSubmit={savePricing} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<label className="block">
<span className="text-sm text-text-secondary">
{t("monthlyFeeLabel")} (CHF)
</span>
<input
type="number"
step="0.01"
min="0"
value={monthly}
onChange={(e) => setMonthly(e.target.value)}
className="mt-1 w-full px-3 py-2 rounded-md border border-border bg-surface-2 text-sm"
required
/>
</label>
<label className="block">
<span className="text-sm text-text-secondary">
{t("setupFeeLabel")} (CHF)
</span>
<input
type="number"
step="0.01"
min="0"
value={setup}
onChange={(e) => setSetup(e.target.value)}
className="mt-1 w-full px-3 py-2 rounded-md border border-border bg-surface-2 text-sm"
required
/>
</label>
<label className="block">
<span className="text-sm text-text-secondary">
{t("threemaMessageLabel")} (CHF)
</span>
<input
type="number"
step="0.0001"
min="0"
value={threema}
onChange={(e) => setThreema(e.target.value)}
className="mt-1 w-full px-3 py-2 rounded-md border border-border bg-surface-2 text-sm"
required
/>
</label>
<label className="block">
<span className="text-sm text-text-secondary">
{t("vatRateLabel")} (%)
</span>
<input
type="number"
step="0.01"
min="0"
max="100"
value={vat}
onChange={(e) => setVat(e.target.value)}
className="mt-1 w-full px-3 py-2 rounded-md border border-border bg-surface-2 text-sm"
required
/>
</label>
</div>
<div className="flex items-center gap-3">
<button
type="submit"
disabled={savingPricing}
className="px-4 py-2 rounded-md bg-accent text-white text-sm disabled:opacity-50"
>
{savingPricing ? t("saving") : t("save")}
</button>
{pricingSaved && (
<span className="text-sm text-success">{t("savedOk")}</span>
)}
{pricingError && (
<span className="text-sm text-error">{pricingError}</span>
)}
</div>
</form>
</Card>
<Card>
<CardHeader>{t("skillPricingTitle")}</CardHeader>
<p className="text-sm text-text-muted mb-4">{t("skillPricingDesc")}</p>
{initialSkillPricing.length > 0 ? (
<table className="w-full text-sm mb-6">
<thead className="text-xs text-text-muted text-left">
<tr>
<th className="pb-2">{t("skillCol")}</th>
<th className="pb-2 text-right">{t("dailyPriceCol")}</th>
<th className="pb-2 text-right">{t("setupFeeCol")}</th>
<th className="pb-2 text-right">{t("actionsCol")}</th>
</tr>
</thead>
<tbody>
{initialSkillPricing.map((sp) => {
const entry = catalogIndex.get(sp.skillId);
return (
<tr
key={sp.skillId}
className="border-t border-border align-top"
>
<td className="py-2">
<div className="font-mono text-xs">{sp.skillId}</div>
{entry && (
<div className="text-xs text-text-muted flex items-center gap-2">
<span>{entry.name}</span>
<span className="text-[10px] uppercase tracking-wider bg-surface-3 px-1.5 py-0.5 rounded">
{entry.category}
</span>
</div>
)}
</td>
<td className="py-2 text-right">
{/* Inline edits write daily + setup together (full
upsert on the API side). The other field is
held constant from the snapshot here. */}
<InlinePriceEditor
skillId={sp.skillId}
initialPrice={sp.dailyPriceChf}
decimals={4}
onSave={(price) =>
upsertSkillPrice(sp.skillId, price, sp.setupFeeChf)
}
/>
</td>
<td className="py-2 text-right">
<InlinePriceEditor
skillId={`${sp.skillId}-setup`}
initialPrice={sp.setupFeeChf}
decimals={2}
onSave={(fee) =>
upsertSkillPrice(sp.skillId, sp.dailyPriceChf, fee)
}
/>
</td>
<td className="py-2 text-right">
<button
onClick={() => deleteSkill(sp.skillId)}
className="text-xs text-error hover:underline"
>
{t("remove")}
</button>
</td>
</tr>
);
})}
</tbody>
</table>
) : (
<p className="text-sm text-text-muted italic mb-4">{t("noSkillsPriced")}</p>
)}
<form onSubmit={onAddNewSkill} className="flex items-end gap-3">
<label className="flex-grow">
<span className="text-xs text-text-muted">{t("addSkillLabel")}</span>
<select
value={newSkillId}
onChange={(e) => setNewSkillId(e.target.value)}
className="mt-1 w-full px-3 py-2 rounded-md border border-border bg-surface-2 text-sm"
>
{(() => {
// Group available options by category for the picker.
// Already-priced packages are filtered out (admin
// edits those inline above).
const available = skillCatalogOptions.filter(
(c) => !pricedIds.has(c.id)
);
const byCat = new Map<string, typeof available>();
for (const c of available) {
if (!byCat.has(c.category)) byCat.set(c.category, []);
byCat.get(c.category)!.push(c);
}
// Labels for the optgroups. Reuse the existing
// packages.categories.* scope which already has
// translations in all four locales.
const labels: Record<string, string> = {
core: tPackages("categories.core"),
channel: tPackages("categories.channels"),
skill: tPackages("categories.skills"),
};
const order: Array<"core" | "channel" | "skill"> = [
"core",
"channel",
"skill",
];
return order.map((cat) => {
const items = byCat.get(cat);
if (!items || items.length === 0) return null;
return (
<optgroup key={cat} label={labels[cat] ?? cat}>
{items.map((c) => (
<option key={c.id} value={c.id}>
{c.name} ({c.id})
</option>
))}
</optgroup>
);
});
})()}
</select>
</label>
<label className="w-28">
<span className="text-xs text-text-muted">
{t("dailyPriceLabel")}
</span>
<input
type="number"
step="0.01"
min="0"
value={newSkillPrice}
onChange={(e) => setNewSkillPrice(e.target.value)}
className="mt-1 w-full px-3 py-2 rounded-md border border-border bg-surface-2 text-sm"
/>
</label>
<label className="w-28">
<span className="text-xs text-text-muted">
{t("skillSetupFeeLabel")}
</span>
<input
type="number"
step="0.01"
min="0"
value={newSkillSetupFee}
onChange={(e) => setNewSkillSetupFee(e.target.value)}
className="mt-1 w-full px-3 py-2 rounded-md border border-border bg-surface-2 text-sm"
/>
</label>
<button
type="submit"
disabled={addingSkill || !newSkillId}
className="px-4 py-2 rounded-md bg-accent text-white text-sm disabled:opacity-50"
>
{addingSkill ? t("saving") : t("add")}
</button>
</form>
{skillError && (
<p className="text-sm text-error mt-2">{skillError}</p>
)}
</Card>
</div>
);
}
/**
* Tiny inline editor for a single numeric price/fee. Mounts in
* "view" mode showing the current value as a clickable badge;
* clicking turns it into an input + save/cancel buttons.
*
* `decimals` controls the display precision in view mode AND the
* step granularity of the input (daily prices use 4dp, setup fees
* use 2dp).
*/
function InlinePriceEditor({
skillId,
initialPrice,
decimals = 2,
onSave,
}: {
skillId: string;
initialPrice: number;
decimals?: number;
onSave: (price: number) => Promise<void> | void;
}) {
const t = useTranslations("adminBilling");
const [editing, setEditing] = useState(false);
const [value, setValue] = useState(String(initialPrice));
const [busy, setBusy] = useState(false);
const step = decimals === 4 ? "0.0001" : "0.01";
if (!editing) {
return (
<button
onClick={() => setEditing(true)}
className="text-sm font-mono hover:underline"
title={t("clickToEdit")}
>
CHF {initialPrice.toFixed(decimals)}
</button>
);
}
return (
<span className="inline-flex items-center gap-1">
<input
type="number"
step={step}
min="0"
value={value}
onChange={(e) => setValue(e.target.value)}
className="w-20 px-2 py-1 text-sm border border-border bg-surface-2 rounded"
autoFocus
/>
<button
onClick={async () => {
setBusy(true);
try {
await onSave(Number(value));
setEditing(false);
} finally {
setBusy(false);
}
}}
disabled={busy}
className="text-xs px-2 py-1 bg-accent text-white rounded"
>
{busy ? "…" : "✓"}
</button>
<button
onClick={() => {
setValue(String(initialPrice));
setEditing(false);
}}
className="text-xs px-2 py-1 border border-border rounded"
>
</button>
</span>
);
}

View File

@@ -0,0 +1,249 @@
"use client";
import { useState } from "react";
import { useTranslations, useFormatter } from "next-intl";
import { Card } from "@/components/ui/card";
import type { CronRun } from "@/types";
interface Props {
initialRecent: CronRun[];
initialLastSuccess: {
monthlyIssue: CronRun | null;
reminders: CronRun | null;
};
}
/**
* Admin cron dashboard. Server pre-loads `initialRecent` and
* `initialLastSuccess`; "Run now" clicks POST to the admin
* endpoints, then re-fetch the history via GET /api/admin/cron/runs.
*
* The trigger buttons disable while busy and surface the resulting
* counters inline so the admin gets immediate feedback without
* needing to scroll to the history table.
*/
export function CronControls({ initialRecent, initialLastSuccess }: Props) {
const t = useTranslations("adminCron");
const fmt = useFormatter();
const [recent, setRecent] = useState(initialRecent);
const [lastSuccess, setLastSuccess] = useState(initialLastSuccess);
const [busy, setBusy] = useState<null | "issue" | "reminders">(null);
const [flash, setFlash] = useState<null | {
kind: "issue" | "reminders";
ok: boolean;
summary: string;
}>(null);
const refresh = async () => {
try {
const res = await fetch("/api/admin/cron/runs");
if (!res.ok) return;
const data = await res.json();
setRecent(data.recent);
setLastSuccess(data.lastSuccess);
} catch {
// swallow — refresh is opportunistic
}
};
const triggerIssue = async () => {
setBusy("issue");
setFlash(null);
try {
const res = await fetch("/api/admin/cron/issue-monthly", {
method: "POST",
});
const j = await res.json();
if (!res.ok) {
setFlash({
kind: "issue",
ok: false,
summary: j.error ?? `HTTP ${res.status}`,
});
} else {
setFlash({
kind: "issue",
ok: true,
summary: t("flashIssueOk", {
success: j.successCount,
skipped: j.skippedCount,
failure: j.failureCount,
}),
});
}
await refresh();
} finally {
setBusy(null);
}
};
const triggerReminders = async () => {
setBusy("reminders");
setFlash(null);
try {
const res = await fetch("/api/admin/cron/send-reminders", {
method: "POST",
});
const j = await res.json();
if (!res.ok) {
setFlash({
kind: "reminders",
ok: false,
summary: j.error ?? `HTTP ${res.status}`,
});
} else {
setFlash({
kind: "reminders",
ok: true,
summary: t("flashRemindersOk", {
success: j.successCount,
skipped: j.skippedCount,
failure: j.failureCount,
}),
});
}
await refresh();
} finally {
setBusy(null);
}
};
const fmtRelative = (iso: string | null) => {
if (!iso) return t("never");
return fmt.dateTime(new Date(iso), {
dateStyle: "medium",
timeStyle: "short",
});
};
// Phase 6: surface failures prominently. Any run in the recent
// window with a non-zero failure_count drives a top-of-page
// banner — the row in the table is already red, but a banner
// means the admin doesn't have to scroll to notice.
const recentFailures = recent.filter((r) => r.failureCount > 0);
const hasRecentFailures = recentFailures.length > 0;
return (
<div className="space-y-8">
{hasRecentFailures && (
<div className="p-4 rounded-md border border-error bg-error/10 text-sm text-error">
<p className="font-medium mb-1">{t("failureBannerTitle")}</p>
<p className="text-xs">
{t("failureBannerBody", { count: recentFailures.length })}
</p>
</div>
)}
<section className="grid gap-4 md:grid-cols-2">
<Card>
<h2 className="text-xs uppercase tracking-wider text-text-muted mb-2">
{t("monthlyIssue")}
</h2>
<p className="text-xs text-text-secondary mb-1">
{t("scheduleIssueLabel")}: <span className="font-mono">{t("scheduleIssueValue")}</span>
</p>
<p className="text-xs text-text-secondary mb-3">
{t("lastSuccess")}: <span className="font-mono">{fmtRelative(lastSuccess.monthlyIssue?.startedAt ?? null)}</span>
</p>
<button
onClick={triggerIssue}
disabled={busy !== null}
className="px-4 py-2 rounded-md bg-accent text-white text-sm font-medium hover:bg-accent-dim transition-colors disabled:opacity-50 cursor-pointer"
>
{busy === "issue" ? t("running") : t("runIssueNow")}
</button>
</Card>
<Card>
<h2 className="text-xs uppercase tracking-wider text-text-muted mb-2">
{t("reminders")}
</h2>
<p className="text-xs text-text-secondary mb-1">
{t("scheduleReminderLabel")}: <span className="font-mono">{t("scheduleReminderValue")}</span>
</p>
<p className="text-xs text-text-secondary mb-3">
{t("lastSuccess")}: <span className="font-mono">{fmtRelative(lastSuccess.reminders?.startedAt ?? null)}</span>
</p>
<button
onClick={triggerReminders}
disabled={busy !== null}
className="px-4 py-2 rounded-md bg-accent text-white text-sm font-medium hover:bg-accent-dim transition-colors disabled:opacity-50 cursor-pointer"
>
{busy === "reminders" ? t("running") : t("runRemindersNow")}
</button>
</Card>
</section>
{flash && (
<div
className={`p-3 rounded-md border text-sm ${
flash.ok
? "border-success bg-success/10 text-success"
: "border-error bg-error/10 text-error"
}`}
>
{flash.summary}
</div>
)}
<section>
<h2 className="text-xs uppercase tracking-wider text-text-muted mb-3">
{t("recentRuns")}
</h2>
<Card>
{recent.length === 0 ? (
<p className="text-sm text-text-muted italic py-4">
{t("noRunsYet")}
</p>
) : (
<table className="w-full text-sm">
<thead className="text-xs text-text-muted text-left">
<tr>
<th className="pb-2">{t("startedCol")}</th>
<th className="pb-2">{t("kindCol")}</th>
<th className="pb-2">{t("triggeredByCol")}</th>
<th className="pb-2 text-right">{t("okCol")}</th>
<th className="pb-2 text-right">{t("skipCol")}</th>
<th className="pb-2 text-right">{t("failCol")}</th>
</tr>
</thead>
<tbody>
{recent.map((r) => (
<tr
key={r.id}
className={`border-t border-border align-top ${
r.failureCount > 0 ? "bg-error/5" : ""
}`}
>
<td className="py-2 text-xs font-mono">
{fmtRelative(r.startedAt)}
</td>
<td className="py-2 text-xs">
{t(`kind.${r.runKind}` as any)}
</td>
<td className="py-2 text-xs text-text-secondary font-mono">
{r.triggeredBy === "cron"
? t("triggeredByCron")
: r.triggeredBy.slice(0, 8) + "…"}
</td>
<td className="py-2 text-right font-mono text-xs text-success">
{r.successCount}
</td>
<td className="py-2 text-right font-mono text-xs text-text-secondary">
{r.skippedCount}
</td>
<td
className={`py-2 text-right font-mono text-xs ${
r.failureCount > 0 ? "text-error" : "text-text-muted"
}`}
>
{r.failureCount}
</td>
</tr>
))}
</tbody>
</table>
)}
</Card>
</section>
</div>
);
}

View File

@@ -0,0 +1,277 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { Card } from "@/components/ui/card";
import type { OpenClawDefaults } from "@/lib/k8s";
interface TenantRow {
name: string;
displayName: string;
phase: string;
override: { tag: string } | null;
}
interface Props {
initialDefaults: OpenClawDefaults;
tenants: TenantRow[];
}
/**
* Two-section admin UI:
* - Default editor card at the top — single input for the tag.
* - Tenant table below — each row has an inline edit/clear control.
*
* No optimistic updates: every save round-trips to the API and we
* router.refresh() to re-render the server-side state. Keeps the UI
* honest about what's actually applied (controller-runtime watch
* latency can be a couple of seconds).
*
* Tag-only by design — see operator notes for rationale.
*/
export function OpenClawAdminPanel({ initialDefaults, tenants }: Props) {
const t = useTranslations("openclawAdmin");
const tCommon = useTranslations("common");
const router = useRouter();
const [defaults, setDefaults] = useState(initialDefaults);
const [defaultTag, setDefaultTag] = useState(initialDefaults.defaultTag);
const [savingDefault, setSavingDefault] = useState(false);
const [defaultError, setDefaultError] = useState("");
const [defaultSaved, setDefaultSaved] = useState(false);
const onSaveDefault = async (e: React.FormEvent) => {
e.preventDefault();
setSavingDefault(true);
setDefaultError("");
setDefaultSaved(false);
try {
const res = await fetch("/api/admin/openclaw", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ defaultTag: defaultTag.trim() }),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || t("saveFailed"));
}
const next = await res.json();
setDefaults(next);
setDefaultSaved(true);
} catch (e: any) {
setDefaultError(e.message);
} finally {
setSavingDefault(false);
}
};
return (
<div className="space-y-8">
{/* Default editor */}
<section className="animate-in animate-in-delay-1">
<h2 className="text-xs font-semibold uppercase tracking-wider text-text-muted mb-3">
{t("defaultSection")}
</h2>
<Card>
<p className="text-sm text-text-secondary mb-4">
{t("defaultDescription")}
</p>
<form onSubmit={onSaveDefault} className="space-y-4">
<div>
<label className="block text-xs uppercase tracking-wider text-text-muted mb-1">
{t("fieldTag")}
</label>
<input
type="text"
value={defaultTag}
onChange={(e) => setDefaultTag(e.target.value)}
placeholder="2026.4.22"
className="w-full px-3 py-2 rounded-lg border border-border bg-surface-2 text-text-primary text-sm font-mono focus:outline-none focus:border-text-secondary"
/>
<p className="text-xs text-text-muted mt-1">{t("emptyHint")}</p>
</div>
{defaultError && (
<div className="text-xs text-red-400 bg-red-400/10 border border-red-400/20 rounded-lg px-3 py-2">
{defaultError}
</div>
)}
{defaultSaved && !defaultError && (
<div className="text-xs text-success bg-success/10 border border-success/20 rounded-lg px-3 py-2">
{t("defaultSaved")}
</div>
)}
<div className="flex justify-end">
<button
type="submit"
disabled={savingDefault}
className="text-sm font-medium px-4 py-2 rounded-lg bg-accent text-white hover:bg-accent/90 transition-colors disabled:opacity-50"
>
{savingDefault ? tCommon("loading") : t("saveDefault")}
</button>
</div>
</form>
</Card>
</section>
{/* Tenant overrides */}
<section className="animate-in animate-in-delay-2">
<h2 className="text-xs font-semibold uppercase tracking-wider text-text-muted mb-3">
{t("overridesSection")}
</h2>
<Card>
{tenants.length === 0 ? (
<p className="text-sm text-text-secondary text-center py-6">
{t("noTenants")}
</p>
) : (
<div className="space-y-2">
{tenants.map((tn) => (
<TenantOverrideRow
key={tn.name}
tenant={tn}
platformDefault={defaults}
onChanged={() => router.refresh()}
/>
))}
</div>
)}
</Card>
</section>
</div>
);
}
/**
* Single row in the tenants table. Collapsed by default; click to
* expand the inline editor.
*/
function TenantOverrideRow({
tenant,
platformDefault,
onChanged,
}: {
tenant: TenantRow;
platformDefault: OpenClawDefaults;
onChanged: () => void;
}) {
const t = useTranslations("openclawAdmin");
const tCommon = useTranslations("common");
const [expanded, setExpanded] = useState(false);
const [tag, setTag] = useState(tenant.override?.tag ?? "");
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
const submit = async (clear = false) => {
setSaving(true);
setError("");
try {
const res = await fetch(
`/api/admin/tenants/${encodeURIComponent(tenant.name)}/openclaw-image`,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(clear ? {} : { tag: tag.trim() }),
}
);
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || t("saveFailed"));
}
setExpanded(false);
onChanged();
} catch (e: any) {
setError(e.message);
} finally {
setSaving(false);
}
};
const effective = tenant.override?.tag
? tenant.override.tag
: platformDefault.defaultTag || t("builtinFallback");
return (
<div className="rounded-lg border border-border bg-surface-2 overflow-hidden">
<button
type="button"
onClick={() => setExpanded((v) => !v)}
className="w-full flex items-center justify-between px-4 py-3 text-left hover:bg-surface-1 transition-colors"
>
<div className="min-w-0 flex-1">
<div className="font-medium text-text-primary truncate">
{tenant.displayName}
</div>
<div className="text-xs text-text-muted font-mono truncate mt-0.5">
{tenant.name}
</div>
</div>
<div className="text-right ml-4 min-w-0">
{tenant.override ? (
<span className="inline-flex items-center px-2 py-0.5 text-xs font-medium rounded-full bg-amber-400/15 text-amber-400 border border-amber-400/20">
{t("statusOverridden")}
</span>
) : (
<span className="inline-flex items-center px-2 py-0.5 text-xs font-medium rounded-full bg-blue-400/15 text-blue-400 border border-blue-400/20">
{t("statusFollowsDefault")}
</span>
)}
<div className="text-xs text-text-muted font-mono truncate max-w-[260px] mt-1">
{effective}
</div>
</div>
</button>
{expanded && (
<div className="px-4 pb-4 pt-1 border-t border-border bg-surface-1">
<div className="mb-3">
<label className="block text-xs uppercase tracking-wider text-text-muted mb-1">
{t("fieldTag")}
</label>
<input
type="text"
value={tag}
onChange={(e) => setTag(e.target.value)}
placeholder={
platformDefault.defaultTag
? `${t("defaultPrefix")} ${platformDefault.defaultTag}`
: ""
}
className="w-full px-3 py-2 rounded-lg border border-border bg-surface-2 text-text-primary text-sm font-mono focus:outline-none focus:border-text-secondary"
/>
</div>
{error && (
<div className="text-xs text-red-400 bg-red-400/10 border border-red-400/20 rounded-lg px-3 py-2 mb-3">
{error}
</div>
)}
<div className="flex flex-wrap gap-2 justify-end">
{tenant.override && (
<button
type="button"
onClick={() => submit(true)}
disabled={saving}
className="text-xs px-3 py-1.5 rounded-lg border border-border text-text-secondary hover:text-text-primary transition-colors disabled:opacity-50"
>
{saving ? tCommon("loading") : t("clearOverride")}
</button>
)}
<button
type="button"
onClick={() => submit(false)}
disabled={saving || !tag.trim()}
className="text-xs px-3 py-1.5 rounded-lg bg-accent text-white hover:bg-accent/90 transition-colors disabled:opacity-50"
>
{saving ? tCommon("loading") : t("saveOverride")}
</button>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,204 @@
"use client";
import { useState, Fragment } from "react";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { Card } from "@/components/ui/card";
import type { SkillActivationRequest } from "@/types";
interface RowData extends SkillActivationRequest {
skillName: string;
companyName: string | null;
}
interface Props {
initialRows: RowData[];
}
/**
* Admin queue table. Each row has Approve and Reject buttons.
* Reject opens an inline reason input that must be filled before
* the call goes through (the API also enforces this — empty
* reasons are 400'd server-side).
*
* Actions hit the admin API endpoints, then router.refresh() to
* re-render the server component with the new state (the row
* disappears once flipped to approved/rejected).
*/
export function PendingSkillRequests({ initialRows }: Props) {
const t = useTranslations("adminSkills");
const router = useRouter();
const [busyId, setBusyId] = useState<string | null>(null);
const [error, setError] = useState("");
// Per-row open-reject-input state. Key = request id.
const [rejectingId, setRejectingId] = useState<string | null>(null);
const [reasonText, setReasonText] = useState("");
const approve = async (id: string) => {
setError("");
setBusyId(id);
try {
const res = await fetch(`/api/admin/skills/pending/${id}/approve`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
if (!res.ok) {
const j = await res.json().catch(() => ({}));
throw new Error(j.error || `HTTP ${res.status}`);
}
router.refresh();
} catch (e: any) {
setError(e.message);
} finally {
setBusyId(null);
}
};
const reject = async (id: string) => {
if (!reasonText.trim()) {
setError(t("reasonRequired"));
return;
}
setError("");
setBusyId(id);
try {
const res = await fetch(`/api/admin/skills/pending/${id}/reject`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ reason: reasonText }),
});
if (!res.ok) {
const j = await res.json().catch(() => ({}));
throw new Error(j.error || `HTTP ${res.status}`);
}
setRejectingId(null);
setReasonText("");
router.refresh();
} catch (e: any) {
setError(e.message);
} finally {
setBusyId(null);
}
};
if (initialRows.length === 0) {
return (
<Card>
<p className="text-sm text-text-muted italic text-center py-6">
{t("emptyQueue")}
</p>
</Card>
);
}
return (
<Card>
{error && (
<div className="mb-3 p-3 rounded-md border border-error bg-error/10 text-sm text-error">
{error}
</div>
)}
<table className="w-full text-sm">
<thead className="text-xs text-text-muted text-left">
<tr>
<th className="pb-2">{t("requestedAtCol")}</th>
<th className="pb-2">{t("skillCol")}</th>
<th className="pb-2">{t("tenantCol")}</th>
<th className="pb-2">{t("orgCol")}</th>
<th className="pb-2 text-right">{t("actionsCol")}</th>
</tr>
</thead>
<tbody>
{initialRows.map((row) => (
<Fragment key={row.id}>
<tr className="border-t border-border align-top">
<td className="py-2 text-xs text-text-muted font-mono">
{row.requestedAt.slice(0, 16).replace("T", " ")}
</td>
<td className="py-2">
<div className="font-medium">{row.skillName}</div>
<div className="text-xs text-text-muted font-mono">
{row.skillId}
</div>
</td>
<td className="py-2 font-mono text-xs">{row.tenantName}</td>
<td className="py-2">
<div className="text-xs">{row.companyName ?? "—"}</div>
<div className="text-xs text-text-muted font-mono">
{row.zitadelOrgId.slice(0, 16)}
</div>
</td>
<td className="py-2 text-right">
{rejectingId !== row.id && (
<div className="flex justify-end gap-2">
<button
onClick={() => {
setRejectingId(row.id);
setReasonText("");
setError("");
}}
disabled={busyId !== null}
className="text-xs px-3 py-1.5 rounded-md border border-error text-error hover:bg-error/10 disabled:opacity-50"
>
{t("rejectBtn")}
</button>
<button
onClick={() => approve(row.id)}
disabled={busyId !== null}
className="text-xs px-3 py-1.5 rounded-md bg-accent text-white disabled:opacity-50"
>
{busyId === row.id ? t("working") : t("approveBtn")}
</button>
</div>
)}
</td>
</tr>
{rejectingId === row.id && (
<tr className="border-t border-border bg-surface-2">
<td colSpan={5} className="py-3 px-3">
<div className="flex flex-col gap-2">
<label className="text-xs text-text-muted">
{t("reasonLabel")}
</label>
<textarea
value={reasonText}
onChange={(e) => setReasonText(e.target.value)}
rows={3}
maxLength={1000}
placeholder={t("reasonPlaceholder")}
className="w-full px-3 py-2 rounded-md border border-border bg-surface-1 text-sm"
autoFocus
/>
<div className="flex justify-end gap-2">
<button
onClick={() => {
setRejectingId(null);
setReasonText("");
}}
disabled={busyId !== null}
className="text-xs px-3 py-1.5 rounded-md border border-border disabled:opacity-50"
>
{t("cancel")}
</button>
<button
onClick={() => reject(row.id)}
disabled={busyId !== null || !reasonText.trim()}
className="text-xs px-3 py-1.5 rounded-md bg-error text-white disabled:opacity-50"
>
{busyId === row.id
? t("working")
: t("confirmRejectBtn")}
</button>
</div>
</div>
</td>
</tr>
)}
</Fragment>
))}
</tbody>
</table>
</Card>
);
}

View File

@@ -0,0 +1,160 @@
import { useTranslations, useFormatter } from "next-intl";
import { Card } from "@/components/ui/card";
import type { Invoice, InvoiceLine } from "@/types";
import { PayInvoiceButton } from "./pay-invoice-button";
import { PaymentStatusBanner } from "./payment-status-banner";
interface Props {
invoice: Invoice;
lines: InvoiceLine[];
}
const statusColors: Record<string, string> = {
open: "text-text-secondary bg-surface-3",
paid: "text-success bg-success/10",
overdue: "text-error bg-error/10",
void: "text-text-muted bg-surface-3",
};
/**
* Read-only invoice detail. Flat list of lines — no per-tenant
* grouping (one invoice per customer; the tenant context is
* already embedded in each line description).
*
* The download link points at /api/billing/invoices/[n]/pdf
* which serves the stored PDF blob inline. Customers using a
* link from their email will hit the same route via this page.
*/
export function CustomerInvoiceDetail({ invoice, lines }: Props) {
const t = useTranslations("customerBilling");
const fmt = useFormatter();
return (
<div className="space-y-6 animate-in">
<PaymentStatusBanner />
<div className="flex items-start justify-between gap-4 flex-wrap">
<div>
<div className="flex items-center gap-3 mb-2">
<h1 className="font-display text-2xl font-semibold">
{invoice.invoiceNumber}
</h1>
<span
className={`text-[10px] uppercase tracking-wider px-2 py-1 rounded-md font-semibold ${
statusColors[invoice.status] ?? "text-text-muted bg-surface-3"
}`}
>
{t(`status.${invoice.status}` as any)}
</span>
</div>
<p className="text-sm text-text-secondary">
{fmt.dateTime(new Date(invoice.periodStart), { dateStyle: "long" })}
<span className="text-text-muted mx-1"></span>
{fmt.dateTime(new Date(invoice.periodEnd), { dateStyle: "long" })}
</p>
</div>
<div className="flex items-start gap-2 flex-wrap">
{/* Phase 4: Pay-with-card available for open + overdue.
Paid/void/draft/uncollectible hide the button — the
API also enforces this, so client-side hiding is just
for the visible affordance. */}
{(invoice.status === "open" || invoice.status === "overdue") && (
<PayInvoiceButton invoiceNumber={invoice.invoiceNumber} />
)}
<a
href={`/api/billing/invoices/${encodeURIComponent(invoice.invoiceNumber)}/pdf`}
target="_blank"
rel="noopener noreferrer"
className="px-4 py-2 rounded-md bg-surface-3 hover:bg-surface-2 border border-border text-sm font-medium transition-colors"
>
{t("downloadPdf")}
</a>
</div>
</div>
<Card>
<div className="space-y-2 mb-4">
<div className="flex justify-between text-sm">
<span className="text-text-muted">{t("billedToLabel")}</span>
<span>{invoice.billingSnapshot.companyName}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-text-muted">{t("issuedAtLabel")}</span>
<span>
{fmt.dateTime(new Date(invoice.issuedAt), { dateStyle: "medium" })}
</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-text-muted">{t("dueAtLabel")}</span>
<span>
{fmt.dateTime(new Date(invoice.dueAt), { dateStyle: "medium" })}
</span>
</div>
{invoice.status === "paid" && invoice.paidAt && (
<div className="flex justify-between text-sm">
<span className="text-text-muted">{t("paidAtLabel")}</span>
<span>
{fmt.dateTime(new Date(invoice.paidAt), { dateStyle: "medium" })}
</span>
</div>
)}
</div>
</Card>
<Card>
<table className="w-full text-sm">
<thead className="text-xs text-text-muted text-left">
<tr>
<th className="pb-2">{t("descriptionCol")}</th>
<th className="pb-2 text-right">{t("qtyCol")}</th>
<th className="pb-2 text-right">{t("unitCol")}</th>
<th className="pb-2 text-right">{t("amountCol")}</th>
</tr>
</thead>
<tbody>
{lines.map((ln) => (
<tr key={ln.id} className="border-t border-border align-top">
<td className="py-2">{ln.description}</td>
<td className="py-2 text-right font-mono text-xs">
{ln.quantity}
{ln.unitLabel ? ` ${ln.unitLabel}` : ""}
</td>
<td className="py-2 text-right font-mono text-xs">
{ln.unitPriceChf.toFixed(2)}
</td>
<td className="py-2 text-right font-mono">
{ln.amountChf.toFixed(2)}
</td>
</tr>
))}
</tbody>
<tfoot>
<tr className="border-t border-border">
<td colSpan={3} className="pt-3 text-right text-text-muted">
{t("subtotalLabel")}
</td>
<td className="pt-3 text-right font-mono">
{invoice.subtotalChf.toFixed(2)}
</td>
</tr>
<tr>
<td colSpan={3} className="pt-1 text-right text-text-muted">
{t("vatLabel", { rate: invoice.vatRate.toFixed(2) })}
</td>
<td className="pt-1 text-right font-mono">
{invoice.vatAmountChf.toFixed(2)}
</td>
</tr>
<tr>
<td colSpan={3} className="pt-2 text-right font-semibold">
{t("totalLabel")}
</td>
<td className="pt-2 text-right font-mono font-semibold text-base">
CHF {invoice.totalChf.toFixed(2)}
</td>
</tr>
</tfoot>
</table>
</Card>
</div>
);
}

View File

@@ -0,0 +1,92 @@
import { useTranslations, useFormatter } from "next-intl";
import { Link } from "@/i18n/navigation";
import { Card } from "@/components/ui/card";
import type { Invoice } from "@/types";
interface Props {
invoices: Invoice[];
}
const statusColors: Record<string, string> = {
open: "text-text-secondary bg-surface-3",
paid: "text-success bg-success/10",
overdue: "text-error bg-error/10",
void: "text-text-muted bg-surface-3 line-through",
};
/**
* Customer's invoice history table. Server component — gets a
* pre-fetched Invoice[] from /billing/page.tsx. Each row links
* to /billing/<invoice-number> for the full detail view.
*
* Columns: number, period, due date, total, status. Status is
* displayed with a colored badge so the customer can scan for
* outstanding ones at a glance.
*/
export function CustomerInvoiceList({ invoices }: Props) {
const t = useTranslations("customerBilling");
const fmt = useFormatter();
if (invoices.length === 0) {
return (
<Card>
<p className="text-sm text-text-muted italic text-center py-8">
{t("emptyHistory")}
</p>
</Card>
);
}
return (
<Card>
<table className="w-full text-sm">
<thead className="text-xs text-text-muted text-left">
<tr>
<th className="pb-2">{t("numberCol")}</th>
<th className="pb-2">{t("periodCol")}</th>
<th className="pb-2">{t("dueCol")}</th>
<th className="pb-2 text-right">{t("totalCol")}</th>
<th className="pb-2 text-right">{t("statusCol")}</th>
</tr>
</thead>
<tbody>
{invoices.map((inv) => (
<tr
key={inv.id}
className="border-t border-border hover:bg-surface-2 transition-colors"
>
<td className="py-2">
<Link
href={`/billing/${inv.invoiceNumber}`}
className="font-mono text-xs text-accent hover:underline"
>
{inv.invoiceNumber}
</Link>
</td>
<td className="py-2 text-xs text-text-secondary">
{fmt.dateTime(new Date(inv.periodStart), { dateStyle: "medium" })}
<span className="text-text-muted mx-1"></span>
{fmt.dateTime(new Date(inv.periodEnd), { dateStyle: "medium" })}
</td>
<td className="py-2 text-xs text-text-secondary">
{fmt.dateTime(new Date(inv.dueAt), { dateStyle: "medium" })}
</td>
<td className="py-2 text-right font-mono">
CHF {inv.totalChf.toFixed(2)}
</td>
<td className="py-2 text-right">
<span
className={`text-[10px] uppercase tracking-wider px-2 py-1 rounded-md font-semibold ${
statusColors[inv.status] ?? "text-text-muted bg-surface-3"
}`}
>
{t(`status.${inv.status}` as any)}
</span>
</td>
</tr>
))}
</tbody>
</table>
</Card>
);
}

View File

@@ -0,0 +1,64 @@
"use client";
import { useState } from "react";
import { useTranslations } from "next-intl";
interface Props {
invoiceNumber: string;
}
/**
* Pay-with-card button. Posts to /api/billing/invoices/[n]/pay,
* which returns a Stripe Checkout Session URL; we redirect the
* browser there.
*
* The button is rendered only by the parent for status='open' or
* 'overdue' invoices — the API enforces this too, but pre-filtering
* UI-side keeps the dead state out of the customer's face.
*/
export function PayInvoiceButton({ invoiceNumber }: Props) {
const t = useTranslations("customerBilling");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const onClick = async () => {
setBusy(true);
setError(null);
try {
const res = await fetch(
`/api/billing/invoices/${encodeURIComponent(invoiceNumber)}/pay`,
{ method: "POST" }
);
const data = await res.json().catch(() => ({}));
if (!res.ok) {
throw new Error(data.error ?? `HTTP ${res.status}`);
}
if (!data.url) {
throw new Error("Payment session URL missing from response.");
}
// Hard navigation, not Next.js router — Stripe Checkout is a
// separate origin and the browser needs to fully leave our app.
window.location.href = data.url;
} catch (e: any) {
setError(e?.message ?? String(e));
setBusy(false);
}
};
return (
<div className="flex flex-col items-end gap-1">
<button
onClick={onClick}
disabled={busy}
className="px-4 py-2 rounded-md bg-accent text-white text-sm font-medium hover:bg-accent-dim transition-colors disabled:opacity-50 cursor-pointer"
>
{busy ? t("redirectingToStripe") : t("payWithCard")}
</button>
{error && (
<span className="text-xs text-error max-w-[260px] text-right">
{error}
</span>
)}
</div>
);
}

View File

@@ -0,0 +1,67 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
/**
* Banner shown after a return from Stripe Checkout.
*
* ?paid=1 → green success banner. The webhook may or may
* not have processed yet, so we phrase the message
* as "Payment received, status will update shortly"
* and don't claim the status is already paid. A
* light auto-refresh after a few seconds nudges
* the page to pick up the new status badge.
*
* ?cancelled=1 → neutral grey banner: "Payment cancelled". The
* invoice stays in 'open' state.
*
* The banner cleans up the query params from the URL so a page
* reload doesn't repeat the message. We use router.replace() to
* keep history clean.
*/
export function PaymentStatusBanner() {
const router = useRouter();
const t = useTranslations("customerBilling");
const [state, setState] = useState<"paid" | "cancelled" | null>(null);
useEffect(() => {
const params = new URLSearchParams(window.location.search);
if (params.has("paid")) {
setState("paid");
// The webhook usually arrives before the browser redirect
// completes, so the page often renders with status='paid'
// on first load and this refresh is a no-op. In the rare
// case where it arrives slightly after, a short refresh
// picks up the status flip. 1.5s is comfortable for both.
const timer = setTimeout(() => {
router.refresh();
}, 1500);
// Strip the query string out of the URL.
const cleanUrl = window.location.pathname;
window.history.replaceState({}, "", cleanUrl);
return () => clearTimeout(timer);
} else if (params.has("cancelled")) {
setState("cancelled");
const cleanUrl = window.location.pathname;
window.history.replaceState({}, "", cleanUrl);
}
}, [router]);
if (state === "paid") {
return (
<div className="mb-4 p-3 rounded-md border border-success bg-success/10 text-sm text-success">
{t("paymentReceived")}
</div>
);
}
if (state === "cancelled") {
return (
<div className="mb-4 p-3 rounded-md border border-border bg-surface-2 text-sm text-text-secondary">
{t("paymentCancelled")}
</div>
);
}
return null;
}

View File

@@ -0,0 +1,189 @@
"use client";
import { useEffect, useState } from "react";
import { useTranslations, useFormatter } from "next-intl";
import { Link } from "@/i18n/navigation";
import { Card } from "@/components/ui/card";
import type { Invoice, InvoiceDraft } from "@/types";
type CurrentResponse =
| { issued: Invoice }
| { draft: InvoiceDraft }
| { error: string; code?: string };
interface Props {
/**
* Whether the viewing user has org-owner role. Drives the
* "complete your billing details" CTA — only owners can edit
* billing settings, so non-owners see a softer message asking
* them to contact their org owner instead. The flag is computed
* server-side and passed in to avoid a second API round-trip.
*/
isOwner: boolean;
}
/**
* Live running total for the current calendar month.
*
* Loads /api/billing/current on mount. Three result shapes:
*
* - { issued } — current-month invoice already exists; we
* link to it instead of showing a draft total.
* - { draft } — still accruing; show subtotal+VAT+total and
* a small line breakdown.
* - { error } — most likely the org has no billing config
* yet; show a friendly hint, not a stack trace.
*
* Client-side because the compute can take a second or two
* (LiteLLM + Threema HTTP calls) and we want a loading spinner.
* No polling — the page is static enough that an explicit
* "refresh" link is good enough if the user wants newer numbers.
*/
export function RunningTotalWidget({ isOwner }: Props) {
const t = useTranslations("customerBilling");
const fmt = useFormatter();
const [data, setData] = useState<CurrentResponse | null>(null);
const [loading, setLoading] = useState(true);
const [refreshCounter, setRefreshCounter] = useState(0);
useEffect(() => {
let cancelled = false;
setLoading(true);
fetch("/api/billing/current")
.then(async (res) => {
const j = (await res.json()) as CurrentResponse;
if (!cancelled) setData(j);
})
.catch((e) => {
if (!cancelled) setData({ error: String(e), code: "FETCH_FAILED" });
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [refreshCounter]);
if (loading) {
return (
<Card>
<p className="text-sm text-text-muted italic py-4">{t("computing")}</p>
</Card>
);
}
if (!data || "error" in data) {
const noConfig =
data && "code" in data && data.code === "COMPUTE_FAILED";
return (
<Card>
<p className="text-sm text-text-secondary py-2">
{noConfig ? t("noBillingConfig") : t("currentPeriodError")}
</p>
{/* Phase 6: owner-only CTA. Non-owners can't edit billing
settings, so we show them a "contact owner" hint instead
— that's gentler than a button that 404s on click. */}
{noConfig && isOwner && (
<Link
href="/settings/billing"
className="inline-block mt-2 px-4 py-2 rounded-md bg-accent text-white text-sm font-medium hover:bg-accent-dim transition-colors"
>
{t("configureBillingCta")}
</Link>
)}
{noConfig && !isOwner && (
<p className="text-xs text-text-muted italic mt-2">
{t("noBillingConfigNonOwner")}
</p>
)}
</Card>
);
}
if ("issued" in data) {
const inv = data.issued;
return (
<Card>
<div className="flex items-start justify-between gap-4 flex-wrap">
<div>
<p className="text-xs text-text-muted">{t("currentInvoiceIssued")}</p>
<Link
href={`/billing/${inv.invoiceNumber}`}
className="font-mono text-sm text-accent hover:underline"
>
{inv.invoiceNumber}
</Link>
</div>
<div className="text-right">
<p className="text-xs text-text-muted">{t("totalLabel")}</p>
<p className="font-mono text-lg font-semibold">
CHF {inv.totalChf.toFixed(2)}
</p>
</div>
</div>
</Card>
);
}
// draft
const draft = data.draft;
const periodLabel = `${fmt.dateTime(new Date(draft.periodStart), {
dateStyle: "long",
})} → ${fmt.dateTime(new Date(draft.periodEnd), { dateStyle: "long" })}`;
return (
<Card>
<div className="flex items-start justify-between gap-4 flex-wrap mb-3">
<div>
<p className="text-xs text-text-muted">{t("accruedSoFar")}</p>
<p className="text-xs text-text-secondary">{periodLabel}</p>
</div>
<div className="text-right">
<p className="text-xs text-text-muted">{t("estimatedTotal")}</p>
<p className="font-mono text-2xl font-semibold text-accent">
CHF {draft.totalChf.toFixed(2)}
</p>
<button
onClick={() => setRefreshCounter((n) => n + 1)}
className="text-[10px] text-text-muted hover:text-text-secondary underline mt-1 cursor-pointer"
>
{t("refresh")}
</button>
</div>
</div>
{draft.lines.length > 0 && (
<details className="text-xs">
<summary className="cursor-pointer text-text-muted hover:text-text-secondary">
{t("breakdownToggle", { count: draft.lines.length })}
</summary>
<table className="w-full mt-2 text-xs">
<tbody>
{draft.lines.map((ln, i) => (
<tr key={i} className="border-t border-border">
<td className="py-1 pr-2">{ln.description}</td>
<td className="py-1 text-right font-mono">
{ln.amountChf.toFixed(2)}
</td>
</tr>
))}
<tr className="border-t border-border">
<td className="py-1 pr-2 text-text-muted text-right">
{t("subtotalLabel")}
</td>
<td className="py-1 text-right font-mono">
{draft.subtotalChf.toFixed(2)}
</td>
</tr>
<tr>
<td className="py-1 pr-2 text-text-muted text-right">
{t("vatLabel", { rate: draft.vatRate.toFixed(2) })}
</td>
<td className="py-1 text-right font-mono">
{draft.vatAmountChf.toFixed(2)}
</td>
</tr>
</tbody>
</table>
</details>
)}
<p className="text-[10px] text-text-muted mt-3 italic">{t("draftNote")}</p>
</Card>
);
}

View File

@@ -3,14 +3,32 @@
import { useState, useCallback } from "react";
import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
import { ThreemaQrModal } from "./threema-qr-modal";
/** Maps channel IDs to the instructions for finding the user ID. */
const CHANNEL_ID_HELP: Record<string, string> = {
telegram: "telegramIdHelp",
discord: "discordIdHelp",
email: "emailIdHelp",
threema: "threemaIdHelp",
// email entry dropped in the Phase A rework — IMAP/SMTP is handled by
// the `mail` skill (category=skill, not channel), so it never appears
// in `enabledChannels`. If a future channel is added to the catalog,
// give it an entry here so the help blurb renders.
};
/**
* Channels whose user list is managed through a dedicated endpoint
* instead of the generic PATCH /api/tenants/:name flow.
*
* Threema is the only one today — adding/removing a Threema ID has to
* synchronise with the central pieced-threema-gateway relay's `routes`
* table (uniqueness enforced there, not in K8s). The
* /api/tenants/:name/threema/routes endpoint owns that two-step
* coordination (relay first, then K8s, with compensation on K8s
* failure). Other channels just patch the K8s spec directly.
*/
const RELAY_MANAGED_CHANNELS = new Set(["threema"]);
interface ChannelUsersProps {
tenantName: string;
/** Currently enabled channel packages (e.g. ["telegram", "discord"]) */
@@ -34,6 +52,14 @@ export function ChannelUsers({
const [inputValues, setInputValues] = useState<Record<string, string>>({});
const [channelUsers, setChannelUsers] =
useState<Record<string, string[]>>(initialChannelUsers);
/** Which channel's QR helper modal is open, if any. */
const [showQrFor, setShowQrFor] = useState<string | null>(null);
/**
* Tracks channels for which we've already auto-opened the helper
* modal on this page load. Prevents the modal from re-popping every
* time the user refocuses the input after dismissing it.
*/
const [autoOpened, setAutoOpened] = useState<Set<string>>(() => new Set());
const updateChannelUsers = useCallback(
async (updated: Record<string, string[]>) => {
@@ -60,6 +86,70 @@ export function ChannelUsers({
[tenantName, router]
);
/**
* Threema (and any future relay-managed channel) uses a dedicated
* endpoint that synchronises the central relay's routes table with
* the K8s spec atomically. We call it per-ID rather than sending the
* whole array because uniqueness is enforced ID-by-ID at the relay,
* and the error UX of "this ID is taken" is per-add.
*/
const addToRelayChannel = useCallback(
async (channel: string, threemaId: string) => {
setSaving(true);
setError("");
try {
const res = await fetch(
`/api/tenants/${tenantName}/${channel}/routes`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ threemaId }),
}
);
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || `Add failed (HTTP ${res.status})`);
}
setChannelUsers((prev) => {
const current = prev[channel] ?? [];
if (current.includes(threemaId)) return prev;
return { ...prev, [channel]: [...current, threemaId] };
});
router.refresh();
} catch (e: any) {
setError(e.message);
} finally {
setSaving(false);
}
},
[tenantName, router]
);
const removeFromRelayChannel = useCallback(
async (channel: string, threemaId: string) => {
setSaving(true);
setError("");
try {
const url = `/api/tenants/${tenantName}/${channel}/routes?threemaId=${encodeURIComponent(threemaId)}`;
const res = await fetch(url, { method: "DELETE" });
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || `Remove failed (HTTP ${res.status})`);
}
setChannelUsers((prev) => {
const current = prev[channel] ?? [];
return { ...prev, [channel]: current.filter((id) => id !== threemaId) };
});
router.refresh();
} catch (e: any) {
setError(e.message);
} finally {
setSaving(false);
}
},
[tenantName, router]
);
const handleAdd = useCallback(
(channel: string) => {
const userId = inputValues[channel]?.trim();
@@ -71,18 +161,27 @@ export function ChannelUsers({
return;
}
setInputValues((prev) => ({ ...prev, [channel]: "" }));
if (RELAY_MANAGED_CHANNELS.has(channel)) {
void addToRelayChannel(channel, userId);
} else {
const updated = {
...channelUsers,
[channel]: [...current, userId],
};
setInputValues((prev) => ({ ...prev, [channel]: "" }));
updateChannelUsers(updated);
}
},
[channelUsers, inputValues, updateChannelUsers, t]
[channelUsers, inputValues, updateChannelUsers, addToRelayChannel, t]
);
const handleRemove = useCallback(
(channel: string, userId: string) => {
if (RELAY_MANAGED_CHANNELS.has(channel)) {
void removeFromRelayChannel(channel, userId);
return;
}
const current = channelUsers[channel] || [];
const updated = {
...channelUsers,
@@ -90,7 +189,7 @@ export function ChannelUsers({
};
updateChannelUsers(updated);
},
[channelUsers, updateChannelUsers]
[channelUsers, updateChannelUsers, removeFromRelayChannel]
);
if (enabledChannels.length === 0) return null;
@@ -134,6 +233,39 @@ export function ChannelUsers({
</span>
</div>
{channel === "threema" && (
<div className="mb-3 flex flex-col sm:flex-row gap-3 items-start sm:items-center justify-between bg-accent/5 border border-accent/30 rounded-lg p-3">
<div className="flex items-start gap-2 flex-1">
<svg
className="w-4 h-4 mt-0.5 text-accent flex-shrink-0"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth="2"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M3 4a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H4a1 1 0 01-1-1V4zM15 4a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1V4zM3 16a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H4a1 1 0 01-1-1v-4zM13 13h3v3h-3zM18 13h3v3h-3zM13 18h3v3h-3zM18 18h3v3h-3z"
/>
</svg>
<div className="text-xs text-text-secondary leading-relaxed">
<p className="font-medium text-text-primary mb-0.5">
{t("threemaSetup.bannerTitle")}
</p>
<p>{t("threemaSetup.bannerBody")}</p>
</div>
</div>
<button
onClick={() => setShowQrFor("threema")}
className="self-stretch sm:self-auto px-3 py-2 text-xs font-medium bg-accent text-surface-0 rounded-lg hover:bg-accent-dim transition-colors whitespace-nowrap cursor-pointer shadow-lg shadow-accent/20"
>
{t("threemaSetup.bannerButton")}
</button>
</div>
)}
{helpKey && (
<p className="text-xs text-text-secondary bg-surface-1 border border-border rounded-lg p-3 mb-3 whitespace-pre-line">
{t(helpKey)}
@@ -176,6 +308,17 @@ export function ChannelUsers({
[channel]: e.target.value,
}))
}
onFocus={() => {
// For threema specifically, open the QR helper the
// first time the user clicks into the input on this
// page load. We don't repeat after dismiss — the
// "Show QR" button next to the channel name covers
// re-opens on demand.
if (channel === "threema" && !autoOpened.has("threema")) {
setShowQrFor("threema");
setAutoOpened((prev) => new Set(prev).add("threema"));
}
}}
onKeyDown={(e) => {
if (e.key === "Enter") handleAdd(channel);
}}
@@ -194,6 +337,11 @@ export function ChannelUsers({
</div>
);
})}
<ThreemaQrModal
open={showQrFor === "threema"}
onClose={() => setShowQrFor(null)}
/>
</div>
);
}

View File

@@ -0,0 +1,82 @@
"use client";
import { useTranslations } from "next-intl";
import { useEffect } from "react";
import { THREEMA_GATEWAY } from "@/lib/threema-gateway-config";
interface ThreemaQrModalProps {
open: boolean;
onClose: () => void;
}
/**
* On-demand modal showing the QR for adding the assistant on Threema.
* Triggered by the "Show QR" button in the threema channel card and
* closes on overlay click, ESC, or the close button.
*
* Uses a plain <img> not next/image — image optimization adds nothing
* for a 57KB static PNG and removes a potential source of rendering
* bugs in the Next.js standalone build.
*/
export function ThreemaQrModal({ open, onClose }: ThreemaQrModalProps) {
const t = useTranslations("channelUsers.threemaSetup");
useEffect(() => {
if (!open) return;
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") onClose();
}
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [open, onClose]);
if (!open) return null;
return (
<div
onClick={onClose}
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4"
>
<div
onClick={(e) => e.stopPropagation()}
className="w-full max-w-md bg-surface-1 border border-border rounded-2xl p-6 shadow-2xl shadow-black/40 space-y-4"
>
<div className="flex items-start justify-between">
<h3 className="text-base font-semibold text-text-primary">
{t("title")}
</h3>
<button
onClick={onClose}
className="text-text-muted hover:text-text-primary text-xl leading-none"
aria-label="Close"
>
×
</button>
</div>
<div className="flex justify-center">
<div className="bg-white p-3 rounded-md">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={THREEMA_GATEWAY.qrCodePath}
alt={t("qrAlt", { gateway: THREEMA_GATEWAY.displayName })}
width={220}
height={220}
style={{ display: "block" }}
/>
</div>
</div>
<p className="text-center text-xs text-text-muted font-mono">
{THREEMA_GATEWAY.displayName}
</p>
<ol className="list-decimal list-inside text-xs text-text-secondary space-y-1.5">
<li>{t("step1")}</li>
<li>{t("step2")}</li>
<li>{t("step3")}</li>
</ol>
</div>
</div>
);
}

View File

@@ -127,11 +127,29 @@ export function BudgetEditableCard({
<button
type="button"
onClick={() => setOpen(true)}
className="bg-surface-1 border border-border rounded-xl p-4 text-left hover:border-text-secondary transition-colors group"
className="bg-surface-1 border border-accent/40 rounded-xl p-4 text-left hover:border-accent transition-colors cursor-pointer focus:outline-none focus:ring-2 focus:ring-accent/40 group block w-full"
>
<div className="text-xs text-text-muted mb-1 flex items-center justify-between">
<span>{t("budget")}</span>
<span className="text-[10px] text-text-muted opacity-0 group-hover:opacity-100 transition-opacity">
<span className="text-[10px] text-accent inline-flex items-center gap-1">
{/* Pencil icon — unambiguous "this is editable" affordance.
Visible at all times (was hover-only before, which on
touch devices and at-a-glance scanning gave no
indication the card was clickable). */}
<svg
xmlns="http://www.w3.org/2000/svg"
width="11"
height="11"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z" />
</svg>
{t("budgetEdit")}
</span>
</div>
@@ -140,17 +158,13 @@ export function BudgetEditableCard({
</div>
</button>
{open && (
<Modal open={open} onClose={() => setOpen(false)} ariaLabel={t("budgetEditTitle")}>
<h3 className="font-display text-lg font-semibold text-text-primary mb-2">
{t("budgetEditTitle")}
</h3>
<p className="text-sm text-text-secondary mb-4">
<p className="text-sm text-text-secondary mb-5">
{t("budgetEditDescription")}
</p>
<div className="text-xs text-amber-400 bg-amber-400/10 border border-amber-400/20 rounded-lg px-3 py-2 mb-5">
{t("budgetOrgScopeWarning")}
</div>
<form onSubmit={onSubmit} className="space-y-4">
{/* Mode toggle: unlimited vs capped. Two radios are
@@ -256,7 +270,6 @@ export function BudgetEditableCard({
</div>
</form>
</Modal>
)}
</>
);
}

View File

@@ -74,6 +74,20 @@ function NavBar() {
{t("settings")}
</NavLink>
)}
{/* Phase 3: Billing visible to anyone signed in. The
page is org-scoped server-side — non-owner members
see the same invoice history their owner does, but
actions like "configure billing details" are gated
separately on the settings page. Personal accounts
see their own (single-tenant) invoices. */}
{user && (
<NavLink
href="/billing"
active={pathname.startsWith("/billing")}
>
{t("billing")}
</NavLink>
)}
{/* Feature 5: Support is available to every signed-in
user. Customers see their own tickets only; platform
admins see the queue. */}

View File

@@ -2,6 +2,7 @@
import { useRouter } from "next/navigation";
import { OnboardingWizard } from "./wizard";
import type { OrgBilling } from "@/types";
interface OnboardingFlowProps {
orgName: string;
@@ -19,6 +20,12 @@ interface OnboardingFlowProps {
* /settings/billing.
*/
hasOrgBilling?: boolean;
/**
* Phase 6 fix3: the actual org_billing record (or null). Drives
* the review-step "Billing to" rendering AND the confirm-step
* validation skip when the billing step was skipped.
*/
existingOrgBilling?: OrgBilling | null;
/**
* Bug 6: when present, the wizard is rendered in edit mode against
* the given pending request. See `OnboardingWizard` for the full
@@ -45,6 +52,7 @@ export function OnboardingFlow({
userName,
userEmail,
hasOrgBilling,
existingOrgBilling,
editingRequest,
}: OnboardingFlowProps) {
const router = useRouter();
@@ -55,6 +63,7 @@ export function OnboardingFlow({
userName={userName}
userEmail={userEmail}
hasOrgBilling={hasOrgBilling}
existingOrgBilling={existingOrgBilling}
editingRequest={editingRequest}
onComplete={() => {
// Navigate back to /dashboard and re-fetch on the server. The

View File

@@ -3,7 +3,7 @@
import { useState, useCallback, useEffect, useRef } from "react";
import { useTranslations } from "next-intl";
import { Card } from "@/components/ui/card";
import { PACKAGE_CATALOG, type PackageDef } from "@/lib/packages";
import { PACKAGE_CATALOG, DEFAULT_PACKAGE_IDS, type PackageDef } from "@/lib/packages";
import { isPersonalOrgName, displayOrgNameFor } from "@/lib/personal-org";
import {
configureStepSchema,
@@ -13,6 +13,7 @@ import {
SUPPORTED_COUNTRIES,
type SupportedCountry,
} from "@/lib/validation";
import type { OrgBilling } from "@/types";
type Step = "welcome" | "configure" | "billing" | "confirm";
@@ -69,6 +70,7 @@ translation, and general question answering.
`;
const CATEGORIES = [
{ key: "core" as const, labelKey: "categories.core" },
{ key: "channel" as const, labelKey: "categories.channels" },
{ key: "skill" as const, labelKey: "categories.skills" },
] as const;
@@ -95,6 +97,17 @@ interface WizardProps {
* fix it before admin approves.
*/
hasOrgBilling?: boolean;
/**
* Phase 6 fix3: the actual org_billing record when one exists.
* Used to render real values on the review-step "Billing to" block
* (rather than the wizard's empty default config.billingAddress)
* AND to skip the confirm-step's client-side validation of
* billingAddress — same logic that already strips billingAddress
* at submit time. Null when no org_billing row exists yet.
* Ignored in edit mode (the editingRequest carries its own
* billingAddress snapshot).
*/
existingOrgBilling?: OrgBilling | null;
/**
* Bug 6: when present, the wizard renders in "edit" mode — fields
* are pre-populated from the request, the SOUL.md auto-fetch is
@@ -133,6 +146,7 @@ export function OnboardingWizard({
userName,
userEmail,
hasOrgBilling,
existingOrgBilling,
editingRequest,
onComplete,
}: WizardProps) {
@@ -198,7 +212,13 @@ export function OnboardingWizard({
agentName: "Assistant",
soulMd: FALLBACK_SOUL.replace("{company}", displayOrgName),
agentsMd: FALLBACK_AGENTS,
packages: [] as string[],
// CORE defaults: heartbeat + cron + active-memory pre-selected so
// the assistant can be proactive, run scheduled tasks, and recall
// stable context out of the box. Customers can untoggle any of
// them before submitting. core-voice is fully wired (Phase B)
// but stays unselected — opt-in keeps audio spend predictable
// for tenants who don't intend to use voice channels.
packages: [...DEFAULT_PACKAGE_IDS] as string[],
billingAddress: {
// For personal accounts, leave the company field empty — it'll
// appear on invoices. The user can still type something if they
@@ -312,7 +332,23 @@ export function OnboardingWizard({
}
// confirm: validate the union (defence in depth — submit handler
// also runs onboardingSchema before POST).
const r = onboardingSchema.safeParse(config);
//
// Phase 6 fix3: when hasOrgBilling=true AND not editing, the
// billing step was skipped and config.billingAddress is the
// empty default. zod's .optional() doesn't help here because the
// field IS present (empty object), so billingAddressSchema
// validates it and fails with required-field errors that the
// user has no way to fix — the form to enter the values was
// skipped on purpose. Strip the field for validation, matching
// the same strip we already do at submit time.
const configForValidation =
hasOrgBilling && !isEditing
? (() => {
const { billingAddress: _b, ...rest } = config;
return rest;
})()
: config;
const r = onboardingSchema.safeParse(configForValidation);
if (r.success) {
setErrors({});
return true;
@@ -691,7 +727,7 @@ export function OnboardingWizard({
<button
type="button"
onClick={() => togglePackage(pkg.id)}
className="w-full flex items-center justify-between px-3 py-2.5 cursor-pointer hover:bg-surface-3/30 transition-colors"
className="w-full flex items-center justify-between px-3 py-2.5 transition-colors cursor-pointer hover:bg-surface-3/30"
>
<div className="text-left">
<span
@@ -1094,42 +1130,84 @@ export function OnboardingWizard({
<ReviewRow
label={t("reviewBillingTo")}
value={
(() => {
// Phase 6 fix3: when the org has billing on file
// and we're not editing, render the saved
// org_billing record (the authoritative source)
// rather than config.billingAddress, which is the
// wizard's empty default state because the billing
// step was skipped. In edit mode, fall back to
// config.billingAddress, which is pre-populated
// from the request being edited.
const useSaved =
hasOrgBilling && !isEditing && existingOrgBilling;
const company = useSaved
? existingOrgBilling!.companyName
: config.billingAddress.company;
const street = useSaved
? existingOrgBilling!.streetAddress
: config.billingAddress.street;
const postalCode = useSaved
? existingOrgBilling!.postalCode
: config.billingAddress.postalCode;
const city = useSaved
? existingOrgBilling!.city
: config.billingAddress.city;
const country = useSaved
? existingOrgBilling!.country
: config.billingAddress.country;
const contactName = useSaved
? existingOrgBilling!.contactName
: null;
return (
<div className="text-text-primary text-right">
{/* For personal: skip the company line so the
invoice rendering matches what the user actually
entered. For company: include it as the first
line. */}
{!isPersonal &&
config.billingAddress.company &&
config.billingAddress.company.trim().length > 0 && (
<div>{config.billingAddress.company}</div>
company &&
company.trim().length > 0 && <div>{company}</div>}
{/* Phase 6 fix2: optional contact-person line
("z.Hd. <name>") only present when the saved
org_billing has it set. */}
{contactName && contactName.trim().length > 0 && (
<div className="text-text-muted">
{t("reviewContactPersonPrefix")} {contactName}
</div>
)}
<div>{config.billingAddress.street}</div>
<div>{street}</div>
<div>
{config.billingAddress.postalCode}{" "}
{config.billingAddress.city}
{postalCode} {city}
</div>
<div className="text-text-muted">
{tCountries(
config.billingAddress.country as SupportedCountry
)}
{tCountries(country as SupportedCountry)}
</div>
</div>
);
})()
}
/>
{/* Bug 35: VAT review row. Company customers see this so
they can verify the VAT id they typed before submitting.
Personal customers never see it — they don't have a
VAT number, the form didn't ask, the review hides it. */}
VAT number, the form didn't ask, the review hides it.
Phase 6 fix3: when reading from existingOrgBilling,
the value comes from there too. */}
{!isPersonal &&
config.billingAddress.vatNumber &&
config.billingAddress.vatNumber.trim().length > 0 && (
(() => {
const vat =
hasOrgBilling && !isEditing && existingOrgBilling
? existingOrgBilling.vatNumber
: config.billingAddress.vatNumber;
return vat && vat.trim().length > 0 ? (
<ReviewRow
label={t("billingVatNumber")}
value={config.billingAddress.vatNumber}
value={vat}
mono
/>
)}
) : null;
})()}
<ReviewRow
label={t("reviewContactEmail")}
value={userEmail || ""}

View File

@@ -1,8 +1,14 @@
"use client";
import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
import { useState } from "react";
import type { PackageDef } from "@/lib/packages";
import type {
SkillActivationRequest,
SkillPricing,
} from "@/types";
import { SkillCostDialog } from "./skill-cost-dialog";
interface Props {
pkg: PackageDef;
@@ -12,6 +18,18 @@ interface Props {
onToggled: () => void;
/** Slice 5: when false, the enable/disable button is hidden. */
canEdit?: boolean;
/**
* Phase 2.5 — most recent non-terminal activation request for this
* skill on this tenant, if any. Drives the "Manual review pending"
* and "Activation rejected" inline states. Approved/withdrawn rows
* never reach the client side.
*/
activationRequest?: SkillActivationRequest | null;
/**
* Phase 2.5 — pricing for this skill if it has any. Triggers the
* cost-disclosure dialog before enable.
*/
pricing?: SkillPricing | null;
}
export function PackageCard({
@@ -21,15 +39,53 @@ export function PackageCard({
tenantName,
onToggled,
canEdit = true,
activationRequest = null,
pricing = null,
}: Props) {
const t = useTranslations();
const router = useRouter();
const [showModal, setShowModal] = useState(false);
const [secrets, setSecrets] = useState<Record<string, string>>({});
const [accepted, setAccepted] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
// Phase 2.5: cost-disclosure flow + activation-request flow.
const [showCostDialog, setShowCostDialog] = useState(false);
const isPriced =
(pricing?.dailyPriceChf ?? 0) > 0 || (pricing?.setupFeeChf ?? 0) > 0;
async function handleEnable() {
function handleEnable() {
// Phase 2.5: gate priced skills behind the cost-disclosure dialog.
// Confirm → proceedWithEnable. Cancel → bail.
if (isPriced) {
setError(null);
setShowCostDialog(true);
return;
}
void proceedWithEnable();
}
async function proceedWithEnable() {
if (pkg.customProvisioning) {
// Platform-side provisioning, then add to packages list.
setSaving(true);
setError(null);
try {
const provRes = await fetch(`/api/tenants/${tenantName}/${pkg.id}`, {
method: "POST",
});
if (!provRes.ok) {
const err = await provRes.json().catch(() => ({}));
throw new Error(err.error || `Provisioning failed (HTTP ${provRes.status})`);
}
await togglePackage(true);
} catch (e: any) {
setError(e.message);
} finally {
setSaving(false);
}
return;
}
if (pkg.requiresSecrets) {
setShowModal(true);
setSecrets({});
@@ -40,6 +96,34 @@ export function PackageCard({
await togglePackage(true);
}
async function handleDisable() {
setSaving(true);
setError(null);
try {
if (pkg.customProvisioning) {
// Revoke platform-side credentials FIRST so the relay drops
// routes before the operator removes the channel from the
// OpenClaw config. Partial-success (token revoked, OpenBao
// delete failed) returns 503 with partial=true and we surface
// the error rather than continuing — the secret may still be
// valid in OpenBao and rolling back the relay revoke isn't
// possible (it cascaded to routes).
const deprovRes = await fetch(`/api/tenants/${tenantName}/${pkg.id}`, {
method: "DELETE",
});
if (!deprovRes.ok) {
const err = await deprovRes.json().catch(() => ({}));
throw new Error(err.error || `Deprovisioning failed (HTTP ${deprovRes.status})`);
}
}
await togglePackage(false);
} catch (e: any) {
setError(e.message);
} finally {
setSaving(false);
}
}
async function togglePackage(enable: boolean) {
setSaving(true);
try {
@@ -64,6 +148,39 @@ export function PackageCard({
}
}
// Phase 2.5: withdraw a still-pending activation request. The
// request row flips to 'withdrawn' (server-side); router.refresh()
// re-renders the tenant page without the pending state, leaving
// the toggle re-enabled if the user wants to retry.
async function withdrawRequest() {
if (!activationRequest || activationRequest.status !== "pending") return;
setSaving(true);
setError(null);
try {
const res = await fetch(
`/api/skills/requests/${activationRequest.id}/withdraw`,
{ method: "POST" }
);
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.error || `HTTP ${res.status}`);
}
router.refresh();
} catch (e: any) {
setError(e.message);
} finally {
setSaving(false);
}
}
// Phase 2.5: retry after a rejection. Same flow as a fresh
// enable; the rejected row stays in the DB as audit trail but a
// new pending row will be created by the PATCH.
function tryAgainAfterRejection() {
setError(null);
handleEnable();
}
async function handleSubmitSecrets() {
if (pkg.disclaimerKey && !accepted) return;
@@ -122,9 +239,52 @@ export function PackageCard({
{pkg.requiresSecrets && (
<span className="text-[10px] text-text-muted">{t("packages.requiresApiKey")}</span>
)}
{canEdit ? (
{/* Phase 2.5: pending or rejected request takes precedence
over the toggle. Approved/withdrawn never reach here.
For packages that needed secrets, surface that they're
safely stored — the user might otherwise worry the
credentials they typed got lost when the activation
was deferred. */}
{canEdit && activationRequest?.status === "pending" ? (
<div className="ml-auto flex flex-col items-end gap-1">
<span
className="text-[10px] text-warning italic"
title={pkg.requiresSecrets ? t("packages.credentialsSavedTip") : undefined}
>
{t("packages.manualReviewPending")}
{pkg.requiresSecrets && (
<span className="text-text-muted ml-1 not-italic">
· {t("packages.credentialsSaved")}
</span>
)}
</span>
<button
onClick={enabled ? () => togglePackage(false) : handleEnable}
onClick={withdrawRequest}
disabled={saving}
className="rounded-lg px-3 py-1.5 text-xs font-medium text-text-secondary hover:text-text-primary bg-surface-3 hover:bg-surface-2 disabled:opacity-50 cursor-pointer"
>
{saving ? "…" : t("packages.withdraw")}
</button>
</div>
) : canEdit && activationRequest?.status === "rejected" ? (
<div className="ml-auto flex flex-col items-end gap-1">
<span
className="text-[10px] text-error italic max-w-[220px] truncate"
title={activationRequest.rejectionReason ?? ""}
>
{t("packages.activationRejected")}: {activationRequest.rejectionReason}
</span>
<button
onClick={tryAgainAfterRejection}
disabled={saving}
className="rounded-lg px-3 py-1.5 text-xs font-medium bg-accent text-surface-0 hover:bg-accent-dim disabled:opacity-50 cursor-pointer shadow-lg shadow-accent/20"
>
{saving ? "…" : t("packages.tryAgain")}
</button>
</div>
) : canEdit ? (
<button
onClick={enabled ? handleDisable : handleEnable}
disabled={saving}
className={`ml-auto rounded-lg px-3 py-1.5 text-xs font-medium transition-all cursor-pointer ${
enabled
@@ -146,6 +306,20 @@ export function PackageCard({
</div>
</div>
{/* Phase 2.5: cost-disclosure modal for priced skills. */}
<SkillCostDialog
open={showCostDialog}
onClose={() => setShowCostDialog(false)}
onConfirm={() => {
setShowCostDialog(false);
void proceedWithEnable();
}}
skillName={pkg.name}
dailyPriceChf={pricing?.dailyPriceChf ?? 0}
setupFeeChf={pricing?.setupFeeChf ?? 0}
busy={saving}
/>
{showModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
<div className="w-full max-w-md bg-surface-1 border border-border rounded-2xl p-6 space-y-4 shadow-2xl shadow-black/40">

View File

@@ -3,6 +3,10 @@
import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
import { PACKAGE_CATALOG } from "@/lib/packages";
import type {
SkillActivationRequest,
SkillPricing,
} from "@/types";
import { PackageCard } from "./package-card";
interface Props {
@@ -12,9 +16,21 @@ interface Props {
onRefresh?: () => void;
/** Slice 5: when false, package toggles and edit affordances are hidden. */
canEdit?: boolean;
/**
* Phase 2.5 — non-terminal activation requests for this tenant.
* Each PackageCard looks up its skill in this array to render the
* pending/rejected inline state. Most recent first.
*/
activationRequests?: SkillActivationRequest[];
/**
* Phase 2.5 — skill pricing keyed by skillId. Drives the cost
* disclosure dialog.
*/
skillPricing?: SkillPricing[];
}
const CATEGORIES = [
{ key: "core" as const, labelKey: "categories.core" },
{ key: "channel" as const, labelKey: "categories.channels" },
{ key: "skill" as const, labelKey: "categories.skills" },
] as const;
@@ -38,11 +54,29 @@ export function PackageList({
conditions,
onRefresh,
canEdit = true,
activationRequests = [],
skillPricing = [],
}: Props) {
const t = useTranslations("packages");
const router = useRouter();
const handleRefresh = onRefresh || (() => router.refresh());
// Build per-skill lookups once so each card render is O(1) rather
// than O(N) over the requests array. `activationRequests` already
// arrives filtered to non-terminal rows (most-recent per
// (skill, status) pair from the server).
const requestBySkill = new Map<string, SkillActivationRequest>();
for (const req of activationRequests) {
// Pending takes precedence over rejected — if both exist for
// the same skill (race or after-rejection-retry), show pending.
const existing = requestBySkill.get(req.skillId);
if (!existing || (existing.status === "rejected" && req.status === "pending")) {
requestBySkill.set(req.skillId, req);
}
}
const pricingBySkill = new Map<string, SkillPricing>();
for (const p of skillPricing) pricingBySkill.set(p.skillId, p);
return (
<div className="space-y-6">
{CATEGORIES.map(({ key, labelKey }) => {
@@ -64,6 +98,8 @@ export function PackageList({
tenantName={tenantName}
onToggled={handleRefresh}
canEdit={canEdit}
activationRequest={requestBySkill.get(pkg.id) ?? null}
pricing={pricingBySkill.get(pkg.id) ?? null}
/>
))}
</div>

View File

@@ -0,0 +1,115 @@
"use client";
import { useTranslations } from "next-intl";
import { Modal } from "@/components/ui/modal";
interface Props {
open: boolean;
onClose: () => void;
onConfirm: () => void;
skillName: string;
dailyPriceChf: number;
setupFeeChf: number;
busy?: boolean;
}
/**
* Cost-disclosure modal shown before activating a priced skill.
*
* Shows the daily rate and setup fee (each only if > 0) and
* requires an explicit Confirm before the activation request goes
* through. Rendered every time the user toggles on a priced skill,
* not once-and-remember — this is recurring-charge consent, not a
* one-time terms agreement.
*
* The setup fee is always shown when configured, with a note
* clarifying it's "one-time, charged on first activation". The
* backend (billing.ts tenantSkillHasBeenBilled) is the authority
* on whether the fee actually fires — we don't second-guess from
* the client. If you've previously activated this skill on this
* tenant, the fee won't appear on the next invoice even though
* the dialog mentions it.
*/
export function SkillCostDialog({
open,
onClose,
onConfirm,
skillName,
dailyPriceChf,
setupFeeChf,
busy = false,
}: Props) {
const t = useTranslations("skillCostDialog");
const showSetupFee = setupFeeChf > 0;
const showDaily = dailyPriceChf > 0;
// Nothing to disclose? Bail to confirm immediately — shouldn't
// normally be shown in this case but guard anyway.
if (!showSetupFee && !showDaily) {
return null;
}
return (
<Modal open={open} onClose={onClose} ariaLabel={t("title")}>
<div className="bg-surface-1 rounded-lg border border-border p-6 max-w-md w-full">
<h2 className="text-lg font-semibold mb-2">{t("title")}</h2>
<p className="text-sm text-text-secondary mb-4">
{t("intro", { skill: skillName })}
</p>
<div className="rounded-md bg-surface-2 border border-border p-4 mb-4 space-y-2">
{showSetupFee && (
<div className="flex justify-between items-baseline">
<div>
<div className="text-sm">{t("setupFeeLabel")}</div>
<div className="text-xs text-text-muted">
{t("setupFeeNote")}
</div>
</div>
<div className="text-sm font-mono">
CHF {setupFeeChf.toFixed(2)}
</div>
</div>
)}
{showDaily && (
/* Display reference monthly cost (daily × 30) plus the
actual daily rate as a sub-note. Billing is always
per UTC day enabled — partial months prorate to that
same daily rate, full months land at roughly the
figure shown (varies ±~3% by month length). */
<div className="flex justify-between items-baseline">
<div>
<div className="text-sm">{t("monthlyPriceLabel")}</div>
<div className="text-xs text-text-muted">
{t("monthlyPriceNote", {
daily: dailyPriceChf.toFixed(2),
})}
</div>
</div>
<div className="text-sm font-mono">
CHF {(dailyPriceChf * 30).toFixed(2)} / {t("monthUnit")}
</div>
</div>
)}
</div>
<p className="text-xs text-text-muted mb-4">{t("disclaimer")}</p>
<div className="flex justify-end gap-2">
<button
onClick={onClose}
disabled={busy}
className="px-4 py-2 rounded-md border border-border text-sm disabled:opacity-50"
>
{t("cancel")}
</button>
<button
onClick={onConfirm}
disabled={busy}
className="px-4 py-2 rounded-md bg-accent text-white text-sm disabled:opacity-50"
>
{busy ? t("confirming") : t("confirm")}
</button>
</div>
</div>
</Modal>
);
}

View File

@@ -0,0 +1,263 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { Card } from "@/components/ui/card";
import type { OrgBilling } from "@/types";
interface Props {
initial: OrgBilling | null;
/**
* Personal-account (individual customer) flag from the session.
* Individuals get a "Full name" field instead of "Company name",
* and the VAT input is hidden entirely — they don't have one and
* showing the field would only confuse. The underlying column is
* still `company_name` in the DB and the invoice PDF; for an
* individual that field carries their full name, which is
* exactly what should print on the invoice.
*/
isPersonal: boolean;
}
/**
* Customer billing settings form. Drives PUT /api/settings/billing
* which upserts org_billing for the caller's org.
*
* Validation is the same regex as the server-side zod schema for
* the country field (ISO 3166-1 alpha-2). Other fields are checked
* for required + max-length client-side; the server is the
* authority and re-validates everything.
*
* On success we router.refresh() the page so the server component
* re-fetches and any "create now" -> "edit" wording flips.
*/
export function BillingSettingsForm({ initial, isPersonal }: Props) {
const t = useTranslations("settingsBilling");
const router = useRouter();
const [form, setForm] = useState({
companyName: initial?.companyName ?? "",
contactName: initial?.contactName ?? "",
streetAddress: initial?.streetAddress ?? "",
postalCode: initial?.postalCode ?? "",
city: initial?.city ?? "",
country: initial?.country ?? "CH",
vatNumber: initial?.vatNumber ?? "",
billingEmail: initial?.billingEmail ?? "",
notes: initial?.notes ?? "",
});
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [savedFlash, setSavedFlash] = useState(false);
const set =
(field: keyof typeof form) =>
(e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) =>
setForm((f) => ({ ...f, [field]: e.target.value }));
const submit = async () => {
setError(null);
setSavedFlash(false);
// Client-side gate on required fields — the server re-validates.
if (
!form.companyName.trim() ||
!form.streetAddress.trim() ||
!form.postalCode.trim() ||
!form.city.trim() ||
!form.country.trim() ||
!form.billingEmail.trim()
) {
setError(t("missingRequired"));
return;
}
if (!/^[A-Za-z]{2}$/.test(form.country.trim())) {
setError(t("invalidCountry"));
return;
}
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(form.billingEmail.trim())) {
setError(t("invalidEmail"));
return;
}
setBusy(true);
try {
const res = await fetch("/api/settings/billing", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
companyName: form.companyName.trim(),
// Personal accounts don't have a contact-name field
// (companyName IS their name); force null so stale state
// from a previously-org-flagged account can't carry over.
contactName: isPersonal ? null : form.contactName.trim() || null,
streetAddress: form.streetAddress.trim(),
postalCode: form.postalCode.trim(),
city: form.city.trim(),
country: form.country.trim().toUpperCase(),
// Personal accounts never have a VAT number — force null
// regardless of stale state, in case a value was stored
// before the account got flagged as personal.
vatNumber: isPersonal ? null : form.vatNumber.trim() || null,
billingEmail: form.billingEmail.trim(),
notes: form.notes.trim() || null,
}),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
throw new Error(data.error ?? `HTTP ${res.status}`);
}
setSavedFlash(true);
router.refresh();
} catch (e: any) {
setError(e?.message ?? String(e));
} finally {
setBusy(false);
}
};
return (
<Card>
<div className="space-y-4">
<Field
label={isPersonal ? t("fullNameLabel") : t("companyNameLabel")}
required
>
<input
type="text"
value={form.companyName}
onChange={set("companyName")}
maxLength={200}
className="w-full px-3 py-2 rounded-md bg-surface-2 border border-border focus:border-accent focus:outline-none text-sm"
/>
</Field>
{!isPersonal && (
<Field label={t("contactNameLabel")} hint={t("contactNameHint")}>
<input
type="text"
value={form.contactName}
onChange={set("contactName")}
maxLength={200}
className="w-full px-3 py-2 rounded-md bg-surface-2 border border-border focus:border-accent focus:outline-none text-sm"
/>
</Field>
)}
<Field label={t("streetAddressLabel")} required>
<input
type="text"
value={form.streetAddress}
onChange={set("streetAddress")}
maxLength={200}
className="w-full px-3 py-2 rounded-md bg-surface-2 border border-border focus:border-accent focus:outline-none text-sm"
/>
</Field>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Field label={t("postalCodeLabel")} required>
<input
type="text"
value={form.postalCode}
onChange={set("postalCode")}
maxLength={20}
className="w-full px-3 py-2 rounded-md bg-surface-2 border border-border focus:border-accent focus:outline-none text-sm"
/>
</Field>
<Field label={t("cityLabel")} required>
<input
type="text"
value={form.city}
onChange={set("city")}
maxLength={100}
className="w-full px-3 py-2 rounded-md bg-surface-2 border border-border focus:border-accent focus:outline-none text-sm"
/>
</Field>
<Field
label={t("countryLabel")}
required
hint={t("countryHint")}
>
<input
type="text"
value={form.country}
onChange={(e) =>
setForm((f) => ({
...f,
country: e.target.value.toUpperCase().slice(0, 2),
}))
}
maxLength={2}
className="w-full px-3 py-2 rounded-md bg-surface-2 border border-border focus:border-accent focus:outline-none text-sm uppercase font-mono"
/>
</Field>
</div>
{!isPersonal && (
<Field label={t("vatNumberLabel")} hint={t("vatNumberHint")}>
<input
type="text"
value={form.vatNumber}
onChange={set("vatNumber")}
maxLength={40}
placeholder="CHE-123.456.789 MWST"
className="w-full px-3 py-2 rounded-md bg-surface-2 border border-border focus:border-accent focus:outline-none text-sm font-mono"
/>
</Field>
)}
<Field label={t("billingEmailLabel")} required hint={t("billingEmailHint")}>
<input
type="email"
value={form.billingEmail}
onChange={set("billingEmail")}
maxLength={200}
className="w-full px-3 py-2 rounded-md bg-surface-2 border border-border focus:border-accent focus:outline-none text-sm"
/>
</Field>
<Field label={t("notesLabel")} hint={t("notesHint")}>
<textarea
value={form.notes}
onChange={set("notes")}
maxLength={2000}
rows={3}
className="w-full px-3 py-2 rounded-md bg-surface-2 border border-border focus:border-accent focus:outline-none text-sm"
/>
</Field>
{error && (
<p className="text-sm text-error">{error}</p>
)}
{savedFlash && (
<p className="text-sm text-success">{t("saved")}</p>
)}
<div className="flex justify-end">
<button
onClick={submit}
disabled={busy}
className="px-4 py-2 rounded-md bg-accent text-white text-sm font-medium hover:bg-accent-dim transition-colors disabled:opacity-50 cursor-pointer"
>
{busy ? t("saving") : initial ? t("saveChanges") : t("createBilling")}
</button>
</div>
</div>
</Card>
);
}
function Field({
label,
required,
hint,
children,
}: {
label: string;
required?: boolean;
hint?: string;
children: React.ReactNode;
}) {
return (
<div>
<label className="block text-xs uppercase tracking-wider text-text-muted mb-1">
{label}
{required && <span className="text-error ml-1">*</span>}
</label>
{children}
{hint && (
<p className="text-xs text-text-muted mt-1 italic">{hint}</p>
)}
</div>
);
}

157
src/lib/billing-i18n.ts Normal file
View File

@@ -0,0 +1,157 @@
/**
* Shared billing localization. Used by:
* - billing.ts (compute path) — pre-renders the localized
* line description and stores it on the invoice line at issue
* time. Descriptions are then frozen in the customer's locale.
* - billing-pdf.tsx (render path) — can fall back to this if a
* stored description is missing (e.g. legacy invoice from the
* pre-i18n era) or if the PDF is re-rendered in a different
* locale (Phase 7).
*
* Locale set matches the portal's next-intl locales: de, en, fr, it.
* Unknown locales fall back to German (Swiss B2B default).
*/
import type { InvoiceLineKind } from "@/types";
export type BillingLocale = "de" | "en" | "fr" | "it";
function normaliseLocale(locale: string): BillingLocale {
if (locale === "en" || locale === "fr" || locale === "it" || locale === "de") {
return locale;
}
return "de";
}
/**
* Localized "N day(s)" — covers the only plural case in billing
* line descriptions. Other plurals (months, requests, messages)
* either don't change form in the supported languages or are
* always >1 in practice.
*/
function days(n: number, locale: BillingLocale): string {
const labels = {
de: { one: "Tag", many: "Tage" },
en: { one: "day", many: "days" },
fr: { one: "jour", many: "jours" },
it: { one: "giorno", many: "giorni" },
} as const;
const label = labels[locale];
return `${n} ${n === 1 ? label.one : label.many}`;
}
/** Subset of InvoiceLine needed for description formatting. */
export interface LineForDescription {
kind: InvoiceLineKind;
tenantName: string | null;
metadata: Record<string, unknown> | null;
}
/**
* Build the localized line description from a line's kind +
* metadata. Pure function — no DB/IO. Output mirrors what the
* PDF and admin preview show in the description column.
*
* Metadata expectations per kind (must match what billing.ts
* stores when emitting the line):
* tenant_monthly: { billable_days, days_in_month }
* tenant_setup: {} (uses tenantName only)
* ai_usage: { requests }
* threema_messages: { in_count, out_count }
* skill_usage: { skill_id, billable_days }
* skill_setup: { skill_id }
* adjustment: { reason? }
*
* Missing fields fall back to "?" so a malformed line still
* renders something readable rather than crashing the PDF.
*/
export function formatLineDescription(
line: LineForDescription,
locale: string
): string {
const L = normaliseLocale(locale);
const m = line.metadata ?? {};
const tenant = line.tenantName ?? "—";
// Helper to fetch a metadata field with a safe fallback.
const f = (key: string): string | number => {
const v = (m as Record<string, unknown>)[key];
if (v === undefined || v === null) return "?";
return v as string | number;
};
switch (line.kind) {
case "tenant_monthly": {
const bd = f("billable_days");
const dim = f("days_in_month");
return {
de: `Monatliche Grundgebühr für ${tenant} (${bd}/${dim} Tage)`,
en: `Monthly fee for ${tenant} (${bd}/${dim} days)`,
fr: `Forfait mensuel pour ${tenant} (${bd}/${dim} jours)`,
it: `Canone mensile per ${tenant} (${bd}/${dim} giorni)`,
}[L];
}
case "tenant_setup":
return {
de: `Einrichtungsgebühr für ${tenant}`,
en: `Setup fee for ${tenant}`,
fr: `Frais de configuration pour ${tenant}`,
it: `Spese di attivazione per ${tenant}`,
}[L];
case "ai_usage": {
const r = f("requests");
return {
de: `KI-Inferenz-Nutzung (${r} Anfragen)`,
en: `AI inference usage (${r} requests)`,
fr: `Utilisation IA (${r} requêtes)`,
it: `Utilizzo IA (${r} richieste)`,
}[L];
}
case "threema_messages": {
const inC = f("in_count");
const outC = f("out_count");
return {
de: `Threema-Nachrichten (${inC} eingehend + ${outC} ausgehend)`,
en: `Threema messages (${inC} in + ${outC} out)`,
fr: `Messages Threema (${inC} entrants + ${outC} sortants)`,
it: `Messaggi Threema (${inC} in entrata + ${outC} in uscita)`,
}[L];
}
case "skill_usage": {
const skill = f("skill_id");
const bdRaw = (m as Record<string, unknown>)["billable_days"];
const bd = typeof bdRaw === "number" ? bdRaw : 0;
return {
de: `Skill: ${skill} (${days(bd, "de")})`,
en: `Skill: ${skill} (${days(bd, "en")})`,
fr: `Skill: ${skill} (${days(bd, "fr")})`,
it: `Skill: ${skill} (${days(bd, "it")})`,
}[L];
}
case "skill_setup": {
const skill = f("skill_id");
return {
de: `Einrichtungsgebühr Skill: ${skill}`,
en: `Setup fee skill: ${skill}`,
fr: `Frais de configuration skill: ${skill}`,
it: `Spese di attivazione skill: ${skill}`,
}[L];
}
case "adjustment": {
const reasonRaw = (m as Record<string, unknown>)["reason"];
const reason = typeof reasonRaw === "string" ? reasonRaw : null;
const base = {
de: "Anpassung",
en: "Adjustment",
fr: "Ajustement",
it: "Rettifica",
}[L];
return reason ? `${base}: ${reason}` : base;
}
}
}

677
src/lib/billing-pdf.tsx Normal file
View File

@@ -0,0 +1,677 @@
/**
* Invoice PDF rendering via @react-pdf/renderer.
*
* Design notes:
*
* - The template is a React component (JSX). Visual tweaks happen
* here — colors, fonts, spacing, layout. To swap branding later,
* edit BRAND_* constants below or replace the logo component.
*
* - All strings are pulled from MESSAGES[locale]. To add a new
* language, copy the German block and translate. Locale is
* frozen on the invoice at issue time (invoices.locale column);
* re-rendering a historical invoice always uses the same locale.
*
* - The logo is inlined as React-PDF SVG primitives so no asset
* loading or font-bundle wrangling is needed. It travels with
* the code.
*
* - VAT note (reverse charge etc.) is appended below the totals
* block. Notes are localized in the same MESSAGES map.
*
* - QR-bill (Swiss bank transfer) is intentionally NOT included
* in v1 — it lands in Phase 7. We render plain bank instructions
* as text.
*/
import React from "react";
import {
Document,
Page,
Text,
View,
StyleSheet,
Svg,
Polygon,
Polyline,
renderToBuffer,
} from "@react-pdf/renderer";
import type { Invoice, InvoiceLine, InvoiceLineKind } from "@/types";
// ---------------------------------------------------------------------------
// Brand constants — edit here to tweak look without touching layout
// ---------------------------------------------------------------------------
const BRAND = {
name: "PieCed IT",
// Primary emerald — matches the logo SVG fill (#10B981).
primary: "#10B981",
// Slightly darker emerald for headings.
primaryDark: "#0a8060",
textColor: "#1a1a1a",
mutedColor: "#666",
borderColor: "#d4d4d4",
// Issuer block — change these to your real legal info.
issuer: {
legalName: "PieCed IT",
addressLine1: "Cedric Mosimann",
addressLine2: "[Strasse Nr.]",
postalCity: "[PLZ] Basel",
country: "Switzerland",
email: "billing@pieced.ch",
web: "pieced.ch",
// Show "MWST-Nr. ..." on PDF when set.
vatNumber: null as string | null,
// Bank instructions — Phase 7 replaces with QR-bill.
bankName: "[Bank name]",
bankIban: "[CHxx xxxx xxxx xxxx xxxx x]",
bankBic: "[BIC]",
},
};
// ---------------------------------------------------------------------------
// Localized strings
// ---------------------------------------------------------------------------
interface PdfStrings {
invoice: string;
invoiceNumber: string;
issueDate: string;
dueDate: string;
period: string;
billTo: string;
// Phase 6 fix: prefix shown before the optional contact-person
// name on the bill-to block. "z.Hd." (DE) / "Attn:" (EN) /
// "À l'attention de" (FR) / "c.a." (IT). Empty/unused when the
// invoice has no contactName on its snapshot.
attentionPrefix: string;
description: string;
quantity: string;
unitPrice: string;
amount: string;
subtotal: string;
vat: string;
total: string;
paymentInstructions: string;
paymentRefHint: string;
thankYou: string;
page: string;
of: string;
// Per-line-kind labels (used as section headers)
kindLabels: Record<InvoiceLineKind, string>;
// VAT compliance notes
reverseCharge: string;
exportNote: string;
}
const MESSAGES: Record<string, PdfStrings> = {
de: {
invoice: "Rechnung",
invoiceNumber: "Rechnungs-Nr.",
issueDate: "Rechnungsdatum",
dueDate: "Zahlbar bis",
period: "Abrechnungsperiode",
billTo: "Rechnungsempfänger",
attentionPrefix: "z.Hd.",
description: "Beschreibung",
quantity: "Menge",
unitPrice: "Einzelpreis",
amount: "Betrag",
subtotal: "Zwischensumme",
vat: "MWST",
total: "Total",
paymentInstructions: "Zahlungsinformationen",
paymentRefHint: "Bitte verwenden Sie die Rechnungsnummer als Referenz.",
thankYou: "Vielen Dank für Ihr Vertrauen.",
page: "Seite",
of: "von",
kindLabels: {
tenant_monthly: "Monatliche Grundgebühr",
tenant_setup: "Einrichtungsgebühr",
ai_usage: "KI-Nutzung",
threema_messages: "Threema-Nachrichten",
skill_usage: "Skill-Nutzung",
skill_setup: "Einrichtungsgebühr Skill",
adjustment: "Anpassung",
},
reverseCharge:
"Steuerschuldnerschaft des Leistungsempfängers (Reverse Charge).",
exportNote: "Dienstleistungsexport — keine MWST in Rechnung gestellt.",
},
en: {
invoice: "Invoice",
invoiceNumber: "Invoice no.",
issueDate: "Issue date",
dueDate: "Due date",
period: "Billing period",
billTo: "Bill to",
attentionPrefix: "Attn:",
description: "Description",
quantity: "Qty",
unitPrice: "Unit price",
amount: "Amount",
subtotal: "Subtotal",
vat: "VAT",
total: "Total",
paymentInstructions: "Payment instructions",
paymentRefHint: "Please use the invoice number as the payment reference.",
thankYou: "Thank you for your business.",
page: "Page",
of: "of",
kindLabels: {
tenant_monthly: "Monthly fee",
tenant_setup: "Setup fee",
ai_usage: "AI usage",
threema_messages: "Threema messages",
skill_usage: "Skill usage",
skill_setup: "Skill setup fee",
adjustment: "Adjustment",
},
reverseCharge:
"Reverse charge — VAT to be accounted for by the recipient.",
exportNote: "Export of services — VAT not applicable.",
},
fr: {
invoice: "Facture",
invoiceNumber: "N° facture",
issueDate: "Date d'émission",
dueDate: "Échéance",
period: "Période de facturation",
billTo: "Destinataire",
attentionPrefix: "À l'attention de",
description: "Description",
quantity: "Qté",
unitPrice: "Prix unitaire",
amount: "Montant",
subtotal: "Sous-total",
vat: "TVA",
total: "Total",
paymentInstructions: "Informations de paiement",
paymentRefHint: "Veuillez utiliser le n° de facture comme référence.",
thankYou: "Merci de votre confiance.",
page: "Page",
of: "sur",
kindLabels: {
tenant_monthly: "Forfait mensuel",
tenant_setup: "Frais de configuration",
ai_usage: "Utilisation IA",
threema_messages: "Messages Threema",
skill_usage: "Utilisation Skill",
skill_setup: "Frais de configuration skill",
adjustment: "Ajustement",
},
reverseCharge:
"Autoliquidation — TVA à acquitter par le destinataire.",
exportNote: "Exportation de services — TVA non applicable.",
},
it: {
invoice: "Fattura",
invoiceNumber: "N. fattura",
issueDate: "Data di emissione",
dueDate: "Scadenza",
period: "Periodo di fatturazione",
billTo: "Destinatario",
attentionPrefix: "c.a.",
description: "Descrizione",
quantity: "Qtà",
unitPrice: "Prezzo unitario",
amount: "Importo",
subtotal: "Subtotale",
vat: "IVA",
total: "Totale",
paymentInstructions: "Istruzioni di pagamento",
paymentRefHint: "Si prega di utilizzare il n. di fattura come riferimento.",
thankYou: "Grazie per la fiducia.",
page: "Pagina",
of: "di",
kindLabels: {
tenant_monthly: "Canone mensile",
tenant_setup: "Spese di attivazione",
ai_usage: "Utilizzo IA",
threema_messages: "Messaggi Threema",
skill_usage: "Utilizzo Skill",
skill_setup: "Spese di attivazione skill",
adjustment: "Rettifica",
},
reverseCharge:
"Inversione contabile — IVA a carico del destinatario.",
exportNote: "Esportazione di servizi — IVA non applicabile.",
},
};
function getStrings(locale: string): PdfStrings {
return MESSAGES[locale] ?? MESSAGES.de;
}
// ---------------------------------------------------------------------------
// Stylesheet
// ---------------------------------------------------------------------------
const styles = StyleSheet.create({
page: {
paddingTop: 40,
paddingBottom: 60,
paddingHorizontal: 40,
fontSize: 9,
color: BRAND.textColor,
lineHeight: 1.4,
},
headerRow: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "flex-start",
marginBottom: 28,
},
logoWrap: { width: 60, height: 90 },
issuerBlock: { textAlign: "right", fontSize: 8.5, color: BRAND.mutedColor },
issuerName: { fontSize: 11, color: BRAND.primaryDark, marginBottom: 2 },
invoiceTitle: { fontSize: 22, color: BRAND.primaryDark, marginBottom: 8 },
metaTable: {
flexDirection: "row",
justifyContent: "space-between",
marginBottom: 20,
},
metaCol: { flexGrow: 1, marginRight: 16 },
metaLabel: { color: BRAND.mutedColor, fontSize: 8, marginBottom: 2 },
metaValue: { fontSize: 10, marginBottom: 6 },
billToBlock: {
marginBottom: 24,
padding: 10,
backgroundColor: "#f7f7f5",
borderLeftWidth: 3,
borderLeftColor: BRAND.primary,
},
billToLabel: { fontSize: 8, color: BRAND.mutedColor, marginBottom: 4 },
billToName: { fontSize: 11, marginBottom: 2 },
table: { marginBottom: 14 },
tableHeader: {
flexDirection: "row",
backgroundColor: BRAND.primaryDark,
color: "#ffffff",
paddingVertical: 5,
paddingHorizontal: 6,
fontSize: 8.5,
},
tableRow: {
flexDirection: "row",
borderBottomWidth: 0.5,
borderBottomColor: BRAND.borderColor,
paddingVertical: 5,
paddingHorizontal: 6,
},
// Column widths (sum ≈ 100%)
colDesc: { width: "52%" },
colQty: { width: "12%", textAlign: "right" },
colUnit: { width: "16%", textAlign: "right" },
colAmt: { width: "20%", textAlign: "right" },
totalsBlock: {
alignSelf: "flex-end",
width: "45%",
marginTop: 8,
},
totalsRow: {
flexDirection: "row",
justifyContent: "space-between",
paddingVertical: 3,
},
totalsLabel: { color: BRAND.mutedColor },
totalsValue: { textAlign: "right" },
totalsGrand: {
flexDirection: "row",
justifyContent: "space-between",
borderTopWidth: 1,
borderTopColor: BRAND.primaryDark,
paddingTop: 6,
marginTop: 4,
},
totalsGrandLabel: { color: BRAND.primaryDark, fontSize: 11 },
totalsGrandValue: { color: BRAND.primaryDark, fontSize: 11, textAlign: "right" },
noteBox: {
marginTop: 18,
padding: 8,
backgroundColor: "#fff8e7",
borderLeftWidth: 2,
borderLeftColor: "#d4a017",
fontSize: 8.5,
},
paymentBlock: {
marginTop: 24,
paddingTop: 12,
borderTopWidth: 0.5,
borderTopColor: BRAND.borderColor,
},
paymentTitle: { fontSize: 10, color: BRAND.primaryDark, marginBottom: 6 },
paymentLine: { fontSize: 9, marginBottom: 1 },
footer: {
position: "absolute",
bottom: 24,
left: 40,
right: 40,
flexDirection: "row",
justifyContent: "space-between",
fontSize: 7.5,
color: BRAND.mutedColor,
borderTopWidth: 0.5,
borderTopColor: BRAND.borderColor,
paddingTop: 8,
},
});
// ---------------------------------------------------------------------------
// Logo — inlined SVG primitives
// ---------------------------------------------------------------------------
/**
* PieCed honeycomb logo. Re-renders the same 6-hex glyph as the
* portal's `public/pieced-logo.svg` using React-PDF's SVG support.
* Width/height are independent of the original viewBox so we can
* scale it without losing stroke quality.
*/
const Logo = ({ size = 60 }: { size?: number }) => (
<Svg width={size} height={size * (106 / 70)} viewBox="0 0 70 106">
{/* H1 solid */}
<Polygon
points="38.5,22.69 31.5,10.566 17.5,10.566 10.5,22.69 17.5,34.814 31.5,34.814"
fill="#10B981"
stroke="#10B981"
strokeWidth={1.6}
/>
{/* H2 outline */}
<Polygon
points="59.5,34.814 52.5,22.69 38.5,22.69 31.5,34.814 38.5,46.938 52.5,46.938"
fill="none"
stroke="#10B981"
strokeWidth={1.8}
/>
{/* H3 outline */}
<Polygon
points="38.5,46.938 31.5,34.814 17.5,34.814 10.5,46.938 17.5,59.062 31.5,59.062"
fill="none"
stroke="#10B981"
strokeWidth={1.8}
/>
{/* H4 solid */}
<Polygon
points="59.5,59.062 52.5,46.938 38.5,46.938 31.5,59.062 38.5,71.186 52.5,71.186"
fill="#10B981"
stroke="#10B981"
strokeWidth={1.6}
/>
{/* H5 partial */}
<Polyline
points="31.5,83.31 38.5,71.186 31.5,59.062 17.5,59.062 10.5,71.186"
fill="none"
stroke="#10B981"
strokeWidth={1.8}
/>
{/* H6 partial */}
<Polyline
points="59.5,83.31 52.5,71.186 38.5,71.186 31.5,83.31 38.5,95.434"
fill="none"
stroke="#10B981"
strokeWidth={1.8}
/>
</Svg>
);
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function fmtChf(n: number, decimals: number = 2): string {
// Swiss thousands separator + decimal point: 1'234.56
const fixed = n.toFixed(decimals);
const [intPart, decPart] = fixed.split(".");
const withSep = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, "'");
return decPart ? `${withSep}.${decPart}` : withSep;
}
function fmtDate(iso: string, locale: string): string {
// Parse YYYY-MM-DD as a calendar date (no timezone shifts).
// For PDF rendering we want a stable representation regardless
// of server timezone.
const [y, m, d] = iso.split("T")[0].split("-").map(Number);
// Locale-specific date format
if (locale === "en") {
return new Date(Date.UTC(y, m - 1, d)).toLocaleDateString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
timeZone: "UTC",
});
}
// DE/FR/IT default: DD.MM.YYYY
return `${String(d).padStart(2, "0")}.${String(m).padStart(2, "0")}.${y}`;
}
// ---------------------------------------------------------------------------
// Document
// ---------------------------------------------------------------------------
interface InvoicePdfProps {
invoice: Invoice;
lines: InvoiceLine[];
}
const InvoicePdf: React.FC<InvoicePdfProps> = ({ invoice, lines }) => {
const s = getStrings(invoice.locale);
const snap = invoice.billingSnapshot;
// Group lines by tenant for visual separation. Lines without a
// tenant_name (org-level adjustments) go to the end.
const linesByTenant = new Map<string | null, InvoiceLine[]>();
for (const ln of lines) {
const key = ln.tenantName;
if (!linesByTenant.has(key)) linesByTenant.set(key, []);
linesByTenant.get(key)!.push(ln);
}
const tenantOrder = [...linesByTenant.keys()].sort((a, b) => {
if (a === null) return 1;
if (b === null) return -1;
return a.localeCompare(b);
});
// VAT note: pick the right localized note based on rate + address.
// Zero rate + EU country = reverse charge; zero rate + other = export.
let vatNote: string | null = null;
if (invoice.vatRate === 0) {
const country = (snap.country || "").toUpperCase();
const isEu = [
"AT","BE","BG","HR","CY","CZ","DK","EE","FI","FR","DE","GR","HU",
"IE","IT","LV","LT","LU","MT","NL","PL","PT","RO","SK","SI","ES","SE",
].includes(country);
vatNote = isEu ? s.reverseCharge : s.exportNote;
}
return (
<Document title={`${s.invoice} ${invoice.invoiceNumber}`}>
<Page size="A4" style={styles.page}>
{/* Header: logo left, issuer right */}
<View style={styles.headerRow}>
<View style={styles.logoWrap}>
<Logo size={60} />
</View>
<View style={styles.issuerBlock}>
<Text style={styles.issuerName}>{BRAND.issuer.legalName}</Text>
<Text>{BRAND.issuer.addressLine1}</Text>
<Text>{BRAND.issuer.addressLine2}</Text>
<Text>{BRAND.issuer.postalCity}</Text>
<Text>{BRAND.issuer.country}</Text>
<Text>{BRAND.issuer.email}</Text>
{BRAND.issuer.vatNumber && (
<Text>MWST-Nr. {BRAND.issuer.vatNumber}</Text>
)}
</View>
</View>
<Text style={styles.invoiceTitle}>{s.invoice}</Text>
{/* Meta row: 3 columns */}
<View style={styles.metaTable}>
<View style={styles.metaCol}>
<Text style={styles.metaLabel}>{s.invoiceNumber}</Text>
<Text style={styles.metaValue}>{invoice.invoiceNumber}</Text>
<Text style={styles.metaLabel}>{s.issueDate}</Text>
<Text style={styles.metaValue}>
{fmtDate(invoice.issuedAt, invoice.locale)}
</Text>
</View>
<View style={styles.metaCol}>
<Text style={styles.metaLabel}>{s.period}</Text>
<Text style={styles.metaValue}>
{fmtDate(invoice.periodStart, invoice.locale)} {" "}
{fmtDate(invoice.periodEnd, invoice.locale)}
</Text>
<Text style={styles.metaLabel}>{s.dueDate}</Text>
<Text style={styles.metaValue}>
{fmtDate(invoice.dueAt, invoice.locale)}
</Text>
</View>
</View>
{/* Bill-to */}
<View style={styles.billToBlock}>
<Text style={styles.billToLabel}>{s.billTo}</Text>
<Text style={styles.billToName}>{snap.companyName}</Text>
{/* Phase 6 fix: optional "z.Hd." / "Attn:" line for routing
the printed invoice internally at the customer. Prints
between the company name and street address, in the
invoice's locale (frozen at issue time). */}
{snap.contactName && (
<Text>
{s.attentionPrefix} {snap.contactName}
</Text>
)}
<Text>{snap.streetAddress}</Text>
<Text>
{snap.postalCode} {snap.city}
</Text>
<Text>{snap.country}</Text>
{snap.vatNumber && <Text>VAT: {snap.vatNumber}</Text>}
<Text>{snap.billingEmail}</Text>
</View>
{/* Line items table */}
<View style={styles.table}>
<View style={styles.tableHeader}>
<Text style={styles.colDesc}>{s.description}</Text>
<Text style={styles.colQty}>{s.quantity}</Text>
<Text style={styles.colUnit}>{s.unitPrice}</Text>
<Text style={styles.colAmt}>{s.amount} (CHF)</Text>
</View>
{tenantOrder.map((tenantKey) => {
const tenantLines = linesByTenant.get(tenantKey)!;
return (
<View key={tenantKey ?? "_org"}>
{tenantKey && (
<View
style={{
paddingVertical: 4,
paddingHorizontal: 6,
backgroundColor: "#f0f9f4",
}}
>
<Text style={{ fontSize: 9, color: BRAND.primaryDark }}>
{tenantKey}
</Text>
</View>
)}
{tenantLines.map((ln) => (
<View key={ln.id} style={styles.tableRow}>
<Text style={styles.colDesc}>{ln.description}</Text>
<Text style={styles.colQty}>
{ln.quantity}
{ln.unitLabel ? ` ${ln.unitLabel}` : ""}
</Text>
<Text style={styles.colUnit}>{fmtChf(ln.unitPriceChf, 5)}</Text>
<Text style={styles.colAmt}>{fmtChf(ln.amountChf)}</Text>
</View>
))}
</View>
);
})}
</View>
{/* Totals */}
<View style={styles.totalsBlock}>
<View style={styles.totalsRow}>
<Text style={styles.totalsLabel}>{s.subtotal}</Text>
<Text style={styles.totalsValue}>{fmtChf(invoice.subtotalChf)}</Text>
</View>
<View style={styles.totalsRow}>
<Text style={styles.totalsLabel}>
{s.vat} ({invoice.vatRate.toFixed(2)}%)
</Text>
<Text style={styles.totalsValue}>{fmtChf(invoice.vatAmountChf)}</Text>
</View>
<View style={styles.totalsGrand}>
<Text style={styles.totalsGrandLabel}>
{s.total} (CHF)
</Text>
<Text style={styles.totalsGrandValue}>{fmtChf(invoice.totalChf)}</Text>
</View>
</View>
{vatNote && (
<View style={styles.noteBox}>
<Text>{vatNote}</Text>
</View>
)}
{/* Payment instructions */}
<View style={styles.paymentBlock}>
<Text style={styles.paymentTitle}>{s.paymentInstructions}</Text>
<Text style={styles.paymentLine}>{BRAND.issuer.legalName}</Text>
<Text style={styles.paymentLine}>{BRAND.issuer.bankName}</Text>
<Text style={styles.paymentLine}>IBAN: {BRAND.issuer.bankIban}</Text>
<Text style={styles.paymentLine}>BIC: {BRAND.issuer.bankBic}</Text>
<Text style={[styles.paymentLine, { marginTop: 6, color: BRAND.mutedColor }]}>
{s.paymentRefHint}
</Text>
<Text style={[styles.paymentLine, { marginTop: 12, color: BRAND.primaryDark }]}>
{s.thankYou}
</Text>
</View>
{/* Footer with page numbers.
react-pdf API quirks (verified against build errors):
- The `render` callback on <View> only exposes
`{ pageNumber, subPageNumber }` — no totalPages.
Only <Text> gets `{ pageNumber, totalPages,
subPageNumber, subPageTotalPages }`.
- <Text>'s render callback must return a STRING
(or array of strings), not JSX. */}
<View style={styles.footer} fixed>
<Text>
{BRAND.issuer.legalName} · {BRAND.issuer.web} · {BRAND.issuer.email}
</Text>
<Text
render={({ pageNumber, totalPages }) =>
`${s.page} ${pageNumber} ${s.of} ${totalPages}`
}
fixed
/>
</View>
</Page>
</Document>
);
};
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
* Render an invoice to a PDF buffer. Caller stores the buffer in
* `invoices.pdf_data` (bytea). Side-effect-free; can be called
* outside a DB transaction.
*
* Typical runtime is 50200ms on a typical invoice with a dozen
* lines.
*/
export async function renderInvoicePdf(
invoice: Invoice,
lines: InvoiceLine[]
): Promise<Buffer> {
return renderToBuffer(<InvoicePdf invoice={invoice} lines={lines} />);
}

838
src/lib/billing.ts Normal file
View File

@@ -0,0 +1,838 @@
/**
* Billing computation pipeline.
*
* Public entry points:
* - computeInvoiceDraft({ zitadelOrgId, year, month, locale? })
* Builds an in-memory InvoiceDraft from the live signals
* (LiteLLM spend, Threema relay usage, tenant skill events,
* lifecycle, suspension). Does NOT persist or render the PDF.
*
* - generateInvoice({ zitadelOrgId, year, month, locale?, dryRun? })
* Calls computeInvoiceDraft, renders the PDF, persists the
* invoice transactionally. Returns the persisted Invoice
* (or the draft if dryRun=true).
*
* Design choices:
*
* - All compute is over UTC calendar days. "Active during day D"
* means the tenant existed and was not fully suspended at some
* moment in [D 00:00 UTC, D+1 00:00 UTC). This matches the
* skill billing rule ("same-day toggle = 1 day") for monthly
* fee proration too.
*
* - Computation is independent of persistence. Callers can preview
* without committing (the admin generate form does this on first
* click), and the same compute path is reused when committing.
*
* - The compute path collects warnings rather than throwing on
* recoverable issues (missing LiteLLM team for a tenant, etc.).
* The UI surfaces these to the admin before they confirm.
*/
import type {
Invoice,
InvoiceBillingSnapshot,
InvoiceDraft,
InvoiceLine,
InvoiceLineKind,
InvoicePaymentMethod,
PiecedTenant,
PlatformPricing,
SkillPricing,
TenantBillingLifecycle,
TenantSkillEvent,
TenantSuspensionEvent,
} from "@/types";
import {
createInvoice,
getInvoiceById,
getOrgBilling,
getOrgBillingConfig,
getPlatformPricing,
getTenantBillingLifecycle,
listSkillEventsForTenant,
listSkillPricing,
listSuspensionEventsForTenant,
tenantHasSetupFeeBilled,
tenantSkillHasBeenBilled,
updateInvoicePdf,
} from "./db";
import { listTenants } from "./k8s";
import { getTeamSpendLogsV2 } from "./litellm";
import { getUsage as getThreemaUsage } from "./threema-relay";
import { renderInvoicePdf } from "./billing-pdf";
import { sendInvoiceIssuedEmail } from "./email";
import { formatLineDescription } from "./billing-i18n";
// ---------------------------------------------------------------------------
// Period helpers
// ---------------------------------------------------------------------------
/**
* Returns the [periodStart, periodEnd] inclusive calendar dates for
* the given month, plus the count of days in the month.
*
* Dates returned as ISO `YYYY-MM-DD` strings (no time). Convertible
* to UTC midnight via `new Date(`${date}T00:00:00Z`)`.
*/
export function monthBounds(year: number, month: number): {
periodStart: string;
periodEnd: string;
daysInMonth: number;
} {
if (month < 1 || month > 12) throw new Error(`Invalid month: ${month}`);
const start = new Date(Date.UTC(year, month - 1, 1));
// Day 0 of next month = last day of this month
const end = new Date(Date.UTC(year, month, 0));
return {
periodStart: start.toISOString().split("T")[0],
periodEnd: end.toISOString().split("T")[0],
daysInMonth: end.getUTCDate(),
};
}
function isoDate(d: Date): string {
return d.toISOString().split("T")[0];
}
function dueDate(periodEnd: string, netDays: number = 30): string {
// due_at = period_end + netDays
const d = new Date(`${periodEnd}T00:00:00Z`);
d.setUTCDate(d.getUTCDate() + netDays);
return isoDate(d);
}
// ---------------------------------------------------------------------------
// Day-set computation (calendar-day model, UTC)
// ---------------------------------------------------------------------------
/**
* Iterates UTC calendar days in [periodStart, periodEnd] inclusive.
* Yields { date: 'YYYY-MM-DD', dayStartMs, dayEndMs } where dayEnd
* is exclusive (next-day-midnight UTC).
*/
function* iterDays(periodStart: string, periodEnd: string) {
const start = new Date(`${periodStart}T00:00:00Z`).getTime();
const end = new Date(`${periodEnd}T00:00:00Z`).getTime();
for (let t = start; t <= end; t += 86_400_000) {
yield {
date: isoDate(new Date(t)),
dayStartMs: t,
dayEndMs: t + 86_400_000,
};
}
}
/**
* Was the tenant "running" (created, not deleted, not suspended) at
* any moment in the half-open interval [dayStartMs, dayEndMs)?
*
* Inputs: tenant lifecycle and the timeline of suspension events
* sorted ascending by occurredAt.
*
* The state-at-day-start is reconstructed from suspension events
* BEFORE the day. If the count of suspension events before the day
* is odd, the tenant was suspended at day start (because we record
* suspend then resume, so an odd prefix-count means the last
* recorded transition is "suspended"). This is robust as long as
* events are correctly ordered.
*
* Actually we use the actual event kinds from the events list,
* not the parity heuristic — the heuristic is documentation for
* intuition.
*/
function activeDuringDay(
lifecycle: TenantBillingLifecycle,
suspensionEvents: TenantSuspensionEvent[],
dayStartMs: number,
dayEndMs: number
): boolean {
// Lifecycle gate: tenant must have existed during some part of the day.
const createdMs = new Date(lifecycle.createdAt).getTime();
const deletedMs = lifecycle.deletedAt
? new Date(lifecycle.deletedAt).getTime()
: Infinity;
if (createdMs >= dayEndMs) return false;
if (deletedMs <= dayStartMs) return false;
// Effective existence window within this day
const existsFrom = Math.max(createdMs, dayStartMs);
const existsTo = Math.min(deletedMs, dayEndMs);
if (existsFrom >= existsTo) return false;
// Determine suspended state at existsFrom by replaying events.
// Initial state at lifecycle.createdAt is 'running' (we don't
// record an explicit 'created → running' event; this is the
// implicit baseline).
let suspended = false;
for (const e of suspensionEvents) {
const ts = new Date(e.occurredAt).getTime();
if (ts > existsFrom) break;
suspended = e.eventKind === "suspended";
}
// Walk events from existsFrom to existsTo. If at any moment the
// tenant is running, the day counts.
if (!suspended) return true;
for (const e of suspensionEvents) {
const ts = new Date(e.occurredAt).getTime();
if (ts <= existsFrom) continue;
if (ts >= existsTo) break;
if (e.eventKind === "resumed") return true;
}
return false;
}
/**
* Was the skill 'enabled' at any moment in the day?
*
* Same shape as activeDuringDay but driven by skill events instead
* of suspension events.
*
* Important: callers must include events from before periodStart in
* `prevState` (state at day start), since a skill enabled three
* months ago and never disabled has no events in the billing
* window but is still enabled.
*/
function skillActiveDuringDay(
events: TenantSkillEvent[],
initiallyEnabled: boolean,
dayStartMs: number,
dayEndMs: number
): boolean {
let enabled = initiallyEnabled;
// First, replay events that occurred AT OR BEFORE dayStartMs to
// get the state at day start.
for (const e of events) {
const ts = new Date(e.occurredAt).getTime();
if (ts > dayStartMs) break;
enabled = e.eventKind === "enabled";
}
if (enabled) return true;
// Walk events in [dayStart, dayEnd). If any 'enabled' event
// appears, the day counts.
for (const e of events) {
const ts = new Date(e.occurredAt).getTime();
if (ts <= dayStartMs) continue;
if (ts >= dayEndMs) break;
if (e.eventKind === "enabled") return true;
}
return false;
}
// ---------------------------------------------------------------------------
// Rounding
// ---------------------------------------------------------------------------
/** Round to 2dp, half-up. */
function round2(n: number): number {
return Math.round(n * 100) / 100;
}
// ---------------------------------------------------------------------------
// VAT logic
// ---------------------------------------------------------------------------
const EU_COUNTRIES = new Set([
"AT", "BE", "BG", "HR", "CY", "CZ", "DK", "EE", "FI", "FR",
"DE", "GR", "HU", "IE", "IT", "LV", "LT", "LU", "MT", "NL",
"PL", "PT", "RO", "SK", "SI", "ES", "SE",
]);
/**
* Determine VAT rate from billing address and the platform default.
* See README for the legal interpretation; this implements the
* defaults you confirmed:
*
* - CH or LI: platform_pricing.vat_rate_chli (default 8.10)
* - EU + VAT number: 0% (reverse charge — B2B)
* - EU without VAT: CH MWST (B2C consumer, we charge our rate)
* - other: 0% (export of services)
*/
function vatRateForAddress(
snapshot: InvoiceBillingSnapshot,
platformPricing: PlatformPricing
): { rate: number; note: string | null } {
const country = snapshot.country?.toUpperCase().trim() ?? "";
if (country === "CH" || country === "LI") {
return { rate: platformPricing.vatRateChli, note: null };
}
if (EU_COUNTRIES.has(country)) {
if (snapshot.vatNumber && snapshot.vatNumber.trim().length > 0) {
return {
rate: 0,
note:
"Steuerschuldnerschaft des Leistungsempfängers / Reverse charge — VAT to be accounted for by the recipient.",
};
}
return { rate: platformPricing.vatRateChli, note: null };
}
return { rate: 0, note: "Export of services — VAT not applicable." };
}
// ---------------------------------------------------------------------------
// Locale default
// ---------------------------------------------------------------------------
/**
* Pick a default invoice locale from the billing country. Admins
* can override at generation time. We default to German for
* CH/LI/AT/DE; French for FR/BE/LU; Italian for IT; English
* otherwise.
*/
export function defaultLocaleForCountry(country: string): string {
const c = (country || "").toUpperCase().trim();
if (["CH", "LI", "AT", "DE"].includes(c)) return "de";
if (["FR", "BE", "LU"].includes(c)) return "fr";
if (c === "IT") return "it";
return "en";
}
// ---------------------------------------------------------------------------
// Tenant signal collectors
// ---------------------------------------------------------------------------
/**
* Sum AI usage spend for a tenant over the billing period via
* LiteLLM. Returns the CHF total (already in CHF — LiteLLM stores
* costs after the platform's USD→CHF conversion) and the request
* count for the metadata.
*
* Tolerates missing litellmTeamId on the tenant: such tenants are
* skipped and the warning is surfaced upstream.
*/
async function collectAiUsage(
tenant: PiecedTenant,
periodStart: string,
periodEnd: string
): Promise<{ spendChf: number; requestCount: number } | null> {
const teamId = tenant.status?.litellmTeamId;
if (!teamId) return null;
const keyAlias = tenant.metadata.name;
let spendChf = 0;
let requestCount = 0;
let page = 1;
// 50-page cap matches the existing usage route's defensive cap.
while (page <= 50) {
const result = await getTeamSpendLogsV2(
teamId,
periodStart,
periodEnd,
page,
100,
keyAlias
);
const rows: any[] = result.data ?? [];
for (const r of rows) {
spendChf += Number(r.spend ?? 0);
requestCount += 1;
}
if (page >= (result.total_pages || 1)) break;
page++;
}
return { spendChf: round2(spendChf), requestCount };
}
/**
* Sum Threema messages (in + out) for the tenant over the period.
* Returns null if the relay refuses or the tenant has no Threema
* package — billing is skipped silently in that case.
*/
async function collectThreemaUsage(
tenant: PiecedTenant,
periodStart: string,
periodEnd: string
): Promise<{ inCount: number; outCount: number } | null> {
const packages = tenant.spec.packages ?? [];
if (!packages.includes("threema")) return null;
// threema-relay.getUsage takes Date params, not strings, and
// returns a discriminated RelayResult<UsageBreakdown> — the
// `ok` discriminant must be checked before reading the totals.
// Period end is exclusive in the relay's API; pass the next-day
// midnight UTC to capture the full last day of the period.
const from = new Date(`${periodStart}T00:00:00Z`);
const to = new Date(`${periodEnd}T00:00:00Z`);
to.setUTCDate(to.getUTCDate() + 1);
const result = await getThreemaUsage(tenant.metadata.name, from, to).catch(
() => null
);
if (!result || !result.ok) return null;
return {
inCount: Number(result.totals?.in ?? 0),
outCount: Number(result.totals?.out ?? 0),
};
}
// ---------------------------------------------------------------------------
// Per-tenant line builders
// ---------------------------------------------------------------------------
async function buildTenantLines(opts: {
tenant: PiecedTenant;
periodStart: string;
periodEnd: string;
daysInMonth: number;
platformPricing: PlatformPricing;
skillPricing: SkillPricing[];
locale: string;
warnings: string[];
displayOrderOffset: number;
}): Promise<Omit<InvoiceLine, "id" | "invoiceId">[]> {
const {
tenant,
periodStart,
periodEnd,
daysInMonth,
platformPricing,
skillPricing,
locale,
warnings,
} = opts;
let displayOrder = opts.displayOrderOffset;
const tenantName = tenant.metadata.name;
const lines: Omit<InvoiceLine, "id" | "invoiceId">[] = [];
// Lifecycle & suspension events — required for monthly proration.
const lifecycle = await getTenantBillingLifecycle(tenantName);
if (!lifecycle) {
warnings.push(
`Tenant "${tenantName}" has no billing lifecycle row — run the Phase 1 backfill.`
);
return lines;
}
// Period interval in millis (extended by one day on each side as
// buffer for events that occur at month boundaries).
const periodStartMs = new Date(`${periodStart}T00:00:00Z`).getTime();
const periodEndMs = new Date(`${periodEnd}T00:00:00Z`).getTime() + 86_400_000;
const suspensionEvents = await listSuspensionEventsForTenant(
tenantName,
new Date(periodStartMs - 365 * 86_400_000), // look back a year for state-at-start
new Date(periodEndMs)
);
// --- tenant_monthly (prorated, suspended days excluded) -------------------
if (platformPricing.tenantMonthlyFeeChf > 0) {
let billableDays = 0;
let suspendedDays = 0;
for (const day of iterDays(periodStart, periodEnd)) {
if (activeDuringDay(lifecycle, suspensionEvents, day.dayStartMs, day.dayEndMs)) {
billableDays++;
} else {
// Distinguish "not yet existed / deleted" from "suspended"
// for the metadata audit trail. Cheap re-check.
const createdMs = new Date(lifecycle.createdAt).getTime();
const deletedMs = lifecycle.deletedAt
? new Date(lifecycle.deletedAt).getTime()
: Infinity;
if (createdMs < day.dayEndMs && deletedMs > day.dayStartMs) {
suspendedDays++;
}
}
}
if (billableDays > 0) {
const unit = platformPricing.tenantMonthlyFeeChf / daysInMonth;
const amount = round2(unit * billableDays);
const metadata = {
billable_days: billableDays,
suspended_days: suspendedDays,
days_in_month: daysInMonth,
};
lines.push({
tenantName,
kind: "tenant_monthly",
description: formatLineDescription(
{ kind: "tenant_monthly", tenantName, metadata },
locale
),
quantity: billableDays,
unitLabel: "days",
unitPriceChf: round2(unit * 1e5) / 1e5,
amountChf: amount,
metadata,
displayOrder: displayOrder++,
});
}
}
// --- tenant_setup (first invoice only) -----------------------------------
if (platformPricing.tenantSetupFeeChf > 0) {
const alreadyBilled = await tenantHasSetupFeeBilled(tenantName);
if (!alreadyBilled) {
lines.push({
tenantName,
kind: "tenant_setup",
description: formatLineDescription(
{ kind: "tenant_setup", tenantName, metadata: null },
locale
),
quantity: 1,
unitLabel: null,
unitPriceChf: platformPricing.tenantSetupFeeChf,
amountChf: round2(platformPricing.tenantSetupFeeChf),
metadata: null,
displayOrder: displayOrder++,
});
}
}
// --- ai_usage --------------------------------------------------------------
const aiUsage = await collectAiUsage(tenant, periodStart, periodEnd).catch(
(e) => {
warnings.push(
`AI usage fetch failed for ${tenantName}: ${e instanceof Error ? e.message : String(e)}`
);
return null;
}
);
if (aiUsage === null && tenant.status?.litellmTeamId) {
// teamId exists but fetch returned null — already warned above
} else if (aiUsage === null) {
warnings.push(
`Tenant ${tenantName} has no LiteLLM team yet — AI usage skipped.`
);
} else if (aiUsage.spendChf > 0) {
const aiMetadata = {
litellm_key_alias: tenantName,
spend_chf: aiUsage.spendChf,
requests: aiUsage.requestCount,
};
lines.push({
tenantName,
kind: "ai_usage",
description: formatLineDescription(
{ kind: "ai_usage", tenantName, metadata: aiMetadata },
locale
),
quantity: 1,
unitLabel: null,
unitPriceChf: aiUsage.spendChf,
amountChf: aiUsage.spendChf,
metadata: aiMetadata,
displayOrder: displayOrder++,
});
}
// --- threema_messages -----------------------------------------------------
if (platformPricing.threemaMessageChf > 0) {
const threema = await collectThreemaUsage(tenant, periodStart, periodEnd);
if (threema && (threema.inCount + threema.outCount) > 0) {
const total = threema.inCount + threema.outCount;
const threemaMetadata = {
in_count: threema.inCount,
out_count: threema.outCount,
total_count: total,
};
lines.push({
tenantName,
kind: "threema_messages",
description: formatLineDescription(
{ kind: "threema_messages", tenantName, metadata: threemaMetadata },
locale
),
quantity: total,
unitLabel: "msgs",
unitPriceChf: platformPricing.threemaMessageChf,
amountChf: round2(total * platformPricing.threemaMessageChf),
metadata: threemaMetadata,
displayOrder: displayOrder++,
});
}
}
// --- skill_usage ----------------------------------------------------------
// For each priced skill, count distinct UTC days the skill was
// enabled during the period.
if (skillPricing.length > 0) {
// Fetch all skill events for the tenant within the period plus
// a long lookback so we can determine state-at-period-start.
// The state-at-day-start logic in skillActiveDuringDay walks
// these events forward.
const allEvents = await listSkillEventsForTenant(
tenantName,
new Date(0),
new Date(periodEndMs)
);
for (const sp of skillPricing) {
const skillEvents = allEvents.filter((e) => e.skillId === sp.skillId);
// Skip cheaply if no events ever existed for this skill on
// this tenant.
if (skillEvents.length === 0) continue;
// Initial state assumption: false. The very first event is
// always 'enabled' (we only record toggles, and the implicit
// pre-toggle state for a never-seen skill is 'disabled').
let billableDays = 0;
for (const day of iterDays(periodStart, periodEnd)) {
if (skillActiveDuringDay(skillEvents, false, day.dayStartMs, day.dayEndMs)) {
billableDays++;
}
}
if (billableDays > 0) {
// Setup fee fires once per (tenant, skill) — before the
// usage line so it appears above it on the PDF.
if (sp.setupFeeChf > 0) {
const alreadyBilled = await tenantSkillHasBeenBilled(
tenantName,
sp.skillId
);
if (!alreadyBilled) {
const setupMetadata = { skill_id: sp.skillId };
lines.push({
tenantName,
kind: "skill_setup",
description: formatLineDescription(
{ kind: "skill_setup", tenantName, metadata: setupMetadata },
locale
),
quantity: 1,
unitLabel: null,
unitPriceChf: sp.setupFeeChf,
amountChf: round2(sp.setupFeeChf),
metadata: setupMetadata,
displayOrder: displayOrder++,
});
}
}
const skillMetadata = {
skill_id: sp.skillId,
billable_days: billableDays,
event_count: skillEvents.length,
};
lines.push({
tenantName,
kind: "skill_usage",
description: formatLineDescription(
{ kind: "skill_usage", tenantName, metadata: skillMetadata },
locale
),
quantity: billableDays,
unitLabel: "days",
unitPriceChf: sp.dailyPriceChf,
amountChf: round2(billableDays * sp.dailyPriceChf),
metadata: skillMetadata,
displayOrder: displayOrder++,
});
}
}
}
return lines;
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
export async function computeInvoiceDraft(opts: {
zitadelOrgId: string;
year: number;
month: number;
locale?: string;
paymentMethod?: InvoicePaymentMethod;
}): Promise<InvoiceDraft> {
const { zitadelOrgId, year, month } = opts;
const { periodStart, periodEnd, daysInMonth } = monthBounds(year, month);
const warnings: string[] = [];
// 1. Billing address. Required — without it we can't produce a
// valid invoice.
const orgBilling = await getOrgBilling(zitadelOrgId);
if (!orgBilling) {
throw new Error(
`Org ${zitadelOrgId} has no billing address on file. ` +
`The customer must complete /settings/billing before an invoice can be issued.`
);
}
const snapshot: InvoiceBillingSnapshot = {
companyName: orgBilling.companyName,
contactName: orgBilling.contactName ?? null,
streetAddress: orgBilling.streetAddress,
postalCode: orgBilling.postalCode,
city: orgBilling.city,
country: orgBilling.country,
vatNumber: orgBilling.vatNumber ?? null,
billingEmail: orgBilling.billingEmail,
notes: orgBilling.notes ?? null,
};
// 2. Platform pricing + skill prices.
const platformPricing = await getPlatformPricing();
const skillPricing = await listSkillPricing();
// 3. Find all tenants for this org. We list from K8s (source of
// truth) and filter by the zitadel-org-id label.
const allTenants = await listTenants();
const orgTenants = allTenants.filter(
(t) => t.metadata.labels?.["pieced.ch/zitadel-org-id"] === zitadelOrgId
);
if (orgTenants.length === 0) {
warnings.push(`No tenants found for org ${zitadelOrgId}.`);
}
// 4. Build lines, grouped per tenant (display order preserved).
// Locale must be resolved before line construction since the
// descriptions are localized at compute time.
const locale = opts.locale ?? defaultLocaleForCountry(snapshot.country);
const lines: Omit<InvoiceLine, "id" | "invoiceId">[] = [];
let nextDisplayOrder = 0;
// Sort tenants by name for stable line ordering across regenerations.
orgTenants.sort((a, b) => a.metadata.name.localeCompare(b.metadata.name));
for (const tenant of orgTenants) {
const tenantLines = await buildTenantLines({
tenant,
periodStart,
periodEnd,
daysInMonth,
platformPricing,
skillPricing,
locale,
warnings,
displayOrderOffset: nextDisplayOrder,
});
lines.push(...tenantLines);
nextDisplayOrder += tenantLines.length;
}
// 5. Subtotal & VAT.
const subtotal = round2(lines.reduce((acc, l) => acc + l.amountChf, 0));
const vat = vatRateForAddress(snapshot, platformPricing);
const vatAmount = round2((subtotal * vat.rate) / 100);
const total = round2(subtotal + vatAmount);
if (vat.note) warnings.push(vat.note);
// 6. Payment method: prefer pay-by-invoice if the admin enabled
// it for the org, otherwise default to invoice. Card payment
// is wired in Phase 4 — for Phase 2 every invoice is 'invoice'.
const orgConfig = await getOrgBillingConfig(zitadelOrgId);
const paymentMethod: InvoicePaymentMethod =
opts.paymentMethod ?? (orgConfig.payByInvoice ? "invoice" : "invoice");
return {
zitadelOrgId,
periodStart,
periodEnd,
dueAt: dueDate(periodEnd, 30),
locale,
paymentMethod,
billingSnapshot: snapshot,
lines,
subtotalChf: subtotal,
vatRate: vat.rate,
vatAmountChf: vatAmount,
totalChf: total,
warnings,
};
}
/**
* Compute + render + persist in one step. If dryRun is true, the
* draft is returned without persisting and no PDF is rendered (the
* preview UI hits this).
*/
export async function generateInvoice(opts: {
zitadelOrgId: string;
year: number;
month: number;
locale?: string;
dryRun?: boolean;
}): Promise<{ draft: InvoiceDraft; invoice: Invoice | null }> {
const draft = await computeInvoiceDraft(opts);
if (opts.dryRun) {
return { draft, invoice: null };
}
// Render the PDF first — if it fails, we never touch the DB.
// The PDF render needs the invoice number, which is allocated
// inside createInvoice's transaction. To keep the PDF rendering
// outside the DB transaction (it can be slow), we render with a
// placeholder number, allocate the real number inside the tx,
// then re-render? No — instead we generate a temporary draft
// number for the PDF and accept that the displayed number on
// the PDF matches what we'll persist (because the allocator is
// serialized).
//
// Practical approach: render the PDF inside createInvoice's tx,
// immediately after allocation. This is fine because react-pdf
// is reasonably fast (~50200 ms for a typical invoice) and
// happens once per invoice.
//
// To avoid restructuring createInvoice, we do this in two
// passes: (1) reserve a number via createInvoice with a
// placeholder PDF; (2) render with the real number; (3) UPDATE
// pdf_data. The trade-off is two write trips but keeps the code
// shape simple. We accept it.
//
// Reasoning behind two-pass: if PDF render is moved inside the
// tx and fails (font missing, etc.), the allocated counter rolls
// back — good. But it also means the connection is held during
// render. At v1 scale that's fine; the choice is reversible.
// Pass 1: allocate number + persist with empty PDF.
const placeholder = await createInvoice(draft, null, null);
try {
const pdfBuffer = await renderInvoicePdf(
placeholder,
draft.lines.map((l, i) => ({
...l,
id: `tmp-${i}`,
invoiceId: placeholder.id,
}))
);
const filename = `${placeholder.invoiceNumber}.pdf`;
// Pass 2: store the PDF bytes.
await updateInvoicePdf(placeholder.id, pdfBuffer, filename);
const finalInvoice = await getInvoiceById(placeholder.id);
// Phase 3: best-effort notification to the billing contact.
// We send AFTER the PDF is fully persisted (so the deep link
// in the email immediately resolves to a downloadable PDF) but
// BEFORE returning, since the cron caller doesn't otherwise
// know to trigger this. Failure is logged, never thrown — a
// mail-server hiccup must not roll back an issued invoice.
// The recipient is the billing email captured in the invoice
// snapshot (immutable; reflects who was on file at issue time).
try {
const settled = finalInvoice ?? placeholder;
const snapshot = settled.billingSnapshot;
if (snapshot.billingEmail) {
const supportedLocales: Array<"en" | "de" | "fr" | "it"> = [
"en", "de", "fr", "it",
];
const locale = supportedLocales.includes(settled.locale as any)
? (settled.locale as "en" | "de" | "fr" | "it")
: "de";
await sendInvoiceIssuedEmail({
to: snapshot.billingEmail,
contactName: snapshot.companyName, // no separate contact-name field
companyName: snapshot.companyName,
invoiceNumber: settled.invoiceNumber,
totalChf: settled.totalChf,
currency: "CHF",
dueAt: settled.dueAt,
lineCount: draft.lines.length,
periodStart: settled.periodStart,
periodEnd: settled.periodEnd,
locale,
});
} else {
console.warn(
`Invoice ${settled.invoiceNumber} issued but billing snapshot has no email — notification skipped.`
);
}
} catch (e) {
console.error(
`Invoice ${placeholder.invoiceNumber} issued; notification email failed:`,
e
);
}
return { draft, invoice: finalInvoice ?? placeholder };
} catch (e) {
// Render failed — leave the persisted row in place so admin can
// inspect it, but surface the error.
throw new Error(
`Invoice ${placeholder.invoiceNumber} persisted but PDF rendering failed: ${
e instanceof Error ? e.message : String(e)
}. Use the admin "delete invoice" tool to clean up if needed.`
);
}
}

360
src/lib/cron.ts Normal file
View File

@@ -0,0 +1,360 @@
/**
* Phase 5 — Automated billing cron logic.
*
* This module hosts the two sweeps:
* - runMonthlyIssuance() — invoked monthly to generate invoices
* for orgs opted into auto-issuance. Idempotent via the
* uniq_invoices_org_period constraint on invoices: a re-run
* for an org that's already been billed for the target period
* gets caught as a duplicate and counted as a skip, not a
* failure.
* - runReminderSweep() — invoked daily. Walks open/overdue
* invoices, sends the appropriate reminder level (1/2/3) once
* per invoice via the invoice_reminders unique-key constraint.
*
* Both entry points return a summary {success, failure, skipped}
* that the caller persists via finishCronRun(). The shared
* structure means the HTTP routes (machine + admin variants) are
* trivial wrappers.
*
* Time-of-month math is timezone-aware: we read the calendar in
* Europe/Zurich rather than UTC, because the K8s CronJob schedules
* at 00:30 local time on the 1st — UTC at that moment is still in
* the previous month, and a naive `getUTCMonth() - 1` would bill
* the wrong period.
*/
import {
finishCronRun,
getLastSuccessfulCronRuns,
getOrgBilling,
getReminderLevelsSent,
listAutoIssueOrgIds,
listInvoicesPendingReminders,
recordReminderSent,
startCronRun,
syncOverdueInvoices,
} from "./db";
import { generateInvoice } from "./billing";
import { sendInvoiceReminderEmail } from "./email";
// The org_billing snapshot's company_name field doubles as the
// recipient name when no separate "billing contact" exists in
// our schema. Same convention as Phase 3's issuance email.
// All cron timing assumes Switzerland's calendar — the operator,
// the customers, and the legal basis (Swiss MWST) are all here.
const TZ = "Europe/Zurich";
export type CronSummary = {
successCount: number;
failureCount: number;
skippedCount: number;
errorDetails: Array<{
orgId?: string;
invoiceId?: string;
reason: string;
}>;
};
// ---------------------------------------------------------------------------
// Monthly issuance
// ---------------------------------------------------------------------------
/**
* The (year, month) of the calendar month that ended JUST BEFORE
* `now` in the configured timezone. This is what the issuance
* sweep bills.
*
* Reading the local-time calendar avoids a UTC-vs-local off-by-one
* when the sweep runs at 00:30 Zurich and UTC is still in the
* previous month.
*/
export function previousLocalMonth(
now: Date = new Date()
): { year: number; month: number } {
const fmt = new Intl.DateTimeFormat("en-CA", {
timeZone: TZ,
year: "numeric",
month: "2-digit",
});
const parts = fmt.formatToParts(now);
const year = Number(parts.find((p) => p.type === "year")!.value);
const month = Number(parts.find((p) => p.type === "month")!.value);
if (month === 1) return { year: year - 1, month: 12 };
return { year, month: month - 1 };
}
export async function runMonthlyIssuance(opts: {
triggeredBy: string;
/** Override target year/month — defaults to previous local month. */
year?: number;
month?: number;
}): Promise<{ runId: string; summary: CronSummary }> {
const target =
opts.year && opts.month
? { year: opts.year, month: opts.month }
: previousLocalMonth();
const runId = await startCronRun("monthly_issue", opts.triggeredBy);
const summary: CronSummary = {
successCount: 0,
failureCount: 0,
skippedCount: 0,
errorDetails: [],
};
try {
const orgIds = await listAutoIssueOrgIds();
for (const orgId of orgIds) {
try {
const orgBilling = await getOrgBilling(orgId);
if (!orgBilling) {
// Auto-issue is enabled but billing details are missing.
// Skip rather than fail — the admin needs to complete the
// address before invoicing can succeed.
summary.skippedCount += 1;
summary.errorDetails.push({
orgId,
reason: "org_billing not configured",
});
continue;
}
// Derive invoice locale from the org's country. PieCed is
// Swiss-default; CH/LI/AT/DE customers get the German PDF,
// FR/BE/LU customers get French, IT customers get Italian,
// anything else falls through to English. Customers needing
// a different locale can still trigger a manual issuance
// with an explicit override from the admin UI.
const locale = pickLocaleForCountry(orgBilling.country);
const { invoice } = await generateInvoice({
zitadelOrgId: orgId,
year: target.year,
month: target.month,
locale,
});
if (invoice) {
summary.successCount += 1;
} else {
// dryRun path — shouldn't happen in production. Defensive.
summary.skippedCount += 1;
}
} catch (e: any) {
// The uniqueness constraint on (zitadel_org_id, period_start)
// surfaces as "An invoice already exists for this org and
// billing period" from createInvoice. Re-running the cron
// mid-month or after a partial completion is therefore safe:
// already-billed orgs end up as skipped, not failed.
const msg = String(e?.message ?? e);
const isAlreadyIssued = /already exists for this org and billing period/i.test(
msg
);
if (isAlreadyIssued) {
summary.skippedCount += 1;
} else {
summary.failureCount += 1;
summary.errorDetails.push({ orgId, reason: msg });
console.error(
`runMonthlyIssuance: org ${orgId} failed:`,
e
);
}
}
}
await finishCronRun(runId, summary);
return { runId, summary };
} catch (e) {
// Catastrophic — the sweep itself failed (DB down, etc).
summary.failureCount += 1;
summary.errorDetails.push({
reason: `sweep aborted: ${e instanceof Error ? e.message : String(e)}`,
});
await finishCronRun(runId, summary).catch(() => undefined);
throw e;
}
}
// ---------------------------------------------------------------------------
// Reminder sweep
// ---------------------------------------------------------------------------
/**
* Which reminder level (if any) is due now for this invoice?
*
* Logic:
* - days_past_due >= 30 AND level 3 not yet sent → 3 (final)
* - else days_past_due >= 14 AND level 2 not yet sent → 2
* - else days_past_due >= 7 AND level 1 not yet sent → 1
* - else → null (nothing to do this run)
*
* One reminder per cron run per invoice — highest applicable
* un-sent level wins. If a customer fell behind quickly and is
* already 35 days past due without ever having received levels
* 1 or 2 (e.g. the cron was broken for a while), they get level
* 3 directly. We don't backfill lower levels.
*/
function nextReminderLevel(
daysPastDue: number,
sent: Set<number>
): 1 | 2 | 3 | null {
if (daysPastDue >= 30 && !sent.has(3)) return 3;
if (daysPastDue >= 14 && !sent.has(2)) return 2;
if (daysPastDue >= 7 && !sent.has(1)) return 1;
return null;
}
function daysBetween(later: Date, earlier: Date): number {
const ms = later.getTime() - earlier.getTime();
return Math.floor(ms / (1000 * 60 * 60 * 24));
}
/**
* Pick a default invoice locale based on the org's country
* (ISO 3166-1 alpha-2 code from org_billing.country). PieCed is
* primarily a Swiss-German operator; CH/LI/AT/DE get German,
* FR/BE/LU get French, IT gets Italian, anything else falls
* through to English.
*
* This only drives the automated issuance default. Manual
* issuance from the admin UI takes an explicit override.
*/
function pickLocaleForCountry(country: string): "de" | "en" | "fr" | "it" {
const c = country.toUpperCase();
if (["CH", "LI", "AT", "DE"].includes(c)) return "de";
if (["FR", "BE", "LU"].includes(c)) return "fr";
if (c === "IT") return "it";
return "en";
}
export async function runReminderSweep(opts: {
triggeredBy: string;
}): Promise<{ runId: string; summary: CronSummary }> {
const runId = await startCronRun("reminders", opts.triggeredBy);
const summary: CronSummary = {
successCount: 0,
failureCount: 0,
skippedCount: 0,
errorDetails: [],
};
try {
// Flip stale 'open' → 'overdue' first so the listing reflects
// current status, and audit trails stay accurate.
await syncOverdueInvoices().catch((e) => {
console.warn("syncOverdueInvoices failed during reminder sweep:", e);
});
const candidates = await listInvoicesPendingReminders();
const now = new Date();
for (const inv of candidates) {
try {
const sent = await getReminderLevelsSent(inv.id);
const dueAt = new Date(inv.dueAt);
const days = daysBetween(now, dueAt);
const level = nextReminderLevel(days, sent);
if (level === null) {
summary.skippedCount += 1;
continue;
}
const billing = inv.billingSnapshot;
if (!billing.billingEmail) {
summary.skippedCount += 1;
summary.errorDetails.push({
invoiceId: inv.id,
reason: "no billing email on snapshot",
});
continue;
}
const supportedLocales: Array<"de" | "en" | "fr" | "it"> = [
"de", "en", "fr", "it",
];
const locale = supportedLocales.includes(inv.locale as any)
? (inv.locale as "de" | "en" | "fr" | "it")
: "de";
await sendInvoiceReminderEmail({
to: billing.billingEmail,
contactName: billing.companyName,
companyName: billing.companyName,
invoiceNumber: inv.invoiceNumber,
totalChf: inv.totalChf,
currency: "CHF",
dueAt: inv.dueAt,
daysPastDue: days,
level,
locale,
});
// Record AFTER the send. If the SMTP send fails the email
// helper logs and doesn't throw, so we'd still record — but
// that's a tradeoff we accept: at-least-once delivery semantics
// with logged warnings is better than at-most-once where a
// transient failure stops the customer from ever getting
// reminded. If duplicate-reminder fatigue becomes a real
// problem in production, switch to: send first, only record
// on confirmed transporter success.
await recordReminderSent({
invoiceId: inv.id,
level,
sentBy: opts.triggeredBy,
emailSentTo: billing.billingEmail,
});
summary.successCount += 1;
} catch (e: any) {
summary.failureCount += 1;
summary.errorDetails.push({
invoiceId: inv.id,
reason: String(e?.message ?? e),
});
console.error(
`runReminderSweep: invoice ${inv.id} failed:`,
e
);
}
}
await finishCronRun(runId, summary);
return { runId, summary };
} catch (e) {
summary.failureCount += 1;
summary.errorDetails.push({
reason: `sweep aborted: ${e instanceof Error ? e.message : String(e)}`,
});
await finishCronRun(runId, summary).catch(() => undefined);
throw e;
}
}
// ---------------------------------------------------------------------------
// Auth — bearer token for the machine endpoints
// ---------------------------------------------------------------------------
/**
* Constant-time bearer token check. The CRON_BEARER_TOKEN env var
* is injected from OpenBao via the portal-cron K8s Secret. Both
* the CronJob and the portal Deployment reference it; the
* CronJob sends it in the Authorization header, the portal checks
* with timing-safe equals to defeat character-by-character probing.
*/
export function verifyCronBearer(authHeader: string | null): boolean {
if (!authHeader) return false;
const expected = process.env.CRON_BEARER_TOKEN;
if (!expected || expected.length < 16) {
// Treat misconfiguration as a hard refusal so a missing/
// accidentally-empty token doesn't silently grant access.
return false;
}
if (!authHeader.startsWith("Bearer ")) return false;
const got = authHeader.slice("Bearer ".length).trim();
if (got.length !== expected.length) return false;
// Constant-time byte compare. Node's Buffer.compare and the
// crypto.timingSafeEqual function both work, but the latter
// throws on length mismatch; the length pre-check above
// protects against that.
let diff = 0;
for (let i = 0; i < got.length; i++) {
diff |= got.charCodeAt(i) ^ expected.charCodeAt(i);
}
return diff === 0;
}
// Re-export for the admin UI to render "last run X ago" indicators.
export { getLastSuccessfulCronRuns };

File diff suppressed because it is too large Load Diff

View File

@@ -723,3 +723,438 @@ export async function sendSupportAdminNotificationEmail(params: {
console.error("Failed to send admin support notification:", err);
}
}
// ---------------------------------------------------------------------------
// Skill activation requests — Phase 2.5
// ---------------------------------------------------------------------------
//
// Three notifications:
//
// sendSkillActivationAdminNotification — to ADMIN_NOTIFICATION_EMAIL
// when a customer requests a
// flagged skill.
//
// sendSkillActivationApprovalEmail — to the customer, on approve.
//
// sendSkillActivationRejectionEmail — to the customer, on reject,
// including the admin's reason.
//
// All three follow the existing patterns in this file (HTML + plaintext,
// escaped vars, best-effort with errors logged not thrown).
/**
* Notify admin (ADMIN_NOTIFICATION_EMAIL) that a customer has
* requested activation of a manual-setup skill. The skill name +
* tenant + requester are all included so admin can act without
* loading the portal.
*/
export async function sendSkillActivationAdminNotification(params: {
tenantName: string;
skillId: string;
skillName: string;
requesterEmail: string;
requesterName: string;
companyName: string | null;
}): Promise<void> {
const adminEmail = process.env.ADMIN_NOTIFICATION_EMAIL;
if (!adminEmail) return;
const safeTenant = escapeHtml(params.tenantName);
const safeSkillId = escapeHtml(params.skillId);
const safeSkillName = escapeHtml(params.skillName);
const safeRequester = escapeHtml(params.requesterName);
const safeRequesterEmail = escapeHtml(params.requesterEmail);
const safeCompany = params.companyName
? escapeHtml(params.companyName)
: "—";
try {
await getTransporter().sendMail({
from: getFrom(),
to: adminEmail,
subject: `[PieCed] Skill activation requested — ${params.skillName} on ${params.tenantName}`,
text: [
"A customer has requested activation of a manual-setup skill.",
"",
`Skill: ${params.skillName} (${params.skillId})`,
`Tenant: ${params.tenantName}`,
`Organization:${params.companyName ?? "—"}`,
`Requested by:${params.requesterName} <${params.requesterEmail}>`,
"",
"Review and act in the admin queue:",
"https://app.pieced.ch/admin/skills/pending",
].join("\n"),
html: `
<div style="font-family: -apple-system, BlinkMacSystemFont, sans-serif; max-width: 560px; padding: 24px; background: #1a1a1a; color: #e5e5e5;">
<h2 style="margin: 0 0 16px; color: #10B981;">Skill activation requested</h2>
<p>A customer has requested activation of a manual-setup skill.</p>
<table style="width:100%; border-collapse: collapse; margin: 12px 0;">
<tr><td style="color:#888; padding:4px 0;">Skill</td><td>${safeSkillName} (<code>${safeSkillId}</code>)</td></tr>
<tr><td style="color:#888; padding:4px 0;">Tenant</td><td><code>${safeTenant}</code></td></tr>
<tr><td style="color:#888; padding:4px 0;">Organization</td><td>${safeCompany}</td></tr>
<tr><td style="color:#888; padding:4px 0;">Requested by</td><td>${safeRequester} &lt;${safeRequesterEmail}&gt;</td></tr>
</table>
<p>
<a href="https://app.pieced.ch/admin/skills/pending" style="display:inline-block; padding:10px 24px; background:#10B981; color:#fff; text-decoration:none; border-radius:8px; font-weight:500;">
Open admin queue
</a>
</p>
<hr style="border:none; border-top:1px solid #333; margin:24px 0;" />
<p style="color:#666; font-size:12px;">PieCed IT — Admin notification</p>
</div>
`,
});
} catch (err) {
console.error("Failed to send skill activation admin notification:", err);
}
}
export async function sendSkillActivationApprovalEmail(params: {
to: string;
contactName: string;
skillName: string;
tenantName: string;
}): Promise<void> {
const safeName = escapeHtml(params.contactName);
const safeSkill = escapeHtml(params.skillName);
const safeTenant = escapeHtml(params.tenantName);
try {
await getTransporter().sendMail({
from: getFrom(),
to: params.to,
subject: `Your skill activation has been approved — ${params.skillName}`,
text: [
`Hello ${params.contactName},`,
"",
`Good news — your request to activate "${params.skillName}" on tenant ${params.tenantName} has been approved and the skill is now live.`,
"",
"You can manage it from your tenant settings.",
"",
"Best regards,",
"PieCed IT",
].join("\n"),
html: `
<div style="font-family: -apple-system, BlinkMacSystemFont, sans-serif; max-width:560px; padding:24px; background:#1a1a1a; color:#e5e5e5;">
<h2 style="margin:0 0 16px; color:#10B981;">Skill approved & activated</h2>
<p>Hello ${safeName},</p>
<p>Your request to activate <strong>${safeSkill}</strong> on tenant <code>${safeTenant}</code> has been approved and the skill is now live.</p>
<p>You can manage it from your tenant settings.</p>
<p>
<a href="https://app.pieced.ch/tenants/${encodeURIComponent(params.tenantName)}" style="display:inline-block; padding:10px 24px; background:#10B981; color:#fff; text-decoration:none; border-radius:8px; font-weight:500;">
Open tenant
</a>
</p>
<hr style="border:none; border-top:1px solid #333; margin:24px 0;" />
<p style="color:#666; font-size:12px;">PieCed IT</p>
</div>
`,
});
} catch (err) {
console.error("Failed to send skill activation approval email:", err);
}
}
export async function sendSkillActivationRejectionEmail(params: {
to: string;
contactName: string;
skillName: string;
tenantName: string;
reason: string;
}): Promise<void> {
const safeName = escapeHtml(params.contactName);
const safeSkill = escapeHtml(params.skillName);
const safeTenant = escapeHtml(params.tenantName);
const safeReason = escapeHtml(params.reason);
try {
await getTransporter().sendMail({
from: getFrom(),
to: params.to,
subject: `Update on your skill activation request — ${params.skillName}`,
text: [
`Hello ${params.contactName},`,
"",
`We were unable to approve your request to activate "${params.skillName}" on tenant ${params.tenantName}.`,
"",
"Reason from our team:",
params.reason,
"",
"You can try again from your tenant settings once the matter is resolved.",
"",
"Best regards,",
"PieCed IT",
].join("\n"),
html: `
<div style="font-family: -apple-system, BlinkMacSystemFont, sans-serif; max-width:560px; padding:24px; background:#1a1a1a; color:#e5e5e5;">
<h2 style="margin:0 0 16px; color:#ef4444;">Activation request not approved</h2>
<p>Hello ${safeName},</p>
<p>We were unable to approve your request to activate <strong>${safeSkill}</strong> on tenant <code>${safeTenant}</code>.</p>
<div style="background:#2a2a2a; border-left:3px solid #ef4444; padding:12px 16px; border-radius:6px; margin:16px 0;">
<p style="color:#ccc; font-size:13px; margin:0;"><strong>Reason from our team:</strong></p>
<p style="color:#aaa; font-size:13px; margin:8px 0 0 0; white-space:pre-wrap;">${safeReason}</p>
</div>
<p>You can try again from your tenant settings once the matter is resolved.</p>
<hr style="border:none; border-top:1px solid #333; margin:24px 0;" />
<p style="color:#666; font-size:12px;">PieCed IT</p>
</div>
`,
});
} catch (err) {
console.error("Failed to send skill activation rejection email:", err);
}
}
// ---------------------------------------------------------------------------
// Invoice issuance — Phase 3
// ---------------------------------------------------------------------------
/**
* Notify the billing contact when a new invoice has been issued.
* Includes a brief summary (total + due date + line count) so the
* recipient can triage without opening the portal, plus a deep
* link to /billing/<invoice number> where they can download the
* PDF. The PDF itself is NOT attached — it lives in the portal,
* keeps mail payloads small, and avoids the audit-trail headache
* of "which copy is authoritative".
*/
export async function sendInvoiceIssuedEmail(params: {
to: string;
contactName: string;
companyName: string;
invoiceNumber: string;
totalChf: number;
currency: string; // "CHF" — passed for future-proofing
dueAt: string; // ISO date
lineCount: number;
periodStart: string; // ISO date
periodEnd: string; // ISO date
locale: "de" | "en" | "fr" | "it";
}): Promise<void> {
// All four locales — the email is sent in the invoice's locale,
// which was frozen at issue time. No fallback to admin's locale.
const L = params.locale;
const subjectsByLocale: Record<typeof L, string> = {
en: `New invoice ${params.invoiceNumber} from PieCed IT — ${params.currency} ${params.totalChf.toFixed(2)}`,
de: `Neue Rechnung ${params.invoiceNumber} von PieCed IT — ${params.currency} ${params.totalChf.toFixed(2)}`,
fr: `Nouvelle facture ${params.invoiceNumber} de PieCed IT — ${params.currency} ${params.totalChf.toFixed(2)}`,
it: `Nuova fattura ${params.invoiceNumber} da PieCed IT — ${params.currency} ${params.totalChf.toFixed(2)}`,
};
const greetingsByLocale: Record<typeof L, string> = {
en: `Hello ${params.contactName},`,
de: `Sehr geehrte/r ${params.contactName},`,
fr: `Bonjour ${params.contactName},`,
it: `Gentile ${params.contactName},`,
};
const introByLocale: Record<typeof L, string> = {
en: `A new invoice has been issued for ${params.companyName}.`,
de: `Für ${params.companyName} wurde eine neue Rechnung ausgestellt.`,
fr: `Une nouvelle facture a été émise pour ${params.companyName}.`,
it: `È stata emessa una nuova fattura per ${params.companyName}.`,
};
const labels: Record<typeof L, Record<string, string>> = {
en: { number: "Invoice", period: "Period", total: "Total", due: "Due by", lines: "Line items", cta: "View invoice & download PDF", signoff: "Best regards", brand: "PieCed IT" },
de: { number: "Rechnung", period: "Zeitraum", total: "Gesamt", due: "Zahlbar bis", lines: "Positionen", cta: "Rechnung ansehen & PDF herunterladen", signoff: "Mit freundlichen Grüssen", brand: "PieCed IT" },
fr: { number: "Facture", period: "Période", total: "Total", due: "À régler avant", lines: "Lignes", cta: "Voir la facture & télécharger le PDF", signoff: "Cordialement", brand: "PieCed IT" },
it: { number: "Fattura", period: "Periodo", total: "Totale", due: "Scadenza", lines: "Voci", cta: "Visualizza fattura & scarica PDF", signoff: "Cordiali saluti", brand: "PieCed IT" },
};
const l = labels[L];
const safeName = escapeHtml(params.contactName);
const safeCompany = escapeHtml(params.companyName);
const safeNumber = escapeHtml(params.invoiceNumber);
const totalFmt = `${params.currency} ${params.totalChf.toFixed(2)}`;
const periodFmt = `${params.periodStart.slice(0, 10)}${params.periodEnd.slice(0, 10)}`;
const dueFmt = params.dueAt.slice(0, 10);
// Both bodies built in the invoice's locale.
const link = `https://app.pieced.ch/billing/${encodeURIComponent(params.invoiceNumber)}`;
try {
await getTransporter().sendMail({
from: getFrom(),
to: params.to,
subject: subjectsByLocale[L],
text: [
greetingsByLocale[L],
"",
introByLocale[L],
"",
`${l.number}: ${params.invoiceNumber}`,
`${l.period}: ${periodFmt}`,
`${l.total}: ${totalFmt}`,
`${l.due}: ${dueFmt}`,
`${l.lines}: ${params.lineCount}`,
"",
`${l.cta}:`,
link,
"",
`${l.signoff},`,
l.brand,
].join("\n"),
html: `
<div style="font-family: -apple-system, BlinkMacSystemFont, sans-serif; max-width: 560px; padding: 24px; background: #1a1a1a; color: #e5e5e5;">
<h2 style="margin: 0 0 16px; color: #10B981;">${escapeHtml(introByLocale[L])}</h2>
<p>${escapeHtml(greetingsByLocale[L])}</p>
<p>${escapeHtml(introByLocale[L])}</p>
<table style="width:100%; border-collapse:collapse; margin:16px 0; font-size:14px;">
<tr><td style="color:#888; padding:6px 0; width:120px;">${l.number}</td><td><strong>${safeNumber}</strong></td></tr>
<tr><td style="color:#888; padding:6px 0;">${l.period}</td><td>${escapeHtml(periodFmt)}</td></tr>
<tr><td style="color:#888; padding:6px 0;">${l.total}</td><td style="color:#10B981; font-weight:600;">${escapeHtml(totalFmt)}</td></tr>
<tr><td style="color:#888; padding:6px 0;">${l.due}</td><td>${escapeHtml(dueFmt)}</td></tr>
<tr><td style="color:#888; padding:6px 0;">${l.lines}</td><td>${params.lineCount}</td></tr>
</table>
<p>
<a href="${link}" style="display:inline-block; padding:10px 24px; background:#10B981; color:#fff; text-decoration:none; border-radius:8px; font-weight:500;">
${l.cta}
</a>
</p>
<hr style="border:none; border-top:1px solid #333; margin:24px 0;" />
<p style="color:#666; font-size:12px;">${l.brand}</p>
</div>
`,
});
} catch (err) {
console.error("Failed to send invoice issued email:", err);
}
}
// ---------------------------------------------------------------------------
// Reminder emails — Phase 5
// ---------------------------------------------------------------------------
/**
* Send a payment reminder for an open/overdue invoice.
*
* Three escalation levels:
* 1 — Gentle nudge: ~7 days past due. Friendly tone, "in case
* you missed it".
* 2 — Firmer reminder: ~14 days past due. Clear that payment is
* outstanding, please pay.
* 3 — Final notice: ~30 days past due. Explicit consequences
* (service may be suspended). Last automated touch — beyond
* this, admin involvement is expected.
*
* Failure is logged, never thrown — the cron sweep must continue
* past a single failed send.
*/
export async function sendInvoiceReminderEmail(params: {
to: string;
contactName: string;
companyName: string;
invoiceNumber: string;
totalChf: number;
currency: string;
dueAt: string;
daysPastDue: number;
level: 1 | 2 | 3;
locale: "de" | "en" | "fr" | "it";
}): Promise<void> {
const L = params.locale;
// Per-locale strings keyed by the three escalation levels.
// Kept inline (rather than the next-intl message files) because
// the email layer doesn't import from React's i18n context.
const SUBJECTS: Record<typeof L, Record<1 | 2 | 3, string>> = {
en: {
1: `Friendly reminder: invoice ${params.invoiceNumber} is overdue`,
2: `Second reminder: invoice ${params.invoiceNumber} is still unpaid`,
3: `Final notice: invoice ${params.invoiceNumber} requires immediate payment`,
},
de: {
1: `Freundliche Erinnerung: Rechnung ${params.invoiceNumber} ist überfällig`,
2: `Zweite Mahnung: Rechnung ${params.invoiceNumber} ist weiterhin unbezahlt`,
3: `Letzte Mahnung: Rechnung ${params.invoiceNumber} erfordert sofortige Zahlung`,
},
fr: {
1: `Rappel amical : la facture ${params.invoiceNumber} est en retard`,
2: `Deuxième rappel : la facture ${params.invoiceNumber} reste impayée`,
3: `Dernier avis : la facture ${params.invoiceNumber} doit être réglée sans délai`,
},
it: {
1: `Promemoria amichevole: la fattura ${params.invoiceNumber} è scaduta`,
2: `Secondo sollecito: la fattura ${params.invoiceNumber} è ancora insoluta`,
3: `Avviso finale: la fattura ${params.invoiceNumber} richiede pagamento immediato`,
},
};
const INTROS: Record<typeof L, Record<1 | 2 | 3, string>> = {
en: {
1: "We noticed this invoice hasn't been settled yet — in case it slipped through.",
2: "This invoice remains unpaid. Please arrange payment at your earliest convenience.",
3: "This invoice is significantly overdue. Service may be suspended if payment is not received promptly.",
},
de: {
1: "Diese Rechnung scheint noch nicht beglichen — falls sie übersehen wurde, möchten wir freundlich daran erinnern.",
2: "Diese Rechnung ist weiterhin unbezahlt. Bitte veranlassen Sie die Zahlung umgehend.",
3: "Diese Rechnung ist erheblich überfällig. Bei nicht zeitnaher Zahlung kann der Dienst ausgesetzt werden.",
},
fr: {
1: "Cette facture n'a pas encore été réglée — au cas où elle vous aurait échappé.",
2: "Cette facture reste impayée. Merci d'effectuer le paiement dans les meilleurs délais.",
3: "Cette facture est en grand retard. Le service pourra être suspendu en l'absence de paiement rapide.",
},
it: {
1: "Questa fattura non risulta ancora saldata — nel caso vi fosse sfuggita.",
2: "Questa fattura risulta ancora insoluta. Si prega di provvedere al pagamento al più presto.",
3: "Questa fattura è significativamente in ritardo. In assenza di pagamento tempestivo il servizio potrà essere sospeso.",
},
};
const LABELS: Record<typeof L, Record<string, string>> = {
en: { num: "Invoice", total: "Total", due: "Due date", days: "Days past due", cta: "View invoice & pay", signoff: "Best regards", brand: "PieCed IT", greeting: "Hello" },
de: { num: "Rechnung", total: "Gesamt", due: "Fälligkeitsdatum", days: "Tage überfällig", cta: "Rechnung ansehen & bezahlen", signoff: "Mit freundlichen Grüssen", brand: "PieCed IT", greeting: "Sehr geehrte/r" },
fr: { num: "Facture", total: "Total", due: "Échéance", days: "Jours de retard", cta: "Voir la facture & payer", signoff: "Cordialement", brand: "PieCed IT", greeting: "Bonjour" },
it: { num: "Fattura", total: "Totale", due: "Scadenza", days: "Giorni di ritardo", cta: "Vedi fattura & paga", signoff: "Cordiali saluti", brand: "PieCed IT", greeting: "Gentile" },
};
const l = LABELS[L];
const safeName = escapeHtml(params.contactName);
const safeCompany = escapeHtml(params.companyName);
const safeNumber = escapeHtml(params.invoiceNumber);
const totalFmt = `${params.currency} ${params.totalChf.toFixed(2)}`;
const dueFmt = params.dueAt.slice(0, 10);
const link = `https://app.pieced.ch/billing/${encodeURIComponent(params.invoiceNumber)}`;
// Final-notice gets red accent; earlier levels keep the brand green.
const accent = params.level === 3 ? "#dc2626" : "#10B981";
try {
await getTransporter().sendMail({
from: getFrom(),
to: params.to,
subject: SUBJECTS[L][params.level],
text: [
`${l.greeting} ${params.contactName},`,
"",
INTROS[L][params.level],
"",
`${l.num}: ${params.invoiceNumber}`,
`${l.total}: ${totalFmt}`,
`${l.due}: ${dueFmt}`,
`${l.days}: ${params.daysPastDue}`,
"",
`${l.cta}: ${link}`,
"",
`${l.signoff},`,
l.brand,
].join("\n"),
html: `
<div style="font-family:-apple-system,BlinkMacSystemFont,sans-serif;max-width:560px;padding:24px;background:#1a1a1a;color:#e5e5e5;">
<h2 style="margin:0 0 16px;color:${accent};">${escapeHtml(SUBJECTS[L][params.level])}</h2>
<p>${l.greeting} ${safeName},</p>
<p>${escapeHtml(INTROS[L][params.level])}</p>
<table style="width:100%;border-collapse:collapse;margin:16px 0;font-size:14px;">
<tr><td style="color:#888;padding:6px 0;width:140px;">${l.num}</td><td><strong>${safeNumber}</strong></td></tr>
<tr><td style="color:#888;padding:6px 0;">${l.total}</td><td style="color:${accent};font-weight:600;">${escapeHtml(totalFmt)}</td></tr>
<tr><td style="color:#888;padding:6px 0;">${l.due}</td><td>${escapeHtml(dueFmt)}</td></tr>
<tr><td style="color:#888;padding:6px 0;">${l.days}</td><td>${params.daysPastDue}</td></tr>
</table>
<p>
<a href="${link}" style="display:inline-block;padding:10px 24px;background:${accent};color:#fff;text-decoration:none;border-radius:8px;font-weight:500;">
${l.cta}
</a>
</p>
<hr style="border:none;border-top:1px solid #333;margin:24px 0;" />
<p style="color:#666;font-size:12px;">${l.brand}</p>
</div>
`,
});
} catch (err) {
console.error(
`Failed to send reminder L${params.level} for invoice ${params.invoiceNumber}:`,
err
);
}
}

View File

@@ -173,3 +173,115 @@ export async function setTenantAnnotation(
}
return res.json() as Promise<PiecedTenant>;
}
// ---------------------------------------------------------------------------
// OpenClaw config ConfigMap helpers (admin-only feature: per-tenant version
// override + platform default).
//
// The ConfigMap lives in the operator's namespace (`pieced-system`). The
// portal's ServiceAccount needs `get/patch` on configmaps in that namespace
// — rules added in the gitops repo.
//
// Tag-only by design — see operator notes for rationale.
// ---------------------------------------------------------------------------
const OPENCLAW_CONFIGMAP_NAME = "pieced-openclaw-config";
/**
* Operator namespace. Reads the env var so the portal can be deployed in
* non-default namespaces without code changes; defaults to "pieced-system"
* matching the operator's chart default.
*/
function getOperatorNamespace(): string {
return process.env.OPERATOR_NAMESPACE ?? "pieced-system";
}
export interface OpenClawDefaults {
/** Image tag (e.g. "2026.4.22"). Empty string means unset. */
defaultTag: string;
}
/**
* Read the platform-default OpenClaw image tag. Returns empty string
* if unset, and `{ defaultTag: "" }` if the ConfigMap doesn't exist yet
* — the operator's built-in fallback is invisible to the portal by
* design (we don't want the UI to claim "current default: 2026.x" when
* it's actually the operator binary's baked-in version; that would be
* misleading once the binary updates).
*/
export async function getOpenClawDefaults(): Promise<OpenClawDefaults> {
const ns = getOperatorNamespace();
const url = `${getBaseUrl()}/api/v1/namespaces/${ns}/configmaps/${OPENCLAW_CONFIGMAP_NAME}`;
const res = await fetch(url, {
headers: { Accept: "application/json", ...getAuthHeaders() },
});
if (res.status === 404) {
return { defaultTag: "" };
}
if (!res.ok) {
const text = await res.text();
const err = new Error(
`K8s GET configmap ${OPENCLAW_CONFIGMAP_NAME}: ${res.status} ${text}`
);
(err as any).statusCode = res.status;
throw err;
}
const cm = (await res.json()) as { data?: Record<string, string> };
return { defaultTag: cm.data?.defaultTag ?? "" };
}
/**
* Update the platform-default OpenClaw image tag. Empty string clears
* the field (operator falls back to its built-in default).
*
* Creates the ConfigMap if it doesn't exist (PATCH on missing resource
* 404s; we retry as POST). Keeps the admin UI usable on a fresh install
* where the helm-shipped CM was deleted or never created.
*/
export async function setOpenClawDefaults(
defaults: OpenClawDefaults
): Promise<OpenClawDefaults> {
const ns = getOperatorNamespace();
const url = `${getBaseUrl()}/api/v1/namespaces/${ns}/configmaps/${OPENCLAW_CONFIGMAP_NAME}`;
const patch = { data: { defaultTag: defaults.defaultTag } };
const res = await fetch(url, {
method: "PATCH",
headers: {
Accept: "application/json",
"Content-Type": "application/merge-patch+json",
...getAuthHeaders(),
},
body: JSON.stringify(patch),
});
if (res.status === 404) {
const createUrl = `${getBaseUrl()}/api/v1/namespaces/${ns}/configmaps`;
const createRes = await fetch(createUrl, {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
...getAuthHeaders(),
},
body: JSON.stringify({
apiVersion: "v1",
kind: "ConfigMap",
metadata: { name: OPENCLAW_CONFIGMAP_NAME, namespace: ns },
data: patch.data,
}),
});
if (!createRes.ok) {
const text = await createRes.text();
throw new Error(
`K8s POST configmap ${OPENCLAW_CONFIGMAP_NAME}: ${createRes.status} ${text}`
);
}
return defaults;
}
if (!res.ok) {
const text = await res.text();
throw new Error(
`K8s PATCH configmap ${OPENCLAW_CONFIGMAP_NAME}: ${res.status} ${text}`
);
}
return defaults;
}

View File

@@ -93,6 +93,94 @@ export async function listTeams(): Promise<any[]> {
return Array.isArray(data) ? data : data?.data ?? data?.teams ?? [];
}
/**
* Find a virtual key on a team by its alias and return its current
* state (token, spend, budget cap, reset cadence). Returns null if
* the alias doesn't match any key on the team.
*
* Why we need this
* ----------------
* Per-tenant budgets live on the virtual key, not the team. The
* portal needs to:
* 1. Display the current key's `max_budget` / `budget_duration` /
* `spend` on the tenant detail page.
* 2. Pass the key's `token` to `/key/update` when the customer
* changes the budget.
*
* The token is opaque to the customer; the operator's
* `FindKeyByAlias` does the same lookup for stale-key cleanup. We
* mirror its API call here.
*/
export async function findKeyByAlias(
teamId: string,
keyAlias: string
): Promise<{
token: string;
spend: number;
maxBudget: number | null;
budgetDuration: string | null;
} | null> {
const data = await litellmFetch(
`/key/list?team_id=${encodeURIComponent(teamId)}&return_full_object=true&include_team_keys=true`
);
const keys: any[] = Array.isArray(data?.keys)
? data.keys
: Array.isArray(data?.data)
? data.data
: Array.isArray(data)
? data
: [];
for (const k of keys) {
if (typeof k !== "object" || k === null) continue;
const alias = k.key_alias ?? k.keyAlias;
if (alias !== keyAlias) continue;
if (typeof k.token !== "string" || !k.token) continue;
return {
token: k.token,
spend: typeof k.spend === "number" ? k.spend : Number(k.spend) || 0,
maxBudget:
typeof k.max_budget === "number"
? k.max_budget
: k.max_budget == null
? null
: Number(k.max_budget) || null,
budgetDuration:
typeof k.budget_duration === "string" ? k.budget_duration : null,
};
}
return null;
}
/**
* Update a virtual key's budget cap and reset duration.
*
* Pass `maxBudget: null` to remove the cap. Pass `budgetDuration:
* null` to make the budget never reset (lifetime cap).
*
* Identified by `key` parameter — accepts either the raw `sk-...`
* token or its hash (LiteLLM accepts both shapes on /key/update).
* The portal flow uses the hash returned by `findKeyByAlias`.
*/
export async function updateKeyBudget(
key: string,
changes: {
maxBudget?: number | null;
budgetDuration?: string | null;
}
): Promise<void> {
const body: Record<string, any> = { key };
if (changes.maxBudget !== undefined) {
body.max_budget = changes.maxBudget;
}
if (changes.budgetDuration !== undefined) {
body.budget_duration = changes.budgetDuration;
}
await litellmFetch("/key/update", {
method: "POST",
body: JSON.stringify(body),
});
}
/**
* Get LiteLLM health status.
*/

View File

@@ -1,9 +1,45 @@
/**
* Portal-side package catalog. Hardcoded mirror of the operator-side
* catalog ConfigMap (deploy/helm/pieced-operator/templates/catalog-cm.yaml).
*
* The two have to stay in sync:
* - `id` here must match the catalog key in the ConfigMap.
* - `secrets[].key` here must match the catalog's env_vars[].secret_key
* so that POST /api/tenants/:name/secrets writes to the same OpenBao
* path the operator's ExternalSecret reads from.
* - `requiresSecrets` is true when the catalog declares any env_var
* that is a secret (vault_path_suffix set, no default value).
*
* Category model (Phase A rework):
* - core — platform-behaviour toggles (heartbeat, cron,
* active-memory, voice). Mostly no secrets. core-voice is
* fully wired (Phase B): toggling installs the STT / TTS /
* Talk surface via the operator's config_patch, routed
* through LiteLLM (pieced-stt, pieced-tts-inbound,
* pieced-tts-talk).
* - channel — messaging integration.
* - skill — ClawHub skill install.
*
* Custom provisioning (Threema):
* The `threema` channel sets `requiresSecrets: false` because its
* credentials are platform-issued, not customer-entered. Enabling
* threema goes through a dedicated endpoint
* (/api/tenants/:name/threema) that mints token + HMAC secret from
* the central pieced-threema-gateway relay and writes them to OpenBao
* at secret/data/tenants/<name>/threema-relay before the package is
* added to spec.packages. Disabling reverses both steps. The
* `customProvisioning` flag here tells the package-card UI to use
* that endpoint instead of the standard /secrets+PATCH dance.
*/
export interface PackageSecretField {
key: string;
labelKey: string;
placeholderKey: string;
}
export type PackageCategory = "core" | "channel" | "skill";
export interface PackageDef {
id: string;
name: string;
@@ -12,10 +48,72 @@ export interface PackageDef {
secrets?: PackageSecretField[];
instructionsKey?: string;
disclaimerKey?: string;
category: "channel" | "skill";
category: PackageCategory;
/**
* When true, enabling/disabling this package goes through
* /api/tenants/:name/<id> (POST/DELETE) instead of the generic
* /secrets+PATCH flow. The handler at that path does platform-side
* provisioning (mint credentials, register with sibling services, etc.)
* that the customer is not aware of.
*/
customProvisioning?: boolean;
/**
* When true, customer-initiated enable requests are routed through
* an admin approval queue (skill_activation_requests) instead of
* being applied immediately. Platform-side manual work (hardware
* provisioning, third-party account setup, DNS, etc.) happens
* between request and approval, so we keep the tenant out of the
* spec until that work is done and the operator would otherwise
* fail to reconcile.
*
* Platform admins bypass the gate (direct PATCH from /admin still
* applies immediately). Disable is always direct — there's no
* gate on turning a skill off.
*
* Orthogonal to `requiresSecrets` and `customProvisioning`. A skill
* can have all three: customer provides credentials, the secrets
* are stored, the activation request lands in the admin queue,
* admin does the manual work, then approves.
*/
requiresManualSetup?: boolean;
}
export const PACKAGE_CATALOG: PackageDef[] = [
// -------------------------------------------------------------------------
// CORE
// -------------------------------------------------------------------------
{
id: "core-heartbeat",
name: "Heartbeat (Proactive Checks)",
descriptionKey: "packages.coreHeartbeat.description",
requiresSecrets: false,
category: "core",
},
{
id: "core-cron",
name: "Scheduled Tasks (Cron)",
descriptionKey: "packages.coreCron.description",
requiresSecrets: false,
category: "core",
},
{
id: "core-active-memory",
name: "Active Memory",
descriptionKey: "packages.coreActiveMemory.description",
requiresSecrets: false,
category: "core",
},
{
id: "core-voice",
name: "Voice Interaction",
descriptionKey: "packages.coreVoice.description",
requiresSecrets: false,
category: "core",
},
// -------------------------------------------------------------------------
// CHANNELS
// -------------------------------------------------------------------------
{
id: "telegram",
name: "Telegram",
@@ -43,42 +141,202 @@ export const PACKAGE_CATALOG: PackageDef[] = [
labelKey: "packages.discord.botTokenLabel",
placeholderKey: "packages.discord.botTokenPlaceholder",
},
// app-id was missing from the portal catalog historically while the
// operator catalog declared DISCORD_APP_ID as a required env var.
// Tenants who enabled Discord ended up with the env var blank
// because the secrets POST never wrote an `app-id` key to OpenBao
// and the operator's ExternalSecret couldn't populate it. Added
// here as part of the Phase A rework to close the alignment gap;
// not strictly secret (the application ID is visible in the bot's
// profile URL) but stored alongside the bot token for convenience.
{
key: "app-id",
labelKey: "packages.discord.appIdLabel",
placeholderKey: "packages.discord.appIdPlaceholder",
},
],
instructionsKey: "packages.discord.instructions",
disclaimerKey: "packages.discord.disclaimer",
category: "channel",
},
{
id: "email",
name: "Email",
descriptionKey: "packages.email.description",
requiresSecrets: true,
secrets: [
{ key: "smtp-host", labelKey: "packages.email.smtpHostLabel", placeholderKey: "packages.email.smtpHostPlaceholder" },
{ key: "smtp-user", labelKey: "packages.email.smtpUserLabel", placeholderKey: "packages.email.smtpUserPlaceholder" },
{ key: "smtp-password", labelKey: "packages.email.smtpPasswordLabel", placeholderKey: "packages.email.smtpPasswordPlaceholder" },
{ key: "imap-host", labelKey: "packages.email.imapHostLabel", placeholderKey: "packages.email.imapHostPlaceholder" },
],
instructionsKey: "packages.email.instructions",
disclaimerKey: "packages.email.disclaimer",
id: "threema",
name: "Threema",
descriptionKey: "packages.threema.description",
// No customer-entered secrets. The token + hmac secret are minted
// server-side by the relay's /admin/tokens endpoint when the
// package is enabled, and stored in OpenBao by the portal. The
// `customProvisioning` flag steers the PackageCard UI through the
// dedicated /api/tenants/:name/threema endpoint instead.
requiresSecrets: false,
customProvisioning: true,
instructionsKey: "packages.threema.instructions",
disclaimerKey: "packages.threema.disclaimer",
category: "channel",
},
// -------------------------------------------------------------------------
// SKILLS
// -------------------------------------------------------------------------
{
id: "web-search",
name: "Web Search",
descriptionKey: "packages.webSearch.description",
id: "git-cli",
name: "Git CLI",
descriptionKey: "packages.gitCli.description",
requiresSecrets: false,
category: "skill",
},
{
id: "document-processing",
name: "Document Processing",
descriptionKey: "packages.documentProcessing.description",
id: "github",
name: "GitHub (gh CLI)",
descriptionKey: "packages.github.description",
requiresSecrets: true,
secrets: [
{
key: "token",
labelKey: "packages.github.tokenLabel",
placeholderKey: "packages.github.tokenPlaceholder",
},
],
instructionsKey: "packages.github.instructions",
category: "skill",
},
{
id: "gitea",
name: "Gitea",
descriptionKey: "packages.gitea.description",
requiresSecrets: true,
secrets: [
{
key: "token",
labelKey: "packages.gitea.tokenLabel",
placeholderKey: "packages.gitea.tokenPlaceholder",
},
],
instructionsKey: "packages.gitea.instructions",
category: "skill",
},
{
id: "whisper-self-hosted",
name: "Whisper (Self-Hosted Transcription)",
descriptionKey: "packages.whisperSelfHosted.description",
requiresSecrets: false,
category: "skill",
},
{
id: "searxng-local-search",
name: "Web Search (SearXNG)",
descriptionKey: "packages.searxngLocalSearch.description",
requiresSecrets: false,
category: "skill",
},
{
id: "gog",
requiresManualSetup: true,
name: "Google Workspace (Gog)",
descriptionKey: "packages.gog.description",
requiresSecrets: true,
secrets: [
{
key: "client-id",
labelKey: "packages.gog.clientIdLabel",
placeholderKey: "packages.gog.clientIdPlaceholder",
},
{
key: "client-secret",
labelKey: "packages.gog.clientSecretLabel",
placeholderKey: "packages.gog.clientSecretPlaceholder",
},
{
key: "refresh-token",
labelKey: "packages.gog.refreshTokenLabel",
placeholderKey: "packages.gog.refreshTokenPlaceholder",
},
],
instructionsKey: "packages.gog.instructions",
disclaimerKey: "packages.gog.disclaimer",
category: "skill",
},
{
id: "mail",
name: "Email (IMAP / SMTP)",
descriptionKey: "packages.mail.description",
requiresSecrets: true,
secrets: [
{
key: "imap-host",
labelKey: "packages.mail.imapHostLabel",
placeholderKey: "packages.mail.imapHostPlaceholder",
},
{
key: "imap-user",
labelKey: "packages.mail.imapUserLabel",
placeholderKey: "packages.mail.imapUserPlaceholder",
},
{
key: "imap-pass",
labelKey: "packages.mail.imapPassLabel",
placeholderKey: "packages.mail.imapPassPlaceholder",
},
{
key: "smtp-host",
labelKey: "packages.mail.smtpHostLabel",
placeholderKey: "packages.mail.smtpHostPlaceholder",
},
{
key: "smtp-user",
labelKey: "packages.mail.smtpUserLabel",
placeholderKey: "packages.mail.smtpUserPlaceholder",
},
{
key: "smtp-pass",
labelKey: "packages.mail.smtpPassLabel",
placeholderKey: "packages.mail.smtpPassPlaceholder",
},
],
instructionsKey: "packages.mail.instructions",
disclaimerKey: "packages.mail.disclaimer",
category: "skill",
},
];
export function getPackageDef(id: string): PackageDef | undefined {
return PACKAGE_CATALOG.find((p) => p.id === id);
}
/**
* IDs of channel-category packages. Derived from the catalog so it
* cannot drift from the source of truth (previously hardcoded as
* `["telegram", "discord", "email"]` in tenants/[name]/page.tsx —
* removed as part of the Phase A package-model rework).
*
* Consumers: tenant detail page (filter spec.packages to channel set
* before rendering the channel-users panel).
*/
export const CHANNEL_PACKAGE_IDS: string[] = PACKAGE_CATALOG
.filter((p) => p.category === "channel")
.map((p) => p.id);
/**
* Default packages selected when the wizard opens a fresh onboarding
* request. The three CORE behaviours that make the assistant feel
* "smart out of the box":
* - heartbeat: proactive checks (otherwise the assistant is purely
* reactive).
* - cron: scheduled tasks (daily briefings, reminders).
* - active-memory: long-term recall of stable preferences and habits.
*
* Each adds some token cost — active-memory the most (one extra
* sub-agent turn per inbound message) — so customers can untoggle any
* of them before submitting.
*
* core-voice is intentionally NOT a default. It is fully wired (Phase B)
* and customers can enable it from the wizard, but it incurs separate
* audio spend on every inbound voice note (Whisper STT) and every
* outbound reply (kani-tts / kokoro-fastapi via LiteLLM). Opt-in keeps
* cost predictable for tenants who don't intend to use voice channels.
*/
export const DEFAULT_PACKAGE_IDS: string[] = [
"core-heartbeat",
"core-cron",
"core-active-memory",
];

260
src/lib/stripe.ts Normal file
View File

@@ -0,0 +1,260 @@
/**
* Server-side Stripe client + helpers for Phase 4 (card payments).
*
* Architecture (see Phase 4 notes):
* 1. Customer clicks "Pay with card" on /billing/<number>.
* 2. Server creates Stripe Checkout Session (mode='payment') with
* the invoice total as a single line item. We pass `customer`
* to reuse an existing Stripe Customer if the org already has
* one, otherwise we create one and persist its id in
* org_billing_config.stripe_customer_id.
* 3. Returns session.url; the browser redirects there.
* 4. Customer pays; Stripe redirects to success_url with the
* session id appended.
* 5. /api/stripe/webhook receives `checkout.session.completed`,
* verifies signature, looks up the invoice id from metadata,
* flips the invoice to 'paid'.
*
* Env vars:
* STRIPE_SECRET_KEY (required) - sk_test_... in sandbox, sk_live_... in prod
* STRIPE_WEBHOOK_SECRET (required for webhook) - whsec_...
* APP_BASE_URL (required) - e.g. https://app.pieced.ch
*
* SDK: stripe@22.x (Node SDK v22), pinned API version 2026-03-25.dahlia.
* Pinning the API version means a `npm update` of the SDK won't
* silently change request/response shapes; we explicitly bump when
* we want a new API version.
*/
import Stripe from "stripe";
import type { Invoice } from "@/types";
// Pinned API version. `as const` narrows this to a string-literal
// type that the Stripe constructor's `apiVersion` field accepts
// exactly. When the installed SDK bumps to a new pinned version,
// TypeScript will surface the mismatch at the `new Stripe(...)` call
// below — bump this string deliberately alongside the SDK upgrade
// and review the API changelog before doing so.
const STRIPE_API_VERSION = "2026-04-22.dahlia" as const;
// Cache the client across hot reloads / serverless invocations.
// We don't instantiate at module load because some build steps run
// without runtime env vars set — only fail when actually used.
let cachedClient: Stripe | null = null;
export function getStripeClient(): Stripe {
if (cachedClient) return cachedClient;
const key = process.env.STRIPE_SECRET_KEY;
if (!key) {
throw new Error(
"STRIPE_SECRET_KEY is not set. Configure it in your environment."
);
}
cachedClient = new Stripe(key, {
apiVersion: STRIPE_API_VERSION,
// Identify ourselves in Stripe's request logs so support can
// distinguish PieCed traffic from other integrations on the
// same account.
appInfo: {
name: "PieCed Portal",
version: "1.0.0",
url: "https://app.pieced.ch",
},
});
return cachedClient;
}
/**
* Return the configured webhook secret. Separated so the webhook
* handler can fail fast with a clear error message rather than the
* generic "STRIPE_SECRET_KEY missing" path above.
*/
export function getWebhookSecret(): string {
const secret = process.env.STRIPE_WEBHOOK_SECRET;
if (!secret) {
throw new Error(
"STRIPE_WEBHOOK_SECRET is not set. Get it from the webhook endpoint in your Stripe dashboard."
);
}
return secret;
}
/**
* Convert a CHF decimal amount (e.g. 123.45) to integer rappen
* (e.g. 12345). Stripe API requires integer amounts in the
* currency's smallest unit. Centralised so we don't have rounding
* drift between callers.
*/
export function chfToRappen(amountChf: number): number {
// toFixed(2) avoids floating-point ugliness (0.1 + 0.2 = 0.30000000000000004).
return Math.round(parseFloat(amountChf.toFixed(2)) * 100);
}
/**
* Look up or create the Stripe Customer for a PieCed org.
*
* Lazy creation: orgs that only pay by invoice never get a Stripe
* Customer. The first "Pay with card" click triggers creation; the
* id is persisted in org_billing_config so subsequent invoices
* reuse it.
*
* Returns the Stripe customer id (`cus_...`).
*/
export async function ensureStripeCustomerForOrg(params: {
zitadelOrgId: string;
// Snapshot taken at click-time, NOT at invoice issuance — the
// org's current address goes on the Stripe customer object.
// Stripe's address on file is independent of any one invoice.
companyName: string;
billingEmail: string;
address: {
line1: string;
postalCode: string;
city: string;
country: string; // ISO 3166-1 alpha-2 (e.g. "CH")
};
}): Promise<string> {
// Lazy import to avoid pulling pg into edge-runtime modules that
// might import this file. Same pattern used elsewhere in lib/.
const { getOrgBillingConfig, updateOrgBillingConfig } = await import("./db");
const existing = await getOrgBillingConfig(params.zitadelOrgId);
if (existing.stripeCustomerId) {
return existing.stripeCustomerId;
}
const stripe = getStripeClient();
const customer = await stripe.customers.create({
email: params.billingEmail,
name: params.companyName,
address: {
line1: params.address.line1,
postal_code: params.address.postalCode,
city: params.address.city,
country: params.address.country,
},
metadata: {
zitadel_org_id: params.zitadelOrgId,
},
});
await updateOrgBillingConfig(params.zitadelOrgId, {
stripeCustomerId: customer.id,
});
return customer.id;
}
/**
* Create a Checkout Session for paying a single invoice by card.
*
* Design notes:
*
* - Single line item with the invoice total (gross, VAT included).
* Our own invoice PDF already breaks down lines + VAT; the Stripe
* page is the checkout, not a duplicate of the invoice.
*
* - `automatic_tax` is disabled because the invoice already has
* VAT computed by our pipeline. Letting Stripe re-calculate
* would double-charge or contradict our PDF.
*
* - `payment_method_types` is NOT set, so Stripe surfaces dynamic
* payment methods configured on the account (cards, TWINT for
* Swiss customers, Apple Pay, Google Pay, etc.) automatically.
*
* - `metadata` and `payment_intent_data.metadata` BOTH carry the
* invoice id. The session-level copy is enough for the
* `checkout.session.completed` webhook; the intent-level copy
* lets us correlate refunds and disputes which fire on the
* PaymentIntent and don't include session metadata.
*
* - `client_reference_id` is set to our invoice id as a stable
* reference. Visible in the Stripe dashboard, useful for support.
*
* - `locale` follows the invoice's locale so the customer sees
* the Stripe page in their language (frozen at invoice issue
* time; consistent with PDF + email).
*/
export async function createCheckoutSessionForInvoice(params: {
invoice: Invoice;
customerId: string;
baseUrl: string;
}): Promise<{ url: string; sessionId: string }> {
const stripe = getStripeClient();
const { invoice, customerId, baseUrl } = params;
// Stripe Checkout supports a limited set of locales; map our
// four to Stripe's codes and fall back to 'auto' if anything
// outside the set ever appears.
//
// We deliberately don't annotate this with
// `Stripe.Checkout.SessionCreateParams.Locale` — stripe-node v22
// ships with a known type-export regression
// (stripe/stripe-node#2662) where params types under namespaced
// resources aren't re-exported from the resource barrel. The
// `as const` literal narrowing gives the variable the union type
// `"de" | "fr" | "it" | "en" | "auto"`, which `sessions.create`
// accepts at the call site via its own inline parameter typing.
// When the SDK fixes the re-export, we can put the annotation
// back without touching the call site.
const stripeLocale =
invoice.locale === "de"
? ("de" as const)
: invoice.locale === "fr"
? ("fr" as const)
: invoice.locale === "it"
? ("it" as const)
: invoice.locale === "en"
? ("en" as const)
: ("auto" as const);
const successUrl = `${baseUrl}/billing/${encodeURIComponent(invoice.invoiceNumber)}?paid=1&session_id={CHECKOUT_SESSION_ID}`;
const cancelUrl = `${baseUrl}/billing/${encodeURIComponent(invoice.invoiceNumber)}?cancelled=1`;
const session = await stripe.checkout.sessions.create({
mode: "payment",
customer: customerId,
client_reference_id: invoice.id,
locale: stripeLocale,
line_items: [
{
quantity: 1,
price_data: {
currency: "chf",
unit_amount: chfToRappen(invoice.totalChf),
product_data: {
name: `Invoice ${invoice.invoiceNumber}`,
description: `PieCed IT — ${invoice.periodStart.slice(0, 10)}${invoice.periodEnd.slice(0, 10)}`,
},
},
},
],
metadata: {
invoice_id: invoice.id,
invoice_number: invoice.invoiceNumber,
zitadel_org_id: invoice.zitadelOrgId,
},
payment_intent_data: {
// Mirror invoice id at the PaymentIntent level so refunds &
// disputes (which fire on the PI, not the session) can be
// correlated to our invoice without an extra lookup.
metadata: {
invoice_id: invoice.id,
invoice_number: invoice.invoiceNumber,
zitadel_org_id: invoice.zitadelOrgId,
},
// Statement descriptor shown on the customer's card
// statement. Limited to 22 chars total; we use the prefix
// since Stripe will prepend the merchant name from the
// account anyway. Keep it short and recognisable.
description: `Invoice ${invoice.invoiceNumber}`,
},
success_url: successUrl,
cancel_url: cancelUrl,
// VAT is already in invoice.totalChf — don't let Stripe touch tax.
automatic_tax: { enabled: false },
});
if (!session.url) {
throw new Error(
`Stripe returned a session without a redirect URL (id=${session.id})`
);
}
return { url: session.url, sessionId: session.id };
}

View File

@@ -0,0 +1,31 @@
/**
* Threema central gateway info shown to customers.
*
* Today PieCed runs exactly one Threema Gateway account (*AIAGENT) and
* every tenant talks to that. The constants below are hardcoded for
* that account. The values are intentionally kept here (and not split
* across i18n / env / runtime config) so when we move to multiple
* gateway accounts there's a single file to refactor.
*
* To go dynamic (future):
* 1. Replace `THREEMA_GATEWAY` constant with a runtime lookup —
* either per-tenant from the relay's admin API, or from an
* env var that lists the active account.
* 2. Move the QR PNG into a server-rendered route that takes a
* gateway ID query param.
* 3. Update consumers to accept the gateway info as a prop and pass
* it from a server component.
*/
export const THREEMA_GATEWAY = {
/** Technical Threema Gateway ID, with leading asterisk. */
id: "*AIAGENT",
/**
* Display name shown to customers. INCLUDES the leading asterisk —
* customers need to recognise this exact string in their Threema
* contacts after scanning the QR, so we don't strip it.
*/
displayName: "*AIAGENT",
/** Public path to the QR code PNG served from `public/`. */
qrCodePath: "/threema/qr_code_AIAGENT.png",
} as const;

202
src/lib/threema-relay.ts Normal file
View File

@@ -0,0 +1,202 @@
/**
* Admin client for the central pieced-threema-gateway relay.
*
* All calls authenticate with a static admin bearer (THREEMA_RELAY_ADMIN_TOKEN
* from env, sourced from OpenBao at secret/data/threema-gateway/admin). The
* portal is the only caller; never expose these functions through a public
* HTTP surface.
*
* Resilience: every method returns a strongly-typed result rather than
* throwing. Network errors surface as `{ ok: false, kind: 'network' }`,
* 4xx/5xx as `{ ok: false, kind: 'http', status, ... }`. Callers that need
* to compensate (e.g. delete a route after a K8s patch failure) can
* inspect `kind` to decide whether retry is sensible.
*/
const RELAY_URL = (process.env.THREEMA_RELAY_URL ?? "").replace(/\/+$/, "");
const ADMIN_TOKEN = process.env.THREEMA_RELAY_ADMIN_TOKEN ?? "";
function assertConfigured(): void {
if (!RELAY_URL) throw new Error("THREEMA_RELAY_URL not set");
if (!ADMIN_TOKEN) throw new Error("THREEMA_RELAY_ADMIN_TOKEN not set");
}
type RelayError =
| { ok: false; kind: "network"; message: string }
| { ok: false; kind: "http"; status: number; message: string; body?: unknown };
export type RelayResult<T> = ({ ok: true } & T) | RelayError;
async function call<T>(
path: string,
init: RequestInit,
): Promise<RelayResult<T>> {
assertConfigured();
try {
const res = await fetch(`${RELAY_URL}${path}`, {
...init,
headers: {
...(init.headers ?? {}),
Authorization: `Bearer ${ADMIN_TOKEN}`,
"Content-Type": "application/json",
},
});
let body: unknown = null;
const text = await res.text().catch(() => "");
if (text) {
try {
body = JSON.parse(text);
} catch {
body = text;
}
}
if (!res.ok) {
const message =
(body && typeof body === "object" && "error" in body && typeof (body as Record<string, unknown>).error === "string"
? ((body as Record<string, unknown>).error as string)
: `HTTP ${res.status}`);
return { ok: false, kind: "http", status: res.status, message, body };
}
return { ok: true, ...(body as Record<string, unknown>) } as RelayResult<T>;
} catch (e) {
return { ok: false, kind: "network", message: (e as Error).message };
}
}
// ---------------------------------------------------------------------------
// Tokens
// ---------------------------------------------------------------------------
/** Mint (or rotate) per-tenant bearer + HMAC secret. */
export function mintToken(
tenantName: string,
): Promise<RelayResult<{ token: string; hmacSecret: string }>> {
return call("/admin/tokens", {
method: "POST",
body: JSON.stringify({ tenantName }),
});
}
/**
* Revoke per-tenant token. Cascades — relay also deletes all routes
* owned by this tenant.
*/
export function revokeToken(
tenantName: string,
): Promise<RelayResult<{ deletedRoutes: number }>> {
return call(`/admin/tokens/${encodeURIComponent(tenantName)}`, {
method: "DELETE",
});
}
export function tokenExists(
tenantName: string,
): Promise<RelayResult<{ exists: true }>> {
return call(`/admin/tokens/${encodeURIComponent(tenantName)}`, {
method: "GET",
});
}
// ---------------------------------------------------------------------------
// Routes
// ---------------------------------------------------------------------------
export interface RouteRow {
threema_id: string;
tenant_name: string;
created_at: string;
created_by: string;
}
export function listRoutes(
tenantName: string,
): Promise<RelayResult<{ routes: RouteRow[] }>> {
return call(`/admin/routes?tenant=${encodeURIComponent(tenantName)}`, {
method: "GET",
});
}
/**
* Create a route. The relay enforces uniqueness — on conflict returns
* status 409 with body `{ ownedBy: <tenantName> | null }`. Callers
* should inspect `kind === 'http'` + status 409 + body.ownedBy to
* distinguish idempotent self-claim from real conflict.
*/
export function createRoute(
tenantName: string,
threemaId: string,
): Promise<RelayResult<{ ok: true }>> {
return call("/admin/routes", {
method: "POST",
body: JSON.stringify({ tenantName, threemaId }),
});
}
export function deleteRoute(
tenantName: string,
threemaId: string,
): Promise<RelayResult<{ ok: true }>> {
return call(
`/admin/routes/${encodeURIComponent(threemaId)}?tenant=${encodeURIComponent(tenantName)}`,
{ method: "DELETE" },
);
}
// ---------------------------------------------------------------------------
// Usage
// ---------------------------------------------------------------------------
export interface UsageBreakdown {
tenant: string;
from: string;
to: string;
totals: { in: number; out: number };
daily: Array<{ day: string; direction: "in" | "out"; count: number }>;
}
export function getUsage(
tenantName: string,
from?: Date,
to?: Date,
): Promise<RelayResult<UsageBreakdown>> {
const qs = new URLSearchParams({ tenant: tenantName });
if (from) qs.set("from", from.toISOString());
if (to) qs.set("to", to.toISOString());
return call(`/admin/usage?${qs.toString()}`, { method: "GET" });
}
// ---------------------------------------------------------------------------
// Health
// ---------------------------------------------------------------------------
export function health(): Promise<RelayResult<{ credits: number }>> {
return call("/admin/health", { method: "GET" });
}
// ---------------------------------------------------------------------------
// Helpers for caller code
// ---------------------------------------------------------------------------
/**
* Type guard: did this route-create fail because the ID is owned by
* someone else? Distinguishes from "owned by SAME tenant" (idempotent).
*/
export function isRouteConflictForOtherTenant(
result: RelayResult<{ ok: true }>,
tenantName: string,
): boolean {
if (result.ok) return false;
if (result.kind !== "http" || result.status !== 409) return false;
const body = result.body as { ownedBy?: string | null } | undefined;
return !!body?.ownedBy && body.ownedBy !== tenantName;
}
export function isRouteConflictForSameTenant(
result: RelayResult<{ ok: true }>,
tenantName: string,
): boolean {
if (result.ok) return false;
if (result.kind !== "http" || result.status !== 409) return false;
const body = result.body as { ownedBy?: string | null } | undefined;
return body?.ownedBy === tenantName;
}

View File

@@ -15,7 +15,8 @@
"team": "Team",
"settings": "Einstellungen",
"optional": "optional",
"support": "Support"
"support": "Support",
"billing": "Abrechnung"
},
"login": {
"title": "PieCed Portal",
@@ -120,7 +121,8 @@
"saveChanges": "Änderungen speichern",
"billingVatNumber": "MWST-Nummer",
"billingVatHelp": "Ihre registrierte MWST-Nummer. Falls Ihre Firma von der MWST befreit ist, leer lassen und in den Notizen erläutern.",
"billingNotesPlaceholderPersonal": "Was wir wissen sollten — bevorzugte Zahlungsart, Rechnungsreferenz, etc."
"billingNotesPlaceholderPersonal": "Was wir wissen sollten — bevorzugte Zahlungsart, Rechnungsreferenz, etc.",
"reviewContactPersonPrefix": "z.Hd."
},
"dashboard": {
"title": "Dashboard",
@@ -189,7 +191,21 @@
"last30Days": "Letzte 30 Tage",
"noData": "Keine Nutzungsdaten verfügbar.",
"dailyBreakdown": "Tagesübersicht",
"requests": "Anfragen"
"requests": "Anfragen",
"budgetEdit": "Bearbeiten",
"budgetEditTitle": "Budget festlegen",
"budgetEditDescription": "Begrenzen Sie, wie viel die Assistenten dieses Tenants ausgeben können, bevor Anfragen abgelehnt werden.",
"budgetModeUnlimited": "Kein Limit",
"budgetModeUnlimitedDescription": "Beliebige Ausgaben, kein Limit.",
"budgetModeCapped": "Limit festlegen",
"budgetModeCappedDescription": "Anfragen ablehnen, sobald die Ausgaben diesen Betrag erreichen.",
"budgetAmount": "Betrag",
"budgetResetCadence": "Zurücksetzen",
"budgetCadence_30d": "Alle 30 Tage",
"budgetCadence_1mo": "Monatlich",
"budgetCadence_1y": "Jährlich",
"budgetInvalid": "Bitte einen positiven Betrag eingeben.",
"budgetSaveFailed": "Budget konnte nicht gespeichert werden. Bitte erneut versuchen."
},
"workspace": {
"save": "Speichern",
@@ -200,7 +216,8 @@
"packages": {
"categories": {
"channels": "Kanäle",
"skills": "Fähigkeiten"
"skills": "Fähigkeiten",
"core": "Kern"
},
"enable": "Aktivieren",
"disable": "Deaktivieren",
@@ -225,29 +242,84 @@
"botTokenLabel": "Discord Bot Token",
"botTokenPlaceholder": "MTAxNjQ0OTk2NjAz...",
"instructions": "1. Gehen Sie zu discord.com/developers/applications\n2. Erstellen Sie eine neue Anwendung und fügen Sie einen Bot hinzu\n3. Kopieren Sie den Bot-Token",
"disclaimer": "Ich bestätige, dass ich diesen Discord-Bot besitze und PieCed IT autorisiere, ihn mit meinem KI-Assistenten zu verbinden."
},
"email": {
"description": "Ermöglichen Sie Ihrem KI-Assistenten, E-Mails zu senden und zu empfangen.",
"smtpHostLabel": "SMTP Host",
"smtpHostPlaceholder": "smtp.example.com",
"smtpUserLabel": "SMTP Benutzername",
"smtpUserPlaceholder": "user@example.com",
"smtpPasswordLabel": "SMTP Passwort",
"smtpPasswordPlaceholder": "••••••••",
"imapHostLabel": "IMAP Host",
"imapHostPlaceholder": "imap.example.com",
"instructions": "Geben Sie SMTP- und IMAP-Zugangsdaten an. Der Assistent nutzt diese zum Senden und Empfangen von Nachrichten.",
"disclaimer": "Ich bestätige, dass ich berechtigt bin, diese E-Mail-Zugangsdaten zu verwenden und dass PieCed IT auf dieses Postfach zugreifen darf."
},
"webSearch": {
"description": "Geben Sie Ihrem KI-Assistenten die Möglichkeit, im Web zu suchen."
},
"documentProcessing": {
"description": "Aktivieren Sie Dokumentenverarbeitung, Zusammenfassung und Extraktion."
"disclaimer": "Ich bestätige, dass ich diesen Discord-Bot besitze und PieCed IT autorisiere, ihn mit meinem KI-Assistenten zu verbinden.",
"appIdLabel": "Discord-Anwendungs-ID",
"appIdPlaceholder": "1819-stellige numerische ID aus dem Developer Portal"
},
"statusEnabled": "aktiviert",
"statusDisabled": "deaktiviert"
"statusDisabled": "deaktiviert",
"coreHeartbeat": {
"description": "Periodischer Agentenlauf alle 30 Minuten, der es dem Assistenten erlaubt, Posteingang, Kalender und andere konfigurierte Quellen zu prüfen und proaktiv Bescheid zu geben, wenn etwas Aufmerksamkeit braucht. Ohne diese Option reagiert der Assistent nur, wenn Sie ihn ansprechen."
},
"coreCron": {
"description": "Erlaubt dem Assistenten, geplante Aufgaben auszuführen (tägliche Briefings, wiederkehrende Erinnerungen, periodische Berichte). Standardmässig deaktiviert. Bei Deaktivierung bleibt das Cron-Werkzeug verfügbar, aber keine geplante Aufgabe wird ausgeführt."
},
"coreActiveMemory": {
"description": "Erlaubt dem Assistenten, stabile Präferenzen, wiederkehrende Gewohnheiten und langfristigen Kontext aus früheren Gesprächen abzurufen. Nutzt einen zusätzlichen Sub-Agent-Lauf pro eingehender Nachricht, um den Memory-Store abzufragen. Nur Direktnachrichten. Kleiner Mehraufwand an Tokens im Tausch gegen Kontinuität und Personalisierung."
},
"coreVoice": {
"description": "Spracherkennung für eingehende Sprachnachrichten und Sprachsynthese für Antworten, über das PieCed-LiteLLM-Gateway, damit Audiokosten pro Mandant erfasst werden. Die Laufzeit-Integration kommt im nächsten Plattform-Release; das Umschalten speichert die Auswahl für diese Auslieferung."
},
"gitCli": {
"description": "Eigenständige Git-Kommandozeilenoperationen (clone, commit, branch, diff, log, status). Für private Repositories konfigurieren Sie die Zugangsdaten in Ihrem Workspace."
},
"github": {
"description": "Interaktion mit GitHub-Repositories über die gh-CLI — Issues, Pull Requests, CI-Läufe, Releases, Gists. Erfordert ein persönliches Zugriffstoken.",
"tokenLabel": "GitHub Persönliches Zugriffstoken",
"tokenPlaceholder": "ghp_… oder github_pat_…",
"instructions": "1. Öffnen Sie https://github.com/settings/tokens\n2. Erstellen Sie ein fein abgestimmtes persönliches Zugriffstoken mit den gewünschten Repo-Berechtigungen\n3. Kopieren Sie das Token (es wird nur einmal angezeigt)"
},
"gitea": {
"description": "Interaktion mit einer Gitea-Instanz — Repositories, Issues, Pull Requests, Releases. Standardmässig die PieCed-Plattform-Gitea unter git.c5ai.ch.",
"tokenLabel": "Gitea-Zugriffstoken",
"tokenPlaceholder": "Erstellt unter Einstellungen → Anwendungen",
"instructions": "1. Melden Sie sich bei Ihrer Gitea-Instanz an (Standard https://git.c5ai.ch)\n2. Gehen Sie zu Einstellungen → Anwendungen → Neues Token erstellen\n3. Vergeben Sie die gewünschten Berechtigungen (repo, issue, user)\n4. Kopieren Sie das Token"
},
"whisperSelfHosted": {
"description": "Transkribieren Sie Audiodateien über die plattformeigene Whisper-Instanz. Nützlich für Ad-hoc-Transkriptionsaufgaben aus dem Chat heraus."
},
"searxngLocalSearch": {
"description": "Datenschutzfreundliche Web-Suche über die interne SearXNG-Instanz der Plattform. Durchsuchen Sie Web, Bilder und News ohne externe API-Aufrufe oder Tracker."
},
"gog": {
"description": "Gebündelter Zugriff auf Gmail, Kalender, Drive, Docs, Sheets und Kontakte via Google OAuth. Setup erfordert ein Google-Cloud-Projekt — wenden Sie sich an den PieCed-Support für die Einrichtung.",
"clientIdLabel": "Google OAuth Client-ID",
"clientIdPlaceholder": "xxxxxxxxxxx.apps.googleusercontent.com",
"clientSecretLabel": "Google OAuth Client-Secret",
"clientSecretPlaceholder": "GOCSPX-…",
"refreshTokenLabel": "Google OAuth Refresh-Token",
"refreshTokenPlaceholder": "1//0g…",
"instructions": "Google Workspace nutzt OAuth. Erstellen Sie einen OAuth-Client in Ihrem Google-Cloud-Projekt, autorisieren Sie ihn mit den benötigten Scopes (Gmail, Kalender, Drive usw.) und fügen Sie die Zugangsdaten unten ein. Mit dem Absenden werden sie sicher gespeichert und Ihre Aktivierung zur Admin-Prüfung eingereiht — nach Genehmigung wird die Integration automatisch aktiviert.",
"disclaimer": "Mit der Aktivierung der Google-Workspace-Integration autorisieren Sie PieCed, in Ihrem Namen auf Gmail, Kalender, Drive, Docs, Sheets und Kontakte zuzugreifen. Daten fliessen über die Google-APIs, vorbehaltlich der Google-Bedingungen."
},
"mail": {
"description": "E-Mails über IMAP lesen, suchen und verwalten; senden über SMTP. Funktioniert mit Gmail (mit App-Passwort), Outlook, Fastmail und jedem standardkonformen IMAP/SMTP-Host.",
"imapHostLabel": "IMAP-Host",
"imapHostPlaceholder": "imap.example.com",
"imapUserLabel": "IMAP-Benutzername",
"imapUserPlaceholder": "benutzer@example.com",
"imapPassLabel": "IMAP-Passwort",
"imapPassPlaceholder": "••••••••",
"smtpHostLabel": "SMTP-Host",
"smtpHostPlaceholder": "smtp.example.com",
"smtpUserLabel": "SMTP-Benutzername",
"smtpUserPlaceholder": "benutzer@example.com",
"smtpPassLabel": "SMTP-Passwort",
"smtpPassPlaceholder": "••••••••",
"instructions": "1. Für Gmail: Aktivieren Sie die 2-Faktor-Authentifizierung, erstellen Sie dann unter https://myaccount.google.com/apppasswords ein App-Passwort und verwenden Sie es als IMAP- und SMTP-Passwort.\n2. Für Outlook / Microsoft 365 mit MFA: Generieren Sie ein App-Passwort in den Sicherheitseinstellungen Ihres Kontos.\n3. Für andere Anbieter: Konsultieren Sie deren IMAP/SMTP-Dokumentation für Hostnamen und Ports.\n4. Typische IMAP-Hosts: imap.gmail.com, outlook.office365.com.\n5. Typische SMTP-Hosts: smtp.gmail.com, smtp.office365.com.",
"disclaimer": "Der Assistent erhält Lese- und Schreibzugriff auf das von Ihnen konfigurierte Postfach. Verwenden Sie eine dedizierte Adresse anstelle eines persönlichen Postfachs, wenn Sie den Umfang einschränken möchten."
},
"threema": {
"description": "Senden und empfangen Sie Nachrichten über Threema. Jede eingehende und ausgehende Nachricht läuft über den gemeinsamen PieCed-Messaging-Dienst und verursacht eine Gebühr pro Nachricht bei Threema — eine Drittanbieter-Kostenposition, unabhängig von Ihrem PieCed-Abonnement.",
"instructions": "1. Aktivieren Sie dieses Paket.\n2. Öffnen Sie Threema auf Ihrem Telefon, scannen Sie den QR-Code unter Autorisierte Benutzer → threema und akzeptieren Sie den Kontakt.\n3. Tragen Sie Ihre eigene Threema-ID unter Autorisierte Benutzer → threema ein, damit der Assistent Ihre Nachrichten erkennt.\n4. Schreiben Sie eine Nachricht aus Threema, um das Gespräch zu beginnen.",
"disclaimer": "Nachrichten zwischen Threema und PieCed werden Ende-zu-Ende verschlüsselt bis zum PieCed-Messaging-Dienst, wo sie entschlüsselt und an Ihren Assistenten weitergeleitet werden. Jede gesendete oder empfangene Nachricht wird gemäss Threema-Tarif pro Nachricht abgerechnet — die aktuellen Preise finden Sie in Ihrem Plan."
},
"manualReviewPending": "Manuelle Prüfung ausstehend",
"withdraw": "Zurückziehen",
"activationRejected": "Abgelehnt",
"tryAgain": "Erneut versuchen",
"credentialsSaved": "Zugangsdaten gespeichert",
"credentialsSavedTip": "Die eingegebenen Zugangsdaten sind sicher gespeichert und werden verwendet, sobald die Aktivierung vom Admin genehmigt wurde. Sie müssen sie nicht erneut eingeben."
},
"admin": {
"title": "Plattform-Admin",
@@ -319,7 +391,11 @@
"statusDown": "Ausgefallen",
"spendChf": "Kosten (CHF)",
"resumeRequestBadge": "Wieder",
"resumeRequestTooltip": "Reaktivierungsanfrage für einen bestehenden Tenant. Bei Genehmigung wird der Tenant wieder aktiviert; keine Provisionierung läuft."
"resumeRequestTooltip": "Reaktivierungsanfrage für einen bestehenden Tenant. Bei Genehmigung wird der Tenant wieder aktiviert; keine Provisionierung läuft.",
"openclawTool": "OpenClaw-Versionen",
"billingTool": "Abrechnung →",
"skillsQueueTool": "Aktivierungs-Warteschlange",
"cronTool": "Automatisierung"
},
"channelUsers": {
"title": "Autorisierte Benutzer",
@@ -331,7 +407,17 @@
"alreadyAdded": "Diese Benutzer-ID ist bereits autorisiert.",
"telegramIdHelp": "So finden Sie Ihre Telegram-Benutzer-ID:\n1. Öffnen Sie Telegram und schreiben Sie @userinfobot\n2. Der Bot antwortet sofort mit Ihrer numerischen ID\n3. Geben Sie diese Nummer hier ein",
"discordIdHelp": "So finden Sie Ihre Discord-Benutzer-ID:\n1. Aktivieren Sie den Entwicklermodus in den Discord-Einstellungen (Erweitert)\n2. Rechtsklick auf Ihren Namen → Benutzer-ID kopieren\n3. Geben Sie diese Nummer hier ein",
"emailIdHelp": "Geben Sie die E-Mail-Adresse ein, die zur Interaktion mit dem Assistenten autorisiert werden soll."
"threemaIdHelp": "Geben Sie Ihre eigene Threema-ID ein — die 8 Zeichen, die in Ihrer Threema-App unter Einstellungen → Meine Threema-ID angezeigt werden. Anschliessend können Sie direkt aus Threema mit dem Assistenten chatten.",
"threemaSetup": {
"title": "Assistenten zu Threema hinzufügen",
"step1": "Öffnen Sie Threema auf Ihrem Telefon.",
"step2": "Tippen Sie auf das Scan-Symbol und scannen Sie diesen QR-Code, um den Assistenten als Kontakt hinzuzufügen.",
"step3": "Fügen Sie anschliessend unten Ihre eigene Threema-ID hinzu.",
"qrAlt": "QR-Code, um {gateway} als Threema-Kontakt hinzuzufügen",
"bannerTitle": "Threema einrichten",
"bannerBody": "Öffnen Sie Threema auf Ihrem Telefon und scannen Sie unseren QR-Code, um den Assistenten als Kontakt hinzuzufügen. Geben Sie anschliessend unten Ihre eigene Threema-ID ein.",
"bannerButton": "QR-Code anzeigen"
}
},
"team": {
"title": "Team",
@@ -397,25 +483,31 @@
"billingDescriptionPersonal": "Adresse und Rechnungs-E-Mail für alle Ihre Tenants."
},
"settingsBilling": {
"title": "Abrechnung",
"subtitle": "Wird beim ersten Onboarding einmalig erfasst und für jeden Tenant Ihrer Organisation wiederverwendet. Aktualisieren Sie hier, wenn sich Ihre Abrechnungsdaten ändern.",
"companyName": "Firmenname",
"streetAddress": "Strasse",
"postalCode": "PLZ",
"city": "Ort",
"country": "Land",
"vatNumber": "MWST-Nummer",
"vatHelp": "Ihre registrierte MWST-Nummer (z. B. CHE-123.456.789 MWST für die Schweiz).",
"billingEmail": "Rechnungs-E-Mail",
"billingEmailHelp": "An diese Adresse werden Rechnungen und Abrechnungskommunikation gesendet.",
"notes": "Notizen",
"notesPlaceholder": "Alles, was die Buchhaltung wissen muss MWST-Befreiung, besondere Rechnungsstellung usw.",
"save": "Speichern",
"title": "Rechnungsdaten",
"subtitle": "Rechnungsadresse, MWST-Nummer und Rechnungskontakt Ihres Unternehmens. Erforderlich, bevor Rechnungen für Ihre Organisation ausgestellt werden können.",
"companyNameLabel": "Firmenname",
"streetAddressLabel": "Strasse und Hausnummer",
"postalCodeLabel": "PLZ",
"cityLabel": "Ort",
"countryLabel": "Ländercode",
"countryHint": "ISO 3166-1 alpha-2 — z.B. CH, DE, AT, FR, IT, GB, US",
"vatNumberLabel": "MWST-Nummer (optional)",
"vatNumberHint": "Für Schweizer Kunden: CHE-XXX.XXX.XXX MWST. EU-Kunden mit USt-IdNr. erhalten eine Reverse-Charge-Rechnung (0% MWST).",
"billingEmailLabel": "Rechnungs-E-Mail",
"billingEmailHint": "Rechnungen und Zahlungserinnerungen werden an diese Adresse gesendet. Kann von Ihrer Konto-E-Mail abweichen.",
"notesLabel": "Bemerkungen (optional)",
"notesHint": "Referenznummern, Bestellnummern oder andere Angaben, die auf der Rechnung erscheinen sollen.",
"saveChanges": "Änderungen speichern",
"createBilling": "Rechnungsdaten speichern",
"saving": "Speichern…",
"saved": "Gespeichert.",
"saveFailed": "Konnte nicht gespeichert werden. Bitte erneut versuchen.",
"lastUpdated": "Zuletzt aktualisiert {when}",
"fullName": "Voller Name",
"notesPlaceholderPersonal": "Was wir wissen sollten — bevorzugte Zahlungsart, Rechnungsreferenz, etc."
"missingRequired": "Bitte alle Pflichtfelder ausfüllen.",
"invalidCountry": "Ländercode muss aus 2 Buchstaben bestehen (z.B. CH).",
"invalidEmail": "Bitte eine gültige E-Mail-Adresse eingeben.",
"fullNameLabel": "Vor- und Nachname",
"subtitlePersonal": "Ihre Rechnungsadresse und Rechnungskontakt. Erforderlich, bevor Rechnungen ausgestellt werden können.",
"contactNameLabel": "Ansprechperson (optional)",
"contactNameHint": "Erscheint als 'z.Hd. <Name>' auf der Rechnung unter dem Firmennamen. Hilfreich für die Zuordnung in der Buchhaltung grösserer Firmen."
},
"support": {
"title": "Support",
@@ -459,5 +551,238 @@
"resolvedBanner": "Dieses Ticket ist erledigt. Antworten Sie unten, falls Sie nachfragen möchten — das öffnet es erneut.",
"adminControlsTitle": "Admin-Steuerung",
"updateFailed": "Änderungen konnten nicht gespeichert werden. Bitte erneut versuchen."
},
"openclawAdmin": {
"title": "OpenClaw-Versionen",
"subtitle": "Plattform-Standard-Tag und Tenant-spezifische Overrides für das Testen neuer Releases konfigurieren.",
"defaultSection": "Plattform-Standard",
"defaultDescription": "Wird von jedem Tenant ohne eigenen Override verwendet.",
"fieldTag": "Tag",
"emptyHint": "Leer lassen, um den eingebauten Operator-Standard zu verwenden.",
"saveDefault": "Standard speichern",
"defaultSaved": "Standard gespeichert. Tenants ohne Override übernehmen den Wert beim nächsten Reconcile.",
"saveFailed": "Speichern fehlgeschlagen. Bitte erneut versuchen.",
"overridesSection": "Tenant-Overrides",
"noTenants": "Keine Tenants im Cluster.",
"statusOverridden": "Override",
"statusFollowsDefault": "Folgt Standard",
"builtinFallback": "(eingebauter Fallback)",
"defaultPrefix": "Standard:",
"saveOverride": "Override speichern",
"clearOverride": "Override entfernen"
},
"adminBilling": {
"title": "Abrechnungsverwaltung",
"subtitle": "Plattform-Preise verwalten, Rechnungen generieren und den Rechnungsstatus aller Organisationen prüfen.",
"backToAdmin": "Zurück zur Verwaltung",
"backToBilling": "Zurück zur Abrechnung",
"backToInvoices": "Zurück zu den Rechnungen",
"totalOpenBalance": "Offener Saldo gesamt",
"orgsWithBalance": "Organisationen mit Saldo",
"overdueInvoices": "Überfällige Rechnungen",
"pricingTitle": "Preise",
"pricingDesc": "Plattform- & Skill-Preise, MWST-Satz.",
"pricingPageDesc": "Plattformweite Preise und Skill-Tagespreise bearbeiten.",
"generateTitle": "Rechnung erstellen",
"generateDesc": "Rechnung für eine Organisation und einen Monat berechnen und ausstellen.",
"generatePageDesc": "Organisation, Periode und Sprache wählen. Die Vorschau zeigt die berechneten Positionen; mit Bestätigen wird die Rechnung ausgestellt und das PDF erzeugt.",
"invoicesTitle": "Rechnungen",
"invoicesDesc": "Alle Rechnungen anzeigen, als bezahlt markieren, PDFs herunterladen.",
"invoicesPageDesc": "Alle von der Plattform ausgestellten Rechnungen. Mit dem Statusfilter offene oder überfällige Positionen einsehen.",
"balancesTitle": "Organisationen mit offenem Saldo",
"orgIdCol": "Zitadel-Org-ID",
"openCountCol": "Offen",
"overdueCountCol": "Überfällig",
"totalOpenCol": "Gesamt offen",
"platformPricingTitle": "Plattform-Preise",
"monthlyFeeLabel": "Monatliche Tenant-Gebühr",
"setupFeeLabel": "Einrichtungsgebühr Tenant",
"threemaMessageLabel": "Threema pro Nachricht",
"vatRateLabel": "MWST-Satz (CH/LI)",
"save": "Speichern",
"saving": "Speichere…",
"savedOk": "Gespeichert",
"skillPricingTitle": "Paket-Preise",
"skillPricingDesc": "Tagespreis und einmalige Einrichtungsgebühr für jedes Paket — Core, Kanal oder Skill. Die Preisgestaltung gilt für jeden Tenant, der das Paket aktiviert.",
"skillCol": "Paket",
"dailyPriceCol": "Tagespreis",
"actionsCol": "",
"remove": "Entfernen",
"noSkillsPriced": "Noch keine Pakete bepreist.",
"addSkillLabel": "Paket hinzufügen",
"dailyPriceLabel": "Tagespreis",
"add": "Hinzufügen",
"confirmDeleteSkillPrice": "Preisgestaltung für {skill} entfernen? Bereits abgerechnete Zeiträume bleiben unberührt.",
"clickToEdit": "Zum Bearbeiten klicken",
"generateFormTitle": "Rechnung erstellen",
"noOrgsToGenerate": "Keine Organisationen mit Tenants gefunden.",
"orgLabel": "Organisation",
"noBillingAddrTag": "keine Rechnungsadresse",
"noBillingAddrWarning": "Diese Organisation hat keine Rechnungsadresse hinterlegt. Der Kunde muss /settings/billing ausfüllen, bevor eine Rechnung ausgestellt werden kann.",
"tenantsLabel": "Tenants",
"yearLabel": "Jahr",
"monthLabel": "Monat",
"localeLabel": "PDF-Sprache",
"localeAuto": "Automatisch",
"previewBtn": "Vorschau",
"commitBtn": "Bestätigen & ausstellen",
"computing": "Berechne…",
"confirmGenerate": "Diese Rechnung ausstellen? Es wird eine Rechnungsnummer vergeben und das PDF erzeugt.",
"previewTitle": "Entwurfsvorschau",
"warningsTitle": "Hinweise",
"noLinesGenerated": "Keine abrechenbaren Positionen für diese Periode.",
"descCol": "Beschreibung",
"qtyCol": "Menge",
"unitPriceCol": "Einzelpreis",
"amountCol": "Betrag (CHF)",
"subtotal": "Zwischensumme",
"vat": "MWST",
"total": "Total",
"statusFilterLabel": "Status",
"allStatuses": "Alle",
"monthFilterLabel": "Periode",
"clearFilter": "Zurücksetzen",
"loading": "Lade…",
"noInvoicesFound": "Keine Rechnungen entsprechen den aktuellen Filtern.",
"invoiceNumberCol": "Nummer",
"orgCol": "Organisation",
"periodCol": "Periode",
"statusCol": "Status",
"totalCol": "Total",
"dueCol": "Fällig",
"status_draft": "Entwurf",
"status_open": "Offen",
"status_paid": "Bezahlt",
"status_overdue": "Überfällig",
"status_void": "Storniert",
"status_uncollectible": "Uneinbringlich",
"dueOnLabel": "Fällig",
"totalLabel": "Total",
"downloadPdfBtn": "PDF herunterladen",
"markPaidBtn": "Als bezahlt markieren",
"paidNotePlaceholder": "Optionale Notiz (z. B. Bankreferenz, Eingangsdatum)",
"confirm": "Bestätigen",
"cancel": "Abbrechen",
"deleteBtn": "Löschen",
"deleting": "Lösche…",
"deleteHint": "Rechnung hart löschen (Test-Tool). Die Nummer bleibt vergeben.",
"confirmDeleteInvoice": "Rechnung {num} löschen? Dies ist eine harte Löschung — die Rechnungsnummer bleibt verbraucht.",
"paidOnLabel": "Bezahlt am",
"lineItemsTitle": "Positionen",
"billToSnapshotTitle": "Rechnungsempfänger",
"setupFeeCol": "Einrichtungsgebühr",
"skillSetupFeeLabel": "Einrichtungsgebühr"
},
"skillCostDialog": {
"title": "Aktivierungskosten bestätigen",
"intro": "Die Aktivierung von {skill} verursacht folgende Kosten:",
"setupFeeLabel": "Einrichtungsgebühr",
"setupFeeNote": "Einmalig, nur bei erster Aktivierung",
"monthlyPriceLabel": "Monatspreis",
"monthlyPriceNote": "CHF {daily}/Tag aktiv; Teilmonate werden taggenau berechnet",
"monthUnit": "Monat",
"disclaimer": "Diese Kosten erscheinen auf Ihrer nächsten Monatsrechnung. Mit der Bestätigung stimmen Sie ihnen zu.",
"cancel": "Abbrechen",
"confirm": "Bestätigen & aktivieren",
"confirming": "Aktiviere…"
},
"adminSkills": {
"title": "Aktivierungs-Warteschlange",
"subtitle": "Kundenanfragen für Pakete, die manuelle plattformseitige Einrichtung benötigen. Genehmigen, sobald die Konfiguration steht; ablehnen mit Grund, wenn die Aktivierung nicht möglich ist.",
"backToAdmin": "Zurück zur Verwaltung",
"emptyQueue": "Keine ausstehenden Skill-Aktivierungsanfragen.",
"requestedAtCol": "Angefragt",
"skillCol": "Skill",
"tenantCol": "Tenant",
"orgCol": "Organisation",
"actionsCol": "",
"approveBtn": "Genehmigen",
"rejectBtn": "Ablehnen",
"confirmRejectBtn": "Ablehnung bestätigen",
"working": "Arbeite…",
"cancel": "Abbrechen",
"reasonLabel": "Grund (wird dem Kunden angezeigt)",
"reasonPlaceholder": "Erklären Sie, warum die Aktivierung nicht erfolgen kann — z. B. fehlende Kundendaten, Hardware nicht verfügbar usw.",
"reasonRequired": "Ein Grund ist für die Ablehnung erforderlich."
},
"customerBilling": {
"title": "Abrechnung",
"subtitle": "Aktueller Zeitraum und Rechnungshistorie. Ausgestellte Rechnungen stehen als PDF-Download bereit.",
"backToBilling": "Zurück zur Abrechnung",
"currentPeriodHeading": "Aktueller Zeitraum",
"historyHeading": "Rechnungshistorie",
"computing": "Berechne aktuellen Periodenbetrag…",
"currentPeriodError": "Aktueller Periodenbetrag konnte nicht geladen werden. Bitte später erneut versuchen.",
"noBillingConfig": "Abrechnungsdaten sind noch nicht hinterlegt. Sobald die Rechnungsadresse Ihrer Organisation eingetragen ist, erscheint hier der laufende Betrag.",
"accruedSoFar": "Bisher in diesem Monat",
"estimatedTotal": "Geschätzter Gesamtbetrag",
"currentInvoiceIssued": "Aktueller Monat bereits abgerechnet",
"refresh": "aktualisieren",
"breakdownToggle": "Aufschlüsselung anzeigen ({count} Positionen)",
"draftNote": "Live-Schätzung. Die endgültige Rechnung kann durch Monatsendrundung, nachgemeldete Nutzungsdaten oder manuelle Anpassungen leicht abweichen.",
"emptyHistory": "Noch keine Rechnungen ausgestellt. Nach Abschluss Ihres ersten Monats erscheinen sie hier.",
"numberCol": "Nummer",
"periodCol": "Zeitraum",
"dueCol": "Fällig",
"totalCol": "Gesamt",
"statusCol": "Status",
"descriptionCol": "Beschreibung",
"qtyCol": "Menge",
"unitCol": "Einzelpreis",
"amountCol": "Betrag",
"billedToLabel": "Rechnungsempfänger",
"issuedAtLabel": "Ausgestellt",
"dueAtLabel": "Zahlbar bis",
"paidAtLabel": "Bezahlt am",
"subtotalLabel": "Zwischensumme",
"vatLabel": "MWST ({rate}%)",
"totalLabel": "Gesamt",
"downloadPdf": "PDF herunterladen",
"status": {
"draft": "Entwurf",
"open": "Offen",
"paid": "Bezahlt",
"overdue": "Überfällig",
"void": "Storniert",
"uncollectible": "Uneinbringlich"
},
"payWithCard": "Mit Karte bezahlen",
"redirectingToStripe": "Weiterleitung…",
"paymentReceived": "Zahlung erhalten — vielen Dank!",
"paymentCancelled": "Zahlung abgebrochen.",
"configureBillingCta": "Rechnungsdaten einrichten",
"noBillingConfigNonOwner": "Nur der Organisations-Owner kann die Rechnungsdaten einrichten. Bitte wenden Sie sich an diese Person, um diesen Schritt abzuschliessen."
},
"adminCron": {
"title": "Abrechnungsautomatisierung",
"subtitle": "Monatliche Rechnungsstellung und tägliche Mahnungsläufe. Beides läuft automatisch; mit den Schaltflächen unten können Sie einen Lauf manuell auslösen.",
"monthlyIssue": "Monatliche Rechnungsstellung",
"reminders": "Mahnungen",
"scheduleIssueLabel": "Zeitplan",
"scheduleIssueValue": "00:30 Europe/Zurich am 1.",
"scheduleReminderLabel": "Zeitplan",
"scheduleReminderValue": "09:00 Europe/Zurich täglich",
"lastSuccess": "Letzter Erfolg",
"never": "nie",
"runIssueNow": "Letzten Monat jetzt abrechnen",
"runRemindersNow": "Mahnungslauf jetzt starten",
"running": "Läuft…",
"flashIssueOk": "Rechnungsstellung abgeschlossen: {success} Rechnungen erstellt, {skipped} übersprungen, {failure} fehlgeschlagen.",
"flashRemindersOk": "Mahnungen versendet: {success} erfolgreich, {skipped} übersprungen, {failure} fehlgeschlagen.",
"recentRuns": "Letzte Läufe (max. 30)",
"noRunsYet": "Noch keine Automatisierungsläufe erfasst.",
"startedCol": "Gestartet",
"kindCol": "Art",
"triggeredByCol": "Ausgelöst von",
"okCol": "OK",
"skipCol": "Übersprungen",
"failCol": "Fehler",
"triggeredByCron": "Cron",
"kind": {
"monthly_issue": "Rechnungsstellung",
"reminders": "Mahnungen"
},
"failureBannerTitle": "Fehler in jüngsten Automatisierungsläufen",
"failureBannerBody": "{count} Lauf/Läufe im aktuellen Fenster haben mindestens einen Fehler gemeldet. Bitte die Tabelle unten prüfen — betroffene Zeilen sind rot hervorgehoben."
}
}

View File

@@ -15,7 +15,8 @@
"team": "Team",
"settings": "Settings",
"optional": "optional",
"support": "Support"
"support": "Support",
"billing": "Billing"
},
"login": {
"title": "PieCed Portal",
@@ -120,7 +121,8 @@
"saveChanges": "Save changes",
"billingVatNumber": "VAT number",
"billingVatHelp": "Your registered VAT identifier. If your company is VAT-exempt, leave blank and explain in the notes field.",
"billingNotesPlaceholderPersonal": "Anything we should know — preferred payment method, billing reference, etc."
"billingNotesPlaceholderPersonal": "Anything we should know — preferred payment method, billing reference, etc.",
"reviewContactPersonPrefix": "Attn:"
},
"dashboard": {
"title": "Dashboard",
@@ -189,7 +191,21 @@
"last30Days": "Last 30 Days",
"noData": "No usage data available.",
"dailyBreakdown": "Daily Breakdown",
"requests": "requests"
"requests": "requests",
"budgetEdit": "Edit",
"budgetEditTitle": "Set spending budget",
"budgetEditDescription": "Cap how much this tenant's assistants can spend before requests start being declined.",
"budgetModeUnlimited": "No limit",
"budgetModeUnlimitedDescription": "Spend as much as needed; no cap.",
"budgetModeCapped": "Set a cap",
"budgetModeCappedDescription": "Stop accepting requests once spend reaches this amount.",
"budgetAmount": "Amount",
"budgetResetCadence": "Reset",
"budgetCadence_30d": "Every 30 days",
"budgetCadence_1mo": "Monthly",
"budgetCadence_1y": "Yearly",
"budgetInvalid": "Please enter a positive amount.",
"budgetSaveFailed": "Could not save budget. Please try again."
},
"workspace": {
"save": "Save",
@@ -200,7 +216,8 @@
"packages": {
"categories": {
"channels": "Channels",
"skills": "Skills"
"skills": "Skills",
"core": "Core"
},
"enable": "Enable",
"disable": "Disable",
@@ -225,29 +242,84 @@
"botTokenLabel": "Discord Bot Token",
"botTokenPlaceholder": "MTAxNjQ0OTk2NjAz...",
"instructions": "1. Go to discord.com/developers/applications\n2. Create a new application and add a bot\n3. Copy the bot token",
"disclaimer": "I confirm I own this Discord bot and authorize PieCed IT to connect it to my AI assistant."
"disclaimer": "I confirm I own this Discord bot and authorize PieCed IT to connect it to my AI assistant.",
"appIdLabel": "Discord Application ID",
"appIdPlaceholder": "18-19 digit numeric ID from Developer Portal"
},
"email": {
"description": "Enable your AI assistant to send and receive email.",
"statusEnabled": "enabled",
"statusDisabled": "disabled",
"coreHeartbeat": {
"description": "Periodic agent run every 30 minutes that lets your assistant check inbox, calendar, and other configured sources and message you proactively when something needs attention. Without this, the assistant only responds when you message it first."
},
"coreCron": {
"description": "Allow the assistant to run scheduled tasks (daily briefings, recurring reminders, periodic reports). Off by default. When off, the agent's cron tool stays available but no scheduled job ever fires."
},
"coreActiveMemory": {
"description": "Lets the assistant recall stable preferences, recurring habits, and long-term context from past conversations during a chat. Uses an extra sub-agent turn per inbound message to query the memory store. Direct-message sessions only. Adds a small token cost in exchange for continuity and personalisation."
},
"coreVoice": {
"description": "Speech-to-text on incoming voice notes and text-to-speech on replies, routed through the PieCed LiteLLM gateway so audio cost is tracked per tenant alongside chat. Runtime wiring lands in the next platform release; toggling now stores the preference for that rollout."
},
"gitCli": {
"description": "Standalone git command-line operations (clone, commit, branch, diff, log, status). For private repositories, configure credentials in your workspace."
},
"github": {
"description": "Interact with GitHub repositories via the gh CLI — issues, pull requests, CI runs, releases, gists. Requires a personal access token.",
"tokenLabel": "GitHub Personal Access Token",
"tokenPlaceholder": "ghp_… or github_pat_…",
"instructions": "1. Open https://github.com/settings/tokens\n2. Generate a fine-grained personal access token with the repo scopes you want the assistant to use\n3. Copy the token (it's shown only once)"
},
"gitea": {
"description": "Interact with a Gitea instance — repositories, issues, pull requests, releases. Defaults to the PieCed-platform Gitea at git.c5ai.ch.",
"tokenLabel": "Gitea Access Token",
"tokenPlaceholder": "Generated under Settings → Applications",
"instructions": "1. Log in to your Gitea instance (default https://git.c5ai.ch)\n2. Go to Settings → Applications → Generate New Token\n3. Grant the scopes you want the assistant to use (repo, issue, user)\n4. Copy the token"
},
"whisperSelfHosted": {
"description": "Transcribe audio files via the platform's self-hosted Whisper instance. Useful for ad-hoc transcription tasks initiated from chat."
},
"searxngLocalSearch": {
"description": "Privacy-respecting web search via the platform's internal SearXNG instance. Search the web, images, and news without external API calls or trackers."
},
"gog": {
"description": "Bundled access to Gmail, Calendar, Drive, Docs, Sheets, and Contacts via Google OAuth. Setup requires a Google Cloud project — contact PieCed support to onboard.",
"clientIdLabel": "Google OAuth Client ID",
"clientIdPlaceholder": "xxxxxxxxxxx.apps.googleusercontent.com",
"clientSecretLabel": "Google OAuth Client Secret",
"clientSecretPlaceholder": "GOCSPX-…",
"refreshTokenLabel": "Google OAuth Refresh Token",
"refreshTokenPlaceholder": "1//0g…",
"instructions": "Google Workspace uses OAuth. Create an OAuth client in your Google Cloud project, authorize it with the scopes you need (Gmail, Calendar, Drive, etc.), then paste the credentials below. Submission stores them securely and queues your activation for admin review — once approved, the integration activates automatically.",
"disclaimer": "By enabling Google Workspace integration you authorize PieCed to access Gmail, Calendar, Drive, Docs, Sheets, and Contacts on your behalf. Data flows through Google's APIs subject to Google's terms."
},
"mail": {
"description": "Read, search, and manage email via IMAP; send via SMTP. Works with Gmail (with an app password), Outlook, Fastmail, and any standard IMAP/SMTP host.",
"imapHostLabel": "IMAP Host",
"imapHostPlaceholder": "imap.example.com",
"imapUserLabel": "IMAP Username",
"imapUserPlaceholder": "user@example.com",
"imapPassLabel": "IMAP Password",
"imapPassPlaceholder": "••••••••",
"smtpHostLabel": "SMTP Host",
"smtpHostPlaceholder": "smtp.example.com",
"smtpUserLabel": "SMTP Username",
"smtpUserPlaceholder": "user@example.com",
"smtpPasswordLabel": "SMTP Password",
"smtpPasswordPlaceholder": "••••••••",
"imapHostLabel": "IMAP Host",
"imapHostPlaceholder": "imap.example.com",
"instructions": "Provide SMTP and IMAP credentials. The assistant uses these to send and monitor messages.",
"disclaimer": "I confirm I am authorized to use these email credentials and that PieCed IT may access this mailbox."
"smtpPassLabel": "SMTP Password",
"smtpPassPlaceholder": "••••••••",
"instructions": "1. For Gmail: enable 2-Step Verification, then create an App Password at https://myaccount.google.com/apppasswords and use it as both IMAP and SMTP password.\n2. For Outlook / Microsoft 365 with MFA: generate an app password in your account's security settings.\n3. For other providers: refer to their IMAP/SMTP documentation for host names and ports.\n4. Typical IMAP hosts: imap.gmail.com, outlook.office365.com.\n5. Typical SMTP hosts: smtp.gmail.com, smtp.office365.com.",
"disclaimer": "The assistant gains read/write access to the mailbox you configure. Consider using a dedicated address rather than a personal inbox if you want to limit scope."
},
"webSearch": {
"description": "Give your AI assistant the ability to search the web."
"threema": {
"description": "Send and receive messages through Threema. Each inbound and outbound message uses the shared PieCed messaging service and incurs a per-message charge from Threema — a third-party cost, separate from your PieCed subscription.",
"instructions": "1. Enable this package.\n2. Open Threema on your phone, scan the QR code shown under Authorized Users → threema, and accept the contact.\n3. Add your own Threema ID under Authorized Users → threema so the assistant recognises your messages.\n4. Send a message from Threema to start chatting with the assistant.",
"disclaimer": "Messages between Threema and PieCed are end-to-end encrypted up to PieCed's messaging service, where they are decrypted to be routed to your assistant. Each message sent or received is counted toward Threema's per-message billing — see your plan for current rates."
},
"documentProcessing": {
"description": "Enable document parsing, summarization, and extraction."
},
"statusEnabled": "enabled",
"statusDisabled": "disabled"
"manualReviewPending": "Manual review pending",
"withdraw": "Withdraw",
"activationRejected": "Rejected",
"tryAgain": "Try again",
"credentialsSaved": "credentials saved",
"credentialsSavedTip": "The credentials you entered are securely stored and will be used as soon as admin approves the activation. You don't need to re-enter them."
},
"admin": {
"title": "Platform Admin",
@@ -319,7 +391,11 @@
"statusDown": "Down",
"spendChf": "Spend (CHF)",
"resumeRequestBadge": "Resume",
"resumeRequestTooltip": "Reactivation request for an existing tenant. Approving will un-suspend the tenant; no provisioning runs."
"resumeRequestTooltip": "Reactivation request for an existing tenant. Approving will un-suspend the tenant; no provisioning runs.",
"openclawTool": "OpenClaw versions",
"billingTool": "Billing →",
"skillsQueueTool": "Activation Queue",
"cronTool": "Automation"
},
"channelUsers": {
"title": "Authorized Users",
@@ -331,7 +407,17 @@
"alreadyAdded": "This user ID is already authorized.",
"telegramIdHelp": "To find your Telegram user ID:\n1. Open Telegram and message @userinfobot\n2. It instantly replies with your numeric ID\n3. Enter that number here",
"discordIdHelp": "To find your Discord user ID:\n1. Enable Developer Mode in Discord settings (Advanced)\n2. Right-click your name → Copy User ID\n3. Enter that number here",
"emailIdHelp": "Enter the email address that should be authorized to interact with the assistant."
"threemaIdHelp": "Enter your own Threema ID — the 8 characters shown in your Threema app under Settings → My Threema ID. Once added, you'll be able to chat with the assistant directly from Threema.",
"threemaSetup": {
"title": "Add the assistant to your Threema",
"step1": "Open Threema on your phone.",
"step2": "Tap the scan icon and scan this QR code to add the assistant as a contact.",
"step3": "Then add your own Threema ID below.",
"qrAlt": "QR code to add {gateway} as a Threema contact",
"bannerTitle": "Set up Threema",
"bannerBody": "Open Threema on your phone and scan our QR code to add the assistant as a contact. Then add your own Threema ID below.",
"bannerButton": "Show QR code"
}
},
"team": {
"title": "Team",
@@ -397,25 +483,31 @@
"billingDescriptionPersonal": "Address and invoice email used for all your tenants."
},
"settingsBilling": {
"title": "Billing",
"subtitle": "Captured once at first onboarding and reused for every tenant in your organization. Update here whenever your billing details change.",
"companyName": "Company name",
"streetAddress": "Street address",
"postalCode": "Postal code",
"city": "City",
"country": "Country",
"vatNumber": "VAT number",
"vatHelp": "Your registered VAT identifier (e.g. CHE-123.456.789 MWST for Switzerland).",
"billingEmail": "Billing email",
"billingEmailHelp": "Where invoices and billing communication will be sent.",
"notes": "Notes",
"notesPlaceholder": "Anything else accounting needs to know — VAT exemption, special invoicing arrangements, etc.",
"save": "Save",
"title": "Billing details",
"subtitle": "Your company's billing address, VAT number, and invoice contact. Required before invoices can be issued for your organization.",
"companyNameLabel": "Company name",
"streetAddressLabel": "Street address",
"postalCodeLabel": "Postal code",
"cityLabel": "City",
"countryLabel": "Country code",
"countryHint": "ISO 3166-1 alpha-2 — e.g. CH, DE, AT, FR, IT, GB, US",
"vatNumberLabel": "VAT number (optional)",
"vatNumberHint": "For Swiss customers: CHE-XXX.XXX.XXX MWST. EU customers with a VAT number get a 0% reverse-charge invoice.",
"billingEmailLabel": "Billing email",
"billingEmailHint": "Invoices and payment reminders are sent here. Can differ from your account email.",
"notesLabel": "Notes (optional)",
"notesHint": "Reference numbers, purchase order tags, or anything else you'd like printed on invoices.",
"saveChanges": "Save changes",
"createBilling": "Save billing details",
"saving": "Saving…",
"saved": "Saved.",
"saveFailed": "Could not save. Please try again.",
"lastUpdated": "Last updated {when}",
"fullName": "Full name",
"notesPlaceholderPersonal": "Anything we should know — preferred payment method, billing reference, etc."
"missingRequired": "Please fill in all required fields.",
"invalidCountry": "Country code must be 2 letters (e.g. CH).",
"invalidEmail": "Please enter a valid email address.",
"fullNameLabel": "Full name",
"subtitlePersonal": "Your billing address and invoice contact. Required before invoices can be issued.",
"contactNameLabel": "Contact person (optional)",
"contactNameHint": "Prints as 'Attn: <name>' on the invoice below the company name. Useful for AP routing in larger organizations."
},
"support": {
"title": "Support",
@@ -459,5 +551,238 @@
"resolvedBanner": "This ticket is resolved. Reply below if you need to follow up — that will reopen it.",
"adminControlsTitle": "Admin controls",
"updateFailed": "Could not save changes. Please try again."
},
"openclawAdmin": {
"title": "OpenClaw versions",
"subtitle": "Configure the platform-default OpenClaw image tag and per-tenant overrides for testing new releases.",
"defaultSection": "Platform default",
"defaultDescription": "Used by every tenant that doesn't have its own override.",
"fieldTag": "Tag",
"emptyHint": "Leave empty to fall back to the operator's built-in default.",
"saveDefault": "Save default",
"defaultSaved": "Default saved. Tenants without overrides will pick this up on the next reconcile.",
"saveFailed": "Could not save. Please try again.",
"overridesSection": "Tenant overrides",
"noTenants": "No tenants in the cluster.",
"statusOverridden": "Override",
"statusFollowsDefault": "Follows default",
"builtinFallback": "(operator built-in fallback)",
"defaultPrefix": "Default:",
"saveOverride": "Save override",
"clearOverride": "Clear override"
},
"adminBilling": {
"title": "Billing administration",
"subtitle": "Manage platform pricing, generate invoices, and review billing status across all organizations.",
"backToAdmin": "Back to Admin",
"backToBilling": "Back to Billing",
"backToInvoices": "Back to Invoices",
"totalOpenBalance": "Total open balance",
"orgsWithBalance": "Orgs with balance",
"overdueInvoices": "Overdue invoices",
"pricingTitle": "Pricing",
"pricingDesc": "Platform & skill prices, VAT rate.",
"pricingPageDesc": "Edit platform-wide pricing and per-skill daily rates.",
"generateTitle": "Generate invoice",
"generateDesc": "Compute and issue an invoice for a given org & month.",
"generatePageDesc": "Pick an org, period and locale. Preview shows the computed lines; commit issues the invoice and renders the PDF.",
"invoicesTitle": "Invoices",
"invoicesDesc": "Browse all issued invoices, mark paid, download PDFs.",
"invoicesPageDesc": "All invoices issued by the platform. Use the status filter to focus on open or overdue items.",
"balancesTitle": "Orgs with open balance",
"orgIdCol": "Zitadel org ID",
"openCountCol": "Open",
"overdueCountCol": "Overdue",
"totalOpenCol": "Total open",
"platformPricingTitle": "Platform pricing",
"monthlyFeeLabel": "Tenant monthly fee",
"setupFeeLabel": "Tenant setup fee",
"threemaMessageLabel": "Threema per message",
"vatRateLabel": "VAT rate (CH/LI)",
"save": "Save",
"saving": "Saving…",
"savedOk": "Saved",
"skillPricingTitle": "Package pricing",
"skillPricingDesc": "Set per-day rate and one-time setup fee for any package — core, channel, or skill. Pricing applies to every tenant that enables the package.",
"skillCol": "Package",
"dailyPriceCol": "Daily price",
"actionsCol": "",
"remove": "Remove",
"noSkillsPriced": "No packages priced yet.",
"addSkillLabel": "Add package",
"dailyPriceLabel": "Daily price",
"add": "Add",
"confirmDeleteSkillPrice": "Remove pricing for {skill}? Already-billed periods are unaffected.",
"clickToEdit": "Click to edit",
"generateFormTitle": "Generate invoice",
"noOrgsToGenerate": "No organizations with tenants found.",
"orgLabel": "Organization",
"noBillingAddrTag": "no billing address",
"noBillingAddrWarning": "This org has no billing address on file. The customer must complete /settings/billing before an invoice can be issued.",
"tenantsLabel": "tenants",
"yearLabel": "Year",
"monthLabel": "Month",
"localeLabel": "PDF language",
"localeAuto": "Auto",
"previewBtn": "Preview",
"commitBtn": "Commit & issue",
"computing": "Computing…",
"confirmGenerate": "Issue this invoice? This action allocates an invoice number and renders the PDF.",
"previewTitle": "Draft preview",
"warningsTitle": "Warnings",
"noLinesGenerated": "No billable lines for this period.",
"descCol": "Description",
"qtyCol": "Qty",
"unitPriceCol": "Unit price",
"amountCol": "Amount (CHF)",
"subtotal": "Subtotal",
"vat": "VAT",
"total": "Total",
"statusFilterLabel": "Status",
"allStatuses": "All",
"monthFilterLabel": "Period",
"clearFilter": "Clear",
"loading": "Loading…",
"noInvoicesFound": "No invoices match the current filters.",
"invoiceNumberCol": "Number",
"orgCol": "Organization",
"periodCol": "Period",
"statusCol": "Status",
"totalCol": "Total",
"dueCol": "Due",
"status_draft": "Draft",
"status_open": "Open",
"status_paid": "Paid",
"status_overdue": "Overdue",
"status_void": "Void",
"status_uncollectible": "Uncollectible",
"dueOnLabel": "Due",
"totalLabel": "Total",
"downloadPdfBtn": "Download PDF",
"markPaidBtn": "Mark as paid",
"paidNotePlaceholder": "Optional note (e.g. bank reference, deposit date)",
"confirm": "Confirm",
"cancel": "Cancel",
"deleteBtn": "Delete",
"deleting": "Deleting…",
"deleteHint": "Hard-delete this invoice (testing tool). Number is consumed.",
"confirmDeleteInvoice": "Delete invoice {num}? This is a hard delete — the invoice number stays consumed.",
"paidOnLabel": "Paid",
"lineItemsTitle": "Line items",
"billToSnapshotTitle": "Billed to",
"setupFeeCol": "Setup fee",
"skillSetupFeeLabel": "Setup fee"
},
"skillCostDialog": {
"title": "Confirm activation cost",
"intro": "Activating {skill} will incur the following charges:",
"setupFeeLabel": "Setup fee",
"setupFeeNote": "One-time, charged on first activation only",
"monthlyPriceLabel": "Monthly price",
"monthlyPriceNote": "CHF {daily}/day enabled; partial months prorated by day",
"monthUnit": "month",
"disclaimer": "These charges appear on your next monthly invoice. By confirming you agree to incur them.",
"cancel": "Cancel",
"confirm": "Confirm & activate",
"confirming": "Activating…"
},
"adminSkills": {
"title": "Activation queue",
"subtitle": "Customer requests to activate packages that need manual platform-side setup. Approve once configuration is in place; reject with a reason if the activation can't proceed.",
"backToAdmin": "Back to Admin",
"emptyQueue": "No pending skill activation requests.",
"requestedAtCol": "Requested",
"skillCol": "Skill",
"tenantCol": "Tenant",
"orgCol": "Organization",
"actionsCol": "",
"approveBtn": "Approve",
"rejectBtn": "Reject",
"confirmRejectBtn": "Confirm rejection",
"working": "Working…",
"cancel": "Cancel",
"reasonLabel": "Reason (shown to the customer)",
"reasonPlaceholder": "Explain why this can't be activated — e.g. missing customer data, hardware unavailable, etc.",
"reasonRequired": "A reason is required to reject."
},
"customerBilling": {
"title": "Billing",
"subtitle": "Your current period and invoice history. Issued invoices are available as PDF downloads.",
"backToBilling": "Back to billing",
"currentPeriodHeading": "Current period",
"historyHeading": "Invoice history",
"computing": "Computing current period total…",
"currentPeriodError": "Could not load the current period total. Please try again later.",
"noBillingConfig": "Billing details haven't been configured yet. Once your organization's billing address is on file, this widget will show the running total.",
"accruedSoFar": "Accrued this month",
"estimatedTotal": "Estimated total",
"currentInvoiceIssued": "Current month already invoiced",
"refresh": "refresh",
"breakdownToggle": "Show breakdown ({count} line items)",
"draftNote": "Live estimate. The final invoice may differ slightly due to end-of-month rounding, late-arriving usage data, or manual adjustments.",
"emptyHistory": "No invoices issued yet. Once your first month closes, you'll see it here.",
"numberCol": "Number",
"periodCol": "Period",
"dueCol": "Due",
"totalCol": "Total",
"statusCol": "Status",
"descriptionCol": "Description",
"qtyCol": "Qty",
"unitCol": "Unit",
"amountCol": "Amount",
"billedToLabel": "Billed to",
"issuedAtLabel": "Issued",
"dueAtLabel": "Due by",
"paidAtLabel": "Paid on",
"subtotalLabel": "Subtotal",
"vatLabel": "VAT ({rate}%)",
"totalLabel": "Total",
"downloadPdf": "Download PDF",
"status": {
"draft": "Draft",
"open": "Open",
"paid": "Paid",
"overdue": "Overdue",
"void": "Void",
"uncollectible": "Uncollectible"
},
"payWithCard": "Pay with card",
"redirectingToStripe": "Redirecting…",
"paymentReceived": "Payment received — thank you!",
"paymentCancelled": "Payment cancelled.",
"configureBillingCta": "Configure billing details",
"noBillingConfigNonOwner": "Only the organization owner can configure billing details. Please contact them to complete this step."
},
"adminCron": {
"title": "Billing automation",
"subtitle": "Monthly issuance and daily reminder sweeps. Both run automatically; use the buttons below to trigger a sweep on demand.",
"monthlyIssue": "Monthly issuance",
"reminders": "Reminders",
"scheduleIssueLabel": "Schedule",
"scheduleIssueValue": "00:30 Europe/Zurich on the 1st",
"scheduleReminderLabel": "Schedule",
"scheduleReminderValue": "09:00 Europe/Zurich daily",
"lastSuccess": "Last success",
"never": "never",
"runIssueNow": "Run last month's issuance now",
"runRemindersNow": "Run reminder sweep now",
"running": "Running…",
"flashIssueOk": "Issuance complete: {success} invoices issued, {skipped} skipped, {failure} failed.",
"flashRemindersOk": "Reminders sent: {success} succeeded, {skipped} skipped, {failure} failed.",
"recentRuns": "Recent runs (last 30)",
"noRunsYet": "No automation runs recorded yet.",
"startedCol": "Started",
"kindCol": "Kind",
"triggeredByCol": "Triggered by",
"okCol": "OK",
"skipCol": "Skipped",
"failCol": "Failed",
"triggeredByCron": "cron",
"kind": {
"monthly_issue": "Issuance",
"reminders": "Reminders"
},
"failureBannerTitle": "Recent automation failures detected",
"failureBannerBody": "{count} run(s) in the recent window reported at least one failure. Review the table below — the affected rows are highlighted in red."
}
}

View File

@@ -15,7 +15,8 @@
"team": "Équipe",
"settings": "Paramètres",
"optional": "facultatif",
"support": "Support"
"support": "Support",
"billing": "Facturation"
},
"login": {
"title": "Portail PieCed",
@@ -120,7 +121,8 @@
"saveChanges": "Enregistrer les modifications",
"billingVatNumber": "Numéro de TVA",
"billingVatHelp": "Votre identifiant TVA enregistré. Si votre entreprise est exonérée de TVA, laissez vide et précisez dans les notes.",
"billingNotesPlaceholderPersonal": "Tout ce que nous devons savoir — moyen de paiement préféré, référence de facturation, etc."
"billingNotesPlaceholderPersonal": "Tout ce que nous devons savoir — moyen de paiement préféré, référence de facturation, etc.",
"reviewContactPersonPrefix": "À l'attention de"
},
"dashboard": {
"title": "Tableau de bord",
@@ -189,7 +191,21 @@
"last30Days": "30 derniers jours",
"noData": "Aucune donnée d'utilisation disponible.",
"dailyBreakdown": "Détail journalier",
"requests": "requêtes"
"requests": "requêtes",
"budgetEdit": "Modifier",
"budgetEditTitle": "Définir un budget",
"budgetEditDescription": "Limitez la dépense des assistants de ce locataire avant que les requêtes ne soient refusées.",
"budgetModeUnlimited": "Aucune limite",
"budgetModeUnlimitedDescription": "Dépense libre, sans plafond.",
"budgetModeCapped": "Définir un plafond",
"budgetModeCappedDescription": "Refuser les requêtes une fois ce montant atteint.",
"budgetAmount": "Montant",
"budgetResetCadence": "Réinitialisation",
"budgetCadence_30d": "Tous les 30 jours",
"budgetCadence_1mo": "Mensuelle",
"budgetCadence_1y": "Annuelle",
"budgetInvalid": "Veuillez saisir un montant positif.",
"budgetSaveFailed": "Impossible d'enregistrer le budget. Veuillez réessayer."
},
"workspace": {
"save": "Enregistrer",
@@ -200,7 +216,8 @@
"packages": {
"categories": {
"channels": "Canaux",
"skills": "Compétences"
"skills": "Compétences",
"core": "Cœur"
},
"enable": "Activer",
"disable": "Désactiver",
@@ -225,29 +242,84 @@
"botTokenLabel": "Token du bot Discord",
"botTokenPlaceholder": "MTAxNjQ0OTk2NjAz...",
"instructions": "1. Allez sur discord.com/developers/applications\n2. Créez une nouvelle application et ajoutez un bot\n3. Copiez le token du bot",
"disclaimer": "Je confirme que je possède ce bot Discord et autorise PieCed IT à le connecter à mon assistant IA."
"disclaimer": "Je confirme que je possède ce bot Discord et autorise PieCed IT à le connecter à mon assistant IA.",
"appIdLabel": "ID d'application Discord",
"appIdPlaceholder": "ID numérique de 1819 chiffres depuis le Developer Portal"
},
"email": {
"description": "Permettez à votre assistant IA d'envoyer et de recevoir des e-mails.",
"statusEnabled": "activé",
"statusDisabled": "désactivé",
"coreHeartbeat": {
"description": "Exécution périodique de l'agent toutes les 30 minutes pour vérifier votre boîte mail, votre agenda et d'autres sources configurées, et vous notifier de manière proactive lorsqu'une attention est requise. Sans cette option, l'assistant ne répond que lorsque vous lui écrivez."
},
"coreCron": {
"description": "Permet à l'assistant d'exécuter des tâches programmées (briefings quotidiens, rappels récurrents, rapports périodiques). Désactivé par défaut. Lorsqu'il est désactivé, l'outil cron reste disponible mais aucune tâche planifiée ne s'exécute."
},
"coreActiveMemory": {
"description": "Permet à l'assistant de se rappeler des préférences stables, des habitudes récurrentes et du contexte à long terme issu de conversations passées. Utilise un tour de sous-agent supplémentaire par message entrant pour interroger la mémoire. Uniquement en messages directs. Légère consommation de tokens supplémentaire en échange de continuité et de personnalisation."
},
"coreVoice": {
"description": "Reconnaissance vocale sur les notes vocales entrantes et synthèse vocale sur les réponses, via la passerelle PieCed LiteLLM pour un suivi du coût audio par tenant. L'intégration runtime arrive dans la prochaine version de la plateforme ; basculer le commutateur enregistre dès maintenant la préférence."
},
"gitCli": {
"description": "Opérations git en ligne de commande autonomes (clone, commit, branch, diff, log, status). Pour les dépôts privés, configurez les identifiants dans votre espace de travail."
},
"github": {
"description": "Interagissez avec les dépôts GitHub via la CLI gh — issues, pull requests, exécutions CI, releases, gists. Nécessite un jeton d'accès personnel.",
"tokenLabel": "Jeton d'accès personnel GitHub",
"tokenPlaceholder": "ghp_… ou github_pat_…",
"instructions": "1. Ouvrez https://github.com/settings/tokens\n2. Générez un jeton d'accès personnel fin avec les portées de dépôt souhaitées\n3. Copiez le jeton (il n'est affiché qu'une fois)"
},
"gitea": {
"description": "Interagissez avec une instance Gitea — dépôts, issues, pull requests, releases. Par défaut, l'instance Gitea PieCed à git.c5ai.ch.",
"tokenLabel": "Jeton d'accès Gitea",
"tokenPlaceholder": "Généré sous Paramètres → Applications",
"instructions": "1. Connectez-vous à votre instance Gitea (par défaut https://git.c5ai.ch)\n2. Allez dans Paramètres → Applications → Générer un nouveau jeton\n3. Accordez les portées souhaitées (repo, issue, user)\n4. Copiez le jeton"
},
"whisperSelfHosted": {
"description": "Transcrivez des fichiers audio via l'instance Whisper auto-hébergée de la plateforme. Utile pour les transcriptions ad hoc initiées depuis le chat."
},
"searxngLocalSearch": {
"description": "Recherche web respectueuse de la vie privée via l'instance SearXNG interne de la plateforme. Recherchez le web, les images et les actualités sans appels d'API externes ni traqueurs."
},
"gog": {
"description": "Accès groupé à Gmail, Agenda, Drive, Docs, Sheets et Contacts via Google OAuth. La configuration nécessite un projet Google Cloud — contactez le support PieCed pour l'intégration.",
"clientIdLabel": "ID client Google OAuth",
"clientIdPlaceholder": "xxxxxxxxxxx.apps.googleusercontent.com",
"clientSecretLabel": "Secret client Google OAuth",
"clientSecretPlaceholder": "GOCSPX-…",
"refreshTokenLabel": "Jeton de rafraîchissement Google OAuth",
"refreshTokenPlaceholder": "1//0g…",
"instructions": "Google Workspace utilise OAuth. Créez un client OAuth dans votre projet Google Cloud, autorisez-le avec les scopes nécessaires (Gmail, Agenda, Drive, etc.), puis collez les identifiants ci-dessous. La soumission les stocke en sécurité et place votre activation dans la file de revue administrative — après approbation, l'intégration s'active automatiquement.",
"disclaimer": "En activant l'intégration de Google Workspace, vous autorisez PieCed à accéder à Gmail, Agenda, Drive, Docs, Sheets et Contacts en votre nom. Les données transitent par les API de Google, soumises aux conditions de Google."
},
"mail": {
"description": "Lisez, recherchez et gérez vos e-mails via IMAP ; envoyez via SMTP. Compatible avec Gmail (avec un mot de passe d'application), Outlook, Fastmail et tout hôte IMAP/SMTP standard.",
"imapHostLabel": "Hôte IMAP",
"imapHostPlaceholder": "imap.example.com",
"imapUserLabel": "Nom d'utilisateur IMAP",
"imapUserPlaceholder": "utilisateur@example.com",
"imapPassLabel": "Mot de passe IMAP",
"imapPassPlaceholder": "••••••••",
"smtpHostLabel": "Hôte SMTP",
"smtpHostPlaceholder": "smtp.example.com",
"smtpUserLabel": "Nom d'utilisateur SMTP",
"smtpUserPlaceholder": "user@example.com",
"smtpPasswordLabel": "Mot de passe SMTP",
"smtpPasswordPlaceholder": "••••••••",
"imapHostLabel": "Hôte IMAP",
"imapHostPlaceholder": "imap.example.com",
"instructions": "Fournissez les identifiants SMTP et IMAP. L'assistant les utilise pour envoyer et surveiller les messages.",
"disclaimer": "Je confirme que je suis autorisé à utiliser ces identifiants e-mail et que PieCed IT peut accéder à cette boîte mail."
"smtpUserPlaceholder": "utilisateur@example.com",
"smtpPassLabel": "Mot de passe SMTP",
"smtpPassPlaceholder": "••••••••",
"instructions": "1. Pour Gmail : activez la validation en deux étapes, puis créez un mot de passe d'application sur https://myaccount.google.com/apppasswords et utilisez-le comme mot de passe IMAP et SMTP.\n2. Pour Outlook / Microsoft 365 avec MFA : générez un mot de passe d'application dans les paramètres de sécurité de votre compte.\n3. Pour les autres fournisseurs : consultez leur documentation IMAP/SMTP pour les noms d'hôte et les ports.\n4. Hôtes IMAP typiques : imap.gmail.com, outlook.office365.com.\n5. Hôtes SMTP typiques : smtp.gmail.com, smtp.office365.com.",
"disclaimer": "L'assistant obtient un accès en lecture/écriture à la boîte aux lettres que vous configurez. Envisagez d'utiliser une adresse dédiée plutôt qu'une boîte personnelle si vous souhaitez limiter la portée."
},
"webSearch": {
"description": "Donnez à votre assistant IA la capacité de rechercher sur le web."
"threema": {
"description": "Envoyez et recevez des messages via Threema. Chaque message entrant ou sortant transite par le service de messagerie PieCed partagé et entraîne des frais par message facturés par Threema — un coût tiers, distinct de votre abonnement PieCed.",
"instructions": "1. Activez ce package.\n2. Ouvrez Threema sur votre téléphone, scannez le QR code affiché dans Utilisateurs autorisés → threema, puis acceptez le contact.\n3. Ajoutez votre propre identifiant Threema sous Utilisateurs autorisés → threema afin que l'assistant reconnaisse vos messages.\n4. Envoyez un message depuis Threema pour commencer la conversation.",
"disclaimer": "Les messages entre Threema et PieCed sont chiffrés de bout en bout jusqu'au service de messagerie PieCed, où ils sont déchiffrés pour être acheminés vers votre assistant. Chaque message envoyé ou reçu est facturé par Threema selon son tarif par message — consultez votre plan pour les tarifs en vigueur."
},
"documentProcessing": {
"description": "Activez l'analyse, le résumé et l'extraction de documents."
},
"statusEnabled": "activé",
"statusDisabled": "désactivé"
"manualReviewPending": "Revue manuelle en attente",
"withdraw": "Retirer",
"activationRejected": "Refusée",
"tryAgain": "Réessayer",
"credentialsSaved": "identifiants enregistrés",
"credentialsSavedTip": "Les identifiants saisis sont stockés en sécurité et seront utilisés dès l'approbation de l'activation par l'administrateur. Vous n'avez pas besoin de les ressaisir."
},
"admin": {
"title": "Admin plateforme",
@@ -319,7 +391,11 @@
"statusDown": "Hors service",
"spendChf": "Coûts (CHF)",
"resumeRequestBadge": "Reprise",
"resumeRequestTooltip": "Demande de réactivation d'un locataire existant. L'approbation le réactivera ; aucun provisionnement ne s'exécute."
"resumeRequestTooltip": "Demande de réactivation d'un locataire existant. L'approbation le réactivera ; aucun provisionnement ne s'exécute.",
"openclawTool": "Versions OpenClaw",
"billingTool": "Facturation →",
"skillsQueueTool": "File d'activation",
"cronTool": "Automatisation"
},
"channelUsers": {
"title": "Utilisateurs autorisés",
@@ -331,7 +407,17 @@
"alreadyAdded": "Cet identifiant est déjà autorisé.",
"telegramIdHelp": "Pour trouver votre identifiant Telegram :\n1. Ouvrez Telegram et envoyez un message à @userinfobot\n2. Il répond instantanément avec votre identifiant numérique\n3. Entrez ce numéro ici",
"discordIdHelp": "Pour trouver votre identifiant Discord :\n1. Activez le mode développeur dans les paramètres Discord (Avancé)\n2. Clic droit sur votre nom → Copier l'identifiant\n3. Entrez ce numéro ici",
"emailIdHelp": "Entrez l'adresse e-mail qui doit être autorisée à interagir avec l'assistant."
"threemaIdHelp": "Saisissez votre propre identifiant Threema — les 8 caractères affichés dans votre application Threema sous Réglages → Mon identifiant Threema. Une fois ajouté, vous pourrez discuter directement avec l'assistant depuis Threema.",
"threemaSetup": {
"title": "Ajouter l'assistant à Threema",
"step1": "Ouvrez Threema sur votre téléphone.",
"step2": "Appuyez sur l'icône de scan et scannez ce QR code pour ajouter l'assistant comme contact.",
"step3": "Puis ajoutez votre propre identifiant Threema ci-dessous.",
"qrAlt": "QR code pour ajouter {gateway} comme contact Threema",
"bannerTitle": "Configurer Threema",
"bannerBody": "Ouvrez Threema sur votre téléphone et scannez notre QR code pour ajouter l'assistant comme contact. Saisissez ensuite votre propre identifiant Threema ci-dessous.",
"bannerButton": "Afficher le QR code"
}
},
"team": {
"title": "Équipe",
@@ -397,25 +483,31 @@
"billingDescriptionPersonal": "Adresse et e-mail de facturation utilisés pour tous vos locataires."
},
"settingsBilling": {
"title": "Facturation",
"subtitle": "Saisie une fois lors de l'inscription et réutilisée pour chaque locataire de votre organisation. Mettez à jour ici dès que vos coordonnées de facturation changent.",
"companyName": "Nom de l'entreprise",
"streetAddress": "Adresse",
"postalCode": "Code postal",
"city": "Ville",
"country": "Pays",
"vatNumber": "Numéro de TVA",
"vatHelp": "Votre identifiant TVA enregistré (par ex. CHE-123.456.789 TVA pour la Suisse).",
"billingEmail": "E-mail de facturation",
"billingEmailHelp": "Adresse à laquelle les factures et la communication de facturation seront envoyées.",
"notes": "Notes",
"notesPlaceholder": "Tout ce que la comptabilité doit savoir exonération de TVA, modalités de facturation particulières, etc.",
"save": "Enregistrer",
"title": "Informations de facturation",
"subtitle": "Adresse de facturation, numéro de TVA et contact pour les factures. Requis avant l'émission de toute facture pour votre organisation.",
"companyNameLabel": "Nom de l'entreprise",
"streetAddressLabel": "Adresse",
"postalCodeLabel": "Code postal",
"cityLabel": "Ville",
"countryLabel": "Code pays",
"countryHint": "ISO 3166-1 alpha-2 — p. ex. CH, DE, AT, FR, IT, GB, US",
"vatNumberLabel": "Numéro de TVA (facultatif)",
"vatNumberHint": "Pour les clients suisses : CHE-XXX.XXX.XXX TVA. Les clients UE avec un n° de TVA reçoivent une facture à 0% (autoliquidation).",
"billingEmailLabel": "E-mail de facturation",
"billingEmailHint": "Les factures et rappels de paiement sont envoyés à cette adresse. Peut différer de l'e-mail du compte.",
"notesLabel": "Notes (facultatif)",
"notesHint": "Numéros de référence, bons de commande, ou toute autre information à imprimer sur les factures.",
"saveChanges": "Enregistrer les modifications",
"createBilling": "Enregistrer les informations",
"saving": "Enregistrement…",
"saved": "Enregistré.",
"saveFailed": "Impossible d'enregistrer. Veuillez réessayer.",
"lastUpdated": "Dernière mise à jour {when}",
"fullName": "Nom complet",
"notesPlaceholderPersonal": "Tout ce que nous devons savoir — moyen de paiement préféré, référence de facturation, etc."
"missingRequired": "Veuillez remplir tous les champs obligatoires.",
"invalidCountry": "Le code pays doit comporter 2 lettres (p. ex. CH).",
"invalidEmail": "Veuillez saisir une adresse e-mail valide.",
"fullNameLabel": "Nom et prénom",
"subtitlePersonal": "Votre adresse de facturation et votre contact. Requis avant l'émission de toute facture.",
"contactNameLabel": "Personne à contacter (facultatif)",
"contactNameHint": "S'imprime « À l'attention de <nom> » sur la facture, sous le nom de l'entreprise. Utile pour le routage en comptabilité dans les grandes organisations."
},
"support": {
"title": "Support",
@@ -459,5 +551,238 @@
"resolvedBanner": "Ce ticket est résolu. Répondez ci-dessous si vous avez besoin d'un suivi — cela le rouvrira.",
"adminControlsTitle": "Contrôles admin",
"updateFailed": "Impossible d'enregistrer les modifications. Veuillez réessayer."
},
"openclawAdmin": {
"title": "Versions OpenClaw",
"subtitle": "Configurer le tag par défaut de la plateforme et les surcharges par locataire pour tester les nouvelles versions.",
"defaultSection": "Défaut de la plateforme",
"defaultDescription": "Utilisé par tous les locataires sans surcharge propre.",
"fieldTag": "Tag",
"emptyHint": "Laisser vide pour utiliser le défaut intégré de l'opérateur.",
"saveDefault": "Enregistrer le défaut",
"defaultSaved": "Défaut enregistré. Les locataires sans surcharge l'appliqueront au prochain réconcile.",
"saveFailed": "Échec de l'enregistrement. Veuillez réessayer.",
"overridesSection": "Surcharges par locataire",
"noTenants": "Aucun locataire dans le cluster.",
"statusOverridden": "Surcharge",
"statusFollowsDefault": "Suit le défaut",
"builtinFallback": "(repli intégré)",
"defaultPrefix": "Défaut :",
"saveOverride": "Enregistrer la surcharge",
"clearOverride": "Supprimer la surcharge"
},
"adminBilling": {
"title": "Administration de la facturation",
"subtitle": "Gérer les tarifs de la plateforme, générer des factures et examiner le statut de facturation des organisations.",
"backToAdmin": "Retour à l'administration",
"backToBilling": "Retour à la facturation",
"backToInvoices": "Retour aux factures",
"totalOpenBalance": "Solde ouvert total",
"orgsWithBalance": "Organisations avec solde",
"overdueInvoices": "Factures en retard",
"pricingTitle": "Tarifs",
"pricingDesc": "Tarifs plateforme & skills, taux TVA.",
"pricingPageDesc": "Modifier les tarifs de la plateforme et les prix journaliers par skill.",
"generateTitle": "Générer une facture",
"generateDesc": "Calculer et émettre une facture pour une organisation et un mois.",
"generatePageDesc": "Choisir une organisation, une période et une langue. L'aperçu affiche les lignes calculées; valider émet la facture et génère le PDF.",
"invoicesTitle": "Factures",
"invoicesDesc": "Parcourir les factures, marquer comme payées, télécharger les PDF.",
"invoicesPageDesc": "Toutes les factures émises par la plateforme. Utiliser le filtre de statut pour cibler les éléments ouverts ou en retard.",
"balancesTitle": "Organisations avec solde ouvert",
"orgIdCol": "ID org Zitadel",
"openCountCol": "Ouvert",
"overdueCountCol": "En retard",
"totalOpenCol": "Total ouvert",
"platformPricingTitle": "Tarifs plateforme",
"monthlyFeeLabel": "Forfait mensuel tenant",
"setupFeeLabel": "Frais de configuration tenant",
"threemaMessageLabel": "Threema par message",
"vatRateLabel": "Taux TVA (CH/LI)",
"save": "Enregistrer",
"saving": "Enregistrement…",
"savedOk": "Enregistré",
"skillPricingTitle": "Tarification des paquets",
"skillPricingDesc": "Tarif journalier et frais de configuration uniques pour chaque paquet — core, canal ou skill. La tarification s'applique à chaque tenant activant le paquet.",
"skillCol": "Paquet",
"dailyPriceCol": "Prix/jour",
"actionsCol": "",
"remove": "Retirer",
"noSkillsPriced": "Aucun paquet tarifé.",
"addSkillLabel": "Ajouter un paquet",
"dailyPriceLabel": "Prix/jour",
"add": "Ajouter",
"confirmDeleteSkillPrice": "Supprimer la tarification de {skill} ? Les périodes déjà facturées ne sont pas affectées.",
"clickToEdit": "Cliquer pour modifier",
"generateFormTitle": "Générer une facture",
"noOrgsToGenerate": "Aucune organisation avec tenants trouvée.",
"orgLabel": "Organisation",
"noBillingAddrTag": "pas d'adresse de facturation",
"noBillingAddrWarning": "Cette organisation n'a pas d'adresse de facturation enregistrée. Le client doit compléter /settings/billing avant qu'une facture puisse être émise.",
"tenantsLabel": "tenants",
"yearLabel": "Année",
"monthLabel": "Mois",
"localeLabel": "Langue PDF",
"localeAuto": "Auto",
"previewBtn": "Aperçu",
"commitBtn": "Valider & émettre",
"computing": "Calcul…",
"confirmGenerate": "Émettre cette facture? Cette action attribue un numéro de facture et génère le PDF.",
"previewTitle": "Aperçu du brouillon",
"warningsTitle": "Avertissements",
"noLinesGenerated": "Aucune ligne facturable pour cette période.",
"descCol": "Description",
"qtyCol": "Qté",
"unitPriceCol": "Prix unitaire",
"amountCol": "Montant (CHF)",
"subtotal": "Sous-total",
"vat": "TVA",
"total": "Total",
"statusFilterLabel": "Statut",
"allStatuses": "Tous",
"monthFilterLabel": "Période",
"clearFilter": "Effacer",
"loading": "Chargement…",
"noInvoicesFound": "Aucune facture ne correspond aux filtres.",
"invoiceNumberCol": "Numéro",
"orgCol": "Organisation",
"periodCol": "Période",
"statusCol": "Statut",
"totalCol": "Total",
"dueCol": "Échéance",
"status_draft": "Brouillon",
"status_open": "Ouverte",
"status_paid": "Payée",
"status_overdue": "En retard",
"status_void": "Annulée",
"status_uncollectible": "Irrécouvrable",
"dueOnLabel": "Échéance",
"totalLabel": "Total",
"downloadPdfBtn": "Télécharger le PDF",
"markPaidBtn": "Marquer comme payée",
"paidNotePlaceholder": "Note facultative (ex. référence bancaire, date de paiement)",
"confirm": "Confirmer",
"cancel": "Annuler",
"deleteBtn": "Supprimer",
"deleting": "Suppression…",
"deleteHint": "Suppression définitive (outil de test). Le numéro reste utilisé.",
"confirmDeleteInvoice": "Supprimer la facture {num}? Suppression définitive — le numéro reste utilisé.",
"paidOnLabel": "Payée le",
"lineItemsTitle": "Lignes",
"billToSnapshotTitle": "Destinataire",
"setupFeeCol": "Frais de configuration",
"skillSetupFeeLabel": "Frais de configuration"
},
"skillCostDialog": {
"title": "Confirmer le coût d'activation",
"intro": "L'activation de {skill} entraînera les frais suivants :",
"setupFeeLabel": "Frais de configuration",
"setupFeeNote": "Unique, facturé uniquement à la première activation",
"monthlyPriceLabel": "Prix mensuel",
"monthlyPriceNote": "CHF {daily}/jour actif ; mois partiels prorata journalier",
"monthUnit": "mois",
"disclaimer": "Ces frais figureront sur votre prochaine facture mensuelle. En confirmant, vous acceptez de les engager.",
"cancel": "Annuler",
"confirm": "Confirmer & activer",
"confirming": "Activation…"
},
"adminSkills": {
"title": "File d'activation",
"subtitle": "Demandes clients d'activation de paquets nécessitant une configuration manuelle côté plateforme. Approuver une fois la configuration en place ; refuser avec un motif si l'activation est impossible.",
"backToAdmin": "Retour à l'administration",
"emptyQueue": "Aucune demande d'activation en attente.",
"requestedAtCol": "Demandée le",
"skillCol": "Skill",
"tenantCol": "Tenant",
"orgCol": "Organisation",
"actionsCol": "",
"approveBtn": "Approuver",
"rejectBtn": "Refuser",
"confirmRejectBtn": "Confirmer le refus",
"working": "En cours…",
"cancel": "Annuler",
"reasonLabel": "Motif (visible par le client)",
"reasonPlaceholder": "Expliquez pourquoi l'activation ne peut pas se faire — ex. données client manquantes, matériel indisponible, etc.",
"reasonRequired": "Un motif est requis pour refuser."
},
"customerBilling": {
"title": "Facturation",
"subtitle": "Période en cours et historique des factures. Les factures émises sont disponibles en téléchargement PDF.",
"backToBilling": "Retour à la facturation",
"currentPeriodHeading": "Période en cours",
"historyHeading": "Historique des factures",
"computing": "Calcul du total de la période en cours…",
"currentPeriodError": "Impossible de charger le total de la période en cours. Veuillez réessayer plus tard.",
"noBillingConfig": "Les informations de facturation ne sont pas encore configurées. Une fois l'adresse de facturation de votre organisation enregistrée, le total en cours apparaîtra ici.",
"accruedSoFar": "Cumulé ce mois",
"estimatedTotal": "Total estimé",
"currentInvoiceIssued": "Mois en cours déjà facturé",
"refresh": "actualiser",
"breakdownToggle": "Afficher le détail ({count} lignes)",
"draftNote": "Estimation en direct. La facture finale peut légèrement varier en raison d'arrondis de fin de mois, de données d'utilisation tardives ou d'ajustements manuels.",
"emptyHistory": "Aucune facture émise pour le moment. Après la clôture de votre premier mois, elles apparaîtront ici.",
"numberCol": "Numéro",
"periodCol": "Période",
"dueCol": "Échéance",
"totalCol": "Total",
"statusCol": "Statut",
"descriptionCol": "Description",
"qtyCol": "Qté",
"unitCol": "Prix unitaire",
"amountCol": "Montant",
"billedToLabel": "Facturé à",
"issuedAtLabel": "Émise le",
"dueAtLabel": "À régler avant",
"paidAtLabel": "Payée le",
"subtotalLabel": "Sous-total",
"vatLabel": "TVA ({rate}%)",
"totalLabel": "Total",
"downloadPdf": "Télécharger le PDF",
"status": {
"draft": "Brouillon",
"open": "Ouverte",
"paid": "Payée",
"overdue": "En retard",
"void": "Annulée",
"uncollectible": "Irrécouvrable"
},
"payWithCard": "Payer par carte",
"redirectingToStripe": "Redirection…",
"paymentReceived": "Paiement reçu — merci !",
"paymentCancelled": "Paiement annulé.",
"configureBillingCta": "Configurer les informations de facturation",
"noBillingConfigNonOwner": "Seul le propriétaire de l'organisation peut configurer les informations de facturation. Veuillez le contacter pour terminer cette étape."
},
"adminCron": {
"title": "Automatisation de la facturation",
"subtitle": "Émission mensuelle et balayage quotidien des rappels. Les deux s'exécutent automatiquement ; utilisez les boutons ci-dessous pour déclencher un lancement à la demande.",
"monthlyIssue": "Émission mensuelle",
"reminders": "Rappels",
"scheduleIssueLabel": "Planning",
"scheduleIssueValue": "00:30 Europe/Zurich le 1er",
"scheduleReminderLabel": "Planning",
"scheduleReminderValue": "09:00 Europe/Zurich quotidien",
"lastSuccess": "Dernière réussite",
"never": "jamais",
"runIssueNow": "Facturer le mois dernier maintenant",
"runRemindersNow": "Lancer les rappels maintenant",
"running": "En cours…",
"flashIssueOk": "Émission terminée : {success} factures émises, {skipped} ignorées, {failure} échouées.",
"flashRemindersOk": "Rappels envoyés : {success} réussis, {skipped} ignorés, {failure} échoués.",
"recentRuns": "Lancements récents (30 derniers)",
"noRunsYet": "Aucun lancement automatique enregistré pour le moment.",
"startedCol": "Démarré",
"kindCol": "Type",
"triggeredByCol": "Déclenché par",
"okCol": "OK",
"skipCol": "Ignorés",
"failCol": "Échoués",
"triggeredByCron": "cron",
"kind": {
"monthly_issue": "Émission",
"reminders": "Rappels"
},
"failureBannerTitle": "Échecs récents détectés",
"failureBannerBody": "{count} lancement(s) récent(s) ont signalé au moins un échec. Consultez le tableau ci-dessous — les lignes concernées sont en rouge."
}
}

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