302 lines
8.4 KiB
TypeScript
302 lines
8.4 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;
|
|
}
|
|
|
|
export function composeInvoice({
|
|
order,
|
|
settings,
|
|
invoiceNumber,
|
|
forceLanguage,
|
|
storno,
|
|
issueDate,
|
|
}: 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;
|
|
const dueDate = !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" : "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());
|
|
}
|