Feature: Stornorechnung gemäß §14 UStG implementiert

This commit is contained in:
fds2610-bot 2026-05-05 09:11:46 +02:00
parent 6206878924
commit 38cc8f885e
10 changed files with 262 additions and 20 deletions

View file

@ -2,6 +2,18 @@
---
## 2026-05-05 – Feature: Stornorechnung (§ 14 UStG)
### Hinzugefügt
- `migrations/042_invoice_storno_of_id.sql`: Neue Spalte `storno_of_id` in `invoices` (FK auf Original)
- `Invoice::createCancellation()`: Legt Stornorechnung mit negierten Beträgen an, setzt Original auf „Storniert", erzeugt GoBD-konforme Stornobuchungen
- Storno-Button in Rechnungsübersicht (`list.php`) und Detailansicht (`show.php`)
- Querverweise Original ↔ Stornorechnung in der Detailansicht
- PDF-Anpassung: Titel „STORNORECHNUNG", Referenzzeile, kein Zahlungshinweis, angepasster Dateiname
- Test `27_StornorechnungTest.php`
---
## 2026-04-28 – Feature: E-Rechnung / ZUGFeRD EN16931
### Hinzugefügt

View file

@ -52,6 +52,7 @@ CRUD für Kundenstammdaten. Kunden werden beim Erstellen von Ausgangsrechnungen
- Buchungsworkflow: Finalisierung erzeugt automatisch einen Beleg und eine Buchung in der Belegverwaltung
- **ZUGFeRD EN16931 (E-Rechnung)**: Jede generierte PDF enthält ein eingebettetes `factur-x.xml` nach EU-Norm EN 16931; Pflichtangaben laut § 14 UStG vollständig abgedeckt; Validierung möglich unter [portal.e-rechnung-bund.de](https://portal.e-rechnung-bund.de/validator); Bibliothek: `horstoeko/zugferd` v1.x
- E-Mail-Versand via SMTP direkt aus der Anwendung
- **Stornorechnung (§ 14 UStG)**: Finalisierte, gesendete oder bezahlte Rechnungen können storniert werden; dabei entsteht automatisch eine nummerierte Stornorechnung mit negierten Beträgen (`storno_of_id`), GoBD-konforme Stornobuchungen heben die Originalbuchungen auf; Querverweise in der Detailansicht, angepasstes PDF-Layout (Titel „STORNORECHNUNG", Referenzzeile)
---

View file

@ -0,0 +1,6 @@
ALTER TABLE invoices
ADD COLUMN storno_of_id INT UNSIGNED NULL DEFAULT NULL
COMMENT 'FK zur stornierten Originalrechnung'
AFTER beleg_id,
ADD CONSTRAINT fk_invoices_storno_of
FOREIGN KEY (storno_of_id) REFERENCES invoices(id) ON DELETE SET NULL;

View file

@ -104,13 +104,30 @@ class InvoiceController
$lines = Invoice::lines((int)$params['id']);
$settings = Setting::get();
// Querverweis: Gibt es eine Stornorechnung zu dieser Rechnung?
$stornorechnung = \Steuerkram\Database::fetchOne(
'SELECT id, rechnungs_nr FROM invoices WHERE storno_of_id = ? LIMIT 1',
[(int)$params['id']]
);
// Querverweis: Ist dies eine Stornorechnung → Originalrechnung laden
$originalrechnung = null;
if (!empty($invoice['storno_of_id'])) {
$originalrechnung = \Steuerkram\Database::fetchOne(
'SELECT id, rechnungs_nr FROM invoices WHERE id = ?',
[(int)$invoice['storno_of_id']]
);
}
$view = new View();
$view->render('layout/base.php', [
'pageTitle' => 'Rechnung ' . ($invoice['rechnungs_nr'] ?? 'Entwurf'),
'content' => $this->renderView('invoices/show', [
'invoice' => $invoice,
'lines' => $lines,
'settings' => $settings,
'invoice' => $invoice,
'lines' => $lines,
'settings' => $settings,
'stornorechnung' => $stornorechnung ?: null,
'originalrechnung' => $originalrechnung,
]),
]);
}
@ -247,6 +264,15 @@ class InvoiceController
$invoice = Invoice::find((int)$params['id']);
if (!$invoice) { http_response_code(404); return; }
// Bei Stornorechnung Originalrechnung laden für Referenzzeile
if (!empty($invoice['storno_of_id'])) {
$original = Invoice::find((int)$invoice['storno_of_id']);
if ($original) {
$invoice['original_rechnungs_nr'] = $original['rechnungs_nr'];
$invoice['original_rechnungs_datum'] = $original['rechnungs_datum'];
}
}
$lines = Invoice::lines((int)$params['id']);
$settings = Setting::get();
@ -411,8 +437,8 @@ class InvoiceController
public function cancel(array $params): void
{
Invoice::cancel((int)$params['id']);
View::redirect('/invoices/' . $params['id'], 'Rechnung storniert.');
$stornoId = Invoice::createCancellation((int)$params['id']);
View::redirect('/invoices/' . $stornoId, 'Stornorechnung erstellt.');
}
public function addLine(array $params): void

View file

@ -53,7 +53,7 @@ class Invoice
public static function find(int $id): array|false
{
return Database::fetchOne(
'SELECT i.*, c.firma, c.nachname, c.vorname, c.anrede, c.kundennummer,
'SELECT i.*, i.storno_of_id, c.firma, c.nachname, c.vorname, c.anrede, c.kundennummer,
c.strasse, c.plz, c.ort, c.land, c.email, c.payment_days
FROM invoices i
JOIN customers c ON i.customer_id = c.id
@ -181,6 +181,61 @@ class Invoice
Database::execute('UPDATE invoices SET status=\'Storniert\', updated_at=NOW() WHERE id=?', [$id]);
}
public static function createCancellation(int $originalId): int
{
$original = self::find($originalId);
if (!$original) {
throw new \RuntimeException('Originalrechnung nicht gefunden.');
}
if (!in_array($original['status'], ['Finalisiert', 'Gesendet', 'Bezahlt'])) {
throw new \RuntimeException('Nur finalisierte, gesendete oder bezahlte Rechnungen können storniert werden.');
}
if (!empty($original['storno_of_id'])) {
throw new \RuntimeException('Eine Stornorechnung kann nicht erneut storniert werden.');
}
$originalLines = self::lines($originalId);
$today = date('Y-m-d');
$fy = FiscalYear::findOrCreateByYear((int)date('Y'));
$nr = self::generateRechnungsNr();
Database::execute(
'INSERT INTO invoices (customer_id, betreff, leistungszeitraum, rechnungs_datum, faellig_datum,
status, rechnungs_nr, fiscal_year_id, storno_of_id)
VALUES (?, ?, ?, ?, ?, \'Finalisiert\', ?, ?, ?)',
[
(int)$original['customer_id'],
'Stornorechnung zu Rechnung ' . $original['rechnungs_nr'],
$original['leistungszeitraum'],
$today,
$today,
$nr,
$fy['id'],
$originalId,
]
);
$stornoId = (int)Database::lastInsertId();
$lines = [];
foreach ($originalLines as $line) {
$lines[] = [
'type' => $line['type'] ?? 'position',
'bezeichnung' => $line['bezeichnung'],
'menge' => $line['menge'],
'einheit' => $line['einheit'],
'einzelpreis' => -round((float)$line['einzelpreis'], 2),
'ust_satz' => $line['ust_satz'],
];
}
self::saveLines($stornoId, $lines);
self::recalcTotals($stornoId);
self::cancel($originalId);
return $stornoId;
}
private static function saveLines(int $invoiceId, array $lines): void
{
$posCounter = 0;

View file

@ -16,7 +16,8 @@ class PdfInvoiceService
public function render(array $invoice, array $lines, array $settings): void
{
$pdfContent = $this->generate($invoice, $lines, $settings);
$filename = 'Rechnung-' . ($invoice['rechnungs_nr'] ?? 'Entwurf') . '.pdf';
$prefix = !empty($invoice['storno_of_id']) ? 'Stornorechnung-' : 'Rechnung-';
$filename = $prefix . ($invoice['rechnungs_nr'] ?? 'Entwurf') . '.pdf';
header('Content-Type: application/pdf');
header('Content-Disposition: attachment; filename="' . $filename . '"');
@ -102,10 +103,23 @@ class PdfInvoiceService
$lz = $inv['leistungszeitraum'] ?? '';
// Fälligkeitsdatum
$fälligText = !empty($inv['faellig_datum'])
? ' Fällig am: <strong>' . $fmtDate($inv['faellig_datum']) . '</strong>'
: '';
$isStorno = !empty($inv['storno_of_id']);
$docTitle = $isStorno ? 'STORNORECHNUNG' : 'Rechnung';
// Referenzzeile für Stornorechnung
$stornoRefHtml = '';
if ($isStorno && !empty($inv['original_rechnungs_nr'])) {
$stornoRefHtml = '<div style="margin-top:4mm;font-size:7pt;color:#c00">'
. 'Stornierung der Rechnung Nr. <strong>' . $h($inv['original_rechnungs_nr']) . '</strong>'
. ' vom ' . $fmtDate($inv['original_rechnungs_datum'] ?? '')
. '</div>';
}
// Fälligkeitsdatum (nicht bei Stornorechnung)
$fälligText = '';
if (!$isStorno && !empty($inv['faellig_datum'])) {
$fälligText = ' Fällig am: <strong>' . $fmtDate($inv['faellig_datum']) . '</strong>';
}
// Positionszeilen
$posRows = '';
@ -153,10 +167,12 @@ class PdfInvoiceService
</div>
</td>
<!-- Rechte Spalte: Dokumententitel -->
<td class="address-right" style="text-align:right;vertical-align:top;font-size:16pt">Rechnung</td>
<td class="address-right" style="text-align:right;vertical-align:top;font-size:16pt">{$docTitle}</td>
</tr>
</table>
{$stornoRefHtml}
<!-- Meta-Felder -->
<div style="height:20mm"></div>
<table class="meta-table">
@ -208,10 +224,8 @@ class PdfInvoiceService
</tr>
</table>
<!-- Zahlungshinweis -->
<p class="payment-note">
Bitte überweisen Sie den Rechnungsbetrag innerhalb {$h($payDays)} Tagen auf das unten genannte Konto.{$fälligText}
</p>
<!-- Zahlungshinweis (nicht bei Stornorechnungen) -->
{$isStorno ? '' : '<p class="payment-note">Bitte überweisen Sie den Rechnungsbetrag innerhalb ' . $h($payDays) . ' Tagen auf das unten genannte Konto.' . $fälligText . '</p>'}
</div>
HTML;

View file

@ -64,6 +64,14 @@
<?php if ($inv['status'] !== 'Entwurf'): ?>
<a href="/invoices/<?= $h($inv['id']) ?>/pdf" class="btn btn-secondary btn-sm">PDF</a>
<?php endif; ?>
<?php if (in_array($inv['status'], ['Finalisiert','Gesendet','Bezahlt'])): ?>
<form method="post"
action="/invoices/<?= $h($inv['id']) ?>/cancel"
style="display:inline"
onsubmit="return confirm('Rechnung <?= $h($inv['rechnungs_nr']) ?> wirklich stornieren und eine Stornorechnung erstellen?')">
<button type="submit" class="btn btn-warning btn-sm">Storno</button>
</form>
<?php endif; ?>
<?php if (!empty($settings['test_mode'])): ?>
<form method="post" action="/invoices/<?= $h($inv['id']) ?>/delete" style="display:inline"
onsubmit="return confirm('Rechnung wirklich löschen?')">

View file

@ -18,6 +18,18 @@ $badgeClass = match($invoice['status']) {
};
?>
<!-- Querverweise Storno -->
<?php if (!empty($originalrechnung)): ?>
<div class="alert alert-warning" style="margin-bottom:.75rem;padding:.6rem 1rem;background:#fff3cd;border-left:3px solid #f59e0b;font-size:.8rem">
Stornorechnung zu: <a href="/invoices/<?= $h($originalrechnung['id']) ?>"><strong><?= $h($originalrechnung['rechnungs_nr']) ?></strong></a>
</div>
<?php endif; ?>
<?php if (!empty($stornorechnung)): ?>
<div class="alert alert-info" style="margin-bottom:.75rem;padding:.6rem 1rem;background:#e0f2fe;border-left:3px solid #0284c7;font-size:.8rem">
Wurde storniert durch: <a href="/invoices/<?= $h($stornorechnung['id']) ?>"><strong><?= $h($stornorechnung['rechnungs_nr']) ?></strong></a>
</div>
<?php endif; ?>
<!-- Aktionsleiste -->
<div class="toolbar mb-2">
<div class="toolbar-left">
@ -41,9 +53,9 @@ $badgeClass = match($invoice['status']) {
<?php if ($invoice['status'] === 'Gesendet'): ?>
<a href="/invoices/<?= $h($invoice['id']) ?>/book" class="btn btn-success">Zahlungseingang erfassen</a>
<?php endif; ?>
<?php if (in_array($invoice['status'], ['Entwurf','Finalisiert','Gesendet'])): ?>
<?php if (in_array($invoice['status'], ['Entwurf','Finalisiert','Gesendet']) && empty($invoice['storno_of_id'])): ?>
<form method="post" action="/invoices/<?= $h($invoice['id']) ?>/cancel"
data-confirm="Rechnung stornieren?">
data-confirm="Rechnung stornieren und Stornorechnung erstellen?">
<button type="submit" class="btn btn-danger">Stornieren</button>
</form>
<?php endif; ?>
@ -90,7 +102,7 @@ $badgeClass = match($invoice['status']) {
<?php if ($invoice['land'] !== 'DE'): ?><br><?= $h($invoice['land']) ?><?php endif; ?>
</div>
</div>
<div style="text-align:right;font-size:15pt">Rechnung</div>
<div style="text-align:right;font-size:15pt"><?= !empty($invoice['storno_of_id']) ? 'STORNORECHNUNG' : 'Rechnung' ?></div>
</div>
<!-- Meta-Tabelle -->
@ -161,10 +173,12 @@ $badgeClass = match($invoice['status']) {
</tr>
</table>
</div>
<?php if (empty($invoice['storno_of_id'])): ?>
<div style="background:#f0f4ff;border-left:3px solid #2563eb;padding:.75rem 1rem;margin-bottom:2rem;font-size:.7rem">
Bitte überweisen Sie den Rechnungsbetrag innerhalb <?= $h($invoice['payment_days'] ?? 14) ?> Tagen auf das unten genannte Konto.
<strong>Fällig am: <?= View::formatDate($invoice['faellig_datum']) ?></strong>
</div>
<?php endif; ?>
</div>
</div><!-- /.invoice-page-body -->

View file

@ -1 +1 @@
1-260503
1-260505

View file

@ -0,0 +1,106 @@
<?php
declare(strict_types=1);
/**
* Test 27: Stornorechnung erstellen via Invoice::createCancellation().
*/
class StornorechnungTest extends TestCase
{
protected string $testName = 'Test 27: Stornorechnung erstellen';
public function run(): void
{
// Testkunden anlegen
$customerId = \Steuerkram\Models\Customer::create([
'nachname' => 'StornoTester',
'vorname' => 'Test',
'firma' => 'Storno GmbH',
'strasse' => 'Testweg 1',
'plz' => '10115',
'ort' => 'Berlin',
'land' => 'DE',
'payment_days' => 14,
]);
// Originalrechnung erstellen und finalisieren
$originalId = \Steuerkram\Models\Invoice::create([
'customer_id' => $customerId,
'betreff' => 'Testrechnung für Storno',
'rechnungs_datum' => date('Y-m-d'),
'lines' => [
[
'bezeichnung' => 'Beratung',
'menge' => 2,
'einheit' => 'Std.',
'einzelpreis' => 100.00,
'ust_satz' => 19.0,
],
[
'bezeichnung' => 'Reisekosten',
'menge' => 1,
'einheit' => 'Pauschal',
'einzelpreis' => 50.00,
'ust_satz' => 19.0,
],
],
]);
\Steuerkram\Models\Invoice::finalize($originalId);
$original = \Steuerkram\Models\Invoice::find($originalId);
$this->assertNotEmpty($original['rechnungs_nr'], 'Original hat Rechnungsnummer');
$this->assertEquals('Finalisiert', $original['status'], 'Original ist finalisiert');
$this->assertEquals(250.00, round((float)$original['betrag_netto'], 2), 'Original Nettobetrag korrekt');
// Stornorechnung erstellen
$stornoId = \Steuerkram\Models\Invoice::createCancellation($originalId);
$this->assertGreaterThan(0, $stornoId, 'createCancellation gibt gültige ID zurück');
// Stornorechnung prüfen
$storno = \Steuerkram\Models\Invoice::find($stornoId);
$this->assertNotEmpty($storno, 'Stornorechnung gefunden');
$this->assertEquals($originalId, (int)$storno['storno_of_id'], 'storno_of_id zeigt auf Original');
$this->assertEquals('Finalisiert', $storno['status'], 'Stornorechnung ist sofort finalisiert');
$this->assertNotEmpty($storno['rechnungs_nr'], 'Stornorechnung hat Rechnungsnummer');
$this->assert($original['rechnungs_nr'] !== $storno['rechnungs_nr'], 'Rechnungsnummern sind verschieden');
// Beträge sind negiert
$this->assertEquals(-250.00, round((float)$storno['betrag_netto'], 2), 'Nettobetrag negiert');
$this->assertEquals(-47.50, round((float)$storno['betrag_ust'], 2), 'USt-Betrag negiert');
$this->assertEquals(-297.50, round((float)$storno['betrag_brutto'], 2), 'Bruttobetrag negiert');
// Originalrechnung ist storniert
$originalNach = \Steuerkram\Models\Invoice::find($originalId);
$this->assertEquals('Storniert', $originalNach['status'], 'Originalrechnung hat Status Storniert');
// Positionen der Stornorechnung sind negiert
$stornoLines = \Steuerkram\Models\Invoice::lines($stornoId);
$this->assertEquals(2, count($stornoLines), 'Stornorechnung hat 2 Positionen');
foreach ($stornoLines as $line) {
if (($line['type'] ?? 'position') === 'position') {
$this->assert((float)$line['menge'] > 0, 'Menge bleibt positiv');
$this->assert((float)$line['einzelpreis'] < 0, 'Einzelpreis ist negativ');
}
}
// Stornobuchungen müssen angelegt worden sein (Original hatte beleg_id nach finalize)
$originalMitBeleg = \Steuerkram\Models\Invoice::find($originalId);
if (!empty($originalMitBeleg['beleg_id'])) {
$bookings = \Steuerkram\Database::fetchAll(
'SELECT id FROM bookings WHERE beleg_id = ? AND is_reversal = 1',
[(int)$originalMitBeleg['beleg_id']]
);
$this->assertGreaterThan(0, count($bookings), 'Stornobuchungen wurden angelegt');
}
// Erneutes Stornieren der Stornorechnung muss fehlschlagen
$exception = false;
try {
\Steuerkram\Models\Invoice::createCancellation($stornoId);
} catch (\RuntimeException $e) {
$exception = true;
}
$this->assert($exception, 'Stornorechnung kann nicht erneut storniert werden');
}
}