feat(accounting): Skonto bei Zahlungen auf Ausgangs- und Eingangsbelege buchen
Skonto wird an der Bankzuordnung gespeichert und gleicht den Beleg zusammen mit der Zahlung aus. Der Betrag wird anteilig nach Steuersätzen auf die Skontokonten verteilt (Standard SKR03, je Mandant einstellbar), im DATEV-Export als eigene Buchung je Steuersatz ausgegeben und in der USt-Auswertung im Zeitraum der Zahlung berücksichtigt. Schlussrechnungen verteilen den Skonto auf die Restforderung nach Abzug der Abschläge. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
5
backend/db/migrations/0084_cash_discounts.sql
Normal file
5
backend/db/migrations/0084_cash_discounts.sql
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
ALTER TABLE "tenants" ADD COLUMN "cashDiscountAccounts" jsonb DEFAULT '{}'::jsonb NOT NULL;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "statementallocations" ADD COLUMN "cash_discount_amount" double precision;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "statementallocations" ADD COLUMN "cash_discount_split" jsonb;
|
||||||
@@ -568,6 +568,13 @@
|
|||||||
"when": 1792022400001,
|
"when": 1792022400001,
|
||||||
"tag": "0083_collaboration_notifications",
|
"tag": "0083_collaboration_notifications",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 81,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1792108800001,
|
||||||
|
"tag": "0084_cash_discounts",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
timestamp,
|
timestamp,
|
||||||
boolean,
|
boolean,
|
||||||
doublePrecision,
|
doublePrecision,
|
||||||
|
jsonb,
|
||||||
} from "drizzle-orm/pg-core"
|
} from "drizzle-orm/pg-core"
|
||||||
|
|
||||||
import { tenants } from "./tenants"
|
import { tenants } from "./tenants"
|
||||||
@@ -30,6 +31,10 @@ export const statementallocations = pgTable("statementallocations", {
|
|||||||
|
|
||||||
amount: doublePrecision("amount").notNull().default(0),
|
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(
|
incominginvoice: bigint("ii_id", { mode: "number" }).references(
|
||||||
() => incominginvoices.id
|
() => incominginvoices.id
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -154,6 +154,8 @@ export const tenants = pgTable(
|
|||||||
}),
|
}),
|
||||||
accountChart: text("accountChart").notNull().default("skr03"),
|
accountChart: text("accountChart").notNull().default("skr03"),
|
||||||
|
|
||||||
|
cashDiscountAccounts: jsonb("cashDiscountAccounts").notNull().default({}),
|
||||||
|
|
||||||
standardEmailForInvoices: text("standardEmailForInvoices"),
|
standardEmailForInvoices: text("standardEmailForInvoices"),
|
||||||
|
|
||||||
extraModules: jsonb("extraModules").notNull().default([]),
|
extraModules: jsonb("extraModules").notNull().default([]),
|
||||||
|
|||||||
@@ -55,6 +55,14 @@ export const prepareStatementAllocationInput = (args: Record<string, unknown>) =
|
|||||||
if (!bookingModes.has(bookingMode)) throw new Error(`Ungültige Aufwandsart: ${bookingMode}`)
|
if (!bookingModes.has(bookingMode)) throw new Error(`Ungültige Aufwandsart: ${bookingMode}`)
|
||||||
|
|
||||||
payload.amount = amount
|
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")
|
if (args.description !== undefined) payload.description = stringArg(args, "description")
|
||||||
payload.bookingMode = bookingMode
|
payload.bookingMode = bookingMode
|
||||||
if (args.datevTaxKey !== undefined) payload.datevTaxKey = stringArg(args, "datevTaxKey")
|
if (args.datevTaxKey !== undefined) payload.datevTaxKey = stringArg(args, "datevTaxKey")
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import { withImages } from "../result"
|
|||||||
import { renderOutgoingDocumentPdf } from "../../modules/outgoing-document-pdf.service"
|
import { renderOutgoingDocumentPdf } from "../../modules/outgoing-document-pdf.service"
|
||||||
import { renderPdfPagesAsPng } from "../../utils/pdfPreview"
|
import { renderPdfPagesAsPng } from "../../utils/pdfPreview"
|
||||||
import { outgoingStatusTools } from "./outgoingStatus"
|
import { outgoingStatusTools } from "./outgoingStatus"
|
||||||
|
import { prepareCashDiscountAllocation } from "../../modules/cash-discount"
|
||||||
|
|
||||||
export { prepareStatementAllocationInput } from "../statementAllocations"
|
export { prepareStatementAllocationInput } from "../statementAllocations"
|
||||||
|
|
||||||
@@ -1613,7 +1614,7 @@ export const accountingTools: McpTool[] = [
|
|||||||
{
|
{
|
||||||
name: "accounting.statement_allocations.create",
|
name: "accounting.statement_allocations.create",
|
||||||
title: "Bankzuweisung erstellen",
|
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"],
|
requiredPermissions: ["accounting.statement_allocations.write"],
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: "object",
|
type: "object",
|
||||||
@@ -1621,6 +1622,7 @@ export const accountingTools: McpTool[] = [
|
|||||||
properties: {
|
properties: {
|
||||||
bankstatement: { type: "number", description: "Bankumsatz-ID. Weglassen für eine manuelle Soll/Haben-Buchung." },
|
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." },
|
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." },
|
createddocument: { type: "number", description: "Ausgangsbeleg-ID." },
|
||||||
incominginvoice: { type: "number", description: "Eingangsbeleg-ID." },
|
incominginvoice: { type: "number", description: "Eingangsbeleg-ID." },
|
||||||
account: { type: "number", description: "Sachkonto-ID beziehungsweise Soll-Sachkonto." },
|
account: { type: "number", description: "Sachkonto-ID beziehungsweise Soll-Sachkonto." },
|
||||||
@@ -1646,8 +1648,9 @@ export const accountingTools: McpTool[] = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
async handler(context, args) {
|
async handler(context, args) {
|
||||||
const payload = prepareStatementAllocationInput(args)
|
const prepared = prepareStatementAllocationInput(args)
|
||||||
await assertStatementAllocationReferences(context, payload)
|
await assertStatementAllocationReferences(context, prepared)
|
||||||
|
const payload = await prepareCashDiscountAllocation(context.server.db, context.tenantId, prepared)
|
||||||
|
|
||||||
const [created] = await context.server.db
|
const [created] = await context.server.db
|
||||||
.insert(statementallocations)
|
.insert(statementallocations)
|
||||||
|
|||||||
@@ -41,10 +41,10 @@ function documentAmount(document: any, advanceInvoices: any[]) {
|
|||||||
return money(money(net) + money(tax19) + money(tax7) - advanceAmount)
|
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)
|
const amount = documentAmount(document, advanceInvoices)
|
||||||
if (![amount, paidAmount].every(Number.isFinite)) throw new Error(`Beleg ${document.id}: Betrag kann nicht berechnet werden`)
|
if (![amount, paidAmount, cashDiscountAmount].every(Number.isFinite)) throw new Error(`Beleg ${document.id}: Betrag kann nicht berechnet werden`)
|
||||||
const openAmount = money(amount - paidAmount)
|
const openAmount = money(amount - paidAmount - cashDiscountAmount)
|
||||||
const cancelled = cancellations.length > 0
|
const cancelled = cancellations.length > 0
|
||||||
const applicable = document.state === "Gebucht" && ["invoices", "advanceInvoices"].includes(document.type) && !cancelled
|
const applicable = document.state === "Gebucht" && ["invoices", "advanceInvoices"].includes(document.type) && !cancelled
|
||||||
// Signed invoices and refunds use the same payment-direction comparison.
|
// 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,
|
id: document.id, type: document.type, documentNumber: document.documentNumber, documentDate: document.documentDate,
|
||||||
state: document.state, customer: document.customer, project: document.project,
|
state: document.state, customer: document.customer, project: document.project,
|
||||||
cancelled, cancellationDocuments: cancellations.map(({ id, documentNumber }) => ({ id, documentNumber })),
|
cancelled, cancellationDocuments: cancellations.map(({ id, documentNumber }) => ({ id, documentNumber })),
|
||||||
paymentStatus, amount, paidAmount: money(paidAmount), openAmount,
|
paymentStatus, amount, paidAmount: money(paidAmount), cashDiscountAmount: money(cashDiscountAmount), openAmount,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,7 +65,7 @@ async function statusesFor(context: McpContext, documents: Array<typeof createdd
|
|||||||
const advanceIds = [...new Set(documents.flatMap(document => Array.isArray(document.usedAdvanceInvoices)
|
const advanceIds = [...new Set(documents.flatMap(document => Array.isArray(document.usedAdvanceInvoices)
|
||||||
? document.usedAdvanceInvoices.filter(id => typeof id === "number" && Number.isSafeInteger(id) && id > 0) as number[] : []))]
|
? document.usedAdvanceInvoices.filter(id => typeof id === "number" && Number.isSafeInteger(id) && id > 0) as number[] : []))]
|
||||||
const [allocations, cancellations, advances] = await Promise.all([
|
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))),
|
.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)
|
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"),
|
.where(and(eq(createddocuments.tenant, context.tenantId), eq(createddocuments.archived, false), eq(createddocuments.type, "cancellationInvoices"),
|
||||||
@@ -73,15 +73,19 @@ async function statusesFor(context: McpContext, documents: Array<typeof createdd
|
|||||||
advanceIds.length ? context.server.db.select().from(createddocuments)
|
advanceIds.length ? context.server.db.select().from(createddocuments)
|
||||||
.where(and(eq(createddocuments.tenant, context.tenantId), eq(createddocuments.archived, false), inArray(createddocuments.id, advanceIds))) : [],
|
.where(and(eq(createddocuments.tenant, context.tenantId), eq(createddocuments.archived, false), inArray(createddocuments.id, advanceIds))) : [],
|
||||||
])
|
])
|
||||||
return documents.map(document => outgoingDocumentStatus(document,
|
return documents.map(document => {
|
||||||
allocations.filter(allocation => allocation.createddocument === document.id).reduce((sum, allocation) => sum + Number(allocation.amount), 0),
|
const documentAllocations = allocations.filter(allocation => allocation.createddocument === document.id)
|
||||||
cancellations.filter(cancellation => cancellation.createddocument === document.id), advances))
|
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[] = [
|
export const outgoingStatusTools: McpTool[] = [
|
||||||
{
|
{
|
||||||
name: "accounting.outgoing_documents.status.get", title: "Ausgangsbelegstatus laden",
|
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"],
|
requiredPermissions: ["accounting.outgoing_documents.read"],
|
||||||
inputSchema: { ...objectField({ id: idField }).schema, required: ["id"] },
|
inputSchema: { ...objectField({ id: idField }).schema, required: ["id"] },
|
||||||
async handler(context, args) {
|
async handler(context, args) {
|
||||||
|
|||||||
274
backend/src/modules/cash-discount.ts
Normal file
274
backend/src/modules/cash-discount.ts
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
import { and, eq, inArray } from "drizzle-orm"
|
||||||
|
import { createddocuments, incominginvoices, statementallocations, tenants } from "../../db/schema"
|
||||||
|
|
||||||
|
// Skonto wird an der Zahlungszuordnung gespeichert. amount bleibt der tatsächlich gezahlte Betrag,
|
||||||
|
// cashDiscountAmount trägt dasselbe Vorzeichen und schließt zusammen mit amount den Beleg aus.
|
||||||
|
|
||||||
|
export type CashDiscountRate = "19" | "7" | "0"
|
||||||
|
export type CashDiscountSide = "received" | "granted"
|
||||||
|
export type CashDiscountAccountConfig = Record<CashDiscountSide, Record<CashDiscountRate, string>>
|
||||||
|
export type CashDiscountSplitItem = { rate: number, account: string, gross: number, net: number, tax: number }
|
||||||
|
|
||||||
|
export const CASH_DISCOUNT_RATES: CashDiscountRate[] = ["19", "7", "0"]
|
||||||
|
|
||||||
|
// SKR03: Erhaltene Skonti (Eingangsbelege) und gewährte Skonti (Ausgangsbelege), jeweils Automatikkonten.
|
||||||
|
export const DEFAULT_CASH_DISCOUNT_ACCOUNTS: CashDiscountAccountConfig = {
|
||||||
|
received: { "19": "3736", "7": "3731", "0": "3730" },
|
||||||
|
granted: { "19": "8736", "7": "8731", "0": "8730" },
|
||||||
|
}
|
||||||
|
|
||||||
|
// Weicht der Skonto um mehr als diesen Anteil vom Beleg ab, liegt sehr wahrscheinlich ein Eingabefehler vor.
|
||||||
|
export const MAX_CASH_DISCOUNT_SHARE = 0.1
|
||||||
|
|
||||||
|
const invalidInput = (message: string) => Object.assign(new Error(message), { statusCode: 400 })
|
||||||
|
const money = (value: number) => Number(value.toFixed(2))
|
||||||
|
const sameSign = (value: number, sign: number) => sign === 0 || Math.sign(value) === 0 || Math.sign(value) === sign
|
||||||
|
|
||||||
|
export function resolveCashDiscountAccounts(config: any): CashDiscountAccountConfig {
|
||||||
|
const result = {
|
||||||
|
received: { ...DEFAULT_CASH_DISCOUNT_ACCOUNTS.received },
|
||||||
|
granted: { ...DEFAULT_CASH_DISCOUNT_ACCOUNTS.granted },
|
||||||
|
}
|
||||||
|
for (const side of ["received", "granted"] as const) {
|
||||||
|
for (const rate of CASH_DISCOUNT_RATES) {
|
||||||
|
const value = config?.[side]?.[rate]
|
||||||
|
if (typeof value === "string" && value.trim()) result[side][rate] = value.trim()
|
||||||
|
else if (typeof value === "number" && Number.isFinite(value)) result[side][rate] = String(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validiert die Einstellung aus den Firmeneinstellungen. Leere Felder fallen auf SKR03 zurück.
|
||||||
|
export function normalizeCashDiscountAccountsInput(input: any): Partial<CashDiscountAccountConfig> {
|
||||||
|
if (!input || typeof input !== "object" || Array.isArray(input)) {
|
||||||
|
throw invalidInput("Die Skontokonten müssen als Objekt übergeben werden.")
|
||||||
|
}
|
||||||
|
const result: Partial<CashDiscountAccountConfig> = {}
|
||||||
|
for (const side of ["received", "granted"] as const) {
|
||||||
|
const entries: Partial<Record<CashDiscountRate, string>> = {}
|
||||||
|
for (const rate of CASH_DISCOUNT_RATES) {
|
||||||
|
const raw = input?.[side]?.[rate]
|
||||||
|
if (raw === undefined || raw === null || String(raw).trim() === "") continue
|
||||||
|
const value = String(raw).trim()
|
||||||
|
if (!/^\d{4,9}$/.test(value)) {
|
||||||
|
throw invalidInput(`Das Skontokonto ${value} muss aus 4 bis 9 Ziffern bestehen.`)
|
||||||
|
}
|
||||||
|
entries[rate] = value
|
||||||
|
}
|
||||||
|
if (Object.keys(entries).length) result[side] = entries as Record<CashDiscountRate, string>
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
export function settledAllocationAmount(allocation: { amount?: any, cashDiscountAmount?: any }) {
|
||||||
|
return Number(allocation?.amount || 0) + Number(allocation?.cashDiscountAmount || 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
type GrossByRate = Record<CashDiscountRate, number>
|
||||||
|
const emptyGross = (): GrossByRate => ({ "19": 0, "7": 0, "0": 0 })
|
||||||
|
|
||||||
|
// Bruttobeträge eines Eingangsbelegs je Steuersatz. Vorzeichen: Ausgaben negativ (wie die Bankzuordnung).
|
||||||
|
export function getIncomingInvoiceGrossByRate(invoice: any): GrossByRate {
|
||||||
|
const gross = emptyGross()
|
||||||
|
const accounts = Array.isArray(invoice?.accounts) ? invoice.accounts : []
|
||||||
|
for (const account of accounts) {
|
||||||
|
const taxType = account?.taxType === null || account?.taxType === undefined ? "null" : String(account.taxType)
|
||||||
|
const amount = Number(account?.amountNet || 0) + Number(account?.amountTax || 0)
|
||||||
|
if (!Number.isFinite(amount)) throw invalidInput("Der Eingangsbeleg enthält ungültige Beträge.")
|
||||||
|
if (amount === 0) continue
|
||||||
|
if (taxType === "19" || taxType === "7") gross[taxType] += amount
|
||||||
|
else if (taxType === "null" || taxType === "" || taxType === "0") gross["0"] += amount
|
||||||
|
else {
|
||||||
|
throw invalidInput("Skonto auf Belege mit innergemeinschaftlichem Erwerb oder §13b UStG bitte manuell buchen.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const override = invoice?.taxAmountOverride
|
||||||
|
if (override !== null && override !== undefined && override !== "") {
|
||||||
|
const calculatedTax = accounts.reduce((sum: number, account: any) => sum + Number(account?.amountTax || 0), 0)
|
||||||
|
const correction = Number(override) - calculatedTax
|
||||||
|
if (Number.isFinite(correction) && Math.abs(correction) >= 0.005) {
|
||||||
|
if (gross["19"] !== 0) gross["19"] += correction
|
||||||
|
else if (gross["7"] !== 0) gross["7"] += correction
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sign = invoice?.expense === false ? 1 : -1
|
||||||
|
return {
|
||||||
|
"19": money(gross["19"] * sign),
|
||||||
|
"7": money(gross["7"] * sign),
|
||||||
|
"0": money(gross["0"] * sign),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rowsGrossByRate = (document: any, taxFree: boolean): GrossByRate => {
|
||||||
|
const net = emptyGross()
|
||||||
|
for (const row of Array.isArray(document?.rows) ? document.rows : []) {
|
||||||
|
if (!row || ["pagebreak", "title", "text"].includes(row.mode)) continue
|
||||||
|
const rowNet = Number((Number(row.quantity || 0) * Number(row.price || 0) * (1 - Number(row.discountPercent || 0) / 100)).toFixed(3))
|
||||||
|
if (!Number.isFinite(rowNet)) throw invalidInput(`Beleg ${document.id}: ungültiger Positionsbetrag`)
|
||||||
|
const taxPercent = Number(row.taxPercent)
|
||||||
|
if (!taxFree && taxPercent === 19) net["19"] += rowNet
|
||||||
|
else if (!taxFree && taxPercent === 7) net["7"] += rowNet
|
||||||
|
else net["0"] += rowNet
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"19": money(money(net["19"]) + money(net["19"] * 0.19)),
|
||||||
|
"7": money(money(net["7"]) + money(net["7"] * 0.07)),
|
||||||
|
"0": money(net["0"]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const createdDocumentAmounts = (document: any, advanceInvoices: any[]) => {
|
||||||
|
if (document?.taxType === "13b UStG") {
|
||||||
|
throw invalidInput("Skonto auf Ausgangsbelege nach §13b UStG bitte manuell buchen.")
|
||||||
|
}
|
||||||
|
const taxFree = ["19 UStG", "12.3 UStG"].includes(String(document?.taxType || ""))
|
||||||
|
const gross = rowsGrossByRate(document, taxFree)
|
||||||
|
const advances = emptyGross()
|
||||||
|
for (const id of Array.isArray(document?.usedAdvanceInvoices) ? document.usedAdvanceInvoices : []) {
|
||||||
|
const row = advanceInvoices.find((invoice) => invoice.id === id)?.rows?.find((row: any) => row?.advanceInvoiceData)
|
||||||
|
if (!row) continue
|
||||||
|
const rate = String(Number(row.taxPercent)) as CashDiscountRate
|
||||||
|
const amount = Number(row.price) * ((100 + Number(row.taxPercent || 0)) / 100)
|
||||||
|
advances[!taxFree && (rate === "19" || rate === "7") ? rate : "0"] += amount
|
||||||
|
}
|
||||||
|
return { gross, advances }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bruttobeträge eines Ausgangsbelegs je Steuersatz. Bei Schlussrechnungen werden die Abschläge mit ihren
|
||||||
|
// ursprünglich berechneten Beträgen abgezogen, damit sich der Skonto auf die Restforderung verteilt.
|
||||||
|
export function getCreatedDocumentGrossByRate(document: any, advanceInvoices: any[] = []): GrossByRate {
|
||||||
|
const { gross, advances } = createdDocumentAmounts(document, advanceInvoices)
|
||||||
|
const total = gross["19"] + gross["7"] + gross["0"]
|
||||||
|
const sign = Math.sign(total) || 1
|
||||||
|
const remaining = emptyGross()
|
||||||
|
for (const rate of CASH_DISCOUNT_RATES) {
|
||||||
|
const value = money(gross[rate] - advances[rate])
|
||||||
|
remaining[rate] = sameSign(value, sign) ? value : 0
|
||||||
|
}
|
||||||
|
return remaining["19"] || remaining["7"] || remaining["0"] ? remaining : gross
|
||||||
|
}
|
||||||
|
|
||||||
|
// Offene Gesamtforderung des Ausgangsbelegs: Bruttobetrag abzüglich der berechneten Abschläge.
|
||||||
|
export function getCreatedDocumentGrossTotal(document: any, advanceInvoices: any[] = []) {
|
||||||
|
const { gross, advances } = createdDocumentAmounts(document, advanceInvoices)
|
||||||
|
return money(gross["19"] + gross["7"] + gross["0"] - advances["19"] - advances["7"] - advances["0"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verteilt den Skonto anteilig auf die Steuersätze; Rundungsdifferenzen gehen an den größten Anteil.
|
||||||
|
export function splitCashDiscount(amount: number, grossByRate: GrossByRate, accounts: Record<CashDiscountRate, string>): CashDiscountSplitItem[] {
|
||||||
|
const discount = money(amount)
|
||||||
|
if (!discount) return []
|
||||||
|
const weights = CASH_DISCOUNT_RATES
|
||||||
|
.map((rate) => ({ rate, weight: Math.abs(Number(grossByRate[rate] || 0)) }))
|
||||||
|
.filter((item) => item.weight > 0)
|
||||||
|
if (!weights.length) weights.push({ rate: "0", weight: 1 })
|
||||||
|
|
||||||
|
const totalWeight = weights.reduce((sum, item) => sum + item.weight, 0)
|
||||||
|
const items = weights.map((item) => ({ rate: item.rate, gross: money(discount * item.weight / totalWeight) }))
|
||||||
|
const difference = money(discount - items.reduce((sum, item) => sum + item.gross, 0))
|
||||||
|
if (difference) {
|
||||||
|
const largest = items.reduce((max, item) => Math.abs(item.gross) > Math.abs(max.gross) ? item : max, items[0])
|
||||||
|
largest.gross = money(largest.gross + difference)
|
||||||
|
}
|
||||||
|
|
||||||
|
return items.filter((item) => item.gross !== 0).map((item) => {
|
||||||
|
const rate = Number(item.rate)
|
||||||
|
const net = money(item.gross / (1 + rate / 100))
|
||||||
|
return { rate, account: accounts[item.rate], gross: item.gross, net, tax: money(item.gross - net) }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bereitet Skonto für eine neue Bankzuordnung vor: Vorzeichen, Plausibilität und Steueraufteilung.
|
||||||
|
export async function prepareCashDiscountAllocation(db: any, tenantId: number, payload: Record<string, any>) {
|
||||||
|
const requested = Number(payload.cashDiscountAmount || 0)
|
||||||
|
if (!Number.isFinite(requested)) throw invalidInput("Der Skontobetrag muss eine gültige Zahl sein.")
|
||||||
|
if (Math.abs(requested) < 0.005) {
|
||||||
|
return { ...payload, cashDiscountAmount: null, cashDiscountSplit: null }
|
||||||
|
}
|
||||||
|
|
||||||
|
const amount = Number(payload.amount || 0)
|
||||||
|
if (!payload.bankstatement) throw invalidInput("Skonto kann nur zusammen mit einer Bankzahlung gebucht werden.")
|
||||||
|
const hasDocument = Boolean(payload.createddocument)
|
||||||
|
const hasInvoice = Boolean(payload.incominginvoice)
|
||||||
|
if (hasDocument === hasInvoice) {
|
||||||
|
throw invalidInput("Skonto ist nur bei der Zuordnung genau eines Ausgangs- oder Eingangsbelegs möglich.")
|
||||||
|
}
|
||||||
|
if (!amount) throw invalidInput("Für Skonto muss ein Zahlbetrag zugeordnet werden.")
|
||||||
|
|
||||||
|
const discount = money(Math.sign(amount) * Math.abs(requested))
|
||||||
|
const [tenant] = await db.select({ cashDiscountAccounts: tenants.cashDiscountAccounts })
|
||||||
|
.from(tenants).where(eq(tenants.id, tenantId)).limit(1)
|
||||||
|
const accounts = resolveCashDiscountAccounts(tenant?.cashDiscountAccounts)
|
||||||
|
|
||||||
|
let grossByRate: GrossByRate
|
||||||
|
let total: number
|
||||||
|
let existing: any[]
|
||||||
|
|
||||||
|
if (hasDocument) {
|
||||||
|
const documentId = Number(payload.createddocument)
|
||||||
|
const [document] = await db.select().from(createddocuments).where(and(
|
||||||
|
eq(createddocuments.id, documentId),
|
||||||
|
eq(createddocuments.tenant, tenantId),
|
||||||
|
)).limit(1)
|
||||||
|
if (!document || document.archived) throw invalidInput("Der Ausgangsbeleg wurde nicht gefunden.")
|
||||||
|
if (document.state !== "Gebucht") throw invalidInput("Skonto ist nur auf gebuchte Ausgangsbelege möglich.")
|
||||||
|
const advanceIds = (Array.isArray(document.usedAdvanceInvoices) ? document.usedAdvanceInvoices : [])
|
||||||
|
.filter((id: any) => Number.isSafeInteger(id) && id > 0)
|
||||||
|
const advanceInvoices = advanceIds.length
|
||||||
|
? await db.select().from(createddocuments).where(and(
|
||||||
|
eq(createddocuments.tenant, tenantId),
|
||||||
|
inArray(createddocuments.id, advanceIds),
|
||||||
|
))
|
||||||
|
: []
|
||||||
|
grossByRate = getCreatedDocumentGrossByRate(document, advanceInvoices)
|
||||||
|
total = getCreatedDocumentGrossTotal(document, advanceInvoices)
|
||||||
|
existing = await db.select({ amount: statementallocations.amount, cashDiscountAmount: statementallocations.cashDiscountAmount })
|
||||||
|
.from(statementallocations).where(and(
|
||||||
|
eq(statementallocations.createddocument, documentId),
|
||||||
|
eq(statementallocations.tenant, tenantId),
|
||||||
|
eq(statementallocations.archived, false),
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
const invoiceId = Number(payload.incominginvoice)
|
||||||
|
const [invoice] = await db.select().from(incominginvoices).where(and(
|
||||||
|
eq(incominginvoices.id, invoiceId),
|
||||||
|
eq(incominginvoices.tenant, tenantId),
|
||||||
|
)).limit(1)
|
||||||
|
if (!invoice || invoice.archived) throw invalidInput("Der Eingangsbeleg wurde nicht gefunden.")
|
||||||
|
if (invoice.state !== "Gebucht") throw invalidInput("Skonto ist nur auf gebuchte Eingangsbelege möglich.")
|
||||||
|
grossByRate = getIncomingInvoiceGrossByRate(invoice)
|
||||||
|
total = money(grossByRate["19"] + grossByRate["7"] + grossByRate["0"])
|
||||||
|
existing = await db.select({ amount: statementallocations.amount, cashDiscountAmount: statementallocations.cashDiscountAmount })
|
||||||
|
.from(statementallocations).where(and(
|
||||||
|
eq(statementallocations.incominginvoice, invoiceId),
|
||||||
|
eq(statementallocations.tenant, tenantId),
|
||||||
|
eq(statementallocations.archived, false),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
validateCashDiscount({ amount, discount, total, settled: existing.reduce((sum, item) => sum + settledAllocationAmount(item), 0) })
|
||||||
|
|
||||||
|
const side: CashDiscountSide = hasDocument ? "granted" : "received"
|
||||||
|
return {
|
||||||
|
...payload,
|
||||||
|
cashDiscountAmount: discount,
|
||||||
|
cashDiscountSplit: splitCashDiscount(discount, grossByRate, accounts[side]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateCashDiscount({ amount, discount, total, settled }: { amount: number, discount: number, total: number, settled: number }) {
|
||||||
|
if (!total) throw invalidInput("Der Beleg hat keinen Betrag, auf den Skonto gebucht werden kann.")
|
||||||
|
if (Math.sign(amount) !== Math.sign(total)) {
|
||||||
|
throw invalidInput("Skonto ist nur bei Zahlungen in Richtung des Belegs möglich.")
|
||||||
|
}
|
||||||
|
const open = money(total - settled)
|
||||||
|
if (Math.abs(amount + discount) > Math.abs(open) + 0.02 || Math.sign(open) !== Math.sign(total)) {
|
||||||
|
throw invalidInput(`Zahlung und Skonto übersteigen den offenen Betrag von ${Math.abs(open).toFixed(2).replace(".", ",")} €.`)
|
||||||
|
}
|
||||||
|
if (Math.abs(discount) > Math.abs(total) * MAX_CASH_DISCOUNT_SHARE + 0.005) {
|
||||||
|
throw invalidInput(`Der Skonto ist höher als ${MAX_CASH_DISCOUNT_SHARE * 100} % des Belegbetrags. Bitte Betrag prüfen.`)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import { centralServicesClient } from "../modules/push-server.client"
|
|||||||
import { findBankInstitutionByBic } from "../modules/banking-institution"
|
import { findBankInstitutionByBic } from "../modules/banking-institution"
|
||||||
import { normalizeBankAllocationDetails, validateBankAllocationCostCentre } from "../modules/bank-allocation-details"
|
import { normalizeBankAllocationDetails, validateBankAllocationCostCentre } from "../modules/bank-allocation-details"
|
||||||
import { CASHBOOK_BANK_ID } from "../modules/bank-balances.service"
|
import { CASHBOOK_BANK_ID } from "../modules/bank-balances.service"
|
||||||
|
import { prepareCashDiscountAllocation, settledAllocationAmount } from "../modules/cash-discount"
|
||||||
|
|
||||||
import {
|
import {
|
||||||
bankrequisitions,
|
bankrequisitions,
|
||||||
@@ -56,7 +57,7 @@ export default async function bankingRoutes(server: FastifyInstance) {
|
|||||||
eq(bankstatements.id, statementId),
|
eq(bankstatements.id, statementId),
|
||||||
eq(bankstatements.tenant, tenantId)
|
eq(bankstatements.tenant, tenantId)
|
||||||
)).limit(1),
|
)).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.incominginvoice, invoiceId),
|
||||||
eq(statementallocations.tenant, tenantId),
|
eq(statementallocations.tenant, tenantId),
|
||||||
eq(statementallocations.archived, false)
|
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 !== ""
|
const correctedTax = invoice.taxAmountOverride !== null && invoice.taxAmountOverride !== undefined && invoice.taxAmountOverride !== ""
|
||||||
? Number(invoice.taxAmountOverride)
|
? Number(invoice.taxAmountOverride)
|
||||||
: accountTotals.tax
|
: accountTotals.tax
|
||||||
const legacyRemaining = Math.max(0, Math.abs(accountTotals.net + accountTotals.tax)
|
const invoiceSettled = invoiceAllocations.reduce((sum, item) => sum + Math.abs(settledAllocationAmount(item)), 0)
|
||||||
- invoiceAllocations.reduce((sum, item) => sum + Math.abs(Number(item.amount || 0)), 0))
|
const legacyRemaining = Math.max(0, Math.abs(accountTotals.net + accountTotals.tax) - invoiceSettled)
|
||||||
const correctedRemaining = Math.max(0, Math.abs(accountTotals.net + correctedTax)
|
const correctedRemaining = Math.max(0, Math.abs(accountTotals.net + correctedTax) - invoiceSettled)
|
||||||
- invoiceAllocations.reduce((sum, item) => sum + Math.abs(Number(item.amount || 0)), 0))
|
|
||||||
const statementRemaining = Math.max(0, Math.abs(Number(statement.amount || 0))
|
const statementRemaining = Math.max(0, Math.abs(Number(statement.amount || 0))
|
||||||
- statementAllocations.reduce((sum, item) => sum + Math.abs(Number(item.amount || 0)), 0))
|
- statementAllocations.reduce((sum, item) => sum + Math.abs(Number(item.amount || 0)), 0))
|
||||||
const requestedAmount = Number(allocation.amount || 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 })
|
if (prepared.error) return reply.code(400).send({ error: prepared.error })
|
||||||
Object.assign(prepared.data, normalizeBankAllocationDetails(prepared.data))
|
Object.assign(prepared.data, normalizeBankAllocationDetails(prepared.data))
|
||||||
await validateBankAllocationCostCentre(server.db, req.user.tenant_id, prepared.data.costcentre)
|
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({
|
const inserted = await server.db.insert(statementallocations).values({
|
||||||
...prepared.data,
|
...prepared.data,
|
||||||
|
|||||||
@@ -507,6 +507,7 @@ export default async function resourceRoutes(server: FastifyInstance) {
|
|||||||
id: statementallocations.id,
|
id: statementallocations.id,
|
||||||
createddocument: statementallocations.createddocument,
|
createddocument: statementallocations.createddocument,
|
||||||
amount: statementallocations.amount,
|
amount: statementallocations.amount,
|
||||||
|
cashDiscountAmount: statementallocations.cashDiscountAmount,
|
||||||
})
|
})
|
||||||
.from(statementallocations)
|
.from(statementallocations)
|
||||||
.where(and(
|
.where(and(
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {and, desc, eq, inArray, isNull} from "drizzle-orm"
|
|||||||
import { enrichProfilesWithBranches } from "../utils/profileBranches"
|
import { enrichProfilesWithBranches } from "../utils/profileBranches"
|
||||||
import { enrichProfilesWithTeams } from "../utils/profileTeams"
|
import { enrichProfilesWithTeams } from "../utils/profileTeams"
|
||||||
import { rotateRefreshToken } from "../utils/authTokens"
|
import { rotateRefreshToken } from "../utils/authTokens"
|
||||||
|
import { normalizeCashDiscountAccountsInput } from "../modules/cash-discount"
|
||||||
|
|
||||||
|
|
||||||
export default async function tenantRoutes(server: FastifyInstance) {
|
export default async function tenantRoutes(server: FastifyInstance) {
|
||||||
@@ -287,6 +288,14 @@ export default async function tenantRoutes(server: FastifyInstance) {
|
|||||||
delete data.portalConfig.primayColor
|
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
|
const updated = await server.db
|
||||||
.update(tenants)
|
.update(tenants)
|
||||||
.set(data)
|
.set(data)
|
||||||
|
|||||||
@@ -104,6 +104,51 @@ export const getBankAccountBookingLine = (alloc: any) => {
|
|||||||
return columns.join(";");
|
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) => {
|
const getIncomingInvoiceTaxOverride = (invoice: any) => {
|
||||||
if (invoice.taxAmountOverride === null || invoice.taxAmountOverride === undefined || invoice.taxAmountOverride === "") return null;
|
if (invoice.taxAmountOverride === null || invoice.taxAmountOverride === undefined || invoice.taxAmountOverride === "") return null;
|
||||||
|
|
||||||
@@ -479,6 +524,10 @@ export async function buildExportZip(
|
|||||||
const ii = alloc.incominginvoice;
|
const ii = alloc.incominginvoice;
|
||||||
const vend = ii.vendor;
|
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;;;;"";;;;;;`);
|
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) {
|
} else if(alloc.account) {
|
||||||
bookingLines.push(getBankAccountBookingLine(alloc));
|
bookingLines.push(getBankAccountBookingLine(alloc));
|
||||||
} else if(alloc.vendor) {
|
} else if(alloc.vendor) {
|
||||||
|
|||||||
@@ -685,14 +685,14 @@ export const generateLiquidityForecast = async (
|
|||||||
if (allocation.createddocument) {
|
if (allocation.createddocument) {
|
||||||
allocationByDocument.set(
|
allocationByDocument.set(
|
||||||
allocation.createddocument,
|
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) {
|
if (allocation.incominginvoice) {
|
||||||
allocationByIncomingInvoice.set(
|
allocationByIncomingInvoice.set(
|
||||||
allocation.incominginvoice,
|
allocation.incominginvoice,
|
||||||
roundMoney((allocationByIncomingInvoice.get(allocation.incominginvoice) || 0) + Number(allocation.amount || 0))
|
roundMoney((allocationByIncomingInvoice.get(allocation.incominginvoice) || 0) + Number(allocation.amount || 0) + Number(allocation.cashDiscountAmount || 0))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
116
backend/tests/cashDiscount.test.ts
Normal file
116
backend/tests/cashDiscount.test.ts
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
import assert from "node:assert/strict"
|
||||||
|
import test from "node:test"
|
||||||
|
import {
|
||||||
|
getCreatedDocumentGrossByRate,
|
||||||
|
getCreatedDocumentGrossTotal,
|
||||||
|
getIncomingInvoiceGrossByRate,
|
||||||
|
normalizeCashDiscountAccountsInput,
|
||||||
|
resolveCashDiscountAccounts,
|
||||||
|
splitCashDiscount,
|
||||||
|
validateCashDiscount,
|
||||||
|
} from "../src/modules/cash-discount"
|
||||||
|
import { getCashDiscountBookingLines } from "../src/utils/export/datev"
|
||||||
|
import { prepareStatementAllocationInput } from "../src/mcp/statementAllocations"
|
||||||
|
|
||||||
|
const defaults = resolveCashDiscountAccounts({})
|
||||||
|
|
||||||
|
test("splits incoming invoice discount by tax rate onto received discount accounts", () => {
|
||||||
|
const gross = getIncomingInvoiceGrossByRate({
|
||||||
|
expense: true,
|
||||||
|
accounts: [
|
||||||
|
{ taxType: "19", amountNet: 100, amountTax: 19 },
|
||||||
|
{ taxType: "7", amountNet: 100, amountTax: 7 },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
assert.deepEqual(gross, { "19": -119, "7": -107, "0": 0 })
|
||||||
|
|
||||||
|
const split = splitCashDiscount(-4.52, gross, defaults.received)
|
||||||
|
assert.deepEqual(split, [
|
||||||
|
{ rate: 19, account: "3736", gross: -2.38, net: -2, tax: -0.38 },
|
||||||
|
{ rate: 7, account: "3731", gross: -2.14, net: -2, tax: -0.14 },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("assigns rounding differences to the largest share", () => {
|
||||||
|
const split = splitCashDiscount(1, { "19": 1, "7": 1, "0": 1 }, defaults.granted)
|
||||||
|
assert.equal(split.reduce((sum, item) => sum + item.gross, 0).toFixed(2), "1.00")
|
||||||
|
assert.equal(split.length, 3)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("rejects discounts on intra-community acquisitions", () => {
|
||||||
|
assert.throws(
|
||||||
|
() => getIncomingInvoiceGrossByRate({ accounts: [{ taxType: "19I", amountNet: 100, amountTax: 0 }] }),
|
||||||
|
/manuell buchen/,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("final invoices spread discounts over the remaining claim after advance invoices", () => {
|
||||||
|
const advance = { id: 7, rows: [{ advanceInvoiceData: true, price: 1000, taxPercent: 19 }] }
|
||||||
|
const final = {
|
||||||
|
id: 8,
|
||||||
|
usedAdvanceInvoices: [7],
|
||||||
|
rows: [
|
||||||
|
{ mode: "normal", quantity: 1, price: 2000, discountPercent: 0, taxPercent: 19 },
|
||||||
|
{ mode: "normal", quantity: 1, price: 1000, discountPercent: 0, taxPercent: 7 },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
assert.deepEqual(getCreatedDocumentGrossByRate(final, [advance]), { "19": 1190, "7": 1070, "0": 0 })
|
||||||
|
assert.equal(getCreatedDocumentGrossTotal(final, [advance]), 2260)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("tax exempt small business invoices use the account without tax", () => {
|
||||||
|
const gross = getCreatedDocumentGrossByRate({
|
||||||
|
taxType: "19 UStG",
|
||||||
|
rows: [{ mode: "normal", quantity: 1, price: 100, discountPercent: 0, taxPercent: 19 }],
|
||||||
|
})
|
||||||
|
assert.deepEqual(gross, { "19": 0, "7": 0, "0": 100 })
|
||||||
|
assert.deepEqual(splitCashDiscount(2, gross, defaults.granted), [{ rate: 0, account: "8730", gross: 2, net: 2, tax: 0 }])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("validates payment plus discount against the open amount", () => {
|
||||||
|
assert.doesNotThrow(() => validateCashDiscount({ amount: 98, discount: 2, total: 100, settled: 0 }))
|
||||||
|
assert.doesNotThrow(() => validateCashDiscount({ amount: -98, discount: -2, total: -100, settled: 0 }))
|
||||||
|
assert.throws(() => validateCashDiscount({ amount: 99, discount: 2, total: 100, settled: 0 }), /offenen Betrag/)
|
||||||
|
assert.throws(() => validateCashDiscount({ amount: 50, discount: 2, total: 100, settled: 50 }), /offenen Betrag/)
|
||||||
|
assert.throws(() => validateCashDiscount({ amount: 80, discount: 20, total: 100, settled: 0 }), /höher als 10 %/)
|
||||||
|
assert.throws(() => validateCashDiscount({ amount: -98, discount: -2, total: 100, settled: 0 }), /Richtung/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("normalizes configured discount accounts and keeps SKR03 defaults", () => {
|
||||||
|
const input = normalizeCashDiscountAccountsInput({ received: { "19": " 3737 ", "7": "" } })
|
||||||
|
assert.deepEqual(input, { received: { "19": "3737" } })
|
||||||
|
assert.equal(resolveCashDiscountAccounts(input).received["19"], "3737")
|
||||||
|
assert.equal(resolveCashDiscountAccounts(input).received["7"], "3731")
|
||||||
|
assert.throws(() => normalizeCashDiscountAccountsInput({ granted: { "19": "87A6" } }), /Ziffern/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("exports discount bookings per tax rate against the business partner", () => {
|
||||||
|
const lines = getCashDiscountBookingLines({
|
||||||
|
bankstatement: { id: 42, date: "2026-10-05" },
|
||||||
|
incominginvoice: { reference: "RE-1", vendor: { vendorNumber: "70001", name: "Lieferant" } },
|
||||||
|
cashDiscountSplit: [
|
||||||
|
{ rate: 19, account: "3736", gross: -2.38, net: -2, tax: -0.38 },
|
||||||
|
{ rate: 7, account: "3731", gross: -2.14, net: -2, tax: -0.14 },
|
||||||
|
],
|
||||||
|
}).map(line => line.split(";"))
|
||||||
|
|
||||||
|
assert.equal(lines.length, 2)
|
||||||
|
assert.equal(lines[0].length, 124)
|
||||||
|
assert.deepEqual([lines[0][0], lines[0][1], lines[0][6], lines[0][7], lines[0][9], lines[0][10]],
|
||||||
|
["2,38", '"H"', "3736", "70001", "0510", '"RE-1"'])
|
||||||
|
|
||||||
|
const [granted] = getCashDiscountBookingLines({
|
||||||
|
bankstatement: { id: 43, date: "2026-10-05" },
|
||||||
|
createddocument: { documentNumber: "RE-100", customer: { customerNumber: "10001", name: "Kunde" } },
|
||||||
|
cashDiscountSplit: [{ rate: 19, account: "8736", gross: 2.38, net: 2, tax: 0.38 }],
|
||||||
|
}).map(line => line.split(";"))
|
||||||
|
assert.deepEqual([granted[0], granted[1], granted[6], granted[7]], ["2,38", '"S"', "8736", "10001"])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("MCP allocation input accepts cash discounts only with bank statements", () => {
|
||||||
|
const payload = prepareStatementAllocationInput({ bankstatement: 1, createddocument: 2, amount: 98, cashDiscountAmount: 2 })
|
||||||
|
assert.equal(payload.cashDiscountAmount, 2)
|
||||||
|
assert.throws(() => prepareStatementAllocationInput({
|
||||||
|
amount: 98, cashDiscountAmount: 2, manualBookingDate: "2026-10-01", account: 1, contraAccount: 2,
|
||||||
|
}), /Bankzahlung/)
|
||||||
|
})
|
||||||
@@ -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).
|
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
|
## Gute Praxis
|
||||||
|
|
||||||
- Regelmäßig abrufen, damit sich keine großen Rückstände bilden.
|
- Regelmäßig abrufen, damit sich keine großen Rückstände bilden.
|
||||||
|
|||||||
@@ -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).
|
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
|
## Gute Praxis
|
||||||
|
|
||||||
- Regelmäßig abrufen, damit sich keine großen Rückstände bilden.
|
- Regelmäßig abrufen, damit sich keine großen Rückstände bilden.
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { sumSettledAllocations } from "~/utils/cashDiscount"
|
||||||
|
|
||||||
export const useSum = () => {
|
export const useSum = () => {
|
||||||
const unwrapCreatedDocuments = (createddocuments = []) => {
|
const unwrapCreatedDocuments = (createddocuments = []) => {
|
||||||
@@ -83,8 +84,8 @@ export const useSum = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getCreatedDocumentOpenAmount = (createddocument, createddocuments = []) => {
|
const getCreatedDocumentOpenAmount = (createddocument, createddocuments = []) => {
|
||||||
let amountPaid = 0
|
// Gebuchter Skonto gleicht den Beleg zusammen mit der Zahlung aus.
|
||||||
createddocument.statementallocations.forEach(allocation => amountPaid += allocation.amount)
|
const amountPaid = sumSettledAllocations(createddocument.statementallocations || [])
|
||||||
|
|
||||||
return Number((getCreatedDocumentSum(createddocument, createddocuments) - amountPaid).toFixed(2))
|
return Number((getCreatedDocumentSum(createddocument, createddocuments) - amountPaid).toFixed(2))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import dayjs from "dayjs"
|
import dayjs from "dayjs"
|
||||||
import { getBankAllocationTaxBreakdown, isDirectBankAllocation } from "~/utils/bankAllocationTax"
|
import { getBankAllocationTaxBreakdown, isDirectBankAllocation } from "~/utils/bankAllocationTax"
|
||||||
|
import { hasCashDiscount } from "~/utils/cashDiscount"
|
||||||
import customParseFormat from "dayjs/plugin/customParseFormat"
|
import customParseFormat from "dayjs/plugin/customParseFormat"
|
||||||
import {
|
import {
|
||||||
formatTaxEvaluationPeriodLabel,
|
formatTaxEvaluationPeriodLabel,
|
||||||
@@ -54,7 +55,7 @@ const loadData = async () => {
|
|||||||
createdDocuments.value = (docs || []).filter(isRelevantOutputDocument)
|
createdDocuments.value = (docs || []).filter(isRelevantOutputDocument)
|
||||||
incomingInvoices.value = (incoming || []).filter(isRelevantInputInvoice)
|
incomingInvoices.value = (incoming || []).filter(isRelevantInputInvoice)
|
||||||
manualBookings.value = (manual as any[]) || []
|
manualBookings.value = (manual as any[]) || []
|
||||||
bankAllocations.value = (allocations || []).filter(isDirectBankAllocation)
|
bankAllocations.value = (allocations || []).filter((allocation) => isDirectBankAllocation(allocation) || hasCashDiscount(allocation))
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
|
import { sumSettledAllocations } from "~/utils/cashDiscount"
|
||||||
import { parseDate } from "@internationalized/date"
|
import { parseDate } from "@internationalized/date"
|
||||||
|
|
||||||
const {$api, $dayjs} = useNuxtApp()
|
const {$api, $dayjs} = useNuxtApp()
|
||||||
@@ -103,12 +104,12 @@ const loadSuggestionData = async () => {
|
|||||||
.map(i => ({
|
.map(i => ({
|
||||||
...i,
|
...i,
|
||||||
docTotal: useSum().getCreatedDocumentSum(i, createddocuments.value),
|
docTotal: useSum().getCreatedDocumentSum(i, createddocuments.value),
|
||||||
statementTotal: Number(i.statementallocations.reduce((n, {amount}) => n + amount, 0)),
|
statementTotal: Number(sumSettledAllocations(i.statementallocations)),
|
||||||
openSum: useSum().getCreatedDocumentOpenAmount(i, createddocuments.value).toFixed(2)
|
openSum: useSum().getCreatedDocumentOpenAmount(i, createddocuments.value).toFixed(2)
|
||||||
}))
|
}))
|
||||||
|
|
||||||
openIncomingInvoices.value = invoiceItems
|
openIncomingInvoices.value = invoiceItems
|
||||||
.filter(i => i.state === "Gebucht" && !i.archived && i.statementallocations.reduce((n, {amount}) => n + amount, 0).toFixed(2) !== getInvoiceSum(i, false))
|
.filter(i => i.state === "Gebucht" && !i.archived && sumSettledAllocations(i.statementallocations).toFixed(2) !== getInvoiceSum(i, false))
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Vorschlagsdaten konnten nicht geladen werden:", err)
|
console.error("Vorschlagsdaten konnten nicht geladen werden:", err)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -232,7 +233,7 @@ const calculateOpenSum = (statement) => {
|
|||||||
const getInvoiceSum = (invoice, onlyOpenSum) => {
|
const getInvoiceSum = (invoice, onlyOpenSum) => {
|
||||||
let sum = Number(useSum().getIncomingInvoiceSum(invoice))
|
let sum = Number(useSum().getIncomingInvoiceSum(invoice))
|
||||||
|
|
||||||
if (onlyOpenSum) sum = sum + Number(invoice.statementallocations.reduce((n, {amount}) => n + amount, 0))
|
if (onlyOpenSum) sum = sum + Number(sumSettledAllocations(invoice.statementallocations))
|
||||||
|
|
||||||
if (invoice.expense) {
|
if (invoice.expense) {
|
||||||
return (sum * -1).toFixed(2)
|
return (sum * -1).toFixed(2)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
import { BANK_ALLOCATION_TAX_ITEMS, getBankAllocationAmounts } from "~/utils/bankAllocationTax"
|
import { BANK_ALLOCATION_TAX_ITEMS, getBankAllocationAmounts } from "~/utils/bankAllocationTax"
|
||||||
|
import { sumSettledAllocations } from "~/utils/cashDiscount"
|
||||||
import {
|
import {
|
||||||
DEPRECIATION_METHOD_ITEMS,
|
DEPRECIATION_METHOD_ITEMS,
|
||||||
EXPENSE_BOOKING_MODE_ITEMS,
|
EXPENSE_BOOKING_MODE_ITEMS,
|
||||||
@@ -54,7 +55,7 @@ const rebuildDocumentLists = () => {
|
|||||||
return {
|
return {
|
||||||
...i,
|
...i,
|
||||||
docTotal: useSum().getCreatedDocumentSum(i, createddocuments.value),
|
docTotal: useSum().getCreatedDocumentSum(i, createddocuments.value),
|
||||||
statementTotal: Number(i.statementallocations.reduce((n, {amount}) => n + amount, 0)),
|
statementTotal: Number(sumSettledAllocations(i.statementallocations)),
|
||||||
openSum: useSum().getCreatedDocumentOpenAmount(i, createddocuments.value).toFixed(2)
|
openSum: useSum().getCreatedDocumentOpenAmount(i, createddocuments.value).toFixed(2)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -62,7 +63,7 @@ const rebuildDocumentLists = () => {
|
|||||||
allocatedDocuments.value = documents.filter(i => i.statementallocations.find(x => x.bankstatement === itemInfo.value.id))
|
allocatedDocuments.value = documents.filter(i => i.statementallocations.find(x => x.bankstatement === itemInfo.value.id))
|
||||||
allocatedIncomingInvoices.value = incominginvoices.value.filter(i => i.statementallocations.find(x => x.bankstatement === itemInfo.value.id))
|
allocatedIncomingInvoices.value = incominginvoices.value.filter(i => i.statementallocations.find(x => x.bankstatement === itemInfo.value.id))
|
||||||
openIncomingInvoices.value = incominginvoices.value.filter(i =>
|
openIncomingInvoices.value = incominginvoices.value.filter(i =>
|
||||||
!i.archived && i.statementallocations.reduce((n, {amount}) => n + amount, 0).toFixed(2) !== getInvoiceSum(i, false)
|
!i.archived && sumSettledAllocations(i.statementallocations).toFixed(2) !== getInvoiceSum(i, false)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,7 +137,7 @@ const separateIBAN = (input) => {
|
|||||||
const getInvoiceSum = (invoice, onlyOpenSum) => {
|
const getInvoiceSum = (invoice, onlyOpenSum) => {
|
||||||
let sum = Number(useSum().getIncomingInvoiceSum(invoice))
|
let sum = Number(useSum().getIncomingInvoiceSum(invoice))
|
||||||
|
|
||||||
if (onlyOpenSum) sum = sum + Number(invoice.statementallocations.reduce((n, {amount}) => n + amount, 0))
|
if (onlyOpenSum) sum = sum + Number(sumSettledAllocations(invoice.statementallocations))
|
||||||
|
|
||||||
if (invoice.expense) {
|
if (invoice.expense) {
|
||||||
return (sum * -1).toFixed(2)
|
return (sum * -1).toFixed(2)
|
||||||
@@ -603,6 +604,76 @@ const getAllocationBookingModeLabel = (item) => {
|
|||||||
return "Sofortaufwand"
|
return "Sofortaufwand"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Skonto: Die Zahlung gleicht zusammen mit dem Skonto den offenen Betrag des Belegs aus.
|
||||||
|
const cashDiscountModalOpen = ref(false)
|
||||||
|
const cashDiscountTarget = ref(null)
|
||||||
|
const cashDiscountInput = ref(0)
|
||||||
|
const savingCashDiscount = ref(false)
|
||||||
|
const cashDiscountError = ref("")
|
||||||
|
|
||||||
|
const getCashDiscountCandidate = (kind, entity) => {
|
||||||
|
const open = Number(kind === "createddocument" ? entity.openSum : getInvoiceSum(entity, true))
|
||||||
|
const payment = Number(manualAllocationSum.value)
|
||||||
|
if (!open || !payment || Math.sign(open) !== Math.sign(payment)) return null
|
||||||
|
if (Math.abs(payment) >= Math.abs(open) - 0.004) return null
|
||||||
|
return { open, payment, discount: Number((open - payment).toFixed(2)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const canBookCashDiscount = (kind, entity) => Boolean(getCashDiscountCandidate(kind, entity))
|
||||||
|
|
||||||
|
const openCashDiscountModal = (kind, entity) => {
|
||||||
|
const candidate = getCashDiscountCandidate(kind, entity)
|
||||||
|
if (!candidate) return
|
||||||
|
cashDiscountTarget.value = {
|
||||||
|
kind,
|
||||||
|
id: entity.id,
|
||||||
|
label: kind === "createddocument"
|
||||||
|
? `${entity.documentNumber} (${entity.customer?.name || "Ohne Kunde"})`
|
||||||
|
: `${entity.reference || "Eingangsbeleg"} (${entity.vendor?.name || "Ohne Lieferant"})`,
|
||||||
|
...candidate
|
||||||
|
}
|
||||||
|
cashDiscountInput.value = Math.abs(candidate.discount)
|
||||||
|
cashDiscountError.value = ""
|
||||||
|
cashDiscountModalOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const cashDiscountPercent = computed(() => {
|
||||||
|
const target = cashDiscountTarget.value
|
||||||
|
if (!target || !target.open) return 0
|
||||||
|
return Math.abs(Number(cashDiscountInput.value || 0)) / Math.abs(target.open) * 100
|
||||||
|
})
|
||||||
|
|
||||||
|
const cashDiscountRemaining = computed(() => {
|
||||||
|
const target = cashDiscountTarget.value
|
||||||
|
if (!target) return 0
|
||||||
|
return Number((Math.abs(target.open) - Math.abs(target.payment) - Math.abs(Number(cashDiscountInput.value || 0))).toFixed(2))
|
||||||
|
})
|
||||||
|
|
||||||
|
const confirmCashDiscount = async () => {
|
||||||
|
const target = cashDiscountTarget.value
|
||||||
|
if (!target || savingCashDiscount.value) return
|
||||||
|
savingCashDiscount.value = true
|
||||||
|
try {
|
||||||
|
await saveAllocation({
|
||||||
|
[target.kind]: target.id,
|
||||||
|
bankstatement: itemInfo.value.id,
|
||||||
|
amount: target.payment,
|
||||||
|
cashDiscountAmount: Math.abs(Number(cashDiscountInput.value || 0)),
|
||||||
|
description: allocationDescription.value || "Zahlung mit Skonto"
|
||||||
|
})
|
||||||
|
cashDiscountModalOpen.value = false
|
||||||
|
cashDiscountTarget.value = null
|
||||||
|
} catch (err) {
|
||||||
|
cashDiscountError.value = err?.data?.error || "Zahlung und Skonto konnten nicht gebucht werden."
|
||||||
|
} finally {
|
||||||
|
savingCashDiscount.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getCashDiscountSplitLabel = (item) => (item.cashDiscountSplit || [])
|
||||||
|
.map(split => `${split.account} (${split.rate} %): ${displayCurrency(split.gross)}`)
|
||||||
|
.join(" · ")
|
||||||
|
|
||||||
setup()
|
setup()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -726,6 +797,9 @@ setup()
|
|||||||
{{ getAllocationTaxLabel(item) }} · Netto {{ displayCurrency(getBankAllocationAmounts(item).amountNet) }}
|
{{ getAllocationTaxLabel(item) }} · Netto {{ displayCurrency(getBankAllocationAmounts(item).amountNet) }}
|
||||||
· Steuer {{ displayCurrency(getBankAllocationAmounts(item).amountTax) }}
|
· Steuer {{ displayCurrency(getBankAllocationAmounts(item).amountTax) }}
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="Number(item.cashDiscountAmount)" class="text-xs text-amber-700 dark:text-amber-400 mt-1">
|
||||||
|
Skonto {{ displayCurrency(item.cashDiscountAmount) }}<span v-if="item.cashDiscountSplit?.length"> · {{ getCashDiscountSplitLabel(item) }}</span>
|
||||||
|
</div>
|
||||||
<div class="mt-1 flex flex-wrap gap-1" v-if="item.bookingMode && item.bookingMode !== 'expense'">
|
<div class="mt-1 flex flex-wrap gap-1" v-if="item.bookingMode && item.bookingMode !== 'expense'">
|
||||||
<UBadge size="xs" color="warning" variant="soft">{{ getAllocationBookingModeLabel(item) }}</UBadge>
|
<UBadge size="xs" color="warning" variant="soft">{{ getAllocationBookingModeLabel(item) }}</UBadge>
|
||||||
<UBadge size="xs" color="neutral" variant="soft" v-if="item.depreciationGroup">{{ item.depreciationGroup }}</UBadge>
|
<UBadge size="xs" color="neutral" variant="soft" v-if="item.depreciationGroup">{{ item.depreciationGroup }}</UBadge>
|
||||||
@@ -756,6 +830,9 @@ setup()
|
|||||||
</div>
|
</div>
|
||||||
<div class="text-right">
|
<div class="text-right">
|
||||||
<div class="font-mono text-sm font-semibold">{{ displayCurrency(item.amount) }}</div>
|
<div class="font-mono text-sm font-semibold">{{ displayCurrency(item.amount) }}</div>
|
||||||
|
<div v-if="Number(item.cashDiscountAmount)" class="font-mono text-xs text-amber-600">
|
||||||
|
+ {{ displayCurrency(item.cashDiscountAmount) }} Skonto
|
||||||
|
</div>
|
||||||
<UButton
|
<UButton
|
||||||
icon="i-heroicons-trash"
|
icon="i-heroicons-trash"
|
||||||
color="error"
|
color="error"
|
||||||
@@ -1003,6 +1080,15 @@ setup()
|
|||||||
>
|
>
|
||||||
Rechnung zuweisen
|
Rechnung zuweisen
|
||||||
</UButton>
|
</UButton>
|
||||||
|
<UButton
|
||||||
|
v-if="canBookCashDiscount('createddocument', document)"
|
||||||
|
size="xs"
|
||||||
|
color="warning"
|
||||||
|
variant="soft"
|
||||||
|
@click="openCashDiscountModal('createddocument', document)"
|
||||||
|
>
|
||||||
|
Mit Skonto
|
||||||
|
</UButton>
|
||||||
<UButton
|
<UButton
|
||||||
size="xs"
|
size="xs"
|
||||||
color="gray"
|
color="gray"
|
||||||
@@ -1037,6 +1123,15 @@ setup()
|
|||||||
>
|
>
|
||||||
Beleg zuweisen
|
Beleg zuweisen
|
||||||
</UButton>
|
</UButton>
|
||||||
|
<UButton
|
||||||
|
v-if="canBookCashDiscount('incominginvoice', invoice)"
|
||||||
|
size="xs"
|
||||||
|
color="warning"
|
||||||
|
variant="soft"
|
||||||
|
@click="openCashDiscountModal('incominginvoice', invoice)"
|
||||||
|
>
|
||||||
|
Mit Skonto
|
||||||
|
</UButton>
|
||||||
<UButton
|
<UButton
|
||||||
size="xs"
|
size="xs"
|
||||||
color="gray"
|
color="gray"
|
||||||
@@ -1098,6 +1193,16 @@ setup()
|
|||||||
@click="saveAllocation({createddocument: document.id, bankstatement: itemInfo.id, amount: Number(Number(document.openSum) < manualAllocationSum ? document.openSum : manualAllocationSum), description: allocationDescription})"
|
@click="saveAllocation({createddocument: document.id, bankstatement: itemInfo.id, amount: Number(Number(document.openSum) < manualAllocationSum ? document.openSum : manualAllocationSum), description: allocationDescription})"
|
||||||
/>
|
/>
|
||||||
</UTooltip>
|
</UTooltip>
|
||||||
|
<UTooltip text="Mit Skonto ausgleichen">
|
||||||
|
<UButton
|
||||||
|
v-if="!itemInfo.statementallocations.find(i => i.createddocument === document.id) && canBookCashDiscount('createddocument', document)"
|
||||||
|
icon="i-heroicons-receipt-percent"
|
||||||
|
size="sm"
|
||||||
|
color="warning"
|
||||||
|
variant="soft"
|
||||||
|
@click="openCashDiscountModal('createddocument', document)"
|
||||||
|
/>
|
||||||
|
</UTooltip>
|
||||||
<UButton
|
<UButton
|
||||||
icon="i-heroicons-eye"
|
icon="i-heroicons-eye"
|
||||||
color="gray"
|
color="gray"
|
||||||
@@ -1157,6 +1262,16 @@ setup()
|
|||||||
@click="saveAllocation({incominginvoice: item.id, bankstatement: itemInfo.id, amount: Number(Math.abs(getInvoiceSum(item,true)) > Math.abs(manualAllocationSum) ? manualAllocationSum : getInvoiceSum(item,true)), description: allocationDescription})"
|
@click="saveAllocation({incominginvoice: item.id, bankstatement: itemInfo.id, amount: Number(Math.abs(getInvoiceSum(item,true)) > Math.abs(manualAllocationSum) ? manualAllocationSum : getInvoiceSum(item,true)), description: allocationDescription})"
|
||||||
/>
|
/>
|
||||||
</UTooltip>
|
</UTooltip>
|
||||||
|
<UTooltip text="Mit Skonto ausgleichen">
|
||||||
|
<UButton
|
||||||
|
v-if="!itemInfo.statementallocations.find(i => i.incominginvoice === item.id) && canBookCashDiscount('incominginvoice', item)"
|
||||||
|
icon="i-heroicons-receipt-percent"
|
||||||
|
size="sm"
|
||||||
|
color="warning"
|
||||||
|
variant="soft"
|
||||||
|
@click="openCashDiscountModal('incominginvoice', item)"
|
||||||
|
/>
|
||||||
|
</UTooltip>
|
||||||
<UButton
|
<UButton
|
||||||
icon="i-heroicons-eye"
|
icon="i-heroicons-eye"
|
||||||
color="gray"
|
color="gray"
|
||||||
@@ -1180,6 +1295,70 @@ setup()
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</UDashboardPanelContent>
|
</UDashboardPanelContent>
|
||||||
|
|
||||||
|
<UModal v-model:open="cashDiscountModalOpen" :ui="{ width: 'sm:max-w-lg' }">
|
||||||
|
<template #content>
|
||||||
|
<UCard v-if="cashDiscountTarget">
|
||||||
|
<template #header>
|
||||||
|
<div class="text-lg font-semibold">Mit Skonto ausgleichen</div>
|
||||||
|
<div class="text-sm text-gray-500 mt-0.5">{{ cashDiscountTarget.label }}</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="grid grid-cols-2 gap-2 text-sm">
|
||||||
|
<div class="text-gray-500">Offener Betrag</div>
|
||||||
|
<div class="text-right font-mono">{{ displayCurrency(Math.abs(cashDiscountTarget.open)) }}</div>
|
||||||
|
<div class="text-gray-500">Zahlung aus diesem Umsatz</div>
|
||||||
|
<div class="text-right font-mono">{{ displayCurrency(Math.abs(cashDiscountTarget.payment)) }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<UFormField label="Skonto (brutto)" :help="`${cashDiscountPercent.toFixed(2).replace('.', ',')} % des offenen Betrags`">
|
||||||
|
<UInput v-model="cashDiscountInput" type="number" step="0.01" min="0">
|
||||||
|
<template #trailing><span class="text-gray-500 text-xs">EUR</span></template>
|
||||||
|
</UInput>
|
||||||
|
</UFormField>
|
||||||
|
|
||||||
|
<UAlert
|
||||||
|
v-if="cashDiscountPercent > 5"
|
||||||
|
color="warning"
|
||||||
|
variant="subtle"
|
||||||
|
icon="i-heroicons-exclamation-triangle"
|
||||||
|
title="Ungewöhnlich hoher Skonto"
|
||||||
|
description="Bitte prüfen, ob es sich wirklich um Skonto handelt und nicht um eine Teilzahlung."
|
||||||
|
/>
|
||||||
|
<UAlert
|
||||||
|
v-if="cashDiscountError"
|
||||||
|
color="error"
|
||||||
|
variant="subtle"
|
||||||
|
icon="i-heroicons-x-circle"
|
||||||
|
:description="cashDiscountError"
|
||||||
|
/>
|
||||||
|
<div v-if="Math.abs(cashDiscountRemaining) >= 0.01" class="text-sm text-amber-600">
|
||||||
|
Danach bleiben {{ displayCurrency(cashDiscountRemaining) }} offen.
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-gray-500">
|
||||||
|
Der Skonto wird anteilig nach Steuersätzen auf die Skontokonten verteilt
|
||||||
|
({{ cashDiscountTarget.kind === 'createddocument' ? 'gewährte' : 'erhaltene' }} Skonti) und korrigiert die
|
||||||
|
{{ cashDiscountTarget.kind === 'createddocument' ? 'Umsatzsteuer' : 'Vorsteuer' }} im Monat der Zahlung.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<UButton color="neutral" variant="ghost" @click="cashDiscountModalOpen = false">Abbrechen</UButton>
|
||||||
|
<UButton
|
||||||
|
color="primary"
|
||||||
|
:loading="savingCashDiscount"
|
||||||
|
:disabled="!(Number(cashDiscountInput) > 0) || cashDiscountRemaining < -0.009"
|
||||||
|
@click="confirmCashDiscount"
|
||||||
|
>
|
||||||
|
Zahlung und Skonto buchen
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</UCard>
|
||||||
|
</template>
|
||||||
|
</UModal>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -164,6 +164,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
|
import { sumSettledAllocations } from "~/utils/cashDiscount"
|
||||||
import CreateDocumentModal from "~/components/createDocumentModal.vue"
|
import CreateDocumentModal from "~/components/createDocumentModal.vue"
|
||||||
import CreateDocumentFromTemplateModal from "~/components/createDocumentFromTemplateModal.vue"
|
import CreateDocumentFromTemplateModal from "~/components/createDocumentFromTemplateModal.vue"
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
@@ -337,10 +338,7 @@ const documentAmounts = computed(() => {
|
|||||||
|
|
||||||
items.value.forEach((item) => {
|
items.value.forEach((item) => {
|
||||||
const amount = sum.getCreatedDocumentSum(item, items.value)
|
const amount = sum.getCreatedDocumentSum(item, items.value)
|
||||||
const amountPaid = (item.statementallocations || []).reduce(
|
const amountPaid = sumSettledAllocations(item.statementallocations || [])
|
||||||
(total, allocation) => total + Number(allocation.amount || 0),
|
|
||||||
0
|
|
||||||
)
|
|
||||||
|
|
||||||
result.set(item.id, {
|
result.set(item.id, {
|
||||||
amount,
|
amount,
|
||||||
|
|||||||
@@ -427,6 +427,7 @@ const togglePortalRelease = async () => {
|
|||||||
<div>
|
<div>
|
||||||
<p class="text-xs text-gray-500">Betrag</p>
|
<p class="text-xs text-gray-500">Betrag</p>
|
||||||
<p class="font-medium">{{ formatCurrency(allocation.amount) }}</p>
|
<p class="font-medium">{{ formatCurrency(allocation.amount) }}</p>
|
||||||
|
<p v-if="Number(allocation.cashDiscountAmount)" class="text-xs text-amber-600">+ {{ formatCurrency(allocation.cashDiscountAmount) }} Skonto</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p class="text-xs text-gray-500">Text</p>
|
<p class="text-xs text-gray-500">Text</p>
|
||||||
|
|||||||
@@ -602,6 +602,7 @@ const hasBlockingIncomingInvoiceErrors = computed(() => blockingIncomingInvoiceE
|
|||||||
<div>
|
<div>
|
||||||
<p class="text-xs text-gray-500">Betrag</p>
|
<p class="text-xs text-gray-500">Betrag</p>
|
||||||
<p class="font-medium">{{ formatMoney(allocation.amount) }}</p>
|
<p class="font-medium">{{ formatMoney(allocation.amount) }}</p>
|
||||||
|
<p v-if="Number(allocation.cashDiscountAmount)" class="text-xs text-amber-600">+ {{ formatMoney(allocation.cashDiscountAmount) }} Skonto</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p class="text-xs text-gray-500">Text</p>
|
<p class="text-xs text-gray-500">Text</p>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
|
import { sumSettledAllocations } from "~/utils/cashDiscount"
|
||||||
import dayjs from "dayjs"
|
import dayjs from "dayjs"
|
||||||
import {useSum} from "~/composables/useSum.js";
|
import {useSum} from "~/composables/useSum.js";
|
||||||
|
|
||||||
@@ -199,8 +200,8 @@ const getInvoiceSum = (invoice) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getPaidAmount = (item) => {
|
const getPaidAmount = (item) => {
|
||||||
let amountPaid = 0
|
// Gebuchter Skonto zählt zum ausgeglichenen Betrag.
|
||||||
item.statementallocations.forEach(allocation => amountPaid += allocation.amount)
|
const amountPaid = sumSettledAllocations(item.statementallocations || [])
|
||||||
return Number(Math.abs(amountPaid).toFixed(2))
|
return Number(Math.abs(amountPaid).toFixed(2))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
|
import { resolveCashDiscountAccounts } from "~/utils/cashDiscount"
|
||||||
import {
|
import {
|
||||||
TAX_EVALUATION_PERIOD_OPTIONS,
|
TAX_EVALUATION_PERIOD_OPTIONS,
|
||||||
normalizeTaxEvaluationPeriod
|
normalizeTaxEvaluationPeriod
|
||||||
@@ -273,6 +274,20 @@ watch(() => auth.activeTenantData?.portalConfig, (config) => {
|
|||||||
})
|
})
|
||||||
const accountChart = ref(auth.activeTenantData.accountChart || "skr03")
|
const accountChart = ref(auth.activeTenantData.accountChart || "skr03")
|
||||||
const taxEvaluationPeriod = ref(normalizeTaxEvaluationPeriod(auth.activeTenantData.taxEvaluationPeriod))
|
const taxEvaluationPeriod = ref(normalizeTaxEvaluationPeriod(auth.activeTenantData.taxEvaluationPeriod))
|
||||||
|
const buildCashDiscountForm = (config) => {
|
||||||
|
const accounts = resolveCashDiscountAccounts(config)
|
||||||
|
return { received: { ...accounts.received }, granted: { ...accounts.granted } }
|
||||||
|
}
|
||||||
|
const cashDiscountAccounts = ref(buildCashDiscountForm(auth.activeTenantData?.cashDiscountAccounts))
|
||||||
|
const cashDiscountRateLabels = { "19": "19 %", "7": "7 %", "0": "ohne USt" }
|
||||||
|
const saveCashDiscountAccounts = async () => {
|
||||||
|
try {
|
||||||
|
await updateTenant({ cashDiscountAccounts: cashDiscountAccounts.value })
|
||||||
|
toast.add({ title: "Skontokonten gespeichert", color: "success" })
|
||||||
|
} catch (err) {
|
||||||
|
toast.add({ title: "Skontokonten konnten nicht gespeichert werden", description: err?.data?.error, color: "error" })
|
||||||
|
}
|
||||||
|
}
|
||||||
const accountChartOptions = [
|
const accountChartOptions = [
|
||||||
{ label: "SKR 03", value: "skr03" },
|
{ label: "SKR 03", value: "skr03" },
|
||||||
{ label: "Verein", value: "verein" }
|
{ label: "Verein", value: "verein" }
|
||||||
@@ -293,6 +308,7 @@ const updateTenant = async (newData) => {
|
|||||||
features.value = { ...defaultFeatures, ...(res?.features || {}) }
|
features.value = { ...defaultFeatures, ...(res?.features || {}) }
|
||||||
businessInfo.value = normalizeBusinessInfo(res?.businessInfo)
|
businessInfo.value = normalizeBusinessInfo(res?.businessInfo)
|
||||||
taxEvaluationPeriod.value = normalizeTaxEvaluationPeriod(res?.taxEvaluationPeriod)
|
taxEvaluationPeriod.value = normalizeTaxEvaluationPeriod(res?.taxEvaluationPeriod)
|
||||||
|
cashDiscountAccounts.value = buildCashDiscountForm(res?.cashDiscountAccounts)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const saveFeatures = async () => {
|
const saveFeatures = async () => {
|
||||||
@@ -776,6 +792,32 @@ onMounted(() => {
|
|||||||
>
|
>
|
||||||
Zeitraum speichern
|
Zeitraum speichern
|
||||||
</UButton>
|
</UButton>
|
||||||
|
<div class="mt-6">
|
||||||
|
<div class="text-sm font-medium">Skontokonten</div>
|
||||||
|
<p class="text-xs text-muted mt-1">
|
||||||
|
DATEV-Automatikkonten für Skonto bei Zahlungen. Standard ist SKR 03.
|
||||||
|
</p>
|
||||||
|
<div class="mt-3 grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
|
<div v-for="side in ['received', 'granted']" :key="side" class="space-y-2">
|
||||||
|
<div class="text-xs font-semibold uppercase text-muted">
|
||||||
|
{{ side === 'received' ? 'Erhaltene Skonti (Eingangsbelege)' : 'Gewährte Skonti (Ausgangsbelege)' }}
|
||||||
|
</div>
|
||||||
|
<UFormField
|
||||||
|
v-for="rate in ['19', '7', '0']"
|
||||||
|
:key="`${side}-${rate}`"
|
||||||
|
:label="cashDiscountRateLabels[rate]"
|
||||||
|
>
|
||||||
|
<UInput v-model="cashDiscountAccounts[side][rate]" inputmode="numeric"/>
|
||||||
|
</UFormField>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<UButton
|
||||||
|
class="mt-3"
|
||||||
|
@click="saveCashDiscountAccounts"
|
||||||
|
>
|
||||||
|
Skontokonten speichern
|
||||||
|
</UButton>
|
||||||
</UForm>
|
</UForm>
|
||||||
</UCard>
|
</UCard>
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { getCashDiscountTaxBreakdown } from "./cashDiscount"
|
||||||
|
|
||||||
export const BANK_ALLOCATION_TAX_ITEMS = [
|
export const BANK_ALLOCATION_TAX_ITEMS = [
|
||||||
{ value: "9", label: "19 % Vorsteuer", rate: 19, kind: "input" },
|
{ value: "9", label: "19 % Vorsteuer", rate: 19, kind: "input" },
|
||||||
{ value: "8", label: "7 % Vorsteuer", rate: 7, kind: "input" },
|
{ value: "8", label: "7 % Vorsteuer", rate: 7, kind: "input" },
|
||||||
@@ -25,19 +27,19 @@ export function getBankAllocationAmounts(allocation: any) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getBankAllocationTaxBreakdown(allocation: any) {
|
export function getBankAllocationTaxBreakdown(allocation: any) {
|
||||||
const output = { net19: 0, tax19: 0, net7: 0, tax7: 0, net0: 0 }
|
// Skonto auf zugeordnete Belege korrigiert die Steuer im Zeitraum der Zahlung.
|
||||||
const input = { ...output }
|
const { output, input } = getCashDiscountTaxBreakdown(allocation)
|
||||||
if (!isDirectBankAllocation(allocation)) return { output, input }
|
if (!isDirectBankAllocation(allocation)) return { output, input }
|
||||||
const { amountNet, amountTax, rate, kind } = getBankAllocationAmounts(allocation)
|
const { amountNet, amountTax, rate, kind } = getBankAllocationAmounts(allocation)
|
||||||
if (kind === "none") return { output, input }
|
if (kind === "none") return { output, input }
|
||||||
const target = kind === "input" ? input : output
|
const target = kind === "input" ? input : output
|
||||||
const sign = kind === "input" ? -1 : 1
|
const sign = kind === "input" ? -1 : 1
|
||||||
if (rate === 19) {
|
if (rate === 19) {
|
||||||
target.net19 = amountNet * sign
|
target.net19 += amountNet * sign
|
||||||
target.tax19 = amountTax * sign
|
target.tax19 += amountTax * sign
|
||||||
} else if (rate === 7) {
|
} else if (rate === 7) {
|
||||||
target.net7 = amountNet * sign
|
target.net7 += amountNet * sign
|
||||||
target.tax7 = amountTax * sign
|
target.tax7 += amountTax * sign
|
||||||
}
|
}
|
||||||
return { output, input }
|
return { output, input }
|
||||||
}
|
}
|
||||||
|
|||||||
61
frontend/utils/cashDiscount.ts
Normal file
61
frontend/utils/cashDiscount.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
// Skonto wird an der Bankzuordnung gespeichert (cashDiscountAmount, gleiches Vorzeichen wie amount).
|
||||||
|
// Zahlbetrag und Skonto gleichen den Beleg gemeinsam aus; der Bankumsatz selbst enthält nur amount.
|
||||||
|
|
||||||
|
export const CASH_DISCOUNT_RATES = ["19", "7", "0"] as const
|
||||||
|
|
||||||
|
export const DEFAULT_CASH_DISCOUNT_ACCOUNTS = {
|
||||||
|
received: { "19": "3736", "7": "3731", "0": "3730" },
|
||||||
|
granted: { "19": "8736", "7": "8731", "0": "8730" },
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getAllocationCashDiscount = (allocation: any) => Number(allocation?.cashDiscountAmount || 0)
|
||||||
|
|
||||||
|
export const getAllocationSettledAmount = (allocation: any) =>
|
||||||
|
Number(allocation?.amount || 0) + getAllocationCashDiscount(allocation)
|
||||||
|
|
||||||
|
export const sumSettledAllocations = (allocations: any[] = []) =>
|
||||||
|
(allocations || []).reduce((sum, allocation) => sum + getAllocationSettledAmount(allocation), 0)
|
||||||
|
|
||||||
|
export const hasCashDiscount = (allocation: any) =>
|
||||||
|
!allocation?.archived && Array.isArray(allocation?.cashDiscountSplit) && allocation.cashDiscountSplit.length > 0
|
||||||
|
|
||||||
|
// Steuerkorrektur nach § 17 UStG im Zeitraum der Zahlung.
|
||||||
|
// Ausgangsbelege mindern die Umsatzsteuer, Eingangsbelege die Vorsteuer (Vorzeichen aus der Zahlung).
|
||||||
|
export function getCashDiscountTaxBreakdown(allocation: any) {
|
||||||
|
const output = { net19: 0, tax19: 0, net7: 0, tax7: 0, net0: 0 }
|
||||||
|
const input = { ...output }
|
||||||
|
if (!hasCashDiscount(allocation)) return { output, input }
|
||||||
|
|
||||||
|
const isOutgoing = Boolean(allocation.createddocument || allocation.cd_id)
|
||||||
|
const target = isOutgoing ? output : input
|
||||||
|
const sign = isOutgoing ? -1 : 1
|
||||||
|
|
||||||
|
for (const item of allocation.cashDiscountSplit) {
|
||||||
|
const net = Number(item?.net || 0) * sign
|
||||||
|
const tax = Number(item?.tax || 0) * sign
|
||||||
|
if (Number(item?.rate) === 19) {
|
||||||
|
target.net19 += net
|
||||||
|
target.tax19 += tax
|
||||||
|
} else if (Number(item?.rate) === 7) {
|
||||||
|
target.net7 += net
|
||||||
|
target.tax7 += tax
|
||||||
|
} else {
|
||||||
|
target.net0 += net
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { output, input }
|
||||||
|
}
|
||||||
|
|
||||||
|
export const resolveCashDiscountAccounts = (config: any) => {
|
||||||
|
const result = {
|
||||||
|
received: { ...DEFAULT_CASH_DISCOUNT_ACCOUNTS.received },
|
||||||
|
granted: { ...DEFAULT_CASH_DISCOUNT_ACCOUNTS.granted },
|
||||||
|
}
|
||||||
|
for (const side of ["received", "granted"] as const) {
|
||||||
|
for (const rate of CASH_DISCOUNT_RATES) {
|
||||||
|
const value = config?.[side]?.[rate]
|
||||||
|
if (value !== undefined && value !== null && String(value).trim()) result[side][rate] = String(value).trim()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user