298 lines
12 KiB
PHP
298 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'] ?? '';
|
|
|
|
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'],
|
|
]);
|
|
}
|
|
$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']
|
|
);
|
|
}
|
|
}
|
|
|
|
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();
|
|
}
|
|
}
|