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 + + + + + + +
+ + + +