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>
163 lines
7.3 KiB
TypeScript
163 lines
7.3 KiB
TypeScript
// Skonto wird an der Bankzuordnung gespeichert (cashDiscountAmount, gleiches Vorzeichen wie amount).
|
|
// Zahlbetrag und Skonto gleichen den Beleg gemeinsam aus; der Bankumsatz selbst enthält nur amount.
|
|
|
|
export const CASH_DISCOUNT_RATES = ["19", "7", "0"] as const
|
|
|
|
export const DEFAULT_CASH_DISCOUNT_ACCOUNTS = {
|
|
received: { "19": "3736", "7": "3731", "0": "3730" },
|
|
granted: { "19": "8736", "7": "8731", "0": "8730" },
|
|
}
|
|
|
|
export const getAllocationCashDiscount = (allocation: any) => Number(allocation?.cashDiscountAmount || 0)
|
|
|
|
export const getAllocationSettledAmount = (allocation: any) =>
|
|
Number(allocation?.amount || 0) + getAllocationCashDiscount(allocation)
|
|
|
|
export const sumSettledAllocations = (allocations: any[] = []) =>
|
|
(allocations || []).reduce((sum, allocation) => sum + getAllocationSettledAmount(allocation), 0)
|
|
|
|
export const hasCashDiscount = (allocation: any) =>
|
|
!allocation?.archived && Array.isArray(allocation?.cashDiscountSplit) && allocation.cashDiscountSplit.length > 0
|
|
|
|
// Steuerkorrektur nach § 17 UStG im Zeitraum der Zahlung.
|
|
// Ausgangsbelege mindern die Umsatzsteuer, Eingangsbelege die Vorsteuer (Vorzeichen aus der Zahlung).
|
|
export function getCashDiscountTaxBreakdown(allocation: any) {
|
|
const output = { net19: 0, tax19: 0, net7: 0, tax7: 0, net0: 0 }
|
|
const input = { ...output }
|
|
if (!hasCashDiscount(allocation)) return { output, input }
|
|
|
|
const isOutgoing = Boolean(allocation.createddocument || allocation.cd_id)
|
|
const target = isOutgoing ? output : input
|
|
const sign = isOutgoing ? -1 : 1
|
|
|
|
for (const item of allocation.cashDiscountSplit) {
|
|
const net = Number(item?.net || 0) * sign
|
|
const tax = Number(item?.tax || 0) * sign
|
|
if (Number(item?.rate) === 19) {
|
|
target.net19 += net
|
|
target.tax19 += tax
|
|
} else if (Number(item?.rate) === 7) {
|
|
target.net7 += net
|
|
target.tax7 += tax
|
|
} else {
|
|
target.net0 += net
|
|
}
|
|
}
|
|
return { output, input }
|
|
}
|
|
|
|
export const resolveCashDiscountAccounts = (config: any) => {
|
|
const result = {
|
|
received: { ...DEFAULT_CASH_DISCOUNT_ACCOUNTS.received },
|
|
granted: { ...DEFAULT_CASH_DISCOUNT_ACCOUNTS.granted },
|
|
}
|
|
for (const side of ["received", "granted"] as const) {
|
|
for (const rate of CASH_DISCOUNT_RATES) {
|
|
const value = config?.[side]?.[rate]
|
|
if (value !== undefined && value !== null && String(value).trim()) result[side][rate] = String(value).trim()
|
|
}
|
|
}
|
|
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),
|
|
}
|
|
}
|