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}/finalize', fn($p) => (new InvoiceController())->finalize($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}/cancel', fn($p) => (new InvoiceController())->cancel($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\Beleg;
|
||||
use Steuerkram\Models\Booking;
|
||||
use Steuerkram\Models\Account;
|
||||
use Steuerkram\Models\Setting;
|
||||
use Steuerkram\Services\PdfInvoiceService;
|
||||
use Steuerkram\View;
|
||||
|
|
@ -172,9 +173,9 @@ class InvoiceController
|
|||
\Steuerkram\Database::beginTransaction();
|
||||
$nr = Invoice::finalize($id);
|
||||
$invoice = Invoice::find($id);
|
||||
$belegNr = $this->createBelegAndBookings($invoice);
|
||||
$belegNr = $this->createBeleg($invoice);
|
||||
\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) {
|
||||
\Steuerkram\Database::rollBack();
|
||||
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');
|
||||
return;
|
||||
}
|
||||
if (!empty($invoice['beleg_id'])) {
|
||||
View::redirect("/invoices/{$id}", 'Rechnung wurde bereits gebucht.', 'warning');
|
||||
if (empty($invoice['beleg_id'])) {
|
||||
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;
|
||||
}
|
||||
|
||||
try {
|
||||
\Steuerkram\Database::beginTransaction();
|
||||
$belegNr = $this->createBelegAndBookings($invoice);
|
||||
\Steuerkram\Database::commit();
|
||||
View::redirect("/invoices/{$id}", 'Rechnung erfolgreich gebucht (Beleg ' . $belegNr . ').');
|
||||
$data['beleg_id'] = $belegId;
|
||||
$data['buchungstyp'] = 'Einnahme';
|
||||
Booking::create($data);
|
||||
View::redirect("/invoices/{$id}/book", 'Buchung erfasst.');
|
||||
} catch (\PDOException $e) {
|
||||
\Steuerkram\Database::rollBack();
|
||||
error_log('[Steuerkram] PDO-Fehler in InvoiceController::book(): ' . $e->getMessage());
|
||||
View::redirect("/invoices/{$id}", 'Datenbankfehler beim Buchen der Rechnung.', 'error');
|
||||
error_log('[Steuerkram] PDO-Fehler in InvoiceController::storeBooking(): ' . $e->getMessage());
|
||||
$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' => ['_exception' => 'Datenbankfehler beim Speichern der Buchung.'],
|
||||
'formData' => $data,
|
||||
]),
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
\Steuerkram\Database::rollBack();
|
||||
View::redirect("/invoices/{$id}", $e->getMessage(), 'error');
|
||||
$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' => ['_exception' => $e->getMessage()],
|
||||
'formData' => $data,
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private function createBelegAndBookings(array $invoice): string
|
||||
private function createBeleg(array $invoice): string
|
||||
{
|
||||
$id = (int)$invoice['id'];
|
||||
|
||||
|
|
@ -235,7 +319,7 @@ class InvoiceController
|
|||
$belegId = Beleg::create([
|
||||
'beleg_nr' => $belegNr,
|
||||
'filename' => $invoice['rechnungs_nr'] . '.pdf',
|
||||
'storage_path' => '', // no physical file for outgoing invoices
|
||||
'storage_path' => '',
|
||||
'mime_type' => 'application/pdf',
|
||||
'file_hash' => hash('sha256', $invoice['rechnungs_nr']),
|
||||
'beleg_datum' => $invoice['rechnungs_datum'],
|
||||
|
|
@ -246,52 +330,6 @@ class InvoiceController
|
|||
'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(
|
||||
'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-group">
|
||||
<label>Buchungsdatum *</label>
|
||||
<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' : '' ?>">
|
||||
|
|
|
|||
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>
|
||||
<?php endif; ?>
|
||||
<?php if ($invoice['status'] === 'Gesendet'): ?>
|
||||
<form method="post" action="/invoices/<?= $h($invoice['id']) ?>/mark-paid">
|
||||
<button type="submit" class="btn btn-success">Als bezahlt markieren</button>
|
||||
</form>
|
||||
<a href="/invoices/<?= $h($invoice['id']) ?>/book" class="btn btn-success">Zahlungseingang erfassen</a>
|
||||
<?php endif; ?>
|
||||
<?php if (in_array($invoice['status'], ['Entwurf','Gesendet'])): ?>
|
||||
<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