import type { ShopSettings } from "@prisma/client"; import type { RawOrderForInvoice, RawShippingLine, RawTaxLine } from "./loadOrderForInvoice.server"; import type { InvoiceLine, InvoiceNotice, InvoiceTotals, InvoiceViewModel, IssuerData, RecipientData, TrackingInfo, VatBreakdownEntry, } from "./types"; import { addDays } from "./format"; import { derivePaymentStatus, getStrings, pickLanguage, type InvoiceLanguage } from "./i18n"; interface ComposeArgs { order: RawOrderForInvoice; settings: ShopSettings; invoiceNumber: string; /** Language override (e.g. for Storno copies). */ forceLanguage?: InvoiceLanguage; /** * When set, produces a Stornorechnung view model: line and total amounts * are negated, `kind` is `"storno"`, and `cancelsNumber` references the * original invoice number. Notices, GiroCode and payment-due date are * suppressed (a storno is informational, not a request for payment). */ storno?: { cancelsNumber: string }; /** Optional override for invoice/delivery date (defaults to order date). */ issueDate?: Date; /** * When true, render as an Angebot/Offer instead of an invoice: * - `kind = "offer"` * - no payment-due date (the dueDate field is repurposed by the renderer * as the offer's validity expiry). * - GiroCode and payment-terms text are suppressed. */ offer?: boolean; } export function composeInvoice({ order, settings, invoiceNumber, forceLanguage, storno, issueDate, offer, }: ComposeArgs): InvoiceViewModel { const language = forceLanguage ?? pickLanguage(order.customer?.locale ?? settings.defaultLanguage); const issuer = mapIssuer(settings); const recipient = mapRecipient(order); const isB2B = !!order.purchasingEntity?.company; const recipientVatId = order.purchasingEntity?.company?.vatId ?? undefined; const strings = getStrings(language); let { lines, totals } = mapLinesAndTotals(order, { shippingItemPrefix: strings.shippingItemPrefix, }); let notices = deriveNotices({ order, settings, isB2B }); const separateShippingAddress = mapSeparateShippingAddress(order); const shippingMethod = order.shippingLine?.title?.trim() || undefined; const tracking = mapTracking(order); const invoiceDate = issueDate ?? new Date(order.processedAt ?? order.createdAt); const deliveryDate = invoiceDate; // For offers we treat `dueDate` as the offer's validity expiry (default 30 // days from issue). The PDF renderer renders a different label. const dueDate = offer ? addDays(invoiceDate, 30) : !storno && settings.paymentTermDays > 0 ? addDays(invoiceDate, settings.paymentTermDays) : undefined; const paid = (order.displayFinancialStatus || "").toUpperCase() === "PAID"; const paymentStatus = derivePaymentStatus(order.displayFinancialStatus); const paymentGatewayNames = (order.paymentGatewayNames ?? []).filter( (n) => typeof n === "string" && n.trim().length > 0, ); if (storno) { lines = lines.map((l) => ({ ...l, unitPriceNet: -l.unitPriceNet, totalNet: -l.totalNet, })); totals = { net: -totals.net, vatBreakdown: totals.vatBreakdown.map((v) => ({ ratePct: v.ratePct, net: -v.net, tax: -v.tax, })), totalVat: -totals.totalVat, gross: -totals.gross, }; // Notices are still relevant (e.g. reverse-charge), but the storno is not // a payment request — leave them in place for legal symmetry. } return { language, currency: order.currencyCode, kind: storno ? "storno" : offer ? "offer" : "invoice", number: invoiceNumber, cancelsNumber: storno?.cancelsNumber, invoiceDate, deliveryDate, dueDate, issuer, recipient, isB2B, recipientVatId, lines, totals, notices, paid, paymentStatus, paymentGatewayNames, orderName: order.name, separateShippingAddress, shippingMethod, tracking, }; } function mapIssuer(s: ShopSettings): IssuerData { return { companyName: s.companyName, legalForm: s.legalForm, ownerName: s.ownerName, addressLine1: s.addressLine1, addressLine2: s.addressLine2, postalCode: s.postalCode, city: s.city, countryCode: s.countryCode, phone: s.phone, email: s.email, website: s.website, vatId: s.vatId, taxNumber: s.taxNumber, registrationNo: s.registrationNo, registrationCourt: s.registrationCourt, bankName: s.bankName, iban: s.iban, bic: s.bic, footerNote: s.footerNote, footerNoteEn: s.footerNoteEn, }; } function mapRecipient(order: RawOrderForInvoice): RecipientData { // Prefer billingAddress; fall back to shippingAddress; fall back to customer name only. const a = order.billingAddress ?? order.shippingAddress ?? null; const customerFullName = [order.customer?.firstName, order.customer?.lastName] .filter(Boolean) .join(" ") .trim(); if (!a) { return { name: customerFullName, company: order.purchasingEntity?.company?.name ?? "", addressLine1: "", addressLine2: "", postalCode: "", city: "", countryCode: "", }; } return { name: a.name ?? customerFullName, company: a.company ?? order.purchasingEntity?.company?.name ?? "", addressLine1: a.address1 ?? "", addressLine2: a.address2 ?? "", postalCode: a.zip ?? "", city: a.city ?? "", countryCode: a.countryCode ?? "", }; } function mapLinesAndTotals( order: RawOrderForInvoice, opts: { shippingItemPrefix: string }, ): { lines: InvoiceLine[]; totals: InvoiceTotals; } { const taxesIncluded = order.taxesIncluded; const linesOut: InvoiceLine[] = []; const vatMap = new Map(); let netSum = 0; order.lineItems.forEach((li, idx) => { const qty = li.quantity; const grossOrNetUnit = parseFloat(li.originalUnitPriceSet.shopMoney.amount); // Total tax for this line summed across its tax lines. const lineTax = li.taxLines.reduce( (acc, t) => acc + parseFloat(t.priceSet.shopMoney.amount), 0, ); // If taxes are included in the unit price, subtract them to get net. const lineGross = grossOrNetUnit * qty + (taxesIncluded ? 0 : lineTax); const lineNet = taxesIncluded ? grossOrNetUnit * qty - lineTax : grossOrNetUnit * qty; const unitNet = qty > 0 ? lineNet / qty : 0; linesOut.push({ position: idx + 1, title: li.title, sku: li.sku ?? undefined, quantity: qty, unitPriceNet: round2(unitNet), totalNet: round2(lineNet), imageUrl: li.imageUrl ?? undefined, }); netSum += lineNet; li.taxLines.forEach((t) => accumulateVat(vatMap, t, parseFloat(t.priceSet.shopMoney.amount), lineNet)); void lineGross; }); // Prefer order-level taxLines for the breakdown grouping if line-level is missing. if (vatMap.size === 0 && order.taxLines.length > 0) { order.taxLines.forEach((t) => { const tax = parseFloat(t.priceSet.shopMoney.amount); // We don't have per-rate net from the order level; approximate by inferring from rate. const rate = normaliseRate(t); const net = rate > 0 ? tax / (rate / 100) : 0; const entry = vatMap.get(rate) || { ratePct: rate, net: 0, tax: 0 }; entry.net += net; entry.tax += tax; vatMap.set(rate, entry); }); } // Append the shipping line as a synthetic invoice row when the order has // a shipping cost > 0. This makes shipping appear in the items table // (visible to the customer) and folds its tax into the VAT breakdown. const shippingLineNet = appendShippingLine( order.shippingLine, taxesIncluded, linesOut, vatMap, opts.shippingItemPrefix, ); netSum += shippingLineNet; const vatBreakdown = Array.from(vatMap.values()) .map((e) => ({ ratePct: e.ratePct, net: round2(e.net), tax: round2(e.tax) })) .filter((e) => e.tax > 0) .sort((a, b) => a.ratePct - b.ratePct); const totalVat = vatBreakdown.reduce((acc, e) => acc + e.tax, 0); const grossFromOrder = order.totalPriceSet ? parseFloat(order.totalPriceSet.shopMoney.amount) : netSum + totalVat; return { lines: linesOut, totals: { net: round2(netSum), vatBreakdown, totalVat: round2(totalVat), gross: round2(grossFromOrder), }, }; } function accumulateVat( vatMap: Map, t: RawTaxLine, taxAmount: number, lineNet: number, ) { if (taxAmount <= 0) return; const rate = normaliseRate(t); const entry = vatMap.get(rate) || { ratePct: rate, net: 0, tax: 0 }; entry.net += lineNet; entry.tax += taxAmount; vatMap.set(rate, entry); } function normaliseRate(t: RawTaxLine): number { if (t.ratePercentage != null) return Number(t.ratePercentage); if (t.rate != null) { const r = Number(t.rate); return r <= 1 ? r * 100 : r; } return 0; } function round2(n: number): number { return Math.round(n * 100) / 100; } function deriveNotices({ order, settings, isB2B, }: { order: RawOrderForInvoice; settings: ShopSettings; isB2B: boolean; }): InvoiceNotice[] { const notices: InvoiceNotice[] = []; const totalTax = order.totalTaxSet ? parseFloat(order.totalTaxSet.shopMoney.amount) : 0; const recipientCountry = order.billingAddress?.countryCode || order.shippingAddress?.countryCode || ""; const issuerCountry = settings.countryCode || "AT"; if (settings.kleinunternehmer) { notices.push({ kind: "kleinunternehmer" }); return notices; // exclusive of the others } if (totalTax === 0) { if ( isB2B && recipientCountry && recipientCountry !== issuerCountry && isEuCountry(recipientCountry) ) { notices.push({ kind: "reverseCharge" }); } else if (recipientCountry && !isEuCountry(recipientCountry)) { notices.push({ kind: "export" }); } } return notices; } const EU_COUNTRIES = new Set([ "AT","BE","BG","CY","CZ","DE","DK","EE","ES","FI","FR","GR","HR","HU","IE", "IT","LT","LU","LV","MT","NL","PL","PT","RO","SE","SI","SK", ]); function isEuCountry(code: string): boolean { return EU_COUNTRIES.has(code.toUpperCase()); } /** * Add a synthetic line item for the order's shipping cost. Returns the net * amount added (used to keep the running net subtotal in sync). Returns 0 * when there's no shipping line or the shipping price is zero (e.g. free * shipping or digital orders). */ function appendShippingLine( shippingLine: RawShippingLine | null, taxesIncluded: boolean, linesOut: InvoiceLine[], vatMap: Map, prefix: string, ): number { if (!shippingLine) return 0; const priceSet = shippingLine.discountedPriceSet ?? shippingLine.originalPriceSet; const grossOrNet = priceSet ? parseFloat(priceSet.shopMoney.amount) : 0; if (!Number.isFinite(grossOrNet) || grossOrNet === 0) return 0; const tax = shippingLine.taxLines.reduce( (acc, t) => acc + parseFloat(t.priceSet.shopMoney.amount), 0, ); const net = taxesIncluded ? grossOrNet - tax : grossOrNet; const title = shippingLine.title?.trim() ? `${prefix}: ${shippingLine.title.trim()}` : prefix; linesOut.push({ position: linesOut.length + 1, title, quantity: 1, unitPriceNet: round2(net), totalNet: round2(net), }); shippingLine.taxLines.forEach((t) => accumulateVat(vatMap, t, parseFloat(t.priceSet.shopMoney.amount), net), ); return net; } /** * Returns the shipping address as a recipient block when it differs in any * meaningful way from the billing address. Returns undefined when both are * the same (so the renderer doesn't show a redundant block) or when there * is no shipping address at all. */ function mapSeparateShippingAddress( order: RawOrderForInvoice, ): RecipientData | undefined { const ship = order.shippingAddress; const bill = order.billingAddress; if (!ship) return undefined; // No billing address → just use the existing recipient block, no need to // duplicate. if (!bill) return undefined; const sameAddress = (ship.name ?? "") === (bill.name ?? "") && (ship.company ?? "") === (bill.company ?? "") && (ship.address1 ?? "") === (bill.address1 ?? "") && (ship.address2 ?? "") === (bill.address2 ?? "") && (ship.zip ?? "") === (bill.zip ?? "") && (ship.city ?? "") === (bill.city ?? "") && (ship.countryCode ?? "") === (bill.countryCode ?? ""); if (sameAddress) return undefined; const customerFullName = [order.customer?.firstName, order.customer?.lastName] .filter(Boolean) .join(" ") .trim(); return { name: ship.name ?? customerFullName, company: ship.company ?? "", addressLine1: ship.address1 ?? "", addressLine2: ship.address2 ?? "", postalCode: ship.zip ?? "", city: ship.city ?? "", countryCode: ship.countryCode ?? "", }; } /** * Flatten tracking info from all fulfillments. Skips entries without a * tracking number. Deduplicates on `number`. */ function mapTracking(order: RawOrderForInvoice): TrackingInfo[] { const out: TrackingInfo[] = []; const seen = new Set(); for (const f of order.fulfillments ?? []) { for (const t of f.trackingInfo ?? []) { const number = (t.number ?? "").trim(); if (!number || seen.has(number)) continue; seen.add(number); out.push({ number, url: t.url?.trim() || undefined, company: t.company?.trim() || undefined, }); } } return out; }