Feature: Zahlungs-Modal in Rechnungsdetailansicht + Invoice::createBookingsFromLines()

This commit is contained in:
fds2610-bot 2026-05-05 15:14:43 +02:00
parent 99cf3dd25b
commit 994eb95f70
5 changed files with 210 additions and 13 deletions

View file

@ -104,6 +104,7 @@ $router->post('/invoices/{id}/delete', fn($p) => (new InvoiceController())->dest
$router->post('/invoices/{id}/lines', fn($p) => (new InvoiceController())->addLine($p));
$router->post('/invoices/{id}/lines/{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));

View file

@ -119,6 +119,14 @@ class InvoiceController
);
}
// 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'),
@ -128,6 +136,8 @@ class InvoiceController
'settings' => $settings,
'stornorechnung' => $stornorechnung ?: null,
'originalrechnung' => $originalrechnung,
'bookings' => $bookings,
'accounts' => $accounts,
]),
]);
}
@ -340,6 +350,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();
@ -360,7 +374,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);
@ -393,6 +408,30 @@ 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'];

View file

@ -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);

View file

@ -50,8 +50,9 @@ $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']) && empty($invoice['storno_of_id'])): ?>
<form method="post" action="/invoices/<?= $h($invoice['id']) ?>/cancel"
@ -366,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">&times;</button>
</div>
<!-- Vorhandene Buchungen -->
<div style="padding:1rem 1.25rem;border-bottom:1px solid #e5e7eb;overflow-x:auto">
<table style="width:100%;font-size:.75rem;border-collapse:collapse">
<thead>
<tr style="background:#f0f0f0">
<th style="padding:.4rem .5rem;text-align:left">Datum</th>
<th style="padding:.4rem .5rem;text-align:left">Kategorie</th>
<th style="padding:.4rem .5rem;text-align:left">Zahlungskonto</th>
<th style="padding:.4rem .5rem;text-align:right">Netto</th>
<th style="padding:.4rem .5rem;text-align:right">USt</th>
<th style="padding:.4rem .5rem;text-align:right">Brutto</th>
<th style="padding:.4rem .5rem">Text</th>
</tr>
</thead>
<tbody>
<?php foreach ($bookings as $bk):
$bkStorno = (bool)$bk['is_reversal'] || !empty($bk['reversed_by']);
?>
<tr style="border-bottom:1px solid #eee;<?= $bkStorno ? 'opacity:.5;background:#fff5f5' : '' ?>">
<td style="padding:.35rem .5rem;white-space:nowrap"><?= $h(View::formatDate($bk['buchungs_datum'])) ?></td>
<td style="padding:.35rem .5rem;font-size:.7rem"><?= $h($bk['kategorie_nr']) ?><br><span style="color:#6b7280"><?= $h($bk['kategorie_name']) ?></span></td>
<td style="padding:.35rem .5rem;font-size:.7rem"><?= $h($bk['zahlungs_nr']) ?><br><span style="color:#6b7280"><?= $h($bk['zahlungs_name']) ?></span></td>
<td style="padding:.35rem .5rem;text-align:right;font-family:monospace"><?= $fmt($bk['betrag_netto']) ?></td>
<td style="padding:.35rem .5rem;text-align:right"><?= number_format((float)$bk['ust_satz'], 0) ?> %</td>
<td style="padding:.35rem .5rem;text-align:right;font-family:monospace;font-weight:600"><?= $fmt($bk['betrag_brutto']) ?></td>
<td style="padding:.35rem .5rem;color:#6b7280;font-size:.7rem">
<?= $h($bk['buchungstext']) ?>
<?php if ((bool)$bk['is_reversal']): ?><span style="color:#dc2626;font-size:.65rem">[Storno]</span>
<?php elseif (!empty($bk['reversed_by'])): ?><span style="color:#dc2626;font-size:.65rem">[Storniert]</span><?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
<?php if (empty($bookings)): ?>
<tr><td colspan="7" style="padding:1rem;text-align:center;color:#9ca3af">Noch keine Buchungen.</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
<!-- Neue Buchung erfassen -->
<div style="padding:1rem 1.25rem">
<div style="font-size:.8rem;font-weight:600;margin-bottom:.75rem">Neue Buchung erfassen</div>
<form method="post" action="/invoices/<?= $h($invoice['id']) ?>/bookings">
<input type="hidden" name="_from_modal" value="1">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:.75rem;margin-bottom:.75rem">
<div>
<label style="font-size:.75rem;font-weight:600;display:block;margin-bottom:.25rem">Zahldatum *</label>
<input type="date" name="buchungs_datum" value="<?= date('Y-m-d') ?>" required style="width:100%">
</div>
<div>
<label style="font-size:.75rem;font-weight:600;display:block;margin-bottom:.25rem">Nettobetrag (€) *</label>
<input type="number" name="betrag_netto" step="0.01" min="0.01" required
value="<?= $h(number_format(abs((float)$invoice['betrag_netto']), 2, '.', '')) ?>"
style="width:100%">
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:.75rem;margin-bottom:.75rem">
<div>
<label style="font-size:.75rem;font-weight:600;display:block;margin-bottom:.25rem">Kategorie-Konto *</label>
<select name="kategorie_konto_id" required style="width:100%">
<option value="">– Konto wählen –</option>
<?php foreach ($kontenGruppen['Betriebseinnahme'] ?? [] as $acc): ?>
<option value="<?= $h($acc['id']) ?>"><?= $h($acc['kontonummer']) ?> – <?= $h($acc['bezeichnung']) ?></option>
<?php endforeach; ?>
</select>
</div>
<div>
<label style="font-size:.75rem;font-weight:600;display:block;margin-bottom:.25rem">Zahlungskonto *</label>
<select name="zahlungskonto_id" required style="width:100%">
<option value="">– Konto wählen –</option>
<?php foreach ($kontenGruppen['Zahlungskonto'] ?? [] as $acc): ?>
<option value="<?= $h($acc['id']) ?>"><?= $h($acc['kontonummer']) ?> – <?= $h($acc['bezeichnung']) ?></option>
<?php endforeach; ?>
</select>
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:.75rem;margin-bottom:.75rem">
<div>
<label style="font-size:.75rem;font-weight:600;display:block;margin-bottom:.25rem">USt-Satz (%)</label>
<select name="ust_satz" style="width:100%">
<?php foreach (['0','7','19'] as $s): ?>
<option value="<?= $s ?>"<?= $s === '19' ? ' selected' : '' ?>><?= $s ?> %</option>
<?php endforeach; ?>
</select>
</div>
<div>
<label style="font-size:.75rem;font-weight:600;display:block;margin-bottom:.25rem">Buchungstext *</label>
<input type="text" name="buchungstext" required
value="Ausgangsrechnung <?= $h($invoice['rechnungs_nr'] ?? '') ?>"
style="width:100%">
</div>
</div>
<div style="display:flex;gap:.75rem;justify-content:flex-end">
<button type="button" class="btn btn-secondary"
onclick="document.getElementById('buchungs-modal').style.display='none'">Schließen</button>
<button type="submit" class="btn btn-primary">Buchung erfassen</button>
</div>
</form>
</div>
</div>
</div>
<script>
(function () {
var flash = document.querySelector('.flash-error, .flash-success');
if (flash) {
var ref = document.referrer || '';
if (ref.indexOf('/invoices/<?= (int)$invoice['id'] ?>/bookings') !== -1) {
document.getElementById('buchungs-modal').style.display = 'flex';
}
}
}());
</script>
<?php endif; ?>

View file

@ -86,16 +86,11 @@ class StornorechnungTest extends TestCase
'UPDATE invoices SET beleg_id=? WHERE id=?', [$belegId, $originalId]
);
$origBookingId = \Steuerkram\Models\Booking::create([
'beleg_id' => $belegId,
'buchungs_datum' => $datum,
'buchungstyp' => 'Einnahme',
'kategorie_konto_id' => $kategorieId,
'zahlungskonto_id' => $zahlungsId,
'betrag_netto' => 250.00,
'ust_satz' => 19.0,
'buchungstext' => 'Test Originalrechnung',
]);
$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');