diff --git a/src/index.ts b/src/index.ts index 61b2ee1..a154b01 100644 --- a/src/index.ts +++ b/src/index.ts @@ -48,6 +48,10 @@ import {loadSecrets, secrets} from "./utils/secrets"; import {initMailer} from "./utils/mailer" import {initS3} from "./utils/s3"; +//Services +import servicesPlugin from "./plugins/services"; +import {prepareIncomingInvoices} from "./modules/cron/prepareIncomingInvoices"; + async function main() { const app = Fastify({ logger: false }); await loadSecrets(); @@ -68,6 +72,7 @@ async function main() { await app.register(tenantPlugin); await app.register(dayjsPlugin); await app.register(dbPlugin); + await app.register(servicesPlugin); app.addHook('preHandler', (req, reply, done) => { console.log(req.method) diff --git a/src/modules/cron/bankstatementsync.service.ts b/src/modules/cron/bankstatementsync.service.ts new file mode 100644 index 0000000..23714fb --- /dev/null +++ b/src/modules/cron/bankstatementsync.service.ts @@ -0,0 +1,246 @@ +// /services/bankStatementService.ts +import axios from "axios" +import dayjs from "dayjs" +import utc from "dayjs/plugin/utc.js" +import {secrets} from "../../utils/secrets" +import {FastifyInstance} from "fastify" + +// Drizzle imports +import { + bankaccounts, + bankstatements, +} from "../../../db/schema" + +import { + eq, + and, + isNull, +} from "drizzle-orm" + +dayjs.extend(utc) + +interface BalanceAmount { + amount: string + currency: string +} + +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[] + } +} + +const normalizeDate = (val: any) => { + if (!val) return null + const d = new Date(val) + return isNaN(d.getTime()) ? null : d +} + +export function bankStatementService(server: FastifyInstance) { + + let accessToken: string | null = null + + // ----------------------------------------------- + // ✔ TOKEN LADEN + // ----------------------------------------------- + const getToken = async () => { + 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): Promise => { + try { + 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) + + const expired = + err.response?.data?.summary?.includes("expired") || + err.response?.data?.detail?.includes("expired") + + if (expired) { + await server.db + .update(bankaccounts) + .set({expired: true}) + .where(eq(bankaccounts.accountId, accountId)) + } + + return false + } + } + + // ----------------------------------------------- + // ✔ Transaktionen laden + // ----------------------------------------------- + const getTransactionData = async (accountId: string) => { + try { + 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) + return null + } + } + + // ----------------------------------------------- + // ✔ Haupt-Sync-Prozess + // ----------------------------------------------- + const syncAccounts = async (tenantId:number) => { + try { + 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))) + + if (!accounts.length) return + + const allNewTransactions: any[] = [] + + for (const account of accounts) { + + // --------------------------- + // 1. BALANCE SYNC + // --------------------------- + const balData = await getBalanceData(account.accountId) + + if (balData === false) break + + if (balData) { + const closing = balData.balances.find( + (i: any) => i.balanceType === "closingBooked" + ) + + const bookedBal = Number(closing.balanceAmount.amount) + + await server.db + .update(bankaccounts) + .set({balance: bookedBal}) + .where(eq(bankaccounts.id, account.id)) + } + + // --------------------------- + // 2. TRANSACTIONS + // --------------------------- + let transactions = await getTransactionData(account.accountId) + if (!transactions) continue + + //@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) + } + + // --------------------------- + // 3. NEW TRANSACTIONS → DB + // --------------------------- + if (allNewTransactions.length > 0) { + await server.db.insert(bankstatements).values(allNewTransactions) + + const affectedAccounts = [ + ...new Set(allNewTransactions.map((t) => t.account)), + ] + + for (const accId of affectedAccounts) { + await server.db + .update(bankaccounts) + .set({syncedAt: dayjs().utc().toISOString()}) + .where(eq(bankaccounts.id, accId)) + } + } + + console.log("Bank statement sync completed.") + } catch (error) { + console.error(error) + } + + } + + return { + run: async (tenant) => { + await getToken() + await syncAccounts(tenant) + console.log("Service: Bankstatement sync finished") + } + } +} diff --git a/src/plugins/services.ts b/src/plugins/services.ts new file mode 100644 index 0000000..d5f32ed --- /dev/null +++ b/src/plugins/services.ts @@ -0,0 +1,24 @@ +// /plugins/services.ts +import fp from "fastify-plugin"; +import { bankStatementService } from "../modules/cron/bankstatementsync.service"; +//import {initDokuboxClient, syncDokubox} from "../modules/cron/dokuboximport.service"; +import { FastifyInstance } from "fastify"; +import {prepareIncomingInvoices} from "../modules/cron/prepareIncomingInvoices"; + +declare module "fastify" { + interface FastifyInstance { + services: { + bankStatements: ReturnType; + //dokuboxSync: ReturnType; + //prepareIncomingInvoices: ReturnType; + }; + } +} + +export default fp(async function servicePlugin(server: FastifyInstance) { + server.decorate("services", { + bankStatements: bankStatementService(server), + //dokuboxSync: syncDokubox(server), + //prepareIncomingInvoices: prepareIncomingInvoices(server), + }); +}); diff --git a/src/routes/functions.ts b/src/routes/functions.ts index c372d32..0258c8c 100644 --- a/src/routes/functions.ts +++ b/src/routes/functions.ts @@ -169,6 +169,10 @@ export default async function functionRoutes(server: FastifyInstance) { await finishManualGeneration(server,execution_id) }) + server.post('/functions/services/bankstatementsync', async (req, reply) => { + await server.services.bankStatements.run(req.user.tenant_id); + }) + /*server.post('/print/zpl/preview', async (req, reply) => { const { zpl, widthMm = 50, heightMm = 30, dpmm = 8, asBase64 = false } = req.body as {zpl:string,widthMm:number,heightMm:number,dpmm:number,asBase64:string}