SEPA-Exportdatei aus Mandaten erzeugen #182
This commit is contained in:
@@ -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<number>
|
||||
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)
|
||||
|
||||
@@ -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<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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,25 +1,57 @@
|
||||
<script setup>
|
||||
|
||||
const createddocuments = ref([])
|
||||
const bankaccounts = ref([])
|
||||
const selected = ref([])
|
||||
const selectedBankaccount = ref(null)
|
||||
const loading = ref(true)
|
||||
const toast = useToast()
|
||||
const router = useRouter()
|
||||
|
||||
const setup = async () => {
|
||||
createddocuments.value = (await useEntities("createddocuments").select()).filter(i => i.payment_type === "direct-debit")
|
||||
selected.value = createddocuments.value
|
||||
loading.value = true
|
||||
const [documents, accounts] = await Promise.all([
|
||||
useEntities("createddocuments").select("*, customer(id,name), outgoingsepamandate(*, bankaccount(*)), statementallocations(*)"),
|
||||
useEntities("bankaccounts").select("*")
|
||||
])
|
||||
|
||||
createddocuments.value = documents.filter(i => i.payment_type === "direct-debit" && i.outgoingsepamandate)
|
||||
bankaccounts.value = accounts.filter(i => !i.archived && !i.expired)
|
||||
selected.value = createddocuments.value
|
||||
selectedBankaccount.value = bankaccounts.value[0]?.id || null
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
setup()
|
||||
|
||||
const createExport = async () => {
|
||||
if (!selectedBankaccount.value) {
|
||||
toast.add({ title: "Bitte wählen Sie ein Gläubigerkonto aus.", color: "error" })
|
||||
return
|
||||
}
|
||||
|
||||
if (!selected.value.length) {
|
||||
toast.add({ title: "Bitte wählen Sie mindestens einen Beleg aus.", color: "error" })
|
||||
return
|
||||
}
|
||||
|
||||
//NUMMERN MAPPEN ZU IDS UND AN BACKEND FUNKTION ÜBERGEBEN
|
||||
const ids = selected.value.map((i) => i.id)
|
||||
|
||||
const res = await useNuxtApp().$api("/api/exports/sepa", {
|
||||
method: "POST",
|
||||
body: {
|
||||
idsToExport: ids
|
||||
idsToExport: ids,
|
||||
creditorBankaccountId: selectedBankaccount.value
|
||||
}
|
||||
})
|
||||
|
||||
if (res.success) {
|
||||
toast.add({ title: "SEPA-Export wird erstellt. Sie finden die Datei anschließend in der Exportliste." })
|
||||
await router.push("/export")
|
||||
} else {
|
||||
toast.add({ title: res.message || "Der SEPA-Export konnte nicht gestartet werden.", color: "error" })
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -34,13 +66,46 @@ const createExport = async () => {
|
||||
</UButton>
|
||||
</template>
|
||||
</UDashboardNavbar>
|
||||
<div class="p-4">
|
||||
<UFormField label="Gläubigerkonto" class="max-w-xl">
|
||||
<USelectMenu
|
||||
v-model="selectedBankaccount"
|
||||
value-key="id"
|
||||
label-key="name"
|
||||
:items="bankaccounts"
|
||||
:search-input="{ placeholder: 'Konto suchen...' }"
|
||||
:filter-fields="['name', 'iban', 'bankId']"
|
||||
class="w-full"
|
||||
>
|
||||
<template #default>
|
||||
{{ bankaccounts.find(i => i.id === selectedBankaccount)?.name || "Gläubigerkonto auswählen" }}
|
||||
</template>
|
||||
<template #item="{ item }">
|
||||
{{ item.name || item.ownerName || "Bankkonto" }} - {{ item.iban }} - {{ item.bankId }}
|
||||
</template>
|
||||
</USelectMenu>
|
||||
</UFormField>
|
||||
</div>
|
||||
<UTable
|
||||
:loading="true"
|
||||
:loading="loading"
|
||||
v-model="selected"
|
||||
:loading-state="{ icon: 'i-heroicons-arrow-path-20-solid', label: 'Loading...' }"
|
||||
:data="createddocuments"
|
||||
:columns="normalizeTableColumns([
|
||||
{ key: 'documentNumber', label: 'Belegnummer' },
|
||||
{ key: 'customer', label: 'Kunde' },
|
||||
{ key: 'documentDate', label: 'Belegdatum' },
|
||||
{ key: 'outgoingsepamandate', label: 'SEPA-Mandat' },
|
||||
])"
|
||||
:empty="{ icon: 'i-heroicons-circle-stack-20-solid', label: 'Keine SEPA-Belege anzuzeigen' }"
|
||||
/>
|
||||
>
|
||||
<template #customer-cell="{ row }">
|
||||
{{ row.original.customer?.name || "-" }}
|
||||
</template>
|
||||
<template #outgoingsepamandate-cell="{ row }">
|
||||
{{ row.original.outgoingsepamandate?.reference || "-" }}
|
||||
</template>
|
||||
</UTable>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
Reference in New Issue
Block a user