Compare commits

..

3 Commits

Author SHA1 Message Date
faf49119ea Phase1: Schema + skill event tracking
All checks were successful
Build and Push / build (push) Successful in 1m27s
2026-05-23 23:50:42 +02:00
ce70fe8480 Phase1: Schema + skill event tracking
Some checks failed
Build and Push / build (push) Failing after 38s
2026-05-23 23:45:04 +02:00
55571b1e59 Threema UX: static file middleware fix, *AIAGENT display, info banner
All checks were successful
Build and Push / build (push) Successful in 1m26s
2026-05-17 17:29:25 +02:00
16 changed files with 1426 additions and 79 deletions

301
README.md
View File

@@ -1,66 +1,273 @@
# Threema UX v2 — QR available on demand
# PieCed Portal — Billing Phase 1 (drop-in replacement)
Replaces the previous attempt that had the QR rendering inline above
the channel help text. That placement didn't work for you in practice
because it wasn't visible when you actually wanted it.
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.
## What this does instead
**v2 fix:** stripped stray backticks from SQL comments that were
closing the `MIGRATION_SQL` template literal early. If you got
"Expected a semicolon" at db.ts:335 with v1, this build is the fix.
1. **"Show QR" link** next to the channel title in the threema card —
visible at all times, clickable any time you want the QR.
---
2. **Auto-opens the modal** the first time you focus the add-ID input
on the page (so a new user adding their first ID sees it without
needing to click "Show QR" themselves). Doesn't re-pop after
dismissal — the link covers re-opens.
3. **Modal** with QR + 3-step instructions + "AIAGENT" label. Plain
`<img>` (no next/image), closes on ESC / overlay click / × button.
The earlier `threema-setup.tsx` component file is removed — replaced by
`threema-qr-modal.tsx`.
## Files
## Files in this drop
```
src/lib/threema-gateway-config.ts # unchanged from before — central gateway constants
src/components/channel-users/threema-qr-modal.tsx # NEW — the modal
src/components/channel-users/channel-users.tsx # MODIFIED — Show QR button + focus auto-open + modal mount
deploy/patch-i18n-threema.mjs # adds threemaSetup.showQr label in 4 langs
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
# Remove the old inline component if you applied the previous archive
rm -f src/components/channel-users/threema-setup.tsx
`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
# Drop new + modified files
unzip -o /path/to/threema-ux-v2.zip
`src/types/index.ts`
- 6 new interfaces appended at the bottom
# Update messages
node deploy/patch-i18n-threema.mjs
`src/app/api/admin/requests/[id]/approve/route.ts`
- Imports `recordTenantCreated`, `recordSkillEvents`,
`recordSuspensionEvent` from `@/lib/db`
- Resume path: records a `resumed` suspension event after
`patchTenantSpec({suspend: false})`
- Provision path: records `recordTenantCreated` + initial
`enabled` events after `createTenant`
# TS check
npx tsc --noEmit
`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.
git add -A
git commit -m "Threema QR: on-demand modal + auto-open on first add"
git push
```
`src/app/api/tenants/[name]/suspend/route.ts`
- Imports `recordSuspensionEvent`
- Records `suspended` or `resumed` after the patch succeeds
After redeploy, the threema card under Authorized Users shows:
`src/app/api/admin/tenants/[name]/delete/route.ts`
- Imports `recordTenantDeleted`
- Stamps `deleted_at` on the lifecycle row after `deleteTenant`
`src/app/api/admin/billing/backfill/route.ts` (new)
- `POST /api/admin/billing/backfill` — platform-only, idempotent
- Reads every live PiecedTenant CR, mirrors creationTimestamp,
current `spec.packages`, and `status.suspendedAt` into the new
tables. Run once after deploy to bootstrap historical data.
### Tables added (Postgres, all idempotent)
```
threema [Show QR] 0 users
────────────────────
<help text: 'Enter your own Threema ID...'>
────────────────────
[ input: A8K2P3X7 ] [ Add ]
^^^ focusing this opens the QR modal the first time
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)
```
Clicking "Show QR" or focusing the input → modal with QR + steps.
The invoice/lines/reminders tables ship now so Phase 2 doesn't need
a second migration, but no code writes to them until Phase 2.
### Design properties
* Every billing-tracking call is wrapped in `try/catch`. A logging
failure never blocks the K8s operation.
* PATCH-diff is computed against the *returned* CR state, not the
pre-patch state, so events match what K8s actually committed.
* Event tables are append-only. Historical billing can be
recomputed reproducibly.
* `tenant_billing_lifecycle` mirrors created_at + deleted_at so
deleted tenants still have a final-invoice anchor.
* All money is `NUMERIC`: 10,2 for CHF amounts, 10,5 for per-unit
prices.
---
## Deploy
1. Extract this zip over your `pieced-portal/` source tree
2. Build & push:
```
./buildanddeploy.sh # or your usual flow
```
3. Bump the image tag in `gitops/apps/portal/deployment.yaml`,
commit, push. ArgoCD picks it up.
4. On pod boot, the next DB query auto-runs `MIGRATION_SQL` (your
existing `ensureSchema` pattern). No manual `psql` needed.
---
## Testing (in order — don't skip steps)
### Step 1 — Migration ran
After the new pod is `Ready`, exec into the portal DB and verify
all 11 new tables exist:
```
kubectl -n portal exec -it portal-db-1 -- \
psql -U portal -d portal -c "\dt"
```
You should see the new tables alongside the existing ones.
Sanity-check the single-row pricing config seed:
```
kubectl -n portal exec -it portal-db-1 -- \
psql -U portal -d portal -c "SELECT * FROM platform_pricing;"
```
Expected: one row, all zeros, vat_rate_chli=8.10.
### Step 2 — Backfill existing tenants
Run the backfill once. From a logged-in admin browser tab DevTools
console:
```js
await fetch('/api/admin/billing/backfill', { method: 'POST' })
.then(r => r.json())
```
Expected response (numbers will vary):
```
{
"message": "Backfill complete.",
"tenantsExamined": 4,
"lifecycleInserted": 4,
"eventsInserted": 12,
"suspensionEventsInserted": 0
}
```
Run it a SECOND time — all three "Inserted" counts should be 0
(idempotency check).
### Step 3 — Verify backfill data
```
kubectl -n portal exec -it portal-db-1 -- psql -U portal -d portal
```
```sql
SELECT tenant_name, zitadel_org_id, created_at, deleted_at
FROM tenant_billing_lifecycle ORDER BY created_at;
SELECT tenant_name, skill_id, event_kind, occurred_at
FROM tenant_skill_events ORDER BY tenant_name, occurred_at;
```
Cross-check against the live CR:
```
kubectl get piecedtenants -o jsonpath='{range .items[*]}{.metadata.name}{": "}{.spec.packages}{"\n"}{end}'
```
Every package currently in `spec.packages` should have a matching
`enabled` event row.
### Step 4 — Live skill toggle
From the customer-facing tenant detail page, enable a package not
previously present (e.g. `searxng-local-search`):
```sql
SELECT * FROM tenant_skill_events
WHERE tenant_name = 'your-test-tenant'
ORDER BY id DESC LIMIT 3;
```
Expect a fresh `enabled` row. Disable the package → expect a
`disabled` row on top.
### Step 5 — Live suspend toggle
Cancel a test tenant from the customer-side button:
```sql
SELECT * FROM tenant_suspension_events
WHERE tenant_name = 'your-test-tenant'
ORDER BY id DESC LIMIT 3;
```
Expect a `suspended` row. Resume via the admin approval flow →
expect a `resumed` row.
### Step 6 — Live delete
Delete a test tenant from the admin panel:
```sql
SELECT tenant_name, created_at, deleted_at
FROM tenant_billing_lifecycle
WHERE tenant_name = 'your-deleted-tenant';
```
`deleted_at` should be stamped with roughly "now".
### Step 7 — Pricing rows survive (optional)
Direct-INSERT a price into `platform_pricing` and `skill_pricing`,
restart the portal pod, confirm rows survive:
```sql
UPDATE platform_pricing
SET tenant_monthly_fee_chf = 49.00,
tenant_setup_fee_chf = 99.00,
threema_message_chf = 0.005
WHERE id = 1;
INSERT INTO skill_pricing (skill_id, daily_price_chf)
VALUES ('searxng-local-search', 0.10)
ON CONFLICT (skill_id) DO UPDATE
SET daily_price_chf = EXCLUDED.daily_price_chf;
```
No application behaviour changes from these — they're inert until
Phase 2 starts computing invoices.
---
## Rollback
The migration is additive — no existing columns/tables touched.
To roll back:
1. Re-deploy the previous portal image (revert the tag in gitops)
2. New tables remain in the DB but are unreferenced. Leave them
in place — Phase 2 will use them again. Or drop them:
```sql
DROP TABLE IF EXISTS invoice_reminders, invoice_lines, invoices,
invoice_number_counters, org_payment_methods, org_billing_config,
tenant_suspension_events, tenant_skill_events,
tenant_billing_lifecycle, skill_pricing, platform_pricing CASCADE;
```
---
## What's NOT in this phase (by design)
* No customer-facing /billing page
* No admin pricing UI
* No invoice generation
* No PDF rendering
* No Stripe wiring
* No reminders or cron
These are Phases 2-6.

View File

@@ -37,7 +37,9 @@ const i18n = {
step2: "Tap the scan icon and scan this QR code to add the assistant as a contact.",
step3: "Then add your own Threema ID below.",
qrAlt: "QR code to add {gateway} as a Threema contact",
showQr: "Show QR",
bannerTitle: "Set up Threema",
bannerBody: "Open Threema on your phone and scan our QR code to add the assistant as a contact. Then add your own Threema ID below.",
bannerButton: "Show QR code",
},
},
de: {
@@ -57,7 +59,9 @@ const i18n = {
step2: "Tippen Sie auf das Scan-Symbol und scannen Sie diesen QR-Code, um den Assistenten als Kontakt hinzuzufügen.",
step3: "Fügen Sie anschliessend unten Ihre eigene Threema-ID hinzu.",
qrAlt: "QR-Code, um {gateway} als Threema-Kontakt hinzuzufügen",
showQr: "QR anzeigen",
bannerTitle: "Threema einrichten",
bannerBody: "Öffnen Sie Threema auf Ihrem Telefon und scannen Sie unseren QR-Code, um den Assistenten als Kontakt hinzuzufügen. Geben Sie anschliessend unten Ihre eigene Threema-ID ein.",
bannerButton: "QR-Code anzeigen",
},
},
fr: {
@@ -77,7 +81,9 @@ const i18n = {
step2: "Appuyez sur l'icône de scan et scannez ce QR code pour ajouter l'assistant comme contact.",
step3: "Puis ajoutez votre propre identifiant Threema ci-dessous.",
qrAlt: "QR code pour ajouter {gateway} comme contact Threema",
showQr: "Afficher le QR",
bannerTitle: "Configurer Threema",
bannerBody: "Ouvrez Threema sur votre téléphone et scannez notre QR code pour ajouter l'assistant comme contact. Saisissez ensuite votre propre identifiant Threema ci-dessous.",
bannerButton: "Afficher le QR code",
},
},
it: {
@@ -97,7 +103,9 @@ const i18n = {
step2: "Tocca l'icona di scansione e scansiona questo QR code per aggiungere l'assistente ai contatti.",
step3: "Quindi aggiungi il tuo ID Threema qui sotto.",
qrAlt: "QR code per aggiungere {gateway} come contatto Threema",
showQr: "Mostra QR",
bannerTitle: "Configura Threema",
bannerBody: "Apri Threema sul tuo telefono e scansiona il nostro QR code per aggiungere l'assistente ai contatti. Inserisci poi il tuo ID Threema qui sotto.",
bannerButton: "Mostra QR code",
},
},
};

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

@@ -225,24 +225,47 @@ export function ChannelUsers({
className="bg-surface-2 border border-border rounded-lg p-4"
>
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<h4 className="text-sm font-medium text-text-primary capitalize">
{channel}
</h4>
{channel === "threema" && (
<button
onClick={() => setShowQrFor("threema")}
className="text-xs font-medium text-accent hover:text-accent-dim cursor-pointer underline underline-offset-2"
>
{t("threemaSetup.showQr")}
</button>
)}
</div>
<h4 className="text-sm font-medium text-text-primary capitalize">
{channel}
</h4>
<span className="text-xs text-text-muted tabular-nums">
{users.length} {t("users")}
</span>
</div>
{channel === "threema" && (
<div className="mb-3 flex flex-col sm:flex-row gap-3 items-start sm:items-center justify-between bg-accent/5 border border-accent/30 rounded-lg p-3">
<div className="flex items-start gap-2 flex-1">
<svg
className="w-4 h-4 mt-0.5 text-accent flex-shrink-0"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth="2"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M3 4a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H4a1 1 0 01-1-1V4zM15 4a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1V4zM3 16a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H4a1 1 0 01-1-1v-4zM13 13h3v3h-3zM18 13h3v3h-3zM13 18h3v3h-3zM18 18h3v3h-3z"
/>
</svg>
<div className="text-xs text-text-secondary leading-relaxed">
<p className="font-medium text-text-primary mb-0.5">
{t("threemaSetup.bannerTitle")}
</p>
<p>{t("threemaSetup.bannerBody")}</p>
</div>
</div>
<button
onClick={() => setShowQrFor("threema")}
className="self-stretch sm:self-auto px-3 py-2 text-xs font-medium bg-accent text-surface-0 rounded-lg hover:bg-accent-dim transition-colors whitespace-nowrap cursor-pointer shadow-lg shadow-accent/20"
>
{t("threemaSetup.bannerButton")}
</button>
</div>
)}
{helpKey && (
<p className="text-xs text-text-secondary bg-surface-1 border border-border rounded-lg p-3 mb-3 whitespace-pre-line">
{t(helpKey)}

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. The kind column lets the PDF renderer
-- group lines (all monthly fees together, all AI usage together,
-- etc.) and the admin UI filter by category.
CREATE TABLE IF NOT EXISTS invoice_lines (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
invoice_id UUID NOT NULL REFERENCES invoices(id) ON DELETE CASCADE,
-- NULL for org-wide items; tenant name for per-tenant breakdowns.
tenant_name TEXT,
kind TEXT NOT NULL CHECK (kind IN (
'tenant_monthly','tenant_setup','ai_usage','threema_messages','skill_usage','adjustment'
)),
description TEXT NOT NULL,
quantity NUMERIC(12,4) NOT NULL DEFAULT 1,
unit_label TEXT,
unit_price_chf NUMERIC(10,5) NOT NULL,
amount_chf NUMERIC(10,2) NOT NULL,
-- Per-kind audit metadata (e.g. {proration_days, days_in_month}
-- for tenant_monthly; {in_count, out_count} for threema_messages).
metadata JSONB,
display_order INT NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_invoice_lines_invoice
ON invoice_lines(invoice_id, display_order);
-- Reminders fired against open/overdue invoices. Level 3 = final.
-- One PDF per reminder, stored alongside.
CREATE TABLE IF NOT EXISTS invoice_reminders (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
invoice_id UUID NOT NULL REFERENCES invoices(id) ON DELETE CASCADE,
level INT NOT NULL CHECK (level IN (1, 2, 3)),
sent_at TIMESTAMPTZ NOT NULL DEFAULT now(),
sent_by TEXT NOT NULL,
pdf_data BYTEA,
pdf_filename TEXT,
email_sent_to TEXT,
UNIQUE (invoice_id, level)
);
CREATE INDEX IF NOT EXISTS idx_invoice_reminders_invoice
ON invoice_reminders(invoice_id, level);
`;
let migrated = false;
@@ -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

@@ -13,21 +13,19 @@
* env var that lists the active account.
* 2. Move the QR PNG into a server-rendered route that takes a
* gateway ID query param.
* 3. Update consumers (today only ThreemaSetup) to accept the
* gateway info as a prop and pass it from a server component.
*
* In display contexts we strip the leading asterisk from the Threema
* ID — customers don't understand the `*X` prefix convention used for
* Gateway accounts, and the QR code carries the real value anyway. We
* keep the asterisk only for places where the technical value matters
* (server-side message routing, debug logs).
* 3. Update consumers to accept the gateway info as a prop and pass
* it from a server component.
*/
export const THREEMA_GATEWAY = {
/** Technical Threema Gateway ID, with leading asterisk. */
id: "*AIAGENT",
/** Display name shown to customers (no asterisk). */
displayName: "AIAGENT",
/**
* Display name shown to customers. INCLUDES the leading asterisk —
* customers need to recognise this exact string in their Threema
* contacts after scanning the QR, so we don't strip it.
*/
displayName: "*AIAGENT",
/** Public path to the QR code PNG served from `public/`. */
qrCodePath: "/threema/qr_code_AIAGENT.png",
} as const;

View File

@@ -403,7 +403,9 @@
"step2": "Tippen Sie auf das Scan-Symbol und scannen Sie diesen QR-Code, um den Assistenten als Kontakt hinzuzufügen.",
"step3": "Fügen Sie anschliessend unten Ihre eigene Threema-ID hinzu.",
"qrAlt": "QR-Code, um {gateway} als Threema-Kontakt hinzuzufügen",
"showQr": "QR anzeigen"
"bannerTitle": "Threema einrichten",
"bannerBody": "Öffnen Sie Threema auf Ihrem Telefon und scannen Sie unseren QR-Code, um den Assistenten als Kontakt hinzuzufügen. Geben Sie anschliessend unten Ihre eigene Threema-ID ein.",
"bannerButton": "QR-Code anzeigen"
}
},
"team": {

View File

@@ -403,7 +403,9 @@
"step2": "Tap the scan icon and scan this QR code to add the assistant as a contact.",
"step3": "Then add your own Threema ID below.",
"qrAlt": "QR code to add {gateway} as a Threema contact",
"showQr": "Show QR"
"bannerTitle": "Set up Threema",
"bannerBody": "Open Threema on your phone and scan our QR code to add the assistant as a contact. Then add your own Threema ID below.",
"bannerButton": "Show QR code"
}
},
"team": {

View File

@@ -403,7 +403,9 @@
"step2": "Appuyez sur l'icône de scan et scannez ce QR code pour ajouter l'assistant comme contact.",
"step3": "Puis ajoutez votre propre identifiant Threema ci-dessous.",
"qrAlt": "QR code pour ajouter {gateway} comme contact Threema",
"showQr": "Afficher le QR"
"bannerTitle": "Configurer Threema",
"bannerBody": "Ouvrez Threema sur votre téléphone et scannez notre QR code pour ajouter l'assistant comme contact. Saisissez ensuite votre propre identifiant Threema ci-dessous.",
"bannerButton": "Afficher le QR code"
}
},
"team": {

View File

@@ -403,7 +403,9 @@
"step2": "Tocca l'icona di scansione e scansiona questo QR code per aggiungere l'assistente ai contatti.",
"step3": "Quindi aggiungi il tuo ID Threema qui sotto.",
"qrAlt": "QR code per aggiungere {gateway} come contatto Threema",
"showQr": "Mostra QR"
"bannerTitle": "Configura Threema",
"bannerBody": "Apri Threema sul tuo telefono e scansiona il nostro QR code per aggiungere l'assistente ai contatti. Inserisci poi il tuo ID Threema qui sotto.",
"bannerButton": "Mostra QR code"
}
},
"team": {

View File

@@ -40,5 +40,10 @@ export default async function middleware(request: NextRequest) {
}
export const config = {
matcher: ["/((?!_next|favicon.ico|api).*)"],
// Excludes _next/* internal routes, the favicon, api routes, AND any
// path containing a dot (covers all static files served from public/,
// e.g. /threema/qr_code_AIAGENT.png). Without the dot exclusion, the
// i18n middleware prepends the locale ("/en/threema/qr_code_AIAGENT.png")
// and the file is not found.
matcher: ["/((?!_next|favicon.ico|api|.*\\..*).*)"],
};

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