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:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user