Compare commits

...

79 Commits

Author SHA1 Message Date
484696a8f5 feat(i18n): make language a user profile attribute (register/profile/login)
All checks were successful
Build and Push / build (push) Successful in 1m47s
2026-05-30 12:49:39 +02:00
ca1a014c01 feat(admin): add search, sorting and pagination to admin tables
All checks were successful
Build and Push / build (push) Successful in 1m43s
2026-05-30 12:24:30 +02:00
d01ab85cbb feat(admin): add search, sorting and pagination to admin tables 2026-05-30 12:23:32 +02:00
610572eafe feat(brand): replace placeholder mark with logo + favicon, fix connect button 2026-05-30 12:23:09 +02:00
73f1af185f feat(tenant): make connect panel dismissible after connecting
All checks were successful
Build and Push / build (push) Successful in 1m49s
2026-05-29 23:55:53 +02:00
c1833c1def feat(onboarding): show recurring monthly fee in the wizard cost summary
All checks were successful
Build and Push / build (push) Successful in 1m42s
2026-05-29 23:38:22 +02:00
521398b0fc feat(team): add access overview matrix for owners 2026-05-29 23:37:56 +02:00
74d276b656 refactor(admin): move approve/reject/delete dialogs onto shared Modal 2026-05-29 23:37:32 +02:00
3110b40cf9 fix(onboarding): explain blocked Next, humanise errors, de-jargon provisioning 2026-05-29 23:28:45 +02:00
08f28aeb93 localise chart + make daily data reachable on touch/keyboard 2026-05-29 23:28:15 +02:00
fb9c0ad25a add 'connect your assistant' guidance 2026-05-29 23:21:30 +02:00
322cfae824 require confirmation before approving tenant requests 2026-05-29 23:20:51 +02:00
7fac3c3aa8 keyboard radiogroup, modal focus trap, nav session hydration
All checks were successful
Build and Push / build (push) Successful in 1m53s
2026-05-29 22:46:03 +02:00
bff3aad1ca add error/loading/404 boundaries, responsive tables, Metadata API
All checks were successful
Build and Push / build (push) Successful in 1m49s
2026-05-29 22:32:08 +02:00
f2a9637058 mobile nav, locale-preserving navigation, accent button contrast
All checks were successful
Build and Push / build (push) Successful in 2m25s
2026-05-29 22:12:51 +02:00
bfc2194e24 Phase8: IT Language adjustments
All checks were successful
Build and Push / build (push) Successful in 1m46s
2026-05-29 17:04:24 +02:00
6f8de14b4a Phase8: Auto bill credit card
All checks were successful
Build and Push / build (push) Successful in 1m48s
2026-05-28 23:45:15 +02:00
a6ed74b1be Phase8: Auto bill credit card
All checks were successful
Build and Push / build (push) Successful in 1m45s
2026-05-28 23:27:32 +02:00
1741574eb2 Phase8: Auto bill credit card
All checks were successful
Build and Push / build (push) Successful in 1m54s
2026-05-28 23:03:46 +02:00
d78f9f2696 Phase8: Auto bill credit card
All checks were successful
Build and Push / build (push) Successful in 1m44s
2026-05-28 21:49:59 +02:00
3fe3597553 Phase8: Auto bill credit card
All checks were successful
Build and Push / build (push) Successful in 1m48s
2026-05-28 21:29:15 +02:00
9243beddd3 Phase8: Auto bill credit card
All checks were successful
Build and Push / build (push) Successful in 1m45s
2026-05-27 22:20:13 +02:00
a6c3c42ec9 Phase8: Auto bill credit card
Some checks failed
Build and Push / build (push) Failing after 1m2s
2026-05-27 22:12:25 +02:00
ee6bb89fb6 Phase8: Auto bill credit card
Some checks failed
Build and Push / build (push) Failing after 42s
2026-05-27 22:06:32 +02:00
ad4f614130 Phase8: Auto bill credit card
All checks were successful
Build and Push / build (push) Successful in 1m45s
2026-05-27 20:45:25 +02:00
8e7691d38a Phase8: Auto bill credit card
Some checks failed
Build and Push / build (push) Failing after 43s
2026-05-27 20:41:17 +02:00
9939f75c03 Phase7c: Fix Cronjob
All checks were successful
Build and Push / build (push) Successful in 1m44s
2026-05-26 23:43:04 +02:00
e69b68b73c Phase7b: Manual Invoice
All checks were successful
Build and Push / build (push) Successful in 1m46s
2026-05-26 23:14:53 +02:00
41c1553b1f Phase7b: Manual Invoice
Some checks failed
Build and Push / build (push) Failing after 57s
2026-05-26 23:12:03 +02:00
38f4c3243e Phase7b: Manual Invoice
Some checks failed
Build and Push / build (push) Failing after 54s
2026-05-26 23:08:07 +02:00
ed915ec539 Phase7b: Manual Invoice
Some checks failed
Build and Push / build (push) Failing after 59s
2026-05-26 23:04:09 +02:00
667617296b Phase7: Void/Refund logic
All checks were successful
Build and Push / build (push) Successful in 1m43s
2026-05-25 22:59:18 +02:00
1c61111da3 Phase7: Void/Refund logic
All checks were successful
Build and Push / build (push) Successful in 1m46s
2026-05-25 22:52:54 +02:00
6fed5b083b Phase7: Void/Refund logic
All checks were successful
Build and Push / build (push) Successful in 1m42s
2026-05-25 22:39:27 +02:00
4f868d751e Phase7: Void/Refund logic
All checks were successful
Build and Push / build (push) Successful in 1m43s
2026-05-25 22:00:24 +02:00
e15a668f8e Phase7: Void/Refund logic
Some checks failed
Build and Push / build (push) Failing after 52s
2026-05-25 21:54:51 +02:00
9cd9879a18 Phase6c: Optional Company contact name
All checks were successful
Build and Push / build (push) Successful in 1m42s
2026-05-25 20:21:26 +02:00
323786672f Phase6c: Optional Company contact name
All checks were successful
Build and Push / build (push) Successful in 1m42s
2026-05-25 14:08:18 +02:00
a1769eeb00 Phase6c: Optional Company contact name
All checks were successful
Build and Push / build (push) Successful in 1m40s
2026-05-25 13:50:16 +02:00
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
161 changed files with 26353 additions and 896 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,59 @@
import { notFound, redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { getSessionUser } from "@/lib/session";
import { getInvoiceDraftById, getOrgBilling } from "@/lib/db";
import { BackLink } from "@/components/ui/back-link";
import { CustomInvoiceEditor } from "@/components/admin/billing/custom-invoice-editor";
/**
* /admin/billing/invoice-drafts/[id] — full editor for an
* in-progress custom invoice.
*
* Phase 8. Server-loads the draft + the org's billing snapshot
* (used to display the bill-to block preview), then hands off to
* the client editor for the interactive line-management UI.
*
* The snapshot is loaded read-only for display. The actual VAT
* computation happens server-side at issue time via
* computeCustomInvoiceTotals, which re-reads the same snapshot.
* That two-time read is intentional: the editor's preview math
* is a hint, the issue-time read is authoritative — if the
* customer updates their billing address between Draft and Issue,
* the invoice reflects the new address.
*/
export default async function InvoiceDraftEditorPage({
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 draft = await getInvoiceDraftById(id);
if (!draft) notFound();
const orgBilling = await getOrgBilling(draft.zitadelOrgId).catch(() => null);
return (
<main className="max-w-5xl mx-auto px-6 py-8">
<BackLink
href="/admin/billing/invoice-drafts"
label={t("backToDrafts")}
/>
<div className="mb-6">
<h1 className="font-display text-2xl font-semibold accent-rule">
{t("editorPageTitle")}
</h1>
<p className="text-sm text-text-secondary mt-3">
{orgBilling?.companyName ?? draft.zitadelOrgId}
</p>
</div>
<CustomInvoiceEditor
draft={draft}
orgBilling={orgBilling}
/>
</main>
);
}

View File

@@ -0,0 +1,72 @@
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { getSessionUser } from "@/lib/session";
import { getOrgBilling, listAllInvoiceDrafts } from "@/lib/db";
import { listTenants } from "@/lib/k8s";
import { BackLink } from "@/components/ui/back-link";
import { DraftList } from "@/components/admin/billing/draft-list";
/**
* /admin/billing/invoice-drafts — list of all open custom-invoice
* drafts across orgs.
*
* Phase 8. Each draft is a JSONB blob the admin is composing into
* an invoice; visible only to platform admins. From here the admin
* can resume editing or discard.
*
* Building an org-name map by reading tenant labels (for the set of
* known orgs) + getOrgBilling per org (for the actual company name)
* so the table can show "Customer X" instead of a raw ZITADEL org id.
*/
export default async function AdminInvoiceDraftsPage() {
const user = await getSessionUser();
if (!user) redirect("/login");
if (!user.isPlatform) redirect("/dashboard");
const t = await getTranslations("adminBilling");
const [drafts, tenants] = await Promise.all([
listAllInvoiceDrafts(),
listTenants().catch(() => []),
]);
// Build the set of distinct ZITADEL org ids from tenant labels,
// PLUS the set referenced by any current draft. Drafts may target
// orgs that don't have tenants yet (rare but possible), so we
// union both sources before fetching billing rows.
const orgIds = new Set<string>();
for (const tnt of tenants) {
const oid = tnt.metadata.labels?.["pieced.ch/zitadel-org-id"];
if (oid) orgIds.add(oid);
}
for (const d of drafts) {
orgIds.add(d.zitadelOrgId);
}
// Look up billing in parallel — same pattern as
// /api/admin/billing/orgs uses. Failure for any single org is
// non-fatal (falls back to the raw id in the table).
const orgNamePairs = await Promise.all(
Array.from(orgIds).map(async (oid) => {
const billing = await getOrgBilling(oid).catch(() => null);
return [oid, billing?.companyName ?? null] as const;
})
);
const orgNameMap: Record<string, string> = {};
for (const [oid, name] of orgNamePairs) {
if (name) orgNameMap[oid] = name;
}
return (
<main className="max-w-5xl mx-auto px-6 py-8">
<BackLink href="/admin/billing" label={t("backToBilling")} />
<div className="mb-6">
<h1 className="font-display text-2xl font-semibold accent-rule">
{t("draftsPageTitle")}
</h1>
<p className="text-sm text-text-secondary mt-3">
{t("draftsPageSubtitle")}
</p>
</div>
<DraftList drafts={drafts} orgNameMap={orgNameMap} />
</main>
);
}

View File

@@ -0,0 +1,40 @@
import { notFound, redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { getSessionUser } from "@/lib/session";
import { getInvoiceDetail, listCreditNotesForInvoice } 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, void, refund, delete, PDF
* download) is a client component for the interactive bits.
*
* Phase 7: also passes any linked credit notes so the detail view
* can show the "this invoice was voided / partially refunded" panel
* without an extra round-trip.
*/
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();
const creditNotes = await listCreditNotesForInvoice(id);
return (
<main className="max-w-4xl mx-auto px-6 py-8">
<BackLink href="/admin/billing/invoices" label={t("backToInvoices")} />
<InvoiceDetailView detail={detail} creditNotes={creditNotes} />
</main>
);
}

View File

@@ -0,0 +1,72 @@
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 { NewInvoiceForm } from "@/components/admin/billing/new-invoice-form";
/**
* /admin/billing/invoices/new — entry point for the custom-invoice
* flow. The admin picks an org, clicks Continue, and lands on the
* editor at /admin/billing/invoice-drafts/<new-id>.
*
* Phase 8. Org list is built from tenant labels + each org's
* billing config (we need the company name and the
* has-billing-snapshot flag to gate the picker — orgs without a
* snapshot can't be invoiced until they complete onboarding or
* admin sets the billing info manually).
*/
export default async function NewInvoicePage() {
const user = await getSessionUser();
if (!user) redirect("/login");
if (!user.isPlatform) redirect("/dashboard");
const t = await getTranslations("adminBilling");
// Tenants give us org membership; getOrgBilling per org gives us
// the snapshot status. We dedupe by org id since one org can own
// many tenants.
const tenants = await listTenants();
const orgIds = new Set<string>();
for (const tnt of tenants) {
const oid = tnt.metadata.labels?.["pieced.ch/zitadel-org-id"];
if (oid) orgIds.add(oid);
}
const orgs = await Promise.all(
Array.from(orgIds).map(async (oid) => {
const billing = await getOrgBilling(oid).catch(() => null);
return {
zitadelOrgId: oid,
companyName: billing?.companyName ?? null,
country: billing?.country ?? null,
hasBillingAddress: !!billing && !!billing.companyName,
};
})
);
// Sort: orgs with billing first (admin's most likely target),
// then alphabetically by company name.
orgs.sort((a, b) => {
if (a.hasBillingAddress !== b.hasBillingAddress) {
return a.hasBillingAddress ? -1 : 1;
}
return (a.companyName ?? "").localeCompare(b.companyName ?? "");
});
return (
<main className="max-w-2xl mx-auto px-6 py-8">
<BackLink
href="/admin/billing/invoices"
label={t("backToInvoices")}
/>
<div className="mb-6">
<h1 className="font-display text-2xl font-semibold accent-rule">
{t("newInvoicePageTitle")}
</h1>
<p className="text-sm text-text-secondary mt-3">
{t("newInvoicePageSubtitle")}
</p>
</div>
<NewInvoiceForm orgs={orgs} />
</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,83 @@
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { getSessionUser } from "@/lib/session";
import { getOrgBilling, getOrgBillingConfig } from "@/lib/db";
import { listTenants } from "@/lib/k8s";
import { BackLink } from "@/components/ui/back-link";
import { OrgPaymentModeList } from "@/components/admin/billing/org-payment-mode-list";
/**
* /admin/billing/orgs — list of orgs with their payment mode
* settings.
*
* Phase 9b-2. The customer's /settings/billing only exposes the
* saved-card flow (auto-pay). Bank-transfer mode is admin-only —
* customer must contact support to request it, admin flips the
* pay_by_invoice flag here. Also exposes the auto_charge_enabled
* pause-switch for support situations.
*
* The page is intentionally minimal: org name, country, current
* mode, has-saved-card indicator, and toggles. Detail-level work
* (open balances, invoice list) is on the existing pages
* (/admin/billing, /admin/billing/invoices).
*/
export default async function AdminOrgsPaymentModePage() {
const user = await getSessionUser();
if (!user) redirect("/login");
if (!user.isPlatform) redirect("/dashboard");
const t = await getTranslations("adminBilling");
// Same org-discovery pattern as /api/admin/billing/orgs: tenant
// labels are the source of truth for org membership. We dedupe by
// org id since one org can own many tenants.
const tenants = await listTenants().catch(() => []);
const orgIds = new Set<string>();
for (const tnt of tenants) {
const oid = tnt.metadata.labels?.["pieced.ch/zitadel-org-id"];
if (oid) orgIds.add(oid);
}
const orgs = await Promise.all(
Array.from(orgIds).map(async (oid) => {
const [billing, cfg] = await Promise.all([
getOrgBilling(oid).catch(() => null),
getOrgBillingConfig(oid),
]);
return {
zitadelOrgId: oid,
companyName: billing?.companyName ?? null,
country: billing?.country ?? null,
hasSavedCard: !!cfg.stripeDefaultPaymentMethodId,
cardLabel:
cfg.stripePmBrand && cfg.stripePmLast4
? `${cfg.stripePmBrand} •••• ${cfg.stripePmLast4}`
: null,
payByInvoice: !!cfg.payByInvoice,
autoChargeEnabled: cfg.autoChargeEnabled !== false,
};
})
);
// Sort: orgs with billing first (most actionable), then by name.
orgs.sort((a, b) => {
if (!!a.companyName !== !!b.companyName) {
return a.companyName ? -1 : 1;
}
return (a.companyName ?? a.zitadelOrgId).localeCompare(
b.companyName ?? b.zitadelOrgId
);
});
return (
<main className="max-w-6xl mx-auto px-6 py-8">
<BackLink href="/admin/billing" label={t("backToBilling")} />
<div className="mb-6">
<h1 className="font-display text-2xl font-semibold accent-rule">
{t("orgsPageTitle")}
</h1>
<p className="text-sm text-text-secondary mt-3">
{t("orgsPageSubtitle")}
</p>
</div>
<OrgPaymentModeList orgs={orgs} />
</main>
);
}

View File

@@ -0,0 +1,136 @@
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-2 md:grid-cols-4 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>
<Link href="/admin/billing/orgs">
<Card interactive>
<div className="font-semibold mb-1">{t("orgsTitle")}</div>
<div className="text-sm text-text-muted">{t("orgsDesc")}</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>
<div className="overflow-x-auto">
<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>
</div>
</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,8 +2,14 @@ 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 async function generateMetadata() {
const t = await getTranslations("common");
return { title: t("admin") };
}
export default async function AdminPage() {
const user = await getSessionUser();
if (!user) redirect("/login");
@@ -19,14 +25,60 @@ 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">
<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 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">

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,90 @@
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { getSessionUser } from "@/lib/session";
import {
listCreditNotesForOrg,
listInvoices,
syncOverdueInvoices,
} from "@/lib/db";
import { CustomerInvoiceList } from "@/components/billing/customer-invoice-list";
import { CustomerCreditNoteList } from "@/components/billing/customer-credit-note-list";
import { RunningTotalWidget } from "@/components/billing/running-total-widget";
/**
* /billing — customer's billing home.
*
* Shows three 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.
* 3. CustomerCreditNoteList — Phase 7. Credit notes (voids and
* refunds) for this org, with PDF download links. Hidden
* entirely when there are none (the common case).
*
* Anyone signed in can view this. The data is org-scoped; even
* non-owner team members see the same view.
*/
export async function generateMetadata() {
const t = await getTranslations("common");
return { title: t("billing") };
}
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);
}
// Parallel fetch — invoices + credit notes are independent.
const [invoices, creditNotes] = await Promise.all([
listInvoices({ zitadelOrgId: user.orgId, limit: 200 }),
listCreditNotesForOrg(user.orgId, 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 mb-8">
<h2 className="text-xs font-semibold uppercase tracking-wider text-text-muted mb-3">
{t("historyHeading")}
</h2>
<CustomerInvoiceList invoices={invoices} />
</section>
{/* Phase 7: credit-note section. CustomerCreditNoteList itself
returns null when there are no credit notes, so this whole
section disappears for orgs in normal operation. */}
{creditNotes.length > 0 && (
<section className="animate-in animate-in-delay-3">
<h2 className="text-xs font-semibold uppercase tracking-wider text-text-muted mb-3">
{t("creditNotesHeading")}
</h2>
<CustomerCreditNoteList creditNotes={creditNotes} />
</section>
)}
</main>
);
}

View File

@@ -4,7 +4,7 @@ import { redirect } from "next/navigation";
import { OnboardingFlow } from "@/components/onboarding/onboarding-flow";
import { BackLink } from "@/components/ui/back-link";
import { listTenants } from "@/lib/k8s";
import { listActiveTenantRequestsByOrgId, getOrgBilling } from "@/lib/db";
import { listActiveTenantRequestsByOrgId, getOrgBilling, getPlatformPricing } from "@/lib/db";
import { personalAccountAtCapacity } from "@/lib/personal-org";
/**
@@ -55,7 +55,10 @@ export default async function NewInstancePage() {
}
const t = await getTranslations("dashboard");
const orgBilling = await getOrgBilling(user.orgId);
const [orgBilling, pricing] = await Promise.all([
getOrgBilling(user.orgId),
getPlatformPricing(),
]);
const hasOrgBilling = orgBilling !== null;
return (
@@ -76,6 +79,9 @@ export default async function NewInstancePage() {
userName={user.name}
userEmail={user.email}
hasOrgBilling={hasOrgBilling}
existingOrgBilling={orgBilling}
setupFeeChf={pricing.tenantSetupFeeChf}
monthlyFeeChf={pricing.tenantMonthlyFeeChf}
/>
</div>
</div>

View File

@@ -6,6 +6,7 @@ import {
listActiveTenantRequestsByOrgId,
syncProvisioningStatuses,
getOrgBilling,
getPlatformPricing,
} from "@/lib/db";
import {
listVisibleTenants,
@@ -21,6 +22,11 @@ import { ProvisioningStatus } from "@/components/onboarding/provisioning-status"
import { formatDateTime } from "@/lib/format";
import Link from "next/link";
export async function generateMetadata() {
const t = await getTranslations("common");
return { title: t("dashboard") };
}
export default async function DashboardPage() {
const user = await getSessionUser();
if (!user) redirect("/login");
@@ -192,6 +198,7 @@ export default async function DashboardPage() {
// component.
const orgBilling = await getOrgBilling(user.orgId);
const hasOrgBilling = orgBilling !== null;
const platformPricing = await getPlatformPricing();
// Pending requests that don't yet have a tenant CR. Once the CR
// exists, the tenant card carries the live phase, so a separate
@@ -317,6 +324,9 @@ export default async function DashboardPage() {
userName={user.name}
userEmail={user.email}
hasOrgBilling={hasOrgBilling}
existingOrgBilling={orgBilling}
setupFeeChf={platformPricing.tenantSetupFeeChf}
monthlyFeeChf={platformPricing.tenantMonthlyFeeChf}
/>
</div>
</div>
@@ -340,7 +350,7 @@ export default async function DashboardPage() {
{canCreate && (
<Link
href="/dashboard/new"
className="shrink-0 inline-flex items-center gap-1.5 py-2 px-4 bg-accent text-white text-xs font-medium rounded-lg hover:bg-accent-dim transition-colors"
className="shrink-0 inline-flex items-center gap-1.5 py-2 px-4 bg-accent text-surface-0 text-xs font-medium rounded-lg hover:bg-accent-dim transition-colors"
>
<span>+</span> {t("createInstance")}
</Link>

View File

@@ -0,0 +1,72 @@
"use client";
import { useEffect } from "react";
import { useTranslations } from "next-intl";
import { Link } from "@/i18n/navigation";
/**
* Error boundary for the [locale] segment. Catches render/data errors
* thrown by any page below the locale layout (which is where K8s, DB,
* LiteLLM and Stripe calls happen). Renders inside NextIntlClientProvider,
* so translations are available. Root-layout failures fall through to
* global-error.tsx instead.
*/
export default function LocaleError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
const t = useTranslations("errors");
useEffect(() => {
// Surface the error for log scraping; the digest correlates with
// the server-side stack in production.
console.error("Portal error boundary:", error);
}, [error]);
return (
<div className="flex min-h-[60vh] items-center justify-center px-5">
<div className="w-full max-w-md text-center">
<div className="mx-auto mb-5 flex h-14 w-14 items-center justify-center rounded-xl bg-error/10">
<svg
className="h-7 w-7 text-error"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.75}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M12 9v4M12 17h.01M10.3 3.86l-8.5 14.7A1.5 1.5 0 003.1 21h17.8a1.5 1.5 0 001.3-2.44l-8.5-14.7a1.5 1.5 0 00-2.6 0z" />
</svg>
</div>
<h1 className="font-display text-xl font-semibold text-text-primary mb-2">
{t("title")}
</h1>
<p className="text-sm text-text-secondary mb-6">{t("description")}</p>
{error?.digest && (
<p className="text-[11px] font-mono text-text-muted mb-6">
{error.digest}
</p>
)}
<div className="flex items-center justify-center gap-3">
<button
onClick={reset}
className="py-2 px-4 rounded-lg bg-accent text-surface-0 text-sm font-medium hover:bg-accent-dim transition-colors cursor-pointer"
>
{t("retry")}
</button>
<Link
href="/dashboard"
className="py-2 px-4 rounded-lg border border-border text-sm font-medium text-text-secondary hover:text-text-primary hover:bg-surface-2 transition-colors"
>
{t("backToDashboard")}
</Link>
</div>
</div>
</div>
);
}

View File

@@ -1,13 +1,36 @@
import type { Metadata, Viewport } from "next";
import { NextIntlClientProvider } from "next-intl";
import { getMessages } from "next-intl/server";
import { getMessages, getTranslations } from "next-intl/server";
import { routing } from "@/i18n/routing";
import { notFound } from "next/navigation";
import { auth } from "@/lib/auth";
import { NavShell } from "@/components/layout/nav-shell";
export function generateStaticParams() {
return routing.locales.map((locale) => ({ locale }));
}
// Metadata API (Next 15) instead of a hand-rolled <head>. The title
// template lets each page export a short `title` (e.g. "Dashboard")
// that renders as "Dashboard · PieCed". Pages that export no metadata
// fall back to the default below.
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations("common");
const appName = t("appName");
return {
title: {
default: `${appName} Portal`,
template: `%s · ${appName}`,
},
description: "PieCed IT — Multi-tenant AI assistant platform",
};
}
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
};
export default async function LocaleLayout({
children,
params,
@@ -22,20 +45,13 @@ export default async function LocaleLayout({
}
const messages = await getMessages();
const session = await auth();
return (
<html lang={locale} className="dark">
<head>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>PieCed Portal</title>
<meta
name="description"
content="PieCed IT — Multi-tenant AI assistant platform"
/>
</head>
<body className="min-h-screen bg-surface-0 text-text-primary antialiased">
<NextIntlClientProvider messages={messages}>
<NavShell>{children}</NavShell>
<NavShell session={session}>{children}</NavShell>
</NextIntlClientProvider>
</body>
</html>

View File

@@ -0,0 +1,25 @@
/**
* Loading skeleton for the [locale] segment. Shown during navigation
* while a server component fetches (the dashboard, for instance, does
* listTenants() + one K8s GET per provisioning row). Textless on
* purpose so it needs no translations and adds no layout shift.
*/
export default function LocaleLoading() {
return (
<div className="animate-pulse" aria-hidden="true">
<div className="mb-8">
<div className="h-7 w-48 rounded-md bg-surface-2" />
<div className="mt-4 h-4 w-72 rounded bg-surface-1" />
</div>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<div
key={i}
className="h-28 rounded-xl border border-border bg-surface-1"
/>
))}
</div>
<span className="sr-only">Loading</span>
</div>
);
}

View File

@@ -1,11 +1,13 @@
"use client";
import { signIn } from "next-auth/react";
import { useTranslations } from "next-intl";
import Link from "next/link";
import { useTranslations, useLocale } from "next-intl";
import { Link, getPathname } from "@/i18n/navigation";
import { Logo } from "@/components/ui/logo";
export default function LoginPage() {
const t = useTranslations("login");
const locale = useLocale();
return (
<div className="fixed inset-0 flex items-center justify-center bg-surface-0">
@@ -24,10 +26,7 @@ export default function LoginPage() {
<div className="relative z-10 w-full max-w-sm px-5 animate-in">
{/* Logo mark */}
<div className="flex justify-center mb-8">
<div className="relative h-12 w-12">
<div className="absolute inset-0 rounded-lg bg-accent/15" />
<div className="absolute inset-[5px] rounded-md bg-accent" />
</div>
<Logo className="h-14 w-auto text-accent" />
</div>
<div className="bg-surface-1 rounded-2xl border border-border p-8 shadow-2xl shadow-black/40">
@@ -39,7 +38,14 @@ export default function LoginPage() {
</p>
<button
onClick={() => signIn("zitadel", { callbackUrl: "/dashboard" })}
onClick={() =>
signIn("zitadel", {
// Preserve the active locale across the OIDC round-trip.
// A bare "/dashboard" would resolve to the default (de)
// locale on return; getPathname prefixes it as needed.
callbackUrl: getPathname({ href: "/dashboard", locale }),
})
}
className="
w-full py-3 px-4 rounded-lg font-medium text-sm
bg-accent text-surface-0 cursor-pointer

View File

@@ -0,0 +1,34 @@
import { getTranslations } from "next-intl/server";
import { Link } from "@/i18n/navigation";
/**
* 404 for the [locale] segment. Triggered by notFound() calls in pages
* below the locale layout. (A notFound() thrown by the locale layout
* itself — e.g. an unknown locale — resolves to the framework default,
* which is acceptable for that narrow case.)
*/
export default async function LocaleNotFound() {
const t = await getTranslations("errors");
return (
<div className="flex min-h-[60vh] items-center justify-center px-5">
<div className="w-full max-w-md text-center">
<div className="font-display text-5xl font-semibold text-accent mb-4 tabular-nums">
404
</div>
<h1 className="font-display text-xl font-semibold text-text-primary mb-2">
{t("notFoundTitle")}
</h1>
<p className="text-sm text-text-secondary mb-6">
{t("notFoundDescription")}
</p>
<Link
href="/dashboard"
className="inline-flex py-2 px-4 rounded-lg bg-accent text-surface-0 text-sm font-medium hover:bg-accent-dim transition-colors"
>
{t("backToDashboard")}
</Link>
</div>
</div>
);
}

View File

@@ -1,5 +1,13 @@
import { redirect } from "next/navigation";
import { redirect } from "@/i18n/navigation";
export default function RootPage() {
redirect("/dashboard");
export default async function RootPage({
params,
}: {
params: Promise<{ locale: string }>;
}) {
// Locale-aware redirect: a bare next/navigation redirect("/dashboard")
// drops the prefix and lands non-default-locale users on the German
// dashboard. The i18n redirect prefixes per the active locale.
const { locale } = await params;
redirect({ href: "/dashboard", locale });
}

View File

@@ -1,8 +1,8 @@
"use client";
import { useState } from "react";
import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
import { useState, useRef, forwardRef } from "react";
import { useTranslations, useLocale } from "next-intl";
import { useRouter, Link } from "@/i18n/navigation";
import { Card } from "@/components/ui/card";
type FormState = "idle" | "submitting" | "success" | "error";
@@ -41,15 +41,45 @@ export default function RegisterPage() {
const [accountType, setAccountType] = useState<AccountType | null>(null);
const locale = useLocale();
const [form, setForm] = useState({
companyName: "",
givenName: "",
familyName: "",
email: "",
// Default to the language the register page is being viewed in;
// the user can change it below. This becomes their ZITADEL
// preferredLanguage and the UI language they land on after login.
preferredLanguage: locale,
});
const [state, setState] = useState<FormState>("idle");
const [error, setError] = useState("");
// Radiogroup keyboard support. `role="radio"` requires roving
// tabindex (one tab stop) + arrow-key navigation between options —
// native buttons don't move focus on arrows. The selected card is
// the tab stop; when nothing is selected yet the first card is
// focusable so keyboard users can enter the group.
const TYPES: AccountType[] = ["personal", "company"];
const cardRefs = useRef<(HTMLButtonElement | null)[]>([]);
const rovingTabIndex = (type: AccountType, index: number) =>
accountType === type || (accountType === null && index === 0) ? 0 : -1;
const handleCardKeyDown = (e: React.KeyboardEvent, index: number) => {
let next: number | null = null;
if (e.key === "ArrowRight" || e.key === "ArrowDown") {
next = (index + 1) % TYPES.length;
} else if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
next = (index - 1 + TYPES.length) % TYPES.length;
}
if (next === null) return;
e.preventDefault();
setAccountType(TYPES[next]);
cardRefs.current[next]?.focus();
};
const isPersonal = accountType === "personal";
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
@@ -70,6 +100,7 @@ export default function RegisterPage() {
givenName: form.givenName,
familyName: form.familyName,
email: form.email,
preferredLanguage: form.preferredLanguage,
isPersonal,
};
if (!isPersonal) {
@@ -120,7 +151,7 @@ export default function RegisterPage() {
</p>
<button
onClick={() => router.push("/login")}
className="w-full py-2.5 px-4 bg-accent text-white text-sm font-medium rounded-lg hover:bg-accent-dim transition-colors"
className="w-full py-2.5 px-4 bg-accent text-surface-0 text-sm font-medium rounded-lg hover:bg-accent-dim transition-colors"
>
{t("goToLogin")}
</button>
@@ -146,8 +177,13 @@ export default function RegisterPage() {
className="grid grid-cols-2 gap-3 mb-6 animate-in animate-in-delay-1"
>
<AccountTypeCard
ref={(el) => {
cardRefs.current[0] = el;
}}
selected={accountType === "personal"}
onClick={() => setAccountType("personal")}
tabIndex={rovingTabIndex("personal", 0)}
onKeyDown={(e) => handleCardKeyDown(e, 0)}
label={t("personalCardTitle")}
description={t("personalCardDescription")}
icon={
@@ -168,8 +204,13 @@ export default function RegisterPage() {
}
/>
<AccountTypeCard
ref={(el) => {
cardRefs.current[1] = el;
}}
selected={accountType === "company"}
onClick={() => setAccountType("company")}
tabIndex={rovingTabIndex("company", 1)}
onKeyDown={(e) => handleCardKeyDown(e, 1)}
label={t("companyCardTitle")}
description={t("companyCardDescription")}
icon={
@@ -261,6 +302,29 @@ export default function RegisterPage() {
/>
</div>
{/* Preferred language */}
<div>
<label className="block text-xs font-semibold uppercase tracking-wider text-text-muted mb-1.5">
{t("languageLabel")}
</label>
<select
name="preferredLanguage"
value={form.preferredLanguage}
onChange={(e) =>
setForm((prev) => ({
...prev,
preferredLanguage: e.target.value,
}))
}
className="w-full px-3 py-2 bg-surface-2 border border-border rounded-lg text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-accent focus:border-accent transition-colors"
>
<option value="de">Deutsch</option>
<option value="en">English</option>
<option value="fr">Français</option>
<option value="it">Italiano</option>
</select>
</div>
{error && (
<div className="text-xs text-red-400 bg-red-400/10 border border-red-400/20 rounded-lg px-3 py-2">
{error}
@@ -270,7 +334,7 @@ export default function RegisterPage() {
<button
type="submit"
disabled={state === "submitting"}
className="w-full py-2.5 px-4 bg-accent text-white text-sm font-medium rounded-lg hover:bg-accent-dim transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
className="w-full py-2.5 px-4 bg-accent text-surface-0 text-sm font-medium rounded-lg hover:bg-accent-dim transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{state === "submitting" ? tCommon("loading") : t("submit")}
</button>
@@ -278,12 +342,12 @@ export default function RegisterPage() {
<p className="text-xs text-text-muted text-center mt-4">
{t("hasAccount")}{" "}
<a
<Link
href="/login"
className="text-accent hover:text-accent-dim transition-colors"
>
{tCommon("login")}
</a>
</Link>
</p>
</Card>
)}
@@ -305,41 +369,42 @@ export default function RegisterPage() {
* and text colours intensify when selected to give a clear "this one
* is on" signal beyond just the border colour.
*/
function AccountTypeCard({
selected,
onClick,
label,
description,
icon,
}: {
selected: boolean;
onClick: () => void;
label: string;
description: string;
icon: React.ReactNode;
}) {
const AccountTypeCard = forwardRef<
HTMLButtonElement,
{
selected: boolean;
onClick: () => void;
label: string;
description: string;
icon: React.ReactNode;
tabIndex: number;
onKeyDown: (e: React.KeyboardEvent) => void;
}
>(function AccountTypeCard(
{ selected, onClick, label, description, icon, tabIndex, onKeyDown },
ref
) {
return (
<button
ref={ref}
type="button"
role="radio"
aria-checked={selected}
tabIndex={tabIndex}
onClick={onClick}
onKeyDown={onKeyDown}
className={`text-left rounded-xl border p-4 transition-colors cursor-pointer focus:outline-none focus:ring-2 focus:ring-accent/40 ${
selected
? "border-accent bg-accent/10"
: "border-border bg-surface-2 hover:border-accent/40 hover:bg-surface-3/30"
}`}
>
<div
className={`mb-2 ${
selected ? "text-accent" : "text-text-muted"
}`}
>
<div className={`mb-2 ${selected ? "text-accent" : "text-text-muted"}`}>
{icon}
</div>
<div
className={`text-sm font-semibold mb-0.5 ${
selected ? "text-text-primary" : "text-text-primary"
selected ? "text-text-primary" : "text-text-secondary"
}`}
>
{label}
@@ -347,4 +412,4 @@ function AccountTypeCard({
<div className="text-xs text-text-muted leading-snug">{description}</div>
</button>
);
}
});

View File

@@ -1,30 +1,40 @@
import { getTranslations } from "next-intl/server";
import { redirect, notFound } from "next/navigation";
import { getSessionUser, canMutate } from "@/lib/session";
import { getOrgBilling } from "@/lib/db";
import { BillingSettingsForm } from "@/components/settings/billing-settings-form";
import { getTranslations } from "next-intl/server";
import { getSessionUser } from "@/lib/session";
import { getOrgBilling, getOrgBillingConfig } from "@/lib/db";
import { BillingSettingsForm } from "@/components/settings/billing-form";
import { SavedCardSection } from "@/components/settings/saved-card-section";
/**
* /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.
*
* Phase 9: also renders the saved-card section (Set up auto-pay /
* Visa dot-dot-dot 4242, expires MM/YY / Update card / Disable
* auto-pay / Remove card) when billing info is on file, plus a
* footer note explaining that bank transfer is available on request.
*/
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, config] = await Promise.all([
getOrgBilling(user.orgId),
getOrgBillingConfig(user.orgId),
]);
return (
<main className="max-w-3xl mx-auto px-6 py-8">
@@ -32,16 +42,30 @@ 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>
<BillingSettingsForm
initial={billing}
isPersonal={user.isPersonal}
orgName={user.orgName}
userName={user.name}
userEmail={user.email}
/>
<div className="animate-in animate-in-delay-1">
<BillingSettingsForm
initial={existing}
isPersonal={user.isPersonal}
/>
</div>
{/* Phase 9: saved-card section. Only shown once billing info
exists — without an address Stripe can't create the
customer object, so the "Set up auto-pay" button would
fail anyway. We give a clear hint up there if the form
is empty (no need to surface the card UI). */}
{existing && (
<div className="animate-in animate-in-delay-2 mt-8">
<SavedCardSection
config={config}
isPayByInvoice={!!config?.payByInvoice}
isPersonal={user.isPersonal}
/>
</div>
)}
</main>
);
}

View File

@@ -14,14 +14,20 @@ import { Card } from "@/components/ui/card";
* Access: any authenticated user (the cards themselves gate further;
* non-owner users would not see "Billing" as actionable, etc.).
*/
export async function generateMetadata() {
const t = await getTranslations("common");
return { title: t("settings") };
}
export default async function SettingsPage() {
const user = await getSessionUser();
if (!user) redirect("/login");
const t = await getTranslations("settings");
// Build the list of settings cards. Each entry has a stable key, a
// route, and a visibility predicate. Currently only billing; this
// shape leaves headroom for adding more without restructuring.
// route, and a visibility predicate. Phase 6 fix5: profile is
// visible to every signed-in user (it's their own identity).
// Billing stays gated behind canMutate.
const sections: Array<{
key: string;
href: string;
@@ -29,6 +35,14 @@ export default async function SettingsPage() {
description: string;
visible: boolean;
}> = [
{
key: "profile",
href: "/settings/profile",
title: t("profileTitle"),
description: t("profileDescription"),
// Every signed-in user can edit their own first/last name.
visible: true,
},
{
key: "billing",
href: "/settings/billing",

View File

@@ -0,0 +1,69 @@
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { getSessionUser } from "@/lib/session";
import { getHumanUserDetail } from "@/lib/zitadel";
import { ProfileSettingsForm } from "@/components/settings/profile-form";
/**
* /settings/profile — every authenticated user can edit their own
* first + last name. Email is shown read-only; changing it requires
* verification and is left to ZITADEL's own self-service flow.
*
* Personal vs company accounts:
* - Both can edit their first/last name in ZITADEL.
* - Personal accounts get an extra hint: editing the ZITADEL name
* does NOT change how the customer's name appears on invoices.
* Invoice identity is in org_billing.company_name (the "Full
* name" field on /settings/billing) and is intentionally
* editable separately, because legal/billing identity may not
* match preferred display identity.
* - Company accounts see an org-membership hint instead.
*
* Server-fetches the current profile from ZITADEL via the
* service-account PAT so the form starts with the canonical values
* rather than whatever happens to be in the JWT (the JWT name might
* be stale if the user updated their name in ZITADEL Console).
*/
export default async function ProfileSettingsPage() {
const user = await getSessionUser();
if (!user) redirect("/login");
const t = await getTranslations("settingsProfile");
let initial = { firstName: "", lastName: "", email: user.email, language: "" };
try {
const profile = await getHumanUserDetail(user.id);
initial = {
firstName: profile.givenName,
lastName: profile.familyName,
email: profile.email || user.email,
language: profile.preferredLanguage,
};
} catch (e) {
// Identity provider unreachable: render the form with whatever
// we know from the session. The session has a combined `name`,
// not split parts, so we leave first/last empty and let the user
// re-enter. Server logs catch the underlying failure.
console.error("ProfileSettingsPage: getHumanUserDetail failed:", e);
}
return (
<main className="max-w-3xl 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">
{user.isPersonal ? t("subtitlePersonal") : t("subtitle")}
</p>
</div>
<div className="animate-in animate-in-delay-1">
<ProfileSettingsForm
initial={initial}
isPersonal={user.isPersonal}
orgName={user.orgName}
/>
</div>
</main>
);
}

View File

@@ -24,6 +24,11 @@ import { TicketCategoryLabel } from "@/components/support/ticket-category-label"
* having recent activity, but we don't sort by status; that's a
* filter the admin can add later if the queue grows.
*/
export async function generateMetadata() {
const t = await getTranslations("common");
return { title: t("support") };
}
export default async function SupportListPage() {
const user = await getSessionUser();
if (!user) redirect("/login");
@@ -48,7 +53,7 @@ export default async function SupportListPage() {
{!user.isPlatform && (
<Link
href="/support/new"
className="text-sm font-medium px-4 py-2 rounded-lg bg-accent text-white hover:bg-accent/90 transition-colors"
className="text-sm font-medium px-4 py-2 rounded-lg bg-accent text-surface-0 hover:bg-accent/90 transition-colors"
>
{t("newTicket")}
</Link>

View File

@@ -6,6 +6,7 @@ import { Card } from "@/components/ui/card";
import { BackLink } from "@/components/ui/back-link";
import { TeamList } from "@/components/team/team-list";
import { InviteForm } from "@/components/team/invite-form";
import { AccessOverview } from "@/components/team/access-overview";
/**
* /team — manage org members.
@@ -17,6 +18,11 @@ import { InviteForm } from "@/components/team/invite-form";
* `<TeamList>` and `<InviteForm>` client components handle live
* updates after invites and refreshes.
*/
export async function generateMetadata() {
const t = await getTranslations("common");
return { title: t("team") };
}
export default async function TeamPage() {
const user = await getSessionUser();
if (!user) redirect("/login");
@@ -65,6 +71,16 @@ export default async function TeamPage() {
canEditRoles={isCustomerOwner(user)}
/>
</section>
{/* Access overview — single place to see which member can reach
which assistant, instead of checking each tenant page. */}
<section className="mt-8 animate-in animate-in-delay-3">
<h2 className="text-xs font-semibold uppercase tracking-wider text-text-muted mb-1">
{t("accessTitle")}
</h2>
<p className="text-xs text-text-muted mb-3">{t("accessDescription")}</p>
<AccessOverview />
</section>
</div>
);
}

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";
@@ -12,9 +16,17 @@ import { WorkspaceEditor } from "@/components/packages/workspace-editor";
import { ChannelUsers } from "@/components/channel-users/channel-users";
import { AssignedUsersPanel } from "@/components/tenants/assigned-users-panel";
import { SubscriptionToggle } from "@/components/tenants/subscription-toggle";
import { ConnectPanel } from "@/components/tenants/connect-panel";
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 +87,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
@@ -194,12 +217,26 @@ export default async function TenantDetailPage({
</div>
)}
{/* Connect: how the customer actually reaches their assistant.
The portal manages the assistant; the assistant lives in the
customer's messaging app. This bridges that gap right at the
top of the page (and calls out the case where no channel is
enabled, which would otherwise leave a running assistant
unreachable). */}
<section className="mb-8 animate-in animate-in-delay-1">
<ConnectPanel
tenantName={name}
enabledChannels={enabledChannels}
phase={tenant.status?.phase ?? "Pending"}
/>
</section>
{/* Usage */}
<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("usage")}
</h2>
<UsageDisplay tenant={name} />
<UsageDisplay tenant={name} canEditBudget={canEdit} />
</section>
{/* Packages */}
@@ -212,6 +249,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,64 @@
import { NextResponse } from "next/server";
import { getSessionUser, requirePlatformRole } from "@/lib/session";
import {
CustomInvoiceValidationError,
issueCustomInvoiceDraft,
} from "@/lib/billing";
import { safeError } from "@/lib/errors";
/**
* POST /api/admin/billing/invoice-drafts/[id]/issue
*
* Phase 8. Convert a draft into a real invoice:
* - Validate payload (must have lines, valid dates, billing snapshot)
* - Allocate invoice number from the shared year-scoped counter
* - Persist invoice with source='custom'
* - Render PDF
* - Email customer
* - Delete the draft
*
* Returns the issued Invoice on success. Errors map cleanly to
* HTTP codes:
* 400 — validation failure (CustomInvoiceValidationError)
* 404 — draft id doesn't exist (also CustomInvoiceValidationError
* since the orchestrator can't tell apart "draft missing"
* from "invalid input" — the message string discriminates)
* 500 — anything else (DB error, Stripe error not applicable here)
*
* Idempotency: this endpoint is NOT idempotent. Issuing twice
* allocates two invoice numbers. The admin UI disables the submit
* button while in-flight, but for safety the backend handles
* double-submit by failing on the second call (the draft was
* deleted by the first).
*/
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;
try {
const invoice = await issueCustomInvoiceDraft({
draftId: id,
issuedBy: user.id,
});
return NextResponse.json({ invoice });
} catch (e) {
if (e instanceof CustomInvoiceValidationError) {
return NextResponse.json({ error: e.message }, { status: 400 });
}
return NextResponse.json(
{ error: safeError(e, "Failed to issue custom invoice") },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,52 @@
import { NextResponse } from "next/server";
import { requirePlatformRole } from "@/lib/session";
import {
CustomInvoiceValidationError,
renderCustomDraftPreview,
} from "@/lib/billing";
import { safeError } from "@/lib/errors";
/**
* GET /api/admin/billing/invoice-drafts/[id]/preview
*
* Phase 8. Render the current draft as a PDF without persisting an
* invoice. The bytes are returned inline so the browser displays
* the document in a new tab. The invoice number on the rendered
* PDF is the placeholder "DRAFT" — no real number is allocated.
*
* Useful for the admin's "Review" step in the draft → review →
* issue flow.
*/
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;
try {
const pdf = await renderCustomDraftPreview(id);
return new NextResponse(new Uint8Array(pdf), {
status: 200,
headers: {
"Content-Type": "application/pdf",
// Inline so the browser displays the PDF immediately. The
// filename is a guide — most browsers ignore it for inline
// disposition but it shows on the "Save as" dialog.
"Content-Disposition": `inline; filename="invoice-draft-${id}.pdf"`,
"Cache-Control": "no-store",
},
});
} catch (e) {
if (e instanceof CustomInvoiceValidationError) {
return NextResponse.json({ error: e.message }, { status: 400 });
}
return NextResponse.json(
{ error: safeError(e, "Failed to render preview") },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,120 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { requirePlatformRole } from "@/lib/session";
import {
deleteInvoiceDraft,
getInvoiceDraftById,
updateInvoiceDraft,
} from "@/lib/db";
import { safeError } from "@/lib/errors";
import type { CustomInvoiceDraftPayload } from "@/types";
/**
* /api/admin/billing/invoice-drafts/[id]
*
* Phase 8.
*
* GET — fetch one draft
* PUT — overwrite the payload (full replace, not patch)
* DELETE — discard the draft
*
* All require platform admin. The org boundary is *not* enforced
* here: a platform admin can edit any draft regardless of which
* org it targets. If we ever introduce a per-org admin role,
* scope filtering would go in this file.
*/
const lineSchema = z.object({
description: z.string().trim().min(1).max(500),
quantity: z.number().finite(),
unitPriceChf: z.number().finite(),
});
const payloadSchema = z.object({
issueDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
dueDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
locale: z.enum(["de", "en", "fr", "it"]),
paymentMethod: z.enum(["invoice", "card"]),
adminNotes: z.string().max(2000).optional(),
lines: z.array(lineSchema).max(100),
});
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;
try {
const draft = await getInvoiceDraftById(id);
if (!draft) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
return NextResponse.json({ draft });
} catch (e) {
return NextResponse.json(
{ error: safeError(e, "Failed to load draft") },
{ status: 500 }
);
}
}
export async function PUT(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
await requirePlatformRole();
} catch {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const { id } = await params;
const body = await request.json().catch(() => ({}));
const parsed = payloadSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: "Invalid request", details: parsed.error.flatten() },
{ status: 400 }
);
}
try {
const updated = await updateInvoiceDraft(
id,
parsed.data as CustomInvoiceDraftPayload
);
if (!updated) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
return NextResponse.json({ draft: updated });
} catch (e) {
return NextResponse.json(
{ error: safeError(e, "Failed to update draft") },
{ status: 500 }
);
}
}
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 deleted = await deleteInvoiceDraft(id);
return NextResponse.json({ deleted });
} catch (e) {
return NextResponse.json(
{ error: safeError(e, "Failed to delete draft") },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,94 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { requirePlatformRole, getSessionUser } from "@/lib/session";
import {
createInvoiceDraft,
listAllInvoiceDrafts,
} from "@/lib/db";
import { safeError } from "@/lib/errors";
import type { CustomInvoiceDraftPayload } from "@/types";
/**
* /api/admin/billing/invoice-drafts
*
* Phase 8. Drafts for the admin "New invoice" flow.
*
* GET — list all open drafts across all orgs, newest-touched first.
* POST — create a new draft for an org with an initial (possibly
* empty) payload. Returns the inserted draft.
*
* Both require platform admin. Drafts have no customer-facing
* surface: they aren't reachable from /billing or any non-admin
* route.
*/
const lineSchema = z.object({
description: z.string().trim().min(1).max(500),
quantity: z.number().finite(),
unitPriceChf: z.number().finite(),
});
const payloadSchema = z.object({
issueDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
dueDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
locale: z.enum(["de", "en", "fr", "it"]),
paymentMethod: z.enum(["invoice", "card"]),
adminNotes: z.string().max(2000).optional(),
lines: z.array(lineSchema).max(100),
});
const createSchema = z.object({
zitadelOrgId: z.string().trim().min(1),
payload: payloadSchema,
});
export async function GET() {
try {
await requirePlatformRole();
} catch {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
try {
const drafts = await listAllInvoiceDrafts();
return NextResponse.json({ drafts });
} catch (e) {
return NextResponse.json(
{ error: safeError(e, "Failed to list drafts") },
{ status: 500 }
);
}
}
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 = createSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: "Invalid request", details: parsed.error.flatten() },
{ status: 400 }
);
}
try {
const draft = await createInvoiceDraft({
zitadelOrgId: parsed.data.zitadelOrgId,
createdBy: user.id,
payload: parsed.data.payload as CustomInvoiceDraftPayload,
});
return NextResponse.json({ draft });
} catch (e) {
return NextResponse.json(
{ error: safeError(e, "Failed to create draft") },
{ status: 500 }
);
}
}

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,88 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { requirePlatformRole, getSessionUser } from "@/lib/session";
import { refundInvoice, RefundNotAllowedError } from "@/lib/billing";
import { safeError } from "@/lib/errors";
/**
* POST /api/admin/billing/invoices/[id]/refund
*
* Phase 7. Refunds a paid invoice (full or partial) and issues a
* credit note. For Stripe-paid invoices, calls Stripe's Refund API
* before any local recording. For invoice-paid customers (bank
* transfer), records the refund locally and assumes the admin
* handled the actual money movement out-of-band.
*
* Body:
* {
* amountChf: number, // positive, <= remaining refundable
* reason: string // required, free-text, max 500
* }
*
* Authorization: platform admin.
*
* Status codes:
* 200 — refund issued, credit note returned
* 400 — bad request (zero/negative amount, etc.)
* 401 / 403 — not authenticated / not platform admin
* 409 — invoice not in a refundable state, or amount exceeds remaining
* 500 — Stripe call failed or another internal error
*
* Idempotency caveats: this endpoint is NOT idempotent against
* client retries. Issuing two refunds quickly will result in two
* Stripe refund calls (and two credit notes). The admin UI should
* disable the submit button while the request is in flight to
* prevent accidental double-clicks. The Stripe charge.refunded
* webhook is idempotent and will not double-count if it fires
* after this endpoint already recorded the refund.
*/
const bodySchema = z.object({
amountChf: z.number().positive().multipleOf(0.01),
reason: z.string().trim().min(1).max(500),
});
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 creditNote = await refundInvoice({
invoiceId: id,
amountChf: parsed.data.amountChf,
reason: parsed.data.reason,
refundedBy: user.id,
});
return NextResponse.json({ creditNote });
} catch (e) {
if (e instanceof RefundNotAllowedError) {
return NextResponse.json(
{ error: e.message, currentStatus: e.currentStatus },
{ status: 409 }
);
}
return NextResponse.json(
{ error: safeError(e, "Refund failed") },
{ status: 500 }
);
}
}

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,77 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { requirePlatformRole, getSessionUser } from "@/lib/session";
import { voidInvoice, VoidNotAllowedError } from "@/lib/billing";
import { safeError } from "@/lib/errors";
/**
* POST /api/admin/billing/invoices/[id]/void
*
* Phase 7. Voids an unpaid invoice and issues a credit note.
*
* Body:
* {
* reason: string // required, free-text, max 500
* }
*
* Authorization: platform admin (same as mark-paid, generate, etc.).
* The acting user's ID lands in invoices.voided_by and on the
* credit_notes.issued_by audit columns.
*
* Status codes:
* 200 — voided, credit note returned in body
* 400 — bad request (missing reason etc.)
* 401 / 403 — not authenticated / not platform admin
* 409 — invoice not in a voidable state
* 500 — anything else (Stripe shouldn't apply here, but if PDF
* render fails the void still went through — see body
* payload for the credit-note number to re-render later)
*/
const bodySchema = z.object({
reason: z.string().trim().min(1).max(500),
});
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 creditNote = await voidInvoice({
invoiceId: id,
reason: parsed.data.reason,
voidedBy: user.id,
});
return NextResponse.json({ creditNote });
} catch (e) {
if (e instanceof VoidNotAllowedError) {
return NextResponse.json(
{ error: e.message, currentStatus: e.currentStatus },
{ status: 409 }
);
}
return NextResponse.json(
{ error: safeError(e, "Void 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,72 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { requirePlatformRole } from "@/lib/session";
import {
getOrgBillingConfig,
setAutoChargeEnabled,
updateOrgBillingConfig,
} from "@/lib/db";
import { safeError } from "@/lib/errors";
/**
* POST /api/admin/billing/orgs/[orgId]/payment-mode
*
* Phase 9b-2. Admin-only override of an org's billing mode:
* - payByInvoice (boolean) — flip the customer's account to
* bank-transfer billing. Auto-charge is skipped entirely for
* these orgs; they receive the regular issued-invoice email
* and pay manually. Switching ON also implicitly stops
* attempting card charges even if a saved card exists.
* - autoChargeEnabled (boolean) — pause auto-charge without
* committing to pay-by-invoice. Useful during disputes or
* billing investigations.
*
* Either flag may be omitted; the endpoint only writes what's
* provided. Returns the updated config.
*/
const bodySchema = z.object({
payByInvoice: z.boolean().optional(),
autoChargeEnabled: z.boolean().optional(),
});
export async function POST(
request: Request,
{ params }: { params: Promise<{ orgId: string }> }
) {
try {
await requirePlatformRole();
} catch {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const { orgId } = 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 { payByInvoice, autoChargeEnabled } = parsed.data;
if (payByInvoice === undefined && autoChargeEnabled === undefined) {
return NextResponse.json(
{ error: "Provide at least one of payByInvoice or autoChargeEnabled" },
{ status: 400 }
);
}
try {
if (payByInvoice !== undefined) {
await updateOrgBillingConfig(orgId, { payByInvoice });
}
if (autoChargeEnabled !== undefined) {
await setAutoChargeEnabled(orgId, autoChargeEnabled);
}
const cfg = await getOrgBillingConfig(orgId);
return NextResponse.json({ config: cfg });
} catch (e) {
return NextResponse.json(
{ error: safeError(e, "Failed to update payment mode") },
{ status: 500 }
);
}
}

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

@@ -9,6 +9,7 @@ import { createTenant, patchTenantSpec, setTenantAnnotation } from "@/lib/k8s";
import { sendApprovalEmail, sendResumeApprovalEmail } from "@/lib/email";
import { decryptSecrets } from "@/lib/crypto";
import { writePackageSecrets } from "@/lib/openbao";
import { createRoute as createRelayRoute } from "@/lib/threema-relay";
import {
getDefaultSoulMd,
getDefaultAgentsMd,
@@ -177,6 +178,29 @@ export async function POST(
? tenantRequest.contactName || "Assistant"
: tenantRequest.companyName;
// Phase 9b: split the customer's initial channel-user ids into
// (a) ids the operator needs in spec.channelUsers (telegram,
// discord, …) — passed straight into createTenant
// (b) Threema ids that ALSO need a relay route registered so
// inbound messages reach this tenant. Threema is in (a)
// AND (b): spec.channelUsers tells the operator the id is
// authorized; the relay's route maps inbound traffic from
// that id to this tenant.
const initialChannelUsers = tenantRequest.channelUsers ?? {};
// Strip channels the customer didn't actually enable (defensive
// — the wizard already filters this, but the row could carry
// stale data if the customer edited their request post-submit).
const filteredChannelUsers: Record<string, string[]> = {};
for (const [channel, ids] of Object.entries(initialChannelUsers)) {
if (!packages.includes(channel)) continue;
const cleaned = (ids ?? [])
.map((s) => (s ?? "").trim())
.filter((s) => s.length > 0);
if (cleaned.length > 0) {
filteredChannelUsers[channel] = cleaned;
}
}
await createTenant(
tenantName,
{
@@ -184,6 +208,9 @@ export async function POST(
agentName: tenantRequest.agentName,
packages,
workspaceFiles,
...(Object.keys(filteredChannelUsers).length > 0
? { channelUsers: filteredChannelUsers }
: {}),
},
{
"pieced.ch/zitadel-org-id": tenantRequest.zitadelOrgId,
@@ -199,6 +226,35 @@ export async function POST(
}
);
// Threema: register relay routes for each id the customer
// entered. Best-effort — a route failure doesn't unwind the
// tenant creation (admin can retry from the tenant page later).
// The Threema package itself isn't enabled on the tenant until
// the customer toggles it from the tenant detail page (which
// also mints the per-tenant token); the routes here pre-warm
// the relay so the first toggle works without re-typing the id.
if (
packages.includes("threema") &&
filteredChannelUsers.threema &&
filteredChannelUsers.threema.length > 0
) {
for (const tid of filteredChannelUsers.threema) {
try {
const res = await createRelayRoute(tenantName, tid);
if (!res.ok) {
console.warn(
`[approve] Threema route create for tenant=${tenantName} id=${tid} returned not-ok: ${res.message}`
);
}
} catch (e) {
console.error(
`[approve] Threema route create threw for tenant=${tenantName} id=${tid}:`,
e
);
}
}
}
// Step 5: Update request status — clear admin notes on re-approval
const updated = await updateTenantRequestStatus(id, "provisioning", {
adminNotes: isReApproval ? null : adminNotes,

View File

@@ -1,8 +1,14 @@
import { NextResponse } from "next/server";
import { requirePlatformRole } from "@/lib/session";
import { getTenantRequestById, updateTenantRequestStatus } from "@/lib/db";
import {
getInvoiceById,
getTenantRequestById,
updateTenantRequestStatus,
} from "@/lib/db";
import { setTenantAnnotation } from "@/lib/k8s";
import { sendRejectionEmail, sendResumeRejectionEmail } from "@/lib/email";
import { refundInvoice, RefundNotAllowedError } from "@/lib/billing";
import type { SessionUser } from "@/types";
/**
* POST /api/admin/requests/[id]/reject
@@ -14,13 +20,23 @@ import { sendRejectionEmail, sendResumeRejectionEmail } from "@/lib/email";
* suspendedAt — rejection doesn't reset it. The customer can submit
* a fresh resume request later if circumstances change, but that
* starts a new pending row and re-stamps the annotation.
*
* Phase 9b: provision rejections that have a linked paid setup
* invoice (setup_invoice_id) trigger an automatic full refund via
* the existing refundInvoice flow. The refund creates a credit
* note + Stripe refund + customer email — same paper trail any
* post-payment refund would have. Best-effort: a refund failure
* does NOT block the rejection (admin can re-refund manually via
* the invoice detail page if needed), but it's logged and surfaced
* in the response so admin sees what happened.
*/
export async function POST(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
let user: SessionUser;
try {
await requirePlatformRole();
user = await requirePlatformRole();
} catch {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
@@ -65,6 +81,63 @@ export async function POST(
}
}
// Phase 9b: refund the setup-fee invoice if one is linked. Only
// applies to provision rejections; resume requests never have a
// setup_invoice_id. Skip silently if no invoice is linked (e.g.
// the request was created before Phase 9b shipped, or the setup
// fee was 0).
const refundSummary: {
attempted: boolean;
succeeded: boolean;
error?: string;
} = { attempted: false, succeeded: false };
if (
tenantRequest.requestType === "provision" &&
tenantRequest.setupInvoiceId
) {
refundSummary.attempted = true;
try {
// refundInvoice expects an explicit CHF amount (no "full"
// sentinel). Compute the remaining refundable amount as
// total minus what's already been refunded. For a fresh
// setup-fee invoice this is just totalChf, but the formula
// is robust if admin had partially refunded earlier (rare
// but possible — same invoice could in theory get a manual
// partial refund, then a rejection).
const inv = await getInvoiceById(tenantRequest.setupInvoiceId);
if (!inv) {
throw new Error(
`Linked setup invoice ${tenantRequest.setupInvoiceId} not found`
);
}
const remaining = Math.round(
(inv.totalChf - (inv.refundedTotalChf ?? 0)) * 100
) / 100;
if (remaining <= 0) {
refundSummary.succeeded = true; // nothing to refund — treat as success
} else {
await refundInvoice({
invoiceId: tenantRequest.setupInvoiceId,
amountChf: remaining,
reason: adminNotes
? `Tenant request rejected: ${adminNotes}`
: "Tenant request rejected",
refundedBy: user.id,
});
refundSummary.succeeded = true;
}
} catch (e: any) {
refundSummary.error =
e instanceof RefundNotAllowedError
? e.message
: (e?.message ?? "refund failed");
console.error(
`Setup-fee refund failed for request ${id} (invoice ${tenantRequest.setupInvoiceId}):`,
e
);
}
}
// Notify customer. Resume requests get a different email — the
// tenant already exists; copy needs to mention "stays suspended" and
// the 60-day retention deadline. Provision rejections use the
@@ -88,5 +161,6 @@ export async function POST(
return NextResponse.json({
message: "Request rejected.",
request: updated,
refund: refundSummary,
});
}

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,27 @@
import { NextResponse } from "next/server";
/**
* POST /api/billing/auto-charge — RETIRED.
*
* Auto-pay is no longer a customer-toggleable setting. A saved
* card on file is the consent to auto-bill; customers manage their
* card via update/remove on /settings/billing, nothing else. The
* auto_charge_enabled flag is now an admin-only pause used during
* disputes, set from /admin/billing/orgs.
*
* This route is kept as an explicit 410 (Gone) so any stale client
* that still POSTs here fails loudly rather than silently toggling
* a flag the customer shouldn't control. The old behaviour lived
* here through Phase 9b-2.
*/
export async function POST() {
return NextResponse.json(
{
error:
"Auto-pay can no longer be disabled. A saved card is required for service. " +
"Contact support if you need to switch to bank-transfer billing.",
code: "auto_pay_not_toggleable",
},
{ status: 410 }
);
}

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,46 @@
import { NextResponse } from "next/server";
import { getSessionUser } from "@/lib/session";
import { clearSavedPaymentMethod, getOrgBillingConfig } from "@/lib/db";
import { detachPaymentMethod } from "@/lib/stripe";
import { safeError } from "@/lib/errors";
/**
* DELETE /api/billing/saved-card
*
* Phase 9. Remove the saved card for the caller's org. Detaches
* the PaymentMethod in Stripe (so it can't be charged again) and
* clears the four display columns + the pm_id reference locally.
*
* Idempotent: calling on an org with no saved card returns 200
* (the desired end-state is already reached).
*
* Auth: any signed-in member of the org. Same reasoning as the
* setup endpoint — card removal is a customer-visible action; it
* doesn't leak anything, and a non-owner needing to remove a
* stolen-card-on-file shouldn't be blocked by role gating.
*/
export async function DELETE() {
const user = await getSessionUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
try {
const cfg = await getOrgBillingConfig(user.orgId);
if (!cfg || !cfg.stripeDefaultPaymentMethodId) {
// Already empty — no-op, return success.
return NextResponse.json({ removed: false });
}
// Stripe detach first. If it fails for a real reason (network,
// 500 from Stripe), we don't clear the DB — admin can retry.
// 404 is treated as success by detachPaymentMethod (PM already
// gone), so we proceed to clear the DB regardless.
await detachPaymentMethod(cfg.stripeDefaultPaymentMethodId);
await clearSavedPaymentMethod(user.orgId);
return NextResponse.json({ removed: true });
} catch (e) {
return NextResponse.json(
{ error: safeError(e, "Failed to remove card") },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,75 @@
import { NextResponse } from "next/server";
import { getSessionUser } from "@/lib/session";
import { getOrgBilling } from "@/lib/db";
import {
createSetupCheckoutSession,
ensureStripeCustomerForOrg,
} from "@/lib/stripe";
import { safeError } from "@/lib/errors";
/**
* POST /api/billing/setup-card
*
* Phase 9. Customer-initiated "Set up auto-pay" / "Update card"
* flow. Creates a Checkout session in setup mode and returns its
* URL — the caller redirects the browser. On completion, the
* webhook handler saves the resulting PaymentMethod's display
* fields against this org's billing config.
*
* Auth: any signed-in member of the org. We don't owner-gate this
* because non-owners might legitimately need to update payment
* (e.g., for a team they administer). The actual card data is
* collected by Stripe, not us — there's nothing to leak from
* misuse here.
*
* Requires an existing billing snapshot (org_billing row). If
* absent, returns 400 — the customer hasn't set their billing
* address yet, and Stripe needs the address for the customer
* object.
*/
export async function POST(request: Request) {
const user = await getSessionUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const orgBilling = await getOrgBilling(user.orgId);
if (!orgBilling) {
return NextResponse.json(
{ error: "Billing address required before saving a card." },
{ status: 400 }
);
}
try {
// Ensure the Stripe customer exists. Idempotent — if we
// already created one for this org (e.g. from a prior
// "Pay by Card" Checkout), it's reused.
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,
},
});
// Base URL for redirect targets — must be the public-facing
// origin since Stripe redirects the browser back. Behind an
// ingress (Cedric's setup) request.url is the internal pod
// address ("0.0.0.0:3000" / cluster.svc), useless for the
// browser. Same env-var pattern as the invoice pay endpoint.
const baseUrl =
process.env.APP_BASE_URL ?? "https://app.pieced.ch";
const session = await createSetupCheckoutSession({
customerId,
baseUrl,
});
return NextResponse.json({ url: session.url });
} catch (e) {
return NextResponse.json(
{ error: safeError(e, "Failed to start card setup") },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,64 @@
import { NextResponse } from "next/server";
import { getSessionUser } from "@/lib/session";
import {
getCreditNoteByNumber,
getCreditNoteByNumberForOrg,
getCreditNotePdf,
} from "@/lib/db";
/**
* GET /api/credit-notes/[number]/pdf
*
* Phase 7. Customer-facing PDF download for a credit note. Returns
* the binary PDF with Content-Disposition: inline so the browser
* renders it in-tab (matching the invoice download behaviour). The
* customer's email links here.
*
* Authorization:
* - The caller must be authenticated.
* - For customer-org callers, the credit note must belong to their
* org (orgId-scoped lookup).
* - Platform admins can fetch any credit note (cross-org lookup).
*
* Returns 404 in both "doesn't exist" and "exists but not yours"
* cases — leak-safe identical to invoice lookup.
*/
export async function GET(
_request: Request,
{ params }: { params: Promise<{ number: string }> }
) {
const user = await getSessionUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { number } = await params;
// URL-decoded number — the route param comes URL-encoded.
const decodedNumber = decodeURIComponent(number);
const cn = user.isPlatform
? await getCreditNoteByNumber(decodedNumber)
: await getCreditNoteByNumberForOrg(decodedNumber, user.orgId);
if (!cn) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
const pdf = await getCreditNotePdf(cn.id);
if (!pdf) {
// The credit note exists but the PDF was never attached. Most
// likely a render failure during issuance — the credit note
// row is still authoritative, the PDF needs re-rendering.
return NextResponse.json(
{
error:
"Credit note exists but its PDF has not been rendered. Please contact support.",
},
{ status: 502 }
);
}
return new NextResponse(new Uint8Array(pdf.data), {
status: 200,
headers: {
"Content-Type": "application/pdf",
"Content-Disposition": `inline; filename="${pdf.filename}"`,
"Cache-Control": "private, no-cache",
},
});
}

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

@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { getSessionUser, canMutate } from "@/lib/session";
import {
getInvoiceById,
getTenantRequestById,
updateTenantRequestStatus,
updateTenantRequestEditableFields,
@@ -9,6 +10,8 @@ import { encryptSecrets } from "@/lib/crypto";
import { setTenantAnnotation } from "@/lib/k8s";
import { onboardingSchema } from "@/lib/validation";
import { safeError } from "@/lib/errors";
import { refundInvoice, RefundNotAllowedError } from "@/lib/billing";
import type { SessionUser, TenantRequest } from "@/types";
/**
* Customer-side controls for a single tenant_request row.
@@ -29,7 +32,7 @@ async function loadAuthorized(
id: string
): Promise<
| { error: NextResponse }
| { req: Awaited<ReturnType<typeof getTenantRequestById>>; }
| { req: TenantRequest; user: SessionUser }
> {
const user = await getSessionUser();
if (!user) {
@@ -55,7 +58,7 @@ async function loadAuthorized(
error: NextResponse.json({ error: "Not found" }, { status: 404 }),
};
}
return { req: tr };
return { req: tr, user };
}
/**
@@ -93,6 +96,50 @@ export async function DELETE(
try {
await updateTenantRequestStatus(id, "cancelled");
// Phase 9b: a 'pending' provision request has already had its
// setup fee charged (the order-time Checkout completed before
// the webhook flipped it to 'pending'). Cancelling it must
// refund that payment, exactly as an admin rejection does.
// Resume requests never carry a setup_invoice_id, so this only
// fires for provision orders. Best-effort: a refund failure is
// logged + surfaced but doesn't block the cancellation (admin
// can refund manually from the invoice page).
let refund: { attempted: boolean; succeeded: boolean; error?: string } = {
attempted: false,
succeeded: false,
};
if (tr.requestType === "provision" && tr.setupInvoiceId) {
refund.attempted = true;
try {
const inv = await getInvoiceById(tr.setupInvoiceId);
if (!inv) {
throw new Error(`Linked setup invoice ${tr.setupInvoiceId} not found`);
}
const remaining =
Math.round((inv.totalChf - (inv.refundedTotalChf ?? 0)) * 100) / 100;
if (remaining <= 0) {
refund.succeeded = true; // nothing left to refund
} else {
await refundInvoice({
invoiceId: tr.setupInvoiceId,
amountChf: remaining,
reason: "Order cancelled by customer",
refundedBy: loaded.user!.id,
});
refund.succeeded = true;
}
} catch (e: any) {
refund.error =
e instanceof RefundNotAllowedError
? e.message
: (e?.message ?? "refund failed");
console.error(
`Setup-fee refund failed for cancelled request ${id} (invoice ${tr.setupInvoiceId}):`,
e
);
}
}
// Customer cancels their own pending resume request: clear the
// operator-side annotation so the 60-day TTL resumes counting.
// Best-effort — the operator handles missing annotation gracefully.
@@ -111,7 +158,7 @@ export async function DELETE(
}
}
return NextResponse.json({ message: "Request cancelled.", id });
return NextResponse.json({ message: "Request cancelled.", id, refund });
} catch (e: any) {
console.error("Failed to cancel request:", e);
return NextResponse.json(

View File

@@ -2,11 +2,14 @@ import { NextRequest, NextResponse } from "next/server";
import { getSessionUser, canMutate } from "@/lib/session";
import {
createTenantRequest,
createTenantRequestPendingPayment,
deletePendingPaymentRequest,
getTenantRequestById,
listTenantRequestsByOrgId,
listActiveTenantRequestsByOrgId,
getMostRecentApprovedRequestForOrg,
getOrgBilling,
getPlatformPricing,
upsertOrgBilling,
} from "@/lib/db";
import { getTenant, listTenants } from "@/lib/k8s";
@@ -19,7 +22,18 @@ import { sendAdminNotificationEmail } from "@/lib/email";
import { encryptSecrets } from "@/lib/crypto";
import { isPersonalOrgName } from "@/lib/personal-org";
import { onboardingSchema, billingAddressSchema } from "@/lib/validation";
import type { OnboardingInput, PiecedTenant, TenantRequest } from "@/types";
import {
createSetupFeeCheckoutSession,
ensureStripeCustomerForOrg,
} from "@/lib/stripe";
import { createTenantSetupFeeInvoice, voidInvoice } from "@/lib/billing";
import { deriveTenantName } from "@/lib/tenant-naming";
import type {
InvoiceBillingSnapshot,
OnboardingInput,
PiecedTenant,
TenantRequest,
} from "@/types";
import { z } from "zod";
/**
@@ -194,6 +208,7 @@ export async function POST(request: Request) {
const input: OnboardingInput & {
packageSecrets?: Record<string, Record<string, string>>;
channelUsers?: Record<string, string[]>;
} = parsed.data;
// Look up an existing approved request for this org to inherit
@@ -252,11 +267,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.
//
@@ -389,7 +417,64 @@ export async function POST(request: Request) {
);
}
const tenantRequest = await createTenantRequest({
// Look up the setup fee. If it's 0 we skip the Checkout flow
// entirely and create a normal pending request (same as the
// pre-Phase-9b behaviour).
const platformPricing = await getPlatformPricing();
const setupFeeChf = platformPricing.tenantSetupFeeChf;
// ZERO-FEE PATH ---------------------------------------------------
// No payment to collect. Create the request directly in 'pending'
// status (same as the pre-Phase-9b flow) and notify admin. The
// wizard treats this response identically to its previous
// success path.
if (setupFeeChf <= 0) {
const tenantRequest = await createTenantRequest({
zitadelOrgId: user.orgId,
zitadelUserId: user.id,
companyName,
instanceName: input.instanceName,
contactName,
contactEmail,
agentName: input.agentName,
soulMd: input.soulMd,
agentsMd: input.agentsMd,
packages: input.packages ?? [],
billingAddress,
billingNotes,
encryptedSecrets,
isPersonal,
channelUsers: input.channelUsers ?? {},
});
try {
await sendAdminNotificationEmail(
tenantRequest.contactEmail,
tenantRequest.contactName,
tenantRequest.instanceName
? `${tenantRequest.companyName} (${tenantRequest.instanceName})`
: tenantRequest.companyName
);
} catch (e) {
console.error("Failed to send admin notification:", e);
}
const allRequests = await listTenantRequestsByOrgId(user.orgId);
return NextResponse.json(
{
message: "Request submitted.",
request: publicRequestShape(tenantRequest),
orgRequestCount: allRequests.length,
},
{ status: 201 }
);
}
// PAID-FEE PATH ---------------------------------------------------
// Insert as 'pending_payment' (tenant_name stays NULL so abandoned
// Checkout sessions don't block retries). Build the setup-fee
// invoice, then start a Checkout session. The wizard follows the
// returned URL; on completion the webhook flips the row to
// 'pending' and admin sees it in their queue.
const tenantRequest = await createTenantRequestPendingPayment({
zitadelOrgId: user.orgId,
zitadelUserId: user.id,
companyName,
@@ -404,32 +489,140 @@ export async function POST(request: Request) {
billingNotes,
encryptedSecrets,
isPersonal,
channelUsers: input.channelUsers ?? {},
});
// Notify admin about the new request. For follow-up instances, include
// the instance name in the notification so the admin sees what's
// being requested without opening the panel.
try {
await sendAdminNotificationEmail(
tenantRequest.contactEmail,
tenantRequest.contactName,
tenantRequest.instanceName
? `${tenantRequest.companyName} (${tenantRequest.instanceName})`
: tenantRequest.companyName
// Derive the future tenant_name — needed on the invoice line so
// tenantHasSetupFeeBilled() in the monthly cron dedup finds the
// already-paid setup fee once the K8s tenant exists. The name is
// request-id-suffix-derived, so abandoned Checkout retries each
// get unique names.
const derivedTenantName = deriveTenantName(
isPersonal ? "personal" : "company",
companyName,
tenantRequest.id
);
// Re-fetch orgBilling here: the variable at the top of POST was
// captured BEFORE the upsertOrgBilling call upstream (which fires
// when the wizard collected the address on first onboarding). For
// a brand-new user that initial fetch returned null; only by
// re-fetching now do we get the row we just wrote. Existing
// customers get the same orgBilling back either way.
const billingForOrder = await getOrgBilling(user.orgId);
if (!billingForOrder) {
console.error(
`Paid-fee onboarding path: no org_billing for org ${user.orgId} even after upsert — wizard did not collect address?`
);
await deletePendingPaymentRequest(tenantRequest.id).catch(() => undefined);
return NextResponse.json(
{ error: "Billing record missing. Please re-save your billing details." },
{ status: 500 }
);
}
const billingSnapshot: InvoiceBillingSnapshot = {
companyName: billingForOrder.companyName,
contactName: billingForOrder.contactName ?? null,
streetAddress: billingForOrder.streetAddress,
postalCode: billingForOrder.postalCode,
city: billingForOrder.city,
country: billingForOrder.country,
vatNumber: billingForOrder.vatNumber ?? null,
billingEmail: billingForOrder.billingEmail,
notes: billingForOrder.notes ?? null,
};
// Locale for the invoice + PDF — pick from the org's country
// using the same heuristic the auto-cron uses.
const c = (billingSnapshot.country ?? "").toUpperCase();
const invoiceLocale: "de" | "en" | "fr" | "it" = ["CH", "LI", "AT", "DE"].includes(c)
? "de"
: ["FR", "BE", "LU"].includes(c)
? "fr"
: c === "IT"
? "it"
: "en";
let setupInvoice;
try {
setupInvoice = await createTenantSetupFeeInvoice({
zitadelOrgId: user.orgId,
tenantName: derivedTenantName,
billingSnapshot,
locale: invoiceLocale,
paymentMethod: "card",
});
} catch (e) {
console.error("Failed to send admin notification:", e);
console.error("Failed to create setup-fee invoice:", e);
// Roll back the pending_payment row so the customer can retry
// without an orphan record.
await deletePendingPaymentRequest(tenantRequest.id).catch(() => undefined);
return NextResponse.json(
{ error: "Failed to prepare setup-fee invoice. Please try again." },
{ status: 500 }
);
}
// For diagnostics: how many other in-flight requests does this org
// already have? Useful for the admin queue.
const allRequests = await listTenantRequestsByOrgId(user.orgId);
// Create the Checkout session. The Stripe customer must exist
// before this — ensureStripeCustomerForOrg returns the existing
// one (idempotent) since the saved-card setup already created it.
let checkoutUrl: string;
try {
const stripeCustomerId = await ensureStripeCustomerForOrg({
zitadelOrgId: user.orgId,
companyName: billingSnapshot.companyName,
billingEmail: billingSnapshot.billingEmail,
address: {
line1: billingSnapshot.streetAddress,
postalCode: billingSnapshot.postalCode,
city: billingSnapshot.city,
country: billingSnapshot.country,
},
});
const baseUrl =
process.env.APP_BASE_URL ?? "https://app.pieced.ch";
const { url } = await createSetupFeeCheckoutSession({
invoice: setupInvoice,
customerId: stripeCustomerId,
baseUrl,
tenantRequestId: tenantRequest.id,
});
checkoutUrl = url;
} catch (e) {
console.error("Failed to create setup-fee Checkout session:", e);
// Roll back BOTH the pending_payment row and the setup invoice
// we already created. The invoice was issued in 'open' status
// but no payment will ever arrive (Checkout never started), so
// void it to keep the ledger clean — an open invoice with no
// route to payment would otherwise linger and show up in
// arrears reports. Void (not delete) preserves the audit trail
// and the void reason. Best-effort: a void failure is logged
// but doesn't change the 500 we return.
await voidInvoice({
invoiceId: setupInvoice.id,
reason: "Order abandoned before payment (Checkout could not be started)",
voidedBy: user.id,
}).catch((ve) =>
console.error(
`Failed to void orphaned setup invoice ${setupInvoice.id}:`,
ve
)
);
await deletePendingPaymentRequest(tenantRequest.id).catch(() => undefined);
return NextResponse.json(
{ error: "Failed to start payment. Please try again." },
{ status: 500 }
);
}
// Don't notify admin yet — the request is invisible to admin
// until the webhook flips it to 'pending'. Notification happens
// there.
return NextResponse.json(
{
message: "Request submitted.",
message: "Redirecting to payment.",
request: publicRequestShape(tenantRequest),
orgRequestCount: allRequests.length,
checkoutUrl,
},
{ status: 201 }
);

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,83 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { getSessionUser } from "@/lib/session";
import {
getHumanUserDetail,
updateHumanUserProfile,
} from "@/lib/zitadel";
/**
* GET /api/settings/profile — read the caller's ZITADEL profile.
* Returns first/last/display name and email. Used by the settings
* page server component to populate the form.
*
* PUT /api/settings/profile — update first + last name. Email is
* NOT mutable here — changing email needs verification flow that
* ZITADEL's own self-service UI already provides; we don't
* duplicate that.
*
* Authorization: any authenticated user can edit their own profile.
* The PAT (ZITADEL_SA_PAT) is used to call the ZITADEL v2 user
* service, but only against the caller's own userId. There is no
* userId field on the request — it's always derived from the
* session, so the route can't be abused to edit other users.
*/
const updateSchema = z.object({
firstName: z.string().trim().min(1).max(100),
lastName: z.string().trim().min(1).max(100),
language: z.enum(["de", "en", "fr", "it"]).optional(),
});
export async function GET() {
const user = await getSessionUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
try {
const profile = await getHumanUserDetail(user.id);
return NextResponse.json({ profile });
} catch (e: any) {
// Surface ZITADEL-side failures (e.g. user not found, PAT expired)
// as 502 — the portal couldn't reach its identity provider, which
// is operationally different from a 4xx on the caller's input.
console.error("getHumanUserDetail failed:", e);
return NextResponse.json(
{ error: "Could not load profile from identity provider" },
{ status: 502 }
);
}
}
export async function PUT(request: Request) {
const user = await getSessionUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const body = await request.json().catch(() => ({}));
const parsed = updateSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: "Invalid request", details: parsed.error.flatten() },
{ status: 400 }
);
}
try {
const result = await updateHumanUserProfile({
userId: user.id,
givenName: parsed.data.firstName,
familyName: parsed.data.lastName,
preferredLanguage: parsed.data.language,
});
return NextResponse.json({
displayName: result.displayName,
changeDate: result.changeDate,
});
} catch (e: any) {
console.error("updateHumanUserProfile failed:", e);
return NextResponse.json(
{ error: "Could not update profile in identity provider" },
{ status: 502 }
);
}
}

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,557 @@
import { NextResponse } from "next/server";
import type Stripe from "stripe";
import {
getPaymentMethodDisplay,
getStripeClient,
getWebhookSecret,
} from "@/lib/stripe";
import {
getInvoiceByStripePaymentIntent,
getInvoiceDetail,
getOrgIdByStripeCustomerId,
getTenantRequestForSetupFlow,
isStripeRefundRecorded,
linkTenantRequestSetupPayment,
markInvoicePaid,
markStripeEventProcessed,
setInvoiceStripePaymentIntent,
setSavedPaymentMethod,
tryRecordStripeEvent,
} from "@/lib/db";
import { sendAdminNotificationEmail } from "@/lib/email";
import { refundInvoice, RefundNotAllowedError } from "@/lib/billing";
/**
* 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> {
// Phase 9: setup-mode sessions don't pay anything — they
// authorize a card for off-session future charges. The
// PaymentMethod is attached to the customer and the session's
// setup_intent.payment_method holds the id we save.
if (session.mode === "setup") {
await handleSetupCompleted(session);
return;
}
// 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}).`
);
// Phase 9b: if this Checkout was the setup-fee flow for a tenant
// order, flip the linked tenant_request row from 'pending_payment'
// to 'pending' so admin sees it in the queue. The invoice line's
// tenant_name has the derived name; we also stamp it on the
// request row so admin can act on it. linkTenantRequestSetupPayment
// is idempotent (no-op if status already advanced).
const flow = session.metadata?.flow;
const tenantRequestId = session.metadata?.tenant_request_id;
if (flow === "setup_fee" && tenantRequestId) {
try {
// The derived tenant_name lives on the invoice line we just
// marked paid. Fetch via getInvoiceDetail (existing helper).
const detail = await getInvoiceDetail(invoiceId);
const setupLine = detail?.lines.find(
(l) => l.kind === "tenant_setup" && l.tenantName
);
if (!setupLine || !setupLine.tenantName) {
console.error(
`Setup-fee webhook for invoice ${invoiceId} has no tenant_setup line with tenant_name; cannot link request ${tenantRequestId}.`
);
} else {
const linked = await linkTenantRequestSetupPayment({
requestId: tenantRequestId,
tenantName: setupLine.tenantName,
setupInvoiceId: invoiceId,
});
if (linked) {
console.log(
`Tenant request ${tenantRequestId} flipped to 'pending' (tenant=${setupLine.tenantName}, setup invoice=${invoiceId}).`
);
// Notify admin now that the payment cleared. Best-effort —
// a failure here doesn't undo the linkage.
try {
const req = await getTenantRequestForSetupFlow(tenantRequestId);
if (req) {
await sendAdminNotificationEmail(
req.contactEmail,
req.contactName,
req.instanceName
? `${req.companyName} (${req.instanceName})`
: req.companyName
);
}
} catch (e) {
console.error(
`Failed to send admin notification for tenant request ${tenantRequestId}:`,
e
);
}
} else {
console.log(
`Tenant request ${tenantRequestId} not in 'pending_payment' (likely already advanced); webhook is a no-op.`
);
}
}
} catch (e) {
console.error(
`Setup-fee webhook for invoice ${invoiceId} failed to link tenant request ${tenantRequestId}:`,
e
);
}
}
// Phase 9b: any payment-mode Checkout that set setup_future_usage
// attaches the resulting PaymentMethod to the customer. Read it
// back and save the display fields against the org's config —
// same behaviour as the setup-mode webhook does. This is what
// makes the setup-fee Checkout also "refresh saved card" without
// an extra step, and it's also what Phase 9b-2's manual-pay
// with setup_future_usage will rely on.
try {
if (paymentIntentId) {
const stripe = getStripeClient();
const pi = await stripe.paymentIntents.retrieve(paymentIntentId);
const pmId =
typeof pi.payment_method === "string"
? pi.payment_method
: pi.payment_method?.id;
const customerId =
typeof pi.customer === "string"
? pi.customer
: pi.customer?.id;
// setup_future_usage on the PI tells us this payment also
// saved the card. If it's not set, this was a one-off pay
// and we shouldn't overwrite anything.
if (pmId && customerId && pi.setup_future_usage === "off_session") {
const orgId = await getOrgIdByStripeCustomerId(customerId);
if (orgId) {
const display = await getPaymentMethodDisplay(pmId);
await setSavedPaymentMethod({
zitadelOrgId: orgId,
stripeCustomerId: customerId,
paymentMethodId: pmId,
brand: display.brand,
last4: display.last4,
expMonth: display.expMonth,
expYear: display.expYear,
});
// Also tell Stripe this PM is the customer's default for
// future invoice charges. Best-effort.
try {
await stripe.customers.update(customerId, {
invoice_settings: { default_payment_method: pmId },
});
} catch (e) {
console.warn(
`Failed to set default_payment_method on customer ${customerId}:`,
e
);
}
console.log(
`Saved PaymentMethod ${pmId} (${display.brand} ${display.last4}) for org ${orgId} via payment-mode Checkout.`
);
}
}
}
} catch (e) {
console.error(
`Failed to save PaymentMethod from payment-mode Checkout (session ${session.id}):`,
e
);
}
}
/**
* Phase 9: handle setup-mode Checkout completion. The customer
* authorized a card for future off-session charges; persist the
* display fields against their org so the portal can show the
* saved card and use it for auto-charge.
*
* The session carries:
* - mode: 'setup'
* - customer: 'cus_xxx' (the Stripe customer id we created)
* - setup_intent: 'seti_xxx' (the SetupIntent — has payment_method)
*
* We look up which org owns the customer (via
* org_billing_config.stripe_customer_id), fetch the SetupIntent
* to find the resulting PaymentMethod id, then fetch the PM for
* its display fields. Three Stripe round-trips total — acceptable
* for a one-off setup event.
*/
async function handleSetupCompleted(
session: Stripe.Checkout.Session
): Promise<void> {
const customerId =
typeof session.customer === "string"
? session.customer
: session.customer?.id;
if (!customerId) {
console.error(
`Setup session ${session.id} completed without a customer; cannot link to org.`
);
return;
}
const orgId = await getOrgIdByStripeCustomerId(customerId);
if (!orgId) {
console.error(
`Setup session ${session.id} for customer ${customerId} has no matching org.`
);
return;
}
const setupIntentId =
typeof session.setup_intent === "string"
? session.setup_intent
: session.setup_intent?.id;
if (!setupIntentId) {
console.error(
`Setup session ${session.id} completed without a setup_intent id.`
);
return;
}
// Read the SetupIntent for the resulting PaymentMethod id.
const stripe = getStripeClient();
const setupIntent = await stripe.setupIntents.retrieve(setupIntentId);
const paymentMethodId =
typeof setupIntent.payment_method === "string"
? setupIntent.payment_method
: setupIntent.payment_method?.id;
if (!paymentMethodId) {
console.error(
`Setup session ${session.id}: setup_intent ${setupIntentId} has no payment_method.`
);
return;
}
// Fetch the PM details for display columns.
const display = await getPaymentMethodDisplay(paymentMethodId);
await setSavedPaymentMethod({
zitadelOrgId: orgId,
stripeCustomerId: customerId,
paymentMethodId,
brand: display.brand,
last4: display.last4,
expMonth: display.expMonth,
expYear: display.expYear,
});
// Also tell Stripe this PM is the customer's default for invoice
// payments — so a future stripe.paymentIntents.create against
// this customer without an explicit payment_method picks it up.
// Best-effort: a failure here doesn't undo the save (we have the
// pm id, we can pass it explicitly when charging in Phase 9b).
try {
await stripe.customers.update(customerId, {
invoice_settings: { default_payment_method: paymentMethodId },
});
} catch (e) {
console.warn(
`Setup session ${session.id}: failed to set default_payment_method on customer ${customerId}; will pass pm id explicitly on charges.`,
e
);
}
console.log(
`Saved PaymentMethod ${paymentMethodId} (${display.brand} ${display.last4}) for org ${orgId}.`
);
}
async function handleChargeRefunded(charge: Stripe.Charge): Promise<void> {
// Phase 7: mirror Stripe refunds into the portal so credit notes
// are issued for refunds initiated in the Stripe Dashboard. For
// refunds initiated via /api/admin/.../refund, this handler is a
// no-op (each refund's stripe_refund_id is already recorded
// before the webhook lands — refundInvoice records it
// synchronously after the Stripe API call).
//
// A charge can have multiple refund objects (multiple partial
// refunds against the same charge accumulate here). We iterate
// and process any that aren't yet recorded in our DB.
const paymentIntentId =
typeof charge.payment_intent === "string"
? charge.payment_intent
: charge.payment_intent?.id;
if (!paymentIntentId) {
console.error(
`charge.refunded for charge ${charge.id} has no payment_intent; cannot link to invoice.`
);
return;
}
const invoice = await getInvoiceByStripePaymentIntent(paymentIntentId);
if (!invoice) {
console.error(
`charge.refunded for payment_intent ${paymentIntentId} has no matching invoice; ignoring.`
);
return;
}
const refundsList = charge.refunds?.data ?? [];
if (refundsList.length === 0) {
// Some charge.refunded events fire with the refunds list
// collapsed (the object includes the aggregated amount_refunded
// but the data array can be omitted depending on Stripe's
// expansion choices). In that case there's nothing for us to
// iterate over here; the actual `refund.created` /
// `refund.updated` events carry per-refund detail and we'd need
// to enable those in Stripe to handle them. For v1 we log and
// rely on the in-portal admin path (refundInvoice) being the
// only refund initiator.
console.log(
`charge.refunded for charge ${charge.id} arrived without refund objects in data; in-portal flow assumed.`
);
return;
}
for (const refund of refundsList) {
try {
// Idempotency: skip refunds we already recorded (either via
// portal admin action or a prior webhook delivery).
if (await isStripeRefundRecorded(refund.id)) {
continue;
}
const amountChf = (refund.amount ?? 0) / 100;
if (amountChf <= 0) continue;
// Map Stripe refund status to ours. Anything other than the
// canonical four falls through to 'pending' so we don't lose
// the record entirely.
let status: "pending" | "succeeded" | "failed" | "canceled" = "pending";
if (refund.status === "succeeded") status = "succeeded";
else if (refund.status === "failed") status = "failed";
else if (refund.status === "canceled") status = "canceled";
// For refunds that originated in Stripe Dashboard we don't
// have a reason to display. Use a sentinel string so the
// credit note PDF has something to print. Admin can edit
// post-hoc if needed (no UI for that today, but the DB row
// is reachable).
const reason = refund.reason
? `Stripe Dashboard: ${refund.reason}`
: "Refund issued via Stripe Dashboard";
// refundInvoice with existingStripeRefund: don't call Stripe
// again (we'd error since the refund already exists), just
// mirror the record into our DB and issue the credit note.
await refundInvoice({
invoiceId: invoice.id,
amountChf,
reason,
refundedBy: "stripe-webhook",
existingStripeRefund: { id: refund.id, status },
});
console.log(
`Mirrored Stripe refund ${refund.id} for invoice ${invoice.invoiceNumber} (CHF ${amountChf.toFixed(2)}).`
);
} catch (e) {
if (e instanceof RefundNotAllowedError) {
// The invoice was already fully refunded by an earlier
// webhook delivery or by an in-portal action. That's fine.
console.log(
`Stripe refund ${refund.id}: ${e.message} (already accounted for).`
);
continue;
}
// For any other error, log but continue to the next refund —
// we don't want one bad refund to block the rest.
console.error(
`Failed to mirror Stripe refund ${refund.id} for invoice ${invoice.invoiceNumber}:`,
e
);
}
}
}
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,17 +205,38 @@ 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: {
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)
: null,
},
// 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)
: null,
budgetDuration: teamInfo?.team_info?.budget_duration ?? null,
},
rateLimits: {
rpm: teamInfo?.team_info?.rpm_limit ?? null,
tpm: teamInfo?.team_info?.tpm_limit ?? null,

78
src/app/global-error.tsx Normal file
View File

@@ -0,0 +1,78 @@
"use client";
import { useEffect } from "react";
/**
* Last-resort boundary for errors thrown in the root layout itself
* (before the locale layout / intl provider mount). It replaces the
* entire document, so it must render its own <html>/<body> and cannot
* use translations or rely on the app stylesheet being applied — styles
* are inlined with the palette's hex values so it renders correctly in
* isolation. Everything below the locale layout is handled by
* [locale]/error.tsx instead; this should almost never be seen.
*/
export default function GlobalError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
console.error("Portal global error:", error);
}, [error]);
return (
<html lang="en" className="dark">
<body
style={{
margin: 0,
minHeight: "100vh",
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "#0a0c10",
color: "#e8ecf4",
fontFamily: "system-ui, sans-serif",
padding: "20px",
}}
>
<div style={{ maxWidth: "28rem", textAlign: "center" }}>
<h1 style={{ fontSize: "1.25rem", fontWeight: 600, margin: "0 0 0.5rem" }}>
Something went wrong
</h1>
<p style={{ fontSize: "0.875rem", color: "#8892a4", margin: "0 0 1.5rem" }}>
An unexpected error occurred. Please try again.
</p>
{error?.digest && (
<p
style={{
fontSize: "11px",
fontFamily: "monospace",
color: "#565e6e",
margin: "0 0 1.5rem",
}}
>
{error.digest}
</p>
)}
<button
onClick={reset}
style={{
padding: "0.5rem 1rem",
borderRadius: "0.5rem",
border: "none",
background: "#00d4aa",
color: "#0a0c10",
fontSize: "0.875rem",
fontWeight: 500,
cursor: "pointer",
}}
>
Try again
</button>
</div>
</body>
</html>
);
}

5
src/app/icon.svg Normal file
View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="5.5 3.69 38 38" role="img" aria-label="PieCed">
<rect x="5.5" y="3.69" width="38" height="38" rx="7" fill="#0B0F0E"/>
<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" stroke-width="1.6" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 354 B

View File

@@ -4,6 +4,15 @@ import { useState, useEffect, useCallback } from "react";
import { useTranslations, useFormatter } from "next-intl";
import type { PiecedTenant, TenantRequest } from "@/types";
import { StatusBadge } from "@/components/ui/status-badge";
import { Modal } from "@/components/ui/modal";
import {
applyTableView,
nextSort,
SearchInput,
SortableTh,
Pagination,
type SortState,
} from "@/components/admin/table-controls";
import { formatDateTime, formatRelative } from "@/lib/format";
import Link from "next/link";
@@ -35,6 +44,11 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
const [actionLoading, setActionLoading] = useState<string | null>(null);
const [rejectModal, setRejectModal] = useState<string | null>(null);
const [rejectNotes, setRejectNotes] = useState("");
// Approve is the highest-consequence request action — it provisions
// real infrastructure and triggers the billable setup fee — so it now
// goes through a confirmation modal like reject/delete, instead of
// firing on a single click.
const [approveModal, setApproveModal] = useState<string | null>(null);
// Tenants state
const [tenants, setTenants] = useState<PiecedTenant[]>(initialTenants);
@@ -48,6 +62,26 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
// Shared
const [error, setError] = useState("");
// Client-side table view state (search / sort / page) for each tab.
const [reqSearch, setReqSearch] = useState("");
const [reqSort, setReqSort] = useState<SortState>({
key: "created",
dir: "desc",
});
const [reqPage, setReqPage] = useState(1);
const [tenSearch, setTenSearch] = useState("");
const [tenSort, setTenSort] = useState<SortState>({
key: "created",
dir: "desc",
});
const [tenPage, setTenPage] = useState(1);
// Action-scoped error — shown inside the active confirmation modal so
// a failed approve/reject/delete surfaces next to the action that
// caused it (and keeps the modal open), rather than as a detached
// panel-level banner that isn't tied to any row.
const [actionError, setActionError] = useState("");
// ─── Requests fetching ───
const fetchRequests = useCallback(async () => {
try {
@@ -125,18 +159,21 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
// ─── Request actions ───
const handleApprove = async (id: string) => {
setActionLoading(id);
setError("");
setActionError("");
try {
const res = await fetch(`/api/admin/requests/${id}/approve`, {
method: "POST",
});
if (!res.ok) {
const data = await res.json();
const data = await res.json().catch(() => ({}));
throw new Error(data.error || "Approve failed");
}
setApproveModal(null);
await fetchRequests();
} catch (e: any) {
setError(e.message);
// Keep the modal open so the admin sees why provisioning didn't
// start; the error renders inside the dialog next to the action.
setActionError(e.message);
} finally {
setActionLoading(null);
}
@@ -144,7 +181,7 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
const handleReject = async (id: string) => {
setActionLoading(id);
setError("");
setActionError("");
try {
const res = await fetch(`/api/admin/requests/${id}/reject`, {
method: "POST",
@@ -152,14 +189,14 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
body: JSON.stringify({ adminNotes: rejectNotes || undefined }),
});
if (!res.ok) {
const data = await res.json();
const data = await res.json().catch(() => ({}));
throw new Error(data.error || "Reject failed");
}
setRejectModal(null);
setRejectNotes("");
await fetchRequests();
} catch (e: any) {
setError(e.message);
setActionError(e.message);
} finally {
setActionLoading(null);
}
@@ -189,7 +226,7 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
const handleDelete = async (name: string) => {
setActionLoading(name);
setError("");
setActionError("");
try {
const res = await fetch(`/api/admin/tenants/${name}/delete`, {
method: "POST",
@@ -216,7 +253,7 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
fetchTenants();
setTimeout(() => fetchTenants(), 1500);
} catch (e: any) {
setError(e.message);
setActionError(e.message);
} finally {
setActionLoading(null);
}
@@ -232,6 +269,53 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
const pendingCount = requests.filter((r) => r.status === "pending").length;
// Derived table views: search → sort → paginate, applied client-side
// on top of the already-fetched lists.
const reqView = applyTableView(requests, {
search: reqSearch,
searchOf: (r) => [
r.companyName,
r.contactName,
r.contactEmail,
r.agentName,
r.tenantName,
],
sort: reqSort,
sortOf: (r, key) =>
key === "company"
? r.companyName || ""
: key === "status"
? r.status || ""
: r.createdAt || "",
page: reqPage,
});
const tenView = applyTableView(tenants, {
search: tenSearch,
searchOf: (tn) => [
tn.metadata.name,
tn.spec.displayName,
tn.spec.agentName,
],
sort: tenSort,
sortOf: (tn, key) =>
key === "name"
? tn.spec.displayName || tn.metadata.name
: key === "phase"
? tn.status?.phase || "Pending"
: tn.metadata.creationTimestamp || "",
page: tenPage,
});
const onReqSort = (key: string) => {
setReqSort((s) => nextSort(s, key));
setReqPage(1);
};
const onTenSort = (key: string) => {
setTenSort((s) => nextSort(s, key));
setTenPage(1);
};
return (
<>
{/* Tab bar */}
@@ -246,7 +330,7 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
>
{t("requests")}
{pendingCount > 0 && tab !== "requests" && (
<span className="ml-1.5 inline-flex items-center justify-center h-4 min-w-[16px] px-1 text-[10px] font-bold bg-accent text-white rounded-full">
<span className="ml-1.5 inline-flex items-center justify-center h-4 min-w-[16px] px-1 text-[10px] font-bold bg-accent text-surface-0 rounded-full">
{pendingCount}
</span>
)}
@@ -301,20 +385,33 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
{/* ───── REQUESTS TAB ───── */}
{tab === "requests" && (
<>
<div className="flex gap-1.5 mb-4 flex-wrap">
{FILTERS.map((f) => (
<button
key={f}
onClick={() => setFilter(f)}
className={`px-3 py-1 text-xs rounded-full transition-colors ${
filter === f
? "bg-accent text-white"
: "bg-surface-2 text-text-muted hover:text-text-secondary border border-border"
}`}
>
{t(`filter_${f}`)}
</button>
))}
<div className="flex items-center justify-between gap-3 mb-4 flex-wrap">
<div className="flex gap-1.5 flex-wrap">
{FILTERS.map((f) => (
<button
key={f}
onClick={() => {
setFilter(f);
setReqPage(1);
}}
className={`px-3 py-1 text-xs rounded-full transition-colors ${
filter === f
? "bg-accent text-surface-0"
: "bg-surface-2 text-text-muted hover:text-text-secondary border border-border"
}`}
>
{t(`filter_${f}`)}
</button>
))}
</div>
<SearchInput
value={reqSearch}
onChange={(v) => {
setReqSearch(v);
setReqPage(1);
}}
placeholder={t("searchRequestsPlaceholder")}
/>
</div>
{loadingRequests ? (
@@ -326,15 +423,22 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
<div className="bg-surface-1 border border-border rounded-xl p-12 text-center">
<p className="text-text-secondary text-sm">{t("noRequests")}</p>
</div>
) : reqView.total === 0 ? (
<div className="bg-surface-1 border border-border rounded-xl p-12 text-center">
<p className="text-text-secondary text-sm">{t("noMatches")}</p>
</div>
) : (
<div className="bg-surface-1 border border-border rounded-xl overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border text-left">
<th className="px-4 py-3 text-xs font-semibold uppercase tracking-wider text-text-muted">
{t("company")}
</th>
<SortableTh
label={t("company")}
sortKey="company"
sort={reqSort}
onSort={onReqSort}
/>
<th className="px-4 py-3 text-xs font-semibold uppercase tracking-wider text-text-muted">
{t("contact")}
</th>
@@ -344,19 +448,26 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
<th className="px-4 py-3 text-xs font-semibold uppercase tracking-wider text-text-muted hidden lg:table-cell">
{t("packages")}
</th>
<th className="px-4 py-3 text-xs font-semibold uppercase tracking-wider text-text-muted">
{t("status")}
</th>
<th className="px-4 py-3 text-xs font-semibold uppercase tracking-wider text-text-muted hidden md:table-cell">
{t("submitted")}
</th>
<SortableTh
label={t("status")}
sortKey="status"
sort={reqSort}
onSort={onReqSort}
/>
<SortableTh
label={t("submitted")}
sortKey="created"
sort={reqSort}
onSort={onReqSort}
className="hidden md:table-cell"
/>
<th className="px-4 py-3 text-xs font-semibold uppercase tracking-wider text-text-muted">
{t("actions")}
</th>
</tr>
</thead>
<tbody>
{requests.map((req) => (
{reqView.paged.map((req) => (
<tr
key={req.id}
className="border-b border-border last:border-0 hover:bg-surface-2/50 transition-colors"
@@ -436,16 +547,20 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
{req.status === "pending" && (
<>
<button
onClick={() => handleApprove(req.id)}
onClick={() => {
setActionError("");
setApproveModal(req.id);
}}
disabled={actionLoading === req.id}
className="px-2.5 py-1 text-xs font-medium bg-emerald-500/15 text-emerald-400 rounded-md hover:bg-emerald-500/25 transition-colors disabled:opacity-50"
>
{actionLoading === req.id
? "…"
: t("approve")}
{t("approve")}
</button>
<button
onClick={() => setRejectModal(req.id)}
onClick={() => {
setActionError("");
setRejectModal(req.id);
}}
disabled={actionLoading === req.id}
className="px-2.5 py-1 text-xs font-medium bg-red-500/15 text-red-400 rounded-md hover:bg-red-500/25 transition-colors disabled:opacity-50"
>
@@ -466,7 +581,10 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
)}
{req.status === "rejected" && (
<button
onClick={() => handleApprove(req.id)}
onClick={() => {
setActionError("");
setApproveModal(req.id);
}}
disabled={actionLoading === req.id}
className="px-2.5 py-1 text-xs font-medium bg-amber-500/15 text-amber-400 rounded-md hover:bg-amber-500/25 transition-colors disabled:opacity-50"
>
@@ -485,6 +603,12 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
</tbody>
</table>
</div>
<Pagination
page={reqView.page}
totalPages={reqView.totalPages}
total={reqView.total}
onPage={setReqPage}
/>
</div>
)}
</>
@@ -522,6 +646,17 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
/>
</div>
<div className="flex justify-end mb-4">
<SearchInput
value={tenSearch}
onChange={(v) => {
setTenSearch(v);
setTenPage(1);
}}
placeholder={t("searchTenantsPlaceholder")}
/>
</div>
{loadingTenants ? (
<div className="bg-surface-1 border border-border rounded-xl p-12 text-center">
<div className="h-5 w-5 border-2 border-accent border-t-transparent rounded-full animate-spin mx-auto mb-2" />
@@ -531,37 +666,51 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
<div className="bg-surface-1 border border-border rounded-xl p-12 text-center">
<p className="text-text-secondary text-sm">{t("noTenants")}</p>
</div>
) : tenView.total === 0 ? (
<div className="bg-surface-1 border border-border rounded-xl p-12 text-center">
<p className="text-text-secondary text-sm">{t("noMatches")}</p>
</div>
) : (
<div className="bg-surface-1 border border-border rounded-xl overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border text-left">
<th className="px-4 py-3 text-xs font-semibold uppercase tracking-wider text-text-muted">
{t("name")}
</th>
<SortableTh
label={t("name")}
sortKey="name"
sort={tenSort}
onSort={onTenSort}
/>
<th className="px-4 py-3 text-xs font-semibold uppercase tracking-wider text-text-muted">
{t("displayName")}
</th>
<th className="px-4 py-3 text-xs font-semibold uppercase tracking-wider text-text-muted">
{t("phase")}
</th>
<SortableTh
label={t("phase")}
sortKey="phase"
sort={tenSort}
onSort={onTenSort}
/>
<th className="px-4 py-3 text-xs font-semibold uppercase tracking-wider text-text-muted hidden md:table-cell">
{t("packages")}
</th>
<th className="px-4 py-3 text-xs font-semibold uppercase tracking-wider text-text-muted hidden md:table-cell">
{t("spendChf")}
</th>
<th className="px-4 py-3 text-xs font-semibold uppercase tracking-wider text-text-muted hidden md:table-cell">
{t("created")}
</th>
<SortableTh
label={t("created")}
sortKey="created"
sort={tenSort}
onSort={onTenSort}
className="hidden md:table-cell"
/>
<th className="px-4 py-3 text-xs font-semibold uppercase tracking-wider text-text-muted">
{t("actions")}
</th>
</tr>
</thead>
<tbody>
{tenants.map((tenant) => {
{tenView.paged.map((tenant) => {
const tenantSpend =
health?.spend?.perTenant?.[tenant.metadata.name];
return (
@@ -642,9 +791,10 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
: t("suspend")}
</button>
<button
onClick={() =>
setDeleteModal(tenant.metadata.name)
}
onClick={() => {
setActionError("");
setDeleteModal(tenant.metadata.name);
}}
disabled={actionLoading === tenant.metadata.name}
className="px-2.5 py-1 text-xs font-medium bg-red-500/15 text-red-400 rounded-md hover:bg-red-500/25 transition-colors disabled:opacity-50"
>
@@ -658,6 +808,12 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
</tbody>
</table>
</div>
<Pagination
page={tenView.page}
totalPages={tenView.totalPages}
total={tenView.total}
onPage={setTenPage}
/>
</div>
)}
</>
@@ -772,10 +928,75 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
</>
)}
{/* ───── APPROVE MODAL ───── */}
<Modal
open={!!approveModal}
onClose={() => {
setApproveModal(null);
setActionError("");
}}
ariaLabel={t("approveTitle")}
>
{approveModal &&
(() => {
const req = requests.find((r) => r.id === approveModal);
const isReapprove = req?.status === "rejected";
return (
<>
<h3 className="font-display text-lg font-semibold text-text-primary mb-2">
{t("approveTitle")}
</h3>
<p className="text-sm text-text-secondary mb-2">
{isReapprove
? t("approveReapproveWarning")
: t("approveWarning")}
</p>
{req && (
<p className="text-xs font-mono text-accent bg-surface-2 border border-border rounded-lg px-3 py-2 mb-4">
{req.companyName}
{req.agentName ? ` · ${req.agentName}` : ""}
</p>
)}
{actionError && (
<p className="text-xs text-red-400 bg-red-400/10 border border-red-400/20 rounded-lg px-3 py-2 mb-4">
{actionError}
</p>
)}
<div className="flex gap-2 justify-end">
<button
onClick={() => {
setApproveModal(null);
setActionError("");
}}
className="px-4 py-2 text-sm text-text-secondary hover:text-text-primary transition-colors"
>
{t("cancelAction")}
</button>
<button
onClick={() => handleApprove(approveModal)}
disabled={actionLoading === approveModal}
className="px-4 py-2 text-sm font-medium bg-emerald-500/15 text-emerald-400 rounded-lg hover:bg-emerald-500/25 transition-colors disabled:opacity-50"
>
{actionLoading === approveModal ? "…" : t("confirmApprove")}
</button>
</div>
</>
);
})()}
</Modal>
{/* ───── REJECT MODAL ───── */}
{rejectModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
<div className="bg-surface-1 border border-border rounded-xl p-6 max-w-md w-full mx-4 shadow-2xl">
<Modal
open={!!rejectModal}
onClose={() => {
setRejectModal(null);
setRejectNotes("");
setActionError("");
}}
ariaLabel={t("rejectTitle")}
>
{rejectModal && (
<>
<h3 className="font-display text-lg font-semibold text-text-primary mb-4">
{t("rejectTitle")}
</h3>
@@ -789,11 +1010,17 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
rows={3}
className="w-full px-3 py-2 bg-surface-2 border border-border rounded-lg text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent focus:border-accent transition-colors resize-none mb-4"
/>
{actionError && (
<p className="text-xs text-red-400 bg-red-400/10 border border-red-400/20 rounded-lg px-3 py-2 mb-4">
{actionError}
</p>
)}
<div className="flex gap-2 justify-end">
<button
onClick={() => {
setRejectModal(null);
setRejectNotes("");
setActionError("");
}}
className="px-4 py-2 text-sm text-text-secondary hover:text-text-primary transition-colors"
>
@@ -807,14 +1034,21 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
{actionLoading === rejectModal ? "…" : t("confirmReject")}
</button>
</div>
</div>
</div>
)}
</>
)}
</Modal>
{/* ───── DELETE MODAL ───── */}
{deleteModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
<div className="bg-surface-1 border border-border rounded-xl p-6 max-w-md w-full mx-4 shadow-2xl">
<Modal
open={!!deleteModal}
onClose={() => {
setDeleteModal(null);
setActionError("");
}}
ariaLabel={t("deleteTitle")}
>
{deleteModal && (
<>
<h3 className="font-display text-lg font-semibold text-text-primary mb-2">
{t("deleteTitle")}
</h3>
@@ -824,9 +1058,17 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
<p className="text-xs font-mono text-accent bg-surface-2 border border-border rounded-lg px-3 py-2 mb-4">
{deleteModal}
</p>
{actionError && (
<p className="text-xs text-red-400 bg-red-400/10 border border-red-400/20 rounded-lg px-3 py-2 mb-4">
{actionError}
</p>
)}
<div className="flex gap-2 justify-end">
<button
onClick={() => setDeleteModal(null)}
onClick={() => {
setDeleteModal(null);
setActionError("");
}}
className="px-4 py-2 text-sm text-text-secondary hover:text-text-primary transition-colors"
>
{t("cancelAction")}
@@ -839,9 +1081,9 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
{actionLoading === deleteModal ? "…" : t("confirmDelete")}
</button>
</div>
</div>
</div>
)}
</>
)}
</Modal>
</>
);
}

View File

@@ -0,0 +1,539 @@
"use client";
import { useState, useMemo, useCallback } from "react";
import { useRouter } from "@/i18n/navigation";
import { useTranslations } from "next-intl";
import { Card, CardHeader } from "@/components/ui/card";
import type {
CustomInvoiceDraftLine,
CustomInvoiceDraftPayload,
InvoiceDraftRecord,
OrgBilling,
} from "@/types";
interface Props {
draft: InvoiceDraftRecord;
orgBilling: OrgBilling | null;
}
const LOCALE_OPTIONS = [
{ value: "de", label: "Deutsch" },
{ value: "en", label: "English" },
{ value: "fr", label: "Français" },
{ value: "it", label: "Italiano" },
];
/**
* Custom invoice editor — Phase 8.
*
* Local state mirrors the persisted payload. Save persists the
* current state via PUT. Preview re-renders the PDF in-memory (no
* persistence). Issue allocates the invoice number and emails the
* customer.
*
* VAT preview is computed client-side from the country in the org
* billing snapshot — it's an estimate for the admin's eye, not
* authoritative. The server recomputes at issue time using the
* same vatRateForAddress() helper to ensure consistency.
*
* Discount/Rabatt is supported via a row with a negative
* unitPriceChf. The "Add discount" button seeds a new row with
* quantity 1 and a -50 placeholder to nudge the admin toward the
* intended sign.
*/
export function CustomInvoiceEditor({ draft, orgBilling }: Props) {
const t = useTranslations("adminBilling");
const router = useRouter();
// Editable state — initialized from the draft payload.
const [issueDate, setIssueDate] = useState(draft.payload.issueDate);
const [dueDate, setDueDate] = useState(draft.payload.dueDate);
const [locale, setLocale] = useState<"de" | "en" | "fr" | "it">(
draft.payload.locale
);
const [paymentMethod, setPaymentMethod] = useState<"invoice" | "card">(
draft.payload.paymentMethod
);
const [adminNotes, setAdminNotes] = useState(draft.payload.adminNotes ?? "");
const [lines, setLines] = useState<CustomInvoiceDraftLine[]>(
draft.payload.lines.length > 0
? draft.payload.lines
: [{ description: "", quantity: 1, unitPriceChf: 0 }]
);
const [busy, setBusy] = useState<null | "save" | "preview" | "issue" | "delete">(
null
);
const [error, setError] = useState("");
const [dirty, setDirty] = useState(false);
// Build current payload — used by every action.
const buildPayload = useCallback((): CustomInvoiceDraftPayload => {
return {
issueDate,
dueDate,
locale,
paymentMethod,
adminNotes: adminNotes.trim() ? adminNotes.trim() : undefined,
lines: lines.map((ln) => ({
description: ln.description,
quantity: Number(ln.quantity) || 0,
unitPriceChf: Number(ln.unitPriceChf) || 0,
})),
};
}, [issueDate, dueDate, locale, paymentMethod, adminNotes, lines]);
// Client-side VAT estimate. The auth-of-truth math runs server-side
// at issue time; this is just to show the admin what they're about
// to commit to.
const totals = useMemo(() => {
const subtotal = Math.round(
lines.reduce(
(s, ln) => s + (Number(ln.quantity) || 0) * (Number(ln.unitPriceChf) || 0),
0
) * 100
) / 100;
// Country-based VAT estimate. Mirrors vatRateForAddress() —
// simplified because the editor doesn't know the platform
// pricing config. Defaults to 8.1 for CH/LI; 0 otherwise.
const country = (orgBilling?.country ?? "").toUpperCase();
let vatRate = 0;
if (country === "CH" || country === "LI") {
vatRate = 8.1;
} else if (orgBilling?.vatNumber) {
vatRate = 0; // reverse charge
} else {
vatRate = 0; // out of scope OR consumer (server will fix)
}
const vatAmount = Math.round(subtotal * (vatRate / 100) * 100) / 100;
const total = Math.round((subtotal + vatAmount) * 100) / 100;
return { subtotal, vatRate, vatAmount, total };
}, [lines, orgBilling]);
// Line management
const updateLine = (idx: number, patch: Partial<CustomInvoiceDraftLine>) => {
setLines((prev) =>
prev.map((ln, i) => (i === idx ? { ...ln, ...patch } : ln))
);
setDirty(true);
};
const addLine = () => {
setLines((prev) => [
...prev,
{ description: "", quantity: 1, unitPriceChf: 0 },
]);
setDirty(true);
};
const addDiscountLine = () => {
setLines((prev) => [
...prev,
{ description: t("editorRabattDefaultDescription"), quantity: 1, unitPriceChf: -50 },
]);
setDirty(true);
};
const removeLine = (idx: number) => {
setLines((prev) => prev.filter((_, i) => i !== idx));
setDirty(true);
};
// Actions
const save = async (): Promise<boolean> => {
setError("");
setBusy("save");
try {
const res = await fetch(
`/api/admin/billing/invoice-drafts/${draft.id}`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(buildPayload()),
}
);
const j = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(j.error || `HTTP ${res.status}`);
setDirty(false);
return true;
} catch (e: any) {
setError(e.message);
return false;
} finally {
setBusy(null);
}
};
const preview = async () => {
// Save first if there are unsaved changes — otherwise the
// preview reflects stale data.
if (dirty) {
const ok = await save();
if (!ok) return;
}
// Open the preview in a new tab. The browser handles the PDF
// download/render natively; we don't need to fetch the bytes
// ourselves.
window.open(
`/api/admin/billing/invoice-drafts/${draft.id}/preview`,
"_blank",
"noopener"
);
};
const issue = async () => {
if (!confirm(t("editorIssueConfirm"))) return;
if (dirty) {
const ok = await save();
if (!ok) return;
}
setError("");
setBusy("issue");
try {
const res = await fetch(
`/api/admin/billing/invoice-drafts/${draft.id}/issue`,
{ method: "POST" }
);
const j = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(j.error || `HTTP ${res.status}`);
// The draft was deleted server-side; go look at the new invoice.
router.push(`/admin/billing/invoices/${j.invoice.id}`);
} catch (e: any) {
setError(e.message);
setBusy(null);
}
};
const deleteDraft = async () => {
if (!confirm(t("editorDeleteConfirm"))) return;
setError("");
setBusy("delete");
try {
const res = await fetch(
`/api/admin/billing/invoice-drafts/${draft.id}`,
{ method: "DELETE" }
);
if (!res.ok) {
const j = await res.json().catch(() => ({}));
throw new Error(j.error || `HTTP ${res.status}`);
}
router.push("/admin/billing/invoice-drafts");
} catch (e: any) {
setError(e.message);
setBusy(null);
}
};
// No billing snapshot = can't issue. Save still works so admin
// can come back once the customer has completed onboarding.
const canIssue =
!!orgBilling &&
lines.length > 0 &&
lines.every((ln) => ln.description.trim().length > 0);
return (
<div className="flex flex-col gap-6">
{/* Bill-to preview — read-only, sourced from the org's billing
snapshot. Issued at issue time. */}
<Card>
<CardHeader>{t("editorBillToHeading")}</CardHeader>
<div className="p-4 text-sm">
{orgBilling ? (
<>
<p className="font-medium">{orgBilling.companyName}</p>
{orgBilling.contactName && (
<p className="text-text-secondary text-xs">
{orgBilling.contactName}
</p>
)}
<p className="text-text-secondary text-xs">
{orgBilling.streetAddress}, {orgBilling.postalCode}{" "}
{orgBilling.city}, {orgBilling.country}
</p>
{orgBilling.vatNumber && (
<p className="text-text-muted text-xs mt-1">
MWST/VAT: {orgBilling.vatNumber}
</p>
)}
<p className="text-text-muted text-xs">
{orgBilling.billingEmail}
</p>
</>
) : (
<p className="text-error">{t("editorNoBillingSnapshot")}</p>
)}
</div>
</Card>
{/* Dates + locale + payment method */}
<Card>
<CardHeader>{t("editorMetadataHeading")}</CardHeader>
<div className="p-4 grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="flex flex-col gap-1">
<label className="text-xs uppercase tracking-wider text-text-muted">
{t("editorIssueDateLabel")}
</label>
<input
type="date"
value={issueDate}
onChange={(e) => {
setIssueDate(e.target.value);
setDirty(true);
}}
className="px-3 py-2 rounded-md border border-border bg-surface-2 text-sm"
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs uppercase tracking-wider text-text-muted">
{t("editorDueDateLabel")}
</label>
<input
type="date"
value={dueDate}
onChange={(e) => {
setDueDate(e.target.value);
setDirty(true);
}}
className="px-3 py-2 rounded-md border border-border bg-surface-2 text-sm"
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs uppercase tracking-wider text-text-muted">
{t("editorLocaleLabel")}
</label>
<select
value={locale}
onChange={(e) => {
setLocale(e.target.value as "de" | "en" | "fr" | "it");
setDirty(true);
}}
className="px-3 py-2 rounded-md border border-border bg-surface-2 text-sm"
>
{LOCALE_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs uppercase tracking-wider text-text-muted">
{t("editorPaymentMethodLabel")}
</label>
<select
value={paymentMethod}
onChange={(e) => {
setPaymentMethod(e.target.value as "invoice" | "card");
setDirty(true);
}}
className="px-3 py-2 rounded-md border border-border bg-surface-2 text-sm"
>
<option value="invoice">{t("editorPaymentInvoice")}</option>
<option value="card">{t("editorPaymentCard")}</option>
</select>
</div>
</div>
</Card>
{/* Line editor */}
<Card>
<CardHeader>{t("editorLinesHeading")}</CardHeader>
<div className="p-4">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="text-xs text-text-muted text-left">
<tr>
<th className="pb-2 pr-3">{t("editorLineDescription")}</th>
<th className="pb-2 pr-3 w-20 text-right">
{t("editorLineQty")}
</th>
<th className="pb-2 pr-3 w-32 text-right">
{t("editorLineUnitPrice")}
</th>
<th className="pb-2 pr-3 w-32 text-right">
{t("editorLineAmount")}
</th>
<th className="pb-2 w-12"></th>
</tr>
</thead>
<tbody>
{lines.map((ln, idx) => {
const amount =
Math.round(
(Number(ln.quantity) || 0) *
(Number(ln.unitPriceChf) || 0) *
100
) / 100;
return (
<tr key={idx} className="border-t border-border">
<td className="py-2 pr-3">
<input
type="text"
value={ln.description}
onChange={(e) =>
updateLine(idx, { description: e.target.value })
}
placeholder={t("editorLineDescriptionPlaceholder")}
className="w-full px-2 py-1.5 rounded border border-border bg-surface-2 text-sm"
maxLength={500}
/>
</td>
<td className="py-2 pr-3">
<input
type="number"
step="0.01"
value={ln.quantity}
onChange={(e) =>
updateLine(idx, {
quantity: parseFloat(e.target.value) || 0,
})
}
className="w-full px-2 py-1.5 rounded border border-border bg-surface-2 text-sm font-mono text-right"
/>
</td>
<td className="py-2 pr-3">
<input
type="number"
step="0.01"
value={ln.unitPriceChf}
onChange={(e) =>
updateLine(idx, {
unitPriceChf: parseFloat(e.target.value) || 0,
})
}
className="w-full px-2 py-1.5 rounded border border-border bg-surface-2 text-sm font-mono text-right"
/>
</td>
<td className="py-2 pr-3 text-right font-mono text-sm whitespace-nowrap">
<span className={amount < 0 ? "text-error" : ""}>
CHF {amount.toFixed(2)}
</span>
</td>
<td className="py-2 text-right">
<button
onClick={() => removeLine(idx)}
className="text-text-muted hover:text-error text-lg leading-none"
title={t("editorLineRemove")}
type="button"
>
×
</button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
<div className="flex gap-2 mt-3">
<button
onClick={addLine}
type="button"
className="px-3 py-1.5 rounded-md border border-border text-sm hover:bg-surface-3"
>
+ {t("editorAddLine")}
</button>
<button
onClick={addDiscountLine}
type="button"
className="px-3 py-1.5 rounded-md border border-border text-sm hover:bg-surface-3 text-text-secondary"
title={t("editorAddDiscountHint")}
>
{t("editorAddDiscount")}
</button>
</div>
</div>
</Card>
{/* Admin notes */}
<Card>
<CardHeader>{t("editorNotesHeading")}</CardHeader>
<div className="p-4">
<textarea
value={adminNotes}
onChange={(e) => {
setAdminNotes(e.target.value);
setDirty(true);
}}
placeholder={t("editorNotesPlaceholder")}
rows={2}
maxLength={2000}
className="w-full px-3 py-2 rounded-md border border-border bg-surface-2 text-sm"
/>
<p className="text-xs text-text-muted mt-1">
{t("editorNotesHint")}
</p>
</div>
</Card>
{/* Totals preview */}
<Card>
<CardHeader>{t("editorTotalsHeading")}</CardHeader>
<div className="p-4 max-w-sm ml-auto text-sm">
<div className="flex justify-between py-1">
<span className="text-text-muted">{t("editorSubtotal")}</span>
<span className="font-mono">CHF {totals.subtotal.toFixed(2)}</span>
</div>
<div className="flex justify-between py-1">
<span className="text-text-muted">
{t("editorVat")} ({totals.vatRate.toFixed(1)}%)
</span>
<span className="font-mono">CHF {totals.vatAmount.toFixed(2)}</span>
</div>
<div className="flex justify-between py-2 border-t border-border mt-1 font-medium">
<span>{t("editorTotal")}</span>
<span className="font-mono">CHF {totals.total.toFixed(2)}</span>
</div>
<p className="text-xs text-text-muted mt-2 italic">
{t("editorTotalsEstimateNote")}
</p>
</div>
</Card>
{/* Error + actions */}
{error && (
<div className="text-sm text-error border border-error/30 bg-error/10 rounded-md px-4 py-2">
{error}
</div>
)}
<div className="flex flex-wrap gap-2 justify-between items-center">
<button
onClick={deleteDraft}
disabled={busy !== null}
className="px-4 py-2 rounded-md border border-error text-error text-sm disabled:opacity-50 hover:bg-error/10"
type="button"
>
{busy === "delete" ? t("deleting") : t("editorDeleteBtn")}
</button>
<div className="flex gap-2 ml-auto">
<button
onClick={save}
disabled={busy !== null || !dirty}
className="px-4 py-2 rounded-md border border-border text-sm disabled:opacity-50"
type="button"
>
{busy === "save"
? t("saving")
: dirty
? t("editorSaveBtn")
: t("editorSavedBtn")}
</button>
<button
onClick={preview}
disabled={busy !== null || lines.length === 0}
className="px-4 py-2 rounded-md border border-border text-sm disabled:opacity-50"
type="button"
>
{busy === "preview" ? t("previewing") : t("editorPreviewBtn")}
</button>
<button
onClick={issue}
disabled={busy !== null || !canIssue}
className="px-4 py-2 rounded-md bg-accent text-surface-0 text-sm disabled:opacity-50"
type="button"
>
{busy === "issue" ? t("issuing") : t("editorIssueBtn")}
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,147 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useTranslations, useFormatter } from "next-intl";
import { Card } from "@/components/ui/card";
import type { InvoiceDraftRecord } from "@/types";
interface Props {
drafts: InvoiceDraftRecord[];
/** Map ZITADEL org id → company name for friendlier display. */
orgNameMap: Record<string, string>;
}
/**
* Renders the drafts table with per-row Edit / Delete actions.
*
* The total preview is the algebraic sum of line amounts (the same
* formula billing.computeCustomInvoiceTotals uses for the subtotal,
* minus VAT — which we don't know without the org's billing
* snapshot). It's a hint, not authoritative; the real total
* appears when the draft is issued.
*
* Empty state shows a clear CTA so a fresh admin knows where to
* start.
*/
export function DraftList({ drafts, orgNameMap }: Props) {
const t = useTranslations("adminBilling");
const fmt = useFormatter();
const router = useRouter();
const [busyId, setBusyId] = useState<string | null>(null);
const onDelete = async (id: string) => {
if (!confirm(t("draftDeleteConfirm"))) return;
setBusyId(id);
try {
const res = await fetch(`/api/admin/billing/invoice-drafts/${id}`, {
method: "DELETE",
});
if (!res.ok) {
const j = await res.json().catch(() => ({}));
throw new Error(j.error || `HTTP ${res.status}`);
}
router.refresh();
} catch (e: any) {
alert(e.message);
} finally {
setBusyId(null);
}
};
if (drafts.length === 0) {
return (
<Card>
<div className="p-6 text-center">
<p className="text-text-secondary mb-4">{t("draftsEmpty")}</p>
<Link
href="/admin/billing/invoices/new"
className="inline-block px-4 py-2 rounded-md bg-accent text-surface-0 text-sm"
>
{t("newInvoiceBtn")}
</Link>
</div>
</Card>
);
}
return (
<Card>
<div className="flex justify-end p-3 border-b border-border">
<Link
href="/admin/billing/invoices/new"
className="inline-block px-3 py-1.5 rounded-md bg-accent text-surface-0 text-sm"
>
{t("newInvoiceBtn")}
</Link>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="text-xs text-text-muted text-left">
<tr>
<th className="pb-2 pl-3 pr-4">{t("draftOrgCol")}</th>
<th className="pb-2 pr-4">{t("draftIssueDateCol")}</th>
<th className="pb-2 pr-4 text-center">{t("draftLinesCol")}</th>
<th className="pb-2 pr-4 text-right">{t("draftSubtotalCol")}</th>
<th className="pb-2 pr-4">{t("draftUpdatedCol")}</th>
<th className="pb-2 pr-3 text-right">{t("draftActionsCol")}</th>
</tr>
</thead>
<tbody>
{drafts.map((d) => {
const subtotal = d.payload.lines.reduce(
(s, ln) =>
s +
Math.round(ln.quantity * ln.unitPriceChf * 100) / 100,
0
);
return (
<tr key={d.id} className="border-t border-border">
<td className="py-2 pl-3 pr-4">
<Link
href={`/admin/billing/invoice-drafts/${d.id}`}
className="hover:underline"
>
{orgNameMap[d.zitadelOrgId] ?? d.zitadelOrgId}
</Link>
</td>
<td className="py-2 pr-4 text-xs font-mono text-text-secondary whitespace-nowrap">
{d.payload.issueDate}
</td>
<td className="py-2 pr-4 text-center text-xs">
{d.payload.lines.length}
</td>
<td className="py-2 pr-4 text-right font-mono text-xs whitespace-nowrap">
CHF {subtotal.toFixed(2)}
</td>
<td className="py-2 pr-4 text-xs text-text-muted whitespace-nowrap">
{fmt.dateTime(new Date(d.updatedAt), {
dateStyle: "medium",
timeStyle: "short",
})}
</td>
<td className="py-2 pr-3 text-right">
<Link
href={`/admin/billing/invoice-drafts/${d.id}`}
className="text-accent hover:underline text-xs mr-3"
>
{t("editBtn")}
</Link>
<button
onClick={() => onDelete(d.id)}
disabled={busyId === d.id}
className="text-error hover:underline text-xs disabled:opacity-50"
>
{busyId === d.id ? t("deleting") : t("deleteBtn")}
</button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</Card>
);
}

View File

@@ -0,0 +1,347 @@
"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-surface-0 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>
)}
<div className="overflow-x-auto">
<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>
<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,643 @@
"use client";
import { useState, Fragment } from "react";
import { useRouter } from "@/i18n/navigation";
import { useTranslations } from "next-intl";
import { Card, CardHeader } from "@/components/ui/card";
import type { CreditNote, InvoiceDetail, InvoiceStatus } from "@/types";
interface Props {
detail: InvoiceDetail;
/**
* Phase 7: credit notes linked to this invoice (voids + refunds).
* Empty array when none. Passed from the server page; client
* doesn't re-fetch — router.refresh() rebuilds after actions.
*/
creditNotes?: CreditNote[];
}
/**
* Renders the invoice header (status, totals, action bar) then
* line items grouped by tenant, then billing snapshot. Actions are
* mark-paid (POST), void (POST), refund (POST), delete (DELETE),
* PDF download (link to /pdf).
*
* Phase 7 adds void + refund. The action bar shows:
* - status open/overdue → Mark paid, Void, Delete
* - status paid → Refund, Delete
* - status partially_refunded → Refund (for remainder), Delete
* - status fully_refunded / void → Delete only (read-only otherwise)
*
* On successful action we router.refresh() — the server-side page
* re-renders against the new DB state, including any new credit
* notes.
*/
export function InvoiceDetailView({ detail, creditNotes = [] }: Props) {
const t = useTranslations("adminBilling");
const router = useRouter();
const { invoice, lines } = detail;
const [busyAction, setBusyAction] = useState<
null | "mark-paid" | "delete" | "void" | "refund"
>(null);
const [actionError, setActionError] = useState("");
const [noteInput, setNoteInput] = useState("");
const [noteOpen, setNoteOpen] = useState(false);
// Phase 7 — void modal state
const [voidOpen, setVoidOpen] = useState(false);
const [voidReason, setVoidReason] = useState("");
// Phase 7 — refund modal state. Amount defaults to the full
// remaining refundable on open.
const [refundOpen, setRefundOpen] = useState(false);
const [refundAmount, setRefundAmount] = useState("");
const [refundReason, setRefundReason] = useState("");
const remainingRefundable =
Math.round(
(invoice.totalChf - invoice.refundedTotalChf) * 100
) / 100;
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);
}
};
// Phase 7 — void: marks an unpaid invoice as cancelled and issues
// a credit note. Backend rejects if the invoice is paid (use
// refund) or already voided/refunded.
const voidInvoice = async () => {
if (!voidReason.trim()) {
setActionError(t("voidReasonRequired"));
return;
}
setActionError("");
setBusyAction("void");
try {
const res = await fetch(
`/api/admin/billing/invoices/${invoice.id}/void`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ reason: voidReason }),
}
);
const j = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(j.error || `HTTP ${res.status}`);
setVoidOpen(false);
setVoidReason("");
router.refresh();
} catch (e: any) {
setActionError(e.message);
} finally {
setBusyAction(null);
}
};
// Phase 7 — refund: paid invoices only. Amount may be partial;
// backend caps at remaining refundable.
const refundInvoice = async () => {
const amt = parseFloat(refundAmount);
if (!isFinite(amt) || amt <= 0) {
setActionError(t("refundAmountInvalid"));
return;
}
if (amt - remainingRefundable > 0.005) {
setActionError(
t("refundAmountExceeds", {
max: remainingRefundable.toFixed(2),
})
);
return;
}
if (!refundReason.trim()) {
setActionError(t("refundReasonRequired"));
return;
}
setActionError("");
setBusyAction("refund");
try {
const res = await fetch(
`/api/admin/billing/invoices/${invoice.id}/refund`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
amountChf: Math.round(amt * 100) / 100,
reason: refundReason,
}),
}
);
const j = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(j.error || `HTTP ${res.status}`);
setRefundOpen(false);
setRefundAmount("");
setRefundReason("");
router.refresh();
} catch (e: any) {
setActionError(e.message);
} finally {
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} />
{invoice.periodStart && invoice.periodEnd && (
<>
<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-surface-0 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-surface-0 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>
)}
</>
)}
{/* Phase 7 — Void: visible only for open/overdue invoices.
Same gating as Mark Paid but mutually exclusive with it
via the chosen action. Opens a small inline form so
the admin can enter a reason; reason is required and
lands on the credit-note PDF. */}
{(invoice.status === "open" || invoice.status === "overdue") && (
<>
{!voidOpen ? (
<button
onClick={() => {
setVoidOpen(true);
setNoteOpen(false);
setRefundOpen(false);
}}
disabled={busyAction !== null}
className="px-4 py-2 rounded-md border border-error text-error text-sm disabled:opacity-50 hover:bg-error/10"
>
{t("voidBtn")}
</button>
) : (
<div className="flex items-center gap-2 flex-grow">
<input
type="text"
placeholder={t("voidReasonPlaceholder")}
value={voidReason}
onChange={(e) => setVoidReason(e.target.value)}
maxLength={500}
className="flex-grow px-3 py-1.5 rounded-md border border-border bg-surface-2 text-sm"
autoFocus
/>
<button
onClick={voidInvoice}
disabled={busyAction !== null}
className="px-3 py-1.5 rounded-md bg-error text-white text-sm disabled:opacity-50"
>
{busyAction === "void" ? t("saving") : t("confirmVoid")}
</button>
<button
onClick={() => {
setVoidOpen(false);
setVoidReason("");
}}
className="px-3 py-1.5 rounded-md border border-border text-sm"
>
{t("cancel")}
</button>
</div>
)}
</>
)}
{/* Phase 7 — Refund: paid invoices, including ones already
partially refunded (as long as some refundable amount
remains). Opens an inline form with amount + reason.
The remaining-refundable hint helps admin pick the
right number. */}
{(invoice.status === "paid" ||
invoice.status === "partially_refunded") &&
remainingRefundable > 0 && (
<>
{!refundOpen ? (
<button
onClick={() => {
setRefundOpen(true);
setNoteOpen(false);
setVoidOpen(false);
setRefundAmount(remainingRefundable.toFixed(2));
}}
disabled={busyAction !== null}
className="px-4 py-2 rounded-md border border-error text-error text-sm disabled:opacity-50 hover:bg-error/10"
>
{t("refundBtn")}
</button>
) : (
<div className="flex flex-col gap-2 flex-grow">
<div className="text-xs text-text-muted">
{t("refundRemainingHint", {
max: remainingRefundable.toFixed(2),
})}
</div>
<div className="flex items-center gap-4 flex-wrap">
<div className="flex flex-col gap-1">
<label className="text-[10px] uppercase tracking-wider text-text-muted">
{t("refundAmountLabel")}
</label>
<input
type="number"
step="0.01"
min="0.01"
max={remainingRefundable}
placeholder="CHF"
value={refundAmount}
onChange={(e) => setRefundAmount(e.target.value)}
className="w-32 px-3 py-1.5 rounded-md border border-border bg-surface-2 text-sm font-mono"
autoFocus
/>
<span className="text-[10px] text-text-muted italic">
{t("refundAmountInclVatHint")}
</span>
</div>
<div className="flex flex-col gap-1 flex-grow min-w-[200px]">
<label className="text-[10px] uppercase tracking-wider text-text-muted">
{t("refundReasonLabel")}
</label>
<input
type="text"
placeholder={t("refundReasonPlaceholder")}
value={refundReason}
onChange={(e) => setRefundReason(e.target.value)}
maxLength={500}
className="w-full px-3 py-1.5 rounded-md border border-border bg-surface-2 text-sm"
/>
</div>
<div className="flex items-center gap-2 self-end">
<button
onClick={refundInvoice}
disabled={busyAction !== null}
className="px-3 py-1.5 rounded-md bg-error text-white text-sm disabled:opacity-50"
>
{busyAction === "refund"
? t("saving")
: t("confirmRefund")}
</button>
<button
onClick={() => {
setRefundOpen(false);
setRefundAmount("");
setRefundReason("");
}}
className="px-3 py-1.5 rounded-md border border-border text-sm"
>
{t("cancel")}
</button>
</div>
</div>
</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>
)}
{/* Phase 7 — void/refund summary lines, shown when applicable.
Surfaces the auditing context that the columns alone don't
(who voided, what the reason was, how much has been
refunded vs how much remains). */}
{invoice.voidedAt && (
<div className="mt-3 text-xs text-text-muted">
{t("voidedOnLabel")}: {invoice.voidedAt} · {invoice.voidedBy}
{invoice.voidReason ? ` · ${invoice.voidReason}` : ""}
</div>
)}
{invoice.refundedTotalChf > 0 && (
<div className="mt-3 text-xs text-text-muted">
{t("refundedTotalLabel")}: CHF{" "}
{invoice.refundedTotalChf.toFixed(2)} ·{" "}
{t("refundedRemainingLabel")}: CHF{" "}
{remainingRefundable.toFixed(2)}
</div>
)}
</Card>
{/* Phase 7 — linked credit notes panel. Hidden when there are
none (most invoices). When present, lists each credit note
with kind, amount, reason, issued date, and PDF download. */}
{creditNotes.length > 0 && (
<Card>
<CardHeader>{t("creditNotesPanelTitle")}</CardHeader>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="text-xs text-text-muted text-left">
<tr>
<th className="pb-2 pr-4">{t("creditNoteNumberHeader")}</th>
<th className="pb-2 pr-4">{t("creditNoteKindHeader")}</th>
<th className="pb-2 pr-4 text-right">
{t("creditNoteAmountHeader")}
</th>
<th className="pb-2 pr-4">{t("creditNoteReasonHeader")}</th>
<th className="pb-2 pr-4">{t("creditNoteIssuedHeader")}</th>
<th className="pb-2 text-right">{t("creditNotePdfHeader")}</th>
</tr>
</thead>
<tbody>
{creditNotes.map((cn) => (
<tr key={cn.id} className="border-t border-border">
<td className="py-2 pr-4 font-mono text-xs">
{cn.creditNoteNumber}
</td>
<td className="py-2 pr-4">
<span className="px-2 py-0.5 rounded text-xs text-error bg-error/10">
{t(`creditNoteKind_${cn.kind}` as any)}
</span>
</td>
<td className="py-2 pr-4 text-right font-mono whitespace-nowrap">
CHF {cn.amountChf.toFixed(2)}
</td>
<td className="py-2 pr-4 text-text-secondary text-xs">
{cn.reason ?? "—"}
</td>
<td className="py-2 pr-4 text-xs text-text-muted whitespace-nowrap">
{cn.issuedAt.slice(0, 10)}
</td>
<td className="py-2 text-right">
{cn.hasPdf ? (
<a
href={`/api/credit-notes/${encodeURIComponent(
cn.creditNoteNumber
)}/pdf`}
target="_blank"
rel="noopener noreferrer"
className="text-accent hover:underline text-xs"
>
{t("downloadPdfBtn")}
</a>
) : (
<span className="text-text-muted text-xs italic">
{t("creditNoteNoPdf")}
</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
)}
{/* Lines */}
<Card>
<CardHeader>{t("lineItemsTitle")}</CardHeader>
<div className="overflow-x-auto">
<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>
<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"
: status === "partially_refunded" || status === "fully_refunded"
? "bg-error/15 text-error"
: "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,206 @@
"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>
)}
{/* Phase 8: shortcuts to the custom-invoice flow. The
Drafts link is muted because most of the time it's
empty; New invoice is the prominent CTA. */}
<div className={`flex items-center gap-3 ${busy ? "" : "ml-auto"}`}>
<Link
href="/admin/billing/invoice-drafts"
className="text-xs text-text-muted hover:underline"
>
{t("draftsLink")}
</Link>
<Link
href="/admin/billing/invoices/new"
className="px-3 py-1.5 rounded-md bg-accent text-surface-0 text-sm"
>
+ {t("newInvoiceBtn")}
</Link>
</div>
</div>
</Card>
<Card>
{invoices.length === 0 ? (
<p className="text-sm text-text-muted italic text-center py-6">
{t("noInvoicesFound")}
</p>
) : (
<div className="overflow-x-auto">
<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
? inv.periodStart.slice(0, 7)
: inv.source === "custom"
? "—"
: ""}
</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>
</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>
);
}

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