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:
commit
678a6b2163
15 changed files with 675 additions and 33 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;
|
||||
6
migrations/043_belege_invoice_id.sql
Normal file
6
migrations/043_belege_invoice_id.sql
Normal 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;
|
||||
|
|
@ -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/{line_id}/delete', fn($p) => (new InvoiceController())->deleteLine($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 ───────────────────────────────────────────
|
||||
$router->get('/assets', fn($p) => (new AssetController())->index($p));
|
||||
|
|
|
|||
|
|
@ -230,6 +230,31 @@ class BelegController
|
|||
$beleg = Beleg::find((int)$params['id']);
|
||||
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'];
|
||||
if (!file_exists($absPath)) { http_response_code(404); return; }
|
||||
|
||||
|
|
|
|||
|
|
@ -104,6 +104,29 @@ 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, 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->render('layout/base.php', [
|
||||
'pageTitle' => 'Rechnung ' . ($invoice['rechnungs_nr'] ?? 'Entwurf'),
|
||||
|
|
@ -111,6 +134,10 @@ class InvoiceController
|
|||
'invoice' => $invoice,
|
||||
'lines' => $lines,
|
||||
'settings' => $settings,
|
||||
'stornorechnung' => $stornorechnung ?: null,
|
||||
'originalrechnung' => $originalrechnung,
|
||||
'bookings' => $bookings,
|
||||
'accounts' => $accounts,
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
|
@ -247,6 +274,30 @@ class InvoiceController
|
|||
$invoice = Invoice::find((int)$params['id']);
|
||||
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']);
|
||||
$settings = Setting::get();
|
||||
|
||||
|
|
@ -314,6 +365,10 @@ class InvoiceController
|
|||
}
|
||||
|
||||
if ($errors) {
|
||||
if (!empty($data['_from_modal'])) {
|
||||
View::redirect("/invoices/{$id}", implode(' ', $errors), 'error');
|
||||
return;
|
||||
}
|
||||
$bookings = Booking::forBeleg($belegId);
|
||||
$accounts = Account::all(['aktiv' => 1]);
|
||||
$view = new View();
|
||||
|
|
@ -334,7 +389,8 @@ class InvoiceController
|
|||
$data['beleg_id'] = $belegId;
|
||||
$data['buchungstyp'] = 'Einnahme';
|
||||
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) {
|
||||
error_log('[Steuerkram] PDO-Fehler in InvoiceController::storeBooking(): ' . $e->getMessage());
|
||||
$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
|
||||
{
|
||||
$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();
|
||||
$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([
|
||||
'invoice_id' => $id,
|
||||
'beleg_nr' => $belegNr,
|
||||
'filename' => $invoice['rechnungs_nr'] . '.pdf',
|
||||
'storage_path' => '',
|
||||
'filename' => $filename,
|
||||
'storage_path' => $storagePath,
|
||||
'mime_type' => 'application/pdf',
|
||||
'file_hash' => hash('sha256', $invoice['rechnungs_nr']),
|
||||
'file_hash' => hash('sha256', $pdf),
|
||||
'beleg_datum' => $invoice['rechnungs_datum'],
|
||||
'rechnungs_nr' => $invoice['rechnungs_nr'],
|
||||
'betrag_brutto' => $invoice['betrag_brutto'],
|
||||
'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(
|
||||
|
|
@ -411,8 +516,12 @@ 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']);
|
||||
$storno = Invoice::find($stornoId);
|
||||
if ($storno) {
|
||||
$this->createBeleg($storno);
|
||||
}
|
||||
View::redirect('/invoices/' . $stornoId, 'Stornorechnung erstellt.');
|
||||
}
|
||||
|
||||
public function addLine(array $params): void
|
||||
|
|
|
|||
|
|
@ -100,11 +100,14 @@ class Beleg
|
|||
$fyId = $fy['id'];
|
||||
}
|
||||
|
||||
$invoiceId = isset($data['invoice_id']) && $data['invoice_id'] ? (int)$data['invoice_id'] : null;
|
||||
|
||||
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)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
[
|
||||
$invoiceId,
|
||||
$data['beleg_nr'],
|
||||
$data['filename'],
|
||||
$data['storage_path'],
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -162,6 +162,39 @@ class Invoice
|
|||
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
|
||||
{
|
||||
$invoice = self::find($id);
|
||||
|
|
@ -181,6 +214,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'],
|
||||
$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
|
||||
{
|
||||
$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,29 @@ 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 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
|
||||
$posRows = '';
|
||||
|
|
@ -127,8 +147,6 @@ class PdfInvoiceService
|
|||
}
|
||||
}
|
||||
|
||||
$payDays = $inv['payment_days'] ?? 14;
|
||||
|
||||
// Empfänger-Land nur wenn nicht DE
|
||||
$land = ($inv['land'] ?? 'DE') !== 'DE' ? '<br>' . $h($inv['land']) : '';
|
||||
|
||||
|
|
@ -153,7 +171,9 @@ 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}{$stornoRefHtml}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
|
|
@ -208,10 +228,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) -->
|
||||
{$zahlungsHinweis}
|
||||
|
||||
</div>
|
||||
HTML;
|
||||
|
|
|
|||
|
|
@ -71,6 +71,14 @@ $suppliers = $suppliers ?? [];
|
|||
<?= $beleg['rechnungs_nr'] ? $h($beleg['rechnungs_nr']) : '<span class="text-muted">–</span>' ?>
|
||||
<?php endif; ?>
|
||||
</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>
|
||||
<span class="text-muted text-sm">Brutto (Buchungen)</span><br>
|
||||
<?= $beleg['betrag_brutto'] ? '<strong>' . $fmt($beleg['betrag_brutto']) . '</strong>' : '<span class="text-muted">–</span>' ?>
|
||||
|
|
|
|||
|
|
@ -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']) && 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'])): ?>
|
||||
<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">
|
||||
|
|
@ -38,12 +50,13 @@ $badgeClass = match($invoice['status']) {
|
|||
<button type="button" class="btn btn-primary"
|
||||
onclick="document.getElementById('send-modal').style.display='flex'">Per E-Mail senden</button>
|
||||
<?php endif; ?>
|
||||
<?php if ($invoice['status'] === 'Gesendet'): ?>
|
||||
<a href="/invoices/<?= $h($invoice['id']) ?>/book" class="btn btn-success">Zahlungseingang erfassen</a>
|
||||
<?php if (!in_array($invoice['status'], ['Entwurf', 'Storniert']) && !empty($invoice['beleg_id'])): ?>
|
||||
<button type="button" class="btn btn-success"
|
||||
onclick="document.getElementById('buchungs-modal').style.display='flex'">Zahlungen</button>
|
||||
<?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 +103,15 @@ $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: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>
|
||||
|
||||
<!-- Meta-Tabelle -->
|
||||
|
|
@ -161,10 +182,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 -->
|
||||
|
|
@ -344,3 +367,131 @@ $badgeClass = match($invoice['status']) {
|
|||
</form>
|
||||
</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">×</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; ?>
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
1-260503
|
||||
1-260505
|
||||
|
|
|
|||
206
tests/27_StornorechnungTest.php
Normal file
206
tests/27_StornorechnungTest.php
Normal 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');
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue