feat(accounting): Skontobedingungen auf Belegen und automatische Skonto-Vorschläge

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 <noreply@anthropic.com>
This commit is contained in:
2026-11-04 09:58:09 +01:00
parent af4a283eb5
commit bd768e404d
29 changed files with 712 additions and 45 deletions

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,
"tag": "0084_cash_discounts",
"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"),
paymentDays: smallint("paymentDays"),
cashDiscountPercent: doublePrecision("cashDiscountPercent"),
cashDiscountDays: smallint("cashDiscountDays"),
deliveryDate: text("deliveryDate"),
contactPerson: uuid("contactPerson"),

View File

@@ -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"),

View File

@@ -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"),

View File

@@ -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),

View File

@@ -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" },

View File

@@ -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],
}),

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.`)
}
}
// ---------------------------------------------------------------------------
// 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(" - ")
}
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<string, string> = {
"Direct Debit": "Einzug",
"Transfer": "Überweisung",

View File

@@ -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) => ({

View File

@@ -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<ParsedElectronicInvoice, "validation"> => {
})
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<ParsedElectronicInvoice, "validation"> => {
},
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<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 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),
paymentMeansCode: text(paymentMeans?.PaymentMeansCode),
cashDiscount: parseCashDiscountPaymentTerms(array(root?.PaymentTerms).flatMap((terms: any) => array(terms?.Note).map(text)).join("\n")),
items,
totals: {
lineNet: number(monetary?.LineExtensionAmount) != null ? roundMoney(sign * number(monetary.LineExtensionAmount)!) : null,

View File

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

View File

@@ -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<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,
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: [

View File

@@ -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,

View File

@@ -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",
},

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,
}), /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/)
})
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 })
})