import xmlbuilder from "xmlbuilder"; import { randomUUID } from "node:crypto"; import dayjs from "dayjs"; import { and, eq, inArray } from "drizzle-orm"; import { bankaccounts, createddocuments, customers, entitybankaccounts, outgoingsepamandates, tenants, } from "../../../db/schema"; import { decrypt } from "../crypt"; const getCreatedDocumentTotal = (item: any) => { let totalNet = 0; let total19 = 0; let total7 = 0; 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); 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.`); } if (!item.mandate.signedAt) { throw new Error(`Mandat ${item.mandate.reference} hat kein Unterschriftsdatum.`); } 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, }; };