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
+940
-44
No files matched your search
@@ -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
|
||||||
|
|||||||
@@ -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.
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 { 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) {
|
||||||
|
|||||||
@@ -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
@@ -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 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
Reference in new issue
Block a user