From ce70fe848053cb99f219c9321f05e87d8a1fc119 Mon Sep 17 00:00:00 2001 From: admin Date: Sat, 23 May 2026 23:45:04 +0200 Subject: [PATCH] Phase1: Schema + skill event tracking --- README.md | 367 ++++++-- src/app/api/admin/billing/backfill/route.ts | 70 ++ .../api/admin/requests/[id]/approve/route.ts | 49 ++ .../api/admin/tenants/[name]/delete/route.ts | 10 + src/app/api/tenants/[name]/route.ts | 45 + src/app/api/tenants/[name]/suspend/route.ts | 28 + src/lib/db.ts | 788 ++++++++++++++++++ src/types/index.ts | 108 +++ 8 files changed, 1406 insertions(+), 59 deletions(-) create mode 100644 src/app/api/admin/billing/backfill/route.ts diff --git a/README.md b/README.md index da51801..9d4fb84 100644 --- a/README.md +++ b/README.md @@ -1,77 +1,326 @@ -# Threema UX v3 — three real fixes +# PieCed Portal — Billing Phase 1 (drop-in replacement) -## What this fixes vs v2 +Schema + event tracking. No UI yet (that lands in Phase 2). +This zip mirrors the `pieced-portal/` repo root — extract over your +existing source tree to apply. -| # | Issue | Fix | -|---|-------|-----| -| 1 | QR image 404'd as `GET /en/threema/qr_code_AIAGENT.png` | Middleware matcher now excludes paths with file extensions, so static files in `public/` are not locale-prefixed | -| 2 | Displayed gateway name as `AIAGENT` (without asterisk) | `displayName` is now `*AIAGENT` (with asterisk) — what users actually see in their Threema contacts | -| 3 | "Show QR" hyperlink — too small, unclear what it does | Replaced with a proper accent-bordered info banner: icon + title + body explaining what to do + prominent "Show QR code" button | +--- -## Files +## Files in this drop ``` -src/middleware.ts # MODIFIED — matcher excludes dot-paths (static files) -src/lib/threema-gateway-config.ts # MODIFIED — displayName: "*AIAGENT" -src/components/channel-users/channel-users.tsx # MODIFIED — banner replaces inline hyperlink -src/components/channel-users/threema-qr-modal.tsx # UNCHANGED from v2 (label now reads "*AIAGENT" automatically via config) -deploy/patch-i18n-threema.mjs # MODIFIED — new bannerTitle/bannerBody/bannerButton keys, showQr dropped -public/threema/qr_code_AIAGENT.png # UNCHANGED +src/lib/db.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 ``` -## Apply +No `package.json` changes — Phase 1 uses only deps already present. -```bash -cd /path/to/pieced-portal +### What changed -unzip -o /path/to/threema-ux-v3.zip -node deploy/patch-i18n-threema.mjs -npx tsc --noEmit -git add -A -git status # eyeball -git commit -m "Threema UX: middleware static fix, *AIAGENT display, info banner" -git push -``` +`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 -## Layout after redeploy +`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 the + `patchTenantSpec({suspend: false})` call + - 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) ``` -┌─────────────────────────────────────────────────────────┐ -│ threema 2 users │ -├─────────────────────────────────────────────────────────┤ -│ ╔═══════════════════════════════════════════════════════╗│ -│ ║ [icon] Set up Threema [ Show QR code ] ║│ ← prominent banner, accent border -│ ║ Open Threema on your phone and scan our ║│ -│ ║ QR code to add the assistant as a contact. ║│ -│ ║ Then add your own Threema ID below. ║│ -│ ╚═══════════════════════════════════════════════════════╝│ -├─────────────────────────────────────────────────────────┤ -│ │ -│ ┌───────┐ ┌───────┐ │ -│ │USER01 ✕│ │USER02 ✕│ │ -│ └───────┘ └───────┘ │ -│ [ A8K2P3X7 ] [ Add ] │ -└─────────────────────────────────────────────────────────┘ +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 banner is always visible whenever the threema channel is enabled. -Clicking "Show QR code" opens the modal with the QR and 3-step -instructions. ESC, overlay click, or × button closes. +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. -Auto-open on first focus of the add-ID input is preserved from v2 — -the modal pops once when a customer clicks into the input to add their -first ID, so a brand-new customer who skipped the banner still gets -the QR right when they need it. +### Design properties -## Verification +* 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. -After redeploy: +--- -1. Open `https://app.pieced.ch/en/tenants/acme-gmbh-2acf4612` in the browser. -2. Scroll to the **Authorized Users → threema** card. -3. Visible banner with icon, title "Set up Threema", body text, and a - clearly clickable "Show QR code" button on the right. -4. Click the button → modal with the QR shows. -5. The label under the QR reads `*AIAGENT` (with asterisk). -6. Browser DevTools → Network → `GET /threema/qr_code_AIAGENT.png` is - `200`, not `404`, and not redirected to `/en/threema/...`. +## 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 your laptop (with a logged-in admin +session cookie) or from another pod with cluster access: + +``` +curl -X POST \ + -H "Cookie: " \ + https://app.pieced.ch/api/admin/billing/backfill +``` + +Or, easier, hit the URL from a logged-in browser tab and use the +DevTools console: + +``` +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): + +``` +{ + "message": "Backfill complete.", + "tenantsExamined": 4, + "lifecycleInserted": 0, + "eventsInserted": 0, + "suspensionEventsInserted": 0 +} +``` + +### Step 3 — Verify backfill data + +``` +kubectl -n portal exec -it portal-db-1 -- psql -U portal -d portal +``` + +```sql +-- All tenants have lifecycle rows +SELECT tenant_name, zitadel_org_id, created_at, deleted_at + FROM tenant_billing_lifecycle ORDER BY created_at; + +-- Initial skill events match each tenant's current spec.packages +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 + +Open the customer-facing tenant detail page for a test tenant. +Enable a package (e.g. `searxng-local-search`) that wasn't on +before. + +```sql +SELECT * FROM tenant_skill_events + WHERE tenant_name = 'your-test-tenant' + ORDER BY id DESC LIMIT 3; +``` + +You should see a fresh `enabled` row with the package id and a +timestamp matching the toggle. + +Disable the same package, re-check — you should now see a +`disabled` row added on top. + +### Step 5 — Live suspend toggle + +From the customer-side cancel button on a test tenant, suspend it: + +```sql +SELECT * FROM tenant_suspension_events + WHERE tenant_name = 'your-test-tenant' + ORDER BY id DESC LIMIT 3; +``` + +Expect a `suspended` row. + +As platform admin, resume it via the resume-request flow. Expect a +`resumed` row to land. + +### Step 6 — Live delete + +Delete a test tenant from the admin panel. Check: + +```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 helpers (smoke test) + +Optional sanity check — set a price for a skill and a monthly fee +via direct SQL (Phase 2 will add a UI for this): + +```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. The check is just "the rows +land and survive a restart". + +### Step 8 — Org billing config auto-create + +Verify the org_billing_config auto-creates on first read. Call the +helper indirectly: open the admin panel (which will eventually +read this); for now, force it via SQL: + +```sql +-- Pick any org id from your tenant labels +SELECT zitadel_org_id, pay_by_invoice, auto_invoice_enabled, + auto_reminders_enabled + FROM org_billing_config; +``` + +At this point this table is empty (Phase 2's UI will populate it). +That's expected and fine. + +--- + +## Rollback + +If anything misbehaves, 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. The new tables remain in the DB but are unreferenced. Leave them + in place — Phase 2 will use them again. Or drop them if you + want a clean slate: + ```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. + +## What to expect in Phase 2 + +* `/admin/billing` page: platform pricing editor, per-skill pricing, + per-org config +* "Generate invoice for org X / month M" admin button (testing tool + + foundation for the Phase 6 monthly cron) +* `lib/billing.ts` introducing the invoice computation pipeline + (LiteLLM spend pull, Threema usage pull, skill day collapse, + monthly-fee proration, suspended-day exclusion, VAT calc) +* PDF rendering via `@react-pdf/renderer` (adds one dep) +* Admin "mark paid" action for the bill-pay flow diff --git a/src/app/api/admin/billing/backfill/route.ts b/src/app/api/admin/billing/backfill/route.ts new file mode 100644 index 0000000..8a5435f --- /dev/null +++ b/src/app/api/admin/billing/backfill/route.ts @@ -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 } + ); + } +} diff --git a/src/app/api/admin/requests/[id]/approve/route.ts b/src/app/api/admin/requests/[id]/approve/route.ts index e91cf18..0a73092 100644 --- a/src/app/api/admin/requests/[id]/approve/route.ts +++ b/src/app/api/admin/requests/[id]/approve/route.ts @@ -4,6 +4,9 @@ import { getTenantRequestById, updateTenantRequestStatus, clearEncryptedSecrets, + recordTenantCreated, + recordSkillEvents, + recordSuspensionEvent, } from "@/lib/db"; import { createTenant, patchTenantSpec, setTenantAnnotation } from "@/lib/k8s"; import { sendApprovalEmail, sendResumeApprovalEmail } from "@/lib/email"; @@ -85,6 +88,23 @@ export async function POST( } try { await patchTenantSpec(tenantRequest.tenantName, { suspend: false }); + + // Billing — Phase 1: record the resume so monthly proration + // counts the suspended segment correctly. Best-effort; if + // logging fails, the approval still succeeds. + try { + await recordSuspensionEvent( + tenantRequest.tenantName, + tenantRequest.zitadelOrgId, + "resumed" + ); + } catch (e) { + console.error( + "billing: failed to record resumed suspension event:", + e + ); + } + // Clear the annotation that pauses the operator's 60-day TTL. // Best-effort — annotation cleanup is also done by the operator // when it sees suspend=false on the next reconcile (it clears @@ -199,6 +219,35 @@ export async function POST( } ); + // Billing — Phase 1: record the tenant's creation and initial + // package state. Anchored at "now" rather than the CR's + // creationTimestamp because we don't get the timestamp back from + // createTenant — the few-millisecond skew vs the CR's actual + // creationTimestamp is irrelevant for monthly billing. + // + // Best-effort: tracking failures must never block provisioning. + // The backfill helper can repair any gaps later if needed. + const billingAnchor = new Date(); + try { + await recordTenantCreated( + tenantName, + tenantRequest.zitadelOrgId, + billingAnchor + ); + await recordSkillEvents( + tenantName, + tenantRequest.zitadelOrgId, + packages, + [], + billingAnchor + ); + } catch (e) { + console.error( + "billing: failed to record tenant creation / initial skill events:", + e + ); + } + // Step 5: Update request status — clear admin notes on re-approval const updated = await updateTenantRequestStatus(id, "provisioning", { adminNotes: isReApproval ? null : adminNotes, diff --git a/src/app/api/admin/tenants/[name]/delete/route.ts b/src/app/api/admin/tenants/[name]/delete/route.ts index f5110ae..a71c612 100644 --- a/src/app/api/admin/tenants/[name]/delete/route.ts +++ b/src/app/api/admin/tenants/[name]/delete/route.ts @@ -4,6 +4,7 @@ import { getTenant, deleteTenant } from "@/lib/k8s"; import { markTenantRequestDeletedByTenantName, removeAllAssignmentsForTenant, + recordTenantDeleted, } from "@/lib/db"; import { safeError } from "@/lib/errors"; @@ -49,6 +50,15 @@ export async function POST( console.error("Failed to clean up tenant assignments:", e) ); + // Billing — Phase 1: stamp deletion timestamp on the lifecycle + // row so the final invoice covering the deletion month can + // prorate correctly. Idempotent at the DB layer; a missing + // lifecycle row (e.g. pre-Phase-1 tenants that haven't been + // backfilled yet) makes this a no-op. + await recordTenantDeleted(name).catch((e) => + console.error("billing: failed to stamp tenant deletion:", e) + ); + return NextResponse.json({ message: "Tenant deletion initiated. The operator will clean up all resources.", }); diff --git a/src/app/api/tenants/[name]/route.ts b/src/app/api/tenants/[name]/route.ts index 53ac925..3199ca5 100644 --- a/src/app/api/tenants/[name]/route.ts +++ b/src/app/api/tenants/[name]/route.ts @@ -3,6 +3,7 @@ import { getSessionUser, canMutate } from "@/lib/session"; import { canUserSeeTenant } from "@/lib/visibility"; import { getTenant, patchTenantSpec } from "@/lib/k8s"; import { getPackageDef } from "@/lib/packages"; +import { recordSkillEvents } from "@/lib/db"; import { safeError } from "@/lib/errors"; const ALLOWED_WORKSPACE_FILES = ["SOUL.md", "AGENTS.md", "TOOLS.md"]; @@ -187,6 +188,50 @@ export async function PATCH( } 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(existing.spec.packages ?? []); + const newSet = new Set(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); } catch (e: any) { return NextResponse.json( diff --git a/src/app/api/tenants/[name]/suspend/route.ts b/src/app/api/tenants/[name]/suspend/route.ts index c177dab..30e690d 100644 --- a/src/app/api/tenants/[name]/suspend/route.ts +++ b/src/app/api/tenants/[name]/suspend/route.ts @@ -3,6 +3,7 @@ import { z } from "zod"; import { getSessionUser, canMutate } from "@/lib/session"; import { getTenant, patchTenantSpec, setTenantAnnotation } from "@/lib/k8s"; import { canUserSeeTenant } from "@/lib/visibility"; +import { recordSuspensionEvent } from "@/lib/db"; import { safeError } from "@/lib/errors"; const patchSchema = z.object({ @@ -101,6 +102,33 @@ export async function PATCH( try { await patchTenantSpec(name, { suspend }); + // Billing — Phase 1: record the transition so monthly proration + // can exclude suspended days from the fixed fee. The portal + // commands this transition; the operator's status.suspendedAt + // lags by a reconcile cycle (seconds), which is irrelevant for + // monthly billing. Best-effort: a logging failure never blocks + // the suspend/resume itself. + try { + const orgId = + tenant.metadata.labels?.["pieced.ch/zitadel-org-id"] ?? null; + if (orgId) { + await recordSuspensionEvent( + name, + orgId, + suspend ? "suspended" : "resumed" + ); + } else { + console.warn( + `billing: tenant ${name} has no zitadel-org-id label; suspension event not recorded` + ); + } + } catch (e) { + console.error( + `billing: failed to record suspension event for ${name}:`, + e + ); + } + // On admin-side resume, also clear the pending-resume-request // annotation if it exists. Belt-and-suspenders: the admin-approve // endpoint already clears it on its happy path, but a platform diff --git a/src/lib/db.ts b/src/lib/db.ts index 620a0c7..1da3c5a 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -272,6 +272,252 @@ const MIGRATION_SQL = ` END $$; CREATE INDEX IF NOT EXISTS idx_support_ticket_comments_ticket 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. `kind` 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; @@ -1336,3 +1582,545 @@ export async function updateSupportTicket( ); 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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> { + 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 = {}; + 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 { + 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 { + 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 { + 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 { + 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 }; +} diff --git a/src/types/index.ts b/src/types/index.ts index 8ef78d5..b3a1d84 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -412,3 +412,111 @@ export interface SupportTicketDetail { ticket: SupportTicket; 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; +}