Fix #9: IST-Versteuerung – Zahldatum statt Buchungsdatum für Belege und Ausgangsrechnungen
This commit is contained in:
parent
60815f7179
commit
6e0f93c3b1
6 changed files with 434 additions and 66 deletions
|
|
@ -86,7 +86,8 @@ $router->get('/invoices/{id}/edit', fn($p) => (new InvoiceController())->edit($p
|
||||||
$router->post('/invoices/{id}', fn($p) => (new InvoiceController())->update($p));
|
$router->post('/invoices/{id}', fn($p) => (new InvoiceController())->update($p));
|
||||||
$router->post('/invoices/{id}/finalize', fn($p) => (new InvoiceController())->finalize($p));
|
$router->post('/invoices/{id}/finalize', fn($p) => (new InvoiceController())->finalize($p));
|
||||||
$router->get('/invoices/{id}/pdf', fn($p) => (new InvoiceController())->pdf($p));
|
$router->get('/invoices/{id}/pdf', fn($p) => (new InvoiceController())->pdf($p));
|
||||||
$router->post('/invoices/{id}/book', fn($p) => (new InvoiceController())->book($p));
|
$router->get('/invoices/{id}/book', fn($p) => (new InvoiceController())->book($p));
|
||||||
|
$router->post('/invoices/{id}/bookings', fn($p) => (new InvoiceController())->storeBooking($p));
|
||||||
$router->post('/invoices/{id}/mark-paid', fn($p) => (new InvoiceController())->markPaid($p));
|
$router->post('/invoices/{id}/mark-paid', fn($p) => (new InvoiceController())->markPaid($p));
|
||||||
$router->post('/invoices/{id}/cancel', fn($p) => (new InvoiceController())->cancel($p));
|
$router->post('/invoices/{id}/cancel', fn($p) => (new InvoiceController())->cancel($p));
|
||||||
$router->post('/invoices/{id}/lines', fn($p) => (new InvoiceController())->addLine($p));
|
$router->post('/invoices/{id}/lines', fn($p) => (new InvoiceController())->addLine($p));
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ use Steuerkram\Models\Invoice;
|
||||||
use Steuerkram\Models\Customer;
|
use Steuerkram\Models\Customer;
|
||||||
use Steuerkram\Models\Beleg;
|
use Steuerkram\Models\Beleg;
|
||||||
use Steuerkram\Models\Booking;
|
use Steuerkram\Models\Booking;
|
||||||
|
use Steuerkram\Models\Account;
|
||||||
use Steuerkram\Models\Setting;
|
use Steuerkram\Models\Setting;
|
||||||
use Steuerkram\Services\PdfInvoiceService;
|
use Steuerkram\Services\PdfInvoiceService;
|
||||||
use Steuerkram\View;
|
use Steuerkram\View;
|
||||||
|
|
@ -172,9 +173,9 @@ class InvoiceController
|
||||||
\Steuerkram\Database::beginTransaction();
|
\Steuerkram\Database::beginTransaction();
|
||||||
$nr = Invoice::finalize($id);
|
$nr = Invoice::finalize($id);
|
||||||
$invoice = Invoice::find($id);
|
$invoice = Invoice::find($id);
|
||||||
$belegNr = $this->createBelegAndBookings($invoice);
|
$belegNr = $this->createBeleg($invoice);
|
||||||
\Steuerkram\Database::commit();
|
\Steuerkram\Database::commit();
|
||||||
View::redirect("/invoices/{$id}", "Rechnung {$nr} wurde finalisiert und gebucht (Beleg {$belegNr}).");
|
View::redirect("/invoices/{$id}", "Rechnung {$nr} wurde finalisiert (Beleg {$belegNr}). Bitte Zahlungseingang erfassen.");
|
||||||
} catch (\PDOException $e) {
|
} catch (\PDOException $e) {
|
||||||
\Steuerkram\Database::rollBack();
|
\Steuerkram\Database::rollBack();
|
||||||
error_log('[Steuerkram] PDO-Fehler in InvoiceController::finalize(): ' . $e->getMessage());
|
error_log('[Steuerkram] PDO-Fehler in InvoiceController::finalize(): ' . $e->getMessage());
|
||||||
|
|
@ -207,27 +208,110 @@ class InvoiceController
|
||||||
View::redirect("/invoices/{$id}", 'Rechnung muss zuerst finalisiert werden.', 'error');
|
View::redirect("/invoices/{$id}", 'Rechnung muss zuerst finalisiert werden.', 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!empty($invoice['beleg_id'])) {
|
if (empty($invoice['beleg_id'])) {
|
||||||
View::redirect("/invoices/{$id}", 'Rechnung wurde bereits gebucht.', 'warning');
|
View::redirect("/invoices/{$id}", 'Kein Beleg zugeordnet.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$bookings = Booking::forBeleg((int)$invoice['beleg_id']);
|
||||||
|
$accounts = Account::all(['aktiv' => 1]);
|
||||||
|
$formErrors = $_GET['_error'] ?? [];
|
||||||
|
$formData = [];
|
||||||
|
|
||||||
|
$view = new View();
|
||||||
|
$view->render('layout/base.php', [
|
||||||
|
'pageTitle' => 'Zahlungseingang – ' . ($invoice['rechnungs_nr'] ?? 'Rechnung'),
|
||||||
|
'content' => $this->renderView('invoices/book', [
|
||||||
|
'invoice' => $invoice,
|
||||||
|
'bookings' => $bookings,
|
||||||
|
'accounts' => $accounts,
|
||||||
|
'formErrors' => $formErrors,
|
||||||
|
'formData' => $formData,
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function storeBooking(array $params): void
|
||||||
|
{
|
||||||
|
$id = (int)$params['id'];
|
||||||
|
$invoice = Invoice::find($id);
|
||||||
|
if (!$invoice) { http_response_code(404); return; }
|
||||||
|
|
||||||
|
$belegId = (int)($invoice['beleg_id'] ?? 0);
|
||||||
|
if (!$belegId) {
|
||||||
|
View::redirect("/invoices/{$id}", 'Kein Beleg zugeordnet.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = $_POST;
|
||||||
|
$errors = [];
|
||||||
|
|
||||||
|
if (empty($data['kategorie_konto_id'])) $errors['kategorie_konto_id'] = 'Pflichtfeld';
|
||||||
|
if (empty($data['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) {
|
||||||
|
$bookings = Booking::forBeleg($belegId);
|
||||||
|
$accounts = Account::all(['aktiv' => 1]);
|
||||||
|
$view = new View();
|
||||||
|
$view->render('layout/base.php', [
|
||||||
|
'pageTitle' => 'Zahlungseingang – ' . ($invoice['rechnungs_nr'] ?? 'Rechnung'),
|
||||||
|
'content' => $this->renderView('invoices/book', [
|
||||||
|
'invoice' => $invoice,
|
||||||
|
'bookings' => $bookings,
|
||||||
|
'accounts' => $accounts,
|
||||||
|
'formErrors' => $errors,
|
||||||
|
'formData' => $data,
|
||||||
|
]),
|
||||||
|
]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
\Steuerkram\Database::beginTransaction();
|
$data['beleg_id'] = $belegId;
|
||||||
$belegNr = $this->createBelegAndBookings($invoice);
|
$data['buchungstyp'] = 'Einnahme';
|
||||||
\Steuerkram\Database::commit();
|
Booking::create($data);
|
||||||
View::redirect("/invoices/{$id}", 'Rechnung erfolgreich gebucht (Beleg ' . $belegNr . ').');
|
View::redirect("/invoices/{$id}/book", 'Buchung erfasst.');
|
||||||
} catch (\PDOException $e) {
|
} catch (\PDOException $e) {
|
||||||
\Steuerkram\Database::rollBack();
|
error_log('[Steuerkram] PDO-Fehler in InvoiceController::storeBooking(): ' . $e->getMessage());
|
||||||
error_log('[Steuerkram] PDO-Fehler in InvoiceController::book(): ' . $e->getMessage());
|
$bookings = Booking::forBeleg($belegId);
|
||||||
View::redirect("/invoices/{$id}", 'Datenbankfehler beim Buchen der Rechnung.', 'error');
|
$accounts = Account::all(['aktiv' => 1]);
|
||||||
|
$view = new View();
|
||||||
|
$view->render('layout/base.php', [
|
||||||
|
'pageTitle' => 'Zahlungseingang – ' . ($invoice['rechnungs_nr'] ?? 'Rechnung'),
|
||||||
|
'content' => $this->renderView('invoices/book', [
|
||||||
|
'invoice' => $invoice,
|
||||||
|
'bookings' => $bookings,
|
||||||
|
'accounts' => $accounts,
|
||||||
|
'formErrors' => ['_exception' => 'Datenbankfehler beim Speichern der Buchung.'],
|
||||||
|
'formData' => $data,
|
||||||
|
]),
|
||||||
|
]);
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
\Steuerkram\Database::rollBack();
|
$bookings = Booking::forBeleg($belegId);
|
||||||
View::redirect("/invoices/{$id}", $e->getMessage(), 'error');
|
$accounts = Account::all(['aktiv' => 1]);
|
||||||
|
$view = new View();
|
||||||
|
$view->render('layout/base.php', [
|
||||||
|
'pageTitle' => 'Zahlungseingang – ' . ($invoice['rechnungs_nr'] ?? 'Rechnung'),
|
||||||
|
'content' => $this->renderView('invoices/book', [
|
||||||
|
'invoice' => $invoice,
|
||||||
|
'bookings' => $bookings,
|
||||||
|
'accounts' => $accounts,
|
||||||
|
'formErrors' => ['_exception' => $e->getMessage()],
|
||||||
|
'formData' => $data,
|
||||||
|
]),
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private function createBelegAndBookings(array $invoice): string
|
private function createBeleg(array $invoice): string
|
||||||
{
|
{
|
||||||
$id = (int)$invoice['id'];
|
$id = (int)$invoice['id'];
|
||||||
|
|
||||||
|
|
@ -235,7 +319,7 @@ class InvoiceController
|
||||||
$belegId = Beleg::create([
|
$belegId = Beleg::create([
|
||||||
'beleg_nr' => $belegNr,
|
'beleg_nr' => $belegNr,
|
||||||
'filename' => $invoice['rechnungs_nr'] . '.pdf',
|
'filename' => $invoice['rechnungs_nr'] . '.pdf',
|
||||||
'storage_path' => '', // no physical file for outgoing invoices
|
'storage_path' => '',
|
||||||
'mime_type' => 'application/pdf',
|
'mime_type' => 'application/pdf',
|
||||||
'file_hash' => hash('sha256', $invoice['rechnungs_nr']),
|
'file_hash' => hash('sha256', $invoice['rechnungs_nr']),
|
||||||
'beleg_datum' => $invoice['rechnungs_datum'],
|
'beleg_datum' => $invoice['rechnungs_datum'],
|
||||||
|
|
@ -246,52 +330,6 @@ class InvoiceController
|
||||||
'notes' => 'Automatisch erstellt aus Rechnung ' . $invoice['rechnungs_nr'],
|
'notes' => 'Automatisch erstellt aus Rechnung ' . $invoice['rechnungs_nr'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$zahlungskonto = \Steuerkram\Database::fetchOne(
|
|
||||||
'SELECT id FROM accounts WHERE kontonummer = ?', ['1400']
|
|
||||||
);
|
|
||||||
if (!$zahlungskonto) {
|
|
||||||
throw new \RuntimeException('Forderungskonto 1400 nicht gefunden.');
|
|
||||||
}
|
|
||||||
|
|
||||||
$erloesKonten = [];
|
|
||||||
foreach (\Steuerkram\Database::fetchAll(
|
|
||||||
"SELECT id, kontonummer FROM accounts WHERE kontonummer IN ('8000','8200','8400')"
|
|
||||||
) as $row) {
|
|
||||||
$erloesKonten[$row['kontonummer']] = $row['id'];
|
|
||||||
}
|
|
||||||
|
|
||||||
$lines = Invoice::lines($id);
|
|
||||||
$gruppenNetto = [];
|
|
||||||
foreach ($lines as $line) {
|
|
||||||
$satz = (float)$line['ust_satz'];
|
|
||||||
$netto = round((float)$line['menge'] * (float)$line['einzelpreis'], 2);
|
|
||||||
$gruppenNetto[$satz] = ($gruppenNetto[$satz] ?? 0.0) + $netto;
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($gruppenNetto as $satz => $netto) {
|
|
||||||
if (abs($satz - 19) < 0.01) {
|
|
||||||
$kategorieId = $erloesKonten['8000'] ?? null;
|
|
||||||
} elseif (abs($satz - 7) < 0.01) {
|
|
||||||
$kategorieId = $erloesKonten['8200'] ?? null;
|
|
||||||
} else {
|
|
||||||
$kategorieId = $erloesKonten['8400'] ?? null;
|
|
||||||
}
|
|
||||||
if (!$kategorieId) {
|
|
||||||
throw new \RuntimeException("Erlöskonto für USt-Satz {$satz}% nicht gefunden.");
|
|
||||||
}
|
|
||||||
|
|
||||||
Booking::create([
|
|
||||||
'beleg_id' => $belegId,
|
|
||||||
'buchungs_datum' => $invoice['rechnungs_datum'],
|
|
||||||
'buchungstyp' => 'Einnahme',
|
|
||||||
'kategorie_konto_id' => $kategorieId,
|
|
||||||
'zahlungskonto_id' => $zahlungskonto['id'],
|
|
||||||
'betrag_netto' => round($netto, 2),
|
|
||||||
'ust_satz' => $satz,
|
|
||||||
'buchungstext' => 'Ausgangsrechnung ' . $invoice['rechnungs_nr'],
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
\Steuerkram\Database::execute(
|
\Steuerkram\Database::execute(
|
||||||
'UPDATE invoices SET beleg_id=? WHERE id=?', [$belegId, $id]
|
'UPDATE invoices SET beleg_id=? WHERE id=?', [$belegId, $id]
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -301,7 +301,7 @@ $suppliers = $suppliers ?? [];
|
||||||
|
|
||||||
<div class="form-grid form-grid-2">
|
<div class="form-grid form-grid-2">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Buchungsdatum *</label>
|
<label>Zahldatum *</label>
|
||||||
<input type="date" name="buchungs_datum"
|
<input type="date" name="buchungs_datum"
|
||||||
value="<?= $h($formData['buchungs_datum'] ?? date('Y-m-d')) ?>" required
|
value="<?= $h($formData['buchungs_datum'] ?? date('Y-m-d')) ?>" required
|
||||||
class="<?= isset($formErrors['buchungs_datum']) ? 'input-error' : '' ?>">
|
class="<?= isset($formErrors['buchungs_datum']) ? 'input-error' : '' ?>">
|
||||||
|
|
|
||||||
219
src/Views/invoices/book.php
Normal file
219
src/Views/invoices/book.php
Normal file
|
|
@ -0,0 +1,219 @@
|
||||||
|
<?php
|
||||||
|
use Steuerkram\View;
|
||||||
|
$h = fn($v) => View::h($v);
|
||||||
|
$fmt = fn($v) => View::formatMoney($v);
|
||||||
|
|
||||||
|
$customerName = !empty($invoice['firma'])
|
||||||
|
? $invoice['firma']
|
||||||
|
: trim(($invoice['anrede'] ?? '') . ' ' . ($invoice['vorname'] ?? '') . ' ' . $invoice['nachname']);
|
||||||
|
|
||||||
|
$formErrors = $formErrors ?? [];
|
||||||
|
$formData = $formData ?? [];
|
||||||
|
|
||||||
|
$kontenGruppen = [];
|
||||||
|
foreach ($accounts as $acc) {
|
||||||
|
$kontenGruppen[$acc['kontenart']][] = $acc;
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
|
||||||
|
<!-- Rechnungs-Header -->
|
||||||
|
<div class="card mb-2">
|
||||||
|
<div class="card-body" style="display:flex;gap:2rem;align-items:center;flex-wrap:wrap;padding:.75rem 1.25rem">
|
||||||
|
<div>
|
||||||
|
<span class="text-muted text-sm">Rechnung</span><br>
|
||||||
|
<strong class="font-mono"><?= $h($invoice['rechnungs_nr'] ?? '–') ?></strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="text-muted text-sm">Kunde</span><br>
|
||||||
|
<?= $h($customerName) ?>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="text-muted text-sm">Rechnungsdatum</span><br>
|
||||||
|
<?= View::formatDate($invoice['rechnungs_datum']) ?>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="text-muted text-sm">Betrag</span><br>
|
||||||
|
<strong><?= $fmt($invoice['betrag_brutto']) ?></strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="text-muted text-sm">Status</span><br>
|
||||||
|
<?php
|
||||||
|
$badgeClass = match($invoice['status']) {
|
||||||
|
'Bezahlt' => 'badge-green',
|
||||||
|
'Storniert' => 'badge-red',
|
||||||
|
'Gesendet' => 'badge-yellow',
|
||||||
|
default => 'badge-gray',
|
||||||
|
};
|
||||||
|
?>
|
||||||
|
<span class="badge <?= $badgeClass ?>"><?= $h($invoice['status']) ?></span>
|
||||||
|
</div>
|
||||||
|
<div style="margin-left:auto;display:flex;gap:.5rem">
|
||||||
|
<a href="/invoices/<?= $h($invoice['id']) ?>" class="btn btn-secondary btn-sm">Zur Rechnung</a>
|
||||||
|
<?php if (!empty($invoice['beleg_id'])): ?>
|
||||||
|
<a href="/belege/<?= $h($invoice['beleg_id']) ?>" class="btn btn-secondary btn-sm">Beleg öffnen</a>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Vorhandene Buchungen -->
|
||||||
|
<div class="card mb-2">
|
||||||
|
<div class="card-header">
|
||||||
|
<div class="card-title">Buchungszeilen (<?= count($bookings) ?>)</div>
|
||||||
|
</div>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Buchungsnr.</th>
|
||||||
|
<th>Zahldatum</th>
|
||||||
|
<th>Kategorie</th>
|
||||||
|
<th>Zahlungskonto</th>
|
||||||
|
<th class="td-right">Netto</th>
|
||||||
|
<th class="td-right">USt</th>
|
||||||
|
<th class="td-right">Brutto</th>
|
||||||
|
<th>Text</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<?php foreach ($bookings as $bk):
|
||||||
|
$isReversed = !empty($bk['reversed_by']);
|
||||||
|
$isReversal = (bool)$bk['is_reversal'];
|
||||||
|
?>
|
||||||
|
<tr style="<?= ($isReversed || $isReversal) ? 'opacity:.55;background:#fff5f5' : '' ?>">
|
||||||
|
<td class="td-mono text-sm"><?= $h($bk['buchungs_nr']) ?></td>
|
||||||
|
<td class="td-muted"><?= View::formatDate($bk['buchungs_datum']) ?></td>
|
||||||
|
<td class="td-mono text-sm"><?= $h($bk['kategorie_nr']) ?><br><span class="td-muted"><?= $h($bk['kategorie_name']) ?></span></td>
|
||||||
|
<td class="td-mono text-sm"><?= $h($bk['zahlungs_nr']) ?><br><span class="td-muted"><?= $h($bk['zahlungs_name']) ?></span></td>
|
||||||
|
<td class="td-right td-mono"><?= $fmt($bk['betrag_netto']) ?></td>
|
||||||
|
<td class="td-right td-mono"><?= number_format((float)$bk['ust_satz'], 0) ?> %</td>
|
||||||
|
<td class="td-right td-mono"><?= $fmt($bk['betrag_brutto']) ?></td>
|
||||||
|
<td class="text-sm">
|
||||||
|
<?= $h($bk['buchungstext']) ?>
|
||||||
|
<?php if ($isReversal): ?>
|
||||||
|
<span class="badge badge-red">Storno</span>
|
||||||
|
<?php elseif ($isReversed): ?>
|
||||||
|
<span class="badge badge-red">Storniert</span>
|
||||||
|
<?php endif; ?>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
<?php if (empty($bookings)): ?>
|
||||||
|
<tr><td colspan="8" class="text-center text-muted" style="padding:1rem">Noch keine Buchungen.</td></tr>
|
||||||
|
<?php endif; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Neue Buchungszeile erfassen -->
|
||||||
|
<?php if ($invoice['status'] !== 'Storniert'): ?>
|
||||||
|
<div class="card mb-2">
|
||||||
|
<div class="card-header">
|
||||||
|
<div class="card-title">Neue Buchungszeile</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<?php if (!empty($formErrors)): ?>
|
||||||
|
<div class="flash flash-error" style="margin-bottom:1rem">
|
||||||
|
<?php if (isset($formErrors['_exception'])): ?>
|
||||||
|
Fehler: <?= $h($formErrors['_exception']) ?>
|
||||||
|
<?php else: ?>
|
||||||
|
<?= implode(' | ', array_map(fn($k,$v) => "<strong>$k:</strong> $v", array_keys($formErrors), $formErrors)) ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
<form method="post" action="/invoices/<?= $h($invoice['id']) ?>/bookings">
|
||||||
|
|
||||||
|
<div class="form-grid form-grid-2">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Zahldatum *</label>
|
||||||
|
<input type="date" name="buchungs_datum"
|
||||||
|
value="<?= $h($formData['buchungs_datum'] ?? date('Y-m-d')) ?>" required
|
||||||
|
class="<?= isset($formErrors['buchungs_datum']) ? 'input-error' : '' ?>">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>USt-Satz (%)</label>
|
||||||
|
<select name="ust_satz">
|
||||||
|
<?php foreach (['0','7','19'] as $s): ?>
|
||||||
|
<option value="<?= $s ?>"<?= ($formData['ust_satz'] ?? '19') === $s ? ' selected' : '' ?>><?= $s ?> %</option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Kategorie-Konto (nur Betriebseinnahmen) -->
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="kategorie_konto_id">Kategorie *</label>
|
||||||
|
<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="5">
|
||||||
|
<option value="">– Konto wählen –</option>
|
||||||
|
<?php if (!empty($kontenGruppen['Betriebseinnahme'])): ?>
|
||||||
|
<optgroup label="── Betriebseinnahme ──">
|
||||||
|
<?php foreach ($kontenGruppen['Betriebseinnahme'] as $acc): ?>
|
||||||
|
<option value="<?= $h($acc['id']) ?>"
|
||||||
|
data-search="<?= $h($acc['kontonummer'] . ' ' . $acc['bezeichnung']) ?>"
|
||||||
|
<?= ($formData['kategorie_konto_id'] ?? '') == $acc['id'] ? 'selected' : '' ?>>
|
||||||
|
<?= $h($acc['kontonummer']) ?> – <?= $h($acc['bezeichnung']) ?>
|
||||||
|
</option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</optgroup>
|
||||||
|
<?php endif; ?>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Zahlungskonto (nur Zahlungskonten) -->
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="zahlungskonto_id">Zahlungskonto *</label>
|
||||||
|
<input type="search" placeholder="Kontonr. oder Bezeichnung filtern…" class="konto-filter"
|
||||||
|
data-target="zahlungskonto_id" autocomplete="off" style="margin-bottom:.3rem">
|
||||||
|
<select id="zahlungskonto_id" name="zahlungskonto_id" required size="4">
|
||||||
|
<option value="">– Konto wählen –</option>
|
||||||
|
<?php if (!empty($kontenGruppen['Zahlungskonto'])): ?>
|
||||||
|
<optgroup label="── Zahlungskonto ──">
|
||||||
|
<?php foreach ($kontenGruppen['Zahlungskonto'] as $acc): ?>
|
||||||
|
<option value="<?= $h($acc['id']) ?>"
|
||||||
|
data-search="<?= $h($acc['kontonummer'] . ' ' . $acc['bezeichnung']) ?>"
|
||||||
|
<?= ($formData['zahlungskonto_id'] ?? '') == $acc['id'] ? 'selected' : '' ?>>
|
||||||
|
<?= $h($acc['kontonummer']) ?> – <?= $h($acc['bezeichnung']) ?>
|
||||||
|
</option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</optgroup>
|
||||||
|
<?php endif; ?>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-grid form-grid-2">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Nettobetrag (€) *</label>
|
||||||
|
<input type="number" name="betrag_netto" step="0.01" min="0.01" required
|
||||||
|
placeholder="0.00"
|
||||||
|
value="<?= $h($formData['betrag_netto'] ?? '') ?>"
|
||||||
|
class="<?= isset($formErrors['betrag_netto']) ? 'input-error' : '' ?>">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Buchungstext *</label>
|
||||||
|
<input type="text" name="buchungstext" required
|
||||||
|
value="<?= $h($formData['buchungstext'] ?? ('Ausgangsrechnung ' . ($invoice['rechnungs_nr'] ?? ''))) ?>"
|
||||||
|
class="<?= isset($formErrors['buchungstext']) ? 'input-error' : '' ?>">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn-primary">Buchung erfassen</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<!-- Als bezahlt markieren -->
|
||||||
|
<?php if ($invoice['status'] === 'Gesendet' && !empty($bookings)): ?>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<p class="text-muted text-sm mb-2">Alle Zahlungseingänge erfasst? Dann die Rechnung als bezahlt markieren.</p>
|
||||||
|
<form method="post" action="/invoices/<?= $h($invoice['id']) ?>/mark-paid"
|
||||||
|
data-confirm="Rechnung als bezahlt markieren?">
|
||||||
|
<button type="submit" class="btn btn-success">Rechnung als bezahlt markieren</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
@ -40,9 +40,7 @@ $badgeClass = match($invoice['status']) {
|
||||||
<a href="/invoices/<?= $h($invoice['id']) ?>/pdf" class="btn btn-primary">PDF herunterladen</a>
|
<a href="/invoices/<?= $h($invoice['id']) ?>/pdf" class="btn btn-primary">PDF herunterladen</a>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<?php if ($invoice['status'] === 'Gesendet'): ?>
|
<?php if ($invoice['status'] === 'Gesendet'): ?>
|
||||||
<form method="post" action="/invoices/<?= $h($invoice['id']) ?>/mark-paid">
|
<a href="/invoices/<?= $h($invoice['id']) ?>/book" class="btn btn-success">Zahlungseingang erfassen</a>
|
||||||
<button type="submit" class="btn btn-success">Als bezahlt markieren</button>
|
|
||||||
</form>
|
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<?php if (in_array($invoice['status'], ['Entwurf','Gesendet'])): ?>
|
<?php if (in_array($invoice['status'], ['Entwurf','Gesendet'])): ?>
|
||||||
<form method="post" action="/invoices/<?= $h($invoice['id']) ?>/cancel"
|
<form method="post" action="/invoices/<?= $h($invoice['id']) ?>/cancel"
|
||||||
|
|
|
||||||
112
tests/10_IstVersteuerungTest.php
Normal file
112
tests/10_IstVersteuerungTest.php
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test 10: IST-Versteuerung – Zahldatum unabhängig vom Rechnungsdatum.
|
||||||
|
*
|
||||||
|
* Prüft, dass:
|
||||||
|
* 1. Finalisieren einer Ausgangsrechnung keine Buchungen erzeugt.
|
||||||
|
* 2. Buchungen separat mit einem abweichenden Zahldatum erfasst werden können.
|
||||||
|
* 3. Die Buchung im Journal mit dem korrekten Zahldatum erscheint.
|
||||||
|
*/
|
||||||
|
class IstVersteuerungTest extends TestCase
|
||||||
|
{
|
||||||
|
protected string $testName = 'Test 10: IST-Versteuerung – Zahldatum';
|
||||||
|
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
$rechnungsDatum = date('Y-m-d');
|
||||||
|
$zahlDatum = date('Y-m-d', strtotime('+7 days'));
|
||||||
|
|
||||||
|
// Konten prüfen
|
||||||
|
$zahlungsId = $this->findAccountId('1200'); // Bank
|
||||||
|
$kategorieId = $this->findAccountId('8000'); // Erlöse 19 %
|
||||||
|
|
||||||
|
if (!$zahlungsId || !$kategorieId) {
|
||||||
|
$zahlungsId = $zahlungsId ?? $this->findAccountId('1400');
|
||||||
|
$kategorieId = $kategorieId ?? $this->findAccountId('8400');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->assert($zahlungsId > 0, 'Zahlungskonto vorhanden');
|
||||||
|
$this->assert($kategorieId > 0, 'Einnahmekategorie vorhanden');
|
||||||
|
|
||||||
|
// Kunde anlegen
|
||||||
|
$customerId = \Steuerkram\Models\Customer::create([
|
||||||
|
'nachname' => 'IstTestKunde',
|
||||||
|
'firma' => 'IST GmbH',
|
||||||
|
'strasse' => 'Musterstr. 1',
|
||||||
|
'plz' => '10115',
|
||||||
|
'ort' => 'Berlin',
|
||||||
|
'payment_days' => 14,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Rechnung anlegen
|
||||||
|
$invoiceId = \Steuerkram\Models\Invoice::create([
|
||||||
|
'customer_id' => $customerId,
|
||||||
|
'betreff' => 'Test IST-Versteuerung',
|
||||||
|
'rechnungs_datum' => $rechnungsDatum,
|
||||||
|
'leistungszeitraum_von' => date('Y-m-01'),
|
||||||
|
'leistungszeitraum_bis' => date('Y-m-t'),
|
||||||
|
'lines' => [
|
||||||
|
[
|
||||||
|
'bezeichnung' => 'Beratung',
|
||||||
|
'menge' => 2,
|
||||||
|
'einheit' => 'Std.',
|
||||||
|
'einzelpreis' => 500.00,
|
||||||
|
'ust_satz' => 19.0,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
$this->assertGreaterThan(0, $invoiceId, 'Rechnung angelegt');
|
||||||
|
|
||||||
|
// Finalisieren
|
||||||
|
$nr = \Steuerkram\Models\Invoice::finalize($invoiceId);
|
||||||
|
$this->assertNotEmpty($nr, 'Rechnungsnummer vergeben');
|
||||||
|
|
||||||
|
$invoice = \Steuerkram\Models\Invoice::find($invoiceId);
|
||||||
|
$this->assertEquals('Gesendet', $invoice['status'] ?? null, 'Status nach Finalisierung: Gesendet');
|
||||||
|
|
||||||
|
// Beleg anlegen (wie in InvoiceController::createBeleg)
|
||||||
|
$belegNr = \Steuerkram\Models\Beleg::generateBelegNr();
|
||||||
|
$belegId = \Steuerkram\Models\Beleg::create([
|
||||||
|
'beleg_nr' => $belegNr,
|
||||||
|
'filename' => $nr . '.pdf',
|
||||||
|
'storage_path' => '',
|
||||||
|
'mime_type' => 'application/pdf',
|
||||||
|
'file_hash' => hash('sha256', $nr),
|
||||||
|
'beleg_datum' => $rechnungsDatum,
|
||||||
|
'eingangs_datum' => $rechnungsDatum,
|
||||||
|
'rechnungs_nr' => $nr,
|
||||||
|
'betrag_brutto' => $invoice['betrag_brutto'],
|
||||||
|
'belegtyp' => 'Ausgangsrechnung',
|
||||||
|
]);
|
||||||
|
\Steuerkram\Database::execute('UPDATE invoices SET beleg_id=? WHERE id=?', [$belegId, $invoiceId]);
|
||||||
|
|
||||||
|
// Noch keine Buchungen vorhanden (finalize erzeugt keine mehr)
|
||||||
|
$buchungenNachFinalisierung = \Steuerkram\Models\Booking::forBeleg($belegId);
|
||||||
|
$this->assertEquals(0, count($buchungenNachFinalisierung), 'Nach Finalisierung: keine Buchungen vorhanden');
|
||||||
|
|
||||||
|
// Buchung separat mit Zahldatum erfassen
|
||||||
|
$buchungId = \Steuerkram\Models\Booking::create([
|
||||||
|
'beleg_id' => $belegId,
|
||||||
|
'buchungs_datum' => $zahlDatum,
|
||||||
|
'buchungstyp' => 'Einnahme',
|
||||||
|
'kategorie_konto_id' => $kategorieId,
|
||||||
|
'zahlungskonto_id' => $zahlungsId,
|
||||||
|
'betrag_netto' => 1000.00,
|
||||||
|
'ust_satz' => 19.0,
|
||||||
|
'buchungstext' => 'Ausgangsrechnung ' . $nr,
|
||||||
|
]);
|
||||||
|
$this->assertGreaterThan(0, $buchungId, 'Buchung mit Zahldatum erfasst');
|
||||||
|
|
||||||
|
// Buchung hat das Zahldatum – nicht das Rechnungsdatum
|
||||||
|
$buchungenNachZahlung = \Steuerkram\Models\Booking::forBeleg($belegId);
|
||||||
|
$this->assertEquals(1, count($buchungenNachZahlung), 'Genau eine Buchung vorhanden');
|
||||||
|
|
||||||
|
$bk = $buchungenNachZahlung[0];
|
||||||
|
$this->assertEquals($zahlDatum, $bk['buchungs_datum'], 'Buchungsdatum ist das Zahldatum');
|
||||||
|
$this->assertNotEmpty($bk['buchungs_datum'] !== $rechnungsDatum || $zahlDatum === $rechnungsDatum,
|
||||||
|
'Zahldatum wurde korrekt gespeichert');
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue