Fix #20: Bewirtungsbuchungen auf 4650/4651 automatisch 70/30 auf Abzugskonto splitten

This commit is contained in:
fds2610 2026-04-01 00:14:07 +02:00
parent f10c2cb0d4
commit 7a893ceab8
6 changed files with 337 additions and 14 deletions

View file

@ -2,6 +2,47 @@
---
## 2026-04-01 – Feature: Bewirtungsbelege (Konten 4650/4651) mit automatischem Split
### Neu
- Buchungen auf Konto 4650 (Bewirtungskosten VSt. 19%) oder 4651 (Bewirtungskosten VSt. 7%) werden automatisch aufgeteilt: 70 % auf das gewählte Konto, 30 % auf das Abzugskonto (4654 bzw. 4656)
- Neue Felder `bewirtungsgrund` und `teilnehmerliste` in der `bookings`-Tabelle (Migration 016)
- Im Buchungsformular erscheinen die beiden Felder automatisch, wenn 4650/4651 als Kategorie gewählt wird
- Vorhandene Buchungen zeigen Bewirtungsgrund und Teilnehmerliste in der Buchungsübersicht an
## 2026-04-01 – Bugfix: Nicht abzugsfähige Kosten (4654/4656) separat in EÜR unter KZ 165
### Behoben
- Konten mit `euer_zeile = '51'` (4654, 4656, 4635, 4636, 4289) fließen nicht mehr in `total_ausgaben` und `gewinn` ein
- Sie erscheinen in einem eigenen Abschnitt „Nicht abzugsfähige Betriebsausgaben (KZ 165)" in Web und PDF
- Migration 015 setzt `euer_zeile = '51'` für Konto 4656 (fehlte bisher)
---
## 2026-03-30 – Bugfix: 30 % Eigenanteil fließt jetzt korrekt in UStVA KZ 81/83
### Behoben
- `Booking::upsertEigenanteil30`: statt zwei Buchungen (8922 Netto + 1776 USt-Korrektur) wird
nur noch eine einzige Buchung auf 8922 mit echtem `ust_satz` und `betrag_ust` erzeugt
- `upsertKorrekturbuchung`: neue Parameter `ustSatz` und `betragUst`; UPDATE und INSERT setzen
jetzt `betrag_ust` und `betrag_brutto` korrekt
- Vorher: `betrag_ust = 0` auf 8922 → KZ 83 blieb leer; 1776-Buchung hatte keine UStVA-Wirkung
---
## 2026-03-30 – Kontenrahmen-Reset: DATEV SKR03 2026 (IST-Versteuerung)
### Geändert
- Alle Buchungen und Belege gelöscht (Testdaten)
- Gesamter Kontenrahmen durch DATEV SKR03 2026 (gültig ab 2026-01-01) ersetzt
- Neue Spalte `euer_zeile` in Tabelle `accounts`: Zeilennummer der Anlage EÜR
- 145 Konten neu angelegt: Klassen 0, 1, 3, 4, 8 (ohne Klasse 9 / ohne Verbindlichkeiten)
- Verfahren: IST-Versteuerung; Konto 1390 „Verrechnungskonto Ist-Versteuerung" enthalten
- UStVA-Kennzeichen korrekt nach DATEV: 81/86 (Umsätze), 61/62 (Vorsteuer), 35/41 (steuerfrei)
- Migration: `013_reset_kontenrahmen_skr03_2026.sql`
---
## 2026-03-30 – KI-gestützte Belegerkennung (experimentell) + 30 % Eigenanteil Telefon/Internet
### Neu

View file

@ -0,0 +1,10 @@
-- Migration 016: Bewirtungsfelder in bookings-Tabelle
-- Speichert Bewirtungsgrund und Teilnehmerliste für Buchungen auf
-- Konten 4650/4651 (Bewirtungskosten 70 % abzugsfähig).
ALTER TABLE bookings
ADD COLUMN IF NOT EXISTS bewirtungsgrund TEXT NULL
COMMENT 'Anlass/Grund der Bewirtung (§ 4 Abs. 5 Nr. 2 EStG)'
AFTER buchungstext,
ADD COLUMN IF NOT EXISTS teilnehmerliste TEXT NULL
COMMENT 'Teilnehmer der Bewirtung (§ 4 Abs. 5 Nr. 2 EStG)'
AFTER bewirtungsgrund;

View file

@ -109,6 +109,17 @@ class BookingController
try {
$data['beleg_id'] = $belegId;
// Bewirtungskonto-Split: 4650→4654, 4651→4656
$konto = \Steuerkram\Database::fetchOne(
'SELECT kontonummer FROM accounts WHERE id = ?',
[(int)$data['kategorie_konto_id']]
);
$kontonummer = $konto['kontonummer'] ?? '';
if ($kontonummer === '4650' || $kontonummer === '4651') {
$this->bucheBewirtungssplit($data, $kontonummer);
} else {
Booking::create($data);
$eigenanteil = isset($data['eigenanteil_30']) ? 1 : 0;
@ -125,6 +136,7 @@ class BookingController
$data['buchungs_datum']
);
}
}
View::redirect("/belege/{$belegId}", 'Buchung erfasst.');
} catch (\PDOException $e) {
@ -156,6 +168,40 @@ class BookingController
]);
}
private function bucheBewirtungssplit(array $data, string $kontonummer): void
{
// Abzugskonto ermitteln
$abzugskontonummer = $kontonummer === '4650' ? '4654' : '4656';
$abzugskonto = \Steuerkram\Database::fetchOne(
'SELECT id FROM accounts WHERE kontonummer = ?',
[$abzugskontonummer]
);
if (!$abzugskonto) {
throw new \RuntimeException("Abzugskonto {$abzugskontonummer} nicht gefunden.");
}
$nettoGesamt = round((float)$data['betrag_netto'], 2);
$ustSatz = (float)($data['ust_satz'] ?? 0);
$netto70 = round($nettoGesamt * 0.7, 2);
$netto30 = round($nettoGesamt - $netto70, 2);
// Buchung 1: 70 % auf Bewirtungskonto (abzugsfähig)
Booking::create(array_merge($data, [
'betrag_netto' => $netto70,
'bewirtungsgrund' => $data['bewirtungsgrund'] ?? null,
'teilnehmerliste' => $data['teilnehmerliste'] ?? null,
]));
// Buchung 2: 30 % auf Abzugskonto (nicht abzugsfähig, § 4 Abs. 5 EStG)
Booking::create(array_merge($data, [
'kategorie_konto_id' => $abzugskonto['id'],
'betrag_netto' => $netto30,
'bewirtungsgrund' => $data['bewirtungsgrund'] ?? null,
'teilnehmerliste' => $data['teilnehmerliste'] ?? null,
]));
}
public function destroy(array $params): void
{
if (!Setting::isTestMode()) {

View file

@ -104,8 +104,9 @@ class Booking
Database::execute(
'INSERT INTO bookings (buchungs_nr, beleg_id, buchungs_datum, buchungstyp,
kategorie_konto_id, zahlungskonto_id,
betrag_netto, ust_satz, steuerart, betrag_ust, betrag_brutto, buchungstext, fiscal_year_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
betrag_netto, ust_satz, steuerart, betrag_ust, betrag_brutto,
buchungstext, bewirtungsgrund, teilnehmerliste, fiscal_year_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
[
$nr,
(int)$data['beleg_id'],
@ -119,6 +120,8 @@ class Booking
$ust,
$brutto,
$data['buchungstext'],
$data['bewirtungsgrund'] ?? null,
$data['teilnehmerliste'] ?? null,
$fy['id'],
]
);

View file

@ -214,6 +214,12 @@ $suppliers = $suppliers ?? [];
<?php elseif ($isReversed): ?>
<span class="badge badge-red">Storniert</span>
<?php endif; ?>
<?php if (!empty($bk['bewirtungsgrund'])): ?>
<br><span class="td-muted" style="font-size:.8em">Grund: <?= $h($bk['bewirtungsgrund']) ?></span>
<?php endif; ?>
<?php if (!empty($bk['teilnehmerliste'])): ?>
<br><span class="td-muted" style="font-size:.8em">Teilnehmer: <?= $h($bk['teilnehmerliste']) ?></span>
<?php endif; ?>
</td>
<td>
<?php if (!$bk['locked'] && !$isReversal && !$isReversed): ?>
@ -371,7 +377,7 @@ $suppliers = $suppliers ?? [];
<input type="search" placeholder="Kontonr. oder Bezeichnung filtern…" class="konto-filter"
data-target="kategorie_konto_id" autocomplete="off" style="margin-bottom:.3rem">
<select id="kategorie_konto_id" name="kategorie_konto_id" required size="6"
onchange="showKontoArt('kategorie_konto_id','kategorie_konto_art',this);updateEigenanteilState()">
onchange="showKontoArt('kategorie_konto_id','kategorie_konto_art',this);updateEigenanteilState();updateBewirtungsState()">
<option value="">– Konto wählen –</option>
<?php foreach (['Betriebsausgabe', 'Betriebseinnahme', 'Privat'] as $art): ?>
<?php if (empty($kontenGruppen[$art])) continue; ?>
@ -379,6 +385,7 @@ $suppliers = $suppliers ?? [];
<?php foreach ($kontenGruppen[$art] as $acc): ?>
<option value="<?= $h($acc['id']) ?>"
data-art="<?= $h($acc['kontenart']) ?>"
data-kontonummer="<?= $h($acc['kontonummer']) ?>"
data-search="<?= $h($acc['kontonummer'] . ' ' . $acc['bezeichnung'] . ' ' . $acc['kontenart']) ?>">
<?= $h($acc['kontonummer']) ?> – <?= $h($acc['bezeichnung']) ?>
</option>
@ -437,6 +444,26 @@ $suppliers = $suppliers ?? [];
</div>
</div>
<!-- Bewirtungsfelder (nur bei Konto 4650/4651) -->
<div id="bewirtungsfelder" style="display:none">
<div class="flash flash-info" style="margin-bottom:.75rem;padding:.5rem .75rem;font-size:.85rem">
<strong>Bewirtungsausgabe:</strong> 70 % werden auf das gewählte Konto gebucht, 30 % automatisch auf das Abzugskonto. Die Vorsteuer wird vollständig berücksichtigt.
</div>
<div class="form-group">
<label for="bewirtungsgrund_input">Bewirtungsgrund *</label>
<textarea name="bewirtungsgrund" id="bewirtungsgrund_input"
style="height:2.4em;resize:none;overflow:hidden"
oninput="this.style.height='auto';this.style.height=this.scrollHeight+'px'"
placeholder="Anlass/Grund der Bewirtung"><?= $h($formData['bewirtungsgrund'] ?? '') ?></textarea>
</div>
<div class="form-group">
<label for="teilnehmerliste_input">Teilnehmerliste *</label>
<textarea name="teilnehmerliste" id="teilnehmerliste_input"
style="height:5.4em;resize:vertical"
placeholder="Namen der Teilnehmer"><?= $h($formData['teilnehmerliste'] ?? '') ?></textarea>
</div>
</div>
<div class="form-group" title="Nur bei Telefon/Internet 19% wählbar" style="margin-bottom:.75rem">
<label id="eigenanteil_group"
style="display:inline-flex;align-items:center;gap:.5rem;opacity:.35;pointer-events:none;cursor:not-allowed">
@ -478,6 +505,23 @@ function filterKategorieKonten(buchungstyp) {
}
updateEigenanteilState();
}
function updateBewirtungsState() {
const kategorieEl = document.getElementById('kategorie_konto_id');
const felder = document.getElementById('bewirtungsfelder');
if (!kategorieEl || !felder) return;
const selectedOpt = kategorieEl.options[kategorieEl.selectedIndex];
const kontonummer = selectedOpt ? (selectedOpt.getAttribute('data-kontonummer') || '') : '';
const istBewirtung = kontonummer === '4650' || kontonummer === '4651';
felder.style.display = istBewirtung ? '' : 'none';
const grundEl = document.getElementById('bewirtungsgrund_input');
const teilnEl = document.getElementById('teilnehmerliste_input');
if (grundEl) grundEl.required = istBewirtung;
if (teilnEl) teilnEl.required = istBewirtung;
}
function updateEigenanteilState() {
const group = document.getElementById('eigenanteil_group');
const cb = document.getElementById('eigenanteil_30_cb');
@ -508,6 +552,7 @@ document.addEventListener('DOMContentLoaded', function() {
if (checked) filterKategorieKonten(checked.value);
updateSteuerartVisibility();
updateEigenanteilState();
updateBewirtungsState();
updateNettoAnzeige();
// Leeres Lieferantenfeld fokussieren wenn kein KI-Block aktiv

View file

@ -0,0 +1,178 @@
<?php
declare(strict_types=1);
/**
* Test 16: Bewirtungskonto-Split (70%/30%) bei Buchungen auf 4650/4651.
*
* Stellt sicher, dass:
* - Buchungen auf 4650 automatisch 70% auf 4650 und 30% auf 4654 aufteilen
* - Buchungen auf 4651 automatisch 70% auf 4651 und 30% auf 4656 aufteilen
* - bewirtungsgrund und teilnehmerliste korrekt gespeichert werden
* - Normale Buchungen (nicht 4650/4651) weiterhin als einzelne Buchung gespeichert werden
*/
class BewirtungssplitTest extends TestCase
{
protected string $testName = 'Test 16: Bewirtungskonto-Split 70%/30%';
public function run(): void
{
$datum = date('Y-m-d');
$bankId = $this->findAccountId('1200');
$kto4650 = $this->findAccountId('4650');
$kto4651 = $this->findAccountId('4651');
$kto4654 = $this->findAccountId('4654');
$kto4656 = $this->findAccountId('4656');
if (!$bankId || !$kto4650 || !$kto4651 || !$kto4654 || !$kto4656) {
$this->assert(false, 'Benötigte Konten (1200, 4650, 4651, 4654, 4656) nicht gefunden');
return;
}
// ── Test 1: Split bei Konto 4650 ─────────────────────────────
$beleg1 = \Steuerkram\Models\Beleg::create([
'beleg_nr' => 'TEST-BEW-4650-' . uniqid(),
'filename' => 'test.pdf',
'storage_path' => '',
'mime_type' => 'application/pdf',
'file_hash' => hash('sha256', 'bew4650' . uniqid()),
'beleg_datum' => $datum,
'eingangs_datum' => $datum,
'belegtyp' => 'Eingangsrechnung',
]);
// Bewirtungssplit direkt über Booking::create + zweite Buchung simulieren
// (wie BookingController::bucheBewirtungssplit() es tut)
$netto = 100.0;
$ustSatz = 19.0;
$netto70 = round($netto * 0.7, 2);
$netto30 = round($netto - $netto70, 2);
$id1a = \Steuerkram\Models\Booking::create([
'beleg_id' => $beleg1,
'buchungs_datum' => $datum,
'buchungstyp' => 'Ausgabe',
'kategorie_konto_id' => $kto4650,
'zahlungskonto_id' => $bankId,
'betrag_netto' => $netto70,
'ust_satz' => $ustSatz,
'buchungstext' => 'Bewirtung Test 70%',
'bewirtungsgrund' => 'Kundengespräch',
'teilnehmerliste' => 'Max Mustermann, Erika Mustermann',
]);
$id1b = \Steuerkram\Models\Booking::create([
'beleg_id' => $beleg1,
'buchungs_datum' => $datum,
'buchungstyp' => 'Ausgabe',
'kategorie_konto_id' => $kto4654,
'zahlungskonto_id' => $bankId,
'betrag_netto' => $netto30,
'ust_satz' => $ustSatz,
'buchungstext' => 'Bewirtung Test 30%',
'bewirtungsgrund' => 'Kundengespräch',
'teilnehmerliste' => 'Max Mustermann, Erika Mustermann',
]);
$b1a = \Steuerkram\Models\Booking::find($id1a);
$b1b = \Steuerkram\Models\Booking::find($id1b);
$this->assertEquals(70.0, (float)$b1a['betrag_netto'], '70%-Buchung hat 70% Netto');
$this->assertEquals(30.0, (float)$b1b['betrag_netto'], '30%-Buchung hat 30% Netto');
$this->assertEquals($kto4650, (int)$b1a['kategorie_konto_id'], '70%-Buchung auf 4650');
$this->assertEquals($kto4654, (int)$b1b['kategorie_konto_id'], '30%-Buchung auf 4654');
// USt wird proportional berechnet
$expectedUst70 = round($netto70 * $ustSatz / 100, 2);
$expectedUst30 = round($netto30 * $ustSatz / 100, 2);
$this->assertEquals($expectedUst70, (float)$b1a['betrag_ust'], '70%-Buchung USt korrekt');
$this->assertEquals($expectedUst30, (float)$b1b['betrag_ust'], '30%-Buchung USt korrekt');
// Gesamt-VSt = 100 * 19% = 19
$this->assertEquals(
round($netto * $ustSatz / 100, 2),
round((float)$b1a['betrag_ust'] + (float)$b1b['betrag_ust'], 2),
'Gesamt-Vorsteuer korrekt (100% des Nettobetrags)'
);
// Bewirtungsfelder gespeichert
$this->assertEquals('Kundengespräch', $b1a['bewirtungsgrund'], 'Bewirtungsgrund gespeichert');
$this->assertEquals('Max Mustermann, Erika Mustermann', $b1a['teilnehmerliste'], 'Teilnehmerliste gespeichert');
// ── Test 2: Split bei Konto 4651 ─────────────────────────────
$beleg2 = \Steuerkram\Models\Beleg::create([
'beleg_nr' => 'TEST-BEW-4651-' . uniqid(),
'filename' => 'test2.pdf',
'storage_path' => '',
'mime_type' => 'application/pdf',
'file_hash' => hash('sha256', 'bew4651' . uniqid()),
'beleg_datum' => $datum,
'eingangs_datum' => $datum,
'belegtyp' => 'Eingangsrechnung',
]);
$id2a = \Steuerkram\Models\Booking::create([
'beleg_id' => $beleg2,
'buchungs_datum' => $datum,
'buchungstyp' => 'Ausgabe',
'kategorie_konto_id' => $kto4651,
'zahlungskonto_id' => $bankId,
'betrag_netto' => round(50.0 * 0.7, 2),
'ust_satz' => 7.0,
'buchungstext' => 'Bewirtung 4651 70%',
'bewirtungsgrund' => 'Teambesprechung',
'teilnehmerliste' => 'Team A',
]);
$id2b = \Steuerkram\Models\Booking::create([
'beleg_id' => $beleg2,
'buchungs_datum' => $datum,
'buchungstyp' => 'Ausgabe',
'kategorie_konto_id' => $kto4656,
'zahlungskonto_id' => $bankId,
'betrag_netto' => round(50.0 - round(50.0 * 0.7, 2), 2),
'ust_satz' => 7.0,
'buchungstext' => 'Bewirtung 4651 30%',
'bewirtungsgrund' => 'Teambesprechung',
'teilnehmerliste' => 'Team A',
]);
$b2a = \Steuerkram\Models\Booking::find($id2a);
$b2b = \Steuerkram\Models\Booking::find($id2b);
$this->assertEquals($kto4651, (int)$b2a['kategorie_konto_id'], '4651-Split: 70% auf 4651');
$this->assertEquals($kto4656, (int)$b2b['kategorie_konto_id'], '4651-Split: 30% auf 4656');
$this->assertEquals(35.0, (float)$b2a['betrag_netto'], '4651-Split: 70% von 50 = 35');
$this->assertEquals(15.0, (float)$b2b['betrag_netto'], '4651-Split: 30% von 50 = 15');
// ── Test 3: Normale Buchung unverändert ──────────────────────
$bankCheck = $this->findAccountId('4600');
if ($bankCheck) {
$beleg3 = \Steuerkram\Models\Beleg::create([
'beleg_nr' => 'TEST-BEW-NORMAL-' . uniqid(),
'filename' => 'test3.pdf',
'storage_path' => '',
'mime_type' => 'application/pdf',
'file_hash' => hash('sha256', 'normal' . uniqid()),
'beleg_datum' => $datum,
'eingangs_datum' => $datum,
'belegtyp' => 'Eingangsrechnung',
]);
$idNormal = \Steuerkram\Models\Booking::create([
'beleg_id' => $beleg3,
'buchungs_datum' => $datum,
'buchungstyp' => 'Ausgabe',
'kategorie_konto_id' => $bankCheck,
'zahlungskonto_id' => $bankId,
'betrag_netto' => 200.0,
'ust_satz' => 19.0,
'buchungstext' => 'Normale Buchung',
]);
$bNormal = \Steuerkram\Models\Booking::find($idNormal);
$this->assertEquals(200.0, (float)$bNormal['betrag_netto'], 'Normale Buchung: voller Nettobetrag unverändert');
$this->assertNotEmpty($idNormal, 'Normale Buchung erfolgreich gespeichert');
}
}
}