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 { createHash } from "node:crypto"; import { bankaccounts, bankstatements, createddocuments, incominginvoices, statementallocations, tenants, } from "../../db/schema"; import { secrets } from "./secrets"; type ForecastEventSource = "open_createddocument" | "open_incominginvoice" | "recurring_bankstatement" | "draft_createddocument" | "tax_settlement"; type TaxEvaluationPeriod = "monthly" | "quarterly" | "yearly"; type ForecastEvent = { date: string; amount: number; label: string; source: ForecastEventSource; sourceId?: number | string | null; recurringKey?: string; confidence?: number; }; type TaxBreakdown = { net19: number; tax19: number; net7: number; tax7: number; net0: number; }; type TaxForecastPeriod = { key: string; label: string; range: string; dueDate: string; outputTax: number; inputTax: number; balance: number; outputCount: number; inputCount: number; }; type RecurringCandidate = { key?: string; 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 createZeroTaxBreakdown = (): TaxBreakdown => ({ net19: 0, tax19: 0, net7: 0, tax7: 0, net0: 0, }); const normalizeText = (value: unknown) => String(value || "") .toLowerCase() .replace(/[^a-z0-9äöüß]+/gi, " ") .replace(/\s+/g, " ") .trim(); const normalizeTaxEvaluationPeriod = (value?: string | null): TaxEvaluationPeriod => { if (value === "quarterly" || value === "yearly") return value; return "monthly"; }; const isTaxFreeDocument = (taxType?: string | null) => { return ["13b UStG", "19 UStG", "12.3 UStG"].includes(String(taxType || "")); }; const getTaxEvaluationPeriodBounds = ( referenceDate: dayjs.ConfigType, period: TaxEvaluationPeriod ) => { const base = dayjs(referenceDate); if (period === "yearly") { return { start: base.startOf("year"), end: base.endOf("year"), }; } if (period === "quarterly") { const quarterStartMonth = Math.floor(base.month() / 3) * 3; const start = base.month(quarterStartMonth).startOf("month"); return { start, end: start.add(2, "month").endOf("month"), }; } return { start: base.startOf("month"), end: base.endOf("month"), }; }; const shiftTaxEvaluationPeriodStart = ( periodStart: dayjs.ConfigType, period: TaxEvaluationPeriod, offset: number ) => { const base = dayjs(periodStart); if (period === "yearly") return base.add(offset, "year").startOf("year"); if (period === "quarterly") return base.add(offset * 3, "month").startOf("month"); return base.add(offset, "month").startOf("month"); }; const formatTaxEvaluationPeriodLabel = ( periodStart: dayjs.ConfigType, period: TaxEvaluationPeriod ) => { const { start } = getTaxEvaluationPeriodBounds(periodStart, period); if (period === "yearly") { return start.format("YYYY"); } if (period === "quarterly") { return `Q${Math.floor(start.month() / 3) + 1} ${start.format("YYYY")}`; } return start.format("MMMM YYYY"); }; const formatTaxEvaluationPeriodRange = ( periodStart: dayjs.ConfigType, period: TaxEvaluationPeriod ) => { const { start, end } = getTaxEvaluationPeriodBounds(periodStart, period); return `${start.format("DD.MM.YYYY")} - ${end.format("DD.MM.YYYY")}`; }; const getTaxSettlementDate = (periodEnd: dayjs.Dayjs) => { return periodEnd.add(1, "month").date(10).startOf("day"); }; const getRecurringKey = (candidate: RecurringCandidate) => { const rawKey = [ Math.sign(candidate.amount), Math.round(Math.abs(candidate.amount) * 100), normalizeText(candidate.label), candidate.interval, ].join("|"); return createHash("sha1").update(rawKey).digest("hex").slice(0, 16); }; 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 getCreatedDocumentTaxBreakdown = (document: any): TaxBreakdown => { const breakdown = createZeroTaxBreakdown(); if (!document || isTaxFreeDocument(document.taxType)) { return breakdown; } (document.rows || []).forEach((row: any) => { if (!row || ["pagebreak", "title", "text"].includes(row.mode)) return; const quantity = Number(row.quantity || 0); const price = Number(row.price || 0); const discountPercent = Number(row.discountPercent || 0); const taxPercent = Number(row.taxPercent || 0); const net = Number((quantity * price * (1 - discountPercent / 100)).toFixed(2)); if (!Number.isFinite(net) || net === 0) return; if (taxPercent === 19) { breakdown.net19 += net; breakdown.tax19 += Number((net * 0.19).toFixed(2)); } else if (taxPercent === 7) { breakdown.net7 += net; breakdown.tax7 += Number((net * 0.07).toFixed(2)); } else { breakdown.net0 += net; } }); return { net19: roundMoney(breakdown.net19), tax19: roundMoney(breakdown.tax19), net7: roundMoney(breakdown.net7), tax7: roundMoney(breakdown.tax7), net0: roundMoney(breakdown.net0), }; }; const getIncomingInvoiceTaxBreakdown = (invoice: any): TaxBreakdown => { const breakdown = createZeroTaxBreakdown(); (invoice?.accounts || []).forEach((account: any) => { const taxType = String(account?.taxType || ""); const amountNet = Number(account?.amountNet || 0); const amountTax = Number(account?.amountTax || 0); if (taxType === "19") { breakdown.net19 += amountNet; breakdown.tax19 += amountTax; } else if (taxType === "7") { breakdown.net7 += amountNet; breakdown.tax7 += amountTax; } else { breakdown.net0 += amountNet; } }); return { net19: roundMoney(breakdown.net19), tax19: roundMoney(breakdown.tax19), net7: roundMoney(breakdown.net7), tax7: roundMoney(breakdown.tax7), net0: roundMoney(breakdown.net0), }; }; 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 getRemainingSignedAmount = (signedAmount: number, allocatedAmount: number) => { const remainingAbsolute = Math.max(0, Math.abs(Number(signedAmount || 0)) - Math.abs(Number(allocatedAmount || 0))); if (remainingAbsolute <= 0.01) return 0; return roundMoney(Math.sign(Number(signedAmount || 0)) * remainingAbsolute); }; 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() || Number(statement.amount || 0) >= 0) 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()) .filter((candidate) => Number(candidate.amount || 0) < 0) .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()] .map((candidate) => ({ ...candidate, key: getRecurringKey(candidate), })) .filter((candidate) => Number(candidate.amount || 0) < 0) .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", recurringKey: candidate.key, confidence: candidate.confidence, }); date = addInterval(date, candidate.interval); guard += 1; } }); return events; }; const buildTaxForecastPeriods = ( documents: any[], incomingInvoices: any[], periodType: TaxEvaluationPeriod, today: dayjs.Dayjs, endDate: dayjs.Dayjs ) => { const currentBounds = getTaxEvaluationPeriodBounds(today, periodType); const periods: TaxForecastPeriod[] = []; let offset = 0; while (offset < 12) { const periodStart = shiftTaxEvaluationPeriodStart(currentBounds.start, periodType, offset); const bounds = getTaxEvaluationPeriodBounds(periodStart, periodType); const dueDate = getTaxSettlementDate(bounds.end); if (dueDate.isAfter(endDate, "day")) break; const outputDocs = documents.filter((document) => { if (document?.state !== "Gebucht") return false; if (!["invoices", "advanceInvoices", "cancellationInvoices"].includes(document?.type)) return false; const date = dayjs(document.documentDate); return date.isValid() && !date.isBefore(bounds.start, "day") && !date.isAfter(bounds.end, "day"); }); const inputDocs = incomingInvoices.filter((invoice) => { if (invoice?.state !== "Gebucht" || !invoice?.date) return false; const date = dayjs(invoice.date); return date.isValid() && !date.isBefore(bounds.start, "day") && !date.isAfter(bounds.end, "day"); }); const outputTax = roundMoney(outputDocs.reduce((sum, document) => { const breakdown = getCreatedDocumentTaxBreakdown(document); return sum + breakdown.tax19 + breakdown.tax7; }, 0)); const inputTax = roundMoney(inputDocs.reduce((sum, invoice) => { const breakdown = getIncomingInvoiceTaxBreakdown(invoice); return sum + breakdown.tax19 + breakdown.tax7; }, 0)); const balance = roundMoney(outputTax - inputTax); periods.push({ key: bounds.start.format("YYYY-MM-DD"), label: formatTaxEvaluationPeriodLabel(bounds.start, periodType), range: formatTaxEvaluationPeriodRange(bounds.start, periodType), dueDate: dueDate.format("YYYY-MM-DD"), outputTax, inputTax, balance, outputCount: outputDocs.length, inputCount: inputDocs.length, }); offset += 1; } return periods; }; export const generateLiquidityForecast = async ( server: FastifyInstance, tenantId: number, ignoredRecurringKeys: string[] = [] ) => { 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, tenantSettings] = 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))), server.db .select({ taxEvaluationPeriod: tenants.taxEvaluationPeriod, }) .from(tenants) .where(eq(tenants.id, tenantId)) .limit(1), ]); const activeAccounts = accounts.filter((account) => !account.archived); const activeStatements = statements.filter((statement) => !statement.archived); const activeDocuments = documents.filter((document) => !document.archived); const activeIncomingInvoices = incomingInvoices.filter((invoice) => !invoice.archived); const activeDocumentIds = new Set(activeDocuments.map((document) => document.id)); const activeIncomingInvoiceIds = new Set(activeIncomingInvoices.map((invoice) => invoice.id)); const taxPeriodType = normalizeTaxEvaluationPeriod(tenantSettings[0]?.taxEvaluationPeriod); const activeAllocations = allocations.filter((allocation) => { if (allocation.archived) return false; if (allocation.createddocument && !activeDocumentIds.has(allocation.createddocument)) return false; if (allocation.incominginvoice && !activeIncomingInvoiceIds.has(allocation.incominginvoice)) return false; return true; }); const startingBalance = roundMoney( activeAccounts .filter((account) => !account.expired) .reduce((sum, account) => sum + Number(account.balance || 0), 0) ); const allocationByDocument = new Map(); const allocationByIncomingInvoice = new Map(); activeAllocations.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(activeDocuments); const openEvents: ForecastEvent[] = []; const draftEvents: ForecastEvent[] = []; activeDocuments .filter((document) => ["invoices", "advanceInvoices"].includes(document.type)) .filter((document) => document.state === "Gebucht" && !cancelledDocumentIds.has(document.id)) .forEach((document) => { const total = getCreatedDocumentGrossAmount(document, activeDocuments); 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, }); }); activeDocuments .filter((document) => ["invoices", "advanceInvoices"].includes(document.type)) .filter((document) => document.state === "Entwurf" && !cancelledDocumentIds.has(document.id)) .forEach((document) => { const total = getCreatedDocumentGrossAmount(document, activeDocuments); if (total <= 0.01) return; const dueDate = dayjs(document.documentDate).isValid() ? dayjs(document.documentDate).add(Number(document.paymentDays || 0), "day") : today; draftEvents.push({ date: dueDate.isBefore(today, "day") ? today.format("YYYY-MM-DD") : dueDate.format("YYYY-MM-DD"), amount: total, label: document.documentNumber || document.title || `Rechnungsentwurf ${document.id}`, source: "draft_createddocument", sourceId: document.id, confidence: 0.35, }); }); activeIncomingInvoices .filter((invoice) => invoice.state === "Gebucht" || invoice.state === "Vorbereitet") .filter((invoice) => !invoice.paid) .forEach((invoice) => { const signedAmount = getIncomingInvoiceSignedAmount(invoice); const openAmount = getRemainingSignedAmount(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(activeStatements); const aiRecurring = await detectRecurringWithAi(server, activeStatements); const ignoredRecurringKeySet = new Set(ignoredRecurringKeys.filter(Boolean)); const recurring = mergeRecurringCandidates(heuristicRecurring, aiRecurring) .filter((candidate) => !candidate.key || !ignoredRecurringKeySet.has(candidate.key)); const taxPeriods = buildTaxForecastPeriods(activeDocuments, activeIncomingInvoices, taxPeriodType, today, endDate); const taxEvents: ForecastEvent[] = taxPeriods .filter((period) => Math.abs(period.balance) > 0.01) .map((period) => ({ date: period.dueDate, amount: roundMoney(period.balance * -1), label: `USt ${period.label}`, source: "tax_settlement" as const, sourceId: period.key, confidence: 0.95, })); const events = [ ...openEvents, ...taxEvents, ...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)); const draftIncome = roundMoney(draftEvents.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: activeAccounts.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)), draftEvents: draftEvents.sort((a, b) => a.date.localeCompare(b.date)), draftIncome, tax: { periodType: taxPeriodType, periods: taxPeriods, totalBalance: roundMoney(taxPeriods.reduce((sum, period) => sum + period.balance, 0)), }, points, ai: { enabled: Boolean(secrets.OPENAI_API_KEY), candidates: aiRecurring.length, }, }; };