Merge remote-tracking branch 'origin/dev' into dev
This commit is contained in:
@@ -1,6 +1,13 @@
|
||||
import { FastifyInstance } from "fastify"
|
||||
import dayjs from "dayjs"
|
||||
import { getInvoiceDataFromGPT } from "../../utils/gpt"
|
||||
import { loadFileBuffer } from "../../utils/fileBuffer"
|
||||
import { detectElectronicInvoice } from "../einvoice/detectElectronicInvoice"
|
||||
import { parseElectronicInvoice } from "../einvoice/parseElectronicInvoice"
|
||||
import {
|
||||
findElectronicInvoiceVendor,
|
||||
mapElectronicInvoiceToIncomingInvoice,
|
||||
} from "../einvoice/mapElectronicInvoice"
|
||||
|
||||
// Drizzle schema
|
||||
import {
|
||||
@@ -24,6 +31,61 @@ const formatInvoiceItemDescription = (item: any) => {
|
||||
return parts.join(" - ")
|
||||
}
|
||||
|
||||
const mapGptDataToIncomingInvoice = (data: any, tenantId: number) => {
|
||||
const itemInfo: any = {
|
||||
tenant: tenantId,
|
||||
state: "Vorbereitet",
|
||||
preparationSource: "gpt",
|
||||
}
|
||||
|
||||
if (data.invoice_number) itemInfo.reference = data.invoice_number
|
||||
if (data.invoice_date && dayjs(data.invoice_date).isValid()) itemInfo.date = dayjs(data.invoice_date).toISOString()
|
||||
if (data.issuer?.id) itemInfo.vendor = data.issuer.id
|
||||
if (data.invoice_duedate && dayjs(data.invoice_duedate).isValid()) itemInfo.dueDate = dayjs(data.invoice_duedate).toISOString()
|
||||
|
||||
const mapPayment: Record<string, string> = {
|
||||
"Direct Debit": "Einzug",
|
||||
"Transfer": "Überweisung",
|
||||
"Credit Card": "Kreditkarte",
|
||||
"Other": "Sonstiges",
|
||||
}
|
||||
if (data.terms) itemInfo.paymentType = mapPayment[data.terms] ?? data.terms
|
||||
|
||||
if (data.invoice_items?.length > 0) {
|
||||
itemInfo.accounts = data.invoice_items
|
||||
.filter((item: any) => item.description || item.total !== null || item.total_without_tax !== null)
|
||||
.map((item: any) => {
|
||||
const total = typeof item.total === "number" ? item.total : null
|
||||
const totalWithoutTax = typeof item.total_without_tax === "number" ? item.total_without_tax : null
|
||||
const amountTax = total !== null && totalWithoutTax !== null
|
||||
? Number((total - totalWithoutTax).toFixed(2))
|
||||
: null
|
||||
|
||||
return {
|
||||
account: item.account_id,
|
||||
description: item.description,
|
||||
amountNet: totalWithoutTax,
|
||||
amountTax,
|
||||
taxType: item.tax_rate !== null ? String(item.tax_rate) : null,
|
||||
amountGross: total,
|
||||
costCentre: null,
|
||||
quantity: item.quantity,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
let description = ""
|
||||
if (data.delivery_note_number) description += `Lieferschein: ${data.delivery_note_number}\n`
|
||||
if (data.reference) description += `Referenz: ${data.reference}\n`
|
||||
for (const item of data.invoice_items || []) {
|
||||
const line = formatInvoiceItemDescription(item)
|
||||
if (line) description += `${line}\n`
|
||||
}
|
||||
itemInfo.description = description.trim()
|
||||
|
||||
return itemInfo
|
||||
}
|
||||
|
||||
export function prepareIncomingInvoices(server: FastifyInstance) {
|
||||
const processInvoices = async (tenantId:number) => {
|
||||
console.log("▶ Starting Incoming Invoice Preparation")
|
||||
@@ -85,75 +147,59 @@ export function prepareIncomingInvoices(server: FastifyInstance) {
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// 3️⃣ Jede Datei einzeln durch GPT jagen & IncomingInvoice erzeugen
|
||||
// 3️⃣ Strukturierte E-Rechnung bevorzugen, GPT als PDF-Fallback verwenden
|
||||
// -------------------------------------------------------------
|
||||
for (const file of filesRes) {
|
||||
console.log(`Processing file ${file.id} for tenant ${tenantId}`)
|
||||
|
||||
const data = await getInvoiceDataFromGPT(server,file, tenantId)
|
||||
let fileData: Buffer
|
||||
|
||||
if (!data) {
|
||||
server.log.warn(`GPT returned no data for file ${file.id}`)
|
||||
try {
|
||||
fileData = await loadFileBuffer(file.path!)
|
||||
} catch (error) {
|
||||
server.log.error(error, `Datei ${file.id} konnte nicht aus S3 geladen werden.`)
|
||||
continue
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// 3.1 IncomingInvoice-Objekt vorbereiten
|
||||
// ---------------------------------------------------------
|
||||
let itemInfo: any = {
|
||||
tenant: tenantId,
|
||||
state: "Vorbereitet"
|
||||
}
|
||||
const electronicInvoice = await detectElectronicInvoice(fileData, file)
|
||||
|
||||
if (data.invoice_number) itemInfo.reference = data.invoice_number
|
||||
if (data.invoice_date && dayjs(data.invoice_date).isValid()) itemInfo.date = dayjs(data.invoice_date).toISOString()
|
||||
if (data.issuer?.id) itemInfo.vendor = data.issuer.id
|
||||
if (data.invoice_duedate && dayjs(data.invoice_duedate).isValid()) itemInfo.dueDate = dayjs(data.invoice_duedate).toISOString()
|
||||
let itemInfo: any = null
|
||||
|
||||
// Payment terms mapping
|
||||
const mapPayment: any = {
|
||||
"Direct Debit": "Einzug",
|
||||
"Transfer": "Überweisung",
|
||||
"Credit Card": "Kreditkarte",
|
||||
"Other": "Sonstiges",
|
||||
}
|
||||
if (data.terms) itemInfo.paymentType = mapPayment[data.terms] ?? data.terms
|
||||
if (electronicInvoice) {
|
||||
server.log.info({
|
||||
fileId: file.id,
|
||||
container: electronicInvoice.container,
|
||||
syntax: electronicInvoice.syntax,
|
||||
attachmentName: electronicInvoice.attachmentName,
|
||||
}, "Strukturierte E-Rechnung erkannt.")
|
||||
|
||||
// 3.2 Positionszeilen konvertieren
|
||||
if (data.invoice_items?.length > 0) {
|
||||
itemInfo.accounts = data.invoice_items
|
||||
.filter(item => item.description || item.total !== null || item.total_without_tax !== null)
|
||||
.map(item => {
|
||||
const total = typeof item.total === "number" ? item.total : null
|
||||
const totalWithoutTax = typeof item.total_without_tax === "number" ? item.total_without_tax : null
|
||||
const amountTax = total !== null && totalWithoutTax !== null
|
||||
? Number((total - totalWithoutTax).toFixed(2))
|
||||
: null
|
||||
try {
|
||||
const parsedInvoice = parseElectronicInvoice(electronicInvoice.xml, electronicInvoice.syntax)
|
||||
const vendor = await findElectronicInvoiceVendor(server, tenantId, parsedInvoice)
|
||||
itemInfo = mapElectronicInvoiceToIncomingInvoice(
|
||||
parsedInvoice,
|
||||
tenantId,
|
||||
vendor?.id || null,
|
||||
electronicInvoice.container,
|
||||
electronicInvoice.attachmentName,
|
||||
)
|
||||
} catch (error) {
|
||||
server.log.error(error, `E-Rechnung aus Datei ${file.id} konnte nicht verarbeitet werden.`)
|
||||
|
||||
return {
|
||||
account: item.account_id,
|
||||
description: item.description,
|
||||
amountNet: totalWithoutTax,
|
||||
amountTax,
|
||||
taxType: item.tax_rate !== null ? String(item.tax_rate) : null,
|
||||
amountGross: total,
|
||||
costCentre: null,
|
||||
quantity: item.quantity,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 3.3 Beschreibung generieren
|
||||
let description = ""
|
||||
if (data.delivery_note_number) description += `Lieferschein: ${data.delivery_note_number}\n`
|
||||
if (data.reference) description += `Referenz: ${data.reference}\n`
|
||||
if (data.invoice_items) {
|
||||
for (const item of data.invoice_items) {
|
||||
const line = formatInvoiceItemDescription(item)
|
||||
if (line) description += `${line}\n`
|
||||
if (electronicInvoice.container === "xml") continue
|
||||
}
|
||||
}
|
||||
itemInfo.description = description.trim()
|
||||
|
||||
if (!itemInfo) {
|
||||
const data = await getInvoiceDataFromGPT(server, file, tenantId, fileData)
|
||||
|
||||
if (!data) {
|
||||
server.log.warn(`GPT returned no data for file ${file.id}`)
|
||||
continue
|
||||
}
|
||||
|
||||
itemInfo = mapGptDataToIncomingInvoice(data, tenantId)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// 4️⃣ IncomingInvoice erstellen
|
||||
|
||||
141
backend/src/modules/einvoice/detectElectronicInvoice.ts
Normal file
141
backend/src/modules/einvoice/detectElectronicInvoice.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import {
|
||||
decodePDFRawStream,
|
||||
PDFDict,
|
||||
PDFDocument,
|
||||
PDFHexString,
|
||||
PDFName,
|
||||
PDFRawStream,
|
||||
PDFString,
|
||||
} from "pdf-lib"
|
||||
|
||||
export type ElectronicInvoiceSyntax = "cii" | "ubl-invoice" | "ubl-credit-note"
|
||||
|
||||
export type ElectronicInvoiceDetection = {
|
||||
container: "pdf" | "xml"
|
||||
syntax: ElectronicInvoiceSyntax
|
||||
xml: Buffer
|
||||
attachmentName: string | null
|
||||
}
|
||||
|
||||
type FileLike = {
|
||||
name?: string | null
|
||||
path?: string | null
|
||||
mimeType?: string | null
|
||||
}
|
||||
|
||||
const decodePdfText = (value: unknown): string | null => {
|
||||
if (value instanceof PDFString || value instanceof PDFHexString) {
|
||||
return value.decodeText()
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const detectXmlSyntax = (xml: Buffer): ElectronicInvoiceSyntax | null => {
|
||||
const sample = xml.subarray(0, Math.min(xml.length, 64 * 1024)).toString("utf8")
|
||||
|
||||
if (/<(?:[A-Za-z_][\w.-]*:)?CrossIndustryInvoice(?:\s|>)/i.test(sample)) {
|
||||
return "cii"
|
||||
}
|
||||
|
||||
if (
|
||||
/<(?:[A-Za-z_][\w.-]*:)?Invoice(?:\s|>)/i.test(sample)
|
||||
&& /urn:oasis:names:specification:ubl:schema:xsd:Invoice-2/i.test(sample)
|
||||
) {
|
||||
return "ubl-invoice"
|
||||
}
|
||||
|
||||
if (
|
||||
/<(?:[A-Za-z_][\w.-]*:)?CreditNote(?:\s|>)/i.test(sample)
|
||||
&& /urn:oasis:names:specification:ubl:schema:xsd:CreditNote-2/i.test(sample)
|
||||
) {
|
||||
return "ubl-credit-note"
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const extractXmlFromFileSpec = (fileSpec: PDFDict): { name: string | null, xml: Buffer } | null => {
|
||||
const context = fileSpec.context
|
||||
const embeddedFiles = context.lookupMaybe(fileSpec.get(PDFName.of("EF")), PDFDict)
|
||||
|
||||
if (!embeddedFiles) return null
|
||||
|
||||
const stream = context.lookup(
|
||||
embeddedFiles.get(PDFName.of("UF")) || embeddedFiles.get(PDFName.of("F")),
|
||||
)
|
||||
|
||||
if (!(stream instanceof PDFRawStream)) return null
|
||||
|
||||
const name = decodePdfText(
|
||||
context.lookup(fileSpec.get(PDFName.of("UF")) || fileSpec.get(PDFName.of("F"))),
|
||||
)
|
||||
const contents = Buffer.from(decodePDFRawStream(stream).decode())
|
||||
|
||||
return { name, xml: contents }
|
||||
}
|
||||
|
||||
const detectEmbeddedInvoice = async (pdf: Buffer): Promise<ElectronicInvoiceDetection | null> => {
|
||||
let document: PDFDocument
|
||||
|
||||
try {
|
||||
document = await PDFDocument.load(pdf, {
|
||||
ignoreEncryption: true,
|
||||
updateMetadata: false,
|
||||
})
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
for (const [, object] of document.context.enumerateIndirectObjects()) {
|
||||
if (!(object instanceof PDFDict)) continue
|
||||
|
||||
const type = object.get(PDFName.of("Type"))
|
||||
if (!(type instanceof PDFName) || type.asString() !== "/Filespec") continue
|
||||
|
||||
const embedded = extractXmlFromFileSpec(object)
|
||||
if (!embedded) continue
|
||||
|
||||
const syntax = detectXmlSyntax(embedded.xml)
|
||||
if (!syntax) continue
|
||||
|
||||
return {
|
||||
container: "pdf",
|
||||
syntax,
|
||||
xml: embedded.xml,
|
||||
attachmentName: embedded.name,
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const hasPdfFileType = (file: FileLike, data: Buffer) => {
|
||||
const filename = String(file.name || file.path || "").toLowerCase()
|
||||
const mimeType = String(file.mimeType || "").toLowerCase().split(";")[0].trim()
|
||||
|
||||
return filename.endsWith(".pdf") || mimeType === "application/pdf" || data.subarray(0, 5).toString("ascii") === "%PDF-"
|
||||
}
|
||||
|
||||
export const detectElectronicInvoice = async (
|
||||
data: Buffer,
|
||||
file: FileLike,
|
||||
): Promise<ElectronicInvoiceDetection | null> => {
|
||||
if (hasPdfFileType(file, data)) {
|
||||
return detectEmbeddedInvoice(data)
|
||||
}
|
||||
|
||||
const beginsLikeXml = data.subarray(0, 1024).toString("utf8").replace(/^\uFEFF/, "").trimStart().startsWith("<")
|
||||
const syntax = beginsLikeXml ? detectXmlSyntax(data) : null
|
||||
|
||||
if (syntax) {
|
||||
return {
|
||||
container: "xml",
|
||||
syntax,
|
||||
xml: data,
|
||||
attachmentName: file.name || file.path?.split("/").pop() || null,
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
93
backend/src/modules/einvoice/mapElectronicInvoice.ts
Normal file
93
backend/src/modules/einvoice/mapElectronicInvoice.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { and, eq } from "drizzle-orm"
|
||||
import type { FastifyInstance } from "fastify"
|
||||
import { vendors } from "../../../db/schema"
|
||||
import type { ParsedElectronicInvoice } from "./types"
|
||||
|
||||
const normalizeIdentifier = (value: unknown) => String(value || "").replace(/[^A-Za-z0-9]/g, "").toUpperCase()
|
||||
const normalizeName = (value: unknown) => String(value || "").normalize("NFKD").replace(/[^A-Za-z0-9]/g, "").toUpperCase()
|
||||
|
||||
const paymentTypeByCode: Record<string, string> = {
|
||||
"10": "Bar",
|
||||
"30": "Überweisung",
|
||||
"48": "Kreditkarte",
|
||||
"49": "Lastschrift",
|
||||
"57": "Sonstiges",
|
||||
"58": "Überweisung",
|
||||
"59": "Lastschrift",
|
||||
}
|
||||
|
||||
export const findElectronicInvoiceVendor = async (
|
||||
server: FastifyInstance,
|
||||
tenantId: number,
|
||||
invoice: ParsedElectronicInvoice,
|
||||
) => {
|
||||
const candidates = await server.db.select().from(vendors).where(and(
|
||||
eq(vendors.tenant, tenantId),
|
||||
eq(vendors.archived, false),
|
||||
))
|
||||
|
||||
const vatId = normalizeIdentifier(invoice.seller.vatId)
|
||||
if (vatId) {
|
||||
const vatMatches = candidates.filter((vendor: any) => normalizeIdentifier(vendor.infoData?.ustid) === vatId)
|
||||
if (vatMatches.length === 1) return vatMatches[0]
|
||||
}
|
||||
|
||||
const sellerName = normalizeName(invoice.seller.name)
|
||||
if (sellerName) {
|
||||
const nameMatches = candidates.filter((vendor) => normalizeName(vendor.name) === sellerName)
|
||||
if (nameMatches.length === 1) return nameMatches[0]
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export const mapElectronicInvoiceToIncomingInvoice = (
|
||||
invoice: ParsedElectronicInvoice,
|
||||
tenantId: number,
|
||||
vendorId: number | null,
|
||||
container: "pdf" | "xml",
|
||||
attachmentName: string | null,
|
||||
) => {
|
||||
const warnings = [...invoice.validation.warnings]
|
||||
if (!vendorId) warnings.push("Rechnungssteller konnte keinem Lieferanten eindeutig zugeordnet werden.")
|
||||
|
||||
const state = invoice.validation.errors.length > 0 || !vendorId ? "Prüfung erforderlich" : "Vorbereitet"
|
||||
const descriptionParts = [
|
||||
invoice.buyerReference ? `Käuferreferenz: ${invoice.buyerReference}` : null,
|
||||
...invoice.items.map((item) => item.description),
|
||||
].filter(Boolean)
|
||||
|
||||
return {
|
||||
tenant: tenantId,
|
||||
state,
|
||||
vendor: vendorId,
|
||||
reference: invoice.invoiceNumber,
|
||||
date: invoice.invoiceDate,
|
||||
dueDate: invoice.dueDate,
|
||||
paymentType: paymentTypeByCode[invoice.paymentMeansCode || ""] || "Sonstiges",
|
||||
description: descriptionParts.join("\n"),
|
||||
accounts: invoice.items.map((item) => ({
|
||||
account: null,
|
||||
description: item.description,
|
||||
amountNet: item.netAmount,
|
||||
amountTax: item.taxAmount,
|
||||
taxType: item.taxCategory === "AE" ? "13B" : item.taxRate === 0 ? "null" : String(item.taxRate),
|
||||
amountGross: item.grossAmount,
|
||||
costCentre: null,
|
||||
quantity: item.quantity,
|
||||
unitCode: item.unitCode,
|
||||
})),
|
||||
preparationSource: "e-invoice",
|
||||
eInvoiceSyntax: invoice.syntax,
|
||||
eInvoiceProfile: invoice.profileId,
|
||||
eInvoiceValidation: {
|
||||
errors: invoice.validation.errors,
|
||||
warnings,
|
||||
totals: invoice.totals,
|
||||
seller: invoice.seller,
|
||||
invoiceType: invoice.invoiceType,
|
||||
container,
|
||||
attachmentName,
|
||||
},
|
||||
}
|
||||
}
|
||||
223
backend/src/modules/einvoice/parseElectronicInvoice.ts
Normal file
223
backend/src/modules/einvoice/parseElectronicInvoice.ts
Normal file
@@ -0,0 +1,223 @@
|
||||
import { XMLParser } from "fast-xml-parser"
|
||||
import type { ElectronicInvoiceSyntax } from "./detectElectronicInvoice"
|
||||
import type { ElectronicInvoiceItem, ParsedElectronicInvoice } from "./types"
|
||||
|
||||
const parser = new XMLParser({
|
||||
ignoreAttributes: false,
|
||||
removeNSPrefix: true,
|
||||
parseTagValue: false,
|
||||
trimValues: true,
|
||||
})
|
||||
|
||||
const array = <T>(value: T | T[] | null | undefined): T[] => value == null ? [] : Array.isArray(value) ? value : [value]
|
||||
|
||||
const text = (value: any): string | null => {
|
||||
if (value == null) return null
|
||||
if (typeof value === "string" || typeof value === "number") return String(value).trim() || null
|
||||
if (typeof value === "object" && value["#text"] != null) return text(value["#text"])
|
||||
return null
|
||||
}
|
||||
|
||||
const number = (value: any): number | null => {
|
||||
const raw = text(value)
|
||||
if (raw == null) return null
|
||||
|
||||
const parsed = Number(raw)
|
||||
return Number.isFinite(parsed) ? parsed : null
|
||||
}
|
||||
|
||||
const roundMoney = (value: number) => Number(value.toFixed(2))
|
||||
|
||||
const date = (value: any): string | null => {
|
||||
const raw = text(value?.DateTimeString ?? value)
|
||||
if (!raw) return null
|
||||
|
||||
if (/^\d{8}$/.test(raw)) {
|
||||
return `${raw.slice(0, 4)}-${raw.slice(4, 6)}-${raw.slice(6, 8)}`
|
||||
}
|
||||
|
||||
const match = raw.match(/^\d{4}-\d{2}-\d{2}/)
|
||||
return match?.[0] || null
|
||||
}
|
||||
|
||||
const findTaxRegistration = (party: any, schemeId: string) => {
|
||||
return text(array(party?.SpecifiedTaxRegistration).find((entry: any) => {
|
||||
return String(entry?.ID?.["@_schemeID"] || "").toUpperCase() === schemeId
|
||||
})?.ID)
|
||||
}
|
||||
|
||||
const ciiDescription = (line: any) => {
|
||||
const product = line?.SpecifiedTradeProduct || {}
|
||||
return [text(product.Name), text(product.Description)].filter(Boolean).join(" - ") || "Position"
|
||||
}
|
||||
|
||||
const parseCii = (root: any): Omit<ParsedElectronicInvoice, "validation"> => {
|
||||
const transaction = root?.SupplyChainTradeTransaction || {}
|
||||
const agreement = transaction?.ApplicableHeaderTradeAgreement || {}
|
||||
const settlement = transaction?.ApplicableHeaderTradeSettlement || {}
|
||||
const monetary = settlement?.SpecifiedTradeSettlementHeaderMonetarySummation || {}
|
||||
const seller = agreement?.SellerTradeParty || {}
|
||||
const typeCode = text(root?.ExchangedDocument?.TypeCode)
|
||||
const sign = typeCode === "381" ? -1 : 1
|
||||
|
||||
const items: ElectronicInvoiceItem[] = array(transaction?.IncludedSupplyChainTradeLineItem).map((line: any) => {
|
||||
const delivery = line?.SpecifiedLineTradeDelivery || {}
|
||||
const lineSettlement = line?.SpecifiedLineTradeSettlement || {}
|
||||
const tradeTax = array(lineSettlement?.ApplicableTradeTax)[0] || {}
|
||||
const quantity = number(delivery?.BilledQuantity)
|
||||
const net = number(lineSettlement?.SpecifiedTradeSettlementLineMonetarySummation?.LineTotalAmount) || 0
|
||||
const taxRate = number(tradeTax?.RateApplicablePercent) || 0
|
||||
const signedNet = roundMoney(sign * net)
|
||||
const taxAmount = roundMoney(signedNet * taxRate / 100)
|
||||
|
||||
return {
|
||||
description: ciiDescription(line),
|
||||
quantity,
|
||||
unitCode: text(delivery?.BilledQuantity?.["@_unitCode"]),
|
||||
netAmount: signedNet,
|
||||
taxRate,
|
||||
taxCategory: text(tradeTax?.CategoryCode),
|
||||
taxAmount,
|
||||
grossAmount: roundMoney(signedNet + taxAmount),
|
||||
}
|
||||
})
|
||||
|
||||
const paymentMeans = array(settlement?.SpecifiedTradeSettlementPaymentMeans)[0] || {}
|
||||
const paymentTerms = array(settlement?.SpecifiedTradePaymentTerms)[0] || {}
|
||||
const guideline = root?.ExchangedDocumentContext?.GuidelineSpecifiedDocumentContextParameter?.ID
|
||||
|
||||
return {
|
||||
syntax: "cii",
|
||||
profileId: text(guideline),
|
||||
invoiceNumber: text(root?.ExchangedDocument?.ID),
|
||||
invoiceDate: date(root?.ExchangedDocument?.IssueDateTime),
|
||||
dueDate: date(paymentTerms?.DueDateDateTime),
|
||||
currency: text(settlement?.InvoiceCurrencyCode),
|
||||
invoiceType: typeCode === "381" ? "credit-note" : "invoice",
|
||||
seller: {
|
||||
name: text(seller?.Name),
|
||||
vatId: findTaxRegistration(seller, "VA"),
|
||||
taxNumber: findTaxRegistration(seller, "FC"),
|
||||
iban: text(paymentMeans?.PayeePartyCreditorFinancialAccount?.IBANID),
|
||||
bic: text(paymentMeans?.PayeeSpecifiedCreditorFinancialInstitution?.BICID),
|
||||
},
|
||||
buyerReference: text(agreement?.BuyerReference),
|
||||
paymentMeansCode: text(paymentMeans?.TypeCode),
|
||||
items,
|
||||
totals: {
|
||||
lineNet: number(monetary?.LineTotalAmount) != null ? roundMoney(sign * number(monetary.LineTotalAmount)!) : null,
|
||||
tax: number(monetary?.TaxTotalAmount) != null ? roundMoney(sign * number(monetary.TaxTotalAmount)!) : null,
|
||||
gross: number(monetary?.GrandTotalAmount) != null ? roundMoney(sign * number(monetary.GrandTotalAmount)!) : null,
|
||||
payable: number(monetary?.DuePayableAmount) != null ? roundMoney(sign * number(monetary.DuePayableAmount)!) : null,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const ublPartyName = (party: any) => text(party?.PartyLegalEntity?.RegistrationName) || text(party?.PartyName?.Name)
|
||||
|
||||
const parseUbl = (root: any, syntax: "ubl-invoice" | "ubl-credit-note"): Omit<ParsedElectronicInvoice, "validation"> => {
|
||||
const isCreditNote = syntax === "ubl-credit-note"
|
||||
const sign = isCreditNote ? -1 : 1
|
||||
const supplier = root?.AccountingSupplierParty?.Party || {}
|
||||
const monetary = root?.LegalMonetaryTotal || {}
|
||||
const paymentMeans = array(root?.PaymentMeans)[0] || {}
|
||||
const sourceLines = array(isCreditNote ? root?.CreditNoteLine : root?.InvoiceLine)
|
||||
|
||||
const items: ElectronicInvoiceItem[] = sourceLines.map((line: any) => {
|
||||
const quantityValue = isCreditNote ? line?.CreditedQuantity : line?.InvoicedQuantity
|
||||
const taxCategory = line?.Item?.ClassifiedTaxCategory || {}
|
||||
const net = number(line?.LineExtensionAmount) || 0
|
||||
const taxRate = number(taxCategory?.Percent) || 0
|
||||
const signedNet = roundMoney(sign * net)
|
||||
const taxAmount = roundMoney(signedNet * taxRate / 100)
|
||||
|
||||
return {
|
||||
description: [text(line?.Item?.Name), text(line?.Item?.Description)].filter(Boolean).join(" - ") || "Position",
|
||||
quantity: number(quantityValue),
|
||||
unitCode: text(quantityValue?.["@_unitCode"]),
|
||||
netAmount: signedNet,
|
||||
taxRate,
|
||||
taxCategory: text(taxCategory?.ID),
|
||||
taxAmount,
|
||||
grossAmount: roundMoney(signedNet + taxAmount),
|
||||
}
|
||||
})
|
||||
|
||||
const taxSchemeIds = array(supplier?.PartyTaxScheme)
|
||||
const vatId = text(taxSchemeIds.find((entry: any) => text(entry?.TaxScheme?.ID)?.toUpperCase() === "VAT")?.CompanyID)
|
||||
|
||||
return {
|
||||
syntax,
|
||||
profileId: text(root?.CustomizationID) || text(root?.ProfileID),
|
||||
invoiceNumber: text(root?.ID),
|
||||
invoiceDate: date(root?.IssueDate),
|
||||
dueDate: date(root?.DueDate) || date(paymentMeans?.PaymentDueDate),
|
||||
currency: text(root?.DocumentCurrencyCode),
|
||||
invoiceType: isCreditNote ? "credit-note" : "invoice",
|
||||
seller: {
|
||||
name: ublPartyName(supplier),
|
||||
vatId,
|
||||
taxNumber: text(supplier?.PartyTaxScheme?.CompanyID),
|
||||
iban: text(paymentMeans?.PayeeFinancialAccount?.ID),
|
||||
bic: text(paymentMeans?.PayeeFinancialAccount?.FinancialInstitutionBranch?.ID),
|
||||
},
|
||||
buyerReference: text(root?.BuyerReference),
|
||||
paymentMeansCode: text(paymentMeans?.PaymentMeansCode),
|
||||
items,
|
||||
totals: {
|
||||
lineNet: number(monetary?.LineExtensionAmount) != null ? roundMoney(sign * number(monetary.LineExtensionAmount)!) : null,
|
||||
tax: number(root?.TaxTotal?.TaxAmount) != null ? roundMoney(sign * number(root.TaxTotal.TaxAmount)!) : null,
|
||||
gross: number(monetary?.TaxInclusiveAmount) != null ? roundMoney(sign * number(monetary.TaxInclusiveAmount)!) : null,
|
||||
payable: number(monetary?.PayableAmount) != null ? roundMoney(sign * number(monetary.PayableAmount)!) : null,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const validate = (invoice: Omit<ParsedElectronicInvoice, "validation">) => {
|
||||
const errors: string[] = []
|
||||
const warnings: string[] = []
|
||||
const tolerance = 0.02
|
||||
|
||||
if (!invoice.invoiceNumber) errors.push("Rechnungsnummer fehlt.")
|
||||
if (!invoice.invoiceDate) errors.push("Rechnungsdatum fehlt oder ist ungültig.")
|
||||
if (!invoice.currency) errors.push("Rechnungswährung fehlt.")
|
||||
if (!invoice.seller.name) errors.push("Name des Rechnungsstellers fehlt.")
|
||||
if (invoice.items.length === 0) errors.push("Die E-Rechnung enthält keine Rechnungspositionen.")
|
||||
|
||||
const calculatedLineNet = roundMoney(invoice.items.reduce((sum, item) => sum + item.netAmount, 0))
|
||||
const calculatedTax = roundMoney(invoice.items.reduce((sum, item) => sum + item.taxAmount, 0))
|
||||
|
||||
if (invoice.totals.lineNet != null && Math.abs(invoice.totals.lineNet - calculatedLineNet) > tolerance) {
|
||||
errors.push(`Positionssumme ${calculatedLineNet.toFixed(2)} stimmt nicht mit der Nettosumme ${invoice.totals.lineNet.toFixed(2)} überein.`)
|
||||
}
|
||||
if (invoice.totals.tax != null && Math.abs(invoice.totals.tax - calculatedTax) > tolerance) {
|
||||
warnings.push(`Berechnete Steuer ${calculatedTax.toFixed(2)} weicht von der Steuer-Gesamtsumme ${invoice.totals.tax.toFixed(2)} ab.`)
|
||||
}
|
||||
if (invoice.totals.gross != null && invoice.totals.lineNet != null && invoice.totals.tax != null) {
|
||||
const expectedGross = roundMoney(invoice.totals.lineNet + invoice.totals.tax)
|
||||
if (Math.abs(expectedGross - invoice.totals.gross) > tolerance) {
|
||||
errors.push(`Bruttosumme ${invoice.totals.gross.toFixed(2)} stimmt nicht mit Netto plus Steuer ${expectedGross.toFixed(2)} überein.`)
|
||||
}
|
||||
}
|
||||
if (!invoice.seller.vatId) warnings.push("Keine USt-ID des Rechnungsstellers enthalten.")
|
||||
if (!invoice.dueDate) warnings.push("Kein Fälligkeitsdatum enthalten.")
|
||||
for (const taxRate of new Set(invoice.items.map((item) => item.taxRate))) {
|
||||
if (![0, 7, 19].includes(taxRate)) warnings.push(`Steuersatz ${taxRate}% ist in FEDEO noch keinem Standard-Steuerschlüssel zugeordnet.`)
|
||||
}
|
||||
|
||||
return { errors, warnings }
|
||||
}
|
||||
|
||||
export const parseElectronicInvoice = (xml: Buffer, syntax: ElectronicInvoiceSyntax): ParsedElectronicInvoice => {
|
||||
const parsed = parser.parse(xml.toString("utf8"))
|
||||
const root = syntax === "cii"
|
||||
? parsed.CrossIndustryInvoice
|
||||
: syntax === "ubl-credit-note"
|
||||
? parsed.CreditNote
|
||||
: parsed.Invoice
|
||||
|
||||
if (!root) throw new Error(`XML-Wurzelelement für ${syntax} wurde nicht gefunden.`)
|
||||
|
||||
const invoice = syntax === "cii" ? parseCii(root) : parseUbl(root, syntax)
|
||||
return { ...invoice, validation: validate(invoice) }
|
||||
}
|
||||
42
backend/src/modules/einvoice/types.ts
Normal file
42
backend/src/modules/einvoice/types.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import type { ElectronicInvoiceSyntax } from "./detectElectronicInvoice"
|
||||
|
||||
export type ElectronicInvoiceItem = {
|
||||
description: string
|
||||
quantity: number | null
|
||||
unitCode: string | null
|
||||
netAmount: number
|
||||
taxRate: number
|
||||
taxCategory: string | null
|
||||
taxAmount: number
|
||||
grossAmount: number
|
||||
}
|
||||
|
||||
export type ParsedElectronicInvoice = {
|
||||
syntax: ElectronicInvoiceSyntax
|
||||
profileId: string | null
|
||||
invoiceNumber: string | null
|
||||
invoiceDate: string | null
|
||||
dueDate: string | null
|
||||
currency: string | null
|
||||
invoiceType: "invoice" | "credit-note"
|
||||
seller: {
|
||||
name: string | null
|
||||
vatId: string | null
|
||||
taxNumber: string | null
|
||||
iban: string | null
|
||||
bic: string | null
|
||||
}
|
||||
buyerReference: string | null
|
||||
paymentMeansCode: string | null
|
||||
items: ElectronicInvoiceItem[]
|
||||
totals: {
|
||||
lineNet: number | null
|
||||
tax: number | null
|
||||
gross: number | null
|
||||
payable: number | null
|
||||
}
|
||||
validation: {
|
||||
errors: string[]
|
||||
warnings: string[]
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,18 @@
|
||||
import { FastifyInstance } from "fastify";
|
||||
import bcrypt from "bcrypt";
|
||||
import jwt from "jsonwebtoken";
|
||||
import { generateRandomPassword, hashPassword } from "../../utils/password";
|
||||
import { sendMail } from "../../utils/mailer";
|
||||
import { secrets } from "../../utils/secrets";
|
||||
|
||||
import { authUsers } from "../../../db/schema";
|
||||
import { authTenantUsers } from "../../../db/schema";
|
||||
import { tenants } from "../../../db/schema";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { eq } from "drizzle-orm";
|
||||
import {
|
||||
createAccessToken,
|
||||
issueRefreshToken,
|
||||
revokeRefreshToken,
|
||||
rotateRefreshToken,
|
||||
} from "../../utils/authTokens";
|
||||
|
||||
export default async function authRoutes(server: FastifyInstance) {
|
||||
|
||||
@@ -61,11 +65,12 @@ export default async function authRoutes(server: FastifyInstance) {
|
||||
properties: {
|
||||
email: { type: "string", format: "email" },
|
||||
password: { type: "string" },
|
||||
rememberMe: { type: "boolean" },
|
||||
},
|
||||
},
|
||||
},
|
||||
}, async (req, reply) => {
|
||||
const body = req.body as { email: string; password: string };
|
||||
const body = req.body as { email: string; password: string; rememberMe?: boolean };
|
||||
|
||||
let user: any = null;
|
||||
|
||||
@@ -122,27 +127,43 @@ export default async function authRoutes(server: FastifyInstance) {
|
||||
return reply.code(401).send({ error: "Invalid credentials" });
|
||||
}
|
||||
|
||||
const token = jwt.sign(
|
||||
{
|
||||
user_id: user.id,
|
||||
email: user.email,
|
||||
tenant_id: req.tenant?.id ?? null,
|
||||
},
|
||||
secrets.JWT_SECRET!,
|
||||
{ expiresIn: "6h" }
|
||||
);
|
||||
const tenantId = req.tenant?.id ? Number(req.tenant.id) : null;
|
||||
const token = createAccessToken(user, tenantId);
|
||||
const refreshToken = await issueRefreshToken(server, user.id, tenantId);
|
||||
|
||||
reply.setCookie("token", token, {
|
||||
path: "/",
|
||||
httpOnly: true,
|
||||
sameSite: process.env.NODE_ENV === "production" ? "none" : "lax",
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
maxAge: 60 * 60 * 6,
|
||||
...(body.rememberMe ? { maxAge: 60 * 60 * 6 } : {}),
|
||||
});
|
||||
|
||||
return { token };
|
||||
return { token, refreshToken };
|
||||
});
|
||||
|
||||
server.post("/auth/refresh", {
|
||||
schema: {
|
||||
tags: ["Auth"],
|
||||
summary: "Refresh a persistent session",
|
||||
body: {
|
||||
type: "object",
|
||||
required: ["refreshToken"],
|
||||
properties: {
|
||||
refreshToken: { type: "string" },
|
||||
},
|
||||
},
|
||||
},
|
||||
}, async (req, reply) => {
|
||||
const { refreshToken } = req.body as { refreshToken: string };
|
||||
const refreshed = await rotateRefreshToken(server, refreshToken);
|
||||
|
||||
if (!refreshed) {
|
||||
return reply.code(401).send({ error: "Invalid or expired refresh token" });
|
||||
}
|
||||
|
||||
return refreshed;
|
||||
});
|
||||
|
||||
// -----------------------------------------------------
|
||||
// LOGOUT
|
||||
@@ -153,6 +174,10 @@ export default async function authRoutes(server: FastifyInstance) {
|
||||
summary: "Logout User"
|
||||
}
|
||||
}, async (req, reply) => {
|
||||
const refreshToken = (req.body as { refreshToken?: string } | undefined)?.refreshToken;
|
||||
if (refreshToken) {
|
||||
await revokeRefreshToken(server, refreshToken);
|
||||
}
|
||||
reply.clearCookie("token", {
|
||||
path: "/",
|
||||
httpOnly: true,
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
import {and, desc, eq, inArray} from "drizzle-orm"
|
||||
import { enrichProfilesWithBranches } from "../utils/profileBranches"
|
||||
import { enrichProfilesWithTeams } from "../utils/profileTeams"
|
||||
import { rotateRefreshToken } from "../utils/authTokens"
|
||||
|
||||
|
||||
export default async function tenantRoutes(server: FastifyInstance) {
|
||||
@@ -65,7 +66,7 @@ export default async function tenantRoutes(server: FastifyInstance) {
|
||||
return reply.code(401).send({ error: "Unauthorized" })
|
||||
}
|
||||
|
||||
const { tenant_id } = req.body as { tenant_id: string }
|
||||
const { tenant_id, refreshToken } = req.body as { tenant_id: string; refreshToken?: string }
|
||||
if (!tenant_id) return reply.code(400).send({ error: "tenant_id required" })
|
||||
|
||||
// prüfen ob der User zu diesem Tenant gehört
|
||||
@@ -81,7 +82,22 @@ export default async function tenantRoutes(server: FastifyInstance) {
|
||||
return reply.code(403).send({ error: "Not a member of this tenant" })
|
||||
}
|
||||
|
||||
// JWT neu erzeugen
|
||||
if (refreshToken) {
|
||||
const session = await rotateRefreshToken(
|
||||
server,
|
||||
refreshToken,
|
||||
Number(tenant_id),
|
||||
req.user.user_id
|
||||
)
|
||||
|
||||
if (!session) {
|
||||
return reply.code(401).send({ error: "Invalid or expired refresh token" })
|
||||
}
|
||||
|
||||
return session
|
||||
}
|
||||
|
||||
// Abwärtskompatibilität für Clients ohne persistente Session
|
||||
const token = jwt.sign(
|
||||
{
|
||||
user_id: req.user.user_id,
|
||||
|
||||
93
backend/src/utils/authTokens.ts
Normal file
93
backend/src/utils/authTokens.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { createHash, randomBytes } from "node:crypto"
|
||||
import jwt from "jsonwebtoken"
|
||||
import { FastifyInstance } from "fastify"
|
||||
import { and, eq, gt, isNull } from "drizzle-orm"
|
||||
|
||||
import { authRefreshTokens, authUsers } from "../../db/schema"
|
||||
import { secrets } from "./secrets"
|
||||
|
||||
const ACCESS_TOKEN_TTL_SECONDS = 60 * 60 * 6
|
||||
const REFRESH_TOKEN_TTL_DAYS = 90
|
||||
|
||||
export function createAccessToken(user: { id: string; email: string }, tenantId: number | null) {
|
||||
return jwt.sign(
|
||||
{
|
||||
user_id: user.id,
|
||||
email: user.email,
|
||||
tenant_id: tenantId,
|
||||
},
|
||||
secrets.JWT_SECRET!,
|
||||
{ expiresIn: ACCESS_TOKEN_TTL_SECONDS }
|
||||
)
|
||||
}
|
||||
|
||||
export function hashRefreshToken(token: string) {
|
||||
return createHash("sha256").update(token, "utf8").digest("hex")
|
||||
}
|
||||
|
||||
export async function issueRefreshToken(
|
||||
server: FastifyInstance,
|
||||
userId: string,
|
||||
tenantId: number | null
|
||||
) {
|
||||
const token = randomBytes(48).toString("base64url")
|
||||
const expiresAt = new Date(Date.now() + REFRESH_TOKEN_TTL_DAYS * 24 * 60 * 60 * 1000)
|
||||
|
||||
await server.db.insert(authRefreshTokens).values({
|
||||
userId,
|
||||
tenantId,
|
||||
tokenHash: hashRefreshToken(token),
|
||||
expiresAt,
|
||||
})
|
||||
|
||||
return token
|
||||
}
|
||||
|
||||
export async function rotateRefreshToken(
|
||||
server: FastifyInstance,
|
||||
token: string,
|
||||
tenantOverride?: number | null,
|
||||
expectedUserId?: string
|
||||
) {
|
||||
const tokenHash = hashRefreshToken(token)
|
||||
const [session] = await server.db
|
||||
.update(authRefreshTokens)
|
||||
.set({ revokedAt: new Date() })
|
||||
.where(and(
|
||||
eq(authRefreshTokens.tokenHash, tokenHash),
|
||||
isNull(authRefreshTokens.revokedAt),
|
||||
gt(authRefreshTokens.expiresAt, new Date())
|
||||
))
|
||||
.returning({
|
||||
id: authRefreshTokens.id,
|
||||
userId: authRefreshTokens.userId,
|
||||
tenantId: authRefreshTokens.tenantId,
|
||||
})
|
||||
|
||||
if (!session) return null
|
||||
if (expectedUserId && session.userId !== expectedUserId) return null
|
||||
|
||||
const [user] = await server.db
|
||||
.select({ id: authUsers.id, email: authUsers.email })
|
||||
.from(authUsers)
|
||||
.where(eq(authUsers.id, session.userId))
|
||||
.limit(1)
|
||||
|
||||
if (!user) return null
|
||||
|
||||
const tenantId = tenantOverride === undefined ? session.tenantId : tenantOverride
|
||||
const refreshToken = await issueRefreshToken(server, session.userId, tenantId)
|
||||
const accessToken = createAccessToken(user, tenantId)
|
||||
|
||||
return { token: accessToken, refreshToken }
|
||||
}
|
||||
|
||||
export async function revokeRefreshToken(server: FastifyInstance, token: string) {
|
||||
await server.db
|
||||
.update(authRefreshTokens)
|
||||
.set({ revokedAt: new Date() })
|
||||
.where(and(
|
||||
eq(authRefreshTokens.tokenHash, hashRefreshToken(token)),
|
||||
isNull(authRefreshTokens.revokedAt)
|
||||
))
|
||||
}
|
||||
26
backend/src/utils/fileBuffer.ts
Normal file
26
backend/src/utils/fileBuffer.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { GetObjectCommand } from "@aws-sdk/client-s3"
|
||||
import { s3 } from "./s3"
|
||||
import { secrets } from "./secrets"
|
||||
|
||||
export const streamToBuffer = async (stream: any): Promise<Buffer> => {
|
||||
const chunks: Buffer[] = []
|
||||
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))
|
||||
}
|
||||
|
||||
return Buffer.concat(chunks)
|
||||
}
|
||||
|
||||
export const loadFileBuffer = async (path: string): Promise<Buffer> => {
|
||||
const response = await s3.send(new GetObjectCommand({
|
||||
Bucket: secrets.S3_BUCKET,
|
||||
Key: path,
|
||||
}))
|
||||
|
||||
if (!response.Body) {
|
||||
throw new Error(`S3-Datei '${path}' enthält keinen lesbaren Inhalt.`)
|
||||
}
|
||||
|
||||
return streamToBuffer(response.Body)
|
||||
}
|
||||
@@ -2,12 +2,11 @@ import dayjs from "dayjs";
|
||||
import OpenAI from "openai";
|
||||
import { z } from "zod";
|
||||
import { zodResponseFormat } from "openai/helpers/zod";
|
||||
import { GetObjectCommand } from "@aws-sdk/client-s3";
|
||||
import { FastifyInstance } from "fastify";
|
||||
|
||||
import { s3 } from "./s3";
|
||||
import { secrets } from "./secrets";
|
||||
import { storeExtractedTextForFile } from "./documentText";
|
||||
import { loadFileBuffer } from "./fileBuffer";
|
||||
import { secrets } from "./secrets";
|
||||
|
||||
// Drizzle schema
|
||||
import { vendors, accounts, tenants } from "../../db/schema";
|
||||
@@ -27,18 +26,6 @@ export const initOpenAi = async () => {
|
||||
});
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// STREAM → BUFFER
|
||||
// ---------------------------------------------------------
|
||||
async function streamToBuffer(stream: any): Promise<Buffer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks: Buffer[] = [];
|
||||
stream.on("data", (chunk: Buffer) => chunks.push(chunk));
|
||||
stream.on("error", reject);
|
||||
stream.on("end", () => resolve(Buffer.concat(chunks)));
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// GPT RESPONSE FORMAT (Zod Schema)
|
||||
// ---------------------------------------------------------
|
||||
@@ -93,7 +80,8 @@ const InstructionFormat = z.object({
|
||||
export const getInvoiceDataFromGPT = async function (
|
||||
server: FastifyInstance,
|
||||
file: any,
|
||||
tenantId: number
|
||||
tenantId: number,
|
||||
suppliedFileData?: Buffer,
|
||||
) {
|
||||
await initOpenAi();
|
||||
|
||||
@@ -109,13 +97,7 @@ export const getInvoiceDataFromGPT = async function (
|
||||
let fileData: Buffer;
|
||||
|
||||
try {
|
||||
const command = new GetObjectCommand({
|
||||
Bucket: secrets.S3_BUCKET,
|
||||
Key: file.path,
|
||||
});
|
||||
|
||||
const response: any = await s3.send(command);
|
||||
fileData = await streamToBuffer(response.Body);
|
||||
fileData = suppliedFileData || await loadFileBuffer(file.path);
|
||||
} catch (err) {
|
||||
console.log(`❌ S3 Download failed for file ${file.id}`, err);
|
||||
return null;
|
||||
|
||||
Reference in New Issue
Block a user