import { FastifyInstance } from "fastify" import axios from "axios" import dayjs from "dayjs" import { secrets } from "../utils/secrets" import { insertHistoryItem } from "../utils/history" import { decrypt, encrypt } from "../utils/crypt" import { DE_BANK_CODE_TO_NAME } from "../utils/deBankCodes" import { DE_BANK_CODE_TO_BIC } from "../utils/deBankBics" import { bankrequisitions, bankstatements, createddocuments, customers, entitybankaccounts, incominginvoices, statementallocations, vendors, } from "../../db/schema" import { eq, and, } from "drizzle-orm" export default async function bankingRoutes(server: FastifyInstance) { const normalizeIban = (value?: string | null) => String(value || "").replace(/\s+/g, "").toUpperCase() const pickPartnerBankData = (statement: any, partnerType: "customer" | "vendor") => { if (!statement) return null const prefersDebit = partnerType === "customer" ? Number(statement.amount) >= 0 : Number(statement.amount) > 0 const primary = prefersDebit ? { iban: statement.debIban } : { iban: statement.credIban } const fallback = prefersDebit ? { iban: statement.credIban } : { iban: statement.debIban } const primaryIban = normalizeIban(primary.iban) if (primaryIban) { return { iban: primaryIban, } } const fallbackIban = normalizeIban(fallback.iban) if (fallbackIban) { return { iban: fallbackIban, } } return null } const mergePartnerIban = (infoData: Record, iban: string, bankAccountId?: number | null) => { if (!iban && !bankAccountId) return infoData || {} const info = infoData && typeof infoData === "object" ? { ...infoData } : {} if (iban) { const existing = Array.isArray(info.bankingIbans) ? info.bankingIbans : [] const merged = [...new Set([...existing.map((i: string) => normalizeIban(i)), iban])] info.bankingIbans = merged if (!info.bankingIban) info.bankingIban = iban } if (bankAccountId) { const existingIds = Array.isArray(info.bankAccountIds) ? info.bankAccountIds : [] if (!existingIds.includes(bankAccountId)) { info.bankAccountIds = [...existingIds, bankAccountId] } } return info } const ibanLengthByCountry: Record = { DE: 22, AT: 20, CH: 21, NL: 18, BE: 16, FR: 27, ES: 24, IT: 27, LU: 20, } const isValidIbanLocal = (iban: string) => { const normalized = normalizeIban(iban) if (!normalized || normalized.length < 15 || normalized.length > 34) return false if (!/^[A-Z]{2}[0-9]{2}[A-Z0-9]+$/.test(normalized)) return false const country = normalized.slice(0, 2) const expectedLength = ibanLengthByCountry[country] if (expectedLength && normalized.length !== expectedLength) return false const rearranged = normalized.slice(4) + normalized.slice(0, 4) let numeric = "" for (const ch of rearranged) { if (ch >= "A" && ch <= "Z") numeric += (ch.charCodeAt(0) - 55).toString() else numeric += ch } let remainder = 0 for (const digit of numeric) { remainder = (remainder * 10 + Number(digit)) % 97 } return remainder === 1 } const resolveGermanBankDataFromIbanLocal = (iban: string) => { const normalized = normalizeIban(iban) if (!isValidIbanLocal(normalized)) return null // Für DE-IBANs kann die BLZ aus Position 5-12 lokal gelesen werden. if (normalized.startsWith("DE") && normalized.length === 22) { const bankCode = normalized.slice(4, 12) const bankName = DE_BANK_CODE_TO_NAME[bankCode] || `Unbekannt (BLZ ${bankCode})` const bic = DE_BANK_CODE_TO_BIC[bankCode] || null return { bankName, bic, bankCode, } } return null } const resolveEntityBankAccountId = async ( tenantId: number, userId: string, iban: string ) => { const normalizedIban = normalizeIban(iban) if (!normalizedIban) return null const bankData = resolveGermanBankDataFromIbanLocal(normalizedIban) const allAccounts = await server.db .select({ id: entitybankaccounts.id, ibanEncrypted: entitybankaccounts.ibanEncrypted, bankNameEncrypted: entitybankaccounts.bankNameEncrypted, bicEncrypted: entitybankaccounts.bicEncrypted, }) .from(entitybankaccounts) .where(eq(entitybankaccounts.tenant, tenantId)) const existing = allAccounts.find((row) => { if (!row.ibanEncrypted) return false try { const decryptedIban = decrypt(row.ibanEncrypted as any) return normalizeIban(decryptedIban) === normalizedIban } catch { return false } }) if (existing?.id) { if (bankData) { let currentBankName = "" let currentBic = "" try { currentBankName = String(decrypt(existing.bankNameEncrypted as any) || "").trim() } catch { currentBankName = "" } try { currentBic = String(decrypt((existing as any).bicEncrypted as any) || "").trim() } catch { currentBic = "" } const nextBankName = bankData?.bankName || "Unbekannt" const nextBic = bankData?.bic || "UNBEKANNT" if (currentBankName !== nextBankName || currentBic !== nextBic) { await server.db .update(entitybankaccounts) .set({ bankNameEncrypted: encrypt(nextBankName), bicEncrypted: encrypt(nextBic), updatedAt: new Date(), updatedBy: userId, }) .where(and(eq(entitybankaccounts.id, Number(existing.id)), eq(entitybankaccounts.tenant, tenantId))) } } return Number(existing.id) } const [created] = await server.db .insert(entitybankaccounts) .values({ tenant: tenantId, ibanEncrypted: encrypt(normalizedIban), bicEncrypted: encrypt(bankData?.bic || "UNBEKANNT"), bankNameEncrypted: encrypt(bankData?.bankName || "Unbekannt"), description: "Automatisch aus Bankbuchung übernommen", updatedAt: new Date(), updatedBy: userId, }) .returning({ id: entitybankaccounts.id }) return created?.id ? Number(created.id) : null } server.get("/banking/iban/:iban", async (req, reply) => { try { const { iban } = req.params as { iban: string } const normalized = normalizeIban(iban) if (!normalized) { return reply.code(400).send({ error: "IBAN missing" }) } const valid = isValidIbanLocal(normalized) const bankData = resolveGermanBankDataFromIbanLocal(normalized) return reply.send({ iban: normalized, valid, bic: bankData?.bic || null, bankName: bankData?.bankName || null, bankCode: bankData?.bankCode || null, }) } catch (err) { server.log.error(err) return reply.code(500).send({ error: "Failed to resolve IBAN data" }) } }) const assignIbanFromStatementToCustomer = async (tenantId: number, userId: string, statementId: number, createdDocumentId?: number) => { if (!createdDocumentId) return const [statement] = await server.db .select() .from(bankstatements) .where(and(eq(bankstatements.id, statementId), eq(bankstatements.tenant, tenantId))) .limit(1) if (!statement) return const [doc] = await server.db .select({ customer: createddocuments.customer }) .from(createddocuments) .where(and(eq(createddocuments.id, createdDocumentId), eq(createddocuments.tenant, tenantId))) .limit(1) const customerId = doc?.customer if (!customerId) return const partnerBank = pickPartnerBankData(statement, "customer") if (!partnerBank?.iban) return const [customer] = await server.db .select({ id: customers.id, infoData: customers.infoData }) .from(customers) .where(and(eq(customers.id, customerId), eq(customers.tenant, tenantId))) .limit(1) if (!customer) return const bankAccountId = await resolveEntityBankAccountId( tenantId, userId, partnerBank.iban ) const newInfoData = mergePartnerIban( (customer.infoData || {}) as Record, partnerBank.iban, bankAccountId ) await server.db .update(customers) .set({ infoData: newInfoData, updatedAt: new Date(), updatedBy: userId, }) .where(and(eq(customers.id, customerId), eq(customers.tenant, tenantId))) } const assignIbanFromStatementToVendor = async (tenantId: number, userId: string, statementId: number, incomingInvoiceId?: number) => { if (!incomingInvoiceId) return const [statement] = await server.db .select() .from(bankstatements) .where(and(eq(bankstatements.id, statementId), eq(bankstatements.tenant, tenantId))) .limit(1) if (!statement) return const [invoice] = await server.db .select({ vendor: incominginvoices.vendor }) .from(incominginvoices) .where(and(eq(incominginvoices.id, incomingInvoiceId), eq(incominginvoices.tenant, tenantId))) .limit(1) const vendorId = invoice?.vendor if (!vendorId) return const partnerBank = pickPartnerBankData(statement, "vendor") if (!partnerBank?.iban) return const [vendor] = await server.db .select({ id: vendors.id, infoData: vendors.infoData }) .from(vendors) .where(and(eq(vendors.id, vendorId), eq(vendors.tenant, tenantId))) .limit(1) if (!vendor) return const bankAccountId = await resolveEntityBankAccountId( tenantId, userId, partnerBank.iban ) const newInfoData = mergePartnerIban( (vendor.infoData || {}) as Record, partnerBank.iban, bankAccountId ) await server.db .update(vendors) .set({ infoData: newInfoData, updatedAt: new Date(), updatedBy: userId, }) .where(and(eq(vendors.id, vendorId), eq(vendors.tenant, tenantId))) } // ------------------------------------------------------------------ // 🔐 GoCardLess Token Handling // ------------------------------------------------------------------ const goCardLessBaseUrl = secrets.GOCARDLESS_BASE_URL const goCardLessSecretId = secrets.GOCARDLESS_SECRET_ID const goCardLessSecretKey = secrets.GOCARDLESS_SECRET_KEY let tokenData: any = null const getToken = async () => { const res = await axios.post(`${goCardLessBaseUrl}/token/new/`, { secret_id: goCardLessSecretId, secret_key: goCardLessSecretKey, }) tokenData = res.data tokenData.created_at = new Date().toISOString() server.log.info("GoCardless token refreshed.") } const checkToken = async () => { if (!tokenData) return await getToken() const expired = dayjs(tokenData.created_at) .add(tokenData.access_expires, "seconds") .isBefore(dayjs()) if (expired) { server.log.info("Refreshing expired GoCardless token …") await getToken() } } // ------------------------------------------------------------------ // 🔗 Create GoCardless Banking Link // ------------------------------------------------------------------ server.get("/banking/link/:institutionid", async (req, reply) => { try { await checkToken() const { institutionid } = req.params as { institutionid: string } const tenantId = req.user?.tenant_id if (!tenantId) return reply.code(401).send({ error: "Unauthorized" }) const { data } = await axios.post( `${goCardLessBaseUrl}/requisitions/`, { redirect: "https://app.fedeo.de/settings/banking", institution_id: institutionid, user_language: "de", }, { headers: { Authorization: `Bearer ${tokenData.access}` }, } ) // DB: Requisition speichern await server.db.insert(bankrequisitions).values({ id: data.id, tenant: tenantId, institutionId: institutionid, status: data.status, }) return reply.send({ link: data.link }) } catch (err: any) { server.log.error(err?.response?.data || err) return reply.code(500).send({ error: "Failed to generate link" }) } }) // ------------------------------------------------------------------ // 🏦 Check Bank Institutions // ------------------------------------------------------------------ server.get("/banking/institutions/:bic", async (req, reply) => { try { const { bic } = req.params as { bic: string } if (!bic) return reply.code(400).send("BIC missing") await checkToken() const { data } = await axios.get( `${goCardLessBaseUrl}/institutions/?country=de`, { headers: { Authorization: `Bearer ${tokenData.access}` } } ) const bank = data.find((i: any) => i.bic.toLowerCase() === bic.toLowerCase()) if (!bank) return reply.code(404).send("Bank not found") return reply.send(bank) } catch (err: any) { server.log.error(err?.response?.data || err) return reply.code(500).send("Failed to fetch institutions") } }) // ------------------------------------------------------------------ // 📄 Get Requisition Details // ------------------------------------------------------------------ server.get("/banking/requisitions/:reqId", async (req, reply) => { try { const { reqId } = req.params as { reqId: string } if (!reqId) return reply.code(400).send("Requisition ID missing") await checkToken() const { data } = await axios.get( `${goCardLessBaseUrl}/requisitions/${reqId}`, { headers: { Authorization: `Bearer ${tokenData.access}` } } ) // Load account details if (data.accounts) { data.accounts = await Promise.all( data.accounts.map(async (accId: string) => { const { data: acc } = await axios.get( `${goCardLessBaseUrl}/accounts/${accId}`, { headers: { Authorization: `Bearer ${tokenData.access}` } } ) return acc }) ) } return reply.send(data) } catch (err: any) { server.log.error(err?.response?.data || err) return reply.code(500).send("Failed to fetch requisition details") } }) // ------------------------------------------------------------------ // 💰 Create Statement Allocation // ------------------------------------------------------------------ server.post("/banking/statements", async (req, reply) => { try { if (!req.user) return reply.code(401).send({ error: "Unauthorized" }) const { data: payload } = req.body as { data: any } const inserted = await server.db.insert(statementallocations).values({ ...payload, tenant: req.user.tenant_id }).returning() const createdRecord = inserted[0] if (createdRecord?.createddocument) { try { await assignIbanFromStatementToCustomer( req.user.tenant_id, req.user.user_id, Number(createdRecord.bankstatement), Number(createdRecord.createddocument) ) } catch (err) { server.log.warn({ err, allocationId: createdRecord.id }, "Konnte IBAN nicht automatisch beim Kunden hinterlegen") } } if (createdRecord?.incominginvoice) { try { await assignIbanFromStatementToVendor( req.user.tenant_id, req.user.user_id, Number(createdRecord.bankstatement), Number(createdRecord.incominginvoice) ) } catch (err) { server.log.warn({ err, allocationId: createdRecord.id }, "Konnte IBAN nicht automatisch beim Lieferanten hinterlegen") } } await insertHistoryItem(server, { entity: "bankstatements", entityId: Number(createdRecord.bankstatement), action: "created", created_by: req.user.user_id, tenant_id: req.user.tenant_id, oldVal: null, newVal: createdRecord, text: "Buchung erstellt", }) return reply.send(createdRecord) } catch (err) { console.error(err) return reply.code(500).send({ error: "Failed to create statement" }) } }) // ------------------------------------------------------------------ // 🗑 Delete Statement Allocation // ------------------------------------------------------------------ server.delete("/banking/statements/:id", async (req, reply) => { try { if (!req.user) return reply.code(401).send({ error: "Unauthorized" }) const { id } = req.params as { id: string } const oldRecord = await server.db .select() .from(statementallocations) .where(eq(statementallocations.id, id)) .limit(1) const old = oldRecord[0] if (!old) return reply.code(404).send({ error: "Record not found" }) await server.db .delete(statementallocations) .where(eq(statementallocations.id, id)) await insertHistoryItem(server, { entity: "bankstatements", entityId: Number(old.bankstatement), action: "deleted", created_by: req.user.user_id, tenant_id: req.user.tenant_id, oldVal: old, newVal: null, text: "Buchung gelöscht", }) return reply.send({ success: true }) } catch (err) { console.error(err) return reply.code(500).send({ error: "Failed to delete statement" }) } }) }