Phase1: Schema + skill event tracking
Some checks failed
Build and Push / build (push) Failing after 38s

This commit is contained in:
2026-05-23 23:45:04 +02:00
parent 55571b1e59
commit ce70fe8480
8 changed files with 1406 additions and 59 deletions

367
README.md
View File

@@ -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. ║│
│ ╚═══════════════════════════════════════════════════════╝│
├─────────────────────────────────────────────────────────┤
│ <help: "Enter your own Threema ID..."> │
│ ┌───────┐ ┌───────┐ │
│ │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: <your authjs session 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

View File

@@ -0,0 +1,70 @@
import { NextResponse } from "next/server";
import { requirePlatformRole } from "@/lib/session";
import { listTenants } from "@/lib/k8s";
import { backfillTenantBillingLifecycle } from "@/lib/db";
import { safeError } from "@/lib/errors";
/**
* POST /api/admin/billing/backfill
*
* One-off bootstrap that reads every live PiecedTenant CR and
* mirrors it into the Phase 1 billing tables:
* - tenant_billing_lifecycle.created_at ← CR's creationTimestamp
* - tenant_skill_events: one 'enabled' event per package in
* spec.packages, anchored at the CR's creationTimestamp
* - tenant_suspension_events: one 'suspended' event if the CR is
* currently suspended (anchored at status.suspendedAt)
*
* Idempotent — re-running is safe. The helper only inserts rows
* for tenants that have no lifecycle row / no events yet; running
* twice produces zero additional rows.
*
* Authorization: platform role only. The body of the request is
* ignored.
*
* Response: counts of rows inserted, mostly for sanity-checking
* (expect non-zero on first run, zero on subsequent runs).
*
* Phase 2 will surface this behind an admin UI button.
*/
export async function POST() {
try {
await requirePlatformRole();
} catch {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
try {
const tenants = await listTenants();
const result = await backfillTenantBillingLifecycle(
tenants.map((t) => ({
name: t.metadata.name,
// Tenants without the org label exist as a pre-Slice-3
// artifact; we still record them but with 'unknown' as the
// org id, which surfaces them in admin reports for manual
// labelling. Per-org billing computation skips rows with
// org id = 'unknown'.
zitadelOrgId:
t.metadata.labels?.["pieced.ch/zitadel-org-id"] ?? "unknown",
createdAt: t.metadata.creationTimestamp
? new Date(t.metadata.creationTimestamp)
: new Date(),
packages: t.spec.packages ?? [],
suspendedAt: t.status?.suspendedAt
? new Date(t.status.suspendedAt)
: null,
}))
);
return NextResponse.json({
message: "Backfill complete.",
tenantsExamined: tenants.length,
...result,
});
} catch (e: any) {
console.error("Backfill failed:", e);
return NextResponse.json(
{ error: safeError(e, "Backfill failed") },
{ status: 500 }
);
}
}

View File

@@ -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,

View File

@@ -4,6 +4,7 @@ import { getTenant, deleteTenant } from "@/lib/k8s";
import {
markTenantRequestDeletedByTenantName,
removeAllAssignmentsForTenant,
recordTenantDeleted,
} from "@/lib/db";
import { safeError } from "@/lib/errors";
@@ -49,6 +50,15 @@ export async function POST(
console.error("Failed to clean up tenant assignments:", e)
);
// Billing — Phase 1: stamp deletion timestamp on the lifecycle
// row so the final invoice covering the deletion month can
// prorate correctly. Idempotent at the DB layer; a missing
// lifecycle row (e.g. pre-Phase-1 tenants that haven't been
// backfilled yet) makes this a no-op.
await recordTenantDeleted(name).catch((e) =>
console.error("billing: failed to stamp tenant deletion:", e)
);
return NextResponse.json({
message: "Tenant deletion initiated. The operator will clean up all resources.",
});

View File

@@ -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<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);
} catch (e: any) {
return NextResponse.json(

View File

@@ -3,6 +3,7 @@ import { z } from "zod";
import { getSessionUser, canMutate } from "@/lib/session";
import { getTenant, patchTenantSpec, setTenantAnnotation } from "@/lib/k8s";
import { canUserSeeTenant } from "@/lib/visibility";
import { recordSuspensionEvent } from "@/lib/db";
import { safeError } from "@/lib/errors";
const patchSchema = z.object({
@@ -101,6 +102,33 @@ export async function PATCH(
try {
await patchTenantSpec(name, { suspend });
// Billing — Phase 1: record the transition so monthly proration
// can exclude suspended days from the fixed fee. The portal
// commands this transition; the operator's status.suspendedAt
// lags by a reconcile cycle (seconds), which is irrelevant for
// monthly billing. Best-effort: a logging failure never blocks
// the suspend/resume itself.
try {
const orgId =
tenant.metadata.labels?.["pieced.ch/zitadel-org-id"] ?? null;
if (orgId) {
await recordSuspensionEvent(
name,
orgId,
suspend ? "suspended" : "resumed"
);
} else {
console.warn(
`billing: tenant ${name} has no zitadel-org-id label; suspension event not recorded`
);
}
} catch (e) {
console.error(
`billing: failed to record suspension event for ${name}:`,
e
);
}
// On admin-side resume, also clear the pending-resume-request
// annotation if it exists. Belt-and-suspenders: the admin-approve
// endpoint already clears it on its happy path, but a platform

View File

@@ -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<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 };
}

View File

@@ -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;
}