import { and, eq, inArray } from "drizzle-orm" import { createddocuments, incominginvoices, statementallocations, tenants } from "../../db/schema" // Skonto wird an der Zahlungszuordnung gespeichert. amount bleibt der tatsächlich gezahlte Betrag, // cashDiscountAmount trägt dasselbe Vorzeichen und schließt zusammen mit amount den Beleg aus. export type CashDiscountRate = "19" | "7" | "0" export type CashDiscountSide = "received" | "granted" export type CashDiscountAccountConfig = Record> export type CashDiscountSplitItem = { rate: number, account: string, gross: number, net: number, tax: number } export const CASH_DISCOUNT_RATES: CashDiscountRate[] = ["19", "7", "0"] // SKR03: Erhaltene Skonti (Eingangsbelege) und gewährte Skonti (Ausgangsbelege), jeweils Automatikkonten. export const DEFAULT_CASH_DISCOUNT_ACCOUNTS: CashDiscountAccountConfig = { received: { "19": "3736", "7": "3731", "0": "3730" }, granted: { "19": "8736", "7": "8731", "0": "8730" }, } // Weicht der Skonto um mehr als diesen Anteil vom Beleg ab, liegt sehr wahrscheinlich ein Eingabefehler vor. export const MAX_CASH_DISCOUNT_SHARE = 0.1 const invalidInput = (message: string) => Object.assign(new Error(message), { statusCode: 400 }) const money = (value: number) => Number(value.toFixed(2)) const sameSign = (value: number, sign: number) => sign === 0 || Math.sign(value) === 0 || Math.sign(value) === sign export function resolveCashDiscountAccounts(config: any): CashDiscountAccountConfig { const result = { received: { ...DEFAULT_CASH_DISCOUNT_ACCOUNTS.received }, granted: { ...DEFAULT_CASH_DISCOUNT_ACCOUNTS.granted }, } for (const side of ["received", "granted"] as const) { for (const rate of CASH_DISCOUNT_RATES) { const value = config?.[side]?.[rate] if (typeof value === "string" && value.trim()) result[side][rate] = value.trim() else if (typeof value === "number" && Number.isFinite(value)) result[side][rate] = String(value) } } return result } // Validiert die Einstellung aus den Firmeneinstellungen. Leere Felder fallen auf SKR03 zurück. export function normalizeCashDiscountAccountsInput(input: any): Partial { if (!input || typeof input !== "object" || Array.isArray(input)) { throw invalidInput("Die Skontokonten müssen als Objekt übergeben werden.") } const result: Partial = {} for (const side of ["received", "granted"] as const) { const entries: Partial> = {} for (const rate of CASH_DISCOUNT_RATES) { const raw = input?.[side]?.[rate] if (raw === undefined || raw === null || String(raw).trim() === "") continue const value = String(raw).trim() if (!/^\d{4,9}$/.test(value)) { throw invalidInput(`Das Skontokonto ${value} muss aus 4 bis 9 Ziffern bestehen.`) } entries[rate] = value } if (Object.keys(entries).length) result[side] = entries as Record } return result } export function settledAllocationAmount(allocation: { amount?: any, cashDiscountAmount?: any }) { return Number(allocation?.amount || 0) + Number(allocation?.cashDiscountAmount || 0) } type GrossByRate = Record const emptyGross = (): GrossByRate => ({ "19": 0, "7": 0, "0": 0 }) // Bruttobeträge eines Eingangsbelegs je Steuersatz. Vorzeichen: Ausgaben negativ (wie die Bankzuordnung). export function getIncomingInvoiceGrossByRate(invoice: any): GrossByRate { const gross = emptyGross() const accounts = Array.isArray(invoice?.accounts) ? invoice.accounts : [] for (const account of accounts) { const taxType = account?.taxType === null || account?.taxType === undefined ? "null" : String(account.taxType) const amount = Number(account?.amountNet || 0) + Number(account?.amountTax || 0) if (!Number.isFinite(amount)) throw invalidInput("Der Eingangsbeleg enthält ungültige Beträge.") if (amount === 0) continue if (taxType === "19" || taxType === "7") gross[taxType] += amount else if (taxType === "null" || taxType === "" || taxType === "0") gross["0"] += amount else { throw invalidInput("Skonto auf Belege mit innergemeinschaftlichem Erwerb oder §13b UStG bitte manuell buchen.") } } const override = invoice?.taxAmountOverride if (override !== null && override !== undefined && override !== "") { const calculatedTax = accounts.reduce((sum: number, account: any) => sum + Number(account?.amountTax || 0), 0) const correction = Number(override) - calculatedTax if (Number.isFinite(correction) && Math.abs(correction) >= 0.005) { if (gross["19"] !== 0) gross["19"] += correction else if (gross["7"] !== 0) gross["7"] += correction } } const sign = invoice?.expense === false ? 1 : -1 return { "19": money(gross["19"] * sign), "7": money(gross["7"] * sign), "0": money(gross["0"] * sign), } } const rowsGrossByRate = (document: any, taxFree: boolean): GrossByRate => { const net = emptyGross() for (const row of Array.isArray(document?.rows) ? document.rows : []) { if (!row || ["pagebreak", "title", "text"].includes(row.mode)) continue const rowNet = Number((Number(row.quantity || 0) * Number(row.price || 0) * (1 - Number(row.discountPercent || 0) / 100)).toFixed(3)) if (!Number.isFinite(rowNet)) throw invalidInput(`Beleg ${document.id}: ungültiger Positionsbetrag`) const taxPercent = Number(row.taxPercent) if (!taxFree && taxPercent === 19) net["19"] += rowNet else if (!taxFree && taxPercent === 7) net["7"] += rowNet else net["0"] += rowNet } return { "19": money(money(net["19"]) + money(net["19"] * 0.19)), "7": money(money(net["7"]) + money(net["7"] * 0.07)), "0": money(net["0"]), } } const createdDocumentAmounts = (document: any, advanceInvoices: any[]) => { if (document?.taxType === "13b UStG") { throw invalidInput("Skonto auf Ausgangsbelege nach §13b UStG bitte manuell buchen.") } const taxFree = ["19 UStG", "12.3 UStG"].includes(String(document?.taxType || "")) const gross = rowsGrossByRate(document, taxFree) const advances = emptyGross() for (const id of Array.isArray(document?.usedAdvanceInvoices) ? document.usedAdvanceInvoices : []) { const row = advanceInvoices.find((invoice) => invoice.id === id)?.rows?.find((row: any) => row?.advanceInvoiceData) if (!row) continue const rate = String(Number(row.taxPercent)) as CashDiscountRate const amount = Number(row.price) * ((100 + Number(row.taxPercent || 0)) / 100) advances[!taxFree && (rate === "19" || rate === "7") ? rate : "0"] += amount } return { gross, advances } } // Bruttobeträge eines Ausgangsbelegs je Steuersatz. Bei Schlussrechnungen werden die Abschläge mit ihren // ursprünglich berechneten Beträgen abgezogen, damit sich der Skonto auf die Restforderung verteilt. export function getCreatedDocumentGrossByRate(document: any, advanceInvoices: any[] = []): GrossByRate { const { gross, advances } = createdDocumentAmounts(document, advanceInvoices) const total = gross["19"] + gross["7"] + gross["0"] const sign = Math.sign(total) || 1 const remaining = emptyGross() for (const rate of CASH_DISCOUNT_RATES) { const value = money(gross[rate] - advances[rate]) remaining[rate] = sameSign(value, sign) ? value : 0 } return remaining["19"] || remaining["7"] || remaining["0"] ? remaining : gross } // Offene Gesamtforderung des Ausgangsbelegs: Bruttobetrag abzüglich der berechneten Abschläge. export function getCreatedDocumentGrossTotal(document: any, advanceInvoices: any[] = []) { const { gross, advances } = createdDocumentAmounts(document, advanceInvoices) return money(gross["19"] + gross["7"] + gross["0"] - advances["19"] - advances["7"] - advances["0"]) } // Verteilt den Skonto anteilig auf die Steuersätze; Rundungsdifferenzen gehen an den größten Anteil. export function splitCashDiscount(amount: number, grossByRate: GrossByRate, accounts: Record): CashDiscountSplitItem[] { const discount = money(amount) if (!discount) return [] const weights = CASH_DISCOUNT_RATES .map((rate) => ({ rate, weight: Math.abs(Number(grossByRate[rate] || 0)) })) .filter((item) => item.weight > 0) if (!weights.length) weights.push({ rate: "0", weight: 1 }) const totalWeight = weights.reduce((sum, item) => sum + item.weight, 0) const items = weights.map((item) => ({ rate: item.rate, gross: money(discount * item.weight / totalWeight) })) const difference = money(discount - items.reduce((sum, item) => sum + item.gross, 0)) if (difference) { const largest = items.reduce((max, item) => Math.abs(item.gross) > Math.abs(max.gross) ? item : max, items[0]) largest.gross = money(largest.gross + difference) } return items.filter((item) => item.gross !== 0).map((item) => { const rate = Number(item.rate) const net = money(item.gross / (1 + rate / 100)) return { rate, account: accounts[item.rate], gross: item.gross, net, tax: money(item.gross - net) } }) } // Bereitet Skonto für eine neue Bankzuordnung vor: Vorzeichen, Plausibilität und Steueraufteilung. export async function prepareCashDiscountAllocation(db: any, tenantId: number, payload: Record) { const requested = Number(payload.cashDiscountAmount || 0) if (!Number.isFinite(requested)) throw invalidInput("Der Skontobetrag muss eine gültige Zahl sein.") if (Math.abs(requested) < 0.005) { return { ...payload, cashDiscountAmount: null, cashDiscountSplit: null } } const amount = Number(payload.amount || 0) if (!payload.bankstatement) throw invalidInput("Skonto kann nur zusammen mit einer Bankzahlung gebucht werden.") const hasDocument = Boolean(payload.createddocument) const hasInvoice = Boolean(payload.incominginvoice) if (hasDocument === hasInvoice) { throw invalidInput("Skonto ist nur bei der Zuordnung genau eines Ausgangs- oder Eingangsbelegs möglich.") } if (!amount) throw invalidInput("Für Skonto muss ein Zahlbetrag zugeordnet werden.") const discount = money(Math.sign(amount) * Math.abs(requested)) const [tenant] = await db.select({ cashDiscountAccounts: tenants.cashDiscountAccounts }) .from(tenants).where(eq(tenants.id, tenantId)).limit(1) const accounts = resolveCashDiscountAccounts(tenant?.cashDiscountAccounts) let grossByRate: GrossByRate let total: number let existing: any[] if (hasDocument) { const documentId = Number(payload.createddocument) const [document] = await db.select().from(createddocuments).where(and( eq(createddocuments.id, documentId), eq(createddocuments.tenant, tenantId), )).limit(1) if (!document || document.archived) throw invalidInput("Der Ausgangsbeleg wurde nicht gefunden.") if (document.state !== "Gebucht") throw invalidInput("Skonto ist nur auf gebuchte Ausgangsbelege möglich.") const advanceIds = (Array.isArray(document.usedAdvanceInvoices) ? document.usedAdvanceInvoices : []) .filter((id: any) => Number.isSafeInteger(id) && id > 0) const advanceInvoices = advanceIds.length ? await db.select().from(createddocuments).where(and( eq(createddocuments.tenant, tenantId), inArray(createddocuments.id, advanceIds), )) : [] grossByRate = getCreatedDocumentGrossByRate(document, advanceInvoices) total = getCreatedDocumentGrossTotal(document, advanceInvoices) existing = await db.select({ amount: statementallocations.amount, cashDiscountAmount: statementallocations.cashDiscountAmount }) .from(statementallocations).where(and( eq(statementallocations.createddocument, documentId), eq(statementallocations.tenant, tenantId), eq(statementallocations.archived, false), )) } else { const invoiceId = Number(payload.incominginvoice) const [invoice] = await db.select().from(incominginvoices).where(and( eq(incominginvoices.id, invoiceId), eq(incominginvoices.tenant, tenantId), )).limit(1) if (!invoice || invoice.archived) throw invalidInput("Der Eingangsbeleg wurde nicht gefunden.") if (invoice.state !== "Gebucht") throw invalidInput("Skonto ist nur auf gebuchte Eingangsbelege möglich.") grossByRate = getIncomingInvoiceGrossByRate(invoice) total = money(grossByRate["19"] + grossByRate["7"] + grossByRate["0"]) existing = await db.select({ amount: statementallocations.amount, cashDiscountAmount: statementallocations.cashDiscountAmount }) .from(statementallocations).where(and( eq(statementallocations.incominginvoice, invoiceId), eq(statementallocations.tenant, tenantId), eq(statementallocations.archived, false), )) } validateCashDiscount({ amount, discount, total, settled: existing.reduce((sum, item) => sum + settledAllocationAmount(item), 0) }) const side: CashDiscountSide = hasDocument ? "granted" : "received" return { ...payload, cashDiscountAmount: discount, cashDiscountSplit: splitCashDiscount(discount, grossByRate, accounts[side]), } } export function validateCashDiscount({ amount, discount, total, settled }: { amount: number, discount: number, total: number, settled: number }) { if (!total) throw invalidInput("Der Beleg hat keinen Betrag, auf den Skonto gebucht werden kann.") if (Math.sign(amount) !== Math.sign(total)) { throw invalidInput("Skonto ist nur bei Zahlungen in Richtung des Belegs möglich.") } const open = money(total - settled) if (Math.abs(amount + discount) > Math.abs(open) + 0.02 || Math.sign(open) !== Math.sign(total)) { throw invalidInput(`Zahlung und Skonto übersteigen den offenen Betrag von ${Math.abs(open).toFixed(2).replace(".", ",")} €.`) } if (Math.abs(discount) > Math.abs(total) * MAX_CASH_DISCOUNT_SHARE + 0.005) { throw invalidInput(`Der Skonto ist höher als ${MAX_CASH_DISCOUNT_SHARE * 100} % des Belegbetrags. Bitte Betrag prüfen.`) } } // --------------------------------------------------------------------------- // Skonto-Bedingungen auf Belegen // --------------------------------------------------------------------------- export type CashDiscountTerms = { percent: number days: number | null dueDate: string | null discount: number payable: number } const formatEuro = (value: number) => new Intl.NumberFormat("de-DE", { style: "currency", currency: "EUR" }).format(value) const formatGermanDate = (value: string) => { const [year, month, day] = value.split("-") return `${day}.${month}.${year}` } const addDays = (date: string, days: number) => { const parsed = new Date(`${String(date).slice(0, 10)}T00:00:00Z`) if (Number.isNaN(parsed.getTime())) return null parsed.setUTCDate(parsed.getUTCDate() + days) return parsed.toISOString().slice(0, 10) } export function normalizeCashDiscountPercent(value: unknown) { if (value === null || value === undefined || value === "") return null const percent = Number(String(value).replace(",", ".")) if (!Number.isFinite(percent) || percent < 0 || percent > 10) { throw invalidInput("Der Skontosatz muss zwischen 0 und 10 % liegen.") } return percent === 0 ? null : Number(percent.toFixed(2)) } export function normalizeCashDiscountDays(value: unknown) { if (value === null || value === undefined || value === "") return null const days = Number(value) if (!Number.isInteger(days) || days < 0 || days > 365) { throw invalidInput("Die Skontofrist muss zwischen 0 und 365 Tagen liegen.") } return days } // Berechnet Frist, Skonto und Zahlbetrag aus Satz, Tagen und Belegdatum. export function buildCashDiscountTerms({ percent, days, documentDate, dueDate, amount }: { percent: unknown, days?: unknown, documentDate?: string | null, dueDate?: string | null, amount: number }): CashDiscountTerms | null { const normalizedPercent = Number(percent) if (!Number.isFinite(normalizedPercent) || normalizedPercent <= 0 || !Number.isFinite(amount) || !amount) return null const normalizedDays = days === null || days === undefined || days === "" ? null : Number(days) const computedDueDate = dueDate ? String(dueDate).slice(0, 10) : documentDate && normalizedDays !== null && Number.isFinite(normalizedDays) ? addDays(documentDate, normalizedDays) : null const discount = money(amount * normalizedPercent / 100) return { percent: normalizedPercent, days: normalizedDays !== null && Number.isFinite(normalizedDays) ? normalizedDays : null, dueDate: computedDueDate, discount, payable: money(amount - discount), } } export function formatCashDiscountText(terms: CashDiscountTerms | null) { if (!terms) return "" const percent = String(terms.percent).replace(".", ",") const deadline = terms.dueDate ? `bis zum ${formatGermanDate(terms.dueDate)}` : terms.days !== null ? `innerhalb von ${terms.days} Tagen` : "bei sofortiger Zahlung" return `Bei Zahlung ${deadline} gewähren wir ${percent} % Skonto (${formatEuro(Math.abs(terms.discount))}), Zahlbetrag ${formatEuro(Math.abs(terms.payable))}.` } // Kontextvariablen für Textvorlagen; der Satz wird automatisch ergänzt, wenn die Vorlage keine Skontovariable nutzt. export function getCashDiscountTemplateContext(terms: CashDiscountTerms | null) { if (!terms) return {} return { skonto_prozent: String(terms.percent).replace(".", ","), skonto_tage: terms.days, skonto_datum: terms.dueDate ? formatGermanDate(terms.dueDate) : null, skonto_betrag: formatEuro(Math.abs(terms.discount)), zahlbetrag_mit_skonto: formatEuro(Math.abs(terms.payable)), skonto_hinweis: formatCashDiscountText(terms), } } export function appendCashDiscountText(renderedText: string, template: string | null | undefined, terms: CashDiscountTerms | null) { if (!terms || /skonto_/i.test(String(template || ""))) return renderedText const sentence = formatCashDiscountText(terms) return renderedText ? `${sentence}\n\n${renderedText}` : sentence } // Liest Skontobedingungen aus E-Rechnungen: XRechnung "#SKONTO#TAGE=14#PROZENT=2.00#" im Zahlungsbedingungstext. export function parseCashDiscountPaymentTerms(text: unknown): { percent: number, days: number } | null { const source = String(text || "") const matches = [...source.matchAll(/#SKONTO#([^\n]*?)#(?:\s|$)/gi)] for (const match of matches) { const segment = match[1] const days = Number(segment.match(/TAGE=(\d+)/i)?.[1]) const percent = Number(segment.match(/PROZENT=(\d+(?:[.,]\d+)?)/i)?.[1]?.replace(",", ".")) if (Number.isInteger(days) && Number.isFinite(percent) && percent > 0) return { percent, days } } return null }