diff --git a/Dockerfile b/Dockerfile index 9021834..967b977 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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/scripts ./scripts 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 ./ ENV PATH="/app/node_modules/.bin:${PATH}" USER 1001 @@ -87,6 +88,9 @@ RUN addgroup --system --gid 1001 nodejs \ && adduser --system --uid 1001 nextjs 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/static ./.next/static COPY --from=builder /app/prisma ./prisma diff --git a/assets/fonts/LICENSE-Liberation.txt b/assets/fonts/LICENSE-Liberation.txt new file mode 100644 index 0000000..aba73e8 --- /dev/null +++ b/assets/fonts/LICENSE-Liberation.txt @@ -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. + diff --git a/assets/fonts/LiberationSans-Bold.ttf b/assets/fonts/LiberationSans-Bold.ttf new file mode 100644 index 0000000..dc5d57f Binary files /dev/null and b/assets/fonts/LiberationSans-Bold.ttf differ diff --git a/assets/fonts/LiberationSans-Regular.ttf b/assets/fonts/LiberationSans-Regular.ttf new file mode 100644 index 0000000..e633985 Binary files /dev/null and b/assets/fonts/LiberationSans-Regular.ttf differ diff --git a/assets/fonts/LiberationSerif-Italic.ttf b/assets/fonts/LiberationSerif-Italic.ttf new file mode 100644 index 0000000..54d5164 Binary files /dev/null and b/assets/fonts/LiberationSerif-Italic.ttf differ diff --git a/assets/fonts/LiberationSerif-Regular.ttf b/assets/fonts/LiberationSerif-Regular.ttf new file mode 100644 index 0000000..5e5550c Binary files /dev/null and b/assets/fonts/LiberationSerif-Regular.ttf differ diff --git a/src/i18n/dictionaries/de.ts b/src/i18n/dictionaries/de.ts index 22fd7fa..e912fdf 100644 --- a/src/i18n/dictionaries/de.ts +++ b/src/i18n/dictionaries/de.ts @@ -1652,6 +1652,12 @@ export const de: Dictionary = { thankYou: "Vielen Dank, dass du unsere Imkerei unterstützt!", pageLabel: (page, total) => `Seite ${page} von ${total}`, legalNote: "Diese Rechnung wurde automatisch erzeugt und ist ohne Unterschrift gültig.", + cancellationTitle: "Stornorechnung", + cancellationNumberLabel: "Stornorechnungsnr.", + cancellationDateLabel: "Datum", + cancelsInvoiceLabel: "Zur Rechnung", + cancellationNote: (invoiceNumber, invoiceDate) => + `Diese Stornorechnung hebt die Rechnung ${invoiceNumber} vom ${invoiceDate} vollständig auf. Der Betrag wird über die ursprüngliche Zahlungsart erstattet.`, }, addressesPage: { title: "Meine Adressen", diff --git a/src/i18n/dictionaries/en.ts b/src/i18n/dictionaries/en.ts index 7464577..102d047 100644 --- a/src/i18n/dictionaries/en.ts +++ b/src/i18n/dictionaries/en.ts @@ -1626,6 +1626,12 @@ export const en: Dictionary = { thankYou: "Thank you for supporting our apiary!", pageLabel: (page, total) => `Page ${page} of ${total}`, legalNote: "This invoice was generated automatically and is valid without a signature.", + cancellationTitle: "Cancellation invoice", + cancellationNumberLabel: "Cancellation no.", + cancellationDateLabel: "Date", + cancelsInvoiceLabel: "Cancels invoice", + cancellationNote: (invoiceNumber, invoiceDate) => + `This cancellation invoice fully reverses invoice ${invoiceNumber} dated ${invoiceDate}. The amount will be refunded via the original payment method.`, }, addressesPage: { title: "My Addresses", diff --git a/src/i18n/dictionary-type.ts b/src/i18n/dictionary-type.ts index 04833a7..637a3fb 100644 --- a/src/i18n/dictionary-type.ts +++ b/src/i18n/dictionary-type.ts @@ -1169,6 +1169,11 @@ export interface Dictionary { thankYou: string; pageLabel: (page: number, total: number) => string; legalNote: string; + cancellationTitle: string; + cancellationNumberLabel: string; + cancellationDateLabel: string; + cancelsInvoiceLabel: string; + cancellationNote: (invoiceNumber: string, invoiceDate: string) => string; }; addressesPage: { title: string; diff --git a/src/lib/einvoice/cii.test.ts b/src/lib/einvoice/cii.test.ts new file mode 100644 index 0000000..3b92d09 --- /dev/null +++ b/src/lib/einvoice/cii.test.ts @@ -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 { + 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: "hallo@wabenhain.com", + }, + 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}[^>]*>([^<]*)`, "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 = /([^<]*) { + 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(/([^<]*) 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("urn:cen.eu:en16931:2017"); + expect(xml).toContain("380"); + expect(xml).toContain('20261012'); + expect(xml).toContain('DE123456789'); + expect(xml).toContain("Rabatt (SOMMER)"); + expect(xml).toContain("Versand"); + }); + + 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("381"); + expect(xml).toMatch( + /RE-2026-0042<\/ram:IssuerAssignedID>20261001 { + // 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 "Bio"' } })); + + // Assert + expect(xml).toContain("Müller & Söhne <GmbH> "Bio""); + }); +}); + +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(`'&'`)).toBe("<a href="x">'&'</a>"); + }); +}); diff --git a/src/lib/einvoice/cii.ts b/src/lib/einvoice/cii.ts new file mode 100644 index 0000000..4738799 --- /dev/null +++ b/src/lib/einvoice/cii.ts @@ -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, "'"); +} + +/** 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}`; +} + +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: `\n${tag("rsm:CrossIndustryInvoice", body, namespaces)}` }; +} diff --git a/src/lib/einvoice/facturx-pdf.ts b/src/lib/einvoice/facturx-pdf.ts new file mode 100644 index 0000000..809543b --- /dev/null +++ b/src/lib/einvoice/facturx-pdf.ts @@ -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 | null = null; + +function fonts(): Record { + if (!fontCache) { + const entries = Object.entries(FONT_FILES).map(([name, file]) => [name, readFileSync(path.join(FONT_DIR, file))]); + fontCache = Object.fromEntries(entries) as Record; + } + 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 ` + + ${name} + Text + external + ${description} + `; +} + +/** + * 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(` + + INVOICE + ${FACTURX_FILENAME} + 1.0 + EN 16931 + + + + + + Factur-X PDFA Extension Schema + ${FX_NAMESPACE} + fx + + ${[ + 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("")} + + + + + + + `); +} diff --git a/src/lib/erp-export.ts b/src/lib/erp-export.ts index 874c3ee..c9d761f 100644 --- a/src/lib/erp-export.ts +++ b/src/lib/erp-export.ts @@ -3,7 +3,7 @@ import type { Prisma } from "@/generated/prisma/client"; import { prisma } from "@/lib/prisma"; import { generateInvoicePdf } from "@/lib/invoice-pdf"; import { readStoredInvoicePdf } from "@/lib/invoice-storage"; -import { vatBreakdown, type VatBreakdownLine } from "@/lib/vat"; +import { vatLinesWithShares, type VatLineWithShares } from "@/lib/vat"; import { isLocale, defaultLocale } from "@/i18n/config"; /** @@ -57,40 +57,10 @@ const invoiceInclude = { type ErpInvoiceRecord = Prisma.InvoiceGetPayload<{ include: typeof invoiceInclude }>; -export interface ErpVatLine extends VatBreakdownLine { - /** Darin enthaltener Versandanteil (brutto). */ - shippingCents: number; - /** Davon abgezogener Rabattanteil (brutto). */ - discountCents: number; -} +export type ErpVatLine = VatLineWithShares; -/** - * Umsatzsteuer je Satz wie auf der Rechnung (lib/vat.ts), dazu der Versand- - * und der Rabattanteil je Satz. Beide Anteile ergeben sich aus derselben - * Rechnung ohne den jeweils anderen Betrag, also ohne eigene Verteilungslogik. - * null, wenn eine Position keinen Steuersatz hat (Bestellungen aus der Zeit - * vor der Steuerausweisung). - */ -export function erpVatLines(order: { - items: ReadonlyArray<{ totalCents: number; vatRatePercent: number | null }>; - shippingCents: number; - discountCents: number; -}): ErpVatLine[] | null { - const items: { totalCents: number; vatRatePercent: number }[] = []; - for (const item of order.items) { - if (item.vatRatePercent == null) return null; - items.push({ totalCents: item.totalCents, vatRatePercent: item.vatRatePercent }); - } - const full = vatBreakdown({ items, shippingCents: order.shippingCents, discountCents: order.discountCents }); - const plain = vatBreakdown({ items, shippingCents: 0, discountCents: 0 }); - const withShipping = vatBreakdown({ items, shippingCents: order.shippingCents, discountCents: 0 }); - const withDiscount = vatBreakdown({ items, shippingCents: 0, discountCents: order.discountCents }); - return full.map((line, index) => ({ - ...line, - shippingCents: withShipping[index].grossCents - plain[index].grossCents, - discountCents: plain[index].grossCents - withDiscount[index].grossCents, - })); -} +/** Umsatzsteuer je Satz mit Versand- und Rabattanteil, siehe lib/vat.ts. */ +export const erpVatLines = vatLinesWithShares; /** Rechnung fuer ERPNext; bewusst ohne Telefonnummer, Postnummer und Notizen (nicht buchungsrelevant). */ export function toErpInvoice(invoice: ErpInvoiceRecord) { diff --git a/src/lib/invoice-pdf.test.ts b/src/lib/invoice-pdf.test.ts index 9dbe755..4d41e27 100644 --- a/src/lib/invoice-pdf.test.ts +++ b/src/lib/invoice-pdf.test.ts @@ -1,3 +1,10 @@ +// @vitest-environment node +// +// Abweichend von der globalen jsdom-Umgebung (vitest.config.ts): pdfkit +// erkennt die eingebetteten Schriften an "instanceof Uint8Array", und unter +// jsdom stammt der Buffer aus einem anderen JS-Realm — die Pruefung schluege +// fehl ("Not a supported font format"). Im Server laeuft ohnehin Node. +import { inflateSync } from "node:zlib"; import { describe, test, expect, vi } from "vitest"; vi.mock("@/lib/company-info", () => ({ @@ -78,4 +85,53 @@ describe("generateInvoicePdf", () => { expect(pages).toBeGreaterThanOrEqual(2); expect(pages).toBeLessThanOrEqual(3); }); + + test("ist eine E-Rechnung: PDF/A-3b mit eingebettetem factur-x.xml (EN 16931)", async () => { + // Act + const pdf = await generateInvoicePdf(input([item(1, 7), item(2, 19)])); + + // Assert + const raw = pdf.toString("latin1"); + expect(raw).toContain("3"); + expect(raw).toContain("EN 16931"); + expect(raw).toMatch(/\/AFRelationship \/Alternative/); + expect(raw).toContain("/OutputIntents"); + const xml = embeddedXml(pdf); + expect(xml).toContain("RE-TEST-1"); + expect(xml).toContain("380"); + expect(xml).toContain("24.90"); + }); + + 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("ST-TEST-1"); + expect(xml).toContain("381"); + expect(xml).toContain("RE-TEST-1"); + 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"); +} diff --git a/src/lib/invoice-pdf.ts b/src/lib/invoice-pdf.ts index a755d36..3aae8eb 100644 --- a/src/lib/invoice-pdf.ts +++ b/src/lib/invoice-pdf.ts @@ -5,6 +5,8 @@ import { getDictionary } from "@/i18n/get-dictionary"; import { invoiceVatBreakdown, type VatBreakdownLine } from "@/lib/vat"; import { groupInvoiceItemsByVatRate, type InvoiceItemGroup } from "@/lib/invoice-groups"; import { INVOICE_COLORS as C, loadInvoiceLogo, drawInvoiceLogo, type InvoiceLogo } from "@/lib/invoice-pdf-brand"; +import { buildCiiXml, type EInvoiceResult } from "@/lib/einvoice/cii"; +import { embedFacturX, pdfaOptions, registerEmbeddedFonts, xmpSafe } from "@/lib/einvoice/facturx-pdf"; import type { Dictionary } from "@/i18n/dictionary-type"; import type { Locale } from "@/i18n/config"; @@ -30,6 +32,8 @@ interface InvoicePdfInput { street: string; zip: string; city: string; + /** ISO-Laendercode der Rechnungsanschrift; fehlt er, gilt Deutschland. */ + country?: string; currency: string; subtotalCents: number; shippingCents: number; @@ -37,6 +41,11 @@ interface InvoicePdfInput { discountCode?: { code: string } | null; items: InvoiceItem[]; }; + /** + * Gesetzt bei der Stornorechnung: sie hebt die Rechnung invoiceNumber vom + * issuedAt auf und traegt alle Betraege mit umgekehrtem Vorzeichen. + */ + cancellation?: { number: string; issuedAt: Date }; } type Company = Awaited>; @@ -44,9 +53,16 @@ type Company = Awaited>; interface Ctx { doc: PDFKit.PDFDocument; t: Dictionary["invoicePage"]; + /** Betrag mit dem Vorzeichen des Belegs (Stornorechnung negativ). */ money: (cents: number) => string; + /** Betrag ohne Vorzeichenumkehr. */ + plain: (cents: number) => string; + isCancellation: boolean; } +/** Sitz des Shops; die Firmenanschrift in den Einstellungen hat kein Land. */ +const SELLER_COUNTRY_CODE = "DE"; + // A4 in Punkt. Ab FOOTER_TOP beginnt die Fusszeile; was oberhalb von // CONTENT_BOTTOM nicht mehr hinpasst, wandert auf die naechste Seite. const PAGE_WIDTH = 595.28; @@ -84,12 +100,17 @@ const COL = { export async function generateInvoicePdf(input: InvoicePdfInput): Promise { const t = getDictionary(input.locale).invoicePage; const [company, logo] = await Promise.all([getCompanyInfo(), loadInvoiceLogo()]); + const title = input.cancellation + ? `${t.cancellationTitle} ${input.cancellation.number}` + : `${t.documentTitle} ${input.invoiceNumber}`; const doc = new PDFDocument({ + ...pdfaOptions(), margin: MARGIN, size: "A4", bufferPages: true, - info: { Title: `${t.documentTitle} ${input.invoiceNumber}`, Author: company.name }, + info: { Title: xmpSafe(title), Author: xmpSafe(company.name) }, }); + registerEmbeddedFonts(doc); const chunks: Buffer[] = []; doc.on("data", (chunk: Buffer) => chunks.push(chunk)); const done = new Promise((resolve, reject) => { @@ -97,7 +118,9 @@ export async function generateInvoicePdf(input: InvoicePdfInput): Promise formatPrice(cents, input.order.currency) }; + const sign = input.cancellation ? -1 : 1; + const plain = (cents: number) => formatPrice(cents, input.order.currency); + const ctx: Ctx = { doc, t, money: (cents) => plain(sign * cents), plain, isCancellation: Boolean(input.cancellation) }; const breakdown = invoiceVatBreakdown(input.order); drawHeader(ctx, company, logo); @@ -106,13 +129,65 @@ export async function generateInvoicePdf(input: InvoicePdfInput): Promise ({ + name: item.productName, + quantity: item.quantity, + totalCents: item.totalCents, + vatRatePercent: item.vatRatePercent, + })), + shippingCents: order.shippingCents, + discountCents: order.discountCents, + discountCode: order.discountCode?.code ?? null, + totalCents: input.totalCents, + precedingInvoice: cancellation ? { number: input.invoiceNumber, issuedAt: input.issuedAt } : undefined, + paymentNote: cancellation + ? "Der Betrag wird über die ursprüngliche Zahlungsart erstattet." + : "Bereits bezahlt (Online-Zahlung über Stripe).", + }); +} + function label(ctx: Ctx, text: string, x: number, y: number): void { ctx.doc .font("Helvetica-Bold") @@ -139,11 +214,19 @@ function drawHeader(ctx: Ctx, company: Company, logo: InvoiceLogo | null): void doc.font("Helvetica").fontSize(8.5).fillColor(C.honeyDark).text(company.claim, textX, 74, { width: 240, lineBreak: false }); } + // "STORNORECHNUNG" ist laenger als "RECHNUNG": die Schrift wird so weit + // verkleinert, dass der Titel in einer Zeile rechts oben bleibt. + const title = (ctx.isCancellation ? t.cancellationTitle : t.documentTitle).toUpperCase(); + const titleX = 300; + const titleOptions = { characterSpacing: 2 }; + let titleSize = 26; + doc.font("Times-Roman").fontSize(titleSize); + while (titleSize > 14 && doc.widthOfString(title, titleOptions) > RIGHT - titleX) { + doc.fontSize(--titleSize); + } doc - .font("Times-Roman") - .fontSize(26) .fillColor(C.forest) - .text(t.documentTitle.toUpperCase(), 360, 48, { width: RIGHT - 360, align: "right", characterSpacing: 2, lineBreak: false }); + .text(title, titleX, 48 + (26 - titleSize) / 2, { ...titleOptions, width: RIGHT - titleX, align: "right", lineBreak: false }); hairline(ctx, MARGIN, RIGHT, 112, C.honey); } @@ -165,9 +248,18 @@ function drawRecipient(ctx: Ctx, input: InvoicePdfInput, company: Company): numb /** Rechnungsdaten rechts oben, inklusive USt-IdNr. (§ 14 Abs. 4 Nr. 2 UStG). */ function drawMetaBox(ctx: Ctx, input: InvoicePdfInput, company: Company): number { const { doc, t } = ctx; + const documentRows: [string, string][] = input.cancellation + ? [ + [t.cancellationNumberLabel, input.cancellation.number], + [t.cancellationDateLabel, formatDate(input.cancellation.issuedAt)], + [t.cancelsInvoiceLabel, input.invoiceNumber], + ] + : [ + [t.invoiceNumberLabel, input.invoiceNumber], + [t.invoiceDateLabel, formatDate(input.issuedAt)], + ]; const rows: [string, string][] = [ - [t.invoiceNumberLabel, input.invoiceNumber], - [t.invoiceDateLabel, formatDate(input.issuedAt)], + ...documentRows, [t.orderNumberLabel, input.order.orderNumber], ...(company.vatId ? ([[t.vatIdLabel, company.vatId]] as [string, string][]) : []), ]; @@ -287,14 +379,16 @@ function drawSummary(ctx: Ctx, input: InvoicePdfInput, breakdown: VatBreakdownLi } function drawTotals(ctx: Ctx, input: InvoicePdfInput, top: number): number { - const { doc, t, money } = ctx; + const { doc, t, money, plain } = ctx; const { order } = input; const x = 330; const discountName = order.discountCode ? `${t.discountLabel} (${order.discountCode.code})` : t.discountLabel; const rows: [string, string][] = [ [t.subtotalLabel, money(order.subtotalCents)], [t.shippingLabel, order.shippingCents === 0 ? t.freeShipping : money(order.shippingCents)], - ...(order.discountCents > 0 ? ([[discountName, `– ${money(order.discountCents)}`]] as [string, string][]) : []), + ...(order.discountCents > 0 + ? ([[discountName, `${ctx.isCancellation ? "+" : "–"} ${plain(order.discountCents)}`]] as [string, string][]) + : []), ]; let y = top; @@ -371,12 +465,20 @@ function drawTaxSummary(ctx: Ctx, breakdown: VatBreakdownLine[], top: number): n return y; } -function drawClosing(ctx: Ctx, breakdown: VatBreakdownLine[], startY: number): void { +function drawClosing(ctx: Ctx, input: InvoicePdfInput, breakdown: VatBreakdownLine[], startY: number): void { const { doc, t } = ctx; const y = ensureSpace(ctx, startY, 40, false); if (breakdown.some((line) => line.ratePercent > 0)) { doc.font("Helvetica").fontSize(8).fillColor(C.sage).text(t.pricesIncludeVat, MARGIN, y, { width: CONTENT_WIDTH }); } + if (input.cancellation) { + doc + .font("Helvetica") + .fontSize(9.5) + .fillColor(C.ink) + .text(t.cancellationNote(input.invoiceNumber, formatDate(input.issuedAt)), MARGIN, y + 16, { width: CONTENT_WIDTH }); + return; + } doc.font("Times-Italic").fontSize(13).fillColor(C.honeyDark).text(t.thankYou, MARGIN, y + 16, { width: CONTENT_WIDTH }); } diff --git a/src/lib/vat.ts b/src/lib/vat.ts index db3a332..92c03ef 100644 --- a/src/lib/vat.ts +++ b/src/lib/vat.ts @@ -252,3 +252,46 @@ export function invoiceVatBreakdown(order: { discountCents: order.discountCents, }); } + +export interface VatLineWithShares extends VatBreakdownLine { + /** Darin enthaltener Versandanteil (brutto). */ + shippingCents: number; + /** Davon abgezogener Rabattanteil (brutto). */ + discountCents: number; +} + +/** + * Umsatzsteuer je Satz wie auf der Rechnung, dazu der Versand- und der + * Rabattanteil je Satz. Beide Anteile ergeben sich aus derselben Rechnung + * ohne den jeweils anderen Betrag, also ohne eigene Verteilungslogik — so + * stimmen sie mit der Aufstellung auf dem PDF auf den Cent ueberein. + * Gebraucht vom ERP-Export und von der E-Rechnung (lib/einvoice). + * + * null, wenn eine Position keinen Steuersatz hat (Bestellungen aus der Zeit + * vor der Steuerausweisung). + */ +export function vatLinesWithShares(order: { + items: ReadonlyArray<{ totalCents: number; vatRatePercent: number | null }>; + shippingCents: number; + discountCents: number; +}): VatLineWithShares[] | null { + const items: { totalCents: number; vatRatePercent: number }[] = []; + for (const item of order.items) { + if (item.vatRatePercent == null) return null; + items.push({ totalCents: item.totalCents, vatRatePercent: item.vatRatePercent }); + } + const full = vatBreakdown({ items, shippingCents: order.shippingCents, discountCents: order.discountCents }); + const plain = vatBreakdown({ items, shippingCents: 0, discountCents: 0 }); + const withShipping = vatBreakdown({ items, shippingCents: order.shippingCents, discountCents: 0 }); + const withDiscount = vatBreakdown({ items, shippingCents: 0, discountCents: order.discountCents }); + return full.map((line, index) => ({ + ...line, + shippingCents: withShipping[index].grossCents - plain[index].grossCents, + discountCents: plain[index].grossCents - withDiscount[index].grossCents, + })); +} + +/** Verteilt ganze Cents im Verhaeltnis der Gewichte (Hare-Niemeyer, siehe distributeProportionally). */ +export function distributeCents(amountCents: number, weights: readonly number[]): number[] { + return distributeProportionally(amountCents, weights); +}