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:
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user