From dd2a9926dcd567e61c47d1874082091df97c94ec Mon Sep 17 00:00:00 2001 From: flfeders Date: Wed, 22 Jul 2026 18:51:13 +0200 Subject: [PATCH] Add payment QR code to invoice PDFs --- .../src/modules/serialexecution.service.ts | 22 +++-- backend/src/utils/pdf.ts | 83 ++++++++++++++++++- frontend/pages/createDocument/edit/[[id]].vue | 16 +++- 3 files changed, 114 insertions(+), 7 deletions(-) diff --git a/backend/src/modules/serialexecution.service.ts b/backend/src/modules/serialexecution.service.ts index e05e51e..a40edde 100644 --- a/backend/src/modules/serialexecution.service.ts +++ b/backend/src/modules/serialexecution.service.ts @@ -405,12 +405,15 @@ async function getCloseData(server:FastifyInstance,item: any, tenant: any, units console.log(item); - const [contact, customer, project, contract] = await Promise.all([ + const [contact, customer, project, contract, bankAccounts] = await Promise.all([ fetchById(server, schema.contacts, item.contact), fetchById(server, schema.customers, item.customer), fetchById(server, schema.projects, item.project), fetchById(server, schema.contracts, item.contract), - item.letterhead ? fetchById(server, schema.letterheads, item.letterhead) : null + server.db + .select() + .from(schema.bankaccounts) + .where(and(eq(schema.bankaccounts.tenant, tenant.id), eq(schema.bankaccounts.archived, false), eq(schema.bankaccounts.expired, false))) ]); @@ -433,7 +436,8 @@ async function getCloseData(server:FastifyInstance,item: any, tenant: any, units contract, // Contract Object units, // Units Array products, // Products Array - services // Services Array + services, // Services Array + bankAccounts // Tenant Bank Accounts ); @@ -528,9 +532,11 @@ export function getDocumentDataBackend( contractData: any, // Vertrag units: any[], // Array aller Einheiten products: any[], // Array aller Produkte - services: any[] // Array aller Services + services: any[], // Array aller Services + bankAccounts: any[] = [] // Array aller Bankkonten ) { const businessInfo = tenant.businessInfo || {}; // Fallback falls leer + const paymentQrBankAccount = bankAccounts.find((account: any) => !account.archived && !account.expired && account.iban) || null // --- 1. Agriculture Logic --- // Prüfen ob 'extraModules' existiert, sonst leeres Array annehmen @@ -724,6 +730,12 @@ export function getDocumentDataBackend( }, agriculture: itemInfo.agriculture, // Falls du AdvanceInvoices brauchst, musst du die Objekte hier übergeben oder leer lassen - usedAdvanceInvoices: [] + usedAdvanceInvoices: [], + paymentQr: paymentQrBankAccount && itemInfo.payment_type === "transfer" ? { + iban: paymentQrBankAccount.iban, + recipientName: paymentQrBankAccount.ownerName || businessInfo.name || tenant.name, + amount: totals.totalGross, + remittance: itemInfo.documentNumber || itemInfo.title, + } : null }; } diff --git a/backend/src/utils/pdf.ts b/backend/src/utils/pdf.ts index 067bf27..21caa5d 100644 --- a/backend/src/utils/pdf.ts +++ b/backend/src/utils/pdf.ts @@ -5,6 +5,7 @@ import {FastifyInstance} from "fastify"; import { GetObjectCommand } from "@aws-sdk/client-s3"; import { s3 } from "./s3"; import { secrets } from "./secrets"; +import bwipjs from "bwip-js" const getCoordinatesForPDFLib = (x:number ,y:number, page:any) => { /* @@ -29,6 +30,61 @@ const getRightAlignedX = (rightEdgeMm: number, text: string, size: number, font: return getCoordinatesForPDFLib(rightEdgeMm, 0, page).x - font.widthOfTextAtSize(text, size) } +const normalizePaymentQrText = (value: any, maxLength: number) => { + return String(value || "") + .replace(/\r?\n/g, " ") + .replace(/[^\x20-\x7EÄÖÜäöüß]/g, "") + .trim() + .slice(0, maxLength) +} + +const normalizeIban = (value: any) => String(value || "").replace(/\s+/g, "").toUpperCase() + +const getPaymentQrAmount = (value: any) => { + if (typeof value === "number" && Number.isFinite(value)) return value + + const normalized = String(value || "") + .replace(/[^\d,.-]/g, "") + .replace(/\./g, "") + .replace(",", ".") + + const amount = Number(normalized) + + return Number.isFinite(amount) ? amount : 0 +} + +const getPaymentQrCodeBuffer = async (invoiceData: any) => { + if (invoiceData?.payment_type && invoiceData.payment_type !== "transfer") return null + + const paymentQr = invoiceData?.paymentQr || {} + const iban = normalizeIban(paymentQr.iban) + const recipientName = normalizePaymentQrText(paymentQr.recipientName || paymentQr.name, 70) + const amount = getPaymentQrAmount(paymentQr.amount ?? invoiceData?.total?.totalSumToPay) + + if (!iban || !recipientName || amount <= 0) return null + + const epcPayload = [ + "BCD", + "002", + "1", + "SCT", + normalizePaymentQrText(paymentQr.bic, 11), + recipientName, + iban, + `EUR${amount.toFixed(2)}`, + "", + normalizePaymentQrText(paymentQr.remittance || invoiceData?.documentNumber || invoiceData?.title, 140), + "", + ].join("\n") + + return bwipjs.toBuffer({ + bcid: "qrcode", + text: epcPayload, + scale: 4, + includetext: false, + }) +} + const getBackgroundSourceBuffer = async (server:FastifyInstance, path:string) => { @@ -70,6 +126,8 @@ export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoi const font = await pdfDoc.embedFont(StandardFonts.Helvetica) const fontBold = await pdfDoc.embedFont(StandardFonts.HelveticaBold) + const paymentQrCodeBuffer = await getPaymentQrCodeBuffer(invoiceData) + const paymentQrCode = paymentQrCodeBuffer ? await pdfDoc.embedPng(paymentQrCodeBuffer) : null let pages = [] let pageCounter = 1 @@ -923,8 +981,31 @@ export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoi color: rgb(0, 0, 0), lineHeight: 10, opacity: 1, - maxWidth: 500 + maxWidth: paymentQrCode ? 360 : 500 }) + + if (paymentQrCode) { + const qrSize = 32 * 2.83 + const qrPosition = getCoordinatesForPDFLib(166, rowHeight + endTextDiff + (invoiceData.totalArray.length - 3) * 8 - 1, page1) + + pages[pageCounter - 1].drawText("Überweisung per QR-Code", { + y: qrPosition.y + qrSize + 7, + x: getCoordinatesForPDFLib(166, 0, page1).x, + size: 8, + color: rgb(0, 0, 0), + lineHeight: 8, + opacity: 1, + maxWidth: 120, + font: fontBold, + }) + + pages[pageCounter - 1].drawImage(paymentQrCode, { + x: qrPosition.x, + y: qrPosition.y, + width: qrSize, + height: qrSize, + }) + } } return await pdfDoc.saveAsBase64() diff --git a/frontend/pages/createDocument/edit/[[id]].vue b/frontend/pages/createDocument/edit/[[id]].vue index 85ff571..40e1862 100644 --- a/frontend/pages/createDocument/edit/[[id]].vue +++ b/frontend/pages/createDocument/edit/[[id]].vue @@ -95,6 +95,7 @@ const customers = ref([]) const contacts = ref([]) const contracts = ref([]) const outgoingsepamandates = ref([]) +const bankaccounts = ref([]) const texttemplates = ref([]) const units = ref([]) const tenantUsers = ref([]) @@ -236,6 +237,12 @@ const setupData = async () => { contacts.value = await useEntities("contacts").select("*") contracts.value = await useEntities("contracts").select("*") outgoingsepamandates.value = await useEntities("outgoingsepamandates").select("*") + try { + bankaccounts.value = await useEntities("bankaccounts").select("*") + } catch (error) { + console.warn("Bankkonten konnten nicht für den Zahlungs-QR-Code geladen werden", error) + bankaccounts.value = [] + } texttemplates.value = await useEntities("texttemplates").select("*") units.value = await useEntities("units").selectSpecial("*") tenantUsers.value = (await useNuxtApp().$api(`/api/tenant/users`, { @@ -1365,6 +1372,7 @@ const getDocumentData = async () => { let customerData = customers.value.find(i => i.id === itemInfo.value.customer) let contactData = contacts.value.find(i => i.id === itemInfo.value.contact) let businessInfo = auth.activeTenantData.businessInfo + const paymentQrBankAccount = bankaccounts.value.find(account => !account.archived && !account.expired && account.iban) if (auth.activeTenantData.extraModules.includes("agriculture")) { itemInfo.value.rows.forEach(row => { @@ -1580,7 +1588,13 @@ const getDocumentData = async () => { agriculture: itemInfo.value.agriculture, usedAdvanceInvoices: itemInfo.value.usedAdvanceInvoices.map(i => { return createddocuments.value.find(x => x.id === i) - }) + }), + paymentQr: paymentQrBankAccount && itemInfo.value.payment_type === "transfer" ? { + iban: paymentQrBankAccount.iban, + recipientName: paymentQrBankAccount.ownerName || businessInfo.name || auth.activeTenantData.name, + amount: documentTotal.value.totalSumToPay, + remittance: itemInfo.value.documentNumber || itemInfo.value.title, + } : null } console.log(returnData)