From c6a0d59c291c5ea2b60d1b2a09268db43fa0f7e1 Mon Sep 17 00:00:00 2001 From: florianfederspiel Date: Fri, 15 May 2026 18:36:48 +0200 Subject: [PATCH] SEPA-Exportdatei aus Mandaten erzeugen #182 --- backend/src/routes/exports.ts | 50 +++- backend/src/utils/export/sepa.ts | 369 ++++++++++++++++++-------- frontend/pages/export/create/sepa.vue | 75 +++++- 3 files changed, 373 insertions(+), 121 deletions(-) diff --git a/backend/src/routes/exports.ts b/backend/src/routes/exports.ts index 31f3aa1..e849f97 100644 --- a/backend/src/routes/exports.ts +++ b/backend/src/routes/exports.ts @@ -67,6 +67,45 @@ const createDatevExport = async (server:FastifyInstance,req:any,startDate,endDat } +const createSepaExport = async (server: FastifyInstance, req: any, idsToExport: number[], creditorBankaccountId: number) => { + const exportData = await createSEPAExport(server, idsToExport, req.user.tenant_id, creditorBankaccountId) + + const fileKey = `${req.user.tenant_id}/exports/SEPA_${dayjs().format("YYYY-MM-DD")}_${randomUUID()}.xml` + + await s3.send( + new PutObjectCommand({ + Bucket: secrets.S3_BUCKET, + Key: fileKey, + Body: exportData.buffer, + ContentType: "application/xml", + }) + ) + + const url = await getSignedUrl( + s3, + new GetObjectCommand({ + Bucket: secrets.S3_BUCKET, + Key: fileKey, + }), + { expiresIn: 60 * 60 * 24 } + ) + + const inserted = await server.db + .insert(generatedexports) + .values({ + tenantId: req.user.tenant_id, + startDate: exportData.startDate, + endDate: exportData.endDate, + validUntil: dayjs().add(24, "hours").toDate(), + filePath: fileKey, + url, + type: "sepa", + }) + .returning() + + console.log(inserted[0]) +} + export default async function exportRoutes(server: FastifyInstance) { //Export DATEV @@ -94,17 +133,24 @@ export default async function exportRoutes(server: FastifyInstance) { }) server.post("/exports/sepa", async (req, reply) => { - const { idsToExport } = req.body as { + const { idsToExport, creditorBankaccountId } = req.body as { idsToExport: Array + creditorBankaccountId: number } + if (!idsToExport?.length || !creditorBankaccountId) { + return reply.send({ + success: false, + message: "Belege und Gläubigerkonto sind Pflichtfelder." + }) + } reply.send({success:true}) setImmediate(async () => { try { - await createSEPAExport(server, idsToExport, req.user.tenant_id) + await createSepaExport(server, req, idsToExport, creditorBankaccountId) console.log("Job done ✅") } catch (err) { console.error("Job failed ❌", err) diff --git a/backend/src/utils/export/sepa.ts b/backend/src/utils/export/sepa.ts index ba37697..c84d7c2 100644 --- a/backend/src/utils/export/sepa.ts +++ b/backend/src/utils/export/sepa.ts @@ -1,127 +1,268 @@ import xmlbuilder from "xmlbuilder"; -import {randomUUID} from "node:crypto"; +import { randomUUID } from "node:crypto"; import dayjs from "dayjs"; import { and, eq, inArray } from "drizzle-orm"; -import { createddocuments, tenants } from "../../../db/schema"; +import { + bankaccounts, + createddocuments, + customers, + entitybankaccounts, + outgoingsepamandates, + tenants, +} from "../../../db/schema"; +import { decrypt } from "../crypt"; -export const createSEPAExport = async (server,idsToExport, tenant_id) => { - const data = await server.db - .select() - .from(createddocuments) - .where(and( - eq(createddocuments.tenant, tenant_id), - inArray(createddocuments.id, idsToExport) - )) +const getCreatedDocumentTotal = (item: any) => { + let totalNet = 0; + let total19 = 0; + let total7 = 0; - const tenantRows = await server.db - .select() - .from(tenants) - .where(eq(tenants.id, tenant_id)) - .limit(1) - const tenantData = tenantRows[0] - console.log(tenantData) + const rows = Array.isArray(item.rows) ? item.rows : []; + rows.forEach((row: any) => { + if (!["pagebreak", "title", "text"].includes(row.mode)) { + const rowPrice = Number(Number(row.quantity) * Number(row.price) * (1 - Number(row.discountPercent) / 100)).toFixed(3); + totalNet += Number(rowPrice); - console.log(data) - - let transactions = [] - - let obj = { - Document: { - '@xmlns':"urn:iso:std:iso:20022:tech:xsd:pain.008.001.02", - 'CstmrDrctDbtInitn': { - 'GrpHdr': { - 'MsgId': randomUUID(), - 'CredDtTm': dayjs().format("YYYY-MM-DDTHH:mm:ss"), - 'NbOfTxs': transactions.length, - 'CtrlSum': 0, // TODO: Total Sum - 'InitgPty': { - 'Nm': tenantData.name - } - }, - 'PmtInf': { - 'PmtInfId': "", // TODO: Mandatsreferenz, - 'PmtMtd': "DD", - 'BtchBookg': "true", // TODO: BatchBooking, - 'NbOfTxs': transactions.length, - 'CtrlSum': 0, //TODO: Total Sum - 'PmtTpInf': { - 'SvcLvl': { - 'Cd': "SEPA" - }, - 'LclInstrm': { - 'Cd': "CORE" // Core für BASIS / B2B für Firmen - }, - 'SeqTp': "RCUR" // TODO: Unterscheidung Einmalig / Wiederkehrend - }, - 'ReqdColltnDt': dayjs().add(3, "days").format("YYYY-MM-DDTHH:mm:ss"), - 'Cdtr': { - 'Nm': tenantData.name - }, - 'CdtrAcct': { - 'Id': { - 'IBAN': "DE" // TODO: IBAN Gläubiger EINSETZEN - } - }, - 'CdtrAgt': { - 'FinInstnId': { - 'BIC': "BIC" // TODO: BIC des Gläubigers einsetzen - } - }, - 'ChrgBr': "SLEV", - 'CdtrSchmeId': { - 'Id': { - 'PrvtId': { - 'Othr': { - 'Id': tenantData.creditorId, - 'SchmeNm': { - 'Prty': "SEPA" - } - } - } - } - }, - //TODO ITERATE ALL INVOICES HERE - 'DrctDbtTxInf': { - 'PmtId': { - 'EndToEndId': "" // TODO: EINDEUTIGE ReFERENZ wahrscheinlich RE Nummer - }, - 'InstdAmt': { - '@Ccy':"EUR", - '#text':100 //TODO: Rechnungssumme zwei NK mit Punkt - }, - 'DrctDbtTx': { - 'MndtRltdInf': { - 'MndtId': "", // TODO: Mandatsref, - 'DtOfSgntr': "", //TODO: Unterschrieben am, - 'AmdmntInd': "" //TODO: Mandat geändert - } - }, - 'DbtrAgt': { - 'FinInstnId': { - 'BIC': "", //TODO: BIC Debtor - } - }, - 'Dbtr': { - 'Nm': "" // TODO NAME Debtor - }, - 'DbtrAcct': { - 'Id': { - 'IBAN': "DE" // TODO IBAN Debtor - } - }, - 'RmtInf': { - 'Ustrd': "" //TODO Verwendungszweck Rechnungsnummer - } - } - } + if (Number(row.taxPercent) === 19) { + total19 += Number(rowPrice) * 0.19; + } else if (Number(row.taxPercent) === 7) { + total7 += Number(rowPrice) * 0.07; } - } + }); + + return Number((Number(totalNet.toFixed(2)) + Number(total19.toFixed(2)) + Number(total7.toFixed(2))).toFixed(2)); +}; + +const cleanIban = (value: string | null | undefined) => (value || "").replace(/\s+/g, "").toUpperCase(); +const cleanBic = (value: string | null | undefined) => (value || "").replace(/\s+/g, "").toUpperCase(); + +const formatAmount = (value: number) => value.toFixed(2); + +const sanitizeText = (value: string | null | undefined, maxLength = 140) => { + return (value || "") + .replace(/[\n\r;]/g, " ") + .replace(/\s+/g, " ") + .trim() + .slice(0, maxLength); +}; + +const getDecryptedEntityBankAccount = (row: typeof entitybankaccounts.$inferSelect) => ({ + iban: cleanIban(decrypt(row.ibanEncrypted as any)), + bic: cleanBic(decrypt(row.bicEncrypted as any)), + bankName: decrypt(row.bankNameEncrypted as any), +}); + +const buildDirectDebitTransaction = (item: any) => { + const amount = getCreatedDocumentTotal(item.document); + if (amount <= 0) { + throw new Error(`Beleg ${item.document.documentNumber || item.document.id} hat keinen positiven Zahlungsbetrag.`); } + const debtorBankAccount = getDecryptedEntityBankAccount(item.debtorBankAccount); + if (!debtorBankAccount.iban || !debtorBankAccount.bic) { + throw new Error(`Bankverbindung für Mandat ${item.mandate.reference} ist unvollständig.`); + } - let doc = xmlbuilder.create(obj, {encoding: 'UTF-8', standalone: true}) + if (!item.mandate.signedAt) { + throw new Error(`Mandat ${item.mandate.reference} hat kein Unterschriftsdatum.`); + } - console.log(doc.end({pretty:true})) + return { + amount, + xml: { + PmtId: { + EndToEndId: sanitizeText(item.document.documentNumber || `Beleg-${item.document.id}`, 35), + }, + InstdAmt: { + "@Ccy": "EUR", + "#text": formatAmount(amount), + }, + DrctDbtTx: { + MndtRltdInf: { + MndtId: sanitizeText(item.mandate.reference, 35), + DtOfSgntr: dayjs(item.mandate.signedAt).format("YYYY-MM-DD"), + AmdmntInd: "false", + }, + }, + DbtrAgt: { + FinInstnId: { + BIC: debtorBankAccount.bic, + }, + }, + Dbtr: { + Nm: sanitizeText(item.customer.name, 70), + }, + DbtrAcct: { + Id: { + IBAN: debtorBankAccount.iban, + }, + }, + RmtInf: { + Ustrd: sanitizeText(`Rechnung ${item.document.documentNumber || item.document.id}`), + }, + }, + }; +}; -} +export const createSEPAExport = async ( + server: any, + idsToExport: number[], + tenantId: number, + creditorBankaccountId: number +) => { + if (!idsToExport.length) { + throw new Error("Es wurden keine Belege für den SEPA-Export ausgewählt."); + } + + const [tenantData] = await server.db + .select() + .from(tenants) + .where(eq(tenants.id, tenantId)) + .limit(1); + + if (!tenantData?.creditorId) { + throw new Error("Für den Mandanten ist keine Gläubiger-ID hinterlegt."); + } + + const [creditorBankAccount] = await server.db + .select() + .from(bankaccounts) + .where(and( + eq(bankaccounts.id, creditorBankaccountId), + eq(bankaccounts.tenant, tenantId) + )) + .limit(1); + + if (!creditorBankAccount) { + throw new Error("Das ausgewählte Gläubigerkonto wurde nicht gefunden."); + } + + const rows = await server.db + .select({ + document: createddocuments, + customer: customers, + mandate: outgoingsepamandates, + debtorBankAccount: entitybankaccounts, + }) + .from(createddocuments) + .innerJoin(customers, eq(createddocuments.customer, customers.id)) + .innerJoin(outgoingsepamandates, eq(createddocuments.outgoingsepamandate, outgoingsepamandates.id)) + .innerJoin(entitybankaccounts, eq(outgoingsepamandates.bankaccount, entitybankaccounts.id)) + .where(and( + eq(createddocuments.tenant, tenantId), + eq(createddocuments.payment_type, "direct-debit"), + inArray(createddocuments.id, idsToExport) + )); + + if (rows.length !== idsToExport.length) { + throw new Error("Nicht alle ausgewählten Belege sind gültige SEPA-Lastschrift-Belege mit Mandat."); + } + + const invalidMandate = rows.find((row) => row.mandate.tenant !== tenantId || row.mandate.status !== "Aktiv" || row.mandate.archived); + if (invalidMandate) { + throw new Error(`Mandat ${invalidMandate.mandate.reference} ist nicht aktiv oder gehört nicht zum Mandanten.`); + } + + const transactions = rows.map((row) => ({ + ...row, + transaction: buildDirectDebitTransaction(row), + })); + + const totalAmount = Number(transactions.reduce((sum, item) => sum + item.transaction.amount, 0).toFixed(2)); + const messageId = randomUUID(); + const collectionDate = dayjs().add(3, "days").format("YYYY-MM-DD"); + const createdAt = dayjs().format("YYYY-MM-DDTHH:mm:ss"); + const creditorIban = cleanIban(creditorBankAccount.iban); + const creditorBic = cleanBic(creditorBankAccount.bankId); + + type SepaTransaction = (typeof transactions)[number]; + const groupedTransactions: Record = {}; + + transactions.forEach((item) => { + const key = `${item.mandate.mandateType || "CORE"}-${item.mandate.sequenceType || "RCUR"}`; + groupedTransactions[key] = groupedTransactions[key] || []; + groupedTransactions[key].push(item); + }); + + const paymentInformations = Object.entries(groupedTransactions).map(([key, items], index) => { + const groupTotal = Number(items.reduce((sum, item) => sum + item.transaction.amount, 0).toFixed(2)); + const [mandateType, sequenceType] = key.split("-"); + + return { + PmtInfId: sanitizeText(`${messageId}-${index + 1}`, 35), + PmtMtd: "DD", + BtchBookg: "true", + NbOfTxs: items.length, + CtrlSum: formatAmount(groupTotal), + PmtTpInf: { + SvcLvl: { + Cd: "SEPA", + }, + LclInstrm: { + Cd: mandateType, + }, + SeqTp: sequenceType, + }, + ReqdColltnDt: collectionDate, + Cdtr: { + Nm: sanitizeText(tenantData.name, 70), + }, + CdtrAcct: { + Id: { + IBAN: creditorIban, + }, + }, + CdtrAgt: { + FinInstnId: { + BIC: creditorBic, + }, + }, + ChrgBr: "SLEV", + CdtrSchmeId: { + Id: { + PrvtId: { + Othr: { + Id: tenantData.creditorId, + SchmeNm: { + Prtry: "SEPA", + }, + }, + }, + }, + }, + DrctDbtTxInf: items.map((item) => item.transaction.xml), + }; + }); + + const obj = { + Document: { + "@xmlns": "urn:iso:std:iso:20022:tech:xsd:pain.008.001.02", + CstmrDrctDbtInitn: { + GrpHdr: { + MsgId: sanitizeText(messageId, 35), + CreDtTm: createdAt, + NbOfTxs: transactions.length, + CtrlSum: formatAmount(totalAmount), + InitgPty: { + Nm: sanitizeText(tenantData.name, 70), + }, + }, + PmtInf: paymentInformations, + }, + }, + }; + + const xml = xmlbuilder.create(obj, { encoding: "UTF-8", standalone: true }).end({ pretty: true }); + const documentDates = rows.map((row) => dayjs(row.document.documentDate)).filter((date) => date.isValid()); + const startDate = documentDates.reduce((min, date) => date.isBefore(min) ? date : min, documentDates[0] || dayjs()); + const endDate = documentDates.reduce((max, date) => date.isAfter(max) ? date : max, documentDates[0] || dayjs()); + + return { + buffer: Buffer.from(xml, "utf-8"), + startDate: startDate.toDate(), + endDate: endDate.toDate(), + count: transactions.length, + totalAmount, + }; +}; diff --git a/frontend/pages/export/create/sepa.vue b/frontend/pages/export/create/sepa.vue index 4e45b18..f37876c 100644 --- a/frontend/pages/export/create/sepa.vue +++ b/frontend/pages/export/create/sepa.vue @@ -1,25 +1,57 @@