feat(accounting): Skonto bei Zahlungen auf Ausgangs- und Eingangsbelege buchen

Skonto wird an der Bankzuordnung gespeichert und gleicht den Beleg zusammen
mit der Zahlung aus. Der Betrag wird anteilig nach Steuersätzen auf die
Skontokonten verteilt (Standard SKR03, je Mandant einstellbar), im
DATEV-Export als eigene Buchung je Steuersatz ausgegeben und in der
USt-Auswertung im Zeitraum der Zahlung berücksichtigt. Schlussrechnungen
verteilen den Skonto auf die Restforderung nach Abzug der Abschläge.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-04 08:51:27 +01:00
parent 28f24aa998
commit af4a283eb5
27 changed files with 837 additions and 41 deletions

View File

@@ -1,6 +1,7 @@
<script setup lang="ts">
import dayjs from "dayjs"
import { getBankAllocationTaxBreakdown, isDirectBankAllocation } from "~/utils/bankAllocationTax"
import { hasCashDiscount } from "~/utils/cashDiscount"
import customParseFormat from "dayjs/plugin/customParseFormat"
import {
formatTaxEvaluationPeriodLabel,
@@ -54,7 +55,7 @@ const loadData = async () => {
createdDocuments.value = (docs || []).filter(isRelevantOutputDocument)
incomingInvoices.value = (incoming || []).filter(isRelevantInputInvoice)
manualBookings.value = (manual as any[]) || []
bankAllocations.value = (allocations || []).filter(isDirectBankAllocation)
bankAllocations.value = (allocations || []).filter((allocation) => isDirectBankAllocation(allocation) || hasCashDiscount(allocation))
} finally {
loading.value = false
}

View File

@@ -1,4 +1,5 @@
<script setup>
import { sumSettledAllocations } from "~/utils/cashDiscount"
import { parseDate } from "@internationalized/date"
const {$api, $dayjs} = useNuxtApp()
@@ -103,12 +104,12 @@ const loadSuggestionData = async () => {
.map(i => ({
...i,
docTotal: useSum().getCreatedDocumentSum(i, createddocuments.value),
statementTotal: Number(i.statementallocations.reduce((n, {amount}) => n + amount, 0)),
statementTotal: Number(sumSettledAllocations(i.statementallocations)),
openSum: useSum().getCreatedDocumentOpenAmount(i, createddocuments.value).toFixed(2)
}))
openIncomingInvoices.value = invoiceItems
.filter(i => i.state === "Gebucht" && !i.archived && i.statementallocations.reduce((n, {amount}) => n + amount, 0).toFixed(2) !== getInvoiceSum(i, false))
.filter(i => i.state === "Gebucht" && !i.archived && sumSettledAllocations(i.statementallocations).toFixed(2) !== getInvoiceSum(i, false))
} catch (err) {
console.error("Vorschlagsdaten konnten nicht geladen werden:", err)
} finally {
@@ -232,7 +233,7 @@ const calculateOpenSum = (statement) => {
const getInvoiceSum = (invoice, onlyOpenSum) => {
let sum = Number(useSum().getIncomingInvoiceSum(invoice))
if (onlyOpenSum) sum = sum + Number(invoice.statementallocations.reduce((n, {amount}) => n + amount, 0))
if (onlyOpenSum) sum = sum + Number(sumSettledAllocations(invoice.statementallocations))
if (invoice.expense) {
return (sum * -1).toFixed(2)

View File

@@ -1,6 +1,7 @@
<script setup>
import dayjs from "dayjs";
import { BANK_ALLOCATION_TAX_ITEMS, getBankAllocationAmounts } from "~/utils/bankAllocationTax"
import { sumSettledAllocations } from "~/utils/cashDiscount"
import {
DEPRECIATION_METHOD_ITEMS,
EXPENSE_BOOKING_MODE_ITEMS,
@@ -54,7 +55,7 @@ const rebuildDocumentLists = () => {
return {
...i,
docTotal: useSum().getCreatedDocumentSum(i, createddocuments.value),
statementTotal: Number(i.statementallocations.reduce((n, {amount}) => n + amount, 0)),
statementTotal: Number(sumSettledAllocations(i.statementallocations)),
openSum: useSum().getCreatedDocumentOpenAmount(i, createddocuments.value).toFixed(2)
}
})
@@ -62,7 +63,7 @@ const rebuildDocumentLists = () => {
allocatedDocuments.value = documents.filter(i => i.statementallocations.find(x => x.bankstatement === itemInfo.value.id))
allocatedIncomingInvoices.value = incominginvoices.value.filter(i => i.statementallocations.find(x => x.bankstatement === itemInfo.value.id))
openIncomingInvoices.value = incominginvoices.value.filter(i =>
!i.archived && i.statementallocations.reduce((n, {amount}) => n + amount, 0).toFixed(2) !== getInvoiceSum(i, false)
!i.archived && sumSettledAllocations(i.statementallocations).toFixed(2) !== getInvoiceSum(i, false)
)
}
@@ -136,7 +137,7 @@ const separateIBAN = (input) => {
const getInvoiceSum = (invoice, onlyOpenSum) => {
let sum = Number(useSum().getIncomingInvoiceSum(invoice))
if (onlyOpenSum) sum = sum + Number(invoice.statementallocations.reduce((n, {amount}) => n + amount, 0))
if (onlyOpenSum) sum = sum + Number(sumSettledAllocations(invoice.statementallocations))
if (invoice.expense) {
return (sum * -1).toFixed(2)
@@ -603,6 +604,76 @@ const getAllocationBookingModeLabel = (item) => {
return "Sofortaufwand"
}
// Skonto: Die Zahlung gleicht zusammen mit dem Skonto den offenen Betrag des Belegs aus.
const cashDiscountModalOpen = ref(false)
const cashDiscountTarget = ref(null)
const cashDiscountInput = ref(0)
const savingCashDiscount = ref(false)
const cashDiscountError = ref("")
const getCashDiscountCandidate = (kind, entity) => {
const open = Number(kind === "createddocument" ? entity.openSum : getInvoiceSum(entity, true))
const payment = Number(manualAllocationSum.value)
if (!open || !payment || Math.sign(open) !== Math.sign(payment)) return null
if (Math.abs(payment) >= Math.abs(open) - 0.004) return null
return { open, payment, discount: Number((open - payment).toFixed(2)) }
}
const canBookCashDiscount = (kind, entity) => Boolean(getCashDiscountCandidate(kind, entity))
const openCashDiscountModal = (kind, entity) => {
const candidate = getCashDiscountCandidate(kind, entity)
if (!candidate) return
cashDiscountTarget.value = {
kind,
id: entity.id,
label: kind === "createddocument"
? `${entity.documentNumber} (${entity.customer?.name || "Ohne Kunde"})`
: `${entity.reference || "Eingangsbeleg"} (${entity.vendor?.name || "Ohne Lieferant"})`,
...candidate
}
cashDiscountInput.value = Math.abs(candidate.discount)
cashDiscountError.value = ""
cashDiscountModalOpen.value = true
}
const cashDiscountPercent = computed(() => {
const target = cashDiscountTarget.value
if (!target || !target.open) return 0
return Math.abs(Number(cashDiscountInput.value || 0)) / Math.abs(target.open) * 100
})
const cashDiscountRemaining = computed(() => {
const target = cashDiscountTarget.value
if (!target) return 0
return Number((Math.abs(target.open) - Math.abs(target.payment) - Math.abs(Number(cashDiscountInput.value || 0))).toFixed(2))
})
const confirmCashDiscount = async () => {
const target = cashDiscountTarget.value
if (!target || savingCashDiscount.value) return
savingCashDiscount.value = true
try {
await saveAllocation({
[target.kind]: target.id,
bankstatement: itemInfo.value.id,
amount: target.payment,
cashDiscountAmount: Math.abs(Number(cashDiscountInput.value || 0)),
description: allocationDescription.value || "Zahlung mit Skonto"
})
cashDiscountModalOpen.value = false
cashDiscountTarget.value = null
} catch (err) {
cashDiscountError.value = err?.data?.error || "Zahlung und Skonto konnten nicht gebucht werden."
} finally {
savingCashDiscount.value = false
}
}
const getCashDiscountSplitLabel = (item) => (item.cashDiscountSplit || [])
.map(split => `${split.account} (${split.rate} %): ${displayCurrency(split.gross)}`)
.join(" · ")
setup()
</script>
@@ -726,6 +797,9 @@ setup()
{{ getAllocationTaxLabel(item) }} · Netto {{ displayCurrency(getBankAllocationAmounts(item).amountNet) }}
· Steuer {{ displayCurrency(getBankAllocationAmounts(item).amountTax) }}
</div>
<div v-if="Number(item.cashDiscountAmount)" class="text-xs text-amber-700 dark:text-amber-400 mt-1">
Skonto {{ displayCurrency(item.cashDiscountAmount) }}<span v-if="item.cashDiscountSplit?.length"> · {{ getCashDiscountSplitLabel(item) }}</span>
</div>
<div class="mt-1 flex flex-wrap gap-1" v-if="item.bookingMode && item.bookingMode !== 'expense'">
<UBadge size="xs" color="warning" variant="soft">{{ getAllocationBookingModeLabel(item) }}</UBadge>
<UBadge size="xs" color="neutral" variant="soft" v-if="item.depreciationGroup">{{ item.depreciationGroup }}</UBadge>
@@ -756,6 +830,9 @@ setup()
</div>
<div class="text-right">
<div class="font-mono text-sm font-semibold">{{ displayCurrency(item.amount) }}</div>
<div v-if="Number(item.cashDiscountAmount)" class="font-mono text-xs text-amber-600">
+ {{ displayCurrency(item.cashDiscountAmount) }} Skonto
</div>
<UButton
icon="i-heroicons-trash"
color="error"
@@ -1003,6 +1080,15 @@ setup()
>
Rechnung zuweisen
</UButton>
<UButton
v-if="canBookCashDiscount('createddocument', document)"
size="xs"
color="warning"
variant="soft"
@click="openCashDiscountModal('createddocument', document)"
>
Mit Skonto
</UButton>
<UButton
size="xs"
color="gray"
@@ -1037,6 +1123,15 @@ setup()
>
Beleg zuweisen
</UButton>
<UButton
v-if="canBookCashDiscount('incominginvoice', invoice)"
size="xs"
color="warning"
variant="soft"
@click="openCashDiscountModal('incominginvoice', invoice)"
>
Mit Skonto
</UButton>
<UButton
size="xs"
color="gray"
@@ -1098,6 +1193,16 @@ setup()
@click="saveAllocation({createddocument: document.id, bankstatement: itemInfo.id, amount: Number(Number(document.openSum) < manualAllocationSum ? document.openSum : manualAllocationSum), description: allocationDescription})"
/>
</UTooltip>
<UTooltip text="Mit Skonto ausgleichen">
<UButton
v-if="!itemInfo.statementallocations.find(i => i.createddocument === document.id) && canBookCashDiscount('createddocument', document)"
icon="i-heroicons-receipt-percent"
size="sm"
color="warning"
variant="soft"
@click="openCashDiscountModal('createddocument', document)"
/>
</UTooltip>
<UButton
icon="i-heroicons-eye"
color="gray"
@@ -1157,6 +1262,16 @@ setup()
@click="saveAllocation({incominginvoice: item.id, bankstatement: itemInfo.id, amount: Number(Math.abs(getInvoiceSum(item,true)) > Math.abs(manualAllocationSum) ? manualAllocationSum : getInvoiceSum(item,true)), description: allocationDescription})"
/>
</UTooltip>
<UTooltip text="Mit Skonto ausgleichen">
<UButton
v-if="!itemInfo.statementallocations.find(i => i.incominginvoice === item.id) && canBookCashDiscount('incominginvoice', item)"
icon="i-heroicons-receipt-percent"
size="sm"
color="warning"
variant="soft"
@click="openCashDiscountModal('incominginvoice', item)"
/>
</UTooltip>
<UButton
icon="i-heroicons-eye"
color="gray"
@@ -1180,6 +1295,70 @@ setup()
</div>
</div>
</UDashboardPanelContent>
<UModal v-model:open="cashDiscountModalOpen" :ui="{ width: 'sm:max-w-lg' }">
<template #content>
<UCard v-if="cashDiscountTarget">
<template #header>
<div class="text-lg font-semibold">Mit Skonto ausgleichen</div>
<div class="text-sm text-gray-500 mt-0.5">{{ cashDiscountTarget.label }}</div>
</template>
<div class="space-y-4">
<div class="grid grid-cols-2 gap-2 text-sm">
<div class="text-gray-500">Offener Betrag</div>
<div class="text-right font-mono">{{ displayCurrency(Math.abs(cashDiscountTarget.open)) }}</div>
<div class="text-gray-500">Zahlung aus diesem Umsatz</div>
<div class="text-right font-mono">{{ displayCurrency(Math.abs(cashDiscountTarget.payment)) }}</div>
</div>
<UFormField label="Skonto (brutto)" :help="`${cashDiscountPercent.toFixed(2).replace('.', ',')} % des offenen Betrags`">
<UInput v-model="cashDiscountInput" type="number" step="0.01" min="0">
<template #trailing><span class="text-gray-500 text-xs">EUR</span></template>
</UInput>
</UFormField>
<UAlert
v-if="cashDiscountPercent > 5"
color="warning"
variant="subtle"
icon="i-heroicons-exclamation-triangle"
title="Ungewöhnlich hoher Skonto"
description="Bitte prüfen, ob es sich wirklich um Skonto handelt und nicht um eine Teilzahlung."
/>
<UAlert
v-if="cashDiscountError"
color="error"
variant="subtle"
icon="i-heroicons-x-circle"
:description="cashDiscountError"
/>
<div v-if="Math.abs(cashDiscountRemaining) >= 0.01" class="text-sm text-amber-600">
Danach bleiben {{ displayCurrency(cashDiscountRemaining) }} offen.
</div>
<p class="text-xs text-gray-500">
Der Skonto wird anteilig nach Steuersätzen auf die Skontokonten verteilt
({{ cashDiscountTarget.kind === 'createddocument' ? 'gewährte' : 'erhaltene' }} Skonti) und korrigiert die
{{ cashDiscountTarget.kind === 'createddocument' ? 'Umsatzsteuer' : 'Vorsteuer' }} im Monat der Zahlung.
</p>
</div>
<template #footer>
<div class="flex justify-end gap-2">
<UButton color="neutral" variant="ghost" @click="cashDiscountModalOpen = false">Abbrechen</UButton>
<UButton
color="primary"
:loading="savingCashDiscount"
:disabled="!(Number(cashDiscountInput) > 0) || cashDiscountRemaining < -0.009"
@click="confirmCashDiscount"
>
Zahlung und Skonto buchen
</UButton>
</div>
</template>
</UCard>
</template>
</UModal>
</template>
<style scoped>

View File

@@ -164,6 +164,7 @@
</template>
<script setup>
import { sumSettledAllocations } from "~/utils/cashDiscount"
import CreateDocumentModal from "~/components/createDocumentModal.vue"
import CreateDocumentFromTemplateModal from "~/components/createDocumentFromTemplateModal.vue"
import dayjs from "dayjs";
@@ -337,10 +338,7 @@ const documentAmounts = computed(() => {
items.value.forEach((item) => {
const amount = sum.getCreatedDocumentSum(item, items.value)
const amountPaid = (item.statementallocations || []).reduce(
(total, allocation) => total + Number(allocation.amount || 0),
0
)
const amountPaid = sumSettledAllocations(item.statementallocations || [])
result.set(item.id, {
amount,

View File

@@ -427,6 +427,7 @@ const togglePortalRelease = async () => {
<div>
<p class="text-xs text-gray-500">Betrag</p>
<p class="font-medium">{{ formatCurrency(allocation.amount) }}</p>
<p v-if="Number(allocation.cashDiscountAmount)" class="text-xs text-amber-600">+ {{ formatCurrency(allocation.cashDiscountAmount) }} Skonto</p>
</div>
<div>
<p class="text-xs text-gray-500">Text</p>

View File

@@ -602,6 +602,7 @@ const hasBlockingIncomingInvoiceErrors = computed(() => blockingIncomingInvoiceE
<div>
<p class="text-xs text-gray-500">Betrag</p>
<p class="font-medium">{{ formatMoney(allocation.amount) }}</p>
<p v-if="Number(allocation.cashDiscountAmount)" class="text-xs text-amber-600">+ {{ formatMoney(allocation.cashDiscountAmount) }} Skonto</p>
</div>
<div>
<p class="text-xs text-gray-500">Text</p>

View File

@@ -1,4 +1,5 @@
<script setup>
import { sumSettledAllocations } from "~/utils/cashDiscount"
import dayjs from "dayjs"
import {useSum} from "~/composables/useSum.js";
@@ -199,8 +200,8 @@ const getInvoiceSum = (invoice) => {
}
const getPaidAmount = (item) => {
let amountPaid = 0
item.statementallocations.forEach(allocation => amountPaid += allocation.amount)
// Gebuchter Skonto zählt zum ausgeglichenen Betrag.
const amountPaid = sumSettledAllocations(item.statementallocations || [])
return Number(Math.abs(amountPaid).toFixed(2))
}

View File

@@ -1,4 +1,5 @@
<script setup>
import { resolveCashDiscountAccounts } from "~/utils/cashDiscount"
import {
TAX_EVALUATION_PERIOD_OPTIONS,
normalizeTaxEvaluationPeriod
@@ -273,6 +274,20 @@ watch(() => auth.activeTenantData?.portalConfig, (config) => {
})
const accountChart = ref(auth.activeTenantData.accountChart || "skr03")
const taxEvaluationPeriod = ref(normalizeTaxEvaluationPeriod(auth.activeTenantData.taxEvaluationPeriod))
const buildCashDiscountForm = (config) => {
const accounts = resolveCashDiscountAccounts(config)
return { received: { ...accounts.received }, granted: { ...accounts.granted } }
}
const cashDiscountAccounts = ref(buildCashDiscountForm(auth.activeTenantData?.cashDiscountAccounts))
const cashDiscountRateLabels = { "19": "19 %", "7": "7 %", "0": "ohne USt" }
const saveCashDiscountAccounts = async () => {
try {
await updateTenant({ cashDiscountAccounts: cashDiscountAccounts.value })
toast.add({ title: "Skontokonten gespeichert", color: "success" })
} catch (err) {
toast.add({ title: "Skontokonten konnten nicht gespeichert werden", description: err?.data?.error, color: "error" })
}
}
const accountChartOptions = [
{ label: "SKR 03", value: "skr03" },
{ label: "Verein", value: "verein" }
@@ -293,6 +308,7 @@ const updateTenant = async (newData) => {
features.value = { ...defaultFeatures, ...(res?.features || {}) }
businessInfo.value = normalizeBusinessInfo(res?.businessInfo)
taxEvaluationPeriod.value = normalizeTaxEvaluationPeriod(res?.taxEvaluationPeriod)
cashDiscountAccounts.value = buildCashDiscountForm(res?.cashDiscountAccounts)
}
}
const saveFeatures = async () => {
@@ -776,6 +792,32 @@ onMounted(() => {
>
Zeitraum speichern
</UButton>
<div class="mt-6">
<div class="text-sm font-medium">Skontokonten</div>
<p class="text-xs text-muted mt-1">
DATEV-Automatikkonten für Skonto bei Zahlungen. Standard ist SKR 03.
</p>
<div class="mt-3 grid grid-cols-1 gap-4 md:grid-cols-2">
<div v-for="side in ['received', 'granted']" :key="side" class="space-y-2">
<div class="text-xs font-semibold uppercase text-muted">
{{ side === 'received' ? 'Erhaltene Skonti (Eingangsbelege)' : 'Gewährte Skonti (Ausgangsbelege)' }}
</div>
<UFormField
v-for="rate in ['19', '7', '0']"
:key="`${side}-${rate}`"
:label="cashDiscountRateLabels[rate]"
>
<UInput v-model="cashDiscountAccounts[side][rate]" inputmode="numeric"/>
</UFormField>
</div>
</div>
</div>
<UButton
class="mt-3"
@click="saveCashDiscountAccounts"
>
Skontokonten speichern
</UButton>
</UForm>
</UCard>