This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user