Merge pull request 'Fix: Storno-Button in Rechnungsübersicht für Stornorechnungen ausblenden' (#67) from fds2610-bot/Steuerkram:main into main

Reviewed-on: https://codeberg.org/fds2610/Steuerkram/pulls/67
This commit is contained in:
fds2610 2026-05-05 17:18:25 +02:00
commit 678a6b2163
15 changed files with 675 additions and 33 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 ## 2026-04-28 – Feature: E-Rechnung / ZUGFeRD EN16931
### Hinzugefügt ### 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 - 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 - **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 - 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

@ -0,0 +1,6 @@
ALTER TABLE belege
ADD COLUMN invoice_id INT UNSIGNED NULL DEFAULT NULL
COMMENT 'FK zur zugehörigen Ausgangsrechnung (wenn kein physischer Beleg)'
AFTER id,
ADD CONSTRAINT fk_belege_invoice
FOREIGN KEY (invoice_id) REFERENCES invoices(id) ON DELETE SET NULL;

View file

@ -104,6 +104,7 @@ $router->post('/invoices/{id}/delete', fn($p) => (new InvoiceController())->dest
$router->post('/invoices/{id}/lines', fn($p) => (new InvoiceController())->addLine($p)); $router->post('/invoices/{id}/lines', fn($p) => (new InvoiceController())->addLine($p));
$router->post('/invoices/{id}/lines/{line_id}/delete', fn($p) => (new InvoiceController())->deleteLine($p)); $router->post('/invoices/{id}/lines/{line_id}/delete', fn($p) => (new InvoiceController())->deleteLine($p));
$router->post('/invoices/{id}/send', fn($p) => (new InvoiceController())->send($p)); $router->post('/invoices/{id}/send', fn($p) => (new InvoiceController())->send($p));
$router->post('/invoices/{id}/quick-book', fn($p) => (new InvoiceController())->quickBook($p));
// ── Anlagevermögen ─────────────────────────────────────────── // ── Anlagevermögen ───────────────────────────────────────────
$router->get('/assets', fn($p) => (new AssetController())->index($p)); $router->get('/assets', fn($p) => (new AssetController())->index($p));

View file

@ -230,6 +230,31 @@ class BelegController
$beleg = Beleg::find((int)$params['id']); $beleg = Beleg::find((int)$params['id']);
if (!$beleg) { http_response_code(404); return; } if (!$beleg) { http_response_code(404); return; }
// Beleg ohne physische Datei → PDF der verknüpften Rechnung inline ausliefern
if (empty($beleg['storage_path']) && !empty($beleg['invoice_id'])) {
$invoice = \Steuerkram\Models\Invoice::find((int)$beleg['invoice_id']);
if (!$invoice) { http_response_code(404); return; }
if (!empty($invoice['storno_of_id'])) {
$original = \Steuerkram\Models\Invoice::find((int)$invoice['storno_of_id']);
if ($original) {
$invoice['original_rechnungs_nr'] = $original['rechnungs_nr'];
$invoice['original_rechnungs_datum'] = $original['rechnungs_datum'];
}
}
$lines = \Steuerkram\Models\Invoice::lines((int)$invoice['id']);
$settings = \Steuerkram\Models\Setting::get();
$svc = new \Steuerkram\Services\PdfInvoiceService();
$pdf = $svc->renderToString($invoice, $lines, $settings);
header('Content-Type: application/pdf');
header('Content-Disposition: inline; filename="' . $beleg['filename'] . '"');
header('Content-Length: ' . strlen($pdf));
echo $pdf;
exit;
}
$absPath = dirname(__DIR__, 2) . '/public/' . $beleg['storage_path']; $absPath = dirname(__DIR__, 2) . '/public/' . $beleg['storage_path'];
if (!file_exists($absPath)) { http_response_code(404); return; } if (!file_exists($absPath)) { http_response_code(404); return; }

View file

@ -104,13 +104,40 @@ class InvoiceController
$lines = Invoice::lines((int)$params['id']); $lines = Invoice::lines((int)$params['id']);
$settings = Setting::get(); $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, rechnungs_datum FROM invoices WHERE id = ?',
[(int)$invoice['storno_of_id']]
);
}
// Buchungen + Konten für das Zahlungs-Modal laden (nur wenn Beleg vorhanden)
$bookings = [];
$accounts = [];
if (!empty($invoice['beleg_id'])) {
$bookings = Booking::forBeleg((int)$invoice['beleg_id']);
$accounts = Account::all(['aktiv' => 1]);
}
$view = new View(); $view = new View();
$view->render('layout/base.php', [ $view->render('layout/base.php', [
'pageTitle' => 'Rechnung ' . ($invoice['rechnungs_nr'] ?? 'Entwurf'), 'pageTitle' => 'Rechnung ' . ($invoice['rechnungs_nr'] ?? 'Entwurf'),
'content' => $this->renderView('invoices/show', [ 'content' => $this->renderView('invoices/show', [
'invoice' => $invoice, 'invoice' => $invoice,
'lines' => $lines, 'lines' => $lines,
'settings' => $settings, 'settings' => $settings,
'stornorechnung' => $stornorechnung ?: null,
'originalrechnung' => $originalrechnung,
'bookings' => $bookings,
'accounts' => $accounts,
]), ]),
]); ]);
} }
@ -244,9 +271,33 @@ class InvoiceController
public function pdf(array $params): void public function pdf(array $params): void
{ {
$invoice = Invoice::find((int)$params['id']); $invoice = Invoice::find((int)$params['id']);
if (!$invoice) { http_response_code(404); return; } if (!$invoice) { http_response_code(404); return; }
// Gespeicherte PDF aus dem Beleg ausliefern (GoBD: unveränderlicher Stand)
if (!empty($invoice['beleg_id'])) {
$beleg = Beleg::find((int)$invoice['beleg_id']);
$absPath = $beleg
? dirname(__DIR__, 2) . '/public/' . $beleg['storage_path']
: '';
if ($absPath && file_exists($absPath)) {
header('Content-Type: application/pdf');
header('Content-Disposition: attachment; filename="' . $beleg['filename'] . '"');
header('Content-Length: ' . filesize($absPath));
readfile($absPath);
return;
}
}
// Fallback: PDF on-the-fly generieren (Entwurf oder Beleg noch ohne Datei)
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']); $lines = Invoice::lines((int)$params['id']);
$settings = Setting::get(); $settings = Setting::get();
@ -314,6 +365,10 @@ class InvoiceController
} }
if ($errors) { if ($errors) {
if (!empty($data['_from_modal'])) {
View::redirect("/invoices/{$id}", implode(' ', $errors), 'error');
return;
}
$bookings = Booking::forBeleg($belegId); $bookings = Booking::forBeleg($belegId);
$accounts = Account::all(['aktiv' => 1]); $accounts = Account::all(['aktiv' => 1]);
$view = new View(); $view = new View();
@ -334,7 +389,8 @@ class InvoiceController
$data['beleg_id'] = $belegId; $data['beleg_id'] = $belegId;
$data['buchungstyp'] = 'Einnahme'; $data['buchungstyp'] = 'Einnahme';
Booking::create($data); Booking::create($data);
View::redirect("/invoices/{$id}/book", 'Buchung erfasst.'); $returnUrl = !empty($data['_from_modal']) ? "/invoices/{$id}" : "/invoices/{$id}/book";
View::redirect($returnUrl, 'Buchung erfasst.');
} catch (\PDOException $e) { } catch (\PDOException $e) {
error_log('[Steuerkram] PDO-Fehler in InvoiceController::storeBooking(): ' . $e->getMessage()); error_log('[Steuerkram] PDO-Fehler in InvoiceController::storeBooking(): ' . $e->getMessage());
$bookings = Booking::forBeleg($belegId); $bookings = Booking::forBeleg($belegId);
@ -367,22 +423,71 @@ class InvoiceController
} }
} }
public function quickBook(array $params): void
{
$id = (int)$params['id'];
$invoice = Invoice::find($id);
if (!$invoice) { http_response_code(404); return; }
$data = $_POST;
if (empty($data['kategorie_konto_id']) || empty($data['zahlungskonto_id']) || empty($data['buchungs_datum'])) {
View::redirect("/invoices/{$id}", 'Bitte Konto und Datum angeben.', 'error');
return;
}
try {
$cnt = count(Invoice::createBookingsFromLines(
$id,
(int)$data['kategorie_konto_id'],
(int)$data['zahlungskonto_id'],
$data['buchungs_datum']
));
View::redirect("/invoices/{$id}", "{$cnt} Buchung(en) automatisch erfasst.");
} catch (\RuntimeException $e) {
View::redirect("/invoices/{$id}", $e->getMessage(), 'error');
}
}
private function createBeleg(array $invoice): string private function createBeleg(array $invoice): string
{ {
$id = (int)$invoice['id']; $id = (int)$invoice['id'];
$lines = Invoice::lines($id);
$settings = Setting::get();
// Bei Stornorechnung Originaldaten für Referenzzeile ergänzen
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'];
}
}
// PDF zum Zeitpunkt der Finalisierung unveränderlich generieren und speichern (GoBD)
$svc = new PdfInvoiceService();
$pdf = $svc->renderToString($invoice, $lines, $settings);
$belegNr = Beleg::generateBelegNr(); $belegNr = Beleg::generateBelegNr();
$year = date('Y', strtotime($invoice['rechnungs_datum']));
$relDir = "static/uploads/{$year}/{$belegNr}";
$absDir = dirname(__DIR__, 2) . "/public/{$relDir}";
if (!is_dir($absDir)) mkdir($absDir, 0755, true);
$filename = $invoice['rechnungs_nr'] . '.pdf';
$storagePath = "{$relDir}/original.pdf";
file_put_contents(dirname(__DIR__, 2) . "/public/{$storagePath}", $pdf);
$belegId = Beleg::create([ $belegId = Beleg::create([
'invoice_id' => $id,
'beleg_nr' => $belegNr, 'beleg_nr' => $belegNr,
'filename' => $invoice['rechnungs_nr'] . '.pdf', 'filename' => $filename,
'storage_path' => '', 'storage_path' => $storagePath,
'mime_type' => 'application/pdf', 'mime_type' => 'application/pdf',
'file_hash' => hash('sha256', $invoice['rechnungs_nr']), 'file_hash' => hash('sha256', $pdf),
'beleg_datum' => $invoice['rechnungs_datum'], 'beleg_datum' => $invoice['rechnungs_datum'],
'rechnungs_nr' => $invoice['rechnungs_nr'], 'rechnungs_nr' => $invoice['rechnungs_nr'],
'betrag_brutto' => $invoice['betrag_brutto'], 'betrag_brutto' => $invoice['betrag_brutto'],
'belegtyp' => 'Ausgangsrechnung', 'belegtyp' => 'Ausgangsrechnung',
'notes' => 'Automatisch erstellt aus Rechnung ' . $invoice['rechnungs_nr'], 'notes' => (!empty($invoice['storno_of_id']) ? 'Stornorechnung zu Rechnung ' : 'Automatisch erstellt aus Rechnung ') . $invoice['rechnungs_nr'],
]); ]);
\Steuerkram\Database::execute( \Steuerkram\Database::execute(
@ -411,8 +516,12 @@ class InvoiceController
public function cancel(array $params): void public function cancel(array $params): void
{ {
Invoice::cancel((int)$params['id']); $stornoId = Invoice::createCancellation((int)$params['id']);
View::redirect('/invoices/' . $params['id'], 'Rechnung storniert.'); $storno = Invoice::find($stornoId);
if ($storno) {
$this->createBeleg($storno);
}
View::redirect('/invoices/' . $stornoId, 'Stornorechnung erstellt.');
} }
public function addLine(array $params): void public function addLine(array $params): void

View file

@ -100,11 +100,14 @@ class Beleg
$fyId = $fy['id']; $fyId = $fy['id'];
} }
$invoiceId = isset($data['invoice_id']) && $data['invoice_id'] ? (int)$data['invoice_id'] : null;
Database::execute( Database::execute(
'INSERT INTO belege (beleg_nr, filename, storage_path, mime_type, file_hash, 'INSERT INTO belege (invoice_id, beleg_nr, filename, storage_path, mime_type, file_hash,
beleg_datum, rechnungs_nr, betrag_brutto, belegtyp, notes, fiscal_year_id) beleg_datum, rechnungs_nr, betrag_brutto, belegtyp, notes, fiscal_year_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
[ [
$invoiceId,
$data['beleg_nr'], $data['beleg_nr'],
$data['filename'], $data['filename'],
$data['storage_path'], $data['storage_path'],

View file

@ -53,7 +53,7 @@ class Invoice
public static function find(int $id): array|false public static function find(int $id): array|false
{ {
return Database::fetchOne( 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 c.strasse, c.plz, c.ort, c.land, c.email, c.payment_days
FROM invoices i FROM invoices i
JOIN customers c ON i.customer_id = c.id JOIN customers c ON i.customer_id = c.id
@ -162,6 +162,39 @@ class Invoice
Database::execute('UPDATE invoices SET status=\'Bezahlt\', updated_at=NOW() WHERE id=?', [$id]); Database::execute('UPDATE invoices SET status=\'Bezahlt\', updated_at=NOW() WHERE id=?', [$id]);
} }
public static function createBookingsFromLines(
int $invoiceId,
int $kategorieKontoId,
int $zahlungskontoId,
string $buchungsDatum
): array {
$inv = self::find($invoiceId);
if (!$inv) throw new \RuntimeException('Rechnung nicht gefunden.');
if (empty($inv['beleg_id'])) throw new \RuntimeException('Rechnung hat keinen Beleg (noch nicht finalisiert?).');
$gruppen = [];
foreach (self::lines($invoiceId) as $line) {
if (($line['type'] ?? 'position') !== 'position') continue;
$satz = (float)$line['ust_satz'];
$gruppen[$satz] = ($gruppen[$satz] ?? 0.0) + round((float)$line['betrag_netto'], 2);
}
$ids = [];
foreach ($gruppen as $satz => $netto) {
$ids[] = Booking::create([
'beleg_id' => (int)$inv['beleg_id'],
'buchungs_datum' => $buchungsDatum,
'buchungstyp' => 'Einnahme',
'kategorie_konto_id' => $kategorieKontoId,
'zahlungskonto_id' => $zahlungskontoId,
'betrag_netto' => round($netto, 2),
'ust_satz' => $satz,
'buchungstext' => 'Ausgangsrechnung ' . $inv['rechnungs_nr'],
]);
}
return $ids;
}
public static function cancel(int $id): void public static function cancel(int $id): void
{ {
$invoice = self::find($id); $invoice = self::find($id);
@ -181,6 +214,61 @@ class Invoice
Database::execute('UPDATE invoices SET status=\'Storniert\', updated_at=NOW() WHERE id=?', [$id]); 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'],
$original['betreff'],
$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 private static function saveLines(int $invoiceId, array $lines): void
{ {
$posCounter = 0; $posCounter = 0;

View file

@ -16,7 +16,8 @@ class PdfInvoiceService
public function render(array $invoice, array $lines, array $settings): void public function render(array $invoice, array $lines, array $settings): void
{ {
$pdfContent = $this->generate($invoice, $lines, $settings); $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-Type: application/pdf');
header('Content-Disposition: attachment; filename="' . $filename . '"'); header('Content-Disposition: attachment; filename="' . $filename . '"');
@ -102,10 +103,29 @@ class PdfInvoiceService
$lz = $inv['leistungszeitraum'] ?? ''; $lz = $inv['leistungszeitraum'] ?? '';
// Fälligkeitsdatum $isStorno = !empty($inv['storno_of_id']);
$fälligText = !empty($inv['faellig_datum']) $docTitle = $isStorno ? 'STORNORECHNUNG' : 'Rechnung';
? ' Fällig am: <strong>' . $fmtDate($inv['faellig_datum']) . '</strong>'
: ''; // Referenzzeile direkt unter dem Dokumententitel (rechte Spalte im Adressblock)
$stornoRefHtml = '';
if ($isStorno && !empty($inv['original_rechnungs_nr'])) {
$stornoRefHtml = '<div style="font-size:7pt;margin-top:2mm">'
. 'zu Rechnung Nr. <strong>' . $h($inv['original_rechnungs_nr']) . '</strong>'
. ' vom ' . $fmtDate($inv['original_rechnungs_datum'] ?? '')
. '</div>';
}
$payDays = $inv['payment_days'] ?? 14;
// Fälligkeitsdatum (nicht bei Stornorechnung)
$fälligText = '';
if (!$isStorno && !empty($inv['faellig_datum'])) {
$fälligText = ' Fällig am: <strong>' . $fmtDate($inv['faellig_datum']) . '</strong>';
}
$zahlungsHinweis = $isStorno ? '' :
'<p class="payment-note">Bitte überweisen Sie den Rechnungsbetrag innerhalb '
. $h($payDays) . ' Tagen auf das unten genannte Konto.' . $fälligText . '</p>';
// Positionszeilen // Positionszeilen
$posRows = ''; $posRows = '';
@ -127,8 +147,6 @@ class PdfInvoiceService
} }
} }
$payDays = $inv['payment_days'] ?? 14;
// Empfänger-Land nur wenn nicht DE // Empfänger-Land nur wenn nicht DE
$land = ($inv['land'] ?? 'DE') !== 'DE' ? '<br>' . $h($inv['land']) : ''; $land = ($inv['land'] ?? 'DE') !== 'DE' ? '<br>' . $h($inv['land']) : '';
@ -153,7 +171,9 @@ class PdfInvoiceService
</div> </div>
</td> </td>
<!-- Rechte Spalte: Dokumententitel --> <!-- 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}{$stornoRefHtml}
</td>
</tr> </tr>
</table> </table>
@ -208,10 +228,8 @@ class PdfInvoiceService
</tr> </tr>
</table> </table>
<!-- Zahlungshinweis --> <!-- Zahlungshinweis (nicht bei Stornorechnungen) -->
<p class="payment-note"> {$zahlungsHinweis}
Bitte überweisen Sie den Rechnungsbetrag innerhalb {$h($payDays)} Tagen auf das unten genannte Konto.{$fälligText}
</p>
</div> </div>
HTML; HTML;

View file

@ -71,6 +71,14 @@ $suppliers = $suppliers ?? [];
<?= $beleg['rechnungs_nr'] ? $h($beleg['rechnungs_nr']) : '<span class="text-muted">–</span>' ?> <?= $beleg['rechnungs_nr'] ? $h($beleg['rechnungs_nr']) : '<span class="text-muted">–</span>' ?>
<?php endif; ?> <?php endif; ?>
</div> </div>
<?php if (!empty($beleg['invoice_id'])): ?>
<div>
<span class="text-muted text-sm">Ausgangsrechnung</span><br>
<a href="/invoices/<?= (int)$beleg['invoice_id'] ?>">
<?= $h($beleg['rechnungs_nr'] ?? ('Rechnung #' . $beleg['invoice_id'])) ?>
</a>
</div>
<?php endif; ?>
<div> <div>
<span class="text-muted text-sm">Brutto (Buchungen)</span><br> <span class="text-muted text-sm">Brutto (Buchungen)</span><br>
<?= $beleg['betrag_brutto'] ? '<strong>' . $fmt($beleg['betrag_brutto']) . '</strong>' : '<span class="text-muted">–</span>' ?> <?= $beleg['betrag_brutto'] ? '<strong>' . $fmt($beleg['betrag_brutto']) . '</strong>' : '<span class="text-muted">–</span>' ?>

View file

@ -64,6 +64,14 @@
<?php if ($inv['status'] !== 'Entwurf'): ?> <?php if ($inv['status'] !== 'Entwurf'): ?>
<a href="/invoices/<?= $h($inv['id']) ?>/pdf" class="btn btn-secondary btn-sm">PDF</a> <a href="/invoices/<?= $h($inv['id']) ?>/pdf" class="btn btn-secondary btn-sm">PDF</a>
<?php endif; ?> <?php endif; ?>
<?php if (in_array($inv['status'], ['Finalisiert','Gesendet','Bezahlt']) && empty($inv['storno_of_id'])): ?>
<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'])): ?> <?php if (!empty($settings['test_mode'])): ?>
<form method="post" action="/invoices/<?= $h($inv['id']) ?>/delete" style="display:inline" <form method="post" action="/invoices/<?= $h($inv['id']) ?>/delete" style="display:inline"
onsubmit="return confirm('Rechnung wirklich löschen?')"> 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 --> <!-- Aktionsleiste -->
<div class="toolbar mb-2"> <div class="toolbar mb-2">
<div class="toolbar-left"> <div class="toolbar-left">
@ -38,12 +50,13 @@ $badgeClass = match($invoice['status']) {
<button type="button" class="btn btn-primary" <button type="button" class="btn btn-primary"
onclick="document.getElementById('send-modal').style.display='flex'">Per E-Mail senden</button> onclick="document.getElementById('send-modal').style.display='flex'">Per E-Mail senden</button>
<?php endif; ?> <?php endif; ?>
<?php if ($invoice['status'] === 'Gesendet'): ?> <?php if (!in_array($invoice['status'], ['Entwurf', 'Storniert']) && !empty($invoice['beleg_id'])): ?>
<a href="/invoices/<?= $h($invoice['id']) ?>/book" class="btn btn-success">Zahlungseingang erfassen</a> <button type="button" class="btn btn-success"
onclick="document.getElementById('buchungs-modal').style.display='flex'">Zahlungen</button>
<?php endif; ?> <?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" <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> <button type="submit" class="btn btn-danger">Stornieren</button>
</form> </form>
<?php endif; ?> <?php endif; ?>
@ -90,7 +103,15 @@ $badgeClass = match($invoice['status']) {
<?php if ($invoice['land'] !== 'DE'): ?><br><?= $h($invoice['land']) ?><?php endif; ?> <?php if ($invoice['land'] !== 'DE'): ?><br><?= $h($invoice['land']) ?><?php endif; ?>
</div> </div>
</div> </div>
<div style="text-align:right;font-size:15pt">Rechnung</div> <div style="text-align:right;font-size:17pt">
<?= !empty($invoice['storno_of_id']) ? 'STORNORECHNUNG' : 'Rechnung' ?>
<?php if (!empty($invoice['storno_of_id']) && !empty($originalrechnung)): ?>
<div style="font-size:.65rem;margin-top:.3rem;font-weight:normal">
zu Rechnung Nr. <strong><?= $h($originalrechnung['rechnungs_nr']) ?></strong>
vom <?= $h(View::formatDate($originalrechnung['rechnungs_datum'] ?? '')) ?>
</div>
<?php endif; ?>
</div>
</div> </div>
<!-- Meta-Tabelle --> <!-- Meta-Tabelle -->
@ -161,10 +182,12 @@ $badgeClass = match($invoice['status']) {
</tr> </tr>
</table> </table>
</div> </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"> <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. 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> <strong>Fällig am: <?= View::formatDate($invoice['faellig_datum']) ?></strong>
</div> </div>
<?php endif; ?>
</div> </div>
</div><!-- /.invoice-page-body --> </div><!-- /.invoice-page-body -->
@ -344,3 +367,131 @@ $badgeClass = match($invoice['status']) {
</form> </form>
</div> </div>
</div> </div>
<!-- Zahlungs-Modal -->
<?php if (!in_array($invoice['status'], ['Entwurf', 'Storniert']) && !empty($invoice['beleg_id'])): ?>
<?php
$kontenGruppen = [];
foreach ($accounts as $acc) { $kontenGruppen[$acc['kontenart']][] = $acc; }
?>
<div id="buchungs-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.45);z-index:1000;align-items:flex-start;justify-content:center;padding:2rem 1rem;overflow-y:auto">
<div style="background:#fff;border-radius:8px;width:100%;max-width:720px;box-shadow:0 8px 32px rgba(0,0,0,.2)">
<div style="display:flex;align-items:center;justify-content:space-between;padding:1rem 1.25rem;border-bottom:1px solid #e5e7eb">
<h3 style="margin:0">Zahlungen – <?= $h($invoice['rechnungs_nr']) ?></h3>
<button onclick="document.getElementById('buchungs-modal').style.display='none'"
style="background:none;border:none;font-size:1.5rem;cursor:pointer;color:#6b7280;line-height:1">&times;</button>
</div>
<!-- Vorhandene Buchungen -->
<div style="padding:1rem 1.25rem;border-bottom:1px solid #e5e7eb;overflow-x:auto">
<table style="width:100%;font-size:.75rem;border-collapse:collapse">
<thead>
<tr style="background:#f0f0f0">
<th style="padding:.4rem .5rem;text-align:left">Datum</th>
<th style="padding:.4rem .5rem;text-align:left">Kategorie</th>
<th style="padding:.4rem .5rem;text-align:left">Zahlungskonto</th>
<th style="padding:.4rem .5rem;text-align:right">Netto</th>
<th style="padding:.4rem .5rem;text-align:right">USt</th>
<th style="padding:.4rem .5rem;text-align:right">Brutto</th>
<th style="padding:.4rem .5rem">Text</th>
</tr>
</thead>
<tbody>
<?php foreach ($bookings as $bk):
$bkStorno = (bool)$bk['is_reversal'] || !empty($bk['reversed_by']);
?>
<tr style="border-bottom:1px solid #eee;<?= $bkStorno ? 'opacity:.5;background:#fff5f5' : '' ?>">
<td style="padding:.35rem .5rem;white-space:nowrap"><?= $h(View::formatDate($bk['buchungs_datum'])) ?></td>
<td style="padding:.35rem .5rem;font-size:.7rem"><?= $h($bk['kategorie_nr']) ?><br><span style="color:#6b7280"><?= $h($bk['kategorie_name']) ?></span></td>
<td style="padding:.35rem .5rem;font-size:.7rem"><?= $h($bk['zahlungs_nr']) ?><br><span style="color:#6b7280"><?= $h($bk['zahlungs_name']) ?></span></td>
<td style="padding:.35rem .5rem;text-align:right;font-family:monospace"><?= $fmt($bk['betrag_netto']) ?></td>
<td style="padding:.35rem .5rem;text-align:right"><?= number_format((float)$bk['ust_satz'], 0) ?> %</td>
<td style="padding:.35rem .5rem;text-align:right;font-family:monospace;font-weight:600"><?= $fmt($bk['betrag_brutto']) ?></td>
<td style="padding:.35rem .5rem;color:#6b7280;font-size:.7rem">
<?= $h($bk['buchungstext']) ?>
<?php if ((bool)$bk['is_reversal']): ?><span style="color:#dc2626;font-size:.65rem">[Storno]</span>
<?php elseif (!empty($bk['reversed_by'])): ?><span style="color:#dc2626;font-size:.65rem">[Storniert]</span><?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
<?php if (empty($bookings)): ?>
<tr><td colspan="7" style="padding:1rem;text-align:center;color:#9ca3af">Noch keine Buchungen.</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
<!-- Neue Buchung erfassen -->
<div style="padding:1rem 1.25rem">
<div style="font-size:.8rem;font-weight:600;margin-bottom:.75rem">Neue Buchung erfassen</div>
<form method="post" action="/invoices/<?= $h($invoice['id']) ?>/bookings">
<input type="hidden" name="_from_modal" value="1">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:.75rem;margin-bottom:.75rem">
<div>
<label style="font-size:.75rem;font-weight:600;display:block;margin-bottom:.25rem">Zahldatum *</label>
<input type="date" name="buchungs_datum" value="<?= date('Y-m-d') ?>" required style="width:100%">
</div>
<div>
<label style="font-size:.75rem;font-weight:600;display:block;margin-bottom:.25rem">Nettobetrag (€) *</label>
<input type="number" name="betrag_netto" step="0.01" min="0.01" required
value="<?= $h(number_format(abs((float)$invoice['betrag_netto']), 2, '.', '')) ?>"
style="width:100%">
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:.75rem;margin-bottom:.75rem">
<div>
<label style="font-size:.75rem;font-weight:600;display:block;margin-bottom:.25rem">Kategorie-Konto *</label>
<select name="kategorie_konto_id" required style="width:100%">
<option value="">– Konto wählen –</option>
<?php foreach ($kontenGruppen['Betriebseinnahme'] ?? [] as $acc): ?>
<option value="<?= $h($acc['id']) ?>"><?= $h($acc['kontonummer']) ?> – <?= $h($acc['bezeichnung']) ?></option>
<?php endforeach; ?>
</select>
</div>
<div>
<label style="font-size:.75rem;font-weight:600;display:block;margin-bottom:.25rem">Zahlungskonto *</label>
<select name="zahlungskonto_id" required style="width:100%">
<option value="">– Konto wählen –</option>
<?php foreach ($kontenGruppen['Zahlungskonto'] ?? [] as $acc): ?>
<option value="<?= $h($acc['id']) ?>"><?= $h($acc['kontonummer']) ?> – <?= $h($acc['bezeichnung']) ?></option>
<?php endforeach; ?>
</select>
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:.75rem;margin-bottom:.75rem">
<div>
<label style="font-size:.75rem;font-weight:600;display:block;margin-bottom:.25rem">USt-Satz (%)</label>
<select name="ust_satz" style="width:100%">
<?php foreach (['0','7','19'] as $s): ?>
<option value="<?= $s ?>"<?= $s === '19' ? ' selected' : '' ?>><?= $s ?> %</option>
<?php endforeach; ?>
</select>
</div>
<div>
<label style="font-size:.75rem;font-weight:600;display:block;margin-bottom:.25rem">Buchungstext *</label>
<input type="text" name="buchungstext" required
value="Ausgangsrechnung <?= $h($invoice['rechnungs_nr'] ?? '') ?>"
style="width:100%">
</div>
</div>
<div style="display:flex;gap:.75rem;justify-content:flex-end">
<button type="button" class="btn btn-secondary"
onclick="document.getElementById('buchungs-modal').style.display='none'">Schließen</button>
<button type="submit" class="btn btn-primary">Buchung erfassen</button>
</div>
</form>
</div>
</div>
</div>
<script>
(function () {
var flash = document.querySelector('.flash-error, .flash-success');
if (flash) {
var ref = document.referrer || '';
if (ref.indexOf('/invoices/<?= (int)$invoice['id'] ?>/bookings') !== -1) {
document.getElementById('buchungs-modal').style.display = 'flex';
}
}
}());
</script>
<?php endif; ?>

View file

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

View file

@ -0,0 +1,206 @@
<?php
declare(strict_types=1);
/**
* Test 27: Stornorechnung erstellen via Invoice::createCancellation().
*
* Prüft End-to-End:
* - DB-Struktur (storno_of_id, negierte Beträge, Status)
* - Stornobuchungen werden angelegt
* - UStVA: Kz 81/81-USt heben sich nach Storno auf
* - EÜR: Erlös-Konto-Saldo nach Storno = vorheriger Stand
*/
class StornorechnungTest extends TestCase
{
protected string $testName = 'Test 27: Stornorechnung – DB, Buchungen, UStVA, EÜR';
public function run(): void
{
$year = (int)date('Y');
$month = (int)date('n');
$datum = date('Y-m-d');
$zahlungsId = $this->findAccountId('1200'); // Bank
$kategorieId = $this->findAccountId('8400'); // Erlöse 19% (ustva_kz=81)
if (!$zahlungsId || !$kategorieId) {
$this->assert(false, 'Konten 1200/8400 nicht gefunden – SKR03-Seed vorhanden?');
return;
}
// 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,
]);
// ── 1. Originalrechnung erstellen + finalisieren ──────────────
$originalId = \Steuerkram\Models\Invoice::create([
'customer_id' => $customerId,
'betreff' => 'Testrechnung für Storno',
'rechnungs_datum' => $datum,
'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');
// ── 2. Beleg + Buchung manuell anlegen (sonst keine Stornowirkung) ──
$belegId = \Steuerkram\Models\Beleg::create([
'beleg_nr' => \Steuerkram\Models\Beleg::generateBelegNr(),
'filename' => $original['rechnungs_nr'] . '.pdf',
'storage_path' => '',
'mime_type' => 'application/pdf',
'file_hash' => hash('sha256', $original['rechnungs_nr'] . uniqid()),
'beleg_datum' => $datum,
'rechnungs_nr' => $original['rechnungs_nr'],
'betrag_brutto' => $original['betrag_brutto'],
'belegtyp' => 'Ausgangsrechnung',
]);
\Steuerkram\Database::execute(
'UPDATE invoices SET beleg_id=? WHERE id=?', [$belegId, $originalId]
);
$bookingIds = \Steuerkram\Models\Invoice::createBookingsFromLines(
$originalId, $kategorieId, $zahlungsId, $datum
);
$this->assertGreaterThan(0, count($bookingIds), 'createBookingsFromLines erzeugt Buchungen');
$origBookingId = $bookingIds[0];
$origBooking = \Steuerkram\Models\Booking::find($origBookingId);
$this->assertEquals(250.00, round((float)$origBooking['betrag_netto'], 2), 'Original-Buchung Netto = 250');
$this->assertEquals(47.50, round((float)$origBooking['betrag_ust'], 2), 'Original-Buchung USt = 47,50');
// ── 3. UStVA + EÜR Stand VOR Stornorechnung erfassen ──────────
$ustvaCalc = new \Steuerkram\Services\UstvaCalculator();
$kzPre = $ustvaCalc->calculate($year, $month);
$kz81NettoPre = (float)($kzPre['81']['betrag'] ?? 0);
$kz83UstPre = (float)($kzPre['83']['steuer'] ?? 0);
$euerCalc = new \Steuerkram\Services\EuerCalculator();
$euerPre = $euerCalc->calculate($year);
$kontoNettoPre = 0.0;
foreach ($euerPre['einnahmen'] as $row) {
if ((int)$row['id'] === (int)$kategorieId) {
$kontoNettoPre = (float)$row['netto'];
break;
}
}
// Sanity-Check: Originalbuchung ist in den Pre-Werten enthalten
$this->assert($kz81NettoPre >= 250.0, 'UStVA Kz 81 Pre enthält Original-Netto (≥ 250)');
$this->assert($kz83UstPre >= 47.50, 'UStVA Kz 83 Pre enthält Original-USt (≥ 47,50)');
$this->assert($kontoNettoPre >= 250.0, 'EÜR Pre: Erlös-Konto enthält Originalbuchung (≥ 250)');
// ── 4. Stornorechnung erstellen ───────────────────────────────
$stornoId = \Steuerkram\Models\Invoice::createCancellation($originalId);
$this->assertGreaterThan(0, $stornoId, 'createCancellation gibt gültige ID zurück');
$storno = \Steuerkram\Models\Invoice::find($stornoId);
$this->assertEquals($originalId, (int)$storno['storno_of_id'], 'storno_of_id zeigt auf Original');
$this->assertEquals('Finalisiert', $storno['status'], 'Stornorechnung ist Finalisiert');
$this->assert($original['rechnungs_nr'] !== $storno['rechnungs_nr'], 'Rechnungsnummern verschieden');
$this->assertEquals(-250.00, round((float)$storno['betrag_netto'], 2), 'Storno Netto negiert');
$this->assertEquals(-47.50, round((float)$storno['betrag_ust'], 2), 'Storno USt negiert');
$this->assertEquals(-297.50, round((float)$storno['betrag_brutto'], 2), 'Storno Brutto negiert');
// Originalrechnung ist storniert
$originalNach = \Steuerkram\Models\Invoice::find($originalId);
$this->assertEquals('Storniert', $originalNach['status'], 'Originalrechnung hat Status Storniert');
// Positionen 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');
$this->assert((float)$line['betrag_netto'] < 0, 'Position Netto < 0');
$this->assert((float)$line['betrag_ust'] < 0, 'Position USt < 0');
}
}
// ── 5. Stornobuchung muss am Originalbeleg hängen ─────────────
$reversalRows = \Steuerkram\Database::fetchAll(
'SELECT * FROM bookings WHERE beleg_id = ? AND is_reversal = 1',
[$belegId]
);
$this->assertEquals(1, count($reversalRows), 'Genau eine Stornobuchung am Originalbeleg');
$reversal = $reversalRows[0];
$this->assertEquals(-250.00, round((float)$reversal['betrag_netto'], 2), 'Stornobuchung Netto = -250');
$this->assertEquals(-47.50, round((float)$reversal['betrag_ust'], 2), 'Stornobuchung USt = -47,50');
$this->assertEquals(-297.50, round((float)$reversal['betrag_brutto'], 2), 'Stornobuchung Brutto = -297,50');
// Original-Buchung verweist auf Stornobuchung (reversed_by)
$origNach = \Steuerkram\Models\Booking::find($origBookingId);
$this->assertEquals(
(int)$reversal['id'],
(int)$origNach['reversed_by'],
'Originalbuchung hat reversed_by = Stornobuchungs-ID'
);
// ── 6. UStVA Kz 81 + 83 müssen sich aufheben ──────────────────
$kzPost = $ustvaCalc->calculate($year, $month);
$kz81NettoPost = (float)($kzPost['81']['betrag'] ?? 0);
$kz83UstPost = (float)($kzPost['83']['steuer'] ?? 0);
$this->assertEquals(
round($kz81NettoPre - 250.00, 2),
round($kz81NettoPost, 2),
'UStVA Kz 81 (Netto): Original + Storno heben sich auf'
);
$this->assertEquals(
round($kz83UstPre - 47.50, 2),
round($kz83UstPost, 2),
'UStVA Kz 83 (USt): Original + Storno heben sich auf'
);
// ── 7. EÜR Erlös-Konto muss sich aufheben ─────────────────────
$euerPost = $euerCalc->calculate($year);
$kontoNettoPost = 0.0;
foreach ($euerPost['einnahmen'] as $row) {
if ((int)$row['id'] === (int)$kategorieId) {
$kontoNettoPost = (float)$row['netto'];
break;
}
}
$this->assertEquals(
round($kontoNettoPre - 250.00, 2),
round($kontoNettoPost, 2),
'EÜR: Original + Storno heben sich auf Erlös-Konto auf'
);
// ── 8. Erneutes Stornieren der Stornorechnung scheitert ───────
$exception = false;
try {
\Steuerkram\Models\Invoice::createCancellation($stornoId);
} catch (\RuntimeException $e) {
$exception = true;
}
$this->assert($exception, 'Stornorechnung kann nicht erneut storniert werden');
}
}