diff --git a/backend/src/utils/liquidityForecast.ts b/backend/src/utils/liquidityForecast.ts index a5d2da3..d7e1320 100644 --- a/backend/src/utils/liquidityForecast.ts +++ b/backend/src/utils/liquidityForecast.ts @@ -12,10 +12,13 @@ import { createddocuments, incominginvoices, statementallocations, + tenants, } from "../../db/schema"; import { secrets } from "./secrets"; -type ForecastEventSource = "open_createddocument" | "open_incominginvoice" | "recurring_bankstatement" | "draft_createddocument"; +type ForecastEventSource = "open_createddocument" | "open_incominginvoice" | "recurring_bankstatement" | "draft_createddocument" | "tax_settlement"; + +type TaxEvaluationPeriod = "monthly" | "quarterly" | "yearly"; type ForecastEvent = { date: string; @@ -27,6 +30,26 @@ type ForecastEvent = { 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; @@ -52,6 +75,13 @@ 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() @@ -59,6 +89,85 @@ const normalizeText = (value: unknown) => String(value || "") .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), @@ -111,6 +220,72 @@ const getCreatedDocumentGrossAmount = (document: any, allDocuments: any[] = []) 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); @@ -300,6 +475,69 @@ const expandRecurringEvents = (recurring: RecurringCandidate[], endDate: dayjs.D 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, @@ -309,7 +547,7 @@ export const generateLiquidityForecast = async ( 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([ + const [accounts, statements, documents, incomingInvoices, allocations, tenantSettings] = await Promise.all([ server.db .select() .from(bankaccounts) @@ -331,6 +569,13 @@ export const generateLiquidityForecast = async ( .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); @@ -339,6 +584,7 @@ export const generateLiquidityForecast = async ( 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; @@ -444,8 +690,20 @@ export const generateLiquidityForecast = async ( 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); @@ -504,6 +762,11 @@ export const generateLiquidityForecast = async ( 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), diff --git a/frontend/pages/accounting/liquidity.vue b/frontend/pages/accounting/liquidity.vue index 9dc854e..02e94c6 100644 --- a/frontend/pages/accounting/liquidity.vue +++ b/frontend/pages/accounting/liquidity.vue @@ -11,7 +11,7 @@ const loading = ref(false) const error = ref("") const cacheLoaded = ref(false) -const CACHE_KEY = "fedeo:liquidityForecast:v1" +const CACHE_KEY = "fedeo:liquidityForecast:v2" const dismissedRecurringKeys = computed(() => { return tempStore.settings?.liquidityForecast?.dismissedRecurringKeys || [] @@ -28,7 +28,8 @@ const sourceLabels = { open_createddocument: "Offene Ausgangsrechnung", open_incominginvoice: "Offener Eingangsbeleg", recurring_bankstatement: "Regelmäßige Bankbewegung", - draft_createddocument: "Rechnungsentwurf" + draft_createddocument: "Rechnungsentwurf", + tax_settlement: "USt-Zahlung" } const intervalLabels = { @@ -156,6 +157,7 @@ const formatDate = (value) => dayjs(value).format("DD.MM.YYYY") const getEventRoute = (event) => { if (event.source === "open_createddocument" && event.sourceId) return `/createDocument/show/${event.sourceId}` if (event.source === "open_incominginvoice" && event.sourceId) return `/incomingInvoices/show/${event.sourceId}` + if (event.source === "tax_settlement") return "/accounting/tax" return null } @@ -245,7 +247,8 @@ const groupedEventSummary = computed(() => { const summary = { open_createddocument: { label: sourceLabels.open_createddocument, amount: 0, count: 0 }, open_incominginvoice: { label: sourceLabels.open_incominginvoice, amount: 0, count: 0 }, - recurring_bankstatement: { label: sourceLabels.recurring_bankstatement, amount: 0, count: 0 } + recurring_bankstatement: { label: sourceLabels.recurring_bankstatement, amount: 0, count: 0 }, + tax_settlement: { label: sourceLabels.tax_settlement, amount: 0, count: 0 } } ;(forecast.value?.events || []).forEach((event) => { @@ -277,6 +280,24 @@ const draftIncomeDrivers = computed(() => { .slice(0, 12) }) +const taxPeriodTypeLabel = computed(() => { + if (forecast.value?.tax?.periodType === "quarterly") return "quartalsweise" + if (forecast.value?.tax?.periodType === "yearly") return "jährlich" + return "monatlich" +}) + +const taxForecastPeriods = computed(() => { + return [...(forecast.value?.tax?.periods || [])] + .filter((period) => Math.abs(Number(period.balance || 0)) > 0.01) + .slice(0, 6) +}) + +const taxEventDrivers = computed(() => { + return [...(forecast.value?.events || [])] + .filter((event) => event.source === "tax_settlement") + .sort((a, b) => a.date.localeCompare(b.date)) +}) + const detailedDays = computed(() => { let previousBalance = Number(forecast.value?.startingBalance || 0) @@ -478,6 +499,79 @@ onMounted(() => { + + + +
+
+
+

Im Prognosezeitraum berücksichtigte USt-Salden

+

Positive Salden werden als Auszahlung an das Finanzamt eingeplant, negative als Erstattung.

+
+ + {{ useCurrency(Number(forecast.tax?.totalBalance || 0) * -1) }} + +
+
+ +
+
+
+

{{ period.label }}

+

{{ period.range }} · Fällig am {{ formatDate(period.dueDate) }}

+
+
+
+ USt Rechnungen + {{ useCurrency(period.outputTax) }} +
+
+ Vorsteuer + {{ useCurrency(period.inputTax) }} +
+
+ Saldo + + {{ useCurrency(period.balance) }} + +
+
+
+

{{ period.outputCount }} Ausgangsbelege · {{ period.inputCount }} Eingangsbelege

+

+ {{ useCurrency(period.balance * -1) }} +

+

Liquiditätseffekt

+
+
+
+
+