dev #257
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)
|
||||||
|
if (!Number(prepared.data.cashDiscountAmount || 0)) {
|
||||||
prepared.data = await useCurrentIncomingInvoiceAmount(req.user.tenant_id, prepared.data)
|
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