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,4 +1,5 @@
<script setup lang="ts">
import { getCashDiscountNetTotals } from "~/utils/cashDiscount"
import dayjs from "dayjs"
import { getBankAllocationAmounts, getBankAllocationTaxBreakdown } from "~/utils/bankAllocationTax"
import {
@@ -183,8 +184,12 @@ const selectedPeriodBounds = computed(() => {
return { start, end }
})
// Skonto wirkt im Zeitraum der Zahlung: gewährter Skonto mindert Erlöse, erhaltener Skonto den Aufwand.
const cashDiscountTotals = computed(() => getCashDiscountNetTotals(filteredStatementAllocations.value))
const incomeTotal = computed(() => {
return Number(filteredDocuments.value.reduce((sum, doc) => sum + computeDocumentNet(doc), 0).toFixed(2))
const documents = filteredDocuments.value.reduce((sum, doc) => sum + computeDocumentNet(doc), 0)
return Number((documents - cashDiscountTotals.value.grantedNet).toFixed(2))
})
const expenseNetTotal = computed(() => {
@@ -196,7 +201,7 @@ const expenseNetTotal = computed(() => {
const depreciations = depreciationTotal.value
return Number((invoiceExpenses + directAccountExpenses + depreciations).toFixed(2))
return Number((invoiceExpenses + directAccountExpenses + depreciations - cashDiscountTotals.value.receivedNet).toFixed(2))
})
const expenseGrossTotal = computed(() => {
@@ -206,7 +211,7 @@ const expenseGrossTotal = computed(() => {
const depreciations = depreciationTotal.value
return Number((invoiceExpenses + directAccountExpenses + depreciations).toFixed(2))
return Number((invoiceExpenses + directAccountExpenses + depreciations - cashDiscountTotals.value.receivedGross).toFixed(2))
})
const taxSummary = computed(() => {

View File

@@ -1,5 +1,5 @@
<script setup>
import { sumSettledAllocations } from "~/utils/cashDiscount"
import { matchCashDiscountPayment, sumSettledAllocations } from "~/utils/cashDiscount"
import { parseDate } from "@internationalized/date"
const {$api, $dayjs} = useNuxtApp()
@@ -387,25 +387,52 @@ const getTopEntitySuggestion = (statement) => {
return suggestions[0] || null
}
// Zahlung entspricht dem offenen Betrag abzüglich vereinbartem Skonto und liegt in der Frist.
const getCashDiscountMatch = (statement, kind, entity) => {
const paymentDate = getStatementDate(statement) ? $dayjs(getStatementDate(statement)).format("YYYY-MM-DD") : null
if (kind === "createddocument") {
const days = entity.cashDiscountDays
return matchCashDiscountPayment({
open: entity.openSum,
payment: calculateOpenSum(statement),
percent: entity.cashDiscountPercent,
dueDate: entity.documentDate && days !== null && days !== undefined && days !== ""
? $dayjs(entity.documentDate).add(Number(days), "day").format("YYYY-MM-DD")
: null,
paymentDate
})
}
return matchCashDiscountPayment({
open: getInvoiceSum(entity, true),
payment: calculateOpenSum(statement),
percent: entity.cashDiscountPercent,
dueDate: entity.cashDiscountDueDate || null,
paymentDate
})
}
const getDocumentSuggestionMeta = (statement, document, suggestion) => {
let score = 0
const remaining = Math.abs(Number(calculateOpenSum(statement)))
const openSum = Math.abs(Number(document.openSum))
const purposeMatches = getMatchingPurposeParts(statement.text, [document.documentNumber, document.title, document.description])
const amountLabel = getAmountMatchLabel(openSum, remaining)
const withinTolerance = isWithinAmountTolerance(openSum, remaining)
const cashDiscountMatch = getCashDiscountMatch(statement, "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, amountLabel: null, purposeMatches: [] }
return { score: 0, amountLabel: null, purposeMatches: [], 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
if (purposeMatches.length > 0) score += 50 + (purposeMatches.length * 10)
return { score, amountLabel, purposeMatches }
return { score, amountLabel, purposeMatches, cashDiscountMatch }
}
const getIncomingInvoiceSuggestionMeta = (statement, invoice, suggestion) => {
@@ -413,20 +440,23 @@ const getIncomingInvoiceSuggestionMeta = (statement, invoice, suggestion) => {
const remaining = Math.abs(Number(calculateOpenSum(statement)))
const openSum = Math.abs(Number(getInvoiceSum(invoice, true)))
const purposeMatches = getMatchingPurposeParts(statement.text, [invoice.reference, invoice.description])
const amountLabel = getAmountMatchLabel(openSum, remaining)
const withinTolerance = isWithinAmountTolerance(openSum, remaining)
const cashDiscountMatch = getCashDiscountMatch(statement, "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, amountLabel: null, purposeMatches: [] }
return { score: 0, amountLabel: null, purposeMatches: [], 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
if (purposeMatches.length > 0) score += 50 + (purposeMatches.length * 10)
return { score, amountLabel, purposeMatches }
return { score, amountLabel, purposeMatches, cashDiscountMatch }
}
const getRowSuggestions = (statement) => {
@@ -435,7 +465,7 @@ const getRowSuggestions = (statement) => {
const topDocument = (entitySuggestion?.type === "customer" ? openDocuments.value.filter(doc => doc.customer?.id === entitySuggestion.id) : openDocuments.value)
.map(document => {
const meta = getDocumentSuggestionMeta(statement, document, entitySuggestion?.type === "customer" ? entitySuggestion : null)
return { ...document, suggestionScore: meta.score, suggestionAmountLabel: meta.amountLabel, suggestionPurposeMatches: meta.purposeMatches, suggestionKey: `document:${document.id}` }
return { ...document, suggestionScore: meta.score, suggestionAmountLabel: meta.amountLabel, suggestionPurposeMatches: meta.purposeMatches, suggestionCashDiscount: meta.cashDiscountMatch, suggestionKey: `document:${document.id}` }
})
.filter(document => !isSuggestionDismissed(statement.id, document.suggestionKey))
.filter(document => document.suggestionScore >= 80 || document.suggestionPurposeMatches?.length > 0)
@@ -444,7 +474,7 @@ const getRowSuggestions = (statement) => {
const topIncomingInvoice = (entitySuggestion?.type === "vendor" ? openIncomingInvoices.value.filter(invoice => invoice.vendor?.id === entitySuggestion.id) : openIncomingInvoices.value)
.map(invoice => {
const meta = getIncomingInvoiceSuggestionMeta(statement, invoice, entitySuggestion?.type === "vendor" ? entitySuggestion : null)
return { ...invoice, suggestionScore: meta.score, suggestionAmountLabel: meta.amountLabel, suggestionPurposeMatches: meta.purposeMatches, suggestionKey: `invoice:${invoice.id}` }
return { ...invoice, suggestionScore: meta.score, suggestionAmountLabel: meta.amountLabel, suggestionPurposeMatches: meta.purposeMatches, suggestionCashDiscount: meta.cashDiscountMatch, suggestionKey: `invoice:${invoice.id}` }
})
.filter(invoice => !isSuggestionDismissed(statement.id, invoice.suggestionKey))
.filter(invoice => invoice.suggestionScore >= 80 || invoice.suggestionPurposeMatches?.length > 0)
@@ -552,6 +582,24 @@ const handleAssignIncomingInvoice = async (row, invoice, event) => {
})
}
// Bucht die Zahlung zusammen mit dem vereinbarten Skonto, wenn Betrag und Frist zu den Skontobedingungen passen.
const handleAssignWithCashDiscount = async (row, kind, entity, event) => {
event.stopPropagation()
const match = entity.suggestionCashDiscount
if (!match) return
try {
await saveAllocation({
[kind]: entity.id,
bankstatement: row.id,
amount: Number(calculateOpenSum(row)),
cashDiscountAmount: match.discount,
description: "Automatischer Vorschlag mit Skonto"
})
} catch (err) {
toast.add({ title: "Skonto konnte nicht gebucht werden", description: err?.data?.error, color: "error" })
}
}
const dismissSuggestion = (row, suggestionKey, event) => {
event.stopPropagation()
const bankingSettings = tempStore.settings?.banking || {}
@@ -928,7 +976,15 @@ onMounted(() => {
</UBadge>
</div>
</div>
<UButton size="sm" color="emerald" @click="handleAssignDocument(selectedSuggestionRow.row, selectedSuggestionRow.suggestions.topDocument, $event)">
<UButton
v-if="selectedSuggestionRow.suggestions.topDocument.suggestionCashDiscount"
size="sm"
color="warning"
@click="handleAssignWithCashDiscount(selectedSuggestionRow.row, 'createddocument', selectedSuggestionRow.suggestions.topDocument, $event)"
>
Mit Skonto zuweisen
</UButton>
<UButton v-else size="sm" color="emerald" @click="handleAssignDocument(selectedSuggestionRow.row, selectedSuggestionRow.suggestions.topDocument, $event)">
Zuweisen
</UButton>
<UButton size="sm" color="gray" variant="ghost" @click="dismissSuggestion(selectedSuggestionRow.row, selectedSuggestionRow.suggestions.topDocument.suggestionKey, $event)">
@@ -951,7 +1007,15 @@ onMounted(() => {
</UBadge>
</div>
</div>
<UButton size="sm" color="error" @click="handleAssignIncomingInvoice(selectedSuggestionRow.row, selectedSuggestionRow.suggestions.topIncomingInvoice, $event)">
<UButton
v-if="selectedSuggestionRow.suggestions.topIncomingInvoice.suggestionCashDiscount"
size="sm"
color="warning"
@click="handleAssignWithCashDiscount(selectedSuggestionRow.row, 'incominginvoice', selectedSuggestionRow.suggestions.topIncomingInvoice, $event)"
>
Mit Skonto zuweisen
</UButton>
<UButton v-else size="sm" color="error" @click="handleAssignIncomingInvoice(selectedSuggestionRow.row, selectedSuggestionRow.suggestions.topIncomingInvoice, $event)">
Zuweisen
</UButton>
<UButton size="sm" color="gray" variant="ghost" @click="dismissSuggestion(selectedSuggestionRow.row, selectedSuggestionRow.suggestions.topIncomingInvoice.suggestionKey, $event)">

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

View File

@@ -6,6 +6,7 @@ import {useFunctions} from "~/composables/useFunctions.js";
import EntityModalButtons from "~/components/EntityModalButtons.vue";
import { documentTemplateHandlebars } from "~/utils/handlebars";
import { getTitleSectionMove, moveTitleSection } from "~/utils/documentTitleSections.js";
import { appendCashDiscountText, buildCashDiscountTerms, formatCashDiscountText, getCashDiscountTemplateContext } from "~/utils/cashDiscount";
const dataStore = useDataStore()
const profileStore = useProfileStore()
@@ -57,6 +58,8 @@ const itemInfo = ref({
deliveryDateType: "Lieferdatum",
dateOfPerformance: null,
paymentDays: auth.activeTenantData.standardPaymentDays,
cashDiscountPercent: null,
cashDiscountDays: null,
payment_type: "transfer",
outgoingsepamandate: null,
availableInPortal: false,
@@ -635,7 +638,11 @@ const setupPage = async () => {
if (optionsToImport.deliveryDate) itemInfo.value.deliveryDate = linkedDocument.deliveryDate
if (optionsToImport.deliveryDateEnd) itemInfo.value.deliveryDateEnd = linkedDocument.deliveryDateEnd
if (optionsToImport.documentDate) itemInfo.value.documentDate = linkedDocument.documentDate
if (optionsToImport.paymentDays) itemInfo.value.paymentDays = linkedDocument.paymentDays
if (optionsToImport.paymentDays) {
itemInfo.value.paymentDays = linkedDocument.paymentDays
itemInfo.value.cashDiscountPercent = linkedDocument.cashDiscountPercent ?? null
itemInfo.value.cashDiscountDays = linkedDocument.cashDiscountDays ?? null
}
if (optionsToImport.payment_type) itemInfo.value.payment_type = linkedDocument.payment_type
if (optionsToImport.customSurchargePercentage) itemInfo.value.customSurchargePercentage = linkedDocument.customSurchargePercentage
if (optionsToImport.contactPerson) itemInfo.value.contactPerson = linkedDocument.contactPerson
@@ -663,6 +670,8 @@ const setupPage = async () => {
itemInfo.value.deliveryDateEnd = linkedDocument.deliveryDateEnd
itemInfo.value.documentDate = linkedDocument.documentDate
itemInfo.value.paymentDays = linkedDocument.paymentDays
itemInfo.value.cashDiscountPercent = linkedDocument.cashDiscountPercent ?? null
itemInfo.value.cashDiscountDays = linkedDocument.cashDiscountDays ?? null
itemInfo.value.payment_type = linkedDocument.payment_type
itemInfo.value.customSurchargePercentage = linkedDocument.customSurchargePercentage
itemInfo.value.contactPerson = linkedDocument.contactPerson
@@ -833,6 +842,10 @@ const setCustomerData = async (customerId, loadOnlyAdress = false) => {
itemInfo.value.address.country = customer.infoData.country
if (!loadOnlyAdress && customer.customPaymentDays) itemInfo.value.paymentDays = customer.customPaymentDays
if (!loadOnlyAdress && customer.customCashDiscountPercent) {
itemInfo.value.cashDiscountPercent = customer.customCashDiscountPercent
itemInfo.value.cashDiscountDays = customer.customCashDiscountDays ?? null
}
if (!loadOnlyAdress && customer.custom_payment_type) itemInfo.value.payment_type = customer.custom_payment_type
if (!loadOnlyAdress) {
itemInfo.value.taxType = normalizeTaxTypeValue(customer.customTaxType)
@@ -1357,6 +1370,17 @@ const documentTotal = computed(() => {
}
})
// Skonto bezieht sich bei Schlussrechnungen auf die Restforderung nach Abzug der Abschläge.
const cashDiscountTerms = computed(() => {
if (!["invoices", "advanceInvoices", "serialInvoices"].includes(itemInfo.value.type)) return null
return buildCashDiscountTerms({
percent: itemInfo.value.cashDiscountPercent,
days: itemInfo.value.cashDiscountDays,
documentDate: itemInfo.value.documentDate ? dayjs(itemInfo.value.documentDate).format("YYYY-MM-DD") : null,
amount: itemInfo.value.type === "invoices" ? documentTotal.value.totalSumToPay : documentTotal.value.totalGross
})
})
const documentReport = computed(() => {
let totalProductsPurchasePrice = 0
@@ -1574,7 +1598,8 @@ const getDocumentData = async () => {
kundenname: customerData && customerData.name,
zahlungsziel_in_tagen: itemInfo.paymentDays,
zahlungsart: itemInfo.payment_type === "transfer" ? "Überweisung" : "Lastschrift",
diesel_gesamtverbrauch: (itemInfo.agriculture && itemInfo.agriculture.dieselUsageTotal) && itemInfo.agriculture.dieselUsageTotal
diesel_gesamtverbrauch: (itemInfo.agriculture && itemInfo.agriculture.dieselUsageTotal) && itemInfo.agriculture.dieselUsageTotal,
...getCashDiscountTemplateContext(cashDiscountTerms.value)
}
}
@@ -1684,7 +1709,7 @@ const getDocumentData = async () => {
],
title: itemInfo.value.title,
description: itemInfo.value.description,
endText: templateEndText(generateContext(itemInfo.value, contactData)),
endText: appendCashDiscountText(templateEndText(generateContext(itemInfo.value, contactData)), itemInfo.value.endText, cashDiscountTerms.value),
startText: templateStartText(generateContext(itemInfo.value, contactData)),
rows: rows,
totalArray: [
@@ -1819,6 +1844,8 @@ const saveSerialInvoice = async () => {
project: normalizeEntityId(itemInfo.value.project),
costcentre: itemInfo.value.costcentre,
paymentDays: itemInfo.value.paymentDays,
cashDiscountPercent: itemInfo.value.cashDiscountPercent === "" || itemInfo.value.cashDiscountPercent === null ? null : Number(itemInfo.value.cashDiscountPercent),
cashDiscountDays: itemInfo.value.cashDiscountDays === "" || itemInfo.value.cashDiscountDays === null ? null : Number(itemInfo.value.cashDiscountDays),
payment_type: itemInfo.value.payment_type,
outgoingsepamandate: normalizeEntityId(itemInfo.value.outgoingsepamandate),
deliveryDateType: "Leistungszeitraum",
@@ -1935,6 +1962,8 @@ const saveDocument = async (state, resetup = false) => {
deliveryDate: itemInfo.value.deliveryDate,
deliveryDateEnd: itemInfo.value.deliveryDateEnd,
paymentDays: itemInfo.value.paymentDays,
cashDiscountPercent: itemInfo.value.cashDiscountPercent === "" || itemInfo.value.cashDiscountPercent === null ? null : Number(itemInfo.value.cashDiscountPercent),
cashDiscountDays: itemInfo.value.cashDiscountDays === "" || itemInfo.value.cashDiscountDays === null ? null : Number(itemInfo.value.cashDiscountDays),
payment_type: itemInfo.value.payment_type,
outgoingsepamandate: normalizeEntityId(itemInfo.value.outgoingsepamandate),
deliveryDateType: itemInfo.value.deliveryDateType,
@@ -2690,6 +2719,39 @@ const setRowData = async (row, service = {sellingPriceComposed: {}}, product = {
class="w-full"
/>
</UFormField>
<UFormField
v-if="['invoices', 'advanceInvoices', 'serialInvoices'].includes(itemInfo.type)"
class="min-w-0 flex-1"
label="Skonto:"
:help="cashDiscountTerms ? formatCashDiscountText(cashDiscountTerms) : undefined"
>
<div class="flex gap-1">
<UInput
type="number"
step="0.01"
min="0"
max="10"
placeholder="%"
v-model="itemInfo.cashDiscountPercent"
class="w-full"
>
<template #trailing>
<span class="text-gray-500 dark:text-gray-400 text-xs">%</span>
</template>
</UInput>
<UInput
type="number"
min="0"
placeholder="Tage"
v-model="itemInfo.cashDiscountDays"
class="w-full"
>
<template #trailing>
<span class="text-gray-500 dark:text-gray-400 text-xs">Tage</span>
</template>
</UInput>
</div>
</UFormField>
<UFormField
class="min-w-0 flex-1"
label="Individueller Aufschlag:"

View File

@@ -34,6 +34,8 @@ const itemInfo = ref({
reference: "",
date: null,
dueDate: null,
cashDiscountPercent: null,
cashDiscountDueDate: null,
paymentType: "Überweisung",
description: "",
state: "Entwurf",
@@ -147,6 +149,28 @@ const setDateFieldToToday = (field) => {
itemInfo.value[field] = dayjs().toDate()
}
// Skontofrist als reines Datum speichern, damit der Vergleich mit dem Zahlungsdatum nicht durch Zeitzonen kippt.
const setCashDiscountDueDate = (value) => {
itemInfo.value.cashDiscountDueDate = value ? dayjs(value.toString()).format("YYYY-MM-DD") : null
}
// Lieferanten-Vorgabe nur bei bewusster Auswahl übernehmen, nicht beim Laden eines Belegs.
const applyVendorCashDiscount = (vendorId) => {
const vendor = vendors.value.find((item) => item.id === vendorId)
if (!vendor?.cashDiscountPercent || itemInfo.value.cashDiscountPercent) return
itemInfo.value.cashDiscountPercent = vendor.cashDiscountPercent
if (itemInfo.value.date && vendor.cashDiscountDays !== null && vendor.cashDiscountDays !== undefined) {
itemInfo.value.cashDiscountDueDate = dayjs(itemInfo.value.date).add(vendor.cashDiscountDays, "day").format("YYYY-MM-DD")
}
}
const cashDiscountAmount = computed(() => {
const percent = Number(itemInfo.value.cashDiscountPercent || 0)
if (!(percent > 0)) return null
const gross = (itemInfo.value.accounts || []).reduce((sum, account) => sum + Number(account.amountNet || 0) + Number(account.amountTax || 0), 0)
return Number((Math.abs(gross) * percent / 100).toFixed(2))
})
const getDateButtonLabel = (value, emptyLabel = "Kein Datum") => value ? dayjs(value).format('DD.MM.YYYY') : emptyLabel
const formatDate = (value) => value ? dayjs(value).format("DD.MM.YYYY") : "-"
const formatMoney = (value) => `${Number(value || 0).toFixed(2).replace(".", ",")} €`
@@ -333,6 +357,9 @@ const updateIncomingInvoice = async (setBooked = false) => {
let item = { ...itemInfo.value }
item.accounts = (item.accounts || []).map((account) => ensureDepreciationDefaults({ ...account }, item.date))
const cashDiscountPercent = Number(item.cashDiscountPercent)
item.cashDiscountPercent = Number.isFinite(cashDiscountPercent) && cashDiscountPercent > 0 ? cashDiscountPercent : null
item.cashDiscountDueDate = item.cashDiscountPercent && item.cashDiscountDueDate ? dayjs(item.cashDiscountDueDate).format("YYYY-MM-DD") : null
delete item.files
item.state = keepBooked ? "Gebucht" : "Entwurf"
@@ -569,6 +596,14 @@ const hasBlockingIncomingInvoiceErrors = computed(() => blockingIncomingInvoiceE
<dt class="text-gray-500">Fälligkeitsdatum</dt>
<dd class="font-medium">{{ formatDate(itemInfo.dueDate) }}</dd>
</div>
<div v-if="itemInfo.cashDiscountPercent">
<dt class="text-gray-500">Skonto</dt>
<dd class="font-medium">
{{ String(itemInfo.cashDiscountPercent).replace(".", ",") }} %
<span v-if="cashDiscountAmount">({{ formatMoney(cashDiscountAmount) }})</span>
<span v-if="itemInfo.cashDiscountDueDate"> bis {{ formatDate(itemInfo.cashDiscountDueDate) }}</span>
</dd>
</div>
<div>
<dt class="text-gray-500">Bankbuchungsdatum</dt>
<dd class="font-medium">{{ bankBookingDateLabel }}</dd>
@@ -637,6 +672,7 @@ const hasBlockingIncomingInvoiceErrors = computed(() => blockingIncomingInvoiceE
:disabled="mode === 'show'"
:filter-fields="['name', 'vendorNumber']"
:color="itemInfo.vendor ? 'primary' : 'error'"
@update:model-value="applyVendorCashDiscount"
>
<template #default>
{{ vendors.find(v => v.id === itemInfo.vendor)?.name || 'Bitte wählen' }}
@@ -657,7 +693,7 @@ const hasBlockingIncomingInvoiceErrors = computed(() => blockingIncomingInvoiceE
v-if="mode !== 'show'"
type="vendors"
:id="itemInfo.vendor"
@return-data="(data) => itemInfo.vendor = data.id"
@return-data="(data) => { itemInfo.vendor = data.id; applyVendorCashDiscount(data.id) }"
/>
</div>
</UFormField>
@@ -736,6 +772,47 @@ const hasBlockingIncomingInvoiceErrors = computed(() => blockingIncomingInvoiceE
</UPopover>
</UFormField>
<UFormField
label="Skonto"
:help="cashDiscountAmount ? `Abzug ${formatMoney(cashDiscountAmount)} bei Zahlung in der Frist` : undefined"
>
<UInput
type="number"
step="0.01"
min="0"
max="10"
placeholder="z. B. 2"
v-model="itemInfo.cashDiscountPercent"
:disabled="mode === 'show'"
class="w-full"
>
<template #trailing><span class="text-gray-500 text-xs">%</span></template>
</UInput>
</UFormField>
<UFormField label="Skonto bis">
<UPopover class="w-full" :content="{ side: 'bottom', align: 'start' }">
<UButton
block
icon="i-heroicons-calendar"
:label="getDateButtonLabel(itemInfo.cashDiscountDueDate, 'Keine Skontofrist')"
:disabled="mode === 'show' || !itemInfo.cashDiscountPercent"
color="neutral"
variant="outline"
class="w-full justify-start"
/>
<template #content>
<div class="p-2">
<UCalendar
:model-value="getCalendarValue(itemInfo.cashDiscountDueDate)"
@update:model-value="setCashDiscountDueDate"
:week-starts-on="1"
/>
</div>
</template>
</UPopover>
</UFormField>
<UFormField label="Beschreibung / Notiz" class="md:col-span-2">
<UTextarea class="w-full" v-model="itemInfo.description" :rows="2" autoresize :disabled="mode === 'show'" />
</UFormField>

View File

@@ -29,12 +29,18 @@ const variableDefinitions = [
{ key: '{{titel}}', label: 'Titel', desc: 'Titel des Kunden' },
{ key: '{{zahlungsziel_in_tagen}}', label: 'Zahlungsziel', desc: 'In Tagen' },
{ key: '{{lohnkosten}}', label: 'Lohnkosten', desc: 'Ausgewiesene Lohnkosten' },
{ key: '{{skonto_hinweis}}', label: 'Skonto-Hinweis', desc: 'Kompletter Satz; ohne Skontovariable wird er automatisch vor den Endtext gesetzt' },
{ key: '{{skonto_prozent}}', label: 'Skonto %', desc: 'Skontosatz' },
{ key: '{{skonto_datum}}', label: 'Skonto bis', desc: 'Datum der Skontofrist' },
{ key: '{{skonto_betrag}}', label: 'Skontobetrag', desc: 'Abzugsbetrag in Euro' },
{ key: '{{zahlbetrag_mit_skonto}}', label: 'Zahlbetrag mit Skonto', desc: 'Rechnungsbetrag abzüglich Skonto' },
]
const conditionalExamples = [
'{{#if vorname}}Hallo {{vorname}},{{/if}}',
'{{#if (eq zahlungsart "Überweisung")}}Bitte überweisen Sie den Betrag.{{/if}}',
'{{#if (gt zahlungsziel_in_tagen 14)}}Vielen Dank für Ihre Zahlung innerhalb des erweiterten Zahlungsziels.{{/if}}',
'{{#if skonto_prozent}}Bei Zahlung bis {{skonto_datum}} ziehen Sie bitte {{skonto_prozent}} % Skonto ab.{{/if}}',
]
// --- Shortcuts ---