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:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-12 05:32:44 +02:00
1 parent f28e5e9d84
commit 508ff24aa7
16 files changed
+940 -44

No files matched your search

+4
View File
@@ -67,6 +67,7 @@ COPY --from=builder --chown=nextjs:nodejs /app/node_modules ./node_modules
COPY --from=builder --chown=nextjs:nodejs /app/src ./src COPY --from=builder --chown=nextjs:nodejs /app/src ./src
COPY --from=builder --chown=nextjs:nodejs /app/scripts ./scripts COPY --from=builder --chown=nextjs:nodejs /app/scripts ./scripts
COPY --from=builder --chown=nextjs:nodejs /app/prisma ./prisma COPY --from=builder --chown=nextjs:nodejs /app/prisma ./prisma
COPY --from=builder --chown=nextjs:nodejs /app/assets ./assets
COPY --from=builder --chown=nextjs:nodejs /app/prisma.config.ts /app/package.json /app/tsconfig.json ./ COPY --from=builder --chown=nextjs:nodejs /app/prisma.config.ts /app/package.json /app/tsconfig.json ./
ENV PATH="/app/node_modules/.bin:${PATH}" ENV PATH="/app/node_modules/.bin:${PATH}"
USER 1001 USER 1001
@@ -87,6 +88,9 @@ RUN addgroup --system --gid 1001 nodejs \
&& adduser --system --uid 1001 nextjs && adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public COPY --from=builder /app/public ./public
# Eingebettete Schriften der Rechnungs-PDFs (PDF/A-3, lib/einvoice/facturx-pdf.ts
# liest sie zur Laufzeit aus process.cwd()/assets/fonts).
COPY --from=builder /app/assets ./assets
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
COPY --from=builder /app/prisma ./prisma COPY --from=builder /app/prisma ./prisma
+102
View File
@@ -0,0 +1,102 @@
Digitized data copyright (c) 2010 Google Corporation
with Reserved Font Arimo, Tinos and Cousine.
Copyright (c) 2012 Red Hat, Inc.
with Reserved Font Name Liberation.
This Font Software is licensed under the SIL Open Font License,
Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
PREAMBLE The goals of the Open Font License (OFL) are to stimulate
worldwide development of collaborative font projects, to support the font
creation efforts of academic and linguistic communities, and to provide
a free and open framework in which fonts may be shared and improved in
partnership with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves.
The fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply to
any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such.
This may include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components
as distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting ? in part or in whole ?
any of the components of the Original Version, by changing formats or
by porting the Font Software to a new environment.
"Author" refers to any designer, engineer, programmer, technical writer
or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining a
copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,in
Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the
corresponding Copyright Holder. This restriction only applies to the
primary font name as presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole, must
be distributed entirely under this license, and must not be distributed
under any other license. The requirement for fonts to remain under
this license does not apply to any document created using the Font
Software.
TERMINATION
This license becomes null and void if any of the above conditions are not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER
DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+6
View File
@@ -1652,6 +1652,12 @@ export const de: Dictionary = {
thankYou: "Vielen Dank, dass du unsere Imkerei unterstützt!", thankYou: "Vielen Dank, dass du unsere Imkerei unterstützt!",
pageLabel: (page, total) => `Seite ${page} von ${total}`, pageLabel: (page, total) => `Seite ${page} von ${total}`,
legalNote: "Diese Rechnung wurde automatisch erzeugt und ist ohne Unterschrift gültig.", 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: { addressesPage: {
title: "Meine Adressen", title: "Meine Adressen",
+6
View File
@@ -1626,6 +1626,12 @@ export const en: Dictionary = {
thankYou: "Thank you for supporting our apiary!", thankYou: "Thank you for supporting our apiary!",
pageLabel: (page, total) => `Page ${page} of ${total}`, pageLabel: (page, total) => `Page ${page} of ${total}`,
legalNote: "This invoice was generated automatically and is valid without a signature.", 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: { addressesPage: {
title: "My Addresses", title: "My Addresses",
+5
View File
@@ -1169,6 +1169,11 @@ export interface Dictionary {
thankYou: string; thankYou: string;
pageLabel: (page: number, total: number) => string; pageLabel: (page: number, total: number) => string;
legalNote: string; legalNote: string;
cancellationTitle: string;
cancellationNumberLabel: string;
cancellationDateLabel: string;
cancelsInvoiceLabel: string;
cancellationNote: (invoiceNumber: string, invoiceDate: string) => string;
}; };
addressesPage: { addressesPage: {
title: string; title: string;
+171
View File
@@ -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 &amp; Söhne &lt;GmbH&gt; &quot;Bio&quot;</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("&lt;a href=&quot;x&quot;&gt;&apos;&amp;&apos;&lt;/a&gt;");
});
});
+317
View File
@@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&apos;");
}
/** 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)}` };
}
+114
View File
@@ -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
View File
@@ -3,7 +3,7 @@ import type { Prisma } from "@/generated/prisma/client";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { generateInvoicePdf } from "@/lib/invoice-pdf"; import { generateInvoicePdf } from "@/lib/invoice-pdf";
import { readStoredInvoicePdf } from "@/lib/invoice-storage"; 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"; import { isLocale, defaultLocale } from "@/i18n/config";
/** /**
@@ -57,40 +57,10 @@ const invoiceInclude = {
type ErpInvoiceRecord = Prisma.InvoiceGetPayload<{ include: typeof invoiceInclude }>; type ErpInvoiceRecord = Prisma.InvoiceGetPayload<{ include: typeof invoiceInclude }>;
export interface ErpVatLine extends VatBreakdownLine { export type ErpVatLine = VatLineWithShares;
/** Darin enthaltener Versandanteil (brutto). */
shippingCents: number;
/** Davon abgezogener Rabattanteil (brutto). */
discountCents: number;
}
/** /** Umsatzsteuer je Satz mit Versand- und Rabattanteil, siehe lib/vat.ts. */
* Umsatzsteuer je Satz wie auf der Rechnung (lib/vat.ts), dazu der Versand- export const erpVatLines = vatLinesWithShares;
* 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,
}));
}
/** Rechnung fuer ERPNext; bewusst ohne Telefonnummer, Postnummer und Notizen (nicht buchungsrelevant). */ /** Rechnung fuer ERPNext; bewusst ohne Telefonnummer, Postnummer und Notizen (nicht buchungsrelevant). */
export function toErpInvoice(invoice: ErpInvoiceRecord) { export function toErpInvoice(invoice: ErpInvoiceRecord) {
+56
View File
@@ -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"; import { describe, test, expect, vi } from "vitest";
vi.mock("@/lib/company-info", () => ({ vi.mock("@/lib/company-info", () => ({
@@ -78,4 +85,53 @@ describe("generateInvoicePdf", () => {
expect(pages).toBeGreaterThanOrEqual(2); expect(pages).toBeGreaterThanOrEqual(2);
expect(pages).toBeLessThanOrEqual(3); 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");
}
+112 -10
View File
@@ -5,6 +5,8 @@ import { getDictionary } from "@/i18n/get-dictionary";
import { invoiceVatBreakdown, type VatBreakdownLine } from "@/lib/vat"; import { invoiceVatBreakdown, type VatBreakdownLine } from "@/lib/vat";
import { groupInvoiceItemsByVatRate, type InvoiceItemGroup } from "@/lib/invoice-groups"; import { groupInvoiceItemsByVatRate, type InvoiceItemGroup } from "@/lib/invoice-groups";
import { INVOICE_COLORS as C, loadInvoiceLogo, drawInvoiceLogo, type InvoiceLogo } from "@/lib/invoice-pdf-brand"; 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 { Dictionary } from "@/i18n/dictionary-type";
import type { Locale } from "@/i18n/config"; import type { Locale } from "@/i18n/config";
@@ -30,6 +32,8 @@ interface InvoicePdfInput {
street: string; street: string;
zip: string; zip: string;
city: string; city: string;
/** ISO-Laendercode der Rechnungsanschrift; fehlt er, gilt Deutschland. */
country?: string;
currency: string; currency: string;
subtotalCents: number; subtotalCents: number;
shippingCents: number; shippingCents: number;
@@ -37,6 +41,11 @@ interface InvoicePdfInput {
discountCode?: { code: string } | null; discountCode?: { code: string } | null;
items: InvoiceItem[]; 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>>; type Company = Awaited<ReturnType<typeof getCompanyInfo>>;
@@ -44,9 +53,16 @@ type Company = Awaited<ReturnType<typeof getCompanyInfo>>;
interface Ctx { interface Ctx {
doc: PDFKit.PDFDocument; doc: PDFKit.PDFDocument;
t: Dictionary["invoicePage"]; t: Dictionary["invoicePage"];
/** Betrag mit dem Vorzeichen des Belegs (Stornorechnung negativ). */
money: (cents: number) => string; 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 // A4 in Punkt. Ab FOOTER_TOP beginnt die Fusszeile; was oberhalb von
// CONTENT_BOTTOM nicht mehr hinpasst, wandert auf die naechste Seite. // CONTENT_BOTTOM nicht mehr hinpasst, wandert auf die naechste Seite.
const PAGE_WIDTH = 595.28; const PAGE_WIDTH = 595.28;
@@ -84,12 +100,17 @@ const COL = {
export async function generateInvoicePdf(input: InvoicePdfInput): Promise<Buffer> { export async function generateInvoicePdf(input: InvoicePdfInput): Promise<Buffer> {
const t = getDictionary(input.locale).invoicePage; const t = getDictionary(input.locale).invoicePage;
const [company, logo] = await Promise.all([getCompanyInfo(), loadInvoiceLogo()]); 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({ const doc = new PDFDocument({
...pdfaOptions(),
margin: MARGIN, margin: MARGIN,
size: "A4", size: "A4",
bufferPages: true, bufferPages: true,
info: { Title: `${t.documentTitle} ${input.invoiceNumber}`, Author: company.name }, info: { Title: xmpSafe(title), Author: xmpSafe(company.name) },
}); });
registerEmbeddedFonts(doc);
const chunks: Buffer[] = []; const chunks: Buffer[] = [];
doc.on("data", (chunk: Buffer) => chunks.push(chunk)); doc.on("data", (chunk: Buffer) => chunks.push(chunk));
const done = new Promise<Buffer>((resolve, reject) => { const done = new Promise<Buffer>((resolve, reject) => {
@@ -97,7 +118,9 @@ export async function generateInvoicePdf(input: InvoicePdfInput): Promise<Buffer
doc.on("error", reject); 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); const breakdown = invoiceVatBreakdown(input.order);
drawHeader(ctx, company, logo); 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); let y = drawTableHeader(ctx, Math.max(addressBottom, metaBottom) + 30);
y = drawItems(ctx, groupInvoiceItemsByVatRate(input.order.items), y); y = drawItems(ctx, groupInvoiceItemsByVatRate(input.order.items), y);
y = drawSummary(ctx, input, breakdown, y + 22); y = drawSummary(ctx, input, breakdown, y + 22);
drawClosing(ctx, breakdown, y + 26); drawClosing(ctx, input, breakdown, y + 26);
drawFooters(ctx, company); 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(); doc.end();
return done; 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 { function label(ctx: Ctx, text: string, x: number, y: number): void {
ctx.doc ctx.doc
.font("Helvetica-Bold") .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 }); 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 doc
.font("Times-Roman")
.fontSize(26)
.fillColor(C.forest) .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); 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). */ /** Rechnungsdaten rechts oben, inklusive USt-IdNr. (§ 14 Abs. 4 Nr. 2 UStG). */
function drawMetaBox(ctx: Ctx, input: InvoicePdfInput, company: Company): number { function drawMetaBox(ctx: Ctx, input: InvoicePdfInput, company: Company): number {
const { doc, t } = ctx; const { doc, t } = ctx;
const rows: [string, string][] = [ 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.invoiceNumberLabel, input.invoiceNumber],
[t.invoiceDateLabel, formatDate(input.issuedAt)], [t.invoiceDateLabel, formatDate(input.issuedAt)],
];
const rows: [string, string][] = [
...documentRows,
[t.orderNumberLabel, input.order.orderNumber], [t.orderNumberLabel, input.order.orderNumber],
...(company.vatId ? ([[t.vatIdLabel, company.vatId]] as [string, string][]) : []), ...(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 { function drawTotals(ctx: Ctx, input: InvoicePdfInput, top: number): number {
const { doc, t, money } = ctx; const { doc, t, money, plain } = ctx;
const { order } = input; const { order } = input;
const x = 330; const x = 330;
const discountName = order.discountCode ? `${t.discountLabel} (${order.discountCode.code})` : t.discountLabel; const discountName = order.discountCode ? `${t.discountLabel} (${order.discountCode.code})` : t.discountLabel;
const rows: [string, string][] = [ const rows: [string, string][] = [
[t.subtotalLabel, money(order.subtotalCents)], [t.subtotalLabel, money(order.subtotalCents)],
[t.shippingLabel, order.shippingCents === 0 ? t.freeShipping : money(order.shippingCents)], [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; let y = top;
@@ -371,12 +465,20 @@ function drawTaxSummary(ctx: Ctx, breakdown: VatBreakdownLine[], top: number): n
return y; 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 { doc, t } = ctx;
const y = ensureSpace(ctx, startY, 40, false); const y = ensureSpace(ctx, startY, 40, false);
if (breakdown.some((line) => line.ratePercent > 0)) { if (breakdown.some((line) => line.ratePercent > 0)) {
doc.font("Helvetica").fontSize(8).fillColor(C.sage).text(t.pricesIncludeVat, MARGIN, y, { width: CONTENT_WIDTH }); 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 }); doc.font("Times-Italic").fontSize(13).fillColor(C.honeyDark).text(t.thankYou, MARGIN, y + 16, { width: CONTENT_WIDTH });
} }
+43
View File
@@ -252,3 +252,46 @@ export function invoiceVatBreakdown(order: {
discountCents: order.discountCents, 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);
}