// /services/bankStatementService.ts import axios from "axios" import { centralServicesClient } from "../push-server.client" import {secrets} from "../../utils/secrets" import {FastifyInstance} from "fastify" // Drizzle imports import { bankaccounts, bankstatements, } from "../../../db/schema" import { eq, and, } from "drizzle-orm" interface BookedTransaction { bookingDate: string valueDate: string internalTransactionId: string transactionAmount: { amount: string; currency: string } creditorAccount?: { iban?: string } creditorName?: string debtorAccount?: { iban?: string } debtorName?: string remittanceInformationUnstructured?: string remittanceInformationStructured?: string remittanceInformationStructuredArray?: string[] additionalInformation?: string } interface TransactionsResponse { transactions: { booked: BookedTransaction[] } } export interface BankStatementSyncResult { accountsFound: number accountsSynced: number transactionsImported: number errors: Array<{ accountId: string; message: string }> } const normalizeDate = (val: any) => { if (!val) return null const d = new Date(val) return isNaN(d.getTime()) ? null : d } export const getBankAccountOwnerName = (account: any) => { const ownerName = typeof account?.owner_name === "string" ? account.owner_name.trim() : "" return ownerName || null } export const isExpiredBankingError = (error: any) => { const values = [ error?.response?.data?.summary, error?.response?.data?.detail, error?.response?.data?.message, error?.message, ] return values.some((value) => typeof value === "string" && value.toLowerCase().includes("expired")) } export function bankStatementService(server: FastifyInstance) { let accessToken: string | null = null const useCentralBanking = Boolean(secrets.FEDEO_CENTRAL_SERVICES_ENABLED && centralServicesClient.configured()) // ----------------------------------------------- // ✔ TOKEN LADEN // ----------------------------------------------- const getToken = async () => { if (useCentralBanking) return console.log("Fetching GoCardless token…") const response = await axios.post( `${secrets.GOCARDLESS_BASE_URL}/token/new/`, { secret_id: secrets.GOCARDLESS_SECRET_ID, secret_key: secrets.GOCARDLESS_SECRET_KEY, } ) accessToken = response.data.access } // ----------------------------------------------- // ✔ Salden laden // ----------------------------------------------- const getBalanceData = async (accountId: string, tenantId: number): Promise => { try { if (useCentralBanking) return await centralServicesClient.getBankingBalances(accountId) const {data} = await axios.get( `${secrets.GOCARDLESS_BASE_URL}/accounts/${accountId}/balances`, { headers: { Authorization: `Bearer ${accessToken}`, Accept: "application/json", }, } ) return data } catch (err: any) { server.log.error(err.response?.data ?? err) if (isExpiredBankingError(err)) { await server.db .update(bankaccounts) .set({expired: true}) .where(and( eq(bankaccounts.accountId, accountId), eq(bankaccounts.tenant, tenantId), )) } throw err } } // ----------------------------------------------- // ✔ Kontoinhaber laden // ----------------------------------------------- const getAccountData = async (accountId: string): Promise => { if (useCentralBanking) return await centralServicesClient.getBankingAccount(accountId) const {data} = await axios.get( `${secrets.GOCARDLESS_BASE_URL}/accounts/${accountId}`, { headers: { Authorization: `Bearer ${accessToken}`, Accept: "application/json", }, } ) return data } // ----------------------------------------------- // ✔ Transaktionen laden // ----------------------------------------------- const getTransactionData = async (accountId: string) => { try { if (useCentralBanking) { const data = await centralServicesClient.getBankingTransactions(accountId) return data.transactions.booked } const {data} = await axios.get( `${secrets.GOCARDLESS_BASE_URL}/accounts/${accountId}/transactions`, { headers: { Authorization: `Bearer ${accessToken}`, Accept: "application/json", }, } ) return data.transactions.booked } catch (err: any) { server.log.error(err.response?.data ?? err) throw err } } // ----------------------------------------------- // ✔ Haupt-Sync-Prozess // ----------------------------------------------- const syncAccounts = async (tenantId:number): Promise => { console.log("Starting account sync…") // 🟦 DB: Aktive Accounts const accounts = await server.db .select() .from(bankaccounts) .where(and(eq(bankaccounts.expired, false),eq(bankaccounts.tenant, tenantId))) const result: BankStatementSyncResult = { accountsFound: accounts.length, accountsSynced: 0, transactionsImported: 0, errors: [], } if (!accounts.length) return result const allNewTransactions: any[] = [] for (const account of accounts) { try { // --------------------------- // 0. KONTOINHABER SYNC // --------------------------- try { const accountData = await getAccountData(account.accountId) const ownerName = getBankAccountOwnerName(accountData) if (ownerName && ownerName !== account.ownerName) { await server.db .update(bankaccounts) .set({ownerName}) .where(eq(bankaccounts.id, account.id)) } } catch (error: any) { server.log.warn({err: error, accountId: account.accountId}, "Kontoinhaber konnte nicht synchronisiert werden") } // --------------------------- // 1. BALANCE SYNC // --------------------------- const balData = await getBalanceData(account.accountId, tenantId) if (balData) { const closing = balData.balances.find( (i: any) => i.balanceType === "closingBooked" ) if (closing?.balanceAmount?.amount !== undefined) { const bookedBal = Number(closing.balanceAmount.amount) if (Number.isFinite(bookedBal)) { await server.db .update(bankaccounts) .set({balance: bookedBal}) .where(eq(bankaccounts.id, account.id)) } } } // --------------------------- // 2. TRANSACTIONS // --------------------------- let transactions = await getTransactionData(account.accountId) //@ts-ignore transactions = transactions.map((item) => ({ account: account.id, date: normalizeDate(item.bookingDate), credIban: item.creditorAccount?.iban ?? null, credName: item.creditorName ?? null, text: ` ${item.remittanceInformationUnstructured ?? ""} ${item.remittanceInformationStructured ?? ""} ${item.additionalInformation ?? ""} ${item.remittanceInformationStructuredArray?.join("") ?? ""} `.trim(), amount: Number(item.transactionAmount.amount), tenant: account.tenant, debIban: item.debtorAccount?.iban ?? null, debName: item.debtorName ?? null, gocardlessId: item.internalTransactionId, currency: item.transactionAmount.currency, valueDate: normalizeDate(item.valueDate), })) // Existierende Statements laden const existing = await server.db .select({gocardlessId: bankstatements.gocardlessId}) .from(bankstatements) .where(eq(bankstatements.tenant, account.tenant)) const filtered = transactions.filter( //@ts-ignore (tx) => !existing.some((x) => x.gocardlessId === tx.gocardlessId) ) allNewTransactions.push(...filtered) await server.db .update(bankaccounts) .set({syncedAt: new Date()}) .where(eq(bankaccounts.id, account.id)) result.accountsSynced++ } catch (error: any) { const message = error?.message || String(error) result.errors.push({accountId: account.accountId, message}) server.log.error({err: error, accountId: account.accountId}, "Bankkonto konnte nicht synchronisiert werden") } } // --------------------------- // 3. NEW TRANSACTIONS → DB // --------------------------- if (allNewTransactions.length > 0) { await server.db.insert(bankstatements).values(allNewTransactions) result.transactionsImported = allNewTransactions.length } console.log("Bank statement sync completed.") return result } return { run: async (tenant) => { await getToken() const result = await syncAccounts(tenant) console.log("Service: Bankstatement sync finished") return result } } }