feat(accounting): Skonto bei Zahlungen auf Ausgangs- und Eingangsbelege buchen
Skonto wird an der Bankzuordnung gespeichert und gleicht den Beleg zusammen mit der Zahlung aus. Der Betrag wird anteilig nach Steuersätzen auf die Skontokonten verteilt (Standard SKR03, je Mandant einstellbar), im DATEV-Export als eigene Buchung je Steuersatz ausgegeben und in der USt-Auswertung im Zeitraum der Zahlung berücksichtigt. Schlussrechnungen verteilen den Skonto auf die Restforderung nach Abzug der Abschläge. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
274
backend/src/modules/cash-discount.ts
Normal file
274
backend/src/modules/cash-discount.ts
Normal file
@@ -0,0 +1,274 @@
|
||||
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<CashDiscountSide, Record<CashDiscountRate, string>>
|
||||
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<CashDiscountAccountConfig> {
|
||||
if (!input || typeof input !== "object" || Array.isArray(input)) {
|
||||
throw invalidInput("Die Skontokonten müssen als Objekt übergeben werden.")
|
||||
}
|
||||
const result: Partial<CashDiscountAccountConfig> = {}
|
||||
for (const side of ["received", "granted"] as const) {
|
||||
const entries: Partial<Record<CashDiscountRate, string>> = {}
|
||||
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<CashDiscountRate, string>
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export function settledAllocationAmount(allocation: { amount?: any, cashDiscountAmount?: any }) {
|
||||
return Number(allocation?.amount || 0) + Number(allocation?.cashDiscountAmount || 0)
|
||||
}
|
||||
|
||||
type GrossByRate = Record<CashDiscountRate, number>
|
||||
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<CashDiscountRate, string>): 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<string, any>) {
|
||||
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.`)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user