This commit is contained in:
@@ -153,5 +153,21 @@ export function formatLineDescription(
|
||||
}[L];
|
||||
return reason ? `${base}: ${reason}` : base;
|
||||
}
|
||||
|
||||
// Phase 8: custom invoice lines. The description is what the
|
||||
// admin typed in the editor — return it verbatim (no template,
|
||||
// no locale-specific formatting). billing.ts persists the
|
||||
// already-trimmed admin input into invoice_lines.description.
|
||||
case "custom_line": {
|
||||
const dRaw = (m as Record<string, unknown>)["description"];
|
||||
if (typeof dRaw === "string" && dRaw.trim().length > 0) return dRaw;
|
||||
// Fallback: the description column on the row itself. The
|
||||
// PDF renderer hands us the line so it can read it directly
|
||||
// — see how billing-pdf invokes formatLineDescription.
|
||||
const onRow = (line as unknown as { description?: string }).description;
|
||||
return onRow && onRow.trim().length > 0
|
||||
? onRow
|
||||
: { de: "Leistung", en: "Service", fr: "Service", it: "Servizio" }[L];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,6 +107,7 @@ const MESSAGES: Record<string, PdfStrings> = {
|
||||
skill_usage: "Skill-Nutzung",
|
||||
skill_setup: "Einrichtungsgebühr Skill",
|
||||
adjustment: "Anpassung",
|
||||
custom_line: "Leistungen",
|
||||
},
|
||||
reverseCharge:
|
||||
"Steuerschuldnerschaft des Leistungsempfängers (Reverse Charge).",
|
||||
@@ -140,6 +141,7 @@ const MESSAGES: Record<string, PdfStrings> = {
|
||||
skill_usage: "Skill usage",
|
||||
skill_setup: "Skill setup fee",
|
||||
adjustment: "Adjustment",
|
||||
custom_line: "Services",
|
||||
},
|
||||
reverseCharge:
|
||||
"Reverse charge — VAT to be accounted for by the recipient.",
|
||||
@@ -173,6 +175,7 @@ const MESSAGES: Record<string, PdfStrings> = {
|
||||
skill_usage: "Utilisation Skill",
|
||||
skill_setup: "Frais de configuration skill",
|
||||
adjustment: "Ajustement",
|
||||
custom_line: "Services",
|
||||
},
|
||||
reverseCharge:
|
||||
"Autoliquidation — TVA à acquitter par le destinataire.",
|
||||
@@ -206,6 +209,7 @@ const MESSAGES: Record<string, PdfStrings> = {
|
||||
skill_usage: "Utilizzo Skill",
|
||||
skill_setup: "Spese di attivazione skill",
|
||||
adjustment: "Rettifica",
|
||||
custom_line: "Servizi",
|
||||
},
|
||||
reverseCharge:
|
||||
"Inversione contabile — IVA a carico del destinatario.",
|
||||
@@ -435,11 +439,18 @@ const InvoicePdf: React.FC<InvoicePdfProps> = ({ invoice, lines }) => {
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.metaCol}>
|
||||
<Text style={styles.metaLabel}>{s.period}</Text>
|
||||
<Text style={styles.metaValue}>
|
||||
{fmtDate(invoice.periodStart, invoice.locale)} —{" "}
|
||||
{fmtDate(invoice.periodEnd, invoice.locale)}
|
||||
</Text>
|
||||
{/* Phase 8: skip the billing-period block on custom
|
||||
invoices (which aren't tied to a period). Due date
|
||||
still renders. */}
|
||||
{invoice.periodStart && invoice.periodEnd && (
|
||||
<>
|
||||
<Text style={styles.metaLabel}>{s.period}</Text>
|
||||
<Text style={styles.metaValue}>
|
||||
{fmtDate(invoice.periodStart, invoice.locale)} —{" "}
|
||||
{fmtDate(invoice.periodEnd, invoice.locale)}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
<Text style={styles.metaLabel}>{s.dueDate}</Text>
|
||||
<Text style={styles.metaValue}>
|
||||
{fmtDate(invoice.dueAt, invoice.locale)}
|
||||
|
||||
@@ -31,9 +31,11 @@
|
||||
|
||||
import type {
|
||||
CreditNote,
|
||||
CustomInvoiceDraftPayload,
|
||||
Invoice,
|
||||
InvoiceBillingSnapshot,
|
||||
InvoiceDraft,
|
||||
InvoiceDraftRecord,
|
||||
InvoiceLine,
|
||||
InvoiceLineKind,
|
||||
InvoicePaymentMethod,
|
||||
@@ -48,7 +50,9 @@ import {
|
||||
attachCreditNotePdf,
|
||||
createCreditNote,
|
||||
createInvoice,
|
||||
deleteInvoiceDraft,
|
||||
getInvoiceById,
|
||||
getInvoiceDraftById,
|
||||
getOrgBilling,
|
||||
getOrgBillingConfig,
|
||||
getPlatformPricing,
|
||||
@@ -247,6 +251,9 @@ const EU_COUNTRIES = new Set([
|
||||
|
||||
/**
|
||||
* Determine VAT rate from billing address and the platform default.
|
||||
* Exported for reuse by the Phase 8 custom-invoice flow so both
|
||||
* pipelines (cron and custom) compute VAT identically.
|
||||
*
|
||||
* See README for the legal interpretation; this implements the
|
||||
* defaults you confirmed:
|
||||
*
|
||||
@@ -255,7 +262,7 @@ const EU_COUNTRIES = new Set([
|
||||
* - EU without VAT: CH MWST (B2C consumer, we charge our rate)
|
||||
* - other: 0% (export of services)
|
||||
*/
|
||||
function vatRateForAddress(
|
||||
export function vatRateForAddress(
|
||||
snapshot: InvoiceBillingSnapshot,
|
||||
platformPricing: PlatformPricing
|
||||
): { rate: number; note: string | null } {
|
||||
@@ -1202,3 +1209,300 @@ export async function refundInvoice(params: {
|
||||
|
||||
return creditNote;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Phase 8 — custom invoices (admin-entered, ad-hoc)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export class CustomInvoiceValidationError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "CustomInvoiceValidationError";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the totals for a custom-invoice draft payload, applying
|
||||
* the same VAT logic the auto cron uses (vatRateForAddress against
|
||||
* the org's billing snapshot).
|
||||
*
|
||||
* Returns the InvoiceDraft the createInvoice helper expects.
|
||||
* Throws CustomInvoiceValidationError on:
|
||||
* - no lines
|
||||
* - any line with empty description or zero quantity
|
||||
* - invalid date (issue or due)
|
||||
* - issue date in past beyond 1 year (probably a typo)
|
||||
* - due before issue
|
||||
*
|
||||
* Negative line amounts are intentionally allowed — they're the
|
||||
* Rabatt / discount mechanism (one row with a negative unitPriceChf).
|
||||
* The algebraic sum becomes the subtotal.
|
||||
*/
|
||||
export async function computeCustomInvoiceTotals(params: {
|
||||
zitadelOrgId: string;
|
||||
payload: CustomInvoiceDraftPayload;
|
||||
}): Promise<InvoiceDraft> {
|
||||
const { zitadelOrgId, payload } = params;
|
||||
|
||||
// Validation
|
||||
if (!payload.lines || payload.lines.length === 0) {
|
||||
throw new CustomInvoiceValidationError(
|
||||
"Custom invoice must have at least one line."
|
||||
);
|
||||
}
|
||||
for (let i = 0; i < payload.lines.length; i++) {
|
||||
const ln = payload.lines[i];
|
||||
if (!ln.description || !ln.description.trim()) {
|
||||
throw new CustomInvoiceValidationError(
|
||||
`Line ${i + 1}: description is required.`
|
||||
);
|
||||
}
|
||||
if (
|
||||
typeof ln.quantity !== "number" ||
|
||||
!isFinite(ln.quantity) ||
|
||||
ln.quantity === 0
|
||||
) {
|
||||
throw new CustomInvoiceValidationError(
|
||||
`Line ${i + 1}: quantity must be a non-zero number.`
|
||||
);
|
||||
}
|
||||
if (typeof ln.unitPriceChf !== "number" || !isFinite(ln.unitPriceChf)) {
|
||||
throw new CustomInvoiceValidationError(
|
||||
`Line ${i + 1}: unit price must be a number (use negative for discounts).`
|
||||
);
|
||||
}
|
||||
}
|
||||
const issueDate = payload.issueDate;
|
||||
const dueDate = payload.dueDate;
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(issueDate)) {
|
||||
throw new CustomInvoiceValidationError(
|
||||
"Issue date must be a valid YYYY-MM-DD."
|
||||
);
|
||||
}
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(dueDate)) {
|
||||
throw new CustomInvoiceValidationError(
|
||||
"Due date must be a valid YYYY-MM-DD."
|
||||
);
|
||||
}
|
||||
if (dueDate < issueDate) {
|
||||
throw new CustomInvoiceValidationError(
|
||||
"Due date cannot be before issue date."
|
||||
);
|
||||
}
|
||||
|
||||
// Billing snapshot — required for any invoice to render.
|
||||
const orgBilling = await getOrgBilling(zitadelOrgId);
|
||||
if (!orgBilling) {
|
||||
throw new CustomInvoiceValidationError(
|
||||
"Org has no billing configuration. Ask the customer to complete onboarding first, or set the billing info from the admin panel."
|
||||
);
|
||||
}
|
||||
// Build the same snapshot shape the auto-cron freezes. Mirroring
|
||||
// the auto flow keeps the PDF renderer happy with one code path.
|
||||
const snapshot: InvoiceBillingSnapshot = {
|
||||
companyName: orgBilling.companyName,
|
||||
contactName: orgBilling.contactName ?? null,
|
||||
streetAddress: orgBilling.streetAddress,
|
||||
city: orgBilling.city,
|
||||
postalCode: orgBilling.postalCode,
|
||||
country: orgBilling.country,
|
||||
vatNumber: orgBilling.vatNumber ?? null,
|
||||
billingEmail: orgBilling.billingEmail,
|
||||
notes: orgBilling.notes ?? null,
|
||||
};
|
||||
|
||||
// VAT — same logic as auto.
|
||||
const platformPricing = await getPlatformPricing();
|
||||
const vat = vatRateForAddress(snapshot, platformPricing);
|
||||
|
||||
// Build invoice lines. quantity * unitPrice rounded to 2 decimals
|
||||
// (rappen precision). We carry the per-line amount on the row so
|
||||
// the PDF doesn't need to recompute and any rounding remains
|
||||
// identical between rendering passes.
|
||||
const lines: Omit<InvoiceLine, "id" | "invoiceId">[] = payload.lines.map(
|
||||
(ln) => {
|
||||
const amount = Math.round(ln.quantity * ln.unitPriceChf * 100) / 100;
|
||||
return {
|
||||
kind: "custom_line" as InvoiceLineKind,
|
||||
description: ln.description.trim(),
|
||||
quantity: ln.quantity,
|
||||
unitPriceChf: ln.unitPriceChf,
|
||||
amountChf: amount,
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// Subtotal is the algebraic sum (negative lines reduce it).
|
||||
const subtotalChf = Math.round(
|
||||
lines.reduce((s, l) => s + l.amountChf, 0) * 100
|
||||
) / 100;
|
||||
// VAT applies to the subtotal AFTER discounts (which is the
|
||||
// legal default in CH — discounts reduce the taxable base).
|
||||
const vatAmountChf = Math.round(subtotalChf * (vat.rate / 100) * 100) / 100;
|
||||
const totalChf = Math.round((subtotalChf + vatAmountChf) * 100) / 100;
|
||||
|
||||
return {
|
||||
zitadelOrgId,
|
||||
source: "custom",
|
||||
periodStart: null,
|
||||
periodEnd: null,
|
||||
issuedAt: `${issueDate}T00:00:00Z`,
|
||||
dueAt: dueDate,
|
||||
locale: payload.locale,
|
||||
paymentMethod: payload.paymentMethod,
|
||||
billingSnapshot: snapshot,
|
||||
lines,
|
||||
subtotalChf,
|
||||
vatRate: vat.rate,
|
||||
vatAmountChf,
|
||||
totalChf,
|
||||
warnings: [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Issue a custom invoice from a draft. Three-step flow:
|
||||
*
|
||||
* 1. Compute totals + validate the payload (computeCustomInvoiceTotals)
|
||||
* 2. Persist via createInvoice (allocates the number, inserts the
|
||||
* row + lines, source='custom', issued_at honours the override)
|
||||
* 3. Render PDF, send email — best-effort each. PDF render failure
|
||||
* leaves the row in place with no PDF; admin can re-render. Email
|
||||
* failure is logged.
|
||||
*
|
||||
* After successful persistence, the draft row is deleted (its job
|
||||
* is done). If persistence fails, the draft stays so the admin can
|
||||
* fix the issue and try again.
|
||||
*/
|
||||
export async function issueCustomInvoiceDraft(params: {
|
||||
draftId: string;
|
||||
issuedBy: string;
|
||||
}): Promise<Invoice> {
|
||||
const draft = await getInvoiceDraftById(params.draftId);
|
||||
if (!draft) {
|
||||
throw new CustomInvoiceValidationError(
|
||||
`Draft not found: ${params.draftId}`
|
||||
);
|
||||
}
|
||||
const invoiceDraft = await computeCustomInvoiceTotals({
|
||||
zitadelOrgId: draft.zitadelOrgId,
|
||||
payload: draft.payload,
|
||||
});
|
||||
|
||||
// Two-pass: persist without PDF first, render against the canonical
|
||||
// row (now has a number), then attach. Same pattern as the auto
|
||||
// flow — keeps the PDF self-referential without juggling temporary
|
||||
// numbers.
|
||||
const placeholder = await createInvoice(invoiceDraft, null, null);
|
||||
|
||||
let pdfBuffer: Buffer | null = null;
|
||||
try {
|
||||
pdfBuffer = await renderInvoicePdf(placeholder, invoiceDraft.lines);
|
||||
const filename = `${placeholder.invoiceNumber}.pdf`;
|
||||
await updateInvoicePdf(placeholder.id, pdfBuffer, filename);
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`Custom invoice ${placeholder.invoiceNumber} persisted but PDF render failed:`,
|
||||
e
|
||||
);
|
||||
// Don't throw — the row exists. Admin can re-render via a
|
||||
// future tool (Phase 8.5 or just by deleting+reissuing).
|
||||
}
|
||||
|
||||
// Best-effort email.
|
||||
try {
|
||||
const snap = invoiceDraft.billingSnapshot;
|
||||
if (snap.billingEmail) {
|
||||
await sendInvoiceIssuedEmail({
|
||||
to: snap.billingEmail,
|
||||
contactName: snap.contactName || snap.companyName,
|
||||
companyName: snap.companyName,
|
||||
invoiceNumber: placeholder.invoiceNumber,
|
||||
totalChf: placeholder.totalChf,
|
||||
currency: "CHF",
|
||||
dueAt: placeholder.dueAt,
|
||||
lineCount: invoiceDraft.lines.length,
|
||||
periodStart: null,
|
||||
periodEnd: null,
|
||||
locale: invoiceDraft.locale as "de" | "en" | "fr" | "it",
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`Custom invoice ${placeholder.invoiceNumber} issued; email send failed.`,
|
||||
e
|
||||
);
|
||||
}
|
||||
|
||||
// Draft did its job — remove it. If this fails the issuance
|
||||
// still stands (we already have a real invoice). Log and move on.
|
||||
try {
|
||||
await deleteInvoiceDraft(draft.id);
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`Custom invoice ${placeholder.invoiceNumber} issued but draft ${draft.id} could not be deleted:`,
|
||||
e
|
||||
);
|
||||
}
|
||||
|
||||
return placeholder;
|
||||
}
|
||||
|
||||
/**
|
||||
* Preview a draft as a PDF without persisting an invoice. The PDF
|
||||
* is rendered with a placeholder number ("DRAFT") and not stored
|
||||
* anywhere — the caller streams the bytes back to the admin's
|
||||
* browser for review.
|
||||
*
|
||||
* Throws CustomInvoiceValidationError if the draft isn't ready to
|
||||
* issue (no lines, missing billing snapshot, etc.) so the editor
|
||||
* can surface the problem before any rendering work.
|
||||
*/
|
||||
export async function renderCustomDraftPreview(
|
||||
draftId: string
|
||||
): Promise<Buffer> {
|
||||
const draft = await getInvoiceDraftById(draftId);
|
||||
if (!draft) {
|
||||
throw new CustomInvoiceValidationError(`Draft not found: ${draftId}`);
|
||||
}
|
||||
const invoiceDraft = await computeCustomInvoiceTotals({
|
||||
zitadelOrgId: draft.zitadelOrgId,
|
||||
payload: draft.payload,
|
||||
});
|
||||
|
||||
// Render against a synthetic Invoice — same shape the persisted
|
||||
// row would have, but with a DRAFT placeholder number. No DB
|
||||
// writes. The PDF renderer doesn't care; it just consumes the
|
||||
// Invoice + lines.
|
||||
const fakeInvoice: Invoice = {
|
||||
id: "preview",
|
||||
invoiceNumber: "DRAFT",
|
||||
zitadelOrgId: draft.zitadelOrgId,
|
||||
source: "custom",
|
||||
periodStart: null,
|
||||
periodEnd: null,
|
||||
issuedAt: invoiceDraft.issuedAt ?? new Date().toISOString(),
|
||||
dueAt: invoiceDraft.dueAt,
|
||||
subtotalChf: invoiceDraft.subtotalChf,
|
||||
vatRate: invoiceDraft.vatRate,
|
||||
vatAmountChf: invoiceDraft.vatAmountChf,
|
||||
totalChf: invoiceDraft.totalChf,
|
||||
status: "draft",
|
||||
locale: invoiceDraft.locale,
|
||||
paymentMethod: invoiceDraft.paymentMethod,
|
||||
billingSnapshot: invoiceDraft.billingSnapshot,
|
||||
stripePaymentIntentId: null,
|
||||
pdfFilename: null,
|
||||
hasPdf: false,
|
||||
adminNotes: null,
|
||||
paidAt: null,
|
||||
paidBy: null,
|
||||
paidMethodDetail: null,
|
||||
voidReason: null,
|
||||
voidedAt: null,
|
||||
voidedBy: null,
|
||||
refundedTotalChf: 0,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
return renderInvoicePdf(fakeInvoice, invoiceDraft.lines);
|
||||
}
|
||||
|
||||
255
src/lib/db.ts
255
src/lib/db.ts
@@ -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;
|
||||
}
|
||||
|
||||
@@ -923,8 +923,8 @@ export async function sendInvoiceIssuedEmail(params: {
|
||||
currency: string; // "CHF" — passed for future-proofing
|
||||
dueAt: string; // ISO date
|
||||
lineCount: number;
|
||||
periodStart: string; // ISO date
|
||||
periodEnd: string; // ISO date
|
||||
periodStart: string | null; // ISO date; null for custom invoices
|
||||
periodEnd: string | null; // ISO date; null for custom invoices
|
||||
locale: "de" | "en" | "fr" | "it";
|
||||
}): Promise<void> {
|
||||
// All four locales — the email is sent in the invoice's locale,
|
||||
@@ -960,7 +960,13 @@ export async function sendInvoiceIssuedEmail(params: {
|
||||
const safeCompany = escapeHtml(params.companyName);
|
||||
const safeNumber = escapeHtml(params.invoiceNumber);
|
||||
const totalFmt = `${params.currency} ${params.totalChf.toFixed(2)}`;
|
||||
const periodFmt = `${params.periodStart.slice(0, 10)} → ${params.periodEnd.slice(0, 10)}`;
|
||||
// Phase 8: period is null for custom invoices. When missing, the
|
||||
// template skips the "Service period:" line entirely; otherwise
|
||||
// it renders the date range as before.
|
||||
const periodFmt =
|
||||
params.periodStart && params.periodEnd
|
||||
? `${params.periodStart.slice(0, 10)} → ${params.periodEnd.slice(0, 10)}`
|
||||
: null;
|
||||
const dueFmt = params.dueAt.slice(0, 10);
|
||||
|
||||
// Both bodies built in the invoice's locale.
|
||||
@@ -977,7 +983,9 @@ export async function sendInvoiceIssuedEmail(params: {
|
||||
introByLocale[L],
|
||||
"",
|
||||
`${l.number}: ${params.invoiceNumber}`,
|
||||
`${l.period}: ${periodFmt}`,
|
||||
// Phase 8: omit the period line entirely for custom
|
||||
// invoices (which have no billing period).
|
||||
...(periodFmt ? [`${l.period}: ${periodFmt}`] : []),
|
||||
`${l.total}: ${totalFmt}`,
|
||||
`${l.due}: ${dueFmt}`,
|
||||
`${l.lines}: ${params.lineCount}`,
|
||||
@@ -995,7 +1003,7 @@ export async function sendInvoiceIssuedEmail(params: {
|
||||
<p>${escapeHtml(introByLocale[L])}</p>
|
||||
<table style="width:100%; border-collapse:collapse; margin:16px 0; font-size:14px;">
|
||||
<tr><td style="color:#888; padding:6px 0; width:120px;">${l.number}</td><td><strong>${safeNumber}</strong></td></tr>
|
||||
<tr><td style="color:#888; padding:6px 0;">${l.period}</td><td>${escapeHtml(periodFmt)}</td></tr>
|
||||
${periodFmt ? `<tr><td style="color:#888; padding:6px 0;">${l.period}</td><td>${escapeHtml(periodFmt)}</td></tr>` : ""}
|
||||
<tr><td style="color:#888; padding:6px 0;">${l.total}</td><td style="color:#10B981; font-weight:600;">${escapeHtml(totalFmt)}</td></tr>
|
||||
<tr><td style="color:#888; padding:6px 0;">${l.due}</td><td>${escapeHtml(dueFmt)}</td></tr>
|
||||
<tr><td style="color:#888; padding:6px 0;">${l.lines}</td><td>${params.lineCount}</td></tr>
|
||||
|
||||
@@ -220,7 +220,13 @@ export async function createCheckoutSessionForInvoice(params: {
|
||||
unit_amount: chfToRappen(invoice.totalChf),
|
||||
product_data: {
|
||||
name: `Invoice ${invoice.invoiceNumber}`,
|
||||
description: `PieCed IT — ${invoice.periodStart.slice(0, 10)} → ${invoice.periodEnd.slice(0, 10)}`,
|
||||
// Phase 8: custom invoices have no period — fall back
|
||||
// to a description that just references the invoice
|
||||
// number and due date.
|
||||
description:
|
||||
invoice.periodStart && invoice.periodEnd
|
||||
? `PieCed IT — ${invoice.periodStart.slice(0, 10)} → ${invoice.periodEnd.slice(0, 10)}`
|
||||
: `PieCed IT — due ${invoice.dueAt.slice(0, 10)}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user