first version
This commit is contained in:
@@ -0,0 +1,299 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
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),
|
||||
});
|
||||
|
||||
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());
|
||||
}
|
||||
Reference in New Issue
Block a user