Compare commits
44 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9243beddd3 | |||
| a6c3c42ec9 | |||
| ee6bb89fb6 | |||
| ad4f614130 | |||
| 8e7691d38a | |||
| 9939f75c03 | |||
| e69b68b73c | |||
| 41c1553b1f | |||
| 38f4c3243e | |||
| ed915ec539 | |||
| 667617296b | |||
| 1c61111da3 | |||
| 6fed5b083b | |||
| 4f868d751e | |||
| e15a668f8e | |||
| 9cd9879a18 | |||
| 323786672f | |||
| a1769eeb00 | |||
| 002867850d | |||
| eea027b3b0 | |||
| 522246e386 | |||
| b3131f7710 | |||
| fadfdd3435 | |||
| 427c7c6204 | |||
| 6a8ad7b4be | |||
| 875ade4351 | |||
| 2a0bb10531 | |||
| 262250564a | |||
| a680d6de9f | |||
| 4a5ae0bb8b | |||
| c21b48c704 | |||
| cf190e5ac5 | |||
| a3b080f542 | |||
| 229bfea263 | |||
| 49b085e59e | |||
| cd15b391ac | |||
| 11d7dbb06e | |||
| d41f0b6ec9 | |||
| 03f8dd9afe | |||
| d4fcc33bc1 | |||
| cdc2210eaf | |||
| 6bf9caa53a | |||
| c8ed27157f | |||
| 6baca1a459 |
293
README.md
293
README.md
@@ -1,273 +1,54 @@
|
|||||||
# PieCed Portal — Billing Phase 1 (drop-in replacement)
|
# PieCed Portal — Billing Phase 1 patch (suspend-via-admin fix)
|
||||||
|
|
||||||
Schema + event tracking. No UI yet (that lands in Phase 2).
|
Single-file fix on top of the Phase 1 v2 drop.
|
||||||
This zip mirrors the `pieced-portal/` repo root — extract over your
|
|
||||||
existing source tree to apply.
|
|
||||||
|
|
||||||
**v2 fix:** stripped stray backticks from SQL comments that were
|
## What it fixes
|
||||||
closing the `MIGRATION_SQL` template literal early. If you got
|
|
||||||
"Expected a semicolon" at db.ts:335 with v1, this build is the fix.
|
|
||||||
|
|
||||||
---
|
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`.
|
||||||
|
|
||||||
## Files in this drop
|
This patch adds the same `recordSuspensionEvent` hook to the
|
||||||
|
admin route. No other code paths affected; no schema changes.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
```
|
```
|
||||||
src/lib/db.ts MODIFIED
|
src/app/api/admin/tenants/[name]/suspend/route.ts MODIFIED
|
||||||
src/types/index.ts MODIFIED
|
|
||||||
src/app/api/admin/requests/[id]/approve/route.ts MODIFIED
|
|
||||||
src/app/api/tenants/[name]/route.ts MODIFIED
|
|
||||||
src/app/api/tenants/[name]/suspend/route.ts MODIFIED
|
|
||||||
src/app/api/admin/tenants/[name]/delete/route.ts MODIFIED
|
|
||||||
src/app/api/admin/billing/backfill/route.ts NEW
|
|
||||||
```
|
```
|
||||||
|
|
||||||
No `package.json` changes — Phase 1 uses only deps already present.
|
|
||||||
|
|
||||||
### What changed
|
|
||||||
|
|
||||||
`src/lib/db.ts`
|
|
||||||
- Extended `MIGRATION_SQL` with 11 new tables (idempotent — uses
|
|
||||||
`CREATE TABLE IF NOT EXISTS`)
|
|
||||||
- Added a new "Billing — Phase 1" section at the bottom with ~25
|
|
||||||
helper functions
|
|
||||||
|
|
||||||
`src/types/index.ts`
|
|
||||||
- 6 new interfaces appended at the bottom
|
|
||||||
|
|
||||||
`src/app/api/admin/requests/[id]/approve/route.ts`
|
|
||||||
- Imports `recordTenantCreated`, `recordSkillEvents`,
|
|
||||||
`recordSuspensionEvent` from `@/lib/db`
|
|
||||||
- Resume path: records a `resumed` suspension event after
|
|
||||||
`patchTenantSpec({suspend: false})`
|
|
||||||
- Provision path: records `recordTenantCreated` + initial
|
|
||||||
`enabled` events after `createTenant`
|
|
||||||
|
|
||||||
`src/app/api/tenants/[name]/route.ts`
|
|
||||||
- Imports `recordSkillEvents`
|
|
||||||
- After `patchTenantSpec` succeeds and the patch touched
|
|
||||||
`packages`, computes the diff (added/removed) and writes events.
|
|
||||||
Diff is computed against the patched CR (the returned state)
|
|
||||||
so events match what K8s committed.
|
|
||||||
|
|
||||||
`src/app/api/tenants/[name]/suspend/route.ts`
|
|
||||||
- Imports `recordSuspensionEvent`
|
|
||||||
- Records `suspended` or `resumed` after the patch succeeds
|
|
||||||
|
|
||||||
`src/app/api/admin/tenants/[name]/delete/route.ts`
|
|
||||||
- Imports `recordTenantDeleted`
|
|
||||||
- Stamps `deleted_at` on the lifecycle row after `deleteTenant`
|
|
||||||
|
|
||||||
`src/app/api/admin/billing/backfill/route.ts` (new)
|
|
||||||
- `POST /api/admin/billing/backfill` — platform-only, idempotent
|
|
||||||
- Reads every live PiecedTenant CR, mirrors creationTimestamp,
|
|
||||||
current `spec.packages`, and `status.suspendedAt` into the new
|
|
||||||
tables. Run once after deploy to bootstrap historical data.
|
|
||||||
|
|
||||||
### Tables added (Postgres, all idempotent)
|
|
||||||
|
|
||||||
```
|
|
||||||
platform_pricing single-row pricing config
|
|
||||||
skill_pricing per-package daily price (optional)
|
|
||||||
tenant_billing_lifecycle per-tenant created_at + deleted_at
|
|
||||||
tenant_skill_events append-only enable/disable log
|
|
||||||
tenant_suspension_events append-only suspend/resume log
|
|
||||||
org_billing_config per-org billing posture (pay-by-invoice,
|
|
||||||
stripe id, auto-cron toggles)
|
|
||||||
org_payment_methods Stripe payment methods (Phase 4)
|
|
||||||
invoice_number_counters gapless per-year counter
|
|
||||||
invoices immutable issued invoices (Phase 2)
|
|
||||||
invoice_lines invoice line items (Phase 2)
|
|
||||||
invoice_reminders sent reminders + their PDFs (Phase 6)
|
|
||||||
```
|
|
||||||
|
|
||||||
The invoice/lines/reminders tables ship now so Phase 2 doesn't need
|
|
||||||
a second migration, but no code writes to them until Phase 2.
|
|
||||||
|
|
||||||
### Design properties
|
|
||||||
|
|
||||||
* Every billing-tracking call is wrapped in `try/catch`. A logging
|
|
||||||
failure never blocks the K8s operation.
|
|
||||||
* PATCH-diff is computed against the *returned* CR state, not the
|
|
||||||
pre-patch state, so events match what K8s actually committed.
|
|
||||||
* Event tables are append-only. Historical billing can be
|
|
||||||
recomputed reproducibly.
|
|
||||||
* `tenant_billing_lifecycle` mirrors created_at + deleted_at so
|
|
||||||
deleted tenants still have a final-invoice anchor.
|
|
||||||
* All money is `NUMERIC`: 10,2 for CHF amounts, 10,5 for per-unit
|
|
||||||
prices.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Deploy
|
## Deploy
|
||||||
|
|
||||||
1. Extract this zip over your `pieced-portal/` source tree
|
Extract over your `pieced-portal/` tree, rebuild, redeploy as
|
||||||
2. Build & push:
|
usual. After the new image is running, verify:
|
||||||
```
|
|
||||||
./buildanddeploy.sh # or your usual flow
|
|
||||||
```
|
|
||||||
3. Bump the image tag in `gitops/apps/portal/deployment.yaml`,
|
|
||||||
commit, push. ArgoCD picks it up.
|
|
||||||
4. On pod boot, the next DB query auto-runs `MIGRATION_SQL` (your
|
|
||||||
existing `ensureSchema` pattern). No manual `psql` needed.
|
|
||||||
|
|
||||||
---
|
1. Suspend any test tenant from the `/admin` panel.
|
||||||
|
2. Check the events table:
|
||||||
|
|
||||||
## Testing (in order — don't skip steps)
|
```bash
|
||||||
|
kubectl -n pieced-system exec -it portal-db-1 -- psql -U postgres -d portal -c \
|
||||||
### Step 1 — Migration ran
|
"SELECT * FROM tenant_suspension_events ORDER BY id DESC LIMIT 5;"
|
||||||
|
|
||||||
After the new pod is `Ready`, exec into the portal DB and verify
|
|
||||||
all 11 new tables exist:
|
|
||||||
|
|
||||||
```
|
|
||||||
kubectl -n portal exec -it portal-db-1 -- \
|
|
||||||
psql -U portal -d portal -c "\dt"
|
|
||||||
```
|
|
||||||
|
|
||||||
You should see the new tables alongside the existing ones.
|
|
||||||
|
|
||||||
Sanity-check the single-row pricing config seed:
|
|
||||||
|
|
||||||
```
|
|
||||||
kubectl -n portal exec -it portal-db-1 -- \
|
|
||||||
psql -U portal -d portal -c "SELECT * FROM platform_pricing;"
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: one row, all zeros, vat_rate_chli=8.10.
|
|
||||||
|
|
||||||
### Step 2 — Backfill existing tenants
|
|
||||||
|
|
||||||
Run the backfill once. From a logged-in admin browser tab DevTools
|
|
||||||
console:
|
|
||||||
|
|
||||||
```js
|
|
||||||
await fetch('/api/admin/billing/backfill', { method: 'POST' })
|
|
||||||
.then(r => r.json())
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected response (numbers will vary):
|
|
||||||
```
|
|
||||||
{
|
|
||||||
"message": "Backfill complete.",
|
|
||||||
"tenantsExamined": 4,
|
|
||||||
"lifecycleInserted": 4,
|
|
||||||
"eventsInserted": 12,
|
|
||||||
"suspensionEventsInserted": 0
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Run it a SECOND time — all three "Inserted" counts should be 0
|
|
||||||
(idempotency check).
|
|
||||||
|
|
||||||
### Step 3 — Verify backfill data
|
|
||||||
|
|
||||||
```
|
|
||||||
kubectl -n portal exec -it portal-db-1 -- psql -U portal -d portal
|
|
||||||
```
|
|
||||||
|
|
||||||
```sql
|
|
||||||
SELECT tenant_name, zitadel_org_id, created_at, deleted_at
|
|
||||||
FROM tenant_billing_lifecycle ORDER BY created_at;
|
|
||||||
|
|
||||||
SELECT tenant_name, skill_id, event_kind, occurred_at
|
|
||||||
FROM tenant_skill_events ORDER BY tenant_name, occurred_at;
|
|
||||||
```
|
|
||||||
|
|
||||||
Cross-check against the live CR:
|
|
||||||
```
|
|
||||||
kubectl get piecedtenants -o jsonpath='{range .items[*]}{.metadata.name}{": "}{.spec.packages}{"\n"}{end}'
|
|
||||||
```
|
|
||||||
|
|
||||||
Every package currently in `spec.packages` should have a matching
|
|
||||||
`enabled` event row.
|
|
||||||
|
|
||||||
### Step 4 — Live skill toggle
|
|
||||||
|
|
||||||
From the customer-facing tenant detail page, enable a package not
|
|
||||||
previously present (e.g. `searxng-local-search`):
|
|
||||||
|
|
||||||
```sql
|
|
||||||
SELECT * FROM tenant_skill_events
|
|
||||||
WHERE tenant_name = 'your-test-tenant'
|
|
||||||
ORDER BY id DESC LIMIT 3;
|
|
||||||
```
|
|
||||||
|
|
||||||
Expect a fresh `enabled` row. Disable the package → expect a
|
|
||||||
`disabled` row on top.
|
|
||||||
|
|
||||||
### Step 5 — Live suspend toggle
|
|
||||||
|
|
||||||
Cancel a test tenant from the customer-side button:
|
|
||||||
|
|
||||||
```sql
|
|
||||||
SELECT * FROM tenant_suspension_events
|
|
||||||
WHERE tenant_name = 'your-test-tenant'
|
|
||||||
ORDER BY id DESC LIMIT 3;
|
|
||||||
```
|
|
||||||
|
|
||||||
Expect a `suspended` row. Resume via the admin approval flow →
|
|
||||||
expect a `resumed` row.
|
|
||||||
|
|
||||||
### Step 6 — Live delete
|
|
||||||
|
|
||||||
Delete a test tenant from the admin panel:
|
|
||||||
|
|
||||||
```sql
|
|
||||||
SELECT tenant_name, created_at, deleted_at
|
|
||||||
FROM tenant_billing_lifecycle
|
|
||||||
WHERE tenant_name = 'your-deleted-tenant';
|
|
||||||
```
|
|
||||||
|
|
||||||
`deleted_at` should be stamped with roughly "now".
|
|
||||||
|
|
||||||
### Step 7 — Pricing rows survive (optional)
|
|
||||||
|
|
||||||
Direct-INSERT a price into `platform_pricing` and `skill_pricing`,
|
|
||||||
restart the portal pod, confirm rows survive:
|
|
||||||
|
|
||||||
```sql
|
|
||||||
UPDATE platform_pricing
|
|
||||||
SET tenant_monthly_fee_chf = 49.00,
|
|
||||||
tenant_setup_fee_chf = 99.00,
|
|
||||||
threema_message_chf = 0.005
|
|
||||||
WHERE id = 1;
|
|
||||||
|
|
||||||
INSERT INTO skill_pricing (skill_id, daily_price_chf)
|
|
||||||
VALUES ('searxng-local-search', 0.10)
|
|
||||||
ON CONFLICT (skill_id) DO UPDATE
|
|
||||||
SET daily_price_chf = EXCLUDED.daily_price_chf;
|
|
||||||
```
|
|
||||||
|
|
||||||
No application behaviour changes from these — they're inert until
|
|
||||||
Phase 2 starts computing invoices.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Rollback
|
|
||||||
|
|
||||||
The migration is additive — no existing columns/tables touched.
|
|
||||||
To roll back:
|
|
||||||
|
|
||||||
1. Re-deploy the previous portal image (revert the tag in gitops)
|
|
||||||
2. New tables remain in the DB but are unreferenced. Leave them
|
|
||||||
in place — Phase 2 will use them again. Or drop them:
|
|
||||||
```sql
|
|
||||||
DROP TABLE IF EXISTS invoice_reminders, invoice_lines, invoices,
|
|
||||||
invoice_number_counters, org_payment_methods, org_billing_config,
|
|
||||||
tenant_suspension_events, tenant_skill_events,
|
|
||||||
tenant_billing_lifecycle, skill_pricing, platform_pricing CASCADE;
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
Expect a fresh `suspended` row for the tenant you just toggled.
|
||||||
|
|
||||||
## What's NOT in this phase (by design)
|
3. Resume → expect a `resumed` row.
|
||||||
|
|
||||||
* No customer-facing /billing page
|
## Why I missed this
|
||||||
* No admin pricing UI
|
|
||||||
* No invoice generation
|
|
||||||
* No PDF rendering
|
|
||||||
* No Stripe wiring
|
|
||||||
* No reminders or cron
|
|
||||||
|
|
||||||
These are Phases 2-6.
|
Both routes share the same shape (PATCH/POST that sets
|
||||||
|
`spec.suspend`), but they differ on:
|
||||||
|
|
||||||
|
- URL path (`/api/admin/tenants/...` vs `/api/tenants/...`)
|
||||||
|
- Method (POST vs PATCH)
|
||||||
|
- Authorization (platform-only vs owner+platform)
|
||||||
|
- Caller (admin panel vs customer cancel button)
|
||||||
|
|
||||||
|
When I grepped for the suspend hook target I matched on the
|
||||||
|
customer endpoint and didn't audit cross-cutting admin
|
||||||
|
duplicates. I've since checked every site that calls
|
||||||
|
`patchTenantSpec`, `createTenant`, or `deleteTenant` — this was
|
||||||
|
the only missed billing-relevant one. Other `patchTenantSpec`
|
||||||
|
sites are confirmed non-billing (openClawImage, channelUsers).
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
587
package-lock.json
generated
587
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",
|
||||||
@@ -18,6 +19,7 @@
|
|||||||
"pg": "^8.20.0",
|
"pg": "^8.20.0",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
|
"stripe": "^22.1.1",
|
||||||
"zod": "^3.24.0"
|
"zod": "^3.24.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -73,6 +75,15 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@babel/runtime": {
|
||||||
|
"version": "7.29.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz",
|
||||||
|
"integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.9.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@emnapi/core": {
|
"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 +1100,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 +1488,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 +2829,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 +3247,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 +3300,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 +3420,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 +3449,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 +3650,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 +3690,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 +4313,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 +4335,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 +4397,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 +4467,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 +4796,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 +4869,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 +5264,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 +5357,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 +5385,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 +5791,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 +5837,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 +5864,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 +6253,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 +6275,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 +6483,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 +6502,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 +6643,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 +6696,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 +6774,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 +6801,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 +6856,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 +6902,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 +6955,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 +7022,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 +7386,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",
|
||||||
@@ -7037,6 +7531,23 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/stripe": {
|
||||||
|
"version": "22.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/stripe/-/stripe-22.1.1.tgz",
|
||||||
|
"integrity": "sha512-cmodIYP27tBkJ8G7DuGgWw0PFuemlFZbuF3Wwr1TrjFjUa3T7NIgCe6TVwX8BO2ynu+xtTuDGfHafNDCPt9lXA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/node": ">=18"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/node": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/styled-jsx": {
|
"node_modules/styled-jsx": {
|
||||||
"version": "5.1.6",
|
"version": "5.1.6",
|
||||||
"resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz",
|
"resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz",
|
||||||
@@ -7086,6 +7597,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 +7668,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 +7903,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 +7995,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 +8195,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",
|
||||||
@@ -20,6 +21,7 @@
|
|||||||
"pg": "^8.20.0",
|
"pg": "^8.20.0",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
|
"stripe": "^22.1.1",
|
||||||
"zod": "^3.24.0"
|
"zod": "^3.24.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
59
src/app/[locale]/admin/billing/invoice-drafts/[id]/page.tsx
Normal file
59
src/app/[locale]/admin/billing/invoice-drafts/[id]/page.tsx
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { notFound, redirect } from "next/navigation";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import { getSessionUser } from "@/lib/session";
|
||||||
|
import { getInvoiceDraftById, getOrgBilling } from "@/lib/db";
|
||||||
|
import { BackLink } from "@/components/ui/back-link";
|
||||||
|
import { CustomInvoiceEditor } from "@/components/admin/billing/custom-invoice-editor";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* /admin/billing/invoice-drafts/[id] — full editor for an
|
||||||
|
* in-progress custom invoice.
|
||||||
|
*
|
||||||
|
* Phase 8. Server-loads the draft + the org's billing snapshot
|
||||||
|
* (used to display the bill-to block preview), then hands off to
|
||||||
|
* the client editor for the interactive line-management UI.
|
||||||
|
*
|
||||||
|
* The snapshot is loaded read-only for display. The actual VAT
|
||||||
|
* computation happens server-side at issue time via
|
||||||
|
* computeCustomInvoiceTotals, which re-reads the same snapshot.
|
||||||
|
* That two-time read is intentional: the editor's preview math
|
||||||
|
* is a hint, the issue-time read is authoritative — if the
|
||||||
|
* customer updates their billing address between Draft and Issue,
|
||||||
|
* the invoice reflects the new address.
|
||||||
|
*/
|
||||||
|
export default async function InvoiceDraftEditorPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
}) {
|
||||||
|
const user = await getSessionUser();
|
||||||
|
if (!user) redirect("/login");
|
||||||
|
if (!user.isPlatform) redirect("/dashboard");
|
||||||
|
const t = await getTranslations("adminBilling");
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
const draft = await getInvoiceDraftById(id);
|
||||||
|
if (!draft) notFound();
|
||||||
|
const orgBilling = await getOrgBilling(draft.zitadelOrgId).catch(() => null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="max-w-5xl mx-auto px-6 py-8">
|
||||||
|
<BackLink
|
||||||
|
href="/admin/billing/invoice-drafts"
|
||||||
|
label={t("backToDrafts")}
|
||||||
|
/>
|
||||||
|
<div className="mb-6">
|
||||||
|
<h1 className="font-display text-2xl font-semibold accent-rule">
|
||||||
|
{t("editorPageTitle")}
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-text-secondary mt-3">
|
||||||
|
{orgBilling?.companyName ?? draft.zitadelOrgId}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<CustomInvoiceEditor
|
||||||
|
draft={draft}
|
||||||
|
orgBilling={orgBilling}
|
||||||
|
/>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
72
src/app/[locale]/admin/billing/invoice-drafts/page.tsx
Normal file
72
src/app/[locale]/admin/billing/invoice-drafts/page.tsx
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import { getSessionUser } from "@/lib/session";
|
||||||
|
import { getOrgBilling, listAllInvoiceDrafts } from "@/lib/db";
|
||||||
|
import { listTenants } from "@/lib/k8s";
|
||||||
|
import { BackLink } from "@/components/ui/back-link";
|
||||||
|
import { DraftList } from "@/components/admin/billing/draft-list";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* /admin/billing/invoice-drafts — list of all open custom-invoice
|
||||||
|
* drafts across orgs.
|
||||||
|
*
|
||||||
|
* Phase 8. Each draft is a JSONB blob the admin is composing into
|
||||||
|
* an invoice; visible only to platform admins. From here the admin
|
||||||
|
* can resume editing or discard.
|
||||||
|
*
|
||||||
|
* Building an org-name map by reading tenant labels (for the set of
|
||||||
|
* known orgs) + getOrgBilling per org (for the actual company name)
|
||||||
|
* so the table can show "Customer X" instead of a raw ZITADEL org id.
|
||||||
|
*/
|
||||||
|
export default async function AdminInvoiceDraftsPage() {
|
||||||
|
const user = await getSessionUser();
|
||||||
|
if (!user) redirect("/login");
|
||||||
|
if (!user.isPlatform) redirect("/dashboard");
|
||||||
|
const t = await getTranslations("adminBilling");
|
||||||
|
|
||||||
|
const [drafts, tenants] = await Promise.all([
|
||||||
|
listAllInvoiceDrafts(),
|
||||||
|
listTenants().catch(() => []),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Build the set of distinct ZITADEL org ids from tenant labels,
|
||||||
|
// PLUS the set referenced by any current draft. Drafts may target
|
||||||
|
// orgs that don't have tenants yet (rare but possible), so we
|
||||||
|
// union both sources before fetching billing rows.
|
||||||
|
const orgIds = new Set<string>();
|
||||||
|
for (const tnt of tenants) {
|
||||||
|
const oid = tnt.metadata.labels?.["pieced.ch/zitadel-org-id"];
|
||||||
|
if (oid) orgIds.add(oid);
|
||||||
|
}
|
||||||
|
for (const d of drafts) {
|
||||||
|
orgIds.add(d.zitadelOrgId);
|
||||||
|
}
|
||||||
|
// Look up billing in parallel — same pattern as
|
||||||
|
// /api/admin/billing/orgs uses. Failure for any single org is
|
||||||
|
// non-fatal (falls back to the raw id in the table).
|
||||||
|
const orgNamePairs = await Promise.all(
|
||||||
|
Array.from(orgIds).map(async (oid) => {
|
||||||
|
const billing = await getOrgBilling(oid).catch(() => null);
|
||||||
|
return [oid, billing?.companyName ?? null] as const;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const orgNameMap: Record<string, string> = {};
|
||||||
|
for (const [oid, name] of orgNamePairs) {
|
||||||
|
if (name) orgNameMap[oid] = name;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="max-w-5xl mx-auto px-6 py-8">
|
||||||
|
<BackLink href="/admin/billing" label={t("backToBilling")} />
|
||||||
|
<div className="mb-6">
|
||||||
|
<h1 className="font-display text-2xl font-semibold accent-rule">
|
||||||
|
{t("draftsPageTitle")}
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-text-secondary mt-3">
|
||||||
|
{t("draftsPageSubtitle")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<DraftList drafts={drafts} orgNameMap={orgNameMap} />
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
40
src/app/[locale]/admin/billing/invoices/[id]/page.tsx
Normal file
40
src/app/[locale]/admin/billing/invoices/[id]/page.tsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { notFound, redirect } from "next/navigation";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import { getSessionUser } from "@/lib/session";
|
||||||
|
import { getInvoiceDetail, listCreditNotesForInvoice } from "@/lib/db";
|
||||||
|
import { BackLink } from "@/components/ui/back-link";
|
||||||
|
import { InvoiceDetailView } from "@/components/admin/billing/invoice-detail-view";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* /admin/billing/invoices/[id] — full detail of one invoice.
|
||||||
|
*
|
||||||
|
* Server-renders the static body (header, lines, totals, billing
|
||||||
|
* snapshot); the action bar (mark-paid, void, refund, delete, PDF
|
||||||
|
* download) is a client component for the interactive bits.
|
||||||
|
*
|
||||||
|
* Phase 7: also passes any linked credit notes so the detail view
|
||||||
|
* can show the "this invoice was voided / partially refunded" panel
|
||||||
|
* without an extra round-trip.
|
||||||
|
*/
|
||||||
|
export default async function AdminInvoiceDetailPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
}) {
|
||||||
|
const user = await getSessionUser();
|
||||||
|
if (!user) redirect("/login");
|
||||||
|
if (!user.isPlatform) redirect("/dashboard");
|
||||||
|
const t = await getTranslations("adminBilling");
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
const detail = await getInvoiceDetail(id);
|
||||||
|
if (!detail) notFound();
|
||||||
|
const creditNotes = await listCreditNotesForInvoice(id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="max-w-4xl mx-auto px-6 py-8">
|
||||||
|
<BackLink href="/admin/billing/invoices" label={t("backToInvoices")} />
|
||||||
|
<InvoiceDetailView detail={detail} creditNotes={creditNotes} />
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
72
src/app/[locale]/admin/billing/invoices/new/page.tsx
Normal file
72
src/app/[locale]/admin/billing/invoices/new/page.tsx
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import { getSessionUser } from "@/lib/session";
|
||||||
|
import { listTenants } from "@/lib/k8s";
|
||||||
|
import { getOrgBilling } from "@/lib/db";
|
||||||
|
import { BackLink } from "@/components/ui/back-link";
|
||||||
|
import { NewInvoiceForm } from "@/components/admin/billing/new-invoice-form";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* /admin/billing/invoices/new — entry point for the custom-invoice
|
||||||
|
* flow. The admin picks an org, clicks Continue, and lands on the
|
||||||
|
* editor at /admin/billing/invoice-drafts/<new-id>.
|
||||||
|
*
|
||||||
|
* Phase 8. Org list is built from tenant labels + each org's
|
||||||
|
* billing config (we need the company name and the
|
||||||
|
* has-billing-snapshot flag to gate the picker — orgs without a
|
||||||
|
* snapshot can't be invoiced until they complete onboarding or
|
||||||
|
* admin sets the billing info manually).
|
||||||
|
*/
|
||||||
|
export default async function NewInvoicePage() {
|
||||||
|
const user = await getSessionUser();
|
||||||
|
if (!user) redirect("/login");
|
||||||
|
if (!user.isPlatform) redirect("/dashboard");
|
||||||
|
const t = await getTranslations("adminBilling");
|
||||||
|
|
||||||
|
// Tenants give us org membership; getOrgBilling per org gives us
|
||||||
|
// the snapshot status. We dedupe by org id since one org can own
|
||||||
|
// many tenants.
|
||||||
|
const tenants = await listTenants();
|
||||||
|
const orgIds = new Set<string>();
|
||||||
|
for (const tnt of tenants) {
|
||||||
|
const oid = tnt.metadata.labels?.["pieced.ch/zitadel-org-id"];
|
||||||
|
if (oid) orgIds.add(oid);
|
||||||
|
}
|
||||||
|
const orgs = await Promise.all(
|
||||||
|
Array.from(orgIds).map(async (oid) => {
|
||||||
|
const billing = await getOrgBilling(oid).catch(() => null);
|
||||||
|
return {
|
||||||
|
zitadelOrgId: oid,
|
||||||
|
companyName: billing?.companyName ?? null,
|
||||||
|
country: billing?.country ?? null,
|
||||||
|
hasBillingAddress: !!billing && !!billing.companyName,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
// Sort: orgs with billing first (admin's most likely target),
|
||||||
|
// then alphabetically by company name.
|
||||||
|
orgs.sort((a, b) => {
|
||||||
|
if (a.hasBillingAddress !== b.hasBillingAddress) {
|
||||||
|
return a.hasBillingAddress ? -1 : 1;
|
||||||
|
}
|
||||||
|
return (a.companyName ?? "").localeCompare(b.companyName ?? "");
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="max-w-2xl mx-auto px-6 py-8">
|
||||||
|
<BackLink
|
||||||
|
href="/admin/billing/invoices"
|
||||||
|
label={t("backToInvoices")}
|
||||||
|
/>
|
||||||
|
<div className="mb-6">
|
||||||
|
<h1 className="font-display text-2xl font-semibold accent-rule">
|
||||||
|
{t("newInvoicePageTitle")}
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-text-secondary mt-3">
|
||||||
|
{t("newInvoicePageSubtitle")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<NewInvoiceForm orgs={orgs} />
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
83
src/app/[locale]/admin/billing/orgs/page.tsx
Normal file
83
src/app/[locale]/admin/billing/orgs/page.tsx
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import { getSessionUser } from "@/lib/session";
|
||||||
|
import { getOrgBilling, getOrgBillingConfig } from "@/lib/db";
|
||||||
|
import { listTenants } from "@/lib/k8s";
|
||||||
|
import { BackLink } from "@/components/ui/back-link";
|
||||||
|
import { OrgPaymentModeList } from "@/components/admin/billing/org-payment-mode-list";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* /admin/billing/orgs — list of orgs with their payment mode
|
||||||
|
* settings.
|
||||||
|
*
|
||||||
|
* Phase 9b-2. The customer's /settings/billing only exposes the
|
||||||
|
* saved-card flow (auto-pay). Bank-transfer mode is admin-only —
|
||||||
|
* customer must contact support to request it, admin flips the
|
||||||
|
* pay_by_invoice flag here. Also exposes the auto_charge_enabled
|
||||||
|
* pause-switch for support situations.
|
||||||
|
*
|
||||||
|
* The page is intentionally minimal: org name, country, current
|
||||||
|
* mode, has-saved-card indicator, and toggles. Detail-level work
|
||||||
|
* (open balances, invoice list) is on the existing pages
|
||||||
|
* (/admin/billing, /admin/billing/invoices).
|
||||||
|
*/
|
||||||
|
export default async function AdminOrgsPaymentModePage() {
|
||||||
|
const user = await getSessionUser();
|
||||||
|
if (!user) redirect("/login");
|
||||||
|
if (!user.isPlatform) redirect("/dashboard");
|
||||||
|
const t = await getTranslations("adminBilling");
|
||||||
|
|
||||||
|
// Same org-discovery pattern as /api/admin/billing/orgs: tenant
|
||||||
|
// labels are the source of truth for org membership. We dedupe by
|
||||||
|
// org id since one org can own many tenants.
|
||||||
|
const tenants = await listTenants().catch(() => []);
|
||||||
|
const orgIds = new Set<string>();
|
||||||
|
for (const tnt of tenants) {
|
||||||
|
const oid = tnt.metadata.labels?.["pieced.ch/zitadel-org-id"];
|
||||||
|
if (oid) orgIds.add(oid);
|
||||||
|
}
|
||||||
|
const orgs = await Promise.all(
|
||||||
|
Array.from(orgIds).map(async (oid) => {
|
||||||
|
const [billing, cfg] = await Promise.all([
|
||||||
|
getOrgBilling(oid).catch(() => null),
|
||||||
|
getOrgBillingConfig(oid),
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
zitadelOrgId: oid,
|
||||||
|
companyName: billing?.companyName ?? null,
|
||||||
|
country: billing?.country ?? null,
|
||||||
|
hasSavedCard: !!cfg.stripeDefaultPaymentMethodId,
|
||||||
|
cardLabel:
|
||||||
|
cfg.stripePmBrand && cfg.stripePmLast4
|
||||||
|
? `${cfg.stripePmBrand} •••• ${cfg.stripePmLast4}`
|
||||||
|
: null,
|
||||||
|
payByInvoice: !!cfg.payByInvoice,
|
||||||
|
autoChargeEnabled: cfg.autoChargeEnabled !== false,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
// Sort: orgs with billing first (most actionable), then by name.
|
||||||
|
orgs.sort((a, b) => {
|
||||||
|
if (!!a.companyName !== !!b.companyName) {
|
||||||
|
return a.companyName ? -1 : 1;
|
||||||
|
}
|
||||||
|
return (a.companyName ?? a.zitadelOrgId).localeCompare(
|
||||||
|
b.companyName ?? b.zitadelOrgId
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="max-w-6xl mx-auto px-6 py-8">
|
||||||
|
<BackLink href="/admin/billing" label={t("backToBilling")} />
|
||||||
|
<div className="mb-6">
|
||||||
|
<h1 className="font-display text-2xl font-semibold accent-rule">
|
||||||
|
{t("orgsPageTitle")}
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-text-secondary mt-3">
|
||||||
|
{t("orgsPageSubtitle")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<OrgPaymentModeList orgs={orgs} />
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
134
src/app/[locale]/admin/billing/page.tsx
Normal file
134
src/app/[locale]/admin/billing/page.tsx
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import { getSessionUser } from "@/lib/session";
|
||||||
|
import { getOrgOpenBalances, syncOverdueInvoices } from "@/lib/db";
|
||||||
|
import { Card } from "@/components/ui/card";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* /admin/billing — landing page with sub-section links and a
|
||||||
|
* quick overview of orgs in arrears.
|
||||||
|
*
|
||||||
|
* Sub-pages:
|
||||||
|
* - /admin/billing/pricing — platform + skill prices
|
||||||
|
* - /admin/billing/generate — manual invoice generator (testing)
|
||||||
|
* - /admin/billing/invoices — invoice list/detail
|
||||||
|
*
|
||||||
|
* The Phase 2 customer-side /billing landing page is added in
|
||||||
|
* Phase 3.
|
||||||
|
*/
|
||||||
|
export default async function AdminBillingPage() {
|
||||||
|
const user = await getSessionUser();
|
||||||
|
if (!user) redirect("/login");
|
||||||
|
if (!user.isPlatform) redirect("/dashboard");
|
||||||
|
const t = await getTranslations("adminBilling");
|
||||||
|
|
||||||
|
// Sweep open invoices past due → 'overdue' so the counters below
|
||||||
|
// reflect reality without needing a cron.
|
||||||
|
await syncOverdueInvoices().catch((e) =>
|
||||||
|
console.error("syncOverdueInvoices failed:", e)
|
||||||
|
);
|
||||||
|
const balances = await getOrgOpenBalances().catch(() => []);
|
||||||
|
const totalOpen = balances.reduce((acc, b) => acc + b.totalOpenChf, 0);
|
||||||
|
const totalOverdue = balances.reduce((acc, b) => acc + b.overdueCount, 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="max-w-5xl mx-auto px-6 py-8">
|
||||||
|
<div className="mb-8 animate-in">
|
||||||
|
<h1 className="font-display text-2xl font-semibold accent-rule">
|
||||||
|
{t("title")}
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-text-secondary mt-3">{t("subtitle")}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats strip */}
|
||||||
|
<div className="grid grid-cols-3 gap-4 mb-8 animate-in animate-in-delay-1">
|
||||||
|
<Card>
|
||||||
|
<div className="text-xs text-text-muted">{t("totalOpenBalance")}</div>
|
||||||
|
<div className="text-2xl font-semibold mt-1">
|
||||||
|
CHF {totalOpen.toFixed(2)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<div className="text-xs text-text-muted">{t("orgsWithBalance")}</div>
|
||||||
|
<div className="text-2xl font-semibold mt-1">{balances.length}</div>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<div className="text-xs text-text-muted">{t("overdueInvoices")}</div>
|
||||||
|
<div className="text-2xl font-semibold mt-1">
|
||||||
|
{totalOverdue > 0 ? (
|
||||||
|
<span className="text-error">{totalOverdue}</span>
|
||||||
|
) : (
|
||||||
|
totalOverdue
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sub-tool cards */}
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8 animate-in animate-in-delay-2">
|
||||||
|
<Link href="/admin/billing/pricing">
|
||||||
|
<Card interactive>
|
||||||
|
<div className="font-semibold mb-1">{t("pricingTitle")}</div>
|
||||||
|
<div className="text-sm text-text-muted">{t("pricingDesc")}</div>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
|
<Link href="/admin/billing/generate">
|
||||||
|
<Card interactive>
|
||||||
|
<div className="font-semibold mb-1">{t("generateTitle")}</div>
|
||||||
|
<div className="text-sm text-text-muted">{t("generateDesc")}</div>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
|
<Link href="/admin/billing/invoices">
|
||||||
|
<Card interactive>
|
||||||
|
<div className="font-semibold mb-1">{t("invoicesTitle")}</div>
|
||||||
|
<div className="text-sm text-text-muted">{t("invoicesDesc")}</div>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
|
<Link href="/admin/billing/orgs">
|
||||||
|
<Card interactive>
|
||||||
|
<div className="font-semibold mb-1">{t("orgsTitle")}</div>
|
||||||
|
<div className="text-sm text-text-muted">{t("orgsDesc")}</div>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Orgs with open balance */}
|
||||||
|
{balances.length > 0 && (
|
||||||
|
<div className="animate-in animate-in-delay-3">
|
||||||
|
<h2 className="text-lg font-semibold mb-3">{t("balancesTitle")}</h2>
|
||||||
|
<Card>
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
44
src/app/[locale]/admin/cron/page.tsx
Normal file
44
src/app/[locale]/admin/cron/page.tsx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import { getSessionUser } from "@/lib/session";
|
||||||
|
import {
|
||||||
|
getLastSuccessfulCronRuns,
|
||||||
|
listRecentCronRuns,
|
||||||
|
} from "@/lib/db";
|
||||||
|
import { CronControls } from "@/components/admin/cron/cron-controls";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* /admin/cron — automation dashboard.
|
||||||
|
*
|
||||||
|
* Shows:
|
||||||
|
* - Last successful run of each kind, with relative time
|
||||||
|
* - Two "Run now" buttons (admin-triggered manual sweeps)
|
||||||
|
* - Recent runs table (last 30)
|
||||||
|
*
|
||||||
|
* Platform-admin gated server-side.
|
||||||
|
*/
|
||||||
|
export default async function AdminCronPage() {
|
||||||
|
const user = await getSessionUser();
|
||||||
|
if (!user || !user.isPlatform) redirect("/login");
|
||||||
|
const t = await getTranslations("adminCron");
|
||||||
|
|
||||||
|
const [recent, lastSuccess] = await Promise.all([
|
||||||
|
listRecentCronRuns(30),
|
||||||
|
getLastSuccessfulCronRuns(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="max-w-5xl mx-auto px-6 py-8">
|
||||||
|
<div className="mb-8 animate-in">
|
||||||
|
<h1 className="font-display text-2xl font-semibold accent-rule">
|
||||||
|
{t("title")}
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-text-secondary mt-3">{t("subtitle")}</p>
|
||||||
|
</div>
|
||||||
|
<CronControls
|
||||||
|
initialRecent={recent}
|
||||||
|
initialLastSuccess={lastSuccess}
|
||||||
|
/>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { getSessionUser } from "@/lib/session";
|
|||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { listTenants } from "@/lib/k8s";
|
import { listTenants } from "@/lib/k8s";
|
||||||
|
import { countPendingSkillActivationRequests } from "@/lib/db";
|
||||||
import { AdminPanel } from "@/components/admin/admin-panel";
|
import { AdminPanel } from "@/components/admin/admin-panel";
|
||||||
|
|
||||||
export default async function AdminPage() {
|
export default async function AdminPage() {
|
||||||
@@ -19,6 +20,12 @@ export default async function AdminPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const tenants = await listTenants();
|
const tenants = await listTenants();
|
||||||
|
// Phase 2.5: badge counter for the skill-activation admin queue.
|
||||||
|
// Cheap COUNT(*) on a partial-indexed status='pending' column —
|
||||||
|
// bounded by request volume and never expected to be high.
|
||||||
|
const pendingSkillCount = await countPendingSkillActivationRequests().catch(
|
||||||
|
() => 0
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -32,12 +39,41 @@ 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. */}
|
||||||
<a
|
<div className="flex items-center gap-2">
|
||||||
href="/admin/openclaw"
|
<a
|
||||||
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"
|
href="/admin/skills/pending"
|
||||||
>
|
className={`text-sm px-4 py-2 rounded-lg border transition-colors flex items-center gap-2 ${
|
||||||
{t("openclawTool")}
|
pendingSkillCount > 0
|
||||||
</a>
|
? "border-warning text-warning hover:bg-warning/10"
|
||||||
|
: "border-border text-text-secondary hover:text-text-primary hover:border-text-secondary"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span>{t("skillsQueueTool")}</span>
|
||||||
|
{pendingSkillCount > 0 && (
|
||||||
|
<span className="text-xs px-1.5 py-0.5 rounded bg-warning text-surface-0 font-semibold">
|
||||||
|
{pendingSkillCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="/admin/billing"
|
||||||
|
className="text-sm px-4 py-2 rounded-lg border border-border text-text-secondary hover:text-text-primary hover:border-text-secondary transition-colors"
|
||||||
|
>
|
||||||
|
{t("billingTool")}
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="/admin/cron"
|
||||||
|
className="text-sm px-4 py-2 rounded-lg border border-border text-text-secondary hover:text-text-primary hover:border-text-secondary transition-colors"
|
||||||
|
>
|
||||||
|
{t("cronTool")}
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="/admin/openclaw"
|
||||||
|
className="text-sm px-4 py-2 rounded-lg border border-border text-text-secondary hover:text-text-primary hover:border-text-secondary transition-colors"
|
||||||
|
>
|
||||||
|
{t("openclawTool")}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="animate-in animate-in-delay-1">
|
<div className="animate-in animate-in-delay-1">
|
||||||
|
|||||||
59
src/app/[locale]/admin/skills/pending/page.tsx
Normal file
59
src/app/[locale]/admin/skills/pending/page.tsx
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import { getSessionUser } from "@/lib/session";
|
||||||
|
import { listPendingSkillActivationRequests, getOrgBilling } from "@/lib/db";
|
||||||
|
import { getPackageDef } from "@/lib/packages";
|
||||||
|
import { BackLink } from "@/components/ui/back-link";
|
||||||
|
import { PendingSkillRequests } from "@/components/admin/skills/pending-skill-requests";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* /admin/skills/pending — admin queue for manual-setup skill
|
||||||
|
* activation requests. Each row shows tenant, skill, requester
|
||||||
|
* info, and offers Approve / Reject actions.
|
||||||
|
*
|
||||||
|
* Server-renders the initial list. Approval/rejection trigger a
|
||||||
|
* client-side fetch + router.refresh() so the row disappears and
|
||||||
|
* the count updates without a hard reload.
|
||||||
|
*/
|
||||||
|
export default async function AdminPendingSkillRequestsPage() {
|
||||||
|
const user = await getSessionUser();
|
||||||
|
if (!user) redirect("/login");
|
||||||
|
if (!user.isPlatform) redirect("/dashboard");
|
||||||
|
const t = await getTranslations("adminSkills");
|
||||||
|
|
||||||
|
const pending = await listPendingSkillActivationRequests();
|
||||||
|
|
||||||
|
// Hydrate display fields: skill name from catalog, org company name
|
||||||
|
// from billing. Skill name fallback to skillId for off-catalog
|
||||||
|
// entries (shouldn't happen but defensive). Company name is
|
||||||
|
// looked up lazily per row; dedup'd via a Map so we don't issue
|
||||||
|
// duplicate getOrgBilling calls for the same org.
|
||||||
|
const seenOrg = new Map<string, string | null>();
|
||||||
|
const rows = await Promise.all(
|
||||||
|
pending.map(async (r) => {
|
||||||
|
if (!seenOrg.has(r.zitadelOrgId)) {
|
||||||
|
const billing = await getOrgBilling(r.zitadelOrgId).catch(() => null);
|
||||||
|
seenOrg.set(r.zitadelOrgId, billing?.companyName ?? null);
|
||||||
|
}
|
||||||
|
const def = getPackageDef(r.skillId);
|
||||||
|
return {
|
||||||
|
...r,
|
||||||
|
skillName: def?.name ?? r.skillId,
|
||||||
|
companyName: seenOrg.get(r.zitadelOrgId) ?? null,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="max-w-5xl mx-auto px-6 py-8">
|
||||||
|
<BackLink href="/admin" label={t("backToAdmin")} />
|
||||||
|
<div className="mb-8 animate-in">
|
||||||
|
<h1 className="font-display text-2xl font-semibold accent-rule">
|
||||||
|
{t("title")}
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-text-secondary mt-3">{t("subtitle")}</p>
|
||||||
|
</div>
|
||||||
|
<PendingSkillRequests initialRows={rows} />
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
35
src/app/[locale]/billing/[invoiceNumber]/page.tsx
Normal file
35
src/app/[locale]/billing/[invoiceNumber]/page.tsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { redirect, notFound } from "next/navigation";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import { getSessionUser } from "@/lib/session";
|
||||||
|
import { getInvoiceByNumberForOrg } from "@/lib/db";
|
||||||
|
import { BackLink } from "@/components/ui/back-link";
|
||||||
|
import { CustomerInvoiceDetail } from "@/components/billing/customer-invoice-detail";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* /billing/[invoiceNumber] — single-invoice view.
|
||||||
|
*
|
||||||
|
* Lookup is by the human-readable invoice number (the YYYY-NNNNN
|
||||||
|
* format printed on the PDF and in the issuance email). Org
|
||||||
|
* filter is enforced in the DB query — a customer trying another
|
||||||
|
* org's number gets 404, not 403, to avoid leaking the existence
|
||||||
|
* of other orgs' invoices.
|
||||||
|
*/
|
||||||
|
export default async function CustomerInvoiceDetailPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ invoiceNumber: string; locale: string }>;
|
||||||
|
}) {
|
||||||
|
const user = await getSessionUser();
|
||||||
|
if (!user) redirect("/login");
|
||||||
|
const { invoiceNumber } = await params;
|
||||||
|
const t = await getTranslations("customerBilling");
|
||||||
|
const detail = await getInvoiceByNumberForOrg(invoiceNumber, user.orgId);
|
||||||
|
if (!detail) notFound();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="max-w-3xl mx-auto px-6 py-8">
|
||||||
|
<BackLink href="/billing" label={t("backToBilling")} />
|
||||||
|
<CustomerInvoiceDetail invoice={detail.invoice} lines={detail.lines} />
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
85
src/app/[locale]/billing/page.tsx
Normal file
85
src/app/[locale]/billing/page.tsx
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import { getSessionUser } from "@/lib/session";
|
||||||
|
import {
|
||||||
|
listCreditNotesForOrg,
|
||||||
|
listInvoices,
|
||||||
|
syncOverdueInvoices,
|
||||||
|
} from "@/lib/db";
|
||||||
|
import { CustomerInvoiceList } from "@/components/billing/customer-invoice-list";
|
||||||
|
import { CustomerCreditNoteList } from "@/components/billing/customer-credit-note-list";
|
||||||
|
import { RunningTotalWidget } from "@/components/billing/running-total-widget";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* /billing — customer's billing home.
|
||||||
|
*
|
||||||
|
* Shows three things:
|
||||||
|
* 1. RunningTotalWidget — current calendar month's accruing cost
|
||||||
|
* (or the already-issued invoice for the current month, if
|
||||||
|
* that ran early).
|
||||||
|
* 2. CustomerInvoiceList — every issued invoice for this org,
|
||||||
|
* newest first. Status is reflected with a colored badge.
|
||||||
|
* 3. CustomerCreditNoteList — Phase 7. Credit notes (voids and
|
||||||
|
* refunds) for this org, with PDF download links. Hidden
|
||||||
|
* entirely when there are none (the common case).
|
||||||
|
*
|
||||||
|
* Anyone signed in can view this. The data is org-scoped; even
|
||||||
|
* non-owner team members see the same view.
|
||||||
|
*/
|
||||||
|
export default async function CustomerBillingPage() {
|
||||||
|
const user = await getSessionUser();
|
||||||
|
if (!user) redirect("/login");
|
||||||
|
const t = await getTranslations("customerBilling");
|
||||||
|
|
||||||
|
// Sync overdue status before listing — cheap, idempotent.
|
||||||
|
try {
|
||||||
|
await syncOverdueInvoices();
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("syncOverdueInvoices failed in /billing:", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parallel fetch — invoices + credit notes are independent.
|
||||||
|
const [invoices, creditNotes] = await Promise.all([
|
||||||
|
listInvoices({ zitadelOrgId: user.orgId, limit: 200 }),
|
||||||
|
listCreditNotesForOrg(user.orgId, 200),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="max-w-5xl mx-auto px-6 py-8">
|
||||||
|
<div className="mb-8 animate-in">
|
||||||
|
<h1 className="font-display text-2xl font-semibold accent-rule">
|
||||||
|
{t("title")}
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-text-secondary mt-3">{t("subtitle")}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="mb-8 animate-in animate-in-delay-1">
|
||||||
|
<h2 className="text-xs font-semibold uppercase tracking-wider text-text-muted mb-3">
|
||||||
|
{t("currentPeriodHeading")}
|
||||||
|
</h2>
|
||||||
|
{/* Phase 6: pass the owner flag so the no-config CTA shows
|
||||||
|
the right call-to-action vs the right hint. */}
|
||||||
|
<RunningTotalWidget isOwner={user.roles.includes("owner")} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="animate-in animate-in-delay-2 mb-8">
|
||||||
|
<h2 className="text-xs font-semibold uppercase tracking-wider text-text-muted mb-3">
|
||||||
|
{t("historyHeading")}
|
||||||
|
</h2>
|
||||||
|
<CustomerInvoiceList invoices={invoices} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Phase 7: credit-note section. CustomerCreditNoteList itself
|
||||||
|
returns null when there are no credit notes, so this whole
|
||||||
|
section disappears for orgs in normal operation. */}
|
||||||
|
{creditNotes.length > 0 && (
|
||||||
|
<section className="animate-in animate-in-delay-3">
|
||||||
|
<h2 className="text-xs font-semibold uppercase tracking-wider text-text-muted mb-3">
|
||||||
|
{t("creditNotesHeading")}
|
||||||
|
</h2>
|
||||||
|
<CustomerCreditNoteList creditNotes={creditNotes} />
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -76,6 +76,7 @@ export default async function NewInstancePage() {
|
|||||||
userName={user.name}
|
userName={user.name}
|
||||||
userEmail={user.email}
|
userEmail={user.email}
|
||||||
hasOrgBilling={hasOrgBilling}
|
hasOrgBilling={hasOrgBilling}
|
||||||
|
existingOrgBilling={orgBilling}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -317,6 +317,7 @@ export default async function DashboardPage() {
|
|||||||
userName={user.name}
|
userName={user.name}
|
||||||
userEmail={user.email}
|
userEmail={user.email}
|
||||||
hasOrgBilling={hasOrgBilling}
|
hasOrgBilling={hasOrgBilling}
|
||||||
|
existingOrgBilling={orgBilling}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,30 +1,40 @@
|
|||||||
import { getTranslations } from "next-intl/server";
|
|
||||||
import { redirect, notFound } from "next/navigation";
|
import { redirect, notFound } from "next/navigation";
|
||||||
import { getSessionUser, canMutate } from "@/lib/session";
|
import { getTranslations } from "next-intl/server";
|
||||||
import { getOrgBilling } from "@/lib/db";
|
import { getSessionUser } from "@/lib/session";
|
||||||
import { BillingSettingsForm } from "@/components/settings/billing-settings-form";
|
import { getOrgBilling, getOrgBillingConfig } from "@/lib/db";
|
||||||
|
import { BillingSettingsForm } from "@/components/settings/billing-form";
|
||||||
|
import { SavedCardSection } from "@/components/settings/saved-card-section";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* /settings/billing — view and edit org-scoped billing (Bug 34/35).
|
* /settings/billing — customer-side billing details management.
|
||||||
*
|
*
|
||||||
* Server-side fetches the existing record (if any) and passes it to
|
* Owner-only by visibility: non-owner members get a 404 (same
|
||||||
* the client form. The form posts to PUT /api/billing on submit.
|
* response as if the page didn't exist). The link to this page
|
||||||
|
* is also hidden from non-owners on /billing and elsewhere, but
|
||||||
|
* the page itself enforces too — a non-owner who learns the URL
|
||||||
|
* still gets 404, not 403, so the page's existence doesn't leak.
|
||||||
*
|
*
|
||||||
* Access: same gate as the API — owners and platform admins. `user`
|
* First-time visitors see an empty form. Subsequent visits see
|
||||||
* role redirects to /settings (which also wouldn't list billing for
|
* the current values, editable. Save creates or updates via the
|
||||||
* them). 403 here would be friendlier than redirect, but the most
|
* shared upsert path; the row's existence drives whether the
|
||||||
* likely cause of a `user` landing on this URL is sharing a bookmark
|
* monthly issuance cron will pick this org up.
|
||||||
* with their owner — silent redirect is gentle.
|
*
|
||||||
|
* Phase 9: also renders the saved-card section (Set up auto-pay /
|
||||||
|
* Visa dot-dot-dot 4242, expires MM/YY / Update card / Disable
|
||||||
|
* auto-pay / Remove card) when billing info is on file, plus a
|
||||||
|
* footer note explaining that bank transfer is available on request.
|
||||||
*/
|
*/
|
||||||
export default async function BillingSettingsPage() {
|
export default async function BillingSettingsPage() {
|
||||||
const user = await getSessionUser();
|
const user = await getSessionUser();
|
||||||
if (!user) redirect("/login");
|
if (!user) redirect("/login");
|
||||||
if (!canMutate(user)) {
|
// Non-owners get a 404 — see comment above.
|
||||||
redirect("/settings");
|
if (!user.roles.includes("owner")) notFound();
|
||||||
}
|
|
||||||
const t = await getTranslations("settingsBilling");
|
|
||||||
|
|
||||||
const billing = await getOrgBilling(user.orgId);
|
const t = await getTranslations("settingsBilling");
|
||||||
|
const [existing, config] = await Promise.all([
|
||||||
|
getOrgBilling(user.orgId),
|
||||||
|
getOrgBillingConfig(user.orgId),
|
||||||
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="max-w-3xl mx-auto px-6 py-8">
|
<main className="max-w-3xl mx-auto px-6 py-8">
|
||||||
@@ -32,16 +42,30 @@ export default async function BillingSettingsPage() {
|
|||||||
<h1 className="font-display text-2xl font-semibold accent-rule">
|
<h1 className="font-display text-2xl font-semibold accent-rule">
|
||||||
{t("title")}
|
{t("title")}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-sm text-text-secondary mt-3">{t("subtitle")}</p>
|
<p className="text-sm text-text-secondary mt-3">
|
||||||
|
{user.isPersonal ? t("subtitlePersonal") : t("subtitle")}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="animate-in animate-in-delay-1">
|
||||||
<BillingSettingsForm
|
<BillingSettingsForm
|
||||||
initial={billing}
|
initial={existing}
|
||||||
isPersonal={user.isPersonal}
|
isPersonal={user.isPersonal}
|
||||||
orgName={user.orgName}
|
/>
|
||||||
userName={user.name}
|
</div>
|
||||||
userEmail={user.email}
|
{/* Phase 9: saved-card section. Only shown once billing info
|
||||||
/>
|
exists — without an address Stripe can't create the
|
||||||
|
customer object, so the "Set up auto-pay" button would
|
||||||
|
fail anyway. We give a clear hint up there if the form
|
||||||
|
is empty (no need to surface the card UI). */}
|
||||||
|
{existing && (
|
||||||
|
<div className="animate-in animate-in-delay-2 mt-8">
|
||||||
|
<SavedCardSection
|
||||||
|
config={config}
|
||||||
|
isPayByInvoice={!!config?.payByInvoice}
|
||||||
|
isPersonal={user.isPersonal}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,8 +20,9 @@ export default async function SettingsPage() {
|
|||||||
const t = await getTranslations("settings");
|
const t = await getTranslations("settings");
|
||||||
|
|
||||||
// Build the list of settings cards. Each entry has a stable key, a
|
// Build the list of settings cards. Each entry has a stable key, a
|
||||||
// route, and a visibility predicate. Currently only billing; this
|
// route, and a visibility predicate. Phase 6 fix5: profile is
|
||||||
// shape leaves headroom for adding more without restructuring.
|
// visible to every signed-in user (it's their own identity).
|
||||||
|
// Billing stays gated behind canMutate.
|
||||||
const sections: Array<{
|
const sections: Array<{
|
||||||
key: string;
|
key: string;
|
||||||
href: string;
|
href: string;
|
||||||
@@ -29,6 +30,14 @@ export default async function SettingsPage() {
|
|||||||
description: string;
|
description: string;
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
}> = [
|
}> = [
|
||||||
|
{
|
||||||
|
key: "profile",
|
||||||
|
href: "/settings/profile",
|
||||||
|
title: t("profileTitle"),
|
||||||
|
description: t("profileDescription"),
|
||||||
|
// Every signed-in user can edit their own first/last name.
|
||||||
|
visible: true,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: "billing",
|
key: "billing",
|
||||||
href: "/settings/billing",
|
href: "/settings/billing",
|
||||||
|
|||||||
68
src/app/[locale]/settings/profile/page.tsx
Normal file
68
src/app/[locale]/settings/profile/page.tsx
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import { getSessionUser } from "@/lib/session";
|
||||||
|
import { getHumanUserDetail } from "@/lib/zitadel";
|
||||||
|
import { ProfileSettingsForm } from "@/components/settings/profile-form";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* /settings/profile — every authenticated user can edit their own
|
||||||
|
* first + last name. Email is shown read-only; changing it requires
|
||||||
|
* verification and is left to ZITADEL's own self-service flow.
|
||||||
|
*
|
||||||
|
* Personal vs company accounts:
|
||||||
|
* - Both can edit their first/last name in ZITADEL.
|
||||||
|
* - Personal accounts get an extra hint: editing the ZITADEL name
|
||||||
|
* does NOT change how the customer's name appears on invoices.
|
||||||
|
* Invoice identity is in org_billing.company_name (the "Full
|
||||||
|
* name" field on /settings/billing) and is intentionally
|
||||||
|
* editable separately, because legal/billing identity may not
|
||||||
|
* match preferred display identity.
|
||||||
|
* - Company accounts see an org-membership hint instead.
|
||||||
|
*
|
||||||
|
* Server-fetches the current profile from ZITADEL via the
|
||||||
|
* service-account PAT so the form starts with the canonical values
|
||||||
|
* rather than whatever happens to be in the JWT (the JWT name might
|
||||||
|
* be stale if the user updated their name in ZITADEL Console).
|
||||||
|
*/
|
||||||
|
export default async function ProfileSettingsPage() {
|
||||||
|
const user = await getSessionUser();
|
||||||
|
if (!user) redirect("/login");
|
||||||
|
|
||||||
|
const t = await getTranslations("settingsProfile");
|
||||||
|
|
||||||
|
let initial = { firstName: "", lastName: "", email: user.email };
|
||||||
|
try {
|
||||||
|
const profile = await getHumanUserDetail(user.id);
|
||||||
|
initial = {
|
||||||
|
firstName: profile.givenName,
|
||||||
|
lastName: profile.familyName,
|
||||||
|
email: profile.email || user.email,
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
// Identity provider unreachable: render the form with whatever
|
||||||
|
// we know from the session. The session has a combined `name`,
|
||||||
|
// not split parts, so we leave first/last empty and let the user
|
||||||
|
// re-enter. Server logs catch the underlying failure.
|
||||||
|
console.error("ProfileSettingsPage: getHumanUserDetail failed:", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="max-w-3xl mx-auto px-6 py-8">
|
||||||
|
<div className="mb-8 animate-in">
|
||||||
|
<h1 className="font-display text-2xl font-semibold accent-rule">
|
||||||
|
{t("title")}
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-text-secondary mt-3">
|
||||||
|
{user.isPersonal ? t("subtitlePersonal") : t("subtitle")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="animate-in animate-in-delay-1">
|
||||||
|
<ProfileSettingsForm
|
||||||
|
initial={initial}
|
||||||
|
isPersonal={user.isPersonal}
|
||||||
|
orgName={user.orgName}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,7 +3,11 @@ import { getTranslations, getFormatter } from "next-intl/server";
|
|||||||
import { redirect, notFound } from "next/navigation";
|
import { redirect, notFound } from "next/navigation";
|
||||||
import { getTenant } from "@/lib/k8s";
|
import { getTenant } from "@/lib/k8s";
|
||||||
import { canUserSeeTenant } from "@/lib/visibility";
|
import { canUserSeeTenant } from "@/lib/visibility";
|
||||||
import { getPendingResumeRequestForTenant } from "@/lib/db";
|
import {
|
||||||
|
getPendingResumeRequestForTenant,
|
||||||
|
listSkillActivationRequestsForTenant,
|
||||||
|
listSkillPricing,
|
||||||
|
} from "@/lib/db";
|
||||||
import { StatusBadge } from "@/components/ui/status-badge";
|
import { StatusBadge } from "@/components/ui/status-badge";
|
||||||
import { WarningBadge } from "@/components/ui/warning-badge";
|
import { WarningBadge } from "@/components/ui/warning-badge";
|
||||||
import { UsageDisplay } from "@/components/dashboard/usage-display";
|
import { UsageDisplay } from "@/components/dashboard/usage-display";
|
||||||
@@ -82,6 +86,17 @@ export default async function TenantDetailPage({
|
|||||||
);
|
);
|
||||||
const channelUsers = tenant.spec.channelUsers || {};
|
const channelUsers = tenant.spec.channelUsers || {};
|
||||||
|
|
||||||
|
// Phase 2.5: surface pending and most-recently-rejected skill
|
||||||
|
// activation requests so PackageCard can render the inline
|
||||||
|
// "Manual review pending" / "Activation rejected" states.
|
||||||
|
// Pricing drives the cost-disclosure dialog before enable.
|
||||||
|
// Both fetches are best-effort — an empty list is the safe
|
||||||
|
// fallback if the DB call fails (cards just show normal toggles).
|
||||||
|
const [activationRequests, skillPricing] = await Promise.all([
|
||||||
|
listSkillActivationRequestsForTenant(name).catch(() => []),
|
||||||
|
listSkillPricing().catch(() => []),
|
||||||
|
]);
|
||||||
|
|
||||||
// Bug 19 fix: every viewer (customer or admin) passes the tenant
|
// Bug 19 fix: every viewer (customer or admin) passes the tenant
|
||||||
// name to UsageDisplay. The /api/usage route resolves team+alias
|
// name to UsageDisplay. The /api/usage route resolves team+alias
|
||||||
// from the tenant CR's status and applies the visibility check, so
|
// from the tenant CR's status and applies the visibility check, so
|
||||||
@@ -219,6 +234,8 @@ export default async function TenantDetailPage({
|
|||||||
enabledPackages={enabledPackages}
|
enabledPackages={enabledPackages}
|
||||||
conditions={tenant.status?.conditions}
|
conditions={tenant.status?.conditions}
|
||||||
canEdit={canEdit}
|
canEdit={canEdit}
|
||||||
|
activationRequests={activationRequests}
|
||||||
|
skillPricing={skillPricing}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
64
src/app/api/admin/billing/invoice-drafts/[id]/issue/route.ts
Normal file
64
src/app/api/admin/billing/invoice-drafts/[id]/issue/route.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { getSessionUser, requirePlatformRole } from "@/lib/session";
|
||||||
|
import {
|
||||||
|
CustomInvoiceValidationError,
|
||||||
|
issueCustomInvoiceDraft,
|
||||||
|
} from "@/lib/billing";
|
||||||
|
import { safeError } from "@/lib/errors";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/admin/billing/invoice-drafts/[id]/issue
|
||||||
|
*
|
||||||
|
* Phase 8. Convert a draft into a real invoice:
|
||||||
|
* - Validate payload (must have lines, valid dates, billing snapshot)
|
||||||
|
* - Allocate invoice number from the shared year-scoped counter
|
||||||
|
* - Persist invoice with source='custom'
|
||||||
|
* - Render PDF
|
||||||
|
* - Email customer
|
||||||
|
* - Delete the draft
|
||||||
|
*
|
||||||
|
* Returns the issued Invoice on success. Errors map cleanly to
|
||||||
|
* HTTP codes:
|
||||||
|
* 400 — validation failure (CustomInvoiceValidationError)
|
||||||
|
* 404 — draft id doesn't exist (also CustomInvoiceValidationError
|
||||||
|
* since the orchestrator can't tell apart "draft missing"
|
||||||
|
* from "invalid input" — the message string discriminates)
|
||||||
|
* 500 — anything else (DB error, Stripe error not applicable here)
|
||||||
|
*
|
||||||
|
* Idempotency: this endpoint is NOT idempotent. Issuing twice
|
||||||
|
* allocates two invoice numbers. The admin UI disables the submit
|
||||||
|
* button while in-flight, but for safety the backend handles
|
||||||
|
* double-submit by failing on the second call (the draft was
|
||||||
|
* deleted by the first).
|
||||||
|
*/
|
||||||
|
export async function POST(
|
||||||
|
_request: Request,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
let user;
|
||||||
|
try {
|
||||||
|
await requirePlatformRole();
|
||||||
|
user = await getSessionUser();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
|
}
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
const { id } = await params;
|
||||||
|
try {
|
||||||
|
const invoice = await issueCustomInvoiceDraft({
|
||||||
|
draftId: id,
|
||||||
|
issuedBy: user.id,
|
||||||
|
});
|
||||||
|
return NextResponse.json({ invoice });
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof CustomInvoiceValidationError) {
|
||||||
|
return NextResponse.json({ error: e.message }, { status: 400 });
|
||||||
|
}
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: safeError(e, "Failed to issue custom invoice") },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { requirePlatformRole } from "@/lib/session";
|
||||||
|
import {
|
||||||
|
CustomInvoiceValidationError,
|
||||||
|
renderCustomDraftPreview,
|
||||||
|
} from "@/lib/billing";
|
||||||
|
import { safeError } from "@/lib/errors";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/admin/billing/invoice-drafts/[id]/preview
|
||||||
|
*
|
||||||
|
* Phase 8. Render the current draft as a PDF without persisting an
|
||||||
|
* invoice. The bytes are returned inline so the browser displays
|
||||||
|
* the document in a new tab. The invoice number on the rendered
|
||||||
|
* PDF is the placeholder "DRAFT" — no real number is allocated.
|
||||||
|
*
|
||||||
|
* Useful for the admin's "Review" step in the draft → review →
|
||||||
|
* issue flow.
|
||||||
|
*/
|
||||||
|
export async function GET(
|
||||||
|
_request: Request,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
await requirePlatformRole();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
|
}
|
||||||
|
const { id } = await params;
|
||||||
|
try {
|
||||||
|
const pdf = await renderCustomDraftPreview(id);
|
||||||
|
return new NextResponse(new Uint8Array(pdf), {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/pdf",
|
||||||
|
// Inline so the browser displays the PDF immediately. The
|
||||||
|
// filename is a guide — most browsers ignore it for inline
|
||||||
|
// disposition but it shows on the "Save as" dialog.
|
||||||
|
"Content-Disposition": `inline; filename="invoice-draft-${id}.pdf"`,
|
||||||
|
"Cache-Control": "no-store",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof CustomInvoiceValidationError) {
|
||||||
|
return NextResponse.json({ error: e.message }, { status: 400 });
|
||||||
|
}
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: safeError(e, "Failed to render preview") },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
120
src/app/api/admin/billing/invoice-drafts/[id]/route.ts
Normal file
120
src/app/api/admin/billing/invoice-drafts/[id]/route.ts
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { requirePlatformRole } from "@/lib/session";
|
||||||
|
import {
|
||||||
|
deleteInvoiceDraft,
|
||||||
|
getInvoiceDraftById,
|
||||||
|
updateInvoiceDraft,
|
||||||
|
} from "@/lib/db";
|
||||||
|
import { safeError } from "@/lib/errors";
|
||||||
|
import type { CustomInvoiceDraftPayload } from "@/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* /api/admin/billing/invoice-drafts/[id]
|
||||||
|
*
|
||||||
|
* Phase 8.
|
||||||
|
*
|
||||||
|
* GET — fetch one draft
|
||||||
|
* PUT — overwrite the payload (full replace, not patch)
|
||||||
|
* DELETE — discard the draft
|
||||||
|
*
|
||||||
|
* All require platform admin. The org boundary is *not* enforced
|
||||||
|
* here: a platform admin can edit any draft regardless of which
|
||||||
|
* org it targets. If we ever introduce a per-org admin role,
|
||||||
|
* scope filtering would go in this file.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const lineSchema = z.object({
|
||||||
|
description: z.string().trim().min(1).max(500),
|
||||||
|
quantity: z.number().finite(),
|
||||||
|
unitPriceChf: z.number().finite(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const payloadSchema = z.object({
|
||||||
|
issueDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||||
|
dueDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||||
|
locale: z.enum(["de", "en", "fr", "it"]),
|
||||||
|
paymentMethod: z.enum(["invoice", "card"]),
|
||||||
|
adminNotes: z.string().max(2000).optional(),
|
||||||
|
lines: z.array(lineSchema).max(100),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
_request: Request,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
await requirePlatformRole();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
|
}
|
||||||
|
const { id } = await params;
|
||||||
|
try {
|
||||||
|
const draft = await getInvoiceDraftById(id);
|
||||||
|
if (!draft) {
|
||||||
|
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
return NextResponse.json({ draft });
|
||||||
|
} catch (e) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: safeError(e, "Failed to load draft") },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
await requirePlatformRole();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
|
}
|
||||||
|
const { id } = await params;
|
||||||
|
const body = await request.json().catch(() => ({}));
|
||||||
|
const parsed = payloadSchema.safeParse(body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Invalid request", details: parsed.error.flatten() },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const updated = await updateInvoiceDraft(
|
||||||
|
id,
|
||||||
|
parsed.data as CustomInvoiceDraftPayload
|
||||||
|
);
|
||||||
|
if (!updated) {
|
||||||
|
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
return NextResponse.json({ draft: updated });
|
||||||
|
} catch (e) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: safeError(e, "Failed to update draft") },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(
|
||||||
|
_request: Request,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
await requirePlatformRole();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
|
}
|
||||||
|
const { id } = await params;
|
||||||
|
try {
|
||||||
|
const deleted = await deleteInvoiceDraft(id);
|
||||||
|
return NextResponse.json({ deleted });
|
||||||
|
} catch (e) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: safeError(e, "Failed to delete draft") },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
94
src/app/api/admin/billing/invoice-drafts/route.ts
Normal file
94
src/app/api/admin/billing/invoice-drafts/route.ts
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { requirePlatformRole, getSessionUser } from "@/lib/session";
|
||||||
|
import {
|
||||||
|
createInvoiceDraft,
|
||||||
|
listAllInvoiceDrafts,
|
||||||
|
} from "@/lib/db";
|
||||||
|
import { safeError } from "@/lib/errors";
|
||||||
|
import type { CustomInvoiceDraftPayload } from "@/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* /api/admin/billing/invoice-drafts
|
||||||
|
*
|
||||||
|
* Phase 8. Drafts for the admin "New invoice" flow.
|
||||||
|
*
|
||||||
|
* GET — list all open drafts across all orgs, newest-touched first.
|
||||||
|
* POST — create a new draft for an org with an initial (possibly
|
||||||
|
* empty) payload. Returns the inserted draft.
|
||||||
|
*
|
||||||
|
* Both require platform admin. Drafts have no customer-facing
|
||||||
|
* surface: they aren't reachable from /billing or any non-admin
|
||||||
|
* route.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const lineSchema = z.object({
|
||||||
|
description: z.string().trim().min(1).max(500),
|
||||||
|
quantity: z.number().finite(),
|
||||||
|
unitPriceChf: z.number().finite(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const payloadSchema = z.object({
|
||||||
|
issueDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||||
|
dueDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||||
|
locale: z.enum(["de", "en", "fr", "it"]),
|
||||||
|
paymentMethod: z.enum(["invoice", "card"]),
|
||||||
|
adminNotes: z.string().max(2000).optional(),
|
||||||
|
lines: z.array(lineSchema).max(100),
|
||||||
|
});
|
||||||
|
|
||||||
|
const createSchema = z.object({
|
||||||
|
zitadelOrgId: z.string().trim().min(1),
|
||||||
|
payload: payloadSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
await requirePlatformRole();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const drafts = await listAllInvoiceDrafts();
|
||||||
|
return NextResponse.json({ drafts });
|
||||||
|
} catch (e) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: safeError(e, "Failed to list drafts") },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
let user;
|
||||||
|
try {
|
||||||
|
await requirePlatformRole();
|
||||||
|
user = await getSessionUser();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
|
}
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
const body = await request.json().catch(() => ({}));
|
||||||
|
const parsed = createSchema.safeParse(body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Invalid request", details: parsed.error.flatten() },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const draft = await createInvoiceDraft({
|
||||||
|
zitadelOrgId: parsed.data.zitadelOrgId,
|
||||||
|
createdBy: user.id,
|
||||||
|
payload: parsed.data.payload as CustomInvoiceDraftPayload,
|
||||||
|
});
|
||||||
|
return NextResponse.json({ draft });
|
||||||
|
} catch (e) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: safeError(e, "Failed to create draft") },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
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",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
88
src/app/api/admin/billing/invoices/[id]/refund/route.ts
Normal file
88
src/app/api/admin/billing/invoices/[id]/refund/route.ts
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { requirePlatformRole, getSessionUser } from "@/lib/session";
|
||||||
|
import { refundInvoice, RefundNotAllowedError } from "@/lib/billing";
|
||||||
|
import { safeError } from "@/lib/errors";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/admin/billing/invoices/[id]/refund
|
||||||
|
*
|
||||||
|
* Phase 7. Refunds a paid invoice (full or partial) and issues a
|
||||||
|
* credit note. For Stripe-paid invoices, calls Stripe's Refund API
|
||||||
|
* before any local recording. For invoice-paid customers (bank
|
||||||
|
* transfer), records the refund locally and assumes the admin
|
||||||
|
* handled the actual money movement out-of-band.
|
||||||
|
*
|
||||||
|
* Body:
|
||||||
|
* {
|
||||||
|
* amountChf: number, // positive, <= remaining refundable
|
||||||
|
* reason: string // required, free-text, max 500
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* Authorization: platform admin.
|
||||||
|
*
|
||||||
|
* Status codes:
|
||||||
|
* 200 — refund issued, credit note returned
|
||||||
|
* 400 — bad request (zero/negative amount, etc.)
|
||||||
|
* 401 / 403 — not authenticated / not platform admin
|
||||||
|
* 409 — invoice not in a refundable state, or amount exceeds remaining
|
||||||
|
* 500 — Stripe call failed or another internal error
|
||||||
|
*
|
||||||
|
* Idempotency caveats: this endpoint is NOT idempotent against
|
||||||
|
* client retries. Issuing two refunds quickly will result in two
|
||||||
|
* Stripe refund calls (and two credit notes). The admin UI should
|
||||||
|
* disable the submit button while the request is in flight to
|
||||||
|
* prevent accidental double-clicks. The Stripe charge.refunded
|
||||||
|
* webhook is idempotent and will not double-count if it fires
|
||||||
|
* after this endpoint already recorded the refund.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const bodySchema = z.object({
|
||||||
|
amountChf: z.number().positive().multipleOf(0.01),
|
||||||
|
reason: z.string().trim().min(1).max(500),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
let user;
|
||||||
|
try {
|
||||||
|
await requirePlatformRole();
|
||||||
|
user = await getSessionUser();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
|
}
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
const { id } = await params;
|
||||||
|
const body = await request.json().catch(() => ({}));
|
||||||
|
const parsed = bodySchema.safeParse(body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Invalid request", details: parsed.error.flatten() },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const creditNote = await refundInvoice({
|
||||||
|
invoiceId: id,
|
||||||
|
amountChf: parsed.data.amountChf,
|
||||||
|
reason: parsed.data.reason,
|
||||||
|
refundedBy: user.id,
|
||||||
|
});
|
||||||
|
return NextResponse.json({ creditNote });
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof RefundNotAllowedError) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: e.message, currentStatus: e.currentStatus },
|
||||||
|
{ status: 409 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: safeError(e, "Refund failed") },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
77
src/app/api/admin/billing/invoices/[id]/void/route.ts
Normal file
77
src/app/api/admin/billing/invoices/[id]/void/route.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { requirePlatformRole, getSessionUser } from "@/lib/session";
|
||||||
|
import { voidInvoice, VoidNotAllowedError } from "@/lib/billing";
|
||||||
|
import { safeError } from "@/lib/errors";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/admin/billing/invoices/[id]/void
|
||||||
|
*
|
||||||
|
* Phase 7. Voids an unpaid invoice and issues a credit note.
|
||||||
|
*
|
||||||
|
* Body:
|
||||||
|
* {
|
||||||
|
* reason: string // required, free-text, max 500
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* Authorization: platform admin (same as mark-paid, generate, etc.).
|
||||||
|
* The acting user's ID lands in invoices.voided_by and on the
|
||||||
|
* credit_notes.issued_by audit columns.
|
||||||
|
*
|
||||||
|
* Status codes:
|
||||||
|
* 200 — voided, credit note returned in body
|
||||||
|
* 400 — bad request (missing reason etc.)
|
||||||
|
* 401 / 403 — not authenticated / not platform admin
|
||||||
|
* 409 — invoice not in a voidable state
|
||||||
|
* 500 — anything else (Stripe shouldn't apply here, but if PDF
|
||||||
|
* render fails the void still went through — see body
|
||||||
|
* payload for the credit-note number to re-render later)
|
||||||
|
*/
|
||||||
|
|
||||||
|
const bodySchema = z.object({
|
||||||
|
reason: z.string().trim().min(1).max(500),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
let user;
|
||||||
|
try {
|
||||||
|
await requirePlatformRole();
|
||||||
|
user = await getSessionUser();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
|
}
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
const { id } = await params;
|
||||||
|
const body = await request.json().catch(() => ({}));
|
||||||
|
const parsed = bodySchema.safeParse(body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Invalid request", details: parsed.error.flatten() },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const creditNote = await voidInvoice({
|
||||||
|
invoiceId: id,
|
||||||
|
reason: parsed.data.reason,
|
||||||
|
voidedBy: user.id,
|
||||||
|
});
|
||||||
|
return NextResponse.json({ creditNote });
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof VoidNotAllowedError) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: e.message, currentStatus: e.currentStatus },
|
||||||
|
{ status: 409 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: safeError(e, "Void failed") },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
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);
|
||||||
|
}
|
||||||
72
src/app/api/admin/billing/orgs/[orgId]/payment-mode/route.ts
Normal file
72
src/app/api/admin/billing/orgs/[orgId]/payment-mode/route.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { requirePlatformRole } from "@/lib/session";
|
||||||
|
import {
|
||||||
|
getOrgBillingConfig,
|
||||||
|
setAutoChargeEnabled,
|
||||||
|
updateOrgBillingConfig,
|
||||||
|
} from "@/lib/db";
|
||||||
|
import { safeError } from "@/lib/errors";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/admin/billing/orgs/[orgId]/payment-mode
|
||||||
|
*
|
||||||
|
* Phase 9b-2. Admin-only override of an org's billing mode:
|
||||||
|
* - payByInvoice (boolean) — flip the customer's account to
|
||||||
|
* bank-transfer billing. Auto-charge is skipped entirely for
|
||||||
|
* these orgs; they receive the regular issued-invoice email
|
||||||
|
* and pay manually. Switching ON also implicitly stops
|
||||||
|
* attempting card charges even if a saved card exists.
|
||||||
|
* - autoChargeEnabled (boolean) — pause auto-charge without
|
||||||
|
* committing to pay-by-invoice. Useful during disputes or
|
||||||
|
* billing investigations.
|
||||||
|
*
|
||||||
|
* Either flag may be omitted; the endpoint only writes what's
|
||||||
|
* provided. Returns the updated config.
|
||||||
|
*/
|
||||||
|
const bodySchema = z.object({
|
||||||
|
payByInvoice: z.boolean().optional(),
|
||||||
|
autoChargeEnabled: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ orgId: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
await requirePlatformRole();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
|
}
|
||||||
|
const { orgId } = await params;
|
||||||
|
const body = await request.json().catch(() => ({}));
|
||||||
|
const parsed = bodySchema.safeParse(body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Invalid request", details: parsed.error.flatten() },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const { payByInvoice, autoChargeEnabled } = parsed.data;
|
||||||
|
if (payByInvoice === undefined && autoChargeEnabled === undefined) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Provide at least one of payByInvoice or autoChargeEnabled" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (payByInvoice !== undefined) {
|
||||||
|
await updateOrgBillingConfig(orgId, { payByInvoice });
|
||||||
|
}
|
||||||
|
if (autoChargeEnabled !== undefined) {
|
||||||
|
await setAutoChargeEnabled(orgId, autoChargeEnabled);
|
||||||
|
}
|
||||||
|
const cfg = await getOrgBillingConfig(orgId);
|
||||||
|
return NextResponse.json({ config: cfg });
|
||||||
|
} catch (e) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: safeError(e, "Failed to update payment mode") },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
80
src/app/api/admin/billing/skill-pricing/route.ts
Normal file
80
src/app/api/admin/billing/skill-pricing/route.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { requirePlatformRole } from "@/lib/session";
|
||||||
|
import { listSkillPricing, setSkillPricing } from "@/lib/db";
|
||||||
|
import { getPackageDef } from "@/lib/packages";
|
||||||
|
import { safeError } from "@/lib/errors";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/admin/billing/skill-pricing
|
||||||
|
* List all configured skill prices.
|
||||||
|
*
|
||||||
|
* PUT /api/admin/billing/skill-pricing
|
||||||
|
* Upsert a daily price for a single skill. Body:
|
||||||
|
* { skillId: string, dailyPriceChf: number }
|
||||||
|
*
|
||||||
|
* Both endpoints are platform-only.
|
||||||
|
*
|
||||||
|
* Note on skillId validation: we accept any package id that exists
|
||||||
|
* in PACKAGE_CATALOG. The PIN to "skills only" is enforced at the
|
||||||
|
* UI layer, not here, so admins can price a non-skill package in
|
||||||
|
* an emergency without code changes.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const upsertSchema = z.object({
|
||||||
|
skillId: z.string().min(1).max(100),
|
||||||
|
dailyPriceChf: z.number().min(0).max(1_000_000),
|
||||||
|
// Optional with default 0 so existing API callers keep working.
|
||||||
|
// Setup fee fires once per (tenant, skill); see billing.ts.
|
||||||
|
setupFeeChf: z.number().min(0).max(1_000_000).optional().default(0),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
await requirePlatformRole();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
|
}
|
||||||
|
const rows = await listSkillPricing();
|
||||||
|
return NextResponse.json(rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(request: Request) {
|
||||||
|
try {
|
||||||
|
await requirePlatformRole();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
|
}
|
||||||
|
const body = await request.json().catch(() => ({}));
|
||||||
|
const parsed = upsertSchema.safeParse(body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Invalid payload", details: parsed.error.flatten() },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Validate the skill id exists in PACKAGE_CATALOG. Returns null
|
||||||
|
// for unknown ids; we reject those rather than persist a row that
|
||||||
|
// would never match a real toggle event.
|
||||||
|
const pkg = getPackageDef(parsed.data.skillId);
|
||||||
|
if (!pkg) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Unknown package id: ${parsed.data.skillId}` },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const row = await setSkillPricing(
|
||||||
|
parsed.data.skillId,
|
||||||
|
parsed.data.dailyPriceChf,
|
||||||
|
parsed.data.setupFeeChf
|
||||||
|
);
|
||||||
|
return NextResponse.json(row);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to upsert skill pricing:", e);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: safeError(e, "Upsert failed") },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
68
src/app/api/admin/cron/issue-monthly/route.ts
Normal file
68
src/app/api/admin/cron/issue-monthly/route.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { getSessionUser, requirePlatformRole } from "@/lib/session";
|
||||||
|
import { runMonthlyIssuance } from "@/lib/cron";
|
||||||
|
import { safeError } from "@/lib/errors";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/admin/cron/issue-monthly
|
||||||
|
*
|
||||||
|
* Admin-side manual trigger for the issuance sweep — same business
|
||||||
|
* logic as /api/cron/issue-monthly, different auth (session-based
|
||||||
|
* platform role check) and the option to override the target
|
||||||
|
* year/month from the request body.
|
||||||
|
*
|
||||||
|
* Body (all optional):
|
||||||
|
* { year?: number, month?: number }
|
||||||
|
*
|
||||||
|
* Default target is the previous local month — matching what the
|
||||||
|
* automated cron would do. Override is useful for catching up after
|
||||||
|
* a failed run or re-billing a past month after fixing data.
|
||||||
|
*/
|
||||||
|
const bodySchema = z.object({
|
||||||
|
year: z.number().int().min(2000).max(3000).optional(),
|
||||||
|
month: z.number().int().min(1).max(12).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
let user;
|
||||||
|
try {
|
||||||
|
await requirePlatformRole();
|
||||||
|
user = await getSessionUser();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
|
}
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
const body = await request.json().catch(() => ({}));
|
||||||
|
const parsed = bodySchema.safeParse(body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Invalid request", details: parsed.error.flatten() },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
(parsed.data.year && !parsed.data.month) ||
|
||||||
|
(parsed.data.month && !parsed.data.year)
|
||||||
|
) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "year and month must both be provided, or neither" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const { runId, summary } = await runMonthlyIssuance({
|
||||||
|
triggeredBy: user.id,
|
||||||
|
year: parsed.data.year,
|
||||||
|
month: parsed.data.month,
|
||||||
|
});
|
||||||
|
return NextResponse.json({ runId, ...summary });
|
||||||
|
} catch (e) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: safeError(e, "Issuance sweep failed.") },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
27
src/app/api/admin/cron/runs/route.ts
Normal file
27
src/app/api/admin/cron/runs/route.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { requirePlatformRole } from "@/lib/session";
|
||||||
|
import {
|
||||||
|
getLastSuccessfulCronRuns,
|
||||||
|
listRecentCronRuns,
|
||||||
|
} from "@/lib/db";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/admin/cron/runs
|
||||||
|
*
|
||||||
|
* Returns recent cron run history plus per-kind "last successful"
|
||||||
|
* summary for the admin /admin/cron dashboard.
|
||||||
|
*
|
||||||
|
* Response: { recent: CronRun[]; lastSuccess: { monthlyIssue, reminders } }
|
||||||
|
*/
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
await requirePlatformRole();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
|
}
|
||||||
|
const [recent, lastSuccess] = await Promise.all([
|
||||||
|
listRecentCronRuns(30),
|
||||||
|
getLastSuccessfulCronRuns(),
|
||||||
|
]);
|
||||||
|
return NextResponse.json({ recent, lastSuccess });
|
||||||
|
}
|
||||||
34
src/app/api/admin/cron/send-reminders/route.ts
Normal file
34
src/app/api/admin/cron/send-reminders/route.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { getSessionUser, requirePlatformRole } from "@/lib/session";
|
||||||
|
import { runReminderSweep } from "@/lib/cron";
|
||||||
|
import { safeError } from "@/lib/errors";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/admin/cron/send-reminders
|
||||||
|
*
|
||||||
|
* Admin-side manual trigger for the reminder sweep. Same logic
|
||||||
|
* as the machine path; session-based platform-role auth.
|
||||||
|
*/
|
||||||
|
export async function POST() {
|
||||||
|
let user;
|
||||||
|
try {
|
||||||
|
await requirePlatformRole();
|
||||||
|
user = await getSessionUser();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
|
}
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const { runId, summary } = await runReminderSweep({
|
||||||
|
triggeredBy: user.id,
|
||||||
|
});
|
||||||
|
return NextResponse.json({ runId, ...summary });
|
||||||
|
} catch (e) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: safeError(e, "Reminder sweep failed.") },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,14 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { requirePlatformRole } from "@/lib/session";
|
import { requirePlatformRole } from "@/lib/session";
|
||||||
import { getTenantRequestById, updateTenantRequestStatus } from "@/lib/db";
|
import {
|
||||||
|
getInvoiceById,
|
||||||
|
getTenantRequestById,
|
||||||
|
updateTenantRequestStatus,
|
||||||
|
} from "@/lib/db";
|
||||||
import { setTenantAnnotation } from "@/lib/k8s";
|
import { setTenantAnnotation } from "@/lib/k8s";
|
||||||
import { sendRejectionEmail, sendResumeRejectionEmail } from "@/lib/email";
|
import { sendRejectionEmail, sendResumeRejectionEmail } from "@/lib/email";
|
||||||
|
import { refundInvoice, RefundNotAllowedError } from "@/lib/billing";
|
||||||
|
import type { SessionUser } from "@/types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/admin/requests/[id]/reject
|
* POST /api/admin/requests/[id]/reject
|
||||||
@@ -14,13 +20,23 @@ import { sendRejectionEmail, sendResumeRejectionEmail } from "@/lib/email";
|
|||||||
* suspendedAt — rejection doesn't reset it. The customer can submit
|
* suspendedAt — rejection doesn't reset it. The customer can submit
|
||||||
* a fresh resume request later if circumstances change, but that
|
* a fresh resume request later if circumstances change, but that
|
||||||
* starts a new pending row and re-stamps the annotation.
|
* starts a new pending row and re-stamps the annotation.
|
||||||
|
*
|
||||||
|
* Phase 9b: provision rejections that have a linked paid setup
|
||||||
|
* invoice (setup_invoice_id) trigger an automatic full refund via
|
||||||
|
* the existing refundInvoice flow. The refund creates a credit
|
||||||
|
* note + Stripe refund + customer email — same paper trail any
|
||||||
|
* post-payment refund would have. Best-effort: a refund failure
|
||||||
|
* does NOT block the rejection (admin can re-refund manually via
|
||||||
|
* the invoice detail page if needed), but it's logged and surfaced
|
||||||
|
* in the response so admin sees what happened.
|
||||||
*/
|
*/
|
||||||
export async function POST(
|
export async function POST(
|
||||||
request: Request,
|
request: Request,
|
||||||
{ params }: { params: Promise<{ id: string }> }
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
) {
|
) {
|
||||||
|
let user: SessionUser;
|
||||||
try {
|
try {
|
||||||
await requirePlatformRole();
|
user = await requirePlatformRole();
|
||||||
} catch {
|
} catch {
|
||||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
}
|
}
|
||||||
@@ -65,6 +81,63 @@ export async function POST(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Phase 9b: refund the setup-fee invoice if one is linked. Only
|
||||||
|
// applies to provision rejections; resume requests never have a
|
||||||
|
// setup_invoice_id. Skip silently if no invoice is linked (e.g.
|
||||||
|
// the request was created before Phase 9b shipped, or the setup
|
||||||
|
// fee was 0).
|
||||||
|
const refundSummary: {
|
||||||
|
attempted: boolean;
|
||||||
|
succeeded: boolean;
|
||||||
|
error?: string;
|
||||||
|
} = { attempted: false, succeeded: false };
|
||||||
|
if (
|
||||||
|
tenantRequest.requestType === "provision" &&
|
||||||
|
tenantRequest.setupInvoiceId
|
||||||
|
) {
|
||||||
|
refundSummary.attempted = true;
|
||||||
|
try {
|
||||||
|
// refundInvoice expects an explicit CHF amount (no "full"
|
||||||
|
// sentinel). Compute the remaining refundable amount as
|
||||||
|
// total minus what's already been refunded. For a fresh
|
||||||
|
// setup-fee invoice this is just totalChf, but the formula
|
||||||
|
// is robust if admin had partially refunded earlier (rare
|
||||||
|
// but possible — same invoice could in theory get a manual
|
||||||
|
// partial refund, then a rejection).
|
||||||
|
const inv = await getInvoiceById(tenantRequest.setupInvoiceId);
|
||||||
|
if (!inv) {
|
||||||
|
throw new Error(
|
||||||
|
`Linked setup invoice ${tenantRequest.setupInvoiceId} not found`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const remaining = Math.round(
|
||||||
|
(inv.totalChf - (inv.refundedTotalChf ?? 0)) * 100
|
||||||
|
) / 100;
|
||||||
|
if (remaining <= 0) {
|
||||||
|
refundSummary.succeeded = true; // nothing to refund — treat as success
|
||||||
|
} else {
|
||||||
|
await refundInvoice({
|
||||||
|
invoiceId: tenantRequest.setupInvoiceId,
|
||||||
|
amountChf: remaining,
|
||||||
|
reason: adminNotes
|
||||||
|
? `Tenant request rejected: ${adminNotes}`
|
||||||
|
: "Tenant request rejected",
|
||||||
|
refundedBy: user.id,
|
||||||
|
});
|
||||||
|
refundSummary.succeeded = true;
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
refundSummary.error =
|
||||||
|
e instanceof RefundNotAllowedError
|
||||||
|
? e.message
|
||||||
|
: (e?.message ?? "refund failed");
|
||||||
|
console.error(
|
||||||
|
`Setup-fee refund failed for request ${id} (invoice ${tenantRequest.setupInvoiceId}):`,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Notify customer. Resume requests get a different email — the
|
// Notify customer. Resume requests get a different email — the
|
||||||
// tenant already exists; copy needs to mention "stays suspended" and
|
// tenant already exists; copy needs to mention "stays suspended" and
|
||||||
// the 60-day retention deadline. Provision rejections use the
|
// the 60-day retention deadline. Provision rejections use the
|
||||||
@@ -88,5 +161,6 @@ export async function POST(
|
|||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
message: "Request rejected.",
|
message: "Request rejected.",
|
||||||
request: updated,
|
request: updated,
|
||||||
|
refund: refundSummary,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
155
src/app/api/admin/skills/pending/[id]/approve/route.ts
Normal file
155
src/app/api/admin/skills/pending/[id]/approve/route.ts
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { getSessionUser, requirePlatformRole } from "@/lib/session";
|
||||||
|
import {
|
||||||
|
getSkillActivationRequestById,
|
||||||
|
recordSkillEvents,
|
||||||
|
updateSkillActivationRequestStatus,
|
||||||
|
} from "@/lib/db";
|
||||||
|
import { getTenant, patchTenantSpec } from "@/lib/k8s";
|
||||||
|
import { getPackageDef } from "@/lib/packages";
|
||||||
|
import { listOrgUsers } from "@/lib/zitadel";
|
||||||
|
import { sendSkillActivationApprovalEmail } from "@/lib/email";
|
||||||
|
import { safeError } from "@/lib/errors";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/admin/skills/pending/[id]/approve
|
||||||
|
*
|
||||||
|
* Atomic-ish approval. Ordering:
|
||||||
|
* 1. Load + sanity-check the request (must be pending).
|
||||||
|
* 2. Patch the tenant CR to include the skill in spec.packages.
|
||||||
|
* 3. Record the skill_event (kind=enabled) for billing.
|
||||||
|
* 4. Flip the request row to 'approved'.
|
||||||
|
* 5. Best-effort approval email to the requester.
|
||||||
|
*
|
||||||
|
* Step 2 is the irreversible one — if it succeeds but step 4 fails
|
||||||
|
* we end up with a skill enabled in K8s but a still-pending request
|
||||||
|
* row. That's a manual cleanup task; we log loudly so admin notices
|
||||||
|
* via the queue page (the request would reappear there).
|
||||||
|
*
|
||||||
|
* The request must be in 'pending' status. Approving an already-
|
||||||
|
* approved/rejected request returns 409.
|
||||||
|
*
|
||||||
|
* Body (optional): { adminNotes?: string }
|
||||||
|
*/
|
||||||
|
export async function POST(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
let admin;
|
||||||
|
try {
|
||||||
|
await requirePlatformRole();
|
||||||
|
admin = await getSessionUser();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
|
}
|
||||||
|
if (!admin) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
const { id } = await params;
|
||||||
|
const body = await request.json().catch(() => ({}));
|
||||||
|
const adminNotes =
|
||||||
|
typeof body.adminNotes === "string" && body.adminNotes.length <= 1000
|
||||||
|
? body.adminNotes
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// 1. Load + sanity-check.
|
||||||
|
const req = await getSkillActivationRequestById(id);
|
||||||
|
if (!req) {
|
||||||
|
return NextResponse.json({ error: "Request not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
if (req.status !== "pending") {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Request is already ${req.status}` },
|
||||||
|
{ status: 409 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Patch the tenant CR — add the skill if not already present.
|
||||||
|
// Defensive: if the tenant was deleted or the skill was somehow
|
||||||
|
// added by another path, we still proceed without duplicate.
|
||||||
|
let tenant;
|
||||||
|
try {
|
||||||
|
tenant = await getTenant(req.tenantName);
|
||||||
|
} catch (e) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Tenant ${req.tenantName} not found: ${safeError(e, "")}` },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!tenant) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Tenant ${req.tenantName} not found` },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentPackages = new Set<string>(tenant.spec.packages ?? []);
|
||||||
|
const alreadyEnabled = currentPackages.has(req.skillId);
|
||||||
|
if (!alreadyEnabled) {
|
||||||
|
currentPackages.add(req.skillId);
|
||||||
|
try {
|
||||||
|
await patchTenantSpec(req.tenantName, {
|
||||||
|
packages: [...currentPackages],
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Failed to enable skill on tenant: ${safeError(e, "")}` },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Record skill event (only if we actually added it — re-adding
|
||||||
|
// would skew the day-count). Best-effort.
|
||||||
|
if (!alreadyEnabled) {
|
||||||
|
try {
|
||||||
|
await recordSkillEvents(req.tenantName, req.zitadelOrgId, [req.skillId], []);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(
|
||||||
|
`Failed to record skill_event after approve (request ${id}):`,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Flip request to approved.
|
||||||
|
const updated = await updateSkillActivationRequestStatus(id, "approved", {
|
||||||
|
reviewedBy: admin.id,
|
||||||
|
adminNotes,
|
||||||
|
});
|
||||||
|
if (!updated) {
|
||||||
|
// Race: another admin tab flipped it between our read and now.
|
||||||
|
// The K8s patch already happened so we don't roll back; log so
|
||||||
|
// the human notices.
|
||||||
|
console.error(
|
||||||
|
`Request ${id} was no longer pending when we tried to mark approved; K8s patch already applied.`
|
||||||
|
);
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error:
|
||||||
|
"Request status changed during approval; the skill may have been enabled. Check the queue.",
|
||||||
|
},
|
||||||
|
{ status: 409 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Email the requester (best-effort). Look up their email via
|
||||||
|
// ZITADEL since we only stored the userId on the request.
|
||||||
|
try {
|
||||||
|
const orgUsers = await listOrgUsers(req.zitadelOrgId);
|
||||||
|
const requester = orgUsers.find((u) => u.userId === req.zitadelUserId);
|
||||||
|
if (requester?.email) {
|
||||||
|
const def = getPackageDef(req.skillId);
|
||||||
|
await sendSkillActivationApprovalEmail({
|
||||||
|
to: requester.email,
|
||||||
|
contactName: requester.displayName || requester.email,
|
||||||
|
skillName: def?.name ?? req.skillId,
|
||||||
|
tenantName: req.tenantName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`Failed to send approval email for request ${id}:`, e);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(updated);
|
||||||
|
}
|
||||||
129
src/app/api/admin/skills/pending/[id]/reject/route.ts
Normal file
129
src/app/api/admin/skills/pending/[id]/reject/route.ts
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { getSessionUser, requirePlatformRole } from "@/lib/session";
|
||||||
|
import {
|
||||||
|
getSkillActivationRequestById,
|
||||||
|
updateSkillActivationRequestStatus,
|
||||||
|
} from "@/lib/db";
|
||||||
|
import { getPackageDef } from "@/lib/packages";
|
||||||
|
import { listOrgUsers } from "@/lib/zitadel";
|
||||||
|
import { sendSkillActivationRejectionEmail } from "@/lib/email";
|
||||||
|
import { deletePackageSecrets } from "@/lib/openbao";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/admin/skills/pending/[id]/reject
|
||||||
|
*
|
||||||
|
* Reject a pending activation request with a required reason that
|
||||||
|
* is shown to the customer (mirroring the tenant-request rejection
|
||||||
|
* flow). The skill is NOT added to the tenant spec — it was never
|
||||||
|
* there in the first place — so the customer's enable attempt is
|
||||||
|
* effectively cancelled. They can try again from their tenant
|
||||||
|
* settings after seeing the reason (a new pending row will be
|
||||||
|
* created by their next toggle).
|
||||||
|
*
|
||||||
|
* Body:
|
||||||
|
* {
|
||||||
|
* reason: string (1..1000 chars, required),
|
||||||
|
* adminNotes?: string (optional, not shown to customer)
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
|
||||||
|
const bodySchema = z.object({
|
||||||
|
reason: z.string().min(1).max(1000),
|
||||||
|
adminNotes: z.string().max(1000).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
let admin;
|
||||||
|
try {
|
||||||
|
await requirePlatformRole();
|
||||||
|
admin = await getSessionUser();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
|
}
|
||||||
|
if (!admin) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
const { id } = await params;
|
||||||
|
const body = await request.json().catch(() => ({}));
|
||||||
|
const parsed = bodySchema.safeParse(body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Invalid request", details: parsed.error.flatten() },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const req = await getSkillActivationRequestById(id);
|
||||||
|
if (!req) {
|
||||||
|
return NextResponse.json({ error: "Request not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
if (req.status !== "pending") {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Request is already ${req.status}` },
|
||||||
|
{ status: 409 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await updateSkillActivationRequestStatus(id, "rejected", {
|
||||||
|
reviewedBy: admin.id,
|
||||||
|
rejectionReason: parsed.data.reason,
|
||||||
|
adminNotes: parsed.data.adminNotes ?? null,
|
||||||
|
});
|
||||||
|
if (!updated) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Request status changed during rejection." },
|
||||||
|
{ status: 409 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup: if the package needed customer-provided secrets, the
|
||||||
|
// user submitted them BEFORE the gate fired (handleSubmitSecrets
|
||||||
|
// in PackageCard writes to OpenBao then PATCHes). Those secrets
|
||||||
|
// are now orphaned — the package never made it into spec, won't
|
||||||
|
// be re-attempted unless the user retries with fresh credentials.
|
||||||
|
// Best-effort delete: keep the OpenBao path clean, avoid stale
|
||||||
|
// creds lurking. Idempotent (404 is fine). Failure is logged but
|
||||||
|
// not propagated — the rejection itself already succeeded.
|
||||||
|
//
|
||||||
|
// We deliberately skip customProvisioning packages here. Those
|
||||||
|
// mint platform-side credentials via a dedicated endpoint and
|
||||||
|
// need symmetric deprovisioning (POST /[pkg.id] → DELETE
|
||||||
|
// /[pkg.id]). Calling deletePackageSecrets wouldn't revoke them
|
||||||
|
// — admin handles that path manually if the rejected request had
|
||||||
|
// already minted resources.
|
||||||
|
const def = getPackageDef(req.skillId);
|
||||||
|
if (def?.requiresSecrets && !def.customProvisioning) {
|
||||||
|
try {
|
||||||
|
await deletePackageSecrets(req.tenantName, req.skillId);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(
|
||||||
|
`Failed to delete orphan secrets for ${req.tenantName}/${req.skillId} after reject:`,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Email the requester with the reason — best-effort.
|
||||||
|
try {
|
||||||
|
const orgUsers = await listOrgUsers(req.zitadelOrgId);
|
||||||
|
const requester = orgUsers.find((u) => u.userId === req.zitadelUserId);
|
||||||
|
if (requester?.email) {
|
||||||
|
const def = getPackageDef(req.skillId);
|
||||||
|
await sendSkillActivationRejectionEmail({
|
||||||
|
to: requester.email,
|
||||||
|
contactName: requester.displayName || requester.email,
|
||||||
|
skillName: def?.name ?? req.skillId,
|
||||||
|
tenantName: req.tenantName,
|
||||||
|
reason: parsed.data.reason,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`Failed to send rejection email for request ${id}:`, e);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(updated);
|
||||||
|
}
|
||||||
22
src/app/api/admin/skills/pending/route.ts
Normal file
22
src/app/api/admin/skills/pending/route.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { requirePlatformRole } from "@/lib/session";
|
||||||
|
import { listPendingSkillActivationRequests } from "@/lib/db";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/admin/skills/pending
|
||||||
|
*
|
||||||
|
* List all pending skill-activation requests across all tenants
|
||||||
|
* and orgs. Powers the admin queue at /admin/skills/pending.
|
||||||
|
*
|
||||||
|
* Platform-role only. Returns up to 500 rows oldest-first so the
|
||||||
|
* queue UI shows the oldest requests at the top (FIFO).
|
||||||
|
*/
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
await requirePlatformRole();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
|
}
|
||||||
|
const rows = await listPendingSkillActivationRequests();
|
||||||
|
return NextResponse.json(rows);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
51
src/app/api/billing/auto-charge/route.ts
Normal file
51
src/app/api/billing/auto-charge/route.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { getSessionUser } from "@/lib/session";
|
||||||
|
import { setAutoChargeEnabled } from "@/lib/db";
|
||||||
|
import { safeError } from "@/lib/errors";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/billing/auto-charge
|
||||||
|
*
|
||||||
|
* Phase 9. Toggle the auto_charge_enabled flag on the caller's
|
||||||
|
* org. The body is `{ enabled: boolean }`.
|
||||||
|
*
|
||||||
|
* When OFF: invoices issued for this org won't trigger an
|
||||||
|
* auto-charge against the saved card. The customer pays
|
||||||
|
* manually (or admin marks paid) — same flow as a bank-transfer
|
||||||
|
* customer.
|
||||||
|
*
|
||||||
|
* When ON: future invoice issuance attempts the auto-charge.
|
||||||
|
* No effect if there's no saved card on file.
|
||||||
|
*
|
||||||
|
* Idempotent: setting OFF on an already-OFF flag is a no-op
|
||||||
|
* (same outcome).
|
||||||
|
*/
|
||||||
|
|
||||||
|
const bodySchema = z.object({
|
||||||
|
enabled: z.boolean(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const user = await getSessionUser();
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
const body = await request.json().catch(() => ({}));
|
||||||
|
const parsed = bodySchema.safeParse(body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Invalid request", details: parsed.error.flatten() },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await setAutoChargeEnabled(user.orgId, parsed.data.enabled);
|
||||||
|
return NextResponse.json({ enabled: parsed.data.enabled });
|
||||||
|
} catch (e) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: safeError(e, "Failed to update auto-charge setting") },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
75
src/app/api/billing/current/route.ts
Normal file
75
src/app/api/billing/current/route.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { getSessionUser } from "@/lib/session";
|
||||||
|
import { computeInvoiceDraft } from "@/lib/billing";
|
||||||
|
import { listInvoices } from "@/lib/db";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/billing/current
|
||||||
|
*
|
||||||
|
* Running total for the current calendar month — what the
|
||||||
|
* customer will be billed if no further activity happens. Uses
|
||||||
|
* the same compute pipeline as the final invoice (LiteLLM spend,
|
||||||
|
* Threema usage, skill day-counting, proration) so the number
|
||||||
|
* the customer sees matches what they'll eventually receive
|
||||||
|
* within the limits of intra-month drift.
|
||||||
|
*
|
||||||
|
* If an invoice has ALREADY been issued for the current month
|
||||||
|
* (e.g. cron ran early, admin manually generated), we return
|
||||||
|
* that issued invoice instead — no point showing a draft that
|
||||||
|
* duplicates a real invoice.
|
||||||
|
*
|
||||||
|
* Returns:
|
||||||
|
* { issued: Invoice } // current-month invoice exists
|
||||||
|
* { draft: InvoiceDraft } // still accruing
|
||||||
|
* { error: ... } // org missing billing config
|
||||||
|
*
|
||||||
|
* Cost: 1 LiteLLM HTTP call + 1 Threema HTTP call + a handful of
|
||||||
|
* DB queries per skill. Sub-second typically. No caching; called
|
||||||
|
* on demand from the customer billing page.
|
||||||
|
*/
|
||||||
|
export async function GET() {
|
||||||
|
const user = await getSessionUser();
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
// Resolve current calendar month from UTC. Billing is UTC-day
|
||||||
|
// based throughout (see billing.ts iterDays comment), so the
|
||||||
|
// running total inherits that same semantics.
|
||||||
|
const now = new Date();
|
||||||
|
const year = now.getUTCFullYear();
|
||||||
|
const month = now.getUTCMonth() + 1; // 1-12
|
||||||
|
const periodMonth = `${year}-${String(month).padStart(2, "0")}`;
|
||||||
|
|
||||||
|
// 1. Has the current month already been invoiced?
|
||||||
|
const existing = await listInvoices({
|
||||||
|
zitadelOrgId: user.orgId,
|
||||||
|
periodMonth,
|
||||||
|
limit: 1,
|
||||||
|
});
|
||||||
|
if (existing.length > 0) {
|
||||||
|
return NextResponse.json({ issued: existing[0] });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Otherwise compute the draft. Falls through to error if the
|
||||||
|
// org doesn't have a billing config yet (no Address on file).
|
||||||
|
try {
|
||||||
|
const draft = await computeInvoiceDraft({
|
||||||
|
zitadelOrgId: user.orgId,
|
||||||
|
year,
|
||||||
|
month,
|
||||||
|
});
|
||||||
|
return NextResponse.json({ draft });
|
||||||
|
} catch (e: any) {
|
||||||
|
// Most likely: org_billing row missing. We surface a 200 with a
|
||||||
|
// soft error code rather than 500 — the customer-side widget
|
||||||
|
// displays a helpful "complete your billing details" message
|
||||||
|
// instead of a stack trace.
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: e?.message ?? "Could not compute running total.",
|
||||||
|
code: e?.code ?? "COMPUTE_FAILED",
|
||||||
|
},
|
||||||
|
{ status: 200 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
105
src/app/api/billing/invoices/[invoiceNumber]/pay/route.ts
Normal file
105
src/app/api/billing/invoices/[invoiceNumber]/pay/route.ts
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { getSessionUser } from "@/lib/session";
|
||||||
|
import {
|
||||||
|
getInvoiceByNumberForOrg,
|
||||||
|
getOrgBilling,
|
||||||
|
} from "@/lib/db";
|
||||||
|
import {
|
||||||
|
createCheckoutSessionForInvoice,
|
||||||
|
ensureStripeCustomerForOrg,
|
||||||
|
} from "@/lib/stripe";
|
||||||
|
import { safeError } from "@/lib/errors";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/billing/invoices/[invoiceNumber]/pay
|
||||||
|
*
|
||||||
|
* Initiates a Stripe Checkout Session for an open invoice. Returns
|
||||||
|
* `{ url }` — the browser is expected to navigate to that URL,
|
||||||
|
* where Stripe hosts the payment UI.
|
||||||
|
*
|
||||||
|
* Authorization: caller must belong to the invoice's org (the DB
|
||||||
|
* query enforces this — wrong-org returns 404, indistinguishable
|
||||||
|
* from a non-existent invoice).
|
||||||
|
*
|
||||||
|
* Preconditions enforced server-side:
|
||||||
|
* - Invoice exists for caller's org
|
||||||
|
* - Invoice status is 'open' or 'overdue' (paid/void/draft/uncollectible
|
||||||
|
* all reject — already-paid invoices in particular must not
|
||||||
|
* create a second Checkout Session, even though Stripe would
|
||||||
|
* deduplicate the actual charge)
|
||||||
|
*
|
||||||
|
* The Stripe Customer for the org is lazily ensured here — first
|
||||||
|
* card click on an org creates the customer; subsequent clicks
|
||||||
|
* reuse the persisted stripe_customer_id.
|
||||||
|
*/
|
||||||
|
export async function POST(
|
||||||
|
_request: Request,
|
||||||
|
{ params }: { params: Promise<{ invoiceNumber: string }> }
|
||||||
|
) {
|
||||||
|
const user = await getSessionUser();
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
const { invoiceNumber } = await params;
|
||||||
|
|
||||||
|
const detail = await getInvoiceByNumberForOrg(invoiceNumber, user.orgId);
|
||||||
|
if (!detail) {
|
||||||
|
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
const inv = detail.invoice;
|
||||||
|
if (inv.status !== "open" && inv.status !== "overdue") {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error:
|
||||||
|
inv.status === "paid"
|
||||||
|
? "This invoice has already been paid."
|
||||||
|
: `This invoice cannot be paid online (status: ${inv.status}).`,
|
||||||
|
},
|
||||||
|
{ status: 409 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// We need org_billing for the customer creation address. The
|
||||||
|
// invoice has a SNAPSHOT but that's frozen at issue time; for
|
||||||
|
// creating/updating the Stripe customer we want the current
|
||||||
|
// address (which may have been corrected since the invoice).
|
||||||
|
// Snapshot is still authoritative on the invoice PDF and total.
|
||||||
|
const orgBilling = await getOrgBilling(user.orgId);
|
||||||
|
if (!orgBilling) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Billing details are not configured for your organization." },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const customerId = await ensureStripeCustomerForOrg({
|
||||||
|
zitadelOrgId: user.orgId,
|
||||||
|
companyName: orgBilling.companyName,
|
||||||
|
billingEmail: orgBilling.billingEmail,
|
||||||
|
address: {
|
||||||
|
line1: orgBilling.streetAddress,
|
||||||
|
postalCode: orgBilling.postalCode,
|
||||||
|
city: orgBilling.city,
|
||||||
|
country: orgBilling.country,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const baseUrl =
|
||||||
|
process.env.APP_BASE_URL ?? "https://app.pieced.ch";
|
||||||
|
const { url } = await createCheckoutSessionForInvoice({
|
||||||
|
invoice: inv,
|
||||||
|
customerId,
|
||||||
|
baseUrl,
|
||||||
|
});
|
||||||
|
return NextResponse.json({ url });
|
||||||
|
} catch (e) {
|
||||||
|
console.error(
|
||||||
|
`Failed to create Checkout Session for invoice ${invoiceNumber}:`,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: safeError(e, "Failed to start card payment.") },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
43
src/app/api/billing/invoices/[invoiceNumber]/pdf/route.ts
Normal file
43
src/app/api/billing/invoices/[invoiceNumber]/pdf/route.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { getSessionUser } from "@/lib/session";
|
||||||
|
import { getInvoiceByNumberForOrg, getInvoicePdf } from "@/lib/db";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/billing/invoices/[invoiceNumber]/pdf
|
||||||
|
*
|
||||||
|
* Customer-facing PDF download. Same Uint8Array.from() variance
|
||||||
|
* fix as the admin route — see /api/admin/billing/invoices/[id]/pdf
|
||||||
|
* for the rationale.
|
||||||
|
*
|
||||||
|
* Authorization: looks up the invoice by number with org scope
|
||||||
|
* baked into the query, then re-fetches the PDF blob by id. A
|
||||||
|
* customer can't probe another org's invoice numbers — they get
|
||||||
|
* 404 either way.
|
||||||
|
*/
|
||||||
|
export async function GET(
|
||||||
|
_request: Request,
|
||||||
|
{ params }: { params: Promise<{ invoiceNumber: string }> }
|
||||||
|
) {
|
||||||
|
const user = await getSessionUser();
|
||||||
|
if (!user) {
|
||||||
|
return new NextResponse("Unauthorized", { status: 401 });
|
||||||
|
}
|
||||||
|
const { invoiceNumber } = await params;
|
||||||
|
const detail = await getInvoiceByNumberForOrg(invoiceNumber, user.orgId);
|
||||||
|
if (!detail) {
|
||||||
|
return new NextResponse("Not found", { status: 404 });
|
||||||
|
}
|
||||||
|
const pdf = await getInvoicePdf(detail.invoice.id);
|
||||||
|
if (!pdf) {
|
||||||
|
return new NextResponse("PDF not available", { status: 404 });
|
||||||
|
}
|
||||||
|
const body = Uint8Array.from(pdf.data);
|
||||||
|
return new NextResponse(body, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/pdf",
|
||||||
|
"Content-Disposition": `inline; filename="${pdf.filename}"`,
|
||||||
|
"Cache-Control": "private, max-age=0, must-revalidate",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
27
src/app/api/billing/invoices/[invoiceNumber]/route.ts
Normal file
27
src/app/api/billing/invoices/[invoiceNumber]/route.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { getSessionUser } from "@/lib/session";
|
||||||
|
import { getInvoiceByNumberForOrg } from "@/lib/db";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/billing/invoices/[invoiceNumber]
|
||||||
|
*
|
||||||
|
* Customer-scoped detail lookup by invoice number (the human-
|
||||||
|
* readable YYYY-NNNNN format the customer sees on the PDF). The
|
||||||
|
* org filter is part of the DB query — a customer probing another
|
||||||
|
* org's invoice number gets the same 404 as a non-existent one.
|
||||||
|
*/
|
||||||
|
export async function GET(
|
||||||
|
_request: Request,
|
||||||
|
{ params }: { params: Promise<{ invoiceNumber: string }> }
|
||||||
|
) {
|
||||||
|
const user = await getSessionUser();
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
const { invoiceNumber } = await params;
|
||||||
|
const detail = await getInvoiceByNumberForOrg(invoiceNumber, user.orgId);
|
||||||
|
if (!detail) {
|
||||||
|
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
return NextResponse.json(detail);
|
||||||
|
}
|
||||||
39
src/app/api/billing/invoices/route.ts
Normal file
39
src/app/api/billing/invoices/route.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { getSessionUser } from "@/lib/session";
|
||||||
|
import { listInvoices, syncOverdueInvoices } from "@/lib/db";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/billing/invoices
|
||||||
|
*
|
||||||
|
* Customer-scoped list of invoices for the caller's org. Returns
|
||||||
|
* a flat array of Invoice headers (no line items — those are
|
||||||
|
* fetched separately by /[invoiceNumber]).
|
||||||
|
*
|
||||||
|
* Status filter is implicit: we return every invoice the
|
||||||
|
* customer's org has, all statuses (issued/paid/overdue/void)
|
||||||
|
* because the customer wants a single billing-history view.
|
||||||
|
*
|
||||||
|
* Before returning we run syncOverdueInvoices() so the displayed
|
||||||
|
* status reflects the current date — issued invoices past their
|
||||||
|
* due_at flip to 'overdue'. Cheap, idempotent, and avoids needing
|
||||||
|
* a separate cron for this transition.
|
||||||
|
*/
|
||||||
|
export async function GET() {
|
||||||
|
const user = await getSessionUser();
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
// Personal accounts have an org too — they share the same shape;
|
||||||
|
// their invoices show up under that synthetic org id.
|
||||||
|
try {
|
||||||
|
await syncOverdueInvoices();
|
||||||
|
} catch (e) {
|
||||||
|
// Non-fatal — display stale status rather than 500.
|
||||||
|
console.warn("syncOverdueInvoices failed in /api/billing/invoices:", e);
|
||||||
|
}
|
||||||
|
const invoices = await listInvoices({
|
||||||
|
zitadelOrgId: user.orgId,
|
||||||
|
limit: 200,
|
||||||
|
});
|
||||||
|
return NextResponse.json(invoices);
|
||||||
|
}
|
||||||
46
src/app/api/billing/saved-card/route.ts
Normal file
46
src/app/api/billing/saved-card/route.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { getSessionUser } from "@/lib/session";
|
||||||
|
import { clearSavedPaymentMethod, getOrgBillingConfig } from "@/lib/db";
|
||||||
|
import { detachPaymentMethod } from "@/lib/stripe";
|
||||||
|
import { safeError } from "@/lib/errors";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/billing/saved-card
|
||||||
|
*
|
||||||
|
* Phase 9. Remove the saved card for the caller's org. Detaches
|
||||||
|
* the PaymentMethod in Stripe (so it can't be charged again) and
|
||||||
|
* clears the four display columns + the pm_id reference locally.
|
||||||
|
*
|
||||||
|
* Idempotent: calling on an org with no saved card returns 200
|
||||||
|
* (the desired end-state is already reached).
|
||||||
|
*
|
||||||
|
* Auth: any signed-in member of the org. Same reasoning as the
|
||||||
|
* setup endpoint — card removal is a customer-visible action; it
|
||||||
|
* doesn't leak anything, and a non-owner needing to remove a
|
||||||
|
* stolen-card-on-file shouldn't be blocked by role gating.
|
||||||
|
*/
|
||||||
|
export async function DELETE() {
|
||||||
|
const user = await getSessionUser();
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const cfg = await getOrgBillingConfig(user.orgId);
|
||||||
|
if (!cfg || !cfg.stripeDefaultPaymentMethodId) {
|
||||||
|
// Already empty — no-op, return success.
|
||||||
|
return NextResponse.json({ removed: false });
|
||||||
|
}
|
||||||
|
// Stripe detach first. If it fails for a real reason (network,
|
||||||
|
// 500 from Stripe), we don't clear the DB — admin can retry.
|
||||||
|
// 404 is treated as success by detachPaymentMethod (PM already
|
||||||
|
// gone), so we proceed to clear the DB regardless.
|
||||||
|
await detachPaymentMethod(cfg.stripeDefaultPaymentMethodId);
|
||||||
|
await clearSavedPaymentMethod(user.orgId);
|
||||||
|
return NextResponse.json({ removed: true });
|
||||||
|
} catch (e) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: safeError(e, "Failed to remove card") },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
75
src/app/api/billing/setup-card/route.ts
Normal file
75
src/app/api/billing/setup-card/route.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { getSessionUser } from "@/lib/session";
|
||||||
|
import { getOrgBilling } from "@/lib/db";
|
||||||
|
import {
|
||||||
|
createSetupCheckoutSession,
|
||||||
|
ensureStripeCustomerForOrg,
|
||||||
|
} from "@/lib/stripe";
|
||||||
|
import { safeError } from "@/lib/errors";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/billing/setup-card
|
||||||
|
*
|
||||||
|
* Phase 9. Customer-initiated "Set up auto-pay" / "Update card"
|
||||||
|
* flow. Creates a Checkout session in setup mode and returns its
|
||||||
|
* URL — the caller redirects the browser. On completion, the
|
||||||
|
* webhook handler saves the resulting PaymentMethod's display
|
||||||
|
* fields against this org's billing config.
|
||||||
|
*
|
||||||
|
* Auth: any signed-in member of the org. We don't owner-gate this
|
||||||
|
* because non-owners might legitimately need to update payment
|
||||||
|
* (e.g., for a team they administer). The actual card data is
|
||||||
|
* collected by Stripe, not us — there's nothing to leak from
|
||||||
|
* misuse here.
|
||||||
|
*
|
||||||
|
* Requires an existing billing snapshot (org_billing row). If
|
||||||
|
* absent, returns 400 — the customer hasn't set their billing
|
||||||
|
* address yet, and Stripe needs the address for the customer
|
||||||
|
* object.
|
||||||
|
*/
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const user = await getSessionUser();
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
const orgBilling = await getOrgBilling(user.orgId);
|
||||||
|
if (!orgBilling) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Billing address required before saving a card." },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
// Ensure the Stripe customer exists. Idempotent — if we
|
||||||
|
// already created one for this org (e.g. from a prior
|
||||||
|
// "Pay by Card" Checkout), it's reused.
|
||||||
|
const customerId = await ensureStripeCustomerForOrg({
|
||||||
|
zitadelOrgId: user.orgId,
|
||||||
|
companyName: orgBilling.companyName,
|
||||||
|
billingEmail: orgBilling.billingEmail,
|
||||||
|
address: {
|
||||||
|
line1: orgBilling.streetAddress,
|
||||||
|
postalCode: orgBilling.postalCode,
|
||||||
|
city: orgBilling.city,
|
||||||
|
country: orgBilling.country,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
// Base URL for redirect targets — must be the public-facing
|
||||||
|
// origin since Stripe redirects the browser back. Behind an
|
||||||
|
// ingress (Cedric's setup) request.url is the internal pod
|
||||||
|
// address ("0.0.0.0:3000" / cluster.svc), useless for the
|
||||||
|
// browser. Same env-var pattern as the invoice pay endpoint.
|
||||||
|
const baseUrl =
|
||||||
|
process.env.APP_BASE_URL ?? "https://app.pieced.ch";
|
||||||
|
const session = await createSetupCheckoutSession({
|
||||||
|
customerId,
|
||||||
|
baseUrl,
|
||||||
|
});
|
||||||
|
return NextResponse.json({ url: session.url });
|
||||||
|
} catch (e) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: safeError(e, "Failed to start card setup") },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
64
src/app/api/credit-notes/[number]/pdf/route.ts
Normal file
64
src/app/api/credit-notes/[number]/pdf/route.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { getSessionUser } from "@/lib/session";
|
||||||
|
import {
|
||||||
|
getCreditNoteByNumber,
|
||||||
|
getCreditNoteByNumberForOrg,
|
||||||
|
getCreditNotePdf,
|
||||||
|
} from "@/lib/db";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/credit-notes/[number]/pdf
|
||||||
|
*
|
||||||
|
* Phase 7. Customer-facing PDF download for a credit note. Returns
|
||||||
|
* the binary PDF with Content-Disposition: inline so the browser
|
||||||
|
* renders it in-tab (matching the invoice download behaviour). The
|
||||||
|
* customer's email links here.
|
||||||
|
*
|
||||||
|
* Authorization:
|
||||||
|
* - The caller must be authenticated.
|
||||||
|
* - For customer-org callers, the credit note must belong to their
|
||||||
|
* org (orgId-scoped lookup).
|
||||||
|
* - Platform admins can fetch any credit note (cross-org lookup).
|
||||||
|
*
|
||||||
|
* Returns 404 in both "doesn't exist" and "exists but not yours"
|
||||||
|
* cases — leak-safe identical to invoice lookup.
|
||||||
|
*/
|
||||||
|
export async function GET(
|
||||||
|
_request: Request,
|
||||||
|
{ params }: { params: Promise<{ number: string }> }
|
||||||
|
) {
|
||||||
|
const user = await getSessionUser();
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
const { number } = await params;
|
||||||
|
// URL-decoded number — the route param comes URL-encoded.
|
||||||
|
const decodedNumber = decodeURIComponent(number);
|
||||||
|
const cn = user.isPlatform
|
||||||
|
? await getCreditNoteByNumber(decodedNumber)
|
||||||
|
: await getCreditNoteByNumberForOrg(decodedNumber, user.orgId);
|
||||||
|
if (!cn) {
|
||||||
|
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
const pdf = await getCreditNotePdf(cn.id);
|
||||||
|
if (!pdf) {
|
||||||
|
// The credit note exists but the PDF was never attached. Most
|
||||||
|
// likely a render failure during issuance — the credit note
|
||||||
|
// row is still authoritative, the PDF needs re-rendering.
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error:
|
||||||
|
"Credit note exists but its PDF has not been rendered. Please contact support.",
|
||||||
|
},
|
||||||
|
{ status: 502 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return new NextResponse(new Uint8Array(pdf.data), {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/pdf",
|
||||||
|
"Content-Disposition": `inline; filename="${pdf.filename}"`,
|
||||||
|
"Cache-Control": "private, no-cache",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
42
src/app/api/cron/issue-monthly/route.ts
Normal file
42
src/app/api/cron/issue-monthly/route.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { runMonthlyIssuance, verifyCronBearer } from "@/lib/cron";
|
||||||
|
import { safeError } from "@/lib/errors";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/cron/issue-monthly
|
||||||
|
*
|
||||||
|
* Machine entry point for the monthly issuance sweep. Authentication
|
||||||
|
* is the shared bearer token in CRON_BEARER_TOKEN, injected from
|
||||||
|
* OpenBao via the portal-cron K8s Secret. The K8s CronJob sends:
|
||||||
|
*
|
||||||
|
* curl -X POST -H "Authorization: Bearer $CRON_BEARER_TOKEN" \
|
||||||
|
* https://app.pieced.ch/api/cron/issue-monthly
|
||||||
|
*
|
||||||
|
* The sweep targets the calendar month that ended just before
|
||||||
|
* "now" in Europe/Zurich. Running it on June 1st at 00:30 Swiss
|
||||||
|
* time bills May; running it on July 5th bills June; etc. The
|
||||||
|
* uniqueness constraint on (org, period_start) makes re-runs
|
||||||
|
* harmless — already-issued orgs are counted as skipped.
|
||||||
|
*
|
||||||
|
* Returns the summary {success, failure, skipped} JSON. The
|
||||||
|
* CronJob doesn't look at the response body (just the status
|
||||||
|
* code) but having a useful one helps debugging via curl.
|
||||||
|
*/
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
if (!verifyCronBearer(request.headers.get("authorization"))) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const { runId, summary } = await runMonthlyIssuance({
|
||||||
|
triggeredBy: "cron",
|
||||||
|
});
|
||||||
|
return NextResponse.json({ runId, ...summary });
|
||||||
|
} catch (e) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: safeError(e, "Issuance sweep failed.") },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
33
src/app/api/cron/send-reminders/route.ts
Normal file
33
src/app/api/cron/send-reminders/route.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { runReminderSweep, verifyCronBearer } from "@/lib/cron";
|
||||||
|
import { safeError } from "@/lib/errors";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/cron/send-reminders
|
||||||
|
*
|
||||||
|
* Machine entry point for the daily reminder sweep. Same auth
|
||||||
|
* (bearer token in CRON_BEARER_TOKEN) and the same response
|
||||||
|
* contract as /api/cron/issue-monthly.
|
||||||
|
*
|
||||||
|
* Schedule: 09:00 Europe/Zurich daily. Picks invoices that are
|
||||||
|
* past their due date and haven't received the corresponding
|
||||||
|
* reminder level yet; sends one email per invoice per run.
|
||||||
|
*/
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
if (!verifyCronBearer(request.headers.get("authorization"))) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const { runId, summary } = await runReminderSweep({
|
||||||
|
triggeredBy: "cron",
|
||||||
|
});
|
||||||
|
return NextResponse.json({ runId, ...summary });
|
||||||
|
} catch (e) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: safeError(e, "Reminder sweep failed.") },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,11 +2,15 @@ import { NextRequest, NextResponse } from "next/server";
|
|||||||
import { getSessionUser, canMutate } from "@/lib/session";
|
import { getSessionUser, canMutate } from "@/lib/session";
|
||||||
import {
|
import {
|
||||||
createTenantRequest,
|
createTenantRequest,
|
||||||
|
createTenantRequestPendingPayment,
|
||||||
|
deletePendingPaymentRequest,
|
||||||
|
getOrgBillingConfig,
|
||||||
getTenantRequestById,
|
getTenantRequestById,
|
||||||
listTenantRequestsByOrgId,
|
listTenantRequestsByOrgId,
|
||||||
listActiveTenantRequestsByOrgId,
|
listActiveTenantRequestsByOrgId,
|
||||||
getMostRecentApprovedRequestForOrg,
|
getMostRecentApprovedRequestForOrg,
|
||||||
getOrgBilling,
|
getOrgBilling,
|
||||||
|
getPlatformPricing,
|
||||||
upsertOrgBilling,
|
upsertOrgBilling,
|
||||||
} from "@/lib/db";
|
} from "@/lib/db";
|
||||||
import { getTenant, listTenants } from "@/lib/k8s";
|
import { getTenant, listTenants } from "@/lib/k8s";
|
||||||
@@ -19,7 +23,18 @@ import { sendAdminNotificationEmail } from "@/lib/email";
|
|||||||
import { encryptSecrets } from "@/lib/crypto";
|
import { encryptSecrets } from "@/lib/crypto";
|
||||||
import { isPersonalOrgName } from "@/lib/personal-org";
|
import { isPersonalOrgName } from "@/lib/personal-org";
|
||||||
import { onboardingSchema, billingAddressSchema } from "@/lib/validation";
|
import { onboardingSchema, billingAddressSchema } from "@/lib/validation";
|
||||||
import type { OnboardingInput, PiecedTenant, TenantRequest } from "@/types";
|
import {
|
||||||
|
createSetupFeeCheckoutSession,
|
||||||
|
ensureStripeCustomerForOrg,
|
||||||
|
} from "@/lib/stripe";
|
||||||
|
import { createTenantSetupFeeInvoice } from "@/lib/billing";
|
||||||
|
import { deriveTenantName } from "@/lib/tenant-naming";
|
||||||
|
import type {
|
||||||
|
InvoiceBillingSnapshot,
|
||||||
|
OnboardingInput,
|
||||||
|
PiecedTenant,
|
||||||
|
TenantRequest,
|
||||||
|
} from "@/types";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -252,11 +267,24 @@ export async function POST(request: Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// For follow-up instances, prefer the on-file company name and contact
|
// The audit copy of company name on this request stays inherited
|
||||||
// details; the user can't change those by re-typing them in the wizard.
|
// from the first request in the org — it's a historical snapshot
|
||||||
|
// of the company name at the time the request was created, and
|
||||||
|
// org_billing is now the canonical source for current values.
|
||||||
|
//
|
||||||
|
// Phase 6 fix4: contactName and contactEmail are NOT inherited.
|
||||||
|
// They identify whoever submitted THIS specific request (drives
|
||||||
|
// admin display, support ticket routing, and email greetings).
|
||||||
|
// The previous "prior?.contactName ?? user.name" pattern locked
|
||||||
|
// the contact to whoever first onboarded the org, which broke for
|
||||||
|
// any subsequent submission by a different user — admin saw the
|
||||||
|
// wrong name, support emails went to the wrong person, and the
|
||||||
|
// actual submitter had no way to correct it because the wizard
|
||||||
|
// doesn't expose a contact-name input. The fix is simply to use
|
||||||
|
// the current session user every time.
|
||||||
const companyName = prior?.companyName ?? user.orgName;
|
const companyName = prior?.companyName ?? user.orgName;
|
||||||
const contactName = prior?.contactName ?? user.name;
|
const contactName = user.name;
|
||||||
const contactEmail = prior?.contactEmail ?? user.email;
|
const contactEmail = user.email;
|
||||||
|
|
||||||
// Bug 35: org-scoped billing.
|
// Bug 35: org-scoped billing.
|
||||||
//
|
//
|
||||||
@@ -389,7 +417,84 @@ export async function POST(request: Request) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const tenantRequest = await createTenantRequest({
|
// Phase 9b: enforce auto-pay before accepting an order. If the
|
||||||
|
// org has no saved card OR has explicitly disabled auto-charge,
|
||||||
|
// the order can't proceed — return 402 with a link to the
|
||||||
|
// settings page where they can set up auto-pay. The wizard
|
||||||
|
// surfaces this as a friendly redirect rather than an error.
|
||||||
|
const cfg = await getOrgBillingConfig(user.orgId);
|
||||||
|
const hasSavedCard = !!cfg.stripeDefaultPaymentMethodId;
|
||||||
|
const autoChargeOn = cfg.autoChargeEnabled !== false;
|
||||||
|
if (!hasSavedCard || !autoChargeOn) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error:
|
||||||
|
"Auto-pay must be set up before ordering a new instance. " +
|
||||||
|
"Please save a card and ensure auto-pay is enabled on /settings/billing.",
|
||||||
|
code: "auto_pay_required",
|
||||||
|
redirectTo: "/settings/billing",
|
||||||
|
},
|
||||||
|
{ status: 402 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look up the setup fee. If it's 0 we skip the Checkout flow
|
||||||
|
// entirely and create a normal pending request (same as the
|
||||||
|
// pre-Phase-9b behaviour).
|
||||||
|
const platformPricing = await getPlatformPricing();
|
||||||
|
const setupFeeChf = platformPricing.tenantSetupFeeChf;
|
||||||
|
|
||||||
|
// ZERO-FEE PATH ---------------------------------------------------
|
||||||
|
// No payment to collect. Create the request directly in 'pending'
|
||||||
|
// status (same as the pre-Phase-9b flow) and notify admin. The
|
||||||
|
// wizard treats this response identically to its previous
|
||||||
|
// success path.
|
||||||
|
if (setupFeeChf <= 0) {
|
||||||
|
const tenantRequest = await createTenantRequest({
|
||||||
|
zitadelOrgId: user.orgId,
|
||||||
|
zitadelUserId: user.id,
|
||||||
|
companyName,
|
||||||
|
instanceName: input.instanceName,
|
||||||
|
contactName,
|
||||||
|
contactEmail,
|
||||||
|
agentName: input.agentName,
|
||||||
|
soulMd: input.soulMd,
|
||||||
|
agentsMd: input.agentsMd,
|
||||||
|
packages: input.packages ?? [],
|
||||||
|
billingAddress,
|
||||||
|
billingNotes,
|
||||||
|
encryptedSecrets,
|
||||||
|
isPersonal,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await sendAdminNotificationEmail(
|
||||||
|
tenantRequest.contactEmail,
|
||||||
|
tenantRequest.contactName,
|
||||||
|
tenantRequest.instanceName
|
||||||
|
? `${tenantRequest.companyName} (${tenantRequest.instanceName})`
|
||||||
|
: tenantRequest.companyName
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to send admin notification:", e);
|
||||||
|
}
|
||||||
|
const allRequests = await listTenantRequestsByOrgId(user.orgId);
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
message: "Request submitted.",
|
||||||
|
request: publicRequestShape(tenantRequest),
|
||||||
|
orgRequestCount: allRequests.length,
|
||||||
|
},
|
||||||
|
{ status: 201 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// PAID-FEE PATH ---------------------------------------------------
|
||||||
|
// Insert as 'pending_payment' (tenant_name stays NULL so abandoned
|
||||||
|
// Checkout sessions don't block retries). Build the setup-fee
|
||||||
|
// invoice, then start a Checkout session. The wizard follows the
|
||||||
|
// returned URL; on completion the webhook flips the row to
|
||||||
|
// 'pending' and admin sees it in their queue.
|
||||||
|
const tenantRequest = await createTenantRequestPendingPayment({
|
||||||
zitadelOrgId: user.orgId,
|
zitadelOrgId: user.orgId,
|
||||||
zitadelUserId: user.id,
|
zitadelUserId: user.id,
|
||||||
companyName,
|
companyName,
|
||||||
@@ -406,30 +511,122 @@ export async function POST(request: Request) {
|
|||||||
isPersonal,
|
isPersonal,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Notify admin about the new request. For follow-up instances, include
|
// Derive the future tenant_name — needed on the invoice line so
|
||||||
// the instance name in the notification so the admin sees what's
|
// tenantHasSetupFeeBilled() in the monthly cron dedup finds the
|
||||||
// being requested without opening the panel.
|
// already-paid setup fee once the K8s tenant exists. The name is
|
||||||
try {
|
// request-id-suffix-derived, so abandoned Checkout retries each
|
||||||
await sendAdminNotificationEmail(
|
// get unique names.
|
||||||
tenantRequest.contactEmail,
|
const derivedTenantName = deriveTenantName(
|
||||||
tenantRequest.contactName,
|
isPersonal ? "personal" : "company",
|
||||||
tenantRequest.instanceName
|
companyName,
|
||||||
? `${tenantRequest.companyName} (${tenantRequest.instanceName})`
|
tenantRequest.id
|
||||||
: tenantRequest.companyName
|
);
|
||||||
|
|
||||||
|
// Build the billing snapshot from the org's address (already
|
||||||
|
// fetched above for the wizard's billing-address resolution).
|
||||||
|
// The snapshot is what the invoice + Stripe customer use.
|
||||||
|
//
|
||||||
|
// orgBilling MUST exist here: the auto-pay pre-check above
|
||||||
|
// requires a saved Stripe PaymentMethod, which can only be
|
||||||
|
// created via ensureStripeCustomerForOrg, which requires
|
||||||
|
// org_billing. If it's missing the system is in an inconsistent
|
||||||
|
// state we shouldn't paper over.
|
||||||
|
if (!orgBilling) {
|
||||||
|
console.error(
|
||||||
|
`Paid-fee onboarding path reached without org_billing for org ${user.orgId} — auto-pay pre-check should have prevented this.`
|
||||||
);
|
);
|
||||||
|
await deletePendingPaymentRequest(tenantRequest.id).catch(() => undefined);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Billing record missing. Please re-save your billing details on /settings/billing." },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const billingSnapshot: InvoiceBillingSnapshot = {
|
||||||
|
companyName: orgBilling.companyName,
|
||||||
|
contactName: orgBilling.contactName ?? null,
|
||||||
|
streetAddress: orgBilling.streetAddress,
|
||||||
|
postalCode: orgBilling.postalCode,
|
||||||
|
city: orgBilling.city,
|
||||||
|
country: orgBilling.country,
|
||||||
|
vatNumber: orgBilling.vatNumber ?? null,
|
||||||
|
billingEmail: orgBilling.billingEmail,
|
||||||
|
notes: orgBilling.notes ?? null,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Locale for the invoice + PDF — pick from the org's country
|
||||||
|
// using the same heuristic the auto-cron uses.
|
||||||
|
const c = (billingSnapshot.country ?? "").toUpperCase();
|
||||||
|
const invoiceLocale: "de" | "en" | "fr" | "it" = ["CH", "LI", "AT", "DE"].includes(c)
|
||||||
|
? "de"
|
||||||
|
: ["FR", "BE", "LU"].includes(c)
|
||||||
|
? "fr"
|
||||||
|
: c === "IT"
|
||||||
|
? "it"
|
||||||
|
: "en";
|
||||||
|
|
||||||
|
let setupInvoice;
|
||||||
|
try {
|
||||||
|
setupInvoice = await createTenantSetupFeeInvoice({
|
||||||
|
zitadelOrgId: user.orgId,
|
||||||
|
tenantName: derivedTenantName,
|
||||||
|
billingSnapshot,
|
||||||
|
locale: invoiceLocale,
|
||||||
|
paymentMethod: "card",
|
||||||
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Failed to send admin notification:", e);
|
console.error("Failed to create setup-fee invoice:", e);
|
||||||
|
// Roll back the pending_payment row so the customer can retry
|
||||||
|
// without an orphan record.
|
||||||
|
await deletePendingPaymentRequest(tenantRequest.id).catch(() => undefined);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Failed to prepare setup-fee invoice. Please try again." },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// For diagnostics: how many other in-flight requests does this org
|
// Create the Checkout session. The Stripe customer must exist
|
||||||
// already have? Useful for the admin queue.
|
// before this — ensureStripeCustomerForOrg returns the existing
|
||||||
const allRequests = await listTenantRequestsByOrgId(user.orgId);
|
// one (idempotent) since the saved-card setup already created it.
|
||||||
|
let checkoutUrl: string;
|
||||||
|
try {
|
||||||
|
const stripeCustomerId = await ensureStripeCustomerForOrg({
|
||||||
|
zitadelOrgId: user.orgId,
|
||||||
|
companyName: billingSnapshot.companyName,
|
||||||
|
billingEmail: billingSnapshot.billingEmail,
|
||||||
|
address: {
|
||||||
|
line1: billingSnapshot.streetAddress,
|
||||||
|
postalCode: billingSnapshot.postalCode,
|
||||||
|
city: billingSnapshot.city,
|
||||||
|
country: billingSnapshot.country,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const baseUrl =
|
||||||
|
process.env.APP_BASE_URL ?? "https://app.pieced.ch";
|
||||||
|
const { url } = await createSetupFeeCheckoutSession({
|
||||||
|
invoice: setupInvoice,
|
||||||
|
customerId: stripeCustomerId,
|
||||||
|
baseUrl,
|
||||||
|
tenantRequestId: tenantRequest.id,
|
||||||
|
});
|
||||||
|
checkoutUrl = url;
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to create setup-fee Checkout session:", e);
|
||||||
|
// Roll back the pending_payment row.
|
||||||
|
await deletePendingPaymentRequest(tenantRequest.id).catch(() => undefined);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Failed to start payment. Please try again." },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't notify admin yet — the request is invisible to admin
|
||||||
|
// until the webhook flips it to 'pending'. Notification happens
|
||||||
|
// there.
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
message: "Request submitted.",
|
message: "Redirecting to payment.",
|
||||||
request: publicRequestShape(tenantRequest),
|
request: publicRequestShape(tenantRequest),
|
||||||
orgRequestCount: allRequests.length,
|
checkoutUrl,
|
||||||
},
|
},
|
||||||
{ status: 201 }
|
{ status: 201 }
|
||||||
);
|
);
|
||||||
|
|||||||
90
src/app/api/settings/billing/route.ts
Normal file
90
src/app/api/settings/billing/route.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { getSessionUser } from "@/lib/session";
|
||||||
|
import { getOrgBilling, upsertOrgBilling } from "@/lib/db";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/settings/billing — read the caller's org_billing row.
|
||||||
|
* Returns null if the org hasn't configured billing yet — the
|
||||||
|
* form renders empty and the PUT will create on first save.
|
||||||
|
*
|
||||||
|
* PUT /api/settings/billing — upsert the row.
|
||||||
|
*
|
||||||
|
* Authorization: caller must have role "owner" in their org.
|
||||||
|
* Non-owners get 403 (they shouldn't have reached the page UI
|
||||||
|
* anyway, which hides the link, but the API enforces too — a
|
||||||
|
* non-owner who hits this directly with curl gets refused).
|
||||||
|
*
|
||||||
|
* Personal accounts are inherently their own owner (single-user
|
||||||
|
* org), so user.roles.includes("owner") returns true and they
|
||||||
|
* can manage their own billing.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const upsertSchema = z.object({
|
||||||
|
companyName: z.string().trim().min(1).max(200),
|
||||||
|
// Phase 6 fix: optional "z.Hd." / "Attn:" line. Personal accounts
|
||||||
|
// never send this (the UI hides the field); orgs may set or leave
|
||||||
|
// it empty.
|
||||||
|
contactName: z.string().trim().max(200).optional().nullable(),
|
||||||
|
streetAddress: z.string().trim().min(1).max(200),
|
||||||
|
postalCode: z.string().trim().min(1).max(20),
|
||||||
|
city: z.string().trim().min(1).max(100),
|
||||||
|
// ISO 3166-1 alpha-2. We normalise to uppercase server-side.
|
||||||
|
country: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.length(2)
|
||||||
|
.regex(/^[A-Za-z]{2}$/, "Use a 2-letter ISO country code (CH, DE, …)"),
|
||||||
|
vatNumber: z.string().trim().max(40).optional().nullable(),
|
||||||
|
billingEmail: z.string().trim().email().max(200),
|
||||||
|
notes: z.string().trim().max(2000).optional().nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
function requireOwner(user: { roles: string[] } | null) {
|
||||||
|
if (!user) return false;
|
||||||
|
return user.roles.includes("owner");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const user = await getSessionUser();
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
if (!requireOwner(user as any)) {
|
||||||
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
|
}
|
||||||
|
const billing = await getOrgBilling(user.orgId);
|
||||||
|
return NextResponse.json({ billing });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(request: Request) {
|
||||||
|
const user = await getSessionUser();
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
if (!requireOwner(user as any)) {
|
||||||
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
|
}
|
||||||
|
const body = await request.json().catch(() => ({}));
|
||||||
|
const parsed = upsertSchema.safeParse(body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Invalid request", details: parsed.error.flatten() },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const data = parsed.data;
|
||||||
|
const billing = await upsertOrgBilling({
|
||||||
|
zitadelOrgId: user.orgId,
|
||||||
|
companyName: data.companyName,
|
||||||
|
contactName: data.contactName ?? null,
|
||||||
|
streetAddress: data.streetAddress,
|
||||||
|
postalCode: data.postalCode,
|
||||||
|
city: data.city,
|
||||||
|
country: data.country.toUpperCase(),
|
||||||
|
vatNumber: data.vatNumber ?? null,
|
||||||
|
billingEmail: data.billingEmail,
|
||||||
|
notes: data.notes ?? null,
|
||||||
|
});
|
||||||
|
return NextResponse.json({ billing });
|
||||||
|
}
|
||||||
81
src/app/api/settings/profile/route.ts
Normal file
81
src/app/api/settings/profile/route.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { getSessionUser } from "@/lib/session";
|
||||||
|
import {
|
||||||
|
getHumanUserDetail,
|
||||||
|
updateHumanUserProfile,
|
||||||
|
} from "@/lib/zitadel";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/settings/profile — read the caller's ZITADEL profile.
|
||||||
|
* Returns first/last/display name and email. Used by the settings
|
||||||
|
* page server component to populate the form.
|
||||||
|
*
|
||||||
|
* PUT /api/settings/profile — update first + last name. Email is
|
||||||
|
* NOT mutable here — changing email needs verification flow that
|
||||||
|
* ZITADEL's own self-service UI already provides; we don't
|
||||||
|
* duplicate that.
|
||||||
|
*
|
||||||
|
* Authorization: any authenticated user can edit their own profile.
|
||||||
|
* The PAT (ZITADEL_SA_PAT) is used to call the ZITADEL v2 user
|
||||||
|
* service, but only against the caller's own userId. There is no
|
||||||
|
* userId field on the request — it's always derived from the
|
||||||
|
* session, so the route can't be abused to edit other users.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const updateSchema = z.object({
|
||||||
|
firstName: z.string().trim().min(1).max(100),
|
||||||
|
lastName: z.string().trim().min(1).max(100),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const user = await getSessionUser();
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const profile = await getHumanUserDetail(user.id);
|
||||||
|
return NextResponse.json({ profile });
|
||||||
|
} catch (e: any) {
|
||||||
|
// Surface ZITADEL-side failures (e.g. user not found, PAT expired)
|
||||||
|
// as 502 — the portal couldn't reach its identity provider, which
|
||||||
|
// is operationally different from a 4xx on the caller's input.
|
||||||
|
console.error("getHumanUserDetail failed:", e);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Could not load profile from identity provider" },
|
||||||
|
{ status: 502 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(request: Request) {
|
||||||
|
const user = await getSessionUser();
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
const body = await request.json().catch(() => ({}));
|
||||||
|
const parsed = updateSchema.safeParse(body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Invalid request", details: parsed.error.flatten() },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const result = await updateHumanUserProfile({
|
||||||
|
userId: user.id,
|
||||||
|
givenName: parsed.data.firstName,
|
||||||
|
familyName: parsed.data.lastName,
|
||||||
|
});
|
||||||
|
return NextResponse.json({
|
||||||
|
displayName: result.displayName,
|
||||||
|
changeDate: result.changeDate,
|
||||||
|
});
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error("updateHumanUserProfile failed:", e);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Could not update profile in identity provider" },
|
||||||
|
{ status: 502 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
23
src/app/api/skills/pricing/route.ts
Normal file
23
src/app/api/skills/pricing/route.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { getSessionUser } from "@/lib/session";
|
||||||
|
import { listSkillPricing } from "@/lib/db";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/skills/pricing
|
||||||
|
*
|
||||||
|
* Returns the platform-wide skill pricing (daily price + setup fee
|
||||||
|
* per skill) for display in the customer's cost-disclosure dialog
|
||||||
|
* before they enable a priced skill. Any logged-in user can read
|
||||||
|
* this — pricing isn't org-specific and is effectively public
|
||||||
|
* information for anyone who'd be considering activation.
|
||||||
|
*
|
||||||
|
* Empty array means no skill is currently priced.
|
||||||
|
*/
|
||||||
|
export async function GET() {
|
||||||
|
const user = await getSessionUser();
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
const rows = await listSkillPricing();
|
||||||
|
return NextResponse.json(rows);
|
||||||
|
}
|
||||||
74
src/app/api/skills/requests/[id]/withdraw/route.ts
Normal file
74
src/app/api/skills/requests/[id]/withdraw/route.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { getSessionUser } from "@/lib/session";
|
||||||
|
import {
|
||||||
|
getSkillActivationRequestById,
|
||||||
|
updateSkillActivationRequestStatus,
|
||||||
|
} from "@/lib/db";
|
||||||
|
import { getPackageDef } from "@/lib/packages";
|
||||||
|
import { deletePackageSecrets } from "@/lib/openbao";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/skills/requests/[id]/withdraw
|
||||||
|
*
|
||||||
|
* The owner of a pending activation request can cancel it. This
|
||||||
|
* doesn't touch K8s (the skill was never enabled) — it just flips
|
||||||
|
* the row to 'withdrawn' so the user's UI clears the pending
|
||||||
|
* state and they can try a different skill or retry later.
|
||||||
|
*
|
||||||
|
* Authorization: only the original requester OR a platform admin
|
||||||
|
* can withdraw a request. We deliberately don't allow other org
|
||||||
|
* members to cancel each other's requests in v1 — the partial
|
||||||
|
* unique index would let one user repeatedly cancel another's
|
||||||
|
* pending request.
|
||||||
|
*/
|
||||||
|
export async function POST(
|
||||||
|
_request: Request,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
const user = await getSessionUser();
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
const { id } = await params;
|
||||||
|
const req = await getSkillActivationRequestById(id);
|
||||||
|
if (!req) {
|
||||||
|
return NextResponse.json({ error: "Request not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
if (!user.isPlatform && req.zitadelUserId !== user.id) {
|
||||||
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
|
}
|
||||||
|
if (req.status !== "pending") {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Request is already ${req.status}` },
|
||||||
|
{ status: 409 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const updated = await updateSkillActivationRequestStatus(id, "withdrawn", {
|
||||||
|
reviewedBy: user.id,
|
||||||
|
});
|
||||||
|
if (!updated) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Request status changed during withdraw." },
|
||||||
|
{ status: 409 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup: same logic as reject — the user submitted secrets
|
||||||
|
// before the gate fired, and those are now orphaned in OpenBao.
|
||||||
|
// Best-effort delete; failure logged but not propagated. Skip
|
||||||
|
// customProvisioning packages (their deprovisioning is a
|
||||||
|
// separate, dedicated endpoint).
|
||||||
|
const def = getPackageDef(req.skillId);
|
||||||
|
if (def?.requiresSecrets && !def.customProvisioning) {
|
||||||
|
try {
|
||||||
|
await deletePackageSecrets(req.tenantName, req.skillId);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(
|
||||||
|
`Failed to delete orphan secrets for ${req.tenantName}/${req.skillId} after withdraw:`,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(updated);
|
||||||
|
}
|
||||||
40
src/app/api/skills/requests/route.ts
Normal file
40
src/app/api/skills/requests/route.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { getSessionUser } from "@/lib/session";
|
||||||
|
import { listSkillActivationRequestsForTenant } from "@/lib/db";
|
||||||
|
import { canUserSeeTenant } from "@/lib/visibility";
|
||||||
|
import { getTenant } from "@/lib/k8s";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/skills/requests?tenant=<name>
|
||||||
|
*
|
||||||
|
* Returns pending and most-recent-rejected skill activation
|
||||||
|
* requests for the named tenant. Used by the tenant settings page
|
||||||
|
* to render the "Manual review pending" or "Activation rejected"
|
||||||
|
* inline states on PackageCard.
|
||||||
|
*
|
||||||
|
* Authorization: the caller must be able to see the tenant (owner
|
||||||
|
* of its org, assigned user, or platform admin).
|
||||||
|
*/
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const user = await getSessionUser();
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const tenantName = searchParams.get("tenant");
|
||||||
|
if (!tenantName) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Missing tenant parameter" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const tenant = await getTenant(tenantName).catch(() => null);
|
||||||
|
if (!tenant) {
|
||||||
|
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
if (!canUserSeeTenant(user, tenant)) {
|
||||||
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
|
}
|
||||||
|
const requests = await listSkillActivationRequestsForTenant(tenantName);
|
||||||
|
return NextResponse.json(requests);
|
||||||
|
}
|
||||||
557
src/app/api/stripe/webhook/route.ts
Normal file
557
src/app/api/stripe/webhook/route.ts
Normal file
@@ -0,0 +1,557 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import type Stripe from "stripe";
|
||||||
|
import {
|
||||||
|
getPaymentMethodDisplay,
|
||||||
|
getStripeClient,
|
||||||
|
getWebhookSecret,
|
||||||
|
} from "@/lib/stripe";
|
||||||
|
import {
|
||||||
|
getInvoiceByStripePaymentIntent,
|
||||||
|
getInvoiceDetail,
|
||||||
|
getOrgIdByStripeCustomerId,
|
||||||
|
getTenantRequestForSetupFlow,
|
||||||
|
isStripeRefundRecorded,
|
||||||
|
linkTenantRequestSetupPayment,
|
||||||
|
markInvoicePaid,
|
||||||
|
markStripeEventProcessed,
|
||||||
|
setInvoiceStripePaymentIntent,
|
||||||
|
setSavedPaymentMethod,
|
||||||
|
tryRecordStripeEvent,
|
||||||
|
} from "@/lib/db";
|
||||||
|
import { sendAdminNotificationEmail } from "@/lib/email";
|
||||||
|
import { refundInvoice, RefundNotAllowedError } from "@/lib/billing";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/stripe/webhook
|
||||||
|
*
|
||||||
|
* Receives signed events from Stripe. The lifecycle:
|
||||||
|
*
|
||||||
|
* 1. Read RAW body (request.text(), NOT request.json() — Stripe's
|
||||||
|
* HMAC is computed over the raw bytes and any JSON re-parse
|
||||||
|
* will subtly mangle whitespace or property ordering and the
|
||||||
|
* signature will fail).
|
||||||
|
* 2. Verify signature against the configured webhook secret. If
|
||||||
|
* verification fails → 400. An attacker forging webhook calls
|
||||||
|
* could otherwise mark our invoices paid.
|
||||||
|
* 3. Idempotency: INSERT the event id into stripe_events. If the
|
||||||
|
* INSERT conflicts (duplicate delivery, which is normal — Stripe
|
||||||
|
* retries failed deliveries for up to 72h), return 200 immediately
|
||||||
|
* so Stripe doesn't keep retrying.
|
||||||
|
* 4. Process the event based on type. Currently we care about:
|
||||||
|
* - checkout.session.completed → flip invoice to paid
|
||||||
|
* - charge.refunded → log; void/credit handling is Phase 7
|
||||||
|
* - payment_intent.payment_failed → log only; the failure is
|
||||||
|
* already shown to the user on
|
||||||
|
* the Stripe page, no action.
|
||||||
|
* Unknown event types are ack'd with 200 (we may have other
|
||||||
|
* events enabled at the Stripe end that we don't yet care about,
|
||||||
|
* and 200 + log is cheaper than 404 + Stripe retries).
|
||||||
|
* 5. Stamp processed_at on success.
|
||||||
|
*
|
||||||
|
* Return contract: 2xx ack → Stripe stops retrying. Any non-2xx →
|
||||||
|
* Stripe retries with exponential backoff up to 72h. We aim for
|
||||||
|
* 200 on every reachable path (verified, deduplicated, or processed),
|
||||||
|
* and only 400 for signature failures (those would never succeed
|
||||||
|
* on retry anyway, so retrying is wasted effort).
|
||||||
|
*
|
||||||
|
* Performance: handlers run synchronously here because PieCed's
|
||||||
|
* event volume is tiny. If/when that changes, the obvious refactor
|
||||||
|
* is to enqueue (Phase 7) and ack first — but at v1 the inline
|
||||||
|
* model is simpler to reason about and harder to lose events with.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Next.js: explicitly disable static optimization; this route MUST
|
||||||
|
// run on every request and must not be cached.
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
// 1. Raw body — Stripe verifies the signature over these exact bytes.
|
||||||
|
const rawBody = await request.text();
|
||||||
|
const signature = request.headers.get("stripe-signature");
|
||||||
|
if (!signature) {
|
||||||
|
return new NextResponse("Missing stripe-signature header", {
|
||||||
|
status: 400,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Verify signature.
|
||||||
|
let event: Stripe.Event;
|
||||||
|
try {
|
||||||
|
const stripe = getStripeClient();
|
||||||
|
const secret = getWebhookSecret();
|
||||||
|
event = stripe.webhooks.constructEvent(rawBody, signature, secret);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Stripe webhook signature verification failed:", err);
|
||||||
|
// 400 — never retry. The webhook configuration is wrong on
|
||||||
|
// either end (rotated secret, wrong endpoint, etc.); retries
|
||||||
|
// won't fix it.
|
||||||
|
return new NextResponse("Invalid signature", { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Idempotency. INSERT event.id → fail-fast on duplicate.
|
||||||
|
let firstDelivery: boolean;
|
||||||
|
try {
|
||||||
|
firstDelivery = await tryRecordStripeEvent(
|
||||||
|
event.id,
|
||||||
|
event.type,
|
||||||
|
event
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(
|
||||||
|
`Failed to record stripe event ${event.id} (${event.type}):`,
|
||||||
|
err
|
||||||
|
);
|
||||||
|
// 5xx so Stripe retries — this is a DB hiccup, not a logic error.
|
||||||
|
return new NextResponse("DB error", { status: 500 });
|
||||||
|
}
|
||||||
|
if (!firstDelivery) {
|
||||||
|
// Already processed; ack happily.
|
||||||
|
return new NextResponse("Duplicate delivery; acknowledged.", {
|
||||||
|
status: 200,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Process. Each handler is responsible for being safe to run
|
||||||
|
// exactly once (we already deduplicated by event.id above).
|
||||||
|
try {
|
||||||
|
switch (event.type) {
|
||||||
|
case "checkout.session.completed":
|
||||||
|
await handleCheckoutCompleted(
|
||||||
|
event.data.object as Stripe.Checkout.Session
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "charge.refunded":
|
||||||
|
await handleChargeRefunded(event.data.object as Stripe.Charge);
|
||||||
|
break;
|
||||||
|
case "payment_intent.payment_failed":
|
||||||
|
await handlePaymentFailed(
|
||||||
|
event.data.object as Stripe.PaymentIntent
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
// Unknown event — log so we notice if Stripe starts sending
|
||||||
|
// something we should handle, but ack so we don't accumulate
|
||||||
|
// retries forever.
|
||||||
|
console.log(
|
||||||
|
`Stripe webhook: ignoring event type ${event.type} (id ${event.id})`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(
|
||||||
|
`Stripe webhook handler failed for ${event.type} (id ${event.id}):`,
|
||||||
|
err
|
||||||
|
);
|
||||||
|
// 5xx → Stripe retries. The handler is idempotent because the
|
||||||
|
// stripe_events row already exists, so on the next attempt we'd
|
||||||
|
// short-circuit at step 3. To actually retry the work we'd need
|
||||||
|
// to DELETE the stripe_events row first; for v1 we don't bother
|
||||||
|
// and let a human investigate the logs.
|
||||||
|
return new NextResponse("Handler error", { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Mark processed.
|
||||||
|
try {
|
||||||
|
await markStripeEventProcessed(event.id);
|
||||||
|
} catch (err) {
|
||||||
|
// Non-fatal — the event was already processed, this is just the
|
||||||
|
// bookkeeping flag. Log and move on.
|
||||||
|
console.error(
|
||||||
|
`Failed to mark stripe event ${event.id} processed:`,
|
||||||
|
err
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new NextResponse("OK", { status: 200 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Handlers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function handleCheckoutCompleted(
|
||||||
|
session: Stripe.Checkout.Session
|
||||||
|
): Promise<void> {
|
||||||
|
// Phase 9: setup-mode sessions don't pay anything — they
|
||||||
|
// authorize a card for off-session future charges. The
|
||||||
|
// PaymentMethod is attached to the customer and the session's
|
||||||
|
// setup_intent.payment_method holds the id we save.
|
||||||
|
if (session.mode === "setup") {
|
||||||
|
await handleSetupCompleted(session);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Defensive: paid sessions are what we want; sessions can also
|
||||||
|
// complete in "unpaid" state (rare for mode=payment, more common
|
||||||
|
// for async/delayed methods like SEPA). Only flip the invoice
|
||||||
|
// when payment actually cleared.
|
||||||
|
if (session.payment_status !== "paid") {
|
||||||
|
console.log(
|
||||||
|
`Checkout session ${session.id} completed but payment_status=${session.payment_status}; waiting for downstream events.`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const invoiceId =
|
||||||
|
session.metadata?.invoice_id ?? session.client_reference_id ?? null;
|
||||||
|
if (!invoiceId) {
|
||||||
|
console.error(
|
||||||
|
`Checkout session ${session.id} completed without invoice_id metadata; cannot link to invoice.`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const paymentIntentId =
|
||||||
|
typeof session.payment_intent === "string"
|
||||||
|
? session.payment_intent
|
||||||
|
: session.payment_intent?.id;
|
||||||
|
|
||||||
|
// Persist the PaymentIntent id on the invoice for traceability +
|
||||||
|
// future refund correlation.
|
||||||
|
if (paymentIntentId) {
|
||||||
|
await setInvoiceStripePaymentIntent(invoiceId, paymentIntentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flip status. markInvoicePaid is idempotent — re-running on an
|
||||||
|
// already-paid invoice returns null and we log + skip.
|
||||||
|
const updated = await markInvoicePaid(invoiceId, {
|
||||||
|
paidBy: "stripe",
|
||||||
|
paidMethodDetail: paymentIntentId
|
||||||
|
? `Stripe Checkout (${paymentIntentId})`
|
||||||
|
: "Stripe Checkout",
|
||||||
|
paidAt: session.created ? new Date(session.created * 1000) : undefined,
|
||||||
|
});
|
||||||
|
if (!updated) {
|
||||||
|
// Already paid or void/draft — fine, nothing to do.
|
||||||
|
console.log(
|
||||||
|
`Invoice ${invoiceId} was not in a payable state when Stripe webhook arrived (likely already paid).`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log(
|
||||||
|
`Invoice ${invoiceId} marked paid via Stripe (session ${session.id}, intent ${paymentIntentId}).`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Phase 9b: if this Checkout was the setup-fee flow for a tenant
|
||||||
|
// order, flip the linked tenant_request row from 'pending_payment'
|
||||||
|
// to 'pending' so admin sees it in the queue. The invoice line's
|
||||||
|
// tenant_name has the derived name; we also stamp it on the
|
||||||
|
// request row so admin can act on it. linkTenantRequestSetupPayment
|
||||||
|
// is idempotent (no-op if status already advanced).
|
||||||
|
const flow = session.metadata?.flow;
|
||||||
|
const tenantRequestId = session.metadata?.tenant_request_id;
|
||||||
|
if (flow === "setup_fee" && tenantRequestId) {
|
||||||
|
try {
|
||||||
|
// The derived tenant_name lives on the invoice line we just
|
||||||
|
// marked paid. Fetch via getInvoiceDetail (existing helper).
|
||||||
|
const detail = await getInvoiceDetail(invoiceId);
|
||||||
|
const setupLine = detail?.lines.find(
|
||||||
|
(l) => l.kind === "tenant_setup" && l.tenantName
|
||||||
|
);
|
||||||
|
if (!setupLine || !setupLine.tenantName) {
|
||||||
|
console.error(
|
||||||
|
`Setup-fee webhook for invoice ${invoiceId} has no tenant_setup line with tenant_name; cannot link request ${tenantRequestId}.`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const linked = await linkTenantRequestSetupPayment({
|
||||||
|
requestId: tenantRequestId,
|
||||||
|
tenantName: setupLine.tenantName,
|
||||||
|
setupInvoiceId: invoiceId,
|
||||||
|
});
|
||||||
|
if (linked) {
|
||||||
|
console.log(
|
||||||
|
`Tenant request ${tenantRequestId} flipped to 'pending' (tenant=${setupLine.tenantName}, setup invoice=${invoiceId}).`
|
||||||
|
);
|
||||||
|
// Notify admin now that the payment cleared. Best-effort —
|
||||||
|
// a failure here doesn't undo the linkage.
|
||||||
|
try {
|
||||||
|
const req = await getTenantRequestForSetupFlow(tenantRequestId);
|
||||||
|
if (req) {
|
||||||
|
await sendAdminNotificationEmail(
|
||||||
|
req.contactEmail,
|
||||||
|
req.contactName,
|
||||||
|
req.instanceName
|
||||||
|
? `${req.companyName} (${req.instanceName})`
|
||||||
|
: req.companyName
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(
|
||||||
|
`Failed to send admin notification for tenant request ${tenantRequestId}:`,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log(
|
||||||
|
`Tenant request ${tenantRequestId} not in 'pending_payment' (likely already advanced); webhook is a no-op.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(
|
||||||
|
`Setup-fee webhook for invoice ${invoiceId} failed to link tenant request ${tenantRequestId}:`,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 9b: any payment-mode Checkout that set setup_future_usage
|
||||||
|
// attaches the resulting PaymentMethod to the customer. Read it
|
||||||
|
// back and save the display fields against the org's config —
|
||||||
|
// same behaviour as the setup-mode webhook does. This is what
|
||||||
|
// makes the setup-fee Checkout also "refresh saved card" without
|
||||||
|
// an extra step, and it's also what Phase 9b-2's manual-pay
|
||||||
|
// with setup_future_usage will rely on.
|
||||||
|
try {
|
||||||
|
if (paymentIntentId) {
|
||||||
|
const stripe = getStripeClient();
|
||||||
|
const pi = await stripe.paymentIntents.retrieve(paymentIntentId);
|
||||||
|
const pmId =
|
||||||
|
typeof pi.payment_method === "string"
|
||||||
|
? pi.payment_method
|
||||||
|
: pi.payment_method?.id;
|
||||||
|
const customerId =
|
||||||
|
typeof pi.customer === "string"
|
||||||
|
? pi.customer
|
||||||
|
: pi.customer?.id;
|
||||||
|
// setup_future_usage on the PI tells us this payment also
|
||||||
|
// saved the card. If it's not set, this was a one-off pay
|
||||||
|
// and we shouldn't overwrite anything.
|
||||||
|
if (pmId && customerId && pi.setup_future_usage === "off_session") {
|
||||||
|
const orgId = await getOrgIdByStripeCustomerId(customerId);
|
||||||
|
if (orgId) {
|
||||||
|
const display = await getPaymentMethodDisplay(pmId);
|
||||||
|
await setSavedPaymentMethod({
|
||||||
|
zitadelOrgId: orgId,
|
||||||
|
stripeCustomerId: customerId,
|
||||||
|
paymentMethodId: pmId,
|
||||||
|
brand: display.brand,
|
||||||
|
last4: display.last4,
|
||||||
|
expMonth: display.expMonth,
|
||||||
|
expYear: display.expYear,
|
||||||
|
});
|
||||||
|
// Also tell Stripe this PM is the customer's default for
|
||||||
|
// future invoice charges. Best-effort.
|
||||||
|
try {
|
||||||
|
await stripe.customers.update(customerId, {
|
||||||
|
invoice_settings: { default_payment_method: pmId },
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(
|
||||||
|
`Failed to set default_payment_method on customer ${customerId}:`,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
console.log(
|
||||||
|
`Saved PaymentMethod ${pmId} (${display.brand} ${display.last4}) for org ${orgId} via payment-mode Checkout.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(
|
||||||
|
`Failed to save PaymentMethod from payment-mode Checkout (session ${session.id}):`,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Phase 9: handle setup-mode Checkout completion. The customer
|
||||||
|
* authorized a card for future off-session charges; persist the
|
||||||
|
* display fields against their org so the portal can show the
|
||||||
|
* saved card and use it for auto-charge.
|
||||||
|
*
|
||||||
|
* The session carries:
|
||||||
|
* - mode: 'setup'
|
||||||
|
* - customer: 'cus_xxx' (the Stripe customer id we created)
|
||||||
|
* - setup_intent: 'seti_xxx' (the SetupIntent — has payment_method)
|
||||||
|
*
|
||||||
|
* We look up which org owns the customer (via
|
||||||
|
* org_billing_config.stripe_customer_id), fetch the SetupIntent
|
||||||
|
* to find the resulting PaymentMethod id, then fetch the PM for
|
||||||
|
* its display fields. Three Stripe round-trips total — acceptable
|
||||||
|
* for a one-off setup event.
|
||||||
|
*/
|
||||||
|
async function handleSetupCompleted(
|
||||||
|
session: Stripe.Checkout.Session
|
||||||
|
): Promise<void> {
|
||||||
|
const customerId =
|
||||||
|
typeof session.customer === "string"
|
||||||
|
? session.customer
|
||||||
|
: session.customer?.id;
|
||||||
|
if (!customerId) {
|
||||||
|
console.error(
|
||||||
|
`Setup session ${session.id} completed without a customer; cannot link to org.`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const orgId = await getOrgIdByStripeCustomerId(customerId);
|
||||||
|
if (!orgId) {
|
||||||
|
console.error(
|
||||||
|
`Setup session ${session.id} for customer ${customerId} has no matching org.`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const setupIntentId =
|
||||||
|
typeof session.setup_intent === "string"
|
||||||
|
? session.setup_intent
|
||||||
|
: session.setup_intent?.id;
|
||||||
|
if (!setupIntentId) {
|
||||||
|
console.error(
|
||||||
|
`Setup session ${session.id} completed without a setup_intent id.`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Read the SetupIntent for the resulting PaymentMethod id.
|
||||||
|
const stripe = getStripeClient();
|
||||||
|
const setupIntent = await stripe.setupIntents.retrieve(setupIntentId);
|
||||||
|
const paymentMethodId =
|
||||||
|
typeof setupIntent.payment_method === "string"
|
||||||
|
? setupIntent.payment_method
|
||||||
|
: setupIntent.payment_method?.id;
|
||||||
|
if (!paymentMethodId) {
|
||||||
|
console.error(
|
||||||
|
`Setup session ${session.id}: setup_intent ${setupIntentId} has no payment_method.`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Fetch the PM details for display columns.
|
||||||
|
const display = await getPaymentMethodDisplay(paymentMethodId);
|
||||||
|
await setSavedPaymentMethod({
|
||||||
|
zitadelOrgId: orgId,
|
||||||
|
stripeCustomerId: customerId,
|
||||||
|
paymentMethodId,
|
||||||
|
brand: display.brand,
|
||||||
|
last4: display.last4,
|
||||||
|
expMonth: display.expMonth,
|
||||||
|
expYear: display.expYear,
|
||||||
|
});
|
||||||
|
// Also tell Stripe this PM is the customer's default for invoice
|
||||||
|
// payments — so a future stripe.paymentIntents.create against
|
||||||
|
// this customer without an explicit payment_method picks it up.
|
||||||
|
// Best-effort: a failure here doesn't undo the save (we have the
|
||||||
|
// pm id, we can pass it explicitly when charging in Phase 9b).
|
||||||
|
try {
|
||||||
|
await stripe.customers.update(customerId, {
|
||||||
|
invoice_settings: { default_payment_method: paymentMethodId },
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(
|
||||||
|
`Setup session ${session.id}: failed to set default_payment_method on customer ${customerId}; will pass pm id explicitly on charges.`,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
console.log(
|
||||||
|
`Saved PaymentMethod ${paymentMethodId} (${display.brand} ${display.last4}) for org ${orgId}.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleChargeRefunded(charge: Stripe.Charge): Promise<void> {
|
||||||
|
// Phase 7: mirror Stripe refunds into the portal so credit notes
|
||||||
|
// are issued for refunds initiated in the Stripe Dashboard. For
|
||||||
|
// refunds initiated via /api/admin/.../refund, this handler is a
|
||||||
|
// no-op (each refund's stripe_refund_id is already recorded
|
||||||
|
// before the webhook lands — refundInvoice records it
|
||||||
|
// synchronously after the Stripe API call).
|
||||||
|
//
|
||||||
|
// A charge can have multiple refund objects (multiple partial
|
||||||
|
// refunds against the same charge accumulate here). We iterate
|
||||||
|
// and process any that aren't yet recorded in our DB.
|
||||||
|
const paymentIntentId =
|
||||||
|
typeof charge.payment_intent === "string"
|
||||||
|
? charge.payment_intent
|
||||||
|
: charge.payment_intent?.id;
|
||||||
|
if (!paymentIntentId) {
|
||||||
|
console.error(
|
||||||
|
`charge.refunded for charge ${charge.id} has no payment_intent; cannot link to invoice.`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const invoice = await getInvoiceByStripePaymentIntent(paymentIntentId);
|
||||||
|
if (!invoice) {
|
||||||
|
console.error(
|
||||||
|
`charge.refunded for payment_intent ${paymentIntentId} has no matching invoice; ignoring.`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const refundsList = charge.refunds?.data ?? [];
|
||||||
|
if (refundsList.length === 0) {
|
||||||
|
// Some charge.refunded events fire with the refunds list
|
||||||
|
// collapsed (the object includes the aggregated amount_refunded
|
||||||
|
// but the data array can be omitted depending on Stripe's
|
||||||
|
// expansion choices). In that case there's nothing for us to
|
||||||
|
// iterate over here; the actual `refund.created` /
|
||||||
|
// `refund.updated` events carry per-refund detail and we'd need
|
||||||
|
// to enable those in Stripe to handle them. For v1 we log and
|
||||||
|
// rely on the in-portal admin path (refundInvoice) being the
|
||||||
|
// only refund initiator.
|
||||||
|
console.log(
|
||||||
|
`charge.refunded for charge ${charge.id} arrived without refund objects in data; in-portal flow assumed.`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const refund of refundsList) {
|
||||||
|
try {
|
||||||
|
// Idempotency: skip refunds we already recorded (either via
|
||||||
|
// portal admin action or a prior webhook delivery).
|
||||||
|
if (await isStripeRefundRecorded(refund.id)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const amountChf = (refund.amount ?? 0) / 100;
|
||||||
|
if (amountChf <= 0) continue;
|
||||||
|
// Map Stripe refund status to ours. Anything other than the
|
||||||
|
// canonical four falls through to 'pending' so we don't lose
|
||||||
|
// the record entirely.
|
||||||
|
let status: "pending" | "succeeded" | "failed" | "canceled" = "pending";
|
||||||
|
if (refund.status === "succeeded") status = "succeeded";
|
||||||
|
else if (refund.status === "failed") status = "failed";
|
||||||
|
else if (refund.status === "canceled") status = "canceled";
|
||||||
|
// For refunds that originated in Stripe Dashboard we don't
|
||||||
|
// have a reason to display. Use a sentinel string so the
|
||||||
|
// credit note PDF has something to print. Admin can edit
|
||||||
|
// post-hoc if needed (no UI for that today, but the DB row
|
||||||
|
// is reachable).
|
||||||
|
const reason = refund.reason
|
||||||
|
? `Stripe Dashboard: ${refund.reason}`
|
||||||
|
: "Refund issued via Stripe Dashboard";
|
||||||
|
// refundInvoice with existingStripeRefund: don't call Stripe
|
||||||
|
// again (we'd error since the refund already exists), just
|
||||||
|
// mirror the record into our DB and issue the credit note.
|
||||||
|
await refundInvoice({
|
||||||
|
invoiceId: invoice.id,
|
||||||
|
amountChf,
|
||||||
|
reason,
|
||||||
|
refundedBy: "stripe-webhook",
|
||||||
|
existingStripeRefund: { id: refund.id, status },
|
||||||
|
});
|
||||||
|
console.log(
|
||||||
|
`Mirrored Stripe refund ${refund.id} for invoice ${invoice.invoiceNumber} (CHF ${amountChf.toFixed(2)}).`
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof RefundNotAllowedError) {
|
||||||
|
// The invoice was already fully refunded by an earlier
|
||||||
|
// webhook delivery or by an in-portal action. That's fine.
|
||||||
|
console.log(
|
||||||
|
`Stripe refund ${refund.id}: ${e.message} (already accounted for).`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// For any other error, log but continue to the next refund —
|
||||||
|
// we don't want one bad refund to block the rest.
|
||||||
|
console.error(
|
||||||
|
`Failed to mirror Stripe refund ${refund.id} for invoice ${invoice.invoiceNumber}:`,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handlePaymentFailed(
|
||||||
|
intent: Stripe.PaymentIntent
|
||||||
|
): Promise<void> {
|
||||||
|
// The Stripe-hosted page already shows the failure to the user.
|
||||||
|
// We log here for support visibility and to surface in Workbench.
|
||||||
|
// No invoice state change — it stays 'open' until paid.
|
||||||
|
console.log(
|
||||||
|
`PaymentIntent ${intent.id} failed: ${
|
||||||
|
intent.last_payment_error?.message ?? "(no message)"
|
||||||
|
}`
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,7 +3,12 @@ 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 {
|
||||||
|
createSkillActivationRequest,
|
||||||
|
getOrgBilling,
|
||||||
|
recordSkillEvents,
|
||||||
|
} from "@/lib/db";
|
||||||
|
import { sendSkillActivationAdminNotification } from "@/lib/email";
|
||||||
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"];
|
||||||
@@ -69,6 +74,17 @@ export async function PATCH(
|
|||||||
|
|
||||||
const specPatch: Record<string, any> = {};
|
const specPatch: Record<string, any> = {};
|
||||||
|
|
||||||
|
// Track manual-setup gate activations created during this PATCH.
|
||||||
|
// We push to the K8s spec only the non-gated skills; the gated
|
||||||
|
// ones live in skill_activation_requests until admin approves
|
||||||
|
// and adds them via the admin endpoint. Platform admins bypass
|
||||||
|
// the gate (direct enable from /admin still applies immediately).
|
||||||
|
let gatedRequests: Array<{
|
||||||
|
skillId: string;
|
||||||
|
requestId: string;
|
||||||
|
skillName: string;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
// ── Validate packages against catalog ──
|
// ── Validate packages against catalog ──
|
||||||
if (body.packages !== undefined) {
|
if (body.packages !== undefined) {
|
||||||
if (!Array.isArray(body.packages) || body.packages.length > 10) {
|
if (!Array.isArray(body.packages) || body.packages.length > 10) {
|
||||||
@@ -85,7 +101,63 @@ export async function PATCH(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
specPatch.packages = body.packages;
|
// Compute the to-be-added set against the existing spec.
|
||||||
|
const existingPackages = new Set<string>(existing.spec.packages ?? []);
|
||||||
|
const desiredPackages: string[] = body.packages;
|
||||||
|
const newlyAdded = desiredPackages.filter(
|
||||||
|
(p) => !existingPackages.has(p)
|
||||||
|
);
|
||||||
|
// Manual-setup gate. Customer adds get routed to the queue;
|
||||||
|
// platform admins go straight through.
|
||||||
|
if (!user.isPlatform && newlyAdded.length > 0) {
|
||||||
|
const orgIdForGate =
|
||||||
|
existing.metadata.labels?.["pieced.ch/zitadel-org-id"];
|
||||||
|
if (!orgIdForGate) {
|
||||||
|
// Defensive: every customer-visible tenant should have the
|
||||||
|
// org label. Without it we can't attribute the request.
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Tenant missing org binding; contact support." },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const gatedSet = new Set<string>();
|
||||||
|
for (const skillId of newlyAdded) {
|
||||||
|
const def = getPackageDef(skillId);
|
||||||
|
if (!def?.requiresManualSetup) continue;
|
||||||
|
gatedSet.add(skillId);
|
||||||
|
try {
|
||||||
|
const req = await createSkillActivationRequest({
|
||||||
|
tenantName: name,
|
||||||
|
zitadelOrgId: orgIdForGate,
|
||||||
|
zitadelUserId: user.id,
|
||||||
|
skillId,
|
||||||
|
});
|
||||||
|
gatedRequests.push({
|
||||||
|
skillId,
|
||||||
|
requestId: req.id,
|
||||||
|
skillName: def.name,
|
||||||
|
});
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e?.code === "REQUEST_ALREADY_PENDING") {
|
||||||
|
// Idempotent: a pending row already exists; just keep
|
||||||
|
// the skill out of the K8s spec and surface it as
|
||||||
|
// gated without creating a duplicate.
|
||||||
|
gatedRequests.push({
|
||||||
|
skillId,
|
||||||
|
requestId: "",
|
||||||
|
skillName: def.name,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Strip gated skills from the desired spec — they must not
|
||||||
|
// reach K8s until approved.
|
||||||
|
specPatch.packages = desiredPackages.filter((p) => !gatedSet.has(p));
|
||||||
|
} else {
|
||||||
|
specPatch.packages = desiredPackages;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Validate workspaceFiles ──
|
// ── Validate workspaceFiles ──
|
||||||
@@ -232,7 +304,49 @@ export async function PATCH(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json(updated);
|
// Phase 2.5: notify admin of newly created activation requests.
|
||||||
|
// Best-effort — email failure must not poison the PATCH response.
|
||||||
|
// requestId === "" means an existing-pending row was reused, so
|
||||||
|
// skip the email in that case (admin already knows).
|
||||||
|
if (gatedRequests.length > 0) {
|
||||||
|
const orgIdForEmail =
|
||||||
|
existing.metadata.labels?.["pieced.ch/zitadel-org-id"] ?? null;
|
||||||
|
const companyName = orgIdForEmail
|
||||||
|
? await getOrgBilling(orgIdForEmail)
|
||||||
|
.then((b) => b?.companyName ?? null)
|
||||||
|
.catch(() => null)
|
||||||
|
: null;
|
||||||
|
for (const g of gatedRequests) {
|
||||||
|
if (!g.requestId) continue;
|
||||||
|
try {
|
||||||
|
await sendSkillActivationAdminNotification({
|
||||||
|
tenantName: name,
|
||||||
|
skillId: g.skillId,
|
||||||
|
skillName: g.skillName,
|
||||||
|
requesterEmail: user.email,
|
||||||
|
requesterName: user.name,
|
||||||
|
companyName,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error(
|
||||||
|
`Failed to send admin notification for skill activation request:`,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
...updated,
|
||||||
|
// Phase 2.5: tells the client which requested-to-enable skills
|
||||||
|
// didn't actually land in the spec because they're awaiting
|
||||||
|
// admin approval. UI uses this to render the "pending review"
|
||||||
|
// state on those skill cards.
|
||||||
|
pendingActivationRequests: gatedRequests.map((g) => ({
|
||||||
|
skillId: g.skillId,
|
||||||
|
skillName: g.skillName,
|
||||||
|
})),
|
||||||
|
});
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: safeError(e, "Failed to update tenant") },
|
{ error: safeError(e, "Failed to update tenant") },
|
||||||
|
|||||||
537
src/components/admin/billing/custom-invoice-editor.tsx
Normal file
537
src/components/admin/billing/custom-invoice-editor.tsx
Normal file
@@ -0,0 +1,537 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useMemo, useCallback } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { Card, CardHeader } from "@/components/ui/card";
|
||||||
|
import type {
|
||||||
|
CustomInvoiceDraftLine,
|
||||||
|
CustomInvoiceDraftPayload,
|
||||||
|
InvoiceDraftRecord,
|
||||||
|
OrgBilling,
|
||||||
|
} from "@/types";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
draft: InvoiceDraftRecord;
|
||||||
|
orgBilling: OrgBilling | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LOCALE_OPTIONS = [
|
||||||
|
{ value: "de", label: "Deutsch" },
|
||||||
|
{ value: "en", label: "English" },
|
||||||
|
{ value: "fr", label: "Français" },
|
||||||
|
{ value: "it", label: "Italiano" },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom invoice editor — Phase 8.
|
||||||
|
*
|
||||||
|
* Local state mirrors the persisted payload. Save persists the
|
||||||
|
* current state via PUT. Preview re-renders the PDF in-memory (no
|
||||||
|
* persistence). Issue allocates the invoice number and emails the
|
||||||
|
* customer.
|
||||||
|
*
|
||||||
|
* VAT preview is computed client-side from the country in the org
|
||||||
|
* billing snapshot — it's an estimate for the admin's eye, not
|
||||||
|
* authoritative. The server recomputes at issue time using the
|
||||||
|
* same vatRateForAddress() helper to ensure consistency.
|
||||||
|
*
|
||||||
|
* Discount/Rabatt is supported via a row with a negative
|
||||||
|
* unitPriceChf. The "Add discount" button seeds a new row with
|
||||||
|
* quantity 1 and a -50 placeholder to nudge the admin toward the
|
||||||
|
* intended sign.
|
||||||
|
*/
|
||||||
|
export function CustomInvoiceEditor({ draft, orgBilling }: Props) {
|
||||||
|
const t = useTranslations("adminBilling");
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
// Editable state — initialized from the draft payload.
|
||||||
|
const [issueDate, setIssueDate] = useState(draft.payload.issueDate);
|
||||||
|
const [dueDate, setDueDate] = useState(draft.payload.dueDate);
|
||||||
|
const [locale, setLocale] = useState<"de" | "en" | "fr" | "it">(
|
||||||
|
draft.payload.locale
|
||||||
|
);
|
||||||
|
const [paymentMethod, setPaymentMethod] = useState<"invoice" | "card">(
|
||||||
|
draft.payload.paymentMethod
|
||||||
|
);
|
||||||
|
const [adminNotes, setAdminNotes] = useState(draft.payload.adminNotes ?? "");
|
||||||
|
const [lines, setLines] = useState<CustomInvoiceDraftLine[]>(
|
||||||
|
draft.payload.lines.length > 0
|
||||||
|
? draft.payload.lines
|
||||||
|
: [{ description: "", quantity: 1, unitPriceChf: 0 }]
|
||||||
|
);
|
||||||
|
|
||||||
|
const [busy, setBusy] = useState<null | "save" | "preview" | "issue" | "delete">(
|
||||||
|
null
|
||||||
|
);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [dirty, setDirty] = useState(false);
|
||||||
|
|
||||||
|
// Build current payload — used by every action.
|
||||||
|
const buildPayload = useCallback((): CustomInvoiceDraftPayload => {
|
||||||
|
return {
|
||||||
|
issueDate,
|
||||||
|
dueDate,
|
||||||
|
locale,
|
||||||
|
paymentMethod,
|
||||||
|
adminNotes: adminNotes.trim() ? adminNotes.trim() : undefined,
|
||||||
|
lines: lines.map((ln) => ({
|
||||||
|
description: ln.description,
|
||||||
|
quantity: Number(ln.quantity) || 0,
|
||||||
|
unitPriceChf: Number(ln.unitPriceChf) || 0,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}, [issueDate, dueDate, locale, paymentMethod, adminNotes, lines]);
|
||||||
|
|
||||||
|
// Client-side VAT estimate. The auth-of-truth math runs server-side
|
||||||
|
// at issue time; this is just to show the admin what they're about
|
||||||
|
// to commit to.
|
||||||
|
const totals = useMemo(() => {
|
||||||
|
const subtotal = Math.round(
|
||||||
|
lines.reduce(
|
||||||
|
(s, ln) => s + (Number(ln.quantity) || 0) * (Number(ln.unitPriceChf) || 0),
|
||||||
|
0
|
||||||
|
) * 100
|
||||||
|
) / 100;
|
||||||
|
// Country-based VAT estimate. Mirrors vatRateForAddress() —
|
||||||
|
// simplified because the editor doesn't know the platform
|
||||||
|
// pricing config. Defaults to 8.1 for CH/LI; 0 otherwise.
|
||||||
|
const country = (orgBilling?.country ?? "").toUpperCase();
|
||||||
|
let vatRate = 0;
|
||||||
|
if (country === "CH" || country === "LI") {
|
||||||
|
vatRate = 8.1;
|
||||||
|
} else if (orgBilling?.vatNumber) {
|
||||||
|
vatRate = 0; // reverse charge
|
||||||
|
} else {
|
||||||
|
vatRate = 0; // out of scope OR consumer (server will fix)
|
||||||
|
}
|
||||||
|
const vatAmount = Math.round(subtotal * (vatRate / 100) * 100) / 100;
|
||||||
|
const total = Math.round((subtotal + vatAmount) * 100) / 100;
|
||||||
|
return { subtotal, vatRate, vatAmount, total };
|
||||||
|
}, [lines, orgBilling]);
|
||||||
|
|
||||||
|
// Line management
|
||||||
|
const updateLine = (idx: number, patch: Partial<CustomInvoiceDraftLine>) => {
|
||||||
|
setLines((prev) =>
|
||||||
|
prev.map((ln, i) => (i === idx ? { ...ln, ...patch } : ln))
|
||||||
|
);
|
||||||
|
setDirty(true);
|
||||||
|
};
|
||||||
|
const addLine = () => {
|
||||||
|
setLines((prev) => [
|
||||||
|
...prev,
|
||||||
|
{ description: "", quantity: 1, unitPriceChf: 0 },
|
||||||
|
]);
|
||||||
|
setDirty(true);
|
||||||
|
};
|
||||||
|
const addDiscountLine = () => {
|
||||||
|
setLines((prev) => [
|
||||||
|
...prev,
|
||||||
|
{ description: t("editorRabattDefaultDescription"), quantity: 1, unitPriceChf: -50 },
|
||||||
|
]);
|
||||||
|
setDirty(true);
|
||||||
|
};
|
||||||
|
const removeLine = (idx: number) => {
|
||||||
|
setLines((prev) => prev.filter((_, i) => i !== idx));
|
||||||
|
setDirty(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
const save = async (): Promise<boolean> => {
|
||||||
|
setError("");
|
||||||
|
setBusy("save");
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`/api/admin/billing/invoice-drafts/${draft.id}`,
|
||||||
|
{
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(buildPayload()),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const j = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) throw new Error(j.error || `HTTP ${res.status}`);
|
||||||
|
setDirty(false);
|
||||||
|
return true;
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e.message);
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
setBusy(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const preview = async () => {
|
||||||
|
// Save first if there are unsaved changes — otherwise the
|
||||||
|
// preview reflects stale data.
|
||||||
|
if (dirty) {
|
||||||
|
const ok = await save();
|
||||||
|
if (!ok) return;
|
||||||
|
}
|
||||||
|
// Open the preview in a new tab. The browser handles the PDF
|
||||||
|
// download/render natively; we don't need to fetch the bytes
|
||||||
|
// ourselves.
|
||||||
|
window.open(
|
||||||
|
`/api/admin/billing/invoice-drafts/${draft.id}/preview`,
|
||||||
|
"_blank",
|
||||||
|
"noopener"
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const issue = async () => {
|
||||||
|
if (!confirm(t("editorIssueConfirm"))) return;
|
||||||
|
if (dirty) {
|
||||||
|
const ok = await save();
|
||||||
|
if (!ok) return;
|
||||||
|
}
|
||||||
|
setError("");
|
||||||
|
setBusy("issue");
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`/api/admin/billing/invoice-drafts/${draft.id}/issue`,
|
||||||
|
{ method: "POST" }
|
||||||
|
);
|
||||||
|
const j = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) throw new Error(j.error || `HTTP ${res.status}`);
|
||||||
|
// The draft was deleted server-side; go look at the new invoice.
|
||||||
|
router.push(`/admin/billing/invoices/${j.invoice.id}`);
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e.message);
|
||||||
|
setBusy(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteDraft = async () => {
|
||||||
|
if (!confirm(t("editorDeleteConfirm"))) return;
|
||||||
|
setError("");
|
||||||
|
setBusy("delete");
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`/api/admin/billing/invoice-drafts/${draft.id}`,
|
||||||
|
{ method: "DELETE" }
|
||||||
|
);
|
||||||
|
if (!res.ok) {
|
||||||
|
const j = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(j.error || `HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
router.push("/admin/billing/invoice-drafts");
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e.message);
|
||||||
|
setBusy(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// No billing snapshot = can't issue. Save still works so admin
|
||||||
|
// can come back once the customer has completed onboarding.
|
||||||
|
const canIssue =
|
||||||
|
!!orgBilling &&
|
||||||
|
lines.length > 0 &&
|
||||||
|
lines.every((ln) => ln.description.trim().length > 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
{/* Bill-to preview — read-only, sourced from the org's billing
|
||||||
|
snapshot. Issued at issue time. */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>{t("editorBillToHeading")}</CardHeader>
|
||||||
|
<div className="p-4 text-sm">
|
||||||
|
{orgBilling ? (
|
||||||
|
<>
|
||||||
|
<p className="font-medium">{orgBilling.companyName}</p>
|
||||||
|
{orgBilling.contactName && (
|
||||||
|
<p className="text-text-secondary text-xs">
|
||||||
|
{orgBilling.contactName}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p className="text-text-secondary text-xs">
|
||||||
|
{orgBilling.streetAddress}, {orgBilling.postalCode}{" "}
|
||||||
|
{orgBilling.city}, {orgBilling.country}
|
||||||
|
</p>
|
||||||
|
{orgBilling.vatNumber && (
|
||||||
|
<p className="text-text-muted text-xs mt-1">
|
||||||
|
MWST/VAT: {orgBilling.vatNumber}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p className="text-text-muted text-xs">
|
||||||
|
{orgBilling.billingEmail}
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="text-error">{t("editorNoBillingSnapshot")}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Dates + locale + payment method */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>{t("editorMetadataHeading")}</CardHeader>
|
||||||
|
<div className="p-4 grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label className="text-xs uppercase tracking-wider text-text-muted">
|
||||||
|
{t("editorIssueDateLabel")}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={issueDate}
|
||||||
|
onChange={(e) => {
|
||||||
|
setIssueDate(e.target.value);
|
||||||
|
setDirty(true);
|
||||||
|
}}
|
||||||
|
className="px-3 py-2 rounded-md border border-border bg-surface-2 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label className="text-xs uppercase tracking-wider text-text-muted">
|
||||||
|
{t("editorDueDateLabel")}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={dueDate}
|
||||||
|
onChange={(e) => {
|
||||||
|
setDueDate(e.target.value);
|
||||||
|
setDirty(true);
|
||||||
|
}}
|
||||||
|
className="px-3 py-2 rounded-md border border-border bg-surface-2 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label className="text-xs uppercase tracking-wider text-text-muted">
|
||||||
|
{t("editorLocaleLabel")}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={locale}
|
||||||
|
onChange={(e) => {
|
||||||
|
setLocale(e.target.value as "de" | "en" | "fr" | "it");
|
||||||
|
setDirty(true);
|
||||||
|
}}
|
||||||
|
className="px-3 py-2 rounded-md border border-border bg-surface-2 text-sm"
|
||||||
|
>
|
||||||
|
{LOCALE_OPTIONS.map((o) => (
|
||||||
|
<option key={o.value} value={o.value}>
|
||||||
|
{o.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label className="text-xs uppercase tracking-wider text-text-muted">
|
||||||
|
{t("editorPaymentMethodLabel")}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={paymentMethod}
|
||||||
|
onChange={(e) => {
|
||||||
|
setPaymentMethod(e.target.value as "invoice" | "card");
|
||||||
|
setDirty(true);
|
||||||
|
}}
|
||||||
|
className="px-3 py-2 rounded-md border border-border bg-surface-2 text-sm"
|
||||||
|
>
|
||||||
|
<option value="invoice">{t("editorPaymentInvoice")}</option>
|
||||||
|
<option value="card">{t("editorPaymentCard")}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Line editor */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>{t("editorLinesHeading")}</CardHeader>
|
||||||
|
<div className="p-4">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="text-xs text-text-muted text-left">
|
||||||
|
<tr>
|
||||||
|
<th className="pb-2 pr-3">{t("editorLineDescription")}</th>
|
||||||
|
<th className="pb-2 pr-3 w-20 text-right">
|
||||||
|
{t("editorLineQty")}
|
||||||
|
</th>
|
||||||
|
<th className="pb-2 pr-3 w-32 text-right">
|
||||||
|
{t("editorLineUnitPrice")}
|
||||||
|
</th>
|
||||||
|
<th className="pb-2 pr-3 w-32 text-right">
|
||||||
|
{t("editorLineAmount")}
|
||||||
|
</th>
|
||||||
|
<th className="pb-2 w-12"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{lines.map((ln, idx) => {
|
||||||
|
const amount =
|
||||||
|
Math.round(
|
||||||
|
(Number(ln.quantity) || 0) *
|
||||||
|
(Number(ln.unitPriceChf) || 0) *
|
||||||
|
100
|
||||||
|
) / 100;
|
||||||
|
return (
|
||||||
|
<tr key={idx} className="border-t border-border">
|
||||||
|
<td className="py-2 pr-3">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={ln.description}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateLine(idx, { description: e.target.value })
|
||||||
|
}
|
||||||
|
placeholder={t("editorLineDescriptionPlaceholder")}
|
||||||
|
className="w-full px-2 py-1.5 rounded border border-border bg-surface-2 text-sm"
|
||||||
|
maxLength={500}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="py-2 pr-3">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
value={ln.quantity}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateLine(idx, {
|
||||||
|
quantity: parseFloat(e.target.value) || 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="w-full px-2 py-1.5 rounded border border-border bg-surface-2 text-sm font-mono text-right"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="py-2 pr-3">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
value={ln.unitPriceChf}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateLine(idx, {
|
||||||
|
unitPriceChf: parseFloat(e.target.value) || 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="w-full px-2 py-1.5 rounded border border-border bg-surface-2 text-sm font-mono text-right"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="py-2 pr-3 text-right font-mono text-sm whitespace-nowrap">
|
||||||
|
<span className={amount < 0 ? "text-error" : ""}>
|
||||||
|
CHF {amount.toFixed(2)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="py-2 text-right">
|
||||||
|
<button
|
||||||
|
onClick={() => removeLine(idx)}
|
||||||
|
className="text-text-muted hover:text-error text-lg leading-none"
|
||||||
|
title={t("editorLineRemove")}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div className="flex gap-2 mt-3">
|
||||||
|
<button
|
||||||
|
onClick={addLine}
|
||||||
|
type="button"
|
||||||
|
className="px-3 py-1.5 rounded-md border border-border text-sm hover:bg-surface-3"
|
||||||
|
>
|
||||||
|
+ {t("editorAddLine")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={addDiscountLine}
|
||||||
|
type="button"
|
||||||
|
className="px-3 py-1.5 rounded-md border border-border text-sm hover:bg-surface-3 text-text-secondary"
|
||||||
|
title={t("editorAddDiscountHint")}
|
||||||
|
>
|
||||||
|
− {t("editorAddDiscount")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Admin notes */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>{t("editorNotesHeading")}</CardHeader>
|
||||||
|
<div className="p-4">
|
||||||
|
<textarea
|
||||||
|
value={adminNotes}
|
||||||
|
onChange={(e) => {
|
||||||
|
setAdminNotes(e.target.value);
|
||||||
|
setDirty(true);
|
||||||
|
}}
|
||||||
|
placeholder={t("editorNotesPlaceholder")}
|
||||||
|
rows={2}
|
||||||
|
maxLength={2000}
|
||||||
|
className="w-full px-3 py-2 rounded-md border border-border bg-surface-2 text-sm"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-text-muted mt-1">
|
||||||
|
{t("editorNotesHint")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Totals preview */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>{t("editorTotalsHeading")}</CardHeader>
|
||||||
|
<div className="p-4 max-w-sm ml-auto text-sm">
|
||||||
|
<div className="flex justify-between py-1">
|
||||||
|
<span className="text-text-muted">{t("editorSubtotal")}</span>
|
||||||
|
<span className="font-mono">CHF {totals.subtotal.toFixed(2)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between py-1">
|
||||||
|
<span className="text-text-muted">
|
||||||
|
{t("editorVat")} ({totals.vatRate.toFixed(1)}%)
|
||||||
|
</span>
|
||||||
|
<span className="font-mono">CHF {totals.vatAmount.toFixed(2)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between py-2 border-t border-border mt-1 font-medium">
|
||||||
|
<span>{t("editorTotal")}</span>
|
||||||
|
<span className="font-mono">CHF {totals.total.toFixed(2)}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-text-muted mt-2 italic">
|
||||||
|
{t("editorTotalsEstimateNote")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Error + actions */}
|
||||||
|
{error && (
|
||||||
|
<div className="text-sm text-error border border-error/30 bg-error/10 rounded-md px-4 py-2">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2 justify-between items-center">
|
||||||
|
<button
|
||||||
|
onClick={deleteDraft}
|
||||||
|
disabled={busy !== null}
|
||||||
|
className="px-4 py-2 rounded-md border border-error text-error text-sm disabled:opacity-50 hover:bg-error/10"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{busy === "delete" ? t("deleting") : t("editorDeleteBtn")}
|
||||||
|
</button>
|
||||||
|
<div className="flex gap-2 ml-auto">
|
||||||
|
<button
|
||||||
|
onClick={save}
|
||||||
|
disabled={busy !== null || !dirty}
|
||||||
|
className="px-4 py-2 rounded-md border border-border text-sm disabled:opacity-50"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{busy === "save"
|
||||||
|
? t("saving")
|
||||||
|
: dirty
|
||||||
|
? t("editorSaveBtn")
|
||||||
|
: t("editorSavedBtn")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={preview}
|
||||||
|
disabled={busy !== null || lines.length === 0}
|
||||||
|
className="px-4 py-2 rounded-md border border-border text-sm disabled:opacity-50"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{busy === "preview" ? t("previewing") : t("editorPreviewBtn")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={issue}
|
||||||
|
disabled={busy !== null || !canIssue}
|
||||||
|
className="px-4 py-2 rounded-md bg-accent text-white text-sm disabled:opacity-50"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{busy === "issue" ? t("issuing") : t("editorIssueBtn")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
145
src/components/admin/billing/draft-list.tsx
Normal file
145
src/components/admin/billing/draft-list.tsx
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useTranslations, useFormatter } from "next-intl";
|
||||||
|
import { Card } from "@/components/ui/card";
|
||||||
|
import type { InvoiceDraftRecord } from "@/types";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
drafts: InvoiceDraftRecord[];
|
||||||
|
/** Map ZITADEL org id → company name for friendlier display. */
|
||||||
|
orgNameMap: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders the drafts table with per-row Edit / Delete actions.
|
||||||
|
*
|
||||||
|
* The total preview is the algebraic sum of line amounts (the same
|
||||||
|
* formula billing.computeCustomInvoiceTotals uses for the subtotal,
|
||||||
|
* minus VAT — which we don't know without the org's billing
|
||||||
|
* snapshot). It's a hint, not authoritative; the real total
|
||||||
|
* appears when the draft is issued.
|
||||||
|
*
|
||||||
|
* Empty state shows a clear CTA so a fresh admin knows where to
|
||||||
|
* start.
|
||||||
|
*/
|
||||||
|
export function DraftList({ drafts, orgNameMap }: Props) {
|
||||||
|
const t = useTranslations("adminBilling");
|
||||||
|
const fmt = useFormatter();
|
||||||
|
const router = useRouter();
|
||||||
|
const [busyId, setBusyId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const onDelete = async (id: string) => {
|
||||||
|
if (!confirm(t("draftDeleteConfirm"))) return;
|
||||||
|
setBusyId(id);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/admin/billing/invoice-drafts/${id}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const j = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(j.error || `HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
router.refresh();
|
||||||
|
} catch (e: any) {
|
||||||
|
alert(e.message);
|
||||||
|
} finally {
|
||||||
|
setBusyId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (drafts.length === 0) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<div className="p-6 text-center">
|
||||||
|
<p className="text-text-secondary mb-4">{t("draftsEmpty")}</p>
|
||||||
|
<Link
|
||||||
|
href="/admin/billing/invoices/new"
|
||||||
|
className="inline-block px-4 py-2 rounded-md bg-accent text-white text-sm"
|
||||||
|
>
|
||||||
|
{t("newInvoiceBtn")}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<div className="flex justify-end p-3 border-b border-border">
|
||||||
|
<Link
|
||||||
|
href="/admin/billing/invoices/new"
|
||||||
|
className="inline-block px-3 py-1.5 rounded-md bg-accent text-white text-sm"
|
||||||
|
>
|
||||||
|
{t("newInvoiceBtn")}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="text-xs text-text-muted text-left">
|
||||||
|
<tr>
|
||||||
|
<th className="pb-2 pl-3 pr-4">{t("draftOrgCol")}</th>
|
||||||
|
<th className="pb-2 pr-4">{t("draftIssueDateCol")}</th>
|
||||||
|
<th className="pb-2 pr-4 text-center">{t("draftLinesCol")}</th>
|
||||||
|
<th className="pb-2 pr-4 text-right">{t("draftSubtotalCol")}</th>
|
||||||
|
<th className="pb-2 pr-4">{t("draftUpdatedCol")}</th>
|
||||||
|
<th className="pb-2 pr-3 text-right">{t("draftActionsCol")}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{drafts.map((d) => {
|
||||||
|
const subtotal = d.payload.lines.reduce(
|
||||||
|
(s, ln) =>
|
||||||
|
s +
|
||||||
|
Math.round(ln.quantity * ln.unitPriceChf * 100) / 100,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<tr key={d.id} className="border-t border-border">
|
||||||
|
<td className="py-2 pl-3 pr-4">
|
||||||
|
<Link
|
||||||
|
href={`/admin/billing/invoice-drafts/${d.id}`}
|
||||||
|
className="hover:underline"
|
||||||
|
>
|
||||||
|
{orgNameMap[d.zitadelOrgId] ?? d.zitadelOrgId}
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
<td className="py-2 pr-4 text-xs font-mono text-text-secondary whitespace-nowrap">
|
||||||
|
{d.payload.issueDate}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 pr-4 text-center text-xs">
|
||||||
|
{d.payload.lines.length}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 pr-4 text-right font-mono text-xs whitespace-nowrap">
|
||||||
|
CHF {subtotal.toFixed(2)}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 pr-4 text-xs text-text-muted whitespace-nowrap">
|
||||||
|
{fmt.dateTime(new Date(d.updatedAt), {
|
||||||
|
dateStyle: "medium",
|
||||||
|
timeStyle: "short",
|
||||||
|
})}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 pr-3 text-right">
|
||||||
|
<Link
|
||||||
|
href={`/admin/billing/invoice-drafts/${d.id}`}
|
||||||
|
className="text-accent hover:underline text-xs mr-3"
|
||||||
|
>
|
||||||
|
{t("editBtn")}
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
onClick={() => onDelete(d.id)}
|
||||||
|
disabled={busyId === d.id}
|
||||||
|
className="text-error hover:underline text-xs disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{busyId === d.id ? t("deleting") : t("deleteBtn")}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
639
src/components/admin/billing/invoice-detail-view.tsx
Normal file
639
src/components/admin/billing/invoice-detail-view.tsx
Normal file
@@ -0,0 +1,639 @@
|
|||||||
|
"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 { CreditNote, InvoiceDetail, InvoiceStatus } from "@/types";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
detail: InvoiceDetail;
|
||||||
|
/**
|
||||||
|
* Phase 7: credit notes linked to this invoice (voids + refunds).
|
||||||
|
* Empty array when none. Passed from the server page; client
|
||||||
|
* doesn't re-fetch — router.refresh() rebuilds after actions.
|
||||||
|
*/
|
||||||
|
creditNotes?: CreditNote[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders the invoice header (status, totals, action bar) then
|
||||||
|
* line items grouped by tenant, then billing snapshot. Actions are
|
||||||
|
* mark-paid (POST), void (POST), refund (POST), delete (DELETE),
|
||||||
|
* PDF download (link to /pdf).
|
||||||
|
*
|
||||||
|
* Phase 7 adds void + refund. The action bar shows:
|
||||||
|
* - status open/overdue → Mark paid, Void, Delete
|
||||||
|
* - status paid → Refund, Delete
|
||||||
|
* - status partially_refunded → Refund (for remainder), Delete
|
||||||
|
* - status fully_refunded / void → Delete only (read-only otherwise)
|
||||||
|
*
|
||||||
|
* On successful action we router.refresh() — the server-side page
|
||||||
|
* re-renders against the new DB state, including any new credit
|
||||||
|
* notes.
|
||||||
|
*/
|
||||||
|
export function InvoiceDetailView({ detail, creditNotes = [] }: Props) {
|
||||||
|
const t = useTranslations("adminBilling");
|
||||||
|
const router = useRouter();
|
||||||
|
const { invoice, lines } = detail;
|
||||||
|
|
||||||
|
const [busyAction, setBusyAction] = useState<
|
||||||
|
null | "mark-paid" | "delete" | "void" | "refund"
|
||||||
|
>(null);
|
||||||
|
const [actionError, setActionError] = useState("");
|
||||||
|
const [noteInput, setNoteInput] = useState("");
|
||||||
|
const [noteOpen, setNoteOpen] = useState(false);
|
||||||
|
|
||||||
|
// Phase 7 — void modal state
|
||||||
|
const [voidOpen, setVoidOpen] = useState(false);
|
||||||
|
const [voidReason, setVoidReason] = useState("");
|
||||||
|
|
||||||
|
// Phase 7 — refund modal state. Amount defaults to the full
|
||||||
|
// remaining refundable on open.
|
||||||
|
const [refundOpen, setRefundOpen] = useState(false);
|
||||||
|
const [refundAmount, setRefundAmount] = useState("");
|
||||||
|
const [refundReason, setRefundReason] = useState("");
|
||||||
|
|
||||||
|
const remainingRefundable =
|
||||||
|
Math.round(
|
||||||
|
(invoice.totalChf - invoice.refundedTotalChf) * 100
|
||||||
|
) / 100;
|
||||||
|
|
||||||
|
const markPaid = async () => {
|
||||||
|
setActionError("");
|
||||||
|
setBusyAction("mark-paid");
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`/api/admin/billing/invoices/${invoice.id}/mark-paid`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ note: noteInput || undefined }),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const j = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) throw new Error(j.error || `HTTP ${res.status}`);
|
||||||
|
setNoteOpen(false);
|
||||||
|
setNoteInput("");
|
||||||
|
router.refresh();
|
||||||
|
} catch (e: any) {
|
||||||
|
setActionError(e.message);
|
||||||
|
} finally {
|
||||||
|
setBusyAction(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteInvoice = async () => {
|
||||||
|
if (!confirm(t("confirmDeleteInvoice", { num: invoice.invoiceNumber })))
|
||||||
|
return;
|
||||||
|
setActionError("");
|
||||||
|
setBusyAction("delete");
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/admin/billing/invoices/${invoice.id}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const j = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(j.error || `HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
router.push("/admin/billing/invoices");
|
||||||
|
} catch (e: any) {
|
||||||
|
setActionError(e.message);
|
||||||
|
setBusyAction(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Phase 7 — void: marks an unpaid invoice as cancelled and issues
|
||||||
|
// a credit note. Backend rejects if the invoice is paid (use
|
||||||
|
// refund) or already voided/refunded.
|
||||||
|
const voidInvoice = async () => {
|
||||||
|
if (!voidReason.trim()) {
|
||||||
|
setActionError(t("voidReasonRequired"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setActionError("");
|
||||||
|
setBusyAction("void");
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`/api/admin/billing/invoices/${invoice.id}/void`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ reason: voidReason }),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const j = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) throw new Error(j.error || `HTTP ${res.status}`);
|
||||||
|
setVoidOpen(false);
|
||||||
|
setVoidReason("");
|
||||||
|
router.refresh();
|
||||||
|
} catch (e: any) {
|
||||||
|
setActionError(e.message);
|
||||||
|
} finally {
|
||||||
|
setBusyAction(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Phase 7 — refund: paid invoices only. Amount may be partial;
|
||||||
|
// backend caps at remaining refundable.
|
||||||
|
const refundInvoice = async () => {
|
||||||
|
const amt = parseFloat(refundAmount);
|
||||||
|
if (!isFinite(amt) || amt <= 0) {
|
||||||
|
setActionError(t("refundAmountInvalid"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (amt - remainingRefundable > 0.005) {
|
||||||
|
setActionError(
|
||||||
|
t("refundAmountExceeds", {
|
||||||
|
max: remainingRefundable.toFixed(2),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!refundReason.trim()) {
|
||||||
|
setActionError(t("refundReasonRequired"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setActionError("");
|
||||||
|
setBusyAction("refund");
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`/api/admin/billing/invoices/${invoice.id}/refund`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
amountChf: Math.round(amt * 100) / 100,
|
||||||
|
reason: refundReason,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const j = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) throw new Error(j.error || `HTTP ${res.status}`);
|
||||||
|
setRefundOpen(false);
|
||||||
|
setRefundAmount("");
|
||||||
|
setRefundReason("");
|
||||||
|
router.refresh();
|
||||||
|
} catch (e: any) {
|
||||||
|
setActionError(e.message);
|
||||||
|
} finally {
|
||||||
|
setBusyAction(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Group lines by tenant for display (matches PDF layout).
|
||||||
|
const linesByTenant = new Map<string | null, typeof lines>();
|
||||||
|
for (const ln of lines) {
|
||||||
|
const k = ln.tenantName;
|
||||||
|
if (!linesByTenant.has(k)) linesByTenant.set(k, []);
|
||||||
|
linesByTenant.get(k)!.push(ln);
|
||||||
|
}
|
||||||
|
const tenantOrder = [...linesByTenant.keys()].sort((a, b) => {
|
||||||
|
if (a === null) return 1;
|
||||||
|
if (b === null) return -1;
|
||||||
|
return a.localeCompare(b);
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4 animate-in">
|
||||||
|
<div className="flex items-end justify-between flex-wrap gap-3">
|
||||||
|
<div>
|
||||||
|
<h1 className="font-display text-2xl font-semibold accent-rule">
|
||||||
|
{invoice.invoiceNumber}
|
||||||
|
</h1>
|
||||||
|
<div className="flex items-center gap-3 mt-3 text-sm">
|
||||||
|
<StatusPill status={invoice.status} />
|
||||||
|
{invoice.periodStart && invoice.periodEnd && (
|
||||||
|
<>
|
||||||
|
<span className="text-text-muted">
|
||||||
|
{invoice.periodStart} → {invoice.periodEnd}
|
||||||
|
</span>
|
||||||
|
<span className="text-text-muted">·</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<span className="text-text-muted">
|
||||||
|
{t("dueOnLabel")}: {invoice.dueAt}
|
||||||
|
</span>
|
||||||
|
<span className="text-text-muted">·</span>
|
||||||
|
<span className="text-text-muted font-mono text-xs">
|
||||||
|
{invoice.locale}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="text-xs text-text-muted">{t("totalLabel")}</div>
|
||||||
|
<div className="text-2xl font-semibold font-mono">
|
||||||
|
CHF {invoice.totalChf.toFixed(2)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action bar */}
|
||||||
|
<Card>
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
{invoice.hasPdf && (
|
||||||
|
<a
|
||||||
|
href={`/api/admin/billing/invoices/${invoice.id}/pdf`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="px-4 py-2 rounded-md border border-border text-sm hover:bg-surface-2"
|
||||||
|
>
|
||||||
|
{t("downloadPdfBtn")}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{(invoice.status === "open" || invoice.status === "overdue") && (
|
||||||
|
<>
|
||||||
|
{!noteOpen ? (
|
||||||
|
<button
|
||||||
|
onClick={() => setNoteOpen(true)}
|
||||||
|
disabled={busyAction !== null}
|
||||||
|
className="px-4 py-2 rounded-md bg-accent text-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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{/* Phase 7 — Void: visible only for open/overdue invoices.
|
||||||
|
Same gating as Mark Paid but mutually exclusive with it
|
||||||
|
via the chosen action. Opens a small inline form so
|
||||||
|
the admin can enter a reason; reason is required and
|
||||||
|
lands on the credit-note PDF. */}
|
||||||
|
{(invoice.status === "open" || invoice.status === "overdue") && (
|
||||||
|
<>
|
||||||
|
{!voidOpen ? (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setVoidOpen(true);
|
||||||
|
setNoteOpen(false);
|
||||||
|
setRefundOpen(false);
|
||||||
|
}}
|
||||||
|
disabled={busyAction !== null}
|
||||||
|
className="px-4 py-2 rounded-md border border-error text-error text-sm disabled:opacity-50 hover:bg-error/10"
|
||||||
|
>
|
||||||
|
{t("voidBtn")}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-2 flex-grow">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder={t("voidReasonPlaceholder")}
|
||||||
|
value={voidReason}
|
||||||
|
onChange={(e) => setVoidReason(e.target.value)}
|
||||||
|
maxLength={500}
|
||||||
|
className="flex-grow px-3 py-1.5 rounded-md border border-border bg-surface-2 text-sm"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={voidInvoice}
|
||||||
|
disabled={busyAction !== null}
|
||||||
|
className="px-3 py-1.5 rounded-md bg-error text-white text-sm disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{busyAction === "void" ? t("saving") : t("confirmVoid")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setVoidOpen(false);
|
||||||
|
setVoidReason("");
|
||||||
|
}}
|
||||||
|
className="px-3 py-1.5 rounded-md border border-border text-sm"
|
||||||
|
>
|
||||||
|
{t("cancel")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{/* Phase 7 — Refund: paid invoices, including ones already
|
||||||
|
partially refunded (as long as some refundable amount
|
||||||
|
remains). Opens an inline form with amount + reason.
|
||||||
|
The remaining-refundable hint helps admin pick the
|
||||||
|
right number. */}
|
||||||
|
{(invoice.status === "paid" ||
|
||||||
|
invoice.status === "partially_refunded") &&
|
||||||
|
remainingRefundable > 0 && (
|
||||||
|
<>
|
||||||
|
{!refundOpen ? (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setRefundOpen(true);
|
||||||
|
setNoteOpen(false);
|
||||||
|
setVoidOpen(false);
|
||||||
|
setRefundAmount(remainingRefundable.toFixed(2));
|
||||||
|
}}
|
||||||
|
disabled={busyAction !== null}
|
||||||
|
className="px-4 py-2 rounded-md border border-error text-error text-sm disabled:opacity-50 hover:bg-error/10"
|
||||||
|
>
|
||||||
|
{t("refundBtn")}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-2 flex-grow">
|
||||||
|
<div className="text-xs text-text-muted">
|
||||||
|
{t("refundRemainingHint", {
|
||||||
|
max: remainingRefundable.toFixed(2),
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4 flex-wrap">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label className="text-[10px] uppercase tracking-wider text-text-muted">
|
||||||
|
{t("refundAmountLabel")}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
min="0.01"
|
||||||
|
max={remainingRefundable}
|
||||||
|
placeholder="CHF"
|
||||||
|
value={refundAmount}
|
||||||
|
onChange={(e) => setRefundAmount(e.target.value)}
|
||||||
|
className="w-32 px-3 py-1.5 rounded-md border border-border bg-surface-2 text-sm font-mono"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<span className="text-[10px] text-text-muted italic">
|
||||||
|
{t("refundAmountInclVatHint")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1 flex-grow min-w-[200px]">
|
||||||
|
<label className="text-[10px] uppercase tracking-wider text-text-muted">
|
||||||
|
{t("refundReasonLabel")}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder={t("refundReasonPlaceholder")}
|
||||||
|
value={refundReason}
|
||||||
|
onChange={(e) => setRefundReason(e.target.value)}
|
||||||
|
maxLength={500}
|
||||||
|
className="w-full px-3 py-1.5 rounded-md border border-border bg-surface-2 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 self-end">
|
||||||
|
<button
|
||||||
|
onClick={refundInvoice}
|
||||||
|
disabled={busyAction !== null}
|
||||||
|
className="px-3 py-1.5 rounded-md bg-error text-white text-sm disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{busyAction === "refund"
|
||||||
|
? t("saving")
|
||||||
|
: t("confirmRefund")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setRefundOpen(false);
|
||||||
|
setRefundAmount("");
|
||||||
|
setRefundReason("");
|
||||||
|
}}
|
||||||
|
className="px-3 py-1.5 rounded-md border border-border text-sm"
|
||||||
|
>
|
||||||
|
{t("cancel")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={deleteInvoice}
|
||||||
|
disabled={busyAction !== null}
|
||||||
|
className="ml-auto px-4 py-2 rounded-md border border-error text-error text-sm disabled:opacity-50 hover:bg-error/10"
|
||||||
|
title={t("deleteHint")}
|
||||||
|
>
|
||||||
|
{busyAction === "delete" ? t("deleting") : t("deleteBtn")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{actionError && (
|
||||||
|
<div className="mt-3 text-sm text-error">{actionError}</div>
|
||||||
|
)}
|
||||||
|
{invoice.paidAt && (
|
||||||
|
<div className="mt-3 text-xs text-text-muted">
|
||||||
|
{t("paidOnLabel")}: {invoice.paidAt} · {invoice.paidBy} ·{" "}
|
||||||
|
{invoice.paidMethodDetail}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* Phase 7 — void/refund summary lines, shown when applicable.
|
||||||
|
Surfaces the auditing context that the columns alone don't
|
||||||
|
(who voided, what the reason was, how much has been
|
||||||
|
refunded vs how much remains). */}
|
||||||
|
{invoice.voidedAt && (
|
||||||
|
<div className="mt-3 text-xs text-text-muted">
|
||||||
|
{t("voidedOnLabel")}: {invoice.voidedAt} · {invoice.voidedBy}
|
||||||
|
{invoice.voidReason ? ` · ${invoice.voidReason}` : ""}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{invoice.refundedTotalChf > 0 && (
|
||||||
|
<div className="mt-3 text-xs text-text-muted">
|
||||||
|
{t("refundedTotalLabel")}: CHF{" "}
|
||||||
|
{invoice.refundedTotalChf.toFixed(2)} ·{" "}
|
||||||
|
{t("refundedRemainingLabel")}: CHF{" "}
|
||||||
|
{remainingRefundable.toFixed(2)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Phase 7 — linked credit notes panel. Hidden when there are
|
||||||
|
none (most invoices). When present, lists each credit note
|
||||||
|
with kind, amount, reason, issued date, and PDF download. */}
|
||||||
|
{creditNotes.length > 0 && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>{t("creditNotesPanelTitle")}</CardHeader>
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="text-xs text-text-muted text-left">
|
||||||
|
<tr>
|
||||||
|
<th className="pb-2 pr-4">{t("creditNoteNumberHeader")}</th>
|
||||||
|
<th className="pb-2 pr-4">{t("creditNoteKindHeader")}</th>
|
||||||
|
<th className="pb-2 pr-4 text-right">
|
||||||
|
{t("creditNoteAmountHeader")}
|
||||||
|
</th>
|
||||||
|
<th className="pb-2 pr-4">{t("creditNoteReasonHeader")}</th>
|
||||||
|
<th className="pb-2 pr-4">{t("creditNoteIssuedHeader")}</th>
|
||||||
|
<th className="pb-2 text-right">{t("creditNotePdfHeader")}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{creditNotes.map((cn) => (
|
||||||
|
<tr key={cn.id} className="border-t border-border">
|
||||||
|
<td className="py-2 pr-4 font-mono text-xs">
|
||||||
|
{cn.creditNoteNumber}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 pr-4">
|
||||||
|
<span className="px-2 py-0.5 rounded text-xs text-error bg-error/10">
|
||||||
|
{t(`creditNoteKind_${cn.kind}` as any)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="py-2 pr-4 text-right font-mono whitespace-nowrap">
|
||||||
|
CHF {cn.amountChf.toFixed(2)}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 pr-4 text-text-secondary text-xs">
|
||||||
|
{cn.reason ?? "—"}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 pr-4 text-xs text-text-muted whitespace-nowrap">
|
||||||
|
{cn.issuedAt.slice(0, 10)}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 text-right">
|
||||||
|
{cn.hasPdf ? (
|
||||||
|
<a
|
||||||
|
href={`/api/credit-notes/${encodeURIComponent(
|
||||||
|
cn.creditNoteNumber
|
||||||
|
)}/pdf`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-accent hover:underline text-xs"
|
||||||
|
>
|
||||||
|
{t("downloadPdfBtn")}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<span className="text-text-muted text-xs italic">
|
||||||
|
{t("creditNoteNoPdf")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</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"
|
||||||
|
: status === "partially_refunded" || status === "fully_refunded"
|
||||||
|
? "bg-error/15 text-error"
|
||||||
|
: "bg-accent/15 text-accent";
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`inline-block px-2 py-0.5 rounded text-xs font-medium ${color}`}
|
||||||
|
>
|
||||||
|
{t(`status_${status}`)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
204
src/components/admin/billing/invoices-table.tsx
Normal file
204
src/components/admin/billing/invoices-table.tsx
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { Card } from "@/components/ui/card";
|
||||||
|
import type { Invoice, InvoiceStatus } from "@/types";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
initialInvoices: Invoice[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_FILTERS: (InvoiceStatus | "all")[] = [
|
||||||
|
"all",
|
||||||
|
"open",
|
||||||
|
"overdue",
|
||||||
|
"paid",
|
||||||
|
"void",
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filterable invoice list. Filters live in URL-less local state
|
||||||
|
* (simpler than syncing to query string for a v1 admin tool); a
|
||||||
|
* page refresh resets.
|
||||||
|
*
|
||||||
|
* Re-fetching strategy: when filters change, hit the API directly
|
||||||
|
* rather than router.refresh() so we don't bounce the user through
|
||||||
|
* a full page render.
|
||||||
|
*/
|
||||||
|
export function InvoicesTable({ initialInvoices }: Props) {
|
||||||
|
const t = useTranslations("adminBilling");
|
||||||
|
const [statusFilter, setStatusFilter] = useState<InvoiceStatus | "all">("all");
|
||||||
|
const [monthFilter, setMonthFilter] = useState("");
|
||||||
|
const [invoices, setInvoices] = useState(initialInvoices);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Effect runs after initial render too; skip refetch on mount
|
||||||
|
// when filters are at their defaults — the server already
|
||||||
|
// gave us the right initial set.
|
||||||
|
if (statusFilter === "all" && monthFilter === "") return;
|
||||||
|
let cancelled = false;
|
||||||
|
setBusy(true);
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (statusFilter !== "all") params.set("status", statusFilter);
|
||||||
|
if (monthFilter) params.set("month", monthFilter);
|
||||||
|
fetch(`/api/admin/billing/invoices?${params}`)
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((data) => {
|
||||||
|
if (!cancelled) setInvoices(data);
|
||||||
|
})
|
||||||
|
.catch((e) => console.error("Failed to load invoices:", e))
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setBusy(false);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [statusFilter, monthFilter]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Card>
|
||||||
|
<div className="flex flex-wrap items-end gap-4">
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-xs text-text-muted">{t("statusFilterLabel")}</span>
|
||||||
|
<select
|
||||||
|
value={statusFilter}
|
||||||
|
onChange={(e) =>
|
||||||
|
setStatusFilter(e.target.value as InvoiceStatus | "all")
|
||||||
|
}
|
||||||
|
className="mt-1 px-3 py-1.5 rounded-md border border-border bg-surface-2 text-sm"
|
||||||
|
>
|
||||||
|
{STATUS_FILTERS.map((s) => (
|
||||||
|
<option key={s} value={s}>
|
||||||
|
{s === "all" ? t("allStatuses") : t(`status_${s}`)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-xs text-text-muted">{t("monthFilterLabel")}</span>
|
||||||
|
<input
|
||||||
|
type="month"
|
||||||
|
value={monthFilter}
|
||||||
|
onChange={(e) => setMonthFilter(e.target.value)}
|
||||||
|
className="mt-1 px-3 py-1.5 rounded-md border border-border bg-surface-2 text-sm"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{monthFilter && (
|
||||||
|
<button
|
||||||
|
onClick={() => setMonthFilter("")}
|
||||||
|
className="text-xs text-text-muted hover:underline"
|
||||||
|
>
|
||||||
|
{t("clearFilter")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{busy && (
|
||||||
|
<span className="text-xs text-text-muted ml-auto">
|
||||||
|
{t("loading")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{/* Phase 8: shortcuts to the custom-invoice flow. The
|
||||||
|
Drafts link is muted because most of the time it's
|
||||||
|
empty; New invoice is the prominent CTA. */}
|
||||||
|
<div className={`flex items-center gap-3 ${busy ? "" : "ml-auto"}`}>
|
||||||
|
<Link
|
||||||
|
href="/admin/billing/invoice-drafts"
|
||||||
|
className="text-xs text-text-muted hover:underline"
|
||||||
|
>
|
||||||
|
{t("draftsLink")}
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/admin/billing/invoices/new"
|
||||||
|
className="px-3 py-1.5 rounded-md bg-accent text-white text-sm"
|
||||||
|
>
|
||||||
|
+ {t("newInvoiceBtn")}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
{invoices.length === 0 ? (
|
||||||
|
<p className="text-sm text-text-muted italic text-center py-6">
|
||||||
|
{t("noInvoicesFound")}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="text-xs text-text-muted text-left">
|
||||||
|
<tr>
|
||||||
|
<th className="pb-2">{t("invoiceNumberCol")}</th>
|
||||||
|
<th className="pb-2">{t("orgCol")}</th>
|
||||||
|
<th className="pb-2">{t("periodCol")}</th>
|
||||||
|
<th className="pb-2">{t("statusCol")}</th>
|
||||||
|
<th className="pb-2 text-right">{t("totalCol")}</th>
|
||||||
|
<th className="pb-2 text-right">{t("dueCol")}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{invoices.map((inv) => (
|
||||||
|
<tr
|
||||||
|
key={inv.id}
|
||||||
|
className="border-t border-border hover:bg-surface-2 cursor-pointer"
|
||||||
|
>
|
||||||
|
<td className="py-2">
|
||||||
|
<Link
|
||||||
|
href={`/admin/billing/invoices/${inv.id}`}
|
||||||
|
className="font-mono text-xs hover:underline"
|
||||||
|
>
|
||||||
|
{inv.invoiceNumber}
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
<td className="py-2">
|
||||||
|
<div className="text-xs">
|
||||||
|
{inv.billingSnapshot.companyName || (
|
||||||
|
<span className="font-mono">{inv.zitadelOrgId}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="py-2 text-xs font-mono">
|
||||||
|
{inv.periodStart
|
||||||
|
? inv.periodStart.slice(0, 7)
|
||||||
|
: inv.source === "custom"
|
||||||
|
? "—"
|
||||||
|
: ""}
|
||||||
|
</td>
|
||||||
|
<td className="py-2">
|
||||||
|
<StatusPill status={inv.status} />
|
||||||
|
</td>
|
||||||
|
<td className="py-2 text-right">
|
||||||
|
CHF {inv.totalChf.toFixed(2)}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 text-right text-xs text-text-muted">
|
||||||
|
{inv.dueAt}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
166
src/components/admin/billing/new-invoice-form.tsx
Normal file
166
src/components/admin/billing/new-invoice-form.tsx
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { Card } from "@/components/ui/card";
|
||||||
|
|
||||||
|
interface OrgEntry {
|
||||||
|
zitadelOrgId: 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" },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Step 1 of the custom-invoice flow: pick an org. Creating the
|
||||||
|
* draft on the backend allocates an id we redirect to; the editor
|
||||||
|
* page then loads the draft and lets the admin add lines.
|
||||||
|
*
|
||||||
|
* The dropdown shows the company name when known, falling back to
|
||||||
|
* the raw org id. Orgs without a billing snapshot are visually
|
||||||
|
* marked and warn the admin — they can still create the draft but
|
||||||
|
* won't be able to issue until billing info is set.
|
||||||
|
*
|
||||||
|
* Default issue date = today; due date = today + 30 days. These
|
||||||
|
* are sensible defaults the editor can override.
|
||||||
|
*/
|
||||||
|
export function NewInvoiceForm({ orgs }: Props) {
|
||||||
|
const t = useTranslations("adminBilling");
|
||||||
|
const router = useRouter();
|
||||||
|
const [orgId, setOrgId] = useState(
|
||||||
|
orgs.find((o) => o.hasBillingAddress)?.zitadelOrgId ??
|
||||||
|
orgs[0]?.zitadelOrgId ??
|
||||||
|
""
|
||||||
|
);
|
||||||
|
const [locale, setLocale] = useState<"de" | "en" | "fr" | "it">("de");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
const selected = orgs.find((o) => o.zitadelOrgId === orgId);
|
||||||
|
|
||||||
|
// Pick a locale default from the org's country if admin hasn't
|
||||||
|
// overridden — same heuristic the auto cron uses.
|
||||||
|
const onOrgChange = (newOrgId: string) => {
|
||||||
|
setOrgId(newOrgId);
|
||||||
|
const o = orgs.find((x) => x.zitadelOrgId === newOrgId);
|
||||||
|
const c = (o?.country ?? "").toUpperCase();
|
||||||
|
if (["CH", "LI", "AT", "DE"].includes(c)) setLocale("de");
|
||||||
|
else if (["FR", "BE", "LU"].includes(c)) setLocale("fr");
|
||||||
|
else if (c === "IT") setLocale("it");
|
||||||
|
else setLocale("en");
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSubmit = async () => {
|
||||||
|
if (!orgId) {
|
||||||
|
setError(t("newInvoiceOrgRequired"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setError("");
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
|
const due = new Date();
|
||||||
|
due.setDate(due.getDate() + 30);
|
||||||
|
const dueIso = due.toISOString().slice(0, 10);
|
||||||
|
const res = await fetch("/api/admin/billing/invoice-drafts", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
zitadelOrgId: orgId,
|
||||||
|
payload: {
|
||||||
|
issueDate: today,
|
||||||
|
dueDate: dueIso,
|
||||||
|
locale,
|
||||||
|
paymentMethod: "invoice",
|
||||||
|
lines: [],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const j = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) throw new Error(j.error || `HTTP ${res.status}`);
|
||||||
|
router.push(`/admin/billing/invoice-drafts/${j.draft.id}`);
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e.message);
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<div className="p-5 flex flex-col gap-4">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label className="text-xs uppercase tracking-wider text-text-muted">
|
||||||
|
{t("newInvoiceOrgLabel")}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={orgId}
|
||||||
|
onChange={(e) => onOrgChange(e.target.value)}
|
||||||
|
className="px-3 py-2 rounded-md border border-border bg-surface-2 text-sm"
|
||||||
|
>
|
||||||
|
<option value="">{t("newInvoiceOrgPlaceholder")}</option>
|
||||||
|
{orgs.map((o) => (
|
||||||
|
<option
|
||||||
|
key={o.zitadelOrgId}
|
||||||
|
value={o.zitadelOrgId}
|
||||||
|
disabled={!o.hasBillingAddress}
|
||||||
|
>
|
||||||
|
{o.companyName ?? o.zitadelOrgId}
|
||||||
|
{!o.hasBillingAddress
|
||||||
|
? ` (${t("newInvoiceOrgNoBilling")})`
|
||||||
|
: ""}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{selected && !selected.hasBillingAddress && (
|
||||||
|
<p className="text-xs text-error mt-1">
|
||||||
|
{t("newInvoiceOrgBillingMissing")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label className="text-xs uppercase tracking-wider text-text-muted">
|
||||||
|
{t("newInvoiceLocaleLabel")}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={locale}
|
||||||
|
onChange={(e) =>
|
||||||
|
setLocale(e.target.value as "de" | "en" | "fr" | "it")
|
||||||
|
}
|
||||||
|
className="px-3 py-2 rounded-md border border-border bg-surface-2 text-sm"
|
||||||
|
>
|
||||||
|
{LOCALE_OPTIONS.map((o) => (
|
||||||
|
<option key={o.value} value={o.value}>
|
||||||
|
{o.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <div className="text-sm text-error">{error}</div>}
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
|
<button
|
||||||
|
onClick={onSubmit}
|
||||||
|
disabled={busy || !orgId || !selected?.hasBillingAddress}
|
||||||
|
className="px-4 py-2 rounded-md bg-accent text-white text-sm disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{busy ? t("creating") : t("newInvoiceContinueBtn")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
158
src/components/admin/billing/org-payment-mode-list.tsx
Normal file
158
src/components/admin/billing/org-payment-mode-list.tsx
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { Card } from "@/components/ui/card";
|
||||||
|
|
||||||
|
interface OrgEntry {
|
||||||
|
zitadelOrgId: string;
|
||||||
|
companyName: string | null;
|
||||||
|
country: string | null;
|
||||||
|
hasSavedCard: boolean;
|
||||||
|
cardLabel: string | null;
|
||||||
|
payByInvoice: boolean;
|
||||||
|
autoChargeEnabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
orgs: OrgEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inline toggles for pay_by_invoice and auto_charge_enabled per
|
||||||
|
* org. Each toggle round-trips to /api/admin/billing/orgs/[orgId]
|
||||||
|
* /payment-mode and then router.refresh() so the server-fetched
|
||||||
|
* state stays canonical (avoids drift between optimistic UI and
|
||||||
|
* the DB).
|
||||||
|
*
|
||||||
|
* Phase 9b-2.
|
||||||
|
*/
|
||||||
|
export function OrgPaymentModeList({ orgs }: Props) {
|
||||||
|
const t = useTranslations("adminBilling");
|
||||||
|
const router = useRouter();
|
||||||
|
const [busy, setBusy] = useState<string | null>(null);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
const toggle = async (
|
||||||
|
orgId: string,
|
||||||
|
patch: { payByInvoice?: boolean; autoChargeEnabled?: boolean }
|
||||||
|
) => {
|
||||||
|
setError("");
|
||||||
|
setBusy(orgId);
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`/api/admin/billing/orgs/${encodeURIComponent(orgId)}/payment-mode`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(patch),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const j = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) throw new Error(j.error || `HTTP ${res.status}`);
|
||||||
|
router.refresh();
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e.message);
|
||||||
|
} finally {
|
||||||
|
setBusy(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (orgs.length === 0) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<div className="p-6 text-center text-text-secondary text-sm">
|
||||||
|
{t("orgsEmpty")}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
{error && (
|
||||||
|
<div className="text-sm text-error border-b border-error/30 bg-error/10 px-4 py-2">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="text-xs text-text-muted text-left">
|
||||||
|
<tr>
|
||||||
|
<th className="pb-2 pl-3 pr-4">{t("orgsColCustomer")}</th>
|
||||||
|
<th className="pb-2 pr-4">{t("orgsColCard")}</th>
|
||||||
|
<th className="pb-2 pr-4 text-center">
|
||||||
|
{t("orgsColPayByInvoice")}
|
||||||
|
</th>
|
||||||
|
<th className="pb-2 pr-4 text-center">
|
||||||
|
{t("orgsColAutoCharge")}
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{orgs.map((o) => (
|
||||||
|
<tr key={o.zitadelOrgId} className="border-t border-border">
|
||||||
|
<td className="py-2 pl-3 pr-4">
|
||||||
|
<div className="font-medium">
|
||||||
|
{o.companyName ?? (
|
||||||
|
<span className="font-mono text-xs">{o.zitadelOrgId}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{o.country && (
|
||||||
|
<div className="text-xs text-text-muted">{o.country}</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 pr-4 text-xs">
|
||||||
|
{o.hasSavedCard ? (
|
||||||
|
<span className="font-mono">{o.cardLabel}</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-text-muted">
|
||||||
|
{t("orgsNoSavedCard")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 pr-4 text-center">
|
||||||
|
<label className="inline-flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={o.payByInvoice}
|
||||||
|
disabled={busy === o.zitadelOrgId}
|
||||||
|
onChange={(e) =>
|
||||||
|
toggle(o.zitadelOrgId, {
|
||||||
|
payByInvoice: e.target.checked,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span className="text-xs">
|
||||||
|
{o.payByInvoice
|
||||||
|
? t("orgsPayByInvoiceOn")
|
||||||
|
: t("orgsPayByInvoiceOff")}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</td>
|
||||||
|
<td className="py-2 pr-4 text-center">
|
||||||
|
<label className="inline-flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={o.autoChargeEnabled}
|
||||||
|
disabled={busy === o.zitadelOrgId || o.payByInvoice}
|
||||||
|
onChange={(e) =>
|
||||||
|
toggle(o.zitadelOrgId, {
|
||||||
|
autoChargeEnabled: e.target.checked,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span className="text-xs">
|
||||||
|
{o.autoChargeEnabled
|
||||||
|
? t("orgsAutoChargeOn")
|
||||||
|
: t("orgsAutoChargeOff")}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
491
src/components/admin/billing/pricing-editor.tsx
Normal file
491
src/components/admin/billing/pricing-editor.tsx
Normal file
@@ -0,0 +1,491 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { Card, CardHeader } from "@/components/ui/card";
|
||||||
|
import type { PlatformPricing, SkillPricing } from "@/types";
|
||||||
|
|
||||||
|
interface CatalogEntry {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
category: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
initialPricing: PlatformPricing;
|
||||||
|
initialSkillPricing: SkillPricing[];
|
||||||
|
catalog: CatalogEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Two-card layout:
|
||||||
|
* 1. Platform pricing form (4 inputs, save = PUT to /pricing).
|
||||||
|
* 2. Skill pricing table — list of priced skills, "Add skill"
|
||||||
|
* picker below.
|
||||||
|
*
|
||||||
|
* No optimistic updates — every save round-trips and we
|
||||||
|
* router.refresh() afterwards so the server-side render stays
|
||||||
|
* the source of truth.
|
||||||
|
*/
|
||||||
|
export function PricingEditor({
|
||||||
|
initialPricing,
|
||||||
|
initialSkillPricing,
|
||||||
|
catalog,
|
||||||
|
}: Props) {
|
||||||
|
const t = useTranslations("adminBilling");
|
||||||
|
const tPackages = useTranslations("packages");
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
// -- Platform pricing form ----------------------------------------------
|
||||||
|
const [monthly, setMonthly] = useState(
|
||||||
|
String(initialPricing.tenantMonthlyFeeChf)
|
||||||
|
);
|
||||||
|
const [setup, setSetup] = useState(String(initialPricing.tenantSetupFeeChf));
|
||||||
|
const [threema, setThreema] = useState(
|
||||||
|
String(initialPricing.threemaMessageChf)
|
||||||
|
);
|
||||||
|
const [vat, setVat] = useState(String(initialPricing.vatRateChli));
|
||||||
|
const [savingPricing, setSavingPricing] = useState(false);
|
||||||
|
const [pricingError, setPricingError] = useState("");
|
||||||
|
const [pricingSaved, setPricingSaved] = useState(false);
|
||||||
|
|
||||||
|
const savePricing = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setSavingPricing(true);
|
||||||
|
setPricingError("");
|
||||||
|
setPricingSaved(false);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/admin/billing/pricing", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
tenantMonthlyFeeChf: Number(monthly),
|
||||||
|
tenantSetupFeeChf: Number(setup),
|
||||||
|
threemaMessageChf: Number(threema),
|
||||||
|
vatRateChli: Number(vat),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const j = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(j.error || `HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
setPricingSaved(true);
|
||||||
|
router.refresh();
|
||||||
|
} catch (e: any) {
|
||||||
|
setPricingError(e.message);
|
||||||
|
} finally {
|
||||||
|
setSavingPricing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// -- Package pricing ----------------------------------------------------
|
||||||
|
// Server is authoritative — we don't keep an editable local copy of the
|
||||||
|
// table; instead each action posts to the API and we router.refresh().
|
||||||
|
//
|
||||||
|
// Naming carry-over: the underlying DB table is `skill_pricing` and the
|
||||||
|
// column is `skill_id`, dating from when only skills were priced. The
|
||||||
|
// model now applies to any PackageDef in the catalog regardless of
|
||||||
|
// category — core, channel, or skill. The state variable names below
|
||||||
|
// (newSkill*, addingSkill, etc.) retain the legacy "skill" prefix
|
||||||
|
// because renaming the entire surface for purely cosmetic reasons
|
||||||
|
// would create churn for no functional gain. Treat "skill" here as
|
||||||
|
// shorthand for "priced package".
|
||||||
|
const [newSkillId, setNewSkillId] = useState(catalog[0]?.id ?? "");
|
||||||
|
const [newSkillPrice, setNewSkillPrice] = useState("0.10");
|
||||||
|
const [newSkillSetupFee, setNewSkillSetupFee] = useState("0");
|
||||||
|
const [addingSkill, setAddingSkill] = useState(false);
|
||||||
|
const [skillError, setSkillError] = useState("");
|
||||||
|
|
||||||
|
// Core upsert — used by both the "add new skill" form and the inline
|
||||||
|
// editors on existing rows. Kept event-free so callers can invoke it
|
||||||
|
// without synthesizing a fake form event. Both `dailyPriceChf` and
|
||||||
|
// `setupFeeChf` are written together because the API does a full
|
||||||
|
// upsert; partial updates would silently zero the other field.
|
||||||
|
const upsertSkillPrice = async (
|
||||||
|
skillId: string,
|
||||||
|
dailyPriceChf: number,
|
||||||
|
setupFeeChf: number
|
||||||
|
) => {
|
||||||
|
setAddingSkill(true);
|
||||||
|
setSkillError("");
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/admin/billing/skill-pricing", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ skillId, dailyPriceChf, setupFeeChf }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const j = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(j.error || `HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
router.refresh();
|
||||||
|
} catch (e: any) {
|
||||||
|
setSkillError(e.message);
|
||||||
|
} finally {
|
||||||
|
setAddingSkill(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onAddNewSkill = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!newSkillId) return;
|
||||||
|
void upsertSkillPrice(
|
||||||
|
newSkillId,
|
||||||
|
Number(newSkillPrice),
|
||||||
|
Number(newSkillSetupFee)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteSkill = async (skillId: string) => {
|
||||||
|
if (!confirm(t("confirmDeleteSkillPrice", { skill: skillId }))) return;
|
||||||
|
setSkillError("");
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`/api/admin/billing/skill-pricing/${encodeURIComponent(skillId)}`,
|
||||||
|
{ method: "DELETE" }
|
||||||
|
);
|
||||||
|
if (!res.ok) {
|
||||||
|
const j = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(j.error || `HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
router.refresh();
|
||||||
|
} catch (e: any) {
|
||||||
|
setSkillError(e.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Pricing applies to any catalog entry regardless of category. Grouped
|
||||||
|
// dropdown sorts options by category for visual scanning — core,
|
||||||
|
// channel, and skill in a single picker.
|
||||||
|
const skillCatalogOptions = [...catalog].sort((a, b) => {
|
||||||
|
const order = { core: 0, channel: 1, skill: 2 } as Record<string, number>;
|
||||||
|
const ca = order[a.category] ?? 99;
|
||||||
|
const cb = order[b.category] ?? 99;
|
||||||
|
if (ca !== cb) return ca - cb;
|
||||||
|
return a.name.localeCompare(b.name);
|
||||||
|
});
|
||||||
|
const catalogIndex = new Map(catalog.map((c) => [c.id, c]));
|
||||||
|
const pricedIds = new Set(initialSkillPricing.map((s) => s.skillId));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>{t("platformPricingTitle")}</CardHeader>
|
||||||
|
<form onSubmit={savePricing} className="space-y-4">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-sm text-text-secondary">
|
||||||
|
{t("monthlyFeeLabel")} (CHF)
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
min="0"
|
||||||
|
value={monthly}
|
||||||
|
onChange={(e) => setMonthly(e.target.value)}
|
||||||
|
className="mt-1 w-full px-3 py-2 rounded-md border border-border bg-surface-2 text-sm"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-sm text-text-secondary">
|
||||||
|
{t("setupFeeLabel")} (CHF)
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
min="0"
|
||||||
|
value={setup}
|
||||||
|
onChange={(e) => setSetup(e.target.value)}
|
||||||
|
className="mt-1 w-full px-3 py-2 rounded-md border border-border bg-surface-2 text-sm"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-sm text-text-secondary">
|
||||||
|
{t("threemaMessageLabel")} (CHF)
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.0001"
|
||||||
|
min="0"
|
||||||
|
value={threema}
|
||||||
|
onChange={(e) => setThreema(e.target.value)}
|
||||||
|
className="mt-1 w-full px-3 py-2 rounded-md border border-border bg-surface-2 text-sm"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-sm text-text-secondary">
|
||||||
|
{t("vatRateLabel")} (%)
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
value={vat}
|
||||||
|
onChange={(e) => setVat(e.target.value)}
|
||||||
|
className="mt-1 w-full px-3 py-2 rounded-md border border-border bg-surface-2 text-sm"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={savingPricing}
|
||||||
|
className="px-4 py-2 rounded-md bg-accent text-white text-sm disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{savingPricing ? t("saving") : t("save")}
|
||||||
|
</button>
|
||||||
|
{pricingSaved && (
|
||||||
|
<span className="text-sm text-success">{t("savedOk")}</span>
|
||||||
|
)}
|
||||||
|
{pricingError && (
|
||||||
|
<span className="text-sm text-error">{pricingError}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>{t("skillPricingTitle")}</CardHeader>
|
||||||
|
<p className="text-sm text-text-muted mb-4">{t("skillPricingDesc")}</p>
|
||||||
|
|
||||||
|
{initialSkillPricing.length > 0 ? (
|
||||||
|
<table className="w-full text-sm mb-6">
|
||||||
|
<thead className="text-xs text-text-muted text-left">
|
||||||
|
<tr>
|
||||||
|
<th className="pb-2">{t("skillCol")}</th>
|
||||||
|
<th className="pb-2 text-right">{t("dailyPriceCol")}</th>
|
||||||
|
<th className="pb-2 text-right">{t("setupFeeCol")}</th>
|
||||||
|
<th className="pb-2 text-right">{t("actionsCol")}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{initialSkillPricing.map((sp) => {
|
||||||
|
const entry = catalogIndex.get(sp.skillId);
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={sp.skillId}
|
||||||
|
className="border-t border-border align-top"
|
||||||
|
>
|
||||||
|
<td className="py-2">
|
||||||
|
<div className="font-mono text-xs">{sp.skillId}</div>
|
||||||
|
{entry && (
|
||||||
|
<div className="text-xs text-text-muted flex items-center gap-2">
|
||||||
|
<span>{entry.name}</span>
|
||||||
|
<span className="text-[10px] uppercase tracking-wider bg-surface-3 px-1.5 py-0.5 rounded">
|
||||||
|
{entry.category}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 text-right">
|
||||||
|
{/* Inline edits write daily + setup together (full
|
||||||
|
upsert on the API side). The other field is
|
||||||
|
held constant from the snapshot here. */}
|
||||||
|
<InlinePriceEditor
|
||||||
|
skillId={sp.skillId}
|
||||||
|
initialPrice={sp.dailyPriceChf}
|
||||||
|
decimals={4}
|
||||||
|
onSave={(price) =>
|
||||||
|
upsertSkillPrice(sp.skillId, price, sp.setupFeeChf)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="py-2 text-right">
|
||||||
|
<InlinePriceEditor
|
||||||
|
skillId={`${sp.skillId}-setup`}
|
||||||
|
initialPrice={sp.setupFeeChf}
|
||||||
|
decimals={2}
|
||||||
|
onSave={(fee) =>
|
||||||
|
upsertSkillPrice(sp.skillId, sp.dailyPriceChf, fee)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="py-2 text-right">
|
||||||
|
<button
|
||||||
|
onClick={() => deleteSkill(sp.skillId)}
|
||||||
|
className="text-xs text-error hover:underline"
|
||||||
|
>
|
||||||
|
{t("remove")}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-text-muted italic mb-4">{t("noSkillsPriced")}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={onAddNewSkill} className="flex items-end gap-3">
|
||||||
|
<label className="flex-grow">
|
||||||
|
<span className="text-xs text-text-muted">{t("addSkillLabel")}</span>
|
||||||
|
<select
|
||||||
|
value={newSkillId}
|
||||||
|
onChange={(e) => setNewSkillId(e.target.value)}
|
||||||
|
className="mt-1 w-full px-3 py-2 rounded-md border border-border bg-surface-2 text-sm"
|
||||||
|
>
|
||||||
|
{(() => {
|
||||||
|
// Group available options by category for the picker.
|
||||||
|
// Already-priced packages are filtered out (admin
|
||||||
|
// edits those inline above).
|
||||||
|
const available = skillCatalogOptions.filter(
|
||||||
|
(c) => !pricedIds.has(c.id)
|
||||||
|
);
|
||||||
|
const byCat = new Map<string, typeof available>();
|
||||||
|
for (const c of available) {
|
||||||
|
if (!byCat.has(c.category)) byCat.set(c.category, []);
|
||||||
|
byCat.get(c.category)!.push(c);
|
||||||
|
}
|
||||||
|
// Labels for the optgroups. Reuse the existing
|
||||||
|
// packages.categories.* scope which already has
|
||||||
|
// translations in all four locales.
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
core: tPackages("categories.core"),
|
||||||
|
channel: tPackages("categories.channels"),
|
||||||
|
skill: tPackages("categories.skills"),
|
||||||
|
};
|
||||||
|
const order: Array<"core" | "channel" | "skill"> = [
|
||||||
|
"core",
|
||||||
|
"channel",
|
||||||
|
"skill",
|
||||||
|
];
|
||||||
|
return order.map((cat) => {
|
||||||
|
const items = byCat.get(cat);
|
||||||
|
if (!items || items.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<optgroup key={cat} label={labels[cat] ?? cat}>
|
||||||
|
{items.map((c) => (
|
||||||
|
<option key={c.id} value={c.id}>
|
||||||
|
{c.name} ({c.id})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</optgroup>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
})()}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="w-28">
|
||||||
|
<span className="text-xs text-text-muted">
|
||||||
|
{t("dailyPriceLabel")}
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
min="0"
|
||||||
|
value={newSkillPrice}
|
||||||
|
onChange={(e) => setNewSkillPrice(e.target.value)}
|
||||||
|
className="mt-1 w-full px-3 py-2 rounded-md border border-border bg-surface-2 text-sm"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="w-28">
|
||||||
|
<span className="text-xs text-text-muted">
|
||||||
|
{t("skillSetupFeeLabel")}
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
min="0"
|
||||||
|
value={newSkillSetupFee}
|
||||||
|
onChange={(e) => setNewSkillSetupFee(e.target.value)}
|
||||||
|
className="mt-1 w-full px-3 py-2 rounded-md border border-border bg-surface-2 text-sm"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={addingSkill || !newSkillId}
|
||||||
|
className="px-4 py-2 rounded-md bg-accent text-white text-sm disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{addingSkill ? t("saving") : t("add")}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{skillError && (
|
||||||
|
<p className="text-sm text-error mt-2">{skillError}</p>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tiny inline editor for a single numeric price/fee. Mounts in
|
||||||
|
* "view" mode showing the current value as a clickable badge;
|
||||||
|
* clicking turns it into an input + save/cancel buttons.
|
||||||
|
*
|
||||||
|
* `decimals` controls the display precision in view mode AND the
|
||||||
|
* step granularity of the input (daily prices use 4dp, setup fees
|
||||||
|
* use 2dp).
|
||||||
|
*/
|
||||||
|
function InlinePriceEditor({
|
||||||
|
skillId,
|
||||||
|
initialPrice,
|
||||||
|
decimals = 2,
|
||||||
|
onSave,
|
||||||
|
}: {
|
||||||
|
skillId: string;
|
||||||
|
initialPrice: number;
|
||||||
|
decimals?: number;
|
||||||
|
onSave: (price: number) => Promise<void> | void;
|
||||||
|
}) {
|
||||||
|
const t = useTranslations("adminBilling");
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
const [value, setValue] = useState(String(initialPrice));
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const step = decimals === 4 ? "0.0001" : "0.01";
|
||||||
|
|
||||||
|
if (!editing) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={() => setEditing(true)}
|
||||||
|
className="text-sm font-mono hover:underline"
|
||||||
|
title={t("clickToEdit")}
|
||||||
|
>
|
||||||
|
CHF {initialPrice.toFixed(decimals)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step={step}
|
||||||
|
min="0"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => setValue(e.target.value)}
|
||||||
|
className="w-20 px-2 py-1 text-sm border border-border bg-surface-2 rounded"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await onSave(Number(value));
|
||||||
|
setEditing(false);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={busy}
|
||||||
|
className="text-xs px-2 py-1 bg-accent text-white rounded"
|
||||||
|
>
|
||||||
|
{busy ? "…" : "✓"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setValue(String(initialPrice));
|
||||||
|
setEditing(false);
|
||||||
|
}}
|
||||||
|
className="text-xs px-2 py-1 border border-border rounded"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
249
src/components/admin/cron/cron-controls.tsx
Normal file
249
src/components/admin/cron/cron-controls.tsx
Normal file
@@ -0,0 +1,249 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useTranslations, useFormatter } from "next-intl";
|
||||||
|
import { Card } from "@/components/ui/card";
|
||||||
|
import type { CronRun } from "@/types";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
initialRecent: CronRun[];
|
||||||
|
initialLastSuccess: {
|
||||||
|
monthlyIssue: CronRun | null;
|
||||||
|
reminders: CronRun | null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin cron dashboard. Server pre-loads `initialRecent` and
|
||||||
|
* `initialLastSuccess`; "Run now" clicks POST to the admin
|
||||||
|
* endpoints, then re-fetch the history via GET /api/admin/cron/runs.
|
||||||
|
*
|
||||||
|
* The trigger buttons disable while busy and surface the resulting
|
||||||
|
* counters inline so the admin gets immediate feedback without
|
||||||
|
* needing to scroll to the history table.
|
||||||
|
*/
|
||||||
|
export function CronControls({ initialRecent, initialLastSuccess }: Props) {
|
||||||
|
const t = useTranslations("adminCron");
|
||||||
|
const fmt = useFormatter();
|
||||||
|
const [recent, setRecent] = useState(initialRecent);
|
||||||
|
const [lastSuccess, setLastSuccess] = useState(initialLastSuccess);
|
||||||
|
const [busy, setBusy] = useState<null | "issue" | "reminders">(null);
|
||||||
|
const [flash, setFlash] = useState<null | {
|
||||||
|
kind: "issue" | "reminders";
|
||||||
|
ok: boolean;
|
||||||
|
summary: string;
|
||||||
|
}>(null);
|
||||||
|
|
||||||
|
const refresh = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/admin/cron/runs");
|
||||||
|
if (!res.ok) return;
|
||||||
|
const data = await res.json();
|
||||||
|
setRecent(data.recent);
|
||||||
|
setLastSuccess(data.lastSuccess);
|
||||||
|
} catch {
|
||||||
|
// swallow — refresh is opportunistic
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const triggerIssue = async () => {
|
||||||
|
setBusy("issue");
|
||||||
|
setFlash(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/admin/cron/issue-monthly", {
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
const j = await res.json();
|
||||||
|
if (!res.ok) {
|
||||||
|
setFlash({
|
||||||
|
kind: "issue",
|
||||||
|
ok: false,
|
||||||
|
summary: j.error ?? `HTTP ${res.status}`,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setFlash({
|
||||||
|
kind: "issue",
|
||||||
|
ok: true,
|
||||||
|
summary: t("flashIssueOk", {
|
||||||
|
success: j.successCount,
|
||||||
|
skipped: j.skippedCount,
|
||||||
|
failure: j.failureCount,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await refresh();
|
||||||
|
} finally {
|
||||||
|
setBusy(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const triggerReminders = async () => {
|
||||||
|
setBusy("reminders");
|
||||||
|
setFlash(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/admin/cron/send-reminders", {
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
const j = await res.json();
|
||||||
|
if (!res.ok) {
|
||||||
|
setFlash({
|
||||||
|
kind: "reminders",
|
||||||
|
ok: false,
|
||||||
|
summary: j.error ?? `HTTP ${res.status}`,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setFlash({
|
||||||
|
kind: "reminders",
|
||||||
|
ok: true,
|
||||||
|
summary: t("flashRemindersOk", {
|
||||||
|
success: j.successCount,
|
||||||
|
skipped: j.skippedCount,
|
||||||
|
failure: j.failureCount,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await refresh();
|
||||||
|
} finally {
|
||||||
|
setBusy(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fmtRelative = (iso: string | null) => {
|
||||||
|
if (!iso) return t("never");
|
||||||
|
return fmt.dateTime(new Date(iso), {
|
||||||
|
dateStyle: "medium",
|
||||||
|
timeStyle: "short",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Phase 6: surface failures prominently. Any run in the recent
|
||||||
|
// window with a non-zero failure_count drives a top-of-page
|
||||||
|
// banner — the row in the table is already red, but a banner
|
||||||
|
// means the admin doesn't have to scroll to notice.
|
||||||
|
const recentFailures = recent.filter((r) => r.failureCount > 0);
|
||||||
|
const hasRecentFailures = recentFailures.length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
{hasRecentFailures && (
|
||||||
|
<div className="p-4 rounded-md border border-error bg-error/10 text-sm text-error">
|
||||||
|
<p className="font-medium mb-1">{t("failureBannerTitle")}</p>
|
||||||
|
<p className="text-xs">
|
||||||
|
{t("failureBannerBody", { count: recentFailures.length })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<section className="grid gap-4 md:grid-cols-2">
|
||||||
|
<Card>
|
||||||
|
<h2 className="text-xs uppercase tracking-wider text-text-muted mb-2">
|
||||||
|
{t("monthlyIssue")}
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs text-text-secondary mb-1">
|
||||||
|
{t("scheduleIssueLabel")}: <span className="font-mono">{t("scheduleIssueValue")}</span>
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-text-secondary mb-3">
|
||||||
|
{t("lastSuccess")}: <span className="font-mono">{fmtRelative(lastSuccess.monthlyIssue?.startedAt ?? null)}</span>
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={triggerIssue}
|
||||||
|
disabled={busy !== null}
|
||||||
|
className="px-4 py-2 rounded-md bg-accent text-white text-sm font-medium hover:bg-accent-dim transition-colors disabled:opacity-50 cursor-pointer"
|
||||||
|
>
|
||||||
|
{busy === "issue" ? t("running") : t("runIssueNow")}
|
||||||
|
</button>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<h2 className="text-xs uppercase tracking-wider text-text-muted mb-2">
|
||||||
|
{t("reminders")}
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs text-text-secondary mb-1">
|
||||||
|
{t("scheduleReminderLabel")}: <span className="font-mono">{t("scheduleReminderValue")}</span>
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-text-secondary mb-3">
|
||||||
|
{t("lastSuccess")}: <span className="font-mono">{fmtRelative(lastSuccess.reminders?.startedAt ?? null)}</span>
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={triggerReminders}
|
||||||
|
disabled={busy !== null}
|
||||||
|
className="px-4 py-2 rounded-md bg-accent text-white text-sm font-medium hover:bg-accent-dim transition-colors disabled:opacity-50 cursor-pointer"
|
||||||
|
>
|
||||||
|
{busy === "reminders" ? t("running") : t("runRemindersNow")}
|
||||||
|
</button>
|
||||||
|
</Card>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{flash && (
|
||||||
|
<div
|
||||||
|
className={`p-3 rounded-md border text-sm ${
|
||||||
|
flash.ok
|
||||||
|
? "border-success bg-success/10 text-success"
|
||||||
|
: "border-error bg-error/10 text-error"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{flash.summary}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2 className="text-xs uppercase tracking-wider text-text-muted mb-3">
|
||||||
|
{t("recentRuns")}
|
||||||
|
</h2>
|
||||||
|
<Card>
|
||||||
|
{recent.length === 0 ? (
|
||||||
|
<p className="text-sm text-text-muted italic py-4">
|
||||||
|
{t("noRunsYet")}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="text-xs text-text-muted text-left">
|
||||||
|
<tr>
|
||||||
|
<th className="pb-2">{t("startedCol")}</th>
|
||||||
|
<th className="pb-2">{t("kindCol")}</th>
|
||||||
|
<th className="pb-2">{t("triggeredByCol")}</th>
|
||||||
|
<th className="pb-2 text-right">{t("okCol")}</th>
|
||||||
|
<th className="pb-2 text-right">{t("skipCol")}</th>
|
||||||
|
<th className="pb-2 text-right">{t("failCol")}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{recent.map((r) => (
|
||||||
|
<tr
|
||||||
|
key={r.id}
|
||||||
|
className={`border-t border-border align-top ${
|
||||||
|
r.failureCount > 0 ? "bg-error/5" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<td className="py-2 text-xs font-mono">
|
||||||
|
{fmtRelative(r.startedAt)}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 text-xs">
|
||||||
|
{t(`kind.${r.runKind}` as any)}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 text-xs text-text-secondary font-mono">
|
||||||
|
{r.triggeredBy === "cron"
|
||||||
|
? t("triggeredByCron")
|
||||||
|
: r.triggeredBy.slice(0, 8) + "…"}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 text-right font-mono text-xs text-success">
|
||||||
|
{r.successCount}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 text-right font-mono text-xs text-text-secondary">
|
||||||
|
{r.skippedCount}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
className={`py-2 text-right font-mono text-xs ${
|
||||||
|
r.failureCount > 0 ? "text-error" : "text-text-muted"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{r.failureCount}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
204
src/components/admin/skills/pending-skill-requests.tsx
Normal file
204
src/components/admin/skills/pending-skill-requests.tsx
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, Fragment } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { Card } from "@/components/ui/card";
|
||||||
|
import type { SkillActivationRequest } from "@/types";
|
||||||
|
|
||||||
|
interface RowData extends SkillActivationRequest {
|
||||||
|
skillName: string;
|
||||||
|
companyName: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
initialRows: RowData[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin queue table. Each row has Approve and Reject buttons.
|
||||||
|
* Reject opens an inline reason input that must be filled before
|
||||||
|
* the call goes through (the API also enforces this — empty
|
||||||
|
* reasons are 400'd server-side).
|
||||||
|
*
|
||||||
|
* Actions hit the admin API endpoints, then router.refresh() to
|
||||||
|
* re-render the server component with the new state (the row
|
||||||
|
* disappears once flipped to approved/rejected).
|
||||||
|
*/
|
||||||
|
export function PendingSkillRequests({ initialRows }: Props) {
|
||||||
|
const t = useTranslations("adminSkills");
|
||||||
|
const router = useRouter();
|
||||||
|
const [busyId, setBusyId] = useState<string | null>(null);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
// Per-row open-reject-input state. Key = request id.
|
||||||
|
const [rejectingId, setRejectingId] = useState<string | null>(null);
|
||||||
|
const [reasonText, setReasonText] = useState("");
|
||||||
|
|
||||||
|
const approve = async (id: string) => {
|
||||||
|
setError("");
|
||||||
|
setBusyId(id);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/admin/skills/pending/${id}/approve`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const j = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(j.error || `HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
router.refresh();
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e.message);
|
||||||
|
} finally {
|
||||||
|
setBusyId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const reject = async (id: string) => {
|
||||||
|
if (!reasonText.trim()) {
|
||||||
|
setError(t("reasonRequired"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setError("");
|
||||||
|
setBusyId(id);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/admin/skills/pending/${id}/reject`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ reason: reasonText }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const j = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(j.error || `HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
setRejectingId(null);
|
||||||
|
setReasonText("");
|
||||||
|
router.refresh();
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e.message);
|
||||||
|
} finally {
|
||||||
|
setBusyId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (initialRows.length === 0) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<p className="text-sm text-text-muted italic text-center py-6">
|
||||||
|
{t("emptyQueue")}
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
{error && (
|
||||||
|
<div className="mb-3 p-3 rounded-md border border-error bg-error/10 text-sm text-error">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="text-xs text-text-muted text-left">
|
||||||
|
<tr>
|
||||||
|
<th className="pb-2">{t("requestedAtCol")}</th>
|
||||||
|
<th className="pb-2">{t("skillCol")}</th>
|
||||||
|
<th className="pb-2">{t("tenantCol")}</th>
|
||||||
|
<th className="pb-2">{t("orgCol")}</th>
|
||||||
|
<th className="pb-2 text-right">{t("actionsCol")}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{initialRows.map((row) => (
|
||||||
|
<Fragment key={row.id}>
|
||||||
|
<tr className="border-t border-border align-top">
|
||||||
|
<td className="py-2 text-xs text-text-muted font-mono">
|
||||||
|
{row.requestedAt.slice(0, 16).replace("T", " ")}
|
||||||
|
</td>
|
||||||
|
<td className="py-2">
|
||||||
|
<div className="font-medium">{row.skillName}</div>
|
||||||
|
<div className="text-xs text-text-muted font-mono">
|
||||||
|
{row.skillId}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="py-2 font-mono text-xs">{row.tenantName}</td>
|
||||||
|
<td className="py-2">
|
||||||
|
<div className="text-xs">{row.companyName ?? "—"}</div>
|
||||||
|
<div className="text-xs text-text-muted font-mono">
|
||||||
|
{row.zitadelOrgId.slice(0, 16)}…
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="py-2 text-right">
|
||||||
|
{rejectingId !== row.id && (
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setRejectingId(row.id);
|
||||||
|
setReasonText("");
|
||||||
|
setError("");
|
||||||
|
}}
|
||||||
|
disabled={busyId !== null}
|
||||||
|
className="text-xs px-3 py-1.5 rounded-md border border-error text-error hover:bg-error/10 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{t("rejectBtn")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => approve(row.id)}
|
||||||
|
disabled={busyId !== null}
|
||||||
|
className="text-xs px-3 py-1.5 rounded-md bg-accent text-white disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{busyId === row.id ? t("working") : t("approveBtn")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{rejectingId === row.id && (
|
||||||
|
<tr className="border-t border-border bg-surface-2">
|
||||||
|
<td colSpan={5} className="py-3 px-3">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<label className="text-xs text-text-muted">
|
||||||
|
{t("reasonLabel")}
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={reasonText}
|
||||||
|
onChange={(e) => setReasonText(e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
maxLength={1000}
|
||||||
|
placeholder={t("reasonPlaceholder")}
|
||||||
|
className="w-full px-3 py-2 rounded-md border border-border bg-surface-1 text-sm"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setRejectingId(null);
|
||||||
|
setReasonText("");
|
||||||
|
}}
|
||||||
|
disabled={busyId !== null}
|
||||||
|
className="text-xs px-3 py-1.5 rounded-md border border-border disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{t("cancel")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => reject(row.id)}
|
||||||
|
disabled={busyId !== null || !reasonText.trim()}
|
||||||
|
className="text-xs px-3 py-1.5 rounded-md bg-error text-white disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{busyId === row.id
|
||||||
|
? t("working")
|
||||||
|
: t("confirmRejectBtn")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
101
src/components/billing/customer-credit-note-list.tsx
Normal file
101
src/components/billing/customer-credit-note-list.tsx
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import { useTranslations, useFormatter } from "next-intl";
|
||||||
|
import { Card } from "@/components/ui/card";
|
||||||
|
import type { CreditNote } from "@/types";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
creditNotes: CreditNote[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const kindColors: Record<string, string> = {
|
||||||
|
// Voids = the invoice was cancelled; gentle red.
|
||||||
|
void: "text-error bg-error/10",
|
||||||
|
// Refunds = money returned; also red but slightly differentiated.
|
||||||
|
refund: "text-error bg-error/10",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Phase 7 — customer's credit-note history below the invoice list.
|
||||||
|
*
|
||||||
|
* Hidden entirely when the org has zero credit notes (most orgs in
|
||||||
|
* normal operation). When present, each row shows the credit-note
|
||||||
|
* number, the invoice it relates to, kind (void / refund), amount,
|
||||||
|
* and a download link to the PDF.
|
||||||
|
*
|
||||||
|
* No detail page — clicking the PDF link opens the document inline
|
||||||
|
* (browser PDF viewer), which IS the credit-note detail view. A
|
||||||
|
* separate per-credit-note web page would duplicate what's in the
|
||||||
|
* PDF and add no value.
|
||||||
|
*/
|
||||||
|
export function CustomerCreditNoteList({ creditNotes }: Props) {
|
||||||
|
const t = useTranslations("customerBilling");
|
||||||
|
const fmt = useFormatter();
|
||||||
|
|
||||||
|
if (creditNotes.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="text-xs text-text-muted text-left">
|
||||||
|
<tr>
|
||||||
|
<th className="pb-2 pr-4">{t("creditNoteNumberCol")}</th>
|
||||||
|
<th className="pb-2 pr-4">{t("creditNoteInvoiceCol")}</th>
|
||||||
|
<th className="pb-2 pr-4">{t("creditNoteIssuedCol")}</th>
|
||||||
|
<th className="pb-2 pr-4 text-right">{t("creditNoteAmountCol")}</th>
|
||||||
|
<th className="pb-2 pr-4 text-right">{t("creditNoteKindCol")}</th>
|
||||||
|
<th className="pb-2 text-right">{t("creditNotePdfCol")}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{creditNotes.map((cn) => (
|
||||||
|
<tr
|
||||||
|
key={cn.id}
|
||||||
|
className="border-t border-border align-middle"
|
||||||
|
>
|
||||||
|
<td className="py-2 pr-4 font-mono text-xs">
|
||||||
|
{cn.creditNoteNumber}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 pr-4 font-mono text-xs text-text-secondary">
|
||||||
|
{cn.invoiceNumber}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 pr-4 text-text-secondary whitespace-nowrap">
|
||||||
|
{fmt.dateTime(new Date(cn.issuedAt), { dateStyle: "medium" })}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 pr-4 text-right font-mono whitespace-nowrap">
|
||||||
|
CHF {cn.amountChf.toFixed(2)}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 pr-4 text-right">
|
||||||
|
<span
|
||||||
|
className={`px-2 py-0.5 rounded text-xs ${
|
||||||
|
kindColors[cn.kind] ?? ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t(`creditNoteKind_${cn.kind}` as any)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="py-2 text-right">
|
||||||
|
{cn.hasPdf ? (
|
||||||
|
<a
|
||||||
|
href={`/api/credit-notes/${encodeURIComponent(
|
||||||
|
cn.creditNoteNumber
|
||||||
|
)}/pdf`}
|
||||||
|
className="text-accent hover:underline text-xs"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
{t("downloadPdf")}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<span className="text-text-muted text-xs italic">
|
||||||
|
{t("creditNoteNoPdf")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
166
src/components/billing/customer-invoice-detail.tsx
Normal file
166
src/components/billing/customer-invoice-detail.tsx
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
import { useTranslations, useFormatter } from "next-intl";
|
||||||
|
import { Card } from "@/components/ui/card";
|
||||||
|
import type { Invoice, InvoiceLine } from "@/types";
|
||||||
|
import { PayInvoiceButton } from "./pay-invoice-button";
|
||||||
|
import { PaymentStatusBanner } from "./payment-status-banner";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
invoice: Invoice;
|
||||||
|
lines: InvoiceLine[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusColors: Record<string, string> = {
|
||||||
|
open: "text-text-secondary bg-surface-3",
|
||||||
|
paid: "text-success bg-success/10",
|
||||||
|
overdue: "text-error bg-error/10",
|
||||||
|
void: "text-text-muted bg-surface-3",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read-only invoice detail. Flat list of lines — no per-tenant
|
||||||
|
* grouping (one invoice per customer; the tenant context is
|
||||||
|
* already embedded in each line description).
|
||||||
|
*
|
||||||
|
* The download link points at /api/billing/invoices/[n]/pdf
|
||||||
|
* which serves the stored PDF blob inline. Customers using a
|
||||||
|
* link from their email will hit the same route via this page.
|
||||||
|
*/
|
||||||
|
export function CustomerInvoiceDetail({ invoice, lines }: Props) {
|
||||||
|
const t = useTranslations("customerBilling");
|
||||||
|
const fmt = useFormatter();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 animate-in">
|
||||||
|
<PaymentStatusBanner />
|
||||||
|
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-3 mb-2">
|
||||||
|
<h1 className="font-display text-2xl font-semibold">
|
||||||
|
{invoice.invoiceNumber}
|
||||||
|
</h1>
|
||||||
|
<span
|
||||||
|
className={`text-[10px] uppercase tracking-wider px-2 py-1 rounded-md font-semibold ${
|
||||||
|
statusColors[invoice.status] ?? "text-text-muted bg-surface-3"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t(`status.${invoice.status}` as any)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{invoice.periodStart && invoice.periodEnd && (
|
||||||
|
<p className="text-sm text-text-secondary">
|
||||||
|
{fmt.dateTime(new Date(invoice.periodStart), {
|
||||||
|
dateStyle: "long",
|
||||||
|
})}
|
||||||
|
<span className="text-text-muted mx-1">→</span>
|
||||||
|
{fmt.dateTime(new Date(invoice.periodEnd), {
|
||||||
|
dateStyle: "long",
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start gap-2 flex-wrap">
|
||||||
|
{/* Phase 4: Pay-with-card available for open + overdue.
|
||||||
|
Paid/void/draft/uncollectible hide the button — the
|
||||||
|
API also enforces this, so client-side hiding is just
|
||||||
|
for the visible affordance. */}
|
||||||
|
{(invoice.status === "open" || invoice.status === "overdue") && (
|
||||||
|
<PayInvoiceButton invoiceNumber={invoice.invoiceNumber} />
|
||||||
|
)}
|
||||||
|
<a
|
||||||
|
href={`/api/billing/invoices/${encodeURIComponent(invoice.invoiceNumber)}/pdf`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="px-4 py-2 rounded-md bg-surface-3 hover:bg-surface-2 border border-border text-sm font-medium transition-colors"
|
||||||
|
>
|
||||||
|
{t("downloadPdf")}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<div className="space-y-2 mb-4">
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-text-muted">{t("billedToLabel")}</span>
|
||||||
|
<span>{invoice.billingSnapshot.companyName}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-text-muted">{t("issuedAtLabel")}</span>
|
||||||
|
<span>
|
||||||
|
{fmt.dateTime(new Date(invoice.issuedAt), { dateStyle: "medium" })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-text-muted">{t("dueAtLabel")}</span>
|
||||||
|
<span>
|
||||||
|
{fmt.dateTime(new Date(invoice.dueAt), { dateStyle: "medium" })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{invoice.status === "paid" && invoice.paidAt && (
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-text-muted">{t("paidAtLabel")}</span>
|
||||||
|
<span>
|
||||||
|
{fmt.dateTime(new Date(invoice.paidAt), { dateStyle: "medium" })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="text-xs text-text-muted text-left">
|
||||||
|
<tr>
|
||||||
|
<th className="pb-2">{t("descriptionCol")}</th>
|
||||||
|
<th className="pb-2 text-right">{t("qtyCol")}</th>
|
||||||
|
<th className="pb-2 text-right">{t("unitCol")}</th>
|
||||||
|
<th className="pb-2 text-right">{t("amountCol")}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{lines.map((ln) => (
|
||||||
|
<tr key={ln.id} className="border-t border-border align-top">
|
||||||
|
<td className="py-2">{ln.description}</td>
|
||||||
|
<td className="py-2 text-right font-mono text-xs">
|
||||||
|
{ln.quantity}
|
||||||
|
{ln.unitLabel ? ` ${ln.unitLabel}` : ""}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 text-right font-mono text-xs">
|
||||||
|
{ln.unitPriceChf.toFixed(2)}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 text-right font-mono">
|
||||||
|
{ln.amountChf.toFixed(2)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr className="border-t border-border">
|
||||||
|
<td colSpan={3} className="pt-3 text-right text-text-muted">
|
||||||
|
{t("subtotalLabel")}
|
||||||
|
</td>
|
||||||
|
<td className="pt-3 text-right font-mono">
|
||||||
|
{invoice.subtotalChf.toFixed(2)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td colSpan={3} className="pt-1 text-right text-text-muted">
|
||||||
|
{t("vatLabel", { rate: invoice.vatRate.toFixed(2) })}
|
||||||
|
</td>
|
||||||
|
<td className="pt-1 text-right font-mono">
|
||||||
|
{invoice.vatAmountChf.toFixed(2)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td colSpan={3} className="pt-2 text-right font-semibold">
|
||||||
|
{t("totalLabel")}
|
||||||
|
</td>
|
||||||
|
<td className="pt-2 text-right font-mono font-semibold text-base">
|
||||||
|
CHF {invoice.totalChf.toFixed(2)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
109
src/components/billing/customer-invoice-list.tsx
Normal file
109
src/components/billing/customer-invoice-list.tsx
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import { useTranslations, useFormatter } from "next-intl";
|
||||||
|
import { Link } from "@/i18n/navigation";
|
||||||
|
import { Card } from "@/components/ui/card";
|
||||||
|
import type { Invoice } from "@/types";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
invoices: Invoice[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusColors: Record<string, string> = {
|
||||||
|
open: "text-text-secondary bg-surface-3",
|
||||||
|
paid: "text-success bg-success/10",
|
||||||
|
overdue: "text-error bg-error/10",
|
||||||
|
void: "text-text-muted bg-surface-3 line-through",
|
||||||
|
// Phase 7: refund states. Red tinting matches the credit-note
|
||||||
|
// PDF accent so customers reading the table get a visual cue
|
||||||
|
// that something was credited back. partially_refunded reads
|
||||||
|
// as a partial state (mixed colour), fully_refunded reads as
|
||||||
|
// closed (line-through like void).
|
||||||
|
partially_refunded: "text-error bg-error/10",
|
||||||
|
fully_refunded: "text-text-muted bg-error/10 line-through",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Customer's invoice history table. Server component — gets a
|
||||||
|
* pre-fetched Invoice[] from /billing/page.tsx. Each row links
|
||||||
|
* to /billing/<invoice-number> for the full detail view.
|
||||||
|
*
|
||||||
|
* Columns: number, period, due date, total, status. Status is
|
||||||
|
* displayed with a colored badge so the customer can scan for
|
||||||
|
* outstanding ones at a glance.
|
||||||
|
*/
|
||||||
|
export function CustomerInvoiceList({ invoices }: Props) {
|
||||||
|
const t = useTranslations("customerBilling");
|
||||||
|
const fmt = useFormatter();
|
||||||
|
|
||||||
|
if (invoices.length === 0) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<p className="text-sm text-text-muted italic text-center py-8">
|
||||||
|
{t("emptyHistory")}
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="text-xs text-text-muted text-left">
|
||||||
|
<tr>
|
||||||
|
<th className="pb-2">{t("numberCol")}</th>
|
||||||
|
<th className="pb-2">{t("periodCol")}</th>
|
||||||
|
<th className="pb-2">{t("dueCol")}</th>
|
||||||
|
<th className="pb-2 text-right">{t("totalCol")}</th>
|
||||||
|
<th className="pb-2 text-right">{t("statusCol")}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{invoices.map((inv) => (
|
||||||
|
<tr
|
||||||
|
key={inv.id}
|
||||||
|
className="border-t border-border hover:bg-surface-2 transition-colors"
|
||||||
|
>
|
||||||
|
<td className="py-2">
|
||||||
|
<Link
|
||||||
|
href={`/billing/${inv.invoiceNumber}`}
|
||||||
|
className="font-mono text-xs text-accent hover:underline"
|
||||||
|
>
|
||||||
|
{inv.invoiceNumber}
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
<td className="py-2 text-xs text-text-secondary">
|
||||||
|
{inv.periodStart && inv.periodEnd ? (
|
||||||
|
<>
|
||||||
|
{fmt.dateTime(new Date(inv.periodStart), {
|
||||||
|
dateStyle: "medium",
|
||||||
|
})}
|
||||||
|
<span className="text-text-muted mx-1">→</span>
|
||||||
|
{fmt.dateTime(new Date(inv.periodEnd), {
|
||||||
|
dateStyle: "medium",
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="text-text-muted">—</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 text-xs text-text-secondary">
|
||||||
|
{fmt.dateTime(new Date(inv.dueAt), { dateStyle: "medium" })}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 text-right font-mono">
|
||||||
|
CHF {inv.totalChf.toFixed(2)}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 text-right">
|
||||||
|
<span
|
||||||
|
className={`text-[10px] uppercase tracking-wider px-2 py-1 rounded-md font-semibold ${
|
||||||
|
statusColors[inv.status] ?? "text-text-muted bg-surface-3"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t(`status.${inv.status}` as any)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
64
src/components/billing/pay-invoice-button.tsx
Normal file
64
src/components/billing/pay-invoice-button.tsx
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
invoiceNumber: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pay-with-card button. Posts to /api/billing/invoices/[n]/pay,
|
||||||
|
* which returns a Stripe Checkout Session URL; we redirect the
|
||||||
|
* browser there.
|
||||||
|
*
|
||||||
|
* The button is rendered only by the parent for status='open' or
|
||||||
|
* 'overdue' invoices — the API enforces this too, but pre-filtering
|
||||||
|
* UI-side keeps the dead state out of the customer's face.
|
||||||
|
*/
|
||||||
|
export function PayInvoiceButton({ invoiceNumber }: Props) {
|
||||||
|
const t = useTranslations("customerBilling");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const onClick = async () => {
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`/api/billing/invoices/${encodeURIComponent(invoiceNumber)}/pay`,
|
||||||
|
{ method: "POST" }
|
||||||
|
);
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(data.error ?? `HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
if (!data.url) {
|
||||||
|
throw new Error("Payment session URL missing from response.");
|
||||||
|
}
|
||||||
|
// Hard navigation, not Next.js router — Stripe Checkout is a
|
||||||
|
// separate origin and the browser needs to fully leave our app.
|
||||||
|
window.location.href = data.url;
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e?.message ?? String(e));
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-end gap-1">
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
disabled={busy}
|
||||||
|
className="px-4 py-2 rounded-md bg-accent text-white text-sm font-medium hover:bg-accent-dim transition-colors disabled:opacity-50 cursor-pointer"
|
||||||
|
>
|
||||||
|
{busy ? t("redirectingToStripe") : t("payWithCard")}
|
||||||
|
</button>
|
||||||
|
{error && (
|
||||||
|
<span className="text-xs text-error max-w-[260px] text-right">
|
||||||
|
{error}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
67
src/components/billing/payment-status-banner.tsx
Normal file
67
src/components/billing/payment-status-banner.tsx
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Banner shown after a return from Stripe Checkout.
|
||||||
|
*
|
||||||
|
* ?paid=1 → green success banner. The webhook may or may
|
||||||
|
* not have processed yet, so we phrase the message
|
||||||
|
* as "Payment received, status will update shortly"
|
||||||
|
* and don't claim the status is already paid. A
|
||||||
|
* light auto-refresh after a few seconds nudges
|
||||||
|
* the page to pick up the new status badge.
|
||||||
|
*
|
||||||
|
* ?cancelled=1 → neutral grey banner: "Payment cancelled". The
|
||||||
|
* invoice stays in 'open' state.
|
||||||
|
*
|
||||||
|
* The banner cleans up the query params from the URL so a page
|
||||||
|
* reload doesn't repeat the message. We use router.replace() to
|
||||||
|
* keep history clean.
|
||||||
|
*/
|
||||||
|
export function PaymentStatusBanner() {
|
||||||
|
const router = useRouter();
|
||||||
|
const t = useTranslations("customerBilling");
|
||||||
|
const [state, setState] = useState<"paid" | "cancelled" | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
if (params.has("paid")) {
|
||||||
|
setState("paid");
|
||||||
|
// The webhook usually arrives before the browser redirect
|
||||||
|
// completes, so the page often renders with status='paid'
|
||||||
|
// on first load and this refresh is a no-op. In the rare
|
||||||
|
// case where it arrives slightly after, a short refresh
|
||||||
|
// picks up the status flip. 1.5s is comfortable for both.
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
router.refresh();
|
||||||
|
}, 1500);
|
||||||
|
// Strip the query string out of the URL.
|
||||||
|
const cleanUrl = window.location.pathname;
|
||||||
|
window.history.replaceState({}, "", cleanUrl);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
} else if (params.has("cancelled")) {
|
||||||
|
setState("cancelled");
|
||||||
|
const cleanUrl = window.location.pathname;
|
||||||
|
window.history.replaceState({}, "", cleanUrl);
|
||||||
|
}
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
|
if (state === "paid") {
|
||||||
|
return (
|
||||||
|
<div className="mb-4 p-3 rounded-md border border-success bg-success/10 text-sm text-success">
|
||||||
|
{t("paymentReceived")}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (state === "cancelled") {
|
||||||
|
return (
|
||||||
|
<div className="mb-4 p-3 rounded-md border border-border bg-surface-2 text-sm text-text-secondary">
|
||||||
|
{t("paymentCancelled")}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
196
src/components/billing/running-total-widget.tsx
Normal file
196
src/components/billing/running-total-widget.tsx
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useTranslations, useFormatter } from "next-intl";
|
||||||
|
import { Link } from "@/i18n/navigation";
|
||||||
|
import { Card } from "@/components/ui/card";
|
||||||
|
import type { Invoice, InvoiceDraft } from "@/types";
|
||||||
|
|
||||||
|
type CurrentResponse =
|
||||||
|
| { issued: Invoice }
|
||||||
|
| { draft: InvoiceDraft }
|
||||||
|
| { error: string; code?: string };
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/**
|
||||||
|
* Whether the viewing user has org-owner role. Drives the
|
||||||
|
* "complete your billing details" CTA — only owners can edit
|
||||||
|
* billing settings, so non-owners see a softer message asking
|
||||||
|
* them to contact their org owner instead. The flag is computed
|
||||||
|
* server-side and passed in to avoid a second API round-trip.
|
||||||
|
*/
|
||||||
|
isOwner: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Live running total for the current calendar month.
|
||||||
|
*
|
||||||
|
* Loads /api/billing/current on mount. Three result shapes:
|
||||||
|
*
|
||||||
|
* - { issued } — current-month invoice already exists; we
|
||||||
|
* link to it instead of showing a draft total.
|
||||||
|
* - { draft } — still accruing; show subtotal+VAT+total and
|
||||||
|
* a small line breakdown.
|
||||||
|
* - { error } — most likely the org has no billing config
|
||||||
|
* yet; show a friendly hint, not a stack trace.
|
||||||
|
*
|
||||||
|
* Client-side because the compute can take a second or two
|
||||||
|
* (LiteLLM + Threema HTTP calls) and we want a loading spinner.
|
||||||
|
* No polling — the page is static enough that an explicit
|
||||||
|
* "refresh" link is good enough if the user wants newer numbers.
|
||||||
|
*/
|
||||||
|
export function RunningTotalWidget({ isOwner }: Props) {
|
||||||
|
const t = useTranslations("customerBilling");
|
||||||
|
const fmt = useFormatter();
|
||||||
|
const [data, setData] = useState<CurrentResponse | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [refreshCounter, setRefreshCounter] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
setLoading(true);
|
||||||
|
fetch("/api/billing/current")
|
||||||
|
.then(async (res) => {
|
||||||
|
const j = (await res.json()) as CurrentResponse;
|
||||||
|
if (!cancelled) setData(j);
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
if (!cancelled) setData({ error: String(e), code: "FETCH_FAILED" });
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setLoading(false);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [refreshCounter]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<p className="text-sm text-text-muted italic py-4">{t("computing")}</p>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!data || "error" in data) {
|
||||||
|
const noConfig =
|
||||||
|
data && "code" in data && data.code === "COMPUTE_FAILED";
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<p className="text-sm text-text-secondary py-2">
|
||||||
|
{noConfig ? t("noBillingConfig") : t("currentPeriodError")}
|
||||||
|
</p>
|
||||||
|
{/* Phase 6: owner-only CTA. Non-owners can't edit billing
|
||||||
|
settings, so we show them a "contact owner" hint instead
|
||||||
|
— that's gentler than a button that 404s on click. */}
|
||||||
|
{noConfig && isOwner && (
|
||||||
|
<Link
|
||||||
|
href="/settings/billing"
|
||||||
|
className="inline-block mt-2 px-4 py-2 rounded-md bg-accent text-white text-sm font-medium hover:bg-accent-dim transition-colors"
|
||||||
|
>
|
||||||
|
{t("configureBillingCta")}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
{noConfig && !isOwner && (
|
||||||
|
<p className="text-xs text-text-muted italic mt-2">
|
||||||
|
{t("noBillingConfigNonOwner")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if ("issued" in data) {
|
||||||
|
const inv = data.issued;
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-text-muted">{t("currentInvoiceIssued")}</p>
|
||||||
|
<Link
|
||||||
|
href={`/billing/${inv.invoiceNumber}`}
|
||||||
|
className="font-mono text-sm text-accent hover:underline"
|
||||||
|
>
|
||||||
|
{inv.invoiceNumber}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="text-xs text-text-muted">{t("totalLabel")}</p>
|
||||||
|
<p className="font-mono text-lg font-semibold">
|
||||||
|
CHF {inv.totalChf.toFixed(2)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// draft
|
||||||
|
const draft = data.draft;
|
||||||
|
// Phase 8: InvoiceDraft.periodStart/End became nullable for the
|
||||||
|
// custom-invoice flow. The running-total widget only renders the
|
||||||
|
// auto-cron draft (always has a period), so the null branch is
|
||||||
|
// defensive — if we ever did hit it the label just collapses.
|
||||||
|
const periodLabel =
|
||||||
|
draft.periodStart && draft.periodEnd
|
||||||
|
? `${fmt.dateTime(new Date(draft.periodStart), {
|
||||||
|
dateStyle: "long",
|
||||||
|
})} → ${fmt.dateTime(new Date(draft.periodEnd), { dateStyle: "long" })}`
|
||||||
|
: "";
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<div className="flex items-start justify-between gap-4 flex-wrap mb-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-text-muted">{t("accruedSoFar")}</p>
|
||||||
|
<p className="text-xs text-text-secondary">{periodLabel}</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="text-xs text-text-muted">{t("estimatedTotal")}</p>
|
||||||
|
<p className="font-mono text-2xl font-semibold text-accent">
|
||||||
|
CHF {draft.totalChf.toFixed(2)}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => setRefreshCounter((n) => n + 1)}
|
||||||
|
className="text-[10px] text-text-muted hover:text-text-secondary underline mt-1 cursor-pointer"
|
||||||
|
>
|
||||||
|
{t("refresh")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{draft.lines.length > 0 && (
|
||||||
|
<details className="text-xs">
|
||||||
|
<summary className="cursor-pointer text-text-muted hover:text-text-secondary">
|
||||||
|
{t("breakdownToggle", { count: draft.lines.length })}
|
||||||
|
</summary>
|
||||||
|
<table className="w-full mt-2 text-xs">
|
||||||
|
<tbody>
|
||||||
|
{draft.lines.map((ln, i) => (
|
||||||
|
<tr key={i} className="border-t border-border">
|
||||||
|
<td className="py-1 pr-2">{ln.description}</td>
|
||||||
|
<td className="py-1 text-right font-mono">
|
||||||
|
{ln.amountChf.toFixed(2)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
<tr className="border-t border-border">
|
||||||
|
<td className="py-1 pr-2 text-text-muted text-right">
|
||||||
|
{t("subtotalLabel")}
|
||||||
|
</td>
|
||||||
|
<td className="py-1 text-right font-mono">
|
||||||
|
{draft.subtotalChf.toFixed(2)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td className="py-1 pr-2 text-text-muted text-right">
|
||||||
|
{t("vatLabel", { rate: draft.vatRate.toFixed(2) })}
|
||||||
|
</td>
|
||||||
|
<td className="py-1 text-right font-mono">
|
||||||
|
{draft.vatAmountChf.toFixed(2)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
|
<p className="text-[10px] text-text-muted mt-3 italic">{t("draftNote")}</p>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -74,6 +74,20 @@ function NavBar() {
|
|||||||
{t("settings")}
|
{t("settings")}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
)}
|
)}
|
||||||
|
{/* Phase 3: Billing visible to anyone signed in. The
|
||||||
|
page is org-scoped server-side — non-owner members
|
||||||
|
see the same invoice history their owner does, but
|
||||||
|
actions like "configure billing details" are gated
|
||||||
|
separately on the settings page. Personal accounts
|
||||||
|
see their own (single-tenant) invoices. */}
|
||||||
|
{user && (
|
||||||
|
<NavLink
|
||||||
|
href="/billing"
|
||||||
|
active={pathname.startsWith("/billing")}
|
||||||
|
>
|
||||||
|
{t("billing")}
|
||||||
|
</NavLink>
|
||||||
|
)}
|
||||||
{/* Feature 5: Support is available to every signed-in
|
{/* Feature 5: Support is available to every signed-in
|
||||||
user. Customers see their own tickets only; platform
|
user. Customers see their own tickets only; platform
|
||||||
admins see the queue. */}
|
admins see the queue. */}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { OnboardingWizard } from "./wizard";
|
import { OnboardingWizard } from "./wizard";
|
||||||
|
import type { OrgBilling } from "@/types";
|
||||||
|
|
||||||
interface OnboardingFlowProps {
|
interface OnboardingFlowProps {
|
||||||
orgName: string;
|
orgName: string;
|
||||||
@@ -19,6 +20,12 @@ interface OnboardingFlowProps {
|
|||||||
* /settings/billing.
|
* /settings/billing.
|
||||||
*/
|
*/
|
||||||
hasOrgBilling?: boolean;
|
hasOrgBilling?: boolean;
|
||||||
|
/**
|
||||||
|
* Phase 6 fix3: the actual org_billing record (or null). Drives
|
||||||
|
* the review-step "Billing to" rendering AND the confirm-step
|
||||||
|
* validation skip when the billing step was skipped.
|
||||||
|
*/
|
||||||
|
existingOrgBilling?: OrgBilling | null;
|
||||||
/**
|
/**
|
||||||
* Bug 6: when present, the wizard is rendered in edit mode against
|
* Bug 6: when present, the wizard is rendered in edit mode against
|
||||||
* the given pending request. See `OnboardingWizard` for the full
|
* the given pending request. See `OnboardingWizard` for the full
|
||||||
@@ -45,6 +52,7 @@ export function OnboardingFlow({
|
|||||||
userName,
|
userName,
|
||||||
userEmail,
|
userEmail,
|
||||||
hasOrgBilling,
|
hasOrgBilling,
|
||||||
|
existingOrgBilling,
|
||||||
editingRequest,
|
editingRequest,
|
||||||
}: OnboardingFlowProps) {
|
}: OnboardingFlowProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -55,6 +63,7 @@ export function OnboardingFlow({
|
|||||||
userName={userName}
|
userName={userName}
|
||||||
userEmail={userEmail}
|
userEmail={userEmail}
|
||||||
hasOrgBilling={hasOrgBilling}
|
hasOrgBilling={hasOrgBilling}
|
||||||
|
existingOrgBilling={existingOrgBilling}
|
||||||
editingRequest={editingRequest}
|
editingRequest={editingRequest}
|
||||||
onComplete={() => {
|
onComplete={() => {
|
||||||
// Navigate back to /dashboard and re-fetch on the server. The
|
// Navigate back to /dashboard and re-fetch on the server. The
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
SUPPORTED_COUNTRIES,
|
SUPPORTED_COUNTRIES,
|
||||||
type SupportedCountry,
|
type SupportedCountry,
|
||||||
} from "@/lib/validation";
|
} from "@/lib/validation";
|
||||||
|
import type { OrgBilling } from "@/types";
|
||||||
|
|
||||||
type Step = "welcome" | "configure" | "billing" | "confirm";
|
type Step = "welcome" | "configure" | "billing" | "confirm";
|
||||||
|
|
||||||
@@ -96,6 +97,17 @@ interface WizardProps {
|
|||||||
* fix it before admin approves.
|
* fix it before admin approves.
|
||||||
*/
|
*/
|
||||||
hasOrgBilling?: boolean;
|
hasOrgBilling?: boolean;
|
||||||
|
/**
|
||||||
|
* Phase 6 fix3: the actual org_billing record when one exists.
|
||||||
|
* Used to render real values on the review-step "Billing to" block
|
||||||
|
* (rather than the wizard's empty default config.billingAddress)
|
||||||
|
* AND to skip the confirm-step's client-side validation of
|
||||||
|
* billingAddress — same logic that already strips billingAddress
|
||||||
|
* at submit time. Null when no org_billing row exists yet.
|
||||||
|
* Ignored in edit mode (the editingRequest carries its own
|
||||||
|
* billingAddress snapshot).
|
||||||
|
*/
|
||||||
|
existingOrgBilling?: OrgBilling | null;
|
||||||
/**
|
/**
|
||||||
* Bug 6: when present, the wizard renders in "edit" mode — fields
|
* Bug 6: when present, the wizard renders in "edit" mode — fields
|
||||||
* are pre-populated from the request, the SOUL.md auto-fetch is
|
* are pre-populated from the request, the SOUL.md auto-fetch is
|
||||||
@@ -134,6 +146,7 @@ export function OnboardingWizard({
|
|||||||
userName,
|
userName,
|
||||||
userEmail,
|
userEmail,
|
||||||
hasOrgBilling,
|
hasOrgBilling,
|
||||||
|
existingOrgBilling,
|
||||||
editingRequest,
|
editingRequest,
|
||||||
onComplete,
|
onComplete,
|
||||||
}: WizardProps) {
|
}: WizardProps) {
|
||||||
@@ -170,6 +183,11 @@ export function OnboardingWizard({
|
|||||||
const [step, setStep] = useState<Step>(isEditing ? "configure" : "welcome");
|
const [step, setStep] = useState<Step>(isEditing ? "configure" : "welcome");
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
// Phase 9b: 402 from the onboarding endpoint indicates the org
|
||||||
|
// needs to set up auto-pay before ordering. We render a tailored
|
||||||
|
// error block with a clickable link to /settings/billing rather
|
||||||
|
// than the generic red message.
|
||||||
|
const [autoPayRequired, setAutoPayRequired] = useState(false);
|
||||||
const [advancedOpen, setAdvancedOpen] = useState(false);
|
const [advancedOpen, setAdvancedOpen] = useState(false);
|
||||||
// In edit mode we already have soulMd/agentsMd from the request;
|
// In edit mode we already have soulMd/agentsMd from the request;
|
||||||
// skip the workspace-defaults round trip that would overwrite them.
|
// skip the workspace-defaults round trip that would overwrite them.
|
||||||
@@ -319,7 +337,23 @@ export function OnboardingWizard({
|
|||||||
}
|
}
|
||||||
// confirm: validate the union (defence in depth — submit handler
|
// confirm: validate the union (defence in depth — submit handler
|
||||||
// also runs onboardingSchema before POST).
|
// also runs onboardingSchema before POST).
|
||||||
const r = onboardingSchema.safeParse(config);
|
//
|
||||||
|
// Phase 6 fix3: when hasOrgBilling=true AND not editing, the
|
||||||
|
// billing step was skipped and config.billingAddress is the
|
||||||
|
// empty default. zod's .optional() doesn't help here because the
|
||||||
|
// field IS present (empty object), so billingAddressSchema
|
||||||
|
// validates it and fails with required-field errors that the
|
||||||
|
// user has no way to fix — the form to enter the values was
|
||||||
|
// skipped on purpose. Strip the field for validation, matching
|
||||||
|
// the same strip we already do at submit time.
|
||||||
|
const configForValidation =
|
||||||
|
hasOrgBilling && !isEditing
|
||||||
|
? (() => {
|
||||||
|
const { billingAddress: _b, ...rest } = config;
|
||||||
|
return rest;
|
||||||
|
})()
|
||||||
|
: config;
|
||||||
|
const r = onboardingSchema.safeParse(configForValidation);
|
||||||
if (r.success) {
|
if (r.success) {
|
||||||
setErrors({});
|
setErrors({});
|
||||||
return true;
|
return true;
|
||||||
@@ -401,6 +435,7 @@ export function OnboardingWizard({
|
|||||||
|
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
setError("");
|
setError("");
|
||||||
|
setAutoPayRequired(false);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Build secrets payload — only for packages that require them
|
// Build secrets payload — only for packages that require them
|
||||||
@@ -447,11 +482,40 @@ export function OnboardingWizard({
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Phase 9b: 402 means the org needs to set up auto-pay
|
||||||
|
// before ordering. Surface a friendly message with a link to
|
||||||
|
// /settings/billing instead of the generic submission error.
|
||||||
|
if (res.status === 402) {
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (data?.code === "auto_pay_required") {
|
||||||
|
setAutoPayRequired(true);
|
||||||
|
setError(t("autoPayRequiredError"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new Error(data.error || "Submission failed");
|
||||||
|
}
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
throw new Error(data.error || "Submission failed");
|
throw new Error(data.error || "Submission failed");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Phase 9b: if the server initiated a setup-fee Checkout, the
|
||||||
|
// response carries a `checkoutUrl`. Redirect the browser
|
||||||
|
// directly — Stripe Checkout is the next step. The
|
||||||
|
// tenant_requests row is already inserted in 'pending_payment'
|
||||||
|
// status; on successful Checkout, the webhook flips it to
|
||||||
|
// 'pending' and admin sees it.
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (data?.checkoutUrl) {
|
||||||
|
// Don't reset submitting=false — let the redirect happen
|
||||||
|
// with the spinner still active so the button stays
|
||||||
|
// disabled.
|
||||||
|
window.location.href = data.checkoutUrl;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zero-fee path or PATCH edit — same behaviour as before.
|
||||||
onComplete();
|
onComplete();
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.message);
|
setError(err.message);
|
||||||
@@ -1101,42 +1165,84 @@ export function OnboardingWizard({
|
|||||||
<ReviewRow
|
<ReviewRow
|
||||||
label={t("reviewBillingTo")}
|
label={t("reviewBillingTo")}
|
||||||
value={
|
value={
|
||||||
<div className="text-text-primary text-right">
|
(() => {
|
||||||
{/* For personal: skip the company line so the
|
// Phase 6 fix3: when the org has billing on file
|
||||||
invoice rendering matches what the user actually
|
// and we're not editing, render the saved
|
||||||
entered. For company: include it as the first
|
// org_billing record (the authoritative source)
|
||||||
line. */}
|
// rather than config.billingAddress, which is the
|
||||||
{!isPersonal &&
|
// wizard's empty default state because the billing
|
||||||
config.billingAddress.company &&
|
// step was skipped. In edit mode, fall back to
|
||||||
config.billingAddress.company.trim().length > 0 && (
|
// config.billingAddress, which is pre-populated
|
||||||
<div>{config.billingAddress.company}</div>
|
// from the request being edited.
|
||||||
)}
|
const useSaved =
|
||||||
<div>{config.billingAddress.street}</div>
|
hasOrgBilling && !isEditing && existingOrgBilling;
|
||||||
<div>
|
const company = useSaved
|
||||||
{config.billingAddress.postalCode}{" "}
|
? existingOrgBilling!.companyName
|
||||||
{config.billingAddress.city}
|
: config.billingAddress.company;
|
||||||
</div>
|
const street = useSaved
|
||||||
<div className="text-text-muted">
|
? existingOrgBilling!.streetAddress
|
||||||
{tCountries(
|
: config.billingAddress.street;
|
||||||
config.billingAddress.country as SupportedCountry
|
const postalCode = useSaved
|
||||||
)}
|
? existingOrgBilling!.postalCode
|
||||||
</div>
|
: config.billingAddress.postalCode;
|
||||||
</div>
|
const city = useSaved
|
||||||
|
? existingOrgBilling!.city
|
||||||
|
: config.billingAddress.city;
|
||||||
|
const country = useSaved
|
||||||
|
? existingOrgBilling!.country
|
||||||
|
: config.billingAddress.country;
|
||||||
|
const contactName = useSaved
|
||||||
|
? existingOrgBilling!.contactName
|
||||||
|
: null;
|
||||||
|
return (
|
||||||
|
<div className="text-text-primary text-right">
|
||||||
|
{/* For personal: skip the company line so the
|
||||||
|
invoice rendering matches what the user actually
|
||||||
|
entered. For company: include it as the first
|
||||||
|
line. */}
|
||||||
|
{!isPersonal &&
|
||||||
|
company &&
|
||||||
|
company.trim().length > 0 && <div>{company}</div>}
|
||||||
|
{/* Phase 6 fix2: optional contact-person line
|
||||||
|
("z.Hd. <name>") only present when the saved
|
||||||
|
org_billing has it set. */}
|
||||||
|
{contactName && contactName.trim().length > 0 && (
|
||||||
|
<div className="text-text-muted">
|
||||||
|
{t("reviewContactPersonPrefix")} {contactName}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>{street}</div>
|
||||||
|
<div>
|
||||||
|
{postalCode} {city}
|
||||||
|
</div>
|
||||||
|
<div className="text-text-muted">
|
||||||
|
{tCountries(country as SupportedCountry)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
{/* Bug 35: VAT review row. Company customers see this so
|
{/* Bug 35: VAT review row. Company customers see this so
|
||||||
they can verify the VAT id they typed before submitting.
|
they can verify the VAT id they typed before submitting.
|
||||||
Personal customers never see it — they don't have a
|
Personal customers never see it — they don't have a
|
||||||
VAT number, the form didn't ask, the review hides it. */}
|
VAT number, the form didn't ask, the review hides it.
|
||||||
|
Phase 6 fix3: when reading from existingOrgBilling,
|
||||||
|
the value comes from there too. */}
|
||||||
{!isPersonal &&
|
{!isPersonal &&
|
||||||
config.billingAddress.vatNumber &&
|
(() => {
|
||||||
config.billingAddress.vatNumber.trim().length > 0 && (
|
const vat =
|
||||||
<ReviewRow
|
hasOrgBilling && !isEditing && existingOrgBilling
|
||||||
label={t("billingVatNumber")}
|
? existingOrgBilling.vatNumber
|
||||||
value={config.billingAddress.vatNumber}
|
: config.billingAddress.vatNumber;
|
||||||
mono
|
return vat && vat.trim().length > 0 ? (
|
||||||
/>
|
<ReviewRow
|
||||||
)}
|
label={t("billingVatNumber")}
|
||||||
|
value={vat}
|
||||||
|
mono
|
||||||
|
/>
|
||||||
|
) : null;
|
||||||
|
})()}
|
||||||
<ReviewRow
|
<ReviewRow
|
||||||
label={t("reviewContactEmail")}
|
label={t("reviewContactEmail")}
|
||||||
value={userEmail || ""}
|
value={userEmail || ""}
|
||||||
@@ -1155,11 +1261,35 @@ export function OnboardingWizard({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-xs text-text-muted">{t("confirmNote")}</p>
|
<p className="text-xs text-text-muted">{t("confirmNote")}</p>
|
||||||
|
|
||||||
|
{/* Phase 9b: order-time setup-fee notice. The exact
|
||||||
|
amount is determined server-side at submit (the
|
||||||
|
platform_pricing table is the authority), but the
|
||||||
|
customer should know that *some* charge happens on
|
||||||
|
the next click. Wording is neutral about the amount
|
||||||
|
— we don't want to mis-display a stale figure. */}
|
||||||
|
<div className="text-xs rounded-md border border-accent/30 bg-accent/10 text-text-secondary px-3 py-3 mt-4">
|
||||||
|
<strong className="block text-text-primary mb-1">
|
||||||
|
{t("setupFeeNoticeHeading")}
|
||||||
|
</strong>
|
||||||
|
{t("setupFeeNoticeBody")}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="text-xs text-red-400 bg-red-400/10 border border-red-400/20 rounded-lg px-3 py-2 mt-4">
|
<div className="text-xs text-red-400 bg-red-400/10 border border-red-400/20 rounded-lg px-3 py-2 mt-4">
|
||||||
{error}
|
{error}
|
||||||
|
{autoPayRequired && (
|
||||||
|
<>
|
||||||
|
{" "}
|
||||||
|
<a
|
||||||
|
href="/settings/billing"
|
||||||
|
className="underline font-medium text-red-300 hover:text-red-200"
|
||||||
|
>
|
||||||
|
{t("autoPaySetupLink")}
|
||||||
|
</a>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import type { PackageDef } from "@/lib/packages";
|
import type { PackageDef } from "@/lib/packages";
|
||||||
|
import type {
|
||||||
|
SkillActivationRequest,
|
||||||
|
SkillPricing,
|
||||||
|
} from "@/types";
|
||||||
|
import { SkillCostDialog } from "./skill-cost-dialog";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
pkg: PackageDef;
|
pkg: PackageDef;
|
||||||
@@ -12,6 +18,18 @@ interface Props {
|
|||||||
onToggled: () => void;
|
onToggled: () => void;
|
||||||
/** Slice 5: when false, the enable/disable button is hidden. */
|
/** Slice 5: when false, the enable/disable button is hidden. */
|
||||||
canEdit?: boolean;
|
canEdit?: boolean;
|
||||||
|
/**
|
||||||
|
* Phase 2.5 — most recent non-terminal activation request for this
|
||||||
|
* skill on this tenant, if any. Drives the "Manual review pending"
|
||||||
|
* and "Activation rejected" inline states. Approved/withdrawn rows
|
||||||
|
* never reach the client side.
|
||||||
|
*/
|
||||||
|
activationRequest?: SkillActivationRequest | null;
|
||||||
|
/**
|
||||||
|
* Phase 2.5 — pricing for this skill if it has any. Triggers the
|
||||||
|
* cost-disclosure dialog before enable.
|
||||||
|
*/
|
||||||
|
pricing?: SkillPricing | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PackageCard({
|
export function PackageCard({
|
||||||
@@ -21,15 +39,33 @@ export function PackageCard({
|
|||||||
tenantName,
|
tenantName,
|
||||||
onToggled,
|
onToggled,
|
||||||
canEdit = true,
|
canEdit = true,
|
||||||
|
activationRequest = null,
|
||||||
|
pricing = null,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
|
const router = useRouter();
|
||||||
const [showModal, setShowModal] = useState(false);
|
const [showModal, setShowModal] = useState(false);
|
||||||
const [secrets, setSecrets] = useState<Record<string, string>>({});
|
const [secrets, setSecrets] = useState<Record<string, string>>({});
|
||||||
const [accepted, setAccepted] = useState(false);
|
const [accepted, setAccepted] = useState(false);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
// Phase 2.5: cost-disclosure flow + activation-request flow.
|
||||||
|
const [showCostDialog, setShowCostDialog] = useState(false);
|
||||||
|
const isPriced =
|
||||||
|
(pricing?.dailyPriceChf ?? 0) > 0 || (pricing?.setupFeeChf ?? 0) > 0;
|
||||||
|
|
||||||
async function handleEnable() {
|
function handleEnable() {
|
||||||
|
// Phase 2.5: gate priced skills behind the cost-disclosure dialog.
|
||||||
|
// Confirm → proceedWithEnable. Cancel → bail.
|
||||||
|
if (isPriced) {
|
||||||
|
setError(null);
|
||||||
|
setShowCostDialog(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void proceedWithEnable();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function proceedWithEnable() {
|
||||||
if (pkg.customProvisioning) {
|
if (pkg.customProvisioning) {
|
||||||
// Platform-side provisioning, then add to packages list.
|
// Platform-side provisioning, then add to packages list.
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
@@ -112,6 +148,39 @@ export function PackageCard({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Phase 2.5: withdraw a still-pending activation request. The
|
||||||
|
// request row flips to 'withdrawn' (server-side); router.refresh()
|
||||||
|
// re-renders the tenant page without the pending state, leaving
|
||||||
|
// the toggle re-enabled if the user wants to retry.
|
||||||
|
async function withdrawRequest() {
|
||||||
|
if (!activationRequest || activationRequest.status !== "pending") return;
|
||||||
|
setSaving(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`/api/skills/requests/${activationRequest.id}/withdraw`,
|
||||||
|
{ method: "POST" }
|
||||||
|
);
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(err.error || `HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
router.refresh();
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e.message);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 2.5: retry after a rejection. Same flow as a fresh
|
||||||
|
// enable; the rejected row stays in the DB as audit trail but a
|
||||||
|
// new pending row will be created by the PATCH.
|
||||||
|
function tryAgainAfterRejection() {
|
||||||
|
setError(null);
|
||||||
|
handleEnable();
|
||||||
|
}
|
||||||
|
|
||||||
async function handleSubmitSecrets() {
|
async function handleSubmitSecrets() {
|
||||||
if (pkg.disclaimerKey && !accepted) return;
|
if (pkg.disclaimerKey && !accepted) return;
|
||||||
|
|
||||||
@@ -170,7 +239,50 @@ export function PackageCard({
|
|||||||
{pkg.requiresSecrets && (
|
{pkg.requiresSecrets && (
|
||||||
<span className="text-[10px] text-text-muted">{t("packages.requiresApiKey")}</span>
|
<span className="text-[10px] text-text-muted">{t("packages.requiresApiKey")}</span>
|
||||||
)}
|
)}
|
||||||
{canEdit ? (
|
{/* Phase 2.5: pending or rejected request takes precedence
|
||||||
|
over the toggle. Approved/withdrawn never reach here.
|
||||||
|
For packages that needed secrets, surface that they're
|
||||||
|
safely stored — the user might otherwise worry the
|
||||||
|
credentials they typed got lost when the activation
|
||||||
|
was deferred. */}
|
||||||
|
{canEdit && activationRequest?.status === "pending" ? (
|
||||||
|
<div className="ml-auto flex flex-col items-end gap-1">
|
||||||
|
<span
|
||||||
|
className="text-[10px] text-warning italic"
|
||||||
|
title={pkg.requiresSecrets ? t("packages.credentialsSavedTip") : undefined}
|
||||||
|
>
|
||||||
|
{t("packages.manualReviewPending")}
|
||||||
|
{pkg.requiresSecrets && (
|
||||||
|
<span className="text-text-muted ml-1 not-italic">
|
||||||
|
· {t("packages.credentialsSaved")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={withdrawRequest}
|
||||||
|
disabled={saving}
|
||||||
|
className="rounded-lg px-3 py-1.5 text-xs font-medium text-text-secondary hover:text-text-primary bg-surface-3 hover:bg-surface-2 disabled:opacity-50 cursor-pointer"
|
||||||
|
>
|
||||||
|
{saving ? "…" : t("packages.withdraw")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : canEdit && activationRequest?.status === "rejected" ? (
|
||||||
|
<div className="ml-auto flex flex-col items-end gap-1">
|
||||||
|
<span
|
||||||
|
className="text-[10px] text-error italic max-w-[220px] truncate"
|
||||||
|
title={activationRequest.rejectionReason ?? ""}
|
||||||
|
>
|
||||||
|
{t("packages.activationRejected")}: {activationRequest.rejectionReason}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={tryAgainAfterRejection}
|
||||||
|
disabled={saving}
|
||||||
|
className="rounded-lg px-3 py-1.5 text-xs font-medium bg-accent text-surface-0 hover:bg-accent-dim disabled:opacity-50 cursor-pointer shadow-lg shadow-accent/20"
|
||||||
|
>
|
||||||
|
{saving ? "…" : t("packages.tryAgain")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : canEdit ? (
|
||||||
<button
|
<button
|
||||||
onClick={enabled ? handleDisable : handleEnable}
|
onClick={enabled ? handleDisable : handleEnable}
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
@@ -194,6 +306,20 @@ export function PackageCard({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Phase 2.5: cost-disclosure modal for priced skills. */}
|
||||||
|
<SkillCostDialog
|
||||||
|
open={showCostDialog}
|
||||||
|
onClose={() => setShowCostDialog(false)}
|
||||||
|
onConfirm={() => {
|
||||||
|
setShowCostDialog(false);
|
||||||
|
void proceedWithEnable();
|
||||||
|
}}
|
||||||
|
skillName={pkg.name}
|
||||||
|
dailyPriceChf={pricing?.dailyPriceChf ?? 0}
|
||||||
|
setupFeeChf={pricing?.setupFeeChf ?? 0}
|
||||||
|
busy={saving}
|
||||||
|
/>
|
||||||
|
|
||||||
{showModal && (
|
{showModal && (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
|
||||||
<div className="w-full max-w-md bg-surface-1 border border-border rounded-2xl p-6 space-y-4 shadow-2xl shadow-black/40">
|
<div className="w-full max-w-md bg-surface-1 border border-border rounded-2xl p-6 space-y-4 shadow-2xl shadow-black/40">
|
||||||
|
|||||||
@@ -3,6 +3,10 @@
|
|||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { PACKAGE_CATALOG } from "@/lib/packages";
|
import { PACKAGE_CATALOG } from "@/lib/packages";
|
||||||
|
import type {
|
||||||
|
SkillActivationRequest,
|
||||||
|
SkillPricing,
|
||||||
|
} from "@/types";
|
||||||
import { PackageCard } from "./package-card";
|
import { PackageCard } from "./package-card";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -12,6 +16,17 @@ interface Props {
|
|||||||
onRefresh?: () => void;
|
onRefresh?: () => void;
|
||||||
/** Slice 5: when false, package toggles and edit affordances are hidden. */
|
/** Slice 5: when false, package toggles and edit affordances are hidden. */
|
||||||
canEdit?: boolean;
|
canEdit?: boolean;
|
||||||
|
/**
|
||||||
|
* Phase 2.5 — non-terminal activation requests for this tenant.
|
||||||
|
* Each PackageCard looks up its skill in this array to render the
|
||||||
|
* pending/rejected inline state. Most recent first.
|
||||||
|
*/
|
||||||
|
activationRequests?: SkillActivationRequest[];
|
||||||
|
/**
|
||||||
|
* Phase 2.5 — skill pricing keyed by skillId. Drives the cost
|
||||||
|
* disclosure dialog.
|
||||||
|
*/
|
||||||
|
skillPricing?: SkillPricing[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const CATEGORIES = [
|
const CATEGORIES = [
|
||||||
@@ -39,11 +54,29 @@ export function PackageList({
|
|||||||
conditions,
|
conditions,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
canEdit = true,
|
canEdit = true,
|
||||||
|
activationRequests = [],
|
||||||
|
skillPricing = [],
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const t = useTranslations("packages");
|
const t = useTranslations("packages");
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const handleRefresh = onRefresh || (() => router.refresh());
|
const handleRefresh = onRefresh || (() => router.refresh());
|
||||||
|
|
||||||
|
// Build per-skill lookups once so each card render is O(1) rather
|
||||||
|
// than O(N) over the requests array. `activationRequests` already
|
||||||
|
// arrives filtered to non-terminal rows (most-recent per
|
||||||
|
// (skill, status) pair from the server).
|
||||||
|
const requestBySkill = new Map<string, SkillActivationRequest>();
|
||||||
|
for (const req of activationRequests) {
|
||||||
|
// Pending takes precedence over rejected — if both exist for
|
||||||
|
// the same skill (race or after-rejection-retry), show pending.
|
||||||
|
const existing = requestBySkill.get(req.skillId);
|
||||||
|
if (!existing || (existing.status === "rejected" && req.status === "pending")) {
|
||||||
|
requestBySkill.set(req.skillId, req);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const pricingBySkill = new Map<string, SkillPricing>();
|
||||||
|
for (const p of skillPricing) pricingBySkill.set(p.skillId, p);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{CATEGORIES.map(({ key, labelKey }) => {
|
{CATEGORIES.map(({ key, labelKey }) => {
|
||||||
@@ -65,6 +98,8 @@ export function PackageList({
|
|||||||
tenantName={tenantName}
|
tenantName={tenantName}
|
||||||
onToggled={handleRefresh}
|
onToggled={handleRefresh}
|
||||||
canEdit={canEdit}
|
canEdit={canEdit}
|
||||||
|
activationRequest={requestBySkill.get(pkg.id) ?? null}
|
||||||
|
pricing={pricingBySkill.get(pkg.id) ?? null}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
115
src/components/packages/skill-cost-dialog.tsx
Normal file
115
src/components/packages/skill-cost-dialog.tsx
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { Modal } from "@/components/ui/modal";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onConfirm: () => void;
|
||||||
|
skillName: string;
|
||||||
|
dailyPriceChf: number;
|
||||||
|
setupFeeChf: number;
|
||||||
|
busy?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cost-disclosure modal shown before activating a priced skill.
|
||||||
|
*
|
||||||
|
* Shows the daily rate and setup fee (each only if > 0) and
|
||||||
|
* requires an explicit Confirm before the activation request goes
|
||||||
|
* through. Rendered every time the user toggles on a priced skill,
|
||||||
|
* not once-and-remember — this is recurring-charge consent, not a
|
||||||
|
* one-time terms agreement.
|
||||||
|
*
|
||||||
|
* The setup fee is always shown when configured, with a note
|
||||||
|
* clarifying it's "one-time, charged on first activation". The
|
||||||
|
* backend (billing.ts tenantSkillHasBeenBilled) is the authority
|
||||||
|
* on whether the fee actually fires — we don't second-guess from
|
||||||
|
* the client. If you've previously activated this skill on this
|
||||||
|
* tenant, the fee won't appear on the next invoice even though
|
||||||
|
* the dialog mentions it.
|
||||||
|
*/
|
||||||
|
export function SkillCostDialog({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
onConfirm,
|
||||||
|
skillName,
|
||||||
|
dailyPriceChf,
|
||||||
|
setupFeeChf,
|
||||||
|
busy = false,
|
||||||
|
}: Props) {
|
||||||
|
const t = useTranslations("skillCostDialog");
|
||||||
|
const showSetupFee = setupFeeChf > 0;
|
||||||
|
const showDaily = dailyPriceChf > 0;
|
||||||
|
// Nothing to disclose? Bail to confirm immediately — shouldn't
|
||||||
|
// normally be shown in this case but guard anyway.
|
||||||
|
if (!showSetupFee && !showDaily) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Modal open={open} onClose={onClose} ariaLabel={t("title")}>
|
||||||
|
<div className="bg-surface-1 rounded-lg border border-border p-6 max-w-md w-full">
|
||||||
|
<h2 className="text-lg font-semibold mb-2">{t("title")}</h2>
|
||||||
|
<p className="text-sm text-text-secondary mb-4">
|
||||||
|
{t("intro", { skill: skillName })}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="rounded-md bg-surface-2 border border-border p-4 mb-4 space-y-2">
|
||||||
|
{showSetupFee && (
|
||||||
|
<div className="flex justify-between items-baseline">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm">{t("setupFeeLabel")}</div>
|
||||||
|
<div className="text-xs text-text-muted">
|
||||||
|
{t("setupFeeNote")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm font-mono">
|
||||||
|
CHF {setupFeeChf.toFixed(2)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{showDaily && (
|
||||||
|
/* Display reference monthly cost (daily × 30) plus the
|
||||||
|
actual daily rate as a sub-note. Billing is always
|
||||||
|
per UTC day enabled — partial months prorate to that
|
||||||
|
same daily rate, full months land at roughly the
|
||||||
|
figure shown (varies ±~3% by month length). */
|
||||||
|
<div className="flex justify-between items-baseline">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm">{t("monthlyPriceLabel")}</div>
|
||||||
|
<div className="text-xs text-text-muted">
|
||||||
|
{t("monthlyPriceNote", {
|
||||||
|
daily: dailyPriceChf.toFixed(2),
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm font-mono">
|
||||||
|
CHF {(dailyPriceChf * 30).toFixed(2)} / {t("monthUnit")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-xs text-text-muted mb-4">{t("disclaimer")}</p>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={busy}
|
||||||
|
className="px-4 py-2 rounded-md border border-border text-sm disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{t("cancel")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onConfirm}
|
||||||
|
disabled={busy}
|
||||||
|
className="px-4 py-2 rounded-md bg-accent text-white text-sm disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{busy ? t("confirming") : t("confirm")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
263
src/components/settings/billing-form.tsx
Normal file
263
src/components/settings/billing-form.tsx
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { Card } from "@/components/ui/card";
|
||||||
|
import type { OrgBilling } from "@/types";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
initial: OrgBilling | null;
|
||||||
|
/**
|
||||||
|
* Personal-account (individual customer) flag from the session.
|
||||||
|
* Individuals get a "Full name" field instead of "Company name",
|
||||||
|
* and the VAT input is hidden entirely — they don't have one and
|
||||||
|
* showing the field would only confuse. The underlying column is
|
||||||
|
* still `company_name` in the DB and the invoice PDF; for an
|
||||||
|
* individual that field carries their full name, which is
|
||||||
|
* exactly what should print on the invoice.
|
||||||
|
*/
|
||||||
|
isPersonal: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Customer billing settings form. Drives PUT /api/settings/billing
|
||||||
|
* which upserts org_billing for the caller's org.
|
||||||
|
*
|
||||||
|
* Validation is the same regex as the server-side zod schema for
|
||||||
|
* the country field (ISO 3166-1 alpha-2). Other fields are checked
|
||||||
|
* for required + max-length client-side; the server is the
|
||||||
|
* authority and re-validates everything.
|
||||||
|
*
|
||||||
|
* On success we router.refresh() the page so the server component
|
||||||
|
* re-fetches and any "create now" -> "edit" wording flips.
|
||||||
|
*/
|
||||||
|
export function BillingSettingsForm({ initial, isPersonal }: Props) {
|
||||||
|
const t = useTranslations("settingsBilling");
|
||||||
|
const router = useRouter();
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
companyName: initial?.companyName ?? "",
|
||||||
|
contactName: initial?.contactName ?? "",
|
||||||
|
streetAddress: initial?.streetAddress ?? "",
|
||||||
|
postalCode: initial?.postalCode ?? "",
|
||||||
|
city: initial?.city ?? "",
|
||||||
|
country: initial?.country ?? "CH",
|
||||||
|
vatNumber: initial?.vatNumber ?? "",
|
||||||
|
billingEmail: initial?.billingEmail ?? "",
|
||||||
|
notes: initial?.notes ?? "",
|
||||||
|
});
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [savedFlash, setSavedFlash] = useState(false);
|
||||||
|
|
||||||
|
const set =
|
||||||
|
(field: keyof typeof form) =>
|
||||||
|
(e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) =>
|
||||||
|
setForm((f) => ({ ...f, [field]: e.target.value }));
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
setError(null);
|
||||||
|
setSavedFlash(false);
|
||||||
|
// Client-side gate on required fields — the server re-validates.
|
||||||
|
if (
|
||||||
|
!form.companyName.trim() ||
|
||||||
|
!form.streetAddress.trim() ||
|
||||||
|
!form.postalCode.trim() ||
|
||||||
|
!form.city.trim() ||
|
||||||
|
!form.country.trim() ||
|
||||||
|
!form.billingEmail.trim()
|
||||||
|
) {
|
||||||
|
setError(t("missingRequired"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!/^[A-Za-z]{2}$/.test(form.country.trim())) {
|
||||||
|
setError(t("invalidCountry"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(form.billingEmail.trim())) {
|
||||||
|
setError(t("invalidEmail"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/settings/billing", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
companyName: form.companyName.trim(),
|
||||||
|
// Personal accounts don't have a contact-name field
|
||||||
|
// (companyName IS their name); force null so stale state
|
||||||
|
// from a previously-org-flagged account can't carry over.
|
||||||
|
contactName: isPersonal ? null : form.contactName.trim() || null,
|
||||||
|
streetAddress: form.streetAddress.trim(),
|
||||||
|
postalCode: form.postalCode.trim(),
|
||||||
|
city: form.city.trim(),
|
||||||
|
country: form.country.trim().toUpperCase(),
|
||||||
|
// Personal accounts never have a VAT number — force null
|
||||||
|
// regardless of stale state, in case a value was stored
|
||||||
|
// before the account got flagged as personal.
|
||||||
|
vatNumber: isPersonal ? null : form.vatNumber.trim() || null,
|
||||||
|
billingEmail: form.billingEmail.trim(),
|
||||||
|
notes: form.notes.trim() || null,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(data.error ?? `HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
setSavedFlash(true);
|
||||||
|
router.refresh();
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e?.message ?? String(e));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Field
|
||||||
|
label={isPersonal ? t("fullNameLabel") : t("companyNameLabel")}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.companyName}
|
||||||
|
onChange={set("companyName")}
|
||||||
|
maxLength={200}
|
||||||
|
className="w-full px-3 py-2 rounded-md bg-surface-2 border border-border focus:border-accent focus:outline-none text-sm"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
{!isPersonal && (
|
||||||
|
<Field label={t("contactNameLabel")} hint={t("contactNameHint")}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.contactName}
|
||||||
|
onChange={set("contactName")}
|
||||||
|
maxLength={200}
|
||||||
|
className="w-full px-3 py-2 rounded-md bg-surface-2 border border-border focus:border-accent focus:outline-none text-sm"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
<Field label={t("streetAddressLabel")} required>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.streetAddress}
|
||||||
|
onChange={set("streetAddress")}
|
||||||
|
maxLength={200}
|
||||||
|
className="w-full px-3 py-2 rounded-md bg-surface-2 border border-border focus:border-accent focus:outline-none text-sm"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<Field label={t("postalCodeLabel")} required>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.postalCode}
|
||||||
|
onChange={set("postalCode")}
|
||||||
|
maxLength={20}
|
||||||
|
className="w-full px-3 py-2 rounded-md bg-surface-2 border border-border focus:border-accent focus:outline-none text-sm"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label={t("cityLabel")} required>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.city}
|
||||||
|
onChange={set("city")}
|
||||||
|
maxLength={100}
|
||||||
|
className="w-full px-3 py-2 rounded-md bg-surface-2 border border-border focus:border-accent focus:outline-none text-sm"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field
|
||||||
|
label={t("countryLabel")}
|
||||||
|
required
|
||||||
|
hint={t("countryHint")}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.country}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm((f) => ({
|
||||||
|
...f,
|
||||||
|
country: e.target.value.toUpperCase().slice(0, 2),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
maxLength={2}
|
||||||
|
className="w-full px-3 py-2 rounded-md bg-surface-2 border border-border focus:border-accent focus:outline-none text-sm uppercase font-mono"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
{!isPersonal && (
|
||||||
|
<Field label={t("vatNumberLabel")} hint={t("vatNumberHint")}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.vatNumber}
|
||||||
|
onChange={set("vatNumber")}
|
||||||
|
maxLength={40}
|
||||||
|
placeholder="CHE-123.456.789 MWST"
|
||||||
|
className="w-full px-3 py-2 rounded-md bg-surface-2 border border-border focus:border-accent focus:outline-none text-sm font-mono"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
<Field label={t("billingEmailLabel")} required hint={t("billingEmailHint")}>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={form.billingEmail}
|
||||||
|
onChange={set("billingEmail")}
|
||||||
|
maxLength={200}
|
||||||
|
className="w-full px-3 py-2 rounded-md bg-surface-2 border border-border focus:border-accent focus:outline-none text-sm"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label={t("notesLabel")} hint={t("notesHint")}>
|
||||||
|
<textarea
|
||||||
|
value={form.notes}
|
||||||
|
onChange={set("notes")}
|
||||||
|
maxLength={2000}
|
||||||
|
rows={3}
|
||||||
|
className="w-full px-3 py-2 rounded-md bg-surface-2 border border-border focus:border-accent focus:outline-none text-sm"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
{error && (
|
||||||
|
<p className="text-sm text-error">{error}</p>
|
||||||
|
)}
|
||||||
|
{savedFlash && (
|
||||||
|
<p className="text-sm text-success">{t("saved")}</p>
|
||||||
|
)}
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<button
|
||||||
|
onClick={submit}
|
||||||
|
disabled={busy}
|
||||||
|
className="px-4 py-2 rounded-md bg-accent text-white text-sm font-medium hover:bg-accent-dim transition-colors disabled:opacity-50 cursor-pointer"
|
||||||
|
>
|
||||||
|
{busy ? t("saving") : initial ? t("saveChanges") : t("createBilling")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Field({
|
||||||
|
label,
|
||||||
|
required,
|
||||||
|
hint,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
required?: boolean;
|
||||||
|
hint?: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs uppercase tracking-wider text-text-muted mb-1">
|
||||||
|
{label}
|
||||||
|
{required && <span className="text-error ml-1">*</span>}
|
||||||
|
</label>
|
||||||
|
{children}
|
||||||
|
{hint && (
|
||||||
|
<p className="text-xs text-text-muted mt-1 italic">{hint}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
187
src/components/settings/profile-form.tsx
Normal file
187
src/components/settings/profile-form.tsx
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useSession } from "next-auth/react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { Card } from "@/components/ui/card";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
initial: {
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
email: string;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Personal-account flag. Drives a small hint about how the ZITADEL
|
||||||
|
* name relates (or doesn't) to invoice identity — see the page
|
||||||
|
* server component for the long explanation.
|
||||||
|
*/
|
||||||
|
isPersonal: boolean;
|
||||||
|
/**
|
||||||
|
* For company accounts: the display org name. Shown in a small
|
||||||
|
* read-only "Member of <org>" hint so the user understands which
|
||||||
|
* identity they're editing. Ignored for personals (orgName is an
|
||||||
|
* opaque "personal-XXXX" string in that case).
|
||||||
|
*/
|
||||||
|
orgName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Edits first/last name in ZITADEL via PUT /api/settings/profile.
|
||||||
|
* Email is shown read-only — changing email requires verification
|
||||||
|
* flow that ZITADEL's own self-service UI handles.
|
||||||
|
*
|
||||||
|
* On save, we trigger NextAuth's `update()` from useSession() with
|
||||||
|
* the new display name. That routes through our jwt callback
|
||||||
|
* (trigger='update' branch) which overlays token.name without a
|
||||||
|
* logout/login. After the cookie is updated we trigger a full page
|
||||||
|
* reload — every server-rendered surface (nav-shell, dashboard
|
||||||
|
* welcome, instance cards) re-reads the cookie on the next request
|
||||||
|
* and renders with the new name. router.refresh() alone wasn't
|
||||||
|
* enough: it re-runs only the current route's server components,
|
||||||
|
* leaving outer-tree segments stale until the user navigates.
|
||||||
|
*/
|
||||||
|
export function ProfileSettingsForm({ initial, isPersonal, orgName }: Props) {
|
||||||
|
const t = useTranslations("settingsProfile");
|
||||||
|
const { update } = useSession();
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
firstName: initial.firstName,
|
||||||
|
lastName: initial.lastName,
|
||||||
|
});
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [savedFlash, setSavedFlash] = useState(false);
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
setError(null);
|
||||||
|
setSavedFlash(false);
|
||||||
|
if (!form.firstName.trim() || !form.lastName.trim()) {
|
||||||
|
setError(t("missingRequired"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/settings/profile", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
firstName: form.firstName.trim(),
|
||||||
|
lastName: form.lastName.trim(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(data.error ?? `HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
// Phase 6 fix5: push the new display name into the session
|
||||||
|
// token. The jwt callback handles trigger='update' and overlays
|
||||||
|
// token.name; the next session callback maps token.name back
|
||||||
|
// to session.user.name. No re-login needed.
|
||||||
|
await update({ name: data.displayName });
|
||||||
|
setSavedFlash(true);
|
||||||
|
// Force a full reload so EVERY server-rendered component picks
|
||||||
|
// up the new session cookie immediately — router.refresh() only
|
||||||
|
// re-runs the current route's server components, leaving the
|
||||||
|
// nav-shell (rendered higher in the tree) and other cached
|
||||||
|
// segments showing the old name until the user navigates.
|
||||||
|
// The 800ms delay lets the "Saved" flash render briefly before
|
||||||
|
// the page reloads, so the user gets visible feedback.
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.reload();
|
||||||
|
}, 800);
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e?.message ?? String(e));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<Field label={t("firstNameLabel")} required>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.firstName}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm((f) => ({ ...f, firstName: e.target.value }))
|
||||||
|
}
|
||||||
|
maxLength={100}
|
||||||
|
className="w-full px-3 py-2 rounded-md bg-surface-2 border border-border focus:border-accent focus:outline-none text-sm"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label={t("lastNameLabel")} required>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.lastName}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm((f) => ({ ...f, lastName: e.target.value }))
|
||||||
|
}
|
||||||
|
maxLength={100}
|
||||||
|
className="w-full px-3 py-2 rounded-md bg-surface-2 border border-border focus:border-accent focus:outline-none text-sm"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
<Field label={t("emailLabel")} hint={t("emailReadOnlyHint")}>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={initial.email}
|
||||||
|
readOnly
|
||||||
|
disabled
|
||||||
|
className="w-full px-3 py-2 rounded-md bg-surface-2 border border-border text-sm text-text-muted cursor-not-allowed"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
{/* Personal vs company hint. Personals get the
|
||||||
|
"this won't change your invoice name" warning since their
|
||||||
|
ZITADEL name and their invoice identity are intentionally
|
||||||
|
decoupled. Company accounts get a benign "member of"
|
||||||
|
context line so they know which org's identity they're
|
||||||
|
editing. */}
|
||||||
|
{isPersonal ? (
|
||||||
|
<p className="text-xs text-text-muted italic">
|
||||||
|
{t("personalAccountHint")}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-text-muted italic">
|
||||||
|
{t("companyAccountHint", { orgName })}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{error && <p className="text-sm text-error">{error}</p>}
|
||||||
|
{savedFlash && <p className="text-sm text-success">{t("saved")}</p>}
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<button
|
||||||
|
onClick={submit}
|
||||||
|
disabled={busy}
|
||||||
|
className="px-4 py-2 rounded-md bg-accent text-white text-sm font-medium hover:bg-accent-dim transition-colors disabled:opacity-50 cursor-pointer"
|
||||||
|
>
|
||||||
|
{busy ? t("saving") : t("saveChanges")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Field({
|
||||||
|
label,
|
||||||
|
required,
|
||||||
|
hint,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
required?: boolean;
|
||||||
|
hint?: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs uppercase tracking-wider text-text-muted mb-1">
|
||||||
|
{label}
|
||||||
|
{required && <span className="text-error ml-1">*</span>}
|
||||||
|
</label>
|
||||||
|
{children}
|
||||||
|
{hint && <p className="text-xs text-text-muted mt-1 italic">{hint}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
302
src/components/settings/saved-card-section.tsx
Normal file
302
src/components/settings/saved-card-section.tsx
Normal file
@@ -0,0 +1,302 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { Card, CardHeader } from "@/components/ui/card";
|
||||||
|
import type { OrgBillingConfig } from "@/types";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
config: OrgBillingConfig | null;
|
||||||
|
/**
|
||||||
|
* True when this org has been flipped to pay-by-invoice by admin.
|
||||||
|
* The card UI still renders (admin-set customers might also have
|
||||||
|
* a saved card as backup), but with an info note that auto-charge
|
||||||
|
* is disabled by their billing mode.
|
||||||
|
*/
|
||||||
|
isPayByInvoice: boolean;
|
||||||
|
/**
|
||||||
|
* Personal-account flag from the session. Personal accounts are
|
||||||
|
* single-user B2C tenants and don't have the bank-transfer
|
||||||
|
* affordance — they pay by card or not at all. We hide the
|
||||||
|
* "Bank transfer is available on request" hint for these accounts
|
||||||
|
* to keep the messaging unambiguous.
|
||||||
|
*/
|
||||||
|
isPersonal: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BRAND_LABELS: Record<string, string> = {
|
||||||
|
visa: "Visa",
|
||||||
|
mastercard: "Mastercard",
|
||||||
|
amex: "American Express",
|
||||||
|
discover: "Discover",
|
||||||
|
jcb: "JCB",
|
||||||
|
diners: "Diners Club",
|
||||||
|
unionpay: "UnionPay",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Saved-card management — Phase 9.
|
||||||
|
*
|
||||||
|
* State derives entirely from the OrgBillingConfig the server
|
||||||
|
* sends down. Actions are: set up (no card → Checkout setup
|
||||||
|
* mode), update (existing card → same Checkout flow, replaces),
|
||||||
|
* remove (DELETE the PM in Stripe + clear local fields), toggle
|
||||||
|
* auto-charge.
|
||||||
|
*
|
||||||
|
* The component watches for ?card_setup=success on mount and
|
||||||
|
* fires a router.refresh() — the success redirect from Stripe
|
||||||
|
* lands here and the new card info needs to load. We also strip
|
||||||
|
* the query param so a page reload doesn't re-trigger.
|
||||||
|
*/
|
||||||
|
export function SavedCardSection({
|
||||||
|
config,
|
||||||
|
isPayByInvoice,
|
||||||
|
isPersonal,
|
||||||
|
}: Props) {
|
||||||
|
const t = useTranslations("settingsBilling");
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const [busy, setBusy] = useState<null | "setup" | "remove" | "toggle">(null);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
// Refresh + clean the URL when Stripe redirects back. Stripe's
|
||||||
|
// webhook is what actually persists the card; the refresh just
|
||||||
|
// re-fetches the server-side config so the new fields appear.
|
||||||
|
useEffect(() => {
|
||||||
|
const status = searchParams.get("card_setup");
|
||||||
|
if (status === "success") {
|
||||||
|
router.replace("/settings/billing");
|
||||||
|
router.refresh();
|
||||||
|
} else if (status === "cancelled") {
|
||||||
|
// Just clean the URL. No-op otherwise.
|
||||||
|
router.replace("/settings/billing");
|
||||||
|
}
|
||||||
|
}, [searchParams, router]);
|
||||||
|
|
||||||
|
const hasCard = !!config?.stripeDefaultPaymentMethodId;
|
||||||
|
const autoChargeOn = config?.autoChargeEnabled !== false;
|
||||||
|
|
||||||
|
const startSetup = async () => {
|
||||||
|
setError("");
|
||||||
|
setBusy("setup");
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/billing/setup-card", { method: "POST" });
|
||||||
|
const j = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) throw new Error(j.error || `HTTP ${res.status}`);
|
||||||
|
if (!j.url) throw new Error("No redirect URL returned");
|
||||||
|
// Hard-redirect — Stripe Checkout doesn't run inside the SPA.
|
||||||
|
window.location.href = j.url;
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e.message);
|
||||||
|
setBusy(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeCard = async () => {
|
||||||
|
if (!confirm(t("savedCardRemoveConfirm"))) return;
|
||||||
|
setError("");
|
||||||
|
setBusy("remove");
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/billing/saved-card", { method: "DELETE" });
|
||||||
|
const j = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) throw new Error(j.error || `HTTP ${res.status}`);
|
||||||
|
router.refresh();
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e.message);
|
||||||
|
} finally {
|
||||||
|
setBusy(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleAutoCharge = async () => {
|
||||||
|
setError("");
|
||||||
|
setBusy("toggle");
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/billing/auto-charge", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ enabled: !autoChargeOn }),
|
||||||
|
});
|
||||||
|
const j = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) throw new Error(j.error || `HTTP ${res.status}`);
|
||||||
|
router.refresh();
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e.message);
|
||||||
|
} finally {
|
||||||
|
setBusy(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Empty state — no card on file.
|
||||||
|
if (!hasCard) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>{t("savedCardHeading")}</CardHeader>
|
||||||
|
<div className="p-5">
|
||||||
|
<p className="text-sm text-text-secondary mb-4">
|
||||||
|
{t("savedCardEmptyBody")}
|
||||||
|
</p>
|
||||||
|
{/* Phase 9: prominent policy notice. Auto-pay is the
|
||||||
|
expected default — emphasise that failure to keep a
|
||||||
|
chargeable card on file may result in tenant suspension.
|
||||||
|
Sits above the CTA so it's seen before the click. */}
|
||||||
|
<div className="text-sm rounded-md border border-warning/40 bg-warning/10 text-warning px-4 py-3 mb-4">
|
||||||
|
<strong className="block mb-1">
|
||||||
|
{t("savedCardAutoPayRequiredHeading")}
|
||||||
|
</strong>
|
||||||
|
<span className="text-text-secondary">
|
||||||
|
{t("savedCardAutoPayRequiredBody")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{error && (
|
||||||
|
<div className="text-sm text-error mb-3">{error}</div>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={startSetup}
|
||||||
|
disabled={busy !== null}
|
||||||
|
className="px-4 py-2 rounded-md bg-accent text-white text-sm disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{busy === "setup" ? t("savedCardRedirecting") : t("savedCardSetupBtn")}
|
||||||
|
</button>
|
||||||
|
{/* Bank-transfer hint shown only for company accounts.
|
||||||
|
Personal (B2C) accounts pay by card only — surfacing
|
||||||
|
the alternative would only confuse. */}
|
||||||
|
{!isPersonal && (
|
||||||
|
<p className="text-xs text-text-muted mt-4">
|
||||||
|
{t("savedCardBankTransferHint")}{" "}
|
||||||
|
<a
|
||||||
|
href="/support"
|
||||||
|
className="text-accent hover:underline"
|
||||||
|
>
|
||||||
|
{t("savedCardBankTransferLink")}
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Card on file.
|
||||||
|
const brandLabel =
|
||||||
|
config?.stripePmBrand
|
||||||
|
? BRAND_LABELS[config.stripePmBrand] ?? config.stripePmBrand
|
||||||
|
: t("savedCardBrandUnknown");
|
||||||
|
const last4 = config?.stripePmLast4 ?? "????";
|
||||||
|
const expMonth = config?.stripePmExpMonth;
|
||||||
|
const expYear = config?.stripePmExpYear;
|
||||||
|
const expLabel =
|
||||||
|
expMonth && expYear
|
||||||
|
? `${String(expMonth).padStart(2, "0")}/${String(expYear).slice(-2)}`
|
||||||
|
: "";
|
||||||
|
// Heuristic for "expiring soon" — if the card expires this calendar
|
||||||
|
// month or next. Stripe's pre-expiration emails handle the real
|
||||||
|
// notification, but a portal hint is friendly too.
|
||||||
|
const now = new Date();
|
||||||
|
const expiringSoon =
|
||||||
|
expMonth &&
|
||||||
|
expYear &&
|
||||||
|
(expYear < now.getFullYear() ||
|
||||||
|
(expYear === now.getFullYear() && expMonth <= now.getMonth() + 2));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>{t("savedCardHeading")}</CardHeader>
|
||||||
|
<div className="p-5">
|
||||||
|
<div className="flex items-center justify-between mb-4 flex-wrap gap-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="font-mono text-sm">
|
||||||
|
{brandLabel} •••• {last4}
|
||||||
|
</span>
|
||||||
|
{expLabel && (
|
||||||
|
<span
|
||||||
|
className={`text-xs ${
|
||||||
|
expiringSoon ? "text-warning" : "text-text-muted"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t("savedCardExpires", { date: expLabel })}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 text-xs">
|
||||||
|
<span
|
||||||
|
className={`px-2 py-0.5 rounded text-xs ${
|
||||||
|
autoChargeOn
|
||||||
|
? "bg-success/15 text-success"
|
||||||
|
: "bg-text-muted/15 text-text-muted"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{autoChargeOn
|
||||||
|
? t("savedCardAutoChargeOn")
|
||||||
|
: t("savedCardAutoChargeOff")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isPayByInvoice && (
|
||||||
|
<div className="text-xs text-text-muted bg-surface-3 rounded-md px-3 py-2 mb-3">
|
||||||
|
{t("savedCardPayByInvoiceNote")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* If the card is on file but the customer has actively
|
||||||
|
disabled auto-pay, surface the suspension-risk reminder.
|
||||||
|
Not shown when admin has flipped them to pay-by-invoice —
|
||||||
|
that's a different deal and the note above explains it. */}
|
||||||
|
{!isPayByInvoice && !autoChargeOn && (
|
||||||
|
<div className="text-xs rounded-md border border-warning/40 bg-warning/10 text-warning px-3 py-2 mb-3">
|
||||||
|
{t("savedCardAutoPayDisabledNote")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && <div className="text-sm text-error mb-3">{error}</div>}
|
||||||
|
|
||||||
|
<div className="flex gap-2 flex-wrap">
|
||||||
|
<button
|
||||||
|
onClick={startSetup}
|
||||||
|
disabled={busy !== null}
|
||||||
|
className="px-3 py-1.5 rounded-md border border-border text-sm disabled:opacity-50 hover:bg-surface-3"
|
||||||
|
>
|
||||||
|
{busy === "setup"
|
||||||
|
? t("savedCardRedirecting")
|
||||||
|
: t("savedCardUpdateBtn")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={toggleAutoCharge}
|
||||||
|
disabled={busy !== null}
|
||||||
|
className="px-3 py-1.5 rounded-md border border-border text-sm disabled:opacity-50 hover:bg-surface-3"
|
||||||
|
>
|
||||||
|
{busy === "toggle"
|
||||||
|
? t("saving")
|
||||||
|
: autoChargeOn
|
||||||
|
? t("savedCardDisableAutoChargeBtn")
|
||||||
|
: t("savedCardEnableAutoChargeBtn")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={removeCard}
|
||||||
|
disabled={busy !== null}
|
||||||
|
className="px-3 py-1.5 rounded-md border border-error text-error text-sm disabled:opacity-50 hover:bg-error/10 ml-auto"
|
||||||
|
>
|
||||||
|
{busy === "remove"
|
||||||
|
? t("savedCardRemoving")
|
||||||
|
: t("savedCardRemoveBtn")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bank-transfer hint shown only for company accounts. */}
|
||||||
|
{!isPersonal && (
|
||||||
|
<p className="text-xs text-text-muted mt-4">
|
||||||
|
{t("savedCardBankTransferHint")}{" "}
|
||||||
|
<a
|
||||||
|
href="/support"
|
||||||
|
className="text-accent hover:underline"
|
||||||
|
>
|
||||||
|
{t("savedCardBankTransferLink")}
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -49,7 +49,31 @@ export const authConfig: NextAuthConfig = {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
callbacks: {
|
callbacks: {
|
||||||
async jwt({ token, account, profile }) {
|
async jwt({ token, account, profile, trigger, session }) {
|
||||||
|
// Phase 6 fix5: client-side `useSession().update({ name })` calls
|
||||||
|
// route through this branch. We trust the new value because the
|
||||||
|
// PUT /api/settings/profile route already wrote it to ZITADEL
|
||||||
|
// and re-fetched the canonical displayName before returning.
|
||||||
|
// The session callback reads token.name directly (see below) so
|
||||||
|
// the update propagates without depending on auth.js's implicit
|
||||||
|
// token→session.user mapping, which is flaky for the name claim
|
||||||
|
// in the v5 OIDC provider configuration.
|
||||||
|
//
|
||||||
|
// Defensive: only the `name` field is accepted from the update
|
||||||
|
// payload, even if the client passes additional keys. Other
|
||||||
|
// identity claims (orgId, roles, sub) come from ZITADEL at
|
||||||
|
// sign-in time and are not user-mutable from a settings page.
|
||||||
|
//
|
||||||
|
// Returns a NEW token object (spread) rather than mutating, so
|
||||||
|
// there is no ambiguity for auth.js about whether the token
|
||||||
|
// changed and needs re-encoding into the session cookie.
|
||||||
|
if (trigger === "update" && session) {
|
||||||
|
const update = session as { name?: unknown };
|
||||||
|
if (typeof update.name === "string") {
|
||||||
|
return { ...token, name: update.name };
|
||||||
|
}
|
||||||
|
return token;
|
||||||
|
}
|
||||||
if (account && profile) {
|
if (account && profile) {
|
||||||
const claims = profile as unknown as ZitadelClaims;
|
const claims = profile as unknown as ZitadelClaims;
|
||||||
token.orgId = claims["urn:zitadel:iam:user:resourceowner:id"];
|
token.orgId = claims["urn:zitadel:iam:user:resourceowner:id"];
|
||||||
@@ -58,6 +82,19 @@ export const authConfig: NextAuthConfig = {
|
|||||||
claims["urn:zitadel:iam:org:project:roles"]
|
claims["urn:zitadel:iam:org:project:roles"]
|
||||||
);
|
);
|
||||||
token.accessToken = account.access_token;
|
token.accessToken = account.access_token;
|
||||||
|
// Phase 6 fix5: explicitly pin the standard name/email claims
|
||||||
|
// onto the token from the OIDC profile. Previously these came
|
||||||
|
// through auth.js's implicit mapping, which works on first
|
||||||
|
// sign-in but isn't reliable after update() — once the update
|
||||||
|
// path overrides token.name, the read-back path needs token
|
||||||
|
// to be the authoritative source. Setting them explicitly
|
||||||
|
// here keeps sign-in and update on the same path.
|
||||||
|
if (typeof profile.name === "string") {
|
||||||
|
token.name = profile.name;
|
||||||
|
}
|
||||||
|
if (typeof profile.email === "string") {
|
||||||
|
token.email = profile.email;
|
||||||
|
}
|
||||||
// Pin token.sub to the OIDC subject. Auth.js v5 otherwise puts a
|
// Pin token.sub to the OIDC subject. Auth.js v5 otherwise puts a
|
||||||
// freshly generated UUID in token.sub on initial sign-in,
|
// freshly generated UUID in token.sub on initial sign-in,
|
||||||
// ignoring what profile() returns for `id`. That UUID then
|
// ignoring what profile() returns for `id`. That UUID then
|
||||||
@@ -80,10 +117,19 @@ export const authConfig: NextAuthConfig = {
|
|||||||
async session({ session, token }) {
|
async session({ session, token }) {
|
||||||
const roles = (token.roles as Role[]) ?? [];
|
const roles = (token.roles as Role[]) ?? [];
|
||||||
const orgName = (token.orgName as string) ?? "";
|
const orgName = (token.orgName as string) ?? "";
|
||||||
|
// Phase 6 fix5: read name and email directly from the token.
|
||||||
|
// Previously this code relied on `session.user?.name`, expecting
|
||||||
|
// auth.js to map token.name → session.user.name automatically.
|
||||||
|
// That mapping is brittle: it works on first sign-in (because
|
||||||
|
// OIDC profile() populates session.user) but not after update()
|
||||||
|
// overrides token.name. Reading from token is the canonical
|
||||||
|
// path regardless of how the token was last written.
|
||||||
|
const tokenName = (token.name as string | undefined) ?? "";
|
||||||
|
const tokenEmail = (token.email as string | undefined) ?? "";
|
||||||
const sessionUser: SessionUser = {
|
const sessionUser: SessionUser = {
|
||||||
id: token.sub!,
|
id: token.sub!,
|
||||||
name: session.user?.name ?? "",
|
name: tokenName || session.user?.name || "",
|
||||||
email: session.user?.email ?? "",
|
email: tokenEmail || session.user?.email || "",
|
||||||
orgId: token.orgId as string,
|
orgId: token.orgId as string,
|
||||||
orgName,
|
orgName,
|
||||||
roles,
|
roles,
|
||||||
@@ -96,6 +142,14 @@ export const authConfig: NextAuthConfig = {
|
|||||||
isPersonal: isPersonalOrgName(orgName),
|
isPersonal: isPersonalOrgName(orgName),
|
||||||
};
|
};
|
||||||
(session as any).platformUser = sessionUser;
|
(session as any).platformUser = sessionUser;
|
||||||
|
// Also overwrite session.user so any client-side code that uses
|
||||||
|
// the standard NextAuth shape (session.user.name) sees the new
|
||||||
|
// value. Pre-fix5 code paths read from session.user.name; this
|
||||||
|
// keeps them working without per-component changes.
|
||||||
|
if (session.user) {
|
||||||
|
session.user.name = sessionUser.name;
|
||||||
|
session.user.email = sessionUser.email;
|
||||||
|
}
|
||||||
return session;
|
return session;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
173
src/lib/billing-i18n.ts
Normal file
173
src/lib/billing-i18n.ts
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
/**
|
||||||
|
* Shared billing localization. Used by:
|
||||||
|
* - billing.ts (compute path) — pre-renders the localized
|
||||||
|
* line description and stores it on the invoice line at issue
|
||||||
|
* time. Descriptions are then frozen in the customer's locale.
|
||||||
|
* - billing-pdf.tsx (render path) — can fall back to this if a
|
||||||
|
* stored description is missing (e.g. legacy invoice from the
|
||||||
|
* pre-i18n era) or if the PDF is re-rendered in a different
|
||||||
|
* locale (Phase 7).
|
||||||
|
*
|
||||||
|
* Locale set matches the portal's next-intl locales: de, en, fr, it.
|
||||||
|
* Unknown locales fall back to German (Swiss B2B default).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { InvoiceLineKind } from "@/types";
|
||||||
|
|
||||||
|
export type BillingLocale = "de" | "en" | "fr" | "it";
|
||||||
|
|
||||||
|
function normaliseLocale(locale: string): BillingLocale {
|
||||||
|
if (locale === "en" || locale === "fr" || locale === "it" || locale === "de") {
|
||||||
|
return locale;
|
||||||
|
}
|
||||||
|
return "de";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Localized "N day(s)" — covers the only plural case in billing
|
||||||
|
* line descriptions. Other plurals (months, requests, messages)
|
||||||
|
* either don't change form in the supported languages or are
|
||||||
|
* always >1 in practice.
|
||||||
|
*/
|
||||||
|
function days(n: number, locale: BillingLocale): string {
|
||||||
|
const labels = {
|
||||||
|
de: { one: "Tag", many: "Tage" },
|
||||||
|
en: { one: "day", many: "days" },
|
||||||
|
fr: { one: "jour", many: "jours" },
|
||||||
|
it: { one: "giorno", many: "giorni" },
|
||||||
|
} as const;
|
||||||
|
const label = labels[locale];
|
||||||
|
return `${n} ${n === 1 ? label.one : label.many}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Subset of InvoiceLine needed for description formatting. */
|
||||||
|
export interface LineForDescription {
|
||||||
|
kind: InvoiceLineKind;
|
||||||
|
tenantName: string | null;
|
||||||
|
metadata: Record<string, unknown> | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the localized line description from a line's kind +
|
||||||
|
* metadata. Pure function — no DB/IO. Output mirrors what the
|
||||||
|
* PDF and admin preview show in the description column.
|
||||||
|
*
|
||||||
|
* Metadata expectations per kind (must match what billing.ts
|
||||||
|
* stores when emitting the line):
|
||||||
|
* tenant_monthly: { billable_days, days_in_month }
|
||||||
|
* tenant_setup: {} (uses tenantName only)
|
||||||
|
* ai_usage: { requests }
|
||||||
|
* threema_messages: { in_count, out_count }
|
||||||
|
* skill_usage: { skill_id, billable_days }
|
||||||
|
* skill_setup: { skill_id }
|
||||||
|
* adjustment: { reason? }
|
||||||
|
*
|
||||||
|
* Missing fields fall back to "?" so a malformed line still
|
||||||
|
* renders something readable rather than crashing the PDF.
|
||||||
|
*/
|
||||||
|
export function formatLineDescription(
|
||||||
|
line: LineForDescription,
|
||||||
|
locale: string
|
||||||
|
): string {
|
||||||
|
const L = normaliseLocale(locale);
|
||||||
|
const m = line.metadata ?? {};
|
||||||
|
const tenant = line.tenantName ?? "—";
|
||||||
|
// Helper to fetch a metadata field with a safe fallback.
|
||||||
|
const f = (key: string): string | number => {
|
||||||
|
const v = (m as Record<string, unknown>)[key];
|
||||||
|
if (v === undefined || v === null) return "?";
|
||||||
|
return v as string | number;
|
||||||
|
};
|
||||||
|
|
||||||
|
switch (line.kind) {
|
||||||
|
case "tenant_monthly": {
|
||||||
|
const bd = f("billable_days");
|
||||||
|
const dim = f("days_in_month");
|
||||||
|
return {
|
||||||
|
de: `Monatliche Grundgebühr für ${tenant} (${bd}/${dim} Tage)`,
|
||||||
|
en: `Monthly fee for ${tenant} (${bd}/${dim} days)`,
|
||||||
|
fr: `Forfait mensuel pour ${tenant} (${bd}/${dim} jours)`,
|
||||||
|
it: `Canone mensile per ${tenant} (${bd}/${dim} giorni)`,
|
||||||
|
}[L];
|
||||||
|
}
|
||||||
|
|
||||||
|
case "tenant_setup":
|
||||||
|
return {
|
||||||
|
de: `Einrichtungsgebühr für ${tenant}`,
|
||||||
|
en: `Setup fee for ${tenant}`,
|
||||||
|
fr: `Frais de configuration pour ${tenant}`,
|
||||||
|
it: `Spese di attivazione per ${tenant}`,
|
||||||
|
}[L];
|
||||||
|
|
||||||
|
case "ai_usage": {
|
||||||
|
const r = f("requests");
|
||||||
|
return {
|
||||||
|
de: `KI-Inferenz-Nutzung (${r} Anfragen)`,
|
||||||
|
en: `AI inference usage (${r} requests)`,
|
||||||
|
fr: `Utilisation IA (${r} requêtes)`,
|
||||||
|
it: `Utilizzo IA (${r} richieste)`,
|
||||||
|
}[L];
|
||||||
|
}
|
||||||
|
|
||||||
|
case "threema_messages": {
|
||||||
|
const inC = f("in_count");
|
||||||
|
const outC = f("out_count");
|
||||||
|
return {
|
||||||
|
de: `Threema-Nachrichten (${inC} eingehend + ${outC} ausgehend)`,
|
||||||
|
en: `Threema messages (${inC} in + ${outC} out)`,
|
||||||
|
fr: `Messages Threema (${inC} entrants + ${outC} sortants)`,
|
||||||
|
it: `Messaggi Threema (${inC} in entrata + ${outC} in uscita)`,
|
||||||
|
}[L];
|
||||||
|
}
|
||||||
|
|
||||||
|
case "skill_usage": {
|
||||||
|
const skill = f("skill_id");
|
||||||
|
const bdRaw = (m as Record<string, unknown>)["billable_days"];
|
||||||
|
const bd = typeof bdRaw === "number" ? bdRaw : 0;
|
||||||
|
return {
|
||||||
|
de: `Skill: ${skill} (${days(bd, "de")})`,
|
||||||
|
en: `Skill: ${skill} (${days(bd, "en")})`,
|
||||||
|
fr: `Skill: ${skill} (${days(bd, "fr")})`,
|
||||||
|
it: `Skill: ${skill} (${days(bd, "it")})`,
|
||||||
|
}[L];
|
||||||
|
}
|
||||||
|
|
||||||
|
case "skill_setup": {
|
||||||
|
const skill = f("skill_id");
|
||||||
|
return {
|
||||||
|
de: `Einrichtungsgebühr Skill: ${skill}`,
|
||||||
|
en: `Setup fee skill: ${skill}`,
|
||||||
|
fr: `Frais de configuration skill: ${skill}`,
|
||||||
|
it: `Spese di attivazione skill: ${skill}`,
|
||||||
|
}[L];
|
||||||
|
}
|
||||||
|
|
||||||
|
case "adjustment": {
|
||||||
|
const reasonRaw = (m as Record<string, unknown>)["reason"];
|
||||||
|
const reason = typeof reasonRaw === "string" ? reasonRaw : null;
|
||||||
|
const base = {
|
||||||
|
de: "Anpassung",
|
||||||
|
en: "Adjustment",
|
||||||
|
fr: "Ajustement",
|
||||||
|
it: "Rettifica",
|
||||||
|
}[L];
|
||||||
|
return reason ? `${base}: ${reason}` : base;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 8: custom invoice lines. The description is what the
|
||||||
|
// admin typed in the editor — return it verbatim (no template,
|
||||||
|
// no locale-specific formatting). billing.ts persists the
|
||||||
|
// already-trimmed admin input into invoice_lines.description.
|
||||||
|
case "custom_line": {
|
||||||
|
const dRaw = (m as Record<string, unknown>)["description"];
|
||||||
|
if (typeof dRaw === "string" && dRaw.trim().length > 0) return dRaw;
|
||||||
|
// Fallback: the description column on the row itself. The
|
||||||
|
// PDF renderer hands us the line so it can read it directly
|
||||||
|
// — see how billing-pdf invokes formatLineDescription.
|
||||||
|
const onRow = (line as unknown as { description?: string }).description;
|
||||||
|
return onRow && onRow.trim().length > 0
|
||||||
|
? onRow
|
||||||
|
: { de: "Leistung", en: "Service", fr: "Service", it: "Servizio" }[L];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
606
src/lib/billing-pdf.tsx
Normal file
606
src/lib/billing-pdf.tsx
Normal file
@@ -0,0 +1,606 @@
|
|||||||
|
/**
|
||||||
|
* 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,
|
||||||
|
renderToBuffer,
|
||||||
|
} from "@react-pdf/renderer";
|
||||||
|
import type { Invoice, InvoiceLine, InvoiceLineKind } from "@/types";
|
||||||
|
import { BRAND, Logo } from "./pdf-brand";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Brand: imported from lib/pdf-brand. Edit there to change issuer
|
||||||
|
// info, colours, or the logo. Both billing-pdf.tsx and credit-note-pdf.tsx
|
||||||
|
// share the same source of truth so a brand change applies to every
|
||||||
|
// PDF the portal produces.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Localized strings
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface PdfStrings {
|
||||||
|
invoice: string;
|
||||||
|
invoiceNumber: string;
|
||||||
|
issueDate: string;
|
||||||
|
dueDate: string;
|
||||||
|
period: string;
|
||||||
|
billTo: string;
|
||||||
|
// Phase 6 fix: prefix shown before the optional contact-person
|
||||||
|
// name on the bill-to block. "z.Hd." (DE) / "Attn:" (EN) /
|
||||||
|
// "À l'attention de" (FR) / "c.a." (IT). Empty/unused when the
|
||||||
|
// invoice has no contactName on its snapshot.
|
||||||
|
attentionPrefix: string;
|
||||||
|
description: string;
|
||||||
|
quantity: string;
|
||||||
|
unitPrice: string;
|
||||||
|
amount: string;
|
||||||
|
subtotal: string;
|
||||||
|
vat: string;
|
||||||
|
total: string;
|
||||||
|
paymentInstructions: string;
|
||||||
|
paymentRefHint: string;
|
||||||
|
thankYou: string;
|
||||||
|
page: string;
|
||||||
|
of: string;
|
||||||
|
// Per-line-kind labels (used as section headers)
|
||||||
|
kindLabels: Record<InvoiceLineKind, string>;
|
||||||
|
// VAT compliance notes
|
||||||
|
reverseCharge: string;
|
||||||
|
exportNote: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MESSAGES: Record<string, PdfStrings> = {
|
||||||
|
de: {
|
||||||
|
invoice: "Rechnung",
|
||||||
|
invoiceNumber: "Rechnungs-Nr.",
|
||||||
|
issueDate: "Rechnungsdatum",
|
||||||
|
dueDate: "Zahlbar bis",
|
||||||
|
period: "Abrechnungsperiode",
|
||||||
|
billTo: "Rechnungsempfänger",
|
||||||
|
attentionPrefix: "z.Hd.",
|
||||||
|
description: "Beschreibung",
|
||||||
|
quantity: "Menge",
|
||||||
|
unitPrice: "Einzelpreis",
|
||||||
|
amount: "Betrag",
|
||||||
|
subtotal: "Zwischensumme",
|
||||||
|
vat: "MWST",
|
||||||
|
total: "Total",
|
||||||
|
paymentInstructions: "Zahlungsinformationen",
|
||||||
|
paymentRefHint: "Bitte verwenden Sie die Rechnungsnummer als Referenz.",
|
||||||
|
thankYou: "Vielen Dank für Ihr Vertrauen.",
|
||||||
|
page: "Seite",
|
||||||
|
of: "von",
|
||||||
|
kindLabels: {
|
||||||
|
tenant_monthly: "Monatliche Grundgebühr",
|
||||||
|
tenant_setup: "Einrichtungsgebühr",
|
||||||
|
ai_usage: "KI-Nutzung",
|
||||||
|
threema_messages: "Threema-Nachrichten",
|
||||||
|
skill_usage: "Skill-Nutzung",
|
||||||
|
skill_setup: "Einrichtungsgebühr Skill",
|
||||||
|
adjustment: "Anpassung",
|
||||||
|
custom_line: "Leistungen",
|
||||||
|
},
|
||||||
|
reverseCharge:
|
||||||
|
"Steuerschuldnerschaft des Leistungsempfängers (Reverse Charge).",
|
||||||
|
exportNote: "Dienstleistungsexport — keine MWST in Rechnung gestellt.",
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
invoice: "Invoice",
|
||||||
|
invoiceNumber: "Invoice no.",
|
||||||
|
issueDate: "Issue date",
|
||||||
|
dueDate: "Due date",
|
||||||
|
period: "Billing period",
|
||||||
|
billTo: "Bill to",
|
||||||
|
attentionPrefix: "Attn:",
|
||||||
|
description: "Description",
|
||||||
|
quantity: "Qty",
|
||||||
|
unitPrice: "Unit price",
|
||||||
|
amount: "Amount",
|
||||||
|
subtotal: "Subtotal",
|
||||||
|
vat: "VAT",
|
||||||
|
total: "Total",
|
||||||
|
paymentInstructions: "Payment instructions",
|
||||||
|
paymentRefHint: "Please use the invoice number as the payment reference.",
|
||||||
|
thankYou: "Thank you for your business.",
|
||||||
|
page: "Page",
|
||||||
|
of: "of",
|
||||||
|
kindLabels: {
|
||||||
|
tenant_monthly: "Monthly fee",
|
||||||
|
tenant_setup: "Setup fee",
|
||||||
|
ai_usage: "AI usage",
|
||||||
|
threema_messages: "Threema messages",
|
||||||
|
skill_usage: "Skill usage",
|
||||||
|
skill_setup: "Skill setup fee",
|
||||||
|
adjustment: "Adjustment",
|
||||||
|
custom_line: "Services",
|
||||||
|
},
|
||||||
|
reverseCharge:
|
||||||
|
"Reverse charge — VAT to be accounted for by the recipient.",
|
||||||
|
exportNote: "Export of services — VAT not applicable.",
|
||||||
|
},
|
||||||
|
fr: {
|
||||||
|
invoice: "Facture",
|
||||||
|
invoiceNumber: "N° facture",
|
||||||
|
issueDate: "Date d'émission",
|
||||||
|
dueDate: "Échéance",
|
||||||
|
period: "Période de facturation",
|
||||||
|
billTo: "Destinataire",
|
||||||
|
attentionPrefix: "À l'attention de",
|
||||||
|
description: "Description",
|
||||||
|
quantity: "Qté",
|
||||||
|
unitPrice: "Prix unitaire",
|
||||||
|
amount: "Montant",
|
||||||
|
subtotal: "Sous-total",
|
||||||
|
vat: "TVA",
|
||||||
|
total: "Total",
|
||||||
|
paymentInstructions: "Informations de paiement",
|
||||||
|
paymentRefHint: "Veuillez utiliser le n° de facture comme référence.",
|
||||||
|
thankYou: "Merci de votre confiance.",
|
||||||
|
page: "Page",
|
||||||
|
of: "sur",
|
||||||
|
kindLabels: {
|
||||||
|
tenant_monthly: "Forfait mensuel",
|
||||||
|
tenant_setup: "Frais de configuration",
|
||||||
|
ai_usage: "Utilisation IA",
|
||||||
|
threema_messages: "Messages Threema",
|
||||||
|
skill_usage: "Utilisation Skill",
|
||||||
|
skill_setup: "Frais de configuration skill",
|
||||||
|
adjustment: "Ajustement",
|
||||||
|
custom_line: "Services",
|
||||||
|
},
|
||||||
|
reverseCharge:
|
||||||
|
"Autoliquidation — TVA à acquitter par le destinataire.",
|
||||||
|
exportNote: "Exportation de services — TVA non applicable.",
|
||||||
|
},
|
||||||
|
it: {
|
||||||
|
invoice: "Fattura",
|
||||||
|
invoiceNumber: "N. fattura",
|
||||||
|
issueDate: "Data di emissione",
|
||||||
|
dueDate: "Scadenza",
|
||||||
|
period: "Periodo di fatturazione",
|
||||||
|
billTo: "Destinatario",
|
||||||
|
attentionPrefix: "c.a.",
|
||||||
|
description: "Descrizione",
|
||||||
|
quantity: "Qtà",
|
||||||
|
unitPrice: "Prezzo unitario",
|
||||||
|
amount: "Importo",
|
||||||
|
subtotal: "Subtotale",
|
||||||
|
vat: "IVA",
|
||||||
|
total: "Totale",
|
||||||
|
paymentInstructions: "Istruzioni di pagamento",
|
||||||
|
paymentRefHint: "Si prega di utilizzare il n. di fattura come riferimento.",
|
||||||
|
thankYou: "Grazie per la fiducia.",
|
||||||
|
page: "Pagina",
|
||||||
|
of: "di",
|
||||||
|
kindLabels: {
|
||||||
|
tenant_monthly: "Canone mensile",
|
||||||
|
tenant_setup: "Spese di attivazione",
|
||||||
|
ai_usage: "Utilizzo IA",
|
||||||
|
threema_messages: "Messaggi Threema",
|
||||||
|
skill_usage: "Utilizzo Skill",
|
||||||
|
skill_setup: "Spese di attivazione skill",
|
||||||
|
adjustment: "Rettifica",
|
||||||
|
custom_line: "Servizi",
|
||||||
|
},
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 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}>
|
||||||
|
{/* Phase 8: skip the billing-period block on custom
|
||||||
|
invoices (which aren't tied to a period). Due date
|
||||||
|
still renders. */}
|
||||||
|
{invoice.periodStart && invoice.periodEnd && (
|
||||||
|
<>
|
||||||
|
<Text style={styles.metaLabel}>{s.period}</Text>
|
||||||
|
<Text style={styles.metaValue}>
|
||||||
|
{fmtDate(invoice.periodStart, invoice.locale)} —{" "}
|
||||||
|
{fmtDate(invoice.periodEnd, invoice.locale)}
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Text style={styles.metaLabel}>{s.dueDate}</Text>
|
||||||
|
<Text style={styles.metaValue}>
|
||||||
|
{fmtDate(invoice.dueAt, invoice.locale)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Bill-to */}
|
||||||
|
<View style={styles.billToBlock}>
|
||||||
|
<Text style={styles.billToLabel}>{s.billTo}</Text>
|
||||||
|
<Text style={styles.billToName}>{snap.companyName}</Text>
|
||||||
|
{/* Phase 6 fix: optional "z.Hd." / "Attn:" line for routing
|
||||||
|
the printed invoice internally at the customer. Prints
|
||||||
|
between the company name and street address, in the
|
||||||
|
invoice's locale (frozen at issue time). */}
|
||||||
|
{snap.contactName && (
|
||||||
|
<Text>
|
||||||
|
{s.attentionPrefix} {snap.contactName}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<Text>{snap.streetAddress}</Text>
|
||||||
|
<Text>
|
||||||
|
{snap.postalCode} {snap.city}
|
||||||
|
</Text>
|
||||||
|
<Text>{snap.country}</Text>
|
||||||
|
{snap.vatNumber && <Text>VAT: {snap.vatNumber}</Text>}
|
||||||
|
<Text>{snap.billingEmail}</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Line items table */}
|
||||||
|
<View style={styles.table}>
|
||||||
|
<View style={styles.tableHeader}>
|
||||||
|
<Text style={styles.colDesc}>{s.description}</Text>
|
||||||
|
<Text style={styles.colQty}>{s.quantity}</Text>
|
||||||
|
<Text style={styles.colUnit}>{s.unitPrice}</Text>
|
||||||
|
<Text style={styles.colAmt}>{s.amount} (CHF)</Text>
|
||||||
|
</View>
|
||||||
|
{tenantOrder.map((tenantKey) => {
|
||||||
|
const tenantLines = linesByTenant.get(tenantKey)!;
|
||||||
|
return (
|
||||||
|
<View key={tenantKey ?? "_org"}>
|
||||||
|
{tenantKey && (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
paddingVertical: 4,
|
||||||
|
paddingHorizontal: 6,
|
||||||
|
backgroundColor: "#f0f9f4",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{ fontSize: 9, color: BRAND.primaryDark }}>
|
||||||
|
{tenantKey}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{tenantLines.map((ln) => (
|
||||||
|
<View key={ln.id} style={styles.tableRow}>
|
||||||
|
<Text style={styles.colDesc}>{ln.description}</Text>
|
||||||
|
<Text style={styles.colQty}>
|
||||||
|
{ln.quantity}
|
||||||
|
{ln.unitLabel ? ` ${ln.unitLabel}` : ""}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.colUnit}>{fmtChf(ln.unitPriceChf, 5)}</Text>
|
||||||
|
<Text style={styles.colAmt}>{fmtChf(ln.amountChf)}</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Totals */}
|
||||||
|
<View style={styles.totalsBlock}>
|
||||||
|
<View style={styles.totalsRow}>
|
||||||
|
<Text style={styles.totalsLabel}>{s.subtotal}</Text>
|
||||||
|
<Text style={styles.totalsValue}>{fmtChf(invoice.subtotalChf)}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.totalsRow}>
|
||||||
|
<Text style={styles.totalsLabel}>
|
||||||
|
{s.vat} ({invoice.vatRate.toFixed(2)}%)
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.totalsValue}>{fmtChf(invoice.vatAmountChf)}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.totalsGrand}>
|
||||||
|
<Text style={styles.totalsGrandLabel}>
|
||||||
|
{s.total} (CHF)
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.totalsGrandValue}>{fmtChf(invoice.totalChf)}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{vatNote && (
|
||||||
|
<View style={styles.noteBox}>
|
||||||
|
<Text>{vatNote}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Payment instructions */}
|
||||||
|
<View style={styles.paymentBlock}>
|
||||||
|
<Text style={styles.paymentTitle}>{s.paymentInstructions}</Text>
|
||||||
|
<Text style={styles.paymentLine}>{BRAND.issuer.legalName}</Text>
|
||||||
|
<Text style={styles.paymentLine}>{BRAND.issuer.bankName}</Text>
|
||||||
|
<Text style={styles.paymentLine}>IBAN: {BRAND.issuer.bankIban}</Text>
|
||||||
|
<Text style={styles.paymentLine}>BIC: {BRAND.issuer.bankBic}</Text>
|
||||||
|
<Text style={[styles.paymentLine, { marginTop: 6, color: BRAND.mutedColor }]}>
|
||||||
|
{s.paymentRefHint}
|
||||||
|
</Text>
|
||||||
|
<Text style={[styles.paymentLine, { marginTop: 12, color: BRAND.primaryDark }]}>
|
||||||
|
{s.thankYou}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Footer with page numbers.
|
||||||
|
react-pdf API quirks (verified against build errors):
|
||||||
|
- The `render` callback on <View> only exposes
|
||||||
|
`{ pageNumber, subPageNumber }` — no totalPages.
|
||||||
|
Only <Text> gets `{ pageNumber, totalPages,
|
||||||
|
subPageNumber, subPageTotalPages }`.
|
||||||
|
- <Text>'s render callback must return a STRING
|
||||||
|
(or array of strings), not JSX. */}
|
||||||
|
<View style={styles.footer} fixed>
|
||||||
|
<Text>
|
||||||
|
{BRAND.issuer.legalName} · {BRAND.issuer.web} · {BRAND.issuer.email}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
render={({ pageNumber, totalPages }) =>
|
||||||
|
`${s.page} ${pageNumber} ${s.of} ${totalPages}`
|
||||||
|
}
|
||||||
|
fixed
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</Page>
|
||||||
|
</Document>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Public API
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render an invoice to a PDF buffer. Caller stores the buffer in
|
||||||
|
* `invoices.pdf_data` (bytea). Side-effect-free; can be called
|
||||||
|
* outside a DB transaction.
|
||||||
|
*
|
||||||
|
* Typical runtime is 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} />);
|
||||||
|
}
|
||||||
1862
src/lib/billing.ts
Normal file
1862
src/lib/billing.ts
Normal file
File diff suppressed because it is too large
Load Diff
467
src/lib/credit-note-pdf.tsx
Normal file
467
src/lib/credit-note-pdf.tsx
Normal file
@@ -0,0 +1,467 @@
|
|||||||
|
/**
|
||||||
|
* Credit-note PDF rendering via @react-pdf/renderer.
|
||||||
|
*
|
||||||
|
* Phase 7. Renders the same brand identity as the invoice PDF
|
||||||
|
* (hexagon logo, issuer block, layout) with one accent override:
|
||||||
|
* red instead of emerald. That difference is enough to make voids
|
||||||
|
* and refunds visually unmistakable from an invoice at a glance,
|
||||||
|
* while keeping every other element (logo shape, fonts, structure,
|
||||||
|
* issuer info, page footer) identical so the document family reads
|
||||||
|
* as one brand.
|
||||||
|
*
|
||||||
|
* Brand + Logo come from lib/pdf-brand. Edit there to change
|
||||||
|
* issuer info, colours, or the logo glyph — both invoice and
|
||||||
|
* credit-note PDFs pick the changes up.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import {
|
||||||
|
Document,
|
||||||
|
Page,
|
||||||
|
Text,
|
||||||
|
View,
|
||||||
|
StyleSheet,
|
||||||
|
renderToBuffer,
|
||||||
|
} from "@react-pdf/renderer";
|
||||||
|
import type { CreditNote, Invoice } from "@/types";
|
||||||
|
import { BRAND, Logo } from "./pdf-brand";
|
||||||
|
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Localized strings
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface CreditNoteStrings {
|
||||||
|
creditNote: string;
|
||||||
|
creditNoteNumber: string;
|
||||||
|
issueDate: string;
|
||||||
|
billTo: string;
|
||||||
|
attentionPrefix: string;
|
||||||
|
referenceInvoice: string;
|
||||||
|
reason: string;
|
||||||
|
voidLineLabel: string;
|
||||||
|
refundLineLabel: string;
|
||||||
|
subtotal: string;
|
||||||
|
vatLabel: string;
|
||||||
|
totalCredited: string;
|
||||||
|
footerVoidNote: string;
|
||||||
|
footerRefundNote: string;
|
||||||
|
vatNoteSwiss: string;
|
||||||
|
vatNoteReverseCharge: string;
|
||||||
|
vatNoteOutOfScope: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MESSAGES: Record<string, CreditNoteStrings> = {
|
||||||
|
de: {
|
||||||
|
creditNote: "Gutschrift",
|
||||||
|
creditNoteNumber: "Gutschrift-Nr.",
|
||||||
|
issueDate: "Ausstellungsdatum",
|
||||||
|
billTo: "Empfänger",
|
||||||
|
attentionPrefix: "z.Hd.",
|
||||||
|
referenceInvoice: "Bezug Rechnung",
|
||||||
|
reason: "Begründung",
|
||||||
|
voidLineLabel: "Stornierung Rechnung {number}",
|
||||||
|
refundLineLabel: "Rückerstattung Rechnung {number}",
|
||||||
|
subtotal: "Zwischensumme",
|
||||||
|
vatLabel: "MWST",
|
||||||
|
totalCredited: "Gesamtbetrag Gutschrift",
|
||||||
|
footerVoidNote:
|
||||||
|
"Diese Gutschrift storniert die oben referenzierte Rechnung. Ein Zahlungsausgleich ist nicht erforderlich.",
|
||||||
|
footerRefundNote:
|
||||||
|
"Diese Gutschrift dokumentiert die Rückerstattung des oben genannten Betrags. Die Auszahlung erfolgt über den ursprünglichen Zahlungsweg.",
|
||||||
|
vatNoteSwiss:
|
||||||
|
"MWST gemäss schweizerischem Mehrwertsteuergesetz (MWSTG).",
|
||||||
|
vatNoteReverseCharge:
|
||||||
|
"Reverse Charge: Steuerschuldnerschaft des Leistungsempfängers nach Art. 196 EU-MwStSyst-RL bzw. nationaler Umsetzung.",
|
||||||
|
vatNoteOutOfScope:
|
||||||
|
"Leistung ausserhalb des Geltungsbereichs der schweizerischen MWST.",
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
creditNote: "Credit note",
|
||||||
|
creditNoteNumber: "Credit note no.",
|
||||||
|
issueDate: "Issue date",
|
||||||
|
billTo: "Bill to",
|
||||||
|
attentionPrefix: "Attn:",
|
||||||
|
referenceInvoice: "Reference invoice",
|
||||||
|
reason: "Reason",
|
||||||
|
voidLineLabel: "Void of invoice {number}",
|
||||||
|
refundLineLabel: "Refund for invoice {number}",
|
||||||
|
subtotal: "Subtotal",
|
||||||
|
vatLabel: "VAT",
|
||||||
|
totalCredited: "Total credited",
|
||||||
|
footerVoidNote:
|
||||||
|
"This credit note voids the referenced invoice. No payment is required.",
|
||||||
|
footerRefundNote:
|
||||||
|
"This credit note documents the refund of the amount above. Settlement occurs via the original payment method.",
|
||||||
|
vatNoteSwiss:
|
||||||
|
"VAT charged in accordance with Swiss VAT law (MWSTG).",
|
||||||
|
vatNoteReverseCharge:
|
||||||
|
"Reverse charge: VAT to be accounted for by the recipient per Art. 196 EU VAT Directive or national implementation.",
|
||||||
|
vatNoteOutOfScope:
|
||||||
|
"Service supplied outside the scope of Swiss VAT.",
|
||||||
|
},
|
||||||
|
fr: {
|
||||||
|
creditNote: "Note de crédit",
|
||||||
|
creditNoteNumber: "N° de note de crédit",
|
||||||
|
issueDate: "Date d'émission",
|
||||||
|
billTo: "Destinataire",
|
||||||
|
attentionPrefix: "À l'attention de",
|
||||||
|
referenceInvoice: "Facture de référence",
|
||||||
|
reason: "Motif",
|
||||||
|
voidLineLabel: "Annulation de la facture {number}",
|
||||||
|
refundLineLabel: "Remboursement de la facture {number}",
|
||||||
|
subtotal: "Sous-total",
|
||||||
|
vatLabel: "TVA",
|
||||||
|
totalCredited: "Total du crédit",
|
||||||
|
footerVoidNote:
|
||||||
|
"Cette note de crédit annule la facture référencée ci-dessus. Aucun paiement n'est requis.",
|
||||||
|
footerRefundNote:
|
||||||
|
"Cette note de crédit documente le remboursement du montant ci-dessus. Le règlement s'effectue via le moyen de paiement initial.",
|
||||||
|
vatNoteSwiss:
|
||||||
|
"TVA facturée conformément à la loi suisse sur la TVA (LTVA).",
|
||||||
|
vatNoteReverseCharge:
|
||||||
|
"Autoliquidation : TVA à acquitter par le destinataire selon l'art. 196 de la directive TVA UE ou sa mise en œuvre nationale.",
|
||||||
|
vatNoteOutOfScope:
|
||||||
|
"Prestation hors du champ d'application de la TVA suisse.",
|
||||||
|
},
|
||||||
|
it: {
|
||||||
|
creditNote: "Nota di credito",
|
||||||
|
creditNoteNumber: "N. nota di credito",
|
||||||
|
issueDate: "Data di emissione",
|
||||||
|
billTo: "Destinatario",
|
||||||
|
attentionPrefix: "c.a.",
|
||||||
|
referenceInvoice: "Fattura di riferimento",
|
||||||
|
reason: "Motivo",
|
||||||
|
voidLineLabel: "Annullamento della fattura {number}",
|
||||||
|
refundLineLabel: "Rimborso della fattura {number}",
|
||||||
|
subtotal: "Subtotale",
|
||||||
|
vatLabel: "IVA",
|
||||||
|
totalCredited: "Totale accreditato",
|
||||||
|
footerVoidNote:
|
||||||
|
"Questa nota di credito annulla la fattura sopra indicata. Non è richiesto alcun pagamento.",
|
||||||
|
footerRefundNote:
|
||||||
|
"Questa nota di credito documenta il rimborso dell'importo sopra indicato. Il regolamento avviene tramite il metodo di pagamento originale.",
|
||||||
|
vatNoteSwiss:
|
||||||
|
"IVA addebitata in conformità alla legge svizzera sull'IVA (LIVA).",
|
||||||
|
vatNoteReverseCharge:
|
||||||
|
"Inversione contabile: IVA dovuta dal destinatario ai sensi dell'art. 196 della direttiva IVA UE o della sua attuazione nazionale.",
|
||||||
|
vatNoteOutOfScope:
|
||||||
|
"Prestazione fuori dal campo di applicazione dell'IVA svizzera.",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function pickStrings(locale: string): CreditNoteStrings {
|
||||||
|
return MESSAGES[locale] ?? MESSAGES.de;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Swiss number formatting — matches billing-pdf for consistency
|
||||||
|
function fmtChf(n: number): string {
|
||||||
|
const fixed = n.toFixed(2);
|
||||||
|
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 {
|
||||||
|
const [y, m, d] = iso.split("T")[0].split("-").map(Number);
|
||||||
|
if (locale === "en") {
|
||||||
|
return new Date(Date.UTC(y, m - 1, d)).toLocaleDateString("en-US", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return `${String(d).padStart(2, "0")}.${String(m).padStart(2, "0")}.${y}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickVatNote(
|
||||||
|
invoice: Invoice,
|
||||||
|
strings: CreditNoteStrings
|
||||||
|
): string | null {
|
||||||
|
const country = invoice.billingSnapshot.country?.toUpperCase();
|
||||||
|
const hasVat = invoice.billingSnapshot.vatNumber?.trim();
|
||||||
|
if (country === "CH" || country === "LI") return strings.vatNoteSwiss;
|
||||||
|
if (hasVat) return strings.vatNoteReverseCharge;
|
||||||
|
return strings.vatNoteOutOfScope;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Styles
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
page: {
|
||||||
|
paddingTop: 36,
|
||||||
|
paddingBottom: 50,
|
||||||
|
paddingHorizontal: 50,
|
||||||
|
fontSize: 10,
|
||||||
|
fontFamily: "Helvetica",
|
||||||
|
color: BRAND.textColor,
|
||||||
|
},
|
||||||
|
headerRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
marginBottom: 32,
|
||||||
|
},
|
||||||
|
logoBlock: { flexDirection: "row", alignItems: "center" },
|
||||||
|
brandName: {
|
||||||
|
fontSize: 16,
|
||||||
|
color: BRAND.primaryDark,
|
||||||
|
marginLeft: 8,
|
||||||
|
fontFamily: "Helvetica-Bold",
|
||||||
|
},
|
||||||
|
issuerBlock: { textAlign: "right", fontSize: 8.5, color: BRAND.mutedColor },
|
||||||
|
issuerName: { fontSize: 11, color: BRAND.primaryDark, marginBottom: 2 },
|
||||||
|
docTitle: {
|
||||||
|
fontSize: 22,
|
||||||
|
color: BRAND.primaryDark,
|
||||||
|
marginBottom: 8,
|
||||||
|
fontFamily: "Helvetica-Bold",
|
||||||
|
},
|
||||||
|
metaTable: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
marginBottom: 20,
|
||||||
|
},
|
||||||
|
metaCol: { flexDirection: "column", minWidth: 140 },
|
||||||
|
metaLabel: { fontSize: 8, color: BRAND.mutedColor, marginBottom: 2 },
|
||||||
|
metaValue: { fontSize: 10 },
|
||||||
|
billTo: {
|
||||||
|
marginBottom: 24,
|
||||||
|
padding: 8,
|
||||||
|
backgroundColor: "#f7f7f5",
|
||||||
|
borderLeftWidth: 3,
|
||||||
|
borderLeftColor: BRAND.primary,
|
||||||
|
},
|
||||||
|
billToLabel: { fontSize: 8, color: BRAND.mutedColor, marginBottom: 4 },
|
||||||
|
billToName: { fontSize: 11, marginBottom: 2 },
|
||||||
|
amountTable: {
|
||||||
|
borderTopWidth: 1,
|
||||||
|
borderTopColor: BRAND.borderColor,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: BRAND.borderColor,
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
amountHeader: {
|
||||||
|
flexDirection: "row",
|
||||||
|
backgroundColor: BRAND.primaryDark,
|
||||||
|
color: "#ffffff",
|
||||||
|
paddingVertical: 5,
|
||||||
|
paddingHorizontal: 6,
|
||||||
|
fontSize: 9,
|
||||||
|
fontFamily: "Helvetica-Bold",
|
||||||
|
},
|
||||||
|
amountRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
paddingVertical: 8,
|
||||||
|
paddingHorizontal: 6,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: "#f0f0f0",
|
||||||
|
},
|
||||||
|
amountDesc: { flex: 1 },
|
||||||
|
amountValue: { width: 90, textAlign: "right" },
|
||||||
|
totals: { marginLeft: "auto", width: 220, marginBottom: 20 },
|
||||||
|
totalsRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
paddingVertical: 3,
|
||||||
|
},
|
||||||
|
totalsLabel: { color: BRAND.mutedColor, fontSize: 10 },
|
||||||
|
totalsValue: { fontSize: 10 },
|
||||||
|
totalsGrand: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
borderTopWidth: 1,
|
||||||
|
borderTopColor: BRAND.primaryDark,
|
||||||
|
paddingTop: 6,
|
||||||
|
marginTop: 4,
|
||||||
|
},
|
||||||
|
totalsGrandLabel: {
|
||||||
|
color: BRAND.primaryDark,
|
||||||
|
fontSize: 11,
|
||||||
|
fontFamily: "Helvetica-Bold",
|
||||||
|
},
|
||||||
|
totalsGrandValue: {
|
||||||
|
color: BRAND.primaryDark,
|
||||||
|
fontSize: 11,
|
||||||
|
textAlign: "right",
|
||||||
|
fontFamily: "Helvetica-Bold",
|
||||||
|
},
|
||||||
|
reasonBox: {
|
||||||
|
marginTop: 4,
|
||||||
|
marginBottom: 18,
|
||||||
|
padding: 8,
|
||||||
|
backgroundColor: "#fafafa",
|
||||||
|
borderLeftWidth: 2,
|
||||||
|
borderLeftColor: BRAND.borderColor,
|
||||||
|
},
|
||||||
|
reasonLabel: {
|
||||||
|
fontSize: 8,
|
||||||
|
color: BRAND.mutedColor,
|
||||||
|
marginBottom: 2,
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
},
|
||||||
|
reasonText: { fontSize: 9.5, color: BRAND.textColor },
|
||||||
|
noteBox: {
|
||||||
|
marginTop: 12,
|
||||||
|
padding: 8,
|
||||||
|
fontSize: 8.5,
|
||||||
|
color: BRAND.mutedColor,
|
||||||
|
lineHeight: 1.5,
|
||||||
|
},
|
||||||
|
footer: {
|
||||||
|
position: "absolute",
|
||||||
|
bottom: 24,
|
||||||
|
left: 50,
|
||||||
|
right: 50,
|
||||||
|
fontSize: 7.5,
|
||||||
|
color: BRAND.mutedColor,
|
||||||
|
textAlign: "center",
|
||||||
|
borderTopWidth: 0.5,
|
||||||
|
borderTopColor: BRAND.borderColor,
|
||||||
|
paddingTop: 6,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
interface CreditNotePdfProps {
|
||||||
|
creditNote: CreditNote;
|
||||||
|
invoice: Invoice;
|
||||||
|
}
|
||||||
|
|
||||||
|
function CreditNotePdfDocument({ creditNote, invoice }: CreditNotePdfProps) {
|
||||||
|
const strings = pickStrings(creditNote.locale);
|
||||||
|
const snap = creditNote.billingSnapshot;
|
||||||
|
const vatNote = pickVatNote(invoice, strings);
|
||||||
|
const amountLabelTemplate =
|
||||||
|
creditNote.kind === "void" ? strings.voidLineLabel : strings.refundLineLabel;
|
||||||
|
const amountLabel = amountLabelTemplate.replace(
|
||||||
|
"{number}",
|
||||||
|
invoice.invoiceNumber
|
||||||
|
);
|
||||||
|
const footerNote =
|
||||||
|
creditNote.kind === "void" ? strings.footerVoidNote : strings.footerRefundNote;
|
||||||
|
// Stored convention: amount_chf is gross (incl. VAT),
|
||||||
|
// vat_amount_chf is the VAT portion. Subtotal computed for
|
||||||
|
// display.
|
||||||
|
const subtotal = creditNote.amountChf - creditNote.vatAmountChf;
|
||||||
|
return (
|
||||||
|
<Document>
|
||||||
|
<Page size="A4" style={styles.page}>
|
||||||
|
{/* Header — SAME hexagon logo as the invoice, tinted red.
|
||||||
|
Issuer block from BRAND.issuer (shared with invoice). */}
|
||||||
|
<View style={styles.headerRow}>
|
||||||
|
<View style={styles.logoBlock}>
|
||||||
|
<Logo size={42} color={BRAND.primary} />
|
||||||
|
<Text style={styles.brandName}>{BRAND.name}</Text>
|
||||||
|
</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>
|
||||||
|
<Text>{BRAND.issuer.web}</Text>
|
||||||
|
{BRAND.issuer.vatNumber && (
|
||||||
|
<Text>MWST-Nr. {BRAND.issuer.vatNumber}</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text style={styles.docTitle}>{strings.creditNote}</Text>
|
||||||
|
|
||||||
|
<View style={styles.metaTable}>
|
||||||
|
<View style={styles.metaCol}>
|
||||||
|
<Text style={styles.metaLabel}>{strings.creditNoteNumber}</Text>
|
||||||
|
<Text style={styles.metaValue}>{creditNote.creditNoteNumber}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.metaCol}>
|
||||||
|
<Text style={styles.metaLabel}>{strings.issueDate}</Text>
|
||||||
|
<Text style={styles.metaValue}>
|
||||||
|
{fmtDate(creditNote.issuedAt, creditNote.locale)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.metaCol}>
|
||||||
|
<Text style={styles.metaLabel}>{strings.referenceInvoice}</Text>
|
||||||
|
<Text style={styles.metaValue}>{invoice.invoiceNumber}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.billTo}>
|
||||||
|
<Text style={styles.billToLabel}>{strings.billTo}</Text>
|
||||||
|
<Text style={styles.billToName}>{snap.companyName}</Text>
|
||||||
|
{snap.contactName && snap.contactName.trim().length > 0 && (
|
||||||
|
<Text>
|
||||||
|
{strings.attentionPrefix} {snap.contactName}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<Text>{snap.streetAddress}</Text>
|
||||||
|
<Text>
|
||||||
|
{snap.postalCode} {snap.city}
|
||||||
|
</Text>
|
||||||
|
<Text>{snap.country}</Text>
|
||||||
|
{snap.vatNumber && <Text>MWST/VAT: {snap.vatNumber}</Text>}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.amountTable}>
|
||||||
|
<View style={styles.amountHeader}>
|
||||||
|
<Text style={styles.amountDesc}> </Text>
|
||||||
|
<Text style={styles.amountValue}>CHF</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.amountRow}>
|
||||||
|
<Text style={styles.amountDesc}>{amountLabel}</Text>
|
||||||
|
<Text style={styles.amountValue}>{fmtChf(subtotal)}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.totals}>
|
||||||
|
<View style={styles.totalsRow}>
|
||||||
|
<Text style={styles.totalsLabel}>{strings.subtotal}</Text>
|
||||||
|
<Text style={styles.totalsValue}>CHF {fmtChf(subtotal)}</Text>
|
||||||
|
</View>
|
||||||
|
{creditNote.vatAmountChf > 0 && (
|
||||||
|
<View style={styles.totalsRow}>
|
||||||
|
<Text style={styles.totalsLabel}>
|
||||||
|
{strings.vatLabel} ({Number(invoice.vatRate).toFixed(1)}%)
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.totalsValue}>
|
||||||
|
CHF {fmtChf(creditNote.vatAmountChf)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
<View style={styles.totalsGrand}>
|
||||||
|
<Text style={styles.totalsGrandLabel}>{strings.totalCredited}</Text>
|
||||||
|
<Text style={styles.totalsGrandValue}>
|
||||||
|
CHF {fmtChf(creditNote.amountChf)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{creditNote.reason && creditNote.reason.trim().length > 0 && (
|
||||||
|
<View style={styles.reasonBox}>
|
||||||
|
<Text style={styles.reasonLabel}>{strings.reason}</Text>
|
||||||
|
<Text style={styles.reasonText}>{creditNote.reason}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<View style={styles.noteBox}>
|
||||||
|
<Text>{footerNote}</Text>
|
||||||
|
{vatNote && <Text style={{ marginTop: 6 }}>{vatNote}</Text>}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text style={styles.footer} fixed>
|
||||||
|
{BRAND.issuer.legalName} · {creditNote.creditNoteNumber}
|
||||||
|
</Text>
|
||||||
|
</Page>
|
||||||
|
</Document>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function renderCreditNotePdf(
|
||||||
|
creditNote: CreditNote,
|
||||||
|
invoice: Invoice
|
||||||
|
): Promise<Buffer> {
|
||||||
|
const doc = <CreditNotePdfDocument creditNote={creditNote} invoice={invoice} />;
|
||||||
|
return renderToBuffer(doc) as unknown as Buffer;
|
||||||
|
}
|
||||||
360
src/lib/cron.ts
Normal file
360
src/lib/cron.ts
Normal file
@@ -0,0 +1,360 @@
|
|||||||
|
/**
|
||||||
|
* Phase 5 — Automated billing cron logic.
|
||||||
|
*
|
||||||
|
* This module hosts the two sweeps:
|
||||||
|
* - runMonthlyIssuance() — invoked monthly to generate invoices
|
||||||
|
* for orgs opted into auto-issuance. Idempotent via the
|
||||||
|
* uniq_invoices_org_period constraint on invoices: a re-run
|
||||||
|
* for an org that's already been billed for the target period
|
||||||
|
* gets caught as a duplicate and counted as a skip, not a
|
||||||
|
* failure.
|
||||||
|
* - runReminderSweep() — invoked daily. Walks open/overdue
|
||||||
|
* invoices, sends the appropriate reminder level (1/2/3) once
|
||||||
|
* per invoice via the invoice_reminders unique-key constraint.
|
||||||
|
*
|
||||||
|
* Both entry points return a summary {success, failure, skipped}
|
||||||
|
* that the caller persists via finishCronRun(). The shared
|
||||||
|
* structure means the HTTP routes (machine + admin variants) are
|
||||||
|
* trivial wrappers.
|
||||||
|
*
|
||||||
|
* Time-of-month math is timezone-aware: we read the calendar in
|
||||||
|
* Europe/Zurich rather than UTC, because the K8s CronJob schedules
|
||||||
|
* at 00:30 local time on the 1st — UTC at that moment is still in
|
||||||
|
* the previous month, and a naive `getUTCMonth() - 1` would bill
|
||||||
|
* the wrong period.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
finishCronRun,
|
||||||
|
getLastSuccessfulCronRuns,
|
||||||
|
getOrgBilling,
|
||||||
|
getReminderLevelsSent,
|
||||||
|
listAutoIssueOrgIds,
|
||||||
|
listInvoicesPendingReminders,
|
||||||
|
recordReminderSent,
|
||||||
|
startCronRun,
|
||||||
|
syncOverdueInvoices,
|
||||||
|
} from "./db";
|
||||||
|
import { generateInvoice } from "./billing";
|
||||||
|
import { sendInvoiceReminderEmail } from "./email";
|
||||||
|
|
||||||
|
// The org_billing snapshot's company_name field doubles as the
|
||||||
|
// recipient name when no separate "billing contact" exists in
|
||||||
|
// our schema. Same convention as Phase 3's issuance email.
|
||||||
|
|
||||||
|
// All cron timing assumes Switzerland's calendar — the operator,
|
||||||
|
// the customers, and the legal basis (Swiss MWST) are all here.
|
||||||
|
const TZ = "Europe/Zurich";
|
||||||
|
|
||||||
|
export type CronSummary = {
|
||||||
|
successCount: number;
|
||||||
|
failureCount: number;
|
||||||
|
skippedCount: number;
|
||||||
|
errorDetails: Array<{
|
||||||
|
orgId?: string;
|
||||||
|
invoiceId?: string;
|
||||||
|
reason: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Monthly issuance
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The (year, month) of the calendar month that ended JUST BEFORE
|
||||||
|
* `now` in the configured timezone. This is what the issuance
|
||||||
|
* sweep bills.
|
||||||
|
*
|
||||||
|
* Reading the local-time calendar avoids a UTC-vs-local off-by-one
|
||||||
|
* when the sweep runs at 00:30 Zurich and UTC is still in the
|
||||||
|
* previous month.
|
||||||
|
*/
|
||||||
|
export function previousLocalMonth(
|
||||||
|
now: Date = new Date()
|
||||||
|
): { year: number; month: number } {
|
||||||
|
const fmt = new Intl.DateTimeFormat("en-CA", {
|
||||||
|
timeZone: TZ,
|
||||||
|
year: "numeric",
|
||||||
|
month: "2-digit",
|
||||||
|
});
|
||||||
|
const parts = fmt.formatToParts(now);
|
||||||
|
const year = Number(parts.find((p) => p.type === "year")!.value);
|
||||||
|
const month = Number(parts.find((p) => p.type === "month")!.value);
|
||||||
|
if (month === 1) return { year: year - 1, month: 12 };
|
||||||
|
return { year, month: month - 1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runMonthlyIssuance(opts: {
|
||||||
|
triggeredBy: string;
|
||||||
|
/** Override target year/month — defaults to previous local month. */
|
||||||
|
year?: number;
|
||||||
|
month?: number;
|
||||||
|
}): Promise<{ runId: string; summary: CronSummary }> {
|
||||||
|
const target =
|
||||||
|
opts.year && opts.month
|
||||||
|
? { year: opts.year, month: opts.month }
|
||||||
|
: previousLocalMonth();
|
||||||
|
const runId = await startCronRun("monthly_issue", opts.triggeredBy);
|
||||||
|
const summary: CronSummary = {
|
||||||
|
successCount: 0,
|
||||||
|
failureCount: 0,
|
||||||
|
skippedCount: 0,
|
||||||
|
errorDetails: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const orgIds = await listAutoIssueOrgIds();
|
||||||
|
for (const orgId of orgIds) {
|
||||||
|
try {
|
||||||
|
const orgBilling = await getOrgBilling(orgId);
|
||||||
|
if (!orgBilling) {
|
||||||
|
// Auto-issue is enabled but billing details are missing.
|
||||||
|
// Skip rather than fail — the admin needs to complete the
|
||||||
|
// address before invoicing can succeed.
|
||||||
|
summary.skippedCount += 1;
|
||||||
|
summary.errorDetails.push({
|
||||||
|
orgId,
|
||||||
|
reason: "org_billing not configured",
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Derive invoice locale from the org's country. PieCed is
|
||||||
|
// Swiss-default; CH/LI/AT/DE customers get the German PDF,
|
||||||
|
// FR/BE/LU customers get French, IT customers get Italian,
|
||||||
|
// anything else falls through to English. Customers needing
|
||||||
|
// a different locale can still trigger a manual issuance
|
||||||
|
// with an explicit override from the admin UI.
|
||||||
|
const locale = pickLocaleForCountry(orgBilling.country);
|
||||||
|
const { invoice } = await generateInvoice({
|
||||||
|
zitadelOrgId: orgId,
|
||||||
|
year: target.year,
|
||||||
|
month: target.month,
|
||||||
|
locale,
|
||||||
|
});
|
||||||
|
if (invoice) {
|
||||||
|
summary.successCount += 1;
|
||||||
|
} else {
|
||||||
|
// dryRun path — shouldn't happen in production. Defensive.
|
||||||
|
summary.skippedCount += 1;
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
// The uniqueness constraint on (zitadel_org_id, period_start)
|
||||||
|
// surfaces as "An invoice already exists for this org and
|
||||||
|
// billing period" from createInvoice. Re-running the cron
|
||||||
|
// mid-month or after a partial completion is therefore safe:
|
||||||
|
// already-billed orgs end up as skipped, not failed.
|
||||||
|
const msg = String(e?.message ?? e);
|
||||||
|
const isAlreadyIssued = /already exists for this org and billing period/i.test(
|
||||||
|
msg
|
||||||
|
);
|
||||||
|
if (isAlreadyIssued) {
|
||||||
|
summary.skippedCount += 1;
|
||||||
|
} else {
|
||||||
|
summary.failureCount += 1;
|
||||||
|
summary.errorDetails.push({ orgId, reason: msg });
|
||||||
|
console.error(
|
||||||
|
`runMonthlyIssuance: org ${orgId} failed:`,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await finishCronRun(runId, summary);
|
||||||
|
return { runId, summary };
|
||||||
|
} catch (e) {
|
||||||
|
// Catastrophic — the sweep itself failed (DB down, etc).
|
||||||
|
summary.failureCount += 1;
|
||||||
|
summary.errorDetails.push({
|
||||||
|
reason: `sweep aborted: ${e instanceof Error ? e.message : String(e)}`,
|
||||||
|
});
|
||||||
|
await finishCronRun(runId, summary).catch(() => undefined);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Reminder sweep
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which reminder level (if any) is due now for this invoice?
|
||||||
|
*
|
||||||
|
* Logic:
|
||||||
|
* - days_past_due >= 30 AND level 3 not yet sent → 3 (final)
|
||||||
|
* - else days_past_due >= 14 AND level 2 not yet sent → 2
|
||||||
|
* - else days_past_due >= 7 AND level 1 not yet sent → 1
|
||||||
|
* - else → null (nothing to do this run)
|
||||||
|
*
|
||||||
|
* One reminder per cron run per invoice — highest applicable
|
||||||
|
* un-sent level wins. If a customer fell behind quickly and is
|
||||||
|
* already 35 days past due without ever having received levels
|
||||||
|
* 1 or 2 (e.g. the cron was broken for a while), they get level
|
||||||
|
* 3 directly. We don't backfill lower levels.
|
||||||
|
*/
|
||||||
|
function nextReminderLevel(
|
||||||
|
daysPastDue: number,
|
||||||
|
sent: Set<number>
|
||||||
|
): 1 | 2 | 3 | null {
|
||||||
|
if (daysPastDue >= 30 && !sent.has(3)) return 3;
|
||||||
|
if (daysPastDue >= 14 && !sent.has(2)) return 2;
|
||||||
|
if (daysPastDue >= 7 && !sent.has(1)) return 1;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function daysBetween(later: Date, earlier: Date): number {
|
||||||
|
const ms = later.getTime() - earlier.getTime();
|
||||||
|
return Math.floor(ms / (1000 * 60 * 60 * 24));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pick a default invoice locale based on the org's country
|
||||||
|
* (ISO 3166-1 alpha-2 code from org_billing.country). PieCed is
|
||||||
|
* primarily a Swiss-German operator; CH/LI/AT/DE get German,
|
||||||
|
* FR/BE/LU get French, IT gets Italian, anything else falls
|
||||||
|
* through to English.
|
||||||
|
*
|
||||||
|
* This only drives the automated issuance default. Manual
|
||||||
|
* issuance from the admin UI takes an explicit override.
|
||||||
|
*/
|
||||||
|
function pickLocaleForCountry(country: string): "de" | "en" | "fr" | "it" {
|
||||||
|
const c = country.toUpperCase();
|
||||||
|
if (["CH", "LI", "AT", "DE"].includes(c)) return "de";
|
||||||
|
if (["FR", "BE", "LU"].includes(c)) return "fr";
|
||||||
|
if (c === "IT") return "it";
|
||||||
|
return "en";
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runReminderSweep(opts: {
|
||||||
|
triggeredBy: string;
|
||||||
|
}): Promise<{ runId: string; summary: CronSummary }> {
|
||||||
|
const runId = await startCronRun("reminders", opts.triggeredBy);
|
||||||
|
const summary: CronSummary = {
|
||||||
|
successCount: 0,
|
||||||
|
failureCount: 0,
|
||||||
|
skippedCount: 0,
|
||||||
|
errorDetails: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Flip stale 'open' → 'overdue' first so the listing reflects
|
||||||
|
// current status, and audit trails stay accurate.
|
||||||
|
await syncOverdueInvoices().catch((e) => {
|
||||||
|
console.warn("syncOverdueInvoices failed during reminder sweep:", e);
|
||||||
|
});
|
||||||
|
|
||||||
|
const candidates = await listInvoicesPendingReminders();
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
for (const inv of candidates) {
|
||||||
|
try {
|
||||||
|
const sent = await getReminderLevelsSent(inv.id);
|
||||||
|
const dueAt = new Date(inv.dueAt);
|
||||||
|
const days = daysBetween(now, dueAt);
|
||||||
|
const level = nextReminderLevel(days, sent);
|
||||||
|
if (level === null) {
|
||||||
|
summary.skippedCount += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const billing = inv.billingSnapshot;
|
||||||
|
if (!billing.billingEmail) {
|
||||||
|
summary.skippedCount += 1;
|
||||||
|
summary.errorDetails.push({
|
||||||
|
invoiceId: inv.id,
|
||||||
|
reason: "no billing email on snapshot",
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const supportedLocales: Array<"de" | "en" | "fr" | "it"> = [
|
||||||
|
"de", "en", "fr", "it",
|
||||||
|
];
|
||||||
|
const locale = supportedLocales.includes(inv.locale as any)
|
||||||
|
? (inv.locale as "de" | "en" | "fr" | "it")
|
||||||
|
: "de";
|
||||||
|
|
||||||
|
await sendInvoiceReminderEmail({
|
||||||
|
to: billing.billingEmail,
|
||||||
|
contactName: billing.companyName,
|
||||||
|
companyName: billing.companyName,
|
||||||
|
invoiceNumber: inv.invoiceNumber,
|
||||||
|
totalChf: inv.totalChf,
|
||||||
|
currency: "CHF",
|
||||||
|
dueAt: inv.dueAt,
|
||||||
|
daysPastDue: days,
|
||||||
|
level,
|
||||||
|
locale,
|
||||||
|
});
|
||||||
|
// Record AFTER the send. If the SMTP send fails the email
|
||||||
|
// helper logs and doesn't throw, so we'd still record — but
|
||||||
|
// that's a tradeoff we accept: at-least-once delivery semantics
|
||||||
|
// with logged warnings is better than at-most-once where a
|
||||||
|
// transient failure stops the customer from ever getting
|
||||||
|
// reminded. If duplicate-reminder fatigue becomes a real
|
||||||
|
// problem in production, switch to: send first, only record
|
||||||
|
// on confirmed transporter success.
|
||||||
|
await recordReminderSent({
|
||||||
|
invoiceId: inv.id,
|
||||||
|
level,
|
||||||
|
sentBy: opts.triggeredBy,
|
||||||
|
emailSentTo: billing.billingEmail,
|
||||||
|
});
|
||||||
|
summary.successCount += 1;
|
||||||
|
} catch (e: any) {
|
||||||
|
summary.failureCount += 1;
|
||||||
|
summary.errorDetails.push({
|
||||||
|
invoiceId: inv.id,
|
||||||
|
reason: String(e?.message ?? e),
|
||||||
|
});
|
||||||
|
console.error(
|
||||||
|
`runReminderSweep: invoice ${inv.id} failed:`,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await finishCronRun(runId, summary);
|
||||||
|
return { runId, summary };
|
||||||
|
} catch (e) {
|
||||||
|
summary.failureCount += 1;
|
||||||
|
summary.errorDetails.push({
|
||||||
|
reason: `sweep aborted: ${e instanceof Error ? e.message : String(e)}`,
|
||||||
|
});
|
||||||
|
await finishCronRun(runId, summary).catch(() => undefined);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Auth — bearer token for the machine endpoints
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constant-time bearer token check. The CRON_BEARER_TOKEN env var
|
||||||
|
* is injected from OpenBao via the portal-cron K8s Secret. Both
|
||||||
|
* the CronJob and the portal Deployment reference it; the
|
||||||
|
* CronJob sends it in the Authorization header, the portal checks
|
||||||
|
* with timing-safe equals to defeat character-by-character probing.
|
||||||
|
*/
|
||||||
|
export function verifyCronBearer(authHeader: string | null): boolean {
|
||||||
|
if (!authHeader) return false;
|
||||||
|
const expected = process.env.CRON_BEARER_TOKEN;
|
||||||
|
if (!expected || expected.length < 16) {
|
||||||
|
// Treat misconfiguration as a hard refusal so a missing/
|
||||||
|
// accidentally-empty token doesn't silently grant access.
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!authHeader.startsWith("Bearer ")) return false;
|
||||||
|
const got = authHeader.slice("Bearer ".length).trim();
|
||||||
|
if (got.length !== expected.length) return false;
|
||||||
|
// Constant-time byte compare. Node's Buffer.compare and the
|
||||||
|
// crypto.timingSafeEqual function both work, but the latter
|
||||||
|
// throws on length mismatch; the length pre-check above
|
||||||
|
// protects against that.
|
||||||
|
let diff = 0;
|
||||||
|
for (let i = 0; i < got.length; i++) {
|
||||||
|
diff |= got.charCodeAt(i) ^ expected.charCodeAt(i);
|
||||||
|
}
|
||||||
|
return diff === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-export for the admin UI to render "last run X ago" indicators.
|
||||||
|
export { getLastSuccessfulCronRuns };
|
||||||
2202
src/lib/db.ts
2202
src/lib/db.ts
File diff suppressed because it is too large
Load Diff
737
src/lib/email.ts
737
src/lib/email.ts
@@ -723,3 +723,740 @@ export async function sendSupportAdminNotificationEmail(params: {
|
|||||||
console.error("Failed to send admin support notification:", err);
|
console.error("Failed to send admin support notification:", err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Skill activation requests — Phase 2.5
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
//
|
||||||
|
// Three notifications:
|
||||||
|
//
|
||||||
|
// sendSkillActivationAdminNotification — to ADMIN_NOTIFICATION_EMAIL
|
||||||
|
// when a customer requests a
|
||||||
|
// flagged skill.
|
||||||
|
//
|
||||||
|
// sendSkillActivationApprovalEmail — to the customer, on approve.
|
||||||
|
//
|
||||||
|
// sendSkillActivationRejectionEmail — to the customer, on reject,
|
||||||
|
// including the admin's reason.
|
||||||
|
//
|
||||||
|
// All three follow the existing patterns in this file (HTML + plaintext,
|
||||||
|
// escaped vars, best-effort with errors logged not thrown).
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notify admin (ADMIN_NOTIFICATION_EMAIL) that a customer has
|
||||||
|
* requested activation of a manual-setup skill. The skill name +
|
||||||
|
* tenant + requester are all included so admin can act without
|
||||||
|
* loading the portal.
|
||||||
|
*/
|
||||||
|
export async function sendSkillActivationAdminNotification(params: {
|
||||||
|
tenantName: string;
|
||||||
|
skillId: string;
|
||||||
|
skillName: string;
|
||||||
|
requesterEmail: string;
|
||||||
|
requesterName: string;
|
||||||
|
companyName: string | null;
|
||||||
|
}): Promise<void> {
|
||||||
|
const adminEmail = process.env.ADMIN_NOTIFICATION_EMAIL;
|
||||||
|
if (!adminEmail) return;
|
||||||
|
const safeTenant = escapeHtml(params.tenantName);
|
||||||
|
const safeSkillId = escapeHtml(params.skillId);
|
||||||
|
const safeSkillName = escapeHtml(params.skillName);
|
||||||
|
const safeRequester = escapeHtml(params.requesterName);
|
||||||
|
const safeRequesterEmail = escapeHtml(params.requesterEmail);
|
||||||
|
const safeCompany = params.companyName
|
||||||
|
? escapeHtml(params.companyName)
|
||||||
|
: "—";
|
||||||
|
try {
|
||||||
|
await getTransporter().sendMail({
|
||||||
|
from: getFrom(),
|
||||||
|
to: adminEmail,
|
||||||
|
subject: `[PieCed] Skill activation requested — ${params.skillName} on ${params.tenantName}`,
|
||||||
|
text: [
|
||||||
|
"A customer has requested activation of a manual-setup skill.",
|
||||||
|
"",
|
||||||
|
`Skill: ${params.skillName} (${params.skillId})`,
|
||||||
|
`Tenant: ${params.tenantName}`,
|
||||||
|
`Organization:${params.companyName ?? "—"}`,
|
||||||
|
`Requested by:${params.requesterName} <${params.requesterEmail}>`,
|
||||||
|
"",
|
||||||
|
"Review and act in the admin queue:",
|
||||||
|
"https://app.pieced.ch/admin/skills/pending",
|
||||||
|
].join("\n"),
|
||||||
|
html: `
|
||||||
|
<div style="font-family: -apple-system, BlinkMacSystemFont, sans-serif; max-width: 560px; padding: 24px; background: #1a1a1a; color: #e5e5e5;">
|
||||||
|
<h2 style="margin: 0 0 16px; color: #10B981;">Skill activation requested</h2>
|
||||||
|
<p>A customer has requested activation of a manual-setup skill.</p>
|
||||||
|
<table style="width:100%; border-collapse: collapse; margin: 12px 0;">
|
||||||
|
<tr><td style="color:#888; padding:4px 0;">Skill</td><td>${safeSkillName} (<code>${safeSkillId}</code>)</td></tr>
|
||||||
|
<tr><td style="color:#888; padding:4px 0;">Tenant</td><td><code>${safeTenant}</code></td></tr>
|
||||||
|
<tr><td style="color:#888; padding:4px 0;">Organization</td><td>${safeCompany}</td></tr>
|
||||||
|
<tr><td style="color:#888; padding:4px 0;">Requested by</td><td>${safeRequester} <${safeRequesterEmail}></td></tr>
|
||||||
|
</table>
|
||||||
|
<p>
|
||||||
|
<a href="https://app.pieced.ch/admin/skills/pending" style="display:inline-block; padding:10px 24px; background:#10B981; color:#fff; text-decoration:none; border-radius:8px; font-weight:500;">
|
||||||
|
Open admin queue
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
<hr style="border:none; border-top:1px solid #333; margin:24px 0;" />
|
||||||
|
<p style="color:#666; font-size:12px;">PieCed IT — Admin notification</p>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to send skill activation admin notification:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendSkillActivationApprovalEmail(params: {
|
||||||
|
to: string;
|
||||||
|
contactName: string;
|
||||||
|
skillName: string;
|
||||||
|
tenantName: string;
|
||||||
|
}): Promise<void> {
|
||||||
|
const safeName = escapeHtml(params.contactName);
|
||||||
|
const safeSkill = escapeHtml(params.skillName);
|
||||||
|
const safeTenant = escapeHtml(params.tenantName);
|
||||||
|
try {
|
||||||
|
await getTransporter().sendMail({
|
||||||
|
from: getFrom(),
|
||||||
|
to: params.to,
|
||||||
|
subject: `Your skill activation has been approved — ${params.skillName}`,
|
||||||
|
text: [
|
||||||
|
`Hello ${params.contactName},`,
|
||||||
|
"",
|
||||||
|
`Good news — your request to activate "${params.skillName}" on tenant ${params.tenantName} has been approved and the skill is now live.`,
|
||||||
|
"",
|
||||||
|
"You can manage it from your tenant settings.",
|
||||||
|
"",
|
||||||
|
"Best regards,",
|
||||||
|
"PieCed IT",
|
||||||
|
].join("\n"),
|
||||||
|
html: `
|
||||||
|
<div style="font-family: -apple-system, BlinkMacSystemFont, sans-serif; max-width:560px; padding:24px; background:#1a1a1a; color:#e5e5e5;">
|
||||||
|
<h2 style="margin:0 0 16px; color:#10B981;">Skill approved & activated</h2>
|
||||||
|
<p>Hello ${safeName},</p>
|
||||||
|
<p>Your request to activate <strong>${safeSkill}</strong> on tenant <code>${safeTenant}</code> has been approved and the skill is now live.</p>
|
||||||
|
<p>You can manage it from your tenant settings.</p>
|
||||||
|
<p>
|
||||||
|
<a href="https://app.pieced.ch/tenants/${encodeURIComponent(params.tenantName)}" style="display:inline-block; padding:10px 24px; background:#10B981; color:#fff; text-decoration:none; border-radius:8px; font-weight:500;">
|
||||||
|
Open tenant
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
<hr style="border:none; border-top:1px solid #333; margin:24px 0;" />
|
||||||
|
<p style="color:#666; font-size:12px;">PieCed IT</p>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to send skill activation approval email:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendSkillActivationRejectionEmail(params: {
|
||||||
|
to: string;
|
||||||
|
contactName: string;
|
||||||
|
skillName: string;
|
||||||
|
tenantName: string;
|
||||||
|
reason: string;
|
||||||
|
}): Promise<void> {
|
||||||
|
const safeName = escapeHtml(params.contactName);
|
||||||
|
const safeSkill = escapeHtml(params.skillName);
|
||||||
|
const safeTenant = escapeHtml(params.tenantName);
|
||||||
|
const safeReason = escapeHtml(params.reason);
|
||||||
|
try {
|
||||||
|
await getTransporter().sendMail({
|
||||||
|
from: getFrom(),
|
||||||
|
to: params.to,
|
||||||
|
subject: `Update on your skill activation request — ${params.skillName}`,
|
||||||
|
text: [
|
||||||
|
`Hello ${params.contactName},`,
|
||||||
|
"",
|
||||||
|
`We were unable to approve your request to activate "${params.skillName}" on tenant ${params.tenantName}.`,
|
||||||
|
"",
|
||||||
|
"Reason from our team:",
|
||||||
|
params.reason,
|
||||||
|
"",
|
||||||
|
"You can try again from your tenant settings once the matter is resolved.",
|
||||||
|
"",
|
||||||
|
"Best regards,",
|
||||||
|
"PieCed IT",
|
||||||
|
].join("\n"),
|
||||||
|
html: `
|
||||||
|
<div style="font-family: -apple-system, BlinkMacSystemFont, sans-serif; max-width:560px; padding:24px; background:#1a1a1a; color:#e5e5e5;">
|
||||||
|
<h2 style="margin:0 0 16px; color:#ef4444;">Activation request not approved</h2>
|
||||||
|
<p>Hello ${safeName},</p>
|
||||||
|
<p>We were unable to approve your request to activate <strong>${safeSkill}</strong> on tenant <code>${safeTenant}</code>.</p>
|
||||||
|
<div style="background:#2a2a2a; border-left:3px solid #ef4444; padding:12px 16px; border-radius:6px; margin:16px 0;">
|
||||||
|
<p style="color:#ccc; font-size:13px; margin:0;"><strong>Reason from our team:</strong></p>
|
||||||
|
<p style="color:#aaa; font-size:13px; margin:8px 0 0 0; white-space:pre-wrap;">${safeReason}</p>
|
||||||
|
</div>
|
||||||
|
<p>You can try again from your tenant settings once the matter is resolved.</p>
|
||||||
|
<hr style="border:none; border-top:1px solid #333; margin:24px 0;" />
|
||||||
|
<p style="color:#666; font-size:12px;">PieCed IT</p>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to send skill activation rejection email:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Invoice issuance — Phase 3
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notify the billing contact when a new invoice has been issued.
|
||||||
|
* Includes a brief summary (total + due date + line count) so the
|
||||||
|
* recipient can triage without opening the portal, plus a deep
|
||||||
|
* link to /billing/<invoice number> where they can download the
|
||||||
|
* PDF. The PDF itself is NOT attached — it lives in the portal,
|
||||||
|
* keeps mail payloads small, and avoids the audit-trail headache
|
||||||
|
* of "which copy is authoritative".
|
||||||
|
*/
|
||||||
|
export async function sendInvoiceIssuedEmail(params: {
|
||||||
|
to: string;
|
||||||
|
contactName: string;
|
||||||
|
companyName: string;
|
||||||
|
invoiceNumber: string;
|
||||||
|
totalChf: number;
|
||||||
|
currency: string; // "CHF" — passed for future-proofing
|
||||||
|
dueAt: string; // ISO date
|
||||||
|
lineCount: number;
|
||||||
|
periodStart: string | null; // ISO date; null for custom invoices
|
||||||
|
periodEnd: string | null; // ISO date; null for custom invoices
|
||||||
|
locale: "de" | "en" | "fr" | "it";
|
||||||
|
}): Promise<void> {
|
||||||
|
// All four locales — the email is sent in the invoice's locale,
|
||||||
|
// which was frozen at issue time. No fallback to admin's locale.
|
||||||
|
const L = params.locale;
|
||||||
|
const subjectsByLocale: Record<typeof L, string> = {
|
||||||
|
en: `New invoice ${params.invoiceNumber} from PieCed IT — ${params.currency} ${params.totalChf.toFixed(2)}`,
|
||||||
|
de: `Neue Rechnung ${params.invoiceNumber} von PieCed IT — ${params.currency} ${params.totalChf.toFixed(2)}`,
|
||||||
|
fr: `Nouvelle facture ${params.invoiceNumber} de PieCed IT — ${params.currency} ${params.totalChf.toFixed(2)}`,
|
||||||
|
it: `Nuova fattura ${params.invoiceNumber} da PieCed IT — ${params.currency} ${params.totalChf.toFixed(2)}`,
|
||||||
|
};
|
||||||
|
const greetingsByLocale: Record<typeof L, string> = {
|
||||||
|
en: `Hello ${params.contactName},`,
|
||||||
|
de: `Sehr geehrte/r ${params.contactName},`,
|
||||||
|
fr: `Bonjour ${params.contactName},`,
|
||||||
|
it: `Gentile ${params.contactName},`,
|
||||||
|
};
|
||||||
|
const introByLocale: Record<typeof L, string> = {
|
||||||
|
en: `A new invoice has been issued for ${params.companyName}.`,
|
||||||
|
de: `Für ${params.companyName} wurde eine neue Rechnung ausgestellt.`,
|
||||||
|
fr: `Une nouvelle facture a été émise pour ${params.companyName}.`,
|
||||||
|
it: `È stata emessa una nuova fattura per ${params.companyName}.`,
|
||||||
|
};
|
||||||
|
const labels: Record<typeof L, Record<string, string>> = {
|
||||||
|
en: { number: "Invoice", period: "Period", total: "Total", due: "Due by", lines: "Line items", cta: "View invoice & download PDF", signoff: "Best regards", brand: "PieCed IT" },
|
||||||
|
de: { number: "Rechnung", period: "Zeitraum", total: "Gesamt", due: "Zahlbar bis", lines: "Positionen", cta: "Rechnung ansehen & PDF herunterladen", signoff: "Mit freundlichen Grüssen", brand: "PieCed IT" },
|
||||||
|
fr: { number: "Facture", period: "Période", total: "Total", due: "À régler avant", lines: "Lignes", cta: "Voir la facture & télécharger le PDF", signoff: "Cordialement", brand: "PieCed IT" },
|
||||||
|
it: { number: "Fattura", period: "Periodo", total: "Totale", due: "Scadenza", lines: "Voci", cta: "Visualizza fattura & scarica PDF", signoff: "Cordiali saluti", brand: "PieCed IT" },
|
||||||
|
};
|
||||||
|
const l = labels[L];
|
||||||
|
|
||||||
|
const safeName = escapeHtml(params.contactName);
|
||||||
|
const safeCompany = escapeHtml(params.companyName);
|
||||||
|
const safeNumber = escapeHtml(params.invoiceNumber);
|
||||||
|
const totalFmt = `${params.currency} ${params.totalChf.toFixed(2)}`;
|
||||||
|
// Phase 8: period is null for custom invoices. When missing, the
|
||||||
|
// template skips the "Service period:" line entirely; otherwise
|
||||||
|
// it renders the date range as before.
|
||||||
|
const periodFmt =
|
||||||
|
params.periodStart && params.periodEnd
|
||||||
|
? `${params.periodStart.slice(0, 10)} → ${params.periodEnd.slice(0, 10)}`
|
||||||
|
: null;
|
||||||
|
const dueFmt = params.dueAt.slice(0, 10);
|
||||||
|
|
||||||
|
// Both bodies built in the invoice's locale.
|
||||||
|
const link = `https://app.pieced.ch/billing/${encodeURIComponent(params.invoiceNumber)}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await getTransporter().sendMail({
|
||||||
|
from: getFrom(),
|
||||||
|
to: params.to,
|
||||||
|
subject: subjectsByLocale[L],
|
||||||
|
text: [
|
||||||
|
greetingsByLocale[L],
|
||||||
|
"",
|
||||||
|
introByLocale[L],
|
||||||
|
"",
|
||||||
|
`${l.number}: ${params.invoiceNumber}`,
|
||||||
|
// Phase 8: omit the period line entirely for custom
|
||||||
|
// invoices (which have no billing period).
|
||||||
|
...(periodFmt ? [`${l.period}: ${periodFmt}`] : []),
|
||||||
|
`${l.total}: ${totalFmt}`,
|
||||||
|
`${l.due}: ${dueFmt}`,
|
||||||
|
`${l.lines}: ${params.lineCount}`,
|
||||||
|
"",
|
||||||
|
`${l.cta}:`,
|
||||||
|
link,
|
||||||
|
"",
|
||||||
|
`${l.signoff},`,
|
||||||
|
l.brand,
|
||||||
|
].join("\n"),
|
||||||
|
html: `
|
||||||
|
<div style="font-family: -apple-system, BlinkMacSystemFont, sans-serif; max-width: 560px; padding: 24px; background: #1a1a1a; color: #e5e5e5;">
|
||||||
|
<h2 style="margin: 0 0 16px; color: #10B981;">${escapeHtml(introByLocale[L])}</h2>
|
||||||
|
<p>${escapeHtml(greetingsByLocale[L])}</p>
|
||||||
|
<p>${escapeHtml(introByLocale[L])}</p>
|
||||||
|
<table style="width:100%; border-collapse:collapse; margin:16px 0; font-size:14px;">
|
||||||
|
<tr><td style="color:#888; padding:6px 0; width:120px;">${l.number}</td><td><strong>${safeNumber}</strong></td></tr>
|
||||||
|
${periodFmt ? `<tr><td style="color:#888; padding:6px 0;">${l.period}</td><td>${escapeHtml(periodFmt)}</td></tr>` : ""}
|
||||||
|
<tr><td style="color:#888; padding:6px 0;">${l.total}</td><td style="color:#10B981; font-weight:600;">${escapeHtml(totalFmt)}</td></tr>
|
||||||
|
<tr><td style="color:#888; padding:6px 0;">${l.due}</td><td>${escapeHtml(dueFmt)}</td></tr>
|
||||||
|
<tr><td style="color:#888; padding:6px 0;">${l.lines}</td><td>${params.lineCount}</td></tr>
|
||||||
|
</table>
|
||||||
|
<p>
|
||||||
|
<a href="${link}" style="display:inline-block; padding:10px 24px; background:#10B981; color:#fff; text-decoration:none; border-radius:8px; font-weight:500;">
|
||||||
|
${l.cta}
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
<hr style="border:none; border-top:1px solid #333; margin:24px 0;" />
|
||||||
|
<p style="color:#666; font-size:12px;">${l.brand}</p>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to send invoice issued email:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Reminder emails — Phase 5
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a payment reminder for an open/overdue invoice.
|
||||||
|
*
|
||||||
|
* Three escalation levels:
|
||||||
|
* 1 — Gentle nudge: ~7 days past due. Friendly tone, "in case
|
||||||
|
* you missed it".
|
||||||
|
* 2 — Firmer reminder: ~14 days past due. Clear that payment is
|
||||||
|
* outstanding, please pay.
|
||||||
|
* 3 — Final notice: ~30 days past due. Explicit consequences
|
||||||
|
* (service may be suspended). Last automated touch — beyond
|
||||||
|
* this, admin involvement is expected.
|
||||||
|
*
|
||||||
|
* Failure is logged, never thrown — the cron sweep must continue
|
||||||
|
* past a single failed send.
|
||||||
|
*/
|
||||||
|
export async function sendInvoiceReminderEmail(params: {
|
||||||
|
to: string;
|
||||||
|
contactName: string;
|
||||||
|
companyName: string;
|
||||||
|
invoiceNumber: string;
|
||||||
|
totalChf: number;
|
||||||
|
currency: string;
|
||||||
|
dueAt: string;
|
||||||
|
daysPastDue: number;
|
||||||
|
level: 1 | 2 | 3;
|
||||||
|
locale: "de" | "en" | "fr" | "it";
|
||||||
|
}): Promise<void> {
|
||||||
|
const L = params.locale;
|
||||||
|
// Per-locale strings keyed by the three escalation levels.
|
||||||
|
// Kept inline (rather than the next-intl message files) because
|
||||||
|
// the email layer doesn't import from React's i18n context.
|
||||||
|
const SUBJECTS: Record<typeof L, Record<1 | 2 | 3, string>> = {
|
||||||
|
en: {
|
||||||
|
1: `Friendly reminder: invoice ${params.invoiceNumber} is overdue`,
|
||||||
|
2: `Second reminder: invoice ${params.invoiceNumber} is still unpaid`,
|
||||||
|
3: `Final notice: invoice ${params.invoiceNumber} requires immediate payment`,
|
||||||
|
},
|
||||||
|
de: {
|
||||||
|
1: `Freundliche Erinnerung: Rechnung ${params.invoiceNumber} ist überfällig`,
|
||||||
|
2: `Zweite Mahnung: Rechnung ${params.invoiceNumber} ist weiterhin unbezahlt`,
|
||||||
|
3: `Letzte Mahnung: Rechnung ${params.invoiceNumber} erfordert sofortige Zahlung`,
|
||||||
|
},
|
||||||
|
fr: {
|
||||||
|
1: `Rappel amical : la facture ${params.invoiceNumber} est en retard`,
|
||||||
|
2: `Deuxième rappel : la facture ${params.invoiceNumber} reste impayée`,
|
||||||
|
3: `Dernier avis : la facture ${params.invoiceNumber} doit être réglée sans délai`,
|
||||||
|
},
|
||||||
|
it: {
|
||||||
|
1: `Promemoria amichevole: la fattura ${params.invoiceNumber} è scaduta`,
|
||||||
|
2: `Secondo sollecito: la fattura ${params.invoiceNumber} è ancora insoluta`,
|
||||||
|
3: `Avviso finale: la fattura ${params.invoiceNumber} richiede pagamento immediato`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const INTROS: Record<typeof L, Record<1 | 2 | 3, string>> = {
|
||||||
|
en: {
|
||||||
|
1: "We noticed this invoice hasn't been settled yet — in case it slipped through.",
|
||||||
|
2: "This invoice remains unpaid. Please arrange payment at your earliest convenience.",
|
||||||
|
3: "This invoice is significantly overdue. Service may be suspended if payment is not received promptly.",
|
||||||
|
},
|
||||||
|
de: {
|
||||||
|
1: "Diese Rechnung scheint noch nicht beglichen — falls sie übersehen wurde, möchten wir freundlich daran erinnern.",
|
||||||
|
2: "Diese Rechnung ist weiterhin unbezahlt. Bitte veranlassen Sie die Zahlung umgehend.",
|
||||||
|
3: "Diese Rechnung ist erheblich überfällig. Bei nicht zeitnaher Zahlung kann der Dienst ausgesetzt werden.",
|
||||||
|
},
|
||||||
|
fr: {
|
||||||
|
1: "Cette facture n'a pas encore été réglée — au cas où elle vous aurait échappé.",
|
||||||
|
2: "Cette facture reste impayée. Merci d'effectuer le paiement dans les meilleurs délais.",
|
||||||
|
3: "Cette facture est en grand retard. Le service pourra être suspendu en l'absence de paiement rapide.",
|
||||||
|
},
|
||||||
|
it: {
|
||||||
|
1: "Questa fattura non risulta ancora saldata — nel caso vi fosse sfuggita.",
|
||||||
|
2: "Questa fattura risulta ancora insoluta. Si prega di provvedere al pagamento al più presto.",
|
||||||
|
3: "Questa fattura è significativamente in ritardo. In assenza di pagamento tempestivo il servizio potrà essere sospeso.",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const LABELS: Record<typeof L, Record<string, string>> = {
|
||||||
|
en: { num: "Invoice", total: "Total", due: "Due date", days: "Days past due", cta: "View invoice & pay", signoff: "Best regards", brand: "PieCed IT", greeting: "Hello" },
|
||||||
|
de: { num: "Rechnung", total: "Gesamt", due: "Fälligkeitsdatum", days: "Tage überfällig", cta: "Rechnung ansehen & bezahlen", signoff: "Mit freundlichen Grüssen", brand: "PieCed IT", greeting: "Sehr geehrte/r" },
|
||||||
|
fr: { num: "Facture", total: "Total", due: "Échéance", days: "Jours de retard", cta: "Voir la facture & payer", signoff: "Cordialement", brand: "PieCed IT", greeting: "Bonjour" },
|
||||||
|
it: { num: "Fattura", total: "Totale", due: "Scadenza", days: "Giorni di ritardo", cta: "Vedi fattura & paga", signoff: "Cordiali saluti", brand: "PieCed IT", greeting: "Gentile" },
|
||||||
|
};
|
||||||
|
const l = LABELS[L];
|
||||||
|
const safeName = escapeHtml(params.contactName);
|
||||||
|
const safeCompany = escapeHtml(params.companyName);
|
||||||
|
const safeNumber = escapeHtml(params.invoiceNumber);
|
||||||
|
const totalFmt = `${params.currency} ${params.totalChf.toFixed(2)}`;
|
||||||
|
const dueFmt = params.dueAt.slice(0, 10);
|
||||||
|
const link = `https://app.pieced.ch/billing/${encodeURIComponent(params.invoiceNumber)}`;
|
||||||
|
// Final-notice gets red accent; earlier levels keep the brand green.
|
||||||
|
const accent = params.level === 3 ? "#dc2626" : "#10B981";
|
||||||
|
|
||||||
|
try {
|
||||||
|
await getTransporter().sendMail({
|
||||||
|
from: getFrom(),
|
||||||
|
to: params.to,
|
||||||
|
subject: SUBJECTS[L][params.level],
|
||||||
|
text: [
|
||||||
|
`${l.greeting} ${params.contactName},`,
|
||||||
|
"",
|
||||||
|
INTROS[L][params.level],
|
||||||
|
"",
|
||||||
|
`${l.num}: ${params.invoiceNumber}`,
|
||||||
|
`${l.total}: ${totalFmt}`,
|
||||||
|
`${l.due}: ${dueFmt}`,
|
||||||
|
`${l.days}: ${params.daysPastDue}`,
|
||||||
|
"",
|
||||||
|
`${l.cta}: ${link}`,
|
||||||
|
"",
|
||||||
|
`${l.signoff},`,
|
||||||
|
l.brand,
|
||||||
|
].join("\n"),
|
||||||
|
html: `
|
||||||
|
<div style="font-family:-apple-system,BlinkMacSystemFont,sans-serif;max-width:560px;padding:24px;background:#1a1a1a;color:#e5e5e5;">
|
||||||
|
<h2 style="margin:0 0 16px;color:${accent};">${escapeHtml(SUBJECTS[L][params.level])}</h2>
|
||||||
|
<p>${l.greeting} ${safeName},</p>
|
||||||
|
<p>${escapeHtml(INTROS[L][params.level])}</p>
|
||||||
|
<table style="width:100%;border-collapse:collapse;margin:16px 0;font-size:14px;">
|
||||||
|
<tr><td style="color:#888;padding:6px 0;width:140px;">${l.num}</td><td><strong>${safeNumber}</strong></td></tr>
|
||||||
|
<tr><td style="color:#888;padding:6px 0;">${l.total}</td><td style="color:${accent};font-weight:600;">${escapeHtml(totalFmt)}</td></tr>
|
||||||
|
<tr><td style="color:#888;padding:6px 0;">${l.due}</td><td>${escapeHtml(dueFmt)}</td></tr>
|
||||||
|
<tr><td style="color:#888;padding:6px 0;">${l.days}</td><td>${params.daysPastDue}</td></tr>
|
||||||
|
</table>
|
||||||
|
<p>
|
||||||
|
<a href="${link}" style="display:inline-block;padding:10px 24px;background:${accent};color:#fff;text-decoration:none;border-radius:8px;font-weight:500;">
|
||||||
|
${l.cta}
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
<hr style="border:none;border-top:1px solid #333;margin:24px 0;" />
|
||||||
|
<p style="color:#666;font-size:12px;">${l.brand}</p>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error(
|
||||||
|
`Failed to send reminder L${params.level} for invoice ${params.invoiceNumber}:`,
|
||||||
|
err
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Credit note emails — Phase 7
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a credit-note notification to the customer's billing email.
|
||||||
|
*
|
||||||
|
* Covers both kinds (void and refund). The subject and body adapt
|
||||||
|
* based on `kind` — voids ("we've cancelled invoice X, no payment
|
||||||
|
* needed") read very differently from refunds ("we've refunded CHF
|
||||||
|
* X, expect to see it on your card statement within 5-10 days").
|
||||||
|
*
|
||||||
|
* Link-only — the PDF is not attached. The customer downloads it
|
||||||
|
* from /api/credit-notes/<number>/pdf when they click through, which
|
||||||
|
* also gives them a permanent in-portal record next to their
|
||||||
|
* invoices. Same approach as invoice emails.
|
||||||
|
*
|
||||||
|
* Best-effort: failures are logged and swallowed. A mail-server
|
||||||
|
* hiccup must never roll back a credit-note issuance.
|
||||||
|
*/
|
||||||
|
export async function sendCreditNoteEmail(params: {
|
||||||
|
to: string;
|
||||||
|
contactName: string;
|
||||||
|
companyName: string;
|
||||||
|
creditNoteNumber: string;
|
||||||
|
invoiceNumber: string;
|
||||||
|
amountChf: number;
|
||||||
|
currency: string;
|
||||||
|
kind: "void" | "refund";
|
||||||
|
reason: string | null;
|
||||||
|
locale: "de" | "en" | "fr" | "it";
|
||||||
|
}): Promise<void> {
|
||||||
|
const L = params.locale;
|
||||||
|
const totalFmt = `${params.currency} ${params.amountChf.toFixed(2)}`;
|
||||||
|
const link = `https://app.pieced.ch/billing/cn/${encodeURIComponent(
|
||||||
|
params.creditNoteNumber
|
||||||
|
)}`;
|
||||||
|
|
||||||
|
// Subject lines diverge between void and refund — different
|
||||||
|
// mental models for the recipient. Void: "your charge is
|
||||||
|
// cancelled". Refund: "your money is on the way back".
|
||||||
|
const subjectsByLocale: Record<typeof L, { void: string; refund: string }> = {
|
||||||
|
en: {
|
||||||
|
void: `Invoice ${params.invoiceNumber} cancelled — credit note ${params.creditNoteNumber}`,
|
||||||
|
refund: `Refund of ${totalFmt} for invoice ${params.invoiceNumber} — credit note ${params.creditNoteNumber}`,
|
||||||
|
},
|
||||||
|
de: {
|
||||||
|
void: `Rechnung ${params.invoiceNumber} storniert — Gutschrift ${params.creditNoteNumber}`,
|
||||||
|
refund: `Rückerstattung ${totalFmt} für Rechnung ${params.invoiceNumber} — Gutschrift ${params.creditNoteNumber}`,
|
||||||
|
},
|
||||||
|
fr: {
|
||||||
|
void: `Facture ${params.invoiceNumber} annulée — note de crédit ${params.creditNoteNumber}`,
|
||||||
|
refund: `Remboursement ${totalFmt} pour la facture ${params.invoiceNumber} — note de crédit ${params.creditNoteNumber}`,
|
||||||
|
},
|
||||||
|
it: {
|
||||||
|
void: `Fattura ${params.invoiceNumber} annullata — nota di credito ${params.creditNoteNumber}`,
|
||||||
|
refund: `Rimborso ${totalFmt} per fattura ${params.invoiceNumber} — nota di credito ${params.creditNoteNumber}`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const greetingsByLocale: Record<typeof L, string> = {
|
||||||
|
en: `Hello ${params.contactName},`,
|
||||||
|
de: `Sehr geehrte/r ${params.contactName},`,
|
||||||
|
fr: `Bonjour ${params.contactName},`,
|
||||||
|
it: `Gentile ${params.contactName},`,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Intro: distinct phrasing per kind in each locale.
|
||||||
|
const introsByLocale: Record<typeof L, { void: string; refund: string }> = {
|
||||||
|
en: {
|
||||||
|
void: `We've cancelled invoice ${params.invoiceNumber}. The invoice is no longer payable, and a credit note has been issued for your records.`,
|
||||||
|
refund: `We've refunded ${totalFmt} for invoice ${params.invoiceNumber}. The refund will appear on the original payment method within 5–10 business days, depending on your bank.`,
|
||||||
|
},
|
||||||
|
de: {
|
||||||
|
void: `Wir haben Rechnung ${params.invoiceNumber} storniert. Die Rechnung ist nicht mehr zahlbar; eine Gutschrift wurde für Ihre Unterlagen ausgestellt.`,
|
||||||
|
refund: `Wir haben ${totalFmt} für Rechnung ${params.invoiceNumber} zurückerstattet. Der Betrag wird je nach Bank innerhalb von 5–10 Geschäftstagen auf dem ursprünglichen Zahlungsweg gutgeschrieben.`,
|
||||||
|
},
|
||||||
|
fr: {
|
||||||
|
void: `Nous avons annulé la facture ${params.invoiceNumber}. La facture n'est plus exigible ; une note de crédit a été émise pour vos archives.`,
|
||||||
|
refund: `Nous avons remboursé ${totalFmt} pour la facture ${params.invoiceNumber}. Le montant apparaîtra sur le moyen de paiement initial sous 5 à 10 jours ouvrés, selon votre banque.`,
|
||||||
|
},
|
||||||
|
it: {
|
||||||
|
void: `Abbiamo annullato la fattura ${params.invoiceNumber}. La fattura non è più dovuta; è stata emessa una nota di credito per la sua documentazione.`,
|
||||||
|
refund: `Abbiamo rimborsato ${totalFmt} per la fattura ${params.invoiceNumber}. L'importo apparirà sul metodo di pagamento originale entro 5–10 giorni lavorativi, a seconda della banca.`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const labels: Record<typeof L, Record<string, string>> = {
|
||||||
|
en: { creditNote: "Credit note", invoice: "Invoice", amount: "Amount", reason: "Reason", cta: "View credit note & download PDF", signoff: "Best regards", brand: "PieCed IT" },
|
||||||
|
de: { creditNote: "Gutschrift", invoice: "Rechnung", amount: "Betrag", reason: "Begründung", cta: "Gutschrift ansehen & PDF herunterladen", signoff: "Mit freundlichen Grüssen", brand: "PieCed IT" },
|
||||||
|
fr: { creditNote: "Note de crédit", invoice: "Facture", amount: "Montant", reason: "Motif", cta: "Voir la note de crédit & télécharger le PDF", signoff: "Cordialement", brand: "PieCed IT" },
|
||||||
|
it: { creditNote: "Nota di credito", invoice: "Fattura", amount: "Importo", reason: "Motivo", cta: "Visualizza nota di credito & scarica PDF", signoff: "Cordiali saluti", brand: "PieCed IT" },
|
||||||
|
};
|
||||||
|
const l = labels[L];
|
||||||
|
|
||||||
|
const subject = subjectsByLocale[L][params.kind];
|
||||||
|
const intro = introsByLocale[L][params.kind];
|
||||||
|
const safeName = escapeHtml(params.contactName);
|
||||||
|
const safeNumberCN = escapeHtml(params.creditNoteNumber);
|
||||||
|
const safeNumberINV = escapeHtml(params.invoiceNumber);
|
||||||
|
const safeReason = params.reason ? escapeHtml(params.reason) : null;
|
||||||
|
|
||||||
|
// PieCed brand emerald — same accent the invoice email uses.
|
||||||
|
// A credit note is still a PieCed IT document; the company
|
||||||
|
// identity stays consistent across the document family. The
|
||||||
|
// doc type is distinguished by the subject line and copy, not
|
||||||
|
// by colour.
|
||||||
|
const ACCENT = "#10B981";
|
||||||
|
|
||||||
|
try {
|
||||||
|
await getTransporter().sendMail({
|
||||||
|
from: getFrom(),
|
||||||
|
to: params.to,
|
||||||
|
subject,
|
||||||
|
text: [
|
||||||
|
greetingsByLocale[L],
|
||||||
|
"",
|
||||||
|
intro,
|
||||||
|
"",
|
||||||
|
`${l.creditNote}: ${params.creditNoteNumber}`,
|
||||||
|
`${l.invoice}: ${params.invoiceNumber}`,
|
||||||
|
`${l.amount}: ${totalFmt}`,
|
||||||
|
...(params.reason ? [`${l.reason}: ${params.reason}`] : []),
|
||||||
|
"",
|
||||||
|
`${l.cta}:`,
|
||||||
|
link,
|
||||||
|
"",
|
||||||
|
`${l.signoff},`,
|
||||||
|
l.brand,
|
||||||
|
].join("\n"),
|
||||||
|
html: `
|
||||||
|
<div style="font-family: -apple-system, BlinkMacSystemFont, sans-serif; max-width: 560px; padding: 24px; background: #1a1a1a; color: #e5e5e5;">
|
||||||
|
<h2 style="margin: 0 0 16px; color: ${ACCENT};">${escapeHtml(intro)}</h2>
|
||||||
|
<p>${safeName === "" ? "" : escapeHtml(greetingsByLocale[L])}</p>
|
||||||
|
<table style="width:100%; border-collapse:collapse; margin:16px 0; font-size:14px;">
|
||||||
|
<tr><td style="color:#888; padding:6px 0; width:140px;">${l.creditNote}</td><td><strong>${safeNumberCN}</strong></td></tr>
|
||||||
|
<tr><td style="color:#888; padding:6px 0;">${l.invoice}</td><td>${safeNumberINV}</td></tr>
|
||||||
|
<tr><td style="color:#888; padding:6px 0;">${l.amount}</td><td style="color:${ACCENT}; font-weight:600;">${escapeHtml(totalFmt)}</td></tr>
|
||||||
|
${safeReason ? `<tr><td style="color:#888; padding:6px 0; vertical-align:top;">${l.reason}</td><td style="color:#bbb;">${safeReason}</td></tr>` : ""}
|
||||||
|
</table>
|
||||||
|
<p>
|
||||||
|
<a href="${link}" style="display:inline-block; padding:10px 24px; background:${ACCENT}; color:#fff; text-decoration:none; border-radius:8px; font-weight:500;">
|
||||||
|
${l.cta}
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
<hr style="border:none; border-top:1px solid #333; margin:24px 0;" />
|
||||||
|
<p style="color:#666; font-size:12px;">${l.brand}</p>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to send credit note email:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Phase 9b-2 — auto-charge failure notice
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sent when an off-session auto-charge attempt fails for an issued
|
||||||
|
* invoice (card declined, expired, 3DS required, etc.). Customer
|
||||||
|
* receives this in their billing-snapshot locale. Contains:
|
||||||
|
* - Invoice number + amount + due date
|
||||||
|
* - Failure reason (a short human-readable string from Stripe)
|
||||||
|
* - Manual-pay link to /billing/<invoiceNumber> where they can
|
||||||
|
* run the regular Pay-by-Card flow (which uses
|
||||||
|
* setup_future_usage to also refresh the saved card)
|
||||||
|
*
|
||||||
|
* Critical: the failure reason from Stripe can contain sensitive
|
||||||
|
* details (card BIN, country, etc.). We pass a sanitized short
|
||||||
|
* string from the caller — never the full raw error.
|
||||||
|
*/
|
||||||
|
export async function sendAutoChargeFailedEmail(params: {
|
||||||
|
to: string;
|
||||||
|
contactName: string;
|
||||||
|
companyName: string;
|
||||||
|
invoiceNumber: string;
|
||||||
|
totalChf: number;
|
||||||
|
currency: string;
|
||||||
|
dueAt: string;
|
||||||
|
/**
|
||||||
|
* Short, customer-safe reason. e.g. "Your card was declined."
|
||||||
|
* or "Your card has expired." Caller maps Stripe error codes to
|
||||||
|
* these strings; we never pass raw API error messages.
|
||||||
|
*/
|
||||||
|
reasonForCustomer: string;
|
||||||
|
locale: "de" | "en" | "fr" | "it";
|
||||||
|
}): Promise<void> {
|
||||||
|
const L = params.locale;
|
||||||
|
const totalFmt = `${params.currency} ${params.totalChf.toFixed(2)}`;
|
||||||
|
const dueFmt = params.dueAt.slice(0, 10);
|
||||||
|
const baseUrl = process.env.APP_BASE_URL ?? "https://app.pieced.ch";
|
||||||
|
const link = `${baseUrl}/billing/${encodeURIComponent(params.invoiceNumber)}`;
|
||||||
|
|
||||||
|
const subjectsByLocale: Record<typeof L, string> = {
|
||||||
|
en: `Auto-charge failed for invoice ${params.invoiceNumber} — please pay manually`,
|
||||||
|
de: `Auto-Abbuchung fehlgeschlagen für Rechnung ${params.invoiceNumber} — bitte manuell bezahlen`,
|
||||||
|
fr: `Échec du prélèvement automatique pour la facture ${params.invoiceNumber} — merci de régler manuellement`,
|
||||||
|
it: `Addebito automatico fallito per la fattura ${params.invoiceNumber} — la preghiamo di pagare manualmente`,
|
||||||
|
};
|
||||||
|
const greetingsByLocale: Record<typeof L, string> = {
|
||||||
|
en: `Hello ${params.contactName},`,
|
||||||
|
de: `Sehr geehrte/r ${params.contactName},`,
|
||||||
|
fr: `Bonjour ${params.contactName},`,
|
||||||
|
it: `Gentile ${params.contactName},`,
|
||||||
|
};
|
||||||
|
const introByLocale: Record<typeof L, string> = {
|
||||||
|
en: `We were unable to charge your saved card for invoice ${params.invoiceNumber} (${params.companyName}).`,
|
||||||
|
de: `Wir konnten die Rechnung ${params.invoiceNumber} (${params.companyName}) nicht über die hinterlegte Karte abbuchen.`,
|
||||||
|
fr: `Nous n'avons pas pu débiter votre carte enregistrée pour la facture ${params.invoiceNumber} (${params.companyName}).`,
|
||||||
|
it: `Non siamo riusciti ad addebitare la carta salvata per la fattura ${params.invoiceNumber} (${params.companyName}).`,
|
||||||
|
};
|
||||||
|
const reasonLabel: Record<typeof L, string> = {
|
||||||
|
en: "Reason given by the card network",
|
||||||
|
de: "Vom Kartennetzwerk gemeldeter Grund",
|
||||||
|
fr: "Motif communiqué par le réseau de carte",
|
||||||
|
it: "Motivo comunicato dal circuito",
|
||||||
|
};
|
||||||
|
const actionLineByLocale: Record<typeof L, string> = {
|
||||||
|
en: `Please pay this invoice manually before ${dueFmt} to avoid service interruption. The "Pay with card" button below will both charge the invoice and update the card we have on file for future charges.`,
|
||||||
|
de: `Bitte begleichen Sie diese Rechnung manuell vor dem ${dueFmt}, um eine Unterbrechung Ihres Dienstes zu vermeiden. Die Schaltfläche "Mit Karte bezahlen" unten begleicht die Rechnung und aktualisiert gleichzeitig die hinterlegte Karte für zukünftige Abbuchungen.`,
|
||||||
|
fr: `Veuillez régler cette facture manuellement avant le ${dueFmt} pour éviter toute interruption du service. Le bouton "Payer par carte" ci-dessous règle la facture et met à jour la carte enregistrée pour les futurs prélèvements.`,
|
||||||
|
it: `La preghiamo di saldare questa fattura manualmente entro il ${dueFmt} per evitare interruzioni del servizio. Il pulsante "Paga con carta" qui sotto salda la fattura e aggiorna allo stesso tempo la carta in archivio per gli addebiti futuri.`,
|
||||||
|
};
|
||||||
|
const labels: Record<typeof L, Record<string, string>> = {
|
||||||
|
en: { number: "Invoice", total: "Total", due: "Due by", cta: "Pay with card", signoff: "Best regards", brand: "PieCed IT" },
|
||||||
|
de: { number: "Rechnung", total: "Gesamt", due: "Zahlbar bis", cta: "Mit Karte bezahlen", signoff: "Mit freundlichen Grüssen", brand: "PieCed IT" },
|
||||||
|
fr: { number: "Facture", total: "Total", due: "À régler avant", cta: "Payer par carte", signoff: "Cordialement", brand: "PieCed IT" },
|
||||||
|
it: { number: "Fattura", total: "Totale", due: "Scadenza", cta: "Paga con carta", signoff: "Cordiali saluti", brand: "PieCed IT" },
|
||||||
|
};
|
||||||
|
const l = labels[L];
|
||||||
|
|
||||||
|
const safeName = escapeHtml(params.contactName);
|
||||||
|
const safeCompany = escapeHtml(params.companyName);
|
||||||
|
const safeNumber = escapeHtml(params.invoiceNumber);
|
||||||
|
const safeReason = escapeHtml(params.reasonForCustomer);
|
||||||
|
const safeIntro = escapeHtml(introByLocale[L]);
|
||||||
|
const safeAction = escapeHtml(actionLineByLocale[L]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await getTransporter().sendMail({
|
||||||
|
from: getFrom(),
|
||||||
|
to: params.to,
|
||||||
|
subject: subjectsByLocale[L],
|
||||||
|
text: [
|
||||||
|
greetingsByLocale[L],
|
||||||
|
"",
|
||||||
|
introByLocale[L],
|
||||||
|
"",
|
||||||
|
`${l.number}: ${params.invoiceNumber}`,
|
||||||
|
`${l.total}: ${totalFmt}`,
|
||||||
|
`${l.due}: ${dueFmt}`,
|
||||||
|
"",
|
||||||
|
`${reasonLabel[L]}: ${params.reasonForCustomer}`,
|
||||||
|
"",
|
||||||
|
actionLineByLocale[L],
|
||||||
|
"",
|
||||||
|
`${l.cta}:`,
|
||||||
|
link,
|
||||||
|
"",
|
||||||
|
`${l.signoff},`,
|
||||||
|
l.brand,
|
||||||
|
].join("\n"),
|
||||||
|
html: `
|
||||||
|
<div style="font-family: -apple-system, BlinkMacSystemFont, sans-serif; max-width: 560px; padding: 24px; background: #1a1a1a; color: #e5e5e5;">
|
||||||
|
<h2 style="margin: 0 0 16px; color: #f59e0b;">${escapeHtml(subjectsByLocale[L])}</h2>
|
||||||
|
<p>${escapeHtml(greetingsByLocale[L])}</p>
|
||||||
|
<p>${safeIntro}</p>
|
||||||
|
<table style="width:100%; border-collapse:collapse; margin:16px 0; font-size:14px;">
|
||||||
|
<tr><td style="color:#888; padding:6px 0; width:120px;">${l.number}</td><td><strong>${safeNumber}</strong></td></tr>
|
||||||
|
<tr><td style="color:#888; padding:6px 0;">${l.total}</td><td style="color:#f59e0b; font-weight:600;">${escapeHtml(totalFmt)}</td></tr>
|
||||||
|
<tr><td style="color:#888; padding:6px 0;">${l.due}</td><td>${escapeHtml(dueFmt)}</td></tr>
|
||||||
|
</table>
|
||||||
|
<div style="background:#2a2a2a; border-left:3px solid #f59e0b; padding:10px 12px; margin:16px 0; font-size:13px;">
|
||||||
|
<strong>${escapeHtml(reasonLabel[L])}:</strong> ${safeReason}
|
||||||
|
</div>
|
||||||
|
<p style="font-size:14px;">${safeAction}</p>
|
||||||
|
<p>
|
||||||
|
<a href="${link}" style="display:inline-block; padding:10px 24px; background:#10B981; color:#fff; text-decoration:none; border-radius:8px; font-weight:500;">
|
||||||
|
${l.cta}
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
<p style="color:#888; font-size:12px; margin-top:24px;">
|
||||||
|
${l.signoff},<br />${l.brand}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to send auto-charge-failed email:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user