Fix #zahlungsstatus: Rechnungsstatus folgt automatisch den erfassten Zahlungen

This commit is contained in:
fds2610-bot 2026-07-31 20:01:32 +02:00
parent d83d63cc14
commit 934532fac9
14 changed files with 348 additions and 30 deletions

View file

@ -2,6 +2,17 @@
---
## 2026-07-31 – Rechnungsstatus folgt automatisch den erfassten Zahlungen
- Bisher blieb eine Ausgangsrechnung auch dann auf „Gesendet", wenn dem zugehörigen Beleg bereits Zahlungsbuchungen zugeordnet waren – der Status wurde ausschließlich manuell über „Rechnung als bezahlt markieren" gesetzt. Neu leitet sich der Status aus der Summe der zugeordneten Buchungen ab: **Teilzahlung** (weniger als der Rechnungsbetrag), **Bezahlt** (exakt der Rechnungsbetrag) und **Überzahlt** (mehr als der Rechnungsbetrag). Werden alle Buchungen storniert, fällt die Rechnung auf „Gesendet" bzw. „Finalisiert" zurück.
- **`migrations/090_invoice_zahlstatus.sql`** (neu): Erweitert das Status-ENUM um `Teilzahlung` und `Überzahlt`, ergänzt die Spalte `sent_at` (damit beim Zurücksetzen zwischen „Finalisiert" und „Gesendet" unterschieden werden kann) und rechnet den Status vorhandener Rechnungen einmalig aus deren Buchungen nach. Manuell auf „Bezahlt" gesetzte sowie stornierte Rechnungen bleiben unangetastet.
- **`src/Models/Invoice.php`:** Neue Methoden `recalcPaymentStatus()` (Statusableitung inkl. Vorzeichennormalisierung für Stornorechnungen mit negativen Beträgen) und `recalcPaymentStatusForBeleg()`. Entwürfe und stornierte Rechnungen werden nie überschrieben.
- **`src/Models/Booking.php`:** `create()` und `reverse()` stoßen die Neuberechnung nach dem Commit an, ebenso das Löschen einer Buchung im Testmodus (`BookingController::destroy()`). Damit greift die Statusberechnung unabhängig davon, ob über die Rechnung, den Beleg oder die Bankzuordnung gebucht wird. `sumBruttoForBeleg()` kann jetzt auf einen Buchungstyp eingeschränkt werden; `sumZahlungen()` summiert die aktiven Einnahme-Hauptbuchungen einer bereits geladenen Liste.
- **`src/Views/invoices/show.php`, `book.php`:** Das Zahlungs-Modal und die Buchungsansicht zeigen unter den Buchungen jetzt Rechnungsbetrag, gezahlten Betrag und den offenen Rest (bzw. den überzahlten Betrag). Die Betragsvorbelegung im Modal entspricht dem offenen Restbetrag statt immer dem vollen Rechnungsnetto, der USt-Satz wird aus der Rechnung vorbelegt.
- **Statusanzeige** in Rechnungsliste, Detailansicht, Buchungsansicht und Kundenakte um die neuen Status ergänzt (`badge-orange` neu für Teilzahlung, `badge-purple` für Überzahlt), Statusfilter der Rechnungsliste umfasst jetzt alle sieben Status.
- **`src/Controllers/AccountController.php`:** Die Kennzahl „offene Rechnungen" auf dem Dashboard berücksichtigt Teilzahlungen und weist nur noch den tatsächlich offenen Restbetrag aus.
- **`tests/73_InvoiceZahlungsstatusTest.php`** (neu): Deckt Teilzahlung, Vollzahlung, Überzahlung, Stornos und den Rückfall auf „Gesendet"/„Finalisiert" ab.
## 2026-07-31 – XML-Rechnung (EN 16931) als Download in der Rechnungs-Detailansicht
- Neben dem PDF-Symbol steht in der Detailansicht einer Rechnung jetzt ein XML-Symbol, das die Rechnungsdaten als reine EN16931-XML-Datei (`Rechnung-<Nr>.xml` bzw. `Stornorechnung-<Nr>.xml`) zum Download anbietet. Bisher war das EN16931-XML nur in die PDF eingebettet und nicht einzeln zu bekommen.

View file

@ -52,7 +52,9 @@ CRUD für Kundenstammdaten. Kunden werden beim Erstellen von Ausgangsrechnungen
- DIN 5008-konformes Layout, PDF-Export via mPDF
- Rechnungsnummernformat **PPPYYYYMM-nn** (konfigurierbarer Präfix + Jahr + Monat + laufende Nummer, monatlicher Reset)
- Status-Workflow: Entwurf → Finalisiert (gesperrt für Bearbeitung) → Gesendet → Bezahlt / Storniert
- Status-Workflow: Entwurf → Finalisiert (gesperrt für Bearbeitung) → Gesendet → Teilzahlung / Bezahlt / Überzahlt / Storniert
- **Zahlungsstatus automatisch**: Sobald dem Beleg einer Rechnung Zahlungsbuchungen zugeordnet werden, ergibt sich der Status aus der Summe der aktiven Einnahme-Buchungen – weniger als der Rechnungsbetrag → `Teilzahlung`, exakt → `Bezahlt`, mehr → `Überzahlt`. Stornierte Buchungen zählen nicht mit; ohne aktive Buchung fällt die Rechnung auf `Gesendet` (falls bereits versendet, Zeitpunkt in `sent_at`) bzw. `Finalisiert` zurück. Entwürfe und stornierte Rechnungen bleiben unberührt. Der Button „Rechnung als bezahlt markieren" bleibt als manuelle Übersteuerung erhalten (z. B. bei Skonto), wird aber von der nächsten Buchungsänderung wieder überschrieben.
- Das Zahlungs-Modal der Detailansicht und die Buchungsansicht zeigen Rechnungsbetrag, gezahlten Betrag und offenen Rest; der Betragsvorschlag entspricht dem offenen Restbetrag.
- 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
- **XML-Rechnung als Einzeldatei**: In der Detailansicht liefert das XML-Symbol neben dem PDF-Symbol die reine XML-Rechnung (`Rechnung-<Nr>.xml`, Route `/invoices/{id}/xml`). Verwendet wird das Profil **XRechnung 3.0** – eine CIUS von EN 16931, die zusätzlich die deutschen BR-DE-Regeln erfüllt und daher von Prüfdiensten wie dem [E-Rechnungsvalidator](https://erechnungsvalidator.service-bw.de) ohne Beanstandung akzeptiert wird. Fehlen Stamm- oder Rechnungsdaten, wird die Datei trotzdem ausgeliefert; die Detailansicht listet die fehlenden Angaben mit ihrer BT-Nummer auf und färbt das Symbol als Warnung ein.

View file

@ -0,0 +1,32 @@
-- Zahlungsstatus für Ausgangsrechnungen: Teilzahlung / Überzahlt
ALTER TABLE invoices
MODIFY COLUMN status ENUM('Entwurf','Finalisiert','Gesendet','Teilzahlung','Bezahlt','Überzahlt','Storniert')
NOT NULL DEFAULT 'Entwurf';
-- Versandzeitpunkt, damit beim Zurücksetzen des Zahlungsstatus
-- zwischen 'Finalisiert' und 'Gesendet' unterschieden werden kann
ALTER TABLE invoices
ADD COLUMN IF NOT EXISTS sent_at DATETIME NULL DEFAULT NULL AFTER status;
UPDATE invoices
SET sent_at = COALESCE(updated_at, created_at)
WHERE sent_at IS NULL
AND status IN ('Gesendet','Bezahlt');
-- Bestandsdaten: Status aus den bereits zugeordneten Buchungen ableiten.
-- Manuell auf 'Bezahlt' gesetzte sowie stornierte Rechnungen bleiben unangetastet.
UPDATE invoices i
JOIN (SELECT beleg_id, SUM(betrag_brutto) AS gezahlt
FROM bookings
WHERE buchungstyp = 'Einnahme' AND is_reversal = 0
AND parent_booking_id IS NULL AND reversed_by IS NULL
GROUP BY beleg_id) b ON b.beleg_id = i.beleg_id
SET i.status = CASE
WHEN ROUND(i.betrag_brutto - b.gezahlt, 2) = 0 THEN 'Bezahlt'
WHEN (i.betrag_brutto > 0 AND b.gezahlt > i.betrag_brutto)
OR (i.betrag_brutto < 0 AND b.gezahlt < i.betrag_brutto) THEN 'Überzahlt'
WHEN (i.betrag_brutto > 0 AND b.gezahlt > 0)
OR (i.betrag_brutto < 0 AND b.gezahlt < 0) THEN 'Teilzahlung'
ELSE i.status
END
WHERE i.status IN ('Finalisiert','Gesendet');

View file

@ -336,6 +336,7 @@ td { padding: .65rem 1rem; vertical-align: middle; }
.badge-blue { background: #dbeafe; color: #1e40af; }
.badge-gray { background: #f1f5f9; color: #475569; }
.badge-purple { background: #ede9fe; color: #6d28d9; }
.badge-orange { background: #ffedd5; color: #9a3412; }
.kuerzel-badge {
display: inline-block;

View file

@ -78,9 +78,15 @@ class AccountController extends BaseController
$stats['vormonat_label'] = $monthNames[$prevMonth - 1];
// Offene Rechnungen
// Bei Teilzahlungen zählt nur der noch offene Restbetrag
$offen = \Steuerkram\Database::fetchOne(
'SELECT COUNT(*) as cnt, COALESCE(SUM(betrag_brutto),0) as total
FROM invoices WHERE status=\'Gesendet\''
'SELECT COUNT(*) as cnt,
COALESCE(SUM(i.betrag_brutto - COALESCE((
SELECT SUM(bk.betrag_brutto) FROM bookings bk
WHERE bk.beleg_id = i.beleg_id AND bk.buchungstyp = \'Einnahme\'
AND bk.is_reversal = 0 AND bk.parent_booking_id IS NULL AND bk.reversed_by IS NULL
), 0)), 0) as total
FROM invoices i WHERE i.status IN (\'Gesendet\', \'Teilzahlung\')'
);
$stats['offene_rechnungen'] = (int)($offen['cnt'] ?? 0);
$stats['offene_rechnungen_sum'] = (float)($offen['total'] ?? 0);

View file

@ -9,6 +9,7 @@ use Steuerkram\Models\Account;
use Steuerkram\Models\Beleg;
use Steuerkram\Models\BankTransaction;
use Steuerkram\Models\FiscalYear;
use Steuerkram\Models\Invoice;
use Steuerkram\Models\Setting;
use Steuerkram\View;
@ -300,6 +301,9 @@ class BookingController extends BaseController
\Steuerkram\Database::execute('DELETE FROM bookings WHERE parent_booking_id = ?', [$id]);
\Steuerkram\Database::execute('DELETE FROM bookings WHERE id = ?', [$id]);
if (!empty($booking['beleg_id'])) {
Invoice::recalcPaymentStatusForBeleg((int)$booking['beleg_id']);
}
$redirect = !empty($_POST['beleg_id'])
? '/belege/' . (int)$_POST['beleg_id']
: '/journal';

View file

@ -277,7 +277,12 @@ class InvoiceController extends BaseController
if ($invoice['status'] === 'Finalisiert') {
\Steuerkram\Database::execute(
"UPDATE invoices SET status='Gesendet', updated_at=NOW() WHERE id=?",
"UPDATE invoices SET status='Gesendet', sent_at=NOW(), updated_at=NOW() WHERE id=?",
[$id]
);
} else {
\Steuerkram\Database::execute(
'UPDATE invoices SET sent_at=NOW(), updated_at=NOW() WHERE id=? AND sent_at IS NULL',
[$id]
);
}

View file

@ -234,6 +234,8 @@ class Booking
throw $e;
}
Invoice::recalcPaymentStatusForBeleg((int)$data['beleg_id']);
return $mainId;
}
@ -326,6 +328,10 @@ class Booking
throw $e;
}
if (!empty($reversalData['beleg_id'])) {
Invoice::recalcPaymentStatusForBeleg((int)$reversalData['beleg_id']);
}
return $reversalId;
}
@ -431,15 +437,31 @@ class Booking
return sprintf('BU-%s-%06d', $year, $next);
}
/** Summiert betrag_brutto aller aktiven Hauptbuchungen eines Belegs. */
public static function sumBruttoForBeleg(int $belegId): float
/** Summiert betrag_brutto der aktiven Einnahme-Hauptbuchungen einer bereits geladenen Buchungsliste. */
public static function sumZahlungen(array $bookings): float
{
$row = Database::fetchOne(
'SELECT COALESCE(SUM(betrag_brutto), 0) AS total
$sum = 0.0;
foreach ($bookings as $bk) {
if ($bk['parent_booking_id'] !== null) continue;
if ((bool)$bk['is_reversal'] || !empty($bk['reversed_by'])) continue;
if (($bk['buchungstyp'] ?? '') !== Constants::BUCHUNGSTYP_EINNAHME) continue;
$sum += (float)$bk['betrag_brutto'];
}
return round($sum, 2);
}
/** Summiert betrag_brutto aller aktiven Hauptbuchungen eines Belegs, optional nur eines Buchungstyps. */
public static function sumBruttoForBeleg(int $belegId, ?string $buchungstyp = null): float
{
$sql = 'SELECT COALESCE(SUM(betrag_brutto), 0) AS total
FROM bookings
WHERE beleg_id = ? AND is_reversal = 0 AND parent_booking_id IS NULL AND reversed_by IS NULL',
[$belegId]
);
WHERE beleg_id = ? AND is_reversal = 0 AND parent_booking_id IS NULL AND reversed_by IS NULL';
$params = [$belegId];
if ($buchungstyp !== null) {
$sql .= ' AND buchungstyp = ?';
$params[] = $buchungstyp;
}
$row = Database::fetchOne($sql, $params);
return round((float)($row['total'] ?? 0), 2);
}

View file

@ -162,6 +162,65 @@ class Invoice
Database::execute('UPDATE invoices SET status=\'Bezahlt\', updated_at=NOW() WHERE id=?', [$id]);
}
/** Status, die aus den zugeordneten Buchungen automatisch abgeleitet werden. */
public const ZAHL_STATUS = ['Finalisiert', 'Gesendet', 'Teilzahlung', 'Bezahlt', 'Überzahlt'];
/**
* Leitet den Zahlungsstatus einer Rechnung aus der Summe der zugeordneten
* Buchungen ab: keine Zahlung → Finalisiert/Gesendet, zu wenig → Teilzahlung,
* exakt → Bezahlt, zu viel → Überzahlt.
* Entwürfe und stornierte Rechnungen bleiben unangetastet.
*/
public static function recalcPaymentStatus(int $invoiceId): ?string
{
$inv = Database::fetchOne(
'SELECT id, beleg_id, betrag_brutto, status, sent_at FROM invoices WHERE id = ?',
[$invoiceId]
);
if (!$inv) return null;
if (!in_array($inv['status'], self::ZAHL_STATUS, true)) return $inv['status'];
if (empty($inv['beleg_id'])) return $inv['status'];
$soll = round((float)$inv['betrag_brutto'], 2);
$ist = Booking::sumBruttoForBeleg((int)$inv['beleg_id'], 'Einnahme');
// Stornorechnungen haben negative Beträge – Vorzeichen normalisieren
if ($soll < 0) {
$soll = -$soll;
$ist = -$ist;
}
$offen = round($soll - $ist, 2);
if ($ist <= 0.0) {
$status = !empty($inv['sent_at']) ? 'Gesendet' : 'Finalisiert';
} elseif ($offen > 0.0) {
$status = 'Teilzahlung';
} elseif ($offen < 0.0) {
$status = 'Überzahlt';
} else {
$status = 'Bezahlt';
}
if ($status !== $inv['status']) {
Database::execute(
'UPDATE invoices SET status=?, updated_at=NOW() WHERE id=?',
[$status, $invoiceId]
);
}
return $status;
}
/** Wie recalcPaymentStatus(), ausgehend vom Beleg (Aufruf nach jeder Buchungsänderung). */
public static function recalcPaymentStatusForBeleg(int $belegId): void
{
$inv = Database::fetchOne('SELECT id FROM invoices WHERE beleg_id = ? LIMIT 1', [$belegId]);
if ($inv) {
self::recalcPaymentStatus((int)$inv['id']);
}
}
public static function createBookingsFromLines(
int $invoiceId,
int $kategorieKontoId,
@ -220,7 +279,7 @@ class Invoice
if (!$original) {
throw new \RuntimeException('Originalrechnung nicht gefunden.');
}
if (!in_array($original['status'], ['Finalisiert', 'Gesendet', 'Bezahlt'])) {
if (!in_array($original['status'], self::ZAHL_STATUS, true)) {
throw new \RuntimeException('Nur finalisierte, gesendete oder bezahlte Rechnungen können storniert werden.');
}
if (!empty($original['storno_of_id'])) {

View file

@ -55,6 +55,8 @@
'Bezahlt' => 'badge-green',
'Storniert' => 'badge-red',
'Gesendet' => 'badge-yellow',
'Teilzahlung' => 'badge-orange',
'Überzahlt' => 'badge-purple',
default => 'badge-gray',
};
?><span class="badge <?= $bc ?>"><?= $h($inv['status']) ?></span></td>

View file

@ -35,6 +35,18 @@ foreach ($accounts as $acc) {
<span class="text-muted text-sm">Betrag</span><br>
<strong><?= $fmt($invoice['betrag_brutto']) ?></strong>
</div>
<?php
$gezahlt = \Steuerkram\Models\Booking::sumZahlungen($bookings);
$offen = round((float)$invoice['betrag_brutto'] - $gezahlt, 2);
?>
<div>
<span class="text-muted text-sm">Gezahlt</span><br>
<?= $fmt($gezahlt) ?>
</div>
<div>
<span class="text-muted text-sm"><?= $offen < 0 ? 'Überzahlt' : 'Offen' ?></span><br>
<strong><?= $fmt(abs($offen)) ?></strong>
</div>
<div>
<span class="text-muted text-sm">Status</span><br>
<?php
@ -42,6 +54,8 @@ foreach ($accounts as $acc) {
'Bezahlt' => 'badge-green',
'Storniert' => 'badge-red',
'Gesendet' => 'badge-yellow',
'Teilzahlung' => 'badge-orange',
'Überzahlt' => 'badge-purple',
default => 'badge-gray',
};
?>
@ -206,10 +220,13 @@ foreach ($accounts as $acc) {
<?php endif; ?>
<!-- Als bezahlt markieren -->
<?php if ($invoice['status'] === 'Gesendet' && !empty($bookings)): ?>
<?php if (in_array($invoice['status'], ['Gesendet', 'Teilzahlung'], true) && !empty($bookings)): ?>
<div class="card">
<div class="card-body">
<p class="text-muted text-sm mb-2">Alle Zahlungseingänge erfasst? Dann die Rechnung als bezahlt markieren.</p>
<p class="text-muted text-sm mb-2">
Der Status ergibt sich normalerweise automatisch aus den erfassten Zahlungen.
Bei Restnachlass (z.&nbsp;B. Skonto) kann die Rechnung hier manuell als bezahlt markiert werden.
</p>
<form method="post" action="/invoices/<?= $h($invoice['id']) ?>/mark-paid"
data-confirm="Rechnung als bezahlt markieren?">
<button type="submit" class="btn btn-success">Rechnung als bezahlt markieren</button>

View file

@ -7,7 +7,7 @@
value="<?= $h($filter['search'] ?? '') ?>" style="width:240px">
<select name="status">
<option value="">Alle Status</option>
<?php foreach (['Entwurf','Gesendet','Bezahlt','Storniert'] as $s): ?>
<?php foreach (['Entwurf','Finalisiert','Gesendet','Teilzahlung','Bezahlt','Überzahlt','Storniert'] as $s): ?>
<option value="<?= $s ?>"<?= ($filter['status'] ?? '') === $s ? ' selected' : '' ?>><?= $s ?></option>
<?php endforeach; ?>
</select>
@ -46,6 +46,8 @@
'Bezahlt' => 'badge-green',
'Storniert' => 'badge-red',
'Gesendet' => 'badge-yellow',
'Teilzahlung' => 'badge-orange',
'Überzahlt' => 'badge-purple',
default => 'badge-gray',
};
$customerName = !empty($inv['firma']) ? $inv['firma'] : trim(($inv['vorname'] ?? '') . ' ' . $inv['nachname']);

View file

@ -19,6 +19,8 @@ $badgeClass = match($invoice['status']) {
'Storniert' => 'badge-red',
'Gesendet' => 'badge-yellow',
'Finalisiert' => 'badge-blue',
'Teilzahlung' => 'badge-orange',
'Überzahlt' => 'badge-purple',
default => 'badge-gray',
};
?>
@ -474,10 +476,42 @@ $badgeClass = match($invoice['status']) {
<tr><td colspan="7" style="padding:1rem;text-align:center;color:#9ca3af">Noch keine Buchungen.</td></tr>
<?php endif; ?>
</tbody>
<?php
$gezahlt = \Steuerkram\Models\Booking::sumZahlungen($bookings);
$offen = round((float)$invoice['betrag_brutto'] - $gezahlt, 2);
?>
<tfoot>
<tr style="border-top:2px solid #e5e7eb">
<td colspan="5" style="padding:.4rem .5rem;text-align:right">Rechnungsbetrag</td>
<td style="padding:.4rem .5rem;text-align:right;font-family:monospace"><?= $fmt($invoice['betrag_brutto']) ?></td>
<td></td>
</tr>
<tr>
<td colspan="5" style="padding:.4rem .5rem;text-align:right">Gezahlt</td>
<td style="padding:.4rem .5rem;text-align:right;font-family:monospace"><?= $fmt($gezahlt) ?></td>
<td></td>
</tr>
<tr>
<td colspan="5" style="padding:.4rem .5rem;text-align:right;font-weight:600"><?= $offen < 0 ? 'Überzahlt' : 'Offen' ?></td>
<td style="padding:.4rem .5rem;text-align:right;font-family:monospace;font-weight:600"><?= $fmt(abs($offen)) ?></td>
<td></td>
</tr>
</tfoot>
</table>
</div>
<!-- Neue Buchung erfassen -->
<?php
// Vorbelegung: noch offener Restbetrag, umgerechnet auf netto mit dem USt-Satz der Rechnung
$ustQuote = (float)$invoice['betrag_netto'] != 0.0
? (float)$invoice['betrag_ust'] / (float)$invoice['betrag_netto']
: 0.0;
$vorschlagNet = $offen > 0
? round($offen / (1 + $ustQuote), 2)
: abs((float)$invoice['betrag_netto']);
$ustVorgabe = (string)(int)round($ustQuote * 100);
if (!in_array($ustVorgabe, ['0', '7', '19'], true)) $ustVorgabe = '19';
?>
<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">
@ -490,7 +524,7 @@ $badgeClass = match($invoice['status']) {
<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, '.', '')) ?>"
value="<?= $h(number_format(abs($vorschlagNet), 2, '.', '')) ?>"
style="width:100%">
</div>
</div>
@ -519,7 +553,7 @@ $badgeClass = match($invoice['status']) {
<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>
<option value="<?= $s ?>"<?= $s === $ustVorgabe ? ' selected' : '' ?>><?= $s ?> %</option>
<?php endforeach; ?>
</select>
</div>

View file

@ -0,0 +1,121 @@
<?php
declare(strict_types=1);
/**
* Test 73: Zahlungsstatus einer Ausgangsrechnung aus den Buchungen ableiten.
*
* Erwartetes Verhalten: Teilbetrag → 'Teilzahlung', Restbetrag → 'Bezahlt',
* weitere Zahlung → 'Überzahlt', Storno aller Buchungen → zurück auf 'Gesendet'
* bzw. 'Finalisiert'.
*/
class InvoiceZahlungsstatusTest extends TestCase
{
protected string $testName = 'Test 73: Rechnungsstatus aus Zahlungen (Teilzahlung/Bezahlt/Überzahlt)';
public function run(): void
{
$zahlungsId = $this->findAccountId('1200'); // Bank
$kategorieId = $this->findAccountId('8400'); // Erlöse 19 % USt
if (!$zahlungsId || !$kategorieId) {
$this->assert(false, 'Konten 1200/8400 nicht gefunden – SKR03-Seed vorhanden?');
return;
}
$customerId = \Steuerkram\Models\Customer::create([
'nachname' => 'Zahlstatus',
'firma' => 'Zahlstatus GmbH',
'strasse' => 'Teststraße 7',
'plz' => '10115',
'ort' => 'Berlin',
'payment_days' => 14,
]);
// Rechnung über 1000 € netto / 1190 € brutto
$invoiceId = \Steuerkram\Models\Invoice::create([
'customer_id' => $customerId,
'betreff' => 'Testrechnung Zahlungsstatus',
'rechnungs_datum' => date('Y-m-d'),
'lines' => [[
'bezeichnung' => 'Beratungsleistung',
'menge' => 5,
'einheit' => 'Std.',
'einzelpreis' => 200.00,
'ust_satz' => 19.0,
]],
]);
$nr = \Steuerkram\Models\Invoice::finalize($invoiceId);
$invoice = \Steuerkram\Models\Invoice::find($invoiceId);
$this->assertEquals(1190.00, (float)$invoice['betrag_brutto'], 'Rechnungsbetrag 1190 € brutto');
// Beleg anlegen und mit der Rechnung verknüpfen
$belegId = \Steuerkram\Models\Beleg::create([
'beleg_nr' => \Steuerkram\Models\Beleg::generateBelegNr(),
'filename' => $nr . '.pdf',
'storage_path' => '',
'mime_type' => 'application/pdf',
'file_hash' => hash('sha256', $nr),
'beleg_datum' => $invoice['rechnungs_datum'],
'rechnungs_nr' => $nr,
'betrag_brutto' => $invoice['betrag_brutto'],
'belegtyp' => 'Ausgangsrechnung',
]);
\Steuerkram\Database::execute('UPDATE invoices SET beleg_id=? WHERE id=?', [$belegId, $invoiceId]);
$buche = function (float $netto) use ($belegId, $kategorieId, $zahlungsId, $nr): int {
return \Steuerkram\Models\Booking::create([
'beleg_id' => $belegId,
'buchungs_datum' => date('Y-m-d'),
'buchungstyp' => 'Einnahme',
'kategorie_konto_id' => $kategorieId,
'zahlungskonto_id' => $zahlungsId,
'betrag_netto' => $netto,
'ust_satz' => 19.0,
'buchungstext' => 'Zahlung Ausgangsrechnung ' . $nr,
]);
};
$status = fn(): string => (string)\Steuerkram\Database::fetchOne(
'SELECT status FROM invoices WHERE id=?', [$invoiceId]
)['status'];
// 1) Teilzahlung: 400 € netto = 476 € brutto von 1190 €
$teilId = $buche(400.00);
$this->assertEquals('Teilzahlung', $status(), 'Nach Teilbetrag ist der Status Teilzahlung');
// 2) Restzahlung: 600 € netto = 714 € brutto → Summe exakt 1190 €
$restId = $buche(600.00);
$this->assertEquals('Bezahlt', $status(), 'Nach Ausgleich des Restbetrags ist der Status Bezahlt');
// 3) Überzahlung: weitere 100 € netto
$ueberId = $buche(100.00);
$this->assertEquals('Überzahlt', $status(), 'Nach zusätzlicher Zahlung ist der Status Überzahlt');
// 4) Überzahlung stornieren → wieder Bezahlt
\Steuerkram\Models\Booking::reverse($ueberId, 'Testkorrektur');
$this->assertEquals('Bezahlt', $status(), 'Nach Storno der Überzahlung wieder Bezahlt');
// 5) Alle übrigen Buchungen stornieren → zurück auf Finalisiert (nie versendet)
\Steuerkram\Models\Booking::reverse($teilId, 'Testkorrektur');
\Steuerkram\Models\Booking::reverse($restId, 'Testkorrektur');
$this->assertEquals('Finalisiert', $status(), 'Ohne aktive Buchungen zurück auf Finalisiert');
// 6) Nach Versand fällt der Status stattdessen auf Gesendet zurück
\Steuerkram\Database::execute(
"UPDATE invoices SET status='Gesendet', sent_at=NOW() WHERE id=?", [$invoiceId]
);
$letzteId = $buche(1000.00);
$this->assertEquals('Bezahlt', $status(), 'Vollzahlung nach Versand ergibt Bezahlt');
\Steuerkram\Models\Booking::reverse($letzteId, 'Testkorrektur');
$this->assertEquals('Gesendet', $status(), 'Nach Storno mit sent_at zurück auf Gesendet');
// Summenhilfsfunktion prüft nur aktive Einnahme-Hauptbuchungen
$this->assertEquals(
0.0,
\Steuerkram\Models\Booking::sumBruttoForBeleg($belegId, 'Einnahme'),
'Zahlungssumme nach allen Stornos wieder 0 €'
);
}
}