feat(accounting): Ausgangsrechnungen als ZUGFeRD-E-Rechnung (EN 16931) erzeugen
Rechnungen, Abschlags-, Storno- und Serienrechnungen erhalten beim Fertigstellen ein eingebettetes factur-x.xml (CII, Profil EN 16931) samt Factur-X-XMP-Metadaten. Das XML wird serverseitig aus dem gespeicherten Beleg erzeugt; fehlende Pflichtangaben werden als Hinweis gemeldet. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -9,7 +9,7 @@
|
|||||||
"fill": "ts-node src/webdav/fill-file-sizes.ts",
|
"fill": "ts-node src/webdav/fill-file-sizes.ts",
|
||||||
"dev:dav": "tsx watch src/webdav/server.ts",
|
"dev:dav": "tsx watch src/webdav/server.ts",
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"test:einvoice": "tsx --test tests/einvoice.test.ts",
|
"test:einvoice": "tsx --test tests/einvoice.test.ts tests/zugferdInvoice.test.ts",
|
||||||
"start": "node dist/src/index.js",
|
"start": "node dist/src/index.js",
|
||||||
"schema:index": "ts-node scripts/generate-schema-index.ts",
|
"schema:index": "ts-node scripts/generate-schema-index.ts",
|
||||||
"bankcodes:update": "tsx scripts/generate-de-bank-codes.ts",
|
"bankcodes:update": "tsx scripts/generate-de-bank-codes.ts",
|
||||||
|
|||||||
342
backend/src/modules/einvoice/buildZugferdInvoice.ts
Normal file
342
backend/src/modules/einvoice/buildZugferdInvoice.ts
Normal file
@@ -0,0 +1,342 @@
|
|||||||
|
import dayjs from "dayjs"
|
||||||
|
import { buildCashDiscountTerms } from "../cash-discount"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erzeugt das strukturierte CII-XML (ZUGFeRD 2.x / Factur-X, Profil EN 16931) für Ausgangsrechnungen.
|
||||||
|
* Grundlage ist der gespeicherte Beleg, nicht die formatierten PDF-Daten, damit alle Beträge numerisch bleiben.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const ZUGFERD_DOCUMENT_TYPES = ["invoices", "advanceInvoices", "cancellationInvoices", "serialInvoices"]
|
||||||
|
export const ZUGFERD_GUIDELINE_ID = "urn:cen.eu:en16931:2017"
|
||||||
|
export const ZUGFERD_FILENAME = "factur-x.xml"
|
||||||
|
|
||||||
|
export type ZugferdInvoiceContext = {
|
||||||
|
tenant: any
|
||||||
|
customer: any
|
||||||
|
contact?: any
|
||||||
|
contactPerson?: any
|
||||||
|
project?: any
|
||||||
|
contract?: any
|
||||||
|
linkedDocument?: any
|
||||||
|
sepaMandate?: any
|
||||||
|
units: any[]
|
||||||
|
products: any[]
|
||||||
|
services: any[]
|
||||||
|
bankAccounts: any[]
|
||||||
|
advanceInvoices: any[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ZugferdInvoiceResult = {
|
||||||
|
xml: string
|
||||||
|
warnings: string[]
|
||||||
|
totals: { lineNet: number, tax: number, gross: number, prepaid: number, payable: number }
|
||||||
|
}
|
||||||
|
|
||||||
|
type TaxCategory = "S" | "Z" | "E" | "AE"
|
||||||
|
type TaxGroup = { category: TaxCategory, rate: number, basis: number, exemptionReason?: string, exemptionReasonCode?: string }
|
||||||
|
|
||||||
|
const round = (value: number, digits = 2) => {
|
||||||
|
const factor = 10 ** digits
|
||||||
|
const rounded = Math.round((value + Number.EPSILON * Math.sign(value)) * factor) / factor
|
||||||
|
return Object.is(rounded, -0) ? 0 : rounded
|
||||||
|
}
|
||||||
|
const amount = (value: number) => round(value).toFixed(2)
|
||||||
|
const decimal = (value: number, maxDigits = 4) => String(round(value, maxDigits))
|
||||||
|
|
||||||
|
const escapeXml = (value: unknown) => String(value ?? "")
|
||||||
|
.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F]/g, "")
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
|
||||||
|
const text = (value: unknown) => String(value ?? "").trim()
|
||||||
|
const el = (name: string, value: unknown, attributes = "") => {
|
||||||
|
const content = text(value)
|
||||||
|
return content ? `<${name}${attributes}>${escapeXml(content)}</${name}>` : ""
|
||||||
|
}
|
||||||
|
const date102 = (value: unknown) => {
|
||||||
|
if (!value) return ""
|
||||||
|
const parsed = dayjs(value as any)
|
||||||
|
return parsed.isValid() ? parsed.format("YYYYMMDD") : ""
|
||||||
|
}
|
||||||
|
const dateElement = (name: string, value: unknown) => {
|
||||||
|
const formatted = date102(value)
|
||||||
|
return formatted ? `<${name}><udt:DateTimeString format="102">${formatted}</udt:DateTimeString></${name}>` : ""
|
||||||
|
}
|
||||||
|
|
||||||
|
const countryCodes: Record<string, string> = {
|
||||||
|
deutschland: "DE", germany: "DE", österreich: "AT", oesterreich: "AT", austria: "AT", schweiz: "CH", switzerland: "CH",
|
||||||
|
liechtenstein: "LI", frankreich: "FR", france: "FR", italien: "IT", italy: "IT", niederlande: "NL", netherlands: "NL",
|
||||||
|
belgien: "BE", belgium: "BE", luxemburg: "LU", luxembourg: "LU", dänemark: "DK", daenemark: "DK", denmark: "DK",
|
||||||
|
polen: "PL", poland: "PL", tschechien: "CZ", "tschechische republik": "CZ", spanien: "ES", spain: "ES",
|
||||||
|
portugal: "PT", schweden: "SE", sweden: "SE", norwegen: "NO", norway: "NO", finnland: "FI", finland: "FI",
|
||||||
|
ungarn: "HU", hungary: "HU", slowakei: "SK", slowenien: "SI", kroatien: "HR", rumänien: "RO", bulgarien: "BG",
|
||||||
|
griechenland: "GR", irland: "IE", ireland: "IE", "vereinigtes königreich": "GB", großbritannien: "GB", england: "GB",
|
||||||
|
"united kingdom": "GB", usa: "US", "vereinigte staaten": "US", estland: "EE", lettland: "LV", litauen: "LT",
|
||||||
|
malta: "MT", zypern: "CY", türkei: "TR",
|
||||||
|
}
|
||||||
|
|
||||||
|
export function countryCode(value: unknown) {
|
||||||
|
const normalized = text(value).toLocaleLowerCase("de")
|
||||||
|
if (!normalized) return "DE"
|
||||||
|
if (/^[a-z]{2}$/.test(normalized)) return normalized.toUpperCase()
|
||||||
|
return countryCodes[normalized] || "DE"
|
||||||
|
}
|
||||||
|
|
||||||
|
// UN/ECE Recommendation 20/21, abgebildet über das Kürzel der FEDEO-Einheit.
|
||||||
|
const unitCodes: Record<string, string> = {
|
||||||
|
stk: "H87", stück: "H87", m: "MTR", meter: "MTR", km: "KMT", kilometer: "KMT", tag: "DAY", tage: "DAY",
|
||||||
|
std: "HUR", h: "HUR", stunde: "HUR", stunden: "HUR", min: "MIN", minute: "MIN", minuten: "MIN",
|
||||||
|
pausch: "LS", pauschal: "LS", psch: "LS", mon: "MON", monat: "MON", jahr: "ANN", woche: "WEE",
|
||||||
|
pck: "PK", packung: "PK", pers: "IE", person: "IE", l: "LTR", liter: "LTR", ha: "HAR", hektar: "HAR",
|
||||||
|
"m²": "MTK", m2: "MTK", qm: "MTK", quadratmeter: "MTK", "m³": "MTQ", m3: "MTQ", kubikmeter: "MTQ",
|
||||||
|
kg: "KGM", kilogramm: "KGM", g: "GRM", t: "TNE", tonne: "TNE", gb: "E34", gigabyte: "E34", kwp: "KWT", kwh: "KWH",
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unitCode(unit: any) {
|
||||||
|
for (const candidate of [unit?.short, unit?.single, unit?.name]) {
|
||||||
|
const code = unitCodes[text(candidate).toLocaleLowerCase("de")]
|
||||||
|
if (code) return code
|
||||||
|
}
|
||||||
|
return "C62"
|
||||||
|
}
|
||||||
|
|
||||||
|
const isItemRow = (row: any) => !["pagebreak", "title", "text"].includes(row?.mode) && !row?.optional && !row?.alternative
|
||||||
|
|
||||||
|
function taxGroupFor(taxType: string | null | undefined, rate: number): Omit<TaxGroup, "basis"> {
|
||||||
|
if (taxType === "13b UStG") return {
|
||||||
|
category: "AE", rate: 0, exemptionReasonCode: "VATEX-EU-AE",
|
||||||
|
exemptionReason: "Steuerschuldnerschaft des Leistungsempfängers gemäß § 13b UStG",
|
||||||
|
}
|
||||||
|
if (taxType === "19 UStG") return {
|
||||||
|
category: "E", rate: 0,
|
||||||
|
exemptionReason: "Kein Ausweis von Umsatzsteuer, da Kleinunternehmer gemäß § 19 UStG",
|
||||||
|
}
|
||||||
|
if (taxType === "12.3 UStG") return { category: "Z", rate: 0 }
|
||||||
|
return rate > 0 ? { category: "S", rate } : { category: "Z", rate: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
function advanceInvoiceGross(invoice: any) {
|
||||||
|
const row = (invoice?.rows || []).find((item: any) => item.advanceInvoiceData)
|
||||||
|
return row ? Number(row.price) * ((100 + Number(row.taxPercent)) / 100) : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function typeCode(type: string) {
|
||||||
|
if (type === "advanceInvoices") return "386"
|
||||||
|
if (type === "cancellationInvoices") return "381"
|
||||||
|
return "380"
|
||||||
|
}
|
||||||
|
|
||||||
|
function partyAddress(address: { zip?: unknown, street?: unknown, special?: unknown, city?: unknown, country?: unknown }) {
|
||||||
|
return `<ram:PostalTradeAddress>${el("ram:PostcodeCode", address.zip)}${el("ram:LineOne", address.street)}${el("ram:LineTwo", address.special)}${el("ram:CityName", address.city)}<ram:CountryID>${countryCode(address.country)}</ram:CountryID></ram:PostalTradeAddress>`
|
||||||
|
}
|
||||||
|
|
||||||
|
function tradeContact(name: unknown, phone?: unknown, email?: unknown) {
|
||||||
|
if (!text(name) && !text(phone) && !text(email)) return ""
|
||||||
|
return `<ram:DefinedTradeContact>${el("ram:PersonName", name)}${text(phone) ? `<ram:TelephoneUniversalCommunication>${el("ram:CompleteNumber", phone)}</ram:TelephoneUniversalCommunication>` : ""}${text(email) ? `<ram:EmailURIUniversalCommunication>${el("ram:URIID", email)}</ram:EmailURIUniversalCommunication>` : ""}</ram:DefinedTradeContact>`
|
||||||
|
}
|
||||||
|
|
||||||
|
function taxRegistrations(vatId: unknown, taxNumber?: unknown) {
|
||||||
|
return [
|
||||||
|
text(vatId) ? `<ram:SpecifiedTaxRegistration><ram:ID schemeID="VA">${escapeXml(text(vatId).replace(/\s+/g, ""))}</ram:ID></ram:SpecifiedTaxRegistration>` : "",
|
||||||
|
text(taxNumber) ? `<ram:SpecifiedTaxRegistration><ram:ID schemeID="FC">${escapeXml(text(taxNumber))}</ram:ID></ram:SpecifiedTaxRegistration>` : "",
|
||||||
|
].join("")
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildZugferdInvoiceXml(document: any, context: ZugferdInvoiceContext): ZugferdInvoiceResult {
|
||||||
|
const warnings: string[] = []
|
||||||
|
const businessInfo = context.tenant?.businessInfo || {}
|
||||||
|
const customer = context.customer || {}
|
||||||
|
const customerInfo = customer.infoData || {}
|
||||||
|
const address = document.address || {}
|
||||||
|
// Stornorechnungen tragen negative Preise; als Gutschrift (381) werden die Beträge positiv ausgewiesen.
|
||||||
|
const sign = document.type === "cancellationInvoices" ? -1 : 1
|
||||||
|
|
||||||
|
if (!text(document.documentNumber)) warnings.push("Rechnungsnummer fehlt")
|
||||||
|
if (!date102(document.documentDate)) warnings.push("Rechnungsdatum fehlt")
|
||||||
|
if (!text(businessInfo.name)) warnings.push("Firmenname des Verkäufers fehlt (Einstellungen > Firmendaten)")
|
||||||
|
if (!text(businessInfo.street) || !text(businessInfo.zip) || !text(businessInfo.city)) warnings.push("Anschrift des Verkäufers ist unvollständig")
|
||||||
|
if (!text(businessInfo.vatId) && !text(businessInfo.taxNumber)) warnings.push("Weder USt-IdNr. noch Steuernummer des Verkäufers hinterlegt")
|
||||||
|
if (!text(customer.name)) warnings.push("Name des Kunden fehlt")
|
||||||
|
if (!text(address.city) && !text(customerInfo.city)) warnings.push("Anschrift des Kunden ist unvollständig")
|
||||||
|
if (document.taxType === "13b UStG" && !text(customerInfo.ustid)) warnings.push("Für § 13b UStG ist die USt-IdNr. des Kunden erforderlich")
|
||||||
|
if (document.taxType === "13b UStG" && !text(businessInfo.vatId)) warnings.push("Für § 13b UStG ist die USt-IdNr. des Verkäufers erforderlich")
|
||||||
|
|
||||||
|
const groups = new Map<string, TaxGroup>()
|
||||||
|
const lines: string[] = []
|
||||||
|
let lineIndex = 0
|
||||||
|
|
||||||
|
for (const row of document.rows || []) {
|
||||||
|
if (!isItemRow(row)) continue
|
||||||
|
lineIndex++
|
||||||
|
const discountPercent = Number(row.discountPercent) || 0
|
||||||
|
const grossUnitPrice = sign * (Number(row.price) || 0)
|
||||||
|
let netUnitPrice = grossUnitPrice * (1 - discountPercent / 100)
|
||||||
|
let quantity = Number(row.quantity) || 0
|
||||||
|
let grossPrice = grossUnitPrice
|
||||||
|
// EN 16931 verlangt nicht-negative Einzelpreise (BR-27); Minderungen laufen über eine negative Menge.
|
||||||
|
if (netUnitPrice < 0 || (netUnitPrice === 0 && grossPrice < 0)) {
|
||||||
|
netUnitPrice = -netUnitPrice
|
||||||
|
grossPrice = -grossPrice
|
||||||
|
quantity = -quantity
|
||||||
|
}
|
||||||
|
const lineTotal = round(quantity * netUnitPrice)
|
||||||
|
const rate = Number(row.taxPercent) || 0
|
||||||
|
const group = taxGroupFor(document.taxType, rate)
|
||||||
|
const key = `${group.category}:${group.rate}`
|
||||||
|
const existing = groups.get(key)
|
||||||
|
if (existing) existing.basis = round(existing.basis + lineTotal)
|
||||||
|
else groups.set(key, { ...group, basis: lineTotal })
|
||||||
|
|
||||||
|
const product = row.mode === "normal" ? context.products.find((item) => item.id === row.product) : null
|
||||||
|
const service = row.mode === "service" ? context.services.find((item) => item.id === row.service) : null
|
||||||
|
const name = text(product?.name || service?.name || row.text) || `Position ${row.pos ?? lineIndex}`
|
||||||
|
const sellerId = product?.article_number
|
||||||
|
const description = text(row.agriculture?.description || row.description)
|
||||||
|
const unit = context.units.find((item) => item.id === row.unit)
|
||||||
|
const allowance = round(grossPrice - netUnitPrice, 4)
|
||||||
|
|
||||||
|
lines.push(
|
||||||
|
`<ram:IncludedSupplyChainTradeLineItem>` +
|
||||||
|
`<ram:AssociatedDocumentLineDocument><ram:LineID>${escapeXml(text(row.pos) || String(lineIndex))}</ram:LineID></ram:AssociatedDocumentLineDocument>` +
|
||||||
|
`<ram:SpecifiedTradeProduct>${el("ram:SellerAssignedID", sellerId)}<ram:Name>${escapeXml(name)}</ram:Name>${el("ram:Description", description)}</ram:SpecifiedTradeProduct>` +
|
||||||
|
`<ram:SpecifiedLineTradeAgreement>` +
|
||||||
|
(allowance > 0
|
||||||
|
? `<ram:GrossPriceProductTradePrice><ram:ChargeAmount>${decimal(grossPrice)}</ram:ChargeAmount><ram:AppliedTradeAllowanceCharge><ram:ChargeIndicator><udt:Indicator>false</udt:Indicator></ram:ChargeIndicator><ram:ActualAmount>${decimal(allowance)}</ram:ActualAmount></ram:AppliedTradeAllowanceCharge></ram:GrossPriceProductTradePrice>`
|
||||||
|
: "") +
|
||||||
|
`<ram:NetPriceProductTradePrice><ram:ChargeAmount>${decimal(netUnitPrice)}</ram:ChargeAmount></ram:NetPriceProductTradePrice>` +
|
||||||
|
`</ram:SpecifiedLineTradeAgreement>` +
|
||||||
|
`<ram:SpecifiedLineTradeDelivery><ram:BilledQuantity unitCode="${unitCode(unit)}">${decimal(quantity)}</ram:BilledQuantity></ram:SpecifiedLineTradeDelivery>` +
|
||||||
|
`<ram:SpecifiedLineTradeSettlement><ram:ApplicableTradeTax><ram:TypeCode>VAT</ram:TypeCode><ram:CategoryCode>${group.category}</ram:CategoryCode><ram:RateApplicablePercent>${decimal(group.rate)}</ram:RateApplicablePercent></ram:ApplicableTradeTax>` +
|
||||||
|
`<ram:SpecifiedTradeSettlementLineMonetarySummation><ram:LineTotalAmount>${amount(lineTotal)}</ram:LineTotalAmount></ram:SpecifiedTradeSettlementLineMonetarySummation></ram:SpecifiedLineTradeSettlement>` +
|
||||||
|
`</ram:IncludedSupplyChainTradeLineItem>`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!lines.length) warnings.push("Der Beleg enthält keine abrechenbaren Positionen")
|
||||||
|
|
||||||
|
const taxGroups = [...groups.values()]
|
||||||
|
const lineNet = round(taxGroups.reduce((sum, group) => sum + group.basis, 0))
|
||||||
|
const taxTotal = round(taxGroups.reduce((sum, group) => sum + round(group.basis * group.rate / 100), 0))
|
||||||
|
const grossTotal = round(lineNet + taxTotal)
|
||||||
|
const prepaid = ["invoices", "cancellationInvoices"].includes(document.type)
|
||||||
|
? round(context.advanceInvoices.reduce((sum, invoice) => sum + advanceInvoiceGross(invoice), 0))
|
||||||
|
: 0
|
||||||
|
const payable = round(grossTotal - prepaid)
|
||||||
|
|
||||||
|
// Zahlungsinformationen
|
||||||
|
const paymentAccount = context.bankAccounts.find((account) => !account.archived && !account.expired && account.iban)
|
||||||
|
const iban = text(businessInfo.iban || paymentAccount?.iban).replace(/\s+/g, "")
|
||||||
|
const bic = text(businessInfo.bic || paymentAccount?.bic).replace(/\s+/g, "")
|
||||||
|
const accountName = text(businessInfo.bankAccountOwner || paymentAccount?.ownerName || businessInfo.name)
|
||||||
|
let paymentMeans = ""
|
||||||
|
if (document.payment_type === "direct-debit") {
|
||||||
|
paymentMeans = `<ram:SpecifiedTradeSettlementPaymentMeans><ram:TypeCode>59</ram:TypeCode><ram:Information>SEPA-Lastschrift</ram:Information></ram:SpecifiedTradeSettlementPaymentMeans>`
|
||||||
|
if (!text(context.tenant?.creditorId)) warnings.push("Für SEPA-Lastschrift ist keine Gläubiger-ID hinterlegt")
|
||||||
|
if (!text(context.sepaMandate?.reference)) warnings.push("Für SEPA-Lastschrift ist keine Mandatsreferenz hinterlegt")
|
||||||
|
} else if (document.payment_type === "transfer" || !document.payment_type) {
|
||||||
|
if (!iban) warnings.push("Für Überweisung ist keine IBAN des Verkäufers hinterlegt")
|
||||||
|
paymentMeans = `<ram:SpecifiedTradeSettlementPaymentMeans><ram:TypeCode>58</ram:TypeCode><ram:Information>SEPA-Überweisung</ram:Information>` +
|
||||||
|
(iban ? `<ram:PayeePartyCreditorFinancialAccount><ram:IBANID>${escapeXml(iban)}</ram:IBANID>${el("ram:AccountName", accountName)}</ram:PayeePartyCreditorFinancialAccount>` : "") +
|
||||||
|
(iban && bic ? `<ram:PayeeSpecifiedCreditorFinancialInstitution><ram:BICID>${escapeXml(bic)}</ram:BICID></ram:PayeeSpecifiedCreditorFinancialInstitution>` : "") +
|
||||||
|
`</ram:SpecifiedTradeSettlementPaymentMeans>`
|
||||||
|
} else {
|
||||||
|
paymentMeans = `<ram:SpecifiedTradeSettlementPaymentMeans><ram:TypeCode>1</ram:TypeCode></ram:SpecifiedTradeSettlementPaymentMeans>`
|
||||||
|
}
|
||||||
|
|
||||||
|
const paymentDays = document.paymentDays === null || document.paymentDays === undefined || document.paymentDays === "" ? null : Number(document.paymentDays)
|
||||||
|
const dueDate = date102(document.documentDate) && paymentDays !== null && Number.isFinite(paymentDays)
|
||||||
|
? dayjs(document.documentDate).add(paymentDays, "day")
|
||||||
|
: null
|
||||||
|
const cashDiscount = ["invoices", "advanceInvoices"].includes(document.type)
|
||||||
|
? buildCashDiscountTerms({ percent: document.cashDiscountPercent, days: document.cashDiscountDays, documentDate: document.documentDate, amount: payable })
|
||||||
|
: null
|
||||||
|
const termsDescription = [
|
||||||
|
payable <= 0 ? "" : dueDate ? `Zahlbar bis zum ${dueDate.format("DD.MM.YYYY")} ohne Abzug.` : "Zahlbar sofort ohne Abzug.",
|
||||||
|
// Strukturierte Skontoangabe nach XRechnung-Konvention, die von gängigen Empfängern ausgewertet wird.
|
||||||
|
cashDiscount && cashDiscount.days !== null
|
||||||
|
? `#SKONTO#TAGE=${cashDiscount.days}#PROZENT=${cashDiscount.percent.toFixed(2)}#BASISBETRAG=${amount(payable)}#`
|
||||||
|
: "",
|
||||||
|
].filter(Boolean).join("\n")
|
||||||
|
const paymentTerms = termsDescription || dueDate || context.sepaMandate?.reference
|
||||||
|
? `<ram:SpecifiedTradePaymentTerms>${el("ram:Description", termsDescription)}${dueDate ? dateElement("ram:DueDateDateTime", dueDate.toDate()) : ""}${el("ram:DirectDebitMandateID", document.payment_type === "direct-debit" ? context.sepaMandate?.reference : "")}</ram:SpecifiedTradePaymentTerms>`
|
||||||
|
: ""
|
||||||
|
|
||||||
|
const taxElements = taxGroups.map((group) =>
|
||||||
|
`<ram:ApplicableTradeTax><ram:CalculatedAmount>${amount(group.basis * group.rate / 100)}</ram:CalculatedAmount><ram:TypeCode>VAT</ram:TypeCode>${el("ram:ExemptionReason", group.exemptionReason)}<ram:BasisAmount>${amount(group.basis)}</ram:BasisAmount><ram:CategoryCode>${group.category}</ram:CategoryCode>${el("ram:ExemptionReasonCode", group.exemptionReasonCode)}<ram:RateApplicablePercent>${decimal(group.rate)}</ram:RateApplicablePercent></ram:ApplicableTradeTax>`,
|
||||||
|
).join("")
|
||||||
|
|
||||||
|
// Leistungsdatum bzw. Leistungszeitraum
|
||||||
|
const deliveryType = text(document.deliveryDateType)
|
||||||
|
const isPeriod = ["Lieferzeitraum", "Leistungszeitraum"].includes(deliveryType)
|
||||||
|
const showDelivery = deliveryType !== "Kein Lieferdatum anzeigen"
|
||||||
|
const deliveryEvent = showDelivery && !isPeriod && date102(document.deliveryDate)
|
||||||
|
? `<ram:ActualDeliverySupplyChainEvent>${dateElement("ram:OccurrenceDateTime", document.deliveryDate)}</ram:ActualDeliverySupplyChainEvent>`
|
||||||
|
: ""
|
||||||
|
const billingPeriod = showDelivery && isPeriod && (date102(document.deliveryDate) || date102(document.deliveryDateEnd))
|
||||||
|
? `<ram:BillingSpecifiedPeriod>${dateElement("ram:StartDateTime", document.deliveryDate)}${dateElement("ram:EndDateTime", document.deliveryDateEnd)}</ram:BillingSpecifiedPeriod>`
|
||||||
|
: ""
|
||||||
|
|
||||||
|
const contactPerson = context.contactPerson || {}
|
||||||
|
const sellerContact = tradeContact(contactPerson.full_name, document.contactTel || contactPerson.fixed_tel || contactPerson.mobile_tel || businessInfo.phone, contactPerson.email || businessInfo.email)
|
||||||
|
const seller = `<ram:SellerTradeParty>${el("ram:Name", businessInfo.name || context.tenant?.name)}${sellerContact}` +
|
||||||
|
partyAddress({ zip: businessInfo.zip, street: businessInfo.street, city: businessInfo.city, country: businessInfo.country }) +
|
||||||
|
(text(businessInfo.email) ? `<ram:URIUniversalCommunication><ram:URIID schemeID="EM">${escapeXml(text(businessInfo.email))}</ram:URIID></ram:URIUniversalCommunication>` : "") +
|
||||||
|
taxRegistrations(businessInfo.vatId, businessInfo.taxNumber) +
|
||||||
|
`</ram:SellerTradeParty>`
|
||||||
|
|
||||||
|
const buyerEmail = text(customerInfo.invoiceEmail || customerInfo.email)
|
||||||
|
const contact = context.contact
|
||||||
|
const buyer = `<ram:BuyerTradeParty>${el("ram:ID", typeof customer.customerNumber === "object" ? "" : customer.customerNumber)}${el("ram:Name", customer.name)}` +
|
||||||
|
(contact ? tradeContact([contact.firstName, contact.lastName].filter(Boolean).join(" "), contact.phoneHome || contact.phoneMobile, contact.email) : "") +
|
||||||
|
partyAddress({
|
||||||
|
zip: address.zip ?? customerInfo.zip,
|
||||||
|
street: address.street ?? customerInfo.street,
|
||||||
|
special: address.special ?? customerInfo.special,
|
||||||
|
city: address.city ?? customerInfo.city,
|
||||||
|
country: address.country ?? customerInfo.country,
|
||||||
|
}) +
|
||||||
|
(buyerEmail ? `<ram:URIUniversalCommunication><ram:URIID schemeID="EM">${escapeXml(buyerEmail)}</ram:URIID></ram:URIUniversalCommunication>` : "") +
|
||||||
|
taxRegistrations(customerInfo.ustid) +
|
||||||
|
`</ram:BuyerTradeParty>`
|
||||||
|
|
||||||
|
const contractReference = el("ram:IssuerAssignedID", context.contract?.contractNumber)
|
||||||
|
const projectReference = context.project?.name
|
||||||
|
? `<ram:SpecifiedProcuringProject><ram:ID>${escapeXml(text(context.project.projectNumber || context.project.id))}</ram:ID><ram:Name>${escapeXml(text(context.project.name))}</ram:Name></ram:SpecifiedProcuringProject>`
|
||||||
|
: ""
|
||||||
|
const precedingInvoice = document.type === "cancellationInvoices" && text(context.linkedDocument?.documentNumber)
|
||||||
|
? `<ram:InvoiceReferencedDocument><ram:IssuerAssignedID>${escapeXml(text(context.linkedDocument.documentNumber))}</ram:IssuerAssignedID>${date102(context.linkedDocument.documentDate) ? `<ram:FormattedIssueDateTime><qdt:DateTimeString format="102">${date102(context.linkedDocument.documentDate)}</qdt:DateTimeString></ram:FormattedIssueDateTime>` : ""}</ram:InvoiceReferencedDocument>`
|
||||||
|
: ""
|
||||||
|
|
||||||
|
const notes = [
|
||||||
|
text(document.title),
|
||||||
|
text(document.description),
|
||||||
|
].filter(Boolean).map((note) => `<ram:IncludedNote><ram:Content>${escapeXml(note)}</ram:Content></ram:IncludedNote>`).join("")
|
||||||
|
|
||||||
|
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<rsm:CrossIndustryInvoice xmlns:rsm="urn:un:unece:uncefact:data:standard:CrossIndustryInvoice:100" xmlns:qdt="urn:un:unece:uncefact:data:standard:QualifiedDataType:100" xmlns:ram="urn:un:unece:uncefact:data:standard:ReusableAggregateBusinessInformationEntity:100" xmlns:udt="urn:un:unece:uncefact:data:standard:UnqualifiedDataType:100">` +
|
||||||
|
`<rsm:ExchangedDocumentContext><ram:GuidelineSpecifiedDocumentContextParameter><ram:ID>${ZUGFERD_GUIDELINE_ID}</ram:ID></ram:GuidelineSpecifiedDocumentContextParameter></rsm:ExchangedDocumentContext>` +
|
||||||
|
`<rsm:ExchangedDocument><ram:ID>${escapeXml(text(document.documentNumber))}</ram:ID><ram:TypeCode>${typeCode(document.type)}</ram:TypeCode>` +
|
||||||
|
`<ram:IssueDateTime><udt:DateTimeString format="102">${date102(document.documentDate) || dayjs().format("YYYYMMDD")}</udt:DateTimeString></ram:IssueDateTime>${notes}</rsm:ExchangedDocument>` +
|
||||||
|
`<rsm:SupplyChainTradeTransaction>${lines.join("")}` +
|
||||||
|
`<ram:ApplicableHeaderTradeAgreement>${el("ram:BuyerReference", context.project?.customerRef)}${seller}${buyer}` +
|
||||||
|
(contractReference ? `<ram:ContractReferencedDocument>${contractReference}</ram:ContractReferencedDocument>` : "") +
|
||||||
|
`${projectReference}</ram:ApplicableHeaderTradeAgreement>` +
|
||||||
|
`<ram:ApplicableHeaderTradeDelivery>${deliveryEvent}</ram:ApplicableHeaderTradeDelivery>` +
|
||||||
|
`<ram:ApplicableHeaderTradeSettlement>` +
|
||||||
|
(document.payment_type === "direct-debit" ? el("ram:CreditorReferenceID", context.tenant?.creditorId) : "") +
|
||||||
|
el("ram:PaymentReference", document.documentNumber) +
|
||||||
|
`<ram:InvoiceCurrencyCode>EUR</ram:InvoiceCurrencyCode>${paymentMeans}${taxElements}${billingPeriod}${paymentTerms}` +
|
||||||
|
`<ram:SpecifiedTradeSettlementHeaderMonetarySummation>` +
|
||||||
|
`<ram:LineTotalAmount>${amount(lineNet)}</ram:LineTotalAmount>` +
|
||||||
|
`<ram:TaxBasisTotalAmount>${amount(lineNet)}</ram:TaxBasisTotalAmount>` +
|
||||||
|
`<ram:TaxTotalAmount currencyID="EUR">${amount(taxTotal)}</ram:TaxTotalAmount>` +
|
||||||
|
`<ram:GrandTotalAmount>${amount(grossTotal)}</ram:GrandTotalAmount>` +
|
||||||
|
(prepaid ? `<ram:TotalPrepaidAmount>${amount(prepaid)}</ram:TotalPrepaidAmount>` : "") +
|
||||||
|
`<ram:DuePayableAmount>${amount(payable)}</ram:DuePayableAmount>` +
|
||||||
|
`</ram:SpecifiedTradeSettlementHeaderMonetarySummation>${precedingInvoice}` +
|
||||||
|
`</ram:ApplicableHeaderTradeSettlement></rsm:SupplyChainTradeTransaction></rsm:CrossIndustryInvoice>`
|
||||||
|
|
||||||
|
return { xml, warnings, totals: { lineNet, tax: taxTotal, gross: grossTotal, prepaid, payable } }
|
||||||
|
}
|
||||||
80
backend/src/modules/einvoice/embedZugferd.ts
Normal file
80
backend/src/modules/einvoice/embedZugferd.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import { AFRelationship, PDFDocument, PDFName } from "pdf-lib"
|
||||||
|
import { ZUGFERD_FILENAME } from "./buildZugferdInvoice"
|
||||||
|
|
||||||
|
const escapeXml = (value: string) => value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">")
|
||||||
|
|
||||||
|
function buildXmp(title: string, now: Date) {
|
||||||
|
const date = now.toISOString()
|
||||||
|
return `<?xpacket begin="" id="W5M0MpCehiHzreSzNTczkc9d"?>
|
||||||
|
<x:xmpmeta xmlns:x="adobe:ns:meta/">
|
||||||
|
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
|
||||||
|
<rdf:Description rdf:about="" xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||||
|
<dc:format>application/pdf</dc:format>
|
||||||
|
<dc:title><rdf:Alt><rdf:li xml:lang="x-default">${escapeXml(title)}</rdf:li></rdf:Alt></dc:title>
|
||||||
|
</rdf:Description>
|
||||||
|
<rdf:Description rdf:about="" xmlns:xmp="http://ns.adobe.com/xap/1.0/">
|
||||||
|
<xmp:CreatorTool>FEDEO</xmp:CreatorTool>
|
||||||
|
<xmp:CreateDate>${date}</xmp:CreateDate>
|
||||||
|
<xmp:ModifyDate>${date}</xmp:ModifyDate>
|
||||||
|
<xmp:MetadataDate>${date}</xmp:MetadataDate>
|
||||||
|
</rdf:Description>
|
||||||
|
<rdf:Description rdf:about="" xmlns:pdf="http://ns.adobe.com/pdf/1.3/">
|
||||||
|
<pdf:Producer>FEDEO</pdf:Producer>
|
||||||
|
</rdf:Description>
|
||||||
|
<rdf:Description rdf:about="" xmlns:fx="urn:factur-x:pdfa:CrossIndustryDocument:invoice:1p0#">
|
||||||
|
<fx:DocumentType>INVOICE</fx:DocumentType>
|
||||||
|
<fx:DocumentFileName>${ZUGFERD_FILENAME}</fx:DocumentFileName>
|
||||||
|
<fx:Version>1.0</fx:Version>
|
||||||
|
<fx:ConformanceLevel>EN 16931</fx:ConformanceLevel>
|
||||||
|
</rdf:Description>
|
||||||
|
<rdf:Description rdf:about="" 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#">
|
||||||
|
<pdfaExtension:schemas><rdf:Bag><rdf:li rdf:parseType="Resource">
|
||||||
|
<pdfaSchema:schema>Factur-X PDFA Extension Schema</pdfaSchema:schema>
|
||||||
|
<pdfaSchema:namespaceURI>urn:factur-x:pdfa:CrossIndustryDocument:invoice:1p0#</pdfaSchema:namespaceURI>
|
||||||
|
<pdfaSchema:prefix>fx</pdfaSchema:prefix>
|
||||||
|
<pdfaSchema:property><rdf:Seq>
|
||||||
|
<rdf:li rdf:parseType="Resource"><pdfaProperty:name>DocumentFileName</pdfaProperty:name><pdfaProperty:valueType>Text</pdfaProperty:valueType><pdfaProperty:category>external</pdfaProperty:category><pdfaProperty:description>name of the embedded XML invoice file</pdfaProperty:description></rdf:li>
|
||||||
|
<rdf:li rdf:parseType="Resource"><pdfaProperty:name>DocumentType</pdfaProperty:name><pdfaProperty:valueType>Text</pdfaProperty:valueType><pdfaProperty:category>external</pdfaProperty:category><pdfaProperty:description>INVOICE</pdfaProperty:description></rdf:li>
|
||||||
|
<rdf:li rdf:parseType="Resource"><pdfaProperty:name>Version</pdfaProperty:name><pdfaProperty:valueType>Text</pdfaProperty:valueType><pdfaProperty:category>external</pdfaProperty:category><pdfaProperty:description>The actual version of the Factur-X XML schema</pdfaProperty:description></rdf:li>
|
||||||
|
<rdf:li rdf:parseType="Resource"><pdfaProperty:name>ConformanceLevel</pdfaProperty:name><pdfaProperty:valueType>Text</pdfaProperty:valueType><pdfaProperty:category>external</pdfaProperty:category><pdfaProperty:description>The conformance level of the embedded Factur-X data</pdfaProperty:description></rdf:li>
|
||||||
|
</rdf:Seq></pdfaSchema:property>
|
||||||
|
</rdf:li></rdf:Bag></pdfaExtension:schemas>
|
||||||
|
</rdf:Description>
|
||||||
|
</rdf:RDF>
|
||||||
|
</x:xmpmeta>
|
||||||
|
<?xpacket end="w"?>`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bettet das CII-XML als factur-x.xml (AFRelationship Alternative) in die PDF ein und ergänzt die
|
||||||
|
* Factur-X/ZUGFeRD-XMP-Metadaten. Ergebnis ist eine hybride ZUGFeRD-Rechnung (PDF + strukturierte Daten).
|
||||||
|
*/
|
||||||
|
export async function embedZugferdXml(pdfBytes: Uint8Array | Buffer, xml: string, options: { title?: string } = {}) {
|
||||||
|
const pdfDoc = await PDFDocument.load(pdfBytes, { updateMetadata: false })
|
||||||
|
const now = new Date()
|
||||||
|
const title = options.title || "Rechnung"
|
||||||
|
|
||||||
|
await pdfDoc.attach(Buffer.from(xml, "utf8"), ZUGFERD_FILENAME, {
|
||||||
|
mimeType: "text/xml",
|
||||||
|
description: "Factur-X/ZUGFeRD Rechnung",
|
||||||
|
creationDate: now,
|
||||||
|
modificationDate: now,
|
||||||
|
afRelationship: AFRelationship.Alternative,
|
||||||
|
})
|
||||||
|
|
||||||
|
pdfDoc.setTitle(title, { showInWindowTitleBar: true })
|
||||||
|
pdfDoc.setProducer("FEDEO")
|
||||||
|
pdfDoc.setCreator("FEDEO")
|
||||||
|
pdfDoc.setCreationDate(now)
|
||||||
|
pdfDoc.setModificationDate(now)
|
||||||
|
pdfDoc.setLanguage("de-DE")
|
||||||
|
|
||||||
|
const metadata = pdfDoc.context.stream(Buffer.from(buildXmp(title, now), "utf8"), {
|
||||||
|
Type: "Metadata",
|
||||||
|
Subtype: "XML",
|
||||||
|
})
|
||||||
|
pdfDoc.catalog.set(PDFName.of("Metadata"), pdfDoc.context.register(metadata))
|
||||||
|
pdfDoc.setSubject("ZUGFeRD / Factur-X EN 16931")
|
||||||
|
|
||||||
|
return Buffer.from(await pdfDoc.save({ useObjectStreams: false }))
|
||||||
|
}
|
||||||
87
backend/src/modules/einvoice/outgoingZugferd.service.ts
Normal file
87
backend/src/modules/einvoice/outgoingZugferd.service.ts
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import { FastifyInstance } from "fastify"
|
||||||
|
import { and, eq, inArray } from "drizzle-orm"
|
||||||
|
import {
|
||||||
|
authProfiles,
|
||||||
|
bankaccounts,
|
||||||
|
contacts,
|
||||||
|
contracts,
|
||||||
|
createddocuments,
|
||||||
|
customers,
|
||||||
|
outgoingsepamandates,
|
||||||
|
products,
|
||||||
|
projects,
|
||||||
|
services,
|
||||||
|
tenants,
|
||||||
|
units,
|
||||||
|
} from "../../../db/schema"
|
||||||
|
import { buildZugferdInvoiceXml, ZUGFERD_DOCUMENT_TYPES, ZugferdInvoiceContext } from "./buildZugferdInvoice"
|
||||||
|
import { embedZugferdXml } from "./embedZugferd"
|
||||||
|
|
||||||
|
export const isZugferdDocument = (document: any) =>
|
||||||
|
Boolean(document && ZUGFERD_DOCUMENT_TYPES.includes(document.type) && document.documentNumber)
|
||||||
|
|
||||||
|
const byId = async (server: FastifyInstance, table: any, id: unknown, tenantId: number) => {
|
||||||
|
if (id === null || id === undefined || id === "") return null
|
||||||
|
const [row] = await server.db.select().from(table).where(and(eq(table.id, Number(id)), eq(table.tenant, tenantId))).limit(1)
|
||||||
|
return row || null
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadZugferdContext(server: FastifyInstance, tenantId: number, document: any): Promise<ZugferdInvoiceContext> {
|
||||||
|
const rows = document.rows || []
|
||||||
|
const productIds = [...new Set(rows.filter((row: any) => row.product).map((row: any) => Number(row.product)))] as number[]
|
||||||
|
const serviceIds = [...new Set(rows.filter((row: any) => row.service).map((row: any) => Number(row.service)))] as number[]
|
||||||
|
const advanceIds = (Array.isArray(document.usedAdvanceInvoices) ? document.usedAdvanceInvoices : []).map(Number) as number[]
|
||||||
|
const profileUser = document.contactPerson || document.created_by
|
||||||
|
|
||||||
|
const [[tenant], customer, contact, project, contract, linkedDocument, sepaMandate, unitRows, productRows, serviceRows, bankAccountRows, advanceRows, profileRows] = await Promise.all([
|
||||||
|
server.db.select().from(tenants).where(eq(tenants.id, tenantId)).limit(1),
|
||||||
|
byId(server, customers, document.customer, tenantId),
|
||||||
|
byId(server, contacts, document.contact, tenantId),
|
||||||
|
byId(server, projects, document.project, tenantId),
|
||||||
|
byId(server, contracts, document.contract, tenantId),
|
||||||
|
byId(server, createddocuments, document.createddocument ?? document.linkedDocument, tenantId),
|
||||||
|
byId(server, outgoingsepamandates, document.outgoingsepamandate, tenantId),
|
||||||
|
server.db.select().from(units),
|
||||||
|
productIds.length ? server.db.select().from(products).where(and(eq(products.tenant, tenantId), inArray(products.id, productIds))) : [],
|
||||||
|
serviceIds.length ? server.db.select().from(services).where(and(eq(services.tenant, tenantId), inArray(services.id, serviceIds))) : [],
|
||||||
|
server.db.select().from(bankaccounts).where(and(eq(bankaccounts.tenant, tenantId), eq(bankaccounts.archived, false))),
|
||||||
|
advanceIds.length ? server.db.select().from(createddocuments).where(and(eq(createddocuments.tenant, tenantId), inArray(createddocuments.id, advanceIds))) : [],
|
||||||
|
profileUser
|
||||||
|
? server.db.select().from(authProfiles).where(and(eq(authProfiles.user_id, profileUser), eq(authProfiles.tenant_id, tenantId))).limit(1)
|
||||||
|
: [],
|
||||||
|
])
|
||||||
|
|
||||||
|
return {
|
||||||
|
tenant,
|
||||||
|
customer,
|
||||||
|
contact,
|
||||||
|
contactPerson: (profileRows as any[])[0] || null,
|
||||||
|
project,
|
||||||
|
contract,
|
||||||
|
linkedDocument,
|
||||||
|
sepaMandate,
|
||||||
|
units: unitRows,
|
||||||
|
products: productRows as any[],
|
||||||
|
services: serviceRows as any[],
|
||||||
|
bankAccounts: bankAccountRows,
|
||||||
|
advanceInvoices: advanceIds.map((id) => (advanceRows as any[]).find((invoice) => invoice.id === id)).filter(Boolean),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Macht aus der gerenderten Rechnungs-PDF eine ZUGFeRD-Rechnung. Fehler bei der E-Rechnung blockieren
|
||||||
|
* die Belegerstellung nicht: dann wird die unveränderte PDF mit einer Warnung zurückgegeben.
|
||||||
|
*/
|
||||||
|
export async function attachZugferdToPdf(server: FastifyInstance, tenantId: number, document: any, pdf: Buffer) {
|
||||||
|
if (!isZugferdDocument(document)) return { pdf, warnings: [] as string[], einvoice: false }
|
||||||
|
try {
|
||||||
|
const context = await loadZugferdContext(server, tenantId, document)
|
||||||
|
const { xml, warnings } = buildZugferdInvoiceXml(document, context)
|
||||||
|
const result = await embedZugferdXml(pdf, xml, { title: document.title || `Rechnung ${document.documentNumber}` })
|
||||||
|
if (warnings.length) server.log.warn({ documentId: document.id, warnings }, "E-Rechnung mit Hinweisen erzeugt")
|
||||||
|
return { pdf: result, warnings, einvoice: true }
|
||||||
|
} catch (error) {
|
||||||
|
server.log.error({ err: error, documentId: document.id }, "E-Rechnung konnte nicht erzeugt werden")
|
||||||
|
return { pdf, warnings: ["E-Rechnung konnte nicht erzeugt werden"], einvoice: false }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import { saveFile } from "../utils/files";
|
|||||||
import {FastifyInstance} from "fastify";
|
import {FastifyInstance} from "fastify";
|
||||||
import {useNextNumberRangeNumber} from "../utils/functions";
|
import {useNextNumberRangeNumber} from "../utils/functions";
|
||||||
import {createInvoicePDF} from "../utils/pdf"; // Achtung: Muss Node.js Buffer unterstützen!
|
import {createInvoicePDF} from "../utils/pdf"; // Achtung: Muss Node.js Buffer unterstützen!
|
||||||
|
import { attachZugferdToPdf } from "./einvoice/outgoingZugferd.service";
|
||||||
import { documentTemplateHandlebars } from "../utils/handlebars";
|
import { documentTemplateHandlebars } from "../utils/handlebars";
|
||||||
import { appendCashDiscountText, buildCashDiscountTerms, getCashDiscountTemplateContext } from "./cash-discount"
|
import { appendCashDiscountText, buildCashDiscountTerms, getCashDiscountTemplateContext } from "./cash-discount"
|
||||||
|
|
||||||
@@ -233,8 +234,14 @@ export const finishManualGeneration = async (server: FastifyInstance, executionI
|
|||||||
console.log(pdfBase64);
|
console.log(pdfBase64);
|
||||||
|
|
||||||
// E. Datei speichern
|
// E. Datei speichern
|
||||||
|
// E-Rechnung (ZUGFeRD) in die PDF einbetten; Grundlage ist der Beleg mit der neuen Nummer
|
||||||
|
const { pdf: fileBuffer } = await attachZugferdToPdf(
|
||||||
|
server,
|
||||||
|
tenantId,
|
||||||
|
{ ...doc, state: "Gebucht", documentNumber: pdfData.documentNumber, title: pdfData.title },
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
const fileBuffer = Buffer.from(pdfBase64.base64, "base64");
|
Buffer.from(pdfBase64.base64, "base64"),
|
||||||
|
);
|
||||||
const filename = `${pdfData.documentNumber}.pdf`;
|
const filename = `${pdfData.documentNumber}.pdf`;
|
||||||
|
|
||||||
await saveFile(
|
await saveFile(
|
||||||
|
|||||||
@@ -18,7 +18,8 @@ import isSameOrBefore from "dayjs/plugin/isSameOrBefore.js"
|
|||||||
import duration from "dayjs/plugin/duration.js";
|
import duration from "dayjs/plugin/duration.js";
|
||||||
import timezone from "dayjs/plugin/timezone.js";
|
import timezone from "dayjs/plugin/timezone.js";
|
||||||
import {generateTimesEvaluation} from "../modules/time/evaluation.service";
|
import {generateTimesEvaluation} from "../modules/time/evaluation.service";
|
||||||
import {citys, files} from "../../db/schema";
|
import {citys, createddocuments, files} from "../../db/schema";
|
||||||
|
import { attachZugferdToPdf } from "../modules/einvoice/outgoingZugferd.service";
|
||||||
import {and, eq, isNull, not} from "drizzle-orm";
|
import {and, eq, isNull, not} from "drizzle-orm";
|
||||||
import {cancelManualGeneration, executeManualGeneration, finishManualGeneration} from "../modules/serialexecution.service";
|
import {cancelManualGeneration, executeManualGeneration, finishManualGeneration} from "../modules/serialexecution.service";
|
||||||
import { updateOutgoingDocumentCostCentres } from "../modules/outgoing-document-cost-centres.service";
|
import { updateOutgoingDocumentCostCentres } from "../modules/outgoing-document-cost-centres.service";
|
||||||
@@ -110,6 +111,8 @@ export default async function functionRoutes(server: FastifyInstance) {
|
|||||||
const body = req.body as {
|
const body = req.body as {
|
||||||
data: any
|
data: any
|
||||||
backgroundPath?: string
|
backgroundPath?: string
|
||||||
|
// Gespeicherter Beleg, aus dem die eingebettete E-Rechnung (ZUGFeRD) erzeugt wird
|
||||||
|
createdDocumentId?: number
|
||||||
}
|
}
|
||||||
const {type} = req.params as {type:string}
|
const {type} = req.params as {type:string}
|
||||||
|
|
||||||
@@ -125,6 +128,17 @@ export default async function functionRoutes(server: FastifyInstance) {
|
|||||||
body.data,
|
body.data,
|
||||||
body.backgroundPath
|
body.backgroundPath
|
||||||
)
|
)
|
||||||
|
if (body.createdDocumentId) {
|
||||||
|
const tenantId = (req as any).user.tenant_id
|
||||||
|
const [document] = await server.db
|
||||||
|
.select()
|
||||||
|
.from(createddocuments)
|
||||||
|
.where(and(eq(createddocuments.id, Number(body.createdDocumentId)), eq(createddocuments.tenant, tenantId)))
|
||||||
|
.limit(1)
|
||||||
|
if (!document) return reply.code(404).send({ error: "Beleg nicht gefunden" })
|
||||||
|
const result = await attachZugferdToPdf(server, tenantId, document, Buffer.from(pdf.base64, "base64"))
|
||||||
|
pdf = { ...pdf, base64: result.pdf.toString("base64"), einvoice: result.einvoice, einvoiceWarnings: result.warnings }
|
||||||
|
}
|
||||||
} else if(type === "timesheet") {
|
} else if(type === "timesheet") {
|
||||||
pdf = await createTimeSheetPDF(
|
pdf = await createTimeSheetPDF(
|
||||||
server,
|
server,
|
||||||
|
|||||||
172
backend/tests/zugferdInvoice.test.ts
Normal file
172
backend/tests/zugferdInvoice.test.ts
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
import assert from "node:assert/strict"
|
||||||
|
import test from "node:test"
|
||||||
|
import { PDFDocument, StandardFonts } from "pdf-lib"
|
||||||
|
import { buildZugferdInvoiceXml, countryCode, unitCode, ZugferdInvoiceContext } from "../src/modules/einvoice/buildZugferdInvoice"
|
||||||
|
import { embedZugferdXml } from "../src/modules/einvoice/embedZugferd"
|
||||||
|
import { detectElectronicInvoice } from "../src/modules/einvoice/detectElectronicInvoice"
|
||||||
|
import { parseElectronicInvoice } from "../src/modules/einvoice/parseElectronicInvoice"
|
||||||
|
|
||||||
|
const context = (overrides: Partial<ZugferdInvoiceContext> = {}): ZugferdInvoiceContext => ({
|
||||||
|
tenant: {
|
||||||
|
name: "Muster GmbH",
|
||||||
|
creditorId: "DE98ZZZ09999999999",
|
||||||
|
businessInfo: {
|
||||||
|
name: "Muster GmbH", street: "Hauptstraße 1", zip: "12345", city: "Musterstadt",
|
||||||
|
vatId: "DE123456789", taxNumber: "12/345/67890", email: "rechnung@muster.de",
|
||||||
|
iban: "DE02120300000000202051", bic: "BYLADEM1001", bankAccountOwner: "Muster GmbH",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
customer: {
|
||||||
|
name: "Kunde & Söhne KG", customerNumber: "10001",
|
||||||
|
infoData: { street: "Kundenweg 5", zip: "54321", city: "Kundenstadt", country: "Deutschland", ustid: "DE987654321", invoiceEmail: "buchhaltung@kunde.de" },
|
||||||
|
},
|
||||||
|
contactPerson: { full_name: "Erika Muster", email: "erika@muster.de" },
|
||||||
|
units: [{ id: 1, short: "Stk" }, { id: 6, short: "Std" }],
|
||||||
|
products: [{ id: 7, name: "Kabel NYM 3x1,5", article_number: "A-7" }],
|
||||||
|
services: [{ id: 3, name: "Montage" }],
|
||||||
|
bankAccounts: [],
|
||||||
|
advanceInvoices: [],
|
||||||
|
...overrides,
|
||||||
|
})
|
||||||
|
|
||||||
|
const invoice = (overrides: any = {}) => ({
|
||||||
|
id: 1,
|
||||||
|
type: "invoices",
|
||||||
|
documentNumber: "RE-2026-0042",
|
||||||
|
documentDate: "2026-11-04",
|
||||||
|
deliveryDateType: "Lieferdatum",
|
||||||
|
deliveryDate: "2026-11-02",
|
||||||
|
paymentDays: 14,
|
||||||
|
payment_type: "transfer",
|
||||||
|
taxType: "Standard",
|
||||||
|
title: "Rechnung-Nr. RE-2026-0042",
|
||||||
|
address: { street: "Kundenweg 5", zip: "54321", city: "Kundenstadt" },
|
||||||
|
rows: [
|
||||||
|
{ pos: "1", mode: "normal", product: 7, quantity: 10, unit: 1, price: 2.345, discountPercent: 0, taxPercent: 19 },
|
||||||
|
{ pos: "2", mode: "service", service: 3, quantity: 1.5, unit: 6, price: 60, discountPercent: 10, taxPercent: 19 },
|
||||||
|
{ pos: "3", mode: "free", text: "Fachbuch", quantity: 1, unit: 1, price: 20, discountPercent: 0, taxPercent: 7 },
|
||||||
|
{ mode: "title", text: "Optionales" },
|
||||||
|
{ pos: "4", mode: "free", text: "Optional", quantity: 1, unit: 1, price: 999, discountPercent: 0, taxPercent: 19, optional: true },
|
||||||
|
{ pos: "5", mode: "free", text: "Gutschrift Altmaterial", quantity: 1, unit: 1, price: -5, discountPercent: 0, taxPercent: 19 },
|
||||||
|
],
|
||||||
|
...overrides,
|
||||||
|
})
|
||||||
|
|
||||||
|
const tag = (xml: string, name: string) => [...xml.matchAll(new RegExp(`<ram:${name}(?:\\s[^>]*)?>([^<]*)</ram:${name}>`, "g"))].map((match) => match[1])
|
||||||
|
|
||||||
|
test("erzeugt eine EN-16931-Rechnung mit konsistenten Summen", () => {
|
||||||
|
const { xml, warnings, totals } = buildZugferdInvoiceXml(invoice(), context())
|
||||||
|
|
||||||
|
assert.deepEqual(warnings, [])
|
||||||
|
// 23,45 + 81,00 + 20,00 - 5,00 (optionale Position ausgeschlossen)
|
||||||
|
assert.equal(totals.lineNet, 119.45)
|
||||||
|
// 19 %: 99,45 -> 18,90; 7 %: 20,00 -> 1,40
|
||||||
|
assert.equal(totals.tax, 20.3)
|
||||||
|
assert.equal(totals.gross, 139.75)
|
||||||
|
assert.equal(totals.payable, 139.75)
|
||||||
|
assert.match(xml, /<ram:ID>urn:cen.eu:en16931:2017<\/ram:ID>/)
|
||||||
|
assert.match(xml, /<ram:TypeCode>380<\/ram:TypeCode>/)
|
||||||
|
assert.match(xml, /Kunde & Söhne KG/)
|
||||||
|
assert.match(xml, /<ram:IBANID>DE02120300000000202051<\/ram:IBANID>/)
|
||||||
|
assert.match(xml, /<ram:ID schemeID="VA">DE987654321<\/ram:ID>/)
|
||||||
|
assert.match(xml, /<ram:SellerAssignedID>A-7<\/ram:SellerAssignedID>/)
|
||||||
|
assert.match(xml, /<ram:DueDateDateTime><udt:DateTimeString format="102">20261118<\/udt:DateTimeString>/)
|
||||||
|
assert.match(xml, /<ram:OccurrenceDateTime><udt:DateTimeString format="102">20261102/)
|
||||||
|
// Negative Preise werden als negative Menge mit positivem Nettopreis abgebildet (BR-27).
|
||||||
|
assert.match(xml, /<ram:ChargeAmount>5<\/ram:ChargeAmount><\/ram:NetPriceProductTradePrice><\/ram:SpecifiedLineTradeAgreement><ram:SpecifiedLineTradeDelivery><ram:BilledQuantity unitCode="H87">-1</)
|
||||||
|
assert.ok(tag(xml, "ChargeAmount").every((value) => Number(value) >= 0))
|
||||||
|
// vier Positionen plus zwei Steuergruppen (19 % und 7 %)
|
||||||
|
assert.deepEqual(tag(xml, "CategoryCode"), ["S", "S", "S", "S", "S", "S"])
|
||||||
|
assert.deepEqual(tag(xml, "RateApplicablePercent").slice(-2), ["19", "7"])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("das eigene XML wird vom FEDEO-Parser ohne Fehler gelesen", async () => {
|
||||||
|
const { xml } = buildZugferdInvoiceXml(invoice(), context())
|
||||||
|
const parsed = parseElectronicInvoice(Buffer.from(xml), "cii")
|
||||||
|
|
||||||
|
assert.deepEqual(parsed.validation.errors, [])
|
||||||
|
assert.equal(parsed.invoiceNumber, "RE-2026-0042")
|
||||||
|
assert.equal(parsed.seller.vatId, "DE123456789")
|
||||||
|
assert.equal(parsed.totals.gross, 139.75)
|
||||||
|
assert.equal(parsed.items.length, 4)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("bettet das XML als factur-x.xml in die PDF ein", async () => {
|
||||||
|
const source = await PDFDocument.create()
|
||||||
|
const page = source.addPage()
|
||||||
|
page.drawText("Rechnung", { font: await source.embedFont(StandardFonts.Helvetica), x: 50, y: 700 })
|
||||||
|
const { xml } = buildZugferdInvoiceXml(invoice(), context())
|
||||||
|
|
||||||
|
const pdf = await embedZugferdXml(await source.save(), xml, { title: "Rechnung RE-2026-0042" })
|
||||||
|
const detection = await detectElectronicInvoice(pdf, { name: "RE-2026-0042.pdf", mimeType: "application/pdf" })
|
||||||
|
|
||||||
|
assert.equal(detection?.container, "pdf")
|
||||||
|
assert.equal(detection?.syntax, "cii")
|
||||||
|
assert.equal(detection?.attachmentName, "factur-x.xml")
|
||||||
|
assert.equal(detection?.xml.toString("utf8"), xml)
|
||||||
|
const raw = pdf.toString("latin1")
|
||||||
|
assert.match(raw, /\/AFRelationship \/Alternative/)
|
||||||
|
assert.match(raw, /<fx:ConformanceLevel>EN 16931<\/fx:ConformanceLevel>/)
|
||||||
|
assert.equal((await PDFDocument.load(pdf)).getPageCount(), 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("Stornorechnung wird als Gutschrift mit positiven Beträgen und Bezug zur Rechnung ausgegeben", () => {
|
||||||
|
const { xml, totals } = buildZugferdInvoiceXml(invoice({
|
||||||
|
type: "cancellationInvoices",
|
||||||
|
documentNumber: "RE-2026-0043",
|
||||||
|
rows: [{ pos: "1", mode: "free", text: "Montage", quantity: 2, unit: 6, price: -50, discountPercent: 0, taxPercent: 19 }],
|
||||||
|
}), context({ linkedDocument: { documentNumber: "RE-2026-0042", documentDate: "2026-11-04" } }))
|
||||||
|
|
||||||
|
assert.match(xml, /<ram:TypeCode>381<\/ram:TypeCode>/)
|
||||||
|
assert.equal(totals.gross, 119)
|
||||||
|
assert.match(xml, /<ram:BilledQuantity unitCode="HUR">2<\/ram:BilledQuantity>/)
|
||||||
|
assert.match(xml, /<ram:InvoiceReferencedDocument><ram:IssuerAssignedID>RE-2026-0042<\/ram:IssuerAssignedID>/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("Schlussrechnung zieht Abschläge als bereits gezahlten Betrag ab", () => {
|
||||||
|
const { xml, totals } = buildZugferdInvoiceXml(invoice({
|
||||||
|
rows: [{ pos: "1", mode: "free", text: "Gesamtleistung", quantity: 1, unit: 1, price: 1000, discountPercent: 0, taxPercent: 19 }],
|
||||||
|
}), context({ advanceInvoices: [{ rows: [{ advanceInvoiceData: {}, price: 500, taxPercent: 19 }] }] }))
|
||||||
|
|
||||||
|
assert.equal(totals.gross, 1190)
|
||||||
|
assert.equal(totals.prepaid, 595)
|
||||||
|
assert.equal(totals.payable, 595)
|
||||||
|
assert.match(xml, /<ram:TotalPrepaidAmount>595.00<\/ram:TotalPrepaidAmount><ram:DuePayableAmount>595.00<\/ram:DuePayableAmount>/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("§ 13b und Kleinunternehmer verwenden die passenden Steuerkategorien", () => {
|
||||||
|
const reverseCharge = buildZugferdInvoiceXml(invoice({ taxType: "13b UStG", rows: [{ pos: "1", mode: "free", text: "Bauleistung", quantity: 1, unit: 1, price: 100, taxPercent: 0 }] }), context())
|
||||||
|
assert.match(reverseCharge.xml, /<ram:CategoryCode>AE<\/ram:CategoryCode><ram:ExemptionReasonCode>VATEX-EU-AE<\/ram:ExemptionReasonCode>/)
|
||||||
|
assert.equal(reverseCharge.totals.tax, 0)
|
||||||
|
|
||||||
|
const smallBusiness = buildZugferdInvoiceXml(invoice({ taxType: "19 UStG", rows: [{ pos: "1", mode: "free", text: "Beratung", quantity: 1, unit: 1, price: 100, taxPercent: 0 }] }), context())
|
||||||
|
assert.match(smallBusiness.xml, /<ram:ExemptionReason>Kein Ausweis von Umsatzsteuer, da Kleinunternehmer gemäß § 19 UStG<\/ram:ExemptionReason>/)
|
||||||
|
assert.match(smallBusiness.xml, /<ram:CategoryCode>E<\/ram:CategoryCode>/)
|
||||||
|
|
||||||
|
const missingVat = buildZugferdInvoiceXml(invoice({ taxType: "13b UStG" }), context({ customer: { name: "Privat", infoData: { city: "X" } } }))
|
||||||
|
assert.ok(missingVat.warnings.some((warning) => warning.includes("USt-IdNr. des Kunden")))
|
||||||
|
})
|
||||||
|
|
||||||
|
test("Leistungszeitraum, Lastschrift und Skonto werden strukturiert übertragen", () => {
|
||||||
|
const { xml, warnings } = buildZugferdInvoiceXml(invoice({
|
||||||
|
deliveryDateType: "Leistungszeitraum", deliveryDate: "2026-10-01", deliveryDateEnd: "2026-10-31",
|
||||||
|
payment_type: "direct-debit", cashDiscountPercent: 2, cashDiscountDays: 7,
|
||||||
|
}), context({ sepaMandate: { reference: "MANDAT-1" } }))
|
||||||
|
|
||||||
|
assert.deepEqual(warnings, [])
|
||||||
|
assert.match(xml, /<ram:BillingSpecifiedPeriod><ram:StartDateTime><udt:DateTimeString format="102">20261001/)
|
||||||
|
assert.match(xml, /<ram:CreditorReferenceID>DE98ZZZ09999999999<\/ram:CreditorReferenceID>/)
|
||||||
|
assert.match(xml, /<ram:TypeCode>59<\/ram:TypeCode>/)
|
||||||
|
assert.match(xml, /<ram:DirectDebitMandateID>MANDAT-1<\/ram:DirectDebitMandateID>/)
|
||||||
|
assert.match(xml, /#SKONTO#TAGE=7#PROZENT=2.00#BASISBETRAG=139.75#/)
|
||||||
|
assert.doesNotMatch(xml, /ActualDeliverySupplyChainEvent/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("ordnet Länder und Einheiten den Codelisten zu", () => {
|
||||||
|
assert.equal(countryCode("Österreich"), "AT")
|
||||||
|
assert.equal(countryCode(""), "DE")
|
||||||
|
assert.equal(countryCode("ch"), "CH")
|
||||||
|
assert.equal(unitCode({ short: "Std" }), "HUR")
|
||||||
|
assert.equal(unitCode({ short: "m²" }), "MTK")
|
||||||
|
assert.equal(unitCode({ short: "unbekannt" }), "C62")
|
||||||
|
})
|
||||||
@@ -34,15 +34,19 @@ export const useFunctions = () => {
|
|||||||
return (await useNuxtApp().$api(`/api/banking/link/${institutionId}`)).link
|
return (await useNuxtApp().$api(`/api/banking/link/${institutionId}`)).link
|
||||||
}
|
}
|
||||||
|
|
||||||
const useCreatePDF = async (data,path,type) => {
|
// options.createdDocumentId: gespeicherter Beleg, aus dem das Backend die eingebettete E-Rechnung (ZUGFeRD) erzeugt
|
||||||
|
const useCreatePDF = async (data,path,type,options = {}) => {
|
||||||
const returnData = await useNuxtApp().$api(`/api/functions/pdf/${type}`, {
|
const returnData = await useNuxtApp().$api(`/api/functions/pdf/${type}`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: {
|
body: {
|
||||||
data: data,
|
data: data,
|
||||||
backgroundPath: path,
|
backgroundPath: path,
|
||||||
|
...(options.createdDocumentId ? { createdDocumentId: options.createdDocumentId } : {}),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (options.onResult) options.onResult(returnData)
|
||||||
|
|
||||||
console.log(returnData)
|
console.log(returnData)
|
||||||
|
|
||||||
return `data:${returnData.mimeType};base64,${returnData.base64}`
|
return `data:${returnData.mimeType};base64,${returnData.base64}`
|
||||||
|
|||||||
@@ -1764,7 +1764,7 @@ const getDocumentData = async () => {
|
|||||||
|
|
||||||
const showDocument = ref(false)
|
const showDocument = ref(false)
|
||||||
const uri = ref("")
|
const uri = ref("")
|
||||||
const generateDocument = async () => {
|
const generateDocument = async (options = {}) => {
|
||||||
showDocument.value = false
|
showDocument.value = false
|
||||||
const selectedLetterhead = getSelectedLetterhead()
|
const selectedLetterhead = getSelectedLetterhead()
|
||||||
|
|
||||||
@@ -1775,7 +1775,7 @@ const generateDocument = async () => {
|
|||||||
|
|
||||||
const path = selectedLetterhead.path
|
const path = selectedLetterhead.path
|
||||||
|
|
||||||
uri.value = await useFunctions().useCreatePDF(await getDocumentData(), path, "createdDocument")
|
uri.value = await useFunctions().useCreatePDF(await getDocumentData(), path, "createdDocument", options)
|
||||||
/*uri.value = await useNuxtApp().$api("/api/functions/createinvoicepdf",{
|
/*uri.value = await useNuxtApp().$api("/api/functions/createinvoicepdf",{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: {
|
body: {
|
||||||
@@ -2048,7 +2048,19 @@ const closeDocument = async () => {
|
|||||||
|
|
||||||
// Erzeugt die PDF aus dem aktuellen Stand und hängt sie an den Beleg an, ohne den Datensatz zu speichern
|
// Erzeugt die PDF aus dem aktuellen Stand und hängt sie an den Beleg an, ohne den Datensatz zu speichern
|
||||||
const uploadGeneratedDocument = async () => {
|
const uploadGeneratedDocument = async () => {
|
||||||
await generateDocument()
|
// Rechnungen werden als E-Rechnung (ZUGFeRD) abgelegt; das XML erzeugt das Backend aus dem gespeicherten Beleg
|
||||||
|
await generateDocument({
|
||||||
|
createdDocumentId: itemInfo.value.id,
|
||||||
|
onResult: (result) => {
|
||||||
|
if (result?.einvoiceWarnings?.length) {
|
||||||
|
toast.add({
|
||||||
|
title: result.einvoice ? "E-Rechnung mit Hinweisen erstellt" : "E-Rechnung konnte nicht erstellt werden",
|
||||||
|
description: result.einvoiceWarnings.join(" · "),
|
||||||
|
color: "warning",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
if (!uri.value) throw new Error("PDF konnte nicht erzeugt werden")
|
if (!uri.value) throw new Error("PDF konnte nicht erzeugt werden")
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user