Files
linumiq-invoice/app/services/invoice/composeInvoice.ts
T
2026-05-09 19:26:33 +02:00

315 lines
8.9 KiB
TypeScript

import type { ShopSettings } from "@prisma/client";
import type { RawOrderForInvoice, RawTaxLine } from "./loadOrderForInvoice.server";
import type {
InvoiceLine,
InvoiceNotice,
InvoiceTotals,
InvoiceViewModel,
IssuerData,
RecipientData,
VatBreakdownEntry,
} from "./types";
import { addDays } from "./format";
import { 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;
let { lines, totals } = mapLinesAndTotals(order);
let notices = deriveNotices({ order, settings, isB2B });
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";
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,
};
}
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): {
lines: InvoiceLine[];
totals: InvoiceTotals;
} {
const taxesIncluded = order.taxesIncluded;
const linesOut: InvoiceLine[] = [];
const vatMap = new Map<number, VatBreakdownEntry>();
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);
});
}
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<number, VatBreakdownEntry>,
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());
}