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:
@@ -59,3 +59,104 @@ export const resolveCashDiscountAccounts = (config: any) => {
|
||||
}
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user