Fix #156: Direkte SQL-Aufrufe aus Controllern in die Modellschicht verschoben
This commit is contained in:
parent
e109f0cb7c
commit
3acd0cea5c
15 changed files with 812 additions and 179 deletions
12
Changes.md
12
Changes.md
|
|
@ -2,6 +2,18 @@
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 2026-08-10 – Direkte SQL-Aufrufe aus Controllern in die Modellschicht verschoben (#156)
|
||||||
|
|
||||||
|
- 34 direkte `Database::`-Aufrufe in `AccountController`, `InvoiceController`, `BookingController`, `UstvaController`, `BankController`, `BelegController` und `CustomerController` verletzten die MVC-Schichtgrenze (`CLAUDE.md`: SQL ausschließlich über `Database::` *innerhalb* der Modelle) und waren dadurch nur über einen vollständigen HTTP-Request testbar. `AssetController.php` bleibt unverändert – die dort verbleibenden vier Aufrufe liegen außerhalb des Umfangs von #155 und sind laut Issue #156 nicht Teil dieses Tickets.
|
||||||
|
- **`src/Models/Booking.php`:** Neue Methoden `dashboardKennzahlen()` (offene Rechnungen, ungebuchte Belege – bislang ungetestete Dashboard-Kennzahlen), `recent(int $limit)`, `journalForAccount(int $accountId, int $year)`, `deleteHard(int $id)` sowie `bucheBewirtungssplit()` (aus `BookingController` verschoben, vgl. Q3 im Issue: Split-Fachlogik gehört zu `Booking`, dort existiert bereits der verwandte Helfer `upsertKorrekturbuchung()`).
|
||||||
|
- **`src/Models/FiscalYear.php`:** Neue Methode `registeredYears()` (Geschäftsjahre aus `fiscal_years`, für das Konten-Journal – die bestehende `availableYears()` liest stattdessen `DISTINCT YEAR()` aus `bookings` und passte inhaltlich nicht).
|
||||||
|
- **`src/Models/Invoice.php`:** Neue Methoden `setBelegId()`, `findStorno()`, `findReference()`, `markSent()`, `deleteLine()`, `forCustomer()` sowie `finalizeWithBeleg()` (bündelt die bisher im Controller offene Transaktion aus Finalisierung + Beleg-Erzeugung; die eigentliche PDF-Erstellung bleibt als Callback beim Aufrufer, da sie Datei-I/O und keine reine Datenbank-Operation ist).
|
||||||
|
- **`src/Models/Beleg.php`:** Neue Methoden `updateBewirtung()`, `updateEigenanteil30()`, `updateBetragBrutto()`, `updateStorage()` sowie `setSupplierId()` (identisch zu `updateSupplier()`, aber bewusst ohne `locked`-Prüfung – wird beim automatischen Vorbelegen des Lieferanten aus einer Bankbuchung verwendet).
|
||||||
|
- **`src/Models/BankTransaction.php`:** Neue Methode `importBatch()` bündelt Batch-Anlage, Zeilen-Import und Transaktionsklammer (vorher offen in `BankController::doImport()`).
|
||||||
|
- **`src/Models/UstvaSubmission.php`** (neu): `all()`, `find()`, `upsert()` für `ustva_submissions`, bisher nur als Inline-SQL in `UstvaController`.
|
||||||
|
- Alle verschobenen SQL-Statements wurden unverändert übernommen; keine Verhaltensänderung außer der bewusst parametrisierten `LIMIT` in `Booking::recent()` (vorher fest `LIMIT 10`, jetzt Parameter mit Default 10) und der Zusammenfassung der Transaktionsklammern in die jeweiligen Modellmethoden.
|
||||||
|
- **`tests/85_ModellAbfragenTest.php`** (neu): Deckt alle oben genannten Modellmethoden anhand bekannter Testdaten ab, Schwerpunkt auf `Booking::dashboardKennzahlen()`. Regressionsschutz am Ende: `grep`-Äquivalent, das außerhalb der dokumentierten `AssetController`-Ausnahme keinen `Database::`-Aufruf mehr in `src/Controllers/` toleriert.
|
||||||
|
|
||||||
## 2026-08-09 – Regressionstest: Reflektiertes XSS über Kontonummer-Freitextfeld (#149)
|
## 2026-08-09 – Regressionstest: Reflektiertes XSS über Kontonummer-Freitextfeld (#149)
|
||||||
|
|
||||||
- `BookingController::store()` schreibt eine nicht auflösbare Freitext-Kontonummer (`kategorie_kontonummer`/`zahlungs_kontonummer`) ungefiltert in `$errors[...]`. Die Ausgabestelle in `src/Views/belege/detail.php:419` escaped Schlüssel und Wert bereits seit Fix #132 (Commit 6489819) konsequent mit `$h()` – der in #149 gemeldete Bug war zum Zeitpunkt der Meldung durch die dortige Änderung bereits geschlossen, nur ohne eigene Testabdeckung für diesen speziellen Eingabepfad.
|
- `BookingController::store()` schreibt eine nicht auflösbare Freitext-Kontonummer (`kategorie_kontonummer`/`zahlungs_kontonummer`) ungefiltert in `$errors[...]`. Die Ausgabestelle in `src/Views/belege/detail.php:419` escaped Schlüssel und Wert bereits seit Fix #132 (Commit 6489819) konsequent mit `$h()` – der in #149 gemeldete Bug war zum Zeitpunkt der Meldung durch die dortige Änderung bereits geschlossen, nur ohne eigene Testabdeckung für diesen speziellen Eingabepfad.
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ namespace Steuerkram\Controllers;
|
||||||
use Steuerkram\Models\Account;
|
use Steuerkram\Models\Account;
|
||||||
use Steuerkram\Models\Booking;
|
use Steuerkram\Models\Booking;
|
||||||
use Steuerkram\Models\Beleg;
|
use Steuerkram\Models\Beleg;
|
||||||
|
use Steuerkram\Models\FiscalYear;
|
||||||
use Steuerkram\Services\EuerCalculator;
|
use Steuerkram\Services\EuerCalculator;
|
||||||
use Steuerkram\View;
|
use Steuerkram\View;
|
||||||
|
|
||||||
|
|
@ -50,37 +51,14 @@ class AccountController extends BaseController
|
||||||
$stats['month_label'] = $monthNames[$month - 1];
|
$stats['month_label'] = $monthNames[$month - 1];
|
||||||
$stats['vormonat_label'] = $monthNames[$prevMonth - 1];
|
$stats['vormonat_label'] = $monthNames[$prevMonth - 1];
|
||||||
|
|
||||||
// Offene Rechnungen
|
// Offene Rechnungen, ungebuchte Belege
|
||||||
// Bei Teilzahlungen zählt nur der noch offene Restbetrag
|
$kennzahlen = Booking::dashboardKennzahlen();
|
||||||
$offen = \Steuerkram\Database::fetchOne(
|
$stats['offene_rechnungen'] = $kennzahlen['offene_rechnungen'];
|
||||||
'SELECT COUNT(*) as cnt,
|
$stats['offene_rechnungen_sum'] = $kennzahlen['offene_rechnungen_sum'];
|
||||||
COALESCE(SUM(i.betrag_brutto - COALESCE((
|
$stats['ungebuchte_belege'] = $kennzahlen['ungebuchte_belege'];
|
||||||
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);
|
|
||||||
|
|
||||||
// Ungebuchte Belege
|
|
||||||
$ungebucht = \Steuerkram\Database::fetchOne(
|
|
||||||
'SELECT COUNT(*) as cnt FROM belege WHERE NOT EXISTS (SELECT 1 FROM bookings WHERE beleg_id = belege.id)'
|
|
||||||
);
|
|
||||||
$stats['ungebuchte_belege'] = (int)($ungebucht['cnt'] ?? 0);
|
|
||||||
|
|
||||||
// Letzte Buchungen
|
// Letzte Buchungen
|
||||||
$recentBookings = \Steuerkram\Database::fetchAll(
|
$recentBookings = Booking::recent(10);
|
||||||
'SELECT bk.*, k.kontonummer as kategorie_nr, k.bezeichnung as kategorie_name,
|
|
||||||
z.kontonummer as zahlungs_nr, z.bezeichnung as zahlungs_name,
|
|
||||||
bl.beleg_nr
|
|
||||||
FROM bookings bk
|
|
||||||
JOIN accounts k ON bk.kategorie_konto_id = k.id
|
|
||||||
JOIN accounts z ON bk.zahlungskonto_id = z.id
|
|
||||||
JOIN belege bl ON bk.beleg_id = bl.id
|
|
||||||
ORDER BY bk.created_at DESC LIMIT 10'
|
|
||||||
);
|
|
||||||
|
|
||||||
$view = new View();
|
$view = new View();
|
||||||
$view->render('layout/base.php', [
|
$view->render('layout/base.php', [
|
||||||
|
|
@ -268,9 +246,7 @@ class AccountController extends BaseController
|
||||||
$account = Account::find($id);
|
$account = Account::find($id);
|
||||||
if (!$account) { http_response_code(404); return; }
|
if (!$account) { http_response_code(404); return; }
|
||||||
|
|
||||||
$availableYears = \Steuerkram\Database::fetchAll(
|
$availableYears = FiscalYear::registeredYears();
|
||||||
'SELECT year FROM fiscal_years ORDER BY year DESC'
|
|
||||||
);
|
|
||||||
$years = array_column($availableYears, 'year');
|
$years = array_column($availableYears, 'year');
|
||||||
if (empty($years)) {
|
if (empty($years)) {
|
||||||
$years = [(int)date('Y')];
|
$years = [(int)date('Y')];
|
||||||
|
|
@ -283,25 +259,7 @@ class AccountController extends BaseController
|
||||||
|
|
||||||
// Buchungen wo dieses Konto Kategorie-Konto ist (alle, auch Kindzeilen)
|
// Buchungen wo dieses Konto Kategorie-Konto ist (alle, auch Kindzeilen)
|
||||||
// ODER Zahlungs-Konto ist (nur Hauptbuchungen, Kindzeilen gehören nicht ans Zahlungskonto)
|
// ODER Zahlungs-Konto ist (nur Hauptbuchungen, Kindzeilen gehören nicht ans Zahlungskonto)
|
||||||
$bookings = \Steuerkram\Database::fetchAll(
|
$bookings = Booking::journalForAccount($id, $year);
|
||||||
'SELECT bk.*,
|
|
||||||
k.kontonummer AS kategorie_nr, k.bezeichnung AS kategorie_name,
|
|
||||||
z.kontonummer AS zahlungs_nr, z.bezeichnung AS zahlungs_name,
|
|
||||||
bl.beleg_nr
|
|
||||||
FROM bookings bk
|
|
||||||
JOIN accounts k ON bk.kategorie_konto_id = k.id
|
|
||||||
JOIN accounts z ON bk.zahlungskonto_id = z.id
|
|
||||||
LEFT JOIN belege bl ON bk.beleg_id = bl.id
|
|
||||||
JOIN fiscal_years fy ON bk.fiscal_year_id = fy.id
|
|
||||||
WHERE fy.year = ?
|
|
||||||
AND (
|
|
||||||
bk.kategorie_konto_id = ?
|
|
||||||
OR (bk.zahlungskonto_id = ? AND bk.parent_booking_id IS NULL
|
|
||||||
AND bk.kategorie_konto_id != ?)
|
|
||||||
)
|
|
||||||
ORDER BY bk.buchungs_datum, bk.id',
|
|
||||||
[$year, $id, $id, $id]
|
|
||||||
);
|
|
||||||
|
|
||||||
$view = new View();
|
$view = new View();
|
||||||
$view->render('layout/base.php', [
|
$view->render('layout/base.php', [
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,6 @@ use Steuerkram\Models\BankProvider;
|
||||||
use Steuerkram\Models\BankSource;
|
use Steuerkram\Models\BankSource;
|
||||||
use Steuerkram\Models\BankTransaction;
|
use Steuerkram\Models\BankTransaction;
|
||||||
use Steuerkram\Controllers\InvoiceController;
|
use Steuerkram\Controllers\InvoiceController;
|
||||||
use Steuerkram\Database;
|
|
||||||
use Steuerkram\Models\Beleg;
|
use Steuerkram\Models\Beleg;
|
||||||
use Steuerkram\Models\Invoice;
|
use Steuerkram\Models\Invoice;
|
||||||
use Steuerkram\Services\BankImportParser;
|
use Steuerkram\Services\BankImportParser;
|
||||||
|
|
@ -202,15 +201,9 @@ class BankController extends BaseController
|
||||||
|
|
||||||
private function doImport(int $sourceId, string $filename, array $rows): void
|
private function doImport(int $sourceId, string $filename, array $rows): void
|
||||||
{
|
{
|
||||||
Database::beginTransaction();
|
|
||||||
try {
|
try {
|
||||||
$batchId = BankTransaction::createBatch($sourceId, $filename, count($rows));
|
$batchId = BankTransaction::importBatch($sourceId, $filename, $rows);
|
||||||
foreach ($rows as $row) {
|
|
||||||
BankTransaction::insertRow($batchId, $row);
|
|
||||||
}
|
|
||||||
Database::commit();
|
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
Database::rollBack();
|
|
||||||
View::redirect('/bank/upload', 'Import fehlgeschlagen: ' . $e->getMessage(), 'error');
|
View::redirect('/bank/upload', 'Import fehlgeschlagen: ' . $e->getMessage(), 'error');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -291,10 +291,7 @@ class BelegController extends BaseController
|
||||||
// Lieferant am Beleg setzen, wenn noch keiner hinterlegt ist
|
// Lieferant am Beleg setzen, wenn noch keiner hinterlegt ist
|
||||||
if (empty($beleg['supplier_name']) && $bankTxSupplier !== '') {
|
if (empty($beleg['supplier_name']) && $bankTxSupplier !== '') {
|
||||||
$supplierId = \Steuerkram\Models\Supplier::findOrCreate($bankTxSupplier);
|
$supplierId = \Steuerkram\Models\Supplier::findOrCreate($bankTxSupplier);
|
||||||
\Steuerkram\Database::execute(
|
Beleg::setSupplierId((int)$params['id'], $supplierId);
|
||||||
'UPDATE belege SET supplier_id=?, updated_at=NOW() WHERE id=?',
|
|
||||||
[$supplierId, (int)$params['id']]
|
|
||||||
);
|
|
||||||
$beleg['supplier_name'] = $bankTxSupplier;
|
$beleg['supplier_name'] = $bankTxSupplier;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -100,7 +100,7 @@ class BookingController extends BaseController
|
||||||
if (!empty($data[$idField])) continue; // Select-Auswahl hat Vorrang
|
if (!empty($data[$idField])) continue; // Select-Auswahl hat Vorrang
|
||||||
$nr = trim($data[$field] ?? '');
|
$nr = trim($data[$field] ?? '');
|
||||||
if ($nr !== '') {
|
if ($nr !== '') {
|
||||||
$acc = \Steuerkram\Database::fetchOne('SELECT id FROM accounts WHERE kontonummer = ?', [$nr]);
|
$acc = Account::findByKontonummer($nr);
|
||||||
if (!$acc) {
|
if (!$acc) {
|
||||||
$errors[$idField] = "Kontonummer '$nr' nicht gefunden.";
|
$errors[$idField] = "Kontonummer '$nr' nicht gefunden.";
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -131,20 +131,14 @@ class BookingController extends BaseController
|
||||||
$data['beleg_id'] = $belegId;
|
$data['beleg_id'] = $belegId;
|
||||||
|
|
||||||
// Bewirtungsfelder am Beleg speichern (gehören zum Beleg, nicht zur Buchung)
|
// Bewirtungsfelder am Beleg speichern (gehören zum Beleg, nicht zur Buchung)
|
||||||
\Steuerkram\Database::execute(
|
Beleg::updateBewirtung(
|
||||||
'UPDATE belege SET bewirtungsgrund=?, teilnehmerliste=?, updated_at=NOW() WHERE id=?',
|
|
||||||
[
|
|
||||||
!empty($data['bewirtungsgrund']) ? $data['bewirtungsgrund'] : null,
|
|
||||||
!empty($data['teilnehmerliste']) ? $data['teilnehmerliste'] : null,
|
|
||||||
$belegId,
|
$belegId,
|
||||||
]
|
!empty($data['bewirtungsgrund']) ? $data['bewirtungsgrund'] : null,
|
||||||
|
!empty($data['teilnehmerliste']) ? $data['teilnehmerliste'] : null
|
||||||
);
|
);
|
||||||
|
|
||||||
// Anlagevermögen: neues Asset anlegen oder bestehendes verknüpfen
|
// Anlagevermögen: neues Asset anlegen oder bestehendes verknüpfen
|
||||||
$konto = \Steuerkram\Database::fetchOne(
|
$konto = Account::find((int)$data['kategorie_konto_id']);
|
||||||
'SELECT id, kontonummer, kontenart FROM accounts WHERE id = ?',
|
|
||||||
[(int)$data['kategorie_konto_id']]
|
|
||||||
);
|
|
||||||
$kontonummer = $konto['kontonummer'] ?? '';
|
$kontonummer = $konto['kontonummer'] ?? '';
|
||||||
|
|
||||||
$newAssetId = null;
|
$newAssetId = null;
|
||||||
|
|
@ -165,15 +159,12 @@ class BookingController extends BaseController
|
||||||
|
|
||||||
// Bewirtungskonto-Split: 4650→4654, 4651→4656, 4652→4657
|
// Bewirtungskonto-Split: 4650→4654, 4651→4656, 4652→4657
|
||||||
if (in_array($kontonummer, ['4650', '4651', '4652'], true)) {
|
if (in_array($kontonummer, ['4650', '4651', '4652'], true)) {
|
||||||
$this->bucheBewirtungssplit($data, $kontonummer);
|
Booking::bucheBewirtungssplit($data, $kontonummer);
|
||||||
} else {
|
} else {
|
||||||
Booking::create($data);
|
Booking::create($data);
|
||||||
|
|
||||||
$eigenanteil = isset($data['eigenanteil_30']) ? 1 : 0;
|
$eigenanteil = isset($data['eigenanteil_30']) ? 1 : 0;
|
||||||
\Steuerkram\Database::execute(
|
Beleg::updateEigenanteil30($belegId, $eigenanteil);
|
||||||
'UPDATE belege SET eigenanteil_30 = ?, updated_at = NOW() WHERE id = ?',
|
|
||||||
[$eigenanteil, $belegId]
|
|
||||||
);
|
|
||||||
|
|
||||||
if ($eigenanteil === 1) {
|
if ($eigenanteil === 1) {
|
||||||
Booking::upsertEigenanteil30(
|
Booking::upsertEigenanteil30(
|
||||||
|
|
@ -187,10 +178,7 @@ class BookingController extends BaseController
|
||||||
|
|
||||||
// Belegbetrag in Metadaten aktualisieren
|
// Belegbetrag in Metadaten aktualisieren
|
||||||
$totalBrutto = Booking::sumBruttoForBeleg($belegId);
|
$totalBrutto = Booking::sumBruttoForBeleg($belegId);
|
||||||
\Steuerkram\Database::execute(
|
Beleg::updateBetragBrutto($belegId, $totalBrutto);
|
||||||
'UPDATE belege SET betrag_brutto = ?, updated_at = NOW() WHERE id = ?',
|
|
||||||
[$totalBrutto, $belegId]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Bankbuchung verknüpfen wenn aus Bank-Kontext
|
// Bankbuchung verknüpfen wenn aus Bank-Kontext
|
||||||
$bankTxId = (int)($data['bank_tx_id'] ?? 0);
|
$bankTxId = (int)($data['bank_tx_id'] ?? 0);
|
||||||
|
|
@ -255,36 +243,6 @@ class BookingController extends BaseController
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function bucheBewirtungssplit(array $data, string $kontonummer): void
|
|
||||||
{
|
|
||||||
// Abzugskonto ermitteln
|
|
||||||
$abzugskontonummer = match($kontonummer) { '4650' => '4654', '4652' => '4657', default => '4656' };
|
|
||||||
$abzugskonto = \Steuerkram\Database::fetchOne(
|
|
||||||
'SELECT id FROM accounts WHERE kontonummer = ?',
|
|
||||||
[$abzugskontonummer]
|
|
||||||
);
|
|
||||||
if (!$abzugskonto) {
|
|
||||||
throw new \RuntimeException("Abzugskonto {$abzugskontonummer} nicht gefunden.");
|
|
||||||
}
|
|
||||||
|
|
||||||
$nettoGesamt = round((float)$data['betrag_netto'], 2);
|
|
||||||
$ustSatz = (float)($data['ust_satz'] ?? 0);
|
|
||||||
|
|
||||||
$netto70 = round($nettoGesamt * 0.7, 2);
|
|
||||||
$netto30 = round($nettoGesamt - $netto70, 2);
|
|
||||||
|
|
||||||
// Buchung 1: 70 % auf Bewirtungskonto (abzugsfähig)
|
|
||||||
Booking::create(array_merge($data, [
|
|
||||||
'betrag_netto' => $netto70,
|
|
||||||
]));
|
|
||||||
|
|
||||||
// Buchung 2: 30 % auf Abzugskonto (nicht abzugsfähig, § 4 Abs. 5 EStG)
|
|
||||||
Booking::create(array_merge($data, [
|
|
||||||
'kategorie_konto_id' => $abzugskonto['id'],
|
|
||||||
'betrag_netto' => $netto30,
|
|
||||||
]));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function destroy(array $params): void
|
public function destroy(array $params): void
|
||||||
{
|
{
|
||||||
if (!Setting::isTestMode()) {
|
if (!Setting::isTestMode()) {
|
||||||
|
|
@ -299,8 +257,7 @@ class BookingController extends BaseController
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
\Steuerkram\Database::execute('DELETE FROM bookings WHERE parent_booking_id = ?', [$id]);
|
Booking::deleteHard($id);
|
||||||
\Steuerkram\Database::execute('DELETE FROM bookings WHERE id = ?', [$id]);
|
|
||||||
if (!empty($booking['beleg_id'])) {
|
if (!empty($booking['beleg_id'])) {
|
||||||
Invoice::recalcPaymentStatusForBeleg((int)$booking['beleg_id']);
|
Invoice::recalcPaymentStatusForBeleg((int)$booking['beleg_id']);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||||
namespace Steuerkram\Controllers;
|
namespace Steuerkram\Controllers;
|
||||||
|
|
||||||
use Steuerkram\Models\Customer;
|
use Steuerkram\Models\Customer;
|
||||||
|
use Steuerkram\Models\Invoice;
|
||||||
use Steuerkram\View;
|
use Steuerkram\View;
|
||||||
|
|
||||||
class CustomerController extends BaseController
|
class CustomerController extends BaseController
|
||||||
|
|
@ -69,10 +70,7 @@ class CustomerController extends BaseController
|
||||||
$customer = Customer::find((int)$params['id']);
|
$customer = Customer::find((int)$params['id']);
|
||||||
if (!$customer) { http_response_code(404); return; }
|
if (!$customer) { http_response_code(404); return; }
|
||||||
|
|
||||||
$invoices = \Steuerkram\Database::fetchAll(
|
$invoices = Invoice::forCustomer((int)$customer['id']);
|
||||||
'SELECT * FROM invoices WHERE customer_id=? ORDER BY rechnungs_datum DESC LIMIT 20',
|
|
||||||
[$customer['id']]
|
|
||||||
);
|
|
||||||
|
|
||||||
$view = new View();
|
$view = new View();
|
||||||
$view->render('layout/base.php', [
|
$view->render('layout/base.php', [
|
||||||
|
|
|
||||||
|
|
@ -111,18 +111,12 @@ class InvoiceController extends BaseController
|
||||||
$settings = Setting::get();
|
$settings = Setting::get();
|
||||||
|
|
||||||
// Querverweis: Gibt es eine Stornorechnung zu dieser Rechnung?
|
// Querverweis: Gibt es eine Stornorechnung zu dieser Rechnung?
|
||||||
$stornorechnung = \Steuerkram\Database::fetchOne(
|
$stornorechnung = Invoice::findStorno((int)$params['id']);
|
||||||
'SELECT id, rechnungs_nr FROM invoices WHERE storno_of_id = ? LIMIT 1',
|
|
||||||
[(int)$params['id']]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Querverweis: Ist dies eine Stornorechnung → Originalrechnung laden
|
// Querverweis: Ist dies eine Stornorechnung → Originalrechnung laden
|
||||||
$originalrechnung = null;
|
$originalrechnung = null;
|
||||||
if (!empty($invoice['storno_of_id'])) {
|
if (!empty($invoice['storno_of_id'])) {
|
||||||
$originalrechnung = \Steuerkram\Database::fetchOne(
|
$originalrechnung = Invoice::findReference((int)$invoice['storno_of_id']);
|
||||||
'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)
|
// Buchungen + Konten für das Zahlungs-Modal laden (nur wenn Beleg vorhanden)
|
||||||
|
|
@ -225,18 +219,12 @@ class InvoiceController extends BaseController
|
||||||
{
|
{
|
||||||
$id = (int)$params['id'];
|
$id = (int)$params['id'];
|
||||||
try {
|
try {
|
||||||
\Steuerkram\Database::beginTransaction();
|
[$nr, $belegNr] = Invoice::finalizeWithBeleg($id, fn(array $invoice): string => self::createBeleg($invoice));
|
||||||
$nr = Invoice::finalize($id);
|
|
||||||
$invoice = Invoice::find($id);
|
|
||||||
$belegNr = self::createBeleg($invoice);
|
|
||||||
\Steuerkram\Database::commit();
|
|
||||||
View::redirect("/invoices/{$id}", "Rechnung {$nr} wurde finalisiert (Beleg {$belegNr}). Bitte Zahlungseingang erfassen.");
|
View::redirect("/invoices/{$id}", "Rechnung {$nr} wurde finalisiert (Beleg {$belegNr}). Bitte Zahlungseingang erfassen.");
|
||||||
} catch (\PDOException $e) {
|
} catch (\PDOException $e) {
|
||||||
\Steuerkram\Database::rollBack();
|
|
||||||
error_log('[Steuerkram] PDO-Fehler in InvoiceController::finalize(): ' . $e->getMessage());
|
error_log('[Steuerkram] PDO-Fehler in InvoiceController::finalize(): ' . $e->getMessage());
|
||||||
View::redirect("/invoices/{$id}", 'Datenbankfehler beim Finalisieren der Rechnung.', 'error');
|
View::redirect("/invoices/{$id}", 'Datenbankfehler beim Finalisieren der Rechnung.', 'error');
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
\Steuerkram\Database::rollBack();
|
|
||||||
View::redirect("/invoices/{$id}", $e->getMessage(), 'error');
|
View::redirect("/invoices/{$id}", $e->getMessage(), 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -275,17 +263,7 @@ class InvoiceController extends BaseController
|
||||||
: null;
|
: null;
|
||||||
(new EmailService())->sendInvoice($invoice, $lines, $settings, $recipients, $customer ?: null);
|
(new EmailService())->sendInvoice($invoice, $lines, $settings, $recipients, $customer ?: null);
|
||||||
|
|
||||||
if ($invoice['status'] === 'Finalisiert') {
|
Invoice::markSent($id, $invoice['status']);
|
||||||
\Steuerkram\Database::execute(
|
|
||||||
"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::redirect("/invoices/{$id}", 'Rechnung erfolgreich gesendet.');
|
View::redirect("/invoices/{$id}", 'Rechnung erfolgreich gesendet.');
|
||||||
} catch (\RuntimeException $e) {
|
} catch (\RuntimeException $e) {
|
||||||
|
|
@ -345,9 +323,11 @@ class InvoiceController extends BaseController
|
||||||
}
|
}
|
||||||
$absFilePath = Beleg::absolutePath(['storage_path' => $relPath]);
|
$absFilePath = Beleg::absolutePath(['storage_path' => $relPath]);
|
||||||
file_put_contents($absFilePath, $pdf);
|
file_put_contents($absFilePath, $pdf);
|
||||||
\Steuerkram\Database::execute(
|
Beleg::updateStorage(
|
||||||
'UPDATE belege SET storage_path=?, filename=?, file_hash=?, updated_at=NOW() WHERE id=?',
|
(int)$invoice['beleg_id'],
|
||||||
[$relPath, ($invoice['rechnungs_nr'] ?? 'Rechnung') . '.pdf', hash('sha256', $pdf), (int)$invoice['beleg_id']]
|
$relPath,
|
||||||
|
($invoice['rechnungs_nr'] ?? 'Rechnung') . '.pdf',
|
||||||
|
hash('sha256', $pdf)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -571,9 +551,7 @@ class InvoiceController extends BaseController
|
||||||
'notes' => (!empty($invoice['storno_of_id']) ? 'Stornorechnung zu Rechnung ' : '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(
|
Invoice::setBelegId($id, $belegId);
|
||||||
'UPDATE invoices SET beleg_id=? WHERE id=?', [$belegId, $id]
|
|
||||||
);
|
|
||||||
|
|
||||||
return $belegNr;
|
return $belegNr;
|
||||||
}
|
}
|
||||||
|
|
@ -613,10 +591,7 @@ class InvoiceController extends BaseController
|
||||||
|
|
||||||
public function deleteLine(array $params): void
|
public function deleteLine(array $params): void
|
||||||
{
|
{
|
||||||
\Steuerkram\Database::execute(
|
Invoice::deleteLine((int)$params['id'], (int)$params['line_id']);
|
||||||
'DELETE FROM invoice_lines WHERE id=? AND invoice_id=?',
|
|
||||||
[(int)$params['line_id'], (int)$params['id']]
|
|
||||||
);
|
|
||||||
View::redirect('/invoices/' . $params['id'] . '/edit', 'Position gelöscht.');
|
View::redirect('/invoices/' . $params['id'] . '/edit', 'Position gelöscht.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ declare(strict_types=1);
|
||||||
namespace Steuerkram\Controllers;
|
namespace Steuerkram\Controllers;
|
||||||
|
|
||||||
use Steuerkram\Models\Setting;
|
use Steuerkram\Models\Setting;
|
||||||
use Steuerkram\Database;
|
use Steuerkram\Models\UstvaSubmission;
|
||||||
use Steuerkram\Services\UstvaCalculator;
|
use Steuerkram\Services\UstvaCalculator;
|
||||||
use Steuerkram\Services\ElsterXmlService;
|
use Steuerkram\Services\ElsterXmlService;
|
||||||
use Steuerkram\View;
|
use Steuerkram\View;
|
||||||
|
|
@ -14,9 +14,7 @@ class UstvaController extends BaseController
|
||||||
{
|
{
|
||||||
public function index(array $params): void
|
public function index(array $params): void
|
||||||
{
|
{
|
||||||
$submissions = Database::fetchAll(
|
$submissions = UstvaSubmission::all();
|
||||||
'SELECT * FROM ustva_submissions ORDER BY year DESC, month DESC'
|
|
||||||
);
|
|
||||||
|
|
||||||
$years = [];
|
$years = [];
|
||||||
for ($y = (int)date('Y'); $y >= 2020; $y--) $years[] = $y;
|
for ($y = (int)date('Y'); $y >= 2020; $y--) $years[] = $y;
|
||||||
|
|
@ -45,9 +43,7 @@ class UstvaController extends BaseController
|
||||||
$kz = $calc->calculate($year, $month);
|
$kz = $calc->calculate($year, $month);
|
||||||
|
|
||||||
// Load saved submission if exists
|
// Load saved submission if exists
|
||||||
$saved = Database::fetchOne(
|
$saved = UstvaSubmission::find($year, $month);
|
||||||
'SELECT * FROM ustva_submissions WHERE year=? AND month=?', [$year, $month]
|
|
||||||
);
|
|
||||||
|
|
||||||
$settings = Setting::get();
|
$settings = Setting::get();
|
||||||
$months = ['Januar','Februar','März','April','Mai','Juni','Juli','August','September','Oktober','November','Dezember'];
|
$months = ['Januar','Februar','März','April','Mai','Juni','Juli','August','September','Oktober','November','Dezember'];
|
||||||
|
|
@ -78,22 +74,7 @@ class UstvaController extends BaseController
|
||||||
$xmlSvc = new ElsterXmlService();
|
$xmlSvc = new ElsterXmlService();
|
||||||
$xml = $xmlSvc->generate($year, $month, $kz);
|
$xml = $xmlSvc->generate($year, $month, $kz);
|
||||||
|
|
||||||
// Upsert
|
UstvaSubmission::upsert($year, $month, $xml, $kz);
|
||||||
$existing = Database::fetchOne(
|
|
||||||
'SELECT id FROM ustva_submissions WHERE year=? AND month=?', [$year, $month]
|
|
||||||
);
|
|
||||||
|
|
||||||
if ($existing) {
|
|
||||||
Database::execute(
|
|
||||||
'UPDATE ustva_submissions SET xml_content=?, field_values=? WHERE year=? AND month=?',
|
|
||||||
[$xml, json_encode($kz, JSON_UNESCAPED_UNICODE), $year, $month]
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
Database::execute(
|
|
||||||
'INSERT INTO ustva_submissions (year, month, xml_content, field_values) VALUES (?,?,?,?)',
|
|
||||||
[$year, $month, $xml, json_encode($kz, JSON_UNESCAPED_UNICODE)]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
View::redirect("/tax/ustva/{$year}/{$month}", 'UStVA gespeichert.');
|
View::redirect("/tax/ustva/{$year}/{$month}", 'UStVA gespeichert.');
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -261,6 +261,23 @@ class BankTransaction
|
||||||
return ['new' => $new, 'dup' => $dup];
|
return ['new' => $new, 'dup' => $dup];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Importiert einen Batch Bank-Zeilen in einer Transaktion; bei Fehlern wird alles zurückgerollt. */
|
||||||
|
public static function importBatch(int $sourceId, string $filename, array $rows): int
|
||||||
|
{
|
||||||
|
Database::beginTransaction();
|
||||||
|
try {
|
||||||
|
$batchId = self::createBatch($sourceId, $filename, count($rows));
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
self::insertRow($batchId, $row);
|
||||||
|
}
|
||||||
|
Database::commit();
|
||||||
|
return $batchId;
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Database::rollBack();
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public static function createBatch(int $sourceId, string $filename, int $rowCount): int
|
public static function createBatch(int $sourceId, string $filename, int $rowCount): int
|
||||||
{
|
{
|
||||||
Database::execute(
|
Database::execute(
|
||||||
|
|
|
||||||
|
|
@ -250,6 +250,47 @@ class Beleg
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Setzt den Lieferanten ohne locked-Prüfung, z.B. beim automatischen Vorbelegen aus einer Bankbuchung. */
|
||||||
|
public static function setSupplierId(int $id, int $supplierId): void
|
||||||
|
{
|
||||||
|
Database::execute(
|
||||||
|
'UPDATE belege SET supplier_id=?, updated_at=NOW() WHERE id=?',
|
||||||
|
[$supplierId, $id]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function updateBewirtung(int $id, ?string $bewirtungsgrund, ?string $teilnehmerliste): void
|
||||||
|
{
|
||||||
|
Database::execute(
|
||||||
|
'UPDATE belege SET bewirtungsgrund=?, teilnehmerliste=?, updated_at=NOW() WHERE id=?',
|
||||||
|
[$bewirtungsgrund, $teilnehmerliste, $id]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function updateEigenanteil30(int $id, int $eigenanteil): void
|
||||||
|
{
|
||||||
|
Database::execute(
|
||||||
|
'UPDATE belege SET eigenanteil_30 = ?, updated_at = NOW() WHERE id = ?',
|
||||||
|
[$eigenanteil, $id]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function updateBetragBrutto(int $id, float $betrag): void
|
||||||
|
{
|
||||||
|
Database::execute(
|
||||||
|
'UPDATE belege SET betrag_brutto = ?, updated_at = NOW() WHERE id = ?',
|
||||||
|
[$betrag, $id]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function updateStorage(int $id, string $storagePath, string $filename, string $hash): void
|
||||||
|
{
|
||||||
|
Database::execute(
|
||||||
|
'UPDATE belege SET storage_path=?, filename=?, file_hash=?, updated_at=NOW() WHERE id=?',
|
||||||
|
[$storagePath, $filename, $hash, $id]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
public static function delete(int $id): bool
|
public static function delete(int $id): bool
|
||||||
{
|
{
|
||||||
$beleg = self::find($id);
|
$beleg = self::find($id);
|
||||||
|
|
|
||||||
|
|
@ -608,4 +608,103 @@ class Booking
|
||||||
[$year]
|
[$year]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Kennzahlen für das Dashboard: offene Rechnungen und ungebuchte Belege. */
|
||||||
|
public static function dashboardKennzahlen(): array
|
||||||
|
{
|
||||||
|
$offen = Database::fetchOne(
|
||||||
|
'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\')'
|
||||||
|
);
|
||||||
|
|
||||||
|
$ungebucht = Database::fetchOne(
|
||||||
|
'SELECT COUNT(*) as cnt FROM belege WHERE NOT EXISTS (SELECT 1 FROM bookings WHERE beleg_id = belege.id)'
|
||||||
|
);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'offene_rechnungen' => (int)($offen['cnt'] ?? 0),
|
||||||
|
'offene_rechnungen_sum' => (float)($offen['total'] ?? 0),
|
||||||
|
'ungebuchte_belege' => (int)($ungebucht['cnt'] ?? 0),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zuletzt angelegte Buchungen für das Dashboard. */
|
||||||
|
public static function recent(int $limit = 10): array
|
||||||
|
{
|
||||||
|
return Database::fetchAll(
|
||||||
|
'SELECT bk.*, k.kontonummer as kategorie_nr, k.bezeichnung as kategorie_name,
|
||||||
|
z.kontonummer as zahlungs_nr, z.bezeichnung as zahlungs_name,
|
||||||
|
bl.beleg_nr
|
||||||
|
FROM bookings bk
|
||||||
|
JOIN accounts k ON bk.kategorie_konto_id = k.id
|
||||||
|
JOIN accounts z ON bk.zahlungskonto_id = z.id
|
||||||
|
JOIN belege bl ON bk.beleg_id = bl.id
|
||||||
|
ORDER BY bk.created_at DESC LIMIT ?',
|
||||||
|
[$limit]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Journal-Buchungen eines Kontos (als Kategorie- oder Zahlungskonto) in einem Geschäftsjahr. */
|
||||||
|
public static function journalForAccount(int $accountId, int $year): array
|
||||||
|
{
|
||||||
|
return Database::fetchAll(
|
||||||
|
'SELECT bk.*,
|
||||||
|
k.kontonummer AS kategorie_nr, k.bezeichnung AS kategorie_name,
|
||||||
|
z.kontonummer AS zahlungs_nr, z.bezeichnung AS zahlungs_name,
|
||||||
|
bl.beleg_nr
|
||||||
|
FROM bookings bk
|
||||||
|
JOIN accounts k ON bk.kategorie_konto_id = k.id
|
||||||
|
JOIN accounts z ON bk.zahlungskonto_id = z.id
|
||||||
|
LEFT JOIN belege bl ON bk.beleg_id = bl.id
|
||||||
|
JOIN fiscal_years fy ON bk.fiscal_year_id = fy.id
|
||||||
|
WHERE fy.year = ?
|
||||||
|
AND (
|
||||||
|
bk.kategorie_konto_id = ?
|
||||||
|
OR (bk.zahlungskonto_id = ? AND bk.parent_booking_id IS NULL
|
||||||
|
AND bk.kategorie_konto_id != ?)
|
||||||
|
)
|
||||||
|
ORDER BY bk.buchungs_datum, bk.id',
|
||||||
|
[$year, $accountId, $accountId, $accountId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Löscht eine Buchung samt Kindbuchungen unwiderruflich (nur Testmodus). */
|
||||||
|
public static function deleteHard(int $id): void
|
||||||
|
{
|
||||||
|
Database::execute('DELETE FROM bookings WHERE parent_booking_id = ?', [$id]);
|
||||||
|
Database::execute('DELETE FROM bookings WHERE id = ?', [$id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bewirtungskonto-Split nach § 4 Abs. 5 EStG: 70 % abzugsfähig auf dem
|
||||||
|
* Bewirtungskonto, 30 % nicht abzugsfähig auf dem zugehörigen Abzugskonto.
|
||||||
|
*/
|
||||||
|
public static function bucheBewirtungssplit(array $data, string $kontonummer): void
|
||||||
|
{
|
||||||
|
$abzugskontonummer = match($kontonummer) { '4650' => '4654', '4652' => '4657', default => '4656' };
|
||||||
|
$abzugskonto = Account::findByKontonummer($abzugskontonummer);
|
||||||
|
if (!$abzugskonto) {
|
||||||
|
throw new \RuntimeException("Abzugskonto {$abzugskontonummer} nicht gefunden.");
|
||||||
|
}
|
||||||
|
|
||||||
|
$nettoGesamt = round((float)$data['betrag_netto'], 2);
|
||||||
|
$netto70 = round($nettoGesamt * 0.7, 2);
|
||||||
|
$netto30 = round($nettoGesamt - $netto70, 2);
|
||||||
|
|
||||||
|
// Buchung 1: 70 % auf Bewirtungskonto (abzugsfähig)
|
||||||
|
self::create(array_merge($data, [
|
||||||
|
'betrag_netto' => $netto70,
|
||||||
|
]));
|
||||||
|
|
||||||
|
// Buchung 2: 30 % auf Abzugskonto (nicht abzugsfähig, § 4 Abs. 5 EStG)
|
||||||
|
self::create(array_merge($data, [
|
||||||
|
'kategorie_konto_id' => $abzugskonto['id'],
|
||||||
|
'betrag_netto' => $netto30,
|
||||||
|
]));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -48,4 +48,10 @@ class FiscalYear
|
||||||
);
|
);
|
||||||
return array_column($years, 'year');
|
return array_column($years, 'year');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Angelegte Geschäftsjahre (fiscal_years-Tabelle), nicht aus Buchungen abgeleitet. */
|
||||||
|
public static function registeredYears(): array
|
||||||
|
{
|
||||||
|
return Database::fetchAll('SELECT year FROM fiscal_years ORDER BY year DESC');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -162,6 +162,83 @@ 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 setBelegId(int $id, int $belegId): void
|
||||||
|
{
|
||||||
|
Database::execute('UPDATE invoices SET beleg_id=? WHERE id=?', [$belegId, $id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stornorechnung zu einer (Original-)Rechnung, falls vorhanden. */
|
||||||
|
public static function findStorno(int $invoiceId): array|false
|
||||||
|
{
|
||||||
|
return Database::fetchOne(
|
||||||
|
'SELECT id, rechnungs_nr FROM invoices WHERE storno_of_id = ? LIMIT 1',
|
||||||
|
[$invoiceId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Kurzreferenz (Nr. + Datum) auf eine andere Rechnung, z.B. die Originalrechnung einer Stornorechnung. */
|
||||||
|
public static function findReference(int $invoiceId): array|false
|
||||||
|
{
|
||||||
|
return Database::fetchOne(
|
||||||
|
'SELECT id, rechnungs_nr, rechnungs_datum FROM invoices WHERE id = ?',
|
||||||
|
[$invoiceId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Setzt den Versandstatus; wiederholter Versand überschreibt sent_at nicht mehr. */
|
||||||
|
public static function markSent(int $id, string $currentStatus): void
|
||||||
|
{
|
||||||
|
if ($currentStatus === 'Finalisiert') {
|
||||||
|
Database::execute(
|
||||||
|
"UPDATE invoices SET status='Gesendet', sent_at=NOW(), updated_at=NOW() WHERE id=?",
|
||||||
|
[$id]
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
Database::execute(
|
||||||
|
'UPDATE invoices SET sent_at=NOW(), updated_at=NOW() WHERE id=? AND sent_at IS NULL',
|
||||||
|
[$id]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function deleteLine(int $invoiceId, int $lineId): void
|
||||||
|
{
|
||||||
|
Database::execute(
|
||||||
|
'DELETE FROM invoice_lines WHERE id=? AND invoice_id=?',
|
||||||
|
[$lineId, $invoiceId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function forCustomer(int $customerId, int $limit = 20): array
|
||||||
|
{
|
||||||
|
return Database::fetchAll(
|
||||||
|
'SELECT * FROM invoices WHERE customer_id=? ORDER BY rechnungs_datum DESC LIMIT ?',
|
||||||
|
[$customerId, $limit]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finalisiert eine Rechnung und erzeugt in derselben Transaktion den
|
||||||
|
* zugehörigen Beleg über den übergebenen Callback (PDF-Erstellung ist
|
||||||
|
* Sache des Aufrufers, nicht der Modellschicht).
|
||||||
|
*
|
||||||
|
* @return array{0: string, 1: string} [Rechnungsnummer, Rückgabewert des Callbacks]
|
||||||
|
*/
|
||||||
|
public static function finalizeWithBeleg(int $id, callable $belegCallback): array
|
||||||
|
{
|
||||||
|
Database::beginTransaction();
|
||||||
|
try {
|
||||||
|
$nr = self::finalize($id);
|
||||||
|
$invoice = self::find($id);
|
||||||
|
$belegNr = $belegCallback($invoice);
|
||||||
|
Database::commit();
|
||||||
|
return [$nr, $belegNr];
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Database::rollBack();
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Status, die aus den zugeordneten Buchungen automatisch abgeleitet werden. */
|
/** Status, die aus den zugeordneten Buchungen automatisch abgeleitet werden. */
|
||||||
public const ZAHL_STATUS = ['Finalisiert', 'Gesendet', 'Teilzahlung', 'Bezahlt', 'Überzahlt'];
|
public const ZAHL_STATUS = ['Finalisiert', 'Gesendet', 'Teilzahlung', 'Bezahlt', 'Überzahlt'];
|
||||||
|
|
||||||
|
|
|
||||||
44
src/Models/UstvaSubmission.php
Normal file
44
src/Models/UstvaSubmission.php
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Steuerkram\Models;
|
||||||
|
|
||||||
|
use Steuerkram\Database;
|
||||||
|
|
||||||
|
class UstvaSubmission
|
||||||
|
{
|
||||||
|
public static function all(): array
|
||||||
|
{
|
||||||
|
return Database::fetchAll('SELECT * FROM ustva_submissions ORDER BY year DESC, month DESC');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function find(int $year, int $month): array|false
|
||||||
|
{
|
||||||
|
return Database::fetchOne(
|
||||||
|
'SELECT * FROM ustva_submissions WHERE year=? AND month=?',
|
||||||
|
[$year, $month]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function upsert(int $year, int $month, string $xml, array $kz): void
|
||||||
|
{
|
||||||
|
$existing = Database::fetchOne(
|
||||||
|
'SELECT id FROM ustva_submissions WHERE year=? AND month=?',
|
||||||
|
[$year, $month]
|
||||||
|
);
|
||||||
|
$fieldValues = json_encode($kz, JSON_UNESCAPED_UNICODE);
|
||||||
|
|
||||||
|
if ($existing) {
|
||||||
|
Database::execute(
|
||||||
|
'UPDATE ustva_submissions SET xml_content=?, field_values=? WHERE year=? AND month=?',
|
||||||
|
[$xml, $fieldValues, $year, $month]
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
Database::execute(
|
||||||
|
'INSERT INTO ustva_submissions (year, month, xml_content, field_values) VALUES (?,?,?,?)',
|
||||||
|
[$year, $month, $xml, $fieldValues]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
478
tests/85_ModellAbfragenTest.php
Normal file
478
tests/85_ModellAbfragenTest.php
Normal file
|
|
@ -0,0 +1,478 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Steuerkram\Database;
|
||||||
|
use Steuerkram\Models\Account;
|
||||||
|
use Steuerkram\Models\BankSource;
|
||||||
|
use Steuerkram\Models\BankTransaction;
|
||||||
|
use Steuerkram\Models\Beleg;
|
||||||
|
use Steuerkram\Models\Booking;
|
||||||
|
use Steuerkram\Models\Customer;
|
||||||
|
use Steuerkram\Models\FiscalYear;
|
||||||
|
use Steuerkram\Models\Invoice;
|
||||||
|
use Steuerkram\Models\Supplier;
|
||||||
|
use Steuerkram\Models\UstvaSubmission;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test 85: Aus Controllern verschobene Modell-Abfragen (Fix #156).
|
||||||
|
*
|
||||||
|
* 54 direkte Database::-Aufrufe in Controllern verletzten die Modellschicht
|
||||||
|
* und waren nur über einen vollständigen HTTP-Request testbar. Dieser Test
|
||||||
|
* deckt die dabei neu entstandenen bzw. konsolidierten Modellmethoden ab –
|
||||||
|
* mit Schwerpunkt auf der bislang völlig ungetesteten Kennzahlenberechnung
|
||||||
|
* Booking::dashboardKennzahlen(). Ein Regressionsschutz am Ende stellt
|
||||||
|
* sicher, dass kein Controller wieder direkt auf Database:: zugreift.
|
||||||
|
*/
|
||||||
|
class ModellAbfragenTest extends TestCase
|
||||||
|
{
|
||||||
|
protected string $testName = 'Test 85: Aus Controllern verschobene Modell-Abfragen (Fix #156)';
|
||||||
|
|
||||||
|
private int $zahlungsId;
|
||||||
|
private int $kategorieId;
|
||||||
|
private int $belegSeq = 0;
|
||||||
|
|
||||||
|
private ?int $offenerBelegId = null;
|
||||||
|
private ?int $recentBookingId = null;
|
||||||
|
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
$this->zahlungsId = (int)$this->findAccountId('1200');
|
||||||
|
$this->kategorieId = (int)$this->findAccountId('8400');
|
||||||
|
|
||||||
|
if (!$this->zahlungsId || !$this->kategorieId) {
|
||||||
|
$this->assert(false, 'Konten 1200/8400 nicht gefunden – SKR03-Seed vorhanden?');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->testDashboardKennzahlen();
|
||||||
|
$this->testRecent();
|
||||||
|
$this->testJournalForAccount();
|
||||||
|
$this->testFiscalYearRegisteredYears();
|
||||||
|
$this->testBewirtungssplit();
|
||||||
|
$this->testDeleteHard();
|
||||||
|
$this->testInvoiceQuerverweise();
|
||||||
|
$this->testInvoiceFinalizeWithBeleg();
|
||||||
|
$this->testBelegUpdates();
|
||||||
|
$this->testUstvaSubmission();
|
||||||
|
$this->testBankImportBatch();
|
||||||
|
$this->testKeineDirektenDatenbankaufrufeInControllern();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function testDashboardKennzahlen(): void
|
||||||
|
{
|
||||||
|
$vorher = Booking::dashboardKennzahlen();
|
||||||
|
|
||||||
|
$customerId = Customer::create([
|
||||||
|
'nachname' => 'Kennzahlen',
|
||||||
|
'firma' => 'Kennzahlen GmbH',
|
||||||
|
'strasse' => 'Teststraße 1',
|
||||||
|
'plz' => '10115',
|
||||||
|
'ort' => 'Berlin',
|
||||||
|
'payment_days' => 14,
|
||||||
|
]);
|
||||||
|
$invoiceId = Invoice::create([
|
||||||
|
'customer_id' => $customerId,
|
||||||
|
'betreff' => 'Testrechnung Dashboard-Kennzahlen',
|
||||||
|
'rechnungs_datum' => date('Y-m-d'),
|
||||||
|
'lines' => [[
|
||||||
|
'bezeichnung' => 'Beratung',
|
||||||
|
'menge' => 1,
|
||||||
|
'einheit' => 'Std.',
|
||||||
|
'einzelpreis' => 500.00,
|
||||||
|
'ust_satz' => 19.0,
|
||||||
|
]],
|
||||||
|
]);
|
||||||
|
Invoice::finalize($invoiceId);
|
||||||
|
$invoice = Invoice::find($invoiceId);
|
||||||
|
|
||||||
|
$belegId = $this->newBeleg('OFFEN', $invoice['rechnungs_datum'], $invoice['rechnungs_nr'], (float)$invoice['betrag_brutto']);
|
||||||
|
Invoice::setBelegId($invoiceId, $belegId);
|
||||||
|
Invoice::markSent($invoiceId, 'Finalisiert');
|
||||||
|
|
||||||
|
// Zweiter, völlig unabhängiger Beleg ohne jede Buchung.
|
||||||
|
$ungebuchterBelegId = $this->newBeleg('UNGEBUCHT', date('Y-m-d'), null, 42.00);
|
||||||
|
|
||||||
|
$nachher = Booking::dashboardKennzahlen();
|
||||||
|
|
||||||
|
$this->assertEquals(
|
||||||
|
$vorher['offene_rechnungen'] + 1,
|
||||||
|
$nachher['offene_rechnungen'],
|
||||||
|
'dashboardKennzahlen(): offene_rechnungen zählt die neue unbezahlte Rechnung'
|
||||||
|
);
|
||||||
|
$this->assertEquals(
|
||||||
|
round($vorher['offene_rechnungen_sum'] + (float)$invoice['betrag_brutto'], 2),
|
||||||
|
round($nachher['offene_rechnungen_sum'], 2),
|
||||||
|
'dashboardKennzahlen(): offene_rechnungen_sum enthält den vollen Rechnungsbetrag (keine Zahlung erfasst)'
|
||||||
|
);
|
||||||
|
$this->assertEquals(
|
||||||
|
$vorher['ungebuchte_belege'] + 2,
|
||||||
|
$nachher['ungebuchte_belege'],
|
||||||
|
'dashboardKennzahlen(): ungebuchte_belege zählt Rechnungs- und Einzelbeleg (beide ohne Buchung)'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Für nachfolgende Tests weiterreichen.
|
||||||
|
$this->offenerBelegId = $belegId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function testRecent(): void
|
||||||
|
{
|
||||||
|
// ust_satz 0, damit Booking::create() keine zusätzliche USt-Kindbuchung
|
||||||
|
// anlegt – sonst wäre nicht eindeutig, welche der beiden Zeilen jünger ist.
|
||||||
|
$bookingId = Booking::create([
|
||||||
|
'beleg_id' => $this->offenerBelegId,
|
||||||
|
'buchungs_datum' => date('Y-m-d'),
|
||||||
|
'buchungstyp' => 'Einnahme',
|
||||||
|
'kategorie_konto_id' => $this->kategorieId,
|
||||||
|
'zahlungskonto_id' => $this->zahlungsId,
|
||||||
|
'betrag_netto' => 100.00,
|
||||||
|
'ust_satz' => 0,
|
||||||
|
'buchungstext' => 'Test recent() Buchung',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$recentOne = Booking::recent(1);
|
||||||
|
$this->assertEquals(1, count($recentOne), 'recent(1) liefert genau einen Datensatz');
|
||||||
|
$this->assertEquals($bookingId, (int)$recentOne[0]['id'], 'recent(1) liefert die zuletzt angelegte Buchung');
|
||||||
|
$this->assertNotEmpty($recentOne[0]['kategorie_nr'] ?? null, 'recent() liefert die Kategorie-Kontonummer mit');
|
||||||
|
$this->assertNotEmpty($recentOne[0]['beleg_nr'] ?? null, 'recent() liefert die Belegnummer mit');
|
||||||
|
|
||||||
|
$recentMany = Booking::recent(50);
|
||||||
|
$ids = array_column($recentMany, 'id');
|
||||||
|
$this->assert(in_array($bookingId, $ids, true), 'recent(50) enthält die soeben angelegte Buchung');
|
||||||
|
|
||||||
|
$this->recentBookingId = $bookingId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function testJournalForAccount(): void
|
||||||
|
{
|
||||||
|
$year = (int)date('Y');
|
||||||
|
|
||||||
|
$rows = Booking::journalForAccount($this->kategorieId, $year);
|
||||||
|
$ids = array_column($rows, 'id');
|
||||||
|
$this->assert(
|
||||||
|
in_array($this->recentBookingId, $ids, true),
|
||||||
|
'journalForAccount() findet die Buchung über das Kategorie-Konto'
|
||||||
|
);
|
||||||
|
|
||||||
|
$rowsZahlung = Booking::journalForAccount($this->zahlungsId, $year);
|
||||||
|
$idsZahlung = array_column($rowsZahlung, 'id');
|
||||||
|
$this->assert(
|
||||||
|
in_array($this->recentBookingId, $idsZahlung, true),
|
||||||
|
'journalForAccount() findet dieselbe Buchung auch über das Zahlungskonto'
|
||||||
|
);
|
||||||
|
|
||||||
|
$rowsAndererJahr = Booking::journalForAccount($this->kategorieId, $year - 50);
|
||||||
|
$idsAndererJahr = array_column($rowsAndererJahr, 'id');
|
||||||
|
$this->assert(
|
||||||
|
!in_array($this->recentBookingId, $idsAndererJahr, true),
|
||||||
|
'journalForAccount() filtert korrekt nach Geschäftsjahr'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function testFiscalYearRegisteredYears(): void
|
||||||
|
{
|
||||||
|
$exotischesJahr = 2091;
|
||||||
|
FiscalYear::findOrCreateByYear($exotischesJahr);
|
||||||
|
|
||||||
|
$jahre = array_column(FiscalYear::registeredYears(), 'year');
|
||||||
|
$this->assert(
|
||||||
|
in_array($exotischesJahr, $jahre, true),
|
||||||
|
'registeredYears() liefert ein frisch angelegtes Geschäftsjahr'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function testBewirtungssplit(): void
|
||||||
|
{
|
||||||
|
$belegId = $this->newBeleg('BEWIRTUNG', date('Y-m-d'), null, 119.00);
|
||||||
|
|
||||||
|
$data = [
|
||||||
|
'beleg_id' => $belegId,
|
||||||
|
'buchungs_datum' => date('Y-m-d'),
|
||||||
|
'buchungstyp' => 'Ausgabe',
|
||||||
|
'kategorie_konto_id' => (int)$this->findAccountId('4650'),
|
||||||
|
'zahlungskonto_id' => $this->zahlungsId,
|
||||||
|
'betrag_netto' => 100.00,
|
||||||
|
'ust_satz' => 19.0,
|
||||||
|
'buchungstext' => 'Testessen Bewirtung',
|
||||||
|
];
|
||||||
|
|
||||||
|
Booking::bucheBewirtungssplit($data, '4650');
|
||||||
|
|
||||||
|
$hauptbuchungen = array_values(array_filter(
|
||||||
|
Booking::forBeleg($belegId),
|
||||||
|
fn(array $b): bool => $b['parent_booking_id'] === null
|
||||||
|
));
|
||||||
|
$this->assertEquals(2, count($hauptbuchungen), 'bucheBewirtungssplit() legt zwei Hauptbuchungen an');
|
||||||
|
|
||||||
|
$konto4650Id = (int)$this->findAccountId('4650');
|
||||||
|
$konto4654Id = (int)$this->findAccountId('4654');
|
||||||
|
|
||||||
|
$b70 = null;
|
||||||
|
$b30 = null;
|
||||||
|
foreach ($hauptbuchungen as $b) {
|
||||||
|
if ((int)$b['kategorie_konto_id'] === $konto4650Id) $b70 = $b;
|
||||||
|
if ((int)$b['kategorie_konto_id'] === $konto4654Id) $b30 = $b;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->assertNotEmpty($b70, '70 %-Buchung liegt auf dem Bewirtungskonto 4650');
|
||||||
|
$this->assertNotEmpty($b30, '30 %-Buchung liegt auf dem Abzugskonto 4654');
|
||||||
|
$this->assertEquals(70.00, (float)($b70['betrag_netto'] ?? 0), '70 % Netto auf dem Bewirtungskonto');
|
||||||
|
$this->assertEquals(30.00, (float)($b30['betrag_netto'] ?? 0), '30 % Netto auf dem Abzugskonto');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function testDeleteHard(): void
|
||||||
|
{
|
||||||
|
$belegId = $this->newBeleg('DELETEHARD', date('Y-m-d'), null, 119.00);
|
||||||
|
|
||||||
|
$bookingId = Booking::create([
|
||||||
|
'beleg_id' => $belegId,
|
||||||
|
'buchungs_datum' => date('Y-m-d'),
|
||||||
|
'buchungstyp' => 'Einnahme',
|
||||||
|
'kategorie_konto_id' => $this->kategorieId,
|
||||||
|
'zahlungskonto_id' => $this->zahlungsId,
|
||||||
|
'betrag_netto' => 100.00,
|
||||||
|
'ust_satz' => 19.0,
|
||||||
|
'buchungstext' => 'Test deleteHard() Buchung',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 19 % USt erzeugt automatisch eine Kindbuchung (USt-Zeile).
|
||||||
|
$kinder = Database::fetchAll('SELECT id FROM bookings WHERE parent_booking_id = ?', [$bookingId]);
|
||||||
|
$this->assertGreaterThan(0, count($kinder), 'Voraussetzung: Hauptbuchung hat eine Kindbuchung');
|
||||||
|
|
||||||
|
Booking::deleteHard($bookingId);
|
||||||
|
|
||||||
|
$this->assert(Booking::find($bookingId) === false, 'deleteHard() entfernt die Hauptbuchung');
|
||||||
|
$verbleibendeKinder = Database::fetchAll('SELECT id FROM bookings WHERE parent_booking_id = ?', [$bookingId]);
|
||||||
|
$this->assertEquals(0, count($verbleibendeKinder), 'deleteHard() entfernt auch die Kindbuchung');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function testInvoiceQuerverweise(): void
|
||||||
|
{
|
||||||
|
$customerId = Customer::create([
|
||||||
|
'nachname' => 'Querverweis',
|
||||||
|
'firma' => 'Querverweis GmbH',
|
||||||
|
'strasse' => 'Teststraße 2',
|
||||||
|
'plz' => '10115',
|
||||||
|
'ort' => 'Berlin',
|
||||||
|
'payment_days' => 14,
|
||||||
|
]);
|
||||||
|
$invoiceId = Invoice::create([
|
||||||
|
'customer_id' => $customerId,
|
||||||
|
'betreff' => 'Testrechnung Querverweis',
|
||||||
|
'rechnungs_datum' => date('Y-m-d'),
|
||||||
|
'lines' => [[
|
||||||
|
'bezeichnung' => 'Beratung',
|
||||||
|
'menge' => 1,
|
||||||
|
'einheit' => 'Std.',
|
||||||
|
'einzelpreis' => 200.00,
|
||||||
|
'ust_satz' => 19.0,
|
||||||
|
]],
|
||||||
|
]);
|
||||||
|
Invoice::finalize($invoiceId);
|
||||||
|
$invoice = Invoice::find($invoiceId);
|
||||||
|
$belegId = $this->newBeleg('QUERVERWEIS', $invoice['rechnungs_datum'], $invoice['rechnungs_nr'], (float)$invoice['betrag_brutto']);
|
||||||
|
Invoice::setBelegId($invoiceId, $belegId);
|
||||||
|
|
||||||
|
$stornoId = Invoice::createCancellation($invoiceId);
|
||||||
|
|
||||||
|
$storno = Invoice::findStorno($invoiceId);
|
||||||
|
$this->assertEquals($stornoId, (int)($storno['id'] ?? 0), 'findStorno() liefert die Stornorechnung zur Originalrechnung');
|
||||||
|
|
||||||
|
$ref = Invoice::findReference($invoiceId);
|
||||||
|
$this->assertEquals($invoice['rechnungs_nr'], $ref['rechnungs_nr'] ?? null, 'findReference() liefert die Rechnungsnummer');
|
||||||
|
$this->assertEquals($invoice['rechnungs_datum'], $ref['rechnungs_datum'] ?? null, 'findReference() liefert das Rechnungsdatum');
|
||||||
|
|
||||||
|
// deleteLine()
|
||||||
|
$lineInvoiceId = Invoice::create([
|
||||||
|
'customer_id' => $customerId,
|
||||||
|
'betreff' => 'Testrechnung mit zwei Positionen',
|
||||||
|
'rechnungs_datum' => date('Y-m-d'),
|
||||||
|
'lines' => [
|
||||||
|
['bezeichnung' => 'Position A', 'menge' => 1, 'einheit' => 'Stk.', 'einzelpreis' => 10.00, 'ust_satz' => 19.0],
|
||||||
|
['bezeichnung' => 'Position B', 'menge' => 1, 'einheit' => 'Stk.', 'einzelpreis' => 20.00, 'ust_satz' => 19.0],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
$lines = Invoice::lines($lineInvoiceId);
|
||||||
|
$this->assertEquals(2, count($lines), 'Voraussetzung: Rechnung hat zwei Positionen');
|
||||||
|
Invoice::deleteLine($lineInvoiceId, (int)$lines[0]['id']);
|
||||||
|
$this->assertEquals(1, count(Invoice::lines($lineInvoiceId)), 'deleteLine() entfernt genau eine Position');
|
||||||
|
|
||||||
|
// forCustomer()
|
||||||
|
$kundenRechnungen = array_column(Invoice::forCustomer($customerId), 'id');
|
||||||
|
$this->assert(in_array($invoiceId, $kundenRechnungen, true), 'forCustomer() liefert die Rechnungen des Kunden');
|
||||||
|
|
||||||
|
// markSent()
|
||||||
|
$markInvoiceId = Invoice::create([
|
||||||
|
'customer_id' => $customerId,
|
||||||
|
'betreff' => 'Testrechnung markSent',
|
||||||
|
'rechnungs_datum' => date('Y-m-d'),
|
||||||
|
'lines' => [['bezeichnung' => 'Beratung', 'menge' => 1, 'einheit' => 'Std.', 'einzelpreis' => 50.00, 'ust_satz' => 19.0]],
|
||||||
|
]);
|
||||||
|
Invoice::finalize($markInvoiceId);
|
||||||
|
Invoice::markSent($markInvoiceId, 'Finalisiert');
|
||||||
|
$markInvoice = Invoice::find($markInvoiceId);
|
||||||
|
$this->assertEquals('Gesendet', $markInvoice['status'], 'markSent() setzt den Status bei der ersten Sendung auf Gesendet');
|
||||||
|
$this->assertNotEmpty($markInvoice['sent_at'], 'markSent() setzt sent_at');
|
||||||
|
|
||||||
|
$sentAtErsteSendung = $markInvoice['sent_at'];
|
||||||
|
Invoice::markSent($markInvoiceId, 'Gesendet');
|
||||||
|
$markInvoiceErneut = Invoice::find($markInvoiceId);
|
||||||
|
$this->assertEquals('Gesendet', $markInvoiceErneut['status'], 'markSent() ändert den Status beim erneuten Versand nicht');
|
||||||
|
$this->assertEquals($sentAtErsteSendung, $markInvoiceErneut['sent_at'], 'markSent() überschreibt sent_at beim erneuten Versand nicht');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function testInvoiceFinalizeWithBeleg(): void
|
||||||
|
{
|
||||||
|
$customerId = Customer::create([
|
||||||
|
'nachname' => 'Finalisierung',
|
||||||
|
'firma' => 'Finalisierung GmbH',
|
||||||
|
'strasse' => 'Teststraße 3',
|
||||||
|
'plz' => '10115',
|
||||||
|
'ort' => 'Berlin',
|
||||||
|
'payment_days' => 14,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$invoiceId = Invoice::create([
|
||||||
|
'customer_id' => $customerId,
|
||||||
|
'betreff' => 'Testrechnung finalizeWithBeleg',
|
||||||
|
'rechnungs_datum' => date('Y-m-d'),
|
||||||
|
'lines' => [['bezeichnung' => 'Beratung', 'menge' => 1, 'einheit' => 'Std.', 'einzelpreis' => 80.00, 'ust_satz' => 19.0]],
|
||||||
|
]);
|
||||||
|
|
||||||
|
[$nr, $belegNr] = Invoice::finalizeWithBeleg($invoiceId, function (array $invoice): string {
|
||||||
|
return 'TESTBELEG-' . $invoice['id'];
|
||||||
|
});
|
||||||
|
|
||||||
|
$this->assertNotEmpty($nr, 'finalizeWithBeleg() liefert die vergebene Rechnungsnummer');
|
||||||
|
$this->assertEquals('TESTBELEG-' . $invoiceId, $belegNr, 'finalizeWithBeleg() liefert den Rückgabewert des Beleg-Callbacks');
|
||||||
|
$this->assertEquals('Finalisiert', Invoice::find($invoiceId)['status'], 'Rechnung ist nach finalizeWithBeleg() finalisiert');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function testBelegUpdates(): void
|
||||||
|
{
|
||||||
|
$belegId = $this->newBeleg('UPDATES', date('Y-m-d'), null, 50.00);
|
||||||
|
|
||||||
|
Beleg::updateBewirtung($belegId, 'Geschäftsessen', 'Person A, Person B');
|
||||||
|
$beleg = Beleg::find($belegId);
|
||||||
|
$this->assertEquals('Geschäftsessen', $beleg['bewirtungsgrund'], 'updateBewirtung() setzt bewirtungsgrund');
|
||||||
|
$this->assertEquals('Person A, Person B', $beleg['teilnehmerliste'], 'updateBewirtung() setzt teilnehmerliste');
|
||||||
|
|
||||||
|
Beleg::updateEigenanteil30($belegId, 1);
|
||||||
|
$this->assertEquals(1, (int)Beleg::find($belegId)['eigenanteil_30'], 'updateEigenanteil30() setzt das Flag');
|
||||||
|
|
||||||
|
// Beleg::find() überschreibt betrag_brutto mit einer SUM-Subquery über
|
||||||
|
// gebuchte Zahlungen (0 ohne Buchungen) – die rohe Spalte daher direkt prüfen.
|
||||||
|
Beleg::updateBetragBrutto($belegId, 123.45);
|
||||||
|
$rohwert = Database::fetchOne('SELECT betrag_brutto FROM belege WHERE id = ?', [$belegId]);
|
||||||
|
$this->assertEquals(123.45, (float)($rohwert['betrag_brutto'] ?? 0), 'updateBetragBrutto() aktualisiert den Betrag');
|
||||||
|
|
||||||
|
$hash = hash('sha256', 'test-inhalt-' . $belegId);
|
||||||
|
Beleg::updateStorage($belegId, '2026/TEST/original.pdf', 'Testdatei.pdf', $hash);
|
||||||
|
$belegNachUpdate = Beleg::find($belegId);
|
||||||
|
$this->assertEquals('2026/TEST/original.pdf', $belegNachUpdate['storage_path'], 'updateStorage() aktualisiert storage_path');
|
||||||
|
$this->assertEquals('Testdatei.pdf', $belegNachUpdate['filename'], 'updateStorage() aktualisiert filename');
|
||||||
|
$this->assertEquals($hash, $belegNachUpdate['file_hash'], 'updateStorage() aktualisiert file_hash');
|
||||||
|
|
||||||
|
$supplierId = Supplier::findOrCreate('Testlieferant Modellabfragen');
|
||||||
|
Beleg::setSupplierId($belegId, $supplierId);
|
||||||
|
$this->assertEquals($supplierId, (int)Beleg::find($belegId)['supplier_id'], 'setSupplierId() setzt den Lieferanten ohne locked-Prüfung');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function testUstvaSubmission(): void
|
||||||
|
{
|
||||||
|
$year = 2077;
|
||||||
|
$month = 6;
|
||||||
|
|
||||||
|
$this->assert(UstvaSubmission::find($year, $month) === false, 'Vor dem Speichern existiert noch keine Übermittlung');
|
||||||
|
|
||||||
|
UstvaSubmission::upsert($year, $month, '<xml>erster-stand</xml>', ['81' => ['wert' => 100]]);
|
||||||
|
$ersterStand = UstvaSubmission::find($year, $month);
|
||||||
|
$this->assertEquals('<xml>erster-stand</xml>', $ersterStand['xml_content'], 'upsert() legt beim ersten Aufruf einen Datensatz an');
|
||||||
|
|
||||||
|
UstvaSubmission::upsert($year, $month, '<xml>zweiter-stand</xml>', ['81' => ['wert' => 200]]);
|
||||||
|
$zweiterStand = UstvaSubmission::find($year, $month);
|
||||||
|
$this->assertEquals('<xml>zweiter-stand</xml>', $zweiterStand['xml_content'], 'upsert() überschreibt beim zweiten Aufruf den Inhalt');
|
||||||
|
$this->assertEquals((int)$ersterStand['id'], (int)$zweiterStand['id'], 'upsert() legt beim zweiten Aufruf keinen zweiten Datensatz an');
|
||||||
|
|
||||||
|
$alleIds = array_column(UstvaSubmission::all(), 'id');
|
||||||
|
$this->assert(in_array((int)$zweiterStand['id'], $alleIds, true), 'all() enthält die gespeicherte Übermittlung');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function testBankImportBatch(): void
|
||||||
|
{
|
||||||
|
$sourceId = BankSource::create([
|
||||||
|
'type' => 'csv',
|
||||||
|
'bezeichnung' => 'Test Modellabfragen CSV',
|
||||||
|
'kuerzel' => 'TMA',
|
||||||
|
'farbe' => '#123456',
|
||||||
|
'skip_rows' => 0,
|
||||||
|
'has_header_row' => 1,
|
||||||
|
'delimiter' => ';',
|
||||||
|
'enclosure' => '"',
|
||||||
|
'col_buchungstag' => 0,
|
||||||
|
'col_wertstellung' => 1,
|
||||||
|
'col_vorgang' => 2,
|
||||||
|
'col_buchungstext' => 3,
|
||||||
|
'col_wert' => 4,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$rows = [
|
||||||
|
['buchungstag' => date('Y-m-d'), 'wertstellung' => date('Y-m-d'), 'vorgang' => 'Gutschrift', 'buchungstext' => 'Modellabfragen Testzeile 1', 'wert' => '10.00'],
|
||||||
|
['buchungstag' => date('Y-m-d'), 'wertstellung' => date('Y-m-d'), 'vorgang' => 'Gutschrift', 'buchungstext' => 'Modellabfragen Testzeile 2', 'wert' => '20.00'],
|
||||||
|
];
|
||||||
|
|
||||||
|
$batchId = BankTransaction::importBatch($sourceId, 'modellabfragen-test.csv', $rows);
|
||||||
|
$this->assertGreaterThan(0, $batchId, 'importBatch() legt einen Import-Batch an');
|
||||||
|
|
||||||
|
$importiert = Database::fetchAll('SELECT * FROM bank_transactions WHERE batch_id = ? ORDER BY id', [$batchId]);
|
||||||
|
$this->assertEquals(2, count($importiert), 'importBatch() legt beide Zeilen im selben Batch an');
|
||||||
|
$this->assertEquals('Modellabfragen Testzeile 1', $importiert[0]['buchungstext'] ?? null, 'Erste Zeile wird unverändert übernommen');
|
||||||
|
$this->assertEquals('20.00', $importiert[1]['wert'] ?? null, 'Zweite Zeile wird unverändert übernommen');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regressionsschutz: kein Controller darf wieder direkt auf Database:: zugreifen.
|
||||||
|
*
|
||||||
|
* Ausnahme AssetController.php: dort verbleiben vier Aufrufe außerhalb des
|
||||||
|
* AfA-Umfangs von Fix #155 (Anlage-Verknüpfung einer Buchung, vorheriges
|
||||||
|
* AfA-Konto, harte Löschung im Testmodus) – laut Issue #156 nicht Teil
|
||||||
|
* dieses Tickets.
|
||||||
|
*/
|
||||||
|
private function testKeineDirektenDatenbankaufrufeInControllern(): void
|
||||||
|
{
|
||||||
|
$verzeichnis = dirname(__DIR__) . '/src/Controllers';
|
||||||
|
$ausnahmen = ['AssetController.php'];
|
||||||
|
|
||||||
|
$treffer = [];
|
||||||
|
foreach (glob($verzeichnis . '/*.php') ?: [] as $datei) {
|
||||||
|
if (in_array(basename($datei), $ausnahmen, true)) continue;
|
||||||
|
$inhalt = file_get_contents($datei);
|
||||||
|
if ($inhalt !== false && str_contains($inhalt, 'Database::')) {
|
||||||
|
$treffer[] = basename($datei);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->assert(
|
||||||
|
empty($treffer),
|
||||||
|
empty($treffer)
|
||||||
|
? 'Kein Controller (außer den dokumentierten Ausnahmen) ruft Database:: direkt auf'
|
||||||
|
: 'Unerwartete Database::-Aufrufe in: ' . implode(', ', $treffer)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function newBeleg(string $suffix, string $belegDatum, ?string $rechnungsNr, float $betragBrutto): int
|
||||||
|
{
|
||||||
|
$this->belegSeq++;
|
||||||
|
return Beleg::create([
|
||||||
|
'beleg_nr' => Beleg::generateBelegNr(),
|
||||||
|
'filename' => "modellabfragen-{$suffix}-{$this->belegSeq}.pdf",
|
||||||
|
'storage_path' => '',
|
||||||
|
'mime_type' => 'application/pdf',
|
||||||
|
'file_hash' => hash('sha256', $suffix . $this->belegSeq),
|
||||||
|
'beleg_datum' => $belegDatum,
|
||||||
|
'rechnungs_nr' => $rechnungsNr,
|
||||||
|
'betrag_brutto' => $betragBrutto,
|
||||||
|
'belegtyp' => $rechnungsNr !== null ? 'Ausgangsrechnung' : 'Eingangsrechnung',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue