From bd768e404df6c5031b4bb58cde456e5a410c162e Mon Sep 17 00:00:00 2001 From: florianfederspiel Date: Wed, 4 Nov 2026 09:58:09 +0100 Subject: [PATCH] =?UTF-8?q?feat(accounting):=20Skontobedingungen=20auf=20B?= =?UTF-8?q?elegen=20und=20automatische=20Skonto-Vorschl=C3=A4ge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ausgangs- und Eingangsbelege erhalten Skontosatz und Frist, mit Vorgaben bei Kunden und Lieferanten. Das PDF ergänzt automatisch einen Skontohinweis (oder nutzt neue Textvorlagen-Variablen); bei Schlussrechnungen gilt der Skonto für die Restforderung. Skontobedingungen werden aus E-Rechnungen (#SKONTO# und ZUGFeRD) sowie von der KI-Vorbereitung übernommen. Die Bankzuordnung erkennt Zahlungen abzüglich Skonto innerhalb der Frist und bietet sie in der Vorschlagsübersicht direkt an. BWA-Erlöse und -Aufwand berücksichtigen gebuchten Skonto im Zeitraum der Zahlung. Co-Authored-By: Claude Opus 5.5 --- .../migrations/0085_cash_discount_terms.sql | 15 +++ backend/db/migrations/meta/_journal.json | 7 ++ backend/db/schema/createddocuments.ts | 2 + backend/db/schema/customers.ts | 3 + backend/db/schema/incominginvoices.ts | 3 + backend/db/schema/vendors.ts | 4 + backend/src/mcp/tools/accounting.ts | 18 ++- backend/src/mcp/tools/masterdataWrites.ts | 4 +- backend/src/modules/cash-discount.ts | 104 ++++++++++++++++++ .../modules/cron/prepareIncomingInvoices.ts | 13 ++- .../modules/einvoice/mapElectronicInvoice.ts | 11 ++ .../einvoice/parseElectronicInvoice.ts | 19 +++- backend/src/modules/einvoice/types.ts | 1 + .../modules/outgoing-document-pdf.service.ts | 13 ++- .../src/modules/serialexecution.service.ts | 14 ++- backend/src/utils/gpt.ts | 4 + backend/tests/cashDiscount.test.ts | 25 +++++ backend/tests/einvoice.test.ts | 24 ++++ docs-site/content/bedienung/bankportal.md | 6 + docs/bedienung/bankportal.md | 6 + frontend/components/displayBWASummary.vue | 12 +- frontend/pages/accounting/bwa.vue | 11 +- frontend/pages/banking/index.vue | 94 +++++++++++++--- .../banking/statements/[mode]/[[id]].vue | 66 ++++++++--- frontend/pages/createDocument/edit/[[id]].vue | 68 +++++++++++- .../pages/incomingInvoices/[mode]/[id].vue | 79 ++++++++++++- frontend/pages/settings/texttemplates.vue | 6 + frontend/stores/data.js | 24 ++++ frontend/utils/cashDiscount.ts | 101 +++++++++++++++++ 29 files changed, 712 insertions(+), 45 deletions(-) create mode 100644 backend/db/migrations/0085_cash_discount_terms.sql diff --git a/backend/db/migrations/0085_cash_discount_terms.sql b/backend/db/migrations/0085_cash_discount_terms.sql new file mode 100644 index 00000000..5a853a0a --- /dev/null +++ b/backend/db/migrations/0085_cash_discount_terms.sql @@ -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; diff --git a/backend/db/migrations/meta/_journal.json b/backend/db/migrations/meta/_journal.json index 586d0dc6..8cd47188 100644 --- a/backend/db/migrations/meta/_journal.json +++ b/backend/db/migrations/meta/_journal.json @@ -575,6 +575,13 @@ "when": 1792108800001, "tag": "0084_cash_discounts", "breakpoints": true + }, + { + "idx": 82, + "version": "7", + "when": 1792195200001, + "tag": "0085_cash_discount_terms", + "breakpoints": true } ] } diff --git a/backend/db/schema/createddocuments.ts b/backend/db/schema/createddocuments.ts index 40b74fab..7229d1f4 100644 --- a/backend/db/schema/createddocuments.ts +++ b/backend/db/schema/createddocuments.ts @@ -71,6 +71,8 @@ export const createddocuments = pgTable("createddocuments", { deliveryDateType: text("deliveryDateType"), paymentDays: smallint("paymentDays"), + cashDiscountPercent: doublePrecision("cashDiscountPercent"), + cashDiscountDays: smallint("cashDiscountDays"), deliveryDate: text("deliveryDate"), contactPerson: uuid("contactPerson"), diff --git a/backend/db/schema/customers.ts b/backend/db/schema/customers.ts index c720d696..6f35a296 100644 --- a/backend/db/schema/customers.ts +++ b/backend/db/schema/customers.ts @@ -7,6 +7,7 @@ import { jsonb, smallint, uuid, + doublePrecision, } from "drizzle-orm/pg-core" import { tenants } from "./tenants" import { authUsers } from "./auth_users" @@ -41,6 +42,8 @@ export const customers = pgTable( profiles: jsonb("profiles").notNull().default([]), customPaymentDays: smallint("customPaymentDays"), + customCashDiscountPercent: doublePrecision("customCashDiscountPercent"), + customCashDiscountDays: smallint("customCashDiscountDays"), firstname: text("firstname"), lastname: text("lastname"), diff --git a/backend/db/schema/incominginvoices.ts b/backend/db/schema/incominginvoices.ts index ef43f312..fff60936 100644 --- a/backend/db/schema/incominginvoices.ts +++ b/backend/db/schema/incominginvoices.ts @@ -7,6 +7,7 @@ import { jsonb, numeric, uuid, + doublePrecision, } from "drizzle-orm/pg-core" import { vendors } from "./vendors" @@ -36,6 +37,8 @@ export const incominginvoices = pgTable("incominginvoices", { document: bigint("document", { mode: "number" }), dueDate: text("dueDate"), + cashDiscountPercent: doublePrecision("cashDiscountPercent"), + cashDiscountDueDate: text("cashDiscountDueDate"), description: text("description"), diff --git a/backend/db/schema/vendors.ts b/backend/db/schema/vendors.ts index 32ae94ff..a9801d38 100644 --- a/backend/db/schema/vendors.ts +++ b/backend/db/schema/vendors.ts @@ -6,6 +6,8 @@ import { boolean, jsonb, uuid, + doublePrecision, + smallint, } from "drizzle-orm/pg-core" import { tenants } from "./tenants" @@ -36,6 +38,8 @@ export const vendors = pgTable("vendors", { archived: boolean("archived").notNull().default(false), defaultPaymentMethod: text("defaultPaymentMethod"), + cashDiscountPercent: doublePrecision("cashDiscountPercent"), + cashDiscountDays: smallint("cashDiscountDays"), updatedAt: timestamp("updated_at", { withTimezone: true }), updatedBy: uuid("updated_by").references(() => authUsers.id), diff --git a/backend/src/mcp/tools/accounting.ts b/backend/src/mcp/tools/accounting.ts index 2a65a89c..d8ea15c8 100644 --- a/backend/src/mcp/tools/accounting.ts +++ b/backend/src/mcp/tools/accounting.ts @@ -36,7 +36,7 @@ import { withImages } from "../result" import { renderOutgoingDocumentPdf } from "../../modules/outgoing-document-pdf.service" import { renderPdfPagesAsPng } from "../../utils/pdfPreview" import { outgoingStatusTools } from "./outgoingStatus" -import { prepareCashDiscountAllocation } from "../../modules/cash-discount" +import { normalizeCashDiscountDays, normalizeCashDiscountPercent, prepareCashDiscountAllocation } from "../../modules/cash-discount" export { prepareStatementAllocationInput } from "../statementAllocations" @@ -247,6 +247,8 @@ const buildOutgoingDocumentPayload = ( for (const field of ["paymentDays"] as const) { 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.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) { 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.document !== undefined) payload.document = numberArg(args, "document") if (args.expense !== undefined) payload.expense = args.expense === true @@ -635,6 +639,8 @@ export const accountingTools: McpTool[] = [ deliveryDateEnd: { type: "string" }, deliveryDateType: { type: "string" }, 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" }, taxType: { type: "string", @@ -692,6 +698,8 @@ export const accountingTools: McpTool[] = [ deliveryDateEnd: { type: "string" }, deliveryDateType: { type: "string" }, 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" }, taxType: { type: "string", @@ -882,6 +890,8 @@ export const accountingTools: McpTool[] = [ deliveryDate: { type: "string" }, deliveryDateEnd: { type: "string" }, 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" }, taxType: { type: "string", @@ -1201,6 +1211,8 @@ export const accountingTools: McpTool[] = [ reference: { type: "string" }, date: { type: "string" }, dueDate: { type: "string" }, + cashDiscountPercent: { type: ["number", "null"] }, + cashDiscountDueDate: { type: ["string", "null"] }, document: { type: "number" }, description: { type: "string" }, paymentType: { type: "string" }, @@ -1318,6 +1330,8 @@ export const accountingTools: McpTool[] = [ reference: { type: "string" }, date: { 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" }, description: { type: "string" }, paymentType: { type: "string" }, @@ -1351,6 +1365,8 @@ export const accountingTools: McpTool[] = [ reference: { type: "string" }, date: { 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" }, description: { type: "string" }, paymentType: { type: "string" }, diff --git a/backend/src/mcp/tools/masterdataWrites.ts b/backend/src/mcp/tools/masterdataWrites.ts index e86bda78..f572591d 100644 --- a/backend/src/mcp/tools/masterdataWrites.ts +++ b/backend/src/mcp/tools/masterdataWrites.ts @@ -46,14 +46,14 @@ export const masterdataWriteConfigs: Config[] = [ makeConfig({ resource: "customers", namespace: "customers", label: "Kunde", resultKey: "customer", table: tables.customers, 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 }, references: [commonProfiles, bankAccounts, { field: "memberrelation", table: tables.memberrelations }], }), makeConfig({ resource: "vendors", namespace: "vendors", label: "Lieferant", resultKey: "vendor", table: tables.vendors, 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 }, references: [commonProfiles, bankAccounts], }), diff --git a/backend/src/modules/cash-discount.ts b/backend/src/modules/cash-discount.ts index 0f0b2e64..781d2130 100644 --- a/backend/src/modules/cash-discount.ts +++ b/backend/src/modules/cash-discount.ts @@ -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.`) } } + +// --------------------------------------------------------------------------- +// 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 +} diff --git a/backend/src/modules/cron/prepareIncomingInvoices.ts b/backend/src/modules/cron/prepareIncomingInvoices.ts index 493b3a0b..3a06aa79 100644 --- a/backend/src/modules/cron/prepareIncomingInvoices.ts +++ b/backend/src/modules/cron/prepareIncomingInvoices.ts @@ -31,7 +31,7 @@ const formatInvoiceItemDescription = (item: any) => { return parts.join(" - ") } -const mapGptDataToIncomingInvoice = (data: any, tenantId: number) => { +export const mapGptDataToIncomingInvoice = (data: any, tenantId: number) => { const itemInfo: any = { tenant: tenantId, state: "Vorbereitet", @@ -43,6 +43,17 @@ const mapGptDataToIncomingInvoice = (data: any, tenantId: number) => { 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() + // 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 = { "Direct Debit": "Einzug", "Transfer": "Überweisung", diff --git a/backend/src/modules/einvoice/mapElectronicInvoice.ts b/backend/src/modules/einvoice/mapElectronicInvoice.ts index e8a25319..56c13e8f 100644 --- a/backend/src/modules/einvoice/mapElectronicInvoice.ts +++ b/backend/src/modules/einvoice/mapElectronicInvoice.ts @@ -41,6 +41,13 @@ export const findElectronicInvoiceVendor = async ( 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 = ( invoice: ParsedElectronicInvoice, tenantId: number, @@ -64,6 +71,10 @@ export const mapElectronicInvoiceToIncomingInvoice = ( reference: invoice.invoiceNumber, date: invoice.invoiceDate, 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", description: descriptionParts.join("\n"), accounts: invoice.items.map((item) => ({ diff --git a/backend/src/modules/einvoice/parseElectronicInvoice.ts b/backend/src/modules/einvoice/parseElectronicInvoice.ts index cea54770..9e6b948f 100644 --- a/backend/src/modules/einvoice/parseElectronicInvoice.ts +++ b/backend/src/modules/einvoice/parseElectronicInvoice.ts @@ -1,6 +1,7 @@ import { XMLParser } from "fast-xml-parser" import type { ElectronicInvoiceSyntax } from "./detectElectronicInvoice" import type { ElectronicInvoiceItem, ParsedElectronicInvoice } from "./types" +import { parseCashDiscountPaymentTerms } from "../cash-discount" const parser = new XMLParser({ ignoreAttributes: false, @@ -83,7 +84,9 @@ const parseCii = (root: any): Omit => { }) 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 return { @@ -103,6 +106,7 @@ const parseCii = (root: any): Omit => { }, buyerReference: text(agreement?.BuyerReference), paymentMeansCode: text(paymentMeans?.TypeCode), + cashDiscount, items, totals: { lineNet: number(monetary?.LineTotalAmount) != null ? roundMoney(sign * number(monetary.LineTotalAmount)!) : null, @@ -113,6 +117,18 @@ const parseCii = (root: any): Omit => { } } +// 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 parseUbl = (root: any, syntax: "ubl-invoice" | "ubl-credit-note"): Omit => { @@ -163,6 +179,7 @@ const parseUbl = (root: any, syntax: "ubl-invoice" | "ubl-credit-note"): Omit array(terms?.Note).map(text)).join("\n")), items, totals: { lineNet: number(monetary?.LineExtensionAmount) != null ? roundMoney(sign * number(monetary.LineExtensionAmount)!) : null, diff --git a/backend/src/modules/einvoice/types.ts b/backend/src/modules/einvoice/types.ts index 607a44e4..cd15cc34 100644 --- a/backend/src/modules/einvoice/types.ts +++ b/backend/src/modules/einvoice/types.ts @@ -28,6 +28,7 @@ export type ParsedElectronicInvoice = { } buyerReference: string | null paymentMeansCode: string | null + cashDiscount?: { percent: number, days: number } | null items: ElectronicInvoiceItem[] totals: { lineNet: number | null diff --git a/backend/src/modules/outgoing-document-pdf.service.ts b/backend/src/modules/outgoing-document-pdf.service.ts index 7f8cded6..5404cda5 100644 --- a/backend/src/modules/outgoing-document-pdf.service.ts +++ b/backend/src/modules/outgoing-document-pdf.service.ts @@ -18,6 +18,7 @@ import { } from "../../db/schema" import { documentTemplateHandlebars } from "../utils/handlebars" import { createInvoicePDF, InvoicePdfLayout } from "../utils/pdf" +import { appendCashDiscountText, buildCashDiscountTerms, getCashDiscountTemplateContext } from "./cash-discount" const quoteLikeDocumentTypes = ["quotes", "costEstimates"] const deliveryNoteLikeDocumentTypes = ["deliveryNotes", "packingSlips"] @@ -130,6 +131,15 @@ export function buildOutgoingDocumentPdfData(source: any, data: OutgoingDocument } 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) => { if (!["pagebreak", "title"].includes(row.mode)) { 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, zahlungsart: document.payment_type === "transfer" ? "Überweisung" : "Lastschrift", diesel_gesamtverbrauch: document.agriculture?.dieselUsageTotal, + ...getCashDiscountTemplateContext(cashDiscountTerms), } const formatDate = (value: any) => value ? dayjs(value).format("DD.MM.YYYY") : "XXXX" const formatSums = (sums: Record) => 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, 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), rows, totalArray: [ diff --git a/backend/src/modules/serialexecution.service.ts b/backend/src/modules/serialexecution.service.ts index 9ea6e7d9..f0d62a1c 100644 --- a/backend/src/modules/serialexecution.service.ts +++ b/backend/src/modules/serialexecution.service.ts @@ -10,6 +10,7 @@ import {FastifyInstance} from "fastify"; import {useNextNumberRangeNumber} from "../utils/functions"; import {createInvoicePDF} from "../utils/pdf"; // Achtung: Muss Node.js Buffer unterstützen! import { documentTemplateHandlebars } from "../utils/handlebars"; +import { appendCashDiscountText, buildCashDiscountTerms, getCashDiscountTemplateContext } from "./cash-discount" dayjs.extend(quarterOfYear); @@ -421,6 +422,8 @@ async function getSaveData(item: any, tenant: any, firstDate: string, lastDate: deliveryDate: firstDate, deliveryDateEnd: lastDate, paymentDays: item.paymentDays, + cashDiscountPercent: item.cashDiscountPercent, + cashDiscountDays: item.cashDiscountDays, payment_type: item.payment_type, deliveryDateType: item.deliveryDateType, info: {}, // Achtung: Postgres erwartet hier valides JSON Objekt @@ -646,6 +649,12 @@ export function getDocumentDataBackend( // --- 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 = () => { return { // lohnkosten: null, // Backend hat diesen Wert oft nicht direkt, ggf. aus itemInfo holen @@ -656,7 +665,8 @@ export function getDocumentDataBackend( kundenname: customerData && customerData.name, zahlungsziel_in_tagen: itemInfo.paymentDays, 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, description: itemInfo.description, // Handlebars Compilation ausführen - endText: templateEndText(generateContext()), + endText: appendCashDiscountText(templateEndText(generateContext()), itemInfo.endText, cashDiscountTerms), startText: templateStartText(generateContext()), rows: rows, totalArray: totalArray, diff --git a/backend/src/utils/gpt.ts b/backend/src/utils/gpt.ts index e7334456..7e2059ef 100644 --- a/backend/src/utils/gpt.ts +++ b/backend/src/utils/gpt.ts @@ -74,6 +74,9 @@ const InstructionFormat = z.object({ tax: nullableNumber, total: nullableNumber, 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" + "Match issuer by name to vendor.id.\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" + "Keep invoice items in original order.\n", }, diff --git a/backend/tests/cashDiscount.test.ts b/backend/tests/cashDiscount.test.ts index e17ddfa7..624d0d11 100644 --- a/backend/tests/cashDiscount.test.ts +++ b/backend/tests/cashDiscount.test.ts @@ -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, }), /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) +}) diff --git a/backend/tests/einvoice.test.ts b/backend/tests/einvoice.test.ts index e58f9e95..c14f78ce 100644 --- a/backend/tests/einvoice.test.ts +++ b/backend/tests/einvoice.test.ts @@ -66,3 +66,27 @@ test("markiert Summenabweichungen als Prüfungsfehler", () => { 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( + "", + "#SKONTO#TAGE=10#PROZENT=2.00#\n", + )) + 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( + "", + "#SKONTO#TAGE=14#PROZENT=3.00#", + )) + const detection = await detectElectronicInvoice(xml, { name: "xrechnung.xml" }) + const parsed = parseElectronicInvoice(detection!.xml, detection!.syntax) + assert.deepEqual(parsed.cashDiscount, { percent: 3, days: 14 }) +}) diff --git a/docs-site/content/bedienung/bankportal.md b/docs-site/content/bedienung/bankportal.md index 1e752e20..b15616ff 100644 --- a/docs-site/content/bedienung/bankportal.md +++ b/docs-site/content/bedienung/bankportal.md @@ -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. - 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 - Regelmäßig abrufen, damit sich keine großen Rückstände bilden. diff --git a/docs/bedienung/bankportal.md b/docs/bedienung/bankportal.md index 1e752e20..b15616ff 100644 --- a/docs/bedienung/bankportal.md +++ b/docs/bedienung/bankportal.md @@ -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. - 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 - Regelmäßig abrufen, damit sich keine großen Rückstände bilden. diff --git a/frontend/components/displayBWASummary.vue b/frontend/components/displayBWASummary.vue index 3b8950b4..90498b36 100644 --- a/frontend/components/displayBWASummary.vue +++ b/frontend/components/displayBWASummary.vue @@ -1,6 +1,7 @@