Steuerkram/src/Controllers/BookingController.php

304 lines
12 KiB
PHP

<?php
declare(strict_types=1);
namespace Steuerkram\Controllers;
use Steuerkram\Models\Booking;
use Steuerkram\Models\Account;
use Steuerkram\Models\Beleg;
use Steuerkram\Models\FiscalYear;
use Steuerkram\Models\Setting;
use Steuerkram\View;
class BookingController
{
public function journal(array $params): void
{
$filter = $_GET;
if (!isset($filter['year'])) $filter['year'] = (string)date('Y');
if (!isset($filter['month'])) $filter['month'] = (string)date('n');
$page = max(1, (int)($_GET['page'] ?? 1));
$result = Booking::journal($filter, $page);
$totalAll = Booking::journal([], 1, 1)['total'];
$years = FiscalYear::availableYears();
$accounts = Account::all(['aktiv' => 1]);
$view = new View();
$view->render('layout/base.php', [
'pageTitle' => 'Buchungsjournal',
'content' => $this->renderView('bookings/journal', [
'rows' => $result['rows'],
'total' => $result['total'],
'totalAll' => $totalAll,
'pages' => $result['pages'],
'page' => $page,
'filter' => $filter,
'years' => $years,
'accounts' => $accounts,
'testMode' => Setting::isTestMode(),
]),
'topbarActions' => '<a href="/journal/export?'.http_build_query($filter).'" class="btn btn-secondary">CSV Export</a>',
]);
}
public function export(array $params): void
{
$filter = $_GET;
unset($filter['page']);
$result = Booking::journal($filter, 1, 99999);
header('Content-Type: text/csv; charset=utf-8');
header('Content-Disposition: attachment; filename="buchungsjournal-' . date('Ymd') . '.csv"');
$out = fopen('php://output', 'w');
fprintf($out, chr(0xEF) . chr(0xBB) . chr(0xBF)); // UTF-8 BOM for Excel
fputcsv($out, [
'Buchungsnr.', 'Datum', 'Belegnr.', 'Typ',
'Kategorie-Kto.', 'Kategorie-Bez.',
'Zahlungskonto-Kto.', 'Zahlungskonto-Bez.',
'Netto (€)', 'USt (%)', 'USt (€)',
'Brutto (€)', 'Buchungstext', 'Storno'
], ';');
foreach ($result['rows'] as $row) {
fputcsv($out, [
$this->csvSanitize((string)$row['buchungs_nr']),
$row['buchungs_datum'],
$this->csvSanitize((string)$row['beleg_nr']),
$row['buchungstyp'],
$row['kategorie_nr'],
$this->csvSanitize((string)$row['kategorie_name']),
$row['zahlungs_nr'],
$this->csvSanitize((string)$row['zahlungs_name']),
number_format((float)$row['betrag_netto'], 2, ',', ''),
number_format((float)$row['ust_satz'], 2, ',', ''),
number_format((float)$row['betrag_ust'], 2, ',', ''),
number_format((float)$row['betrag_brutto'], 2, ',', ''),
$this->csvSanitize((string)$row['buchungstext']),
$row['is_reversal'] ? 'Ja' : 'Nein',
], ';');
}
fclose($out);
}
public function store(array $params): void
{
$belegId = (int)$params['beleg_id'];
$data = $_POST;
$errors = [];
// Direkte Kontonummer-Eingabe: nur auflösen wenn nichts per Select gewählt
foreach ([
'kategorie_kontonummer' => 'kategorie_konto_id',
'zahlungs_kontonummer' => 'zahlungskonto_id',
] as $field => $idField) {
if (!empty($data[$idField])) continue; // Select-Auswahl hat Vorrang
$nr = trim($data[$field] ?? '');
if ($nr !== '') {
$acc = \Steuerkram\Database::fetchOne('SELECT id FROM accounts WHERE kontonummer = ?', [$nr]);
if (!$acc) {
$errors[$idField] = "Kontonummer '$nr' nicht gefunden.";
} else {
$data[$idField] = $acc['id'];
}
}
}
// Validate
if (empty($data['buchungstyp'])) $errors['buchungstyp'] = 'Pflichtfeld';
if (empty($data['kategorie_konto_id']) && !isset($errors['kategorie_konto_id'])) $errors['kategorie_konto_id'] = 'Pflichtfeld';
if (empty($data['zahlungskonto_id']) && !isset($errors['zahlungskonto_id'])) $errors['zahlungskonto_id'] = 'Pflichtfeld';
if (empty($data['buchungs_datum'])) $errors['buchungs_datum'] = 'Pflichtfeld';
if (!isset($data['betrag_netto']) || $data['betrag_netto'] === '')
$errors['betrag_netto'] = 'Pflichtfeld';
if (empty($data['buchungstext'])) $errors['buchungstext'] = 'Pflichtfeld';
if (empty($errors) && $data['kategorie_konto_id'] === $data['zahlungskonto_id']) {
$errors['zahlungskonto_id'] = 'Kategorie- und Zahlungskonto dürfen nicht identisch sein.';
}
if ($errors) {
$this->renderBelegDetail($belegId, $errors, $data);
return;
}
try {
$data['beleg_id'] = $belegId;
// Bewirtungsfelder am Beleg speichern (gehören zum Beleg, nicht zur Buchung)
\Steuerkram\Database::execute(
'UPDATE belege SET bewirtungsgrund=?, teilnehmerliste=?, updated_at=NOW() WHERE id=?',
[
!empty($data['bewirtungsgrund']) ? $data['bewirtungsgrund'] : null,
!empty($data['teilnehmerliste']) ? $data['teilnehmerliste'] : null,
$belegId,
]
);
// Anlagevermögen: neues Asset anlegen oder bestehendes verknüpfen
$konto = \Steuerkram\Database::fetchOne(
'SELECT id, kontonummer, kontenart FROM accounts WHERE id = ?',
[(int)$data['kategorie_konto_id']]
);
$kontonummer = $konto['kontonummer'] ?? '';
$newAssetId = null;
if (($konto['kontenart'] ?? '') === 'Anlagevermögen') {
$assetId = isset($data['asset_id']) && $data['asset_id'] !== '' ? (int)$data['asset_id'] : null;
if ($assetId === null) {
// Neues Anlagegut anlegen
$assetId = \Steuerkram\Models\Asset::create([
'bezeichnung' => $data['buchungstext'],
'account_id' => (int)$data['kategorie_konto_id'],
'anschaffungsdatum' => $data['buchungs_datum'],
'anschaffungskosten' => (float)$data['betrag_netto'],
]);
$newAssetId = $assetId;
}
$data['asset_id'] = $assetId;
}
// Bewirtungskonto-Split: 4650→4654, 4651→4656, 4652→4657
if (in_array($kontonummer, ['4650', '4651', '4652'], true)) {
$this->bucheBewirtungssplit($data, $kontonummer);
} else {
Booking::create($data);
$eigenanteil = isset($data['eigenanteil_30']) ? 1 : 0;
\Steuerkram\Database::execute(
'UPDATE belege SET eigenanteil_30 = ?, updated_at = NOW() WHERE id = ?',
[$eigenanteil, $belegId]
);
if ($eigenanteil === 1) {
Booking::upsertEigenanteil30(
$belegId,
(float)$data['betrag_netto'],
(float)($data['ust_satz'] ?? 0),
$data['buchungs_datum']
);
}
}
if ($newAssetId !== null) {
View::redirect("/assets/{$newAssetId}/edit", 'Buchung erfasst. Bitte Wirtschaftsgut-Details ergänzen.');
} else {
View::redirect("/belege/{$belegId}", 'Buchung erfasst.');
}
} catch (\PDOException $e) {
error_log('[Steuerkram] PDO-Fehler in BookingController::store(): ' . $e->getMessage());
$this->renderBelegDetail($belegId, ['_exception' => 'Datenbankfehler beim Speichern der Buchung.'], $data);
} catch (\Exception $e) {
$this->renderBelegDetail($belegId, ['_exception' => $e->getMessage()], $data);
}
}
private function renderBelegDetail(int $belegId, array $errors, array $formData = []): void
{
$beleg = \Steuerkram\Models\Beleg::find($belegId);
$bookings = \Steuerkram\Models\Booking::forBeleg($belegId);
$accounts = \Steuerkram\Models\Account::all(['aktiv' => 1]);
$integrity = \Steuerkram\Models\Beleg::verifyIntegrity($belegId);
$assets = \Steuerkram\Models\Asset::all();
$view = new View();
$view->render('layout/base.php', [
'pageTitle' => 'Beleg ' . ($beleg['beleg_nr'] ?? ''),
'content' => $this->renderView('belege/detail', [
'beleg' => $beleg,
'bookings' => $bookings,
'integrity' => $integrity,
'accounts' => $accounts,
'assets' => $assets,
'formErrors' => $errors,
'formData' => $formData,
]),
]);
}
private function bucheBewirtungssplit(array $data, string $kontonummer): void
{
// Abzugskonto ermitteln
$abzugskontonummer = match($kontonummer) { '4650' => '4654', '4652' => '4657', default => '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,
]));
// 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,
]));
}
public function destroy(array $params): void
{
if (!Setting::isTestMode()) {
View::redirect('/journal', 'Löschen ist im Produktivmodus nicht erlaubt (GoBD).', 'error');
return;
}
$id = (int)$params['id'];
$booking = Booking::find($id);
if (!$booking) {
View::redirect('/journal', 'Buchung nicht gefunden.', 'error');
return;
}
\Steuerkram\Database::execute('DELETE FROM bookings WHERE id = ?', [$id]);
$redirect = !empty($_POST['beleg_id'])
? '/belege/' . (int)$_POST['beleg_id']
: '/journal';
View::redirect($redirect, 'Buchung gelöscht (Testmodus).');
}
public function reverse(array $params): void
{
$id = (int)$params['id'];
$reason = trim($_POST['reason'] ?? 'Manuelle Stornierung');
try {
Booking::reverse($id, $reason);
$booking = Booking::find($id);
View::redirect("/belege/{$booking['beleg_id']}", 'Buchung wurde storniert.');
} catch (\PDOException $e) {
error_log('[Steuerkram] PDO-Fehler in BookingController::reverse(): ' . $e->getMessage());
View::redirect('/journal', 'Datenbankfehler beim Stornieren der Buchung.', 'error');
} catch (\Exception $e) {
View::redirect('/journal', $e->getMessage(), 'error');
}
}
private function csvSanitize(string $value): string
{
if ($value !== '' && in_array($value[0], ['=', '+', '-', '@'], true)) {
return "'" . $value;
}
return $value;
}
private function renderView(string $template, array $data): string
{
extract($data, EXTR_SKIP);
ob_start();
include dirname(__DIR__) . '/Views/' . $template . '.php';
return ob_get_clean();
}
}