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:
5
backend/db/migrations/0084_cash_discounts.sql
Normal file
5
backend/db/migrations/0084_cash_discounts.sql
Normal file
@@ -0,0 +1,5 @@
|
||||
ALTER TABLE "tenants" ADD COLUMN "cashDiscountAccounts" jsonb DEFAULT '{}'::jsonb NOT NULL;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "statementallocations" ADD COLUMN "cash_discount_amount" double precision;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "statementallocations" ADD COLUMN "cash_discount_split" jsonb;
|
||||
@@ -568,6 +568,13 @@
|
||||
"when": 1792022400001,
|
||||
"tag": "0083_collaboration_notifications",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 81,
|
||||
"version": "7",
|
||||
"when": 1792108800001,
|
||||
"tag": "0084_cash_discounts",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
timestamp,
|
||||
boolean,
|
||||
doublePrecision,
|
||||
jsonb,
|
||||
} from "drizzle-orm/pg-core"
|
||||
|
||||
import { tenants } from "./tenants"
|
||||
@@ -30,6 +31,10 @@ export const statementallocations = pgTable("statementallocations", {
|
||||
|
||||
amount: doublePrecision("amount").notNull().default(0),
|
||||
|
||||
// Skonto mit dem Vorzeichen der Zahlung; amount bleibt der tatsächlich gezahlte Betrag.
|
||||
cashDiscountAmount: doublePrecision("cash_discount_amount"),
|
||||
cashDiscountSplit: jsonb("cash_discount_split"),
|
||||
|
||||
incominginvoice: bigint("ii_id", { mode: "number" }).references(
|
||||
() => incominginvoices.id
|
||||
),
|
||||
|
||||
@@ -154,6 +154,8 @@ export const tenants = pgTable(
|
||||
}),
|
||||
accountChart: text("accountChart").notNull().default("skr03"),
|
||||
|
||||
cashDiscountAccounts: jsonb("cashDiscountAccounts").notNull().default({}),
|
||||
|
||||
standardEmailForInvoices: text("standardEmailForInvoices"),
|
||||
|
||||
extraModules: jsonb("extraModules").notNull().default([]),
|
||||
|
||||
@@ -55,6 +55,14 @@ export const prepareStatementAllocationInput = (args: Record<string, unknown>) =
|
||||
if (!bookingModes.has(bookingMode)) throw new Error(`Ungültige Aufwandsart: ${bookingMode}`)
|
||||
|
||||
payload.amount = amount
|
||||
if (hasValue(args.cashDiscountAmount)) {
|
||||
const cashDiscountAmount = Number(args.cashDiscountAmount)
|
||||
if (!Number.isFinite(cashDiscountAmount)) throw new Error("cashDiscountAmount muss eine gültige Zahl sein")
|
||||
if (cashDiscountAmount !== 0) {
|
||||
if (!hasValue(args.bankstatement)) throw new Error("Skonto ist nur zusammen mit einer Bankzahlung möglich")
|
||||
payload.cashDiscountAmount = cashDiscountAmount
|
||||
}
|
||||
}
|
||||
if (args.description !== undefined) payload.description = stringArg(args, "description")
|
||||
payload.bookingMode = bookingMode
|
||||
if (args.datevTaxKey !== undefined) payload.datevTaxKey = stringArg(args, "datevTaxKey")
|
||||
|
||||
@@ -36,6 +36,7 @@ import { withImages } from "../result"
|
||||
import { renderOutgoingDocumentPdf } from "../../modules/outgoing-document-pdf.service"
|
||||
import { renderPdfPagesAsPng } from "../../utils/pdfPreview"
|
||||
import { outgoingStatusTools } from "./outgoingStatus"
|
||||
import { prepareCashDiscountAllocation } from "../../modules/cash-discount"
|
||||
|
||||
export { prepareStatementAllocationInput } from "../statementAllocations"
|
||||
|
||||
@@ -1613,7 +1614,7 @@ export const accountingTools: McpTool[] = [
|
||||
{
|
||||
name: "accounting.statement_allocations.create",
|
||||
title: "Bankzuweisung erstellen",
|
||||
description: "Erstellt eine Bankzuweisung oder manuelle Soll/Haben-Buchung. Unterstützt Ausgangs- und Eingangsbelege, Sachkonten, zusätzliche Konten, Debitoren, Kreditoren, Teilbeträge, DATEV-Steuerschlüssel und Abschreibungen.",
|
||||
description: "Erstellt eine Bankzuweisung oder manuelle Soll/Haben-Buchung. Unterstützt Ausgangs- und Eingangsbelege, Sachkonten, zusätzliche Konten, Debitoren, Kreditoren, Teilbeträge, Skonto, DATEV-Steuerschlüssel und Abschreibungen.",
|
||||
requiredPermissions: ["accounting.statement_allocations.write"],
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
@@ -1621,6 +1622,7 @@ export const accountingTools: McpTool[] = [
|
||||
properties: {
|
||||
bankstatement: { type: "number", description: "Bankumsatz-ID. Weglassen für eine manuelle Soll/Haben-Buchung." },
|
||||
amount: { type: "number", description: "Zuweisungsbetrag mit Vorzeichen des Bankumsatzes; bei manuellen Buchungen positiv." },
|
||||
cashDiscountAmount: { type: "number", description: "Skonto (brutto) zu einem Ausgangs- oder Eingangsbeleg, zusätzlich zum gezahlten amount. Nur mit bankstatement; das Vorzeichen wird an amount angeglichen. Wird anteilig nach Steuersätzen auf die Skontokonten verteilt." },
|
||||
createddocument: { type: "number", description: "Ausgangsbeleg-ID." },
|
||||
incominginvoice: { type: "number", description: "Eingangsbeleg-ID." },
|
||||
account: { type: "number", description: "Sachkonto-ID beziehungsweise Soll-Sachkonto." },
|
||||
@@ -1646,8 +1648,9 @@ export const accountingTools: McpTool[] = [
|
||||
},
|
||||
},
|
||||
async handler(context, args) {
|
||||
const payload = prepareStatementAllocationInput(args)
|
||||
await assertStatementAllocationReferences(context, payload)
|
||||
const prepared = prepareStatementAllocationInput(args)
|
||||
await assertStatementAllocationReferences(context, prepared)
|
||||
const payload = await prepareCashDiscountAllocation(context.server.db, context.tenantId, prepared)
|
||||
|
||||
const [created] = await context.server.db
|
||||
.insert(statementallocations)
|
||||
|
||||
@@ -41,10 +41,10 @@ function documentAmount(document: any, advanceInvoices: any[]) {
|
||||
return money(money(net) + money(tax19) + money(tax7) - advanceAmount)
|
||||
}
|
||||
|
||||
export function outgoingDocumentStatus(document: any, paidAmount: number, cancellations: any[], advanceInvoices: any[]) {
|
||||
export function outgoingDocumentStatus(document: any, paidAmount: number, cancellations: any[], advanceInvoices: any[], cashDiscountAmount = 0) {
|
||||
const amount = documentAmount(document, advanceInvoices)
|
||||
if (![amount, paidAmount].every(Number.isFinite)) throw new Error(`Beleg ${document.id}: Betrag kann nicht berechnet werden`)
|
||||
const openAmount = money(amount - paidAmount)
|
||||
if (![amount, paidAmount, cashDiscountAmount].every(Number.isFinite)) throw new Error(`Beleg ${document.id}: Betrag kann nicht berechnet werden`)
|
||||
const openAmount = money(amount - paidAmount - cashDiscountAmount)
|
||||
const cancelled = cancellations.length > 0
|
||||
const applicable = document.state === "Gebucht" && ["invoices", "advanceInvoices"].includes(document.type) && !cancelled
|
||||
// Signed invoices and refunds use the same payment-direction comparison.
|
||||
@@ -55,7 +55,7 @@ export function outgoingDocumentStatus(document: any, paidAmount: number, cancel
|
||||
id: document.id, type: document.type, documentNumber: document.documentNumber, documentDate: document.documentDate,
|
||||
state: document.state, customer: document.customer, project: document.project,
|
||||
cancelled, cancellationDocuments: cancellations.map(({ id, documentNumber }) => ({ id, documentNumber })),
|
||||
paymentStatus, amount, paidAmount: money(paidAmount), openAmount,
|
||||
paymentStatus, amount, paidAmount: money(paidAmount), cashDiscountAmount: money(cashDiscountAmount), openAmount,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,7 +65,7 @@ async function statusesFor(context: McpContext, documents: Array<typeof createdd
|
||||
const advanceIds = [...new Set(documents.flatMap(document => Array.isArray(document.usedAdvanceInvoices)
|
||||
? document.usedAdvanceInvoices.filter(id => typeof id === "number" && Number.isSafeInteger(id) && id > 0) as number[] : []))]
|
||||
const [allocations, cancellations, advances] = await Promise.all([
|
||||
context.server.db.select({ createddocument: statementallocations.createddocument, amount: statementallocations.amount }).from(statementallocations)
|
||||
context.server.db.select({ createddocument: statementallocations.createddocument, amount: statementallocations.amount, cashDiscountAmount: statementallocations.cashDiscountAmount }).from(statementallocations)
|
||||
.where(and(eq(statementallocations.tenant, context.tenantId), eq(statementallocations.archived, false), inArray(statementallocations.createddocument, ids))),
|
||||
context.server.db.select({ id: createddocuments.id, createddocument: createddocuments.createddocument, documentNumber: createddocuments.documentNumber }).from(createddocuments)
|
||||
.where(and(eq(createddocuments.tenant, context.tenantId), eq(createddocuments.archived, false), eq(createddocuments.type, "cancellationInvoices"),
|
||||
@@ -73,15 +73,19 @@ async function statusesFor(context: McpContext, documents: Array<typeof createdd
|
||||
advanceIds.length ? context.server.db.select().from(createddocuments)
|
||||
.where(and(eq(createddocuments.tenant, context.tenantId), eq(createddocuments.archived, false), inArray(createddocuments.id, advanceIds))) : [],
|
||||
])
|
||||
return documents.map(document => outgoingDocumentStatus(document,
|
||||
allocations.filter(allocation => allocation.createddocument === document.id).reduce((sum, allocation) => sum + Number(allocation.amount), 0),
|
||||
cancellations.filter(cancellation => cancellation.createddocument === document.id), advances))
|
||||
return documents.map(document => {
|
||||
const documentAllocations = allocations.filter(allocation => allocation.createddocument === document.id)
|
||||
return outgoingDocumentStatus(document,
|
||||
documentAllocations.reduce((sum, allocation) => sum + Number(allocation.amount), 0),
|
||||
cancellations.filter(cancellation => cancellation.createddocument === document.id), advances,
|
||||
documentAllocations.reduce((sum, allocation) => sum + Number(allocation.cashDiscountAmount || 0), 0))
|
||||
})
|
||||
}
|
||||
|
||||
export const outgoingStatusTools: McpTool[] = [
|
||||
{
|
||||
name: "accounting.outgoing_documents.status.get", title: "Ausgangsbelegstatus laden",
|
||||
description: "Lädt Buchungs-, Zahlungs- und Stornostatus mit Gesamtbetrag, zugeordneten Zahlungen und offenem Betrag. Zahlungsstatus gilt für gebuchte, nicht stornierte Rechnungen und Abschlagsrechnungen; sonst null. Ändert keine Belege oder Buchungen.",
|
||||
description: "Lädt Buchungs-, Zahlungs- und Stornostatus mit Gesamtbetrag, zugeordneten Zahlungen, gebuchtem Skonto und offenem Betrag. Zahlungsstatus gilt für gebuchte, nicht stornierte Rechnungen und Abschlagsrechnungen; sonst null. Ändert keine Belege oder Buchungen.",
|
||||
requiredPermissions: ["accounting.outgoing_documents.read"],
|
||||
inputSchema: { ...objectField({ id: idField }).schema, required: ["id"] },
|
||||
async handler(context, args) {
|
||||
|
||||
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.`)
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import { centralServicesClient } from "../modules/push-server.client"
|
||||
import { findBankInstitutionByBic } from "../modules/banking-institution"
|
||||
import { normalizeBankAllocationDetails, validateBankAllocationCostCentre } from "../modules/bank-allocation-details"
|
||||
import { CASHBOOK_BANK_ID } from "../modules/bank-balances.service"
|
||||
import { prepareCashDiscountAllocation, settledAllocationAmount } from "../modules/cash-discount"
|
||||
|
||||
import {
|
||||
bankrequisitions,
|
||||
@@ -56,7 +57,7 @@ export default async function bankingRoutes(server: FastifyInstance) {
|
||||
eq(bankstatements.id, statementId),
|
||||
eq(bankstatements.tenant, tenantId)
|
||||
)).limit(1),
|
||||
server.db.select({ amount: statementallocations.amount }).from(statementallocations).where(and(
|
||||
server.db.select({ amount: statementallocations.amount, cashDiscountAmount: statementallocations.cashDiscountAmount }).from(statementallocations).where(and(
|
||||
eq(statementallocations.incominginvoice, invoiceId),
|
||||
eq(statementallocations.tenant, tenantId),
|
||||
eq(statementallocations.archived, false)
|
||||
@@ -77,10 +78,9 @@ export default async function bankingRoutes(server: FastifyInstance) {
|
||||
const correctedTax = invoice.taxAmountOverride !== null && invoice.taxAmountOverride !== undefined && invoice.taxAmountOverride !== ""
|
||||
? Number(invoice.taxAmountOverride)
|
||||
: accountTotals.tax
|
||||
const legacyRemaining = Math.max(0, Math.abs(accountTotals.net + accountTotals.tax)
|
||||
- invoiceAllocations.reduce((sum, item) => sum + Math.abs(Number(item.amount || 0)), 0))
|
||||
const correctedRemaining = Math.max(0, Math.abs(accountTotals.net + correctedTax)
|
||||
- invoiceAllocations.reduce((sum, item) => sum + Math.abs(Number(item.amount || 0)), 0))
|
||||
const invoiceSettled = invoiceAllocations.reduce((sum, item) => sum + Math.abs(settledAllocationAmount(item)), 0)
|
||||
const legacyRemaining = Math.max(0, Math.abs(accountTotals.net + accountTotals.tax) - invoiceSettled)
|
||||
const correctedRemaining = Math.max(0, Math.abs(accountTotals.net + correctedTax) - invoiceSettled)
|
||||
const statementRemaining = Math.max(0, Math.abs(Number(statement.amount || 0))
|
||||
- statementAllocations.reduce((sum, item) => sum + Math.abs(Number(item.amount || 0)), 0))
|
||||
const requestedAmount = Number(allocation.amount || 0)
|
||||
@@ -1168,7 +1168,10 @@ export default async function bankingRoutes(server: FastifyInstance) {
|
||||
if (prepared.error) return reply.code(400).send({ error: prepared.error })
|
||||
Object.assign(prepared.data, normalizeBankAllocationDetails(prepared.data))
|
||||
await validateBankAllocationCostCentre(server.db, req.user.tenant_id, prepared.data.costcentre)
|
||||
prepared.data = await useCurrentIncomingInvoiceAmount(req.user.tenant_id, prepared.data)
|
||||
if (!Number(prepared.data.cashDiscountAmount || 0)) {
|
||||
prepared.data = await useCurrentIncomingInvoiceAmount(req.user.tenant_id, prepared.data)
|
||||
}
|
||||
prepared.data = await prepareCashDiscountAllocation(server.db, req.user.tenant_id, prepared.data)
|
||||
|
||||
const inserted = await server.db.insert(statementallocations).values({
|
||||
...prepared.data,
|
||||
|
||||
@@ -507,6 +507,7 @@ export default async function resourceRoutes(server: FastifyInstance) {
|
||||
id: statementallocations.id,
|
||||
createddocument: statementallocations.createddocument,
|
||||
amount: statementallocations.amount,
|
||||
cashDiscountAmount: statementallocations.cashDiscountAmount,
|
||||
})
|
||||
.from(statementallocations)
|
||||
.where(and(
|
||||
|
||||
@@ -15,6 +15,7 @@ import {and, desc, eq, inArray, isNull} from "drizzle-orm"
|
||||
import { enrichProfilesWithBranches } from "../utils/profileBranches"
|
||||
import { enrichProfilesWithTeams } from "../utils/profileTeams"
|
||||
import { rotateRefreshToken } from "../utils/authTokens"
|
||||
import { normalizeCashDiscountAccountsInput } from "../modules/cash-discount"
|
||||
|
||||
|
||||
export default async function tenantRoutes(server: FastifyInstance) {
|
||||
@@ -287,6 +288,14 @@ export default async function tenantRoutes(server: FastifyInstance) {
|
||||
delete data.portalConfig.primayColor
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(data, "cashDiscountAccounts")) {
|
||||
try {
|
||||
data.cashDiscountAccounts = normalizeCashDiscountAccountsInput(data.cashDiscountAccounts)
|
||||
} catch (err: any) {
|
||||
return reply.code(400).send({ error: err.message })
|
||||
}
|
||||
}
|
||||
|
||||
const updated = await server.db
|
||||
.update(tenants)
|
||||
.set(data)
|
||||
|
||||
@@ -104,6 +104,51 @@ export const getBankAccountBookingLine = (alloc: any) => {
|
||||
return columns.join(";");
|
||||
};
|
||||
|
||||
// Skonto als eigene Buchung je Steuersatz: Skontokonto (Automatikkonto) gegen Debitor bzw. Kreditor.
|
||||
// Positiver Skonto (Zahlungseingang) steht im Soll des Skontokontos, negativer (Zahlungsausgang) im Haben.
|
||||
export const getCashDiscountBookingLines = (alloc: any) => {
|
||||
const split = Array.isArray(alloc?.cashDiscountSplit) ? alloc.cashDiscountSplit : [];
|
||||
if (!split.length) return [];
|
||||
|
||||
const bs = alloc.bankstatement;
|
||||
const cd = alloc.createddocument;
|
||||
const ii = alloc.incominginvoice;
|
||||
const partner = cd ? cd.customer : ii?.vendor;
|
||||
const partnerNumber = cd ? partner?.customerNumber : partner?.vendorNumber;
|
||||
const reference = cd ? cd.documentNumber : ii?.reference;
|
||||
const prefix = cd ? "Skonto AR" : "Skonto ER";
|
||||
const date = bs?.date || alloc.manualBookingDate;
|
||||
const quote = (value: any) => `"${escapeString(String(value ?? ""))}"`;
|
||||
|
||||
return split
|
||||
.filter((item: any) => Number(item?.gross) && item?.account)
|
||||
.map((item: any) => {
|
||||
const columns = Array(124).fill("");
|
||||
columns[0] = displayCurrency(Number(item.gross), true);
|
||||
columns[1] = quote(Number(item.gross) > 0 ? "S" : "H");
|
||||
columns[6] = item.account;
|
||||
columns[7] = partnerNumber || "";
|
||||
columns[8] = quote("");
|
||||
columns[9] = formatDatevDate(date, "DDMM");
|
||||
columns[10] = quote(reference || "");
|
||||
columns[13] = quote(`${prefix} ${reference || ""} ${item.rate} % - ${partner?.name || ""}`.substring(0, 59));
|
||||
columns[20] = quote("Geschäftspartner");
|
||||
columns[21] = quote(partner?.name || "");
|
||||
columns[22] = quote("Kundennummer");
|
||||
columns[23] = quote(partnerNumber || "");
|
||||
columns[24] = quote("Belegnummer");
|
||||
columns[25] = quote(reference || "");
|
||||
columns[28] = quote("Belegdatum");
|
||||
columns[29] = quote(formatDatevDate(date, "DD.MM.YYYY"));
|
||||
if (bs?.id) {
|
||||
columns[47] = "Bank-Id";
|
||||
columns[48] = bs.id;
|
||||
}
|
||||
columns[112] = "0";
|
||||
return columns.join(";");
|
||||
});
|
||||
};
|
||||
|
||||
const getIncomingInvoiceTaxOverride = (invoice: any) => {
|
||||
if (invoice.taxAmountOverride === null || invoice.taxAmountOverride === undefined || invoice.taxAmountOverride === "") return null;
|
||||
|
||||
@@ -479,6 +524,10 @@ export async function buildExportZip(
|
||||
const ii = alloc.incominginvoice;
|
||||
const vend = ii.vendor;
|
||||
bookingLines.push(`${displayCurrency(alloc.amount,true)};"${shSelector}";;;;;${datevKonto};${vend?.vendorNumber};"";${formatDatevDate(ii.date, "DDMM")};"${ii.reference}";;;"${`ZA${alloc.description} ${bsText} `.substring(0,59)}";;;;;;;"Geschäftspartner";"${vend?.name}";"Kundennummer";"${vend?.vendorNumber}";"Belegnummer";"${ii.reference}";"Leistungsdatum";"${formatDatevDate(ii.date, "DD.MM.YYYY")}";"Belegdatum";"${dateFull}";;;;;;;;;;"";;;;;;;;Bank-Id;${alloc.bankstatement.id};;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;0;;;;"";;;;;;`);
|
||||
}
|
||||
|
||||
if((alloc.createddocument && alloc.createddocument.customer) || (alloc.incominginvoice && alloc.incominginvoice.vendor)) {
|
||||
bookingLines.push(...getCashDiscountBookingLines(alloc));
|
||||
} else if(alloc.account) {
|
||||
bookingLines.push(getBankAccountBookingLine(alloc));
|
||||
} else if(alloc.vendor) {
|
||||
|
||||
@@ -685,14 +685,14 @@ export const generateLiquidityForecast = async (
|
||||
if (allocation.createddocument) {
|
||||
allocationByDocument.set(
|
||||
allocation.createddocument,
|
||||
roundMoney((allocationByDocument.get(allocation.createddocument) || 0) + Number(allocation.amount || 0))
|
||||
roundMoney((allocationByDocument.get(allocation.createddocument) || 0) + Number(allocation.amount || 0) + Number(allocation.cashDiscountAmount || 0))
|
||||
);
|
||||
}
|
||||
|
||||
if (allocation.incominginvoice) {
|
||||
allocationByIncomingInvoice.set(
|
||||
allocation.incominginvoice,
|
||||
roundMoney((allocationByIncomingInvoice.get(allocation.incominginvoice) || 0) + Number(allocation.amount || 0))
|
||||
roundMoney((allocationByIncomingInvoice.get(allocation.incominginvoice) || 0) + Number(allocation.amount || 0) + Number(allocation.cashDiscountAmount || 0))
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
116
backend/tests/cashDiscount.test.ts
Normal file
116
backend/tests/cashDiscount.test.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import assert from "node:assert/strict"
|
||||
import test from "node:test"
|
||||
import {
|
||||
getCreatedDocumentGrossByRate,
|
||||
getCreatedDocumentGrossTotal,
|
||||
getIncomingInvoiceGrossByRate,
|
||||
normalizeCashDiscountAccountsInput,
|
||||
resolveCashDiscountAccounts,
|
||||
splitCashDiscount,
|
||||
validateCashDiscount,
|
||||
} from "../src/modules/cash-discount"
|
||||
import { getCashDiscountBookingLines } from "../src/utils/export/datev"
|
||||
import { prepareStatementAllocationInput } from "../src/mcp/statementAllocations"
|
||||
|
||||
const defaults = resolveCashDiscountAccounts({})
|
||||
|
||||
test("splits incoming invoice discount by tax rate onto received discount accounts", () => {
|
||||
const gross = getIncomingInvoiceGrossByRate({
|
||||
expense: true,
|
||||
accounts: [
|
||||
{ taxType: "19", amountNet: 100, amountTax: 19 },
|
||||
{ taxType: "7", amountNet: 100, amountTax: 7 },
|
||||
],
|
||||
})
|
||||
assert.deepEqual(gross, { "19": -119, "7": -107, "0": 0 })
|
||||
|
||||
const split = splitCashDiscount(-4.52, gross, defaults.received)
|
||||
assert.deepEqual(split, [
|
||||
{ rate: 19, account: "3736", gross: -2.38, net: -2, tax: -0.38 },
|
||||
{ rate: 7, account: "3731", gross: -2.14, net: -2, tax: -0.14 },
|
||||
])
|
||||
})
|
||||
|
||||
test("assigns rounding differences to the largest share", () => {
|
||||
const split = splitCashDiscount(1, { "19": 1, "7": 1, "0": 1 }, defaults.granted)
|
||||
assert.equal(split.reduce((sum, item) => sum + item.gross, 0).toFixed(2), "1.00")
|
||||
assert.equal(split.length, 3)
|
||||
})
|
||||
|
||||
test("rejects discounts on intra-community acquisitions", () => {
|
||||
assert.throws(
|
||||
() => getIncomingInvoiceGrossByRate({ accounts: [{ taxType: "19I", amountNet: 100, amountTax: 0 }] }),
|
||||
/manuell buchen/,
|
||||
)
|
||||
})
|
||||
|
||||
test("final invoices spread discounts over the remaining claim after advance invoices", () => {
|
||||
const advance = { id: 7, rows: [{ advanceInvoiceData: true, price: 1000, taxPercent: 19 }] }
|
||||
const final = {
|
||||
id: 8,
|
||||
usedAdvanceInvoices: [7],
|
||||
rows: [
|
||||
{ mode: "normal", quantity: 1, price: 2000, discountPercent: 0, taxPercent: 19 },
|
||||
{ mode: "normal", quantity: 1, price: 1000, discountPercent: 0, taxPercent: 7 },
|
||||
],
|
||||
}
|
||||
assert.deepEqual(getCreatedDocumentGrossByRate(final, [advance]), { "19": 1190, "7": 1070, "0": 0 })
|
||||
assert.equal(getCreatedDocumentGrossTotal(final, [advance]), 2260)
|
||||
})
|
||||
|
||||
test("tax exempt small business invoices use the account without tax", () => {
|
||||
const gross = getCreatedDocumentGrossByRate({
|
||||
taxType: "19 UStG",
|
||||
rows: [{ mode: "normal", quantity: 1, price: 100, discountPercent: 0, taxPercent: 19 }],
|
||||
})
|
||||
assert.deepEqual(gross, { "19": 0, "7": 0, "0": 100 })
|
||||
assert.deepEqual(splitCashDiscount(2, gross, defaults.granted), [{ rate: 0, account: "8730", gross: 2, net: 2, tax: 0 }])
|
||||
})
|
||||
|
||||
test("validates payment plus discount against the open amount", () => {
|
||||
assert.doesNotThrow(() => validateCashDiscount({ amount: 98, discount: 2, total: 100, settled: 0 }))
|
||||
assert.doesNotThrow(() => validateCashDiscount({ amount: -98, discount: -2, total: -100, settled: 0 }))
|
||||
assert.throws(() => validateCashDiscount({ amount: 99, discount: 2, total: 100, settled: 0 }), /offenen Betrag/)
|
||||
assert.throws(() => validateCashDiscount({ amount: 50, discount: 2, total: 100, settled: 50 }), /offenen Betrag/)
|
||||
assert.throws(() => validateCashDiscount({ amount: 80, discount: 20, total: 100, settled: 0 }), /höher als 10 %/)
|
||||
assert.throws(() => validateCashDiscount({ amount: -98, discount: -2, total: 100, settled: 0 }), /Richtung/)
|
||||
})
|
||||
|
||||
test("normalizes configured discount accounts and keeps SKR03 defaults", () => {
|
||||
const input = normalizeCashDiscountAccountsInput({ received: { "19": " 3737 ", "7": "" } })
|
||||
assert.deepEqual(input, { received: { "19": "3737" } })
|
||||
assert.equal(resolveCashDiscountAccounts(input).received["19"], "3737")
|
||||
assert.equal(resolveCashDiscountAccounts(input).received["7"], "3731")
|
||||
assert.throws(() => normalizeCashDiscountAccountsInput({ granted: { "19": "87A6" } }), /Ziffern/)
|
||||
})
|
||||
|
||||
test("exports discount bookings per tax rate against the business partner", () => {
|
||||
const lines = getCashDiscountBookingLines({
|
||||
bankstatement: { id: 42, date: "2026-10-05" },
|
||||
incominginvoice: { reference: "RE-1", vendor: { vendorNumber: "70001", name: "Lieferant" } },
|
||||
cashDiscountSplit: [
|
||||
{ rate: 19, account: "3736", gross: -2.38, net: -2, tax: -0.38 },
|
||||
{ rate: 7, account: "3731", gross: -2.14, net: -2, tax: -0.14 },
|
||||
],
|
||||
}).map(line => line.split(";"))
|
||||
|
||||
assert.equal(lines.length, 2)
|
||||
assert.equal(lines[0].length, 124)
|
||||
assert.deepEqual([lines[0][0], lines[0][1], lines[0][6], lines[0][7], lines[0][9], lines[0][10]],
|
||||
["2,38", '"H"', "3736", "70001", "0510", '"RE-1"'])
|
||||
|
||||
const [granted] = getCashDiscountBookingLines({
|
||||
bankstatement: { id: 43, date: "2026-10-05" },
|
||||
createddocument: { documentNumber: "RE-100", customer: { customerNumber: "10001", name: "Kunde" } },
|
||||
cashDiscountSplit: [{ rate: 19, account: "8736", gross: 2.38, net: 2, tax: 0.38 }],
|
||||
}).map(line => line.split(";"))
|
||||
assert.deepEqual([granted[0], granted[1], granted[6], granted[7]], ["2,38", '"S"', "8736", "10001"])
|
||||
})
|
||||
|
||||
test("MCP allocation input accepts cash discounts only with bank statements", () => {
|
||||
const payload = prepareStatementAllocationInput({ bankstatement: 1, createddocument: 2, amount: 98, cashDiscountAmount: 2 })
|
||||
assert.equal(payload.cashDiscountAmount, 2)
|
||||
assert.throws(() => prepareStatementAllocationInput({
|
||||
amount: 98, cashDiscountAmount: 2, manualBookingDate: "2026-10-01", account: 1, contraAccount: 2,
|
||||
}), /Bankzahlung/)
|
||||
})
|
||||
Reference in New Issue
Block a user