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

@@ -1,7 +1,7 @@
<script setup>
import dayjs from "dayjs";
import { BANK_ALLOCATION_TAX_ITEMS, getBankAllocationAmounts } from "~/utils/bankAllocationTax"
import { sumSettledAllocations } from "~/utils/cashDiscount"
import { matchCashDiscountPayment, sumSettledAllocations } from "~/utils/cashDiscount"
import {
DEPRECIATION_METHOD_ITEMS,
EXPENSE_BOOKING_MODE_ITEMS,
@@ -421,6 +421,30 @@ const isWithinAmountTolerance = (candidateSum, remaining) => {
return difference / reference <= 0.1
}
// Erkennt Zahlungen, die genau dem offenen Betrag abzüglich vereinbartem Skonto entsprechen und in der Frist liegen.
const getCashDiscountMatch = (kind, entity) => {
const paymentDate = itemInfo.value?.date || itemInfo.value?.valueDate
if (kind === "createddocument") {
const days = entity.cashDiscountDays
return matchCashDiscountPayment({
open: entity.openSum,
payment: calculateOpenSum.value,
percent: entity.cashDiscountPercent,
dueDate: entity.documentDate && days !== null && days !== undefined && days !== ""
? dayjs(entity.documentDate).add(Number(days), "day").format("YYYY-MM-DD")
: null,
paymentDate: paymentDate ? dayjs(paymentDate).format("YYYY-MM-DD") : null
})
}
return matchCashDiscountPayment({
open: getInvoiceSum(entity, true),
payment: calculateOpenSum.value,
percent: entity.cashDiscountPercent,
dueDate: entity.cashDiscountDueDate || null,
paymentDate: paymentDate ? dayjs(paymentDate).format("YYYY-MM-DD") : null
})
}
const getDocumentSuggestionMeta = (document, suggestion) => {
let score = 0
const remaining = Math.abs(Number(calculateOpenSum.value))
@@ -430,18 +454,22 @@ const getDocumentSuggestionMeta = (document, suggestion) => {
document.title,
document.description
])
const amountLabel = getAmountMatchLabel(openSum, remaining)
const withinTolerance = isWithinAmountTolerance(openSum, remaining)
const cashDiscountMatch = getCashDiscountMatch("createddocument", document)
const amountLabel = cashDiscountMatch
? `Passt mit ${String(cashDiscountMatch.percent).replace(".", ",")} % Skonto`
: getAmountMatchLabel(openSum, remaining)
const withinTolerance = cashDiscountMatch || isWithinAmountTolerance(openSum, remaining)
if (!withinTolerance) {
return {
score: 0,
purposeMatches: [],
amountLabel: null
amountLabel: null,
cashDiscountMatch: null
}
}
if (openSum === remaining) score += 100
if (openSum === remaining || cashDiscountMatch) score += 100
else score += Math.max(0, 60 - Math.abs(openSum - remaining))
if (suggestion && document.customer?.id === suggestion.id) score += 80
@@ -450,7 +478,8 @@ const getDocumentSuggestionMeta = (document, suggestion) => {
return {
score,
purposeMatches,
amountLabel
amountLabel,
cashDiscountMatch
}
}
@@ -462,18 +491,22 @@ const getInvoiceSuggestionMeta = (invoice, suggestion) => {
invoice.reference,
invoice.description
])
const amountLabel = getAmountMatchLabel(openSum, remaining)
const withinTolerance = isWithinAmountTolerance(openSum, remaining)
const cashDiscountMatch = getCashDiscountMatch("incominginvoice", invoice)
const amountLabel = cashDiscountMatch
? `Passt mit ${String(cashDiscountMatch.percent).replace(".", ",")} % Skonto`
: getAmountMatchLabel(openSum, remaining)
const withinTolerance = cashDiscountMatch || isWithinAmountTolerance(openSum, remaining)
if (!withinTolerance) {
return {
score: 0,
purposeMatches: [],
amountLabel: null
amountLabel: null,
cashDiscountMatch: null
}
}
if (openSum === remaining) score += 100
if (openSum === remaining || cashDiscountMatch) score += 100
else score += Math.max(0, 60 - Math.abs(openSum - remaining))
if (suggestion && invoice.vendor?.id === suggestion.id) score += 80
@@ -482,7 +515,8 @@ const getInvoiceSuggestionMeta = (invoice, suggestion) => {
return {
score,
purposeMatches,
amountLabel
amountLabel,
cashDiscountMatch
}
}
@@ -498,6 +532,7 @@ const suggestedDocuments = computed(() => {
suggestionScore: meta.score,
suggestionPurposeMatches: meta.purposeMatches,
suggestionAmountLabel: meta.amountLabel,
suggestionCashDiscount: meta.cashDiscountMatch,
suggestionKey: `document:${document.id}`
}
})
@@ -518,6 +553,7 @@ const suggestedIncomingInvoices = computed(() => {
suggestionScore: meta.score,
suggestionPurposeMatches: meta.purposeMatches,
suggestionAmountLabel: meta.amountLabel,
suggestionCashDiscount: meta.cashDiscountMatch,
suggestionKey: `invoice:${invoice.id}`
}
})
@@ -537,6 +573,7 @@ const fallbackSuggestedDocuments = computed(() => {
suggestionScore: meta.score,
suggestionPurposeMatches: meta.purposeMatches,
suggestionAmountLabel: meta.amountLabel,
suggestionCashDiscount: meta.cashDiscountMatch,
suggestionKey: `document:${document.id}`
}
})
@@ -557,6 +594,7 @@ const fallbackSuggestedIncomingInvoices = computed(() => {
suggestionScore: meta.score,
suggestionPurposeMatches: meta.purposeMatches,
suggestionAmountLabel: meta.amountLabel,
suggestionCashDiscount: meta.cashDiscountMatch,
suggestionKey: `invoice:${invoice.id}`
}
})
@@ -1084,7 +1122,7 @@ setup()
v-if="canBookCashDiscount('createddocument', document)"
size="xs"
color="warning"
variant="soft"
:variant="document.suggestionCashDiscount ? 'solid' : 'soft'"
@click="openCashDiscountModal('createddocument', document)"
>
Mit Skonto
@@ -1127,7 +1165,7 @@ setup()
v-if="canBookCashDiscount('incominginvoice', invoice)"
size="xs"
color="warning"
variant="soft"
:variant="invoice.suggestionCashDiscount ? 'solid' : 'soft'"
@click="openCashDiscountModal('incominginvoice', invoice)"
>
Mit Skonto
@@ -1182,6 +1220,7 @@ setup()
<div class="text-right">
<div class="font-mono font-bold text-sm">{{ displayCurrency(document.openSum) }}</div>
<div class="text-xs text-gray-400">Offen</div>
<UBadge v-if="getCashDiscountMatch('createddocument', document)" size="xs" color="warning" variant="subtle">Skonto passt</UBadge>
</div>
<UTooltip text="Zuweisen">
<UButton
@@ -1251,6 +1290,7 @@ setup()
{{ displayCurrency(getInvoiceSum(item, true)) }}
</div>
<div class="text-xs text-gray-400">Offen</div>
<UBadge v-if="getCashDiscountMatch('incominginvoice', item)" size="xs" color="warning" variant="subtle">Skonto passt</UBadge>
</div>
<UTooltip text="Zuweisen">
<UButton