Files
pieced-portal/src/lib/db.ts
admin a6ed74b1be
All checks were successful
Build and Push / build (push) Successful in 1m45s
Phase8: Auto bill credit card
2026-05-28 23:27:32 +02:00

4308 lines
154 KiB
TypeScript

import { Pool } from "pg";
import type {
BillingAddress,
OrgBilling,
SupportTicket,
SupportTicketComment,
SupportTicketCommentAuthorKind,
SupportTicketCategory,
SupportTicketStatus,
TenantRequest,
TenantRequestStatus,
} from "@/types";
import { listTenants, getTenant } from "./k8s";
// ---------------------------------------------------------------------------
// Connection pool (singleton)
// ---------------------------------------------------------------------------
let pool: Pool | null = null;
function getPool(): Pool {
if (!pool) {
const connectionString =
process.env.DATABASE_URL ??
"postgresql://portal:portal@portal-db-rw.portal.svc:5432/portal";
pool = new Pool({ connectionString, max: 5 });
}
return pool;
}
// ---------------------------------------------------------------------------
// Schema migration (auto-run on first query)
// ---------------------------------------------------------------------------
// Notes on the Slice 3 changes
// ----------------------------
// 1. Removed `UNIQUE` from `zitadel_org_id` in the CREATE TABLE for fresh
// installs, AND emit a defensive `DROP CONSTRAINT IF EXISTS` for
// existing installs whose schema was created pre-Slice-3. The
// constraint was Postgres-autonamed; the name is deterministic.
// 2. Added `instance_name TEXT` — the customer's human label per
// instance (e.g. "Production", "Dev"). NULL is fine and means "use
// the company name for display".
// 3. Added a unique index on `tenant_name WHERE NOT NULL`. Multiple
// rows in the table can have NULL tenant_name (pending/rejected
// requests), but every approved row points to a distinct K8s CR.
// 4. Added `(zitadel_org_id, status)` index for the list-by-org queries
// introduced this slice.
const MIGRATION_SQL = `
CREATE TABLE IF NOT EXISTS tenant_requests (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
zitadel_org_id TEXT NOT NULL,
zitadel_user_id TEXT NOT NULL,
company_name TEXT NOT NULL,
instance_name TEXT,
contact_name TEXT NOT NULL,
contact_email TEXT NOT NULL,
agent_name TEXT NOT NULL DEFAULT 'Assistant',
soul_md TEXT,
agents_md TEXT,
packages TEXT[] DEFAULT '{}',
billing_address JSONB DEFAULT '{}',
billing_notes TEXT,
status TEXT NOT NULL DEFAULT 'pending',
admin_notes TEXT,
tenant_name TEXT,
encrypted_secrets BYTEA,
is_personal BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_tenant_requests_status ON tenant_requests(status);
CREATE INDEX IF NOT EXISTS idx_tenant_requests_org_id ON tenant_requests(zitadel_org_id);
CREATE INDEX IF NOT EXISTS idx_tenant_requests_org_status ON tenant_requests(zitadel_org_id, status);
-- Note: the unique constraint on tenant_name is NOT created here.
-- Pre-Bug-37 we had a non-partial UNIQUE on tenant_name, which is
-- incompatible with resume requests (same tenant_name, different
-- request_type). The new partial unique indexes are created
-- further down in the migration block, after the request_type
-- column has been added and backfilled. This bootstrap section
-- only creates indexes that are safe regardless of request_type
-- semantics.
-- Idempotent column adds for existing databases
ALTER TABLE tenant_requests ADD COLUMN IF NOT EXISTS encrypted_secrets BYTEA;
ALTER TABLE tenant_requests ADD COLUMN IF NOT EXISTS agents_md TEXT;
ALTER TABLE tenant_requests ADD COLUMN IF NOT EXISTS instance_name TEXT;
ALTER TABLE tenant_requests ADD COLUMN IF NOT EXISTS is_personal BOOLEAN NOT NULL DEFAULT FALSE;
-- Bug 13: customer-side dismissal of rejected requests. NULL means "still
-- visible on the dashboard"; non-null means "customer clicked Dismiss".
-- Pending/approved/active rows keep this NULL by definition — the field
-- is only meaningful for rejected and cancelled rows.
ALTER TABLE tenant_requests ADD COLUMN IF NOT EXISTS dismissed_at TIMESTAMPTZ;
-- Phase 9b: link a provision request to the paid setup-fee invoice
-- it was charged against at order time. Null on requests created
-- before Phase 9b, on resume requests, and during the brief
-- 'pending_payment' window before the Stripe webhook fires. The
-- admin reject flow refunds this invoice via the existing
-- refundInvoice helper.
ALTER TABLE tenant_requests
ADD COLUMN IF NOT EXISTS setup_invoice_id UUID REFERENCES invoices(id);
CREATE INDEX IF NOT EXISTS idx_tenant_requests_setup_invoice
ON tenant_requests(setup_invoice_id)
WHERE setup_invoice_id IS NOT NULL;
-- Phase 9b: optional initial channel-user ids per channel package
-- collected during onboarding. JSONB so the shape can vary by
-- channel (today it's a string[] per channel id, matching
-- PiecedTenantSpec.channelUsers). Default '{}' so reads on legacy
-- rows return an empty object rather than null.
ALTER TABLE tenant_requests
ADD COLUMN IF NOT EXISTS channel_users JSONB NOT NULL DEFAULT '{}'::jsonb;
-- Feature 6: free-form customer note attached to the request.
-- Currently surfaced only by resume requests (where the customer
-- explains why they want reactivation), but the column is generic
-- so future flows could reuse it. Distinct from billing_notes
-- (provision-only, accounting-related) and admin_notes (admin's
-- reason on reject/approve). Optional — nullable.
ALTER TABLE tenant_requests ADD COLUMN IF NOT EXISTS customer_notes TEXT;
-- Bug 37a: resume requests use the same table as provision requests so
-- the customer dashboard and admin queue share rendering. Discriminator
-- is request_type. Default 'provision' on backfill keeps existing rows
-- working without explicit migration.
--
-- Resume rows have:
-- request_type = 'resume'
-- tenant_name = the existing tenant being requested for reactivation
-- zitadel_org_id = the org owning that tenant
-- zitadel_user_id = the requesting customer
-- status = pending → approved/rejected (or cancelled by customer)
-- most provision-only fields (packages, billing_address, etc.) are NULL
ALTER TABLE tenant_requests ADD COLUMN IF NOT EXISTS request_type TEXT NOT NULL DEFAULT 'provision';
-- Constrain to the known set so a future code change can't accidentally
-- write a third type without first widening this constraint.
DO $$ BEGIN
ALTER TABLE tenant_requests ADD CONSTRAINT tenant_requests_request_type_check
CHECK (request_type IN ('provision', 'resume'));
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
-- Tenant_name uniqueness was originally meant for "one tenant CR per
-- approved provision request". Resume requests reuse a tenant_name,
-- so the uniqueness must now be scoped to provision rows only.
DROP INDEX IF EXISTS uniq_tenant_requests_tenant_name;
CREATE UNIQUE INDEX IF NOT EXISTS uniq_tenant_requests_tenant_name_provision
ON tenant_requests(tenant_name)
WHERE tenant_name IS NOT NULL AND request_type = 'provision';
-- Only one pending resume request per tenant at a time. Otherwise a
-- customer could spam-create resume requests (the admin queue would
-- bloat) or two admins might race on approving duplicates.
CREATE UNIQUE INDEX IF NOT EXISTS uniq_tenant_requests_pending_resume
ON tenant_requests(tenant_name)
WHERE tenant_name IS NOT NULL AND request_type = 'resume' AND status = 'pending';
-- Slice 3: drop the legacy 1-org-1-request constraint if it exists
ALTER TABLE tenant_requests DROP CONSTRAINT IF EXISTS tenant_requests_zitadel_org_id_key;
-- Workspace templates: admin-editable default content for workspace files
CREATE TABLE IF NOT EXISTS workspace_templates (
file_key TEXT PRIMARY KEY,
content TEXT NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- ---------------------------------------------------------------------------
-- Slice 6: per-tenant user assignments
-- ---------------------------------------------------------------------------
--
-- Each row grants ONE user visibility into ONE tenant within their own
-- ZITADEL org. Used to narrow the customer 'user' role from "everything
-- in the org" to "only the tenants I've been assigned to". Owners and
-- platform users bypass this table entirely.
--
-- Composite PK is (tenant_name, zitadel_user_id) — a user is either
-- assigned to a tenant or not, no degree.
--
-- The zitadel_org_id column is denormalised onto every row so cascade
-- cleanups when a user leaves an org can be expressed as a single
-- DELETE WHERE zitadel_org_id=$1 AND zitadel_user_id=$2 — without
-- joining tenant_requests. The assigned_by column tracks which user
-- (the owner usually) granted the assignment, for audit.
--
-- Cascade on tenant deletion is enforced in application code (the
-- admin delete handler calls removeAllAssignmentsForTenant) rather
-- than via FK — there's no FK target, since K8s CRs aren't a Postgres
-- table.
CREATE TABLE IF NOT EXISTS tenant_user_assignments (
tenant_name TEXT NOT NULL,
zitadel_org_id TEXT NOT NULL,
zitadel_user_id TEXT NOT NULL,
assigned_at TIMESTAMPTZ NOT NULL DEFAULT now(),
assigned_by TEXT NOT NULL,
PRIMARY KEY (tenant_name, zitadel_user_id)
);
CREATE INDEX IF NOT EXISTS idx_tua_user ON tenant_user_assignments(zitadel_user_id);
CREATE INDEX IF NOT EXISTS idx_tua_org ON tenant_user_assignments(zitadel_org_id);
-- Bug 35: org-scoped billing. One row per ZITADEL org; captured by
-- the first tenant request inline, editable afterwards via
-- /settings/billing. Subsequent tenant requests in the same org read
-- this and skip the billing step entirely.
--
-- vat_number is nullable: required at write time for company orgs
-- (enforced by the API, not the schema, because "company-or-personal"
-- isn't expressible as a column constraint). Notes is free-form
-- accounting context — VAT exemption reasons, special invoicing
-- arrangements, etc.
--
-- We do NOT migrate data from tenant_requests.billing_address into
-- this table automatically. Existing customers re-enter on next
-- tenant or via settings — the data set is small (single-digit
-- customers in pilot) and re-entering is the simplest path.
CREATE TABLE IF NOT EXISTS org_billing (
zitadel_org_id TEXT PRIMARY KEY,
company_name TEXT NOT NULL,
-- Phase 6 fix: optional contact-person line shown on the
-- invoice PDF below the company name (e.g. "z.Hd. Herr Müller").
-- Not normally needed since invoices are delivered by email
-- link, but useful when customers forward the PDF internally
-- for AP routing in larger organizations.
contact_name TEXT,
street_address TEXT NOT NULL,
postal_code TEXT NOT NULL,
city TEXT NOT NULL,
country TEXT NOT NULL,
vat_number TEXT,
billing_email TEXT NOT NULL,
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Phase 6 fix: ensure the column exists on databases that were
-- created before contact_name was added to the base schema above.
-- IF NOT EXISTS makes this safe to run repeatedly via ensureSchema.
ALTER TABLE org_billing ADD COLUMN IF NOT EXISTS contact_name TEXT;
-- Feature 5: lightweight customer support / feedback tickets.
-- Scoped strictly per-user (zitadel_user_id), not per-org —
-- coworkers in the same org cannot see each other's tickets. The
-- index on (zitadel_user_id, status) is what most customer-side
-- queries hit; the index on (status, updated_at DESC) is for the
-- admin queue.
--
-- contact_email / contact_name are frozen at creation time so the
-- ticket retains a working "reply-to" identity even if the user
-- later changes their email or display name in ZITADEL.
CREATE TABLE IF NOT EXISTS support_tickets (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
zitadel_org_id TEXT NOT NULL,
zitadel_user_id TEXT NOT NULL,
title TEXT NOT NULL,
description TEXT NOT NULL,
category TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'open',
contact_email TEXT NOT NULL,
contact_name TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- CHECK constraints added separately so re-running the migration
-- against an existing table (without these constraints) works.
-- IF NOT EXISTS isn't supported on ADD CONSTRAINT, hence the
-- DO $$ wrapper.
DO $$ BEGIN
ALTER TABLE support_tickets ADD CONSTRAINT support_tickets_category_check
CHECK (category IN ('bug','feature_request','question','billing','other'));
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
DO $$ BEGIN
ALTER TABLE support_tickets ADD CONSTRAINT support_tickets_status_check
CHECK (status IN ('open','in_progress','waiting_for_customer','resolved','reopened'));
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
CREATE INDEX IF NOT EXISTS idx_support_tickets_user
ON support_tickets(zitadel_user_id, updated_at DESC);
CREATE INDEX IF NOT EXISTS idx_support_tickets_status
ON support_tickets(status, updated_at DESC);
-- Threaded comments. ON DELETE CASCADE so deleting a ticket
-- cleans up its history; we don't currently expose ticket
-- deletion in the UI but the cascade keeps options open.
CREATE TABLE IF NOT EXISTS support_ticket_comments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
ticket_id UUID NOT NULL REFERENCES support_tickets(id) ON DELETE CASCADE,
author_user_id TEXT NOT NULL,
author_name TEXT NOT NULL,
author_kind TEXT NOT NULL,
body TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
DO $$ BEGIN
ALTER TABLE support_ticket_comments ADD CONSTRAINT support_ticket_comments_author_kind_check
CHECK (author_kind IN ('customer','admin'));
EXCEPTION WHEN duplicate_object THEN NULL;
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()
);
-- Phase 2 addition: per-skill one-time setup fee. Charged the
-- first time a given (tenant, skill) appears on an invoice line.
-- Default 0 so pricing rows created before this column exists
-- stay free until the admin sets a fee.
ALTER TABLE skill_pricing
ADD COLUMN IF NOT EXISTS setup_fee_chf NUMERIC(10,2) NOT NULL DEFAULT 0;
-- 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()
);
-- Phase 9: saved-card columns. The PaymentMethod id ('pm_xxx')
-- is the handle for off-session charges; brand/last4/exp are
-- display fields. No PAN, CVV, or anything PCI-scope — Stripe
-- holds those. The columns are nullable because a fresh org has
-- no saved card; setting up auto-pay populates them via the
-- checkout.session.completed webhook in setup mode.
ALTER TABLE org_billing_config
ADD COLUMN IF NOT EXISTS stripe_default_payment_method_id TEXT;
ALTER TABLE org_billing_config
ADD COLUMN IF NOT EXISTS stripe_pm_brand TEXT;
ALTER TABLE org_billing_config
ADD COLUMN IF NOT EXISTS stripe_pm_last4 TEXT;
ALTER TABLE org_billing_config
ADD COLUMN IF NOT EXISTS stripe_pm_exp_month INTEGER;
ALTER TABLE org_billing_config
ADD COLUMN IF NOT EXISTS stripe_pm_exp_year INTEGER;
-- Phase 9: off-session auto-charge gate. Default TRUE — new orgs
-- pay by card automatically when an invoice is issued (assuming
-- they've also set up a saved card). Admin can flip OFF to pause
-- charging without removing the saved card.
ALTER TABLE org_billing_config
ADD COLUMN IF NOT EXISTS auto_charge_enabled BOOLEAN NOT NULL DEFAULT TRUE;
-- 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()
);
-- Phase 2 addition: PDF locale, frozen at issue time so re-rendering
-- an old invoice produces an identical document. Defaults to 'de'
-- since most pilot customers are Swiss B2B; the generator UI lets
-- admin override at issue time.
ALTER TABLE invoices
ADD COLUMN IF NOT EXISTS locale TEXT NOT NULL DEFAULT 'de';
-- Phase 7 schema: void + refund tracking on the existing invoices
-- table, plus the credit-note and refund-event sub-entities. These
-- ALTERs are idempotent (IF NOT EXISTS) so re-running ensureSchema
-- on an already-migrated DB is a no-op.
ALTER TABLE invoices
ADD COLUMN IF NOT EXISTS void_reason TEXT;
ALTER TABLE invoices
ADD COLUMN IF NOT EXISTS voided_at TIMESTAMPTZ;
ALTER TABLE invoices
ADD COLUMN IF NOT EXISTS voided_by TEXT;
ALTER TABLE invoices
ADD COLUMN IF NOT EXISTS refunded_total_chf NUMERIC(10,2) NOT NULL DEFAULT 0;
-- Extend the status CHECK to allow partially/fully_refunded. We
-- drop-then-add because there's no "ALTER CONSTRAINT ... ADD
-- VALUE" in stock Postgres. Drop is conditional on the constraint
-- name; the constraint is auto-named after the table+column.
DO $constraints$
BEGIN
ALTER TABLE invoices DROP CONSTRAINT IF EXISTS invoices_status_check;
ALTER TABLE invoices ADD CONSTRAINT invoices_status_check CHECK (
status IN ('draft','open','paid','overdue','void','uncollectible',
'partially_refunded','fully_refunded')
);
END
$constraints$;
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);
-- Phase 8: distinguish auto (cron) from custom (admin-entered)
-- invoices. All pre-Phase-8 rows backfill to 'auto' via the
-- column DEFAULT. Custom invoices skip the per-org/per-month
-- uniqueness guard (admin may issue multiple custom invoices
-- against the same org in the same month).
ALTER TABLE invoices
ADD COLUMN IF NOT EXISTS source TEXT NOT NULL DEFAULT 'auto'
CHECK (source IN ('auto','custom'));
-- period_start / period_end become nullable so custom invoices
-- (no fixed billing period) can leave them empty. Auto-cron
-- invoices keep their values; only the NOT NULL constraint is
-- relaxed. Idempotent: DROP NOT NULL on an already-nullable column
-- is a no-op in Postgres.
ALTER TABLE invoices ALTER COLUMN period_start DROP NOT NULL;
ALTER TABLE invoices ALTER COLUMN period_end DROP NOT NULL;
-- Replace the old unconditional uniqueness with a partial index
-- limited to auto invoices. Both invariants the cron relies on
-- (no double-issuance per period) survive; custom invoices are
-- unaffected. Idempotent: DROP IF EXISTS handles the migration
-- case, and CREATE IF NOT EXISTS handles re-runs.
DROP INDEX IF EXISTS uniq_invoices_org_period;
CREATE UNIQUE INDEX IF NOT EXISTS uniq_invoices_org_period_auto
ON invoices(zitadel_org_id, period_start)
WHERE source = 'auto';
-- Phase 7: credit notes. One row per void or refund event. The
-- credit_note_number follows CN-YYYY-NNNNN allocated from the
-- per-year counter below.
CREATE TABLE IF NOT EXISTS credit_note_counters (
year INTEGER PRIMARY KEY,
last_number INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS credit_notes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
credit_note_number TEXT NOT NULL UNIQUE,
invoice_id UUID NOT NULL REFERENCES invoices(id),
zitadel_org_id TEXT NOT NULL,
kind TEXT NOT NULL CHECK (kind IN ('void','refund')),
amount_chf NUMERIC(10,2) NOT NULL,
vat_amount_chf NUMERIC(10,2) NOT NULL DEFAULT 0,
reason TEXT,
issued_at TIMESTAMPTZ NOT NULL DEFAULT now(),
issued_by TEXT NOT NULL,
locale TEXT NOT NULL DEFAULT 'de',
pdf_data BYTEA,
pdf_filename TEXT,
-- Frozen snapshot of the customer's billing address at the time
-- the credit note is issued. Mirrors invoices.billing_snapshot
-- so the PDF is self-contained and reproducible.
billing_snapshot JSONB NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_credit_notes_invoice
ON credit_notes(invoice_id);
CREATE INDEX IF NOT EXISTS idx_credit_notes_org
ON credit_notes(zitadel_org_id, issued_at DESC);
-- Phase 7: per-refund-event log. Each row is one Stripe Refund
-- object (stripe_refund_id non-null) OR one manual admin action
-- for invoice-paid customers (stripe_refund_id null). The
-- credit_note_id links the refund to the credit note PDF it
-- generated.
CREATE TABLE IF NOT EXISTS invoice_refunds (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
invoice_id UUID NOT NULL REFERENCES invoices(id),
stripe_refund_id TEXT UNIQUE,
amount_chf NUMERIC(10,2) NOT NULL,
reason TEXT,
status TEXT NOT NULL DEFAULT 'succeeded'
CHECK (status IN ('pending','succeeded','failed','canceled')),
refunded_at TIMESTAMPTZ NOT NULL DEFAULT now(),
refunded_by TEXT NOT NULL,
credit_note_id UUID REFERENCES credit_notes(id)
);
CREATE INDEX IF NOT EXISTS idx_invoice_refunds_invoice
ON invoice_refunds(invoice_id);
-- Phase 7 fix: the credit_notes.invoice_id and
-- invoice_refunds.invoice_id FKs were originally created without
-- ON DELETE CASCADE, which made admin "delete invoice" fail with
-- a FK violation when the invoice had any voids/refunds attached.
-- The production policy is to never delete an invoice that's been
-- refunded (the credit notes are part of the customer's records),
-- but the schema should allow the admin tool to clean up for test
-- data. We drop and re-add the FKs with CASCADE so a delete tears
-- down everything related. The DROP/ADD pair is idempotent — on a
-- DB that already has the CASCADE variant it's a no-op (we drop
-- by name and re-add identically).
DO $cnfk$
BEGIN
ALTER TABLE credit_notes
DROP CONSTRAINT IF EXISTS credit_notes_invoice_id_fkey;
ALTER TABLE credit_notes
ADD CONSTRAINT credit_notes_invoice_id_fkey
FOREIGN KEY (invoice_id) REFERENCES invoices(id) ON DELETE CASCADE;
END
$cnfk$;
DO $irfk$
BEGIN
ALTER TABLE invoice_refunds
DROP CONSTRAINT IF EXISTS invoice_refunds_invoice_id_fkey;
ALTER TABLE invoice_refunds
ADD CONSTRAINT invoice_refunds_invoice_id_fkey
FOREIGN KEY (invoice_id) REFERENCES invoices(id) ON DELETE CASCADE;
END
$irfk$;
-- 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','skill_setup','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);
-- Phase 2.5: queue for skills flagged with requiresManualSetup in
-- the package catalog. A user-side enable on a flagged skill
-- creates a pending row here instead of mutating tenant.spec.packages;
-- the operator never sees the skill until admin approves and adds
-- it to the spec. Disable is always direct — there's no gate on
-- turning a skill off, even one that previously required setup.
CREATE TABLE IF NOT EXISTS skill_activation_requests (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_name TEXT NOT NULL,
zitadel_org_id TEXT NOT NULL,
zitadel_user_id TEXT NOT NULL,
skill_id TEXT NOT NULL,
status TEXT NOT NULL CHECK (status IN ('pending', 'approved', 'rejected', 'withdrawn')) DEFAULT 'pending',
requested_at TIMESTAMPTZ NOT NULL DEFAULT now(),
reviewed_at TIMESTAMPTZ,
reviewed_by TEXT,
rejection_reason TEXT,
admin_notes TEXT
);
-- Only one in-flight request per (tenant, skill). Rejected and
-- approved rows don't block new requests; user can retry after a
-- rejection by toggling the skill again.
CREATE UNIQUE INDEX IF NOT EXISTS uniq_skill_act_one_pending
ON skill_activation_requests (tenant_name, skill_id)
WHERE status = 'pending';
-- Admin queue lookup — partial index keeps it tiny.
CREATE INDEX IF NOT EXISTS idx_skill_act_pending_status
ON skill_activation_requests (requested_at DESC)
WHERE status = 'pending';
-- Per-tenant lookup for the customer UI's pending+rejected display.
CREATE INDEX IF NOT EXISTS idx_skill_act_tenant
ON skill_activation_requests (tenant_name, requested_at DESC);
-- Phase 3 fix: the original invoice_lines.kind CHECK constraint
-- was created without 'skill_setup' (which Phase 2-fix6 added as
-- a new line kind for per-skill setup fees). CREATE TABLE IF NOT
-- EXISTS doesn't update constraints on existing tables, so we
-- explicitly drop and re-add with the full kind set on every
-- boot. Idempotent — DROP IF EXISTS swallows the not-yet-exists
-- case (fresh installs); ADD always re-creates. Constraint name
-- follows Postgres's default <table>_<column>_check.
ALTER TABLE invoice_lines
DROP CONSTRAINT IF EXISTS invoice_lines_kind_check;
ALTER TABLE invoice_lines
ADD CONSTRAINT invoice_lines_kind_check
CHECK (kind IN (
'tenant_monthly','tenant_setup','ai_usage','threema_messages',
'skill_usage','skill_setup','adjustment',
-- Phase 8: lines on admin-created custom invoices. PDF
-- groups these under a "Services" section header.
'custom_line'
));
-- Phase 8: drafts for the admin "New invoice" flow. The payload
-- is a JSONB blob with the in-progress form (lines, dates,
-- locale, etc.). On Issue the payload is read, a real Invoice
-- row is allocated via createInvoice with source='custom', and
-- this draft row is deleted. Drafts are admin-only — they have
-- no invoice number, no PDF, and aren't reachable from any
-- customer-facing route.
--
-- Why a JSONB blob instead of mirroring the invoices schema:
-- drafts and issued invoices share only a fraction of fields
-- (no number, no totals computed yet, possibly incomplete
-- billing snapshot), and any parallel-table design would force
-- a costly conversion step. The blob keeps the schema minimal
-- and the read/write paths trivial.
CREATE TABLE IF NOT EXISTS invoice_drafts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
zitadel_org_id TEXT NOT NULL,
created_by TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
payload JSONB NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_invoice_drafts_org
ON invoice_drafts(zitadel_org_id, updated_at DESC);
CREATE INDEX IF NOT EXISTS idx_invoice_drafts_recent
ON invoice_drafts(updated_at DESC);
-- Phase 4: Stripe webhook idempotency. Stripe guarantees at-least-once
-- delivery and retries failures with exponential backoff for up to 72h,
-- so the same event.id can arrive multiple times. We insert each
-- event.id with the PK constraint enforcing uniqueness; INSERT either
-- succeeds (first delivery → process the event) or fails with 23505
-- (duplicate → ack with 200 and skip). The payload column is invaluable
-- when diagnosing a webhook that processed wrong; keep it small and
-- prune old rows out-of-band if storage becomes a concern (Phase 7).
CREATE TABLE IF NOT EXISTS stripe_events (
event_id TEXT PRIMARY KEY,
event_type TEXT NOT NULL,
received_at TIMESTAMPTZ NOT NULL DEFAULT now(),
processed_at TIMESTAMPTZ,
payload JSONB
);
CREATE INDEX IF NOT EXISTS idx_stripe_events_type_received
ON stripe_events (event_type, received_at DESC);
-- Phase 5: Cron run history. One row per invocation of either the
-- monthly issuance sweep or the daily reminder sweep, regardless of
-- whether it ran from K8s CronJob or an admin's manual trigger.
-- The summary counters let the admin UI render "last run: 12 issued,
-- 0 failed" without joining against invoices/reminders. Detail rows
-- live in the JSONB error_details on failure for diagnosis.
CREATE TABLE IF NOT EXISTS cron_run_history (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
run_kind TEXT NOT NULL CHECK (run_kind IN ('monthly_issue','reminders')),
triggered_by TEXT NOT NULL, -- 'cron' or '<admin-user-id>'
started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
finished_at TIMESTAMPTZ,
success_count INT NOT NULL DEFAULT 0,
failure_count INT NOT NULL DEFAULT 0,
skipped_count INT NOT NULL DEFAULT 0,
error_details JSONB
);
CREATE INDEX IF NOT EXISTS idx_cron_run_history_kind_started
ON cron_run_history (run_kind, started_at DESC);
`;
let migrated = false;
export async function ensureSchema(): Promise<void> {
if (migrated) return;
await getPool().query(MIGRATION_SQL);
migrated = true;
}
// ---------------------------------------------------------------------------
// Workspace templates
// ---------------------------------------------------------------------------
/**
* Get a workspace template by file key (e.g. "SOUL.md", "AGENTS.md", "TOOLS.md").
* Returns null if no template is stored for this key.
*/
export async function getWorkspaceTemplate(
fileKey: string
): Promise<string | null> {
await ensureSchema();
const result = await getPool().query<{ content: string }>(
"SELECT content FROM workspace_templates WHERE file_key = $1",
[fileKey]
);
return result.rows[0]?.content ?? null;
}
/**
* Upsert a workspace template.
*/
export async function setWorkspaceTemplate(
fileKey: string,
content: string
): Promise<void> {
await ensureSchema();
await getPool().query(
`INSERT INTO workspace_templates (file_key, content, updated_at)
VALUES ($1, $2, now())
ON CONFLICT (file_key) DO UPDATE SET content = $2, updated_at = now()`,
[fileKey, content]
);
}
/**
* List all workspace templates.
*/
export async function listWorkspaceTemplates(): Promise<
Array<{ fileKey: string; content: string; updatedAt: string }>
> {
await ensureSchema();
const result = await getPool().query(
"SELECT file_key, content, updated_at FROM workspace_templates ORDER BY file_key"
);
return result.rows.map((r: any) => ({
fileKey: r.file_key,
content: r.content,
updatedAt: r.updated_at?.toISOString?.() ?? r.updated_at,
}));
}
// ---------------------------------------------------------------------------
// Tenant requests CRUD
// ---------------------------------------------------------------------------
export async function createTenantRequest(
params: Omit<TenantRequest, "id" | "status" | "createdAt" | "updatedAt"> & {
encryptedSecrets?: Buffer;
}
): Promise<TenantRequest> {
await ensureSchema();
const result = await getPool().query<TenantRequest>(
`INSERT INTO tenant_requests
(zitadel_org_id, zitadel_user_id, company_name, instance_name,
contact_name, contact_email, agent_name, soul_md, agents_md,
packages, billing_address, billing_notes, encrypted_secrets,
is_personal, channel_users)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15::jsonb)
RETURNING *`,
[
params.zitadelOrgId,
params.zitadelUserId,
params.companyName,
params.instanceName ?? null,
params.contactName,
params.contactEmail,
params.agentName,
params.soulMd,
params.agentsMd ?? null,
params.packages,
JSON.stringify(params.billingAddress),
params.billingNotes,
params.encryptedSecrets ?? null,
params.isPersonal ?? false,
JSON.stringify(params.channelUsers ?? {}),
]
);
return mapRow(result.rows[0]);
}
export async function getTenantRequestById(
id: string
): Promise<TenantRequest | null> {
await ensureSchema();
const result = await getPool().query<TenantRequest>(
"SELECT * FROM tenant_requests WHERE id = $1",
[id]
);
return result.rows[0] ? mapRow(result.rows[0]) : null;
}
/**
* Slice 3: returns ALL requests for an org, most recent first.
*
* Replaces the pre-Slice-3 `getTenantRequestByOrgId` which returned the
* single most recent row. Callers that previously assumed one-row-per-org
* must now iterate or pick by status. The intent is explicit at every
* call site, which is the point of the rename.
*
* Includes rows in every status (pending, approved, provisioning, active,
* rejected, deleted). For "active or in-flight only" filtering, see
* {@link listActiveTenantRequestsByOrgId}.
*/
export async function listTenantRequestsByOrgId(
orgId: string
): Promise<TenantRequest[]> {
await ensureSchema();
const result = await getPool().query<TenantRequest>(
"SELECT * FROM tenant_requests WHERE zitadel_org_id = $1 ORDER BY created_at DESC",
[orgId]
);
return result.rows.map(mapRow);
}
/**
* As {@link listTenantRequestsByOrgId} but tuned for the customer's
* dashboard view.
*
* Returns:
* - All non-terminal rows (pending, approved, provisioning, active),
* because the customer needs to see what's in flight.
* - Terminal-failed rows (rejected, cancelled) that the customer
* hasn't dismissed yet (Bug 13). Without this, a rejection that
* happens while the customer isn't online would only be
* communicated by email — easy to miss.
*
* Excludes:
* - `deleted` rows (admin tore down the tenant — historical, not
* actionable).
* - Dismissed rejected/cancelled rows.
*/
export async function listActiveTenantRequestsByOrgId(
orgId: string
): Promise<TenantRequest[]> {
await ensureSchema();
const result = await getPool().query<TenantRequest>(
`SELECT * FROM tenant_requests
WHERE zitadel_org_id = $1
AND status <> 'deleted'
AND (status NOT IN ('rejected', 'cancelled') OR dismissed_at IS NULL)
ORDER BY created_at DESC`,
[orgId]
);
return result.rows.map(mapRow);
}
/**
* Returns the most recent approved-or-active request for an org. Used to
* seed billing/contact defaults when a customer creates an additional
* instance — saves them re-typing data already on file.
*
* Returns null if the org has never had an approved instance (e.g. first
* registration is still pending).
*/
export async function getMostRecentApprovedRequestForOrg(
orgId: string
): Promise<TenantRequest | null> {
await ensureSchema();
const result = await getPool().query<TenantRequest>(
`SELECT * FROM tenant_requests
WHERE zitadel_org_id = $1
AND status IN ('approved', 'provisioning', 'active')
ORDER BY created_at DESC
LIMIT 1`,
[orgId]
);
return result.rows[0] ? mapRow(result.rows[0]) : null;
}
export async function listTenantRequests(
status?: TenantRequestStatus
): Promise<TenantRequest[]> {
await ensureSchema();
// Phase 9b: 'pending_payment' rows are pre-Checkout: the customer
// submitted the wizard but hasn't paid the setup fee yet. They're
// invisible to admin until the webhook flips them to 'pending'.
// The explicit filter path still allows querying them (e.g.
// ?status=pending_payment) for debugging.
const result = status
? await getPool().query<TenantRequest>(
"SELECT * FROM tenant_requests WHERE status = $1 ORDER BY created_at DESC",
[status]
)
: await getPool().query<TenantRequest>(
"SELECT * FROM tenant_requests WHERE status <> 'pending_payment' ORDER BY created_at DESC"
);
return result.rows.map(mapRow);
}
export async function updateTenantRequestStatus(
id: string,
status: TenantRequestStatus,
extra?: {
adminNotes?: string | null;
tenantName?: string;
clearAdminNotes?: boolean;
}
): Promise<TenantRequest> {
await ensureSchema();
const sets = ["status = $2", "updated_at = now()"];
const values: any[] = [id, status];
let idx = 3;
if (extra?.adminNotes !== undefined) {
sets.push(`admin_notes = $${idx}`);
values.push(extra.adminNotes);
idx++;
}
if (extra?.clearAdminNotes) {
sets.push("admin_notes = NULL");
}
if (extra?.tenantName) {
sets.push(`tenant_name = $${idx}`);
values.push(extra.tenantName);
idx++;
}
const result = await getPool().query<TenantRequest>(
`UPDATE tenant_requests SET ${sets.join(", ")} WHERE id = $1 RETURNING *`,
values
);
return mapRow(result.rows[0]);
}
/**
* Clear the encrypted_secrets column after secrets have been written to OpenBao.
* Called during admin approval after successful vault writes.
*/
export async function clearEncryptedSecrets(requestId: string): Promise<void> {
await ensureSchema();
await getPool().query(
"UPDATE tenant_requests SET encrypted_secrets = NULL, updated_at = now() WHERE id = $1",
[requestId]
);
}
/**
* Set dismissed_at = now() on a request row. Used when a customer
* clicks "Dismiss" on a rejected/cancelled card on their dashboard
* (Bug 13). The row stays in the database for history/audit but
* stops appearing in `listActiveTenantRequestsByOrgId`.
*
* Idempotent: dismissing an already-dismissed row is a no-op.
* Caller is responsible for verifying the row belongs to the user's
* org before calling.
*/
/**
* Create a resume request (Bug 37a). Used when an owner of a suspended
* tenant wants to reactivate it. Resume is admin-gated — the request
* sits as `pending` until a platform admin approves or rejects it.
*
* Tenant-name uniqueness is enforced for `pending` resume rows by a
* partial unique index, so a customer can't spam the queue with
* duplicate resume requests for the same tenant. The DB throws a
* unique-violation if they try; callers should catch that and translate
* to a 409.
*
* Why this lives in tenant_requests instead of a separate table:
* - the lifecycle is identical (pending → approved/rejected, plus
* customer-side cancel and dismiss-after-terminal)
* - the customer dashboard renders pending+resume cards from the
* same `listActiveTenantRequestsByOrgId` query — adding a separate
* table would mean two queries and union-merging in the UI
* - the admin queue likewise treats them uniformly
* The cost is a discriminator column (`request_type`) and most
* provision-only fields being null on resume rows. That's a tradeoff
* I think is worth it.
*/
export async function createResumeRequest(params: {
tenantName: string;
zitadelOrgId: string;
zitadelUserId: string;
contactName: string;
contactEmail: string;
// Provision-only fields default sensibly. company_name + agent_name
// are NOT NULL in the original schema; we copy them from the existing
// tenant request for traceability rather than storing dummy values.
companyName: string;
agentName: string;
/**
* Feature 6: optional free-form note from the customer explaining
* why they want reactivation. Surfaced to admin in the queue and
* forwarded to the platform notification email so the admin can
* decide before opening the request.
*/
customerNotes?: string | null;
}): Promise<TenantRequest> {
await ensureSchema();
const result = await getPool().query(
`INSERT INTO tenant_requests (
zitadel_org_id, zitadel_user_id, company_name,
contact_name, contact_email, agent_name,
tenant_name, request_type, status, customer_notes
) VALUES ($1, $2, $3, $4, $5, $6, $7, 'resume', 'pending', $8)
RETURNING *`,
[
params.zitadelOrgId,
params.zitadelUserId,
params.companyName,
params.contactName,
params.contactEmail,
params.agentName,
params.tenantName,
params.customerNotes ?? null,
]
);
return mapRow(result.rows[0]);
}
/**
* Get the most recent provision request for a tenant_name. Used by
* Bug 37a's resume-request creation to populate company_name and
* agent_name (NOT NULL columns) from the original provision row
* rather than make up values.
*
* Returns null when no such row exists — should be impossible in
* normal flow (resume requests are only created for already-existing
* tenants whose CR was created via approving a provision request),
* but the caller should guard against it for safety.
*/
export async function getTenantRequestByTenantName(
tenantName: string
): Promise<TenantRequest | null> {
await ensureSchema();
const result = await getPool().query(
`SELECT * FROM tenant_requests
WHERE tenant_name = $1
AND request_type = 'provision'
ORDER BY created_at DESC
LIMIT 1`,
[tenantName]
);
return result.rows.length > 0 ? mapRow(result.rows[0]) : null;
}
/**
* Return the in-flight (pending) resume request for a given tenant, if
* any. Used both to gate the customer's "Request reactivation" button
* (don't allow a second when one's already pending) and by the admin
* UI to navigate from the tenant detail page to the awaiting request.
*
* Returns null when no pending resume exists. Approved/rejected rows
* are never returned — they're terminal.
*/
export async function getPendingResumeRequestForTenant(
tenantName: string
): Promise<TenantRequest | null> {
await ensureSchema();
const result = await getPool().query(
`SELECT * FROM tenant_requests
WHERE tenant_name = $1
AND request_type = 'resume'
AND status = 'pending'
LIMIT 1`,
[tenantName]
);
return result.rows.length > 0 ? mapRow(result.rows[0]) : null;
}
export async function dismissTenantRequest(id: string): Promise<void> {
await ensureSchema();
await getPool().query(
`UPDATE tenant_requests
SET dismissed_at = COALESCE(dismissed_at, now()),
updated_at = now()
WHERE id = $1`,
[id]
);
}
/**
* Update editable fields of a still-pending tenant request. Bug 6 — a
* customer who notices a typo or wants to add a package after submitting
* the wizard should be able to fix it without admin involvement.
*
* Only the customer-input fields are updateable. `status`, `tenant_name`,
* `admin_notes`, `encrypted_secrets`, `is_personal`, `zitadel_*` and
* timestamps are managed elsewhere and intentionally not here.
*
* The caller is responsible for:
* - verifying the row belongs to the user's org
* - verifying status === 'pending' (editing approved/provisioning rows
* would race against the operator)
*
* Returns the updated row, or null if the id didn't match anything.
*/
export async function updateTenantRequestEditableFields(
id: string,
fields: {
instanceName?: string | null;
agentName?: string;
soulMd?: string;
agentsMd?: string | null;
packages?: string[];
billingAddress?: BillingAddress;
billingNotes?: string;
encryptedSecrets?: Buffer | null;
}
): Promise<TenantRequest | null> {
await ensureSchema();
const sets: string[] = ["updated_at = now()"];
const values: any[] = [id];
let idx = 2;
// Map JS field names to SQL columns. Each entry is gated on
// `!== undefined` so passing only some fields just updates those.
const colMap: Array<[keyof typeof fields, string]> = [
["instanceName", "instance_name"],
["agentName", "agent_name"],
["soulMd", "soul_md"],
["agentsMd", "agents_md"],
["packages", "packages"],
["billingAddress", "billing_address"],
["billingNotes", "billing_notes"],
["encryptedSecrets", "encrypted_secrets"],
];
for (const [jsField, sqlCol] of colMap) {
const v = fields[jsField];
if (v === undefined) continue;
sets.push(`${sqlCol} = $${idx}`);
values.push(v);
idx++;
}
if (sets.length === 1) {
// No editable fields supplied — return the row unchanged rather
// than running a useless UPDATE that just bumps updated_at.
const cur = await getTenantRequestById(id);
return cur;
}
const result = await getPool().query<TenantRequest>(
`UPDATE tenant_requests SET ${sets.join(", ")} WHERE id = $1 RETURNING *`,
values
);
return result.rows[0] ? mapRow(result.rows[0]) : null;
}
/**
* Wrapper around domain-check.ts that injects the portal's connection pool.
* Kept here so route handlers don't need direct access to the pool.
*/
export async function checkDuplicateDomain(email: string) {
await ensureSchema();
// Lazy import to keep db.ts free of fetch/AbortSignal at module load time.
const { checkRegistrationDomain } = await import("./domain-check");
return checkRegistrationDomain(getPool(), email);
}
/**
* Mark a single tenant request as "deleted" when the associated tenant CR
* is deleted. With multi-tenant per org this affects exactly one row,
* since tenant_name is unique by index. The customer's other instances
* are untouched.
*/
export async function markTenantRequestDeletedByTenantName(
tenantName: string
): Promise<void> {
await ensureSchema();
await getPool().query(
"UPDATE tenant_requests SET status = 'deleted', tenant_name = NULL, updated_at = now() WHERE tenant_name = $1",
[tenantName]
);
}
/**
* Delete a tenant request row entirely. Used when a customer re-submits
* after their previous tenant was deleted by admin.
*/
export async function deleteTenantRequest(id: string): Promise<void> {
await ensureSchema();
await getPool().query("DELETE FROM tenant_requests WHERE id = $1", [id]);
}
/**
* Reconcile the portal's tenant_requests table against actual cluster
* state. Three passes, walking only rows with `tenant_name` set:
*
* 1. provisioning → active: when a tenant CR's phase reaches Ready
* or Running, the portal flips the row to active so the
* "provisioning…" card transitions into the running tenant view.
*
* 2. active/provisioning → deleted: when the corresponding CR no
* longer exists in the cluster (404), or is mid-deletion (has
* metadata.deletionTimestamp set), the row gets flipped to
* `deleted`. The DB is otherwise blind to operator-initiated
* deletions — when the 60-day TTL fires (Bug 37b) and the
* operator deletes a suspended tenant, the portal would happily
* keep showing the "Your assistant is ready!" card forever.
* Without this reconciliation the dashboard drifts from reality.
*
* 3. pending resume → cancelled: when a pending resume request's
* tenant is no longer suspended (admin resumed it directly,
* tenant was deleted, or it was never suspended in the first
* place), the request is moot. Flip to 'cancelled' so the
* pending-resume unique index releases for any future genuine
* resume request. We pick `cancelled` over `rejected` because
* the customer didn't do anything wrong — circumstances just
* changed.
*
* Errors are tolerated per-row: a transient API hiccup on one tenant
* shouldn't fail the whole sweep. Skipped rows get retried next call.
*
* Slice 3 note: with multi-tenant per org, this iterates each row
* individually (keyed by its own tenant_name), so multiple in-flight
* tenants in the same org are handled correctly.
*/
export async function syncProvisioningStatuses(): Promise<void> {
await ensureSchema();
// Active+provisioning rows: status reflects "the tenant should
// exist and be running".
// Pending resume rows: status reflects "the tenant is suspended,
// awaiting reactivation".
// Both need cluster-side validation; we fetch them in one query
// and dispatch on (status, request_type).
const result = await getPool().query<TenantRequest>(
`SELECT * FROM tenant_requests
WHERE tenant_name IS NOT NULL
AND (
status IN ('provisioning', 'active')
OR (status = 'pending' AND request_type = 'resume')
)`
);
for (const row of result.rows) {
const mapped = mapRow(row);
if (!mapped.tenantName) continue;
let tenant: Awaited<ReturnType<typeof getTenant>> = null;
try {
tenant = await getTenant(mapped.tenantName);
} catch {
// Transient API error — skip this row, retry on next sweep.
continue;
}
// Pending resume request: validity hinges on tenant being suspended.
if (
mapped.status === "pending" &&
mapped.requestType === "resume"
) {
// Tenant doesn't exist or is being deleted: cancel the resume
// request (it can never be fulfilled). Don't fall through to
// the "deleted" branch below — that would also flip the
// provision row, which is the right thing for a CR-level
// deletion but we want this resume row specifically resolved
// here.
if (!tenant || tenant.metadata.deletionTimestamp) {
await updateTenantRequestStatus(mapped.id, "cancelled");
continue;
}
// Tenant is no longer suspended: the request is moot.
// Cancel it (the customer didn't do anything wrong; the
// condition the request was about no longer applies).
if (!tenant.spec.suspend) {
await updateTenantRequestStatus(mapped.id, "cancelled");
continue;
}
// Tenant still suspended, request still relevant. Leave as-is.
continue;
}
// Active or provisioning row: CR gone, or mid-deletion. Flip the
// row to 'deleted'. `markTenantRequestDeletedByTenantName` flips
// every row with this tenant_name (provision + any resume rows),
// which is the right thing for a CR-level deletion.
if (!tenant || tenant.metadata.deletionTimestamp) {
await markTenantRequestDeletedByTenantName(mapped.tenantName);
continue;
}
// CR exists and is healthy. Promote provisioning → active when
// the operator reports the tenant has reached steady state.
// Keep `active` rows on `active` regardless of phase — a
// temporarily-Reconfiguring tenant is still active from the
// portal's billing/visibility perspective.
if (
mapped.status === "provisioning" &&
(tenant.status?.phase === "Ready" || tenant.status?.phase === "Running")
) {
await updateTenantRequestStatus(mapped.id, "active");
}
}
}
// ---------------------------------------------------------------------------
// Row mapper
// ---------------------------------------------------------------------------
function mapRow(row: any): TenantRequest {
return {
id: row.id,
zitadelOrgId: row.zitadel_org_id,
zitadelUserId: row.zitadel_user_id,
companyName: row.company_name,
instanceName: row.instance_name ?? null,
contactName: row.contact_name,
contactEmail: row.contact_email,
agentName: row.agent_name,
soulMd: row.soul_md,
agentsMd: row.agents_md ?? null,
packages: row.packages ?? [],
billingAddress: row.billing_address ?? {},
billingNotes: row.billing_notes,
customerNotes: row.customer_notes ?? null,
status: row.status as TenantRequestStatus,
adminNotes: row.admin_notes,
tenantName: row.tenant_name,
setupInvoiceId: row.setup_invoice_id ?? null,
channelUsers: (row.channel_users ?? {}) as Record<string, string[]>,
encryptedSecrets: row.encrypted_secrets ?? null,
isPersonal: row.is_personal ?? false,
dismissedAt:
row.dismissed_at?.toISOString?.() ?? row.dismissed_at ?? null,
requestType: (row.request_type ?? "provision") as
| "provision"
| "resume",
createdAt: row.created_at?.toISOString?.() ?? row.created_at,
updatedAt: row.updated_at?.toISOString?.() ?? row.updated_at,
};
}
// ---------------------------------------------------------------------------
// Bug 35: org-scoped billing
// ---------------------------------------------------------------------------
function rowToOrgBilling(row: any): OrgBilling {
return {
zitadelOrgId: row.zitadel_org_id,
companyName: row.company_name,
contactName: row.contact_name ?? null,
streetAddress: row.street_address,
postalCode: row.postal_code,
city: row.city,
country: row.country,
vatNumber: row.vat_number ?? null,
billingEmail: row.billing_email,
notes: row.notes ?? null,
createdAt: row.created_at?.toISOString?.() ?? row.created_at,
updatedAt: row.updated_at?.toISOString?.() ?? row.updated_at,
};
}
/**
* Fetch org billing if it exists. Returns null when the org has never
* captured billing — that's the signal the wizard uses to know
* whether to render the inline billing step on the first tenant
* request.
*/
export async function getOrgBilling(
zitadelOrgId: string
): Promise<OrgBilling | null> {
await ensureSchema();
const result = await getPool().query(
"SELECT * FROM org_billing WHERE zitadel_org_id = $1",
[zitadelOrgId]
);
return result.rows.length > 0 ? rowToOrgBilling(result.rows[0]) : null;
}
/**
* Insert or update org billing. Single function for both because the
* UI flow makes the "first time vs editing" distinction in a single
* settings page that doesn't need to know which one it's doing.
*
* VAT-required-for-companies isn't enforced here — that's an API
* concern (the API knows whether the caller is a company org).
* Keeping the DB layer dumb.
*/
export async function upsertOrgBilling(
data: Omit<OrgBilling, "createdAt" | "updatedAt">
): Promise<OrgBilling> {
await ensureSchema();
const result = await getPool().query(
`INSERT INTO org_billing (
zitadel_org_id, company_name, contact_name, street_address,
postal_code, city, country, vat_number, billing_email, notes
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
ON CONFLICT (zitadel_org_id) DO UPDATE SET
company_name = EXCLUDED.company_name,
contact_name = EXCLUDED.contact_name,
street_address = EXCLUDED.street_address,
postal_code = EXCLUDED.postal_code,
city = EXCLUDED.city,
country = EXCLUDED.country,
vat_number = EXCLUDED.vat_number,
billing_email = EXCLUDED.billing_email,
notes = EXCLUDED.notes,
updated_at = now()
RETURNING *`,
[
data.zitadelOrgId,
data.companyName,
data.contactName ?? null,
data.streetAddress,
data.postalCode,
data.city,
data.country,
data.vatNumber ?? null,
data.billingEmail,
data.notes ?? null,
]
);
return rowToOrgBilling(result.rows[0]);
}
// ---------------------------------------------------------------------------
// Slice 6: tenant ↔ user assignments
// ---------------------------------------------------------------------------
/**
* One assignment grants one user visibility into one tenant. Returned
* shape is the camelCase mirror of the Postgres row.
*/
export interface TenantUserAssignment {
tenantName: string;
zitadelOrgId: string;
zitadelUserId: string;
assignedAt: string;
assignedBy: string;
}
function mapAssignmentRow(row: any): TenantUserAssignment {
return {
tenantName: row.tenant_name,
zitadelOrgId: row.zitadel_org_id,
zitadelUserId: row.zitadel_user_id,
assignedAt: row.assigned_at?.toISOString?.() ?? row.assigned_at,
assignedBy: row.assigned_by,
};
}
/**
* Returns the set of tenant CR names assigned to the given user.
*
* Hot path on every read for `user`-role customers, so it's intentionally
* a single indexed lookup. The returned array is small (a handful of
* tenants per user); callers usually wrap it in a Set.
*
* Note: this does NOT cross-check the org id — assignments are per-user,
* and a user's org context comes from their JWT. If a user's
* authorization is revoked at the ZITADEL level, their JWT ceases to
* carry the customer role and they can't reach the dashboard at all;
* the orphan rows are cleaned up the next time their org membership
* is re-evaluated (Slice 7's removeAllAssignmentsForUser).
*/
export async function listTenantAssignmentsForUser(
userId: string
): Promise<string[]> {
await ensureSchema();
const result = await getPool().query<{ tenant_name: string }>(
"SELECT tenant_name FROM tenant_user_assignments WHERE zitadel_user_id = $1",
[userId]
);
return result.rows.map((r) => r.tenant_name);
}
/**
* Returns all assignments for a single tenant. Used by the team UI
* (Slice 7) to render "who has access to this instance". Includes
* `assignedBy` and `assignedAt` for audit display.
*/
export async function listAssignmentsForTenant(
tenantName: string
): Promise<TenantUserAssignment[]> {
await ensureSchema();
const result = await getPool().query(
"SELECT * FROM tenant_user_assignments WHERE tenant_name = $1 ORDER BY assigned_at DESC",
[tenantName]
);
return result.rows.map(mapAssignmentRow);
}
/**
* Grant a user access to a tenant. Idempotent — a duplicate INSERT
* is silently ignored via ON CONFLICT, and the existing
* `assigned_at`/`assigned_by` are preserved (we don't update them on
* re-assign).
*
* Caller is responsible for verifying:
* - The actor (`assignedBy`) holds owner/platform role in `orgId`.
* - The target user (`userId`) is actually a member of the same
* ZITADEL org. We don't validate this here — the team UI fetches
* the org's user list from ZITADEL and selects from it.
* - The tenant CR exists and is labelled with the same `orgId`.
*/
export async function addTenantAssignment(params: {
tenantName: string;
orgId: string;
userId: string;
assignedBy: string;
}): Promise<void> {
await ensureSchema();
await getPool().query(
`INSERT INTO tenant_user_assignments
(tenant_name, zitadel_org_id, zitadel_user_id, assigned_by)
VALUES ($1, $2, $3, $4)
ON CONFLICT (tenant_name, zitadel_user_id) DO NOTHING`,
[params.tenantName, params.orgId, params.userId, params.assignedBy]
);
}
/**
* Revoke a user's access to a tenant. No-op if the row doesn't exist.
*/
export async function removeTenantAssignment(
tenantName: string,
userId: string
): Promise<void> {
await ensureSchema();
await getPool().query(
"DELETE FROM tenant_user_assignments WHERE tenant_name = $1 AND zitadel_user_id = $2",
[tenantName, userId]
);
}
/**
* Cascade cleanup: drop ALL assignments for a tenant when the tenant
* itself is deleted. Called from the admin delete handler.
*
* Without this, an orphan row would stick around forever — a future
* tenant with the same name (won't happen given Slice 1's UUID-suffix
* naming, but defense in depth) would inherit the old assignments.
*/
export async function removeAllAssignmentsForTenant(
tenantName: string
): Promise<void> {
await ensureSchema();
await getPool().query(
"DELETE FROM tenant_user_assignments WHERE tenant_name = $1",
[tenantName]
);
}
/**
* Cascade cleanup: drop ALL assignments for a user within a specific
* org. Used by Slice 7's "remove member" flow when an owner kicks a
* user out of the org. Scoped by `orgId` so a user with assignments in
* org A doesn't lose them when removed from org B (multi-org users
* exist when a person registers personally and is also invited to a
* company).
*/
export async function removeAllAssignmentsForUser(
orgId: string,
userId: string
): Promise<void> {
await ensureSchema();
await getPool().query(
"DELETE FROM tenant_user_assignments WHERE zitadel_org_id = $1 AND zitadel_user_id = $2",
[orgId, userId]
);
}
// ---------------------------------------------------------------------------
// Feature 5: support tickets
// ---------------------------------------------------------------------------
function rowToSupportTicket(row: any): SupportTicket {
return {
id: row.id,
zitadelOrgId: row.zitadel_org_id,
zitadelUserId: row.zitadel_user_id,
title: row.title,
description: row.description,
category: row.category as SupportTicketCategory,
status: row.status as SupportTicketStatus,
contactEmail: row.contact_email,
contactName: row.contact_name,
createdAt: row.created_at?.toISOString?.() ?? row.created_at,
updatedAt: row.updated_at?.toISOString?.() ?? row.updated_at,
};
}
function rowToSupportTicketComment(row: any): SupportTicketComment {
return {
id: row.id,
ticketId: row.ticket_id,
authorUserId: row.author_user_id,
authorName: row.author_name,
authorKind: row.author_kind as SupportTicketCommentAuthorKind,
body: row.body,
createdAt: row.created_at?.toISOString?.() ?? row.created_at,
};
}
/**
* Create a new support ticket. The contact_name/contact_email are
* snapshotted from the session at creation time — see SupportTicket
* doc for why.
*/
export async function createSupportTicket(params: {
zitadelOrgId: string;
zitadelUserId: string;
title: string;
description: string;
category: SupportTicketCategory;
contactName: string;
contactEmail: string;
}): Promise<SupportTicket> {
await ensureSchema();
const result = await getPool().query(
`INSERT INTO support_tickets (
zitadel_org_id, zitadel_user_id, title, description, category,
contact_name, contact_email
) VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING *`,
[
params.zitadelOrgId,
params.zitadelUserId,
params.title,
params.description,
params.category,
params.contactName,
params.contactEmail,
]
);
return rowToSupportTicket(result.rows[0]);
}
/** Tickets created by a single user, newest activity first. */
export async function listSupportTicketsForUser(
zitadelUserId: string
): Promise<SupportTicket[]> {
await ensureSchema();
const result = await getPool().query(
`SELECT * FROM support_tickets
WHERE zitadel_user_id = $1
ORDER BY updated_at DESC`,
[zitadelUserId]
);
return result.rows.map(rowToSupportTicket);
}
/**
* Admin queue. Returns every ticket across all users/orgs, newest
* activity first. Pending tickets (open/reopened) bubble to the top
* by virtue of recent activity, but the API doesn't sort by status —
* the admin UI handles filtering and bucketing.
*/
export async function listAllSupportTickets(): Promise<SupportTicket[]> {
await ensureSchema();
const result = await getPool().query(
`SELECT * FROM support_tickets ORDER BY updated_at DESC`
);
return result.rows.map(rowToSupportTicket);
}
export async function getSupportTicketById(
id: string
): Promise<SupportTicket | null> {
await ensureSchema();
const result = await getPool().query(
"SELECT * FROM support_tickets WHERE id = $1",
[id]
);
return result.rows.length > 0 ? rowToSupportTicket(result.rows[0]) : null;
}
export async function listCommentsForTicket(
ticketId: string
): Promise<SupportTicketComment[]> {
await ensureSchema();
const result = await getPool().query(
`SELECT * FROM support_ticket_comments
WHERE ticket_id = $1
ORDER BY created_at`,
[ticketId]
);
return result.rows.map(rowToSupportTicketComment);
}
/**
* Insert a comment. Bumps the parent ticket's `updated_at` so the
* activity sort orders work — done in a transaction so the two are
* atomic from any concurrent reader's perspective.
*
* Caller is responsible for status auto-bumping (e.g. customer
* replying to a `waiting_for_customer` ticket → `in_progress`); the
* DB layer just writes what it's told.
*/
export async function createSupportTicketComment(params: {
ticketId: string;
authorUserId: string;
authorName: string;
authorKind: SupportTicketCommentAuthorKind;
body: string;
}): Promise<SupportTicketComment> {
await ensureSchema();
const client = await getPool().connect();
try {
await client.query("BEGIN");
const inserted = await client.query(
`INSERT INTO support_ticket_comments (
ticket_id, author_user_id, author_name, author_kind, body
) VALUES ($1, $2, $3, $4, $5)
RETURNING *`,
[
params.ticketId,
params.authorUserId,
params.authorName,
params.authorKind,
params.body,
]
);
await client.query(
"UPDATE support_tickets SET updated_at = now() WHERE id = $1",
[params.ticketId]
);
await client.query("COMMIT");
return rowToSupportTicketComment(inserted.rows[0]);
} catch (e) {
await client.query("ROLLBACK");
throw e;
} finally {
client.release();
}
}
/**
* Update mutable fields on a ticket. Only category and status are
* mutable; title/description are frozen post-creation. Returns the
* updated row so callers can email the right contact_email
* afterwards.
*/
export async function updateSupportTicket(
id: string,
changes: { status?: SupportTicketStatus; category?: SupportTicketCategory }
): Promise<SupportTicket | null> {
await ensureSchema();
const sets: string[] = ["updated_at = now()"];
const values: any[] = [id];
let idx = 2;
if (changes.status !== undefined) {
sets.push(`status = $${idx}`);
values.push(changes.status);
idx++;
}
if (changes.category !== undefined) {
sets.push(`category = $${idx}`);
values.push(changes.category);
idx++;
}
// No-op early exit. Without an actual change we still want
// updated_at refreshed if the caller asked for one, but if they
// passed neither field there's nothing to do.
if (sets.length === 1) return getSupportTicketById(id);
const result = await getPool().query(
`UPDATE support_tickets SET ${sets.join(", ")} WHERE id = $1 RETURNING *`,
values
);
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),
setupFeeChf: Number(row.setup_fee_chf ?? 0),
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 pricing for a package. `dailyPriceChf` activates
* usage-based billing (one billable unit per UTC day the package
* was enabled). `setupFeeChf` is a one-time charge emitted on the
* first invoice line for any given (tenant, skill).
*
* Both fields are required so admin must consciously set 0 to mean
* "no setup fee" rather than accidentally inheriting an old value
* from a partial update.
*/
export async function setSkillPricing(
skillId: string,
dailyPriceChf: number,
setupFeeChf: number
): Promise<SkillPricing> {
await ensureSchema();
const result = await getPool().query(
`INSERT INTO skill_pricing (skill_id, daily_price_chf, setup_fee_chf)
VALUES ($1, $2, $3)
ON CONFLICT (skill_id) DO UPDATE SET
daily_price_chf = EXCLUDED.daily_price_chf,
setup_fee_chf = EXCLUDED.setup_fee_chf,
updated_at = now()
RETURNING *`,
[skillId, dailyPriceChf, setupFeeChf]
);
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,
stripeDefaultPaymentMethodId: row.stripe_default_payment_method_id ?? null,
stripePmBrand: row.stripe_pm_brand ?? null,
stripePmLast4: row.stripe_pm_last4 ?? null,
stripePmExpMonth:
row.stripe_pm_exp_month != null ? Number(row.stripe_pm_exp_month) : null,
stripePmExpYear:
row.stripe_pm_exp_year != null ? Number(row.stripe_pm_exp_year) : null,
autoChargeEnabled:
row.auto_charge_enabled === undefined ? true : !!row.auto_charge_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 };
}
// ---------------------------------------------------------------------------
// Billing — Phase 2: invoice persistence
// ---------------------------------------------------------------------------
//
// Invoice creation is intentionally a single transaction: allocate
// number, INSERT invoice, INSERT lines, store PDF — all-or-nothing.
// The Postgres invoice_number_counters row lock serializes
// concurrent allocators for the same year, producing gapless
// numbering even under bursts.
import type {
Invoice,
InvoiceBillingSnapshot,
InvoiceDetail,
InvoiceDraft,
InvoiceLine,
InvoiceStatus,
CreditNote,
CreditNoteKind,
InvoiceRefund,
CustomInvoiceDraftPayload,
InvoiceDraftRecord,
} from "@/types";
function rowToInvoice(row: any): Invoice {
// Phase 8: period_start / period_end are nullable for custom
// invoices (no fixed billing period). Convert defensively whether
// the driver returned a string or Date.
const periodStartIso = row.period_start == null
? null
: typeof row.period_start === "string"
? row.period_start
: row.period_start.toISOString().split("T")[0];
const periodEndIso = row.period_end == null
? null
: typeof row.period_end === "string"
? row.period_end
: row.period_end.toISOString().split("T")[0];
return {
id: row.id,
invoiceNumber: row.invoice_number,
zitadelOrgId: row.zitadel_org_id,
source: (row.source ?? "auto") as "auto" | "custom",
periodStart: periodStartIso,
periodEnd: periodEndIso,
issuedAt: row.issued_at?.toISOString?.() ?? row.issued_at,
dueAt: typeof row.due_at === "string"
? row.due_at
: row.due_at.toISOString().split("T")[0],
subtotalChf: Number(row.subtotal_chf),
vatRate: Number(row.vat_rate),
vatAmountChf: Number(row.vat_amount_chf),
totalChf: Number(row.total_chf),
status: row.status as InvoiceStatus,
locale: row.locale ?? "de",
paymentMethod: row.payment_method,
billingSnapshot: row.billing_snapshot as InvoiceBillingSnapshot,
stripePaymentIntentId: row.stripe_payment_intent_id ?? null,
pdfFilename: row.pdf_filename ?? null,
hasPdf: row.has_pdf ?? row.pdf_data !== null,
adminNotes: row.admin_notes ?? null,
paidAt: row.paid_at?.toISOString?.() ?? row.paid_at ?? null,
paidBy: row.paid_by ?? null,
paidMethodDetail: row.paid_method_detail ?? null,
voidReason: row.void_reason ?? null,
voidedAt: row.voided_at?.toISOString?.() ?? row.voided_at ?? null,
voidedBy: row.voided_by ?? null,
refundedTotalChf: row.refunded_total_chf != null
? Number(row.refunded_total_chf)
: 0,
createdAt: row.created_at?.toISOString?.() ?? row.created_at,
};
}
function rowToInvoiceLine(row: any): InvoiceLine {
return {
id: row.id,
invoiceId: row.invoice_id,
tenantName: row.tenant_name ?? null,
kind: row.kind,
description: row.description,
quantity: Number(row.quantity),
unitLabel: row.unit_label ?? null,
unitPriceChf: Number(row.unit_price_chf),
amountChf: Number(row.amount_chf),
metadata: row.metadata ?? null,
displayOrder: row.display_order,
};
}
// Standard SELECT projection that includes a cheap NOT-NULL probe of
// pdf_data instead of pulling the bytes themselves. Crucial for list
// endpoints — a few KB per row across hundreds of invoices is wasted
// network and memory.
const INVOICE_LIST_COLUMNS = `
id, invoice_number, zitadel_org_id, period_start, period_end,
issued_at, due_at, subtotal_chf, vat_rate, vat_amount_chf,
total_chf, status, locale, payment_method, billing_snapshot,
stripe_payment_intent_id, pdf_filename, admin_notes, paid_at,
paid_by, paid_method_detail, void_reason, voided_at, voided_by,
refunded_total_chf, source, created_at,
(pdf_data IS NOT NULL) AS has_pdf
`;
/**
* Persist a fully-computed invoice draft with its lines and PDF in
* a single transaction. Allocates the year-scoped invoice number
* inside the same transaction so a rollback restores the counter
* (gapless guarantee).
*
* The caller is responsible for upstream validation:
* - the (org, period) uniqueness (the unique index will reject
* duplicates, but we return a clear error message rather than
* leaking the constraint name)
* - the draft's lines/totals are consistent (compute pipeline
* ensures this)
*
* `pdfBuffer` is the rendered PDF bytes; pass null if PDF is
* generated separately or stored in a side channel. For Phase 2 we
* always render synchronously and pass the buffer here.
*/
export async function createInvoice(
draft: InvoiceDraft,
pdfBuffer: Buffer | null,
pdfFilename: string | null
): Promise<Invoice> {
await ensureSchema();
const pool = getPool();
const client = await pool.connect();
try {
await client.query("BEGIN");
// Phase 8: pick the year for invoice-number allocation.
// - auto invoices: use period_start (the original behaviour)
// - custom invoices: use the override issued_at if set, else
// the year of "now"
// The sequence is shared across auto and custom — every invoice
// gets a number from the same year-scoped counter so the audit
// trail is gapless and sequential regardless of source.
let year: number;
if (draft.source === "custom") {
const yearSource = draft.issuedAt
? draft.issuedAt.slice(0, 4)
: new Date().getUTCFullYear().toString();
year = parseInt(yearSource, 10);
} else if (draft.periodStart) {
year = parseInt(draft.periodStart.slice(0, 4), 10);
} else {
year = new Date().getUTCFullYear();
}
const counterResult = await client.query(
`INSERT INTO invoice_number_counters (year, last_number)
VALUES ($1, 1)
ON CONFLICT (year) DO UPDATE SET
last_number = invoice_number_counters.last_number + 1
RETURNING last_number`,
[year]
);
const seq = counterResult.rows[0].last_number;
const invoiceNumber = `${year}-${String(seq).padStart(5, "0")}`;
// Phase 8: optional override of issued_at (custom flow lets
// admin backdate or future-date). Empty → now(); set → that
// exact date.
const source = draft.source ?? "auto";
const inv = await client.query(
`INSERT INTO invoices (
invoice_number, zitadel_org_id, period_start, period_end,
issued_at, due_at, subtotal_chf, vat_rate, vat_amount_chf,
total_chf, status, locale, payment_method, billing_snapshot,
pdf_data, pdf_filename, source
) VALUES (
$1, $2, $3::date, $4::date,
COALESCE($5::timestamptz, now()),
$6::date, $7, $8, $9, $10,
'open', $11, $12, $13::jsonb, $14, $15, $16
)
RETURNING ${INVOICE_LIST_COLUMNS}`,
[
invoiceNumber,
draft.zitadelOrgId,
draft.periodStart, // may be null for custom
draft.periodEnd, // may be null for custom
draft.issuedAt ?? null,
draft.dueAt,
draft.subtotalChf,
draft.vatRate,
draft.vatAmountChf,
draft.totalChf,
draft.locale,
draft.paymentMethod,
JSON.stringify(draft.billingSnapshot),
pdfBuffer,
pdfFilename,
source,
]
);
const invoiceId = inv.rows[0].id;
// Insert lines in batch — one INSERT statement is significantly
// faster than per-line round-trips, which matters when an invoice
// accumulates many ai_usage / skill_usage lines.
if (draft.lines.length > 0) {
const placeholders: string[] = [];
const values: any[] = [];
let idx = 1;
for (const line of draft.lines) {
placeholders.push(
`($${idx++}, $${idx++}, $${idx++}, $${idx++}, $${idx++}, $${idx++}, $${idx++}, $${idx++}, $${idx++}::jsonb, $${idx++})`
);
values.push(
invoiceId,
line.tenantName,
line.kind,
line.description,
line.quantity,
line.unitLabel,
line.unitPriceChf,
line.amountChf,
line.metadata ? JSON.stringify(line.metadata) : null,
line.displayOrder
);
}
await client.query(
`INSERT INTO invoice_lines (
invoice_id, tenant_name, kind, description, quantity,
unit_label, unit_price_chf, amount_chf, metadata, display_order
) VALUES ${placeholders.join(", ")}`,
values
);
}
await client.query("COMMIT");
return rowToInvoice(inv.rows[0]);
} catch (e: any) {
await client.query("ROLLBACK").catch(() => undefined);
// Translate the uniqueness violation into a user-friendly error.
// 23505 = unique_violation in Postgres. The partial index is
// named uniq_invoices_org_period_auto post-Phase-8; we match
// both for back-compat with DBs that haven't run the migration
// yet (the old name) or have run it (the new name).
if (
e?.code === "23505" &&
/uniq_invoices_org_period/.test(e?.constraint ?? "")
) {
const month = draft.periodStart?.slice(0, 7) ?? "this period";
throw new Error(
`An invoice already exists for this org and billing period (${month}). ` +
`Delete the existing invoice first if you want to regenerate.`
);
}
throw e;
} finally {
client.release();
}
}
export async function getInvoiceById(id: string): Promise<Invoice | null> {
await ensureSchema();
const result = await getPool().query(
`SELECT ${INVOICE_LIST_COLUMNS} FROM invoices WHERE id = $1`,
[id]
);
return result.rows.length > 0 ? rowToInvoice(result.rows[0]) : null;
}
export async function getInvoiceDetail(
id: string
): Promise<InvoiceDetail | null> {
const invoice = await getInvoiceById(id);
if (!invoice) return null;
const lines = await getPool().query(
`SELECT * FROM invoice_lines WHERE invoice_id = $1
ORDER BY display_order, id`,
[id]
);
return { invoice, lines: lines.rows.map(rowToInvoiceLine) };
}
/**
* Phase 3 — customer-scoped lookup by human-readable invoice
* number with ownership enforcement in a single query. The org
* filter is part of the WHERE clause so a customer can't probe
* another org's invoice numbers (which are sequential and easy
* to guess) and get a different status code (404 vs 403) than
* for their own — both miss-and-not-yours return null.
*
* Used by /api/billing/invoices/[invoiceNumber] and the
* /billing/[invoiceNumber] customer page.
*/
export async function getInvoiceByNumberForOrg(
invoiceNumber: string,
zitadelOrgId: string
): Promise<InvoiceDetail | null> {
await ensureSchema();
const head = await getPool().query(
`SELECT ${INVOICE_LIST_COLUMNS} FROM invoices
WHERE invoice_number = $1 AND zitadel_org_id = $2
LIMIT 1`,
[invoiceNumber, zitadelOrgId]
);
if (head.rows.length === 0) return null;
const invoice = rowToInvoice(head.rows[0]);
const lines = await getPool().query(
`SELECT * FROM invoice_lines WHERE invoice_id = $1
ORDER BY display_order, id`,
[invoice.id]
);
return { invoice, lines: lines.rows.map(rowToInvoiceLine) };
}
/**
* Fetch the PDF bytes for an invoice. Returns null if no PDF was
* stored (shouldn't happen in v1; defensive against partial state).
*/
export async function getInvoicePdf(
id: string
): Promise<{ data: Buffer; filename: string } | null> {
await ensureSchema();
const result = await getPool().query(
"SELECT pdf_data, pdf_filename, invoice_number FROM invoices WHERE id = $1",
[id]
);
if (result.rows.length === 0) return null;
const row = result.rows[0];
if (!row.pdf_data) return null;
return {
data: row.pdf_data,
filename: row.pdf_filename ?? `${row.invoice_number}.pdf`,
};
}
/**
* List invoices, optionally filtered. Used by the admin invoice
* list page and (Phase 3) the customer-facing /billing page.
*
* The customer-facing call site MUST pass `zitadelOrgId` to scope
* results — this helper does not enforce that itself.
*/
export async function listInvoices(filters: {
zitadelOrgId?: string;
status?: InvoiceStatus;
/** Inclusive YYYY-MM filter on period_start. */
periodMonth?: string;
limit?: number;
} = {}): Promise<Invoice[]> {
await ensureSchema();
const where: string[] = [];
const values: any[] = [];
let idx = 1;
if (filters.zitadelOrgId) {
where.push(`zitadel_org_id = $${idx++}`);
values.push(filters.zitadelOrgId);
}
if (filters.status) {
where.push(`status = $${idx++}`);
values.push(filters.status);
}
if (filters.periodMonth) {
where.push(`to_char(period_start, 'YYYY-MM') = $${idx++}`);
values.push(filters.periodMonth);
}
const limit = filters.limit ?? 200;
const sql =
`SELECT ${INVOICE_LIST_COLUMNS} FROM invoices ` +
(where.length > 0 ? `WHERE ${where.join(" AND ")} ` : "") +
`ORDER BY issued_at DESC LIMIT $${idx}`;
values.push(limit);
const result = await getPool().query(sql, values);
return result.rows.map(rowToInvoice);
}
/**
* Sweep open invoices past their due date to `overdue` status.
* Cheap idempotent UPDATE; safe to call on every admin list view
* to keep status fresh without a dedicated cron.
*/
export async function syncOverdueInvoices(): Promise<number> {
await ensureSchema();
const result = await getPool().query(
`UPDATE invoices
SET status = 'overdue'
WHERE status = 'open'
AND due_at < CURRENT_DATE`
);
return result.rowCount ?? 0;
}
export async function markInvoicePaid(
id: string,
opts: { paidBy: string; paidMethodDetail?: string | null; paidAt?: Date }
): Promise<Invoice | null> {
await ensureSchema();
const result = await getPool().query(
`UPDATE invoices
SET status = 'paid',
paid_at = COALESCE($2::timestamptz, now()),
paid_by = $3,
paid_method_detail = $4
WHERE id = $1
AND status IN ('open', 'overdue')
RETURNING ${INVOICE_LIST_COLUMNS}`,
[
id,
opts.paidAt ?? null,
opts.paidBy,
opts.paidMethodDetail ?? null,
]
);
return result.rows.length > 0 ? rowToInvoice(result.rows[0]) : null;
}
/**
* Hard delete an invoice and its lines (CASCADE).
*
* This is the testing tool — Swiss bookkeeping requires immutable
* invoices in production, but during pilot/testing we need to
* iterate. The gap left in the invoice number sequence is
* intentional and documented; no attempt to "recycle" numbers.
*
* Reminders (and their PDFs) cascade-delete via the FK.
*/
export async function deleteInvoice(id: string): Promise<boolean> {
await ensureSchema();
const result = await getPool().query(
"DELETE FROM invoices WHERE id = $1 RETURNING id",
[id]
);
return (result.rowCount ?? 0) > 0;
}
/**
* Has this tenant ever been billed a setup fee? Drives the
* compute pipeline's "include setup line on first invoice"
* decision. Looks at invoice_lines directly so it survives org
* billing config edits.
*/
export async function tenantHasSetupFeeBilled(
tenantName: string
): Promise<boolean> {
await ensureSchema();
const result = await getPool().query(
`SELECT 1 FROM invoice_lines
WHERE tenant_name = $1 AND kind = 'tenant_setup'
LIMIT 1`,
[tenantName]
);
return result.rows.length > 0;
}
/**
* Has this (tenant, skill) pair already appeared on any prior
* invoice line — either as setup or usage? Drives the per-skill
* setup-fee gate. Same "first appearance" semantics as the tenant
* setup fee: a previously-free skill that newly gets a setup fee
* configured will trigger the fee on its next billed period.
*
* Uses metadata->>'skill_id' (which is what both skill_setup and
* skill_usage lines store) rather than parsing description.
*/
export async function tenantSkillHasBeenBilled(
tenantName: string,
skillId: string
): Promise<boolean> {
await ensureSchema();
const result = await getPool().query(
`SELECT 1 FROM invoice_lines
WHERE tenant_name = $1
AND kind IN ('skill_setup', 'skill_usage')
AND metadata->>'skill_id' = $2
LIMIT 1`,
[tenantName, skillId]
);
return result.rows.length > 0;
}
/**
* Aggregate open balance per org for the admin overview. Returns
* orgs with at least one open or overdue invoice; orgs in good
* standing don't appear.
*/
export async function getOrgOpenBalances(): Promise<{
zitadelOrgId: string;
openCount: number;
overdueCount: number;
totalOpenChf: number;
}[]> {
await ensureSchema();
const result = await getPool().query(
`SELECT
zitadel_org_id,
COUNT(*) FILTER (WHERE status = 'open') AS open_count,
COUNT(*) FILTER (WHERE status = 'overdue') AS overdue_count,
SUM(total_chf) FILTER (WHERE status IN ('open', 'overdue')) AS total_open
FROM invoices
WHERE status IN ('open', 'overdue')
GROUP BY zitadel_org_id
ORDER BY total_open DESC`
);
return result.rows.map((r) => ({
zitadelOrgId: r.zitadel_org_id,
openCount: Number(r.open_count),
overdueCount: Number(r.overdue_count),
totalOpenChf: Number(r.total_open),
}));
}
/**
* Update the stored PDF for an invoice. Used by the two-pass
* compute pipeline: insert invoice with empty PDF → render PDF with
* the allocated invoice number → write bytes back.
*
* Could be merged into createInvoice via a render callback in a
* future cleanup, but two passes are simpler and the extra UPDATE
* is cheap.
*/
export async function updateInvoicePdf(
invoiceId: string,
pdfBuffer: Buffer,
filename: string
): Promise<void> {
await ensureSchema();
await getPool().query(
"UPDATE invoices SET pdf_data = $2, pdf_filename = $3 WHERE id = $1",
[invoiceId, pdfBuffer, filename]
);
}
// ---------------------------------------------------------------------------
// Skill activation requests — Phase 2.5
// ---------------------------------------------------------------------------
import type {
SkillActivationRequest,
SkillActivationStatus,
} from "@/types";
function rowToSkillActivationRequest(row: any): SkillActivationRequest {
return {
id: row.id,
tenantName: row.tenant_name,
zitadelOrgId: row.zitadel_org_id,
zitadelUserId: row.zitadel_user_id,
skillId: row.skill_id,
status: row.status as SkillActivationStatus,
requestedAt: row.requested_at?.toISOString?.() ?? row.requested_at,
reviewedAt: row.reviewed_at?.toISOString?.() ?? row.reviewed_at ?? null,
reviewedBy: row.reviewed_by ?? null,
rejectionReason: row.rejection_reason ?? null,
adminNotes: row.admin_notes ?? null,
};
}
/**
* Insert a pending activation request. Throws a tagged error if a
* pending row already exists for the (tenant, skill) — the partial
* unique index enforces this. The caller surfaces "request already
* pending" to the user rather than letting it 500.
*/
export async function createSkillActivationRequest(params: {
tenantName: string;
zitadelOrgId: string;
zitadelUserId: string;
skillId: string;
}): Promise<SkillActivationRequest> {
await ensureSchema();
try {
const result = await getPool().query(
`INSERT INTO skill_activation_requests
(tenant_name, zitadel_org_id, zitadel_user_id, skill_id)
VALUES ($1, $2, $3, $4)
RETURNING *`,
[
params.tenantName,
params.zitadelOrgId,
params.zitadelUserId,
params.skillId,
]
);
return rowToSkillActivationRequest(result.rows[0]);
} catch (e: any) {
if (e?.code === "23505") {
const err = new Error(
`A pending activation request already exists for ${params.skillId} on ${params.tenantName}.`
);
(err as any).code = "REQUEST_ALREADY_PENDING";
throw err;
}
throw e;
}
}
export async function getSkillActivationRequestById(
id: string
): Promise<SkillActivationRequest | null> {
await ensureSchema();
const result = await getPool().query(
"SELECT * FROM skill_activation_requests WHERE id = $1",
[id]
);
return result.rows.length > 0
? rowToSkillActivationRequest(result.rows[0])
: null;
}
/**
* All pending requests across all tenants — feeds the admin queue
* page. Capped to 500 rows for safety; unlikely to ever be hit but
* keeps the page bounded.
*/
export async function listPendingSkillActivationRequests(): Promise<
SkillActivationRequest[]
> {
await ensureSchema();
const result = await getPool().query(
`SELECT * FROM skill_activation_requests
WHERE status = 'pending'
ORDER BY requested_at ASC
LIMIT 500`
);
return result.rows.map(rowToSkillActivationRequest);
}
export async function countPendingSkillActivationRequests(): Promise<number> {
await ensureSchema();
const result = await getPool().query(
"SELECT COUNT(*)::int AS c FROM skill_activation_requests WHERE status = 'pending'"
);
return result.rows[0]?.c ?? 0;
}
/**
* Requests visible to a customer for one tenant. Returns:
* - pending: rows the user might want to withdraw
* - rejected: the most recent rejection per skill, so the user
* sees why and can retry
* Approved and withdrawn rows are excluded (terminal states with
* no user-visible UI effect after the fact).
*/
export async function listSkillActivationRequestsForTenant(
tenantName: string
): Promise<SkillActivationRequest[]> {
await ensureSchema();
const result = await getPool().query(
`WITH ranked AS (
SELECT *,
ROW_NUMBER() OVER (
PARTITION BY skill_id, status
ORDER BY requested_at DESC
) AS rn
FROM skill_activation_requests
WHERE tenant_name = $1
AND status IN ('pending', 'rejected')
)
SELECT * FROM ranked WHERE rn = 1
ORDER BY requested_at DESC`,
[tenantName]
);
return result.rows.map(rowToSkillActivationRequest);
}
/**
* Transition a request's status. The caller is responsible for the
* side effects (K8s patch on approve, email send) — this function
* only touches the row.
*
* Returns null when the row doesn't exist or isn't in 'pending'
* status. That null is meaningful: it tells the caller the
* transition didn't happen (already approved/rejected by another
* admin tab, etc.) and downstream actions should be skipped.
*/
export async function updateSkillActivationRequestStatus(
id: string,
newStatus: Exclude<SkillActivationStatus, "pending">,
opts: {
reviewedBy: string;
rejectionReason?: string | null;
adminNotes?: string | null;
}
): Promise<SkillActivationRequest | null> {
await ensureSchema();
const result = await getPool().query(
`UPDATE skill_activation_requests
SET status = $2,
reviewed_at = now(),
reviewed_by = $3,
rejection_reason = $4,
admin_notes = COALESCE($5, admin_notes)
WHERE id = $1
AND status = 'pending'
RETURNING *`,
[id, newStatus, opts.reviewedBy, opts.rejectionReason ?? null, opts.adminNotes ?? null]
);
return result.rows.length > 0
? rowToSkillActivationRequest(result.rows[0])
: null;
}
// ---------------------------------------------------------------------------
// Phase 3 diagnostic — single-purpose helper for the /api/admin/billing/debug
// endpoint. Returns raw invoice_line rows for a tenant filtered to setup-fee
// rows, so a human can verify what the billing emission code's SQL is
// actually seeing. Not intended for production use; kept here for shipping
// hotfixes when running-total drafts diverge from expected behaviour.
// ---------------------------------------------------------------------------
export async function debugListSetupLines(
tenantName: string
): Promise<Array<{
id: string;
invoice_id: string;
tenant_name: string;
kind: string;
amount_chf: number;
description: string;
}>> {
await ensureSchema();
const result = await getPool().query(
`SELECT id, invoice_id, tenant_name, kind, amount_chf, description
FROM invoice_lines
WHERE tenant_name = $1
AND kind = 'tenant_setup'`,
[tenantName]
);
return result.rows;
}
// ---------------------------------------------------------------------------
// Stripe — Phase 4
// ---------------------------------------------------------------------------
/**
* Attempt to record receipt of a Stripe webhook event. Returns true
* when this is the first time we've seen the event (caller should
* process it), false when the event_id was already present
* (caller should ack with 200 and skip — Stripe retries are
* normal and we must be idempotent).
*
* The whole-payload JSONB is stored so a misbehaving event can be
* diagnosed after the fact without re-fetching from Stripe.
*/
export async function tryRecordStripeEvent(
eventId: string,
eventType: string,
payload: unknown
): Promise<boolean> {
await ensureSchema();
try {
await getPool().query(
`INSERT INTO stripe_events (event_id, event_type, payload)
VALUES ($1, $2, $3::jsonb)`,
[eventId, eventType, JSON.stringify(payload)]
);
return true;
} catch (e: any) {
// 23505 = unique_violation; the row already exists, meaning we've
// seen this event before. That's the normal duplicate-delivery
// case — return false so the caller short-circuits.
if (e?.code === "23505") return false;
throw e;
}
}
/**
* Stamp processed_at on a stripe_events row once the handler has
* finished its work successfully. Lets us spot stuck events
* (received but not processed) for diagnosis.
*/
export async function markStripeEventProcessed(eventId: string): Promise<void> {
await ensureSchema();
await getPool().query(
"UPDATE stripe_events SET processed_at = now() WHERE event_id = $1",
[eventId]
);
}
/**
* Persist the Stripe PaymentIntent id on an invoice. Used by the
* webhook handler once the Checkout Session completes — at that
* point Stripe has minted the PaymentIntent and we want to be
* able to find the Stripe-side record from the invoice (and vice
* versa via metadata).
*
* Idempotent: re-running with the same value is a no-op. The
* column was added in Phase 2 schema; this helper was missing.
*/
export async function setInvoiceStripePaymentIntent(
invoiceId: string,
paymentIntentId: string
): Promise<void> {
await ensureSchema();
await getPool().query(
`UPDATE invoices
SET stripe_payment_intent_id = $2
WHERE id = $1
AND (stripe_payment_intent_id IS NULL OR stripe_payment_intent_id = $2)`,
[invoiceId, paymentIntentId]
);
}
// ---------------------------------------------------------------------------
// Phase 5 — Cron run history + reminder helpers
// ---------------------------------------------------------------------------
import type { CronRun, CronRunKind } from "@/types";
function rowToCronRun(row: any): CronRun {
return {
id: row.id,
runKind: row.run_kind,
triggeredBy: row.triggered_by,
startedAt:
row.started_at?.toISOString?.() ?? String(row.started_at),
finishedAt: row.finished_at
? row.finished_at.toISOString?.() ?? String(row.finished_at)
: null,
successCount: Number(row.success_count ?? 0),
failureCount: Number(row.failure_count ?? 0),
skippedCount: Number(row.skipped_count ?? 0),
errorDetails: row.error_details ?? null,
};
}
/**
* Open a new cron-run row in 'started' state. Returns the row's
* id which the caller passes to finishCronRun() with the summary
* stats once the sweep completes.
*
* Separating start/finish lets the admin UI distinguish an in-
* progress run from a finished one, and lets a crashed pod leave
* a forensic trace ("started but never finished — investigate").
*/
export async function startCronRun(
runKind: CronRunKind,
triggeredBy: string
): Promise<string> {
await ensureSchema();
const result = await getPool().query(
`INSERT INTO cron_run_history (run_kind, triggered_by)
VALUES ($1, $2)
RETURNING id`,
[runKind, triggeredBy]
);
return result.rows[0].id;
}
export async function finishCronRun(
id: string,
summary: {
successCount: number;
failureCount: number;
skippedCount: number;
errorDetails?: unknown;
}
): Promise<void> {
await ensureSchema();
await getPool().query(
`UPDATE cron_run_history
SET finished_at = now(),
success_count = $2,
failure_count = $3,
skipped_count = $4,
error_details = $5::jsonb
WHERE id = $1`,
[
id,
summary.successCount,
summary.failureCount,
summary.skippedCount,
summary.errorDetails ? JSON.stringify(summary.errorDetails) : null,
]
);
}
export async function listRecentCronRuns(
limit = 30
): Promise<CronRun[]> {
await ensureSchema();
const result = await getPool().query(
`SELECT * FROM cron_run_history
ORDER BY started_at DESC
LIMIT $1`,
[limit]
);
return result.rows.map(rowToCronRun);
}
/**
* Most recent successful run of each kind. Drives the admin
* dashboard's "last issuance: N days ago" indicator. Returns
* null for a kind that has never run successfully.
*/
export async function getLastSuccessfulCronRuns(): Promise<{
monthlyIssue: CronRun | null;
reminders: CronRun | null;
}> {
await ensureSchema();
const result = await getPool().query(
`SELECT DISTINCT ON (run_kind) *
FROM cron_run_history
WHERE finished_at IS NOT NULL AND failure_count = 0
ORDER BY run_kind, started_at DESC`
);
const map: Record<string, CronRun> = {};
for (const row of result.rows) {
map[row.run_kind] = rowToCronRun(row);
}
return {
monthlyIssue: map["monthly_issue"] ?? null,
reminders: map["reminders"] ?? null,
};
}
/**
* IDs of all orgs with auto-issue enabled. Drives the monthly
* issuance sweep. Returns just the zitadel_org_id strings — the
* caller fetches OrgBilling per-org during the sweep so a bad
* row doesn't poison the whole list at SELECT time.
*/
export async function listAutoIssueOrgIds(): Promise<string[]> {
await ensureSchema();
const result = await getPool().query(
`SELECT zitadel_org_id FROM org_billing_config
WHERE auto_invoice_enabled = TRUE`
);
return result.rows.map((r) => r.zitadel_org_id as string);
}
/**
* Open or overdue invoices whose org has auto-reminders enabled
* and whose due_at is at least 7 days in the past. The reminder
* sweep takes this list and picks the right level (1/2/3) per
* invoice based on days-past-due AND which levels have already
* been sent.
*
* We don't filter by "needs reminder X yet" in SQL because the
* level logic is more readable in TypeScript and the candidate
* set is small (only past-due invoices for opted-in orgs).
*/
export async function listInvoicesPendingReminders(): Promise<Invoice[]> {
await ensureSchema();
// Bug fix: the prior version did `FROM invoices i JOIN org_billing_config c ON c.zitadel_org_id = i.zitadel_org_id`,
// but INVOICE_LIST_COLUMNS selects unqualified column names —
// `zitadel_org_id` appears in both tables and Postgres rejects
// it as ambiguous. Rewriting as a subquery filter keeps every
// referenced column on the single `invoices` row source, so the
// unqualified list stays valid (matching every other caller of
// INVOICE_LIST_COLUMNS in this file).
//
// Semantics are unchanged: only include invoices belonging to
// orgs that have opted into auto-reminders. Orgs with no
// org_billing_config row at all are excluded (same as before —
// the inner join didn't match for them either).
const result = await getPool().query(
`SELECT ${INVOICE_LIST_COLUMNS}
FROM invoices
WHERE status IN ('open','overdue')
AND due_at < now() - INTERVAL '7 days'
AND zitadel_org_id IN (
SELECT zitadel_org_id FROM org_billing_config
WHERE auto_reminders_enabled = TRUE
)
ORDER BY due_at ASC`
);
return result.rows.map(rowToInvoice);
}
/**
* Which reminder levels have already been sent for this invoice?
* Returns a Set of {1, 2, 3} subset. Drives the "send the next
* level only" logic in the reminder sweep.
*/
export async function getReminderLevelsSent(
invoiceId: string
): Promise<Set<number>> {
await ensureSchema();
const result = await getPool().query(
`SELECT level FROM invoice_reminders WHERE invoice_id = $1`,
[invoiceId]
);
return new Set(result.rows.map((r) => Number(r.level)));
}
/**
* Mark a reminder as sent. Wrapped in an INSERT ... ON CONFLICT
* DO NOTHING so a retry of the same level after a partial failure
* is a no-op rather than a 23505 explosion. Returns true if a row
* was inserted (first send), false on conflict (already sent).
*/
export async function recordReminderSent(params: {
invoiceId: string;
level: 1 | 2 | 3;
sentBy: string;
emailSentTo: string;
}): Promise<boolean> {
await ensureSchema();
const result = await getPool().query(
`INSERT INTO invoice_reminders
(invoice_id, level, sent_by, email_sent_to)
VALUES ($1, $2, $3, $4)
ON CONFLICT (invoice_id, level) DO NOTHING
RETURNING id`,
[params.invoiceId, params.level, params.sentBy, params.emailSentTo]
);
return result.rowCount === 1;
}
// ---------------------------------------------------------------------------
// Phase 7 — credit notes and refunds
// ---------------------------------------------------------------------------
function rowToCreditNote(row: any): CreditNote {
return {
id: row.id,
creditNoteNumber: row.credit_note_number,
invoiceId: row.invoice_id,
invoiceNumber: row.invoice_number, // joined column when available
zitadelOrgId: row.zitadel_org_id,
kind: row.kind as CreditNoteKind,
amountChf: Number(row.amount_chf),
vatAmountChf: Number(row.vat_amount_chf),
reason: row.reason ?? null,
issuedAt: row.issued_at?.toISOString?.() ?? row.issued_at,
issuedBy: row.issued_by,
locale: row.locale ?? "de",
pdfFilename: row.pdf_filename ?? null,
hasPdf: row.has_pdf ?? row.pdf_data !== null,
billingSnapshot: row.billing_snapshot as InvoiceBillingSnapshot,
};
}
function rowToInvoiceRefund(row: any): InvoiceRefund {
return {
id: row.id,
invoiceId: row.invoice_id,
stripeRefundId: row.stripe_refund_id ?? null,
amountChf: Number(row.amount_chf),
reason: row.reason ?? null,
status: row.status,
refundedAt: row.refunded_at?.toISOString?.() ?? row.refunded_at,
refundedBy: row.refunded_by,
creditNoteId: row.credit_note_id ?? null,
};
}
const CREDIT_NOTE_COLUMNS = `
cn.id, cn.credit_note_number, cn.invoice_id, cn.zitadel_org_id,
cn.kind, cn.amount_chf, cn.vat_amount_chf, cn.reason, cn.issued_at,
cn.issued_by, cn.locale, cn.pdf_filename, cn.billing_snapshot,
(cn.pdf_data IS NOT NULL) AS has_pdf,
inv.invoice_number AS invoice_number
`;
/**
* Allocate the next credit-note number for the given year. Uses the
* per-year counter table with a row-level lock, same approach as
* invoice numbering. Format: CN-2026-00001 (5-digit padding, matches
* invoice padding for visual consistency even though Cedric agreed
* to "CN-YYYY-NNNN" originally — the extra digit is harmless headroom
* and keeps eyes from misreading "CN-2026-1" next to "2026-00001").
*
* Must be called inside a transaction; the caller passes the same
* client so the allocation and the INSERT roll back together if
* anything downstream fails.
*/
async function allocateCreditNoteNumber(
client: any,
year: number
): Promise<string> {
const r = await client.query(
`INSERT INTO credit_note_counters (year, last_number)
VALUES ($1, 1)
ON CONFLICT (year) DO UPDATE SET
last_number = credit_note_counters.last_number + 1
RETURNING last_number`,
[year]
);
const seq = r.rows[0].last_number;
return `CN-${year}-${String(seq).padStart(5, "0")}`;
}
/**
* Persist a new credit note (without its PDF — that's attached later
* via attachCreditNotePdf so the PDF render can read the just-inserted
* row, including its credit_note_number, for self-referential rendering).
*
* Snapshots the invoice's billing block at issue time. Returns the
* inserted row (with PDF still null).
*/
export async function createCreditNote(params: {
invoiceId: string;
zitadelOrgId: string;
kind: CreditNoteKind;
amountChf: number;
vatAmountChf: number;
reason: string | null;
issuedBy: string;
locale: string;
billingSnapshot: InvoiceBillingSnapshot;
}): Promise<CreditNote> {
await ensureSchema();
const pool = getPool();
const client = await pool.connect();
try {
await client.query("BEGIN");
// Allocate from the year of NOW — credit notes are issued
// "today", not retroactively, so the year is current.
const year = new Date().getUTCFullYear();
const creditNoteNumber = await allocateCreditNoteNumber(client, year);
const inserted = await client.query(
`INSERT INTO credit_notes (
credit_note_number, invoice_id, zitadel_org_id, kind,
amount_chf, vat_amount_chf, reason, issued_by, locale,
billing_snapshot
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10::jsonb)
RETURNING *`,
[
creditNoteNumber,
params.invoiceId,
params.zitadelOrgId,
params.kind,
params.amountChf,
params.vatAmountChf,
params.reason,
params.issuedBy,
params.locale,
JSON.stringify(params.billingSnapshot),
]
);
await client.query("COMMIT");
// Re-query with the invoice_number join so the returned row has
// it populated (matches what list/get methods return).
const detail = await pool.query(
`SELECT ${CREDIT_NOTE_COLUMNS}
FROM credit_notes cn
JOIN invoices inv ON inv.id = cn.invoice_id
WHERE cn.id = $1`,
[inserted.rows[0].id]
);
return rowToCreditNote(detail.rows[0]);
} catch (e) {
await client.query("ROLLBACK");
throw e;
} finally {
client.release();
}
}
/**
* Attach a freshly-rendered PDF to an existing credit note row.
* Two-phase issue (insert row, render PDF, attach PDF) mirrors the
* invoice flow, where the PDF generation needs the number on the
* row to render itself.
*/
export async function attachCreditNotePdf(
creditNoteId: string,
pdfBuffer: Buffer,
pdfFilename: string
): Promise<void> {
await getPool().query(
`UPDATE credit_notes
SET pdf_data = $1, pdf_filename = $2
WHERE id = $3`,
[pdfBuffer, pdfFilename, creditNoteId]
);
}
/**
* Read a credit note by its number, scoped to an org. Returns null
* if the row doesn't exist OR exists but belongs to a different org
* (same 404-not-403 leak-protection as the invoice equivalent).
*/
export async function getCreditNoteByNumberForOrg(
creditNoteNumber: string,
zitadelOrgId: string
): Promise<CreditNote | null> {
await ensureSchema();
const result = await getPool().query(
`SELECT ${CREDIT_NOTE_COLUMNS}
FROM credit_notes cn
JOIN invoices inv ON inv.id = cn.invoice_id
WHERE cn.credit_note_number = $1 AND cn.zitadel_org_id = $2
LIMIT 1`,
[creditNoteNumber, zitadelOrgId]
);
return result.rows.length > 0 ? rowToCreditNote(result.rows[0]) : null;
}
/** Platform-admin variant: look up by number regardless of org. */
export async function getCreditNoteByNumber(
creditNoteNumber: string
): Promise<CreditNote | null> {
await ensureSchema();
const result = await getPool().query(
`SELECT ${CREDIT_NOTE_COLUMNS}
FROM credit_notes cn
JOIN invoices inv ON inv.id = cn.invoice_id
WHERE cn.credit_note_number = $1
LIMIT 1`,
[creditNoteNumber]
);
return result.rows.length > 0 ? rowToCreditNote(result.rows[0]) : null;
}
/**
* List credit notes for an org, newest first. Used by /billing to
* render the credit-note list alongside the invoice list.
*/
export async function listCreditNotesForOrg(
zitadelOrgId: string,
limit = 50
): Promise<CreditNote[]> {
await ensureSchema();
const result = await getPool().query(
`SELECT ${CREDIT_NOTE_COLUMNS}
FROM credit_notes cn
JOIN invoices inv ON inv.id = cn.invoice_id
WHERE cn.zitadel_org_id = $1
ORDER BY cn.issued_at DESC
LIMIT $2`,
[zitadelOrgId, limit]
);
return result.rows.map(rowToCreditNote);
}
/**
* All credit notes linked to a specific invoice. Used by the invoice
* detail page to surface "this invoice was voided / partially
* refunded by these credit notes".
*/
export async function listCreditNotesForInvoice(
invoiceId: string
): Promise<CreditNote[]> {
await ensureSchema();
const result = await getPool().query(
`SELECT ${CREDIT_NOTE_COLUMNS}
FROM credit_notes cn
JOIN invoices inv ON inv.id = cn.invoice_id
WHERE cn.invoice_id = $1
ORDER BY cn.issued_at ASC`,
[invoiceId]
);
return result.rows.map(rowToCreditNote);
}
/**
* Fetch the PDF bytes for a credit note. Returns null if no PDF was
* ever attached (which would be a bug — every credit note should
* have one) or if the credit note doesn't exist.
*/
export async function getCreditNotePdf(
creditNoteId: string
): Promise<{ data: Buffer; filename: string } | null> {
const result = await getPool().query(
`SELECT pdf_data, pdf_filename, credit_note_number
FROM credit_notes WHERE id = $1`,
[creditNoteId]
);
if (result.rows.length === 0) return null;
const row = result.rows[0];
if (!row.pdf_data) return null;
return {
data: row.pdf_data,
filename: row.pdf_filename ?? `${row.credit_note_number}.pdf`,
};
}
/**
* Mark an invoice as voided and bump the status. Caller is
* responsible for ensuring the invoice is in a void-eligible state
* (status='open' or 'overdue'); this helper doesn't enforce that —
* billing.voidInvoice() does.
*/
export async function markInvoiceVoided(params: {
invoiceId: string;
reason: string;
voidedBy: string;
}): Promise<void> {
await getPool().query(
`UPDATE invoices
SET status = 'void',
void_reason = $2,
voided_at = now(),
voided_by = $3
WHERE id = $1`,
[params.invoiceId, params.reason, params.voidedBy]
);
}
/**
* Record a refund event and (optionally) bump the invoice status.
* The status transition is:
* refunded_total + amount >= total_chf → fully_refunded
* otherwise → partially_refunded
*
* Idempotent against Stripe webhook replays via stripe_refund_id:
* if a row with the same Stripe refund id already exists, returns
* the existing row without double-counting. Manual (non-Stripe)
* refunds have stripe_refund_id=null and can't be deduped — caller
* must guard against double-submit at the UI/API layer.
*
* Returns the recorded refund, the updated invoice's new total
* refunded amount, and the resulting invoice status.
*/
export interface RecordRefundResult {
refund: InvoiceRefund;
alreadyExisted: boolean;
newRefundedTotalChf: number;
newInvoiceStatus: InvoiceStatus;
}
export async function recordInvoiceRefund(params: {
invoiceId: string;
stripeRefundId: string | null;
amountChf: number;
reason: string | null;
refundedBy: string;
creditNoteId: string | null;
status?: "pending" | "succeeded" | "failed" | "canceled";
}): Promise<RecordRefundResult> {
await ensureSchema();
const pool = getPool();
const client = await pool.connect();
try {
await client.query("BEGIN");
// Webhook idempotency: if the Stripe refund id is already
// recorded, return the existing row without re-adding to the
// running total. The invoice status row reflects the cumulative
// state independent of how many times this function is called.
if (params.stripeRefundId) {
const existing = await client.query(
`SELECT * FROM invoice_refunds WHERE stripe_refund_id = $1`,
[params.stripeRefundId]
);
if (existing.rows.length > 0) {
const inv = await client.query(
`SELECT refunded_total_chf, status
FROM invoices WHERE id = $1`,
[params.invoiceId]
);
await client.query("COMMIT");
return {
refund: rowToInvoiceRefund(existing.rows[0]),
alreadyExisted: true,
newRefundedTotalChf: Number(inv.rows[0]?.refunded_total_chf ?? 0),
newInvoiceStatus: (inv.rows[0]?.status ?? "paid") as InvoiceStatus,
};
}
}
// Insert the refund event.
const inserted = await client.query(
`INSERT INTO invoice_refunds (
invoice_id, stripe_refund_id, amount_chf, reason,
status, refunded_by, credit_note_id
)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING *`,
[
params.invoiceId,
params.stripeRefundId,
params.amountChf,
params.reason,
params.status ?? "succeeded",
params.refundedBy,
params.creditNoteId,
]
);
const recordedStatus = inserted.rows[0].status;
// Only succeeded refunds count toward the invoice's
// refunded_total. Pending/failed refunds are tracked for audit
// but don't change the customer-visible state.
if (recordedStatus !== "succeeded") {
const inv = await client.query(
`SELECT refunded_total_chf, status FROM invoices WHERE id = $1`,
[params.invoiceId]
);
await client.query("COMMIT");
return {
refund: rowToInvoiceRefund(inserted.rows[0]),
alreadyExisted: false,
newRefundedTotalChf: Number(inv.rows[0]?.refunded_total_chf ?? 0),
newInvoiceStatus: (inv.rows[0]?.status ?? "paid") as InvoiceStatus,
};
}
// Update aggregate + status atomically based on the new total.
const updated = await client.query(
`UPDATE invoices
SET refunded_total_chf = refunded_total_chf + $2,
status = CASE
WHEN refunded_total_chf + $2 >= total_chf
THEN 'fully_refunded'
ELSE 'partially_refunded'
END
WHERE id = $1
RETURNING refunded_total_chf, status`,
[params.invoiceId, params.amountChf]
);
await client.query("COMMIT");
return {
refund: rowToInvoiceRefund(inserted.rows[0]),
alreadyExisted: false,
newRefundedTotalChf: Number(updated.rows[0].refunded_total_chf),
newInvoiceStatus: updated.rows[0].status as InvoiceStatus,
};
} catch (e) {
await client.query("ROLLBACK");
throw e;
} finally {
client.release();
}
}
/** All refund events for an invoice, ordered oldest first. */
export async function listRefundsForInvoice(
invoiceId: string
): Promise<InvoiceRefund[]> {
await ensureSchema();
const result = await getPool().query(
`SELECT * FROM invoice_refunds
WHERE invoice_id = $1
ORDER BY refunded_at ASC`,
[invoiceId]
);
return result.rows.map(rowToInvoiceRefund);
}
/**
* Phase 7: find an invoice by its Stripe PaymentIntent id. Used by
* the charge.refunded webhook to locate the invoice when a refund
* is initiated outside the portal (e.g. directly in Stripe
* Dashboard).
*
* Returns null if no invoice has that payment intent recorded. That
* shouldn't happen in normal flow — every Stripe-paid invoice gets
* its intent stored at checkout.session.completed time — but a
* refund event for an unknown intent should be logged and ignored
* rather than throwing.
*/
export async function getInvoiceByStripePaymentIntent(
paymentIntentId: string
): Promise<Invoice | null> {
await ensureSchema();
const result = await getPool().query(
`SELECT ${INVOICE_LIST_COLUMNS} FROM invoices
WHERE stripe_payment_intent_id = $1
LIMIT 1`,
[paymentIntentId]
);
return result.rows.length > 0 ? rowToInvoice(result.rows[0]) : null;
}
/**
* Phase 7: check if a particular Stripe refund id is already
* recorded in invoice_refunds. Used by the charge.refunded webhook
* to skip refunds that were initiated via /api/admin/.../refund
* (which records them immediately) — the webhook would otherwise
* try to double-create the credit note.
*/
export async function isStripeRefundRecorded(
stripeRefundId: string
): Promise<boolean> {
const result = await getPool().query(
`SELECT 1 FROM invoice_refunds WHERE stripe_refund_id = $1 LIMIT 1`,
[stripeRefundId]
);
return result.rows.length > 0;
}
// ---------------------------------------------------------------------------
// Phase 8 — custom invoice drafts
// ---------------------------------------------------------------------------
function rowToInvoiceDraft(row: any): InvoiceDraftRecord {
return {
id: row.id,
zitadelOrgId: row.zitadel_org_id,
createdBy: row.created_by,
createdAt: row.created_at?.toISOString?.() ?? row.created_at,
updatedAt: row.updated_at?.toISOString?.() ?? row.updated_at,
payload: row.payload as CustomInvoiceDraftPayload,
};
}
/**
* Create a new draft for the given org with the supplied payload.
* The payload is whatever the editor has so far (possibly minimal —
* just a date and an empty lines array). Returns the inserted row.
*/
export async function createInvoiceDraft(params: {
zitadelOrgId: string;
createdBy: string;
payload: CustomInvoiceDraftPayload;
}): Promise<InvoiceDraftRecord> {
await ensureSchema();
const result = await getPool().query(
`INSERT INTO invoice_drafts (zitadel_org_id, created_by, payload)
VALUES ($1, $2, $3::jsonb)
RETURNING *`,
[params.zitadelOrgId, params.createdBy, JSON.stringify(params.payload)]
);
return rowToInvoiceDraft(result.rows[0]);
}
/**
* Update an existing draft's payload. Updated_at gets bumped to now()
* so the drafts list can sort by recent activity. Returns the
* updated row, or null if no row with that id exists.
*
* Org boundary check is the caller's responsibility (the admin API
* route only accepts requests from platform admins, but you could
* pass a zitadelOrgId filter here too if you ever expose drafts to
* customer-level roles).
*/
export async function updateInvoiceDraft(
id: string,
payload: CustomInvoiceDraftPayload
): Promise<InvoiceDraftRecord | null> {
const result = await getPool().query(
`UPDATE invoice_drafts
SET payload = $2::jsonb,
updated_at = now()
WHERE id = $1
RETURNING *`,
[id, JSON.stringify(payload)]
);
return result.rows.length > 0 ? rowToInvoiceDraft(result.rows[0]) : null;
}
export async function getInvoiceDraftById(
id: string
): Promise<InvoiceDraftRecord | null> {
await ensureSchema();
const result = await getPool().query(
"SELECT * FROM invoice_drafts WHERE id = $1",
[id]
);
return result.rows.length > 0 ? rowToInvoiceDraft(result.rows[0]) : null;
}
/**
* List all open drafts across all orgs, newest first. Used by the
* admin "Drafts" tab so the admin can resume any in-progress
* invoice. Drafts are tiny (a JSONB payload), so we don't paginate
* by default — 200 rows is plenty of headroom for a solo-founder
* workflow.
*/
export async function listAllInvoiceDrafts(
limit = 200
): Promise<InvoiceDraftRecord[]> {
await ensureSchema();
const result = await getPool().query(
`SELECT * FROM invoice_drafts ORDER BY updated_at DESC LIMIT $1`,
[limit]
);
return result.rows.map(rowToInvoiceDraft);
}
/**
* Per-org listing — used if you ever want to surface drafts on an
* org-detail page or filter by org from the drafts list.
*/
export async function listInvoiceDraftsForOrg(
zitadelOrgId: string,
limit = 50
): Promise<InvoiceDraftRecord[]> {
await ensureSchema();
const result = await getPool().query(
`SELECT * FROM invoice_drafts
WHERE zitadel_org_id = $1
ORDER BY updated_at DESC
LIMIT $2`,
[zitadelOrgId, limit]
);
return result.rows.map(rowToInvoiceDraft);
}
export async function deleteInvoiceDraft(id: string): Promise<boolean> {
const result = await getPool().query(
"DELETE FROM invoice_drafts WHERE id = $1",
[id]
);
return (result.rowCount ?? 0) > 0;
}
// ---------------------------------------------------------------------------
// Phase 9 — saved-card management for off-session auto-charge
// ---------------------------------------------------------------------------
/**
* Persist a saved PaymentMethod against an org's billing config.
* Called from the webhook after a successful setup-mode Checkout
* session, and again when "Pay by Card" with setup_future_usage
* delivers a fresh PaymentMethod. Upserts the config row in case
* the org has none yet (rare — onboarding usually creates one,
* but defensive doesn't hurt).
*
* Only display fields (brand/last4/exp) are persisted. The full PAN
* is never seen by this code — Stripe holds it.
*/
export async function setSavedPaymentMethod(params: {
zitadelOrgId: string;
stripeCustomerId: string;
paymentMethodId: string;
brand: string | null;
last4: string | null;
expMonth: number | null;
expYear: number | null;
}): Promise<void> {
await ensureSchema();
await getPool().query(
`INSERT INTO org_billing_config (
zitadel_org_id, stripe_customer_id,
stripe_default_payment_method_id, stripe_pm_brand, stripe_pm_last4,
stripe_pm_exp_month, stripe_pm_exp_year, updated_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, now())
ON CONFLICT (zitadel_org_id) DO UPDATE SET
stripe_customer_id = COALESCE(org_billing_config.stripe_customer_id, EXCLUDED.stripe_customer_id),
stripe_default_payment_method_id = EXCLUDED.stripe_default_payment_method_id,
stripe_pm_brand = EXCLUDED.stripe_pm_brand,
stripe_pm_last4 = EXCLUDED.stripe_pm_last4,
stripe_pm_exp_month = EXCLUDED.stripe_pm_exp_month,
stripe_pm_exp_year = EXCLUDED.stripe_pm_exp_year,
updated_at = now()`,
[
params.zitadelOrgId,
params.stripeCustomerId,
params.paymentMethodId,
params.brand,
params.last4,
params.expMonth,
params.expYear,
]
);
}
/**
* Clear the saved PaymentMethod fields. Used when the customer
* clicks "Remove card" — the Stripe-side detach happens in the
* caller (stripe.detachPaymentMethod); this just nulls the
* portal-side display fields and the pm id reference.
*
* Does not touch stripe_customer_id (the customer object survives),
* auto_charge_enabled, or any other config — only the four card
* fields and the pm id pointer.
*/
export async function clearSavedPaymentMethod(
zitadelOrgId: string
): Promise<void> {
await getPool().query(
`UPDATE org_billing_config
SET stripe_default_payment_method_id = NULL,
stripe_pm_brand = NULL,
stripe_pm_last4 = NULL,
stripe_pm_exp_month = NULL,
stripe_pm_exp_year = NULL,
updated_at = now()
WHERE zitadel_org_id = $1`,
[zitadelOrgId]
);
}
/**
* Toggle the auto_charge_enabled flag. Used by the customer's
* "Disable auto-pay / Enable auto-pay" button in /settings/billing
* and (Phase 9b) the admin override on /admin/billing/orgs.
*/
export async function setAutoChargeEnabled(
zitadelOrgId: string,
enabled: boolean
): Promise<void> {
await getPool().query(
`INSERT INTO org_billing_config (zitadel_org_id, auto_charge_enabled, updated_at)
VALUES ($1, $2, now())
ON CONFLICT (zitadel_org_id) DO UPDATE SET
auto_charge_enabled = EXCLUDED.auto_charge_enabled,
updated_at = now()`,
[zitadelOrgId, enabled]
);
}
/**
* Look up the org id for a given Stripe customer id — used by the
* webhook when a checkout.session.completed in setup mode arrives
* and we need to find which org to save the card against. The
* customer id is the join key Stripe gives us in the session.
*/
export async function getOrgIdByStripeCustomerId(
stripeCustomerId: string
): Promise<string | null> {
await ensureSchema();
const result = await getPool().query(
`SELECT zitadel_org_id FROM org_billing_config
WHERE stripe_customer_id = $1
LIMIT 1`,
[stripeCustomerId]
);
return result.rows.length > 0 ? result.rows[0].zitadel_org_id : null;
}
// ---------------------------------------------------------------------------
// Phase 9b — tenant order with setup-fee charge
// ---------------------------------------------------------------------------
/**
* Phase 9b: invoked by the Stripe webhook when the setup-fee
* Checkout for a tenant order completes. Atomically:
* - flips the request status from 'pending_payment' → 'pending'
* (admin queue now sees it)
* - sets tenant_name to the derived value (so monthly cron's
* setup-fee dedup works)
* - links the paid invoice via setup_invoice_id (so admin reject
* can refund it via the existing refund flow)
*
* Idempotent on the request side: if the webhook re-fires after
* the row already has status='pending', the UPDATE is a no-op
* (same values). On the rare case of webhook retry happening after
* admin already approved/rejected, the WHERE clause guards against
* regressing the status.
*/
export async function linkTenantRequestSetupPayment(params: {
requestId: string;
tenantName: string;
setupInvoiceId: string;
}): Promise<boolean> {
const result = await getPool().query(
`UPDATE tenant_requests
SET status = 'pending',
tenant_name = $2,
setup_invoice_id = $3,
updated_at = now()
WHERE id = $1
AND status = 'pending_payment'
RETURNING id`,
[params.requestId, params.tenantName, params.setupInvoiceId]
);
return (result.rowCount ?? 0) > 0;
}
/**
* Look up a tenant request by id without restricting by status —
* used by the webhook + reject handler. Caller is responsible for
* any role-gating; this is a pure read.
*/
export async function getTenantRequestForSetupFlow(
requestId: string
): Promise<TenantRequest | null> {
await ensureSchema();
const result = await getPool().query(
`SELECT * FROM tenant_requests WHERE id = $1`,
[requestId]
);
return result.rows.length > 0
? mapRow(result.rows[0])
: null;
}
/**
* Insert a tenant request row in the 'pending_payment' status —
* used at order time, before the Stripe Checkout completes. Once
* payment succeeds the webhook flips it to 'pending' via
* linkTenantRequestSetupPayment.
*
* tenant_name stays NULL throughout pending_payment so the unique
* partial index uniq_tenant_requests_tenant_name_provision
* (WHERE tenant_name IS NOT NULL) doesn't block retries from
* abandoned Checkout sessions. The derived tenant_name is computed
* by the caller from the inserted row's id and stored only at
* webhook time.
*/
export async function createTenantRequestPendingPayment(params: {
zitadelOrgId: string;
zitadelUserId: string;
companyName: string;
instanceName?: string | null;
contactName: string;
contactEmail: string;
agentName: string;
soulMd?: string;
agentsMd?: string | null;
packages: string[];
billingAddress: BillingAddress;
billingNotes?: string;
encryptedSecrets?: Buffer | null;
isPersonal: boolean;
channelUsers?: Record<string, string[]>;
}): Promise<TenantRequest> {
await ensureSchema();
const result = await getPool().query(
`INSERT INTO tenant_requests (
zitadel_org_id, zitadel_user_id,
company_name, instance_name, contact_name, contact_email,
agent_name, soul_md, agents_md, packages,
billing_address, billing_notes,
encrypted_secrets, is_personal,
channel_users,
status, request_type
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11::jsonb, $12,
$13, $14, $15::jsonb, 'pending_payment', 'provision'
)
RETURNING *`,
[
params.zitadelOrgId,
params.zitadelUserId,
params.companyName,
params.instanceName ?? null,
params.contactName,
params.contactEmail,
params.agentName,
params.soulMd ?? null,
params.agentsMd ?? null,
params.packages,
JSON.stringify(params.billingAddress),
params.billingNotes ?? null,
params.encryptedSecrets ?? null,
params.isPersonal,
JSON.stringify(params.channelUsers ?? {}),
]
);
return mapRow(result.rows[0]);
}
/**
* Delete a pending_payment row — used when admin or system needs
* to clean up an abandoned order (e.g. Checkout session expired
* before the customer completed payment). Guarded: only deletes
* if status is still 'pending_payment' so we never accidentally
* delete a request that admin has already approved.
*
* Also nulls any setup_invoice_id reference before deleting so we
* don't leave dangling FK refs (we don't have ON DELETE behavior
* defined on the column).
*/
export async function deletePendingPaymentRequest(
requestId: string
): Promise<boolean> {
const result = await getPool().query(
`DELETE FROM tenant_requests
WHERE id = $1 AND status = 'pending_payment'
RETURNING id`,
[requestId]
);
return (result.rowCount ?? 0) > 0;
}