Phase7b: Manual Invoice
Some checks failed
Build and Push / build (push) Failing after 59s

This commit is contained in:
2026-05-26 23:04:09 +02:00
parent 667617296b
commit ed915ec539
26 changed files with 2365 additions and 65 deletions

View File

@@ -522,10 +522,30 @@ const MIGRATION_SQL = `
ON invoices(zitadel_org_id, issued_at DESC);
CREATE INDEX IF NOT EXISTS idx_invoices_status
ON invoices(status, due_at);
-- One invoice per org per billing month — protects the monthly
-- cron from double-issuing if it gets retried mid-run.
CREATE UNIQUE INDEX IF NOT EXISTS uniq_invoices_org_period
ON invoices(zitadel_org_id, period_start);
-- 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
@@ -695,9 +715,39 @@ const MIGRATION_SQL = `
ADD CONSTRAINT invoice_lines_kind_check
CHECK (kind IN (
'tenant_monthly','tenant_setup','ai_usage','threema_messages',
'skill_usage','skill_setup','adjustment'
'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
@@ -2372,19 +2422,31 @@ import type {
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,
periodStart: typeof row.period_start === "string"
? row.period_start
: row.period_start.toISOString().split("T")[0],
periodEnd: typeof row.period_end === "string"
? row.period_end
: row.period_end.toISOString().split("T")[0],
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
@@ -2440,7 +2502,7 @@ const INVOICE_LIST_COLUMNS = `
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, created_at,
refunded_total_chf, source, created_at,
(pdf_data IS NOT NULL) AS has_pdf
`;
@@ -2472,9 +2534,24 @@ export async function createInvoice(
try {
await client.query("BEGIN");
// Allocate number for the year of period_start. Locking the
// counter row prevents concurrent allocators from racing.
const year = parseInt(draft.periodStart.slice(0, 4), 10);
// 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)
@@ -2486,24 +2563,29 @@ export async function createInvoice(
const seq = counterResult.rows[0].last_number;
const invoiceNumber = `${year}-${String(seq).padStart(5, "0")}`;
// Insert invoice row. PDF goes inline as bytea for v1; we can
// migrate to MinIO/S3 later if storage gets noisy.
// 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
pdf_data, pdf_filename, source
) VALUES (
$1, $2, $3::date, $4::date, now(), $5::date, $6, $7, $8, $9,
'open', $10, $11, $12::jsonb, $13, $14
$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,
draft.periodEnd,
draft.periodStart, // may be null for custom
draft.periodEnd, // may be null for custom
draft.issuedAt ?? null,
draft.dueAt,
draft.subtotalChf,
draft.vatRate,
@@ -2514,6 +2596,7 @@ export async function createInvoice(
JSON.stringify(draft.billingSnapshot),
pdfBuffer,
pdfFilename,
source,
]
);
const invoiceId = inv.rows[0].id;
@@ -2556,9 +2639,15 @@ export async function createInvoice(
} catch (e: any) {
await client.query("ROLLBACK").catch(() => undefined);
// Translate the uniqueness violation into a user-friendly error.
// 23505 = unique_violation in Postgres.
if (e?.code === "23505" && /uniq_invoices_org_period/.test(e?.constraint ?? "")) {
const month = draft.periodStart.slice(0, 7);
// 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.`
@@ -3767,3 +3856,119 @@ export async function isStripeRefundRecorded(
);
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;
}