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

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