From af4a283eb5119c03f48733e0dd28423b8fe545f7 Mon Sep 17 00:00:00 2001 From: florianfederspiel Date: Wed, 4 Nov 2026 08:51:27 +0100 Subject: [PATCH] feat(accounting): Skonto bei Zahlungen auf Ausgangs- und Eingangsbelege buchen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/db/migrations/0084_cash_discounts.sql | 5 + backend/db/migrations/meta/_journal.json | 7 + backend/db/schema/statementallocations.ts | 5 + backend/db/schema/tenants.ts | 2 + backend/src/mcp/statementAllocations.ts | 8 + backend/src/mcp/tools/accounting.ts | 9 +- backend/src/mcp/tools/outgoingStatus.ts | 22 +- backend/src/modules/cash-discount.ts | 274 ++++++++++++++++++ backend/src/routes/banking.ts | 15 +- backend/src/routes/resources/main.ts | 1 + backend/src/routes/tenant.ts | 9 + backend/src/utils/export/datev.ts | 49 ++++ backend/src/utils/liquidityForecast.ts | 4 +- backend/tests/cashDiscount.test.ts | 116 ++++++++ docs-site/content/bedienung/bankportal.md | 11 + docs/bedienung/bankportal.md | 11 + frontend/composables/useSum.js | 5 +- frontend/pages/accounting/tax.vue | 3 +- frontend/pages/banking/index.vue | 7 +- .../banking/statements/[mode]/[[id]].vue | 185 +++++++++++- frontend/pages/createDocument/index.vue | 6 +- frontend/pages/createDocument/show/[id].vue | 1 + .../pages/incomingInvoices/[mode]/[id].vue | 1 + frontend/pages/incomingInvoices/index.vue | 5 +- frontend/pages/settings/tenant.vue | 42 +++ frontend/utils/bankAllocationTax.ts | 14 +- frontend/utils/cashDiscount.ts | 61 ++++ 27 files changed, 837 insertions(+), 41 deletions(-) create mode 100644 backend/db/migrations/0084_cash_discounts.sql create mode 100644 backend/src/modules/cash-discount.ts create mode 100644 backend/tests/cashDiscount.test.ts create mode 100644 frontend/utils/cashDiscount.ts diff --git a/backend/db/migrations/0084_cash_discounts.sql b/backend/db/migrations/0084_cash_discounts.sql new file mode 100644 index 00000000..d82923ae --- /dev/null +++ b/backend/db/migrations/0084_cash_discounts.sql @@ -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; diff --git a/backend/db/migrations/meta/_journal.json b/backend/db/migrations/meta/_journal.json index 81c77fba..586d0dc6 100644 --- a/backend/db/migrations/meta/_journal.json +++ b/backend/db/migrations/meta/_journal.json @@ -568,6 +568,13 @@ "when": 1792022400001, "tag": "0083_collaboration_notifications", "breakpoints": true + }, + { + "idx": 81, + "version": "7", + "when": 1792108800001, + "tag": "0084_cash_discounts", + "breakpoints": true } ] } diff --git a/backend/db/schema/statementallocations.ts b/backend/db/schema/statementallocations.ts index 3d6032d6..18ee6536 100644 --- a/backend/db/schema/statementallocations.ts +++ b/backend/db/schema/statementallocations.ts @@ -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 ), diff --git a/backend/db/schema/tenants.ts b/backend/db/schema/tenants.ts index 8e7a8bb2..efa05d1b 100644 --- a/backend/db/schema/tenants.ts +++ b/backend/db/schema/tenants.ts @@ -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([]), diff --git a/backend/src/mcp/statementAllocations.ts b/backend/src/mcp/statementAllocations.ts index f5e26974..5e6d3b19 100644 --- a/backend/src/mcp/statementAllocations.ts +++ b/backend/src/mcp/statementAllocations.ts @@ -55,6 +55,14 @@ export const prepareStatementAllocationInput = (args: Record) = 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") diff --git a/backend/src/mcp/tools/accounting.ts b/backend/src/mcp/tools/accounting.ts index 779d7260..2a65a89c 100644 --- a/backend/src/mcp/tools/accounting.ts +++ b/backend/src/mcp/tools/accounting.ts @@ -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) diff --git a/backend/src/mcp/tools/outgoingStatus.ts b/backend/src/mcp/tools/outgoingStatus.ts index 8c3a265e..4cf7a0e8 100644 --- a/backend/src/mcp/tools/outgoingStatus.ts +++ b/backend/src/mcp/tools/outgoingStatus.ts @@ -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 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 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) { diff --git a/backend/src/modules/cash-discount.ts b/backend/src/modules/cash-discount.ts new file mode 100644 index 00000000..0f0b2e64 --- /dev/null +++ b/backend/src/modules/cash-discount.ts @@ -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> +export type CashDiscountSplitItem = { rate: number, account: string, gross: number, net: number, tax: number } + +export const CASH_DISCOUNT_RATES: CashDiscountRate[] = ["19", "7", "0"] + +// SKR03: Erhaltene Skonti (Eingangsbelege) und gewährte Skonti (Ausgangsbelege), jeweils Automatikkonten. +export const DEFAULT_CASH_DISCOUNT_ACCOUNTS: CashDiscountAccountConfig = { + received: { "19": "3736", "7": "3731", "0": "3730" }, + granted: { "19": "8736", "7": "8731", "0": "8730" }, +} + +// Weicht der Skonto um mehr als diesen Anteil vom Beleg ab, liegt sehr wahrscheinlich ein Eingabefehler vor. +export const MAX_CASH_DISCOUNT_SHARE = 0.1 + +const invalidInput = (message: string) => Object.assign(new Error(message), { statusCode: 400 }) +const money = (value: number) => Number(value.toFixed(2)) +const sameSign = (value: number, sign: number) => sign === 0 || Math.sign(value) === 0 || Math.sign(value) === sign + +export function resolveCashDiscountAccounts(config: any): CashDiscountAccountConfig { + const result = { + received: { ...DEFAULT_CASH_DISCOUNT_ACCOUNTS.received }, + granted: { ...DEFAULT_CASH_DISCOUNT_ACCOUNTS.granted }, + } + for (const side of ["received", "granted"] as const) { + for (const rate of CASH_DISCOUNT_RATES) { + const value = config?.[side]?.[rate] + if (typeof value === "string" && value.trim()) result[side][rate] = value.trim() + else if (typeof value === "number" && Number.isFinite(value)) result[side][rate] = String(value) + } + } + return result +} + +// Validiert die Einstellung aus den Firmeneinstellungen. Leere Felder fallen auf SKR03 zurück. +export function normalizeCashDiscountAccountsInput(input: any): Partial { + if (!input || typeof input !== "object" || Array.isArray(input)) { + throw invalidInput("Die Skontokonten müssen als Objekt übergeben werden.") + } + const result: Partial = {} + for (const side of ["received", "granted"] as const) { + const entries: Partial> = {} + for (const rate of CASH_DISCOUNT_RATES) { + const raw = input?.[side]?.[rate] + if (raw === undefined || raw === null || String(raw).trim() === "") continue + const value = String(raw).trim() + if (!/^\d{4,9}$/.test(value)) { + throw invalidInput(`Das Skontokonto ${value} muss aus 4 bis 9 Ziffern bestehen.`) + } + entries[rate] = value + } + if (Object.keys(entries).length) result[side] = entries as Record + } + return result +} + +export function settledAllocationAmount(allocation: { amount?: any, cashDiscountAmount?: any }) { + return Number(allocation?.amount || 0) + Number(allocation?.cashDiscountAmount || 0) +} + +type GrossByRate = Record +const emptyGross = (): GrossByRate => ({ "19": 0, "7": 0, "0": 0 }) + +// Bruttobeträge eines Eingangsbelegs je Steuersatz. Vorzeichen: Ausgaben negativ (wie die Bankzuordnung). +export function getIncomingInvoiceGrossByRate(invoice: any): GrossByRate { + const gross = emptyGross() + const accounts = Array.isArray(invoice?.accounts) ? invoice.accounts : [] + for (const account of accounts) { + const taxType = account?.taxType === null || account?.taxType === undefined ? "null" : String(account.taxType) + const amount = Number(account?.amountNet || 0) + Number(account?.amountTax || 0) + if (!Number.isFinite(amount)) throw invalidInput("Der Eingangsbeleg enthält ungültige Beträge.") + if (amount === 0) continue + if (taxType === "19" || taxType === "7") gross[taxType] += amount + else if (taxType === "null" || taxType === "" || taxType === "0") gross["0"] += amount + else { + throw invalidInput("Skonto auf Belege mit innergemeinschaftlichem Erwerb oder §13b UStG bitte manuell buchen.") + } + } + + const override = invoice?.taxAmountOverride + if (override !== null && override !== undefined && override !== "") { + const calculatedTax = accounts.reduce((sum: number, account: any) => sum + Number(account?.amountTax || 0), 0) + const correction = Number(override) - calculatedTax + if (Number.isFinite(correction) && Math.abs(correction) >= 0.005) { + if (gross["19"] !== 0) gross["19"] += correction + else if (gross["7"] !== 0) gross["7"] += correction + } + } + + const sign = invoice?.expense === false ? 1 : -1 + return { + "19": money(gross["19"] * sign), + "7": money(gross["7"] * sign), + "0": money(gross["0"] * sign), + } +} + +const rowsGrossByRate = (document: any, taxFree: boolean): GrossByRate => { + const net = emptyGross() + for (const row of Array.isArray(document?.rows) ? document.rows : []) { + if (!row || ["pagebreak", "title", "text"].includes(row.mode)) continue + const rowNet = Number((Number(row.quantity || 0) * Number(row.price || 0) * (1 - Number(row.discountPercent || 0) / 100)).toFixed(3)) + if (!Number.isFinite(rowNet)) throw invalidInput(`Beleg ${document.id}: ungültiger Positionsbetrag`) + const taxPercent = Number(row.taxPercent) + if (!taxFree && taxPercent === 19) net["19"] += rowNet + else if (!taxFree && taxPercent === 7) net["7"] += rowNet + else net["0"] += rowNet + } + return { + "19": money(money(net["19"]) + money(net["19"] * 0.19)), + "7": money(money(net["7"]) + money(net["7"] * 0.07)), + "0": money(net["0"]), + } +} + +const createdDocumentAmounts = (document: any, advanceInvoices: any[]) => { + if (document?.taxType === "13b UStG") { + throw invalidInput("Skonto auf Ausgangsbelege nach §13b UStG bitte manuell buchen.") + } + const taxFree = ["19 UStG", "12.3 UStG"].includes(String(document?.taxType || "")) + const gross = rowsGrossByRate(document, taxFree) + const advances = emptyGross() + for (const id of Array.isArray(document?.usedAdvanceInvoices) ? document.usedAdvanceInvoices : []) { + const row = advanceInvoices.find((invoice) => invoice.id === id)?.rows?.find((row: any) => row?.advanceInvoiceData) + if (!row) continue + const rate = String(Number(row.taxPercent)) as CashDiscountRate + const amount = Number(row.price) * ((100 + Number(row.taxPercent || 0)) / 100) + advances[!taxFree && (rate === "19" || rate === "7") ? rate : "0"] += amount + } + return { gross, advances } +} + +// Bruttobeträge eines Ausgangsbelegs je Steuersatz. Bei Schlussrechnungen werden die Abschläge mit ihren +// ursprünglich berechneten Beträgen abgezogen, damit sich der Skonto auf die Restforderung verteilt. +export function getCreatedDocumentGrossByRate(document: any, advanceInvoices: any[] = []): GrossByRate { + const { gross, advances } = createdDocumentAmounts(document, advanceInvoices) + const total = gross["19"] + gross["7"] + gross["0"] + const sign = Math.sign(total) || 1 + const remaining = emptyGross() + for (const rate of CASH_DISCOUNT_RATES) { + const value = money(gross[rate] - advances[rate]) + remaining[rate] = sameSign(value, sign) ? value : 0 + } + return remaining["19"] || remaining["7"] || remaining["0"] ? remaining : gross +} + +// Offene Gesamtforderung des Ausgangsbelegs: Bruttobetrag abzüglich der berechneten Abschläge. +export function getCreatedDocumentGrossTotal(document: any, advanceInvoices: any[] = []) { + const { gross, advances } = createdDocumentAmounts(document, advanceInvoices) + return money(gross["19"] + gross["7"] + gross["0"] - advances["19"] - advances["7"] - advances["0"]) +} + +// Verteilt den Skonto anteilig auf die Steuersätze; Rundungsdifferenzen gehen an den größten Anteil. +export function splitCashDiscount(amount: number, grossByRate: GrossByRate, accounts: Record): CashDiscountSplitItem[] { + const discount = money(amount) + if (!discount) return [] + const weights = CASH_DISCOUNT_RATES + .map((rate) => ({ rate, weight: Math.abs(Number(grossByRate[rate] || 0)) })) + .filter((item) => item.weight > 0) + if (!weights.length) weights.push({ rate: "0", weight: 1 }) + + const totalWeight = weights.reduce((sum, item) => sum + item.weight, 0) + const items = weights.map((item) => ({ rate: item.rate, gross: money(discount * item.weight / totalWeight) })) + const difference = money(discount - items.reduce((sum, item) => sum + item.gross, 0)) + if (difference) { + const largest = items.reduce((max, item) => Math.abs(item.gross) > Math.abs(max.gross) ? item : max, items[0]) + largest.gross = money(largest.gross + difference) + } + + return items.filter((item) => item.gross !== 0).map((item) => { + const rate = Number(item.rate) + const net = money(item.gross / (1 + rate / 100)) + return { rate, account: accounts[item.rate], gross: item.gross, net, tax: money(item.gross - net) } + }) +} + +// Bereitet Skonto für eine neue Bankzuordnung vor: Vorzeichen, Plausibilität und Steueraufteilung. +export async function prepareCashDiscountAllocation(db: any, tenantId: number, payload: Record) { + const requested = Number(payload.cashDiscountAmount || 0) + if (!Number.isFinite(requested)) throw invalidInput("Der Skontobetrag muss eine gültige Zahl sein.") + if (Math.abs(requested) < 0.005) { + return { ...payload, cashDiscountAmount: null, cashDiscountSplit: null } + } + + const amount = Number(payload.amount || 0) + if (!payload.bankstatement) throw invalidInput("Skonto kann nur zusammen mit einer Bankzahlung gebucht werden.") + const hasDocument = Boolean(payload.createddocument) + const hasInvoice = Boolean(payload.incominginvoice) + if (hasDocument === hasInvoice) { + throw invalidInput("Skonto ist nur bei der Zuordnung genau eines Ausgangs- oder Eingangsbelegs möglich.") + } + if (!amount) throw invalidInput("Für Skonto muss ein Zahlbetrag zugeordnet werden.") + + const discount = money(Math.sign(amount) * Math.abs(requested)) + const [tenant] = await db.select({ cashDiscountAccounts: tenants.cashDiscountAccounts }) + .from(tenants).where(eq(tenants.id, tenantId)).limit(1) + const accounts = resolveCashDiscountAccounts(tenant?.cashDiscountAccounts) + + let grossByRate: GrossByRate + let total: number + let existing: any[] + + if (hasDocument) { + const documentId = Number(payload.createddocument) + const [document] = await db.select().from(createddocuments).where(and( + eq(createddocuments.id, documentId), + eq(createddocuments.tenant, tenantId), + )).limit(1) + if (!document || document.archived) throw invalidInput("Der Ausgangsbeleg wurde nicht gefunden.") + if (document.state !== "Gebucht") throw invalidInput("Skonto ist nur auf gebuchte Ausgangsbelege möglich.") + const advanceIds = (Array.isArray(document.usedAdvanceInvoices) ? document.usedAdvanceInvoices : []) + .filter((id: any) => Number.isSafeInteger(id) && id > 0) + const advanceInvoices = advanceIds.length + ? await db.select().from(createddocuments).where(and( + eq(createddocuments.tenant, tenantId), + inArray(createddocuments.id, advanceIds), + )) + : [] + grossByRate = getCreatedDocumentGrossByRate(document, advanceInvoices) + total = getCreatedDocumentGrossTotal(document, advanceInvoices) + existing = await db.select({ amount: statementallocations.amount, cashDiscountAmount: statementallocations.cashDiscountAmount }) + .from(statementallocations).where(and( + eq(statementallocations.createddocument, documentId), + eq(statementallocations.tenant, tenantId), + eq(statementallocations.archived, false), + )) + } else { + const invoiceId = Number(payload.incominginvoice) + const [invoice] = await db.select().from(incominginvoices).where(and( + eq(incominginvoices.id, invoiceId), + eq(incominginvoices.tenant, tenantId), + )).limit(1) + if (!invoice || invoice.archived) throw invalidInput("Der Eingangsbeleg wurde nicht gefunden.") + if (invoice.state !== "Gebucht") throw invalidInput("Skonto ist nur auf gebuchte Eingangsbelege möglich.") + grossByRate = getIncomingInvoiceGrossByRate(invoice) + total = money(grossByRate["19"] + grossByRate["7"] + grossByRate["0"]) + existing = await db.select({ amount: statementallocations.amount, cashDiscountAmount: statementallocations.cashDiscountAmount }) + .from(statementallocations).where(and( + eq(statementallocations.incominginvoice, invoiceId), + eq(statementallocations.tenant, tenantId), + eq(statementallocations.archived, false), + )) + } + + validateCashDiscount({ amount, discount, total, settled: existing.reduce((sum, item) => sum + settledAllocationAmount(item), 0) }) + + const side: CashDiscountSide = hasDocument ? "granted" : "received" + return { + ...payload, + cashDiscountAmount: discount, + cashDiscountSplit: splitCashDiscount(discount, grossByRate, accounts[side]), + } +} + +export function validateCashDiscount({ amount, discount, total, settled }: { amount: number, discount: number, total: number, settled: number }) { + if (!total) throw invalidInput("Der Beleg hat keinen Betrag, auf den Skonto gebucht werden kann.") + if (Math.sign(amount) !== Math.sign(total)) { + throw invalidInput("Skonto ist nur bei Zahlungen in Richtung des Belegs möglich.") + } + const open = money(total - settled) + if (Math.abs(amount + discount) > Math.abs(open) + 0.02 || Math.sign(open) !== Math.sign(total)) { + throw invalidInput(`Zahlung und Skonto übersteigen den offenen Betrag von ${Math.abs(open).toFixed(2).replace(".", ",")} €.`) + } + if (Math.abs(discount) > Math.abs(total) * MAX_CASH_DISCOUNT_SHARE + 0.005) { + throw invalidInput(`Der Skonto ist höher als ${MAX_CASH_DISCOUNT_SHARE * 100} % des Belegbetrags. Bitte Betrag prüfen.`) + } +} diff --git a/backend/src/routes/banking.ts b/backend/src/routes/banking.ts index 8de6a5bb..01d19c06 100644 --- a/backend/src/routes/banking.ts +++ b/backend/src/routes/banking.ts @@ -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, diff --git a/backend/src/routes/resources/main.ts b/backend/src/routes/resources/main.ts index bdf34cd8..20ff4214 100644 --- a/backend/src/routes/resources/main.ts +++ b/backend/src/routes/resources/main.ts @@ -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( diff --git a/backend/src/routes/tenant.ts b/backend/src/routes/tenant.ts index 8681272a..f2c363dc 100644 --- a/backend/src/routes/tenant.ts +++ b/backend/src/routes/tenant.ts @@ -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) diff --git a/backend/src/utils/export/datev.ts b/backend/src/utils/export/datev.ts index 29f09cc9..eda65a11 100644 --- a/backend/src/utils/export/datev.ts +++ b/backend/src/utils/export/datev.ts @@ -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) { diff --git a/backend/src/utils/liquidityForecast.ts b/backend/src/utils/liquidityForecast.ts index 5422c1f6..1818d5a1 100644 --- a/backend/src/utils/liquidityForecast.ts +++ b/backend/src/utils/liquidityForecast.ts @@ -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)) ); } }); diff --git a/backend/tests/cashDiscount.test.ts b/backend/tests/cashDiscount.test.ts new file mode 100644 index 00000000..e17ddfa7 --- /dev/null +++ b/backend/tests/cashDiscount.test.ts @@ -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/) +}) diff --git a/docs-site/content/bedienung/bankportal.md b/docs-site/content/bedienung/bankportal.md index 13f3a101..1e752e20 100644 --- a/docs-site/content/bedienung/bankportal.md +++ b/docs-site/content/bedienung/bankportal.md @@ -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. diff --git a/docs/bedienung/bankportal.md b/docs/bedienung/bankportal.md index 13f3a101..1e752e20 100644 --- a/docs/bedienung/bankportal.md +++ b/docs/bedienung/bankportal.md @@ -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. diff --git a/frontend/composables/useSum.js b/frontend/composables/useSum.js index abdbd446..04a5a230 100644 --- a/frontend/composables/useSum.js +++ b/frontend/composables/useSum.js @@ -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)) } diff --git a/frontend/pages/accounting/tax.vue b/frontend/pages/accounting/tax.vue index 9a113cb4..a2f936f5 100644 --- a/frontend/pages/accounting/tax.vue +++ b/frontend/pages/accounting/tax.vue @@ -1,6 +1,7 @@ @@ -726,6 +797,9 @@ setup() {{ getAllocationTaxLabel(item) }} · Netto {{ displayCurrency(getBankAllocationAmounts(item).amountNet) }} · Steuer {{ displayCurrency(getBankAllocationAmounts(item).amountTax) }} +
+ Skonto {{ displayCurrency(item.cashDiscountAmount) }} · {{ getCashDiscountSplitLabel(item) }} +
{{ getAllocationBookingModeLabel(item) }} {{ item.depreciationGroup }} @@ -756,6 +830,9 @@ setup()
{{ displayCurrency(item.amount) }}
+
+ + {{ displayCurrency(item.cashDiscountAmount) }} Skonto +
Rechnung zuweisen + + Mit Skonto + Beleg zuweisen + + Mit Skonto + + + + + + +
+ + + +