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, bankaccounts, bankstatements, accounts, createddocuments, customers, entitybankaccounts, incominginvoices, ownaccounts, statementallocations, vendors, } from "../../db/schema" import { eq, and, isNull, aliasedTable, desc, } from "drizzle-orm" export default async function bankingRoutes(server: FastifyInstance) { const CASHBOOK_BANK_ID = "fedeo-cashbook" const ContraAccounts = aliasedTable(accounts, "contra_accounts") const ContraCustomers = aliasedTable(customers, "contra_customers") const ContraVendors = aliasedTable(vendors, "contra_vendors") const ContraOwnaccounts = aliasedTable(ownaccounts, "contra_ownaccounts") const ManualInvoices = aliasedTable(incominginvoices, "manual_invoices") const ManualInvoiceVendors = aliasedTable(vendors, "manual_invoice_vendors") const normalizeManualSide = (payload: any, keys: string[]) => keys.filter((key) => payload[key] !== null && payload[key] !== undefined && payload[key] !== "") const cashbookAccountFilter = (tenantId: number) => and( eq(bankaccounts.tenant, tenantId), eq(bankaccounts.bankId, CASHBOOK_BANK_ID), eq(bankaccounts.archived, false) ) const buildCashbookCounterPayload = (type: string, id: any) => { const numericId = type === "ownaccount" ? id : Number(id) if (!id || (type !== "ownaccount" && !Number.isFinite(numericId))) return null if (type === "account") return { account: numericId } if (type === "customer") return { customer: numericId } if (type === "vendor") return { vendor: numericId } if (type === "ownaccount") return { ownaccount: numericId } if (type === "incominginvoice") return { incominginvoice: numericId } return null } const prepareStatementAllocationPayload = (payload: any) => { const next = { ...payload } const isManualBooking = !next.bankstatement if (!isManualBooking) { next.manualBookingDate = null next.contraAccount = null next.contraCustomer = null next.contraVendor = null next.contraOwnaccount = null next.datevTaxKey = next.datevTaxKey ? String(next.datevTaxKey).trim() : null next.manualInvoiceSide = null return { data: next } } const debitKeys = ["account", "customer", "vendor", "ownaccount"] const creditKeys = ["contraAccount", "contraCustomer", "contraVendor", "contraOwnaccount"] const hasManualInvoice = next.incominginvoice !== null && next.incominginvoice !== undefined && next.incominginvoice !== "" const debitSide = normalizeManualSide(next, debitKeys) const creditSide = normalizeManualSide(next, creditKeys) if (hasManualInvoice) { if (next.manualInvoiceSide === "debit") debitSide.push("incominginvoice") else if (next.manualInvoiceSide === "credit") creditSide.push("incominginvoice") else return { error: "Für zugewiesene Eingangsbelege muss Soll oder Haben ausgewählt sein." } } else { next.manualInvoiceSide = null } if (!next.manualBookingDate || !dayjs(next.manualBookingDate).isValid()) { return { error: "Für manuelle Buchungen ist ein gültiges Buchungsdatum erforderlich." } } if (!Number.isFinite(Number(next.amount)) || Number(next.amount) <= 0) { return { error: "Für manuelle Buchungen muss der Betrag größer als 0 sein." } } if (debitSide.length !== 1 || creditSide.length !== 1) { return { error: "Für manuelle Buchungen muss genau ein Soll- und ein Haben-Konto ausgewählt werden." } } next.amount = Math.abs(Number(next.amount)) next.bankstatement = null next.manualBookingDate = dayjs(next.manualBookingDate).format("YYYY-MM-DD") next.datevTaxKey = next.datevTaxKey ? String(next.datevTaxKey).trim() : null return { data: next } } server.get("/banking/cashbooks", async (req, reply) => { try { if (!req.user) return reply.code(401).send({ error: "Unauthorized" }) const rows = await server.db .select() .from(bankaccounts) .where(cashbookAccountFilter(req.user.tenant_id)) .orderBy(bankaccounts.name) return reply.send(rows) } catch (err) { server.log.error(err) return reply.code(500).send({ error: "Failed to load cashbooks" }) } }) server.post("/banking/cashbooks", async (req, reply) => { try { if (!req.user) return reply.code(401).send({ error: "Unauthorized" }) const body = req.body as { name?: string datevNumber?: string openingBalance?: number } const name = String(body.name || "").trim() const datevNumber = String(body.datevNumber || "").trim() const openingBalance = Number(body.openingBalance || 0) if (!name) return reply.code(400).send({ error: "Bitte eine Bezeichnung für die Kasse angeben." }) if (!datevNumber) return reply.code(400).send({ error: "Bitte eine Kontennummer für die Kasse angeben." }) if (!Number.isFinite(openingBalance)) return reply.code(400).send({ error: "Der Anfangsbestand ist ungültig." }) const uniquePart = `${req.user.tenant_id}-${Date.now()}` const inserted = await server.db.insert(bankaccounts).values({ name, iban: `CASH-${uniquePart}`, tenant: req.user.tenant_id, bankId: CASHBOOK_BANK_ID, ownerName: name, accountId: `cashbook-${uniquePart}`, balance: openingBalance, datevNumber, updatedBy: req.user.user_id, }).returning() const createdRecord = inserted[0] return reply.send(createdRecord) } catch (err) { server.log.error(err) return reply.code(500).send({ error: "Failed to create cashbook" }) } }) server.patch("/banking/cashbooks/:id/archive", async (req, reply) => { try { if (!req.user) return reply.code(401).send({ error: "Unauthorized" }) const { id } = req.params as { id: string } const updated = await server.db.update(bankaccounts) .set({ archived: true, updatedAt: new Date(), updatedBy: req.user.user_id, }) .where(and( eq(bankaccounts.id, Number(id)), eq(bankaccounts.tenant, req.user.tenant_id), eq(bankaccounts.bankId, CASHBOOK_BANK_ID) )) .returning() if (!updated[0]) return reply.code(404).send({ error: "Cashbook not found" }) return reply.send(updated[0]) } catch (err) { server.log.error(err) return reply.code(500).send({ error: "Failed to archive cashbook" }) } }) server.get("/banking/cashbooks/:id/bookings", async (req, reply) => { try { if (!req.user) return reply.code(401).send({ error: "Unauthorized" }) const { id } = req.params as { id: string } const cashbookId = Number(id) if (!Number.isFinite(cashbookId)) return reply.code(400).send({ error: "Ungültige Kasse." }) const rows = await server.db.select({ statement: bankstatements, allocation: statementallocations, account: accounts, customer: customers, vendor: vendors, ownaccount: ownaccounts, incominginvoice: ManualInvoices, incominginvoiceVendor: ManualInvoiceVendors, }) .from(bankstatements) .leftJoin(statementallocations, eq(statementallocations.bankstatement, bankstatements.id)) .leftJoin(accounts, eq(statementallocations.account, accounts.id)) .leftJoin(customers, eq(statementallocations.customer, customers.id)) .leftJoin(vendors, eq(statementallocations.vendor, vendors.id)) .leftJoin(ownaccounts, eq(statementallocations.ownaccount, ownaccounts.id)) .leftJoin(ManualInvoices, eq(statementallocations.incominginvoice, ManualInvoices.id)) .leftJoin(ManualInvoiceVendors, eq(ManualInvoices.vendor, ManualInvoiceVendors.id)) .where(and( eq(bankstatements.tenant, req.user.tenant_id), eq(bankstatements.account, cashbookId), eq(bankstatements.archived, false) )) .orderBy(desc(bankstatements.date), desc(bankstatements.createdAt)) return reply.send(rows.map((row) => ({ ...row.statement, allocation: row.allocation, account: row.account, customer: row.customer, vendor: row.vendor, ownaccount: row.ownaccount, incominginvoice: row.incominginvoice ? { ...row.incominginvoice, vendor: row.incominginvoiceVendor, } : null, }))) } catch (err) { server.log.error(err) return reply.code(500).send({ error: "Failed to load cashbook bookings" }) } }) server.post("/banking/cashbooks/:id/bookings", async (req, reply) => { try { if (!req.user) return reply.code(401).send({ error: "Unauthorized" }) const { id } = req.params as { id: string } const cashbookId = Number(id) const body = req.body as { date?: string amount?: number direction?: "income" | "expense" counterType?: string counterId?: string | number description?: string datevTaxKey?: string | null } if (!Number.isFinite(cashbookId)) return reply.code(400).send({ error: "Ungültige Kasse." }) const bookingDate = body.date && dayjs(body.date).isValid() ? dayjs(body.date) : dayjs() if (!Number.isFinite(Number(body.amount)) || Number(body.amount) <= 0) return reply.code(400).send({ error: "Der Betrag muss größer als 0 sein." }) if (body.direction !== "income" && body.direction !== "expense") return reply.code(400).send({ error: "Bitte Einnahme oder Ausgabe auswählen." }) const cashbook = await server.db.select().from(bankaccounts).where(and( eq(bankaccounts.id, cashbookId), eq(bankaccounts.tenant, req.user.tenant_id), eq(bankaccounts.bankId, CASHBOOK_BANK_ID), eq(bankaccounts.archived, false) )).limit(1) if (!cashbook[0]) return reply.code(404).send({ error: "Kasse nicht gefunden." }) const hasCounterInput = Boolean(body.counterType || body.counterId) const counterPayload = hasCounterInput ? buildCashbookCounterPayload(String(body.counterType || ""), body.counterId) : null if (hasCounterInput && !counterPayload) return reply.code(400).send({ error: "Bitte ein gültiges Gegenkonto auswählen." }) const signedAmount = body.direction === "income" ? Math.abs(Number(body.amount)) : -Math.abs(Number(body.amount)) const description = String(body.description || "").trim() || (body.direction === "income" ? "Bareinnahme" : "Barausgabe") const created = await server.db.transaction(async (tx) => { const insertedStatements = await tx.insert(bankstatements).values({ account: cashbookId, date: bookingDate.format("YYYY-MM-DD"), valueDate: bookingDate.format("YYYY-MM-DD"), amount: signedAmount, tenant: req.user.tenant_id, text: description, currency: "EUR", credName: body.direction === "income" ? cashbook[0].name : description, debName: body.direction === "expense" ? cashbook[0].name : description, updatedBy: req.user.user_id, }).returning() const statement = insertedStatements[0] const insertedAllocations = counterPayload ? await tx.insert(statementallocations).values({ bankstatement: statement.id, amount: signedAmount, tenant: req.user.tenant_id, description, datevTaxKey: body.datevTaxKey ? String(body.datevTaxKey).trim() : null, ...counterPayload, }).returning() : [] return { statement, allocation: insertedAllocations[0] || null, } }) await insertHistoryItem(server, { entity: "bankstatements", entityId: Number(created.statement.id), action: "created", created_by: req.user.user_id, tenant_id: req.user.tenant_id, oldVal: null, newVal: created, text: "Kassenbuchung erstellt", }) return reply.send(created) } catch (err) { server.log.error(err) return reply.code(500).send({ error: "Failed to create cashbook booking" }) } }) server.delete("/banking/cashbook-bookings/:id", async (req, reply) => { try { if (!req.user) return reply.code(401).send({ error: "Unauthorized" }) const { id } = req.params as { id: string } const statementId = Number(id) if (!Number.isFinite(statementId)) return reply.code(400).send({ error: "Ungültige Buchung." }) const records = await server.db.select({ statement: bankstatements, cashbook: bankaccounts, }) .from(bankstatements) .innerJoin(bankaccounts, eq(bankstatements.account, bankaccounts.id)) .where(and( eq(bankstatements.id, statementId), eq(bankstatements.tenant, req.user.tenant_id), eq(bankaccounts.bankId, CASHBOOK_BANK_ID) )) .limit(1) if (!records[0]) return reply.code(404).send({ error: "Kassenbuchung nicht gefunden." }) await server.db.transaction(async (tx) => { await tx.delete(statementallocations).where(eq(statementallocations.bankstatement, statementId)) await tx.delete(bankstatements).where(eq(bankstatements.id, statementId)) }) await insertHistoryItem(server, { entity: "bankstatements", entityId: statementId, action: "deleted", created_by: req.user.user_id, tenant_id: req.user.tenant_id, oldVal: records[0].statement, newVal: null, text: "Kassenbuchung gelöscht", }) return reply.send({ success: true }) } catch (err) { server.log.error(err) return reply.code(500).send({ error: "Failed to delete cashbook booking" }) } }) const normalizeIban = (value?: string | null) => String(value || "").replace(/\s+/g, "").toUpperCase() const normalizeName = (value?: string | null) => String(value || "") .toLowerCase() .replace(/[^a-z0-9äöüß]+/gi, " ") .replace(/\s+/g, " ") .trim() 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 pickPartnerReference = (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, name: statement.debName } : { iban: statement.credIban, name: statement.credName } const fallback = prefersDebit ? { iban: statement.credIban, name: statement.credName } : { iban: statement.debIban, name: statement.debName } return { iban: normalizeIban(primary.iban) || normalizeIban(fallback.iban) || null, name: String(primary.name || fallback.name || "").trim() || 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" }) } }) server.get("/banking/statements/:id/suggestions", async (req, reply) => { try { if (!req.user) return reply.code(401).send({ error: "Unauthorized" }) const { id } = req.params as { id: string } const statementId = Number(id) if (!statementId) return reply.code(400).send({ error: "Invalid statement id" }) const [statement] = await server.db .select() .from(bankstatements) .where(and(eq(bankstatements.id, statementId), eq(bankstatements.tenant, req.user.tenant_id))) .limit(1) if (!statement) return reply.code(404).send({ error: "Statement not found" }) const partnerType: "customer" | "vendor" = Number(statement.amount) >= 0 ? "customer" : "vendor" const partnerRef = pickPartnerReference(statement, partnerType) const suggestions: Array> = [] let matchedBankAccountId: number | null = null if (partnerRef?.iban) { const allAccounts = await server.db .select({ id: entitybankaccounts.id, ibanEncrypted: entitybankaccounts.ibanEncrypted, }) .from(entitybankaccounts) .where(eq(entitybankaccounts.tenant, req.user.tenant_id)) const matchingAccount = allAccounts.find((row) => { if (!row.ibanEncrypted) return false try { return normalizeIban(decrypt(row.ibanEncrypted as any)) === partnerRef.iban } catch { return false } }) matchedBankAccountId = matchingAccount?.id ? Number(matchingAccount.id) : null } if (partnerType === "customer") { const customerRows = await server.db .select({ id: customers.id, name: customers.name, customerNumber: customers.customerNumber, infoData: customers.infoData, }) .from(customers) .where(and(eq(customers.tenant, req.user.tenant_id), eq(customers.archived, false))) for (const row of customerRows) { const infoData = row.infoData && typeof row.infoData === "object" ? row.infoData as Record : {} const bankAccountIds = Array.isArray(infoData.bankAccountIds) ? infoData.bankAccountIds.map(Number) : [] const bankingIbans = Array.isArray(infoData.bankingIbans) ? infoData.bankingIbans.map((iban) => normalizeIban(String(iban))) : [] const normalizedEntityName = normalizeName(row.name) const normalizedStatementName = normalizeName(partnerRef?.name) const matchesBankAccountId = matchedBankAccountId ? bankAccountIds.includes(matchedBankAccountId) : false const matchesIban = partnerRef?.iban ? bankingIbans.includes(partnerRef.iban) : false const exactNameMatch = normalizedEntityName && normalizedStatementName && normalizedEntityName === normalizedStatementName const partialNameMatch = normalizedEntityName && normalizedStatementName ? normalizedEntityName.includes(normalizedStatementName) || normalizedStatementName.includes(normalizedEntityName) : false let score = 0 let reason = "" if (matchesBankAccountId && matchesIban) { score = 100 reason = "IBAN und hinterlegte Bankverbindung stimmen überein" } else if (matchesBankAccountId) { score = 95 reason = "Hinterlegte Bankverbindung passt zur IBAN" } else if (matchesIban) { score = 90 reason = "IBAN wurde bereits bei diesem Kunden verwendet" } else if (exactNameMatch) { score = 60 reason = "Name passt exakt zur Buchung" } else if (partialNameMatch) { score = 45 reason = "Name ähnelt der Buchung" } if (!score) continue suggestions.push({ type: "customer", id: row.id, name: row.name, number: row.customerNumber, score, reason, }) } } else { const vendorRows = await server.db .select({ id: vendors.id, name: vendors.name, vendorNumber: vendors.vendorNumber, infoData: vendors.infoData, }) .from(vendors) .where(and(eq(vendors.tenant, req.user.tenant_id), eq(vendors.archived, false))) for (const row of vendorRows) { const infoData = row.infoData && typeof row.infoData === "object" ? row.infoData as Record : {} const bankAccountIds = Array.isArray(infoData.bankAccountIds) ? infoData.bankAccountIds.map(Number) : [] const bankingIbans = Array.isArray(infoData.bankingIbans) ? infoData.bankingIbans.map((iban) => normalizeIban(String(iban))) : [] const normalizedEntityName = normalizeName(row.name) const normalizedStatementName = normalizeName(partnerRef?.name) const matchesBankAccountId = matchedBankAccountId ? bankAccountIds.includes(matchedBankAccountId) : false const matchesIban = partnerRef?.iban ? bankingIbans.includes(partnerRef.iban) : false const exactNameMatch = normalizedEntityName && normalizedStatementName && normalizedEntityName === normalizedStatementName const partialNameMatch = normalizedEntityName && normalizedStatementName ? normalizedEntityName.includes(normalizedStatementName) || normalizedStatementName.includes(normalizedEntityName) : false let score = 0 let reason = "" if (matchesBankAccountId && matchesIban) { score = 100 reason = "IBAN und hinterlegte Bankverbindung stimmen überein" } else if (matchesBankAccountId) { score = 95 reason = "Hinterlegte Bankverbindung passt zur IBAN" } else if (matchesIban) { score = 90 reason = "IBAN wurde bereits bei diesem Lieferanten verwendet" } else if (exactNameMatch) { score = 60 reason = "Name passt exakt zur Buchung" } else if (partialNameMatch) { score = 45 reason = "Name ähnelt der Buchung" } if (!score) continue suggestions.push({ type: "vendor", id: row.id, name: row.name, number: row.vendorNumber, score, reason, }) } } suggestions.sort((a, b) => b.score - a.score || String(a.name).localeCompare(String(b.name), "de")) return reply.send({ partnerType, partnerName: partnerRef?.name || null, partnerIban: partnerRef?.iban || null, suggestions: suggestions.slice(0, 5), }) } catch (err) { server.log.error(err) return reply.code(500).send({ error: "Failed to load statement suggestions" }) } }) 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") } }) // ------------------------------------------------------------------ // 📒 List Manual Statement Allocations // ------------------------------------------------------------------ server.get("/banking/manual-bookings", async (req, reply) => { try { if (!req.user) return reply.code(401).send({ error: "Unauthorized" }) const rows = await server.db.select({ allocation: statementallocations, account: accounts, customer: customers, vendor: vendors, ownaccount: ownaccounts, contraAccount: ContraAccounts, contraCustomer: ContraCustomers, contraVendor: ContraVendors, contraOwnaccount: ContraOwnaccounts, incominginvoice: ManualInvoices, incominginvoiceVendor: ManualInvoiceVendors, }) .from(statementallocations) .leftJoin(accounts, eq(statementallocations.account, accounts.id)) .leftJoin(customers, eq(statementallocations.customer, customers.id)) .leftJoin(vendors, eq(statementallocations.vendor, vendors.id)) .leftJoin(ownaccounts, eq(statementallocations.ownaccount, ownaccounts.id)) .leftJoin(ContraAccounts, eq(statementallocations.contraAccount, ContraAccounts.id)) .leftJoin(ContraCustomers, eq(statementallocations.contraCustomer, ContraCustomers.id)) .leftJoin(ContraVendors, eq(statementallocations.contraVendor, ContraVendors.id)) .leftJoin(ContraOwnaccounts, eq(statementallocations.contraOwnaccount, ContraOwnaccounts.id)) .leftJoin(ManualInvoices, eq(statementallocations.incominginvoice, ManualInvoices.id)) .leftJoin(ManualInvoiceVendors, eq(ManualInvoices.vendor, ManualInvoiceVendors.id)) .where(and( eq(statementallocations.tenant, req.user.tenant_id), eq(statementallocations.archived, false), isNull(statementallocations.bankstatement) )) return reply.send(rows.map((row) => ({ ...row.allocation, account: row.account, customer: row.customer, vendor: row.vendor, ownaccount: row.ownaccount, contraAccount: row.contraAccount, contraCustomer: row.contraCustomer, contraVendor: row.contraVendor, contraOwnaccount: row.contraOwnaccount, incominginvoice: row.incominginvoice ? { ...row.incominginvoice, vendor: row.incominginvoiceVendor, } : null, }))) } catch (err) { console.error(err) return reply.code(500).send({ error: "Failed to load manual bookings" }) } }) // ------------------------------------------------------------------ // 💰 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 prepared = prepareStatementAllocationPayload(payload) if (prepared.error) return reply.code(400).send({ error: prepared.error }) const inserted = await server.db.insert(statementallocations).values({ ...prepared.data, 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") } } if (createdRecord.bankstatement) { 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)) if (old.bankstatement) { 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" }) } }) }