269 lines
9.3 KiB
TypeScript
269 lines
9.3 KiB
TypeScript
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<string, SepaTransaction[]> = {};
|
|
|
|
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,
|
|
};
|
|
};
|