Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 11d7dbb06e | |||
| d41f0b6ec9 | |||
| 03f8dd9afe | |||
| d4fcc33bc1 | |||
| cdc2210eaf | |||
| 6bf9caa53a | |||
| c8ed27157f | |||
| 6baca1a459 | |||
| faf49119ea | |||
| ce70fe8480 | |||
| 55571b1e59 | |||
| c0ff22394c |
140
README.md
140
README.md
@@ -1,116 +1,54 @@
|
|||||||
# Threema UX rework + QR code
|
# PieCed Portal — Billing Phase 1 patch (suspend-via-admin fix)
|
||||||
|
|
||||||
|
Single-file fix on top of the Phase 1 v2 drop.
|
||||||
|
|
||||||
|
## What it fixes
|
||||||
|
|
||||||
|
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`.
|
||||||
|
|
||||||
|
This patch adds the same `recordSuspensionEvent` hook to the
|
||||||
|
admin route. No other code paths affected; no schema changes.
|
||||||
|
|
||||||
## Files
|
## Files
|
||||||
|
|
||||||
```
|
```
|
||||||
src/lib/threema-gateway-config.ts # NEW — single source of truth for gateway ID + QR path
|
src/app/api/admin/tenants/[name]/suspend/route.ts MODIFIED
|
||||||
src/components/channel-users/threema-setup.tsx # NEW — QR + 3-step instructions component
|
|
||||||
src/components/channel-users/channel-users.tsx # MODIFIED — renders <ThreemaSetup /> for the threema channel
|
|
||||||
deploy/patch-i18n-threema.mjs # REPLACES earlier version — customer-friendly texts in 4 langs
|
|
||||||
public/threema/qr_code_AIAGENT.png # NEW — the QR you uploaded
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## What changed (UX, customer-facing)
|
## Deploy
|
||||||
|
|
||||||
1. **Package description / instructions / disclaimer.** No mention of
|
Extract over your `pieced-portal/` tree, rebuild, redeploy as
|
||||||
"Gateway account", "Gateway credentials", or anything about asterisks.
|
usual. After the new image is running, verify:
|
||||||
The disclaimer now explicitly says messages are end-to-end encrypted
|
|
||||||
only up to PieCed's messaging service (where they get decrypted to
|
|
||||||
route to the assistant) — accurate, not overclaiming. Crucially it
|
|
||||||
also says **Threema charges per message** so customers know that
|
|
||||||
sending/receiving on this channel has a cost separate from their
|
|
||||||
PieCed subscription.
|
|
||||||
|
|
||||||
2. **Threema ID help text.** Now tells the customer to add **their
|
1. Suspend any test tenant from the `/admin` panel.
|
||||||
own** ID, with explicit instructions for finding it in the Threema
|
2. Check the events table:
|
||||||
app (Settings → My Threema ID). Drops the asterisk / Gateway-prefix
|
|
||||||
explanation entirely.
|
|
||||||
|
|
||||||
3. **QR code component (NEW).** Shown above the help text in the
|
```bash
|
||||||
channel-users panel when the threema channel is enabled. Three-step
|
kubectl -n pieced-system exec -it portal-db-1 -- psql -U postgres -d portal -c \
|
||||||
flow: open Threema, scan, add your own ID below.
|
"SELECT * FROM tenant_suspension_events ORDER BY id DESC LIMIT 5;"
|
||||||
|
```
|
||||||
|
|
||||||
4. **All four languages** (en/de/fr/it) updated consistently.
|
Expect a fresh `suspended` row for the tenant you just toggled.
|
||||||
|
|
||||||
## What changed (technical)
|
3. Resume → expect a `resumed` row.
|
||||||
|
|
||||||
- Gateway constants centralised in `src/lib/threema-gateway-config.ts`.
|
## Why I missed this
|
||||||
Today hardcoded to `*AIAGENT` + `/threema/qr_code_AIAGENT.png`. When
|
|
||||||
you need multiple gateway accounts, edit that one file (and the
|
|
||||||
inline TODO block tells the next person how).
|
|
||||||
- The component uses Next.js `<Image>` — automatic optimisation,
|
|
||||||
lazy-loaded by default (no `priority`).
|
|
||||||
- The PNG goes in `public/threema/`, served as a static asset under
|
|
||||||
`/threema/qr_code_AIAGENT.png` — no API route, no auth wrapper, the
|
|
||||||
QR encodes a Threema invitation anyone can scan anyway.
|
|
||||||
|
|
||||||
## Apply
|
Both routes share the same shape (PATCH/POST that sets
|
||||||
|
`spec.suspend`), but they differ on:
|
||||||
|
|
||||||
```bash
|
- URL path (`/api/admin/tenants/...` vs `/api/tenants/...`)
|
||||||
cd /path/to/pieced-portal
|
- Method (POST vs PATCH)
|
||||||
|
- Authorization (platform-only vs owner+platform)
|
||||||
|
- Caller (admin panel vs customer cancel button)
|
||||||
|
|
||||||
# Drop in new files (overwrites prior channel-users.tsx and i18n script)
|
When I grepped for the suspend hook target I matched on the
|
||||||
cp -r <unzipped>/* .
|
customer endpoint and didn't audit cross-cutting admin
|
||||||
|
duplicates. I've since checked every site that calls
|
||||||
# Quick TS check (the new component uses next/image — should be fine)
|
`patchTenantSpec`, `createTenant`, or `deleteTenant` — this was
|
||||||
npx tsc --noEmit
|
the only missed billing-relevant one. Other `patchTenantSpec`
|
||||||
|
sites are confirmed non-billing (openClawImage, channelUsers).
|
||||||
# Patch the message files
|
|
||||||
node deploy/patch-i18n-threema.mjs
|
|
||||||
# Should print 4 lines, one per language
|
|
||||||
|
|
||||||
# Commit + push
|
|
||||||
git add -A
|
|
||||||
git status # eyeball the changes
|
|
||||||
git commit -m "Threema: customer-friendly texts + QR setup component"
|
|
||||||
git push
|
|
||||||
```
|
|
||||||
|
|
||||||
## Verify after redeploy
|
|
||||||
|
|
||||||
1. Open the portal as a customer admin, pick the test tenant.
|
|
||||||
2. Disable + re-enable Threema in the package list (or just look at
|
|
||||||
the channel-users panel — the QR shows there regardless).
|
|
||||||
3. Authorized Users → threema section should show:
|
|
||||||
- QR code on the left
|
|
||||||
- "AIAGENT" label under the QR (no asterisk)
|
|
||||||
- 3-step instruction list
|
|
||||||
- Help text below: "Enter your own Threema ID..."
|
|
||||||
- Existing user pills + Add input
|
|
||||||
|
|
||||||
Scan the QR with your phone — it should prompt to add `*AIAGENT` as a
|
|
||||||
contact in Threema with trust level "verified by the operator" (the
|
|
||||||
QR encodes the contact's public key).
|
|
||||||
|
|
||||||
## Billing log query (re-run after sending a few messages)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
POD=$(kubectl -n threema-gateway get pods -l 'cnpg.io/cluster=pieced-threema-gateway-db,role=primary' -o jsonpath='{.items[0].metadata.name}')
|
|
||||||
DBPASS=$(kubectl -n threema-gateway get secret pieced-threema-gateway-db-app -o jsonpath='{.data.password}' | base64 -d)
|
|
||||||
|
|
||||||
# Per-tenant in/out counts
|
|
||||||
kubectl -n threema-gateway exec $POD -- env PGPASSWORD="$DBPASS" \
|
|
||||||
psql -U relay -d relay -c \
|
|
||||||
"SELECT tenant_name, direction, count(*), max(created_at) FROM messages GROUP BY tenant_name, direction ORDER BY tenant_name, direction;"
|
|
||||||
```
|
|
||||||
|
|
||||||
That's your billing source — count(*) × Threema's per-message rate ×
|
|
||||||
direction-specific multiplier = customer charge.
|
|
||||||
|
|
||||||
## Future: dynamic gateway accounts
|
|
||||||
|
|
||||||
Today's hardcoded `*AIAGENT` works for one shared gateway. When you
|
|
||||||
move to per-tenant gateway accounts (Threema's bigger plans, or to
|
|
||||||
isolate billing per tenant):
|
|
||||||
|
|
||||||
1. Update `src/lib/threema-gateway-config.ts`:
|
|
||||||
- Make the constants a lookup keyed by tenant
|
|
||||||
- Or fetch from `/api/tenants/<name>/threema` (new admin route)
|
|
||||||
2. Update `threema-setup.tsx` to accept gateway info as props
|
|
||||||
3. Update the parent `channel-users.tsx` to pass tenant-specific values
|
|
||||||
4. Replace the static PNG with a server route that generates per-tenant
|
|
||||||
QR codes from each gateway's `id` + `publicKey`
|
|
||||||
|
|
||||||
The single config file means this refactor is contained — every
|
|
||||||
consumer reads from one place, so once that one place returns the
|
|
||||||
right values, everything else falls in line.
|
|
||||||
|
|||||||
@@ -37,6 +37,9 @@ const i18n = {
|
|||||||
step2: "Tap the scan icon and scan this QR code to add the assistant as a contact.",
|
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.",
|
step3: "Then add your own Threema ID below.",
|
||||||
qrAlt: "QR code to add {gateway} as a Threema contact",
|
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: {
|
de: {
|
||||||
@@ -56,6 +59,9 @@ const i18n = {
|
|||||||
step2: "Tippen Sie auf das Scan-Symbol und scannen Sie diesen QR-Code, um den Assistenten als Kontakt hinzuzufügen.",
|
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.",
|
step3: "Fügen Sie anschliessend unten Ihre eigene Threema-ID hinzu.",
|
||||||
qrAlt: "QR-Code, um {gateway} als Threema-Kontakt hinzuzufügen",
|
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: {
|
fr: {
|
||||||
@@ -75,6 +81,9 @@ const i18n = {
|
|||||||
step2: "Appuyez sur l'icône de scan et scannez ce QR code pour ajouter l'assistant comme contact.",
|
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.",
|
step3: "Puis ajoutez votre propre identifiant Threema ci-dessous.",
|
||||||
qrAlt: "QR code pour ajouter {gateway} comme contact Threema",
|
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: {
|
it: {
|
||||||
@@ -94,6 +103,9 @@ const i18n = {
|
|||||||
step2: "Tocca l'icona di scansione e scansiona questo QR code per aggiungere l'assistente ai contatti.",
|
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.",
|
step3: "Quindi aggiungi il tuo ID Threema qui sotto.",
|
||||||
qrAlt: "QR code per aggiungere {gateway} come contatto Threema",
|
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",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,7 +5,11 @@ const withNextIntl = createNextIntlPlugin();
|
|||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
output: "standalone",
|
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);
|
export default withNextIntl(nextConfig);
|
||||||
|
|||||||
569
package-lock.json
generated
569
package-lock.json
generated
@@ -9,6 +9,7 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@kubernetes/client-node": "^1.4.0",
|
"@kubernetes/client-node": "^1.4.0",
|
||||||
|
"@react-pdf/renderer": "^4.4.0",
|
||||||
"@types/nodemailer": "^8.0.0",
|
"@types/nodemailer": "^8.0.0",
|
||||||
"@types/pg": "^8.20.0",
|
"@types/pg": "^8.20.0",
|
||||||
"next": "^15.5.15",
|
"next": "^15.5.15",
|
||||||
@@ -73,6 +74,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": {
|
"node_modules/@emnapi/core": {
|
||||||
"version": "1.9.2",
|
"version": "1.9.2",
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz",
|
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz",
|
||||||
@@ -1089,6 +1099,30 @@
|
|||||||
"node": ">= 10"
|
"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": {
|
"node_modules/@nodelib/fs.scandir": {
|
||||||
"version": "2.1.5",
|
"version": "2.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
||||||
@@ -1453,6 +1487,183 @@
|
|||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"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": {
|
"node_modules/@rtsao/scc": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
|
||||||
@@ -2617,6 +2828,12 @@
|
|||||||
"win32"
|
"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": {
|
"node_modules/acorn": {
|
||||||
"version": "8.16.0",
|
"version": "8.16.0",
|
||||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
||||||
@@ -3029,6 +3246,35 @@
|
|||||||
"bare-path": "^3.0.0"
|
"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": {
|
"node_modules/brace-expansion": {
|
||||||
"version": "1.1.13",
|
"version": "1.1.13",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz",
|
||||||
@@ -3053,6 +3299,24 @@
|
|||||||
"node": ">=8"
|
"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": {
|
"node_modules/call-bind": {
|
||||||
"version": "1.0.9",
|
"version": "1.0.9",
|
||||||
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz",
|
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz",
|
||||||
@@ -3155,6 +3419,15 @@
|
|||||||
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
|
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/color-convert": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||||
@@ -3175,6 +3448,27 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/combined-stream": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||||
@@ -3355,6 +3649,12 @@
|
|||||||
"node": ">=8"
|
"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": {
|
"node_modules/doctrine": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz",
|
||||||
@@ -3389,6 +3689,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/end-of-stream": {
|
||||||
"version": "1.4.5",
|
"version": "1.4.5",
|
||||||
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
|
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
|
||||||
@@ -4006,6 +4312,15 @@
|
|||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/events-universal": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz",
|
||||||
@@ -4019,7 +4334,6 @@
|
|||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/fast-fifo": {
|
"node_modules/fast-fifo": {
|
||||||
@@ -4082,6 +4396,12 @@
|
|||||||
"reusify": "^1.0.4"
|
"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": {
|
"node_modules/file-entry-cache": {
|
||||||
"version": "8.0.0",
|
"version": "8.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
|
||||||
@@ -4146,6 +4466,23 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"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": {
|
"node_modules/for-each": {
|
||||||
"version": "0.3.5",
|
"version": "0.3.5",
|
||||||
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
|
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
|
||||||
@@ -4458,6 +4795,27 @@
|
|||||||
"node": ">=14"
|
"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": {
|
"node_modules/icu-minify": {
|
||||||
"version": "4.9.0",
|
"version": "4.9.0",
|
||||||
"resolved": "https://registry.npmjs.org/icu-minify/-/icu-minify-4.9.0.tgz",
|
"resolved": "https://registry.npmjs.org/icu-minify/-/icu-minify-4.9.0.tgz",
|
||||||
@@ -4510,6 +4868,12 @@
|
|||||||
"node": ">=0.8.19"
|
"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": {
|
"node_modules/internal-slot": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz",
|
||||||
@@ -4899,6 +5263,12 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/is-weakmap": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz",
|
||||||
@@ -4986,6 +5356,15 @@
|
|||||||
"node": ">= 0.4"
|
"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": {
|
"node_modules/jiti": {
|
||||||
"version": "2.6.1",
|
"version": "2.6.1",
|
||||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
|
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
|
||||||
@@ -5005,11 +5384,16 @@
|
|||||||
"url": "https://github.com/sponsors/panva"
|
"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": {
|
"node_modules/js-tokens": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||||
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
|
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/js-yaml": {
|
"node_modules/js-yaml": {
|
||||||
@@ -5406,6 +5790,25 @@
|
|||||||
"url": "https://opencollective.com/parcel"
|
"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": {
|
"node_modules/locate-path": {
|
||||||
"version": "6.0.0",
|
"version": "6.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
|
||||||
@@ -5433,7 +5836,6 @@
|
|||||||
"version": "1.4.0",
|
"version": "1.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||||
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
|
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"js-tokens": "^3.0.0 || ^4.0.0"
|
"js-tokens": "^3.0.0 || ^4.0.0"
|
||||||
@@ -5461,6 +5863,12 @@
|
|||||||
"node": ">= 0.4"
|
"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": {
|
"node_modules/merge2": {
|
||||||
"version": "1.4.1",
|
"version": "1.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
|
||||||
@@ -5844,6 +6252,15 @@
|
|||||||
"node": ">=6.0.0"
|
"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": {
|
"node_modules/oauth4webapi": {
|
||||||
"version": "3.8.5",
|
"version": "3.8.5",
|
||||||
"resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.5.tgz",
|
"resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.5.tgz",
|
||||||
@@ -5857,7 +6274,6 @@
|
|||||||
"version": "4.1.1",
|
"version": "4.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||||
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
|
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
@@ -6066,6 +6482,12 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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": {
|
"node_modules/parent-module": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
||||||
@@ -6079,6 +6501,12 @@
|
|||||||
"node": ">=6"
|
"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": {
|
"node_modules/path-exists": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||||
@@ -6214,6 +6642,14 @@
|
|||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"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": {
|
"node_modules/po-parser": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/po-parser/-/po-parser-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/po-parser/-/po-parser-2.1.1.tgz",
|
||||||
@@ -6259,6 +6695,12 @@
|
|||||||
"node": "^10 || ^12 || >=14"
|
"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": {
|
"node_modules/postgres-array": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
|
||||||
@@ -6331,7 +6773,6 @@
|
|||||||
"version": "15.8.1",
|
"version": "15.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||||
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
|
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"loose-envify": "^1.4.0",
|
"loose-envify": "^1.4.0",
|
||||||
@@ -6359,6 +6800,15 @@
|
|||||||
"node": ">=6"
|
"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": {
|
"node_modules/queue-microtask": {
|
||||||
"version": "1.2.3",
|
"version": "1.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
||||||
@@ -6405,7 +6855,6 @@
|
|||||||
"version": "16.13.1",
|
"version": "16.13.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/reflect.getprototypeof": {
|
"node_modules/reflect.getprototypeof": {
|
||||||
@@ -6452,6 +6901,15 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/resolve": {
|
||||||
"version": "2.0.0-next.6",
|
"version": "2.0.0-next.6",
|
||||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.6.tgz",
|
"resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.6.tgz",
|
||||||
@@ -6496,6 +6954,12 @@
|
|||||||
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
|
"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": {
|
"node_modules/reusify": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
|
||||||
@@ -6557,6 +7021,26 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/safe-push-apply": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz",
|
||||||
@@ -6901,6 +7385,15 @@
|
|||||||
"text-decoder": "^1.1.0"
|
"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": {
|
"node_modules/string.prototype.includes": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz",
|
||||||
@@ -7086,6 +7579,12 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/tailwindcss": {
|
||||||
"version": "4.2.2",
|
"version": "4.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz",
|
||||||
@@ -7151,6 +7650,12 @@
|
|||||||
"b4a": "^1.6.4"
|
"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": {
|
"node_modules/tinyglobby": {
|
||||||
"version": "0.2.16",
|
"version": "0.2.16",
|
||||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
|
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
|
||||||
@@ -7380,6 +7885,32 @@
|
|||||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/unrs-resolver": {
|
||||||
"version": "1.11.1",
|
"version": "1.11.1",
|
||||||
"resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.11.1.tgz",
|
"resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.11.1.tgz",
|
||||||
@@ -7446,6 +7977,26 @@
|
|||||||
"react": "^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0"
|
"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": {
|
"node_modules/webidl-conversions": {
|
||||||
"version": "3.0.1",
|
"version": "3.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
|
||||||
@@ -7626,6 +8177,12 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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": {
|
"node_modules/zod": {
|
||||||
"version": "3.25.76",
|
"version": "3.25.76",
|
||||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@kubernetes/client-node": "^1.4.0",
|
"@kubernetes/client-node": "^1.4.0",
|
||||||
|
"@react-pdf/renderer": "^4.4.0",
|
||||||
"@types/nodemailer": "^8.0.0",
|
"@types/nodemailer": "^8.0.0",
|
||||||
"@types/pg": "^8.20.0",
|
"@types/pg": "^8.20.0",
|
||||||
"next": "^15.5.15",
|
"next": "^15.5.15",
|
||||||
|
|||||||
71
src/app/[locale]/admin/billing/generate/page.tsx
Normal file
71
src/app/[locale]/admin/billing/generate/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
35
src/app/[locale]/admin/billing/invoices/[id]/page.tsx
Normal file
35
src/app/[locale]/admin/billing/invoices/[id]/page.tsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { notFound, redirect } from "next/navigation";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import { getSessionUser } from "@/lib/session";
|
||||||
|
import { getInvoiceDetail } from "@/lib/db";
|
||||||
|
import { BackLink } from "@/components/ui/back-link";
|
||||||
|
import { InvoiceDetailView } from "@/components/admin/billing/invoice-detail-view";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* /admin/billing/invoices/[id] — full detail of one invoice.
|
||||||
|
*
|
||||||
|
* Server-renders the static body (header, lines, totals, billing
|
||||||
|
* snapshot); the action bar (mark-paid, delete, PDF download) is
|
||||||
|
* a client component for the interactive bits.
|
||||||
|
*/
|
||||||
|
export default async function AdminInvoiceDetailPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
}) {
|
||||||
|
const user = await getSessionUser();
|
||||||
|
if (!user) redirect("/login");
|
||||||
|
if (!user.isPlatform) redirect("/dashboard");
|
||||||
|
const t = await getTranslations("adminBilling");
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
const detail = await getInvoiceDetail(id);
|
||||||
|
if (!detail) notFound();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="max-w-4xl mx-auto px-6 py-8">
|
||||||
|
<BackLink href="/admin/billing/invoices" label={t("backToInvoices")} />
|
||||||
|
<InvoiceDetailView detail={detail} />
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
39
src/app/[locale]/admin/billing/invoices/page.tsx
Normal file
39
src/app/[locale]/admin/billing/invoices/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
128
src/app/[locale]/admin/billing/page.tsx
Normal file
128
src/app/[locale]/admin/billing/page.tsx
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import { getSessionUser } from "@/lib/session";
|
||||||
|
import { getOrgOpenBalances, syncOverdueInvoices } from "@/lib/db";
|
||||||
|
import { Card } from "@/components/ui/card";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* /admin/billing — landing page with sub-section links and a
|
||||||
|
* quick overview of orgs in arrears.
|
||||||
|
*
|
||||||
|
* Sub-pages:
|
||||||
|
* - /admin/billing/pricing — platform + skill prices
|
||||||
|
* - /admin/billing/generate — manual invoice generator (testing)
|
||||||
|
* - /admin/billing/invoices — invoice list/detail
|
||||||
|
*
|
||||||
|
* The Phase 2 customer-side /billing landing page is added in
|
||||||
|
* Phase 3.
|
||||||
|
*/
|
||||||
|
export default async function AdminBillingPage() {
|
||||||
|
const user = await getSessionUser();
|
||||||
|
if (!user) redirect("/login");
|
||||||
|
if (!user.isPlatform) redirect("/dashboard");
|
||||||
|
const t = await getTranslations("adminBilling");
|
||||||
|
|
||||||
|
// Sweep open invoices past due → 'overdue' so the counters below
|
||||||
|
// reflect reality without needing a cron.
|
||||||
|
await syncOverdueInvoices().catch((e) =>
|
||||||
|
console.error("syncOverdueInvoices failed:", e)
|
||||||
|
);
|
||||||
|
const balances = await getOrgOpenBalances().catch(() => []);
|
||||||
|
const totalOpen = balances.reduce((acc, b) => acc + b.totalOpenChf, 0);
|
||||||
|
const totalOverdue = balances.reduce((acc, b) => acc + b.overdueCount, 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="max-w-5xl mx-auto px-6 py-8">
|
||||||
|
<div className="mb-8 animate-in">
|
||||||
|
<h1 className="font-display text-2xl font-semibold accent-rule">
|
||||||
|
{t("title")}
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-text-secondary mt-3">{t("subtitle")}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats strip */}
|
||||||
|
<div className="grid grid-cols-3 gap-4 mb-8 animate-in animate-in-delay-1">
|
||||||
|
<Card>
|
||||||
|
<div className="text-xs text-text-muted">{t("totalOpenBalance")}</div>
|
||||||
|
<div className="text-2xl font-semibold mt-1">
|
||||||
|
CHF {totalOpen.toFixed(2)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<div className="text-xs text-text-muted">{t("orgsWithBalance")}</div>
|
||||||
|
<div className="text-2xl font-semibold mt-1">{balances.length}</div>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<div className="text-xs text-text-muted">{t("overdueInvoices")}</div>
|
||||||
|
<div className="text-2xl font-semibold mt-1">
|
||||||
|
{totalOverdue > 0 ? (
|
||||||
|
<span className="text-error">{totalOverdue}</span>
|
||||||
|
) : (
|
||||||
|
totalOverdue
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sub-tool cards */}
|
||||||
|
<div className="grid grid-cols-3 gap-4 mb-8 animate-in animate-in-delay-2">
|
||||||
|
<Link href="/admin/billing/pricing">
|
||||||
|
<Card interactive>
|
||||||
|
<div className="font-semibold mb-1">{t("pricingTitle")}</div>
|
||||||
|
<div className="text-sm text-text-muted">{t("pricingDesc")}</div>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
|
<Link href="/admin/billing/generate">
|
||||||
|
<Card interactive>
|
||||||
|
<div className="font-semibold mb-1">{t("generateTitle")}</div>
|
||||||
|
<div className="text-sm text-text-muted">{t("generateDesc")}</div>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
|
<Link href="/admin/billing/invoices">
|
||||||
|
<Card interactive>
|
||||||
|
<div className="font-semibold mb-1">{t("invoicesTitle")}</div>
|
||||||
|
<div className="text-sm text-text-muted">{t("invoicesDesc")}</div>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Orgs with open balance */}
|
||||||
|
{balances.length > 0 && (
|
||||||
|
<div className="animate-in animate-in-delay-3">
|
||||||
|
<h2 className="text-lg font-semibold mb-3">{t("balancesTitle")}</h2>
|
||||||
|
<Card>
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="text-xs text-text-muted text-left">
|
||||||
|
<tr>
|
||||||
|
<th className="pb-2">{t("orgIdCol")}</th>
|
||||||
|
<th className="pb-2 text-right">{t("openCountCol")}</th>
|
||||||
|
<th className="pb-2 text-right">{t("overdueCountCol")}</th>
|
||||||
|
<th className="pb-2 text-right">{t("totalOpenCol")}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{balances.map((b) => (
|
||||||
|
<tr key={b.zitadelOrgId} className="border-t border-border">
|
||||||
|
<td className="py-2 font-mono text-xs">{b.zitadelOrgId}</td>
|
||||||
|
<td className="py-2 text-right">{b.openCount}</td>
|
||||||
|
<td className="py-2 text-right">
|
||||||
|
{b.overdueCount > 0 ? (
|
||||||
|
<span className="text-error">{b.overdueCount}</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-text-muted">0</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 text-right">
|
||||||
|
CHF {b.totalOpenChf.toFixed(2)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
55
src/app/[locale]/admin/billing/pricing/page.tsx
Normal file
55
src/app/[locale]/admin/billing/pricing/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -32,6 +32,13 @@ export default async function AdminPage() {
|
|||||||
{/* Sub-tools: links to other admin pages. Plain links rather
|
{/* Sub-tools: links to other admin pages. Plain links rather
|
||||||
than nav-shell entries — these are platform-team utilities,
|
than nav-shell entries — these are platform-team utilities,
|
||||||
not main navigation. */}
|
not main navigation. */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<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
|
<a
|
||||||
href="/admin/openclaw"
|
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"
|
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"
|
||||||
@@ -39,6 +46,7 @@ export default async function AdminPage() {
|
|||||||
{t("openclawTool")}
|
{t("openclawTool")}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="animate-in animate-in-delay-1">
|
<div className="animate-in animate-in-delay-1">
|
||||||
<AdminPanel initialTenants={tenants} />
|
<AdminPanel initialTenants={tenants} />
|
||||||
|
|||||||
70
src/app/api/admin/billing/backfill/route.ts
Normal file
70
src/app/api/admin/billing/backfill/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
66
src/app/api/admin/billing/generate/route.ts
Normal file
66
src/app/api/admin/billing/generate/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
81
src/app/api/admin/billing/invoices/[id]/mark-paid/route.ts
Normal file
81
src/app/api/admin/billing/invoices/[id]/mark-paid/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
48
src/app/api/admin/billing/invoices/[id]/pdf/route.ts
Normal file
48
src/app/api/admin/billing/invoices/[id]/pdf/route.ts
Normal 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",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
55
src/app/api/admin/billing/invoices/[id]/route.ts
Normal file
55
src/app/api/admin/billing/invoices/[id]/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
44
src/app/api/admin/billing/invoices/route.ts
Normal file
44
src/app/api/admin/billing/invoices/route.ts
Normal 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);
|
||||||
|
}
|
||||||
80
src/app/api/admin/billing/orgs/route.ts
Normal file
80
src/app/api/admin/billing/orgs/route.ts
Normal 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);
|
||||||
|
}
|
||||||
59
src/app/api/admin/billing/pricing/route.ts
Normal file
59
src/app/api/admin/billing/pricing/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
33
src/app/api/admin/billing/skill-pricing/[skill]/route.ts
Normal file
33
src/app/api/admin/billing/skill-pricing/[skill]/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
76
src/app/api/admin/billing/skill-pricing/route.ts
Normal file
76
src/app/api/admin/billing/skill-pricing/route.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
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),
|
||||||
|
});
|
||||||
|
|
||||||
|
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
|
||||||
|
);
|
||||||
|
return NextResponse.json(row);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to upsert skill pricing:", e);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: safeError(e, "Upsert failed") },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,9 @@ import {
|
|||||||
getTenantRequestById,
|
getTenantRequestById,
|
||||||
updateTenantRequestStatus,
|
updateTenantRequestStatus,
|
||||||
clearEncryptedSecrets,
|
clearEncryptedSecrets,
|
||||||
|
recordTenantCreated,
|
||||||
|
recordSkillEvents,
|
||||||
|
recordSuspensionEvent,
|
||||||
} from "@/lib/db";
|
} from "@/lib/db";
|
||||||
import { createTenant, patchTenantSpec, setTenantAnnotation } from "@/lib/k8s";
|
import { createTenant, patchTenantSpec, setTenantAnnotation } from "@/lib/k8s";
|
||||||
import { sendApprovalEmail, sendResumeApprovalEmail } from "@/lib/email";
|
import { sendApprovalEmail, sendResumeApprovalEmail } from "@/lib/email";
|
||||||
@@ -85,6 +88,23 @@ export async function POST(
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await patchTenantSpec(tenantRequest.tenantName, { suspend: false });
|
await patchTenantSpec(tenantRequest.tenantName, { suspend: false });
|
||||||
|
|
||||||
|
// Billing — Phase 1: record the resume so monthly proration
|
||||||
|
// counts the suspended segment correctly. Best-effort; if
|
||||||
|
// logging fails, the approval still succeeds.
|
||||||
|
try {
|
||||||
|
await recordSuspensionEvent(
|
||||||
|
tenantRequest.tenantName,
|
||||||
|
tenantRequest.zitadelOrgId,
|
||||||
|
"resumed"
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(
|
||||||
|
"billing: failed to record resumed suspension event:",
|
||||||
|
e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Clear the annotation that pauses the operator's 60-day TTL.
|
// Clear the annotation that pauses the operator's 60-day TTL.
|
||||||
// Best-effort — annotation cleanup is also done by the operator
|
// Best-effort — annotation cleanup is also done by the operator
|
||||||
// when it sees suspend=false on the next reconcile (it clears
|
// when it sees suspend=false on the next reconcile (it clears
|
||||||
@@ -199,6 +219,35 @@ export async function POST(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Billing — Phase 1: record the tenant's creation and initial
|
||||||
|
// package state. Anchored at "now" rather than the CR's
|
||||||
|
// creationTimestamp because we don't get the timestamp back from
|
||||||
|
// createTenant — the few-millisecond skew vs the CR's actual
|
||||||
|
// creationTimestamp is irrelevant for monthly billing.
|
||||||
|
//
|
||||||
|
// Best-effort: tracking failures must never block provisioning.
|
||||||
|
// The backfill helper can repair any gaps later if needed.
|
||||||
|
const billingAnchor = new Date();
|
||||||
|
try {
|
||||||
|
await recordTenantCreated(
|
||||||
|
tenantName,
|
||||||
|
tenantRequest.zitadelOrgId,
|
||||||
|
billingAnchor
|
||||||
|
);
|
||||||
|
await recordSkillEvents(
|
||||||
|
tenantName,
|
||||||
|
tenantRequest.zitadelOrgId,
|
||||||
|
packages,
|
||||||
|
[],
|
||||||
|
billingAnchor
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(
|
||||||
|
"billing: failed to record tenant creation / initial skill events:",
|
||||||
|
e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Step 5: Update request status — clear admin notes on re-approval
|
// Step 5: Update request status — clear admin notes on re-approval
|
||||||
const updated = await updateTenantRequestStatus(id, "provisioning", {
|
const updated = await updateTenantRequestStatus(id, "provisioning", {
|
||||||
adminNotes: isReApproval ? null : adminNotes,
|
adminNotes: isReApproval ? null : adminNotes,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { getTenant, deleteTenant } from "@/lib/k8s";
|
|||||||
import {
|
import {
|
||||||
markTenantRequestDeletedByTenantName,
|
markTenantRequestDeletedByTenantName,
|
||||||
removeAllAssignmentsForTenant,
|
removeAllAssignmentsForTenant,
|
||||||
|
recordTenantDeleted,
|
||||||
} from "@/lib/db";
|
} from "@/lib/db";
|
||||||
import { safeError } from "@/lib/errors";
|
import { safeError } from "@/lib/errors";
|
||||||
|
|
||||||
@@ -49,6 +50,15 @@ export async function POST(
|
|||||||
console.error("Failed to clean up tenant assignments:", e)
|
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({
|
return NextResponse.json({
|
||||||
message: "Tenant deletion initiated. The operator will clean up all resources.",
|
message: "Tenant deletion initiated. The operator will clean up all resources.",
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { requirePlatformRole } from "@/lib/session";
|
import { requirePlatformRole } from "@/lib/session";
|
||||||
import { getTenant, patchTenantSpec } from "@/lib/k8s";
|
import { getTenant, patchTenantSpec } from "@/lib/k8s";
|
||||||
|
import { recordSuspensionEvent } from "@/lib/db";
|
||||||
import { safeError } from "@/lib/errors";
|
import { safeError } from "@/lib/errors";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -29,6 +30,32 @@ export async function POST(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const updated = await patchTenantSpec(name, { suspend });
|
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({
|
return NextResponse.json({
|
||||||
message: suspend ? "Tenant suspended." : "Tenant resumed.",
|
message: suspend ? "Tenant suspended." : "Tenant resumed.",
|
||||||
tenant: updated,
|
tenant: updated,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { getSessionUser, canMutate } from "@/lib/session";
|
|||||||
import { canUserSeeTenant } from "@/lib/visibility";
|
import { canUserSeeTenant } from "@/lib/visibility";
|
||||||
import { getTenant, patchTenantSpec } from "@/lib/k8s";
|
import { getTenant, patchTenantSpec } from "@/lib/k8s";
|
||||||
import { getPackageDef } from "@/lib/packages";
|
import { getPackageDef } from "@/lib/packages";
|
||||||
|
import { recordSkillEvents } from "@/lib/db";
|
||||||
import { safeError } from "@/lib/errors";
|
import { safeError } from "@/lib/errors";
|
||||||
|
|
||||||
const ALLOWED_WORKSPACE_FILES = ["SOUL.md", "AGENTS.md", "TOOLS.md"];
|
const ALLOWED_WORKSPACE_FILES = ["SOUL.md", "AGENTS.md", "TOOLS.md"];
|
||||||
@@ -187,6 +188,50 @@ export async function PATCH(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const updated = await patchTenantSpec(name, specPatch);
|
const updated = await patchTenantSpec(name, specPatch);
|
||||||
|
|
||||||
|
// 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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return NextResponse.json(updated);
|
return NextResponse.json(updated);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { z } from "zod";
|
|||||||
import { getSessionUser, canMutate } from "@/lib/session";
|
import { getSessionUser, canMutate } from "@/lib/session";
|
||||||
import { getTenant, patchTenantSpec, setTenantAnnotation } from "@/lib/k8s";
|
import { getTenant, patchTenantSpec, setTenantAnnotation } from "@/lib/k8s";
|
||||||
import { canUserSeeTenant } from "@/lib/visibility";
|
import { canUserSeeTenant } from "@/lib/visibility";
|
||||||
|
import { recordSuspensionEvent } from "@/lib/db";
|
||||||
import { safeError } from "@/lib/errors";
|
import { safeError } from "@/lib/errors";
|
||||||
|
|
||||||
const patchSchema = z.object({
|
const patchSchema = z.object({
|
||||||
@@ -101,6 +102,33 @@ export async function PATCH(
|
|||||||
try {
|
try {
|
||||||
await patchTenantSpec(name, { suspend });
|
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
|
// On admin-side resume, also clear the pending-resume-request
|
||||||
// annotation if it exists. Belt-and-suspenders: the admin-approve
|
// annotation if it exists. Belt-and-suspenders: the admin-approve
|
||||||
// endpoint already clears it on its happy path, but a platform
|
// endpoint already clears it on its happy path, but a platform
|
||||||
|
|||||||
345
src/components/admin/billing/generate-form.tsx
Normal file
345
src/components/admin/billing/generate-form.tsx
Normal file
@@ -0,0 +1,345 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, Fragment } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { Card, CardHeader } from "@/components/ui/card";
|
||||||
|
import type { InvoiceDraft } from "@/types";
|
||||||
|
|
||||||
|
interface OrgEntry {
|
||||||
|
zitadelOrgId: string;
|
||||||
|
tenantNames: string[];
|
||||||
|
companyName: string | null;
|
||||||
|
country: string | null;
|
||||||
|
hasBillingAddress: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
orgs: OrgEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const LOCALE_OPTIONS = [
|
||||||
|
{ value: "de", label: "Deutsch" },
|
||||||
|
{ value: "en", label: "English" },
|
||||||
|
{ value: "fr", label: "Français" },
|
||||||
|
{ value: "it", label: "Italiano" },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Two-step flow: preview (dryRun) → commit.
|
||||||
|
*
|
||||||
|
* Preview displays the InvoiceDraft (lines, subtotal, VAT, total)
|
||||||
|
* plus any warnings. Admin reviews and either commits or aborts.
|
||||||
|
* Commit re-runs the generator without dryRun and redirects to the
|
||||||
|
* persisted invoice's detail page.
|
||||||
|
*/
|
||||||
|
export function GenerateForm({ orgs }: Props) {
|
||||||
|
const t = useTranslations("adminBilling");
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
// Default to previous calendar month — that's the typical "bill
|
||||||
|
// for last month" use case.
|
||||||
|
const now = new Date();
|
||||||
|
const prevMonth = new Date(now.getFullYear(), now.getMonth() - 1, 1);
|
||||||
|
const [orgId, setOrgId] = useState(orgs[0]?.zitadelOrgId ?? "");
|
||||||
|
const [year, setYear] = useState(String(prevMonth.getFullYear()));
|
||||||
|
const [month, setMonth] = useState(String(prevMonth.getMonth() + 1));
|
||||||
|
const [locale, setLocale] = useState<string>("");
|
||||||
|
const [draft, setDraft] = useState<InvoiceDraft | null>(null);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
const selectedOrg = orgs.find((o) => o.zitadelOrgId === orgId);
|
||||||
|
// Auto-detect default locale from country if admin hasn't picked
|
||||||
|
// one. Same logic as billing.ts's defaultLocaleForCountry.
|
||||||
|
const effectiveLocale =
|
||||||
|
locale ||
|
||||||
|
(() => {
|
||||||
|
const c = (selectedOrg?.country || "").toUpperCase();
|
||||||
|
if (["CH", "LI", "AT", "DE"].includes(c)) return "de";
|
||||||
|
if (["FR", "BE", "LU"].includes(c)) return "fr";
|
||||||
|
if (c === "IT") return "it";
|
||||||
|
return "en";
|
||||||
|
})();
|
||||||
|
|
||||||
|
const preview = async () => {
|
||||||
|
setError("");
|
||||||
|
setDraft(null);
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/admin/billing/generate", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
zitadelOrgId: orgId,
|
||||||
|
year: Number(year),
|
||||||
|
month: Number(month),
|
||||||
|
locale: effectiveLocale,
|
||||||
|
dryRun: true,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const j = await res.json();
|
||||||
|
if (!res.ok) throw new Error(j.error || `HTTP ${res.status}`);
|
||||||
|
setDraft(j.draft);
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e.message);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const commit = async () => {
|
||||||
|
if (!draft) return;
|
||||||
|
if (!confirm(t("confirmGenerate"))) return;
|
||||||
|
setError("");
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/admin/billing/generate", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
zitadelOrgId: orgId,
|
||||||
|
year: Number(year),
|
||||||
|
month: Number(month),
|
||||||
|
locale: effectiveLocale,
|
||||||
|
dryRun: false,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const j = await res.json();
|
||||||
|
if (!res.ok) throw new Error(j.error || `HTTP ${res.status}`);
|
||||||
|
// Navigate to the new invoice's detail page.
|
||||||
|
if (j.invoice?.id) {
|
||||||
|
router.push(`/admin/billing/invoices/${j.invoice.id}`);
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e.message);
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>{t("generateFormTitle")}</CardHeader>
|
||||||
|
{orgs.length === 0 ? (
|
||||||
|
<p className="text-sm text-text-muted italic">{t("noOrgsToGenerate")}</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-sm text-text-secondary">{t("orgLabel")}</span>
|
||||||
|
<select
|
||||||
|
value={orgId}
|
||||||
|
onChange={(e) => {
|
||||||
|
setOrgId(e.target.value);
|
||||||
|
setDraft(null);
|
||||||
|
}}
|
||||||
|
className="mt-1 w-full px-3 py-2 rounded-md border border-border bg-surface-2 text-sm"
|
||||||
|
>
|
||||||
|
{orgs.map((o) => (
|
||||||
|
<option key={o.zitadelOrgId} value={o.zitadelOrgId}>
|
||||||
|
{o.companyName ?? o.zitadelOrgId}
|
||||||
|
{!o.hasBillingAddress ? ` — ${t("noBillingAddrTag")}` : ""}
|
||||||
|
{` (${o.tenantNames.length} ${t("tenantsLabel")})`}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{selectedOrg && !selectedOrg.hasBillingAddress && (
|
||||||
|
<p className="text-xs text-error mt-1">
|
||||||
|
{t("noBillingAddrWarning")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-sm text-text-secondary">{t("yearLabel")}</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="2020"
|
||||||
|
max="2100"
|
||||||
|
value={year}
|
||||||
|
onChange={(e) => {
|
||||||
|
setYear(e.target.value);
|
||||||
|
setDraft(null);
|
||||||
|
}}
|
||||||
|
className="mt-1 w-full px-3 py-2 rounded-md border border-border bg-surface-2 text-sm"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-sm text-text-secondary">{t("monthLabel")}</span>
|
||||||
|
<select
|
||||||
|
value={month}
|
||||||
|
onChange={(e) => {
|
||||||
|
setMonth(e.target.value);
|
||||||
|
setDraft(null);
|
||||||
|
}}
|
||||||
|
className="mt-1 w-full px-3 py-2 rounded-md border border-border bg-surface-2 text-sm"
|
||||||
|
>
|
||||||
|
{Array.from({ length: 12 }, (_, i) => i + 1).map((m) => (
|
||||||
|
<option key={m} value={m}>
|
||||||
|
{String(m).padStart(2, "0")}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-sm text-text-secondary">
|
||||||
|
{t("localeLabel")}
|
||||||
|
</span>
|
||||||
|
<select
|
||||||
|
value={locale}
|
||||||
|
onChange={(e) => {
|
||||||
|
setLocale(e.target.value);
|
||||||
|
setDraft(null);
|
||||||
|
}}
|
||||||
|
className="mt-1 w-full px-3 py-2 rounded-md border border-border bg-surface-2 text-sm"
|
||||||
|
>
|
||||||
|
<option value="">
|
||||||
|
{t("localeAuto")} ({effectiveLocale})
|
||||||
|
</option>
|
||||||
|
{LOCALE_OPTIONS.map((o) => (
|
||||||
|
<option key={o.value} value={o.value}>
|
||||||
|
{o.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 pt-2">
|
||||||
|
<button
|
||||||
|
onClick={preview}
|
||||||
|
disabled={busy || !selectedOrg?.hasBillingAddress}
|
||||||
|
className="px-4 py-2 rounded-md border border-border text-sm disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{busy && !draft ? t("computing") : t("previewBtn")}
|
||||||
|
</button>
|
||||||
|
{draft && (
|
||||||
|
<button
|
||||||
|
onClick={commit}
|
||||||
|
disabled={busy}
|
||||||
|
className="px-4 py-2 rounded-md bg-accent text-white text-sm disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{busy ? t("saving") : t("commitBtn")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<span className="text-sm text-error">{error}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{draft && <DraftPreview draft={draft} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DraftPreview({ draft }: { draft: InvoiceDraft }) {
|
||||||
|
const t = useTranslations("adminBilling");
|
||||||
|
|
||||||
|
// Group lines by tenant for the preview (matches PDF layout).
|
||||||
|
const linesByTenant = new Map<string | null, typeof draft.lines>();
|
||||||
|
for (const ln of draft.lines) {
|
||||||
|
const key = ln.tenantName;
|
||||||
|
if (!linesByTenant.has(key)) linesByTenant.set(key, []);
|
||||||
|
linesByTenant.get(key)!.push(ln);
|
||||||
|
}
|
||||||
|
const tenantOrder = [...linesByTenant.keys()].sort((a, b) => {
|
||||||
|
if (a === null) return 1;
|
||||||
|
if (b === null) return -1;
|
||||||
|
return a.localeCompare(b);
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
{t("previewTitle")} — {draft.periodStart} → {draft.periodEnd}
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
{draft.warnings.length > 0 && (
|
||||||
|
<div className="mb-4 p-3 rounded-md border border-warning bg-warning/10 text-sm space-y-1">
|
||||||
|
<div className="font-semibold text-warning">{t("warningsTitle")}</div>
|
||||||
|
{draft.warnings.map((w, i) => (
|
||||||
|
<div key={i} className="text-text-secondary">• {w}</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="text-xs text-text-muted text-left">
|
||||||
|
<tr>
|
||||||
|
<th className="pb-2">{t("descCol")}</th>
|
||||||
|
<th className="pb-2 text-right">{t("qtyCol")}</th>
|
||||||
|
<th className="pb-2 text-right">{t("unitPriceCol")}</th>
|
||||||
|
<th className="pb-2 text-right">{t("amountCol")}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{tenantOrder.map((tenantKey) => {
|
||||||
|
const lines = linesByTenant.get(tenantKey)!;
|
||||||
|
return (
|
||||||
|
<Fragment key={tenantKey ?? "_org"}>
|
||||||
|
{tenantKey && (
|
||||||
|
<tr className="border-t border-border">
|
||||||
|
<td colSpan={4} className="py-1.5 pt-3">
|
||||||
|
<span className="text-xs font-semibold text-accent">
|
||||||
|
{tenantKey}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
{lines.map((ln, i) => (
|
||||||
|
<tr
|
||||||
|
key={`${tenantKey}-${i}`}
|
||||||
|
className="border-t border-border"
|
||||||
|
>
|
||||||
|
<td className="py-1.5">
|
||||||
|
<div>{ln.description}</div>
|
||||||
|
<div className="text-xs text-text-muted font-mono">
|
||||||
|
{ln.kind}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="py-1.5 text-right">
|
||||||
|
{ln.quantity}
|
||||||
|
{ln.unitLabel ? ` ${ln.unitLabel}` : ""}
|
||||||
|
</td>
|
||||||
|
<td className="py-1.5 text-right font-mono text-xs">
|
||||||
|
{ln.unitPriceChf.toFixed(4)}
|
||||||
|
</td>
|
||||||
|
<td className="py-1.5 text-right">
|
||||||
|
{ln.amountChf.toFixed(2)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{draft.lines.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={4} className="py-4 text-center text-text-muted italic">
|
||||||
|
{t("noLinesGenerated")}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div className="mt-4 pt-3 border-t border-border space-y-1 text-sm">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-text-muted">{t("subtotal")}</span>
|
||||||
|
<span>CHF {draft.subtotalChf.toFixed(2)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-text-muted">
|
||||||
|
{t("vat")} ({draft.vatRate.toFixed(2)}%)
|
||||||
|
</span>
|
||||||
|
<span>CHF {draft.vatAmountChf.toFixed(2)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between pt-1 border-t border-border font-semibold">
|
||||||
|
<span>{t("total")}</span>
|
||||||
|
<span>CHF {draft.totalChf.toFixed(2)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
307
src/components/admin/billing/invoice-detail-view.tsx
Normal file
307
src/components/admin/billing/invoice-detail-view.tsx
Normal file
@@ -0,0 +1,307 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, Fragment } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { Card, CardHeader } from "@/components/ui/card";
|
||||||
|
import type { InvoiceDetail, InvoiceStatus } from "@/types";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
detail: InvoiceDetail;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders the invoice header (status, totals, action bar) then
|
||||||
|
* line items grouped by tenant, then billing snapshot. Actions are
|
||||||
|
* mark-paid (POST), delete (DELETE), PDF download (link to /pdf).
|
||||||
|
*
|
||||||
|
* On successful action we router.refresh() — the server-side page
|
||||||
|
* re-renders against the new DB state. For delete we navigate
|
||||||
|
* away first.
|
||||||
|
*/
|
||||||
|
export function InvoiceDetailView({ detail }: Props) {
|
||||||
|
const t = useTranslations("adminBilling");
|
||||||
|
const router = useRouter();
|
||||||
|
const { invoice, lines } = detail;
|
||||||
|
|
||||||
|
const [busyAction, setBusyAction] = useState<null | "mark-paid" | "delete">(
|
||||||
|
null
|
||||||
|
);
|
||||||
|
const [actionError, setActionError] = useState("");
|
||||||
|
const [noteInput, setNoteInput] = useState("");
|
||||||
|
const [noteOpen, setNoteOpen] = useState(false);
|
||||||
|
|
||||||
|
const markPaid = async () => {
|
||||||
|
setActionError("");
|
||||||
|
setBusyAction("mark-paid");
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`/api/admin/billing/invoices/${invoice.id}/mark-paid`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ note: noteInput || undefined }),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const j = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) throw new Error(j.error || `HTTP ${res.status}`);
|
||||||
|
setNoteOpen(false);
|
||||||
|
setNoteInput("");
|
||||||
|
router.refresh();
|
||||||
|
} catch (e: any) {
|
||||||
|
setActionError(e.message);
|
||||||
|
} finally {
|
||||||
|
setBusyAction(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteInvoice = async () => {
|
||||||
|
if (!confirm(t("confirmDeleteInvoice", { num: invoice.invoiceNumber })))
|
||||||
|
return;
|
||||||
|
setActionError("");
|
||||||
|
setBusyAction("delete");
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/admin/billing/invoices/${invoice.id}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const j = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(j.error || `HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
router.push("/admin/billing/invoices");
|
||||||
|
} catch (e: any) {
|
||||||
|
setActionError(e.message);
|
||||||
|
setBusyAction(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Group lines by tenant for display (matches PDF layout).
|
||||||
|
const linesByTenant = new Map<string | null, typeof lines>();
|
||||||
|
for (const ln of lines) {
|
||||||
|
const k = ln.tenantName;
|
||||||
|
if (!linesByTenant.has(k)) linesByTenant.set(k, []);
|
||||||
|
linesByTenant.get(k)!.push(ln);
|
||||||
|
}
|
||||||
|
const tenantOrder = [...linesByTenant.keys()].sort((a, b) => {
|
||||||
|
if (a === null) return 1;
|
||||||
|
if (b === null) return -1;
|
||||||
|
return a.localeCompare(b);
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4 animate-in">
|
||||||
|
<div className="flex items-end justify-between flex-wrap gap-3">
|
||||||
|
<div>
|
||||||
|
<h1 className="font-display text-2xl font-semibold accent-rule">
|
||||||
|
{invoice.invoiceNumber}
|
||||||
|
</h1>
|
||||||
|
<div className="flex items-center gap-3 mt-3 text-sm">
|
||||||
|
<StatusPill status={invoice.status} />
|
||||||
|
<span className="text-text-muted">
|
||||||
|
{invoice.periodStart} → {invoice.periodEnd}
|
||||||
|
</span>
|
||||||
|
<span className="text-text-muted">·</span>
|
||||||
|
<span className="text-text-muted">
|
||||||
|
{t("dueOnLabel")}: {invoice.dueAt}
|
||||||
|
</span>
|
||||||
|
<span className="text-text-muted">·</span>
|
||||||
|
<span className="text-text-muted font-mono text-xs">
|
||||||
|
{invoice.locale}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="text-xs text-text-muted">{t("totalLabel")}</div>
|
||||||
|
<div className="text-2xl font-semibold font-mono">
|
||||||
|
CHF {invoice.totalChf.toFixed(2)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action bar */}
|
||||||
|
<Card>
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
{invoice.hasPdf && (
|
||||||
|
<a
|
||||||
|
href={`/api/admin/billing/invoices/${invoice.id}/pdf`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="px-4 py-2 rounded-md border border-border text-sm hover:bg-surface-2"
|
||||||
|
>
|
||||||
|
{t("downloadPdfBtn")}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{(invoice.status === "open" || invoice.status === "overdue") && (
|
||||||
|
<>
|
||||||
|
{!noteOpen ? (
|
||||||
|
<button
|
||||||
|
onClick={() => setNoteOpen(true)}
|
||||||
|
disabled={busyAction !== null}
|
||||||
|
className="px-4 py-2 rounded-md bg-accent text-white text-sm disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{t("markPaidBtn")}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-2 flex-grow">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder={t("paidNotePlaceholder")}
|
||||||
|
value={noteInput}
|
||||||
|
onChange={(e) => setNoteInput(e.target.value)}
|
||||||
|
className="flex-grow px-3 py-1.5 rounded-md border border-border bg-surface-2 text-sm"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={markPaid}
|
||||||
|
disabled={busyAction !== null}
|
||||||
|
className="px-3 py-1.5 rounded-md bg-accent text-white text-sm disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{busyAction === "mark-paid" ? t("saving") : t("confirm")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setNoteOpen(false);
|
||||||
|
setNoteInput("");
|
||||||
|
}}
|
||||||
|
className="px-3 py-1.5 rounded-md border border-border text-sm"
|
||||||
|
>
|
||||||
|
{t("cancel")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={deleteInvoice}
|
||||||
|
disabled={busyAction !== null}
|
||||||
|
className="ml-auto px-4 py-2 rounded-md border border-error text-error text-sm disabled:opacity-50 hover:bg-error/10"
|
||||||
|
title={t("deleteHint")}
|
||||||
|
>
|
||||||
|
{busyAction === "delete" ? t("deleting") : t("deleteBtn")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{actionError && (
|
||||||
|
<div className="mt-3 text-sm text-error">{actionError}</div>
|
||||||
|
)}
|
||||||
|
{invoice.paidAt && (
|
||||||
|
<div className="mt-3 text-xs text-text-muted">
|
||||||
|
{t("paidOnLabel")}: {invoice.paidAt} · {invoice.paidBy} ·{" "}
|
||||||
|
{invoice.paidMethodDetail}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Lines */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>{t("lineItemsTitle")}</CardHeader>
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="text-xs text-text-muted text-left">
|
||||||
|
<tr>
|
||||||
|
<th className="pb-2">{t("descCol")}</th>
|
||||||
|
<th className="pb-2 text-right">{t("qtyCol")}</th>
|
||||||
|
<th className="pb-2 text-right">{t("unitPriceCol")}</th>
|
||||||
|
<th className="pb-2 text-right">{t("amountCol")}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{tenantOrder.map((tenantKey) => {
|
||||||
|
const tenantLines = linesByTenant.get(tenantKey)!;
|
||||||
|
return (
|
||||||
|
<Fragment key={tenantKey ?? "_org"}>
|
||||||
|
{tenantKey && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={4} className="pt-3 pb-1">
|
||||||
|
<span className="text-xs font-semibold text-accent">
|
||||||
|
{tenantKey}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
{tenantLines.map((ln) => (
|
||||||
|
<tr key={ln.id} className="border-t border-border">
|
||||||
|
<td className="py-1.5">
|
||||||
|
<div>{ln.description}</div>
|
||||||
|
<div className="text-xs text-text-muted font-mono">
|
||||||
|
{ln.kind}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="py-1.5 text-right">
|
||||||
|
{ln.quantity}
|
||||||
|
{ln.unitLabel ? ` ${ln.unitLabel}` : ""}
|
||||||
|
</td>
|
||||||
|
<td className="py-1.5 text-right font-mono text-xs">
|
||||||
|
{ln.unitPriceChf.toFixed(4)}
|
||||||
|
</td>
|
||||||
|
<td className="py-1.5 text-right">
|
||||||
|
{ln.amountChf.toFixed(2)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div className="mt-4 pt-3 border-t border-border space-y-1 text-sm">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-text-muted">{t("subtotal")}</span>
|
||||||
|
<span>CHF {invoice.subtotalChf.toFixed(2)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-text-muted">
|
||||||
|
{t("vat")} ({invoice.vatRate.toFixed(2)}%)
|
||||||
|
</span>
|
||||||
|
<span>CHF {invoice.vatAmountChf.toFixed(2)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between pt-1 border-t border-border font-semibold">
|
||||||
|
<span>{t("total")}</span>
|
||||||
|
<span>CHF {invoice.totalChf.toFixed(2)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Billing snapshot */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>{t("billToSnapshotTitle")}</CardHeader>
|
||||||
|
<div className="text-sm space-y-1">
|
||||||
|
<div className="font-semibold">
|
||||||
|
{invoice.billingSnapshot.companyName}
|
||||||
|
</div>
|
||||||
|
<div>{invoice.billingSnapshot.streetAddress}</div>
|
||||||
|
<div>
|
||||||
|
{invoice.billingSnapshot.postalCode}{" "}
|
||||||
|
{invoice.billingSnapshot.city}
|
||||||
|
</div>
|
||||||
|
<div>{invoice.billingSnapshot.country}</div>
|
||||||
|
{invoice.billingSnapshot.vatNumber && (
|
||||||
|
<div className="text-text-muted">
|
||||||
|
VAT: {invoice.billingSnapshot.vatNumber}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="text-text-muted">
|
||||||
|
{invoice.billingSnapshot.billingEmail}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusPill({ status }: { status: InvoiceStatus }) {
|
||||||
|
const t = useTranslations("adminBilling");
|
||||||
|
const color =
|
||||||
|
status === "paid"
|
||||||
|
? "bg-success/15 text-success"
|
||||||
|
: status === "overdue"
|
||||||
|
? "bg-error/15 text-error"
|
||||||
|
: status === "void" || status === "uncollectible"
|
||||||
|
? "bg-text-muted/15 text-text-muted"
|
||||||
|
: "bg-accent/15 text-accent";
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`inline-block px-2 py-0.5 rounded text-xs font-medium ${color}`}
|
||||||
|
>
|
||||||
|
{t(`status_${status}`)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
183
src/components/admin/billing/invoices-table.tsx
Normal file
183
src/components/admin/billing/invoices-table.tsx
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { Card } from "@/components/ui/card";
|
||||||
|
import type { Invoice, InvoiceStatus } from "@/types";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
initialInvoices: Invoice[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_FILTERS: (InvoiceStatus | "all")[] = [
|
||||||
|
"all",
|
||||||
|
"open",
|
||||||
|
"overdue",
|
||||||
|
"paid",
|
||||||
|
"void",
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filterable invoice list. Filters live in URL-less local state
|
||||||
|
* (simpler than syncing to query string for a v1 admin tool); a
|
||||||
|
* page refresh resets.
|
||||||
|
*
|
||||||
|
* Re-fetching strategy: when filters change, hit the API directly
|
||||||
|
* rather than router.refresh() so we don't bounce the user through
|
||||||
|
* a full page render.
|
||||||
|
*/
|
||||||
|
export function InvoicesTable({ initialInvoices }: Props) {
|
||||||
|
const t = useTranslations("adminBilling");
|
||||||
|
const [statusFilter, setStatusFilter] = useState<InvoiceStatus | "all">("all");
|
||||||
|
const [monthFilter, setMonthFilter] = useState("");
|
||||||
|
const [invoices, setInvoices] = useState(initialInvoices);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Effect runs after initial render too; skip refetch on mount
|
||||||
|
// when filters are at their defaults — the server already
|
||||||
|
// gave us the right initial set.
|
||||||
|
if (statusFilter === "all" && monthFilter === "") return;
|
||||||
|
let cancelled = false;
|
||||||
|
setBusy(true);
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (statusFilter !== "all") params.set("status", statusFilter);
|
||||||
|
if (monthFilter) params.set("month", monthFilter);
|
||||||
|
fetch(`/api/admin/billing/invoices?${params}`)
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((data) => {
|
||||||
|
if (!cancelled) setInvoices(data);
|
||||||
|
})
|
||||||
|
.catch((e) => console.error("Failed to load invoices:", e))
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setBusy(false);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [statusFilter, monthFilter]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Card>
|
||||||
|
<div className="flex flex-wrap items-end gap-4">
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-xs text-text-muted">{t("statusFilterLabel")}</span>
|
||||||
|
<select
|
||||||
|
value={statusFilter}
|
||||||
|
onChange={(e) =>
|
||||||
|
setStatusFilter(e.target.value as InvoiceStatus | "all")
|
||||||
|
}
|
||||||
|
className="mt-1 px-3 py-1.5 rounded-md border border-border bg-surface-2 text-sm"
|
||||||
|
>
|
||||||
|
{STATUS_FILTERS.map((s) => (
|
||||||
|
<option key={s} value={s}>
|
||||||
|
{s === "all" ? t("allStatuses") : t(`status_${s}`)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-xs text-text-muted">{t("monthFilterLabel")}</span>
|
||||||
|
<input
|
||||||
|
type="month"
|
||||||
|
value={monthFilter}
|
||||||
|
onChange={(e) => setMonthFilter(e.target.value)}
|
||||||
|
className="mt-1 px-3 py-1.5 rounded-md border border-border bg-surface-2 text-sm"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{monthFilter && (
|
||||||
|
<button
|
||||||
|
onClick={() => setMonthFilter("")}
|
||||||
|
className="text-xs text-text-muted hover:underline"
|
||||||
|
>
|
||||||
|
{t("clearFilter")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{busy && (
|
||||||
|
<span className="text-xs text-text-muted ml-auto">
|
||||||
|
{t("loading")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
{invoices.length === 0 ? (
|
||||||
|
<p className="text-sm text-text-muted italic text-center py-6">
|
||||||
|
{t("noInvoicesFound")}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="text-xs text-text-muted text-left">
|
||||||
|
<tr>
|
||||||
|
<th className="pb-2">{t("invoiceNumberCol")}</th>
|
||||||
|
<th className="pb-2">{t("orgCol")}</th>
|
||||||
|
<th className="pb-2">{t("periodCol")}</th>
|
||||||
|
<th className="pb-2">{t("statusCol")}</th>
|
||||||
|
<th className="pb-2 text-right">{t("totalCol")}</th>
|
||||||
|
<th className="pb-2 text-right">{t("dueCol")}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{invoices.map((inv) => (
|
||||||
|
<tr
|
||||||
|
key={inv.id}
|
||||||
|
className="border-t border-border hover:bg-surface-2 cursor-pointer"
|
||||||
|
>
|
||||||
|
<td className="py-2">
|
||||||
|
<Link
|
||||||
|
href={`/admin/billing/invoices/${inv.id}`}
|
||||||
|
className="font-mono text-xs hover:underline"
|
||||||
|
>
|
||||||
|
{inv.invoiceNumber}
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
<td className="py-2">
|
||||||
|
<div className="text-xs">
|
||||||
|
{inv.billingSnapshot.companyName || (
|
||||||
|
<span className="font-mono">{inv.zitadelOrgId}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="py-2 text-xs font-mono">
|
||||||
|
{inv.periodStart.slice(0, 7)}
|
||||||
|
</td>
|
||||||
|
<td className="py-2">
|
||||||
|
<StatusPill status={inv.status} />
|
||||||
|
</td>
|
||||||
|
<td className="py-2 text-right">
|
||||||
|
CHF {inv.totalChf.toFixed(2)}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 text-right text-xs text-text-muted">
|
||||||
|
{inv.dueAt}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusPill({ status }: { status: InvoiceStatus }) {
|
||||||
|
const t = useTranslations("adminBilling");
|
||||||
|
const color =
|
||||||
|
status === "paid"
|
||||||
|
? "bg-success/15 text-success"
|
||||||
|
: status === "overdue"
|
||||||
|
? "bg-error/15 text-error"
|
||||||
|
: status === "void" || status === "uncollectible"
|
||||||
|
? "bg-text-muted/15 text-text-muted"
|
||||||
|
: "bg-accent/15 text-accent";
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`inline-block px-2 py-0.5 rounded text-xs font-medium ${color}`}
|
||||||
|
>
|
||||||
|
{t(`status_${status}`)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
391
src/components/admin/billing/pricing-editor.tsx
Normal file
391
src/components/admin/billing/pricing-editor.tsx
Normal file
@@ -0,0 +1,391 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { Card, CardHeader } from "@/components/ui/card";
|
||||||
|
import type { PlatformPricing, SkillPricing } from "@/types";
|
||||||
|
|
||||||
|
interface CatalogEntry {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
category: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
initialPricing: PlatformPricing;
|
||||||
|
initialSkillPricing: SkillPricing[];
|
||||||
|
catalog: CatalogEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Two-card layout:
|
||||||
|
* 1. Platform pricing form (4 inputs, save = PUT to /pricing).
|
||||||
|
* 2. Skill pricing table — list of priced skills, "Add skill"
|
||||||
|
* picker below.
|
||||||
|
*
|
||||||
|
* No optimistic updates — every save round-trips and we
|
||||||
|
* router.refresh() afterwards so the server-side render stays
|
||||||
|
* the source of truth.
|
||||||
|
*/
|
||||||
|
export function PricingEditor({
|
||||||
|
initialPricing,
|
||||||
|
initialSkillPricing,
|
||||||
|
catalog,
|
||||||
|
}: Props) {
|
||||||
|
const t = useTranslations("adminBilling");
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
// -- Platform pricing form ----------------------------------------------
|
||||||
|
const [monthly, setMonthly] = useState(
|
||||||
|
String(initialPricing.tenantMonthlyFeeChf)
|
||||||
|
);
|
||||||
|
const [setup, setSetup] = useState(String(initialPricing.tenantSetupFeeChf));
|
||||||
|
const [threema, setThreema] = useState(
|
||||||
|
String(initialPricing.threemaMessageChf)
|
||||||
|
);
|
||||||
|
const [vat, setVat] = useState(String(initialPricing.vatRateChli));
|
||||||
|
const [savingPricing, setSavingPricing] = useState(false);
|
||||||
|
const [pricingError, setPricingError] = useState("");
|
||||||
|
const [pricingSaved, setPricingSaved] = useState(false);
|
||||||
|
|
||||||
|
const savePricing = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setSavingPricing(true);
|
||||||
|
setPricingError("");
|
||||||
|
setPricingSaved(false);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/admin/billing/pricing", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
tenantMonthlyFeeChf: Number(monthly),
|
||||||
|
tenantSetupFeeChf: Number(setup),
|
||||||
|
threemaMessageChf: Number(threema),
|
||||||
|
vatRateChli: Number(vat),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const j = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(j.error || `HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
setPricingSaved(true);
|
||||||
|
router.refresh();
|
||||||
|
} catch (e: any) {
|
||||||
|
setPricingError(e.message);
|
||||||
|
} finally {
|
||||||
|
setSavingPricing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// -- Skill pricing ------------------------------------------------------
|
||||||
|
// Server is authoritative — we don't keep an editable local copy of the
|
||||||
|
// table; instead each action posts to the API and we router.refresh().
|
||||||
|
const [newSkillId, setNewSkillId] = useState(
|
||||||
|
catalog.find((c) => c.category === "skill")?.id ?? ""
|
||||||
|
);
|
||||||
|
const [newSkillPrice, setNewSkillPrice] = useState("0.10");
|
||||||
|
const [addingSkill, setAddingSkill] = useState(false);
|
||||||
|
const [skillError, setSkillError] = useState("");
|
||||||
|
|
||||||
|
// Core upsert — used by both the "add new skill" form and the inline
|
||||||
|
// editor on existing rows. Kept event-free so callers can invoke it
|
||||||
|
// without synthesizing a fake form event.
|
||||||
|
const upsertSkillPrice = async (skillId: string, dailyPriceChf: number) => {
|
||||||
|
setAddingSkill(true);
|
||||||
|
setSkillError("");
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/admin/billing/skill-pricing", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ skillId, dailyPriceChf }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const j = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(j.error || `HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
router.refresh();
|
||||||
|
} catch (e: any) {
|
||||||
|
setSkillError(e.message);
|
||||||
|
} finally {
|
||||||
|
setAddingSkill(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onAddNewSkill = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!newSkillId) return;
|
||||||
|
void upsertSkillPrice(newSkillId, Number(newSkillPrice));
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteSkill = async (skillId: string) => {
|
||||||
|
if (!confirm(t("confirmDeleteSkillPrice", { skill: skillId }))) return;
|
||||||
|
setSkillError("");
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`/api/admin/billing/skill-pricing/${encodeURIComponent(skillId)}`,
|
||||||
|
{ method: "DELETE" }
|
||||||
|
);
|
||||||
|
if (!res.ok) {
|
||||||
|
const j = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(j.error || `HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
router.refresh();
|
||||||
|
} catch (e: any) {
|
||||||
|
setSkillError(e.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Catalog filtered to skill-kind entries for the picker, but keeping
|
||||||
|
// existing pricing rows even if they reference non-skill packages.
|
||||||
|
const skillCatalogOptions = catalog.filter((c) => c.category === "skill");
|
||||||
|
const catalogIndex = new Map(catalog.map((c) => [c.id, c]));
|
||||||
|
const pricedIds = new Set(initialSkillPricing.map((s) => s.skillId));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>{t("platformPricingTitle")}</CardHeader>
|
||||||
|
<form onSubmit={savePricing} className="space-y-4">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-sm text-text-secondary">
|
||||||
|
{t("monthlyFeeLabel")} (CHF)
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
min="0"
|
||||||
|
value={monthly}
|
||||||
|
onChange={(e) => setMonthly(e.target.value)}
|
||||||
|
className="mt-1 w-full px-3 py-2 rounded-md border border-border bg-surface-2 text-sm"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-sm text-text-secondary">
|
||||||
|
{t("setupFeeLabel")} (CHF)
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
min="0"
|
||||||
|
value={setup}
|
||||||
|
onChange={(e) => setSetup(e.target.value)}
|
||||||
|
className="mt-1 w-full px-3 py-2 rounded-md border border-border bg-surface-2 text-sm"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-sm text-text-secondary">
|
||||||
|
{t("threemaMessageLabel")} (CHF)
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.0001"
|
||||||
|
min="0"
|
||||||
|
value={threema}
|
||||||
|
onChange={(e) => setThreema(e.target.value)}
|
||||||
|
className="mt-1 w-full px-3 py-2 rounded-md border border-border bg-surface-2 text-sm"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-sm text-text-secondary">
|
||||||
|
{t("vatRateLabel")} (%)
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
value={vat}
|
||||||
|
onChange={(e) => setVat(e.target.value)}
|
||||||
|
className="mt-1 w-full px-3 py-2 rounded-md border border-border bg-surface-2 text-sm"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={savingPricing}
|
||||||
|
className="px-4 py-2 rounded-md bg-accent text-white text-sm disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{savingPricing ? t("saving") : t("save")}
|
||||||
|
</button>
|
||||||
|
{pricingSaved && (
|
||||||
|
<span className="text-sm text-success">{t("savedOk")}</span>
|
||||||
|
)}
|
||||||
|
{pricingError && (
|
||||||
|
<span className="text-sm text-error">{pricingError}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>{t("skillPricingTitle")}</CardHeader>
|
||||||
|
<p className="text-sm text-text-muted mb-4">{t("skillPricingDesc")}</p>
|
||||||
|
|
||||||
|
{initialSkillPricing.length > 0 ? (
|
||||||
|
<table className="w-full text-sm mb-6">
|
||||||
|
<thead className="text-xs text-text-muted text-left">
|
||||||
|
<tr>
|
||||||
|
<th className="pb-2">{t("skillCol")}</th>
|
||||||
|
<th className="pb-2 text-right">{t("dailyPriceCol")}</th>
|
||||||
|
<th className="pb-2 text-right">{t("actionsCol")}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{initialSkillPricing.map((sp) => {
|
||||||
|
const entry = catalogIndex.get(sp.skillId);
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={sp.skillId}
|
||||||
|
className="border-t border-border align-top"
|
||||||
|
>
|
||||||
|
<td className="py-2">
|
||||||
|
<div className="font-mono text-xs">{sp.skillId}</div>
|
||||||
|
{entry && (
|
||||||
|
<div className="text-xs text-text-muted">{entry.name}</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 text-right">
|
||||||
|
<InlinePriceEditor
|
||||||
|
skillId={sp.skillId}
|
||||||
|
initialPrice={sp.dailyPriceChf}
|
||||||
|
onSave={(price) => upsertSkillPrice(sp.skillId, price)}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="py-2 text-right">
|
||||||
|
<button
|
||||||
|
onClick={() => deleteSkill(sp.skillId)}
|
||||||
|
className="text-xs text-error hover:underline"
|
||||||
|
>
|
||||||
|
{t("remove")}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-text-muted italic mb-4">{t("noSkillsPriced")}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={onAddNewSkill} className="flex items-end gap-3">
|
||||||
|
<label className="flex-grow">
|
||||||
|
<span className="text-xs text-text-muted">{t("addSkillLabel")}</span>
|
||||||
|
<select
|
||||||
|
value={newSkillId}
|
||||||
|
onChange={(e) => setNewSkillId(e.target.value)}
|
||||||
|
className="mt-1 w-full px-3 py-2 rounded-md border border-border bg-surface-2 text-sm"
|
||||||
|
>
|
||||||
|
{skillCatalogOptions
|
||||||
|
.filter((c) => !pricedIds.has(c.id))
|
||||||
|
.map((c) => (
|
||||||
|
<option key={c.id} value={c.id}>
|
||||||
|
{c.name} ({c.id})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="w-32">
|
||||||
|
<span className="text-xs text-text-muted">
|
||||||
|
{t("dailyPriceLabel")} (CHF)
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
min="0"
|
||||||
|
value={newSkillPrice}
|
||||||
|
onChange={(e) => setNewSkillPrice(e.target.value)}
|
||||||
|
className="mt-1 w-full px-3 py-2 rounded-md border border-border bg-surface-2 text-sm"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={addingSkill || !newSkillId}
|
||||||
|
className="px-4 py-2 rounded-md bg-accent text-white text-sm disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{addingSkill ? t("saving") : t("add")}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{skillError && (
|
||||||
|
<p className="text-sm text-error mt-2">{skillError}</p>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tiny inline editor for a single skill's daily price. Mounts in
|
||||||
|
* "view" mode showing the current value as a clickable badge;
|
||||||
|
* clicking turns it into an input + save/cancel buttons.
|
||||||
|
*/
|
||||||
|
function InlinePriceEditor({
|
||||||
|
skillId,
|
||||||
|
initialPrice,
|
||||||
|
onSave,
|
||||||
|
}: {
|
||||||
|
skillId: string;
|
||||||
|
initialPrice: number;
|
||||||
|
onSave: (price: number) => Promise<void> | void;
|
||||||
|
}) {
|
||||||
|
const t = useTranslations("adminBilling");
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
const [value, setValue] = useState(String(initialPrice));
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
if (!editing) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={() => setEditing(true)}
|
||||||
|
className="text-sm font-mono hover:underline"
|
||||||
|
title={t("clickToEdit")}
|
||||||
|
>
|
||||||
|
CHF {initialPrice.toFixed(2)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
min="0"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => setValue(e.target.value)}
|
||||||
|
className="w-20 px-2 py-1 text-sm border border-border bg-surface-2 rounded"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await onSave(Number(value));
|
||||||
|
setEditing(false);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={busy}
|
||||||
|
className="text-xs px-2 py-1 bg-accent text-white rounded"
|
||||||
|
>
|
||||||
|
{busy ? "…" : "✓"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setValue(String(initialPrice));
|
||||||
|
setEditing(false);
|
||||||
|
}}
|
||||||
|
className="text-xs px-2 py-1 border border-border rounded"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useState, useCallback } from "react";
|
import { useState, useCallback } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
import { ThreemaQrModal } from "./threema-qr-modal";
|
||||||
|
|
||||||
/** Maps channel IDs to the instructions for finding the user ID. */
|
/** Maps channel IDs to the instructions for finding the user ID. */
|
||||||
const CHANNEL_ID_HELP: Record<string, string> = {
|
const CHANNEL_ID_HELP: Record<string, string> = {
|
||||||
@@ -51,6 +52,14 @@ export function ChannelUsers({
|
|||||||
const [inputValues, setInputValues] = useState<Record<string, string>>({});
|
const [inputValues, setInputValues] = useState<Record<string, string>>({});
|
||||||
const [channelUsers, setChannelUsers] =
|
const [channelUsers, setChannelUsers] =
|
||||||
useState<Record<string, string[]>>(initialChannelUsers);
|
useState<Record<string, string[]>>(initialChannelUsers);
|
||||||
|
/** Which channel's QR helper modal is open, if any. */
|
||||||
|
const [showQrFor, setShowQrFor] = useState<string | null>(null);
|
||||||
|
/**
|
||||||
|
* Tracks channels for which we've already auto-opened the helper
|
||||||
|
* modal on this page load. Prevents the modal from re-popping every
|
||||||
|
* time the user refocuses the input after dismissing it.
|
||||||
|
*/
|
||||||
|
const [autoOpened, setAutoOpened] = useState<Set<string>>(() => new Set());
|
||||||
|
|
||||||
const updateChannelUsers = useCallback(
|
const updateChannelUsers = useCallback(
|
||||||
async (updated: Record<string, string[]>) => {
|
async (updated: Record<string, string[]>) => {
|
||||||
@@ -224,6 +233,39 @@ export function ChannelUsers({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{channel === "threema" && (
|
||||||
|
<div className="mb-3 flex flex-col sm:flex-row gap-3 items-start sm:items-center justify-between bg-accent/5 border border-accent/30 rounded-lg p-3">
|
||||||
|
<div className="flex items-start gap-2 flex-1">
|
||||||
|
<svg
|
||||||
|
className="w-4 h-4 mt-0.5 text-accent flex-shrink-0"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M3 4a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H4a1 1 0 01-1-1V4zM15 4a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1V4zM3 16a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H4a1 1 0 01-1-1v-4zM13 13h3v3h-3zM18 13h3v3h-3zM13 18h3v3h-3zM18 18h3v3h-3z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<div className="text-xs text-text-secondary leading-relaxed">
|
||||||
|
<p className="font-medium text-text-primary mb-0.5">
|
||||||
|
{t("threemaSetup.bannerTitle")}
|
||||||
|
</p>
|
||||||
|
<p>{t("threemaSetup.bannerBody")}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowQrFor("threema")}
|
||||||
|
className="self-stretch sm:self-auto px-3 py-2 text-xs font-medium bg-accent text-surface-0 rounded-lg hover:bg-accent-dim transition-colors whitespace-nowrap cursor-pointer shadow-lg shadow-accent/20"
|
||||||
|
>
|
||||||
|
{t("threemaSetup.bannerButton")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{helpKey && (
|
{helpKey && (
|
||||||
<p className="text-xs text-text-secondary bg-surface-1 border border-border rounded-lg p-3 mb-3 whitespace-pre-line">
|
<p className="text-xs text-text-secondary bg-surface-1 border border-border rounded-lg p-3 mb-3 whitespace-pre-line">
|
||||||
{t(helpKey)}
|
{t(helpKey)}
|
||||||
@@ -266,6 +308,17 @@ export function ChannelUsers({
|
|||||||
[channel]: e.target.value,
|
[channel]: e.target.value,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
onFocus={() => {
|
||||||
|
// For threema specifically, open the QR helper the
|
||||||
|
// first time the user clicks into the input on this
|
||||||
|
// page load. We don't repeat after dismiss — the
|
||||||
|
// "Show QR" button next to the channel name covers
|
||||||
|
// re-opens on demand.
|
||||||
|
if (channel === "threema" && !autoOpened.has("threema")) {
|
||||||
|
setShowQrFor("threema");
|
||||||
|
setAutoOpened((prev) => new Set(prev).add("threema"));
|
||||||
|
}
|
||||||
|
}}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Enter") handleAdd(channel);
|
if (e.key === "Enter") handleAdd(channel);
|
||||||
}}
|
}}
|
||||||
@@ -284,6 +337,11 @@ export function ChannelUsers({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
|
<ThreemaQrModal
|
||||||
|
open={showQrFor === "threema"}
|
||||||
|
onClose={() => setShowQrFor(null)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
82
src/components/channel-users/threema-qr-modal.tsx
Normal file
82
src/components/channel-users/threema-qr-modal.tsx
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { THREEMA_GATEWAY } from "@/lib/threema-gateway-config";
|
||||||
|
|
||||||
|
interface ThreemaQrModalProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* On-demand modal showing the QR for adding the assistant on Threema.
|
||||||
|
* Triggered by the "Show QR" button in the threema channel card and
|
||||||
|
* closes on overlay click, ESC, or the close button.
|
||||||
|
*
|
||||||
|
* Uses a plain <img> not next/image — image optimization adds nothing
|
||||||
|
* for a 57KB static PNG and removes a potential source of rendering
|
||||||
|
* bugs in the Next.js standalone build.
|
||||||
|
*/
|
||||||
|
export function ThreemaQrModal({ open, onClose }: ThreemaQrModalProps) {
|
||||||
|
const t = useTranslations("channelUsers.threemaSetup");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
function onKey(e: KeyboardEvent) {
|
||||||
|
if (e.key === "Escape") onClose();
|
||||||
|
}
|
||||||
|
window.addEventListener("keydown", onKey);
|
||||||
|
return () => window.removeEventListener("keydown", onKey);
|
||||||
|
}, [open, onClose]);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onClick={onClose}
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="w-full max-w-md bg-surface-1 border border-border rounded-2xl p-6 shadow-2xl shadow-black/40 space-y-4"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<h3 className="text-base font-semibold text-text-primary">
|
||||||
|
{t("title")}
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-text-muted hover:text-text-primary text-xl leading-none"
|
||||||
|
aria-label="Close"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<div className="bg-white p-3 rounded-md">
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img
|
||||||
|
src={THREEMA_GATEWAY.qrCodePath}
|
||||||
|
alt={t("qrAlt", { gateway: THREEMA_GATEWAY.displayName })}
|
||||||
|
width={220}
|
||||||
|
height={220}
|
||||||
|
style={{ display: "block" }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-center text-xs text-text-muted font-mono">
|
||||||
|
{THREEMA_GATEWAY.displayName}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ol className="list-decimal list-inside text-xs text-text-secondary space-y-1.5">
|
||||||
|
<li>{t("step1")}</li>
|
||||||
|
<li>{t("step2")}</li>
|
||||||
|
<li>{t("step3")}</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
655
src/lib/billing-pdf.tsx
Normal file
655
src/lib/billing-pdf.tsx
Normal file
@@ -0,0 +1,655 @@
|
|||||||
|
/**
|
||||||
|
* Invoice PDF rendering via @react-pdf/renderer.
|
||||||
|
*
|
||||||
|
* Design notes:
|
||||||
|
*
|
||||||
|
* - The template is a React component (JSX). Visual tweaks happen
|
||||||
|
* here — colors, fonts, spacing, layout. To swap branding later,
|
||||||
|
* edit BRAND_* constants below or replace the logo component.
|
||||||
|
*
|
||||||
|
* - All strings are pulled from MESSAGES[locale]. To add a new
|
||||||
|
* language, copy the German block and translate. Locale is
|
||||||
|
* frozen on the invoice at issue time (invoices.locale column);
|
||||||
|
* re-rendering a historical invoice always uses the same locale.
|
||||||
|
*
|
||||||
|
* - The logo is inlined as React-PDF SVG primitives so no asset
|
||||||
|
* loading or font-bundle wrangling is needed. It travels with
|
||||||
|
* the code.
|
||||||
|
*
|
||||||
|
* - VAT note (reverse charge etc.) is appended below the totals
|
||||||
|
* block. Notes are localized in the same MESSAGES map.
|
||||||
|
*
|
||||||
|
* - QR-bill (Swiss bank transfer) is intentionally NOT included
|
||||||
|
* in v1 — it lands in Phase 7. We render plain bank instructions
|
||||||
|
* as text.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import {
|
||||||
|
Document,
|
||||||
|
Page,
|
||||||
|
Text,
|
||||||
|
View,
|
||||||
|
StyleSheet,
|
||||||
|
Svg,
|
||||||
|
Polygon,
|
||||||
|
Polyline,
|
||||||
|
renderToBuffer,
|
||||||
|
} from "@react-pdf/renderer";
|
||||||
|
import type { Invoice, InvoiceLine, InvoiceLineKind } from "@/types";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Brand constants — edit here to tweak look without touching layout
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const BRAND = {
|
||||||
|
name: "PieCed IT",
|
||||||
|
// Primary emerald — matches the logo SVG fill (#10B981).
|
||||||
|
primary: "#10B981",
|
||||||
|
// Slightly darker emerald for headings.
|
||||||
|
primaryDark: "#0a8060",
|
||||||
|
textColor: "#1a1a1a",
|
||||||
|
mutedColor: "#666",
|
||||||
|
borderColor: "#d4d4d4",
|
||||||
|
// Issuer block — change these to your real legal info.
|
||||||
|
issuer: {
|
||||||
|
legalName: "PieCed IT",
|
||||||
|
addressLine1: "Cedric Mosimann",
|
||||||
|
addressLine2: "[Strasse Nr.]",
|
||||||
|
postalCity: "[PLZ] Basel",
|
||||||
|
country: "Switzerland",
|
||||||
|
email: "billing@pieced.ch",
|
||||||
|
web: "pieced.ch",
|
||||||
|
// Show "MWST-Nr. ..." on PDF when set.
|
||||||
|
vatNumber: null as string | null,
|
||||||
|
// Bank instructions — Phase 7 replaces with QR-bill.
|
||||||
|
bankName: "[Bank name]",
|
||||||
|
bankIban: "[CHxx xxxx xxxx xxxx xxxx x]",
|
||||||
|
bankBic: "[BIC]",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Localized strings
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface PdfStrings {
|
||||||
|
invoice: string;
|
||||||
|
invoiceNumber: string;
|
||||||
|
issueDate: string;
|
||||||
|
dueDate: string;
|
||||||
|
period: string;
|
||||||
|
billTo: string;
|
||||||
|
description: string;
|
||||||
|
quantity: string;
|
||||||
|
unitPrice: string;
|
||||||
|
amount: string;
|
||||||
|
subtotal: string;
|
||||||
|
vat: string;
|
||||||
|
total: string;
|
||||||
|
paymentInstructions: string;
|
||||||
|
paymentRefHint: string;
|
||||||
|
thankYou: string;
|
||||||
|
page: string;
|
||||||
|
of: string;
|
||||||
|
// Per-line-kind labels (used as section headers)
|
||||||
|
kindLabels: Record<InvoiceLineKind, string>;
|
||||||
|
// VAT compliance notes
|
||||||
|
reverseCharge: string;
|
||||||
|
exportNote: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MESSAGES: Record<string, PdfStrings> = {
|
||||||
|
de: {
|
||||||
|
invoice: "Rechnung",
|
||||||
|
invoiceNumber: "Rechnungs-Nr.",
|
||||||
|
issueDate: "Rechnungsdatum",
|
||||||
|
dueDate: "Zahlbar bis",
|
||||||
|
period: "Abrechnungsperiode",
|
||||||
|
billTo: "Rechnungsempfänger",
|
||||||
|
description: "Beschreibung",
|
||||||
|
quantity: "Menge",
|
||||||
|
unitPrice: "Einzelpreis",
|
||||||
|
amount: "Betrag",
|
||||||
|
subtotal: "Zwischensumme",
|
||||||
|
vat: "MWST",
|
||||||
|
total: "Total",
|
||||||
|
paymentInstructions: "Zahlungsinformationen",
|
||||||
|
paymentRefHint: "Bitte verwenden Sie die Rechnungsnummer als Referenz.",
|
||||||
|
thankYou: "Vielen Dank für Ihr Vertrauen.",
|
||||||
|
page: "Seite",
|
||||||
|
of: "von",
|
||||||
|
kindLabels: {
|
||||||
|
tenant_monthly: "Monatliche Grundgebühr",
|
||||||
|
tenant_setup: "Einrichtungsgebühr",
|
||||||
|
ai_usage: "KI-Nutzung",
|
||||||
|
threema_messages: "Threema-Nachrichten",
|
||||||
|
skill_usage: "Skill-Nutzung",
|
||||||
|
adjustment: "Anpassung",
|
||||||
|
},
|
||||||
|
reverseCharge:
|
||||||
|
"Steuerschuldnerschaft des Leistungsempfängers (Reverse Charge).",
|
||||||
|
exportNote: "Dienstleistungsexport — keine MWST in Rechnung gestellt.",
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
invoice: "Invoice",
|
||||||
|
invoiceNumber: "Invoice no.",
|
||||||
|
issueDate: "Issue date",
|
||||||
|
dueDate: "Due date",
|
||||||
|
period: "Billing period",
|
||||||
|
billTo: "Bill to",
|
||||||
|
description: "Description",
|
||||||
|
quantity: "Qty",
|
||||||
|
unitPrice: "Unit price",
|
||||||
|
amount: "Amount",
|
||||||
|
subtotal: "Subtotal",
|
||||||
|
vat: "VAT",
|
||||||
|
total: "Total",
|
||||||
|
paymentInstructions: "Payment instructions",
|
||||||
|
paymentRefHint: "Please use the invoice number as the payment reference.",
|
||||||
|
thankYou: "Thank you for your business.",
|
||||||
|
page: "Page",
|
||||||
|
of: "of",
|
||||||
|
kindLabels: {
|
||||||
|
tenant_monthly: "Monthly fee",
|
||||||
|
tenant_setup: "Setup fee",
|
||||||
|
ai_usage: "AI usage",
|
||||||
|
threema_messages: "Threema messages",
|
||||||
|
skill_usage: "Skill usage",
|
||||||
|
adjustment: "Adjustment",
|
||||||
|
},
|
||||||
|
reverseCharge:
|
||||||
|
"Reverse charge — VAT to be accounted for by the recipient.",
|
||||||
|
exportNote: "Export of services — VAT not applicable.",
|
||||||
|
},
|
||||||
|
fr: {
|
||||||
|
invoice: "Facture",
|
||||||
|
invoiceNumber: "N° facture",
|
||||||
|
issueDate: "Date d'émission",
|
||||||
|
dueDate: "Échéance",
|
||||||
|
period: "Période de facturation",
|
||||||
|
billTo: "Destinataire",
|
||||||
|
description: "Description",
|
||||||
|
quantity: "Qté",
|
||||||
|
unitPrice: "Prix unitaire",
|
||||||
|
amount: "Montant",
|
||||||
|
subtotal: "Sous-total",
|
||||||
|
vat: "TVA",
|
||||||
|
total: "Total",
|
||||||
|
paymentInstructions: "Informations de paiement",
|
||||||
|
paymentRefHint: "Veuillez utiliser le n° de facture comme référence.",
|
||||||
|
thankYou: "Merci de votre confiance.",
|
||||||
|
page: "Page",
|
||||||
|
of: "sur",
|
||||||
|
kindLabels: {
|
||||||
|
tenant_monthly: "Forfait mensuel",
|
||||||
|
tenant_setup: "Frais de configuration",
|
||||||
|
ai_usage: "Utilisation IA",
|
||||||
|
threema_messages: "Messages Threema",
|
||||||
|
skill_usage: "Utilisation Skill",
|
||||||
|
adjustment: "Ajustement",
|
||||||
|
},
|
||||||
|
reverseCharge:
|
||||||
|
"Autoliquidation — TVA à acquitter par le destinataire.",
|
||||||
|
exportNote: "Exportation de services — TVA non applicable.",
|
||||||
|
},
|
||||||
|
it: {
|
||||||
|
invoice: "Fattura",
|
||||||
|
invoiceNumber: "N. fattura",
|
||||||
|
issueDate: "Data di emissione",
|
||||||
|
dueDate: "Scadenza",
|
||||||
|
period: "Periodo di fatturazione",
|
||||||
|
billTo: "Destinatario",
|
||||||
|
description: "Descrizione",
|
||||||
|
quantity: "Qtà",
|
||||||
|
unitPrice: "Prezzo unitario",
|
||||||
|
amount: "Importo",
|
||||||
|
subtotal: "Subtotale",
|
||||||
|
vat: "IVA",
|
||||||
|
total: "Totale",
|
||||||
|
paymentInstructions: "Istruzioni di pagamento",
|
||||||
|
paymentRefHint: "Si prega di utilizzare il n. di fattura come riferimento.",
|
||||||
|
thankYou: "Grazie per la fiducia.",
|
||||||
|
page: "Pagina",
|
||||||
|
of: "di",
|
||||||
|
kindLabels: {
|
||||||
|
tenant_monthly: "Canone mensile",
|
||||||
|
tenant_setup: "Spese di attivazione",
|
||||||
|
ai_usage: "Utilizzo IA",
|
||||||
|
threema_messages: "Messaggi Threema",
|
||||||
|
skill_usage: "Utilizzo Skill",
|
||||||
|
adjustment: "Rettifica",
|
||||||
|
},
|
||||||
|
reverseCharge:
|
||||||
|
"Inversione contabile — IVA a carico del destinatario.",
|
||||||
|
exportNote: "Esportazione di servizi — IVA non applicabile.",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function getStrings(locale: string): PdfStrings {
|
||||||
|
return MESSAGES[locale] ?? MESSAGES.de;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Stylesheet
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
page: {
|
||||||
|
paddingTop: 40,
|
||||||
|
paddingBottom: 60,
|
||||||
|
paddingHorizontal: 40,
|
||||||
|
fontSize: 9,
|
||||||
|
color: BRAND.textColor,
|
||||||
|
lineHeight: 1.4,
|
||||||
|
},
|
||||||
|
headerRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "flex-start",
|
||||||
|
marginBottom: 28,
|
||||||
|
},
|
||||||
|
logoWrap: { width: 60, height: 90 },
|
||||||
|
issuerBlock: { textAlign: "right", fontSize: 8.5, color: BRAND.mutedColor },
|
||||||
|
issuerName: { fontSize: 11, color: BRAND.primaryDark, marginBottom: 2 },
|
||||||
|
invoiceTitle: { fontSize: 22, color: BRAND.primaryDark, marginBottom: 8 },
|
||||||
|
metaTable: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
marginBottom: 20,
|
||||||
|
},
|
||||||
|
metaCol: { flexGrow: 1, marginRight: 16 },
|
||||||
|
metaLabel: { color: BRAND.mutedColor, fontSize: 8, marginBottom: 2 },
|
||||||
|
metaValue: { fontSize: 10, marginBottom: 6 },
|
||||||
|
billToBlock: {
|
||||||
|
marginBottom: 24,
|
||||||
|
padding: 10,
|
||||||
|
backgroundColor: "#f7f7f5",
|
||||||
|
borderLeftWidth: 3,
|
||||||
|
borderLeftColor: BRAND.primary,
|
||||||
|
},
|
||||||
|
billToLabel: { fontSize: 8, color: BRAND.mutedColor, marginBottom: 4 },
|
||||||
|
billToName: { fontSize: 11, marginBottom: 2 },
|
||||||
|
table: { marginBottom: 14 },
|
||||||
|
tableHeader: {
|
||||||
|
flexDirection: "row",
|
||||||
|
backgroundColor: BRAND.primaryDark,
|
||||||
|
color: "#ffffff",
|
||||||
|
paddingVertical: 5,
|
||||||
|
paddingHorizontal: 6,
|
||||||
|
fontSize: 8.5,
|
||||||
|
},
|
||||||
|
tableRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
borderBottomWidth: 0.5,
|
||||||
|
borderBottomColor: BRAND.borderColor,
|
||||||
|
paddingVertical: 5,
|
||||||
|
paddingHorizontal: 6,
|
||||||
|
},
|
||||||
|
// Column widths (sum ≈ 100%)
|
||||||
|
colDesc: { width: "52%" },
|
||||||
|
colQty: { width: "12%", textAlign: "right" },
|
||||||
|
colUnit: { width: "16%", textAlign: "right" },
|
||||||
|
colAmt: { width: "20%", textAlign: "right" },
|
||||||
|
totalsBlock: {
|
||||||
|
alignSelf: "flex-end",
|
||||||
|
width: "45%",
|
||||||
|
marginTop: 8,
|
||||||
|
},
|
||||||
|
totalsRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
paddingVertical: 3,
|
||||||
|
},
|
||||||
|
totalsLabel: { color: BRAND.mutedColor },
|
||||||
|
totalsValue: { textAlign: "right" },
|
||||||
|
totalsGrand: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
borderTopWidth: 1,
|
||||||
|
borderTopColor: BRAND.primaryDark,
|
||||||
|
paddingTop: 6,
|
||||||
|
marginTop: 4,
|
||||||
|
},
|
||||||
|
totalsGrandLabel: { color: BRAND.primaryDark, fontSize: 11 },
|
||||||
|
totalsGrandValue: { color: BRAND.primaryDark, fontSize: 11, textAlign: "right" },
|
||||||
|
noteBox: {
|
||||||
|
marginTop: 18,
|
||||||
|
padding: 8,
|
||||||
|
backgroundColor: "#fff8e7",
|
||||||
|
borderLeftWidth: 2,
|
||||||
|
borderLeftColor: "#d4a017",
|
||||||
|
fontSize: 8.5,
|
||||||
|
},
|
||||||
|
paymentBlock: {
|
||||||
|
marginTop: 24,
|
||||||
|
paddingTop: 12,
|
||||||
|
borderTopWidth: 0.5,
|
||||||
|
borderTopColor: BRAND.borderColor,
|
||||||
|
},
|
||||||
|
paymentTitle: { fontSize: 10, color: BRAND.primaryDark, marginBottom: 6 },
|
||||||
|
paymentLine: { fontSize: 9, marginBottom: 1 },
|
||||||
|
footer: {
|
||||||
|
position: "absolute",
|
||||||
|
bottom: 24,
|
||||||
|
left: 40,
|
||||||
|
right: 40,
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
fontSize: 7.5,
|
||||||
|
color: BRAND.mutedColor,
|
||||||
|
borderTopWidth: 0.5,
|
||||||
|
borderTopColor: BRAND.borderColor,
|
||||||
|
paddingTop: 8,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Logo — inlined SVG primitives
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PieCed honeycomb logo. Re-renders the same 6-hex glyph as the
|
||||||
|
* portal's `public/pieced-logo.svg` using React-PDF's SVG support.
|
||||||
|
* Width/height are independent of the original viewBox so we can
|
||||||
|
* scale it without losing stroke quality.
|
||||||
|
*/
|
||||||
|
const Logo = ({ size = 60 }: { size?: number }) => (
|
||||||
|
<Svg width={size} height={size * (106 / 70)} viewBox="0 0 70 106">
|
||||||
|
{/* H1 solid */}
|
||||||
|
<Polygon
|
||||||
|
points="38.5,22.69 31.5,10.566 17.5,10.566 10.5,22.69 17.5,34.814 31.5,34.814"
|
||||||
|
fill="#10B981"
|
||||||
|
stroke="#10B981"
|
||||||
|
strokeWidth={1.6}
|
||||||
|
/>
|
||||||
|
{/* H2 outline */}
|
||||||
|
<Polygon
|
||||||
|
points="59.5,34.814 52.5,22.69 38.5,22.69 31.5,34.814 38.5,46.938 52.5,46.938"
|
||||||
|
fill="none"
|
||||||
|
stroke="#10B981"
|
||||||
|
strokeWidth={1.8}
|
||||||
|
/>
|
||||||
|
{/* H3 outline */}
|
||||||
|
<Polygon
|
||||||
|
points="38.5,46.938 31.5,34.814 17.5,34.814 10.5,46.938 17.5,59.062 31.5,59.062"
|
||||||
|
fill="none"
|
||||||
|
stroke="#10B981"
|
||||||
|
strokeWidth={1.8}
|
||||||
|
/>
|
||||||
|
{/* H4 solid */}
|
||||||
|
<Polygon
|
||||||
|
points="59.5,59.062 52.5,46.938 38.5,46.938 31.5,59.062 38.5,71.186 52.5,71.186"
|
||||||
|
fill="#10B981"
|
||||||
|
stroke="#10B981"
|
||||||
|
strokeWidth={1.6}
|
||||||
|
/>
|
||||||
|
{/* H5 partial */}
|
||||||
|
<Polyline
|
||||||
|
points="31.5,83.31 38.5,71.186 31.5,59.062 17.5,59.062 10.5,71.186"
|
||||||
|
fill="none"
|
||||||
|
stroke="#10B981"
|
||||||
|
strokeWidth={1.8}
|
||||||
|
/>
|
||||||
|
{/* H6 partial */}
|
||||||
|
<Polyline
|
||||||
|
points="59.5,83.31 52.5,71.186 38.5,71.186 31.5,83.31 38.5,95.434"
|
||||||
|
fill="none"
|
||||||
|
stroke="#10B981"
|
||||||
|
strokeWidth={1.8}
|
||||||
|
/>
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function fmtChf(n: number, decimals: number = 2): string {
|
||||||
|
// Swiss thousands separator + decimal point: 1'234.56
|
||||||
|
const fixed = n.toFixed(decimals);
|
||||||
|
const [intPart, decPart] = fixed.split(".");
|
||||||
|
const withSep = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, "'");
|
||||||
|
return decPart ? `${withSep}.${decPart}` : withSep;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtDate(iso: string, locale: string): string {
|
||||||
|
// Parse YYYY-MM-DD as a calendar date (no timezone shifts).
|
||||||
|
// For PDF rendering we want a stable representation regardless
|
||||||
|
// of server timezone.
|
||||||
|
const [y, m, d] = iso.split("T")[0].split("-").map(Number);
|
||||||
|
// Locale-specific date format
|
||||||
|
if (locale === "en") {
|
||||||
|
return new Date(Date.UTC(y, m - 1, d)).toLocaleDateString("en-US", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
timeZone: "UTC",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// DE/FR/IT default: DD.MM.YYYY
|
||||||
|
return `${String(d).padStart(2, "0")}.${String(m).padStart(2, "0")}.${y}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Document
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface InvoicePdfProps {
|
||||||
|
invoice: Invoice;
|
||||||
|
lines: InvoiceLine[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const InvoicePdf: React.FC<InvoicePdfProps> = ({ invoice, lines }) => {
|
||||||
|
const s = getStrings(invoice.locale);
|
||||||
|
const snap = invoice.billingSnapshot;
|
||||||
|
|
||||||
|
// Group lines by tenant for visual separation. Lines without a
|
||||||
|
// tenant_name (org-level adjustments) go to the end.
|
||||||
|
const linesByTenant = new Map<string | null, InvoiceLine[]>();
|
||||||
|
for (const ln of lines) {
|
||||||
|
const key = ln.tenantName;
|
||||||
|
if (!linesByTenant.has(key)) linesByTenant.set(key, []);
|
||||||
|
linesByTenant.get(key)!.push(ln);
|
||||||
|
}
|
||||||
|
const tenantOrder = [...linesByTenant.keys()].sort((a, b) => {
|
||||||
|
if (a === null) return 1;
|
||||||
|
if (b === null) return -1;
|
||||||
|
return a.localeCompare(b);
|
||||||
|
});
|
||||||
|
|
||||||
|
// VAT note: pick the right localized note based on rate + address.
|
||||||
|
// Zero rate + EU country = reverse charge; zero rate + other = export.
|
||||||
|
let vatNote: string | null = null;
|
||||||
|
if (invoice.vatRate === 0) {
|
||||||
|
const country = (snap.country || "").toUpperCase();
|
||||||
|
const isEu = [
|
||||||
|
"AT","BE","BG","HR","CY","CZ","DK","EE","FI","FR","DE","GR","HU",
|
||||||
|
"IE","IT","LV","LT","LU","MT","NL","PL","PT","RO","SK","SI","ES","SE",
|
||||||
|
].includes(country);
|
||||||
|
vatNote = isEu ? s.reverseCharge : s.exportNote;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Document title={`${s.invoice} ${invoice.invoiceNumber}`}>
|
||||||
|
<Page size="A4" style={styles.page}>
|
||||||
|
{/* Header: logo left, issuer right */}
|
||||||
|
<View style={styles.headerRow}>
|
||||||
|
<View style={styles.logoWrap}>
|
||||||
|
<Logo size={60} />
|
||||||
|
</View>
|
||||||
|
<View style={styles.issuerBlock}>
|
||||||
|
<Text style={styles.issuerName}>{BRAND.issuer.legalName}</Text>
|
||||||
|
<Text>{BRAND.issuer.addressLine1}</Text>
|
||||||
|
<Text>{BRAND.issuer.addressLine2}</Text>
|
||||||
|
<Text>{BRAND.issuer.postalCity}</Text>
|
||||||
|
<Text>{BRAND.issuer.country}</Text>
|
||||||
|
<Text>{BRAND.issuer.email}</Text>
|
||||||
|
{BRAND.issuer.vatNumber && (
|
||||||
|
<Text>MWST-Nr. {BRAND.issuer.vatNumber}</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text style={styles.invoiceTitle}>{s.invoice}</Text>
|
||||||
|
|
||||||
|
{/* Meta row: 3 columns */}
|
||||||
|
<View style={styles.metaTable}>
|
||||||
|
<View style={styles.metaCol}>
|
||||||
|
<Text style={styles.metaLabel}>{s.invoiceNumber}</Text>
|
||||||
|
<Text style={styles.metaValue}>{invoice.invoiceNumber}</Text>
|
||||||
|
<Text style={styles.metaLabel}>{s.issueDate}</Text>
|
||||||
|
<Text style={styles.metaValue}>
|
||||||
|
{fmtDate(invoice.issuedAt, invoice.locale)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.metaCol}>
|
||||||
|
<Text style={styles.metaLabel}>{s.period}</Text>
|
||||||
|
<Text style={styles.metaValue}>
|
||||||
|
{fmtDate(invoice.periodStart, invoice.locale)} —{" "}
|
||||||
|
{fmtDate(invoice.periodEnd, invoice.locale)}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.metaLabel}>{s.dueDate}</Text>
|
||||||
|
<Text style={styles.metaValue}>
|
||||||
|
{fmtDate(invoice.dueAt, invoice.locale)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Bill-to */}
|
||||||
|
<View style={styles.billToBlock}>
|
||||||
|
<Text style={styles.billToLabel}>{s.billTo}</Text>
|
||||||
|
<Text style={styles.billToName}>{snap.companyName}</Text>
|
||||||
|
<Text>{snap.streetAddress}</Text>
|
||||||
|
<Text>
|
||||||
|
{snap.postalCode} {snap.city}
|
||||||
|
</Text>
|
||||||
|
<Text>{snap.country}</Text>
|
||||||
|
{snap.vatNumber && <Text>VAT: {snap.vatNumber}</Text>}
|
||||||
|
<Text>{snap.billingEmail}</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Line items table */}
|
||||||
|
<View style={styles.table}>
|
||||||
|
<View style={styles.tableHeader}>
|
||||||
|
<Text style={styles.colDesc}>{s.description}</Text>
|
||||||
|
<Text style={styles.colQty}>{s.quantity}</Text>
|
||||||
|
<Text style={styles.colUnit}>{s.unitPrice}</Text>
|
||||||
|
<Text style={styles.colAmt}>{s.amount} (CHF)</Text>
|
||||||
|
</View>
|
||||||
|
{tenantOrder.map((tenantKey) => {
|
||||||
|
const tenantLines = linesByTenant.get(tenantKey)!;
|
||||||
|
return (
|
||||||
|
<View key={tenantKey ?? "_org"}>
|
||||||
|
{tenantKey && (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
paddingVertical: 4,
|
||||||
|
paddingHorizontal: 6,
|
||||||
|
backgroundColor: "#f0f9f4",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{ fontSize: 9, color: BRAND.primaryDark }}>
|
||||||
|
{tenantKey}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{tenantLines.map((ln) => (
|
||||||
|
<View key={ln.id} style={styles.tableRow}>
|
||||||
|
<Text style={styles.colDesc}>{ln.description}</Text>
|
||||||
|
<Text style={styles.colQty}>
|
||||||
|
{ln.quantity}
|
||||||
|
{ln.unitLabel ? ` ${ln.unitLabel}` : ""}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.colUnit}>{fmtChf(ln.unitPriceChf, 5)}</Text>
|
||||||
|
<Text style={styles.colAmt}>{fmtChf(ln.amountChf)}</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Totals */}
|
||||||
|
<View style={styles.totalsBlock}>
|
||||||
|
<View style={styles.totalsRow}>
|
||||||
|
<Text style={styles.totalsLabel}>{s.subtotal}</Text>
|
||||||
|
<Text style={styles.totalsValue}>{fmtChf(invoice.subtotalChf)}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.totalsRow}>
|
||||||
|
<Text style={styles.totalsLabel}>
|
||||||
|
{s.vat} ({invoice.vatRate.toFixed(2)}%)
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.totalsValue}>{fmtChf(invoice.vatAmountChf)}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.totalsGrand}>
|
||||||
|
<Text style={styles.totalsGrandLabel}>
|
||||||
|
{s.total} (CHF)
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.totalsGrandValue}>{fmtChf(invoice.totalChf)}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{vatNote && (
|
||||||
|
<View style={styles.noteBox}>
|
||||||
|
<Text>{vatNote}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Payment instructions */}
|
||||||
|
<View style={styles.paymentBlock}>
|
||||||
|
<Text style={styles.paymentTitle}>{s.paymentInstructions}</Text>
|
||||||
|
<Text style={styles.paymentLine}>{BRAND.issuer.legalName}</Text>
|
||||||
|
<Text style={styles.paymentLine}>{BRAND.issuer.bankName}</Text>
|
||||||
|
<Text style={styles.paymentLine}>IBAN: {BRAND.issuer.bankIban}</Text>
|
||||||
|
<Text style={styles.paymentLine}>BIC: {BRAND.issuer.bankBic}</Text>
|
||||||
|
<Text style={[styles.paymentLine, { marginTop: 6, color: BRAND.mutedColor }]}>
|
||||||
|
{s.paymentRefHint}
|
||||||
|
</Text>
|
||||||
|
<Text style={[styles.paymentLine, { marginTop: 12, color: BRAND.primaryDark }]}>
|
||||||
|
{s.thankYou}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Footer with page numbers.
|
||||||
|
react-pdf API quirks (verified against build errors):
|
||||||
|
- The `render` callback on <View> only exposes
|
||||||
|
`{ pageNumber, subPageNumber }` — no totalPages.
|
||||||
|
Only <Text> gets `{ pageNumber, totalPages,
|
||||||
|
subPageNumber, subPageTotalPages }`.
|
||||||
|
- <Text>'s render callback must return a STRING
|
||||||
|
(or array of strings), not JSX. */}
|
||||||
|
<View style={styles.footer} fixed>
|
||||||
|
<Text>
|
||||||
|
{BRAND.issuer.legalName} · {BRAND.issuer.web} · {BRAND.issuer.email}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
render={({ pageNumber, totalPages }) =>
|
||||||
|
`${s.page} ${pageNumber} ${s.of} ${totalPages}`
|
||||||
|
}
|
||||||
|
fixed
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</Page>
|
||||||
|
</Document>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Public API
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render an invoice to a PDF buffer. Caller stores the buffer in
|
||||||
|
* `invoices.pdf_data` (bytea). Side-effect-free; can be called
|
||||||
|
* outside a DB transaction.
|
||||||
|
*
|
||||||
|
* Typical runtime is 50–200ms on a typical invoice with a dozen
|
||||||
|
* lines.
|
||||||
|
*/
|
||||||
|
export async function renderInvoicePdf(
|
||||||
|
invoice: Invoice,
|
||||||
|
lines: InvoiceLine[]
|
||||||
|
): Promise<Buffer> {
|
||||||
|
return renderToBuffer(<InvoicePdf invoice={invoice} lines={lines} />);
|
||||||
|
}
|
||||||
743
src/lib/billing.ts
Normal file
743
src/lib/billing.ts
Normal file
@@ -0,0 +1,743 @@
|
|||||||
|
/**
|
||||||
|
* Billing computation pipeline.
|
||||||
|
*
|
||||||
|
* Public entry points:
|
||||||
|
* - computeInvoiceDraft({ zitadelOrgId, year, month, locale? })
|
||||||
|
* Builds an in-memory InvoiceDraft from the live signals
|
||||||
|
* (LiteLLM spend, Threema relay usage, tenant skill events,
|
||||||
|
* lifecycle, suspension). Does NOT persist or render the PDF.
|
||||||
|
*
|
||||||
|
* - generateInvoice({ zitadelOrgId, year, month, locale?, dryRun? })
|
||||||
|
* Calls computeInvoiceDraft, renders the PDF, persists the
|
||||||
|
* invoice transactionally. Returns the persisted Invoice
|
||||||
|
* (or the draft if dryRun=true).
|
||||||
|
*
|
||||||
|
* Design choices:
|
||||||
|
*
|
||||||
|
* - All compute is over UTC calendar days. "Active during day D"
|
||||||
|
* means the tenant existed and was not fully suspended at some
|
||||||
|
* moment in [D 00:00 UTC, D+1 00:00 UTC). This matches the
|
||||||
|
* skill billing rule ("same-day toggle = 1 day") for monthly
|
||||||
|
* fee proration too.
|
||||||
|
*
|
||||||
|
* - Computation is independent of persistence. Callers can preview
|
||||||
|
* without committing (the admin generate form does this on first
|
||||||
|
* click), and the same compute path is reused when committing.
|
||||||
|
*
|
||||||
|
* - The compute path collects warnings rather than throwing on
|
||||||
|
* recoverable issues (missing LiteLLM team for a tenant, etc.).
|
||||||
|
* The UI surfaces these to the admin before they confirm.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type {
|
||||||
|
Invoice,
|
||||||
|
InvoiceBillingSnapshot,
|
||||||
|
InvoiceDraft,
|
||||||
|
InvoiceLine,
|
||||||
|
InvoiceLineKind,
|
||||||
|
InvoicePaymentMethod,
|
||||||
|
PiecedTenant,
|
||||||
|
PlatformPricing,
|
||||||
|
SkillPricing,
|
||||||
|
TenantBillingLifecycle,
|
||||||
|
TenantSkillEvent,
|
||||||
|
TenantSuspensionEvent,
|
||||||
|
} from "@/types";
|
||||||
|
import {
|
||||||
|
createInvoice,
|
||||||
|
getInvoiceById,
|
||||||
|
getOrgBilling,
|
||||||
|
getOrgBillingConfig,
|
||||||
|
getPlatformPricing,
|
||||||
|
getTenantBillingLifecycle,
|
||||||
|
listSkillEventsForTenant,
|
||||||
|
listSkillPricing,
|
||||||
|
listSuspensionEventsForTenant,
|
||||||
|
tenantHasSetupFeeBilled,
|
||||||
|
updateInvoicePdf,
|
||||||
|
} from "./db";
|
||||||
|
import { listTenants } from "./k8s";
|
||||||
|
import { getTeamSpendLogsV2 } from "./litellm";
|
||||||
|
import { getUsage as getThreemaUsage } from "./threema-relay";
|
||||||
|
import { renderInvoicePdf } from "./billing-pdf";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Period helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the [periodStart, periodEnd] inclusive calendar dates for
|
||||||
|
* the given month, plus the count of days in the month.
|
||||||
|
*
|
||||||
|
* Dates returned as ISO `YYYY-MM-DD` strings (no time). Convertible
|
||||||
|
* to UTC midnight via `new Date(`${date}T00:00:00Z`)`.
|
||||||
|
*/
|
||||||
|
export function monthBounds(year: number, month: number): {
|
||||||
|
periodStart: string;
|
||||||
|
periodEnd: string;
|
||||||
|
daysInMonth: number;
|
||||||
|
} {
|
||||||
|
if (month < 1 || month > 12) throw new Error(`Invalid month: ${month}`);
|
||||||
|
const start = new Date(Date.UTC(year, month - 1, 1));
|
||||||
|
// Day 0 of next month = last day of this month
|
||||||
|
const end = new Date(Date.UTC(year, month, 0));
|
||||||
|
return {
|
||||||
|
periodStart: start.toISOString().split("T")[0],
|
||||||
|
periodEnd: end.toISOString().split("T")[0],
|
||||||
|
daysInMonth: end.getUTCDate(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isoDate(d: Date): string {
|
||||||
|
return d.toISOString().split("T")[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
function dueDate(periodEnd: string, netDays: number = 30): string {
|
||||||
|
// due_at = period_end + netDays
|
||||||
|
const d = new Date(`${periodEnd}T00:00:00Z`);
|
||||||
|
d.setUTCDate(d.getUTCDate() + netDays);
|
||||||
|
return isoDate(d);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Day-set computation (calendar-day model, UTC)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Iterates UTC calendar days in [periodStart, periodEnd] inclusive.
|
||||||
|
* Yields { date: 'YYYY-MM-DD', dayStartMs, dayEndMs } where dayEnd
|
||||||
|
* is exclusive (next-day-midnight UTC).
|
||||||
|
*/
|
||||||
|
function* iterDays(periodStart: string, periodEnd: string) {
|
||||||
|
const start = new Date(`${periodStart}T00:00:00Z`).getTime();
|
||||||
|
const end = new Date(`${periodEnd}T00:00:00Z`).getTime();
|
||||||
|
for (let t = start; t <= end; t += 86_400_000) {
|
||||||
|
yield {
|
||||||
|
date: isoDate(new Date(t)),
|
||||||
|
dayStartMs: t,
|
||||||
|
dayEndMs: t + 86_400_000,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Was the tenant "running" (created, not deleted, not suspended) at
|
||||||
|
* any moment in the half-open interval [dayStartMs, dayEndMs)?
|
||||||
|
*
|
||||||
|
* Inputs: tenant lifecycle and the timeline of suspension events
|
||||||
|
* sorted ascending by occurredAt.
|
||||||
|
*
|
||||||
|
* The state-at-day-start is reconstructed from suspension events
|
||||||
|
* BEFORE the day. If the count of suspension events before the day
|
||||||
|
* is odd, the tenant was suspended at day start (because we record
|
||||||
|
* suspend then resume, so an odd prefix-count means the last
|
||||||
|
* recorded transition is "suspended"). This is robust as long as
|
||||||
|
* events are correctly ordered.
|
||||||
|
*
|
||||||
|
* Actually we use the actual event kinds from the events list,
|
||||||
|
* not the parity heuristic — the heuristic is documentation for
|
||||||
|
* intuition.
|
||||||
|
*/
|
||||||
|
function activeDuringDay(
|
||||||
|
lifecycle: TenantBillingLifecycle,
|
||||||
|
suspensionEvents: TenantSuspensionEvent[],
|
||||||
|
dayStartMs: number,
|
||||||
|
dayEndMs: number
|
||||||
|
): boolean {
|
||||||
|
// Lifecycle gate: tenant must have existed during some part of the day.
|
||||||
|
const createdMs = new Date(lifecycle.createdAt).getTime();
|
||||||
|
const deletedMs = lifecycle.deletedAt
|
||||||
|
? new Date(lifecycle.deletedAt).getTime()
|
||||||
|
: Infinity;
|
||||||
|
if (createdMs >= dayEndMs) return false;
|
||||||
|
if (deletedMs <= dayStartMs) return false;
|
||||||
|
// Effective existence window within this day
|
||||||
|
const existsFrom = Math.max(createdMs, dayStartMs);
|
||||||
|
const existsTo = Math.min(deletedMs, dayEndMs);
|
||||||
|
if (existsFrom >= existsTo) return false;
|
||||||
|
|
||||||
|
// Determine suspended state at existsFrom by replaying events.
|
||||||
|
// Initial state at lifecycle.createdAt is 'running' (we don't
|
||||||
|
// record an explicit 'created → running' event; this is the
|
||||||
|
// implicit baseline).
|
||||||
|
let suspended = false;
|
||||||
|
for (const e of suspensionEvents) {
|
||||||
|
const ts = new Date(e.occurredAt).getTime();
|
||||||
|
if (ts > existsFrom) break;
|
||||||
|
suspended = e.eventKind === "suspended";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Walk events from existsFrom to existsTo. If at any moment the
|
||||||
|
// tenant is running, the day counts.
|
||||||
|
if (!suspended) return true;
|
||||||
|
for (const e of suspensionEvents) {
|
||||||
|
const ts = new Date(e.occurredAt).getTime();
|
||||||
|
if (ts <= existsFrom) continue;
|
||||||
|
if (ts >= existsTo) break;
|
||||||
|
if (e.eventKind === "resumed") return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Was the skill 'enabled' at any moment in the day?
|
||||||
|
*
|
||||||
|
* Same shape as activeDuringDay but driven by skill events instead
|
||||||
|
* of suspension events.
|
||||||
|
*
|
||||||
|
* Important: callers must include events from before periodStart in
|
||||||
|
* `prevState` (state at day start), since a skill enabled three
|
||||||
|
* months ago and never disabled has no events in the billing
|
||||||
|
* window but is still enabled.
|
||||||
|
*/
|
||||||
|
function skillActiveDuringDay(
|
||||||
|
events: TenantSkillEvent[],
|
||||||
|
initiallyEnabled: boolean,
|
||||||
|
dayStartMs: number,
|
||||||
|
dayEndMs: number
|
||||||
|
): boolean {
|
||||||
|
let enabled = initiallyEnabled;
|
||||||
|
// First, replay events that occurred AT OR BEFORE dayStartMs to
|
||||||
|
// get the state at day start.
|
||||||
|
for (const e of events) {
|
||||||
|
const ts = new Date(e.occurredAt).getTime();
|
||||||
|
if (ts > dayStartMs) break;
|
||||||
|
enabled = e.eventKind === "enabled";
|
||||||
|
}
|
||||||
|
if (enabled) return true;
|
||||||
|
// Walk events in [dayStart, dayEnd). If any 'enabled' event
|
||||||
|
// appears, the day counts.
|
||||||
|
for (const e of events) {
|
||||||
|
const ts = new Date(e.occurredAt).getTime();
|
||||||
|
if (ts <= dayStartMs) continue;
|
||||||
|
if (ts >= dayEndMs) break;
|
||||||
|
if (e.eventKind === "enabled") return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Rounding
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Round to 2dp, half-up. */
|
||||||
|
function round2(n: number): number {
|
||||||
|
return Math.round(n * 100) / 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// VAT logic
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const EU_COUNTRIES = new Set([
|
||||||
|
"AT", "BE", "BG", "HR", "CY", "CZ", "DK", "EE", "FI", "FR",
|
||||||
|
"DE", "GR", "HU", "IE", "IT", "LV", "LT", "LU", "MT", "NL",
|
||||||
|
"PL", "PT", "RO", "SK", "SI", "ES", "SE",
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine VAT rate from billing address and the platform default.
|
||||||
|
* See README for the legal interpretation; this implements the
|
||||||
|
* defaults you confirmed:
|
||||||
|
*
|
||||||
|
* - CH or LI: platform_pricing.vat_rate_chli (default 8.10)
|
||||||
|
* - EU + VAT number: 0% (reverse charge — B2B)
|
||||||
|
* - EU without VAT: CH MWST (B2C consumer, we charge our rate)
|
||||||
|
* - other: 0% (export of services)
|
||||||
|
*/
|
||||||
|
function vatRateForAddress(
|
||||||
|
snapshot: InvoiceBillingSnapshot,
|
||||||
|
platformPricing: PlatformPricing
|
||||||
|
): { rate: number; note: string | null } {
|
||||||
|
const country = snapshot.country?.toUpperCase().trim() ?? "";
|
||||||
|
if (country === "CH" || country === "LI") {
|
||||||
|
return { rate: platformPricing.vatRateChli, note: null };
|
||||||
|
}
|
||||||
|
if (EU_COUNTRIES.has(country)) {
|
||||||
|
if (snapshot.vatNumber && snapshot.vatNumber.trim().length > 0) {
|
||||||
|
return {
|
||||||
|
rate: 0,
|
||||||
|
note:
|
||||||
|
"Steuerschuldnerschaft des Leistungsempfängers / Reverse charge — VAT to be accounted for by the recipient.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { rate: platformPricing.vatRateChli, note: null };
|
||||||
|
}
|
||||||
|
return { rate: 0, note: "Export of services — VAT not applicable." };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Locale default
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pick a default invoice locale from the billing country. Admins
|
||||||
|
* can override at generation time. We default to German for
|
||||||
|
* CH/LI/AT/DE; French for FR/BE/LU; Italian for IT; English
|
||||||
|
* otherwise.
|
||||||
|
*/
|
||||||
|
export function defaultLocaleForCountry(country: string): string {
|
||||||
|
const c = (country || "").toUpperCase().trim();
|
||||||
|
if (["CH", "LI", "AT", "DE"].includes(c)) return "de";
|
||||||
|
if (["FR", "BE", "LU"].includes(c)) return "fr";
|
||||||
|
if (c === "IT") return "it";
|
||||||
|
return "en";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tenant signal collectors
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sum AI usage spend for a tenant over the billing period via
|
||||||
|
* LiteLLM. Returns the CHF total (already in CHF — LiteLLM stores
|
||||||
|
* costs after the platform's USD→CHF conversion) and the request
|
||||||
|
* count for the metadata.
|
||||||
|
*
|
||||||
|
* Tolerates missing litellmTeamId on the tenant: such tenants are
|
||||||
|
* skipped and the warning is surfaced upstream.
|
||||||
|
*/
|
||||||
|
async function collectAiUsage(
|
||||||
|
tenant: PiecedTenant,
|
||||||
|
periodStart: string,
|
||||||
|
periodEnd: string
|
||||||
|
): Promise<{ spendChf: number; requestCount: number } | null> {
|
||||||
|
const teamId = tenant.status?.litellmTeamId;
|
||||||
|
if (!teamId) return null;
|
||||||
|
const keyAlias = tenant.metadata.name;
|
||||||
|
let spendChf = 0;
|
||||||
|
let requestCount = 0;
|
||||||
|
let page = 1;
|
||||||
|
// 50-page cap matches the existing usage route's defensive cap.
|
||||||
|
while (page <= 50) {
|
||||||
|
const result = await getTeamSpendLogsV2(
|
||||||
|
teamId,
|
||||||
|
periodStart,
|
||||||
|
periodEnd,
|
||||||
|
page,
|
||||||
|
100,
|
||||||
|
keyAlias
|
||||||
|
);
|
||||||
|
const rows: any[] = result.data ?? [];
|
||||||
|
for (const r of rows) {
|
||||||
|
spendChf += Number(r.spend ?? 0);
|
||||||
|
requestCount += 1;
|
||||||
|
}
|
||||||
|
if (page >= (result.total_pages || 1)) break;
|
||||||
|
page++;
|
||||||
|
}
|
||||||
|
return { spendChf: round2(spendChf), requestCount };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sum Threema messages (in + out) for the tenant over the period.
|
||||||
|
* Returns null if the relay refuses or the tenant has no Threema
|
||||||
|
* package — billing is skipped silently in that case.
|
||||||
|
*/
|
||||||
|
async function collectThreemaUsage(
|
||||||
|
tenant: PiecedTenant,
|
||||||
|
periodStart: string,
|
||||||
|
periodEnd: string
|
||||||
|
): Promise<{ inCount: number; outCount: number } | null> {
|
||||||
|
const packages = tenant.spec.packages ?? [];
|
||||||
|
if (!packages.includes("threema")) return null;
|
||||||
|
// threema-relay.getUsage takes Date params, not strings, and
|
||||||
|
// returns a discriminated RelayResult<UsageBreakdown> — the
|
||||||
|
// `ok` discriminant must be checked before reading the totals.
|
||||||
|
// Period end is exclusive in the relay's API; pass the next-day
|
||||||
|
// midnight UTC to capture the full last day of the period.
|
||||||
|
const from = new Date(`${periodStart}T00:00:00Z`);
|
||||||
|
const to = new Date(`${periodEnd}T00:00:00Z`);
|
||||||
|
to.setUTCDate(to.getUTCDate() + 1);
|
||||||
|
const result = await getThreemaUsage(tenant.metadata.name, from, to).catch(
|
||||||
|
() => null
|
||||||
|
);
|
||||||
|
if (!result || !result.ok) return null;
|
||||||
|
return {
|
||||||
|
inCount: Number(result.totals?.in ?? 0),
|
||||||
|
outCount: Number(result.totals?.out ?? 0),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Per-tenant line builders
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function buildTenantLines(opts: {
|
||||||
|
tenant: PiecedTenant;
|
||||||
|
periodStart: string;
|
||||||
|
periodEnd: string;
|
||||||
|
daysInMonth: number;
|
||||||
|
platformPricing: PlatformPricing;
|
||||||
|
skillPricing: SkillPricing[];
|
||||||
|
warnings: string[];
|
||||||
|
displayOrderOffset: number;
|
||||||
|
}): Promise<Omit<InvoiceLine, "id" | "invoiceId">[]> {
|
||||||
|
const {
|
||||||
|
tenant,
|
||||||
|
periodStart,
|
||||||
|
periodEnd,
|
||||||
|
daysInMonth,
|
||||||
|
platformPricing,
|
||||||
|
skillPricing,
|
||||||
|
warnings,
|
||||||
|
} = opts;
|
||||||
|
let displayOrder = opts.displayOrderOffset;
|
||||||
|
const tenantName = tenant.metadata.name;
|
||||||
|
const lines: Omit<InvoiceLine, "id" | "invoiceId">[] = [];
|
||||||
|
|
||||||
|
// Lifecycle & suspension events — required for monthly proration.
|
||||||
|
const lifecycle = await getTenantBillingLifecycle(tenantName);
|
||||||
|
if (!lifecycle) {
|
||||||
|
warnings.push(
|
||||||
|
`Tenant "${tenantName}" has no billing lifecycle row — run the Phase 1 backfill.`
|
||||||
|
);
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Period interval in millis (extended by one day on each side as
|
||||||
|
// buffer for events that occur at month boundaries).
|
||||||
|
const periodStartMs = new Date(`${periodStart}T00:00:00Z`).getTime();
|
||||||
|
const periodEndMs = new Date(`${periodEnd}T00:00:00Z`).getTime() + 86_400_000;
|
||||||
|
|
||||||
|
const suspensionEvents = await listSuspensionEventsForTenant(
|
||||||
|
tenantName,
|
||||||
|
new Date(periodStartMs - 365 * 86_400_000), // look back a year for state-at-start
|
||||||
|
new Date(periodEndMs)
|
||||||
|
);
|
||||||
|
|
||||||
|
// --- tenant_monthly (prorated, suspended days excluded) -------------------
|
||||||
|
if (platformPricing.tenantMonthlyFeeChf > 0) {
|
||||||
|
let billableDays = 0;
|
||||||
|
let suspendedDays = 0;
|
||||||
|
for (const day of iterDays(periodStart, periodEnd)) {
|
||||||
|
if (activeDuringDay(lifecycle, suspensionEvents, day.dayStartMs, day.dayEndMs)) {
|
||||||
|
billableDays++;
|
||||||
|
} else {
|
||||||
|
// Distinguish "not yet existed / deleted" from "suspended"
|
||||||
|
// for the metadata audit trail. Cheap re-check.
|
||||||
|
const createdMs = new Date(lifecycle.createdAt).getTime();
|
||||||
|
const deletedMs = lifecycle.deletedAt
|
||||||
|
? new Date(lifecycle.deletedAt).getTime()
|
||||||
|
: Infinity;
|
||||||
|
if (createdMs < day.dayEndMs && deletedMs > day.dayStartMs) {
|
||||||
|
suspendedDays++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (billableDays > 0) {
|
||||||
|
const unit = platformPricing.tenantMonthlyFeeChf / daysInMonth;
|
||||||
|
const amount = round2(unit * billableDays);
|
||||||
|
lines.push({
|
||||||
|
tenantName,
|
||||||
|
kind: "tenant_monthly",
|
||||||
|
description: `Monthly fee for ${tenantName} (${billableDays}/${daysInMonth} days)`,
|
||||||
|
quantity: billableDays,
|
||||||
|
unitLabel: "days",
|
||||||
|
unitPriceChf: round2(unit * 1e5) / 1e5,
|
||||||
|
amountChf: amount,
|
||||||
|
metadata: {
|
||||||
|
billable_days: billableDays,
|
||||||
|
suspended_days: suspendedDays,
|
||||||
|
days_in_month: daysInMonth,
|
||||||
|
},
|
||||||
|
displayOrder: displayOrder++,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- tenant_setup (first invoice only) -----------------------------------
|
||||||
|
if (platformPricing.tenantSetupFeeChf > 0) {
|
||||||
|
const alreadyBilled = await tenantHasSetupFeeBilled(tenantName);
|
||||||
|
if (!alreadyBilled) {
|
||||||
|
lines.push({
|
||||||
|
tenantName,
|
||||||
|
kind: "tenant_setup",
|
||||||
|
description: `Setup fee for ${tenantName}`,
|
||||||
|
quantity: 1,
|
||||||
|
unitLabel: null,
|
||||||
|
unitPriceChf: platformPricing.tenantSetupFeeChf,
|
||||||
|
amountChf: round2(platformPricing.tenantSetupFeeChf),
|
||||||
|
metadata: null,
|
||||||
|
displayOrder: displayOrder++,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- ai_usage --------------------------------------------------------------
|
||||||
|
const aiUsage = await collectAiUsage(tenant, periodStart, periodEnd).catch(
|
||||||
|
(e) => {
|
||||||
|
warnings.push(
|
||||||
|
`AI usage fetch failed for ${tenantName}: ${e instanceof Error ? e.message : String(e)}`
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (aiUsage === null && tenant.status?.litellmTeamId) {
|
||||||
|
// teamId exists but fetch returned null — already warned above
|
||||||
|
} else if (aiUsage === null) {
|
||||||
|
warnings.push(
|
||||||
|
`Tenant ${tenantName} has no LiteLLM team yet — AI usage skipped.`
|
||||||
|
);
|
||||||
|
} else if (aiUsage.spendChf > 0) {
|
||||||
|
lines.push({
|
||||||
|
tenantName,
|
||||||
|
kind: "ai_usage",
|
||||||
|
description: `AI inference usage (${aiUsage.requestCount} requests)`,
|
||||||
|
quantity: 1,
|
||||||
|
unitLabel: null,
|
||||||
|
unitPriceChf: aiUsage.spendChf,
|
||||||
|
amountChf: aiUsage.spendChf,
|
||||||
|
metadata: {
|
||||||
|
litellm_key_alias: tenantName,
|
||||||
|
spend_chf: aiUsage.spendChf,
|
||||||
|
requests: aiUsage.requestCount,
|
||||||
|
},
|
||||||
|
displayOrder: displayOrder++,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- threema_messages -----------------------------------------------------
|
||||||
|
if (platformPricing.threemaMessageChf > 0) {
|
||||||
|
const threema = await collectThreemaUsage(tenant, periodStart, periodEnd);
|
||||||
|
if (threema && (threema.inCount + threema.outCount) > 0) {
|
||||||
|
const total = threema.inCount + threema.outCount;
|
||||||
|
lines.push({
|
||||||
|
tenantName,
|
||||||
|
kind: "threema_messages",
|
||||||
|
description: `Threema messages (${threema.inCount} in + ${threema.outCount} out)`,
|
||||||
|
quantity: total,
|
||||||
|
unitLabel: "msgs",
|
||||||
|
unitPriceChf: platformPricing.threemaMessageChf,
|
||||||
|
amountChf: round2(total * platformPricing.threemaMessageChf),
|
||||||
|
metadata: {
|
||||||
|
in_count: threema.inCount,
|
||||||
|
out_count: threema.outCount,
|
||||||
|
total_count: total,
|
||||||
|
},
|
||||||
|
displayOrder: displayOrder++,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- skill_usage ----------------------------------------------------------
|
||||||
|
// For each priced skill, count distinct UTC days the skill was
|
||||||
|
// enabled during the period.
|
||||||
|
if (skillPricing.length > 0) {
|
||||||
|
// Fetch all skill events for the tenant within the period plus
|
||||||
|
// a long lookback so we can determine state-at-period-start.
|
||||||
|
// The state-at-day-start logic in skillActiveDuringDay walks
|
||||||
|
// these events forward.
|
||||||
|
const allEvents = await listSkillEventsForTenant(
|
||||||
|
tenantName,
|
||||||
|
new Date(0),
|
||||||
|
new Date(periodEndMs)
|
||||||
|
);
|
||||||
|
for (const sp of skillPricing) {
|
||||||
|
const skillEvents = allEvents.filter((e) => e.skillId === sp.skillId);
|
||||||
|
// Skip cheaply if no events ever existed for this skill on
|
||||||
|
// this tenant.
|
||||||
|
if (skillEvents.length === 0) continue;
|
||||||
|
// Initial state assumption: false. The very first event is
|
||||||
|
// always 'enabled' (we only record toggles, and the implicit
|
||||||
|
// pre-toggle state for a never-seen skill is 'disabled').
|
||||||
|
let billableDays = 0;
|
||||||
|
for (const day of iterDays(periodStart, periodEnd)) {
|
||||||
|
if (skillActiveDuringDay(skillEvents, false, day.dayStartMs, day.dayEndMs)) {
|
||||||
|
billableDays++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (billableDays > 0) {
|
||||||
|
lines.push({
|
||||||
|
tenantName,
|
||||||
|
kind: "skill_usage",
|
||||||
|
description: `Skill: ${sp.skillId} (${billableDays} day${billableDays === 1 ? "" : "s"})`,
|
||||||
|
quantity: billableDays,
|
||||||
|
unitLabel: "days",
|
||||||
|
unitPriceChf: sp.dailyPriceChf,
|
||||||
|
amountChf: round2(billableDays * sp.dailyPriceChf),
|
||||||
|
metadata: {
|
||||||
|
skill_id: sp.skillId,
|
||||||
|
billable_days: billableDays,
|
||||||
|
event_count: skillEvents.length,
|
||||||
|
},
|
||||||
|
displayOrder: displayOrder++,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Public API
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export async function computeInvoiceDraft(opts: {
|
||||||
|
zitadelOrgId: string;
|
||||||
|
year: number;
|
||||||
|
month: number;
|
||||||
|
locale?: string;
|
||||||
|
paymentMethod?: InvoicePaymentMethod;
|
||||||
|
}): Promise<InvoiceDraft> {
|
||||||
|
const { zitadelOrgId, year, month } = opts;
|
||||||
|
const { periodStart, periodEnd, daysInMonth } = monthBounds(year, month);
|
||||||
|
const warnings: string[] = [];
|
||||||
|
|
||||||
|
// 1. Billing address. Required — without it we can't produce a
|
||||||
|
// valid invoice.
|
||||||
|
const orgBilling = await getOrgBilling(zitadelOrgId);
|
||||||
|
if (!orgBilling) {
|
||||||
|
throw new Error(
|
||||||
|
`Org ${zitadelOrgId} has no billing address on file. ` +
|
||||||
|
`The customer must complete /settings/billing before an invoice can be issued.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const snapshot: InvoiceBillingSnapshot = {
|
||||||
|
companyName: orgBilling.companyName,
|
||||||
|
streetAddress: orgBilling.streetAddress,
|
||||||
|
postalCode: orgBilling.postalCode,
|
||||||
|
city: orgBilling.city,
|
||||||
|
country: orgBilling.country,
|
||||||
|
vatNumber: orgBilling.vatNumber ?? null,
|
||||||
|
billingEmail: orgBilling.billingEmail,
|
||||||
|
notes: orgBilling.notes ?? null,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 2. Platform pricing + skill prices.
|
||||||
|
const platformPricing = await getPlatformPricing();
|
||||||
|
const skillPricing = await listSkillPricing();
|
||||||
|
|
||||||
|
// 3. Find all tenants for this org. We list from K8s (source of
|
||||||
|
// truth) and filter by the zitadel-org-id label.
|
||||||
|
const allTenants = await listTenants();
|
||||||
|
const orgTenants = allTenants.filter(
|
||||||
|
(t) => t.metadata.labels?.["pieced.ch/zitadel-org-id"] === zitadelOrgId
|
||||||
|
);
|
||||||
|
if (orgTenants.length === 0) {
|
||||||
|
warnings.push(`No tenants found for org ${zitadelOrgId}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Build lines, grouped per tenant (display order preserved).
|
||||||
|
const lines: Omit<InvoiceLine, "id" | "invoiceId">[] = [];
|
||||||
|
let nextDisplayOrder = 0;
|
||||||
|
// Sort tenants by name for stable line ordering across regenerations.
|
||||||
|
orgTenants.sort((a, b) => a.metadata.name.localeCompare(b.metadata.name));
|
||||||
|
for (const tenant of orgTenants) {
|
||||||
|
const tenantLines = await buildTenantLines({
|
||||||
|
tenant,
|
||||||
|
periodStart,
|
||||||
|
periodEnd,
|
||||||
|
daysInMonth,
|
||||||
|
platformPricing,
|
||||||
|
skillPricing,
|
||||||
|
warnings,
|
||||||
|
displayOrderOffset: nextDisplayOrder,
|
||||||
|
});
|
||||||
|
lines.push(...tenantLines);
|
||||||
|
nextDisplayOrder += tenantLines.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Subtotal & VAT.
|
||||||
|
const subtotal = round2(lines.reduce((acc, l) => acc + l.amountChf, 0));
|
||||||
|
const vat = vatRateForAddress(snapshot, platformPricing);
|
||||||
|
const vatAmount = round2((subtotal * vat.rate) / 100);
|
||||||
|
const total = round2(subtotal + vatAmount);
|
||||||
|
if (vat.note) warnings.push(vat.note);
|
||||||
|
|
||||||
|
// 6. Payment method: prefer pay-by-invoice if the admin enabled
|
||||||
|
// it for the org, otherwise default to invoice. Card payment
|
||||||
|
// is wired in Phase 4 — for Phase 2 every invoice is 'invoice'.
|
||||||
|
const orgConfig = await getOrgBillingConfig(zitadelOrgId);
|
||||||
|
const paymentMethod: InvoicePaymentMethod =
|
||||||
|
opts.paymentMethod ?? (orgConfig.payByInvoice ? "invoice" : "invoice");
|
||||||
|
|
||||||
|
// 7. Locale resolution
|
||||||
|
const locale = opts.locale ?? defaultLocaleForCountry(snapshot.country);
|
||||||
|
|
||||||
|
return {
|
||||||
|
zitadelOrgId,
|
||||||
|
periodStart,
|
||||||
|
periodEnd,
|
||||||
|
dueAt: dueDate(periodEnd, 30),
|
||||||
|
locale,
|
||||||
|
paymentMethod,
|
||||||
|
billingSnapshot: snapshot,
|
||||||
|
lines,
|
||||||
|
subtotalChf: subtotal,
|
||||||
|
vatRate: vat.rate,
|
||||||
|
vatAmountChf: vatAmount,
|
||||||
|
totalChf: total,
|
||||||
|
warnings,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute + render + persist in one step. If dryRun is true, the
|
||||||
|
* draft is returned without persisting and no PDF is rendered (the
|
||||||
|
* preview UI hits this).
|
||||||
|
*/
|
||||||
|
export async function generateInvoice(opts: {
|
||||||
|
zitadelOrgId: string;
|
||||||
|
year: number;
|
||||||
|
month: number;
|
||||||
|
locale?: string;
|
||||||
|
dryRun?: boolean;
|
||||||
|
}): Promise<{ draft: InvoiceDraft; invoice: Invoice | null }> {
|
||||||
|
const draft = await computeInvoiceDraft(opts);
|
||||||
|
if (opts.dryRun) {
|
||||||
|
return { draft, invoice: null };
|
||||||
|
}
|
||||||
|
// Render the PDF first — if it fails, we never touch the DB.
|
||||||
|
// The PDF render needs the invoice number, which is allocated
|
||||||
|
// inside createInvoice's transaction. To keep the PDF rendering
|
||||||
|
// outside the DB transaction (it can be slow), we render with a
|
||||||
|
// placeholder number, allocate the real number inside the tx,
|
||||||
|
// then re-render? No — instead we generate a temporary draft
|
||||||
|
// number for the PDF and accept that the displayed number on
|
||||||
|
// the PDF matches what we'll persist (because the allocator is
|
||||||
|
// serialized).
|
||||||
|
//
|
||||||
|
// Practical approach: render the PDF inside createInvoice's tx,
|
||||||
|
// immediately after allocation. This is fine because react-pdf
|
||||||
|
// is reasonably fast (~50–200 ms for a typical invoice) and
|
||||||
|
// happens once per invoice.
|
||||||
|
//
|
||||||
|
// To avoid restructuring createInvoice, we do this in two
|
||||||
|
// passes: (1) reserve a number via createInvoice with a
|
||||||
|
// placeholder PDF; (2) render with the real number; (3) UPDATE
|
||||||
|
// pdf_data. The trade-off is two write trips but keeps the code
|
||||||
|
// shape simple. We accept it.
|
||||||
|
//
|
||||||
|
// Reasoning behind two-pass: if PDF render is moved inside the
|
||||||
|
// tx and fails (font missing, etc.), the allocated counter rolls
|
||||||
|
// back — good. But it also means the connection is held during
|
||||||
|
// render. At v1 scale that's fine; the choice is reversible.
|
||||||
|
|
||||||
|
// Pass 1: allocate number + persist with empty PDF.
|
||||||
|
const placeholder = await createInvoice(draft, null, null);
|
||||||
|
try {
|
||||||
|
const pdfBuffer = await renderInvoicePdf(
|
||||||
|
placeholder,
|
||||||
|
draft.lines.map((l, i) => ({
|
||||||
|
...l,
|
||||||
|
id: `tmp-${i}`,
|
||||||
|
invoiceId: placeholder.id,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
const filename = `${placeholder.invoiceNumber}.pdf`;
|
||||||
|
// Pass 2: store the PDF bytes.
|
||||||
|
await updateInvoicePdf(placeholder.id, pdfBuffer, filename);
|
||||||
|
const finalInvoice = await getInvoiceById(placeholder.id);
|
||||||
|
return { draft, invoice: finalInvoice ?? placeholder };
|
||||||
|
} catch (e) {
|
||||||
|
// Render failed — leave the persisted row in place so admin can
|
||||||
|
// inspect it, but surface the error.
|
||||||
|
throw new Error(
|
||||||
|
`Invoice ${placeholder.invoiceNumber} persisted but PDF rendering failed: ${
|
||||||
|
e instanceof Error ? e.message : String(e)
|
||||||
|
}. Use the admin "delete invoice" tool to clean up if needed.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
1215
src/lib/db.ts
1215
src/lib/db.ts
File diff suppressed because it is too large
Load Diff
31
src/lib/threema-gateway-config.ts
Normal file
31
src/lib/threema-gateway-config.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
/**
|
||||||
|
* Threema central gateway info shown to customers.
|
||||||
|
*
|
||||||
|
* Today PieCed runs exactly one Threema Gateway account (*AIAGENT) and
|
||||||
|
* every tenant talks to that. The constants below are hardcoded for
|
||||||
|
* that account. The values are intentionally kept here (and not split
|
||||||
|
* across i18n / env / runtime config) so when we move to multiple
|
||||||
|
* gateway accounts there's a single file to refactor.
|
||||||
|
*
|
||||||
|
* To go dynamic (future):
|
||||||
|
* 1. Replace `THREEMA_GATEWAY` constant with a runtime lookup —
|
||||||
|
* either per-tenant from the relay's admin API, or from an
|
||||||
|
* env var that lists the active account.
|
||||||
|
* 2. Move the QR PNG into a server-rendered route that takes a
|
||||||
|
* gateway ID query param.
|
||||||
|
* 3. Update consumers to accept the gateway info as a prop and pass
|
||||||
|
* it from a server component.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const THREEMA_GATEWAY = {
|
||||||
|
/** Technical Threema Gateway ID, with leading asterisk. */
|
||||||
|
id: "*AIAGENT",
|
||||||
|
/**
|
||||||
|
* Display name shown to customers. INCLUDES the leading asterisk —
|
||||||
|
* customers need to recognise this exact string in their Threema
|
||||||
|
* contacts after scanning the QR, so we don't strip it.
|
||||||
|
*/
|
||||||
|
displayName: "*AIAGENT",
|
||||||
|
/** Public path to the QR code PNG served from `public/`. */
|
||||||
|
qrCodePath: "/threema/qr_code_AIAGENT.png",
|
||||||
|
} as const;
|
||||||
@@ -384,7 +384,8 @@
|
|||||||
"spendChf": "Kosten (CHF)",
|
"spendChf": "Kosten (CHF)",
|
||||||
"resumeRequestBadge": "Wieder",
|
"resumeRequestBadge": "Wieder",
|
||||||
"resumeRequestTooltip": "Reaktivierungsanfrage für einen bestehenden Tenant. Bei Genehmigung wird der Tenant wieder aktiviert; keine Provisionierung läuft.",
|
"resumeRequestTooltip": "Reaktivierungsanfrage für einen bestehenden Tenant. Bei Genehmigung wird der Tenant wieder aktiviert; keine Provisionierung läuft.",
|
||||||
"openclawTool": "OpenClaw-Versionen"
|
"openclawTool": "OpenClaw-Versionen",
|
||||||
|
"billingTool": "Abrechnung →"
|
||||||
},
|
},
|
||||||
"channelUsers": {
|
"channelUsers": {
|
||||||
"title": "Autorisierte Benutzer",
|
"title": "Autorisierte Benutzer",
|
||||||
@@ -402,7 +403,10 @@
|
|||||||
"step1": "Öffnen Sie Threema auf Ihrem Telefon.",
|
"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.",
|
"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.",
|
"step3": "Fügen Sie anschliessend unten Ihre eigene Threema-ID hinzu.",
|
||||||
"qrAlt": "QR-Code, um {gateway} als Threema-Kontakt hinzuzufügen"
|
"qrAlt": "QR-Code, um {gateway} als Threema-Kontakt hinzuzufügen",
|
||||||
|
"bannerTitle": "Threema einrichten",
|
||||||
|
"bannerBody": "Öffnen Sie Threema auf Ihrem Telefon und scannen Sie unseren QR-Code, um den Assistenten als Kontakt hinzuzufügen. Geben Sie anschliessend unten Ihre eigene Threema-ID ein.",
|
||||||
|
"bannerButton": "QR-Code anzeigen"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"team": {
|
"team": {
|
||||||
@@ -550,5 +554,105 @@
|
|||||||
"defaultPrefix": "Standard:",
|
"defaultPrefix": "Standard:",
|
||||||
"saveOverride": "Override speichern",
|
"saveOverride": "Override speichern",
|
||||||
"clearOverride": "Override entfernen"
|
"clearOverride": "Override entfernen"
|
||||||
|
},
|
||||||
|
"adminBilling": {
|
||||||
|
"title": "Abrechnungsverwaltung",
|
||||||
|
"subtitle": "Plattform-Preise verwalten, Rechnungen generieren und den Rechnungsstatus aller Organisationen prüfen.",
|
||||||
|
"backToAdmin": "Zurück zur Verwaltung",
|
||||||
|
"backToBilling": "Zurück zur Abrechnung",
|
||||||
|
"backToInvoices": "Zurück zu den Rechnungen",
|
||||||
|
"totalOpenBalance": "Offener Saldo gesamt",
|
||||||
|
"orgsWithBalance": "Organisationen mit Saldo",
|
||||||
|
"overdueInvoices": "Überfällige Rechnungen",
|
||||||
|
"pricingTitle": "Preise",
|
||||||
|
"pricingDesc": "Plattform- & Skill-Preise, MWST-Satz.",
|
||||||
|
"pricingPageDesc": "Plattformweite Preise und Skill-Tagespreise bearbeiten.",
|
||||||
|
"generateTitle": "Rechnung erstellen",
|
||||||
|
"generateDesc": "Rechnung für eine Organisation und einen Monat berechnen und ausstellen.",
|
||||||
|
"generatePageDesc": "Organisation, Periode und Sprache wählen. Die Vorschau zeigt die berechneten Positionen; mit Bestätigen wird die Rechnung ausgestellt und das PDF erzeugt.",
|
||||||
|
"invoicesTitle": "Rechnungen",
|
||||||
|
"invoicesDesc": "Alle Rechnungen anzeigen, als bezahlt markieren, PDFs herunterladen.",
|
||||||
|
"invoicesPageDesc": "Alle von der Plattform ausgestellten Rechnungen. Mit dem Statusfilter offene oder überfällige Positionen einsehen.",
|
||||||
|
"balancesTitle": "Organisationen mit offenem Saldo",
|
||||||
|
"orgIdCol": "Zitadel-Org-ID",
|
||||||
|
"openCountCol": "Offen",
|
||||||
|
"overdueCountCol": "Überfällig",
|
||||||
|
"totalOpenCol": "Gesamt offen",
|
||||||
|
"platformPricingTitle": "Plattform-Preise",
|
||||||
|
"monthlyFeeLabel": "Monatliche Tenant-Gebühr",
|
||||||
|
"setupFeeLabel": "Einrichtungsgebühr Tenant",
|
||||||
|
"threemaMessageLabel": "Threema pro Nachricht",
|
||||||
|
"vatRateLabel": "MWST-Satz (CH/LI)",
|
||||||
|
"save": "Speichern",
|
||||||
|
"saving": "Speichere…",
|
||||||
|
"savedOk": "Gespeichert",
|
||||||
|
"skillPricingTitle": "Skill-Preise",
|
||||||
|
"skillPricingDesc": "Tagespreis pro Skill. Ein zu beliebigem Zeitpunkt an einem UTC-Tag aktivierter Skill zählt als ein abrechenbarer Tag.",
|
||||||
|
"skillCol": "Skill",
|
||||||
|
"dailyPriceCol": "Tagespreis",
|
||||||
|
"actionsCol": "",
|
||||||
|
"remove": "Entfernen",
|
||||||
|
"noSkillsPriced": "Noch keine Skills bepreist.",
|
||||||
|
"addSkillLabel": "Skill hinzufügen",
|
||||||
|
"dailyPriceLabel": "Tagespreis",
|
||||||
|
"add": "Hinzufügen",
|
||||||
|
"confirmDeleteSkillPrice": "Preis für {skill} entfernen?",
|
||||||
|
"clickToEdit": "Zum Bearbeiten klicken",
|
||||||
|
"generateFormTitle": "Rechnung erstellen",
|
||||||
|
"noOrgsToGenerate": "Keine Organisationen mit Tenants gefunden.",
|
||||||
|
"orgLabel": "Organisation",
|
||||||
|
"noBillingAddrTag": "keine Rechnungsadresse",
|
||||||
|
"noBillingAddrWarning": "Diese Organisation hat keine Rechnungsadresse hinterlegt. Der Kunde muss /settings/billing ausfüllen, bevor eine Rechnung ausgestellt werden kann.",
|
||||||
|
"tenantsLabel": "Tenants",
|
||||||
|
"yearLabel": "Jahr",
|
||||||
|
"monthLabel": "Monat",
|
||||||
|
"localeLabel": "PDF-Sprache",
|
||||||
|
"localeAuto": "Automatisch",
|
||||||
|
"previewBtn": "Vorschau",
|
||||||
|
"commitBtn": "Bestätigen & ausstellen",
|
||||||
|
"computing": "Berechne…",
|
||||||
|
"confirmGenerate": "Diese Rechnung ausstellen? Es wird eine Rechnungsnummer vergeben und das PDF erzeugt.",
|
||||||
|
"previewTitle": "Entwurfsvorschau",
|
||||||
|
"warningsTitle": "Hinweise",
|
||||||
|
"noLinesGenerated": "Keine abrechenbaren Positionen für diese Periode.",
|
||||||
|
"descCol": "Beschreibung",
|
||||||
|
"qtyCol": "Menge",
|
||||||
|
"unitPriceCol": "Einzelpreis",
|
||||||
|
"amountCol": "Betrag (CHF)",
|
||||||
|
"subtotal": "Zwischensumme",
|
||||||
|
"vat": "MWST",
|
||||||
|
"total": "Total",
|
||||||
|
"statusFilterLabel": "Status",
|
||||||
|
"allStatuses": "Alle",
|
||||||
|
"monthFilterLabel": "Periode",
|
||||||
|
"clearFilter": "Zurücksetzen",
|
||||||
|
"loading": "Lade…",
|
||||||
|
"noInvoicesFound": "Keine Rechnungen entsprechen den aktuellen Filtern.",
|
||||||
|
"invoiceNumberCol": "Nummer",
|
||||||
|
"orgCol": "Organisation",
|
||||||
|
"periodCol": "Periode",
|
||||||
|
"statusCol": "Status",
|
||||||
|
"totalCol": "Total",
|
||||||
|
"dueCol": "Fällig",
|
||||||
|
"status_draft": "Entwurf",
|
||||||
|
"status_open": "Offen",
|
||||||
|
"status_paid": "Bezahlt",
|
||||||
|
"status_overdue": "Überfällig",
|
||||||
|
"status_void": "Storniert",
|
||||||
|
"status_uncollectible": "Uneinbringlich",
|
||||||
|
"dueOnLabel": "Fällig",
|
||||||
|
"totalLabel": "Total",
|
||||||
|
"downloadPdfBtn": "PDF herunterladen",
|
||||||
|
"markPaidBtn": "Als bezahlt markieren",
|
||||||
|
"paidNotePlaceholder": "Optionale Notiz (z. B. Bankreferenz, Eingangsdatum)",
|
||||||
|
"confirm": "Bestätigen",
|
||||||
|
"cancel": "Abbrechen",
|
||||||
|
"deleteBtn": "Löschen",
|
||||||
|
"deleting": "Lösche…",
|
||||||
|
"deleteHint": "Rechnung hart löschen (Test-Tool). Die Nummer bleibt vergeben.",
|
||||||
|
"confirmDeleteInvoice": "Rechnung {num} löschen? Dies ist eine harte Löschung — die Rechnungsnummer bleibt verbraucht.",
|
||||||
|
"paidOnLabel": "Bezahlt am",
|
||||||
|
"lineItemsTitle": "Positionen",
|
||||||
|
"billToSnapshotTitle": "Rechnungsempfänger"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -384,7 +384,8 @@
|
|||||||
"spendChf": "Spend (CHF)",
|
"spendChf": "Spend (CHF)",
|
||||||
"resumeRequestBadge": "Resume",
|
"resumeRequestBadge": "Resume",
|
||||||
"resumeRequestTooltip": "Reactivation request for an existing tenant. Approving will un-suspend the tenant; no provisioning runs.",
|
"resumeRequestTooltip": "Reactivation request for an existing tenant. Approving will un-suspend the tenant; no provisioning runs.",
|
||||||
"openclawTool": "OpenClaw versions"
|
"openclawTool": "OpenClaw versions",
|
||||||
|
"billingTool": "Billing →"
|
||||||
},
|
},
|
||||||
"channelUsers": {
|
"channelUsers": {
|
||||||
"title": "Authorized Users",
|
"title": "Authorized Users",
|
||||||
@@ -402,7 +403,10 @@
|
|||||||
"step1": "Open Threema on your phone.",
|
"step1": "Open Threema on your phone.",
|
||||||
"step2": "Tap the scan icon and scan this QR code to add the assistant as a contact.",
|
"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.",
|
"step3": "Then add your own Threema ID below.",
|
||||||
"qrAlt": "QR code to add {gateway} as a Threema contact"
|
"qrAlt": "QR code to add {gateway} as a Threema contact",
|
||||||
|
"bannerTitle": "Set up Threema",
|
||||||
|
"bannerBody": "Open Threema on your phone and scan our QR code to add the assistant as a contact. Then add your own Threema ID below.",
|
||||||
|
"bannerButton": "Show QR code"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"team": {
|
"team": {
|
||||||
@@ -550,5 +554,105 @@
|
|||||||
"defaultPrefix": "Default:",
|
"defaultPrefix": "Default:",
|
||||||
"saveOverride": "Save override",
|
"saveOverride": "Save override",
|
||||||
"clearOverride": "Clear override"
|
"clearOverride": "Clear override"
|
||||||
|
},
|
||||||
|
"adminBilling": {
|
||||||
|
"title": "Billing administration",
|
||||||
|
"subtitle": "Manage platform pricing, generate invoices, and review billing status across all organizations.",
|
||||||
|
"backToAdmin": "Back to Admin",
|
||||||
|
"backToBilling": "Back to Billing",
|
||||||
|
"backToInvoices": "Back to Invoices",
|
||||||
|
"totalOpenBalance": "Total open balance",
|
||||||
|
"orgsWithBalance": "Orgs with balance",
|
||||||
|
"overdueInvoices": "Overdue invoices",
|
||||||
|
"pricingTitle": "Pricing",
|
||||||
|
"pricingDesc": "Platform & skill prices, VAT rate.",
|
||||||
|
"pricingPageDesc": "Edit platform-wide pricing and per-skill daily rates.",
|
||||||
|
"generateTitle": "Generate invoice",
|
||||||
|
"generateDesc": "Compute and issue an invoice for a given org & month.",
|
||||||
|
"generatePageDesc": "Pick an org, period and locale. Preview shows the computed lines; commit issues the invoice and renders the PDF.",
|
||||||
|
"invoicesTitle": "Invoices",
|
||||||
|
"invoicesDesc": "Browse all issued invoices, mark paid, download PDFs.",
|
||||||
|
"invoicesPageDesc": "All invoices issued by the platform. Use the status filter to focus on open or overdue items.",
|
||||||
|
"balancesTitle": "Orgs with open balance",
|
||||||
|
"orgIdCol": "Zitadel org ID",
|
||||||
|
"openCountCol": "Open",
|
||||||
|
"overdueCountCol": "Overdue",
|
||||||
|
"totalOpenCol": "Total open",
|
||||||
|
"platformPricingTitle": "Platform pricing",
|
||||||
|
"monthlyFeeLabel": "Tenant monthly fee",
|
||||||
|
"setupFeeLabel": "Tenant setup fee",
|
||||||
|
"threemaMessageLabel": "Threema per message",
|
||||||
|
"vatRateLabel": "VAT rate (CH/LI)",
|
||||||
|
"save": "Save",
|
||||||
|
"saving": "Saving…",
|
||||||
|
"savedOk": "Saved",
|
||||||
|
"skillPricingTitle": "Skill pricing",
|
||||||
|
"skillPricingDesc": "Per-skill daily price. A skill enabled at any point during a UTC day counts as one billable day.",
|
||||||
|
"skillCol": "Skill",
|
||||||
|
"dailyPriceCol": "Daily price",
|
||||||
|
"actionsCol": "",
|
||||||
|
"remove": "Remove",
|
||||||
|
"noSkillsPriced": "No skills are priced yet.",
|
||||||
|
"addSkillLabel": "Add skill",
|
||||||
|
"dailyPriceLabel": "Daily price",
|
||||||
|
"add": "Add",
|
||||||
|
"confirmDeleteSkillPrice": "Remove pricing for {skill}?",
|
||||||
|
"clickToEdit": "Click to edit",
|
||||||
|
"generateFormTitle": "Generate invoice",
|
||||||
|
"noOrgsToGenerate": "No organizations with tenants found.",
|
||||||
|
"orgLabel": "Organization",
|
||||||
|
"noBillingAddrTag": "no billing address",
|
||||||
|
"noBillingAddrWarning": "This org has no billing address on file. The customer must complete /settings/billing before an invoice can be issued.",
|
||||||
|
"tenantsLabel": "tenants",
|
||||||
|
"yearLabel": "Year",
|
||||||
|
"monthLabel": "Month",
|
||||||
|
"localeLabel": "PDF language",
|
||||||
|
"localeAuto": "Auto",
|
||||||
|
"previewBtn": "Preview",
|
||||||
|
"commitBtn": "Commit & issue",
|
||||||
|
"computing": "Computing…",
|
||||||
|
"confirmGenerate": "Issue this invoice? This action allocates an invoice number and renders the PDF.",
|
||||||
|
"previewTitle": "Draft preview",
|
||||||
|
"warningsTitle": "Warnings",
|
||||||
|
"noLinesGenerated": "No billable lines for this period.",
|
||||||
|
"descCol": "Description",
|
||||||
|
"qtyCol": "Qty",
|
||||||
|
"unitPriceCol": "Unit price",
|
||||||
|
"amountCol": "Amount (CHF)",
|
||||||
|
"subtotal": "Subtotal",
|
||||||
|
"vat": "VAT",
|
||||||
|
"total": "Total",
|
||||||
|
"statusFilterLabel": "Status",
|
||||||
|
"allStatuses": "All",
|
||||||
|
"monthFilterLabel": "Period",
|
||||||
|
"clearFilter": "Clear",
|
||||||
|
"loading": "Loading…",
|
||||||
|
"noInvoicesFound": "No invoices match the current filters.",
|
||||||
|
"invoiceNumberCol": "Number",
|
||||||
|
"orgCol": "Organization",
|
||||||
|
"periodCol": "Period",
|
||||||
|
"statusCol": "Status",
|
||||||
|
"totalCol": "Total",
|
||||||
|
"dueCol": "Due",
|
||||||
|
"status_draft": "Draft",
|
||||||
|
"status_open": "Open",
|
||||||
|
"status_paid": "Paid",
|
||||||
|
"status_overdue": "Overdue",
|
||||||
|
"status_void": "Void",
|
||||||
|
"status_uncollectible": "Uncollectible",
|
||||||
|
"dueOnLabel": "Due",
|
||||||
|
"totalLabel": "Total",
|
||||||
|
"downloadPdfBtn": "Download PDF",
|
||||||
|
"markPaidBtn": "Mark as paid",
|
||||||
|
"paidNotePlaceholder": "Optional note (e.g. bank reference, deposit date)",
|
||||||
|
"confirm": "Confirm",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"deleteBtn": "Delete",
|
||||||
|
"deleting": "Deleting…",
|
||||||
|
"deleteHint": "Hard-delete this invoice (testing tool). Number is consumed.",
|
||||||
|
"confirmDeleteInvoice": "Delete invoice {num}? This is a hard delete — the invoice number stays consumed.",
|
||||||
|
"paidOnLabel": "Paid",
|
||||||
|
"lineItemsTitle": "Line items",
|
||||||
|
"billToSnapshotTitle": "Billed to"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -384,7 +384,8 @@
|
|||||||
"spendChf": "Coûts (CHF)",
|
"spendChf": "Coûts (CHF)",
|
||||||
"resumeRequestBadge": "Reprise",
|
"resumeRequestBadge": "Reprise",
|
||||||
"resumeRequestTooltip": "Demande de réactivation d'un locataire existant. L'approbation le réactivera ; aucun provisionnement ne s'exécute.",
|
"resumeRequestTooltip": "Demande de réactivation d'un locataire existant. L'approbation le réactivera ; aucun provisionnement ne s'exécute.",
|
||||||
"openclawTool": "Versions OpenClaw"
|
"openclawTool": "Versions OpenClaw",
|
||||||
|
"billingTool": "Facturation →"
|
||||||
},
|
},
|
||||||
"channelUsers": {
|
"channelUsers": {
|
||||||
"title": "Utilisateurs autorisés",
|
"title": "Utilisateurs autorisés",
|
||||||
@@ -402,7 +403,10 @@
|
|||||||
"step1": "Ouvrez Threema sur votre téléphone.",
|
"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.",
|
"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.",
|
"step3": "Puis ajoutez votre propre identifiant Threema ci-dessous.",
|
||||||
"qrAlt": "QR code pour ajouter {gateway} comme contact Threema"
|
"qrAlt": "QR code pour ajouter {gateway} comme contact Threema",
|
||||||
|
"bannerTitle": "Configurer Threema",
|
||||||
|
"bannerBody": "Ouvrez Threema sur votre téléphone et scannez notre QR code pour ajouter l'assistant comme contact. Saisissez ensuite votre propre identifiant Threema ci-dessous.",
|
||||||
|
"bannerButton": "Afficher le QR code"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"team": {
|
"team": {
|
||||||
@@ -550,5 +554,105 @@
|
|||||||
"defaultPrefix": "Défaut :",
|
"defaultPrefix": "Défaut :",
|
||||||
"saveOverride": "Enregistrer la surcharge",
|
"saveOverride": "Enregistrer la surcharge",
|
||||||
"clearOverride": "Supprimer la surcharge"
|
"clearOverride": "Supprimer la surcharge"
|
||||||
|
},
|
||||||
|
"adminBilling": {
|
||||||
|
"title": "Administration de la facturation",
|
||||||
|
"subtitle": "Gérer les tarifs de la plateforme, générer des factures et examiner le statut de facturation des organisations.",
|
||||||
|
"backToAdmin": "Retour à l'administration",
|
||||||
|
"backToBilling": "Retour à la facturation",
|
||||||
|
"backToInvoices": "Retour aux factures",
|
||||||
|
"totalOpenBalance": "Solde ouvert total",
|
||||||
|
"orgsWithBalance": "Organisations avec solde",
|
||||||
|
"overdueInvoices": "Factures en retard",
|
||||||
|
"pricingTitle": "Tarifs",
|
||||||
|
"pricingDesc": "Tarifs plateforme & skills, taux TVA.",
|
||||||
|
"pricingPageDesc": "Modifier les tarifs de la plateforme et les prix journaliers par skill.",
|
||||||
|
"generateTitle": "Générer une facture",
|
||||||
|
"generateDesc": "Calculer et émettre une facture pour une organisation et un mois.",
|
||||||
|
"generatePageDesc": "Choisir une organisation, une période et une langue. L'aperçu affiche les lignes calculées; valider émet la facture et génère le PDF.",
|
||||||
|
"invoicesTitle": "Factures",
|
||||||
|
"invoicesDesc": "Parcourir les factures, marquer comme payées, télécharger les PDF.",
|
||||||
|
"invoicesPageDesc": "Toutes les factures émises par la plateforme. Utiliser le filtre de statut pour cibler les éléments ouverts ou en retard.",
|
||||||
|
"balancesTitle": "Organisations avec solde ouvert",
|
||||||
|
"orgIdCol": "ID org Zitadel",
|
||||||
|
"openCountCol": "Ouvert",
|
||||||
|
"overdueCountCol": "En retard",
|
||||||
|
"totalOpenCol": "Total ouvert",
|
||||||
|
"platformPricingTitle": "Tarifs plateforme",
|
||||||
|
"monthlyFeeLabel": "Forfait mensuel tenant",
|
||||||
|
"setupFeeLabel": "Frais de configuration tenant",
|
||||||
|
"threemaMessageLabel": "Threema par message",
|
||||||
|
"vatRateLabel": "Taux TVA (CH/LI)",
|
||||||
|
"save": "Enregistrer",
|
||||||
|
"saving": "Enregistrement…",
|
||||||
|
"savedOk": "Enregistré",
|
||||||
|
"skillPricingTitle": "Tarifs des skills",
|
||||||
|
"skillPricingDesc": "Prix journalier par skill. Un skill activé à tout moment au cours d'une journée UTC compte comme un jour facturable.",
|
||||||
|
"skillCol": "Skill",
|
||||||
|
"dailyPriceCol": "Prix/jour",
|
||||||
|
"actionsCol": "",
|
||||||
|
"remove": "Retirer",
|
||||||
|
"noSkillsPriced": "Aucun skill n'a encore de prix.",
|
||||||
|
"addSkillLabel": "Ajouter un skill",
|
||||||
|
"dailyPriceLabel": "Prix/jour",
|
||||||
|
"add": "Ajouter",
|
||||||
|
"confirmDeleteSkillPrice": "Retirer le prix pour {skill}?",
|
||||||
|
"clickToEdit": "Cliquer pour modifier",
|
||||||
|
"generateFormTitle": "Générer une facture",
|
||||||
|
"noOrgsToGenerate": "Aucune organisation avec tenants trouvée.",
|
||||||
|
"orgLabel": "Organisation",
|
||||||
|
"noBillingAddrTag": "pas d'adresse de facturation",
|
||||||
|
"noBillingAddrWarning": "Cette organisation n'a pas d'adresse de facturation enregistrée. Le client doit compléter /settings/billing avant qu'une facture puisse être émise.",
|
||||||
|
"tenantsLabel": "tenants",
|
||||||
|
"yearLabel": "Année",
|
||||||
|
"monthLabel": "Mois",
|
||||||
|
"localeLabel": "Langue PDF",
|
||||||
|
"localeAuto": "Auto",
|
||||||
|
"previewBtn": "Aperçu",
|
||||||
|
"commitBtn": "Valider & émettre",
|
||||||
|
"computing": "Calcul…",
|
||||||
|
"confirmGenerate": "Émettre cette facture? Cette action attribue un numéro de facture et génère le PDF.",
|
||||||
|
"previewTitle": "Aperçu du brouillon",
|
||||||
|
"warningsTitle": "Avertissements",
|
||||||
|
"noLinesGenerated": "Aucune ligne facturable pour cette période.",
|
||||||
|
"descCol": "Description",
|
||||||
|
"qtyCol": "Qté",
|
||||||
|
"unitPriceCol": "Prix unitaire",
|
||||||
|
"amountCol": "Montant (CHF)",
|
||||||
|
"subtotal": "Sous-total",
|
||||||
|
"vat": "TVA",
|
||||||
|
"total": "Total",
|
||||||
|
"statusFilterLabel": "Statut",
|
||||||
|
"allStatuses": "Tous",
|
||||||
|
"monthFilterLabel": "Période",
|
||||||
|
"clearFilter": "Effacer",
|
||||||
|
"loading": "Chargement…",
|
||||||
|
"noInvoicesFound": "Aucune facture ne correspond aux filtres.",
|
||||||
|
"invoiceNumberCol": "Numéro",
|
||||||
|
"orgCol": "Organisation",
|
||||||
|
"periodCol": "Période",
|
||||||
|
"statusCol": "Statut",
|
||||||
|
"totalCol": "Total",
|
||||||
|
"dueCol": "Échéance",
|
||||||
|
"status_draft": "Brouillon",
|
||||||
|
"status_open": "Ouverte",
|
||||||
|
"status_paid": "Payée",
|
||||||
|
"status_overdue": "En retard",
|
||||||
|
"status_void": "Annulée",
|
||||||
|
"status_uncollectible": "Irrécouvrable",
|
||||||
|
"dueOnLabel": "Échéance",
|
||||||
|
"totalLabel": "Total",
|
||||||
|
"downloadPdfBtn": "Télécharger le PDF",
|
||||||
|
"markPaidBtn": "Marquer comme payée",
|
||||||
|
"paidNotePlaceholder": "Note facultative (ex. référence bancaire, date de paiement)",
|
||||||
|
"confirm": "Confirmer",
|
||||||
|
"cancel": "Annuler",
|
||||||
|
"deleteBtn": "Supprimer",
|
||||||
|
"deleting": "Suppression…",
|
||||||
|
"deleteHint": "Suppression définitive (outil de test). Le numéro reste utilisé.",
|
||||||
|
"confirmDeleteInvoice": "Supprimer la facture {num}? Suppression définitive — le numéro reste utilisé.",
|
||||||
|
"paidOnLabel": "Payée le",
|
||||||
|
"lineItemsTitle": "Lignes",
|
||||||
|
"billToSnapshotTitle": "Destinataire"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -384,7 +384,8 @@
|
|||||||
"spendChf": "Costi (CHF)",
|
"spendChf": "Costi (CHF)",
|
||||||
"resumeRequestBadge": "Ripresa",
|
"resumeRequestBadge": "Ripresa",
|
||||||
"resumeRequestTooltip": "Richiesta di riattivazione di un tenant esistente. L'approvazione lo riattiverà; non viene eseguito alcun provisioning.",
|
"resumeRequestTooltip": "Richiesta di riattivazione di un tenant esistente. L'approvazione lo riattiverà; non viene eseguito alcun provisioning.",
|
||||||
"openclawTool": "Versioni OpenClaw"
|
"openclawTool": "Versioni OpenClaw",
|
||||||
|
"billingTool": "Fatturazione →"
|
||||||
},
|
},
|
||||||
"channelUsers": {
|
"channelUsers": {
|
||||||
"title": "Utenti autorizzati",
|
"title": "Utenti autorizzati",
|
||||||
@@ -402,7 +403,10 @@
|
|||||||
"step1": "Apri Threema sul tuo telefono.",
|
"step1": "Apri Threema sul tuo telefono.",
|
||||||
"step2": "Tocca l'icona di scansione e scansiona questo QR code per aggiungere l'assistente ai contatti.",
|
"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.",
|
"step3": "Quindi aggiungi il tuo ID Threema qui sotto.",
|
||||||
"qrAlt": "QR code per aggiungere {gateway} come contatto Threema"
|
"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"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"team": {
|
"team": {
|
||||||
@@ -550,5 +554,105 @@
|
|||||||
"defaultPrefix": "Predefinito:",
|
"defaultPrefix": "Predefinito:",
|
||||||
"saveOverride": "Salva override",
|
"saveOverride": "Salva override",
|
||||||
"clearOverride": "Rimuovi override"
|
"clearOverride": "Rimuovi override"
|
||||||
|
},
|
||||||
|
"adminBilling": {
|
||||||
|
"title": "Amministrazione fatturazione",
|
||||||
|
"subtitle": "Gestire prezzi della piattaforma, generare fatture e verificare lo stato di fatturazione delle organizzazioni.",
|
||||||
|
"backToAdmin": "Torna ad amministrazione",
|
||||||
|
"backToBilling": "Torna alla fatturazione",
|
||||||
|
"backToInvoices": "Torna alle fatture",
|
||||||
|
"totalOpenBalance": "Saldo aperto totale",
|
||||||
|
"orgsWithBalance": "Organizzazioni con saldo",
|
||||||
|
"overdueInvoices": "Fatture scadute",
|
||||||
|
"pricingTitle": "Prezzi",
|
||||||
|
"pricingDesc": "Prezzi piattaforma & skill, aliquota IVA.",
|
||||||
|
"pricingPageDesc": "Modificare i prezzi della piattaforma e i prezzi giornalieri per skill.",
|
||||||
|
"generateTitle": "Genera fattura",
|
||||||
|
"generateDesc": "Calcolare ed emettere una fattura per organizzazione e mese.",
|
||||||
|
"generatePageDesc": "Scegli organizzazione, periodo e lingua. L'anteprima mostra le righe calcolate; conferma emette la fattura e genera il PDF.",
|
||||||
|
"invoicesTitle": "Fatture",
|
||||||
|
"invoicesDesc": "Sfoglia le fatture, segna come pagate, scarica i PDF.",
|
||||||
|
"invoicesPageDesc": "Tutte le fatture emesse dalla piattaforma. Usa il filtro di stato per focalizzarti su voci aperte o scadute.",
|
||||||
|
"balancesTitle": "Organizzazioni con saldo aperto",
|
||||||
|
"orgIdCol": "ID org Zitadel",
|
||||||
|
"openCountCol": "Aperte",
|
||||||
|
"overdueCountCol": "Scadute",
|
||||||
|
"totalOpenCol": "Totale aperto",
|
||||||
|
"platformPricingTitle": "Prezzi piattaforma",
|
||||||
|
"monthlyFeeLabel": "Canone mensile tenant",
|
||||||
|
"setupFeeLabel": "Spese di attivazione tenant",
|
||||||
|
"threemaMessageLabel": "Threema per messaggio",
|
||||||
|
"vatRateLabel": "Aliquota IVA (CH/LI)",
|
||||||
|
"save": "Salva",
|
||||||
|
"saving": "Salvataggio…",
|
||||||
|
"savedOk": "Salvato",
|
||||||
|
"skillPricingTitle": "Prezzi skill",
|
||||||
|
"skillPricingDesc": "Prezzo giornaliero per skill. Una skill attiva in qualsiasi momento di un giorno UTC conta come un giorno fatturabile.",
|
||||||
|
"skillCol": "Skill",
|
||||||
|
"dailyPriceCol": "Prezzo/giorno",
|
||||||
|
"actionsCol": "",
|
||||||
|
"remove": "Rimuovi",
|
||||||
|
"noSkillsPriced": "Nessuna skill ha ancora un prezzo.",
|
||||||
|
"addSkillLabel": "Aggiungi skill",
|
||||||
|
"dailyPriceLabel": "Prezzo/giorno",
|
||||||
|
"add": "Aggiungi",
|
||||||
|
"confirmDeleteSkillPrice": "Rimuovere il prezzo per {skill}?",
|
||||||
|
"clickToEdit": "Clicca per modificare",
|
||||||
|
"generateFormTitle": "Genera fattura",
|
||||||
|
"noOrgsToGenerate": "Nessuna organizzazione con tenant trovata.",
|
||||||
|
"orgLabel": "Organizzazione",
|
||||||
|
"noBillingAddrTag": "nessun indirizzo di fatturazione",
|
||||||
|
"noBillingAddrWarning": "Questa organizzazione non ha un indirizzo di fatturazione registrato. Il cliente deve completare /settings/billing prima che una fattura possa essere emessa.",
|
||||||
|
"tenantsLabel": "tenant",
|
||||||
|
"yearLabel": "Anno",
|
||||||
|
"monthLabel": "Mese",
|
||||||
|
"localeLabel": "Lingua PDF",
|
||||||
|
"localeAuto": "Auto",
|
||||||
|
"previewBtn": "Anteprima",
|
||||||
|
"commitBtn": "Conferma & emetti",
|
||||||
|
"computing": "Calcolo…",
|
||||||
|
"confirmGenerate": "Emettere questa fattura? L'operazione assegna un numero di fattura e genera il PDF.",
|
||||||
|
"previewTitle": "Anteprima bozza",
|
||||||
|
"warningsTitle": "Avvisi",
|
||||||
|
"noLinesGenerated": "Nessuna riga fatturabile per questo periodo.",
|
||||||
|
"descCol": "Descrizione",
|
||||||
|
"qtyCol": "Qtà",
|
||||||
|
"unitPriceCol": "Prezzo unitario",
|
||||||
|
"amountCol": "Importo (CHF)",
|
||||||
|
"subtotal": "Subtotale",
|
||||||
|
"vat": "IVA",
|
||||||
|
"total": "Totale",
|
||||||
|
"statusFilterLabel": "Stato",
|
||||||
|
"allStatuses": "Tutti",
|
||||||
|
"monthFilterLabel": "Periodo",
|
||||||
|
"clearFilter": "Pulisci",
|
||||||
|
"loading": "Caricamento…",
|
||||||
|
"noInvoicesFound": "Nessuna fattura corrisponde ai filtri.",
|
||||||
|
"invoiceNumberCol": "Numero",
|
||||||
|
"orgCol": "Organizzazione",
|
||||||
|
"periodCol": "Periodo",
|
||||||
|
"statusCol": "Stato",
|
||||||
|
"totalCol": "Totale",
|
||||||
|
"dueCol": "Scadenza",
|
||||||
|
"status_draft": "Bozza",
|
||||||
|
"status_open": "Aperta",
|
||||||
|
"status_paid": "Pagata",
|
||||||
|
"status_overdue": "Scaduta",
|
||||||
|
"status_void": "Annullata",
|
||||||
|
"status_uncollectible": "Inesigibile",
|
||||||
|
"dueOnLabel": "Scadenza",
|
||||||
|
"totalLabel": "Totale",
|
||||||
|
"downloadPdfBtn": "Scarica PDF",
|
||||||
|
"markPaidBtn": "Segna come pagata",
|
||||||
|
"paidNotePlaceholder": "Nota opzionale (es. riferimento bancario, data di pagamento)",
|
||||||
|
"confirm": "Conferma",
|
||||||
|
"cancel": "Annulla",
|
||||||
|
"deleteBtn": "Elimina",
|
||||||
|
"deleting": "Eliminazione…",
|
||||||
|
"deleteHint": "Eliminazione definitiva (strumento di test). Il numero rimane consumato.",
|
||||||
|
"confirmDeleteInvoice": "Eliminare la fattura {num}? Eliminazione definitiva — il numero rimane consumato.",
|
||||||
|
"paidOnLabel": "Pagata il",
|
||||||
|
"lineItemsTitle": "Righe",
|
||||||
|
"billToSnapshotTitle": "Destinatario"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,5 +40,10 @@ export default async function middleware(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
matcher: ["/((?!_next|favicon.ico|api).*)"],
|
// Excludes _next/* internal routes, the favicon, api routes, AND any
|
||||||
|
// path containing a dot (covers all static files served from public/,
|
||||||
|
// e.g. /threema/qr_code_AIAGENT.png). Without the dot exclusion, the
|
||||||
|
// i18n middleware prepends the locale ("/en/threema/qr_code_AIAGENT.png")
|
||||||
|
// and the file is not found.
|
||||||
|
matcher: ["/((?!_next|favicon.ico|api|.*\\..*).*)"],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -412,3 +412,238 @@ export interface SupportTicketDetail {
|
|||||||
ticket: SupportTicket;
|
ticket: SupportTicket;
|
||||||
comments: SupportTicketComment[];
|
comments: SupportTicketComment[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Billing — Phase 1: pricing, lifecycle, and events
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
//
|
||||||
|
// All money values are numbers (CHF). The DB stores NUMERIC and the
|
||||||
|
// helpers coerce to Number on read. JS floats are exact for integers
|
||||||
|
// up to 2^53; at this domain (CHF amounts to 2 decimals, unit prices
|
||||||
|
// to 5 decimals) precision is fine. The Phase 2 billing computation
|
||||||
|
// will still do arithmetic carefully (sum in cents, round at the
|
||||||
|
// end) to avoid 0.1 + 0.2 surprises.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single-row platform pricing config. Editable via the admin
|
||||||
|
* pricing page (Phase 2). The `vatRateChli` field is the rate
|
||||||
|
* applied to invoices whose billing address resolves to CH/LI;
|
||||||
|
* foreign customers' rates are decided per-invoice from address +
|
||||||
|
* VAT number, not from this config.
|
||||||
|
*/
|
||||||
|
export interface PlatformPricing {
|
||||||
|
tenantMonthlyFeeChf: number;
|
||||||
|
tenantSetupFeeChf: number;
|
||||||
|
threemaMessageChf: number;
|
||||||
|
vatRateChli: number;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-package daily price. Phase 2's admin UI restricts setting
|
||||||
|
* these to skill-category packages, but the schema accepts any
|
||||||
|
* package id. A row's existence is what activates billing for that
|
||||||
|
* package; deleting the row makes it free without affecting the
|
||||||
|
* append-only event log.
|
||||||
|
*/
|
||||||
|
export interface SkillPricing {
|
||||||
|
skillId: string;
|
||||||
|
dailyPriceChf: number;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tenant lifecycle bookends mirrored from K8s into Postgres so
|
||||||
|
* deleted tenants still have a billing record for their final
|
||||||
|
* invoice. `createdAt` matches PiecedTenant.metadata.creationTimestamp
|
||||||
|
* at the moment of approval; `deletedAt` is stamped when the admin
|
||||||
|
* delete endpoint runs.
|
||||||
|
*/
|
||||||
|
export interface TenantBillingLifecycle {
|
||||||
|
tenantName: string;
|
||||||
|
zitadelOrgId: string;
|
||||||
|
createdAt: string;
|
||||||
|
deletedAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Append-only enable/disable event for a package on a tenant.
|
||||||
|
* Phase 2's billing computation reads the event stream within the
|
||||||
|
* billing window and collapses it to a set of UTC days during
|
||||||
|
* which the package was active.
|
||||||
|
*/
|
||||||
|
export interface TenantSkillEvent {
|
||||||
|
id: string;
|
||||||
|
tenantName: string;
|
||||||
|
zitadelOrgId: string;
|
||||||
|
skillId: string;
|
||||||
|
eventKind: "enabled" | "disabled";
|
||||||
|
occurredAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Append-only suspend/resume event. Recorded by the portal at
|
||||||
|
* command time (when PATCH spec.suspend lands), not at operator
|
||||||
|
* reconcile time. The few-second delta is irrelevant for monthly
|
||||||
|
* billing.
|
||||||
|
*/
|
||||||
|
export interface TenantSuspensionEvent {
|
||||||
|
id: string;
|
||||||
|
tenantName: string;
|
||||||
|
zitadelOrgId: string;
|
||||||
|
eventKind: "suspended" | "resumed";
|
||||||
|
occurredAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-org billing posture and Stripe linkage. Distinct from
|
||||||
|
* OrgBilling (which is the customer-editable address/VAT block):
|
||||||
|
* this one is admin-controlled.
|
||||||
|
*
|
||||||
|
* `payByInvoice` flips the onboarding gate (Phase 4): when true,
|
||||||
|
* tenant requests for this org are approvable without a card on
|
||||||
|
* file. When false, the customer must have a validated Stripe
|
||||||
|
* payment method before admin approval is allowed.
|
||||||
|
*
|
||||||
|
* `stripeCustomerId` is populated by Phase 4's onboarding flow.
|
||||||
|
* `autoInvoiceEnabled` / `autoRemindersEnabled` give admin per-org
|
||||||
|
* kill switches for the Phase 6 cron without disabling the cron
|
||||||
|
* globally.
|
||||||
|
*/
|
||||||
|
export interface OrgBillingConfig {
|
||||||
|
zitadelOrgId: string;
|
||||||
|
payByInvoice: boolean;
|
||||||
|
stripeCustomerId: string | null;
|
||||||
|
autoInvoiceEnabled: boolean;
|
||||||
|
autoRemindersEnabled: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Billing — Phase 2: invoices and lines
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export type InvoiceStatus =
|
||||||
|
| "draft"
|
||||||
|
| "open"
|
||||||
|
| "paid"
|
||||||
|
| "overdue"
|
||||||
|
| "void"
|
||||||
|
| "uncollectible";
|
||||||
|
|
||||||
|
export type InvoicePaymentMethod = "invoice" | "card";
|
||||||
|
|
||||||
|
export type InvoiceLineKind =
|
||||||
|
| "tenant_monthly"
|
||||||
|
| "tenant_setup"
|
||||||
|
| "ai_usage"
|
||||||
|
| "threema_messages"
|
||||||
|
| "skill_usage"
|
||||||
|
| "adjustment";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Snapshot of the customer's billing details captured at invoice
|
||||||
|
* issue time. Subsequent edits to org_billing do not mutate
|
||||||
|
* historical invoices.
|
||||||
|
*
|
||||||
|
* Field names mirror OrgBilling (minus the timestamps) so the
|
||||||
|
* snapshot is a straightforward copy at issue time.
|
||||||
|
*/
|
||||||
|
export interface InvoiceBillingSnapshot {
|
||||||
|
companyName: string;
|
||||||
|
streetAddress: string;
|
||||||
|
postalCode: string;
|
||||||
|
city: string;
|
||||||
|
country: string;
|
||||||
|
vatNumber: string | null;
|
||||||
|
billingEmail: string;
|
||||||
|
notes: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One line on an invoice. The `metadata` shape varies by `kind`:
|
||||||
|
* tenant_monthly: { proration_days, days_in_month, billable_days, suspended_days }
|
||||||
|
* tenant_setup: {}
|
||||||
|
* ai_usage: { litellm_key_alias, spend_chf, requests }
|
||||||
|
* threema_messages: { in_count, out_count, total_count }
|
||||||
|
* skill_usage: { skill_id, billable_days, event_count }
|
||||||
|
* adjustment: { reason, admin_user_id }
|
||||||
|
*/
|
||||||
|
export interface InvoiceLine {
|
||||||
|
id: string;
|
||||||
|
invoiceId: string;
|
||||||
|
tenantName: string | null;
|
||||||
|
kind: InvoiceLineKind;
|
||||||
|
description: string;
|
||||||
|
quantity: number;
|
||||||
|
unitLabel: string | null;
|
||||||
|
unitPriceChf: number;
|
||||||
|
amountChf: number;
|
||||||
|
metadata: Record<string, unknown> | null;
|
||||||
|
displayOrder: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Immutable invoice record. The PDF blob is fetched separately via
|
||||||
|
* the download endpoint to avoid loading bytea on every list query.
|
||||||
|
*/
|
||||||
|
export interface Invoice {
|
||||||
|
id: string;
|
||||||
|
invoiceNumber: string;
|
||||||
|
zitadelOrgId: string;
|
||||||
|
periodStart: string; // ISO date (YYYY-MM-DD)
|
||||||
|
periodEnd: string;
|
||||||
|
issuedAt: string;
|
||||||
|
dueAt: string;
|
||||||
|
subtotalChf: number;
|
||||||
|
vatRate: number;
|
||||||
|
vatAmountChf: number;
|
||||||
|
totalChf: number;
|
||||||
|
status: InvoiceStatus;
|
||||||
|
locale: string;
|
||||||
|
paymentMethod: InvoicePaymentMethod;
|
||||||
|
billingSnapshot: InvoiceBillingSnapshot;
|
||||||
|
stripePaymentIntentId: string | null;
|
||||||
|
pdfFilename: string | null;
|
||||||
|
hasPdf: boolean; // computed: pdf_data IS NOT NULL
|
||||||
|
adminNotes: string | null;
|
||||||
|
paidAt: string | null;
|
||||||
|
paidBy: string | null;
|
||||||
|
paidMethodDetail: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Invoice with its line items, used by detail views. */
|
||||||
|
export interface InvoiceDetail {
|
||||||
|
invoice: Invoice;
|
||||||
|
lines: InvoiceLine[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In-memory draft produced by the computation pipeline before the
|
||||||
|
* invoice is allocated a number and persisted. Used by both the
|
||||||
|
* preview endpoint (return without persisting) and the commit
|
||||||
|
* endpoint (compute → persist atomically).
|
||||||
|
*/
|
||||||
|
export interface InvoiceDraft {
|
||||||
|
zitadelOrgId: string;
|
||||||
|
periodStart: string;
|
||||||
|
periodEnd: string;
|
||||||
|
dueAt: string;
|
||||||
|
locale: string;
|
||||||
|
paymentMethod: InvoicePaymentMethod;
|
||||||
|
billingSnapshot: InvoiceBillingSnapshot;
|
||||||
|
lines: Omit<InvoiceLine, "id" | "invoiceId">[];
|
||||||
|
subtotalChf: number;
|
||||||
|
vatRate: number;
|
||||||
|
vatAmountChf: number;
|
||||||
|
totalChf: number;
|
||||||
|
/**
|
||||||
|
* Non-blocking warnings the compute pipeline surfaced — e.g.
|
||||||
|
* "tenant X has no LiteLLM team, AI usage skipped". Rendered in
|
||||||
|
* the admin UI to help the operator decide whether to commit.
|
||||||
|
*/
|
||||||
|
warnings: string[];
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user