Feature: Zahlungs-Modal in Rechnungsdetailansicht + Invoice::createBookingsFromLines()
This commit is contained in:
parent
99cf3dd25b
commit
994eb95f70
5 changed files with 210 additions and 13 deletions
|
|
@ -104,6 +104,7 @@ $router->post('/invoices/{id}/delete', fn($p) => (new InvoiceController())->dest
|
||||||
$router->post('/invoices/{id}/lines', fn($p) => (new InvoiceController())->addLine($p));
|
$router->post('/invoices/{id}/lines', fn($p) => (new InvoiceController())->addLine($p));
|
||||||
$router->post('/invoices/{id}/lines/{line_id}/delete', fn($p) => (new InvoiceController())->deleteLine($p));
|
$router->post('/invoices/{id}/lines/{line_id}/delete', fn($p) => (new InvoiceController())->deleteLine($p));
|
||||||
$router->post('/invoices/{id}/send', fn($p) => (new InvoiceController())->send($p));
|
$router->post('/invoices/{id}/send', fn($p) => (new InvoiceController())->send($p));
|
||||||
|
$router->post('/invoices/{id}/quick-book', fn($p) => (new InvoiceController())->quickBook($p));
|
||||||
|
|
||||||
// ── Anlagevermögen ───────────────────────────────────────────
|
// ── Anlagevermögen ───────────────────────────────────────────
|
||||||
$router->get('/assets', fn($p) => (new AssetController())->index($p));
|
$router->get('/assets', fn($p) => (new AssetController())->index($p));
|
||||||
|
|
|
||||||
|
|
@ -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 = new View();
|
||||||
$view->render('layout/base.php', [
|
$view->render('layout/base.php', [
|
||||||
'pageTitle' => 'Rechnung ' . ($invoice['rechnungs_nr'] ?? 'Entwurf'),
|
'pageTitle' => 'Rechnung ' . ($invoice['rechnungs_nr'] ?? 'Entwurf'),
|
||||||
|
|
@ -128,6 +136,8 @@ class InvoiceController
|
||||||
'settings' => $settings,
|
'settings' => $settings,
|
||||||
'stornorechnung' => $stornorechnung ?: null,
|
'stornorechnung' => $stornorechnung ?: null,
|
||||||
'originalrechnung' => $originalrechnung,
|
'originalrechnung' => $originalrechnung,
|
||||||
|
'bookings' => $bookings,
|
||||||
|
'accounts' => $accounts,
|
||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
@ -340,6 +350,10 @@ class InvoiceController
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($errors) {
|
if ($errors) {
|
||||||
|
if (!empty($data['_from_modal'])) {
|
||||||
|
View::redirect("/invoices/{$id}", implode(' ', $errors), 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
$bookings = Booking::forBeleg($belegId);
|
$bookings = Booking::forBeleg($belegId);
|
||||||
$accounts = Account::all(['aktiv' => 1]);
|
$accounts = Account::all(['aktiv' => 1]);
|
||||||
$view = new View();
|
$view = new View();
|
||||||
|
|
@ -360,7 +374,8 @@ class InvoiceController
|
||||||
$data['beleg_id'] = $belegId;
|
$data['beleg_id'] = $belegId;
|
||||||
$data['buchungstyp'] = 'Einnahme';
|
$data['buchungstyp'] = 'Einnahme';
|
||||||
Booking::create($data);
|
Booking::create($data);
|
||||||
View::redirect("/invoices/{$id}/book", 'Buchung erfasst.');
|
$returnUrl = !empty($data['_from_modal']) ? "/invoices/{$id}" : "/invoices/{$id}/book";
|
||||||
|
View::redirect($returnUrl, 'Buchung erfasst.');
|
||||||
} catch (\PDOException $e) {
|
} catch (\PDOException $e) {
|
||||||
error_log('[Steuerkram] PDO-Fehler in InvoiceController::storeBooking(): ' . $e->getMessage());
|
error_log('[Steuerkram] PDO-Fehler in InvoiceController::storeBooking(): ' . $e->getMessage());
|
||||||
$bookings = Booking::forBeleg($belegId);
|
$bookings = Booking::forBeleg($belegId);
|
||||||
|
|
@ -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
|
private function createBeleg(array $invoice): string
|
||||||
{
|
{
|
||||||
$id = (int)$invoice['id'];
|
$id = (int)$invoice['id'];
|
||||||
|
|
|
||||||
|
|
@ -162,6 +162,39 @@ class Invoice
|
||||||
Database::execute('UPDATE invoices SET status=\'Bezahlt\', updated_at=NOW() WHERE id=?', [$id]);
|
Database::execute('UPDATE invoices SET status=\'Bezahlt\', updated_at=NOW() WHERE id=?', [$id]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static function createBookingsFromLines(
|
||||||
|
int $invoiceId,
|
||||||
|
int $kategorieKontoId,
|
||||||
|
int $zahlungskontoId,
|
||||||
|
string $buchungsDatum
|
||||||
|
): array {
|
||||||
|
$inv = self::find($invoiceId);
|
||||||
|
if (!$inv) throw new \RuntimeException('Rechnung nicht gefunden.');
|
||||||
|
if (empty($inv['beleg_id'])) throw new \RuntimeException('Rechnung hat keinen Beleg (noch nicht finalisiert?).');
|
||||||
|
|
||||||
|
$gruppen = [];
|
||||||
|
foreach (self::lines($invoiceId) as $line) {
|
||||||
|
if (($line['type'] ?? 'position') !== 'position') continue;
|
||||||
|
$satz = (float)$line['ust_satz'];
|
||||||
|
$gruppen[$satz] = ($gruppen[$satz] ?? 0.0) + round((float)$line['betrag_netto'], 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
$ids = [];
|
||||||
|
foreach ($gruppen as $satz => $netto) {
|
||||||
|
$ids[] = Booking::create([
|
||||||
|
'beleg_id' => (int)$inv['beleg_id'],
|
||||||
|
'buchungs_datum' => $buchungsDatum,
|
||||||
|
'buchungstyp' => 'Einnahme',
|
||||||
|
'kategorie_konto_id' => $kategorieKontoId,
|
||||||
|
'zahlungskonto_id' => $zahlungskontoId,
|
||||||
|
'betrag_netto' => round($netto, 2),
|
||||||
|
'ust_satz' => $satz,
|
||||||
|
'buchungstext' => 'Ausgangsrechnung ' . $inv['rechnungs_nr'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
return $ids;
|
||||||
|
}
|
||||||
|
|
||||||
public static function cancel(int $id): void
|
public static function cancel(int $id): void
|
||||||
{
|
{
|
||||||
$invoice = self::find($id);
|
$invoice = self::find($id);
|
||||||
|
|
|
||||||
|
|
@ -50,8 +50,9 @@ $badgeClass = match($invoice['status']) {
|
||||||
<button type="button" class="btn btn-primary"
|
<button type="button" class="btn btn-primary"
|
||||||
onclick="document.getElementById('send-modal').style.display='flex'">Per E-Mail senden</button>
|
onclick="document.getElementById('send-modal').style.display='flex'">Per E-Mail senden</button>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<?php if ($invoice['status'] === 'Gesendet'): ?>
|
<?php if (!in_array($invoice['status'], ['Entwurf', 'Storniert']) && !empty($invoice['beleg_id'])): ?>
|
||||||
<a href="/invoices/<?= $h($invoice['id']) ?>/book" class="btn btn-success">Zahlungseingang erfassen</a>
|
<button type="button" class="btn btn-success"
|
||||||
|
onclick="document.getElementById('buchungs-modal').style.display='flex'">Zahlungen</button>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<?php if (in_array($invoice['status'], ['Entwurf','Finalisiert','Gesendet']) && empty($invoice['storno_of_id'])): ?>
|
<?php if (in_array($invoice['status'], ['Entwurf','Finalisiert','Gesendet']) && empty($invoice['storno_of_id'])): ?>
|
||||||
<form method="post" action="/invoices/<?= $h($invoice['id']) ?>/cancel"
|
<form method="post" action="/invoices/<?= $h($invoice['id']) ?>/cancel"
|
||||||
|
|
@ -366,3 +367,131 @@ $badgeClass = match($invoice['status']) {
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Zahlungs-Modal -->
|
||||||
|
<?php if (!in_array($invoice['status'], ['Entwurf', 'Storniert']) && !empty($invoice['beleg_id'])): ?>
|
||||||
|
<?php
|
||||||
|
$kontenGruppen = [];
|
||||||
|
foreach ($accounts as $acc) { $kontenGruppen[$acc['kontenart']][] = $acc; }
|
||||||
|
?>
|
||||||
|
<div id="buchungs-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.45);z-index:1000;align-items:flex-start;justify-content:center;padding:2rem 1rem;overflow-y:auto">
|
||||||
|
<div style="background:#fff;border-radius:8px;width:100%;max-width:720px;box-shadow:0 8px 32px rgba(0,0,0,.2)">
|
||||||
|
<div style="display:flex;align-items:center;justify-content:space-between;padding:1rem 1.25rem;border-bottom:1px solid #e5e7eb">
|
||||||
|
<h3 style="margin:0">Zahlungen – <?= $h($invoice['rechnungs_nr']) ?></h3>
|
||||||
|
<button onclick="document.getElementById('buchungs-modal').style.display='none'"
|
||||||
|
style="background:none;border:none;font-size:1.5rem;cursor:pointer;color:#6b7280;line-height:1">×</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; ?>
|
||||||
|
|
|
||||||
|
|
@ -86,16 +86,11 @@ class StornorechnungTest extends TestCase
|
||||||
'UPDATE invoices SET beleg_id=? WHERE id=?', [$belegId, $originalId]
|
'UPDATE invoices SET beleg_id=? WHERE id=?', [$belegId, $originalId]
|
||||||
);
|
);
|
||||||
|
|
||||||
$origBookingId = \Steuerkram\Models\Booking::create([
|
$bookingIds = \Steuerkram\Models\Invoice::createBookingsFromLines(
|
||||||
'beleg_id' => $belegId,
|
$originalId, $kategorieId, $zahlungsId, $datum
|
||||||
'buchungs_datum' => $datum,
|
);
|
||||||
'buchungstyp' => 'Einnahme',
|
$this->assertGreaterThan(0, count($bookingIds), 'createBookingsFromLines erzeugt Buchungen');
|
||||||
'kategorie_konto_id' => $kategorieId,
|
$origBookingId = $bookingIds[0];
|
||||||
'zahlungskonto_id' => $zahlungsId,
|
|
||||||
'betrag_netto' => 250.00,
|
|
||||||
'ust_satz' => 19.0,
|
|
||||||
'buchungstext' => 'Test Originalrechnung',
|
|
||||||
]);
|
|
||||||
$origBooking = \Steuerkram\Models\Booking::find($origBookingId);
|
$origBooking = \Steuerkram\Models\Booking::find($origBookingId);
|
||||||
$this->assertEquals(250.00, round((float)$origBooking['betrag_netto'], 2), 'Original-Buchung Netto = 250');
|
$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');
|
$this->assertEquals(47.50, round((float)$origBooking['betrag_ust'], 2), 'Original-Buchung USt = 47,50');
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue