dev #257

Merged
flfeders merged 8 commits from dev into main 2026-11-04 10:35:20 +00:00
27 changed files with 837 additions and 41 deletions
Showing only changes of commit af4a283eb5 - Show all commits

View 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;

View File

@@ -568,6 +568,13 @@
"when": 1792022400001,
"tag": "0083_collaboration_notifications",
"breakpoints": true
},
{
"idx": 81,
"version": "7",
"when": 1792108800001,
"tag": "0084_cash_discounts",
"breakpoints": true
}
]
}

View File

@@ -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
),

View File

@@ -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([]),

View File

@@ -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")

View File

@@ -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)

View File

@@ -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) {

View 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.`)
}
}

View File

@@ -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)
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,

View File

@@ -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(

View File

@@ -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)

View File

@@ -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) {

View File

@@ -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))
);
}
});

View 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/)
})

View File

@@ -36,6 +36,17 @@ Vor dem Buchen werden Netto-, Steuer- und Bruttobetrag angezeigt. Beispielsweise
Der DATEV-Export übergibt den Bruttobetrag mit dem gewählten BU-Schlüssel (9/8 für Vorsteuer, 3/2 für Umsatzsteuer) und die Kostenstellennummer in **KOST1**. „Ohne Umsatzsteuer“ verwendet Schlüssel 40, um auch eine Kontenautomatik aufzuheben. Bestehende Buchungen ohne Steuerauswahl werden nicht nachträglich umgerechnet. Die Zuordnung folgt den [DATEV-Steuerschlüsseln](https://www.datev.de/dnlexom/v2/content/files/st2286167563_de.pdf) und der [DATEV-Dokumentation zur Aufhebung der Automatik](https://apps.datev.de/help-center/api/knowledge-help/v1/content/entities/st2112778507_de.pdf?docId=0907888&save=False).
## Zahlung mit Skonto ausgleichen
Zahlt ein Kunde weniger als den offenen Betrag oder ziehst du bei einem Lieferanten Skonto ab, öffne den Bankumsatz und wähle beim Beleg **Mit Skonto ausgleichen** (Prozent-Symbol). Der Vorschlag ist die Differenz zwischen offenem Betrag und Zahlung. Zahlung und Skonto gleichen den Beleg gemeinsam aus; der Bankumsatz enthält weiterhin nur den gezahlten Betrag.
- Der Skonto wird anteilig nach den Steuersätzen des Belegs verteilt und mindert die Umsatzsteuer (Ausgangsbelege) bzw. Vorsteuer (Eingangsbelege) im Zeitraum der Zahlung.
- Bei Schlussrechnungen bleiben die Abschläge mit ihren ursprünglich berechneten Beträgen abgezogen. Skonto auf eine Abschlagsrechnung wird bei deren Zahlung gebucht, Skonto auf die Schlussrechnung bezieht sich auf die Restforderung.
- Der DATEV-Export erzeugt je Steuersatz eine eigene Buchung vom Skontokonto gegen Debitor bzw. Kreditor mit der Rechnungsnummer als Belegfeld.
- Standard sind die SKR-03-Automatikkonten 3736/3731/3730 (erhaltene Skonti) und 8736/8731/8730 (gewährte Skonti). Abweichende Konten trägst du unter **Firmeneinstellungen → Rechnung & Kontakt → Skontokonten** ein.
- Skonto über 10 % des Belegs, auf Belege nach §13b UStG oder mit innergemeinschaftlichem Erwerb wird abgelehnt und muss manuell gebucht werden.
- Wird die Bankzuordnung gelöscht, entfällt auch der Skonto.
## Gute Praxis
- Regelmäßig abrufen, damit sich keine großen Rückstände bilden.

View File

@@ -36,6 +36,17 @@ Vor dem Buchen werden Netto-, Steuer- und Bruttobetrag angezeigt. Beispielsweise
Der DATEV-Export übergibt den Bruttobetrag mit dem gewählten BU-Schlüssel (9/8 für Vorsteuer, 3/2 für Umsatzsteuer) und die Kostenstellennummer in **KOST1**. „Ohne Umsatzsteuer“ verwendet Schlüssel 40, um auch eine Kontenautomatik aufzuheben. Bestehende Buchungen ohne Steuerauswahl werden nicht nachträglich umgerechnet. Die Zuordnung folgt den [DATEV-Steuerschlüsseln](https://www.datev.de/dnlexom/v2/content/files/st2286167563_de.pdf) und der [DATEV-Dokumentation zur Aufhebung der Automatik](https://apps.datev.de/help-center/api/knowledge-help/v1/content/entities/st2112778507_de.pdf?docId=0907888&save=False).
## Zahlung mit Skonto ausgleichen
Zahlt ein Kunde weniger als den offenen Betrag oder ziehst du bei einem Lieferanten Skonto ab, öffne den Bankumsatz und wähle beim Beleg **Mit Skonto ausgleichen** (Prozent-Symbol). Der Vorschlag ist die Differenz zwischen offenem Betrag und Zahlung. Zahlung und Skonto gleichen den Beleg gemeinsam aus; der Bankumsatz enthält weiterhin nur den gezahlten Betrag.
- Der Skonto wird anteilig nach den Steuersätzen des Belegs verteilt und mindert die Umsatzsteuer (Ausgangsbelege) bzw. Vorsteuer (Eingangsbelege) im Zeitraum der Zahlung.
- Bei Schlussrechnungen bleiben die Abschläge mit ihren ursprünglich berechneten Beträgen abgezogen. Skonto auf eine Abschlagsrechnung wird bei deren Zahlung gebucht, Skonto auf die Schlussrechnung bezieht sich auf die Restforderung.
- Der DATEV-Export erzeugt je Steuersatz eine eigene Buchung vom Skontokonto gegen Debitor bzw. Kreditor mit der Rechnungsnummer als Belegfeld.
- Standard sind die SKR-03-Automatikkonten 3736/3731/3730 (erhaltene Skonti) und 8736/8731/8730 (gewährte Skonti). Abweichende Konten trägst du unter **Firmeneinstellungen → Rechnung & Kontakt → Skontokonten** ein.
- Skonto über 10 % des Belegs, auf Belege nach §13b UStG oder mit innergemeinschaftlichem Erwerb wird abgelehnt und muss manuell gebucht werden.
- Wird die Bankzuordnung gelöscht, entfällt auch der Skonto.
## Gute Praxis
- Regelmäßig abrufen, damit sich keine großen Rückstände bilden.

View File

@@ -1,3 +1,4 @@
import { sumSettledAllocations } from "~/utils/cashDiscount"
export const useSum = () => {
const unwrapCreatedDocuments = (createddocuments = []) => {
@@ -83,8 +84,8 @@ export const useSum = () => {
}
const getCreatedDocumentOpenAmount = (createddocument, createddocuments = []) => {
let amountPaid = 0
createddocument.statementallocations.forEach(allocation => amountPaid += allocation.amount)
// Gebuchter Skonto gleicht den Beleg zusammen mit der Zahlung aus.
const amountPaid = sumSettledAllocations(createddocument.statementallocations || [])
return Number((getCreatedDocumentSum(createddocument, createddocuments) - amountPaid).toFixed(2))
}

View File

@@ -1,6 +1,7 @@
<script setup lang="ts">
import dayjs from "dayjs"
import { getBankAllocationTaxBreakdown, isDirectBankAllocation } from "~/utils/bankAllocationTax"
import { hasCashDiscount } from "~/utils/cashDiscount"
import customParseFormat from "dayjs/plugin/customParseFormat"
import {
formatTaxEvaluationPeriodLabel,
@@ -54,7 +55,7 @@ const loadData = async () => {
createdDocuments.value = (docs || []).filter(isRelevantOutputDocument)
incomingInvoices.value = (incoming || []).filter(isRelevantInputInvoice)
manualBookings.value = (manual as any[]) || []
bankAllocations.value = (allocations || []).filter(isDirectBankAllocation)
bankAllocations.value = (allocations || []).filter((allocation) => isDirectBankAllocation(allocation) || hasCashDiscount(allocation))
} finally {
loading.value = false
}

View File

@@ -1,4 +1,5 @@
<script setup>
import { sumSettledAllocations } from "~/utils/cashDiscount"
import { parseDate } from "@internationalized/date"
const {$api, $dayjs} = useNuxtApp()
@@ -103,12 +104,12 @@ const loadSuggestionData = async () => {
.map(i => ({
...i,
docTotal: useSum().getCreatedDocumentSum(i, createddocuments.value),
statementTotal: Number(i.statementallocations.reduce((n, {amount}) => n + amount, 0)),
statementTotal: Number(sumSettledAllocations(i.statementallocations)),
openSum: useSum().getCreatedDocumentOpenAmount(i, createddocuments.value).toFixed(2)
}))
openIncomingInvoices.value = invoiceItems
.filter(i => i.state === "Gebucht" && !i.archived && i.statementallocations.reduce((n, {amount}) => n + amount, 0).toFixed(2) !== getInvoiceSum(i, false))
.filter(i => i.state === "Gebucht" && !i.archived && sumSettledAllocations(i.statementallocations).toFixed(2) !== getInvoiceSum(i, false))
} catch (err) {
console.error("Vorschlagsdaten konnten nicht geladen werden:", err)
} finally {
@@ -232,7 +233,7 @@ const calculateOpenSum = (statement) => {
const getInvoiceSum = (invoice, onlyOpenSum) => {
let sum = Number(useSum().getIncomingInvoiceSum(invoice))
if (onlyOpenSum) sum = sum + Number(invoice.statementallocations.reduce((n, {amount}) => n + amount, 0))
if (onlyOpenSum) sum = sum + Number(sumSettledAllocations(invoice.statementallocations))
if (invoice.expense) {
return (sum * -1).toFixed(2)

View File

@@ -1,6 +1,7 @@
<script setup>
import dayjs from "dayjs";
import { BANK_ALLOCATION_TAX_ITEMS, getBankAllocationAmounts } from "~/utils/bankAllocationTax"
import { sumSettledAllocations } from "~/utils/cashDiscount"
import {
DEPRECIATION_METHOD_ITEMS,
EXPENSE_BOOKING_MODE_ITEMS,
@@ -54,7 +55,7 @@ const rebuildDocumentLists = () => {
return {
...i,
docTotal: useSum().getCreatedDocumentSum(i, createddocuments.value),
statementTotal: Number(i.statementallocations.reduce((n, {amount}) => n + amount, 0)),
statementTotal: Number(sumSettledAllocations(i.statementallocations)),
openSum: useSum().getCreatedDocumentOpenAmount(i, createddocuments.value).toFixed(2)
}
})
@@ -62,7 +63,7 @@ const rebuildDocumentLists = () => {
allocatedDocuments.value = documents.filter(i => i.statementallocations.find(x => x.bankstatement === itemInfo.value.id))
allocatedIncomingInvoices.value = incominginvoices.value.filter(i => i.statementallocations.find(x => x.bankstatement === itemInfo.value.id))
openIncomingInvoices.value = incominginvoices.value.filter(i =>
!i.archived && i.statementallocations.reduce((n, {amount}) => n + amount, 0).toFixed(2) !== getInvoiceSum(i, false)
!i.archived && sumSettledAllocations(i.statementallocations).toFixed(2) !== getInvoiceSum(i, false)
)
}
@@ -136,7 +137,7 @@ const separateIBAN = (input) => {
const getInvoiceSum = (invoice, onlyOpenSum) => {
let sum = Number(useSum().getIncomingInvoiceSum(invoice))
if (onlyOpenSum) sum = sum + Number(invoice.statementallocations.reduce((n, {amount}) => n + amount, 0))
if (onlyOpenSum) sum = sum + Number(sumSettledAllocations(invoice.statementallocations))
if (invoice.expense) {
return (sum * -1).toFixed(2)
@@ -603,6 +604,76 @@ const getAllocationBookingModeLabel = (item) => {
return "Sofortaufwand"
}
// Skonto: Die Zahlung gleicht zusammen mit dem Skonto den offenen Betrag des Belegs aus.
const cashDiscountModalOpen = ref(false)
const cashDiscountTarget = ref(null)
const cashDiscountInput = ref(0)
const savingCashDiscount = ref(false)
const cashDiscountError = ref("")
const getCashDiscountCandidate = (kind, entity) => {
const open = Number(kind === "createddocument" ? entity.openSum : getInvoiceSum(entity, true))
const payment = Number(manualAllocationSum.value)
if (!open || !payment || Math.sign(open) !== Math.sign(payment)) return null
if (Math.abs(payment) >= Math.abs(open) - 0.004) return null
return { open, payment, discount: Number((open - payment).toFixed(2)) }
}
const canBookCashDiscount = (kind, entity) => Boolean(getCashDiscountCandidate(kind, entity))
const openCashDiscountModal = (kind, entity) => {
const candidate = getCashDiscountCandidate(kind, entity)
if (!candidate) return
cashDiscountTarget.value = {
kind,
id: entity.id,
label: kind === "createddocument"
? `${entity.documentNumber} (${entity.customer?.name || "Ohne Kunde"})`
: `${entity.reference || "Eingangsbeleg"} (${entity.vendor?.name || "Ohne Lieferant"})`,
...candidate
}
cashDiscountInput.value = Math.abs(candidate.discount)
cashDiscountError.value = ""
cashDiscountModalOpen.value = true
}
const cashDiscountPercent = computed(() => {
const target = cashDiscountTarget.value
if (!target || !target.open) return 0
return Math.abs(Number(cashDiscountInput.value || 0)) / Math.abs(target.open) * 100
})
const cashDiscountRemaining = computed(() => {
const target = cashDiscountTarget.value
if (!target) return 0
return Number((Math.abs(target.open) - Math.abs(target.payment) - Math.abs(Number(cashDiscountInput.value || 0))).toFixed(2))
})
const confirmCashDiscount = async () => {
const target = cashDiscountTarget.value
if (!target || savingCashDiscount.value) return
savingCashDiscount.value = true
try {
await saveAllocation({
[target.kind]: target.id,
bankstatement: itemInfo.value.id,
amount: target.payment,
cashDiscountAmount: Math.abs(Number(cashDiscountInput.value || 0)),
description: allocationDescription.value || "Zahlung mit Skonto"
})
cashDiscountModalOpen.value = false
cashDiscountTarget.value = null
} catch (err) {
cashDiscountError.value = err?.data?.error || "Zahlung und Skonto konnten nicht gebucht werden."
} finally {
savingCashDiscount.value = false
}
}
const getCashDiscountSplitLabel = (item) => (item.cashDiscountSplit || [])
.map(split => `${split.account} (${split.rate} %): ${displayCurrency(split.gross)}`)
.join(" · ")
setup()
</script>
@@ -726,6 +797,9 @@ setup()
{{ getAllocationTaxLabel(item) }} · Netto {{ displayCurrency(getBankAllocationAmounts(item).amountNet) }}
· Steuer {{ displayCurrency(getBankAllocationAmounts(item).amountTax) }}
</div>
<div v-if="Number(item.cashDiscountAmount)" class="text-xs text-amber-700 dark:text-amber-400 mt-1">
Skonto {{ displayCurrency(item.cashDiscountAmount) }}<span v-if="item.cashDiscountSplit?.length"> · {{ getCashDiscountSplitLabel(item) }}</span>
</div>
<div class="mt-1 flex flex-wrap gap-1" v-if="item.bookingMode && item.bookingMode !== 'expense'">
<UBadge size="xs" color="warning" variant="soft">{{ getAllocationBookingModeLabel(item) }}</UBadge>
<UBadge size="xs" color="neutral" variant="soft" v-if="item.depreciationGroup">{{ item.depreciationGroup }}</UBadge>
@@ -756,6 +830,9 @@ setup()
</div>
<div class="text-right">
<div class="font-mono text-sm font-semibold">{{ displayCurrency(item.amount) }}</div>
<div v-if="Number(item.cashDiscountAmount)" class="font-mono text-xs text-amber-600">
+ {{ displayCurrency(item.cashDiscountAmount) }} Skonto
</div>
<UButton
icon="i-heroicons-trash"
color="error"
@@ -1003,6 +1080,15 @@ setup()
>
Rechnung zuweisen
</UButton>
<UButton
v-if="canBookCashDiscount('createddocument', document)"
size="xs"
color="warning"
variant="soft"
@click="openCashDiscountModal('createddocument', document)"
>
Mit Skonto
</UButton>
<UButton
size="xs"
color="gray"
@@ -1037,6 +1123,15 @@ setup()
>
Beleg zuweisen
</UButton>
<UButton
v-if="canBookCashDiscount('incominginvoice', invoice)"
size="xs"
color="warning"
variant="soft"
@click="openCashDiscountModal('incominginvoice', invoice)"
>
Mit Skonto
</UButton>
<UButton
size="xs"
color="gray"
@@ -1098,6 +1193,16 @@ setup()
@click="saveAllocation({createddocument: document.id, bankstatement: itemInfo.id, amount: Number(Number(document.openSum) < manualAllocationSum ? document.openSum : manualAllocationSum), description: allocationDescription})"
/>
</UTooltip>
<UTooltip text="Mit Skonto ausgleichen">
<UButton
v-if="!itemInfo.statementallocations.find(i => i.createddocument === document.id) && canBookCashDiscount('createddocument', document)"
icon="i-heroicons-receipt-percent"
size="sm"
color="warning"
variant="soft"
@click="openCashDiscountModal('createddocument', document)"
/>
</UTooltip>
<UButton
icon="i-heroicons-eye"
color="gray"
@@ -1157,6 +1262,16 @@ setup()
@click="saveAllocation({incominginvoice: item.id, bankstatement: itemInfo.id, amount: Number(Math.abs(getInvoiceSum(item,true)) > Math.abs(manualAllocationSum) ? manualAllocationSum : getInvoiceSum(item,true)), description: allocationDescription})"
/>
</UTooltip>
<UTooltip text="Mit Skonto ausgleichen">
<UButton
v-if="!itemInfo.statementallocations.find(i => i.incominginvoice === item.id) && canBookCashDiscount('incominginvoice', item)"
icon="i-heroicons-receipt-percent"
size="sm"
color="warning"
variant="soft"
@click="openCashDiscountModal('incominginvoice', item)"
/>
</UTooltip>
<UButton
icon="i-heroicons-eye"
color="gray"
@@ -1180,6 +1295,70 @@ setup()
</div>
</div>
</UDashboardPanelContent>
<UModal v-model:open="cashDiscountModalOpen" :ui="{ width: 'sm:max-w-lg' }">
<template #content>
<UCard v-if="cashDiscountTarget">
<template #header>
<div class="text-lg font-semibold">Mit Skonto ausgleichen</div>
<div class="text-sm text-gray-500 mt-0.5">{{ cashDiscountTarget.label }}</div>
</template>
<div class="space-y-4">
<div class="grid grid-cols-2 gap-2 text-sm">
<div class="text-gray-500">Offener Betrag</div>
<div class="text-right font-mono">{{ displayCurrency(Math.abs(cashDiscountTarget.open)) }}</div>
<div class="text-gray-500">Zahlung aus diesem Umsatz</div>
<div class="text-right font-mono">{{ displayCurrency(Math.abs(cashDiscountTarget.payment)) }}</div>
</div>
<UFormField label="Skonto (brutto)" :help="`${cashDiscountPercent.toFixed(2).replace('.', ',')} % des offenen Betrags`">
<UInput v-model="cashDiscountInput" type="number" step="0.01" min="0">
<template #trailing><span class="text-gray-500 text-xs">EUR</span></template>
</UInput>
</UFormField>
<UAlert
v-if="cashDiscountPercent > 5"
color="warning"
variant="subtle"
icon="i-heroicons-exclamation-triangle"
title="Ungewöhnlich hoher Skonto"
description="Bitte prüfen, ob es sich wirklich um Skonto handelt und nicht um eine Teilzahlung."
/>
<UAlert
v-if="cashDiscountError"
color="error"
variant="subtle"
icon="i-heroicons-x-circle"
:description="cashDiscountError"
/>
<div v-if="Math.abs(cashDiscountRemaining) >= 0.01" class="text-sm text-amber-600">
Danach bleiben {{ displayCurrency(cashDiscountRemaining) }} offen.
</div>
<p class="text-xs text-gray-500">
Der Skonto wird anteilig nach Steuersätzen auf die Skontokonten verteilt
({{ cashDiscountTarget.kind === 'createddocument' ? 'gewährte' : 'erhaltene' }} Skonti) und korrigiert die
{{ cashDiscountTarget.kind === 'createddocument' ? 'Umsatzsteuer' : 'Vorsteuer' }} im Monat der Zahlung.
</p>
</div>
<template #footer>
<div class="flex justify-end gap-2">
<UButton color="neutral" variant="ghost" @click="cashDiscountModalOpen = false">Abbrechen</UButton>
<UButton
color="primary"
:loading="savingCashDiscount"
:disabled="!(Number(cashDiscountInput) > 0) || cashDiscountRemaining < -0.009"
@click="confirmCashDiscount"
>
Zahlung und Skonto buchen
</UButton>
</div>
</template>
</UCard>
</template>
</UModal>
</template>
<style scoped>

View File

@@ -164,6 +164,7 @@
</template>
<script setup>
import { sumSettledAllocations } from "~/utils/cashDiscount"
import CreateDocumentModal from "~/components/createDocumentModal.vue"
import CreateDocumentFromTemplateModal from "~/components/createDocumentFromTemplateModal.vue"
import dayjs from "dayjs";
@@ -337,10 +338,7 @@ const documentAmounts = computed(() => {
items.value.forEach((item) => {
const amount = sum.getCreatedDocumentSum(item, items.value)
const amountPaid = (item.statementallocations || []).reduce(
(total, allocation) => total + Number(allocation.amount || 0),
0
)
const amountPaid = sumSettledAllocations(item.statementallocations || [])
result.set(item.id, {
amount,

View File

@@ -427,6 +427,7 @@ const togglePortalRelease = async () => {
<div>
<p class="text-xs text-gray-500">Betrag</p>
<p class="font-medium">{{ formatCurrency(allocation.amount) }}</p>
<p v-if="Number(allocation.cashDiscountAmount)" class="text-xs text-amber-600">+ {{ formatCurrency(allocation.cashDiscountAmount) }} Skonto</p>
</div>
<div>
<p class="text-xs text-gray-500">Text</p>

View File

@@ -602,6 +602,7 @@ const hasBlockingIncomingInvoiceErrors = computed(() => blockingIncomingInvoiceE
<div>
<p class="text-xs text-gray-500">Betrag</p>
<p class="font-medium">{{ formatMoney(allocation.amount) }}</p>
<p v-if="Number(allocation.cashDiscountAmount)" class="text-xs text-amber-600">+ {{ formatMoney(allocation.cashDiscountAmount) }} Skonto</p>
</div>
<div>
<p class="text-xs text-gray-500">Text</p>

View File

@@ -1,4 +1,5 @@
<script setup>
import { sumSettledAllocations } from "~/utils/cashDiscount"
import dayjs from "dayjs"
import {useSum} from "~/composables/useSum.js";
@@ -199,8 +200,8 @@ const getInvoiceSum = (invoice) => {
}
const getPaidAmount = (item) => {
let amountPaid = 0
item.statementallocations.forEach(allocation => amountPaid += allocation.amount)
// Gebuchter Skonto zählt zum ausgeglichenen Betrag.
const amountPaid = sumSettledAllocations(item.statementallocations || [])
return Number(Math.abs(amountPaid).toFixed(2))
}

View File

@@ -1,4 +1,5 @@
<script setup>
import { resolveCashDiscountAccounts } from "~/utils/cashDiscount"
import {
TAX_EVALUATION_PERIOD_OPTIONS,
normalizeTaxEvaluationPeriod
@@ -273,6 +274,20 @@ watch(() => auth.activeTenantData?.portalConfig, (config) => {
})
const accountChart = ref(auth.activeTenantData.accountChart || "skr03")
const taxEvaluationPeriod = ref(normalizeTaxEvaluationPeriod(auth.activeTenantData.taxEvaluationPeriod))
const buildCashDiscountForm = (config) => {
const accounts = resolveCashDiscountAccounts(config)
return { received: { ...accounts.received }, granted: { ...accounts.granted } }
}
const cashDiscountAccounts = ref(buildCashDiscountForm(auth.activeTenantData?.cashDiscountAccounts))
const cashDiscountRateLabels = { "19": "19 %", "7": "7 %", "0": "ohne USt" }
const saveCashDiscountAccounts = async () => {
try {
await updateTenant({ cashDiscountAccounts: cashDiscountAccounts.value })
toast.add({ title: "Skontokonten gespeichert", color: "success" })
} catch (err) {
toast.add({ title: "Skontokonten konnten nicht gespeichert werden", description: err?.data?.error, color: "error" })
}
}
const accountChartOptions = [
{ label: "SKR 03", value: "skr03" },
{ label: "Verein", value: "verein" }
@@ -293,6 +308,7 @@ const updateTenant = async (newData) => {
features.value = { ...defaultFeatures, ...(res?.features || {}) }
businessInfo.value = normalizeBusinessInfo(res?.businessInfo)
taxEvaluationPeriod.value = normalizeTaxEvaluationPeriod(res?.taxEvaluationPeriod)
cashDiscountAccounts.value = buildCashDiscountForm(res?.cashDiscountAccounts)
}
}
const saveFeatures = async () => {
@@ -776,6 +792,32 @@ onMounted(() => {
>
Zeitraum speichern
</UButton>
<div class="mt-6">
<div class="text-sm font-medium">Skontokonten</div>
<p class="text-xs text-muted mt-1">
DATEV-Automatikkonten für Skonto bei Zahlungen. Standard ist SKR 03.
</p>
<div class="mt-3 grid grid-cols-1 gap-4 md:grid-cols-2">
<div v-for="side in ['received', 'granted']" :key="side" class="space-y-2">
<div class="text-xs font-semibold uppercase text-muted">
{{ side === 'received' ? 'Erhaltene Skonti (Eingangsbelege)' : 'Gewährte Skonti (Ausgangsbelege)' }}
</div>
<UFormField
v-for="rate in ['19', '7', '0']"
:key="`${side}-${rate}`"
:label="cashDiscountRateLabels[rate]"
>
<UInput v-model="cashDiscountAccounts[side][rate]" inputmode="numeric"/>
</UFormField>
</div>
</div>
</div>
<UButton
class="mt-3"
@click="saveCashDiscountAccounts"
>
Skontokonten speichern
</UButton>
</UForm>
</UCard>

View File

@@ -1,3 +1,5 @@
import { getCashDiscountTaxBreakdown } from "./cashDiscount"
export const BANK_ALLOCATION_TAX_ITEMS = [
{ value: "9", label: "19 % Vorsteuer", rate: 19, kind: "input" },
{ value: "8", label: "7 % Vorsteuer", rate: 7, kind: "input" },
@@ -25,19 +27,19 @@ export function getBankAllocationAmounts(allocation: any) {
}
export function getBankAllocationTaxBreakdown(allocation: any) {
const output = { net19: 0, tax19: 0, net7: 0, tax7: 0, net0: 0 }
const input = { ...output }
// Skonto auf zugeordnete Belege korrigiert die Steuer im Zeitraum der Zahlung.
const { output, input } = getCashDiscountTaxBreakdown(allocation)
if (!isDirectBankAllocation(allocation)) return { output, input }
const { amountNet, amountTax, rate, kind } = getBankAllocationAmounts(allocation)
if (kind === "none") return { output, input }
const target = kind === "input" ? input : output
const sign = kind === "input" ? -1 : 1
if (rate === 19) {
target.net19 = amountNet * sign
target.tax19 = amountTax * sign
target.net19 += amountNet * sign
target.tax19 += amountTax * sign
} else if (rate === 7) {
target.net7 = amountNet * sign
target.tax7 = amountTax * sign
target.net7 += amountNet * sign
target.tax7 += amountTax * sign
}
return { output, input }
}

View File

@@ -0,0 +1,61 @@
// Skonto wird an der Bankzuordnung gespeichert (cashDiscountAmount, gleiches Vorzeichen wie amount).
// Zahlbetrag und Skonto gleichen den Beleg gemeinsam aus; der Bankumsatz selbst enthält nur amount.
export const CASH_DISCOUNT_RATES = ["19", "7", "0"] as const
export const DEFAULT_CASH_DISCOUNT_ACCOUNTS = {
received: { "19": "3736", "7": "3731", "0": "3730" },
granted: { "19": "8736", "7": "8731", "0": "8730" },
}
export const getAllocationCashDiscount = (allocation: any) => Number(allocation?.cashDiscountAmount || 0)
export const getAllocationSettledAmount = (allocation: any) =>
Number(allocation?.amount || 0) + getAllocationCashDiscount(allocation)
export const sumSettledAllocations = (allocations: any[] = []) =>
(allocations || []).reduce((sum, allocation) => sum + getAllocationSettledAmount(allocation), 0)
export const hasCashDiscount = (allocation: any) =>
!allocation?.archived && Array.isArray(allocation?.cashDiscountSplit) && allocation.cashDiscountSplit.length > 0
// Steuerkorrektur nach § 17 UStG im Zeitraum der Zahlung.
// Ausgangsbelege mindern die Umsatzsteuer, Eingangsbelege die Vorsteuer (Vorzeichen aus der Zahlung).
export function getCashDiscountTaxBreakdown(allocation: any) {
const output = { net19: 0, tax19: 0, net7: 0, tax7: 0, net0: 0 }
const input = { ...output }
if (!hasCashDiscount(allocation)) return { output, input }
const isOutgoing = Boolean(allocation.createddocument || allocation.cd_id)
const target = isOutgoing ? output : input
const sign = isOutgoing ? -1 : 1
for (const item of allocation.cashDiscountSplit) {
const net = Number(item?.net || 0) * sign
const tax = Number(item?.tax || 0) * sign
if (Number(item?.rate) === 19) {
target.net19 += net
target.tax19 += tax
} else if (Number(item?.rate) === 7) {
target.net7 += net
target.tax7 += tax
} else {
target.net0 += net
}
}
return { output, input }
}
export const resolveCashDiscountAccounts = (config: any) => {
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 (value !== undefined && value !== null && String(value).trim()) result[side][rate] = String(value).trim()
}
}
return result
}