dev #257

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

View File

@@ -0,0 +1,15 @@
ALTER TABLE "createddocuments" ADD COLUMN "cashDiscountPercent" double precision;
--> statement-breakpoint
ALTER TABLE "createddocuments" ADD COLUMN "cashDiscountDays" smallint;
--> statement-breakpoint
ALTER TABLE "incominginvoices" ADD COLUMN "cashDiscountPercent" double precision;
--> statement-breakpoint
ALTER TABLE "incominginvoices" ADD COLUMN "cashDiscountDueDate" text;
--> statement-breakpoint
ALTER TABLE "customers" ADD COLUMN "customCashDiscountPercent" double precision;
--> statement-breakpoint
ALTER TABLE "customers" ADD COLUMN "customCashDiscountDays" smallint;
--> statement-breakpoint
ALTER TABLE "vendors" ADD COLUMN "cashDiscountPercent" double precision;
--> statement-breakpoint
ALTER TABLE "vendors" ADD COLUMN "cashDiscountDays" smallint;

View File

@@ -575,6 +575,13 @@
"when": 1792108800001, "when": 1792108800001,
"tag": "0084_cash_discounts", "tag": "0084_cash_discounts",
"breakpoints": true "breakpoints": true
},
{
"idx": 82,
"version": "7",
"when": 1792195200001,
"tag": "0085_cash_discount_terms",
"breakpoints": true
} }
] ]
} }

View File

@@ -71,6 +71,8 @@ export const createddocuments = pgTable("createddocuments", {
deliveryDateType: text("deliveryDateType"), deliveryDateType: text("deliveryDateType"),
paymentDays: smallint("paymentDays"), paymentDays: smallint("paymentDays"),
cashDiscountPercent: doublePrecision("cashDiscountPercent"),
cashDiscountDays: smallint("cashDiscountDays"),
deliveryDate: text("deliveryDate"), deliveryDate: text("deliveryDate"),
contactPerson: uuid("contactPerson"), contactPerson: uuid("contactPerson"),

View File

@@ -7,6 +7,7 @@ import {
jsonb, jsonb,
smallint, smallint,
uuid, uuid,
doublePrecision,
} from "drizzle-orm/pg-core" } from "drizzle-orm/pg-core"
import { tenants } from "./tenants" import { tenants } from "./tenants"
import { authUsers } from "./auth_users" import { authUsers } from "./auth_users"
@@ -41,6 +42,8 @@ export const customers = pgTable(
profiles: jsonb("profiles").notNull().default([]), profiles: jsonb("profiles").notNull().default([]),
customPaymentDays: smallint("customPaymentDays"), customPaymentDays: smallint("customPaymentDays"),
customCashDiscountPercent: doublePrecision("customCashDiscountPercent"),
customCashDiscountDays: smallint("customCashDiscountDays"),
firstname: text("firstname"), firstname: text("firstname"),
lastname: text("lastname"), lastname: text("lastname"),

View File

@@ -7,6 +7,7 @@ import {
jsonb, jsonb,
numeric, numeric,
uuid, uuid,
doublePrecision,
} from "drizzle-orm/pg-core" } from "drizzle-orm/pg-core"
import { vendors } from "./vendors" import { vendors } from "./vendors"
@@ -36,6 +37,8 @@ export const incominginvoices = pgTable("incominginvoices", {
document: bigint("document", { mode: "number" }), document: bigint("document", { mode: "number" }),
dueDate: text("dueDate"), dueDate: text("dueDate"),
cashDiscountPercent: doublePrecision("cashDiscountPercent"),
cashDiscountDueDate: text("cashDiscountDueDate"),
description: text("description"), description: text("description"),

View File

@@ -6,6 +6,8 @@ import {
boolean, boolean,
jsonb, jsonb,
uuid, uuid,
doublePrecision,
smallint,
} from "drizzle-orm/pg-core" } from "drizzle-orm/pg-core"
import { tenants } from "./tenants" import { tenants } from "./tenants"
@@ -36,6 +38,8 @@ export const vendors = pgTable("vendors", {
archived: boolean("archived").notNull().default(false), archived: boolean("archived").notNull().default(false),
defaultPaymentMethod: text("defaultPaymentMethod"), defaultPaymentMethod: text("defaultPaymentMethod"),
cashDiscountPercent: doublePrecision("cashDiscountPercent"),
cashDiscountDays: smallint("cashDiscountDays"),
updatedAt: timestamp("updated_at", { withTimezone: true }), updatedAt: timestamp("updated_at", { withTimezone: true }),
updatedBy: uuid("updated_by").references(() => authUsers.id), updatedBy: uuid("updated_by").references(() => authUsers.id),

View File

@@ -36,7 +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" import { normalizeCashDiscountDays, normalizeCashDiscountPercent, prepareCashDiscountAllocation } from "../../modules/cash-discount"
export { prepareStatementAllocationInput } from "../statementAllocations" export { prepareStatementAllocationInput } from "../statementAllocations"
@@ -247,6 +247,8 @@ const buildOutgoingDocumentPayload = (
for (const field of ["paymentDays"] as const) { for (const field of ["paymentDays"] as const) {
if (args[field] !== undefined) payload[field] = numberArg(args, field) if (args[field] !== undefined) payload[field] = numberArg(args, field)
} }
if (args.cashDiscountPercent !== undefined) payload.cashDiscountPercent = normalizeCashDiscountPercent(args.cashDiscountPercent)
if (args.cashDiscountDays !== undefined) payload.cashDiscountDays = normalizeCashDiscountDays(args.cashDiscountDays)
if (args.type !== undefined) payload.type = documentTypeArg(args) if (args.type !== undefined) payload.type = documentTypeArg(args)
if (args.address !== undefined) payload.address = optionalObjectArg(args, "address") if (args.address !== undefined) payload.address = optionalObjectArg(args, "address")
@@ -432,6 +434,8 @@ const buildIncomingInvoicePayload = (
for (const field of ["state", "reference", "date", "dueDate", "description", "paymentType"] as const) { for (const field of ["state", "reference", "date", "dueDate", "description", "paymentType"] as const) {
if (args[field] !== undefined) payload[field] = stringArg(args, field) if (args[field] !== undefined) payload[field] = stringArg(args, field)
} }
if (args.cashDiscountPercent !== undefined) payload.cashDiscountPercent = normalizeCashDiscountPercent(args.cashDiscountPercent)
if (args.cashDiscountDueDate !== undefined) payload.cashDiscountDueDate = stringArg(args, "cashDiscountDueDate")
if (args.vendor !== undefined) payload.vendor = numberArg(args, "vendor") if (args.vendor !== undefined) payload.vendor = numberArg(args, "vendor")
if (args.document !== undefined) payload.document = numberArg(args, "document") if (args.document !== undefined) payload.document = numberArg(args, "document")
if (args.expense !== undefined) payload.expense = args.expense === true if (args.expense !== undefined) payload.expense = args.expense === true
@@ -635,6 +639,8 @@ export const accountingTools: McpTool[] = [
deliveryDateEnd: { type: "string" }, deliveryDateEnd: { type: "string" },
deliveryDateType: { type: "string" }, deliveryDateType: { type: "string" },
paymentDays: { type: "number" }, paymentDays: { type: "number" },
cashDiscountPercent: { type: ["number", "null"], description: "Skontosatz in Prozent (0 bis 10). Wird im PDF als Skontohinweis ausgegeben." },
cashDiscountDays: { type: ["number", "null"], description: "Skontofrist in Tagen ab Belegdatum." },
payment_type: { type: "string" }, payment_type: { type: "string" },
taxType: { taxType: {
type: "string", type: "string",
@@ -692,6 +698,8 @@ export const accountingTools: McpTool[] = [
deliveryDateEnd: { type: "string" }, deliveryDateEnd: { type: "string" },
deliveryDateType: { type: "string" }, deliveryDateType: { type: "string" },
paymentDays: { type: "number" }, paymentDays: { type: "number" },
cashDiscountPercent: { type: ["number", "null"], description: "Skontosatz in Prozent (0 bis 10). Wird im PDF als Skontohinweis ausgegeben." },
cashDiscountDays: { type: ["number", "null"], description: "Skontofrist in Tagen ab Belegdatum." },
payment_type: { type: "string" }, payment_type: { type: "string" },
taxType: { taxType: {
type: "string", type: "string",
@@ -882,6 +890,8 @@ export const accountingTools: McpTool[] = [
deliveryDate: { type: "string" }, deliveryDate: { type: "string" },
deliveryDateEnd: { type: "string" }, deliveryDateEnd: { type: "string" },
paymentDays: { type: "number" }, paymentDays: { type: "number" },
cashDiscountPercent: { type: ["number", "null"], description: "Skontosatz in Prozent (0 bis 10). Wird im PDF als Skontohinweis ausgegeben." },
cashDiscountDays: { type: ["number", "null"], description: "Skontofrist in Tagen ab Belegdatum." },
payment_type: { type: "string" }, payment_type: { type: "string" },
taxType: { taxType: {
type: "string", type: "string",
@@ -1201,6 +1211,8 @@ export const accountingTools: McpTool[] = [
reference: { type: "string" }, reference: { type: "string" },
date: { type: "string" }, date: { type: "string" },
dueDate: { type: "string" }, dueDate: { type: "string" },
cashDiscountPercent: { type: ["number", "null"] },
cashDiscountDueDate: { type: ["string", "null"] },
document: { type: "number" }, document: { type: "number" },
description: { type: "string" }, description: { type: "string" },
paymentType: { type: "string" }, paymentType: { type: "string" },
@@ -1318,6 +1330,8 @@ export const accountingTools: McpTool[] = [
reference: { type: "string" }, reference: { type: "string" },
date: { type: "string" }, date: { type: "string" },
dueDate: { type: "string" }, dueDate: { type: "string" },
cashDiscountPercent: { type: ["number", "null"], description: "Skontosatz in Prozent laut Rechnung." },
cashDiscountDueDate: { type: ["string", "null"], description: "Letzter Tag der Skontofrist (YYYY-MM-DD)." },
document: { type: "number" }, document: { type: "number" },
description: { type: "string" }, description: { type: "string" },
paymentType: { type: "string" }, paymentType: { type: "string" },
@@ -1351,6 +1365,8 @@ export const accountingTools: McpTool[] = [
reference: { type: "string" }, reference: { type: "string" },
date: { type: "string" }, date: { type: "string" },
dueDate: { type: "string" }, dueDate: { type: "string" },
cashDiscountPercent: { type: ["number", "null"], description: "Skontosatz in Prozent laut Rechnung." },
cashDiscountDueDate: { type: ["string", "null"], description: "Letzter Tag der Skontofrist (YYYY-MM-DD)." },
document: { type: "number" }, document: { type: "number" },
description: { type: "string" }, description: { type: "string" },
paymentType: { type: "string" }, paymentType: { type: "string" },

View File

@@ -46,14 +46,14 @@ export const masterdataWriteConfigs: Config[] = [
makeConfig({ makeConfig({
resource: "customers", namespace: "customers", label: "Kunde", resultKey: "customer", table: tables.customers, resource: "customers", namespace: "customers", label: "Kunde", resultKey: "customer", table: tables.customers,
required: ["name"], numberField: "customerNumber", required: ["name"], numberField: "customerNumber",
fieldNames: "name customerNumber infoData active notes type isCompany profiles customPaymentDays firstname lastname customSurchargePercentage salutation title nameAddition availableInPortal customPaymentType customTaxType memberrelation", fieldNames: "name customerNumber infoData active notes type isCompany profiles customPaymentDays firstname lastname customSurchargePercentage salutation title nameAddition availableInPortal customPaymentType customTaxType memberrelation customCashDiscountPercent customCashDiscountDays",
overrides: { customerNumber: textField(true), infoData: infoDataField, profiles: profilesField, memberrelation: idField }, overrides: { customerNumber: textField(true), infoData: infoDataField, profiles: profilesField, memberrelation: idField },
references: [commonProfiles, bankAccounts, { field: "memberrelation", table: tables.memberrelations }], references: [commonProfiles, bankAccounts, { field: "memberrelation", table: tables.memberrelations }],
}), }),
makeConfig({ makeConfig({
resource: "vendors", namespace: "vendors", label: "Lieferant", resultKey: "vendor", table: tables.vendors, resource: "vendors", namespace: "vendors", label: "Lieferant", resultKey: "vendor", table: tables.vendors,
required: ["name"], numberField: "vendorNumber", required: ["name"], numberField: "vendorNumber",
fieldNames: "name vendorNumber infoData notes hasSEPA profiles defaultPaymentMethod", fieldNames: "name vendorNumber infoData notes hasSEPA profiles defaultPaymentMethod cashDiscountPercent cashDiscountDays",
overrides: { vendorNumber: textField(true), infoData: infoDataField, profiles: profilesField }, overrides: { vendorNumber: textField(true), infoData: infoDataField, profiles: profilesField },
references: [commonProfiles, bankAccounts], references: [commonProfiles, bankAccounts],
}), }),

View File

@@ -272,3 +272,107 @@ export function validateCashDiscount({ amount, discount, total, settled }: { amo
throw invalidInput(`Der Skonto ist höher als ${MAX_CASH_DISCOUNT_SHARE * 100} % des Belegbetrags. Bitte Betrag prüfen.`) throw invalidInput(`Der Skonto ist höher als ${MAX_CASH_DISCOUNT_SHARE * 100} % des Belegbetrags. Bitte Betrag prüfen.`)
} }
} }
// ---------------------------------------------------------------------------
// Skonto-Bedingungen auf Belegen
// ---------------------------------------------------------------------------
export type CashDiscountTerms = {
percent: number
days: number | null
dueDate: string | null
discount: number
payable: number
}
const formatEuro = (value: number) => new Intl.NumberFormat("de-DE", { style: "currency", currency: "EUR" }).format(value)
const formatGermanDate = (value: string) => {
const [year, month, day] = value.split("-")
return `${day}.${month}.${year}`
}
const addDays = (date: string, days: number) => {
const parsed = new Date(`${String(date).slice(0, 10)}T00:00:00Z`)
if (Number.isNaN(parsed.getTime())) return null
parsed.setUTCDate(parsed.getUTCDate() + days)
return parsed.toISOString().slice(0, 10)
}
export function normalizeCashDiscountPercent(value: unknown) {
if (value === null || value === undefined || value === "") return null
const percent = Number(String(value).replace(",", "."))
if (!Number.isFinite(percent) || percent < 0 || percent > 10) {
throw invalidInput("Der Skontosatz muss zwischen 0 und 10 % liegen.")
}
return percent === 0 ? null : Number(percent.toFixed(2))
}
export function normalizeCashDiscountDays(value: unknown) {
if (value === null || value === undefined || value === "") return null
const days = Number(value)
if (!Number.isInteger(days) || days < 0 || days > 365) {
throw invalidInput("Die Skontofrist muss zwischen 0 und 365 Tagen liegen.")
}
return days
}
// Berechnet Frist, Skonto und Zahlbetrag aus Satz, Tagen und Belegdatum.
export function buildCashDiscountTerms({ percent, days, documentDate, dueDate, amount }: {
percent: unknown, days?: unknown, documentDate?: string | null, dueDate?: string | null, amount: number
}): CashDiscountTerms | null {
const normalizedPercent = Number(percent)
if (!Number.isFinite(normalizedPercent) || normalizedPercent <= 0 || !Number.isFinite(amount) || !amount) return null
const normalizedDays = days === null || days === undefined || days === "" ? null : Number(days)
const computedDueDate = dueDate
? String(dueDate).slice(0, 10)
: documentDate && normalizedDays !== null && Number.isFinite(normalizedDays) ? addDays(documentDate, normalizedDays) : null
const discount = money(amount * normalizedPercent / 100)
return {
percent: normalizedPercent,
days: normalizedDays !== null && Number.isFinite(normalizedDays) ? normalizedDays : null,
dueDate: computedDueDate,
discount,
payable: money(amount - discount),
}
}
export function formatCashDiscountText(terms: CashDiscountTerms | null) {
if (!terms) return ""
const percent = String(terms.percent).replace(".", ",")
const deadline = terms.dueDate
? `bis zum ${formatGermanDate(terms.dueDate)}`
: terms.days !== null ? `innerhalb von ${terms.days} Tagen` : "bei sofortiger Zahlung"
return `Bei Zahlung ${deadline} gewähren wir ${percent} % Skonto (${formatEuro(Math.abs(terms.discount))}), Zahlbetrag ${formatEuro(Math.abs(terms.payable))}.`
}
// Kontextvariablen für Textvorlagen; der Satz wird automatisch ergänzt, wenn die Vorlage keine Skontovariable nutzt.
export function getCashDiscountTemplateContext(terms: CashDiscountTerms | null) {
if (!terms) return {}
return {
skonto_prozent: String(terms.percent).replace(".", ","),
skonto_tage: terms.days,
skonto_datum: terms.dueDate ? formatGermanDate(terms.dueDate) : null,
skonto_betrag: formatEuro(Math.abs(terms.discount)),
zahlbetrag_mit_skonto: formatEuro(Math.abs(terms.payable)),
skonto_hinweis: formatCashDiscountText(terms),
}
}
export function appendCashDiscountText(renderedText: string, template: string | null | undefined, terms: CashDiscountTerms | null) {
if (!terms || /skonto_/i.test(String(template || ""))) return renderedText
const sentence = formatCashDiscountText(terms)
return renderedText ? `${sentence}\n\n${renderedText}` : sentence
}
// Liest Skontobedingungen aus E-Rechnungen: XRechnung "#SKONTO#TAGE=14#PROZENT=2.00#" im Zahlungsbedingungstext.
export function parseCashDiscountPaymentTerms(text: unknown): { percent: number, days: number } | null {
const source = String(text || "")
const matches = [...source.matchAll(/#SKONTO#([^\n]*?)#(?:\s|$)/gi)]
for (const match of matches) {
const segment = match[1]
const days = Number(segment.match(/TAGE=(\d+)/i)?.[1])
const percent = Number(segment.match(/PROZENT=(\d+(?:[.,]\d+)?)/i)?.[1]?.replace(",", "."))
if (Number.isInteger(days) && Number.isFinite(percent) && percent > 0) return { percent, days }
}
return null
}

View File

@@ -31,7 +31,7 @@ const formatInvoiceItemDescription = (item: any) => {
return parts.join(" - ") return parts.join(" - ")
} }
const mapGptDataToIncomingInvoice = (data: any, tenantId: number) => { export const mapGptDataToIncomingInvoice = (data: any, tenantId: number) => {
const itemInfo: any = { const itemInfo: any = {
tenant: tenantId, tenant: tenantId,
state: "Vorbereitet", state: "Vorbereitet",
@@ -43,6 +43,17 @@ const mapGptDataToIncomingInvoice = (data: any, tenantId: number) => {
if (data.issuer?.id) itemInfo.vendor = data.issuer.id if (data.issuer?.id) itemInfo.vendor = data.issuer.id
if (data.invoice_duedate && dayjs(data.invoice_duedate).isValid()) itemInfo.dueDate = dayjs(data.invoice_duedate).toISOString() if (data.invoice_duedate && dayjs(data.invoice_duedate).isValid()) itemInfo.dueDate = dayjs(data.invoice_duedate).toISOString()
// Skonto nur übernehmen, wenn die Rechnung ihn ausdrücklich nennt (siehe Prompt).
const cashDiscountPercent = Number(data.cash_discount_percent)
if (Number.isFinite(cashDiscountPercent) && cashDiscountPercent > 0 && cashDiscountPercent <= 10) {
itemInfo.cashDiscountPercent = Number(cashDiscountPercent.toFixed(2))
if (data.cash_discount_due_date && dayjs(data.cash_discount_due_date).isValid()) {
itemInfo.cashDiscountDueDate = dayjs(data.cash_discount_due_date).format("YYYY-MM-DD")
} else if (Number.isInteger(data.cash_discount_days) && data.invoice_date && dayjs(data.invoice_date).isValid()) {
itemInfo.cashDiscountDueDate = dayjs(data.invoice_date).add(data.cash_discount_days, "day").format("YYYY-MM-DD")
}
}
const mapPayment: Record<string, string> = { const mapPayment: Record<string, string> = {
"Direct Debit": "Einzug", "Direct Debit": "Einzug",
"Transfer": "Überweisung", "Transfer": "Überweisung",

View File

@@ -41,6 +41,13 @@ export const findElectronicInvoiceVendor = async (
return null return null
} }
const addDaysToDate = (date: string, days: number) => {
const parsed = new Date(`${String(date).slice(0, 10)}T00:00:00Z`)
if (Number.isNaN(parsed.getTime())) return null
parsed.setUTCDate(parsed.getUTCDate() + days)
return parsed.toISOString().slice(0, 10)
}
export const mapElectronicInvoiceToIncomingInvoice = ( export const mapElectronicInvoiceToIncomingInvoice = (
invoice: ParsedElectronicInvoice, invoice: ParsedElectronicInvoice,
tenantId: number, tenantId: number,
@@ -64,6 +71,10 @@ export const mapElectronicInvoiceToIncomingInvoice = (
reference: invoice.invoiceNumber, reference: invoice.invoiceNumber,
date: invoice.invoiceDate, date: invoice.invoiceDate,
dueDate: invoice.dueDate, dueDate: invoice.dueDate,
cashDiscountPercent: invoice.cashDiscount?.percent ?? null,
cashDiscountDueDate: invoice.cashDiscount && invoice.invoiceDate
? addDaysToDate(invoice.invoiceDate, invoice.cashDiscount.days)
: null,
paymentType: paymentTypeByCode[invoice.paymentMeansCode || ""] || "Sonstiges", paymentType: paymentTypeByCode[invoice.paymentMeansCode || ""] || "Sonstiges",
description: descriptionParts.join("\n"), description: descriptionParts.join("\n"),
accounts: invoice.items.map((item) => ({ accounts: invoice.items.map((item) => ({

View File

@@ -1,6 +1,7 @@
import { XMLParser } from "fast-xml-parser" import { XMLParser } from "fast-xml-parser"
import type { ElectronicInvoiceSyntax } from "./detectElectronicInvoice" import type { ElectronicInvoiceSyntax } from "./detectElectronicInvoice"
import type { ElectronicInvoiceItem, ParsedElectronicInvoice } from "./types" import type { ElectronicInvoiceItem, ParsedElectronicInvoice } from "./types"
import { parseCashDiscountPaymentTerms } from "../cash-discount"
const parser = new XMLParser({ const parser = new XMLParser({
ignoreAttributes: false, ignoreAttributes: false,
@@ -83,7 +84,9 @@ const parseCii = (root: any): Omit<ParsedElectronicInvoice, "validation"> => {
}) })
const paymentMeans = array(settlement?.SpecifiedTradeSettlementPaymentMeans)[0] || {} const paymentMeans = array(settlement?.SpecifiedTradeSettlementPaymentMeans)[0] || {}
const paymentTerms = array(settlement?.SpecifiedTradePaymentTerms)[0] || {} const paymentTermsList = array(settlement?.SpecifiedTradePaymentTerms)
const paymentTerms = paymentTermsList[0] || {}
const cashDiscount = ciiCashDiscount(paymentTermsList)
const guideline = root?.ExchangedDocumentContext?.GuidelineSpecifiedDocumentContextParameter?.ID const guideline = root?.ExchangedDocumentContext?.GuidelineSpecifiedDocumentContextParameter?.ID
return { return {
@@ -103,6 +106,7 @@ const parseCii = (root: any): Omit<ParsedElectronicInvoice, "validation"> => {
}, },
buyerReference: text(agreement?.BuyerReference), buyerReference: text(agreement?.BuyerReference),
paymentMeansCode: text(paymentMeans?.TypeCode), paymentMeansCode: text(paymentMeans?.TypeCode),
cashDiscount,
items, items,
totals: { totals: {
lineNet: number(monetary?.LineTotalAmount) != null ? roundMoney(sign * number(monetary.LineTotalAmount)!) : null, lineNet: number(monetary?.LineTotalAmount) != null ? roundMoney(sign * number(monetary.LineTotalAmount)!) : null,
@@ -113,6 +117,18 @@ const parseCii = (root: any): Omit<ParsedElectronicInvoice, "validation"> => {
} }
} }
// Skonto steht in XRechnung als "#SKONTO#TAGE=14#PROZENT=2.00#" im Zahlungsbedingungstext,
// in ZUGFeRD EXTENDED zusätzlich strukturiert als ApplicableTradePaymentDiscountTerms.
const ciiCashDiscount = (paymentTermsList: any[]) => {
for (const terms of paymentTermsList) {
const discountTerms = array(terms?.ApplicableTradePaymentDiscountTerms)[0]
const percent = number(discountTerms?.CalculationPercent)
const days = number(discountTerms?.BasisPeriodMeasure)
if (percent && percent > 0 && days !== null && Number.isInteger(days)) return { percent, days }
}
return parseCashDiscountPaymentTerms(paymentTermsList.flatMap((terms: any) => array(terms?.Description).map(text)).join("\n"))
}
const ublPartyName = (party: any) => text(party?.PartyLegalEntity?.RegistrationName) || text(party?.PartyName?.Name) const ublPartyName = (party: any) => text(party?.PartyLegalEntity?.RegistrationName) || text(party?.PartyName?.Name)
const parseUbl = (root: any, syntax: "ubl-invoice" | "ubl-credit-note"): Omit<ParsedElectronicInvoice, "validation"> => { const parseUbl = (root: any, syntax: "ubl-invoice" | "ubl-credit-note"): Omit<ParsedElectronicInvoice, "validation"> => {
@@ -163,6 +179,7 @@ const parseUbl = (root: any, syntax: "ubl-invoice" | "ubl-credit-note"): Omit<Pa
}, },
buyerReference: text(root?.BuyerReference), buyerReference: text(root?.BuyerReference),
paymentMeansCode: text(paymentMeans?.PaymentMeansCode), paymentMeansCode: text(paymentMeans?.PaymentMeansCode),
cashDiscount: parseCashDiscountPaymentTerms(array(root?.PaymentTerms).flatMap((terms: any) => array(terms?.Note).map(text)).join("\n")),
items, items,
totals: { totals: {
lineNet: number(monetary?.LineExtensionAmount) != null ? roundMoney(sign * number(monetary.LineExtensionAmount)!) : null, lineNet: number(monetary?.LineExtensionAmount) != null ? roundMoney(sign * number(monetary.LineExtensionAmount)!) : null,

View File

@@ -28,6 +28,7 @@ export type ParsedElectronicInvoice = {
} }
buyerReference: string | null buyerReference: string | null
paymentMeansCode: string | null paymentMeansCode: string | null
cashDiscount?: { percent: number, days: number } | null
items: ElectronicInvoiceItem[] items: ElectronicInvoiceItem[]
totals: { totals: {
lineNet: number | null lineNet: number | null

View File

@@ -18,6 +18,7 @@ import {
} from "../../db/schema" } from "../../db/schema"
import { documentTemplateHandlebars } from "../utils/handlebars" import { documentTemplateHandlebars } from "../utils/handlebars"
import { createInvoicePDF, InvoicePdfLayout } from "../utils/pdf" import { createInvoicePDF, InvoicePdfLayout } from "../utils/pdf"
import { appendCashDiscountText, buildCashDiscountTerms, getCashDiscountTemplateContext } from "./cash-discount"
const quoteLikeDocumentTypes = ["quotes", "costEstimates"] const quoteLikeDocumentTypes = ["quotes", "costEstimates"]
const deliveryNoteLikeDocumentTypes = ["deliveryNotes", "packingSlips"] const deliveryNoteLikeDocumentTypes = ["deliveryNotes", "packingSlips"]
@@ -130,6 +131,15 @@ export function buildOutgoingDocumentPdfData(source: any, data: OutgoingDocument
} }
const totals = calculateOutgoingDocumentTotals(document, data.advanceInvoices) const totals = calculateOutgoingDocumentTotals(document, data.advanceInvoices)
// Skonto bezieht sich bei Schlussrechnungen auf die Restforderung nach Abzug der Abschläge.
const cashDiscountTerms = ["invoices", "advanceInvoices"].includes(document.type)
? buildCashDiscountTerms({
percent: document.cashDiscountPercent,
days: document.cashDiscountDays,
documentDate: document.documentDate,
amount: document.type === "invoices" ? totals.totalSumToPay : totals.totalGross,
})
: null
const rows = document.rows.map((row: any) => { const rows = document.rows.map((row: any) => {
if (!["pagebreak", "title"].includes(row.mode)) { if (!["pagebreak", "title"].includes(row.mode)) {
if (row.agriculture?.description) row.descriptionText = row.agriculture.description if (row.agriculture?.description) row.descriptionText = row.agriculture.description
@@ -160,6 +170,7 @@ export function buildOutgoingDocumentPdfData(source: any, data: OutgoingDocument
zahlungsziel_in_tagen: document.paymentDays, zahlungsziel_in_tagen: document.paymentDays,
zahlungsart: document.payment_type === "transfer" ? "Überweisung" : "Lastschrift", zahlungsart: document.payment_type === "transfer" ? "Überweisung" : "Lastschrift",
diesel_gesamtverbrauch: document.agriculture?.dieselUsageTotal, diesel_gesamtverbrauch: document.agriculture?.dieselUsageTotal,
...getCashDiscountTemplateContext(cashDiscountTerms),
} }
const formatDate = (value: any) => value ? dayjs(value).format("DD.MM.YYYY") : "XXXX" const formatDate = (value: any) => value ? dayjs(value).format("DD.MM.YYYY") : "XXXX"
const formatSums = (sums: Record<string, number>) => Object.fromEntries(Object.entries(sums).map(([key, value]) => [key, renderCurrency(value)])) const formatSums = (sums: Record<string, number>) => Object.fromEntries(Object.entries(sums).map(([key, value]) => [key, renderCurrency(value)]))
@@ -198,7 +209,7 @@ export function buildOutgoingDocumentPdfData(source: any, data: OutgoingDocument
], ],
title: document.title, title: document.title,
description: document.description, description: document.description,
endText: documentTemplateHandlebars.compile(document.endText || "")(context), endText: appendCashDiscountText(documentTemplateHandlebars.compile(document.endText || "")(context), document.endText, cashDiscountTerms),
startText: documentTemplateHandlebars.compile(document.startText || "")(context), startText: documentTemplateHandlebars.compile(document.startText || "")(context),
rows, rows,
totalArray: [ totalArray: [

View File

@@ -10,6 +10,7 @@ import {FastifyInstance} from "fastify";
import {useNextNumberRangeNumber} from "../utils/functions"; import {useNextNumberRangeNumber} from "../utils/functions";
import {createInvoicePDF} from "../utils/pdf"; // Achtung: Muss Node.js Buffer unterstützen! import {createInvoicePDF} from "../utils/pdf"; // Achtung: Muss Node.js Buffer unterstützen!
import { documentTemplateHandlebars } from "../utils/handlebars"; import { documentTemplateHandlebars } from "../utils/handlebars";
import { appendCashDiscountText, buildCashDiscountTerms, getCashDiscountTemplateContext } from "./cash-discount"
dayjs.extend(quarterOfYear); dayjs.extend(quarterOfYear);
@@ -421,6 +422,8 @@ async function getSaveData(item: any, tenant: any, firstDate: string, lastDate:
deliveryDate: firstDate, deliveryDate: firstDate,
deliveryDateEnd: lastDate, deliveryDateEnd: lastDate,
paymentDays: item.paymentDays, paymentDays: item.paymentDays,
cashDiscountPercent: item.cashDiscountPercent,
cashDiscountDays: item.cashDiscountDays,
payment_type: item.payment_type, payment_type: item.payment_type,
deliveryDateType: item.deliveryDateType, deliveryDateType: item.deliveryDateType,
info: {}, // Achtung: Postgres erwartet hier valides JSON Objekt info: {}, // Achtung: Postgres erwartet hier valides JSON Objekt
@@ -646,6 +649,12 @@ export function getDocumentDataBackend(
// --- 5. Handlebars Context --- // --- 5. Handlebars Context ---
const cashDiscountTerms = buildCashDiscountTerms({
percent: itemInfo.cashDiscountPercent,
days: itemInfo.cashDiscountDays,
documentDate: itemInfo.documentDate ? dayjs(itemInfo.documentDate).format("YYYY-MM-DD") : null,
amount: totals.totalGross,
});
const generateContext = () => { const generateContext = () => {
return { return {
// lohnkosten: null, // Backend hat diesen Wert oft nicht direkt, ggf. aus itemInfo holen // lohnkosten: null, // Backend hat diesen Wert oft nicht direkt, ggf. aus itemInfo holen
@@ -656,7 +665,8 @@ export function getDocumentDataBackend(
kundenname: customerData && customerData.name, kundenname: customerData && customerData.name,
zahlungsziel_in_tagen: itemInfo.paymentDays, zahlungsziel_in_tagen: itemInfo.paymentDays,
zahlungsart: itemInfo.payment_type === "transfer" ? "Überweisung" : "Lastschrift", zahlungsart: itemInfo.payment_type === "transfer" ? "Überweisung" : "Lastschrift",
diesel_gesamtverbrauch: (itemInfo.agriculture && itemInfo.agriculture.dieselUsageTotal) || null diesel_gesamtverbrauch: (itemInfo.agriculture && itemInfo.agriculture.dieselUsageTotal) || null,
...getCashDiscountTemplateContext(cashDiscountTerms),
}; };
}; };
@@ -756,7 +766,7 @@ export function getDocumentDataBackend(
title: itemInfo.title, title: itemInfo.title,
description: itemInfo.description, description: itemInfo.description,
// Handlebars Compilation ausführen // Handlebars Compilation ausführen
endText: templateEndText(generateContext()), endText: appendCashDiscountText(templateEndText(generateContext()), itemInfo.endText, cashDiscountTerms),
startText: templateStartText(generateContext()), startText: templateStartText(generateContext()),
rows: rows, rows: rows,
totalArray: totalArray, totalArray: totalArray,

View File

@@ -74,6 +74,9 @@ const InstructionFormat = z.object({
tax: nullableNumber, tax: nullableNumber,
total: nullableNumber, total: nullableNumber,
terms: nullableString, terms: nullableString,
cash_discount_percent: nullableNumber,
cash_discount_days: nullableNumber,
cash_discount_due_date: nullableString,
}); });
// --------------------------------------------------------- // ---------------------------------------------------------
@@ -190,6 +193,7 @@ export const getInvoiceDataFromGPT = async function (
"Do not merge summary totals into fabricated invoice_items.\n" + "Do not merge summary totals into fabricated invoice_items.\n" +
"Match issuer by name to vendor.id.\n" + "Match issuer by name to vendor.id.\n" +
"Match invoice items to account id based on label/number.\n" + "Match invoice items to account id based on label/number.\n" +
"Set cash_discount_percent, cash_discount_days and cash_discount_due_date only when the invoice explicitly offers Skonto/cash discount (e.g. '2 % Skonto bei Zahlung innerhalb 10 Tagen'); otherwise null.\n" +
"Convert dates to YYYY-MM-DD.\n" + "Convert dates to YYYY-MM-DD.\n" +
"Keep invoice items in original order.\n", "Keep invoice items in original order.\n",
}, },

View File

@@ -114,3 +114,28 @@ test("MCP allocation input accepts cash discounts only with bank statements", ()
amount: 98, cashDiscountAmount: 2, manualBookingDate: "2026-10-01", account: 1, contraAccount: 2, amount: 98, cashDiscountAmount: 2, manualBookingDate: "2026-10-01", account: 1, contraAccount: 2,
}), /Bankzahlung/) }), /Bankzahlung/)
}) })
test("builds cash discount terms and the PDF sentence", async () => {
const { appendCashDiscountText, buildCashDiscountTerms, formatCashDiscountText, getCashDiscountTemplateContext, parseCashDiscountPaymentTerms } = await import("../src/modules/cash-discount")
const terms = buildCashDiscountTerms({ percent: 2, days: 10, documentDate: "2026-11-04", amount: 1190 })
assert.deepEqual(terms, { percent: 2, days: 10, dueDate: "2026-11-14", discount: 23.8, payable: 1166.2 })
assert.equal(formatCashDiscountText(terms).replace(/ /g, " "),
"Bei Zahlung bis zum 14.11.2026 gewähren wir 2 % Skonto (23,80 €), Zahlbetrag 1.166,20 €.")
assert.equal(getCashDiscountTemplateContext(terms).skonto_datum, "14.11.2026")
assert.match(appendCashDiscountText("Vielen Dank.", "Vielen Dank.", terms), /^Bei Zahlung .*\n\nVielen Dank\.$/)
assert.equal(appendCashDiscountText("Eigener Text", "{{skonto_hinweis}}", terms), "Eigener Text")
assert.equal(buildCashDiscountTerms({ percent: null, days: 10, documentDate: "2026-11-04", amount: 100 }), null)
assert.deepEqual(parseCashDiscountPaymentTerms("#SKONTO#TAGE=7#PROZENT=3.00#\n#SKONTO#TAGE=14#PROZENT=2.00#"), { percent: 3, days: 7 })
assert.deepEqual(parseCashDiscountPaymentTerms("#SKONTO#TAGE=14#PROZENT=2,5#BASISBETRAG=100.00#"), { percent: 2.5, days: 14 })
assert.equal(parseCashDiscountPaymentTerms("Zahlbar ohne Abzug"), null)
})
test("maps explicit AI cash discount data onto incoming invoices", async () => {
const { mapGptDataToIncomingInvoice } = await import("../src/modules/cron/prepareIncomingInvoices")
const mapped = mapGptDataToIncomingInvoice({ invoice_date: "2026-11-01", cash_discount_percent: 2, cash_discount_days: 10 }, 1)
assert.equal(mapped.cashDiscountPercent, 2)
assert.equal(mapped.cashDiscountDueDate, "2026-11-11")
const ignored = mapGptDataToIncomingInvoice({ invoice_date: "2026-11-01", cash_discount_percent: 25 }, 1)
assert.equal(ignored.cashDiscountPercent, undefined)
})

View File

@@ -66,3 +66,27 @@ test("markiert Summenabweichungen als Prüfungsfehler", () => {
assert.match(parsed.validation.errors.join(" "), /Positionssumme/) assert.match(parsed.validation.errors.join(" "), /Positionssumme/)
}) })
test("reads XRechnung cash discount terms from CII payment terms", async () => {
const xml = Buffer.from(ciiInvoice.toString().replace(
"<ram:SpecifiedTradePaymentTerms>",
"<ram:SpecifiedTradePaymentTerms><ram:Description>#SKONTO#TAGE=10#PROZENT=2.00#\n</ram:Description>",
))
const detection = await detectElectronicInvoice(xml, { name: "rechnung.xml" })
const parsed = parseElectronicInvoice(detection!.xml, detection!.syntax)
assert.deepEqual(parsed.cashDiscount, { percent: 2, days: 10 })
const mapped = mapElectronicInvoiceToIncomingInvoice(parsed, 1, 5, "xml", null)
assert.equal(mapped.cashDiscountPercent, 2)
assert.equal(mapped.cashDiscountDueDate, "2026-07-31")
})
test("reads cash discount terms from UBL payment terms notes", async () => {
const xml = Buffer.from(ublInvoice.toString().replace(
"<cac:PaymentMeans>",
"<cac:PaymentTerms><cbc:Note>#SKONTO#TAGE=14#PROZENT=3.00#</cbc:Note></cac:PaymentTerms><cac:PaymentMeans>",
))
const detection = await detectElectronicInvoice(xml, { name: "xrechnung.xml" })
const parsed = parseElectronicInvoice(detection!.xml, detection!.syntax)
assert.deepEqual(parsed.cashDiscount, { percent: 3, days: 14 })
})

View File

@@ -47,6 +47,12 @@ Zahlt ein Kunde weniger als den offenen Betrag oder ziehst du bei einem Lieferan
- Skonto über 10 % des Belegs, auf Belege nach §13b UStG oder mit innergemeinschaftlichem Erwerb wird abgelehnt und muss manuell gebucht werden. - 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. - Wird die Bankzuordnung gelöscht, entfällt auch der Skonto.
### Skontobedingungen auf Belegen
- **Ausgangsbelege:** Im Beleg-Editor Skontosatz und Frist in Tagen eintragen. Kunden können unter *Skonto in %* und *Skontofrist in Tagen* eine Vorgabe haben, die bei der Kundenauswahl übernommen wird. Im PDF steht automatisch ein Satz wie „Bei Zahlung bis zum 14.11.2026 gewähren wir 2 % Skonto (23,80 €), Zahlbetrag 1.166,20 €.“ vor dem Endtext. Wer den Satz selbst formulieren will, nutzt in der Textvorlage `{{skonto_hinweis}}`, `{{skonto_prozent}}`, `{{skonto_datum}}`, `{{skonto_betrag}}` oder `{{zahlbetrag_mit_skonto}}`; dann entfällt der automatische Satz. Bei Schlussrechnungen bezieht sich der Skonto auf die Restforderung nach Abzug der Abschläge.
- **Eingangsbelege:** Skontosatz und letzter Skontotag werden aus E-Rechnungen (`#SKONTO#TAGE=…#PROZENT=…#` bzw. ZUGFeRD-Skontobedingungen) und, wenn ausdrücklich genannt, von der KI-Vorbereitung übernommen. Lieferanten können eine Vorgabe haben, die bei der Lieferantenauswahl eingesetzt wird.
- **Vorschläge:** Entspricht eine Zahlung dem offenen Betrag abzüglich des vereinbarten Skontos und liegt sie in der Frist, zeigt die Bankzuordnung „Passt mit 2 % Skonto“. In der Vorschlagsübersicht lässt sich die Zahlung dann direkt mit Skonto zuweisen.
## 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.

View File

@@ -47,6 +47,12 @@ Zahlt ein Kunde weniger als den offenen Betrag oder ziehst du bei einem Lieferan
- Skonto über 10 % des Belegs, auf Belege nach §13b UStG oder mit innergemeinschaftlichem Erwerb wird abgelehnt und muss manuell gebucht werden. - 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. - Wird die Bankzuordnung gelöscht, entfällt auch der Skonto.
### Skontobedingungen auf Belegen
- **Ausgangsbelege:** Im Beleg-Editor Skontosatz und Frist in Tagen eintragen. Kunden können unter *Skonto in %* und *Skontofrist in Tagen* eine Vorgabe haben, die bei der Kundenauswahl übernommen wird. Im PDF steht automatisch ein Satz wie „Bei Zahlung bis zum 14.11.2026 gewähren wir 2 % Skonto (23,80 €), Zahlbetrag 1.166,20 €.“ vor dem Endtext. Wer den Satz selbst formulieren will, nutzt in der Textvorlage `{{skonto_hinweis}}`, `{{skonto_prozent}}`, `{{skonto_datum}}`, `{{skonto_betrag}}` oder `{{zahlbetrag_mit_skonto}}`; dann entfällt der automatische Satz. Bei Schlussrechnungen bezieht sich der Skonto auf die Restforderung nach Abzug der Abschläge.
- **Eingangsbelege:** Skontosatz und letzter Skontotag werden aus E-Rechnungen (`#SKONTO#TAGE=…#PROZENT=…#` bzw. ZUGFeRD-Skontobedingungen) und, wenn ausdrücklich genannt, von der KI-Vorbereitung übernommen. Lieferanten können eine Vorgabe haben, die bei der Lieferantenauswahl eingesetzt wird.
- **Vorschläge:** Entspricht eine Zahlung dem offenen Betrag abzüglich des vereinbarten Skontos und liegt sie in der Frist, zeigt die Bankzuordnung „Passt mit 2 % Skonto“. In der Vorschlagsübersicht lässt sich die Zahlung dann direkt mit Skonto zuweisen.
## 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.

View File

@@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import dayjs from "dayjs" import dayjs from "dayjs"
import { getBankAllocationTaxBreakdown } from "~/utils/bankAllocationTax" import { getBankAllocationTaxBreakdown } from "~/utils/bankAllocationTax"
import { getCashDiscountNetTotals } from "~/utils/cashDiscount"
import { import {
getCreatedDocumentTaxBreakdown, getCreatedDocumentTaxBreakdown,
getIncomingInvoiceTaxBreakdown getIncomingInvoiceTaxBreakdown
@@ -90,7 +91,7 @@ const loadSummary = async () => {
return touchesAccount && date.isValid() && !date.isBefore(bounds.start, "day") && !date.isAfter(bounds.end, "day") return touchesAccount && date.isValid() && !date.isBefore(bounds.start, "day") && !date.isAfter(bounds.end, "day")
}) })
const income = outputDocs.reduce((sum: number, doc: any) => { const documentIncome = outputDocs.reduce((sum: number, doc: any) => {
return sum + (doc.rows || []).reduce((rowSum: number, row: any) => { return sum + (doc.rows || []).reduce((rowSum: number, row: any) => {
if (!row || ["pagebreak", "title", "text"].includes(row.mode)) { if (!row || ["pagebreak", "title", "text"].includes(row.mode)) {
return rowSum return rowSum
@@ -132,7 +133,14 @@ const loadSummary = async () => {
const { output, input } = getBankAllocationTaxBreakdown(allocation) const { output, input } = getBankAllocationTaxBreakdown(allocation)
return sum + output.tax19 + output.tax7 - input.tax19 - input.tax7 return sum + output.tax19 + output.tax7 - input.tax19 - input.tax7
}, 0) }, 0)
const expenses = invoiceExpenses + directAccountExpenses + depreciations // Skonto wirkt im Monat der Zahlung auf Erlöse und Aufwand.
const cashDiscountTotals = getCashDiscountNetTotals((allocations || []).filter((allocation: any) => {
const date = dayjs(allocation.bankstatement?.date || allocation.bankstatement?.valueDate)
return date.isValid() && !date.isBefore(bounds.start, "day") && !date.isAfter(bounds.end, "day")
}))
const expenses = invoiceExpenses + directAccountExpenses + depreciations - cashDiscountTotals.receivedNet
const income = documentIncome - cashDiscountTotals.grantedNet
summary.value = { summary.value = {
label: dayjs().format("MMMM YYYY"), label: dayjs().format("MMMM YYYY"),

View File

@@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { getCashDiscountNetTotals } from "~/utils/cashDiscount"
import dayjs from "dayjs" import dayjs from "dayjs"
import { getBankAllocationAmounts, getBankAllocationTaxBreakdown } from "~/utils/bankAllocationTax" import { getBankAllocationAmounts, getBankAllocationTaxBreakdown } from "~/utils/bankAllocationTax"
import { import {
@@ -183,8 +184,12 @@ const selectedPeriodBounds = computed(() => {
return { start, end } return { start, end }
}) })
// Skonto wirkt im Zeitraum der Zahlung: gewährter Skonto mindert Erlöse, erhaltener Skonto den Aufwand.
const cashDiscountTotals = computed(() => getCashDiscountNetTotals(filteredStatementAllocations.value))
const incomeTotal = computed(() => { const incomeTotal = computed(() => {
return Number(filteredDocuments.value.reduce((sum, doc) => sum + computeDocumentNet(doc), 0).toFixed(2)) const documents = filteredDocuments.value.reduce((sum, doc) => sum + computeDocumentNet(doc), 0)
return Number((documents - cashDiscountTotals.value.grantedNet).toFixed(2))
}) })
const expenseNetTotal = computed(() => { const expenseNetTotal = computed(() => {
@@ -196,7 +201,7 @@ const expenseNetTotal = computed(() => {
const depreciations = depreciationTotal.value const depreciations = depreciationTotal.value
return Number((invoiceExpenses + directAccountExpenses + depreciations).toFixed(2)) return Number((invoiceExpenses + directAccountExpenses + depreciations - cashDiscountTotals.value.receivedNet).toFixed(2))
}) })
const expenseGrossTotal = computed(() => { const expenseGrossTotal = computed(() => {
@@ -206,7 +211,7 @@ const expenseGrossTotal = computed(() => {
const depreciations = depreciationTotal.value const depreciations = depreciationTotal.value
return Number((invoiceExpenses + directAccountExpenses + depreciations).toFixed(2)) return Number((invoiceExpenses + directAccountExpenses + depreciations - cashDiscountTotals.value.receivedGross).toFixed(2))
}) })
const taxSummary = computed(() => { const taxSummary = computed(() => {

View File

@@ -1,5 +1,5 @@
<script setup> <script setup>
import { sumSettledAllocations } from "~/utils/cashDiscount" import { matchCashDiscountPayment, sumSettledAllocations } from "~/utils/cashDiscount"
import { parseDate } from "@internationalized/date" import { parseDate } from "@internationalized/date"
const {$api, $dayjs} = useNuxtApp() const {$api, $dayjs} = useNuxtApp()
@@ -387,25 +387,52 @@ const getTopEntitySuggestion = (statement) => {
return suggestions[0] || null return suggestions[0] || null
} }
// Zahlung entspricht dem offenen Betrag abzüglich vereinbartem Skonto und liegt in der Frist.
const getCashDiscountMatch = (statement, kind, entity) => {
const paymentDate = getStatementDate(statement) ? $dayjs(getStatementDate(statement)).format("YYYY-MM-DD") : null
if (kind === "createddocument") {
const days = entity.cashDiscountDays
return matchCashDiscountPayment({
open: entity.openSum,
payment: calculateOpenSum(statement),
percent: entity.cashDiscountPercent,
dueDate: entity.documentDate && days !== null && days !== undefined && days !== ""
? $dayjs(entity.documentDate).add(Number(days), "day").format("YYYY-MM-DD")
: null,
paymentDate
})
}
return matchCashDiscountPayment({
open: getInvoiceSum(entity, true),
payment: calculateOpenSum(statement),
percent: entity.cashDiscountPercent,
dueDate: entity.cashDiscountDueDate || null,
paymentDate
})
}
const getDocumentSuggestionMeta = (statement, document, suggestion) => { const getDocumentSuggestionMeta = (statement, document, suggestion) => {
let score = 0 let score = 0
const remaining = Math.abs(Number(calculateOpenSum(statement))) const remaining = Math.abs(Number(calculateOpenSum(statement)))
const openSum = Math.abs(Number(document.openSum)) const openSum = Math.abs(Number(document.openSum))
const purposeMatches = getMatchingPurposeParts(statement.text, [document.documentNumber, document.title, document.description]) const purposeMatches = getMatchingPurposeParts(statement.text, [document.documentNumber, document.title, document.description])
const amountLabel = getAmountMatchLabel(openSum, remaining) const cashDiscountMatch = getCashDiscountMatch(statement, "createddocument", document)
const withinTolerance = isWithinAmountTolerance(openSum, remaining) const amountLabel = cashDiscountMatch
? `Passt mit ${String(cashDiscountMatch.percent).replace(".", ",")} % Skonto`
: getAmountMatchLabel(openSum, remaining)
const withinTolerance = cashDiscountMatch || isWithinAmountTolerance(openSum, remaining)
if (!withinTolerance) { if (!withinTolerance) {
return { score: 0, amountLabel: null, purposeMatches: [] } return { score: 0, amountLabel: null, purposeMatches: [], cashDiscountMatch: null }
} }
if (openSum === remaining) score += 100 if (openSum === remaining || cashDiscountMatch) score += 100
else score += Math.max(0, 60 - Math.abs(openSum - remaining)) else score += Math.max(0, 60 - Math.abs(openSum - remaining))
if (suggestion && document.customer?.id === suggestion.id) score += 80 if (suggestion && document.customer?.id === suggestion.id) score += 80
if (purposeMatches.length > 0) score += 50 + (purposeMatches.length * 10) if (purposeMatches.length > 0) score += 50 + (purposeMatches.length * 10)
return { score, amountLabel, purposeMatches } return { score, amountLabel, purposeMatches, cashDiscountMatch }
} }
const getIncomingInvoiceSuggestionMeta = (statement, invoice, suggestion) => { const getIncomingInvoiceSuggestionMeta = (statement, invoice, suggestion) => {
@@ -413,20 +440,23 @@ const getIncomingInvoiceSuggestionMeta = (statement, invoice, suggestion) => {
const remaining = Math.abs(Number(calculateOpenSum(statement))) const remaining = Math.abs(Number(calculateOpenSum(statement)))
const openSum = Math.abs(Number(getInvoiceSum(invoice, true))) const openSum = Math.abs(Number(getInvoiceSum(invoice, true)))
const purposeMatches = getMatchingPurposeParts(statement.text, [invoice.reference, invoice.description]) const purposeMatches = getMatchingPurposeParts(statement.text, [invoice.reference, invoice.description])
const amountLabel = getAmountMatchLabel(openSum, remaining) const cashDiscountMatch = getCashDiscountMatch(statement, "incominginvoice", invoice)
const withinTolerance = isWithinAmountTolerance(openSum, remaining) const amountLabel = cashDiscountMatch
? `Passt mit ${String(cashDiscountMatch.percent).replace(".", ",")} % Skonto`
: getAmountMatchLabel(openSum, remaining)
const withinTolerance = cashDiscountMatch || isWithinAmountTolerance(openSum, remaining)
if (!withinTolerance) { if (!withinTolerance) {
return { score: 0, amountLabel: null, purposeMatches: [] } return { score: 0, amountLabel: null, purposeMatches: [], cashDiscountMatch: null }
} }
if (openSum === remaining) score += 100 if (openSum === remaining || cashDiscountMatch) score += 100
else score += Math.max(0, 60 - Math.abs(openSum - remaining)) else score += Math.max(0, 60 - Math.abs(openSum - remaining))
if (suggestion && invoice.vendor?.id === suggestion.id) score += 80 if (suggestion && invoice.vendor?.id === suggestion.id) score += 80
if (purposeMatches.length > 0) score += 50 + (purposeMatches.length * 10) if (purposeMatches.length > 0) score += 50 + (purposeMatches.length * 10)
return { score, amountLabel, purposeMatches } return { score, amountLabel, purposeMatches, cashDiscountMatch }
} }
const getRowSuggestions = (statement) => { const getRowSuggestions = (statement) => {
@@ -435,7 +465,7 @@ const getRowSuggestions = (statement) => {
const topDocument = (entitySuggestion?.type === "customer" ? openDocuments.value.filter(doc => doc.customer?.id === entitySuggestion.id) : openDocuments.value) const topDocument = (entitySuggestion?.type === "customer" ? openDocuments.value.filter(doc => doc.customer?.id === entitySuggestion.id) : openDocuments.value)
.map(document => { .map(document => {
const meta = getDocumentSuggestionMeta(statement, document, entitySuggestion?.type === "customer" ? entitySuggestion : null) const meta = getDocumentSuggestionMeta(statement, document, entitySuggestion?.type === "customer" ? entitySuggestion : null)
return { ...document, suggestionScore: meta.score, suggestionAmountLabel: meta.amountLabel, suggestionPurposeMatches: meta.purposeMatches, suggestionKey: `document:${document.id}` } return { ...document, suggestionScore: meta.score, suggestionAmountLabel: meta.amountLabel, suggestionPurposeMatches: meta.purposeMatches, suggestionCashDiscount: meta.cashDiscountMatch, suggestionKey: `document:${document.id}` }
}) })
.filter(document => !isSuggestionDismissed(statement.id, document.suggestionKey)) .filter(document => !isSuggestionDismissed(statement.id, document.suggestionKey))
.filter(document => document.suggestionScore >= 80 || document.suggestionPurposeMatches?.length > 0) .filter(document => document.suggestionScore >= 80 || document.suggestionPurposeMatches?.length > 0)
@@ -444,7 +474,7 @@ const getRowSuggestions = (statement) => {
const topIncomingInvoice = (entitySuggestion?.type === "vendor" ? openIncomingInvoices.value.filter(invoice => invoice.vendor?.id === entitySuggestion.id) : openIncomingInvoices.value) const topIncomingInvoice = (entitySuggestion?.type === "vendor" ? openIncomingInvoices.value.filter(invoice => invoice.vendor?.id === entitySuggestion.id) : openIncomingInvoices.value)
.map(invoice => { .map(invoice => {
const meta = getIncomingInvoiceSuggestionMeta(statement, invoice, entitySuggestion?.type === "vendor" ? entitySuggestion : null) const meta = getIncomingInvoiceSuggestionMeta(statement, invoice, entitySuggestion?.type === "vendor" ? entitySuggestion : null)
return { ...invoice, suggestionScore: meta.score, suggestionAmountLabel: meta.amountLabel, suggestionPurposeMatches: meta.purposeMatches, suggestionKey: `invoice:${invoice.id}` } return { ...invoice, suggestionScore: meta.score, suggestionAmountLabel: meta.amountLabel, suggestionPurposeMatches: meta.purposeMatches, suggestionCashDiscount: meta.cashDiscountMatch, suggestionKey: `invoice:${invoice.id}` }
}) })
.filter(invoice => !isSuggestionDismissed(statement.id, invoice.suggestionKey)) .filter(invoice => !isSuggestionDismissed(statement.id, invoice.suggestionKey))
.filter(invoice => invoice.suggestionScore >= 80 || invoice.suggestionPurposeMatches?.length > 0) .filter(invoice => invoice.suggestionScore >= 80 || invoice.suggestionPurposeMatches?.length > 0)
@@ -552,6 +582,24 @@ const handleAssignIncomingInvoice = async (row, invoice, event) => {
}) })
} }
// Bucht die Zahlung zusammen mit dem vereinbarten Skonto, wenn Betrag und Frist zu den Skontobedingungen passen.
const handleAssignWithCashDiscount = async (row, kind, entity, event) => {
event.stopPropagation()
const match = entity.suggestionCashDiscount
if (!match) return
try {
await saveAllocation({
[kind]: entity.id,
bankstatement: row.id,
amount: Number(calculateOpenSum(row)),
cashDiscountAmount: match.discount,
description: "Automatischer Vorschlag mit Skonto"
})
} catch (err) {
toast.add({ title: "Skonto konnte nicht gebucht werden", description: err?.data?.error, color: "error" })
}
}
const dismissSuggestion = (row, suggestionKey, event) => { const dismissSuggestion = (row, suggestionKey, event) => {
event.stopPropagation() event.stopPropagation()
const bankingSettings = tempStore.settings?.banking || {} const bankingSettings = tempStore.settings?.banking || {}
@@ -928,7 +976,15 @@ onMounted(() => {
</UBadge> </UBadge>
</div> </div>
</div> </div>
<UButton size="sm" color="emerald" @click="handleAssignDocument(selectedSuggestionRow.row, selectedSuggestionRow.suggestions.topDocument, $event)"> <UButton
v-if="selectedSuggestionRow.suggestions.topDocument.suggestionCashDiscount"
size="sm"
color="warning"
@click="handleAssignWithCashDiscount(selectedSuggestionRow.row, 'createddocument', selectedSuggestionRow.suggestions.topDocument, $event)"
>
Mit Skonto zuweisen
</UButton>
<UButton v-else size="sm" color="emerald" @click="handleAssignDocument(selectedSuggestionRow.row, selectedSuggestionRow.suggestions.topDocument, $event)">
Zuweisen Zuweisen
</UButton> </UButton>
<UButton size="sm" color="gray" variant="ghost" @click="dismissSuggestion(selectedSuggestionRow.row, selectedSuggestionRow.suggestions.topDocument.suggestionKey, $event)"> <UButton size="sm" color="gray" variant="ghost" @click="dismissSuggestion(selectedSuggestionRow.row, selectedSuggestionRow.suggestions.topDocument.suggestionKey, $event)">
@@ -951,7 +1007,15 @@ onMounted(() => {
</UBadge> </UBadge>
</div> </div>
</div> </div>
<UButton size="sm" color="error" @click="handleAssignIncomingInvoice(selectedSuggestionRow.row, selectedSuggestionRow.suggestions.topIncomingInvoice, $event)"> <UButton
v-if="selectedSuggestionRow.suggestions.topIncomingInvoice.suggestionCashDiscount"
size="sm"
color="warning"
@click="handleAssignWithCashDiscount(selectedSuggestionRow.row, 'incominginvoice', selectedSuggestionRow.suggestions.topIncomingInvoice, $event)"
>
Mit Skonto zuweisen
</UButton>
<UButton v-else size="sm" color="error" @click="handleAssignIncomingInvoice(selectedSuggestionRow.row, selectedSuggestionRow.suggestions.topIncomingInvoice, $event)">
Zuweisen Zuweisen
</UButton> </UButton>
<UButton size="sm" color="gray" variant="ghost" @click="dismissSuggestion(selectedSuggestionRow.row, selectedSuggestionRow.suggestions.topIncomingInvoice.suggestionKey, $event)"> <UButton size="sm" color="gray" variant="ghost" @click="dismissSuggestion(selectedSuggestionRow.row, selectedSuggestionRow.suggestions.topIncomingInvoice.suggestionKey, $event)">

View File

@@ -1,7 +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 { matchCashDiscountPayment, sumSettledAllocations } from "~/utils/cashDiscount"
import { import {
DEPRECIATION_METHOD_ITEMS, DEPRECIATION_METHOD_ITEMS,
EXPENSE_BOOKING_MODE_ITEMS, EXPENSE_BOOKING_MODE_ITEMS,
@@ -421,6 +421,30 @@ const isWithinAmountTolerance = (candidateSum, remaining) => {
return difference / reference <= 0.1 return difference / reference <= 0.1
} }
// Erkennt Zahlungen, die genau dem offenen Betrag abzüglich vereinbartem Skonto entsprechen und in der Frist liegen.
const getCashDiscountMatch = (kind, entity) => {
const paymentDate = itemInfo.value?.date || itemInfo.value?.valueDate
if (kind === "createddocument") {
const days = entity.cashDiscountDays
return matchCashDiscountPayment({
open: entity.openSum,
payment: calculateOpenSum.value,
percent: entity.cashDiscountPercent,
dueDate: entity.documentDate && days !== null && days !== undefined && days !== ""
? dayjs(entity.documentDate).add(Number(days), "day").format("YYYY-MM-DD")
: null,
paymentDate: paymentDate ? dayjs(paymentDate).format("YYYY-MM-DD") : null
})
}
return matchCashDiscountPayment({
open: getInvoiceSum(entity, true),
payment: calculateOpenSum.value,
percent: entity.cashDiscountPercent,
dueDate: entity.cashDiscountDueDate || null,
paymentDate: paymentDate ? dayjs(paymentDate).format("YYYY-MM-DD") : null
})
}
const getDocumentSuggestionMeta = (document, suggestion) => { const getDocumentSuggestionMeta = (document, suggestion) => {
let score = 0 let score = 0
const remaining = Math.abs(Number(calculateOpenSum.value)) const remaining = Math.abs(Number(calculateOpenSum.value))
@@ -430,18 +454,22 @@ const getDocumentSuggestionMeta = (document, suggestion) => {
document.title, document.title,
document.description document.description
]) ])
const amountLabel = getAmountMatchLabel(openSum, remaining) const cashDiscountMatch = getCashDiscountMatch("createddocument", document)
const withinTolerance = isWithinAmountTolerance(openSum, remaining) const amountLabel = cashDiscountMatch
? `Passt mit ${String(cashDiscountMatch.percent).replace(".", ",")} % Skonto`
: getAmountMatchLabel(openSum, remaining)
const withinTolerance = cashDiscountMatch || isWithinAmountTolerance(openSum, remaining)
if (!withinTolerance) { if (!withinTolerance) {
return { return {
score: 0, score: 0,
purposeMatches: [], purposeMatches: [],
amountLabel: null amountLabel: null,
cashDiscountMatch: null
} }
} }
if (openSum === remaining) score += 100 if (openSum === remaining || cashDiscountMatch) score += 100
else score += Math.max(0, 60 - Math.abs(openSum - remaining)) else score += Math.max(0, 60 - Math.abs(openSum - remaining))
if (suggestion && document.customer?.id === suggestion.id) score += 80 if (suggestion && document.customer?.id === suggestion.id) score += 80
@@ -450,7 +478,8 @@ const getDocumentSuggestionMeta = (document, suggestion) => {
return { return {
score, score,
purposeMatches, purposeMatches,
amountLabel amountLabel,
cashDiscountMatch
} }
} }
@@ -462,18 +491,22 @@ const getInvoiceSuggestionMeta = (invoice, suggestion) => {
invoice.reference, invoice.reference,
invoice.description invoice.description
]) ])
const amountLabel = getAmountMatchLabel(openSum, remaining) const cashDiscountMatch = getCashDiscountMatch("incominginvoice", invoice)
const withinTolerance = isWithinAmountTolerance(openSum, remaining) const amountLabel = cashDiscountMatch
? `Passt mit ${String(cashDiscountMatch.percent).replace(".", ",")} % Skonto`
: getAmountMatchLabel(openSum, remaining)
const withinTolerance = cashDiscountMatch || isWithinAmountTolerance(openSum, remaining)
if (!withinTolerance) { if (!withinTolerance) {
return { return {
score: 0, score: 0,
purposeMatches: [], purposeMatches: [],
amountLabel: null amountLabel: null,
cashDiscountMatch: null
} }
} }
if (openSum === remaining) score += 100 if (openSum === remaining || cashDiscountMatch) score += 100
else score += Math.max(0, 60 - Math.abs(openSum - remaining)) else score += Math.max(0, 60 - Math.abs(openSum - remaining))
if (suggestion && invoice.vendor?.id === suggestion.id) score += 80 if (suggestion && invoice.vendor?.id === suggestion.id) score += 80
@@ -482,7 +515,8 @@ const getInvoiceSuggestionMeta = (invoice, suggestion) => {
return { return {
score, score,
purposeMatches, purposeMatches,
amountLabel amountLabel,
cashDiscountMatch
} }
} }
@@ -498,6 +532,7 @@ const suggestedDocuments = computed(() => {
suggestionScore: meta.score, suggestionScore: meta.score,
suggestionPurposeMatches: meta.purposeMatches, suggestionPurposeMatches: meta.purposeMatches,
suggestionAmountLabel: meta.amountLabel, suggestionAmountLabel: meta.amountLabel,
suggestionCashDiscount: meta.cashDiscountMatch,
suggestionKey: `document:${document.id}` suggestionKey: `document:${document.id}`
} }
}) })
@@ -518,6 +553,7 @@ const suggestedIncomingInvoices = computed(() => {
suggestionScore: meta.score, suggestionScore: meta.score,
suggestionPurposeMatches: meta.purposeMatches, suggestionPurposeMatches: meta.purposeMatches,
suggestionAmountLabel: meta.amountLabel, suggestionAmountLabel: meta.amountLabel,
suggestionCashDiscount: meta.cashDiscountMatch,
suggestionKey: `invoice:${invoice.id}` suggestionKey: `invoice:${invoice.id}`
} }
}) })
@@ -537,6 +573,7 @@ const fallbackSuggestedDocuments = computed(() => {
suggestionScore: meta.score, suggestionScore: meta.score,
suggestionPurposeMatches: meta.purposeMatches, suggestionPurposeMatches: meta.purposeMatches,
suggestionAmountLabel: meta.amountLabel, suggestionAmountLabel: meta.amountLabel,
suggestionCashDiscount: meta.cashDiscountMatch,
suggestionKey: `document:${document.id}` suggestionKey: `document:${document.id}`
} }
}) })
@@ -557,6 +594,7 @@ const fallbackSuggestedIncomingInvoices = computed(() => {
suggestionScore: meta.score, suggestionScore: meta.score,
suggestionPurposeMatches: meta.purposeMatches, suggestionPurposeMatches: meta.purposeMatches,
suggestionAmountLabel: meta.amountLabel, suggestionAmountLabel: meta.amountLabel,
suggestionCashDiscount: meta.cashDiscountMatch,
suggestionKey: `invoice:${invoice.id}` suggestionKey: `invoice:${invoice.id}`
} }
}) })
@@ -1084,7 +1122,7 @@ setup()
v-if="canBookCashDiscount('createddocument', document)" v-if="canBookCashDiscount('createddocument', document)"
size="xs" size="xs"
color="warning" color="warning"
variant="soft" :variant="document.suggestionCashDiscount ? 'solid' : 'soft'"
@click="openCashDiscountModal('createddocument', document)" @click="openCashDiscountModal('createddocument', document)"
> >
Mit Skonto Mit Skonto
@@ -1127,7 +1165,7 @@ setup()
v-if="canBookCashDiscount('incominginvoice', invoice)" v-if="canBookCashDiscount('incominginvoice', invoice)"
size="xs" size="xs"
color="warning" color="warning"
variant="soft" :variant="invoice.suggestionCashDiscount ? 'solid' : 'soft'"
@click="openCashDiscountModal('incominginvoice', invoice)" @click="openCashDiscountModal('incominginvoice', invoice)"
> >
Mit Skonto Mit Skonto
@@ -1182,6 +1220,7 @@ setup()
<div class="text-right"> <div class="text-right">
<div class="font-mono font-bold text-sm">{{ displayCurrency(document.openSum) }}</div> <div class="font-mono font-bold text-sm">{{ displayCurrency(document.openSum) }}</div>
<div class="text-xs text-gray-400">Offen</div> <div class="text-xs text-gray-400">Offen</div>
<UBadge v-if="getCashDiscountMatch('createddocument', document)" size="xs" color="warning" variant="subtle">Skonto passt</UBadge>
</div> </div>
<UTooltip text="Zuweisen"> <UTooltip text="Zuweisen">
<UButton <UButton
@@ -1251,6 +1290,7 @@ setup()
{{ displayCurrency(getInvoiceSum(item, true)) }} {{ displayCurrency(getInvoiceSum(item, true)) }}
</div> </div>
<div class="text-xs text-gray-400">Offen</div> <div class="text-xs text-gray-400">Offen</div>
<UBadge v-if="getCashDiscountMatch('incominginvoice', item)" size="xs" color="warning" variant="subtle">Skonto passt</UBadge>
</div> </div>
<UTooltip text="Zuweisen"> <UTooltip text="Zuweisen">
<UButton <UButton

View File

@@ -6,6 +6,7 @@ import {useFunctions} from "~/composables/useFunctions.js";
import EntityModalButtons from "~/components/EntityModalButtons.vue"; import EntityModalButtons from "~/components/EntityModalButtons.vue";
import { documentTemplateHandlebars } from "~/utils/handlebars"; import { documentTemplateHandlebars } from "~/utils/handlebars";
import { getTitleSectionMove, moveTitleSection } from "~/utils/documentTitleSections.js"; import { getTitleSectionMove, moveTitleSection } from "~/utils/documentTitleSections.js";
import { appendCashDiscountText, buildCashDiscountTerms, formatCashDiscountText, getCashDiscountTemplateContext } from "~/utils/cashDiscount";
const dataStore = useDataStore() const dataStore = useDataStore()
const profileStore = useProfileStore() const profileStore = useProfileStore()
@@ -57,6 +58,8 @@ const itemInfo = ref({
deliveryDateType: "Lieferdatum", deliveryDateType: "Lieferdatum",
dateOfPerformance: null, dateOfPerformance: null,
paymentDays: auth.activeTenantData.standardPaymentDays, paymentDays: auth.activeTenantData.standardPaymentDays,
cashDiscountPercent: null,
cashDiscountDays: null,
payment_type: "transfer", payment_type: "transfer",
outgoingsepamandate: null, outgoingsepamandate: null,
availableInPortal: false, availableInPortal: false,
@@ -635,7 +638,11 @@ const setupPage = async () => {
if (optionsToImport.deliveryDate) itemInfo.value.deliveryDate = linkedDocument.deliveryDate if (optionsToImport.deliveryDate) itemInfo.value.deliveryDate = linkedDocument.deliveryDate
if (optionsToImport.deliveryDateEnd) itemInfo.value.deliveryDateEnd = linkedDocument.deliveryDateEnd if (optionsToImport.deliveryDateEnd) itemInfo.value.deliveryDateEnd = linkedDocument.deliveryDateEnd
if (optionsToImport.documentDate) itemInfo.value.documentDate = linkedDocument.documentDate if (optionsToImport.documentDate) itemInfo.value.documentDate = linkedDocument.documentDate
if (optionsToImport.paymentDays) itemInfo.value.paymentDays = linkedDocument.paymentDays if (optionsToImport.paymentDays) {
itemInfo.value.paymentDays = linkedDocument.paymentDays
itemInfo.value.cashDiscountPercent = linkedDocument.cashDiscountPercent ?? null
itemInfo.value.cashDiscountDays = linkedDocument.cashDiscountDays ?? null
}
if (optionsToImport.payment_type) itemInfo.value.payment_type = linkedDocument.payment_type if (optionsToImport.payment_type) itemInfo.value.payment_type = linkedDocument.payment_type
if (optionsToImport.customSurchargePercentage) itemInfo.value.customSurchargePercentage = linkedDocument.customSurchargePercentage if (optionsToImport.customSurchargePercentage) itemInfo.value.customSurchargePercentage = linkedDocument.customSurchargePercentage
if (optionsToImport.contactPerson) itemInfo.value.contactPerson = linkedDocument.contactPerson if (optionsToImport.contactPerson) itemInfo.value.contactPerson = linkedDocument.contactPerson
@@ -663,6 +670,8 @@ const setupPage = async () => {
itemInfo.value.deliveryDateEnd = linkedDocument.deliveryDateEnd itemInfo.value.deliveryDateEnd = linkedDocument.deliveryDateEnd
itemInfo.value.documentDate = linkedDocument.documentDate itemInfo.value.documentDate = linkedDocument.documentDate
itemInfo.value.paymentDays = linkedDocument.paymentDays itemInfo.value.paymentDays = linkedDocument.paymentDays
itemInfo.value.cashDiscountPercent = linkedDocument.cashDiscountPercent ?? null
itemInfo.value.cashDiscountDays = linkedDocument.cashDiscountDays ?? null
itemInfo.value.payment_type = linkedDocument.payment_type itemInfo.value.payment_type = linkedDocument.payment_type
itemInfo.value.customSurchargePercentage = linkedDocument.customSurchargePercentage itemInfo.value.customSurchargePercentage = linkedDocument.customSurchargePercentage
itemInfo.value.contactPerson = linkedDocument.contactPerson itemInfo.value.contactPerson = linkedDocument.contactPerson
@@ -833,6 +842,10 @@ const setCustomerData = async (customerId, loadOnlyAdress = false) => {
itemInfo.value.address.country = customer.infoData.country itemInfo.value.address.country = customer.infoData.country
if (!loadOnlyAdress && customer.customPaymentDays) itemInfo.value.paymentDays = customer.customPaymentDays if (!loadOnlyAdress && customer.customPaymentDays) itemInfo.value.paymentDays = customer.customPaymentDays
if (!loadOnlyAdress && customer.customCashDiscountPercent) {
itemInfo.value.cashDiscountPercent = customer.customCashDiscountPercent
itemInfo.value.cashDiscountDays = customer.customCashDiscountDays ?? null
}
if (!loadOnlyAdress && customer.custom_payment_type) itemInfo.value.payment_type = customer.custom_payment_type if (!loadOnlyAdress && customer.custom_payment_type) itemInfo.value.payment_type = customer.custom_payment_type
if (!loadOnlyAdress) { if (!loadOnlyAdress) {
itemInfo.value.taxType = normalizeTaxTypeValue(customer.customTaxType) itemInfo.value.taxType = normalizeTaxTypeValue(customer.customTaxType)
@@ -1357,6 +1370,17 @@ const documentTotal = computed(() => {
} }
}) })
// Skonto bezieht sich bei Schlussrechnungen auf die Restforderung nach Abzug der Abschläge.
const cashDiscountTerms = computed(() => {
if (!["invoices", "advanceInvoices", "serialInvoices"].includes(itemInfo.value.type)) return null
return buildCashDiscountTerms({
percent: itemInfo.value.cashDiscountPercent,
days: itemInfo.value.cashDiscountDays,
documentDate: itemInfo.value.documentDate ? dayjs(itemInfo.value.documentDate).format("YYYY-MM-DD") : null,
amount: itemInfo.value.type === "invoices" ? documentTotal.value.totalSumToPay : documentTotal.value.totalGross
})
})
const documentReport = computed(() => { const documentReport = computed(() => {
let totalProductsPurchasePrice = 0 let totalProductsPurchasePrice = 0
@@ -1574,7 +1598,8 @@ const getDocumentData = async () => {
kundenname: customerData && customerData.name, kundenname: customerData && customerData.name,
zahlungsziel_in_tagen: itemInfo.paymentDays, zahlungsziel_in_tagen: itemInfo.paymentDays,
zahlungsart: itemInfo.payment_type === "transfer" ? "Überweisung" : "Lastschrift", zahlungsart: itemInfo.payment_type === "transfer" ? "Überweisung" : "Lastschrift",
diesel_gesamtverbrauch: (itemInfo.agriculture && itemInfo.agriculture.dieselUsageTotal) && itemInfo.agriculture.dieselUsageTotal diesel_gesamtverbrauch: (itemInfo.agriculture && itemInfo.agriculture.dieselUsageTotal) && itemInfo.agriculture.dieselUsageTotal,
...getCashDiscountTemplateContext(cashDiscountTerms.value)
} }
} }
@@ -1684,7 +1709,7 @@ const getDocumentData = async () => {
], ],
title: itemInfo.value.title, title: itemInfo.value.title,
description: itemInfo.value.description, description: itemInfo.value.description,
endText: templateEndText(generateContext(itemInfo.value, contactData)), endText: appendCashDiscountText(templateEndText(generateContext(itemInfo.value, contactData)), itemInfo.value.endText, cashDiscountTerms.value),
startText: templateStartText(generateContext(itemInfo.value, contactData)), startText: templateStartText(generateContext(itemInfo.value, contactData)),
rows: rows, rows: rows,
totalArray: [ totalArray: [
@@ -1819,6 +1844,8 @@ const saveSerialInvoice = async () => {
project: normalizeEntityId(itemInfo.value.project), project: normalizeEntityId(itemInfo.value.project),
costcentre: itemInfo.value.costcentre, costcentre: itemInfo.value.costcentre,
paymentDays: itemInfo.value.paymentDays, paymentDays: itemInfo.value.paymentDays,
cashDiscountPercent: itemInfo.value.cashDiscountPercent === "" || itemInfo.value.cashDiscountPercent === null ? null : Number(itemInfo.value.cashDiscountPercent),
cashDiscountDays: itemInfo.value.cashDiscountDays === "" || itemInfo.value.cashDiscountDays === null ? null : Number(itemInfo.value.cashDiscountDays),
payment_type: itemInfo.value.payment_type, payment_type: itemInfo.value.payment_type,
outgoingsepamandate: normalizeEntityId(itemInfo.value.outgoingsepamandate), outgoingsepamandate: normalizeEntityId(itemInfo.value.outgoingsepamandate),
deliveryDateType: "Leistungszeitraum", deliveryDateType: "Leistungszeitraum",
@@ -1935,6 +1962,8 @@ const saveDocument = async (state, resetup = false) => {
deliveryDate: itemInfo.value.deliveryDate, deliveryDate: itemInfo.value.deliveryDate,
deliveryDateEnd: itemInfo.value.deliveryDateEnd, deliveryDateEnd: itemInfo.value.deliveryDateEnd,
paymentDays: itemInfo.value.paymentDays, paymentDays: itemInfo.value.paymentDays,
cashDiscountPercent: itemInfo.value.cashDiscountPercent === "" || itemInfo.value.cashDiscountPercent === null ? null : Number(itemInfo.value.cashDiscountPercent),
cashDiscountDays: itemInfo.value.cashDiscountDays === "" || itemInfo.value.cashDiscountDays === null ? null : Number(itemInfo.value.cashDiscountDays),
payment_type: itemInfo.value.payment_type, payment_type: itemInfo.value.payment_type,
outgoingsepamandate: normalizeEntityId(itemInfo.value.outgoingsepamandate), outgoingsepamandate: normalizeEntityId(itemInfo.value.outgoingsepamandate),
deliveryDateType: itemInfo.value.deliveryDateType, deliveryDateType: itemInfo.value.deliveryDateType,
@@ -2690,6 +2719,39 @@ const setRowData = async (row, service = {sellingPriceComposed: {}}, product = {
class="w-full" class="w-full"
/> />
</UFormField> </UFormField>
<UFormField
v-if="['invoices', 'advanceInvoices', 'serialInvoices'].includes(itemInfo.type)"
class="min-w-0 flex-1"
label="Skonto:"
:help="cashDiscountTerms ? formatCashDiscountText(cashDiscountTerms) : undefined"
>
<div class="flex gap-1">
<UInput
type="number"
step="0.01"
min="0"
max="10"
placeholder="%"
v-model="itemInfo.cashDiscountPercent"
class="w-full"
>
<template #trailing>
<span class="text-gray-500 dark:text-gray-400 text-xs">%</span>
</template>
</UInput>
<UInput
type="number"
min="0"
placeholder="Tage"
v-model="itemInfo.cashDiscountDays"
class="w-full"
>
<template #trailing>
<span class="text-gray-500 dark:text-gray-400 text-xs">Tage</span>
</template>
</UInput>
</div>
</UFormField>
<UFormField <UFormField
class="min-w-0 flex-1" class="min-w-0 flex-1"
label="Individueller Aufschlag:" label="Individueller Aufschlag:"

View File

@@ -34,6 +34,8 @@ const itemInfo = ref({
reference: "", reference: "",
date: null, date: null,
dueDate: null, dueDate: null,
cashDiscountPercent: null,
cashDiscountDueDate: null,
paymentType: "Überweisung", paymentType: "Überweisung",
description: "", description: "",
state: "Entwurf", state: "Entwurf",
@@ -147,6 +149,28 @@ const setDateFieldToToday = (field) => {
itemInfo.value[field] = dayjs().toDate() itemInfo.value[field] = dayjs().toDate()
} }
// Skontofrist als reines Datum speichern, damit der Vergleich mit dem Zahlungsdatum nicht durch Zeitzonen kippt.
const setCashDiscountDueDate = (value) => {
itemInfo.value.cashDiscountDueDate = value ? dayjs(value.toString()).format("YYYY-MM-DD") : null
}
// Lieferanten-Vorgabe nur bei bewusster Auswahl übernehmen, nicht beim Laden eines Belegs.
const applyVendorCashDiscount = (vendorId) => {
const vendor = vendors.value.find((item) => item.id === vendorId)
if (!vendor?.cashDiscountPercent || itemInfo.value.cashDiscountPercent) return
itemInfo.value.cashDiscountPercent = vendor.cashDiscountPercent
if (itemInfo.value.date && vendor.cashDiscountDays !== null && vendor.cashDiscountDays !== undefined) {
itemInfo.value.cashDiscountDueDate = dayjs(itemInfo.value.date).add(vendor.cashDiscountDays, "day").format("YYYY-MM-DD")
}
}
const cashDiscountAmount = computed(() => {
const percent = Number(itemInfo.value.cashDiscountPercent || 0)
if (!(percent > 0)) return null
const gross = (itemInfo.value.accounts || []).reduce((sum, account) => sum + Number(account.amountNet || 0) + Number(account.amountTax || 0), 0)
return Number((Math.abs(gross) * percent / 100).toFixed(2))
})
const getDateButtonLabel = (value, emptyLabel = "Kein Datum") => value ? dayjs(value).format('DD.MM.YYYY') : emptyLabel const getDateButtonLabel = (value, emptyLabel = "Kein Datum") => value ? dayjs(value).format('DD.MM.YYYY') : emptyLabel
const formatDate = (value) => value ? dayjs(value).format("DD.MM.YYYY") : "-" const formatDate = (value) => value ? dayjs(value).format("DD.MM.YYYY") : "-"
const formatMoney = (value) => `${Number(value || 0).toFixed(2).replace(".", ",")} €` const formatMoney = (value) => `${Number(value || 0).toFixed(2).replace(".", ",")} €`
@@ -333,6 +357,9 @@ const updateIncomingInvoice = async (setBooked = false) => {
let item = { ...itemInfo.value } let item = { ...itemInfo.value }
item.accounts = (item.accounts || []).map((account) => ensureDepreciationDefaults({ ...account }, item.date)) item.accounts = (item.accounts || []).map((account) => ensureDepreciationDefaults({ ...account }, item.date))
const cashDiscountPercent = Number(item.cashDiscountPercent)
item.cashDiscountPercent = Number.isFinite(cashDiscountPercent) && cashDiscountPercent > 0 ? cashDiscountPercent : null
item.cashDiscountDueDate = item.cashDiscountPercent && item.cashDiscountDueDate ? dayjs(item.cashDiscountDueDate).format("YYYY-MM-DD") : null
delete item.files delete item.files
item.state = keepBooked ? "Gebucht" : "Entwurf" item.state = keepBooked ? "Gebucht" : "Entwurf"
@@ -569,6 +596,14 @@ const hasBlockingIncomingInvoiceErrors = computed(() => blockingIncomingInvoiceE
<dt class="text-gray-500">Fälligkeitsdatum</dt> <dt class="text-gray-500">Fälligkeitsdatum</dt>
<dd class="font-medium">{{ formatDate(itemInfo.dueDate) }}</dd> <dd class="font-medium">{{ formatDate(itemInfo.dueDate) }}</dd>
</div> </div>
<div v-if="itemInfo.cashDiscountPercent">
<dt class="text-gray-500">Skonto</dt>
<dd class="font-medium">
{{ String(itemInfo.cashDiscountPercent).replace(".", ",") }} %
<span v-if="cashDiscountAmount">({{ formatMoney(cashDiscountAmount) }})</span>
<span v-if="itemInfo.cashDiscountDueDate"> bis {{ formatDate(itemInfo.cashDiscountDueDate) }}</span>
</dd>
</div>
<div> <div>
<dt class="text-gray-500">Bankbuchungsdatum</dt> <dt class="text-gray-500">Bankbuchungsdatum</dt>
<dd class="font-medium">{{ bankBookingDateLabel }}</dd> <dd class="font-medium">{{ bankBookingDateLabel }}</dd>
@@ -637,6 +672,7 @@ const hasBlockingIncomingInvoiceErrors = computed(() => blockingIncomingInvoiceE
:disabled="mode === 'show'" :disabled="mode === 'show'"
:filter-fields="['name', 'vendorNumber']" :filter-fields="['name', 'vendorNumber']"
:color="itemInfo.vendor ? 'primary' : 'error'" :color="itemInfo.vendor ? 'primary' : 'error'"
@update:model-value="applyVendorCashDiscount"
> >
<template #default> <template #default>
{{ vendors.find(v => v.id === itemInfo.vendor)?.name || 'Bitte wählen' }} {{ vendors.find(v => v.id === itemInfo.vendor)?.name || 'Bitte wählen' }}
@@ -657,7 +693,7 @@ const hasBlockingIncomingInvoiceErrors = computed(() => blockingIncomingInvoiceE
v-if="mode !== 'show'" v-if="mode !== 'show'"
type="vendors" type="vendors"
:id="itemInfo.vendor" :id="itemInfo.vendor"
@return-data="(data) => itemInfo.vendor = data.id" @return-data="(data) => { itemInfo.vendor = data.id; applyVendorCashDiscount(data.id) }"
/> />
</div> </div>
</UFormField> </UFormField>
@@ -736,6 +772,47 @@ const hasBlockingIncomingInvoiceErrors = computed(() => blockingIncomingInvoiceE
</UPopover> </UPopover>
</UFormField> </UFormField>
<UFormField
label="Skonto"
:help="cashDiscountAmount ? `Abzug ${formatMoney(cashDiscountAmount)} bei Zahlung in der Frist` : undefined"
>
<UInput
type="number"
step="0.01"
min="0"
max="10"
placeholder="z. B. 2"
v-model="itemInfo.cashDiscountPercent"
:disabled="mode === 'show'"
class="w-full"
>
<template #trailing><span class="text-gray-500 text-xs">%</span></template>
</UInput>
</UFormField>
<UFormField label="Skonto bis">
<UPopover class="w-full" :content="{ side: 'bottom', align: 'start' }">
<UButton
block
icon="i-heroicons-calendar"
:label="getDateButtonLabel(itemInfo.cashDiscountDueDate, 'Keine Skontofrist')"
:disabled="mode === 'show' || !itemInfo.cashDiscountPercent"
color="neutral"
variant="outline"
class="w-full justify-start"
/>
<template #content>
<div class="p-2">
<UCalendar
:model-value="getCalendarValue(itemInfo.cashDiscountDueDate)"
@update:model-value="setCashDiscountDueDate"
:week-starts-on="1"
/>
</div>
</template>
</UPopover>
</UFormField>
<UFormField label="Beschreibung / Notiz" class="md:col-span-2"> <UFormField label="Beschreibung / Notiz" class="md:col-span-2">
<UTextarea class="w-full" v-model="itemInfo.description" :rows="2" autoresize :disabled="mode === 'show'" /> <UTextarea class="w-full" v-model="itemInfo.description" :rows="2" autoresize :disabled="mode === 'show'" />
</UFormField> </UFormField>

View File

@@ -29,12 +29,18 @@ const variableDefinitions = [
{ key: '{{titel}}', label: 'Titel', desc: 'Titel des Kunden' }, { key: '{{titel}}', label: 'Titel', desc: 'Titel des Kunden' },
{ key: '{{zahlungsziel_in_tagen}}', label: 'Zahlungsziel', desc: 'In Tagen' }, { key: '{{zahlungsziel_in_tagen}}', label: 'Zahlungsziel', desc: 'In Tagen' },
{ key: '{{lohnkosten}}', label: 'Lohnkosten', desc: 'Ausgewiesene Lohnkosten' }, { key: '{{lohnkosten}}', label: 'Lohnkosten', desc: 'Ausgewiesene Lohnkosten' },
{ key: '{{skonto_hinweis}}', label: 'Skonto-Hinweis', desc: 'Kompletter Satz; ohne Skontovariable wird er automatisch vor den Endtext gesetzt' },
{ key: '{{skonto_prozent}}', label: 'Skonto %', desc: 'Skontosatz' },
{ key: '{{skonto_datum}}', label: 'Skonto bis', desc: 'Datum der Skontofrist' },
{ key: '{{skonto_betrag}}', label: 'Skontobetrag', desc: 'Abzugsbetrag in Euro' },
{ key: '{{zahlbetrag_mit_skonto}}', label: 'Zahlbetrag mit Skonto', desc: 'Rechnungsbetrag abzüglich Skonto' },
] ]
const conditionalExamples = [ const conditionalExamples = [
'{{#if vorname}}Hallo {{vorname}},{{/if}}', '{{#if vorname}}Hallo {{vorname}},{{/if}}',
'{{#if (eq zahlungsart "Überweisung")}}Bitte überweisen Sie den Betrag.{{/if}}', '{{#if (eq zahlungsart "Überweisung")}}Bitte überweisen Sie den Betrag.{{/if}}',
'{{#if (gt zahlungsziel_in_tagen 14)}}Vielen Dank für Ihre Zahlung innerhalb des erweiterten Zahlungsziels.{{/if}}', '{{#if (gt zahlungsziel_in_tagen 14)}}Vielen Dank für Ihre Zahlung innerhalb des erweiterten Zahlungsziels.{{/if}}',
'{{#if skonto_prozent}}Bei Zahlung bis {{skonto_datum}} ziehen Sie bitte {{skonto_prozent}} % Skonto ab.{{/if}}',
] ]
// --- Shortcuts --- // --- Shortcuts ---

View File

@@ -345,6 +345,18 @@ export const useDataStore = defineStore('data', () => {
inputType: "number", inputType: "number",
inputColumn: "Allgemeines", inputColumn: "Allgemeines",
sortable: true sortable: true
},{
key: "customCashDiscountPercent",
label: "Skonto in %",
inputType: "number",
inputColumn: "Allgemeines",
disabledInTable: true
},{
key: "customCashDiscountDays",
label: "Skontofrist in Tagen",
inputType: "number",
inputColumn: "Allgemeines",
disabledInTable: true
},{ },{
key: "custom_payment_type", key: "custom_payment_type",
label: "Zahlungsart", label: "Zahlungsart",
@@ -2007,6 +2019,18 @@ export const useDataStore = defineStore('data', () => {
inputType: "bool", inputType: "bool",
component: sepa component: sepa
}, },
{
key: "cashDiscountPercent",
label: "Skonto in %",
inputType: "number",
disabledInTable: true
},
{
key: "cashDiscountDays",
label: "Skontofrist in Tagen",
inputType: "number",
disabledInTable: true
},
{ {
key: "defaultPaymentMethod", key: "defaultPaymentMethod",
label: "Standard Zahlungsmethode", label: "Standard Zahlungsmethode",

View File

@@ -59,3 +59,104 @@ export const resolveCashDiscountAccounts = (config: any) => {
} }
return result return result
} }
// ---------------------------------------------------------------------------
// Skonto-Bedingungen auf Belegen (gleiche Logik wie backend/src/modules/cash-discount.ts)
// ---------------------------------------------------------------------------
const roundMoney = (value: number) => Number(value.toFixed(2))
const formatEuro = (value: number) => new Intl.NumberFormat("de-DE", { style: "currency", currency: "EUR" }).format(value)
const formatGermanDate = (value: string) => {
const [year, month, day] = value.split("-")
return `${day}.${month}.${year}`
}
const addDays = (date: any, days: number) => {
if (!date) return null
const iso = typeof date === "string" ? date.slice(0, 10) : new Date(date?.toISOString?.() || date).toISOString().slice(0, 10)
const parsed = new Date(`${iso}T00:00:00Z`)
if (Number.isNaN(parsed.getTime())) return null
parsed.setUTCDate(parsed.getUTCDate() + days)
return parsed.toISOString().slice(0, 10)
}
export function buildCashDiscountTerms({ percent, days, documentDate, dueDate, amount }: any) {
const normalizedPercent = Number(percent)
if (!Number.isFinite(normalizedPercent) || normalizedPercent <= 0 || !Number.isFinite(Number(amount)) || !Number(amount)) return null
const normalizedDays = days === null || days === undefined || days === "" ? null : Number(days)
const computedDueDate = dueDate
? String(dueDate).slice(0, 10)
: normalizedDays !== null && Number.isFinite(normalizedDays) ? addDays(documentDate, normalizedDays) : null
const discount = roundMoney(Number(amount) * normalizedPercent / 100)
return {
percent: normalizedPercent,
days: normalizedDays !== null && Number.isFinite(normalizedDays) ? normalizedDays : null,
dueDate: computedDueDate,
discount,
payable: roundMoney(Number(amount) - discount),
}
}
export function formatCashDiscountText(terms: any) {
if (!terms) return ""
const percent = String(terms.percent).replace(".", ",")
const deadline = terms.dueDate
? `bis zum ${formatGermanDate(terms.dueDate)}`
: terms.days !== null ? `innerhalb von ${terms.days} Tagen` : "bei sofortiger Zahlung"
return `Bei Zahlung ${deadline} gewähren wir ${percent} % Skonto (${formatEuro(Math.abs(terms.discount))}), Zahlbetrag ${formatEuro(Math.abs(terms.payable))}.`
}
export function getCashDiscountTemplateContext(terms: any) {
if (!terms) return {}
return {
skonto_prozent: String(terms.percent).replace(".", ","),
skonto_tage: terms.days,
skonto_datum: terms.dueDate ? formatGermanDate(terms.dueDate) : null,
skonto_betrag: formatEuro(Math.abs(terms.discount)),
zahlbetrag_mit_skonto: formatEuro(Math.abs(terms.payable)),
skonto_hinweis: formatCashDiscountText(terms),
}
}
export function appendCashDiscountText(renderedText: string, template: string | null | undefined, terms: any) {
if (!terms || /skonto_/i.test(String(template || ""))) return renderedText
const sentence = formatCashDiscountText(terms)
return renderedText ? `${sentence}\n\n${renderedText}` : sentence
}
// Liegt eine Zahlung innerhalb der Skontofrist und passt sie zum Betrag abzüglich Skonto?
export function matchCashDiscountPayment({ open, payment, percent, dueDate, paymentDate }: any) {
const openAmount = Math.abs(Number(open || 0))
const paid = Math.abs(Number(payment || 0))
const rate = Number(percent || 0)
if (!openAmount || !paid || !(rate > 0)) return null
if (dueDate && paymentDate && String(paymentDate).slice(0, 10) > String(dueDate).slice(0, 10)) return null
const expected = roundMoney(openAmount * (1 - rate / 100))
if (Math.abs(expected - paid) > 0.05) return null
return { discount: roundMoney(openAmount - paid), percent: rate }
}
// Nettowirkung von Skonto für die BWA: gewährter Skonto mindert Erlöse, erhaltener Skonto den Aufwand.
// Vorzeichen: Erlösminderung positiv; Aufwand im Vorzeichen der Eingangsbelege (Ausgaben positiv).
export function getCashDiscountNetTotals(allocations: any[] = []) {
let grantedNet = 0
let receivedNet = 0
let receivedGross = 0
for (const allocation of allocations || []) {
if (!hasCashDiscount(allocation)) continue
const isOutgoing = Boolean(allocation.createddocument || allocation.cd_id)
for (const item of allocation.cashDiscountSplit) {
if (isOutgoing) grantedNet += Number(item?.net || 0)
else {
receivedNet += Number(item?.net || 0)
receivedGross += Number(item?.gross || 0)
}
}
}
return {
grantedNet: roundMoney(grantedNet),
// Eingangsbelege: Zahlungen (und damit Skonto) sind negativ, Aufwand wird positiv geführt.
receivedNet: roundMoney(-receivedNet),
receivedGross: roundMoney(-receivedGross),
}
}