feat(invoice): Rechnungen als E-Rechnung (ZUGFeRD/Factur-X EN 16931)
Jedes Rechnungs-PDF ist jetzt eine hybride E-Rechnung: PDF/A-3b mit eingebettetem factur-x.xml (CII D16B, Profil EN 16931). Netto und Steuer je Satz kommen unveraendert aus der Aufstellung des PDFs, Versand und Rabatt stehen als Zu- und Abschlag je Satz; XML und PDF tragen dieselben Summen. - lib/einvoice/cii.ts: XML-Erzeugung (Rechnung 380, Stornorechnung 381 mit Bezug), ohne USt-IdNr. oder bei Positionen ohne Satz nur PDF mit Warnung - lib/einvoice/facturx-pdf.ts: PDF/A-3b, Factur-X-XMP mit Erweiterungsschema, eingebettete Liberation-Schriften (gleiche Laufweiten wie Helvetica/Times) - invoice-pdf: Darstellung als Stornorechnung (negative Betraege, Bezug) - Geprueft mit XSD, CEN-Schematron EN 16931 1.3.16 und Mustang 2.26 (veraPDF: PDF/A-3b konform, XML valid) Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
f28e5e9d84
commit
508ff24aa7
16 files changed
+941
-45
No files matched your search
@@ -1652,6 +1652,12 @@ export const de: Dictionary = {
|
||||
thankYou: "Vielen Dank, dass du unsere Imkerei unterstützt!",
|
||||
pageLabel: (page, total) => `Seite ${page} von ${total}`,
|
||||
legalNote: "Diese Rechnung wurde automatisch erzeugt und ist ohne Unterschrift gültig.",
|
||||
cancellationTitle: "Stornorechnung",
|
||||
cancellationNumberLabel: "Stornorechnungsnr.",
|
||||
cancellationDateLabel: "Datum",
|
||||
cancelsInvoiceLabel: "Zur Rechnung",
|
||||
cancellationNote: (invoiceNumber, invoiceDate) =>
|
||||
`Diese Stornorechnung hebt die Rechnung ${invoiceNumber} vom ${invoiceDate} vollständig auf. Der Betrag wird über die ursprüngliche Zahlungsart erstattet.`,
|
||||
},
|
||||
addressesPage: {
|
||||
title: "Meine Adressen",
|
||||
|
||||
@@ -1626,6 +1626,12 @@ export const en: Dictionary = {
|
||||
thankYou: "Thank you for supporting our apiary!",
|
||||
pageLabel: (page, total) => `Page ${page} of ${total}`,
|
||||
legalNote: "This invoice was generated automatically and is valid without a signature.",
|
||||
cancellationTitle: "Cancellation invoice",
|
||||
cancellationNumberLabel: "Cancellation no.",
|
||||
cancellationDateLabel: "Date",
|
||||
cancelsInvoiceLabel: "Cancels invoice",
|
||||
cancellationNote: (invoiceNumber, invoiceDate) =>
|
||||
`This cancellation invoice fully reverses invoice ${invoiceNumber} dated ${invoiceDate}. The amount will be refunded via the original payment method.`,
|
||||
},
|
||||
addressesPage: {
|
||||
title: "My Addresses",
|
||||
|
||||
@@ -1169,6 +1169,11 @@ export interface Dictionary {
|
||||
thankYou: string;
|
||||
pageLabel: (page: number, total: number) => string;
|
||||
legalNote: string;
|
||||
cancellationTitle: string;
|
||||
cancellationNumberLabel: string;
|
||||
cancellationDateLabel: string;
|
||||
cancelsInvoiceLabel: string;
|
||||
cancellationNote: (invoiceNumber: string, invoiceDate: string) => string;
|
||||
};
|
||||
addressesPage: {
|
||||
title: string;
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
import { describe, test, expect } from "vitest";
|
||||
import { amount, buildCiiXml, escapeXml, type EInvoiceInput } from "./cii";
|
||||
import { vatBreakdown } from "@/lib/vat";
|
||||
|
||||
function input(overrides: Partial<EInvoiceInput> = {}): EInvoiceInput {
|
||||
const items = [
|
||||
{ name: "Waldhonig 500 g", quantity: 3, totalCents: 2697, vatRatePercent: 7 },
|
||||
{ name: "Bienenwachskerze", quantity: 1, totalCents: 1290, vatRatePercent: 19 },
|
||||
{ name: "Blütenhonig 250 g", quantity: 2, totalCents: 1198, vatRatePercent: 7 },
|
||||
];
|
||||
return {
|
||||
kind: "invoice",
|
||||
number: "RE-2026-0042",
|
||||
issuedAt: new Date(2026, 9, 12, 10, 0),
|
||||
currency: "EUR",
|
||||
orderNumber: "WH-2026-0042",
|
||||
seller: {
|
||||
name: "Wabenhain",
|
||||
street: "Musterweg 1",
|
||||
zip: "12345",
|
||||
city: "Musterstadt",
|
||||
countryCode: "DE",
|
||||
vatId: "DE123456789",
|
||||
email: "[email protected]",
|
||||
},
|
||||
buyer: { name: "Erika Musterfrau", street: "Musterstraße 1", zip: "80331", city: "München", countryCode: "DE" },
|
||||
items,
|
||||
shippingCents: 490,
|
||||
discountCents: 300,
|
||||
discountCode: "SOMMER",
|
||||
totalCents: 2697 + 1290 + 1198 + 490 - 300,
|
||||
paymentNote: "Bereits bezahlt (Online-Zahlung über Stripe).",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function values(xml: string, element: string): string[] {
|
||||
return [...xml.matchAll(new RegExp(`<${element}[^>]*>([^<]*)</${element}>`, "g"))].map((match) => match[1]);
|
||||
}
|
||||
|
||||
function cents(value: string): number {
|
||||
return Math.round(Number(value) * 100);
|
||||
}
|
||||
|
||||
function single(xml: string, element: string): number {
|
||||
const found = values(xml, element);
|
||||
expect(found).toHaveLength(1);
|
||||
return cents(found[0]);
|
||||
}
|
||||
|
||||
function headerLineTotal(xml: string): number {
|
||||
const match = /<ram:SpecifiedTradeSettlementHeaderMonetarySummation><ram:LineTotalAmount>([^<]*)</.exec(xml);
|
||||
if (!match) throw new Error("BT-106 fehlt");
|
||||
return cents(match[1]);
|
||||
}
|
||||
|
||||
function ok(data: EInvoiceInput): string {
|
||||
const result = buildCiiXml(data);
|
||||
if (!result.ok) throw new Error(result.reason);
|
||||
return result.xml;
|
||||
}
|
||||
|
||||
describe("buildCiiXml", () => {
|
||||
test("Summen stimmen mit der Steueraufstellung des PDFs auf den Cent ueberein", () => {
|
||||
// Arrange
|
||||
const data = input();
|
||||
const rated = data.items.map((item) => ({ totalCents: item.totalCents, vatRatePercent: item.vatRatePercent ?? 0 }));
|
||||
const expected = vatBreakdown({ items: rated, shippingCents: 490, discountCents: 300 });
|
||||
|
||||
// Act
|
||||
const xml = ok(data);
|
||||
|
||||
// Assert
|
||||
expect(values(xml, "ram:BasisAmount").map(cents)).toEqual(expected.map((line) => line.netCents));
|
||||
expect(values(xml, "ram:CalculatedAmount").map(cents)).toEqual(expected.map((line) => line.vatCents));
|
||||
const basis = single(xml, "ram:TaxBasisTotalAmount");
|
||||
const tax = single(xml, "ram:TaxTotalAmount");
|
||||
expect(headerLineTotal(xml) - single(xml, "ram:AllowanceTotalAmount") + single(xml, "ram:ChargeTotalAmount")).toBe(basis);
|
||||
expect(basis + tax).toBe(data.totalCents);
|
||||
expect(single(xml, "ram:GrandTotalAmount")).toBe(data.totalCents);
|
||||
expect(single(xml, "ram:TotalPrepaidAmount")).toBe(data.totalCents);
|
||||
expect(single(xml, "ram:DuePayableAmount")).toBe(0);
|
||||
});
|
||||
|
||||
test("Positionen ergeben zusammen genau die Positionssumme (BT-106)", () => {
|
||||
// Act
|
||||
const xml = ok(input());
|
||||
|
||||
// Assert
|
||||
const lineAmounts = [
|
||||
...xml.matchAll(/<ram:SpecifiedTradeSettlementLineMonetarySummation><ram:LineTotalAmount>([^<]*)</g),
|
||||
].map((match) => cents(match[1]));
|
||||
expect(lineAmounts).toHaveLength(3);
|
||||
expect(lineAmounts.reduce((sum, value) => sum + value, 0)).toBe(headerLineTotal(xml));
|
||||
expect(values(xml, "ram:BilledQuantity")).toEqual(["3", "2", "1"]);
|
||||
});
|
||||
|
||||
test("Kopfdaten: Profil EN 16931, Typ 380, Datum, Verkaeufer mit USt-IdNr.", () => {
|
||||
// Act
|
||||
const xml = ok(input());
|
||||
|
||||
// Assert
|
||||
expect(xml).toContain("<ram:ID>urn:cen.eu:en16931:2017</ram:ID>");
|
||||
expect(xml).toContain("<ram:TypeCode>380</ram:TypeCode>");
|
||||
expect(xml).toContain('<udt:DateTimeString format="102">20261012</udt:DateTimeString>');
|
||||
expect(xml).toContain('<ram:ID schemeID="VA">DE123456789</ram:ID>');
|
||||
expect(xml).toContain("<ram:Reason>Rabatt (SOMMER)</ram:Reason>");
|
||||
expect(xml).toContain("<ram:Reason>Versand</ram:Reason>");
|
||||
});
|
||||
|
||||
test("Stornorechnung ist eine Gutschrift (381) mit Bezug, offen zur Erstattung", () => {
|
||||
// Arrange
|
||||
const data = input({
|
||||
kind: "creditNote",
|
||||
number: "ST-2026-0001",
|
||||
precedingInvoice: { number: "RE-2026-0042", issuedAt: new Date(2026, 9, 1, 12, 0) },
|
||||
paymentNote: "Der Betrag wird über die ursprüngliche Zahlungsart erstattet.",
|
||||
});
|
||||
|
||||
// Act
|
||||
const xml = ok(data);
|
||||
|
||||
// Assert
|
||||
expect(xml).toContain("<ram:TypeCode>381</ram:TypeCode>");
|
||||
expect(xml).toMatch(
|
||||
/<ram:InvoiceReferencedDocument><ram:IssuerAssignedID>RE-2026-0042<\/ram:IssuerAssignedID><ram:FormattedIssueDateTime><qdt:DateTimeString format="102">20261001</
|
||||
);
|
||||
expect(single(xml, "ram:TotalPrepaidAmount")).toBe(0);
|
||||
expect(single(xml, "ram:DuePayableAmount")).toBe(data.totalCents);
|
||||
});
|
||||
|
||||
test("Kleinunternehmer: Kategorie E mit Befreiungsgrund", () => {
|
||||
// Arrange
|
||||
const items = [{ name: "Honig", quantity: 1, totalCents: 1000, vatRatePercent: 0 }];
|
||||
|
||||
// Act
|
||||
const xml = ok(input({ items, shippingCents: 0, discountCents: 0, totalCents: 1000 }));
|
||||
|
||||
// Assert
|
||||
expect(values(xml, "ram:CategoryCode").every((code) => code === "E")).toBe(true);
|
||||
expect(xml).toContain("§ 19 UStG");
|
||||
expect(single(xml, "ram:TaxTotalAmount")).toBe(0);
|
||||
});
|
||||
|
||||
test("ohne USt-IdNr. oder mit Position ohne Steuersatz keine E-Rechnung", () => {
|
||||
expect(buildCiiXml(input({ seller: { ...input().seller, vatId: " " } }))).toMatchObject({ ok: false });
|
||||
const items = [{ name: "Alt", quantity: 1, totalCents: 1000, vatRatePercent: null }];
|
||||
expect(buildCiiXml(input({ items, shippingCents: 0, discountCents: 0, totalCents: 1000 }))).toMatchObject({ ok: false });
|
||||
});
|
||||
|
||||
test("Sonderzeichen im Namen werden maskiert", () => {
|
||||
// Act
|
||||
const xml = ok(input({ buyer: { ...input().buyer, name: 'Müller & Söhne <GmbH> "Bio"' } }));
|
||||
|
||||
// Assert
|
||||
expect(xml).toContain("<ram:Name>Müller & Söhne <GmbH> "Bio"</ram:Name>");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Hilfsfunktionen", () => {
|
||||
test("amount formatiert Cent ohne Gleitkommafehler", () => {
|
||||
expect(amount(0)).toBe("0.00");
|
||||
expect(amount(5)).toBe("0.05");
|
||||
expect(amount(123456)).toBe("1234.56");
|
||||
expect(amount(-250)).toBe("-2.50");
|
||||
});
|
||||
|
||||
test("escapeXml maskiert alle fuenf Sonderzeichen", () => {
|
||||
expect(escapeXml(`<a href="x">'&'</a>`)).toBe("<a href="x">'&'</a>");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,317 @@
|
||||
import { distributeCents, splitGross, vatLinesWithShares } from "@/lib/vat";
|
||||
|
||||
/**
|
||||
* E-Rechnung nach EN 16931 als UN/CEFACT Cross Industry Invoice (CII D16B),
|
||||
* Profil "EN 16931" (ZUGFeRD 2.x COMFORT / Factur-X). Das XML wird in das
|
||||
* Rechnungs-PDF eingebettet (lib/einvoice/facturx-pdf.ts); PDF und XML
|
||||
* zusammen sind die hybride E-Rechnung, das XML ist der massgebliche Teil.
|
||||
*
|
||||
* Die Shop-Preise sind Bruttopreise, EN 16931 rechnet netto. Damit XML und PDF
|
||||
* auf den Cent dieselben Summen tragen, kommen Netto und Steuer je Satz
|
||||
* unveraendert aus der Aufstellung des PDFs (lib/vat.ts); nur die Aufteilung
|
||||
* auf die Positionen wird hier gerechnet:
|
||||
* Netto der Waren je Satz = Netto des Satzes - Versand netto + Rabatt netto,
|
||||
* centgenau verteilt nach den Bruttobetraegen der Positionen. Versand und
|
||||
* Rabatt stehen als Zu- und Abschlag auf Belegebene, je Satz.
|
||||
*
|
||||
* Eine Stornorechnung ist eine Gutschrift (Typ 381) mit positiven Betraegen
|
||||
* und Verweis auf die aufgehobene Rechnung (BT-25/BT-26).
|
||||
*/
|
||||
|
||||
export const EN16931_GUIDELINE_ID = "urn:cen.eu:en16931:2017";
|
||||
export const FACTURX_FILENAME = "factur-x.xml";
|
||||
|
||||
const NS = {
|
||||
rsm: "urn:un:unece:uncefact:data:standard:CrossIndustryInvoice:100",
|
||||
ram: "urn:un:unece:uncefact:data:standard:ReusableAggregateBusinessInformationEntity:100",
|
||||
qdt: "urn:un:unece:uncefact:data:standard:QualifiedDataType:100",
|
||||
udt: "urn:un:unece:uncefact:data:standard:UnqualifiedDataType:100",
|
||||
} as const;
|
||||
|
||||
const TYPE_CODE = { invoice: "380", creditNote: "381" } as const;
|
||||
/** UN/ECE Rec. 20: Stueck. */
|
||||
const UNIT_PIECE = "H87";
|
||||
const SMALL_BUSINESS_REASON = "Kein Ausweis von Umsatzsteuer, da Kleinunternehmer gemäß § 19 UStG";
|
||||
|
||||
export type EInvoiceKind = keyof typeof TYPE_CODE;
|
||||
|
||||
export interface EInvoiceParty {
|
||||
name: string;
|
||||
street: string;
|
||||
zip: string;
|
||||
city: string;
|
||||
/** ISO 3166-1 alpha-2, z. B. "DE". */
|
||||
countryCode: string;
|
||||
}
|
||||
|
||||
export interface EInvoiceSeller extends EInvoiceParty {
|
||||
vatId: string;
|
||||
email?: string | null;
|
||||
}
|
||||
|
||||
export interface EInvoiceItem {
|
||||
name: string;
|
||||
quantity: number;
|
||||
/** Bruttobetrag der Position in Cent. */
|
||||
totalCents: number;
|
||||
vatRatePercent: number | null;
|
||||
}
|
||||
|
||||
export interface EInvoiceInput {
|
||||
kind: EInvoiceKind;
|
||||
number: string;
|
||||
issuedAt: Date;
|
||||
currency: string;
|
||||
orderNumber: string;
|
||||
seller: EInvoiceSeller;
|
||||
buyer: EInvoiceParty;
|
||||
items: EInvoiceItem[];
|
||||
/** Brutto, wie in der Bestellung. */
|
||||
shippingCents: number;
|
||||
discountCents: number;
|
||||
discountCode?: string | null;
|
||||
/** Rechnungsbetrag brutto; muss der Summe der Steueraufstellung entsprechen. */
|
||||
totalCents: number;
|
||||
/** Nur bei der Stornorechnung: die aufgehobene Rechnung. */
|
||||
precedingInvoice?: { number: string; issuedAt: Date };
|
||||
/** Zahlungsbedingungen (BT-20), z. B. "Bezahlt über Stripe". */
|
||||
paymentNote: string;
|
||||
}
|
||||
|
||||
export type EInvoiceResult = { ok: true; xml: string } | { ok: false; reason: string };
|
||||
|
||||
export function escapeXml(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
/** Cent als Dezimalbetrag mit zwei Stellen, ohne Gleitkomma. */
|
||||
export function amount(cents: number): string {
|
||||
const sign = cents < 0 ? "-" : "";
|
||||
const abs = Math.abs(cents);
|
||||
return `${sign}${Math.floor(abs / 100)}.${String(abs % 100).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
/** Nettopreis je Stueck mit vier Nachkommastellen (Zehntausendstel Euro). */
|
||||
function unitPrice(lineNetCents: number, quantity: number): string {
|
||||
const tenThousandths = Math.round((lineNetCents * 100) / quantity);
|
||||
return `${Math.floor(tenThousandths / 10000)}.${String(tenThousandths % 10000).padStart(4, "0")}`;
|
||||
}
|
||||
|
||||
/** Datum im Format 102 (JJJJMMTT), Kalendertag wie auf dem PDF (lib/format.ts). */
|
||||
function date102(value: Date): string {
|
||||
const month = String(value.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(value.getDate()).padStart(2, "0");
|
||||
return `${value.getFullYear()}${month}${day}`;
|
||||
}
|
||||
|
||||
function percent(value: number): string {
|
||||
return value.toFixed(2);
|
||||
}
|
||||
|
||||
function tag(name: string, content: string, attributes = ""): string {
|
||||
return `<${name}${attributes}>${content}</${name}>`;
|
||||
}
|
||||
|
||||
function text(name: string, value: string, attributes = ""): string {
|
||||
return tag(name, escapeXml(value), attributes);
|
||||
}
|
||||
|
||||
interface TaxCategory {
|
||||
code: "S" | "E";
|
||||
ratePercent: number;
|
||||
}
|
||||
|
||||
interface RateBlock {
|
||||
category: TaxCategory;
|
||||
netCents: number;
|
||||
vatCents: number;
|
||||
shippingNetCents: number;
|
||||
discountNetCents: number;
|
||||
lines: { item: EInvoiceItem; netCents: number }[];
|
||||
}
|
||||
|
||||
function categoryXml(category: TaxCategory): string {
|
||||
return (
|
||||
text("ram:TypeCode", "VAT") +
|
||||
text("ram:CategoryCode", category.code) +
|
||||
text("ram:RateApplicablePercent", percent(category.ratePercent))
|
||||
);
|
||||
}
|
||||
|
||||
function partyXml(party: EInvoiceParty, extra = ""): string {
|
||||
const address =
|
||||
text("ram:PostcodeCode", party.zip) +
|
||||
text("ram:LineOne", party.street) +
|
||||
text("ram:CityName", party.city) +
|
||||
text("ram:CountryID", party.countryCode);
|
||||
return text("ram:Name", party.name) + tag("ram:PostalTradeAddress", address) + extra;
|
||||
}
|
||||
|
||||
function lineXml(index: number, line: RateBlock["lines"][number], category: TaxCategory): string {
|
||||
return tag(
|
||||
"ram:IncludedSupplyChainTradeLineItem",
|
||||
tag("ram:AssociatedDocumentLineDocument", text("ram:LineID", String(index))) +
|
||||
tag("ram:SpecifiedTradeProduct", text("ram:Name", line.item.name)) +
|
||||
tag(
|
||||
"ram:SpecifiedLineTradeAgreement",
|
||||
tag("ram:NetPriceProductTradePrice", text("ram:ChargeAmount", unitPrice(line.netCents, line.item.quantity)))
|
||||
) +
|
||||
tag("ram:SpecifiedLineTradeDelivery", text("ram:BilledQuantity", String(line.item.quantity), ` unitCode="${UNIT_PIECE}"`)) +
|
||||
tag(
|
||||
"ram:SpecifiedLineTradeSettlement",
|
||||
tag("ram:ApplicableTradeTax", categoryXml(category)) +
|
||||
tag("ram:SpecifiedTradeSettlementLineMonetarySummation", text("ram:LineTotalAmount", amount(line.netCents)))
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function allowanceChargeXml(isCharge: boolean, netCents: number, reason: string, category: TaxCategory): string {
|
||||
return tag(
|
||||
"ram:SpecifiedTradeAllowanceCharge",
|
||||
tag("ram:ChargeIndicator", tag("udt:Indicator", isCharge ? "true" : "false")) +
|
||||
text("ram:ActualAmount", amount(netCents)) +
|
||||
text("ram:Reason", reason) +
|
||||
tag("ram:CategoryTradeTax", categoryXml(category))
|
||||
);
|
||||
}
|
||||
|
||||
function headerTaxXml(block: RateBlock): string {
|
||||
return tag(
|
||||
"ram:ApplicableTradeTax",
|
||||
text("ram:CalculatedAmount", amount(block.vatCents)) +
|
||||
text("ram:TypeCode", "VAT") +
|
||||
(block.category.code === "E" ? text("ram:ExemptionReason", SMALL_BUSINESS_REASON) : "") +
|
||||
text("ram:BasisAmount", amount(block.netCents)) +
|
||||
text("ram:CategoryCode", block.category.code) +
|
||||
text("ram:RateApplicablePercent", percent(block.category.ratePercent))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Steuerbloecke je Satz, oder die Begruendung, warum sich keine gueltige
|
||||
* E-Rechnung bilden laesst (Position ohne Satz, steuerfrei gemischt mit
|
||||
* steuerpflichtig, Summen passen nicht).
|
||||
*/
|
||||
function rateBlocks(input: EInvoiceInput): RateBlock[] | string {
|
||||
const lines = vatLinesWithShares(input);
|
||||
if (!lines || lines.length === 0) return "Positionen ohne Steuersatz";
|
||||
const exempt = lines.filter((line) => line.ratePercent === 0);
|
||||
if (exempt.length > 0 && exempt.length !== lines.length) {
|
||||
return "Steuerfreie und steuerpflichtige Positionen gemischt";
|
||||
}
|
||||
const gross = lines.reduce((sum, line) => sum + line.grossCents, 0);
|
||||
if (gross !== input.totalCents) return `Steueraufstellung ${gross} weicht vom Rechnungsbetrag ${input.totalCents} ab`;
|
||||
|
||||
return lines.map((line) => {
|
||||
const category: TaxCategory = { code: line.ratePercent > 0 ? "S" : "E", ratePercent: line.ratePercent };
|
||||
const shippingNetCents = splitGross(line.shippingCents, line.ratePercent).netCents;
|
||||
const discountNetCents = splitGross(line.discountCents, line.ratePercent).netCents;
|
||||
const items = input.items.filter((item) => item.vatRatePercent === line.ratePercent);
|
||||
const goodsNet = line.netCents - shippingNetCents + discountNetCents;
|
||||
const nets = distributeCents(goodsNet, items.map((item) => item.totalCents));
|
||||
return {
|
||||
category,
|
||||
netCents: line.netCents,
|
||||
vatCents: line.vatCents,
|
||||
shippingNetCents,
|
||||
discountNetCents,
|
||||
lines: items.map((item, index) => ({ item, netCents: nets[index] })),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function settlementXml(input: EInvoiceInput, blocks: RateBlock[]): string {
|
||||
const discountReason = input.discountCode ? `Rabatt (${input.discountCode})` : "Rabatt";
|
||||
const allowancesCharges = blocks
|
||||
.flatMap((block) => [
|
||||
block.shippingNetCents ? allowanceChargeXml(true, block.shippingNetCents, "Versand", block.category) : "",
|
||||
block.discountNetCents ? allowanceChargeXml(false, block.discountNetCents, discountReason, block.category) : "",
|
||||
])
|
||||
.join("");
|
||||
|
||||
const sum = (pick: (block: RateBlock) => number) => blocks.reduce((total, block) => total + pick(block), 0);
|
||||
const lineTotal = sum((block) => block.lines.reduce((total, line) => total + line.netCents, 0));
|
||||
const taxBasis = sum((block) => block.netCents);
|
||||
const taxTotal = sum((block) => block.vatCents);
|
||||
const grandTotal = taxBasis + taxTotal;
|
||||
// Rechnung: per Stripe bereits bezahlt. Stornorechnung: der Betrag geht an die Kundschaft zurueck.
|
||||
const prepaid = input.kind === "invoice" ? grandTotal : 0;
|
||||
|
||||
const reference = input.precedingInvoice
|
||||
? tag(
|
||||
"ram:InvoiceReferencedDocument",
|
||||
text("ram:IssuerAssignedID", input.precedingInvoice.number) +
|
||||
tag("ram:FormattedIssueDateTime", text("qdt:DateTimeString", date102(input.precedingInvoice.issuedAt), ' format="102"'))
|
||||
)
|
||||
: "";
|
||||
|
||||
return (
|
||||
text("ram:InvoiceCurrencyCode", input.currency) +
|
||||
blocks.map(headerTaxXml).join("") +
|
||||
allowancesCharges +
|
||||
tag("ram:SpecifiedTradePaymentTerms", text("ram:Description", input.paymentNote)) +
|
||||
tag(
|
||||
"ram:SpecifiedTradeSettlementHeaderMonetarySummation",
|
||||
text("ram:LineTotalAmount", amount(lineTotal)) +
|
||||
text("ram:ChargeTotalAmount", amount(sum((block) => block.shippingNetCents))) +
|
||||
text("ram:AllowanceTotalAmount", amount(sum((block) => block.discountNetCents))) +
|
||||
text("ram:TaxBasisTotalAmount", amount(taxBasis)) +
|
||||
text("ram:TaxTotalAmount", amount(taxTotal), ` currencyID="${escapeXml(input.currency)}"`) +
|
||||
text("ram:GrandTotalAmount", amount(grandTotal)) +
|
||||
text("ram:TotalPrepaidAmount", amount(prepaid)) +
|
||||
text("ram:DuePayableAmount", amount(grandTotal - prepaid))
|
||||
) +
|
||||
reference
|
||||
);
|
||||
}
|
||||
|
||||
export function buildCiiXml(input: EInvoiceInput): EInvoiceResult {
|
||||
if (!input.seller.vatId.trim()) return { ok: false, reason: "USt-IdNr. des Shops fehlt (Einstellungen > Firma)" };
|
||||
if (input.kind === "creditNote" && !input.precedingInvoice) return { ok: false, reason: "Stornorechnung ohne Bezug" };
|
||||
const blocks = rateBlocks(input);
|
||||
if (typeof blocks === "string") return { ok: false, reason: blocks };
|
||||
|
||||
let lineIndex = 0;
|
||||
const lineItems = blocks.flatMap((block) => block.lines.map((line) => lineXml(++lineIndex, line, block.category))).join("");
|
||||
const seller = input.seller;
|
||||
const sellerExtra =
|
||||
(seller.email ? tag("ram:URIUniversalCommunication", text("ram:URIID", seller.email, ' schemeID="EM"')) : "") +
|
||||
tag("ram:SpecifiedTaxRegistration", text("ram:ID", seller.vatId.trim(), ' schemeID="VA"'));
|
||||
const note = input.precedingInvoice
|
||||
? tag("ram:IncludedNote", text("ram:Content", `Stornorechnung zur Rechnung ${input.precedingInvoice.number}`))
|
||||
: "";
|
||||
|
||||
const body =
|
||||
tag(
|
||||
"rsm:ExchangedDocumentContext",
|
||||
tag("ram:GuidelineSpecifiedDocumentContextParameter", text("ram:ID", EN16931_GUIDELINE_ID))
|
||||
) +
|
||||
tag(
|
||||
"rsm:ExchangedDocument",
|
||||
text("ram:ID", input.number) +
|
||||
text("ram:TypeCode", TYPE_CODE[input.kind]) +
|
||||
tag("ram:IssueDateTime", text("udt:DateTimeString", date102(input.issuedAt), ' format="102"')) +
|
||||
note
|
||||
) +
|
||||
tag(
|
||||
"rsm:SupplyChainTradeTransaction",
|
||||
lineItems +
|
||||
tag(
|
||||
"ram:ApplicableHeaderTradeAgreement",
|
||||
tag("ram:SellerTradeParty", partyXml(seller, sellerExtra)) +
|
||||
tag("ram:BuyerTradeParty", partyXml(input.buyer)) +
|
||||
tag("ram:BuyerOrderReferencedDocument", text("ram:IssuerAssignedID", input.orderNumber))
|
||||
) +
|
||||
tag("ram:ApplicableHeaderTradeDelivery", "") +
|
||||
tag("ram:ApplicableHeaderTradeSettlement", settlementXml(input, blocks))
|
||||
);
|
||||
const namespaces = Object.entries(NS)
|
||||
.map(([prefix, uri]) => ` xmlns:${prefix}="${uri}"`)
|
||||
.join("");
|
||||
return { ok: true, xml: `<?xml version="1.0" encoding="UTF-8"?>\n${tag("rsm:CrossIndustryInvoice", body, namespaces)}` };
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { FACTURX_FILENAME } from "@/lib/einvoice/cii";
|
||||
|
||||
/**
|
||||
* PDF/A-3b mit eingebettetem Factur-X-XML (ZUGFeRD 2.x, Profil EN 16931).
|
||||
*
|
||||
* PDF/A verlangt eingebettete Schriften; die 14 Standardschriften von pdfkit
|
||||
* (Helvetica, Times) sind das nicht. Statt ihrer werden Liberation Sans und
|
||||
* Liberation Serif eingebettet (SIL Open Font License, assets/fonts), die
|
||||
* dieselben Laufweiten haben — das Layout der Rechnung bleibt unveraendert.
|
||||
* Sie werden unter den Namen der Standardschriften registriert, damit der
|
||||
* Zeichencode (font("Helvetica-Bold") usw.) gleich bleibt.
|
||||
*/
|
||||
|
||||
const FONT_DIR = path.join(/*turbopackIgnore: true*/ process.cwd(), "assets", "fonts");
|
||||
|
||||
const FONT_FILES = {
|
||||
Helvetica: "LiberationSans-Regular.ttf",
|
||||
"Helvetica-Bold": "LiberationSans-Bold.ttf",
|
||||
"Times-Roman": "LiberationSerif-Regular.ttf",
|
||||
"Times-Italic": "LiberationSerif-Italic.ttf",
|
||||
} as const;
|
||||
|
||||
type FontName = keyof typeof FONT_FILES;
|
||||
|
||||
let fontCache: Record<FontName, Buffer> | null = null;
|
||||
|
||||
function fonts(): Record<FontName, Buffer> {
|
||||
if (!fontCache) {
|
||||
const entries = Object.entries(FONT_FILES).map(([name, file]) => [name, readFileSync(path.join(FONT_DIR, file))]);
|
||||
fontCache = Object.fromEntries(entries) as Record<FontName, Buffer>;
|
||||
}
|
||||
return fontCache;
|
||||
}
|
||||
|
||||
/**
|
||||
* Optionen fuer new PDFDocument(): PDF/A-3b und eine eingebettete Grundschrift.
|
||||
* Ohne "font" laedt pdfkit beim Anlegen die Standard-Helvetica, die dann
|
||||
* nicht eingebettet im Dokument stuende.
|
||||
*/
|
||||
export function pdfaOptions(): PDFKit.PDFDocumentOptions {
|
||||
return { pdfVersion: "1.7", subset: "PDF/A-3b", font: path.join(FONT_DIR, FONT_FILES.Helvetica) };
|
||||
}
|
||||
|
||||
/** Registriert die eingebetteten Schriften unter den Namen der Standardschriften. */
|
||||
export function registerEmbeddedFonts(doc: PDFKit.PDFDocument): void {
|
||||
for (const [name, data] of Object.entries(fonts())) {
|
||||
doc.registerFont(name, data);
|
||||
}
|
||||
}
|
||||
|
||||
/** pdfkit schreibt Titel und Autor ungeprueft in die XMP-Metadaten; dort duerfen sie kein XML aufbrechen. */
|
||||
export function xmpSafe(value: string): string {
|
||||
return value.replace(/[<>&]/g, " ").replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
const FX_NAMESPACE = "urn:factur-x:pdfa:CrossIndustryDocument:invoice:1p0#";
|
||||
|
||||
function fxProperty(name: string, description: string): string {
|
||||
return `
|
||||
<rdf:li rdf:parseType="Resource">
|
||||
<pdfaProperty:name>${name}</pdfaProperty:name>
|
||||
<pdfaProperty:valueType>Text</pdfaProperty:valueType>
|
||||
<pdfaProperty:category>external</pdfaProperty:category>
|
||||
<pdfaProperty:description>${description}</pdfaProperty:description>
|
||||
</rdf:li>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bettet das XML als factur-x.xml ein (AFRelationship "Alternative", wie
|
||||
* ZUGFeRD es fuer Deutschland vorsieht) und traegt die Factur-X-Angaben samt
|
||||
* PDF/A-Erweiterungsschema in die XMP-Metadaten ein. Vor doc.end() aufrufen.
|
||||
*/
|
||||
export function embedFacturX(doc: PDFKit.PDFDocument, xml: string, issuedAt: Date): void {
|
||||
// "relationship" kennt pdfkit (AFRelationship), die Typdefinition noch nicht.
|
||||
const options: PDFKit.Mixins.PDFAttachmentOptions & { relationship: string } = {
|
||||
name: FACTURX_FILENAME,
|
||||
type: "text/xml",
|
||||
relationship: "Alternative",
|
||||
description: "Factur-X / ZUGFeRD invoice (EN 16931)",
|
||||
creationDate: issuedAt,
|
||||
modifiedDate: issuedAt,
|
||||
};
|
||||
doc.file(Buffer.from(xml, "utf8"), options);
|
||||
doc.appendXML(`
|
||||
<rdf:Description xmlns:fx="${FX_NAMESPACE}" rdf:about="">
|
||||
<fx:DocumentType>INVOICE</fx:DocumentType>
|
||||
<fx:DocumentFileName>${FACTURX_FILENAME}</fx:DocumentFileName>
|
||||
<fx:Version>1.0</fx:Version>
|
||||
<fx:ConformanceLevel>EN 16931</fx:ConformanceLevel>
|
||||
</rdf:Description>
|
||||
<rdf:Description xmlns:pdfaExtension="http://www.aiim.org/pdfa/ns/extension/" xmlns:pdfaSchema="http://www.aiim.org/pdfa/ns/schema#" xmlns:pdfaProperty="http://www.aiim.org/pdfa/ns/property#" rdf:about="">
|
||||
<pdfaExtension:schemas>
|
||||
<rdf:Bag>
|
||||
<rdf:li rdf:parseType="Resource">
|
||||
<pdfaSchema:schema>Factur-X PDFA Extension Schema</pdfaSchema:schema>
|
||||
<pdfaSchema:namespaceURI>${FX_NAMESPACE}</pdfaSchema:namespaceURI>
|
||||
<pdfaSchema:prefix>fx</pdfaSchema:prefix>
|
||||
<pdfaSchema:property>
|
||||
<rdf:Seq>${[
|
||||
fxProperty("DocumentFileName", "The name of the embedded XML document"),
|
||||
fxProperty("DocumentType", "The type of the hybrid document in capital letters, e.g. INVOICE or ORDER"),
|
||||
fxProperty("Version", "The actual version of the standard applying to the embedded XML document"),
|
||||
fxProperty("ConformanceLevel", "The conformance level of the embedded XML document"),
|
||||
].join("")}
|
||||
</rdf:Seq>
|
||||
</pdfaSchema:property>
|
||||
</rdf:li>
|
||||
</rdf:Bag>
|
||||
</pdfaExtension:schemas>
|
||||
</rdf:Description>
|
||||
`);
|
||||
}
|
||||
+4
-34
@@ -3,7 +3,7 @@ import type { Prisma } from "@/generated/prisma/client";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { generateInvoicePdf } from "@/lib/invoice-pdf";
|
||||
import { readStoredInvoicePdf } from "@/lib/invoice-storage";
|
||||
import { vatBreakdown, type VatBreakdownLine } from "@/lib/vat";
|
||||
import { vatLinesWithShares, type VatLineWithShares } from "@/lib/vat";
|
||||
import { isLocale, defaultLocale } from "@/i18n/config";
|
||||
|
||||
/**
|
||||
@@ -57,40 +57,10 @@ const invoiceInclude = {
|
||||
|
||||
type ErpInvoiceRecord = Prisma.InvoiceGetPayload<{ include: typeof invoiceInclude }>;
|
||||
|
||||
export interface ErpVatLine extends VatBreakdownLine {
|
||||
/** Darin enthaltener Versandanteil (brutto). */
|
||||
shippingCents: number;
|
||||
/** Davon abgezogener Rabattanteil (brutto). */
|
||||
discountCents: number;
|
||||
}
|
||||
export type ErpVatLine = VatLineWithShares;
|
||||
|
||||
/**
|
||||
* Umsatzsteuer je Satz wie auf der Rechnung (lib/vat.ts), dazu der Versand-
|
||||
* und der Rabattanteil je Satz. Beide Anteile ergeben sich aus derselben
|
||||
* Rechnung ohne den jeweils anderen Betrag, also ohne eigene Verteilungslogik.
|
||||
* null, wenn eine Position keinen Steuersatz hat (Bestellungen aus der Zeit
|
||||
* vor der Steuerausweisung).
|
||||
*/
|
||||
export function erpVatLines(order: {
|
||||
items: ReadonlyArray<{ totalCents: number; vatRatePercent: number | null }>;
|
||||
shippingCents: number;
|
||||
discountCents: number;
|
||||
}): ErpVatLine[] | null {
|
||||
const items: { totalCents: number; vatRatePercent: number }[] = [];
|
||||
for (const item of order.items) {
|
||||
if (item.vatRatePercent == null) return null;
|
||||
items.push({ totalCents: item.totalCents, vatRatePercent: item.vatRatePercent });
|
||||
}
|
||||
const full = vatBreakdown({ items, shippingCents: order.shippingCents, discountCents: order.discountCents });
|
||||
const plain = vatBreakdown({ items, shippingCents: 0, discountCents: 0 });
|
||||
const withShipping = vatBreakdown({ items, shippingCents: order.shippingCents, discountCents: 0 });
|
||||
const withDiscount = vatBreakdown({ items, shippingCents: 0, discountCents: order.discountCents });
|
||||
return full.map((line, index) => ({
|
||||
...line,
|
||||
shippingCents: withShipping[index].grossCents - plain[index].grossCents,
|
||||
discountCents: plain[index].grossCents - withDiscount[index].grossCents,
|
||||
}));
|
||||
}
|
||||
/** Umsatzsteuer je Satz mit Versand- und Rabattanteil, siehe lib/vat.ts. */
|
||||
export const erpVatLines = vatLinesWithShares;
|
||||
|
||||
/** Rechnung fuer ERPNext; bewusst ohne Telefonnummer, Postnummer und Notizen (nicht buchungsrelevant). */
|
||||
export function toErpInvoice(invoice: ErpInvoiceRecord) {
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
// @vitest-environment node
|
||||
//
|
||||
// Abweichend von der globalen jsdom-Umgebung (vitest.config.ts): pdfkit
|
||||
// erkennt die eingebetteten Schriften an "instanceof Uint8Array", und unter
|
||||
// jsdom stammt der Buffer aus einem anderen JS-Realm — die Pruefung schluege
|
||||
// fehl ("Not a supported font format"). Im Server laeuft ohnehin Node.
|
||||
import { inflateSync } from "node:zlib";
|
||||
import { describe, test, expect, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/company-info", () => ({
|
||||
@@ -78,4 +85,53 @@ describe("generateInvoicePdf", () => {
|
||||
expect(pages).toBeGreaterThanOrEqual(2);
|
||||
expect(pages).toBeLessThanOrEqual(3);
|
||||
});
|
||||
|
||||
test("ist eine E-Rechnung: PDF/A-3b mit eingebettetem factur-x.xml (EN 16931)", async () => {
|
||||
// Act
|
||||
const pdf = await generateInvoicePdf(input([item(1, 7), item(2, 19)]));
|
||||
|
||||
// Assert
|
||||
const raw = pdf.toString("latin1");
|
||||
expect(raw).toContain("<pdfaid:part>3</pdfaid:part>");
|
||||
expect(raw).toContain("<fx:ConformanceLevel>EN 16931</fx:ConformanceLevel>");
|
||||
expect(raw).toMatch(/\/AFRelationship \/Alternative/);
|
||||
expect(raw).toContain("/OutputIntents");
|
||||
const xml = embeddedXml(pdf);
|
||||
expect(xml).toContain("<ram:ID>RE-TEST-1</ram:ID>");
|
||||
expect(xml).toContain("<ram:TypeCode>380</ram:TypeCode>");
|
||||
expect(xml).toContain("<ram:GrandTotalAmount>24.90</ram:GrandTotalAmount>");
|
||||
});
|
||||
|
||||
test("bettet alle Schriften ein (keine Standardschrift ohne Einbettung)", async () => {
|
||||
// Act
|
||||
const raw = (await generateInvoicePdf(input([item(1, 7)]))).toString("latin1");
|
||||
|
||||
// Assert: eingebettete Teilschriften tragen ein Praefix wie "ABCDEF+".
|
||||
const baseFonts = [...raw.matchAll(/\/BaseFont \/([^\s/>]+)/g)].map((match) => match[1]);
|
||||
expect(baseFonts.length).toBeGreaterThan(0);
|
||||
expect(baseFonts.every((name) => /^[A-Z]{6}\+Liberation/.test(name))).toBe(true);
|
||||
expect(raw).toContain("/FontFile2");
|
||||
});
|
||||
|
||||
test("Stornorechnung: Gutschrift mit Bezug auf die Rechnung", async () => {
|
||||
// Act
|
||||
const pdf = await generateInvoicePdf({
|
||||
...input([item(1, 7)]),
|
||||
cancellation: { number: "ST-TEST-1", issuedAt: new Date("2026-10-05T00:00:00Z") },
|
||||
});
|
||||
|
||||
// Assert
|
||||
const xml = embeddedXml(pdf);
|
||||
expect(xml).toContain("<ram:ID>ST-TEST-1</ram:ID>");
|
||||
expect(xml).toContain("<ram:TypeCode>381</ram:TypeCode>");
|
||||
expect(xml).toContain("<ram:IssuerAssignedID>RE-TEST-1</ram:IssuerAssignedID>");
|
||||
expect(pdf.toString("latin1")).toContain("Stornorechnung ST-TEST-1");
|
||||
});
|
||||
});
|
||||
|
||||
/** Das eingebettete factur-x.xml (Stream des EmbeddedFile, deflate-komprimiert). */
|
||||
function embeddedXml(pdf: Buffer): string {
|
||||
const match = /\/Type \/EmbeddedFile[\s\S]*?stream\r?\n([\s\S]*?)\r?\nendstream/.exec(pdf.toString("latin1"));
|
||||
if (!match) throw new Error("kein eingebettetes XML");
|
||||
return inflateSync(Buffer.from(match[1], "latin1")).toString("utf8");
|
||||
}
|
||||
+113
-11
@@ -5,6 +5,8 @@ import { getDictionary } from "@/i18n/get-dictionary";
|
||||
import { invoiceVatBreakdown, type VatBreakdownLine } from "@/lib/vat";
|
||||
import { groupInvoiceItemsByVatRate, type InvoiceItemGroup } from "@/lib/invoice-groups";
|
||||
import { INVOICE_COLORS as C, loadInvoiceLogo, drawInvoiceLogo, type InvoiceLogo } from "@/lib/invoice-pdf-brand";
|
||||
import { buildCiiXml, type EInvoiceResult } from "@/lib/einvoice/cii";
|
||||
import { embedFacturX, pdfaOptions, registerEmbeddedFonts, xmpSafe } from "@/lib/einvoice/facturx-pdf";
|
||||
import type { Dictionary } from "@/i18n/dictionary-type";
|
||||
import type { Locale } from "@/i18n/config";
|
||||
|
||||
@@ -30,6 +32,8 @@ interface InvoicePdfInput {
|
||||
street: string;
|
||||
zip: string;
|
||||
city: string;
|
||||
/** ISO-Laendercode der Rechnungsanschrift; fehlt er, gilt Deutschland. */
|
||||
country?: string;
|
||||
currency: string;
|
||||
subtotalCents: number;
|
||||
shippingCents: number;
|
||||
@@ -37,6 +41,11 @@ interface InvoicePdfInput {
|
||||
discountCode?: { code: string } | null;
|
||||
items: InvoiceItem[];
|
||||
};
|
||||
/**
|
||||
* Gesetzt bei der Stornorechnung: sie hebt die Rechnung invoiceNumber vom
|
||||
* issuedAt auf und traegt alle Betraege mit umgekehrtem Vorzeichen.
|
||||
*/
|
||||
cancellation?: { number: string; issuedAt: Date };
|
||||
}
|
||||
|
||||
type Company = Awaited<ReturnType<typeof getCompanyInfo>>;
|
||||
@@ -44,9 +53,16 @@ type Company = Awaited<ReturnType<typeof getCompanyInfo>>;
|
||||
interface Ctx {
|
||||
doc: PDFKit.PDFDocument;
|
||||
t: Dictionary["invoicePage"];
|
||||
/** Betrag mit dem Vorzeichen des Belegs (Stornorechnung negativ). */
|
||||
money: (cents: number) => string;
|
||||
/** Betrag ohne Vorzeichenumkehr. */
|
||||
plain: (cents: number) => string;
|
||||
isCancellation: boolean;
|
||||
}
|
||||
|
||||
/** Sitz des Shops; die Firmenanschrift in den Einstellungen hat kein Land. */
|
||||
const SELLER_COUNTRY_CODE = "DE";
|
||||
|
||||
// A4 in Punkt. Ab FOOTER_TOP beginnt die Fusszeile; was oberhalb von
|
||||
// CONTENT_BOTTOM nicht mehr hinpasst, wandert auf die naechste Seite.
|
||||
const PAGE_WIDTH = 595.28;
|
||||
@@ -84,12 +100,17 @@ const COL = {
|
||||
export async function generateInvoicePdf(input: InvoicePdfInput): Promise<Buffer> {
|
||||
const t = getDictionary(input.locale).invoicePage;
|
||||
const [company, logo] = await Promise.all([getCompanyInfo(), loadInvoiceLogo()]);
|
||||
const title = input.cancellation
|
||||
? `${t.cancellationTitle} ${input.cancellation.number}`
|
||||
: `${t.documentTitle} ${input.invoiceNumber}`;
|
||||
const doc = new PDFDocument({
|
||||
...pdfaOptions(),
|
||||
margin: MARGIN,
|
||||
size: "A4",
|
||||
bufferPages: true,
|
||||
info: { Title: `${t.documentTitle} ${input.invoiceNumber}`, Author: company.name },
|
||||
info: { Title: xmpSafe(title), Author: xmpSafe(company.name) },
|
||||
});
|
||||
registerEmbeddedFonts(doc);
|
||||
const chunks: Buffer[] = [];
|
||||
doc.on("data", (chunk: Buffer) => chunks.push(chunk));
|
||||
const done = new Promise<Buffer>((resolve, reject) => {
|
||||
@@ -97,7 +118,9 @@ export async function generateInvoicePdf(input: InvoicePdfInput): Promise<Buffer
|
||||
doc.on("error", reject);
|
||||
});
|
||||
|
||||
const ctx: Ctx = { doc, t, money: (cents) => formatPrice(cents, input.order.currency) };
|
||||
const sign = input.cancellation ? -1 : 1;
|
||||
const plain = (cents: number) => formatPrice(cents, input.order.currency);
|
||||
const ctx: Ctx = { doc, t, money: (cents) => plain(sign * cents), plain, isCancellation: Boolean(input.cancellation) };
|
||||
const breakdown = invoiceVatBreakdown(input.order);
|
||||
|
||||
drawHeader(ctx, company, logo);
|
||||
@@ -106,13 +129,65 @@ export async function generateInvoicePdf(input: InvoicePdfInput): Promise<Buffer
|
||||
let y = drawTableHeader(ctx, Math.max(addressBottom, metaBottom) + 30);
|
||||
y = drawItems(ctx, groupInvoiceItemsByVatRate(input.order.items), y);
|
||||
y = drawSummary(ctx, input, breakdown, y + 22);
|
||||
drawClosing(ctx, breakdown, y + 26);
|
||||
drawClosing(ctx, input, breakdown, y + 26);
|
||||
drawFooters(ctx, company);
|
||||
|
||||
const einvoice = eInvoiceXml(input, company);
|
||||
if (einvoice.ok) {
|
||||
embedFacturX(doc, einvoice.xml, input.cancellation?.issuedAt ?? input.issuedAt);
|
||||
} else {
|
||||
console.warn(`[invoice-pdf] ${input.cancellation?.number ?? input.invoiceNumber} ohne E-Rechnung (nur PDF): ${einvoice.reason}`);
|
||||
}
|
||||
|
||||
doc.end();
|
||||
return done;
|
||||
}
|
||||
|
||||
/**
|
||||
* Das XML der E-Rechnung (EN 16931) aus denselben Daten wie das PDF. Die
|
||||
* Stornorechnung ist eine Gutschrift mit Bezug auf die aufgehobene Rechnung.
|
||||
*/
|
||||
function eInvoiceXml(input: InvoicePdfInput, company: Company): EInvoiceResult {
|
||||
const { order, cancellation } = input;
|
||||
return buildCiiXml({
|
||||
kind: cancellation ? "creditNote" : "invoice",
|
||||
number: cancellation?.number ?? input.invoiceNumber,
|
||||
issuedAt: cancellation?.issuedAt ?? input.issuedAt,
|
||||
currency: order.currency,
|
||||
orderNumber: order.orderNumber,
|
||||
seller: {
|
||||
name: company.name,
|
||||
street: company.address.street,
|
||||
zip: company.address.zip,
|
||||
city: company.address.city,
|
||||
countryCode: SELLER_COUNTRY_CODE,
|
||||
vatId: company.vatId,
|
||||
email: company.email,
|
||||
},
|
||||
buyer: {
|
||||
name: `${order.firstName} ${order.lastName}`.trim(),
|
||||
street: order.street,
|
||||
zip: order.zip,
|
||||
city: order.city,
|
||||
countryCode: (order.country || SELLER_COUNTRY_CODE).toUpperCase(),
|
||||
},
|
||||
items: order.items.map((item) => ({
|
||||
name: item.productName,
|
||||
quantity: item.quantity,
|
||||
totalCents: item.totalCents,
|
||||
vatRatePercent: item.vatRatePercent,
|
||||
})),
|
||||
shippingCents: order.shippingCents,
|
||||
discountCents: order.discountCents,
|
||||
discountCode: order.discountCode?.code ?? null,
|
||||
totalCents: input.totalCents,
|
||||
precedingInvoice: cancellation ? { number: input.invoiceNumber, issuedAt: input.issuedAt } : undefined,
|
||||
paymentNote: cancellation
|
||||
? "Der Betrag wird über die ursprüngliche Zahlungsart erstattet."
|
||||
: "Bereits bezahlt (Online-Zahlung über Stripe).",
|
||||
});
|
||||
}
|
||||
|
||||
function label(ctx: Ctx, text: string, x: number, y: number): void {
|
||||
ctx.doc
|
||||
.font("Helvetica-Bold")
|
||||
@@ -139,11 +214,19 @@ function drawHeader(ctx: Ctx, company: Company, logo: InvoiceLogo | null): void
|
||||
doc.font("Helvetica").fontSize(8.5).fillColor(C.honeyDark).text(company.claim, textX, 74, { width: 240, lineBreak: false });
|
||||
}
|
||||
|
||||
// "STORNORECHNUNG" ist laenger als "RECHNUNG": die Schrift wird so weit
|
||||
// verkleinert, dass der Titel in einer Zeile rechts oben bleibt.
|
||||
const title = (ctx.isCancellation ? t.cancellationTitle : t.documentTitle).toUpperCase();
|
||||
const titleX = 300;
|
||||
const titleOptions = { characterSpacing: 2 };
|
||||
let titleSize = 26;
|
||||
doc.font("Times-Roman").fontSize(titleSize);
|
||||
while (titleSize > 14 && doc.widthOfString(title, titleOptions) > RIGHT - titleX) {
|
||||
doc.fontSize(--titleSize);
|
||||
}
|
||||
doc
|
||||
.font("Times-Roman")
|
||||
.fontSize(26)
|
||||
.fillColor(C.forest)
|
||||
.text(t.documentTitle.toUpperCase(), 360, 48, { width: RIGHT - 360, align: "right", characterSpacing: 2, lineBreak: false });
|
||||
.text(title, titleX, 48 + (26 - titleSize) / 2, { ...titleOptions, width: RIGHT - titleX, align: "right", lineBreak: false });
|
||||
hairline(ctx, MARGIN, RIGHT, 112, C.honey);
|
||||
}
|
||||
|
||||
@@ -165,9 +248,18 @@ function drawRecipient(ctx: Ctx, input: InvoicePdfInput, company: Company): numb
|
||||
/** Rechnungsdaten rechts oben, inklusive USt-IdNr. (§ 14 Abs. 4 Nr. 2 UStG). */
|
||||
function drawMetaBox(ctx: Ctx, input: InvoicePdfInput, company: Company): number {
|
||||
const { doc, t } = ctx;
|
||||
const documentRows: [string, string][] = input.cancellation
|
||||
? [
|
||||
[t.cancellationNumberLabel, input.cancellation.number],
|
||||
[t.cancellationDateLabel, formatDate(input.cancellation.issuedAt)],
|
||||
[t.cancelsInvoiceLabel, input.invoiceNumber],
|
||||
]
|
||||
: [
|
||||
[t.invoiceNumberLabel, input.invoiceNumber],
|
||||
[t.invoiceDateLabel, formatDate(input.issuedAt)],
|
||||
];
|
||||
const rows: [string, string][] = [
|
||||
[t.invoiceNumberLabel, input.invoiceNumber],
|
||||
[t.invoiceDateLabel, formatDate(input.issuedAt)],
|
||||
...documentRows,
|
||||
[t.orderNumberLabel, input.order.orderNumber],
|
||||
...(company.vatId ? ([[t.vatIdLabel, company.vatId]] as [string, string][]) : []),
|
||||
];
|
||||
@@ -287,14 +379,16 @@ function drawSummary(ctx: Ctx, input: InvoicePdfInput, breakdown: VatBreakdownLi
|
||||
}
|
||||
|
||||
function drawTotals(ctx: Ctx, input: InvoicePdfInput, top: number): number {
|
||||
const { doc, t, money } = ctx;
|
||||
const { doc, t, money, plain } = ctx;
|
||||
const { order } = input;
|
||||
const x = 330;
|
||||
const discountName = order.discountCode ? `${t.discountLabel} (${order.discountCode.code})` : t.discountLabel;
|
||||
const rows: [string, string][] = [
|
||||
[t.subtotalLabel, money(order.subtotalCents)],
|
||||
[t.shippingLabel, order.shippingCents === 0 ? t.freeShipping : money(order.shippingCents)],
|
||||
...(order.discountCents > 0 ? ([[discountName, `– ${money(order.discountCents)}`]] as [string, string][]) : []),
|
||||
...(order.discountCents > 0
|
||||
? ([[discountName, `${ctx.isCancellation ? "+" : "–"} ${plain(order.discountCents)}`]] as [string, string][])
|
||||
: []),
|
||||
];
|
||||
|
||||
let y = top;
|
||||
@@ -371,12 +465,20 @@ function drawTaxSummary(ctx: Ctx, breakdown: VatBreakdownLine[], top: number): n
|
||||
return y;
|
||||
}
|
||||
|
||||
function drawClosing(ctx: Ctx, breakdown: VatBreakdownLine[], startY: number): void {
|
||||
function drawClosing(ctx: Ctx, input: InvoicePdfInput, breakdown: VatBreakdownLine[], startY: number): void {
|
||||
const { doc, t } = ctx;
|
||||
const y = ensureSpace(ctx, startY, 40, false);
|
||||
if (breakdown.some((line) => line.ratePercent > 0)) {
|
||||
doc.font("Helvetica").fontSize(8).fillColor(C.sage).text(t.pricesIncludeVat, MARGIN, y, { width: CONTENT_WIDTH });
|
||||
}
|
||||
if (input.cancellation) {
|
||||
doc
|
||||
.font("Helvetica")
|
||||
.fontSize(9.5)
|
||||
.fillColor(C.ink)
|
||||
.text(t.cancellationNote(input.invoiceNumber, formatDate(input.issuedAt)), MARGIN, y + 16, { width: CONTENT_WIDTH });
|
||||
return;
|
||||
}
|
||||
doc.font("Times-Italic").fontSize(13).fillColor(C.honeyDark).text(t.thankYou, MARGIN, y + 16, { width: CONTENT_WIDTH });
|
||||
}
|
||||
|
||||
|
||||
@@ -252,3 +252,46 @@ export function invoiceVatBreakdown(order: {
|
||||
discountCents: order.discountCents,
|
||||
});
|
||||
}
|
||||
|
||||
export interface VatLineWithShares extends VatBreakdownLine {
|
||||
/** Darin enthaltener Versandanteil (brutto). */
|
||||
shippingCents: number;
|
||||
/** Davon abgezogener Rabattanteil (brutto). */
|
||||
discountCents: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Umsatzsteuer je Satz wie auf der Rechnung, dazu der Versand- und der
|
||||
* Rabattanteil je Satz. Beide Anteile ergeben sich aus derselben Rechnung
|
||||
* ohne den jeweils anderen Betrag, also ohne eigene Verteilungslogik — so
|
||||
* stimmen sie mit der Aufstellung auf dem PDF auf den Cent ueberein.
|
||||
* Gebraucht vom ERP-Export und von der E-Rechnung (lib/einvoice).
|
||||
*
|
||||
* null, wenn eine Position keinen Steuersatz hat (Bestellungen aus der Zeit
|
||||
* vor der Steuerausweisung).
|
||||
*/
|
||||
export function vatLinesWithShares(order: {
|
||||
items: ReadonlyArray<{ totalCents: number; vatRatePercent: number | null }>;
|
||||
shippingCents: number;
|
||||
discountCents: number;
|
||||
}): VatLineWithShares[] | null {
|
||||
const items: { totalCents: number; vatRatePercent: number }[] = [];
|
||||
for (const item of order.items) {
|
||||
if (item.vatRatePercent == null) return null;
|
||||
items.push({ totalCents: item.totalCents, vatRatePercent: item.vatRatePercent });
|
||||
}
|
||||
const full = vatBreakdown({ items, shippingCents: order.shippingCents, discountCents: order.discountCents });
|
||||
const plain = vatBreakdown({ items, shippingCents: 0, discountCents: 0 });
|
||||
const withShipping = vatBreakdown({ items, shippingCents: order.shippingCents, discountCents: 0 });
|
||||
const withDiscount = vatBreakdown({ items, shippingCents: 0, discountCents: order.discountCents });
|
||||
return full.map((line, index) => ({
|
||||
...line,
|
||||
shippingCents: withShipping[index].grossCents - plain[index].grossCents,
|
||||
discountCents: plain[index].grossCents - withDiscount[index].grossCents,
|
||||
}));
|
||||
}
|
||||
|
||||
/** Verteilt ganze Cents im Verhaeltnis der Gewichte (Hare-Niemeyer, siehe distributeProportionally). */
|
||||
export function distributeCents(amountCents: number, weights: readonly number[]): number[] {
|
||||
return distributeProportionally(amountCents, weights);
|
||||
}
|
||||
Reference in new issue
Block a user