Feature: E-Mail-Versand Rechnungen – Texte, Status Finalisiert, SMTP-Einstellungen

This commit is contained in:
fds2610-bot 2026-04-28 15:07:47 +02:00
parent 4485acbe1f
commit c50e41e57b
12 changed files with 212 additions and 97 deletions

View file

@ -0,0 +1,3 @@
ALTER TABLE invoices
MODIFY COLUMN status ENUM('Entwurf','Finalisiert','Gesendet','Bezahlt','Storniert')
NOT NULL DEFAULT 'Entwurf';

View file

@ -0,0 +1,3 @@
ALTER TABLE company_settings
ADD COLUMN IF NOT EXISTS mail_subject_template VARCHAR(500) NOT NULL DEFAULT 'Rechnung %RNR%' AFTER mail_from_email,
ADD COLUMN IF NOT EXISTS mail_body_template TEXT AFTER mail_subject_template;

View file

@ -131,6 +131,7 @@ $router->get('/tax/euer/{year}/konto/{konto_id}', fn($p) => (new EuerController(
// ── Einstellungen ────────────────────────────────────────────
$router->get('/settings', fn($p) => (new SettingsController())->show($p));
$router->post('/settings', fn($p) => (new SettingsController())->update($p));
$router->post('/settings/mail-template', fn($p) => (new SettingsController())->mailTemplate($p));
$router->post('/settings/mail-test', fn($p) => (new SettingsController())->mailTest($p));
$router->post('/settings/logo', fn($p) => (new SettingsController())->uploadLogo($p));
$router->post('/settings/logo/delete', fn($p) => (new SettingsController())->deleteLogo($p));

View file

@ -90,6 +90,25 @@ class SettingsController
View::redirect('/settings', 'Logo hochgeladen.');
}
public function mailTemplate(array $params): void
{
header('Content-Type: application/json');
$subject = trim($_POST['mail_subject_template'] ?? '');
$body = trim($_POST['mail_body_template'] ?? '');
if ($subject === '') {
echo json_encode(['error' => 'Betreff darf nicht leer sein.']);
return;
}
\Steuerkram\Database::execute(
'UPDATE company_settings SET mail_subject_template=?, mail_body_template=? WHERE id=1',
[$subject, $body ?: null]
);
echo json_encode(['ok' => true]);
}
public function mailTest(array $params): void
{
header('Content-Type: application/json');

View file

@ -150,7 +150,7 @@ class Invoice
$fy = FiscalYear::findOrCreateByYear($year);
Database::execute(
'UPDATE invoices SET rechnungs_nr=?, status=\'Gesendet\', fiscal_year_id=?, updated_at=NOW() WHERE id=?',
'UPDATE invoices SET rechnungs_nr=?, status=\'Finalisiert\', fiscal_year_id=?, updated_at=NOW() WHERE id=?',
[$nr, $fy['id'], $id]
);

View file

@ -39,6 +39,7 @@ class Setting
mail_smtp_host=?, mail_smtp_port=?, mail_smtp_encryption=?,
mail_smtp_user=?, mail_smtp_pass=?,
mail_from_name=?, mail_from_email=?,
mail_subject_template=?, mail_body_template=?,
logo_max_width=?, logo_max_height=?,
logo_position=?, logo_margin_side=?, logo_margin_top=?
WHERE id=1',
@ -78,6 +79,8 @@ class Setting
$data['mail_smtp_pass'] ?: '',
$data['mail_from_name'] ?: '',
$data['mail_from_email'] ?: '',
$data['mail_subject_template'] ?: 'Rechnung %RNR%',
$data['mail_body_template'] ?: null,
max(50, (int)($data['logo_max_width'] ?? 600)),
max(20, (int)($data['logo_max_height'] ?? 100)),
$logoPosition,

View file

@ -20,15 +20,19 @@ class EmailService
$pdfContent = $pdfService->renderToString($invoice, $lines, $settings);
$nr = $invoice['rechnungs_nr'] ?? 'Entwurf';
$firma = $settings['firma'] ?? '';
$subject = 'Rechnung ' . $nr;
$filename = 'Rechnung-' . $nr . '.pdf';
$body = "Guten Tag,\n\n"
. "anbei erhalten Sie die Rechnung {$nr} von {$firma}.\n\n"
$defaultBody = "Guten Tag,\n\n"
. "anbei erhalten Sie die Rechnung %RNR% von %FNAME%.\n\n"
. "Bei Fragen stehen wir Ihnen gerne zur Verfügung.\n\n"
. "Mit freundlichen Grüßen\n"
. $firma;
. "%INHABER%";
$subjectTpl = ($settings['mail_subject_template'] ?? '') ?: 'Rechnung %RNR%';
$bodyTpl = ($settings['mail_body_template'] ?? '') ?: $defaultBody;
$subject = $this->replacePlaceholders($subjectTpl, $invoice, $settings);
$body = $this->replacePlaceholders($bodyTpl, $invoice, $settings);
$mailer->send($recipients, $subject, $body, $pdfContent, $filename);
}
@ -48,6 +52,15 @@ class EmailService
);
}
private function replacePlaceholders(string $text, array $invoice, array $settings): string
{
return str_replace(
['%RNR%', '%FNAME%', '%INHABER%'],
[$invoice['rechnungs_nr'] ?? 'Entwurf', $settings['firma'] ?? '', $settings['inhaber'] ?? ''],
$text
);
}
private function buildMailer(array $settings): SmtpMailer
{
$host = $settings['mail_smtp_host'] ?? '';

View file

@ -10,10 +10,11 @@ $customerName = !empty($invoice['firma'])
$lzHtml = $h($invoice['leistungszeitraum'] ?? '');
$badgeClass = match($invoice['status']) {
'Bezahlt' => 'badge-green',
'Storniert' => 'badge-red',
'Gesendet' => 'badge-yellow',
default => 'badge-gray',
'Bezahlt' => 'badge-green',
'Storniert' => 'badge-red',
'Gesendet' => 'badge-yellow',
'Finalisiert' => 'badge-blue',
default => 'badge-gray',
};
?>
@ -40,7 +41,7 @@ $badgeClass = match($invoice['status']) {
<?php if ($invoice['status'] === 'Gesendet'): ?>
<a href="/invoices/<?= $h($invoice['id']) ?>/book" class="btn btn-success">Zahlungseingang erfassen</a>
<?php endif; ?>
<?php if (in_array($invoice['status'], ['Entwurf','Gesendet'])): ?>
<?php if (in_array($invoice['status'], ['Entwurf','Finalisiert','Gesendet'])): ?>
<form method="post" action="/invoices/<?= $h($invoice['id']) ?>/cancel"
data-confirm="Rechnung stornieren?">
<button type="submit" class="btn btn-danger">Stornieren</button>

View file

@ -1,6 +1,10 @@
<?php use Steuerkram\View; $h = fn($v) => View::h($v); ?>
<form id="settings-form" method="post" action="/settings">
<input type="hidden" id="hf-mail-subject" name="mail_subject_template"
value="<?= $h($settings['mail_subject_template'] ?? 'Rechnung %RNR%') ?>">
<input type="hidden" id="hf-mail-body" name="mail_body_template"
value="<?= $h($settings['mail_body_template'] ?? '') ?>">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1.5rem;align-items:start">
<!-- Firmendaten -->
@ -81,9 +85,55 @@
</div>
</div>
</div>
<div class="card mb-2">
<div class="card-header"><div class="card-title">Bankdaten</div></div>
<div class="card-body">
<div class="form-group">
<label>Bankname</label>
<input type="text" name="bank_name" value="<?= $h($settings['bank_name'] ?? '') ?>">
</div>
<div class="form-group">
<label>IBAN</label>
<input type="text" name="iban" value="<?= $h($settings['iban'] ?? '') ?>" placeholder="DE89 3704 0044 0532 0130 00">
</div>
<div class="form-group">
<label>BIC</label>
<input type="text" name="bic" value="<?= $h($settings['bic'] ?? '') ?>">
</div>
</div>
</div>
<div class="card mb-2">
<div class="card-header"><div class="card-title">Nummernkreise</div></div>
<div class="card-body">
<div class="form-grid form-grid-2">
<div class="form-group">
<label>Rechnungs-Präfix</label>
<input type="text" name="invoice_nr_prefix" value="<?= $h($settings['invoice_nr_prefix'] ?? 'AR') ?>" maxlength="10">
</div>
<div class="form-group">
<label>Beleg-Präfix</label>
<input type="text" name="beleg_nr_prefix" value="<?= $h($settings['beleg_nr_prefix'] ?? 'BLG') ?>" maxlength="10">
</div>
<div class="form-group">
<label>Anlagen-Präfix</label>
<input type="text" name="anlage_nr_prefix" value="<?= $h($settings['anlage_nr_prefix'] ?? 'ANL') ?>" maxlength="10">
</div>
<div class="form-group">
<label>Kunden-Präfix</label>
<input type="text" name="customer_nr_prefix" value="<?= $h($settings['customer_nr_prefix'] ?? 'K-') ?>" maxlength="10">
</div>
</div>
<div class="form-group">
<label>Standard-Zahlungsziel (Tage)</label>
<input type="number" name="payment_days_default" value="<?= $h($settings['payment_days_default'] ?? 14) ?>" min="0">
</div>
</div>
</div>
</div>
<!-- Bankdaten + Logo + Nummernkreise -->
<!-- rechte Spalte -->
<div>
<!-- Betriebsmodus -->
<div class="card mb-2" style="border:2px solid <?= ($settings['test_mode'] ?? 1) ? 'var(--color-warning,#f59e0b)' : 'var(--color-success,#22c55e)' ?>">
@ -210,52 +260,6 @@
</div>
</div>
<div class="card mb-2">
<div class="card-header"><div class="card-title">Bankdaten</div></div>
<div class="card-body">
<div class="form-group">
<label>Bankname</label>
<input type="text" name="bank_name" value="<?= $h($settings['bank_name'] ?? '') ?>">
</div>
<div class="form-group">
<label>IBAN</label>
<input type="text" name="iban" value="<?= $h($settings['iban'] ?? '') ?>" placeholder="DE89 3704 0044 0532 0130 00">
</div>
<div class="form-group">
<label>BIC</label>
<input type="text" name="bic" value="<?= $h($settings['bic'] ?? '') ?>">
</div>
</div>
</div>
<div class="card mb-2">
<div class="card-header"><div class="card-title">Nummernkreise</div></div>
<div class="card-body">
<div class="form-grid form-grid-2">
<div class="form-group">
<label>Rechnungs-Präfix</label>
<input type="text" name="invoice_nr_prefix" value="<?= $h($settings['invoice_nr_prefix'] ?? 'AR') ?>" maxlength="10">
</div>
<div class="form-group">
<label>Beleg-Präfix</label>
<input type="text" name="beleg_nr_prefix" value="<?= $h($settings['beleg_nr_prefix'] ?? 'BLG') ?>" maxlength="10">
</div>
<div class="form-group">
<label>Anlagen-Präfix</label>
<input type="text" name="anlage_nr_prefix" value="<?= $h($settings['anlage_nr_prefix'] ?? 'ANL') ?>" maxlength="10">
</div>
<div class="form-group">
<label>Kunden-Präfix</label>
<input type="text" name="customer_nr_prefix" value="<?= $h($settings['customer_nr_prefix'] ?? 'K-') ?>" maxlength="10">
</div>
</div>
<div class="form-group">
<label>Standard-Zahlungsziel (Tage)</label>
<input type="number" name="payment_days_default" value="<?= $h($settings['payment_days_default'] ?? 14) ?>" min="0">
</div>
</div>
</div>
<!-- E-Mail-Versand -->
<div class="card mb-2">
<div class="card-header"><div class="card-title">E-Mail-Versand (SMTP)</div></div>
@ -298,45 +302,15 @@
<input type="email" name="mail_from_email" value="<?= $h($settings['mail_from_email'] ?? '') ?>">
</div>
</div>
<button type="button" class="btn btn-secondary" id="btn-mail-test">Test-Mail senden</button>
<span id="mail-test-result" style="margin-left:.75rem;font-size:.8rem"></span>
<div style="display:flex;align-items:center;gap:.5rem;flex-wrap:wrap">
<button type="button" class="btn btn-secondary" id="btn-mail-text">Text bearbeiten</button>
<button type="button" class="btn btn-secondary" id="btn-mail-test">Test-Mail senden</button>
<span id="mail-test-result" style="font-size:.8rem"></span>
</div>
</div>
</div>
</div>
</div>
</form>
<script>
document.getElementById('btn-mail-test')?.addEventListener('click', function () {
var btn = this;
var result = document.getElementById('mail-test-result');
btn.disabled = true;
result.textContent = 'Sende…';
result.style.color = '';
fetch('/settings/mail-test', { method: 'POST', headers: { 'X-Requested-With': 'XMLHttpRequest' } })
.then(function (r) { return r.json(); })
.then(function (data) {
if (data.ok) {
result.textContent = '✓ Test-Mail erfolgreich gesendet.';
result.style.color = 'green';
} else {
result.textContent = '✗ ' + (data.error || 'Unbekannter Fehler');
result.style.color = 'red';
}
})
.catch(function () {
result.textContent = '✗ Netzwerkfehler';
result.style.color = 'red';
})
.finally(function () { btn.disabled = false; });
});
</script>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1.5rem;margin-top:0">
<div></div>
<div>
<!-- Logo -->
<div class="card mb-2">
<div class="card-header"><div class="card-title">Logo</div></div>
<div class="card-body">
@ -407,9 +381,61 @@ document.getElementById('btn-mail-test')?.addEventListener('click', function ()
</div>
</div>
</div>
</div>
</div>
</form>
<!-- Modal: E-Mail-Text bearbeiten -->
<div id="mail-text-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.45);z-index:1000;align-items:center;justify-content:center">
<div style="background:#fff;border-radius:8px;padding:1.5rem;width:520px;max-width:95vw;box-shadow:0 8px 32px rgba(0,0,0,.2)">
<h3 style="margin:0 0 1rem">E-Mail-Text bearbeiten</h3>
<p style="font-size:.78rem;color:#666;margin:0 0 1rem">
Platzhalter: <code>%RNR%</code> = Rechnungsnummer &nbsp;·&nbsp; <code>%FNAME%</code> = Firmenname &nbsp;·&nbsp; <code>%INHABER%</code> = Inhaber
</p>
<div class="form-group">
<label>Betreff</label>
<input type="text" id="mail-text-subject" style="width:100%"
value="<?= $h($settings['mail_subject_template'] ?? 'Rechnung %RNR%') ?>">
</div>
<div class="form-group">
<label>Text</label>
<textarea id="mail-text-body" rows="8" style="width:100%;resize:vertical;font-family:inherit;font-size:.85rem"><?= $h($settings['mail_body_template'] ?? '') ?></textarea>
</div>
<div style="display:flex;gap:.75rem;justify-content:flex-end;margin-top:1.25rem">
<button type="button" class="btn btn-secondary" id="btn-mail-text-cancel">Abbrechen</button>
<button type="button" class="btn btn-primary" id="btn-mail-text-save">Speichern</button>
</div>
</div>
</div>
<script>
document.getElementById('btn-mail-test')?.addEventListener('click', function () {
var btn = this;
var result = document.getElementById('mail-test-result');
btn.disabled = true;
result.textContent = 'Sende…';
result.style.color = '';
fetch('/settings/mail-test', { method: 'POST', headers: { 'X-Requested-With': 'XMLHttpRequest' } })
.then(function (r) { return r.json(); })
.then(function (data) {
if (data.ok) {
result.textContent = '✓ Test-Mail erfolgreich gesendet.';
result.style.color = 'green';
} else {
result.textContent = '✗ ' + (data.error || 'Unbekannter Fehler');
result.style.color = 'red';
}
})
.catch(function () {
result.textContent = '✗ Netzwerkfehler';
result.style.color = 'red';
})
.finally(function () { btn.disabled = false; });
});
</script>
<script>
function updateModeLabel(cb) {
const track = document.getElementById('toggle_track');
@ -442,4 +468,50 @@ function logoPositionChanged() {
document.getElementById('logo-margin-fields').style.display =
(pos === 'left' || pos === 'right') ? 'block' : 'none';
}
// E-Mail-Text-Modal
document.getElementById('btn-mail-text')?.addEventListener('click', function () {
document.getElementById('mail-text-modal').style.display = 'flex';
});
document.getElementById('btn-mail-text-cancel')?.addEventListener('click', function () {
document.getElementById('mail-text-modal').style.display = 'none';
});
document.getElementById('btn-mail-text-save')?.addEventListener('click', function () {
var btn = this;
var subject = document.getElementById('mail-text-subject').value.trim();
var body = document.getElementById('mail-text-body').value;
var result = document.getElementById('mail-test-result');
if (!subject) {
document.getElementById('mail-text-subject').focus();
return;
}
btn.disabled = true;
var fd = new FormData();
fd.append('mail_subject_template', subject);
fd.append('mail_body_template', body);
fetch('/settings/mail-template', { method: 'POST', body: fd })
.then(function (r) { return r.json(); })
.then(function (data) {
if (data.ok) {
// Versteckte Felder im Hauptformular synchron halten
document.getElementById('hf-mail-subject').value = subject;
document.getElementById('hf-mail-body').value = body;
document.getElementById('mail-text-modal').style.display = 'none';
result.textContent = '✓ E-Mail-Text gespeichert.';
result.style.color = 'green';
setTimeout(function () { result.textContent = ''; }, 3000);
} else {
result.textContent = '✗ ' + (data.error || 'Fehler beim Speichern.');
result.style.color = 'red';
}
})
.catch(function () {
result.textContent = '✗ Netzwerkfehler.';
result.style.color = 'red';
})
.finally(function () { btn.disabled = false; });
});
</script>

View file

@ -63,7 +63,7 @@ class InvoiceBuchungTest extends TestCase
$this->assertNotEmpty($nr, 'Rechnung wurde finalisiert (Rechnungsnummer erhalten)');
$invoice = \Steuerkram\Models\Invoice::find($invoiceId);
$this->assertEquals('Gesendet', $invoice['status'] ?? null, 'Rechnungsstatus ist Gesendet');
$this->assertEquals('Finalisiert', $invoice['status'] ?? null, 'Rechnungsstatus ist Finalisiert');
// Buchungen erzeugen (EÜR: Einnahme)
$belegNr = \Steuerkram\Models\Beleg::generateBelegNr();

View file

@ -62,7 +62,7 @@ class InvoiceToReportsTest extends TestCase
$this->assertNotEmpty($rechnungsNr, 'Rechnungsnummer nach Finalisierung vorhanden');
$invoice = \Steuerkram\Models\Invoice::find($invoiceId);
$this->assertEquals('Gesendet', $invoice['status'] ?? null, 'Status nach Finalisierung: Gesendet');
$this->assertEquals('Finalisiert', $invoice['status'] ?? null, 'Status nach Finalisierung: Finalisiert');
// ── Beleg und Buchungen anlegen (wie InvoiceController::createBelegAndBookings) ──
$belegNr = \Steuerkram\Models\Beleg::generateBelegNr();

View file

@ -65,7 +65,7 @@ class IstVersteuerungTest extends TestCase
$this->assertNotEmpty($nr, 'Rechnungsnummer vergeben');
$invoice = \Steuerkram\Models\Invoice::find($invoiceId);
$this->assertEquals('Gesendet', $invoice['status'] ?? null, 'Status nach Finalisierung: Gesendet');
$this->assertEquals('Finalisiert', $invoice['status'] ?? null, 'Status nach Finalisierung: Finalisiert');
// Beleg anlegen (wie in InvoiceController::createBeleg)
$belegNr = \Steuerkram\Models\Beleg::generateBelegNr();