Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| faf49119ea | |||
| ce70fe8480 | |||
| 55571b1e59 |
301
README.md
301
README.md
@@ -1,66 +1,273 @@
|
|||||||
# Threema UX v2 — QR available on demand
|
# PieCed Portal — Billing Phase 1 (drop-in replacement)
|
||||||
|
|
||||||
Replaces the previous attempt that had the QR rendering inline above
|
Schema + event tracking. No UI yet (that lands in Phase 2).
|
||||||
the channel help text. That placement didn't work for you in practice
|
This zip mirrors the `pieced-portal/` repo root — extract over your
|
||||||
because it wasn't visible when you actually wanted it.
|
existing source tree to apply.
|
||||||
|
|
||||||
## What this does instead
|
**v2 fix:** stripped stray backticks from SQL comments that were
|
||||||
|
closing the `MIGRATION_SQL` template literal early. If you got
|
||||||
|
"Expected a semicolon" at db.ts:335 with v1, this build is the fix.
|
||||||
|
|
||||||
1. **"Show QR" link** next to the channel title in the threema card —
|
---
|
||||||
visible at all times, clickable any time you want the QR.
|
|
||||||
|
|
||||||
2. **Auto-opens the modal** the first time you focus the add-ID input
|
## Files in this drop
|
||||||
on the page (so a new user adding their first ID sees it without
|
|
||||||
needing to click "Show QR" themselves). Doesn't re-pop after
|
|
||||||
dismissal — the link covers re-opens.
|
|
||||||
|
|
||||||
3. **Modal** with QR + 3-step instructions + "AIAGENT" label. Plain
|
|
||||||
`<img>` (no next/image), closes on ESC / overlay click / × button.
|
|
||||||
|
|
||||||
The earlier `threema-setup.tsx` component file is removed — replaced by
|
|
||||||
`threema-qr-modal.tsx`.
|
|
||||||
|
|
||||||
## Files
|
|
||||||
|
|
||||||
```
|
```
|
||||||
src/lib/threema-gateway-config.ts # unchanged from before — central gateway constants
|
src/lib/db.ts MODIFIED
|
||||||
src/components/channel-users/threema-qr-modal.tsx # NEW — the modal
|
src/types/index.ts MODIFIED
|
||||||
src/components/channel-users/channel-users.tsx # MODIFIED — Show QR button + focus auto-open + modal mount
|
src/app/api/admin/requests/[id]/approve/route.ts MODIFIED
|
||||||
deploy/patch-i18n-threema.mjs # adds threemaSetup.showQr label in 4 langs
|
src/app/api/tenants/[name]/route.ts MODIFIED
|
||||||
public/threema/qr_code_AIAGENT.png # unchanged
|
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
|
||||||
```
|
```
|
||||||
|
|
||||||
## Apply
|
No `package.json` changes — Phase 1 uses only deps already present.
|
||||||
|
|
||||||
```bash
|
### What changed
|
||||||
cd /path/to/pieced-portal
|
|
||||||
|
|
||||||
# Remove the old inline component if you applied the previous archive
|
`src/lib/db.ts`
|
||||||
rm -f src/components/channel-users/threema-setup.tsx
|
- 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
|
||||||
|
|
||||||
# Drop new + modified files
|
`src/types/index.ts`
|
||||||
unzip -o /path/to/threema-ux-v2.zip
|
- 6 new interfaces appended at the bottom
|
||||||
|
|
||||||
# Update messages
|
`src/app/api/admin/requests/[id]/approve/route.ts`
|
||||||
node deploy/patch-i18n-threema.mjs
|
- 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`
|
||||||
|
|
||||||
# TS check
|
`src/app/api/tenants/[name]/route.ts`
|
||||||
npx tsc --noEmit
|
- 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.
|
||||||
|
|
||||||
git add -A
|
`src/app/api/tenants/[name]/suspend/route.ts`
|
||||||
git commit -m "Threema QR: on-demand modal + auto-open on first add"
|
- Imports `recordSuspensionEvent`
|
||||||
git push
|
- Records `suspended` or `resumed` after the patch succeeds
|
||||||
```
|
|
||||||
|
|
||||||
After redeploy, the threema card under Authorized Users shows:
|
`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)
|
||||||
|
|
||||||
```
|
```
|
||||||
threema [Show QR] 0 users
|
platform_pricing single-row pricing config
|
||||||
────────────────────
|
skill_pricing per-package daily price (optional)
|
||||||
<help text: 'Enter your own Threema ID...'>
|
tenant_billing_lifecycle per-tenant created_at + deleted_at
|
||||||
────────────────────
|
tenant_skill_events append-only enable/disable log
|
||||||
[ input: A8K2P3X7 ] [ Add ]
|
tenant_suspension_events append-only suspend/resume log
|
||||||
^^^ focusing this opens the QR modal the first time
|
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)
|
||||||
```
|
```
|
||||||
|
|
||||||
Clicking "Show QR" or focusing the input → modal with QR + steps.
|
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
|
||||||
|
|
||||||
|
1. Extract this zip over your `pieced-portal/` source tree
|
||||||
|
2. Build & push:
|
||||||
|
```
|
||||||
|
./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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing (in order — don't skip steps)
|
||||||
|
|
||||||
|
### Step 1 — Migration ran
|
||||||
|
|
||||||
|
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;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What's NOT in this phase (by design)
|
||||||
|
|
||||||
|
* No customer-facing /billing page
|
||||||
|
* No admin pricing UI
|
||||||
|
* No invoice generation
|
||||||
|
* No PDF rendering
|
||||||
|
* No Stripe wiring
|
||||||
|
* No reminders or cron
|
||||||
|
|
||||||
|
These are Phases 2-6.
|
||||||
|
|||||||
@@ -37,7 +37,9 @@ const i18n = {
|
|||||||
step2: "Tap the scan icon and scan this QR code to add the assistant as a contact.",
|
step2: "Tap the scan icon and scan this QR code to add the assistant as a contact.",
|
||||||
step3: "Then add your own Threema ID below.",
|
step3: "Then add your own Threema ID below.",
|
||||||
qrAlt: "QR code to add {gateway} as a Threema contact",
|
qrAlt: "QR code to add {gateway} as a Threema contact",
|
||||||
showQr: "Show QR",
|
bannerTitle: "Set up Threema",
|
||||||
|
bannerBody: "Open Threema on your phone and scan our QR code to add the assistant as a contact. Then add your own Threema ID below.",
|
||||||
|
bannerButton: "Show QR code",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
de: {
|
de: {
|
||||||
@@ -57,7 +59,9 @@ const i18n = {
|
|||||||
step2: "Tippen Sie auf das Scan-Symbol und scannen Sie diesen QR-Code, um den Assistenten als Kontakt hinzuzufügen.",
|
step2: "Tippen Sie auf das Scan-Symbol und scannen Sie diesen QR-Code, um den Assistenten als Kontakt hinzuzufügen.",
|
||||||
step3: "Fügen Sie anschliessend unten Ihre eigene Threema-ID hinzu.",
|
step3: "Fügen Sie anschliessend unten Ihre eigene Threema-ID hinzu.",
|
||||||
qrAlt: "QR-Code, um {gateway} als Threema-Kontakt hinzuzufügen",
|
qrAlt: "QR-Code, um {gateway} als Threema-Kontakt hinzuzufügen",
|
||||||
showQr: "QR anzeigen",
|
bannerTitle: "Threema einrichten",
|
||||||
|
bannerBody: "Öffnen Sie Threema auf Ihrem Telefon und scannen Sie unseren QR-Code, um den Assistenten als Kontakt hinzuzufügen. Geben Sie anschliessend unten Ihre eigene Threema-ID ein.",
|
||||||
|
bannerButton: "QR-Code anzeigen",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
fr: {
|
fr: {
|
||||||
@@ -77,7 +81,9 @@ const i18n = {
|
|||||||
step2: "Appuyez sur l'icône de scan et scannez ce QR code pour ajouter l'assistant comme contact.",
|
step2: "Appuyez sur l'icône de scan et scannez ce QR code pour ajouter l'assistant comme contact.",
|
||||||
step3: "Puis ajoutez votre propre identifiant Threema ci-dessous.",
|
step3: "Puis ajoutez votre propre identifiant Threema ci-dessous.",
|
||||||
qrAlt: "QR code pour ajouter {gateway} comme contact Threema",
|
qrAlt: "QR code pour ajouter {gateway} comme contact Threema",
|
||||||
showQr: "Afficher le QR",
|
bannerTitle: "Configurer Threema",
|
||||||
|
bannerBody: "Ouvrez Threema sur votre téléphone et scannez notre QR code pour ajouter l'assistant comme contact. Saisissez ensuite votre propre identifiant Threema ci-dessous.",
|
||||||
|
bannerButton: "Afficher le QR code",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
it: {
|
it: {
|
||||||
@@ -97,7 +103,9 @@ const i18n = {
|
|||||||
step2: "Tocca l'icona di scansione e scansiona questo QR code per aggiungere l'assistente ai contatti.",
|
step2: "Tocca l'icona di scansione e scansiona questo QR code per aggiungere l'assistente ai contatti.",
|
||||||
step3: "Quindi aggiungi il tuo ID Threema qui sotto.",
|
step3: "Quindi aggiungi il tuo ID Threema qui sotto.",
|
||||||
qrAlt: "QR code per aggiungere {gateway} come contatto Threema",
|
qrAlt: "QR code per aggiungere {gateway} come contatto Threema",
|
||||||
showQr: "Mostra QR",
|
bannerTitle: "Configura Threema",
|
||||||
|
bannerBody: "Apri Threema sul tuo telefono e scansiona il nostro QR code per aggiungere l'assistente ai contatti. Inserisci poi il tuo ID Threema qui sotto.",
|
||||||
|
bannerButton: "Mostra QR code",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
70
src/app/api/admin/billing/backfill/route.ts
Normal file
70
src/app/api/admin/billing/backfill/route.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { requirePlatformRole } from "@/lib/session";
|
||||||
|
import { listTenants } from "@/lib/k8s";
|
||||||
|
import { backfillTenantBillingLifecycle } from "@/lib/db";
|
||||||
|
import { safeError } from "@/lib/errors";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/admin/billing/backfill
|
||||||
|
*
|
||||||
|
* One-off bootstrap that reads every live PiecedTenant CR and
|
||||||
|
* mirrors it into the Phase 1 billing tables:
|
||||||
|
* - tenant_billing_lifecycle.created_at ← CR's creationTimestamp
|
||||||
|
* - tenant_skill_events: one 'enabled' event per package in
|
||||||
|
* spec.packages, anchored at the CR's creationTimestamp
|
||||||
|
* - tenant_suspension_events: one 'suspended' event if the CR is
|
||||||
|
* currently suspended (anchored at status.suspendedAt)
|
||||||
|
*
|
||||||
|
* Idempotent — re-running is safe. The helper only inserts rows
|
||||||
|
* for tenants that have no lifecycle row / no events yet; running
|
||||||
|
* twice produces zero additional rows.
|
||||||
|
*
|
||||||
|
* Authorization: platform role only. The body of the request is
|
||||||
|
* ignored.
|
||||||
|
*
|
||||||
|
* Response: counts of rows inserted, mostly for sanity-checking
|
||||||
|
* (expect non-zero on first run, zero on subsequent runs).
|
||||||
|
*
|
||||||
|
* Phase 2 will surface this behind an admin UI button.
|
||||||
|
*/
|
||||||
|
export async function POST() {
|
||||||
|
try {
|
||||||
|
await requirePlatformRole();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const tenants = await listTenants();
|
||||||
|
const result = await backfillTenantBillingLifecycle(
|
||||||
|
tenants.map((t) => ({
|
||||||
|
name: t.metadata.name,
|
||||||
|
// Tenants without the org label exist as a pre-Slice-3
|
||||||
|
// artifact; we still record them but with 'unknown' as the
|
||||||
|
// org id, which surfaces them in admin reports for manual
|
||||||
|
// labelling. Per-org billing computation skips rows with
|
||||||
|
// org id = 'unknown'.
|
||||||
|
zitadelOrgId:
|
||||||
|
t.metadata.labels?.["pieced.ch/zitadel-org-id"] ?? "unknown",
|
||||||
|
createdAt: t.metadata.creationTimestamp
|
||||||
|
? new Date(t.metadata.creationTimestamp)
|
||||||
|
: new Date(),
|
||||||
|
packages: t.spec.packages ?? [],
|
||||||
|
suspendedAt: t.status?.suspendedAt
|
||||||
|
? new Date(t.status.suspendedAt)
|
||||||
|
: null,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
return NextResponse.json({
|
||||||
|
message: "Backfill complete.",
|
||||||
|
tenantsExamined: tenants.length,
|
||||||
|
...result,
|
||||||
|
});
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error("Backfill failed:", e);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: safeError(e, "Backfill failed") },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,9 @@ import {
|
|||||||
getTenantRequestById,
|
getTenantRequestById,
|
||||||
updateTenantRequestStatus,
|
updateTenantRequestStatus,
|
||||||
clearEncryptedSecrets,
|
clearEncryptedSecrets,
|
||||||
|
recordTenantCreated,
|
||||||
|
recordSkillEvents,
|
||||||
|
recordSuspensionEvent,
|
||||||
} from "@/lib/db";
|
} from "@/lib/db";
|
||||||
import { createTenant, patchTenantSpec, setTenantAnnotation } from "@/lib/k8s";
|
import { createTenant, patchTenantSpec, setTenantAnnotation } from "@/lib/k8s";
|
||||||
import { sendApprovalEmail, sendResumeApprovalEmail } from "@/lib/email";
|
import { sendApprovalEmail, sendResumeApprovalEmail } from "@/lib/email";
|
||||||
@@ -85,6 +88,23 @@ export async function POST(
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await patchTenantSpec(tenantRequest.tenantName, { suspend: false });
|
await patchTenantSpec(tenantRequest.tenantName, { suspend: false });
|
||||||
|
|
||||||
|
// Billing — Phase 1: record the resume so monthly proration
|
||||||
|
// counts the suspended segment correctly. Best-effort; if
|
||||||
|
// logging fails, the approval still succeeds.
|
||||||
|
try {
|
||||||
|
await recordSuspensionEvent(
|
||||||
|
tenantRequest.tenantName,
|
||||||
|
tenantRequest.zitadelOrgId,
|
||||||
|
"resumed"
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(
|
||||||
|
"billing: failed to record resumed suspension event:",
|
||||||
|
e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Clear the annotation that pauses the operator's 60-day TTL.
|
// Clear the annotation that pauses the operator's 60-day TTL.
|
||||||
// Best-effort — annotation cleanup is also done by the operator
|
// Best-effort — annotation cleanup is also done by the operator
|
||||||
// when it sees suspend=false on the next reconcile (it clears
|
// when it sees suspend=false on the next reconcile (it clears
|
||||||
@@ -199,6 +219,35 @@ export async function POST(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Billing — Phase 1: record the tenant's creation and initial
|
||||||
|
// package state. Anchored at "now" rather than the CR's
|
||||||
|
// creationTimestamp because we don't get the timestamp back from
|
||||||
|
// createTenant — the few-millisecond skew vs the CR's actual
|
||||||
|
// creationTimestamp is irrelevant for monthly billing.
|
||||||
|
//
|
||||||
|
// Best-effort: tracking failures must never block provisioning.
|
||||||
|
// The backfill helper can repair any gaps later if needed.
|
||||||
|
const billingAnchor = new Date();
|
||||||
|
try {
|
||||||
|
await recordTenantCreated(
|
||||||
|
tenantName,
|
||||||
|
tenantRequest.zitadelOrgId,
|
||||||
|
billingAnchor
|
||||||
|
);
|
||||||
|
await recordSkillEvents(
|
||||||
|
tenantName,
|
||||||
|
tenantRequest.zitadelOrgId,
|
||||||
|
packages,
|
||||||
|
[],
|
||||||
|
billingAnchor
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(
|
||||||
|
"billing: failed to record tenant creation / initial skill events:",
|
||||||
|
e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Step 5: Update request status — clear admin notes on re-approval
|
// Step 5: Update request status — clear admin notes on re-approval
|
||||||
const updated = await updateTenantRequestStatus(id, "provisioning", {
|
const updated = await updateTenantRequestStatus(id, "provisioning", {
|
||||||
adminNotes: isReApproval ? null : adminNotes,
|
adminNotes: isReApproval ? null : adminNotes,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { getTenant, deleteTenant } from "@/lib/k8s";
|
|||||||
import {
|
import {
|
||||||
markTenantRequestDeletedByTenantName,
|
markTenantRequestDeletedByTenantName,
|
||||||
removeAllAssignmentsForTenant,
|
removeAllAssignmentsForTenant,
|
||||||
|
recordTenantDeleted,
|
||||||
} from "@/lib/db";
|
} from "@/lib/db";
|
||||||
import { safeError } from "@/lib/errors";
|
import { safeError } from "@/lib/errors";
|
||||||
|
|
||||||
@@ -49,6 +50,15 @@ export async function POST(
|
|||||||
console.error("Failed to clean up tenant assignments:", e)
|
console.error("Failed to clean up tenant assignments:", e)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Billing — Phase 1: stamp deletion timestamp on the lifecycle
|
||||||
|
// row so the final invoice covering the deletion month can
|
||||||
|
// prorate correctly. Idempotent at the DB layer; a missing
|
||||||
|
// lifecycle row (e.g. pre-Phase-1 tenants that haven't been
|
||||||
|
// backfilled yet) makes this a no-op.
|
||||||
|
await recordTenantDeleted(name).catch((e) =>
|
||||||
|
console.error("billing: failed to stamp tenant deletion:", e)
|
||||||
|
);
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
message: "Tenant deletion initiated. The operator will clean up all resources.",
|
message: "Tenant deletion initiated. The operator will clean up all resources.",
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { getSessionUser, canMutate } from "@/lib/session";
|
|||||||
import { canUserSeeTenant } from "@/lib/visibility";
|
import { canUserSeeTenant } from "@/lib/visibility";
|
||||||
import { getTenant, patchTenantSpec } from "@/lib/k8s";
|
import { getTenant, patchTenantSpec } from "@/lib/k8s";
|
||||||
import { getPackageDef } from "@/lib/packages";
|
import { getPackageDef } from "@/lib/packages";
|
||||||
|
import { recordSkillEvents } from "@/lib/db";
|
||||||
import { safeError } from "@/lib/errors";
|
import { safeError } from "@/lib/errors";
|
||||||
|
|
||||||
const ALLOWED_WORKSPACE_FILES = ["SOUL.md", "AGENTS.md", "TOOLS.md"];
|
const ALLOWED_WORKSPACE_FILES = ["SOUL.md", "AGENTS.md", "TOOLS.md"];
|
||||||
@@ -187,6 +188,50 @@ export async function PATCH(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const updated = await patchTenantSpec(name, specPatch);
|
const updated = await patchTenantSpec(name, specPatch);
|
||||||
|
|
||||||
|
// Billing — Phase 1: if packages changed, record enable/disable
|
||||||
|
// events. The diff is computed against the patched CR (the
|
||||||
|
// returned state) rather than `existing` so the events match
|
||||||
|
// what K8s actually committed. Best-effort: a logging failure
|
||||||
|
// never poisons the PATCH response — drift would be reconciled
|
||||||
|
// on the next backfill or by the next normal toggle.
|
||||||
|
//
|
||||||
|
// Note on races: two concurrent PATCHes could each see the
|
||||||
|
// same `existing` and both succeed at the K8s layer (last write
|
||||||
|
// wins for spec.packages, which is replaced wholesale). The
|
||||||
|
// events from the losing PATCH would then describe a transition
|
||||||
|
// that no longer reflects reality. Acceptable trade-off for v1
|
||||||
|
// — the toggle UI sends one request at a time and races would
|
||||||
|
// only matter for adjacent same-day toggles, which the billing
|
||||||
|
// computation collapses to a single billable day anyway.
|
||||||
|
if (specPatch.packages !== undefined) {
|
||||||
|
try {
|
||||||
|
const orgId =
|
||||||
|
existing.metadata.labels?.["pieced.ch/zitadel-org-id"] ?? null;
|
||||||
|
if (orgId) {
|
||||||
|
const oldSet = new Set<string>(existing.spec.packages ?? []);
|
||||||
|
const newSet = new Set<string>(updated.spec.packages ?? []);
|
||||||
|
const added = [...newSet].filter((x) => !oldSet.has(x));
|
||||||
|
const removed = [...oldSet].filter((x) => !newSet.has(x));
|
||||||
|
if (added.length > 0 || removed.length > 0) {
|
||||||
|
await recordSkillEvents(name, orgId, added, removed);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// A tenant without the org label is a pre-Slice-3 artifact
|
||||||
|
// — we can't attribute its skill events to any org. Log
|
||||||
|
// and skip rather than guess.
|
||||||
|
console.warn(
|
||||||
|
`billing: tenant ${name} has no zitadel-org-id label; skill events not recorded`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(
|
||||||
|
`billing: failed to record skill events for ${name}:`,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return NextResponse.json(updated);
|
return NextResponse.json(updated);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { z } from "zod";
|
|||||||
import { getSessionUser, canMutate } from "@/lib/session";
|
import { getSessionUser, canMutate } from "@/lib/session";
|
||||||
import { getTenant, patchTenantSpec, setTenantAnnotation } from "@/lib/k8s";
|
import { getTenant, patchTenantSpec, setTenantAnnotation } from "@/lib/k8s";
|
||||||
import { canUserSeeTenant } from "@/lib/visibility";
|
import { canUserSeeTenant } from "@/lib/visibility";
|
||||||
|
import { recordSuspensionEvent } from "@/lib/db";
|
||||||
import { safeError } from "@/lib/errors";
|
import { safeError } from "@/lib/errors";
|
||||||
|
|
||||||
const patchSchema = z.object({
|
const patchSchema = z.object({
|
||||||
@@ -101,6 +102,33 @@ export async function PATCH(
|
|||||||
try {
|
try {
|
||||||
await patchTenantSpec(name, { suspend });
|
await patchTenantSpec(name, { suspend });
|
||||||
|
|
||||||
|
// Billing — Phase 1: record the transition so monthly proration
|
||||||
|
// can exclude suspended days from the fixed fee. The portal
|
||||||
|
// commands this transition; the operator's status.suspendedAt
|
||||||
|
// lags by a reconcile cycle (seconds), which is irrelevant for
|
||||||
|
// monthly billing. Best-effort: a logging failure never blocks
|
||||||
|
// the suspend/resume itself.
|
||||||
|
try {
|
||||||
|
const orgId =
|
||||||
|
tenant.metadata.labels?.["pieced.ch/zitadel-org-id"] ?? null;
|
||||||
|
if (orgId) {
|
||||||
|
await recordSuspensionEvent(
|
||||||
|
name,
|
||||||
|
orgId,
|
||||||
|
suspend ? "suspended" : "resumed"
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
console.warn(
|
||||||
|
`billing: tenant ${name} has no zitadel-org-id label; suspension event not recorded`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(
|
||||||
|
`billing: failed to record suspension event for ${name}:`,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// On admin-side resume, also clear the pending-resume-request
|
// On admin-side resume, also clear the pending-resume-request
|
||||||
// annotation if it exists. Belt-and-suspenders: the admin-approve
|
// annotation if it exists. Belt-and-suspenders: the admin-approve
|
||||||
// endpoint already clears it on its happy path, but a platform
|
// endpoint already clears it on its happy path, but a platform
|
||||||
|
|||||||
@@ -225,24 +225,47 @@ export function ChannelUsers({
|
|||||||
className="bg-surface-2 border border-border rounded-lg p-4"
|
className="bg-surface-2 border border-border rounded-lg p-4"
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<h4 className="text-sm font-medium text-text-primary capitalize">
|
<h4 className="text-sm font-medium text-text-primary capitalize">
|
||||||
{channel}
|
{channel}
|
||||||
</h4>
|
</h4>
|
||||||
{channel === "threema" && (
|
|
||||||
<button
|
|
||||||
onClick={() => setShowQrFor("threema")}
|
|
||||||
className="text-xs font-medium text-accent hover:text-accent-dim cursor-pointer underline underline-offset-2"
|
|
||||||
>
|
|
||||||
{t("threemaSetup.showQr")}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<span className="text-xs text-text-muted tabular-nums">
|
<span className="text-xs text-text-muted tabular-nums">
|
||||||
{users.length} {t("users")}
|
{users.length} {t("users")}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{channel === "threema" && (
|
||||||
|
<div className="mb-3 flex flex-col sm:flex-row gap-3 items-start sm:items-center justify-between bg-accent/5 border border-accent/30 rounded-lg p-3">
|
||||||
|
<div className="flex items-start gap-2 flex-1">
|
||||||
|
<svg
|
||||||
|
className="w-4 h-4 mt-0.5 text-accent flex-shrink-0"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M3 4a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H4a1 1 0 01-1-1V4zM15 4a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1V4zM3 16a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H4a1 1 0 01-1-1v-4zM13 13h3v3h-3zM18 13h3v3h-3zM13 18h3v3h-3zM18 18h3v3h-3z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<div className="text-xs text-text-secondary leading-relaxed">
|
||||||
|
<p className="font-medium text-text-primary mb-0.5">
|
||||||
|
{t("threemaSetup.bannerTitle")}
|
||||||
|
</p>
|
||||||
|
<p>{t("threemaSetup.bannerBody")}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowQrFor("threema")}
|
||||||
|
className="self-stretch sm:self-auto px-3 py-2 text-xs font-medium bg-accent text-surface-0 rounded-lg hover:bg-accent-dim transition-colors whitespace-nowrap cursor-pointer shadow-lg shadow-accent/20"
|
||||||
|
>
|
||||||
|
{t("threemaSetup.bannerButton")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{helpKey && (
|
{helpKey && (
|
||||||
<p className="text-xs text-text-secondary bg-surface-1 border border-border rounded-lg p-3 mb-3 whitespace-pre-line">
|
<p className="text-xs text-text-secondary bg-surface-1 border border-border rounded-lg p-3 mb-3 whitespace-pre-line">
|
||||||
{t(helpKey)}
|
{t(helpKey)}
|
||||||
|
|||||||
788
src/lib/db.ts
788
src/lib/db.ts
@@ -272,6 +272,252 @@ const MIGRATION_SQL = `
|
|||||||
END $$;
|
END $$;
|
||||||
CREATE INDEX IF NOT EXISTS idx_support_ticket_comments_ticket
|
CREATE INDEX IF NOT EXISTS idx_support_ticket_comments_ticket
|
||||||
ON support_ticket_comments(ticket_id, created_at);
|
ON support_ticket_comments(ticket_id, created_at);
|
||||||
|
|
||||||
|
-- =========================================================================
|
||||||
|
-- Billing — Phase 1: pricing, lifecycle, and events
|
||||||
|
-- =========================================================================
|
||||||
|
--
|
||||||
|
-- This block introduces the schema for the consolidated billing
|
||||||
|
-- subsystem. Phase 1 lands the schema + writes the lifecycle and
|
||||||
|
-- skill-event rows that downstream phases consume; invoice
|
||||||
|
-- generation, PDF rendering, Stripe wiring and reminders are added
|
||||||
|
-- in later phases. The invoice/line/reminder tables are created
|
||||||
|
-- here so all billing schema lives in a single migration block,
|
||||||
|
-- but no code writes to them until Phase 2.
|
||||||
|
--
|
||||||
|
-- Money columns: NUMERIC(10,2) for CHF amounts (max ~99 million.99
|
||||||
|
-- which is fine for the foreseeable future) and NUMERIC(10,5) for
|
||||||
|
-- per-unit prices (Threema messages and skill-days can have
|
||||||
|
-- sub-rappen unit prices — 0.00012 CHF/message is the kind of
|
||||||
|
-- granularity we want to keep precise across multiplication).
|
||||||
|
--
|
||||||
|
-- All timestamps are TIMESTAMPTZ. Billing-day computations use UTC
|
||||||
|
-- by convention (matches all other stored timestamps; avoids DST
|
||||||
|
-- ambiguity around month boundaries).
|
||||||
|
|
||||||
|
-- Single-row platform pricing config. The id=1 CHECK and explicit
|
||||||
|
-- DEFAULT 1 make accidental multi-row inserts impossible, and
|
||||||
|
-- callers can always SELECT * FROM platform_pricing WHERE id = 1.
|
||||||
|
-- (Could use a settings KV table; this shape is clearer for
|
||||||
|
-- typed columns that the admin UI binds to directly.)
|
||||||
|
CREATE TABLE IF NOT EXISTS platform_pricing (
|
||||||
|
id INT PRIMARY KEY DEFAULT 1 CHECK (id = 1),
|
||||||
|
tenant_monthly_fee_chf NUMERIC(10,2) NOT NULL DEFAULT 0,
|
||||||
|
tenant_setup_fee_chf NUMERIC(10,2) NOT NULL DEFAULT 0,
|
||||||
|
-- Single price per Threema message; the relay reports in+out
|
||||||
|
-- separately but the spec ("billed per Message") treats both
|
||||||
|
-- directions identically. If asymmetric pricing is needed
|
||||||
|
-- later, split into in/out columns — additive change.
|
||||||
|
threema_message_chf NUMERIC(10,5) NOT NULL DEFAULT 0,
|
||||||
|
-- Default VAT rate for CH/LI customers, as percent (e.g. 8.10).
|
||||||
|
-- Foreign customers' effective rate is computed at invoice time
|
||||||
|
-- from billing address + VAT number (reverse-charge logic).
|
||||||
|
vat_rate_chli NUMERIC(5,2) NOT NULL DEFAULT 8.10,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
-- Ensure the single row exists. ON CONFLICT DO NOTHING is idempotent
|
||||||
|
-- on every migration run.
|
||||||
|
INSERT INTO platform_pricing (id) VALUES (1) ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
-- Per-package optional daily price. Any package id can have a row;
|
||||||
|
-- the admin UI in Phase 2 will only expose skill-category packages
|
||||||
|
-- because that's what the spec called for, but nothing here
|
||||||
|
-- prevents pricing other categories later.
|
||||||
|
--
|
||||||
|
-- "skill" naming follows the spec ("custom pricing also for skills").
|
||||||
|
CREATE TABLE IF NOT EXISTS skill_pricing (
|
||||||
|
skill_id TEXT PRIMARY KEY,
|
||||||
|
daily_price_chf NUMERIC(10,5) NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- One row per tenant. created_at anchors first-month proration;
|
||||||
|
-- deleted_at (nullable, stamped on delete) anchors last-month
|
||||||
|
-- proration. The PiecedTenant CR is the source of truth for
|
||||||
|
-- existence, but once the CR is deleted we lose its
|
||||||
|
-- creationTimestamp — so we mirror those two bookends here.
|
||||||
|
CREATE TABLE IF NOT EXISTS tenant_billing_lifecycle (
|
||||||
|
tenant_name TEXT PRIMARY KEY,
|
||||||
|
zitadel_org_id TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL,
|
||||||
|
deleted_at TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tenant_billing_lifecycle_org
|
||||||
|
ON tenant_billing_lifecycle(zitadel_org_id);
|
||||||
|
|
||||||
|
-- Skill enable/disable events. One row per state change; same-day
|
||||||
|
-- toggles still produce multiple rows, and the billing computation
|
||||||
|
-- collapses to distinct UTC days at compute time. This append-only
|
||||||
|
-- log preserves history for audit and lets us re-bill historical
|
||||||
|
-- months reproducibly.
|
||||||
|
--
|
||||||
|
-- skill_id is the package id from PACKAGE_CATALOG. We store
|
||||||
|
-- events for ALL package toggles, not just skill-category — the
|
||||||
|
-- channel/core toggles are cheap to record and may become billable
|
||||||
|
-- in the future without a schema change.
|
||||||
|
CREATE TABLE IF NOT EXISTS tenant_skill_events (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
tenant_name TEXT NOT NULL,
|
||||||
|
zitadel_org_id TEXT NOT NULL,
|
||||||
|
skill_id TEXT NOT NULL,
|
||||||
|
event_kind TEXT NOT NULL CHECK (event_kind IN ('enabled', 'disabled')),
|
||||||
|
occurred_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tenant_skill_events_tenant_skill
|
||||||
|
ON tenant_skill_events(tenant_name, skill_id, occurred_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tenant_skill_events_org_time
|
||||||
|
ON tenant_skill_events(zitadel_org_id, occurred_at);
|
||||||
|
|
||||||
|
-- Suspend/resume transitions. Same shape as skill events. Reading
|
||||||
|
-- these in order reconstructs the suspended-state segments for
|
||||||
|
-- monthly fee proration: a tenant in 'suspended' state pays no
|
||||||
|
-- monthly fee for the days it was suspended.
|
||||||
|
--
|
||||||
|
-- The portal commands the transition (PATCH spec.suspend); the
|
||||||
|
-- operator observes and stamps PiecedTenantStatus.suspendedAt
|
||||||
|
-- after reconcile. We record the event at command time — billing
|
||||||
|
-- is monthly so the few-second reconcile lag is irrelevant.
|
||||||
|
CREATE TABLE IF NOT EXISTS tenant_suspension_events (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
tenant_name TEXT NOT NULL,
|
||||||
|
zitadel_org_id TEXT NOT NULL,
|
||||||
|
event_kind TEXT NOT NULL CHECK (event_kind IN ('suspended','resumed')),
|
||||||
|
occurred_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tenant_suspension_events_tenant
|
||||||
|
ON tenant_suspension_events(tenant_name, occurred_at);
|
||||||
|
|
||||||
|
-- Per-org billing configuration. Distinct from org_billing
|
||||||
|
-- (address/VAT/email): that table is customer-editable, this one
|
||||||
|
-- is admin-controlled and holds the payment posture.
|
||||||
|
--
|
||||||
|
-- Defaults: a new org has pay_by_invoice = false (must use a
|
||||||
|
-- credit card per the onboarding gate in Phase 4) and auto
|
||||||
|
-- billing/reminders enabled. Admin can flip pay_by_invoice on
|
||||||
|
-- per customer, after which approval no longer requires a card.
|
||||||
|
CREATE TABLE IF NOT EXISTS org_billing_config (
|
||||||
|
zitadel_org_id TEXT PRIMARY KEY,
|
||||||
|
pay_by_invoice BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
stripe_customer_id TEXT,
|
||||||
|
auto_invoice_enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
auto_reminders_enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Stripe payment methods. Populated by the Phase 4 webhook handler.
|
||||||
|
-- Created in Phase 1 so all billing schema is together; rows are
|
||||||
|
-- empty until Phase 4 ships.
|
||||||
|
CREATE TABLE IF NOT EXISTS org_payment_methods (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
zitadel_org_id TEXT NOT NULL,
|
||||||
|
stripe_payment_method_id TEXT NOT NULL UNIQUE,
|
||||||
|
brand TEXT,
|
||||||
|
last4 TEXT,
|
||||||
|
exp_month INT,
|
||||||
|
exp_year INT,
|
||||||
|
is_default BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_org_payment_methods_org
|
||||||
|
ON org_payment_methods(zitadel_org_id);
|
||||||
|
-- At most one default payment method per org. Partial unique index
|
||||||
|
-- so non-default rows don't conflict with each other.
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uniq_org_payment_methods_default
|
||||||
|
ON org_payment_methods(zitadel_org_id) WHERE is_default = TRUE;
|
||||||
|
|
||||||
|
-- Gapless per-year invoice number counter (Art. 957a OR
|
||||||
|
-- compliance). A Postgres SEQUENCE would be faster but allows
|
||||||
|
-- gaps on rollback; this counter table is SELECT FOR UPDATE-able
|
||||||
|
-- and produces gapless numbers when the invoice insert is in the
|
||||||
|
-- same transaction. Populated lazily — the first invoice of each
|
||||||
|
-- year inserts its row.
|
||||||
|
CREATE TABLE IF NOT EXISTS invoice_number_counters (
|
||||||
|
year INT PRIMARY KEY,
|
||||||
|
last_number INT NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Issued invoices. Immutable once status leaves 'draft'.
|
||||||
|
-- billing_snapshot captures the address/VAT/email at issue time
|
||||||
|
-- so subsequent edits to org_billing don't mutate historical
|
||||||
|
-- invoices.
|
||||||
|
CREATE TABLE IF NOT EXISTS invoices (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
invoice_number TEXT NOT NULL UNIQUE,
|
||||||
|
zitadel_org_id TEXT NOT NULL,
|
||||||
|
-- Billing period as DATEs (not timestamps): a calendar month.
|
||||||
|
-- period_end is the last day of the month, inclusive.
|
||||||
|
period_start DATE NOT NULL,
|
||||||
|
period_end DATE NOT NULL,
|
||||||
|
issued_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
due_at DATE NOT NULL,
|
||||||
|
subtotal_chf NUMERIC(10,2) NOT NULL,
|
||||||
|
vat_rate NUMERIC(5,2) NOT NULL,
|
||||||
|
vat_amount_chf NUMERIC(10,2) NOT NULL,
|
||||||
|
total_chf NUMERIC(10,2) NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'open' CHECK (
|
||||||
|
status IN ('draft','open','paid','overdue','void','uncollectible')
|
||||||
|
),
|
||||||
|
billing_snapshot JSONB NOT NULL,
|
||||||
|
payment_method TEXT NOT NULL CHECK (payment_method IN ('invoice','card')),
|
||||||
|
stripe_payment_intent_id TEXT,
|
||||||
|
pdf_data BYTEA,
|
||||||
|
pdf_filename TEXT,
|
||||||
|
admin_notes TEXT,
|
||||||
|
paid_at TIMESTAMPTZ,
|
||||||
|
paid_by TEXT,
|
||||||
|
paid_method_detail TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_invoices_org
|
||||||
|
ON invoices(zitadel_org_id, issued_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_invoices_status
|
||||||
|
ON invoices(status, due_at);
|
||||||
|
-- One invoice per org per billing month — protects the monthly
|
||||||
|
-- cron from double-issuing if it gets retried mid-run.
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uniq_invoices_org_period
|
||||||
|
ON invoices(zitadel_org_id, period_start);
|
||||||
|
|
||||||
|
-- Invoice line items. The kind column lets the PDF renderer
|
||||||
|
-- group lines (all monthly fees together, all AI usage together,
|
||||||
|
-- etc.) and the admin UI filter by category.
|
||||||
|
CREATE TABLE IF NOT EXISTS invoice_lines (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
invoice_id UUID NOT NULL REFERENCES invoices(id) ON DELETE CASCADE,
|
||||||
|
-- NULL for org-wide items; tenant name for per-tenant breakdowns.
|
||||||
|
tenant_name TEXT,
|
||||||
|
kind TEXT NOT NULL CHECK (kind IN (
|
||||||
|
'tenant_monthly','tenant_setup','ai_usage','threema_messages','skill_usage','adjustment'
|
||||||
|
)),
|
||||||
|
description TEXT NOT NULL,
|
||||||
|
quantity NUMERIC(12,4) NOT NULL DEFAULT 1,
|
||||||
|
unit_label TEXT,
|
||||||
|
unit_price_chf NUMERIC(10,5) NOT NULL,
|
||||||
|
amount_chf NUMERIC(10,2) NOT NULL,
|
||||||
|
-- Per-kind audit metadata (e.g. {proration_days, days_in_month}
|
||||||
|
-- for tenant_monthly; {in_count, out_count} for threema_messages).
|
||||||
|
metadata JSONB,
|
||||||
|
display_order INT NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_invoice_lines_invoice
|
||||||
|
ON invoice_lines(invoice_id, display_order);
|
||||||
|
|
||||||
|
-- Reminders fired against open/overdue invoices. Level 3 = final.
|
||||||
|
-- One PDF per reminder, stored alongside.
|
||||||
|
CREATE TABLE IF NOT EXISTS invoice_reminders (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
invoice_id UUID NOT NULL REFERENCES invoices(id) ON DELETE CASCADE,
|
||||||
|
level INT NOT NULL CHECK (level IN (1, 2, 3)),
|
||||||
|
sent_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
sent_by TEXT NOT NULL,
|
||||||
|
pdf_data BYTEA,
|
||||||
|
pdf_filename TEXT,
|
||||||
|
email_sent_to TEXT,
|
||||||
|
UNIQUE (invoice_id, level)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_invoice_reminders_invoice
|
||||||
|
ON invoice_reminders(invoice_id, level);
|
||||||
`;
|
`;
|
||||||
|
|
||||||
let migrated = false;
|
let migrated = false;
|
||||||
@@ -1336,3 +1582,545 @@ export async function updateSupportTicket(
|
|||||||
);
|
);
|
||||||
return result.rows.length > 0 ? rowToSupportTicket(result.rows[0]) : null;
|
return result.rows.length > 0 ? rowToSupportTicket(result.rows[0]) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Billing — Phase 1: pricing, lifecycle, and skill events
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
//
|
||||||
|
// All helpers are intentionally narrow CRUD — no business logic.
|
||||||
|
// Higher-level operations (compute monthly proration, build an
|
||||||
|
// invoice from raw signals) belong in a future lib/billing.ts
|
||||||
|
// introduced by Phase 2.
|
||||||
|
//
|
||||||
|
// Hook callers should treat every write here as best-effort: if
|
||||||
|
// recording a lifecycle/event row fails, log and continue — never
|
||||||
|
// fail the underlying K8s mutation. Drift is corrected by the
|
||||||
|
// idempotent backfill helper at the bottom of this section.
|
||||||
|
|
||||||
|
import type {
|
||||||
|
PlatformPricing,
|
||||||
|
SkillPricing,
|
||||||
|
OrgBillingConfig,
|
||||||
|
TenantBillingLifecycle,
|
||||||
|
TenantSkillEvent,
|
||||||
|
TenantSuspensionEvent,
|
||||||
|
} from "@/types";
|
||||||
|
|
||||||
|
// --- platform_pricing ------------------------------------------------------
|
||||||
|
|
||||||
|
function rowToPlatformPricing(row: any): PlatformPricing {
|
||||||
|
return {
|
||||||
|
tenantMonthlyFeeChf: Number(row.tenant_monthly_fee_chf),
|
||||||
|
tenantSetupFeeChf: Number(row.tenant_setup_fee_chf),
|
||||||
|
threemaMessageChf: Number(row.threema_message_chf),
|
||||||
|
vatRateChli: Number(row.vat_rate_chli),
|
||||||
|
updatedAt: row.updated_at?.toISOString?.() ?? row.updated_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the single-row platform pricing config. The migration seeds
|
||||||
|
* the row with zeros on first run, so this never returns null on a
|
||||||
|
* properly migrated database.
|
||||||
|
*/
|
||||||
|
export async function getPlatformPricing(): Promise<PlatformPricing> {
|
||||||
|
await ensureSchema();
|
||||||
|
const result = await getPool().query(
|
||||||
|
"SELECT * FROM platform_pricing WHERE id = 1"
|
||||||
|
);
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
// Defensive: re-seed if the row went missing (manual DELETE,
|
||||||
|
// partial restore, etc.). The migration's INSERT ON CONFLICT
|
||||||
|
// should have ensured this, but a stale row state shouldn't
|
||||||
|
// crash the helper.
|
||||||
|
await getPool().query(
|
||||||
|
"INSERT INTO platform_pricing (id) VALUES (1) ON CONFLICT DO NOTHING"
|
||||||
|
);
|
||||||
|
const retry = await getPool().query(
|
||||||
|
"SELECT * FROM platform_pricing WHERE id = 1"
|
||||||
|
);
|
||||||
|
return rowToPlatformPricing(retry.rows[0]);
|
||||||
|
}
|
||||||
|
return rowToPlatformPricing(result.rows[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update one or more pricing fields. Pass only the fields to change;
|
||||||
|
* unspecified fields are left as-is. `updated_at` is always refreshed.
|
||||||
|
*/
|
||||||
|
export async function updatePlatformPricing(changes: {
|
||||||
|
tenantMonthlyFeeChf?: number;
|
||||||
|
tenantSetupFeeChf?: number;
|
||||||
|
threemaMessageChf?: number;
|
||||||
|
vatRateChli?: number;
|
||||||
|
}): Promise<PlatformPricing> {
|
||||||
|
await ensureSchema();
|
||||||
|
const sets: string[] = ["updated_at = now()"];
|
||||||
|
const values: any[] = [];
|
||||||
|
let idx = 1;
|
||||||
|
if (changes.tenantMonthlyFeeChf !== undefined) {
|
||||||
|
sets.push(`tenant_monthly_fee_chf = $${idx++}`);
|
||||||
|
values.push(changes.tenantMonthlyFeeChf);
|
||||||
|
}
|
||||||
|
if (changes.tenantSetupFeeChf !== undefined) {
|
||||||
|
sets.push(`tenant_setup_fee_chf = $${idx++}`);
|
||||||
|
values.push(changes.tenantSetupFeeChf);
|
||||||
|
}
|
||||||
|
if (changes.threemaMessageChf !== undefined) {
|
||||||
|
sets.push(`threema_message_chf = $${idx++}`);
|
||||||
|
values.push(changes.threemaMessageChf);
|
||||||
|
}
|
||||||
|
if (changes.vatRateChli !== undefined) {
|
||||||
|
sets.push(`vat_rate_chli = $${idx++}`);
|
||||||
|
values.push(changes.vatRateChli);
|
||||||
|
}
|
||||||
|
// Only updated_at would change → still execute so the caller's
|
||||||
|
// intent ("touch the row") isn't silently dropped, but make it
|
||||||
|
// an explicit no-op if no fields were provided.
|
||||||
|
if (sets.length === 1 && values.length === 0) {
|
||||||
|
return getPlatformPricing();
|
||||||
|
}
|
||||||
|
const result = await getPool().query(
|
||||||
|
`UPDATE platform_pricing SET ${sets.join(", ")} WHERE id = 1 RETURNING *`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
return rowToPlatformPricing(result.rows[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- skill_pricing ---------------------------------------------------------
|
||||||
|
|
||||||
|
function rowToSkillPricing(row: any): SkillPricing {
|
||||||
|
return {
|
||||||
|
skillId: row.skill_id,
|
||||||
|
dailyPriceChf: Number(row.daily_price_chf),
|
||||||
|
createdAt: row.created_at?.toISOString?.() ?? row.created_at,
|
||||||
|
updatedAt: row.updated_at?.toISOString?.() ?? row.updated_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listSkillPricing(): Promise<SkillPricing[]> {
|
||||||
|
await ensureSchema();
|
||||||
|
const result = await getPool().query(
|
||||||
|
"SELECT * FROM skill_pricing ORDER BY skill_id"
|
||||||
|
);
|
||||||
|
return result.rows.map(rowToSkillPricing);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSkillPricing(
|
||||||
|
skillId: string
|
||||||
|
): Promise<SkillPricing | null> {
|
||||||
|
await ensureSchema();
|
||||||
|
const result = await getPool().query(
|
||||||
|
"SELECT * FROM skill_pricing WHERE skill_id = $1",
|
||||||
|
[skillId]
|
||||||
|
);
|
||||||
|
return result.rows.length > 0 ? rowToSkillPricing(result.rows[0]) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upsert a daily price for a package. Setting a price activates
|
||||||
|
* usage-based billing for the (tenant, skill) pair: every UTC day
|
||||||
|
* the package was enabled in the billing month is one unit on the
|
||||||
|
* invoice.
|
||||||
|
*/
|
||||||
|
export async function setSkillPricing(
|
||||||
|
skillId: string,
|
||||||
|
dailyPriceChf: number
|
||||||
|
): Promise<SkillPricing> {
|
||||||
|
await ensureSchema();
|
||||||
|
const result = await getPool().query(
|
||||||
|
`INSERT INTO skill_pricing (skill_id, daily_price_chf)
|
||||||
|
VALUES ($1, $2)
|
||||||
|
ON CONFLICT (skill_id) DO UPDATE SET
|
||||||
|
daily_price_chf = EXCLUDED.daily_price_chf,
|
||||||
|
updated_at = now()
|
||||||
|
RETURNING *`,
|
||||||
|
[skillId, dailyPriceChf]
|
||||||
|
);
|
||||||
|
return rowToSkillPricing(result.rows[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove the price for a package. Day-tracking events continue to
|
||||||
|
* be recorded (cheap append-only) but the package becomes free
|
||||||
|
* effective immediately. Historical invoices already issued are
|
||||||
|
* unaffected.
|
||||||
|
*/
|
||||||
|
export async function removeSkillPricing(skillId: string): Promise<void> {
|
||||||
|
await ensureSchema();
|
||||||
|
await getPool().query("DELETE FROM skill_pricing WHERE skill_id = $1", [
|
||||||
|
skillId,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- tenant_billing_lifecycle ---------------------------------------------
|
||||||
|
|
||||||
|
function rowToTenantBillingLifecycle(row: any): TenantBillingLifecycle {
|
||||||
|
return {
|
||||||
|
tenantName: row.tenant_name,
|
||||||
|
zitadelOrgId: row.zitadel_org_id,
|
||||||
|
createdAt: row.created_at?.toISOString?.() ?? row.created_at,
|
||||||
|
deletedAt: row.deleted_at?.toISOString?.() ?? row.deleted_at ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTenantBillingLifecycle(
|
||||||
|
tenantName: string
|
||||||
|
): Promise<TenantBillingLifecycle | null> {
|
||||||
|
await ensureSchema();
|
||||||
|
const result = await getPool().query(
|
||||||
|
"SELECT * FROM tenant_billing_lifecycle WHERE tenant_name = $1",
|
||||||
|
[tenantName]
|
||||||
|
);
|
||||||
|
return result.rows.length > 0
|
||||||
|
? rowToTenantBillingLifecycle(result.rows[0])
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record a tenant's creation for billing purposes. Idempotent on
|
||||||
|
* `tenant_name` — re-running with a different created_at is a no-op
|
||||||
|
* so re-approvals don't move the proration anchor. Pair with
|
||||||
|
* recordInitialSkillEvents() at the same call site.
|
||||||
|
*/
|
||||||
|
export async function recordTenantCreated(
|
||||||
|
tenantName: string,
|
||||||
|
zitadelOrgId: string,
|
||||||
|
createdAt?: Date
|
||||||
|
): Promise<void> {
|
||||||
|
await ensureSchema();
|
||||||
|
await getPool().query(
|
||||||
|
`INSERT INTO tenant_billing_lifecycle (tenant_name, zitadel_org_id, created_at)
|
||||||
|
VALUES ($1, $2, COALESCE($3::timestamptz, now()))
|
||||||
|
ON CONFLICT (tenant_name) DO NOTHING`,
|
||||||
|
[tenantName, zitadelOrgId, createdAt ?? null]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stamp deletion timestamp. Idempotent — calling twice keeps the
|
||||||
|
* first deletion's timestamp. Pair with closing-skill-disabled
|
||||||
|
* events at the same call site if you want the events log to
|
||||||
|
* reflect that everything is now off.
|
||||||
|
*
|
||||||
|
* We deliberately don't delete the row — the lifecycle record is
|
||||||
|
* needed for any final invoice covering the deletion month.
|
||||||
|
*/
|
||||||
|
export async function recordTenantDeleted(
|
||||||
|
tenantName: string,
|
||||||
|
deletedAt?: Date
|
||||||
|
): Promise<void> {
|
||||||
|
await ensureSchema();
|
||||||
|
await getPool().query(
|
||||||
|
`UPDATE tenant_billing_lifecycle
|
||||||
|
SET deleted_at = COALESCE(deleted_at, COALESCE($2::timestamptz, now()))
|
||||||
|
WHERE tenant_name = $1`,
|
||||||
|
[tenantName, deletedAt ?? null]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- tenant_skill_events --------------------------------------------------
|
||||||
|
|
||||||
|
function rowToTenantSkillEvent(row: any): TenantSkillEvent {
|
||||||
|
return {
|
||||||
|
id: String(row.id),
|
||||||
|
tenantName: row.tenant_name,
|
||||||
|
zitadelOrgId: row.zitadel_org_id,
|
||||||
|
skillId: row.skill_id,
|
||||||
|
eventKind: row.event_kind as "enabled" | "disabled",
|
||||||
|
occurredAt: row.occurred_at?.toISOString?.() ?? row.occurred_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Append a batch of enabled/disabled events. Single multi-row INSERT
|
||||||
|
* so all rows share an effectively-identical occurred_at when
|
||||||
|
* `occurredAt` is omitted (otherwise they'd be a few microseconds
|
||||||
|
* apart, which would skew the "is this skill on for day D" computation
|
||||||
|
* around midnight UTC).
|
||||||
|
*
|
||||||
|
* Empty arrays are a no-op (no SQL fired).
|
||||||
|
*/
|
||||||
|
export async function recordSkillEvents(
|
||||||
|
tenantName: string,
|
||||||
|
zitadelOrgId: string,
|
||||||
|
added: string[],
|
||||||
|
removed: string[],
|
||||||
|
occurredAt?: Date
|
||||||
|
): Promise<void> {
|
||||||
|
if (added.length === 0 && removed.length === 0) return;
|
||||||
|
await ensureSchema();
|
||||||
|
const at = occurredAt ?? new Date();
|
||||||
|
// Build placeholders. Each row uses 5 placeholders.
|
||||||
|
const values: any[] = [];
|
||||||
|
const rows: string[] = [];
|
||||||
|
let idx = 1;
|
||||||
|
for (const skillId of added) {
|
||||||
|
rows.push(`($${idx++}, $${idx++}, $${idx++}, 'enabled', $${idx++})`);
|
||||||
|
values.push(tenantName, zitadelOrgId, skillId, at);
|
||||||
|
}
|
||||||
|
for (const skillId of removed) {
|
||||||
|
rows.push(`($${idx++}, $${idx++}, $${idx++}, 'disabled', $${idx++})`);
|
||||||
|
values.push(tenantName, zitadelOrgId, skillId, at);
|
||||||
|
}
|
||||||
|
await getPool().query(
|
||||||
|
`INSERT INTO tenant_skill_events
|
||||||
|
(tenant_name, zitadel_org_id, skill_id, event_kind, occurred_at)
|
||||||
|
VALUES ${rows.join(", ")}`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read events for a tenant within a half-open interval — `from`
|
||||||
|
* inclusive, `to` exclusive. Used by Phase 2's billing computation
|
||||||
|
* to collapse to billable days. Returned in chronological order.
|
||||||
|
*/
|
||||||
|
export async function listSkillEventsForTenant(
|
||||||
|
tenantName: string,
|
||||||
|
from: Date,
|
||||||
|
to: Date
|
||||||
|
): Promise<TenantSkillEvent[]> {
|
||||||
|
await ensureSchema();
|
||||||
|
const result = await getPool().query(
|
||||||
|
`SELECT * FROM tenant_skill_events
|
||||||
|
WHERE tenant_name = $1
|
||||||
|
AND occurred_at >= $2
|
||||||
|
AND occurred_at < $3
|
||||||
|
ORDER BY occurred_at, id`,
|
||||||
|
[tenantName, from, to]
|
||||||
|
);
|
||||||
|
return result.rows.map(rowToTenantSkillEvent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the most recent event for each (tenant, skill) pair as of
|
||||||
|
* a moment in time. Phase 2 uses this to know which skills were
|
||||||
|
* enabled at the start of a billing window.
|
||||||
|
*
|
||||||
|
* Implemented with DISTINCT ON for a single round-trip; the
|
||||||
|
* (tenant_name, skill_id, occurred_at) index supports the sort.
|
||||||
|
*/
|
||||||
|
export async function getSkillStateAt(
|
||||||
|
tenantName: string,
|
||||||
|
asOf: Date
|
||||||
|
): Promise<Record<string, "enabled" | "disabled">> {
|
||||||
|
await ensureSchema();
|
||||||
|
const result = await getPool().query(
|
||||||
|
`SELECT DISTINCT ON (skill_id) skill_id, event_kind
|
||||||
|
FROM tenant_skill_events
|
||||||
|
WHERE tenant_name = $1 AND occurred_at <= $2
|
||||||
|
ORDER BY skill_id, occurred_at DESC, id DESC`,
|
||||||
|
[tenantName, asOf]
|
||||||
|
);
|
||||||
|
const out: Record<string, "enabled" | "disabled"> = {};
|
||||||
|
for (const row of result.rows) {
|
||||||
|
out[row.skill_id] = row.event_kind as "enabled" | "disabled";
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- tenant_suspension_events ---------------------------------------------
|
||||||
|
|
||||||
|
function rowToTenantSuspensionEvent(row: any): TenantSuspensionEvent {
|
||||||
|
return {
|
||||||
|
id: String(row.id),
|
||||||
|
tenantName: row.tenant_name,
|
||||||
|
zitadelOrgId: row.zitadel_org_id,
|
||||||
|
eventKind: row.event_kind as "suspended" | "resumed",
|
||||||
|
occurredAt: row.occurred_at?.toISOString?.() ?? row.occurred_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function recordSuspensionEvent(
|
||||||
|
tenantName: string,
|
||||||
|
zitadelOrgId: string,
|
||||||
|
eventKind: "suspended" | "resumed",
|
||||||
|
occurredAt?: Date
|
||||||
|
): Promise<void> {
|
||||||
|
await ensureSchema();
|
||||||
|
await getPool().query(
|
||||||
|
`INSERT INTO tenant_suspension_events
|
||||||
|
(tenant_name, zitadel_org_id, event_kind, occurred_at)
|
||||||
|
VALUES ($1, $2, $3, COALESCE($4::timestamptz, now()))`,
|
||||||
|
[tenantName, zitadelOrgId, eventKind, occurredAt ?? null]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listSuspensionEventsForTenant(
|
||||||
|
tenantName: string,
|
||||||
|
from: Date,
|
||||||
|
to: Date
|
||||||
|
): Promise<TenantSuspensionEvent[]> {
|
||||||
|
await ensureSchema();
|
||||||
|
const result = await getPool().query(
|
||||||
|
`SELECT * FROM tenant_suspension_events
|
||||||
|
WHERE tenant_name = $1
|
||||||
|
AND occurred_at >= $2
|
||||||
|
AND occurred_at < $3
|
||||||
|
ORDER BY occurred_at, id`,
|
||||||
|
[tenantName, from, to]
|
||||||
|
);
|
||||||
|
return result.rows.map(rowToTenantSuspensionEvent);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- org_billing_config ---------------------------------------------------
|
||||||
|
|
||||||
|
function rowToOrgBillingConfig(row: any): OrgBillingConfig {
|
||||||
|
return {
|
||||||
|
zitadelOrgId: row.zitadel_org_id,
|
||||||
|
payByInvoice: row.pay_by_invoice,
|
||||||
|
stripeCustomerId: row.stripe_customer_id ?? null,
|
||||||
|
autoInvoiceEnabled: row.auto_invoice_enabled,
|
||||||
|
autoRemindersEnabled: row.auto_reminders_enabled,
|
||||||
|
createdAt: row.created_at?.toISOString?.() ?? row.created_at,
|
||||||
|
updatedAt: row.updated_at?.toISOString?.() ?? row.updated_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get config for an org, auto-creating with defaults if missing.
|
||||||
|
* Returning the row (vs null) simplifies callers: the gate logic in
|
||||||
|
* Phase 4 ("approve only if pay_by_invoice OR has card") doesn't
|
||||||
|
* need a "what if no row" branch.
|
||||||
|
*
|
||||||
|
* Defaults are baked into the table's column defaults, so the
|
||||||
|
* INSERT here only needs the primary key.
|
||||||
|
*/
|
||||||
|
export async function getOrgBillingConfig(
|
||||||
|
zitadelOrgId: string
|
||||||
|
): Promise<OrgBillingConfig> {
|
||||||
|
await ensureSchema();
|
||||||
|
await getPool().query(
|
||||||
|
`INSERT INTO org_billing_config (zitadel_org_id)
|
||||||
|
VALUES ($1)
|
||||||
|
ON CONFLICT (zitadel_org_id) DO NOTHING`,
|
||||||
|
[zitadelOrgId]
|
||||||
|
);
|
||||||
|
const result = await getPool().query(
|
||||||
|
"SELECT * FROM org_billing_config WHERE zitadel_org_id = $1",
|
||||||
|
[zitadelOrgId]
|
||||||
|
);
|
||||||
|
return rowToOrgBillingConfig(result.rows[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateOrgBillingConfig(
|
||||||
|
zitadelOrgId: string,
|
||||||
|
changes: {
|
||||||
|
payByInvoice?: boolean;
|
||||||
|
stripeCustomerId?: string | null;
|
||||||
|
autoInvoiceEnabled?: boolean;
|
||||||
|
autoRemindersEnabled?: boolean;
|
||||||
|
}
|
||||||
|
): Promise<OrgBillingConfig> {
|
||||||
|
await ensureSchema();
|
||||||
|
// Ensure row exists first — mirrors getOrgBillingConfig's
|
||||||
|
// auto-create.
|
||||||
|
await getPool().query(
|
||||||
|
`INSERT INTO org_billing_config (zitadel_org_id)
|
||||||
|
VALUES ($1)
|
||||||
|
ON CONFLICT (zitadel_org_id) DO NOTHING`,
|
||||||
|
[zitadelOrgId]
|
||||||
|
);
|
||||||
|
const sets: string[] = ["updated_at = now()"];
|
||||||
|
const values: any[] = [zitadelOrgId];
|
||||||
|
let idx = 2;
|
||||||
|
if (changes.payByInvoice !== undefined) {
|
||||||
|
sets.push(`pay_by_invoice = $${idx++}`);
|
||||||
|
values.push(changes.payByInvoice);
|
||||||
|
}
|
||||||
|
if (changes.stripeCustomerId !== undefined) {
|
||||||
|
sets.push(`stripe_customer_id = $${idx++}`);
|
||||||
|
values.push(changes.stripeCustomerId);
|
||||||
|
}
|
||||||
|
if (changes.autoInvoiceEnabled !== undefined) {
|
||||||
|
sets.push(`auto_invoice_enabled = $${idx++}`);
|
||||||
|
values.push(changes.autoInvoiceEnabled);
|
||||||
|
}
|
||||||
|
if (changes.autoRemindersEnabled !== undefined) {
|
||||||
|
sets.push(`auto_reminders_enabled = $${idx++}`);
|
||||||
|
values.push(changes.autoRemindersEnabled);
|
||||||
|
}
|
||||||
|
const result = await getPool().query(
|
||||||
|
`UPDATE org_billing_config
|
||||||
|
SET ${sets.join(", ")}
|
||||||
|
WHERE zitadel_org_id = $1
|
||||||
|
RETURNING *`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
return rowToOrgBillingConfig(result.rows[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Backfill -------------------------------------------------------------
|
||||||
|
//
|
||||||
|
// Idempotent one-time bootstrap for tenants that existed before
|
||||||
|
// Phase 1 shipped. Phase 2 wraps this in an admin endpoint; for now
|
||||||
|
// it can be invoked from a one-off node script.
|
||||||
|
//
|
||||||
|
// For each PiecedTenant CR:
|
||||||
|
// - If no tenant_billing_lifecycle row exists, insert one with
|
||||||
|
// created_at = metadata.creationTimestamp.
|
||||||
|
// - If no tenant_skill_events row exists for the tenant, insert
|
||||||
|
// 'enabled' events at the same timestamp for every package
|
||||||
|
// currently in spec.packages.
|
||||||
|
// Tenants suspended at backfill time get a 'suspended' event at
|
||||||
|
// status.suspendedAt (operator-stamped); resumed tenants get nothing
|
||||||
|
// extra (default state is "running").
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a count of lifecycle rows inserted and skill events
|
||||||
|
* recorded — both expected to be zero on a second run.
|
||||||
|
*/
|
||||||
|
export async function backfillTenantBillingLifecycle(tenants: {
|
||||||
|
name: string;
|
||||||
|
zitadelOrgId: string;
|
||||||
|
createdAt: Date;
|
||||||
|
packages: string[];
|
||||||
|
suspendedAt: Date | null;
|
||||||
|
}[]): Promise<{ lifecycleInserted: number; eventsInserted: number; suspensionEventsInserted: number }> {
|
||||||
|
await ensureSchema();
|
||||||
|
let lifecycleInserted = 0;
|
||||||
|
let eventsInserted = 0;
|
||||||
|
let suspensionEventsInserted = 0;
|
||||||
|
for (const t of tenants) {
|
||||||
|
// Lifecycle row — idempotent.
|
||||||
|
const existing = await getTenantBillingLifecycle(t.name);
|
||||||
|
if (!existing) {
|
||||||
|
await recordTenantCreated(t.name, t.zitadelOrgId, t.createdAt);
|
||||||
|
lifecycleInserted++;
|
||||||
|
}
|
||||||
|
// Initial skill events — only if the tenant has zero events at
|
||||||
|
// all. We don't want to add to an active event stream.
|
||||||
|
const eventsRow = await getPool().query(
|
||||||
|
"SELECT 1 FROM tenant_skill_events WHERE tenant_name = $1 LIMIT 1",
|
||||||
|
[t.name]
|
||||||
|
);
|
||||||
|
if (eventsRow.rows.length === 0 && t.packages.length > 0) {
|
||||||
|
await recordSkillEvents(
|
||||||
|
t.name,
|
||||||
|
t.zitadelOrgId,
|
||||||
|
t.packages,
|
||||||
|
[],
|
||||||
|
t.createdAt
|
||||||
|
);
|
||||||
|
eventsInserted += t.packages.length;
|
||||||
|
}
|
||||||
|
// Suspension state — only if the tenant has zero suspension
|
||||||
|
// events. If it's currently suspended, record one 'suspended'
|
||||||
|
// event at the operator-stamped time so proration sees it.
|
||||||
|
const susRow = await getPool().query(
|
||||||
|
"SELECT 1 FROM tenant_suspension_events WHERE tenant_name = $1 LIMIT 1",
|
||||||
|
[t.name]
|
||||||
|
);
|
||||||
|
if (susRow.rows.length === 0 && t.suspendedAt) {
|
||||||
|
await recordSuspensionEvent(
|
||||||
|
t.name,
|
||||||
|
t.zitadelOrgId,
|
||||||
|
"suspended",
|
||||||
|
t.suspendedAt
|
||||||
|
);
|
||||||
|
suspensionEventsInserted++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { lifecycleInserted, eventsInserted, suspensionEventsInserted };
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,21 +13,19 @@
|
|||||||
* env var that lists the active account.
|
* env var that lists the active account.
|
||||||
* 2. Move the QR PNG into a server-rendered route that takes a
|
* 2. Move the QR PNG into a server-rendered route that takes a
|
||||||
* gateway ID query param.
|
* gateway ID query param.
|
||||||
* 3. Update consumers (today only ThreemaSetup) to accept the
|
* 3. Update consumers to accept the gateway info as a prop and pass
|
||||||
* gateway info as a prop and pass it from a server component.
|
* it from a server component.
|
||||||
*
|
|
||||||
* In display contexts we strip the leading asterisk from the Threema
|
|
||||||
* ID — customers don't understand the `*X` prefix convention used for
|
|
||||||
* Gateway accounts, and the QR code carries the real value anyway. We
|
|
||||||
* keep the asterisk only for places where the technical value matters
|
|
||||||
* (server-side message routing, debug logs).
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export const THREEMA_GATEWAY = {
|
export const THREEMA_GATEWAY = {
|
||||||
/** Technical Threema Gateway ID, with leading asterisk. */
|
/** Technical Threema Gateway ID, with leading asterisk. */
|
||||||
id: "*AIAGENT",
|
id: "*AIAGENT",
|
||||||
/** Display name shown to customers (no asterisk). */
|
/**
|
||||||
displayName: "AIAGENT",
|
* Display name shown to customers. INCLUDES the leading asterisk —
|
||||||
|
* customers need to recognise this exact string in their Threema
|
||||||
|
* contacts after scanning the QR, so we don't strip it.
|
||||||
|
*/
|
||||||
|
displayName: "*AIAGENT",
|
||||||
/** Public path to the QR code PNG served from `public/`. */
|
/** Public path to the QR code PNG served from `public/`. */
|
||||||
qrCodePath: "/threema/qr_code_AIAGENT.png",
|
qrCodePath: "/threema/qr_code_AIAGENT.png",
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -403,7 +403,9 @@
|
|||||||
"step2": "Tippen Sie auf das Scan-Symbol und scannen Sie diesen QR-Code, um den Assistenten als Kontakt hinzuzufügen.",
|
"step2": "Tippen Sie auf das Scan-Symbol und scannen Sie diesen QR-Code, um den Assistenten als Kontakt hinzuzufügen.",
|
||||||
"step3": "Fügen Sie anschliessend unten Ihre eigene Threema-ID hinzu.",
|
"step3": "Fügen Sie anschliessend unten Ihre eigene Threema-ID hinzu.",
|
||||||
"qrAlt": "QR-Code, um {gateway} als Threema-Kontakt hinzuzufügen",
|
"qrAlt": "QR-Code, um {gateway} als Threema-Kontakt hinzuzufügen",
|
||||||
"showQr": "QR anzeigen"
|
"bannerTitle": "Threema einrichten",
|
||||||
|
"bannerBody": "Öffnen Sie Threema auf Ihrem Telefon und scannen Sie unseren QR-Code, um den Assistenten als Kontakt hinzuzufügen. Geben Sie anschliessend unten Ihre eigene Threema-ID ein.",
|
||||||
|
"bannerButton": "QR-Code anzeigen"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"team": {
|
"team": {
|
||||||
|
|||||||
@@ -403,7 +403,9 @@
|
|||||||
"step2": "Tap the scan icon and scan this QR code to add the assistant as a contact.",
|
"step2": "Tap the scan icon and scan this QR code to add the assistant as a contact.",
|
||||||
"step3": "Then add your own Threema ID below.",
|
"step3": "Then add your own Threema ID below.",
|
||||||
"qrAlt": "QR code to add {gateway} as a Threema contact",
|
"qrAlt": "QR code to add {gateway} as a Threema contact",
|
||||||
"showQr": "Show QR"
|
"bannerTitle": "Set up Threema",
|
||||||
|
"bannerBody": "Open Threema on your phone and scan our QR code to add the assistant as a contact. Then add your own Threema ID below.",
|
||||||
|
"bannerButton": "Show QR code"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"team": {
|
"team": {
|
||||||
|
|||||||
@@ -403,7 +403,9 @@
|
|||||||
"step2": "Appuyez sur l'icône de scan et scannez ce QR code pour ajouter l'assistant comme contact.",
|
"step2": "Appuyez sur l'icône de scan et scannez ce QR code pour ajouter l'assistant comme contact.",
|
||||||
"step3": "Puis ajoutez votre propre identifiant Threema ci-dessous.",
|
"step3": "Puis ajoutez votre propre identifiant Threema ci-dessous.",
|
||||||
"qrAlt": "QR code pour ajouter {gateway} comme contact Threema",
|
"qrAlt": "QR code pour ajouter {gateway} comme contact Threema",
|
||||||
"showQr": "Afficher le QR"
|
"bannerTitle": "Configurer Threema",
|
||||||
|
"bannerBody": "Ouvrez Threema sur votre téléphone et scannez notre QR code pour ajouter l'assistant comme contact. Saisissez ensuite votre propre identifiant Threema ci-dessous.",
|
||||||
|
"bannerButton": "Afficher le QR code"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"team": {
|
"team": {
|
||||||
|
|||||||
@@ -403,7 +403,9 @@
|
|||||||
"step2": "Tocca l'icona di scansione e scansiona questo QR code per aggiungere l'assistente ai contatti.",
|
"step2": "Tocca l'icona di scansione e scansiona questo QR code per aggiungere l'assistente ai contatti.",
|
||||||
"step3": "Quindi aggiungi il tuo ID Threema qui sotto.",
|
"step3": "Quindi aggiungi il tuo ID Threema qui sotto.",
|
||||||
"qrAlt": "QR code per aggiungere {gateway} come contatto Threema",
|
"qrAlt": "QR code per aggiungere {gateway} come contatto Threema",
|
||||||
"showQr": "Mostra QR"
|
"bannerTitle": "Configura Threema",
|
||||||
|
"bannerBody": "Apri Threema sul tuo telefono e scansiona il nostro QR code per aggiungere l'assistente ai contatti. Inserisci poi il tuo ID Threema qui sotto.",
|
||||||
|
"bannerButton": "Mostra QR code"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"team": {
|
"team": {
|
||||||
|
|||||||
@@ -40,5 +40,10 @@ export default async function middleware(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
matcher: ["/((?!_next|favicon.ico|api).*)"],
|
// Excludes _next/* internal routes, the favicon, api routes, AND any
|
||||||
|
// path containing a dot (covers all static files served from public/,
|
||||||
|
// e.g. /threema/qr_code_AIAGENT.png). Without the dot exclusion, the
|
||||||
|
// i18n middleware prepends the locale ("/en/threema/qr_code_AIAGENT.png")
|
||||||
|
// and the file is not found.
|
||||||
|
matcher: ["/((?!_next|favicon.ico|api|.*\\..*).*)"],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -412,3 +412,111 @@ export interface SupportTicketDetail {
|
|||||||
ticket: SupportTicket;
|
ticket: SupportTicket;
|
||||||
comments: SupportTicketComment[];
|
comments: SupportTicketComment[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Billing — Phase 1: pricing, lifecycle, and events
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
//
|
||||||
|
// All money values are numbers (CHF). The DB stores NUMERIC and the
|
||||||
|
// helpers coerce to Number on read. JS floats are exact for integers
|
||||||
|
// up to 2^53; at this domain (CHF amounts to 2 decimals, unit prices
|
||||||
|
// to 5 decimals) precision is fine. The Phase 2 billing computation
|
||||||
|
// will still do arithmetic carefully (sum in cents, round at the
|
||||||
|
// end) to avoid 0.1 + 0.2 surprises.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single-row platform pricing config. Editable via the admin
|
||||||
|
* pricing page (Phase 2). The `vatRateChli` field is the rate
|
||||||
|
* applied to invoices whose billing address resolves to CH/LI;
|
||||||
|
* foreign customers' rates are decided per-invoice from address +
|
||||||
|
* VAT number, not from this config.
|
||||||
|
*/
|
||||||
|
export interface PlatformPricing {
|
||||||
|
tenantMonthlyFeeChf: number;
|
||||||
|
tenantSetupFeeChf: number;
|
||||||
|
threemaMessageChf: number;
|
||||||
|
vatRateChli: number;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-package daily price. Phase 2's admin UI restricts setting
|
||||||
|
* these to skill-category packages, but the schema accepts any
|
||||||
|
* package id. A row's existence is what activates billing for that
|
||||||
|
* package; deleting the row makes it free without affecting the
|
||||||
|
* append-only event log.
|
||||||
|
*/
|
||||||
|
export interface SkillPricing {
|
||||||
|
skillId: string;
|
||||||
|
dailyPriceChf: number;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tenant lifecycle bookends mirrored from K8s into Postgres so
|
||||||
|
* deleted tenants still have a billing record for their final
|
||||||
|
* invoice. `createdAt` matches PiecedTenant.metadata.creationTimestamp
|
||||||
|
* at the moment of approval; `deletedAt` is stamped when the admin
|
||||||
|
* delete endpoint runs.
|
||||||
|
*/
|
||||||
|
export interface TenantBillingLifecycle {
|
||||||
|
tenantName: string;
|
||||||
|
zitadelOrgId: string;
|
||||||
|
createdAt: string;
|
||||||
|
deletedAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Append-only enable/disable event for a package on a tenant.
|
||||||
|
* Phase 2's billing computation reads the event stream within the
|
||||||
|
* billing window and collapses it to a set of UTC days during
|
||||||
|
* which the package was active.
|
||||||
|
*/
|
||||||
|
export interface TenantSkillEvent {
|
||||||
|
id: string;
|
||||||
|
tenantName: string;
|
||||||
|
zitadelOrgId: string;
|
||||||
|
skillId: string;
|
||||||
|
eventKind: "enabled" | "disabled";
|
||||||
|
occurredAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Append-only suspend/resume event. Recorded by the portal at
|
||||||
|
* command time (when PATCH spec.suspend lands), not at operator
|
||||||
|
* reconcile time. The few-second delta is irrelevant for monthly
|
||||||
|
* billing.
|
||||||
|
*/
|
||||||
|
export interface TenantSuspensionEvent {
|
||||||
|
id: string;
|
||||||
|
tenantName: string;
|
||||||
|
zitadelOrgId: string;
|
||||||
|
eventKind: "suspended" | "resumed";
|
||||||
|
occurredAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-org billing posture and Stripe linkage. Distinct from
|
||||||
|
* OrgBilling (which is the customer-editable address/VAT block):
|
||||||
|
* this one is admin-controlled.
|
||||||
|
*
|
||||||
|
* `payByInvoice` flips the onboarding gate (Phase 4): when true,
|
||||||
|
* tenant requests for this org are approvable without a card on
|
||||||
|
* file. When false, the customer must have a validated Stripe
|
||||||
|
* payment method before admin approval is allowed.
|
||||||
|
*
|
||||||
|
* `stripeCustomerId` is populated by Phase 4's onboarding flow.
|
||||||
|
* `autoInvoiceEnabled` / `autoRemindersEnabled` give admin per-org
|
||||||
|
* kill switches for the Phase 6 cron without disabling the cron
|
||||||
|
* globally.
|
||||||
|
*/
|
||||||
|
export interface OrgBillingConfig {
|
||||||
|
zitadelOrgId: string;
|
||||||
|
payByInvoice: boolean;
|
||||||
|
stripeCustomerId: string | null;
|
||||||
|
autoInvoiceEnabled: boolean;
|
||||||
|
autoRemindersEnabled: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user