Feature: Stornorechnung gemäß §14 UStG implementiert
This commit is contained in:
parent
6206878924
commit
38cc8f885e
10 changed files with 262 additions and 20 deletions
12
Changes.md
12
Changes.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
6
migrations/042_invoice_storno_of_id.sql
Normal file
6
migrations/042_invoice_storno_of_id.sql
Normal 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;
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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?')">
|
||||
|
|
|
|||
|
|
@ -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 -->
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
1-260503
|
||||
1-260505
|
||||
|
|
|
|||
106
tests/27_StornorechnungTest.php
Normal file
106
tests/27_StornorechnungTest.php
Normal 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');
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue