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/{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));
|
||||
|
|
|
|||
|
|
@ -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'];
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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">×</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]
|
||||
);
|
||||
|
||||
$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');
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue