diff --git a/backend/src/routes/functions.ts b/backend/src/routes/functions.ts index 35f726b..f6f1aff 100644 --- a/backend/src/routes/functions.ts +++ b/backend/src/routes/functions.ts @@ -24,6 +24,7 @@ import {executeManualGeneration, finishManualGeneration} from "../modules/serial import { s3 } from "../utils/s3"; import { secrets } from "../utils/secrets"; import { storeExtractedTextForFile } from "../utils/documentText"; +import { generateLiquidityForecast } from "../utils/liquidityForecast"; dayjs.extend(customParseFormat) dayjs.extend(isoWeek) dayjs.extend(isBetween) @@ -306,6 +307,15 @@ export default async function functionRoutes(server: FastifyInstance) { await server.services.prepareIncomingInvoices.run(req.user.tenant_id) }) + server.get('/functions/liquidity-forecast', async (req, reply) => { + try { + return await generateLiquidityForecast(server, req.user.tenant_id) + } catch (err) { + req.log.error(err) + return reply.code(500).send({ error: "Liquiditätsprognose konnte nicht erstellt werden." }) + } + }) + server.post('/functions/services/backfillfiletext', async (req, reply) => { const tenantId = req.user.tenant_id diff --git a/backend/src/utils/liquidityForecast.ts b/backend/src/utils/liquidityForecast.ts new file mode 100644 index 0000000..a010db6 --- /dev/null +++ b/backend/src/utils/liquidityForecast.ts @@ -0,0 +1,441 @@ +import dayjs from "dayjs"; +import OpenAI from "openai"; +import { z } from "zod"; +import { zodResponseFormat } from "openai/helpers/zod"; +import { and, desc, eq, gte } from "drizzle-orm"; +import { FastifyInstance } from "fastify"; + +import { + bankaccounts, + bankstatements, + createddocuments, + incominginvoices, + statementallocations, +} from "../../db/schema"; +import { secrets } from "./secrets"; + +type ForecastEventSource = "open_createddocument" | "open_incominginvoice" | "recurring_bankstatement"; + +type ForecastEvent = { + date: string; + amount: number; + label: string; + source: ForecastEventSource; + sourceId?: number | string | null; + confidence?: number; +}; + +type RecurringCandidate = { + label: string; + amount: number; + interval: "weekly" | "monthly" | "quarterly" | "yearly"; + nextDate: string; + confidence: number; + evidence: string; +}; + +const AiRecurringFormat = z.object({ + candidates: z.array(z.object({ + label: z.string(), + amount: z.number(), + interval: z.enum(["weekly", "monthly", "quarterly", "yearly"]), + nextDate: z.string(), + confidence: z.number().min(0).max(1), + evidence: z.string(), + })), +}); + +const FORECAST_DAYS = 90; +const HISTORY_MONTHS = 12; + +const roundMoney = (value: number) => Number(Number(value || 0).toFixed(2)); + +const normalizeText = (value: unknown) => String(value || "") + .toLowerCase() + .replace(/[^a-z0-9äöüß]+/gi, " ") + .replace(/\s+/g, " ") + .trim(); + +const addInterval = (date: dayjs.Dayjs, interval: RecurringCandidate["interval"]) => { + if (interval === "weekly") return date.add(1, "week"); + if (interval === "quarterly") return date.add(3, "month"); + if (interval === "yearly") return date.add(1, "year"); + return date.add(1, "month"); +}; + +const getStatementPartner = (statement: any) => { + return statement.amount < 0 + ? statement.credName || statement.debName || statement.text || "Regelmäßige Ausgabe" + : statement.debName || statement.credName || statement.text || "Regelmäßige Einnahme"; +}; + +const getCreatedDocumentGrossAmount = (document: any, allDocuments: any[] = []) => { + let totalNet = 0; + let totalTax = 0; + + (document.rows || []).forEach((row: any) => { + if (["pagebreak", "title", "text"].includes(row.mode)) return; + + const rowNet = Number( + (Number(row.quantity || 0) * Number(row.price || 0) * (1 - Number(row.discountPercent || 0) / 100)).toFixed(3) + ); + const taxPercent = Number(row.taxPercent); + + totalNet += rowNet; + totalTax += rowNet * (Number.isFinite(taxPercent) ? taxPercent : 0) / 100; + }); + + let advancePayments = 0; + (document.usedAdvanceInvoices || []).forEach((advanceInvoiceId: number) => { + const advanceInvoice = allDocuments.find((item) => item.id === advanceInvoiceId); + const advanceRow = advanceInvoice?.rows?.find((row: any) => row.advanceInvoiceData); + if (!advanceRow) return; + + advancePayments += Number(advanceRow.price || 0) * ((100 + Number(advanceRow.taxPercent || 0)) / 100); + }); + + return roundMoney(Number(totalNet.toFixed(2)) + Number(totalTax.toFixed(2)) - advancePayments); +}; + +const getIncomingInvoiceSignedAmount = (invoice: any) => { + const amount = (invoice.accounts || []).reduce((sum: number, account: any) => { + return sum + Number(account.amountNet || 0) + Number(account.amountTax || 0); + }, 0); + + return roundMoney(invoice.expense === false ? amount : amount * -1); +}; + +const findCancellationDocumentIds = (documents: any[]) => { + return new Set( + documents + .filter((document) => document.type === "cancellationInvoices" && document.state !== "Entwurf" && !document.archived) + .map((document) => typeof document.createddocument === "object" ? document.createddocument?.id : document.createddocument) + .filter(Boolean) + ); +}; + +const inferInterval = (dates: dayjs.Dayjs[]): RecurringCandidate["interval"] | null => { + if (dates.length < 3) return null; + + const gaps = dates + .slice(1) + .map((date, index) => date.diff(dates[index], "day")) + .filter((gap) => gap > 0); + + const averageGap = gaps.reduce((sum, gap) => sum + gap, 0) / Math.max(gaps.length, 1); + + if (averageGap >= 6 && averageGap <= 8) return "weekly"; + if (averageGap >= 25 && averageGap <= 35) return "monthly"; + if (averageGap >= 80 && averageGap <= 100) return "quarterly"; + if (averageGap >= 340 && averageGap <= 390) return "yearly"; + + return null; +}; + +const detectRecurringHeuristically = (statements: any[]): RecurringCandidate[] => { + const groups = new Map(); + + statements.forEach((statement) => { + const parsedDate = dayjs(statement.valueDate || statement.date); + if (!parsedDate.isValid()) return; + + const partner = normalizeText(getStatementPartner(statement)); + const purpose = normalizeText(statement.text).split(" ").slice(0, 5).join(" "); + const amountBucket = Math.round(Number(statement.amount || 0) * 100); + const key = [Math.sign(amountBucket), Math.abs(amountBucket), partner || purpose].join("|"); + + if (!groups.has(key)) groups.set(key, []); + groups.get(key)!.push(statement); + }); + + return [...groups.values()] + .map((group) => { + const sorted = group + .map((statement) => ({ ...statement, parsedDate: dayjs(statement.valueDate || statement.date) })) + .sort((a, b) => a.parsedDate.valueOf() - b.parsedDate.valueOf()); + + const interval = inferInterval(sorted.map((statement) => statement.parsedDate)); + if (!interval) return null; + + let next = addInterval(sorted[sorted.length - 1].parsedDate, interval); + while (next.isBefore(dayjs(), "day")) { + next = addInterval(next, interval); + } + + const amount = roundMoney(sorted.reduce((sum, statement) => sum + Number(statement.amount || 0), 0) / sorted.length); + const partner = getStatementPartner(sorted[sorted.length - 1]); + + return { + label: partner, + amount, + interval, + nextDate: next.format("YYYY-MM-DD"), + confidence: Math.min(0.9, 0.55 + sorted.length * 0.08), + evidence: `${sorted.length} ähnliche Bankbewegungen erkannt`, + }; + }) + .filter(Boolean) as RecurringCandidate[]; +}; + +const detectRecurringWithAi = async (server: FastifyInstance, statements: any[]): Promise => { + if (!secrets.OPENAI_API_KEY || statements.length < 6) return []; + + const openai = new OpenAI({ apiKey: secrets.OPENAI_API_KEY }); + const compactStatements = statements.slice(0, 220).map((statement) => ({ + date: statement.valueDate || statement.date, + amount: roundMoney(Number(statement.amount || 0)), + partner: getStatementPartner(statement), + text: String(statement.text || "").slice(0, 160), + })); + + try { + const completion = await openai.chat.completions.parse({ + model: "gpt-4o", + store: true, + response_format: zodResponseFormat(AiRecurringFormat as any, "liquidity_recurring_transactions"), + messages: [ + { + role: "system", + content: "Du erkennst wiederkehrende Bankbewegungen für eine Liquiditätsprognose. Gib nur Muster zurück, die durch die gelieferten Umsätze plausibel belegt sind.", + }, + { + role: "user", + content: JSON.stringify({ + today: dayjs().format("YYYY-MM-DD"), + horizonDays: FORECAST_DAYS, + bankStatements: compactStatements, + rules: [ + "amount ist aus Sicht des Bankkontos: negative Werte sind Auszahlungen, positive Werte Einzahlungen.", + "nextDate muss in der Zukunft liegen.", + "Nutze keine einmaligen oder unsicheren Bewegungen.", + ], + }), + }, + ], + }); + + return (completion.choices[0].message.parsed?.candidates || []) + .filter((candidate) => dayjs(candidate.nextDate).isValid()) + .map((candidate) => ({ + ...candidate, + amount: roundMoney(candidate.amount), + confidence: Math.max(0, Math.min(1, candidate.confidence)), + })); + } catch (error) { + server.log.warn("KI-Erkennung für regelmäßige Bankbewegungen konnte nicht ausgeführt werden."); + server.log.warn(error); + return []; + } +}; + +const mergeRecurringCandidates = (heuristic: RecurringCandidate[], ai: RecurringCandidate[]) => { + const merged = new Map(); + + [...heuristic, ...ai].forEach((candidate) => { + const key = [ + Math.sign(candidate.amount), + Math.round(Math.abs(candidate.amount) * 100), + normalizeText(candidate.label).slice(0, 32), + candidate.interval, + ].join("|"); + const existing = merged.get(key); + + if (!existing || candidate.confidence > existing.confidence) { + merged.set(key, candidate); + } + }); + + return [...merged.values()] + .filter((candidate) => Math.abs(candidate.amount) >= 1) + .sort((a, b) => a.nextDate.localeCompare(b.nextDate)); +}; + +const expandRecurringEvents = (recurring: RecurringCandidate[], endDate: dayjs.Dayjs): ForecastEvent[] => { + const events: ForecastEvent[] = []; + + recurring.forEach((candidate) => { + let date = dayjs(candidate.nextDate); + let guard = 0; + + while (date.isValid() && !date.isAfter(endDate, "day") && guard < 40) { + events.push({ + date: date.format("YYYY-MM-DD"), + amount: roundMoney(candidate.amount), + label: candidate.label, + source: "recurring_bankstatement", + confidence: candidate.confidence, + }); + date = addInterval(date, candidate.interval); + guard += 1; + } + }); + + return events; +}; + +export const generateLiquidityForecast = async (server: FastifyInstance, tenantId: number) => { + const today = dayjs().startOf("day"); + const endDate = today.add(FORECAST_DAYS, "day"); + const historyStart = today.subtract(HISTORY_MONTHS, "month").format("YYYY-MM-DD"); + + const [accounts, statements, documents, incomingInvoices, allocations] = await Promise.all([ + server.db + .select() + .from(bankaccounts) + .where(and(eq(bankaccounts.tenant, tenantId), eq(bankaccounts.archived, false))), + server.db + .select() + .from(bankstatements) + .where(and(eq(bankstatements.tenant, tenantId), eq(bankstatements.archived, false), gte(bankstatements.valueDate, historyStart))) + .orderBy(desc(bankstatements.valueDate)), + server.db + .select() + .from(createddocuments) + .where(and(eq(createddocuments.tenant, tenantId), eq(createddocuments.archived, false))), + server.db + .select() + .from(incominginvoices) + .where(and(eq(incominginvoices.tenant, tenantId), eq(incominginvoices.archived, false))), + server.db + .select() + .from(statementallocations) + .where(and(eq(statementallocations.tenant, tenantId), eq(statementallocations.archived, false))), + ]); + + const startingBalance = roundMoney( + accounts + .filter((account) => !account.expired) + .reduce((sum, account) => sum + Number(account.balance || 0), 0) + ); + const allocationByDocument = new Map(); + const allocationByIncomingInvoice = new Map(); + + allocations.forEach((allocation) => { + if (allocation.createddocument) { + allocationByDocument.set( + allocation.createddocument, + roundMoney((allocationByDocument.get(allocation.createddocument) || 0) + Number(allocation.amount || 0)) + ); + } + + if (allocation.incominginvoice) { + allocationByIncomingInvoice.set( + allocation.incominginvoice, + roundMoney((allocationByIncomingInvoice.get(allocation.incominginvoice) || 0) + Number(allocation.amount || 0)) + ); + } + }); + + const cancelledDocumentIds = findCancellationDocumentIds(documents); + const openEvents: ForecastEvent[] = []; + + documents + .filter((document) => ["invoices", "advanceInvoices"].includes(document.type)) + .filter((document) => document.state === "Gebucht" && !cancelledDocumentIds.has(document.id)) + .forEach((document) => { + const total = getCreatedDocumentGrossAmount(document, documents); + const openAmount = roundMoney(total - (allocationByDocument.get(document.id) || 0)); + if (openAmount <= 0.01) return; + + const dueDate = dayjs(document.documentDate).isValid() + ? dayjs(document.documentDate).add(Number(document.paymentDays || 0), "day") + : today; + + openEvents.push({ + date: dueDate.isBefore(today, "day") ? today.format("YYYY-MM-DD") : dueDate.format("YYYY-MM-DD"), + amount: openAmount, + label: document.documentNumber || document.title || `Ausgangsbeleg ${document.id}`, + source: "open_createddocument", + sourceId: document.id, + confidence: 1, + }); + }); + + incomingInvoices + .filter((invoice) => invoice.state === "Gebucht" || invoice.state === "Vorbereitet") + .forEach((invoice) => { + const signedAmount = getIncomingInvoiceSignedAmount(invoice); + const openAmount = roundMoney(signedAmount + (allocationByIncomingInvoice.get(invoice.id) || 0)); + if (Math.abs(openAmount) <= 0.01) return; + + const dueDate = dayjs(invoice.dueDate || invoice.date).isValid() + ? dayjs(invoice.dueDate || invoice.date) + : today; + + openEvents.push({ + date: dueDate.isBefore(today, "day") ? today.format("YYYY-MM-DD") : dueDate.format("YYYY-MM-DD"), + amount: openAmount, + label: invoice.reference || invoice.description || `Eingangsbeleg ${invoice.id}`, + source: "open_incominginvoice", + sourceId: invoice.id, + confidence: invoice.state === "Gebucht" ? 1 : 0.8, + }); + }); + + const heuristicRecurring = detectRecurringHeuristically(statements); + const aiRecurring = await detectRecurringWithAi(server, statements); + const recurring = mergeRecurringCandidates(heuristicRecurring, aiRecurring); + const events = [ + ...openEvents, + ...expandRecurringEvents(recurring, endDate), + ].filter((event) => { + const date = dayjs(event.date); + return date.isValid() && !date.isAfter(endDate, "day"); + }); + + const dailyEvents = new Map(); + events.forEach((event) => { + if (!dailyEvents.has(event.date)) dailyEvents.set(event.date, []); + dailyEvents.get(event.date)!.push(event); + }); + + let runningBalance = startingBalance; + const points = []; + + for (let offset = 0; offset <= FORECAST_DAYS; offset += 1) { + const date = today.add(offset, "day").format("YYYY-MM-DD"); + const dayEvents = dailyEvents.get(date) || []; + const income = roundMoney(dayEvents.filter((event) => event.amount > 0).reduce((sum, event) => sum + event.amount, 0)); + const expense = roundMoney(dayEvents.filter((event) => event.amount < 0).reduce((sum, event) => sum + event.amount, 0)); + + runningBalance = roundMoney(runningBalance + income + expense); + + points.push({ + date, + balance: runningBalance, + income, + expense, + events: dayEvents.sort((a, b) => Math.abs(b.amount) - Math.abs(a.amount)), + }); + } + + const lowestPoint = points.reduce((lowest, point) => point.balance < lowest.balance ? point : lowest, points[0]); + const totalIncome = roundMoney(events.filter((event) => event.amount > 0).reduce((sum, event) => sum + event.amount, 0)); + const totalExpense = roundMoney(events.filter((event) => event.amount < 0).reduce((sum, event) => sum + event.amount, 0)); + + return { + generatedAt: new Date().toISOString(), + horizonDays: FORECAST_DAYS, + startingBalance, + endingBalance: points[points.length - 1]?.balance || startingBalance, + lowestBalance: lowestPoint?.balance || startingBalance, + lowestBalanceDate: lowestPoint?.date || today.format("YYYY-MM-DD"), + totalIncome, + totalExpense, + accounts: accounts.map((account) => ({ + id: account.id, + name: account.name, + iban: account.iban, + balance: roundMoney(Number(account.balance || 0)), + expired: account.expired, + syncedAt: account.syncedAt, + })), + recurring, + events: events.sort((a, b) => a.date.localeCompare(b.date)), + points, + ai: { + enabled: Boolean(secrets.OPENAI_API_KEY), + candidates: aiRecurring.length, + }, + }; +}; diff --git a/frontend/components/MainNav.vue b/frontend/components/MainNav.vue index 0b617b6..debe12f 100644 --- a/frontend/components/MainNav.vue +++ b/frontend/components/MainNav.vue @@ -141,7 +141,7 @@ const links = computed(() => { to: "/accounting/depreciation", icon: "i-heroicons-calendar-days", } : null, - ((featureEnabled("createDocument") || featureEnabled("incomingInvoices")) || featureEnabled("accounts") || featureEnabled("ownaccounts") || featureEnabled("costcentres")) ? { + ((featureEnabled("createDocument") || featureEnabled("incomingInvoices")) || featureEnabled("accounts") || featureEnabled("ownaccounts") || featureEnabled("costcentres") || featureEnabled("banking")) ? { label: "Auswertungen", icon: "i-heroicons-chart-pie", defaultOpen: false, @@ -156,6 +156,11 @@ const links = computed(() => { to: "/accounting/bwa", icon: "i-heroicons-chart-bar-square", } : null, + featureEnabled("banking") ? { + label: "Liquidität", + to: "/accounting/liquidity", + icon: "i-heroicons-banknotes", + } : null, featureEnabled("costcentres") ? { label: "Kostenstellen", to: "/standardEntity/costcentres", diff --git a/frontend/composables/useFunctions.js b/frontend/composables/useFunctions.js index e3dab39..4673e65 100644 --- a/frontend/composables/useFunctions.js +++ b/frontend/composables/useFunctions.js @@ -97,5 +97,9 @@ export const useFunctions = () => { return await useNuxtApp().$api(`/api/banking/statements/${statementId}/suggestions`) } - return {getWorkingTimesEvaluationData, useNextNumber, useBankingGenerateLink, useZipCheck, useBankingCheckInstitutions, useBankingListRequisitions, useBankingResolveIban, useBankingStatementSuggestions, useCreatePDF} + const useLiquidityForecast = async () => { + return await useNuxtApp().$api("/api/functions/liquidity-forecast") + } + + return {getWorkingTimesEvaluationData, useNextNumber, useBankingGenerateLink, useZipCheck, useBankingCheckInstitutions, useBankingListRequisitions, useBankingResolveIban, useBankingStatementSuggestions, useLiquidityForecast, useCreatePDF} } diff --git a/frontend/pages/accounting/liquidity.vue b/frontend/pages/accounting/liquidity.vue new file mode 100644 index 0000000..f32cc72 --- /dev/null +++ b/frontend/pages/accounting/liquidity.vue @@ -0,0 +1,264 @@ + + + diff --git a/frontend/plugins/chartjs.ts b/frontend/plugins/chartjs.ts index 328257c..d56df3d 100644 --- a/frontend/plugins/chartjs.ts +++ b/frontend/plugins/chartjs.ts @@ -1,4 +1,4 @@ -import { Chart, Title, Tooltip, Legend, LineElement, CategoryScale, LinearScale, PointElement } from 'chart.js' +import { Chart, Title, Tooltip, Legend, LineElement, CategoryScale, LinearScale, PointElement, Filler } from 'chart.js' export default defineNuxtPlugin(() => { - Chart.register(CategoryScale, LinearScale, LineElement, Title, Tooltip, Legend, PointElement) -}) \ No newline at end of file + Chart.register(CategoryScale, LinearScale, LineElement, Title, Tooltip, Legend, PointElement, Filler) +})