Merge pull request 'Feature: Rechnungsversand per E-Mail (eigener SMTP-Client)' (#62) from fds2610-bot/Steuerkram:main into main

Reviewed-on: https://codeberg.org/fds2610/Steuerkram/pulls/62
This commit is contained in:
fds2610 2026-04-28 15:09:18 +02:00
commit 586d39f260
16 changed files with 680 additions and 60 deletions

View file

@ -0,0 +1,8 @@
ALTER TABLE company_settings
ADD COLUMN IF NOT EXISTS mail_smtp_host VARCHAR(255) NOT NULL DEFAULT '' AFTER nextcloud_done,
ADD COLUMN IF NOT EXISTS mail_smtp_port SMALLINT UNSIGNED NOT NULL DEFAULT 587 AFTER mail_smtp_host,
ADD COLUMN IF NOT EXISTS mail_smtp_encryption VARCHAR(10) NOT NULL DEFAULT 'tls' AFTER mail_smtp_port,
ADD COLUMN IF NOT EXISTS mail_smtp_user VARCHAR(255) NOT NULL DEFAULT '' AFTER mail_smtp_encryption,
ADD COLUMN IF NOT EXISTS mail_smtp_pass VARCHAR(255) NOT NULL DEFAULT '' AFTER mail_smtp_user,
ADD COLUMN IF NOT EXISTS mail_from_name VARCHAR(255) NOT NULL DEFAULT '' AFTER mail_smtp_pass,
ADD COLUMN IF NOT EXISTS mail_from_email VARCHAR(255) NOT NULL DEFAULT '' AFTER mail_from_name;

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

@ -103,6 +103,7 @@ $router->post('/invoices/{id}/cancel', fn($p) => (new InvoiceController())->canc
$router->post('/invoices/{id}/delete', fn($p) => (new InvoiceController())->destroy($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}/send', fn($p) => (new InvoiceController())->send($p));
// ── Anlagevermögen ───────────────────────────────────────────
$router->get('/assets', fn($p) => (new AssetController())->index($p));
@ -130,6 +131,8 @@ $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

@ -10,6 +10,7 @@ use Steuerkram\Models\Beleg;
use Steuerkram\Models\Booking;
use Steuerkram\Models\Account;
use Steuerkram\Models\Setting;
use Steuerkram\Services\EmailService;
use Steuerkram\Services\PdfInvoiceService;
use Steuerkram\View;
@ -197,6 +198,50 @@ class InvoiceController
}
}
public function send(array $params): void
{
$id = (int)$params['id'];
$invoice = Invoice::find($id);
if (!$invoice) { http_response_code(404); return; }
if ($invoice['status'] === 'Entwurf') {
View::redirect("/invoices/{$id}", 'Entwürfe können nicht gesendet werden.', 'error');
return;
}
// Empfänger aus POST (bis zu 3), Fallback: Kunden-E-Mail
$recipients = array_values(array_filter(array_map(
'trim',
array_merge(
[$_POST['email_to'] ?? $invoice['email'] ?? ''],
[$_POST['email_cc1'] ?? ''],
[$_POST['email_cc2'] ?? ''],
)
)));
if (empty($recipients)) {
View::redirect("/invoices/{$id}", 'Keine Empfänger-E-Mail-Adresse angegeben.', 'error');
return;
}
try {
$lines = Invoice::lines($id);
$settings = Setting::get();
(new EmailService())->sendInvoice($invoice, $lines, $settings, $recipients);
if ($invoice['status'] === 'Finalisiert') {
\Steuerkram\Database::execute(
"UPDATE invoices SET status='Gesendet', updated_at=NOW() WHERE id=?",
[$id]
);
}
View::redirect("/invoices/{$id}", 'Rechnung erfolgreich gesendet.');
} catch (\RuntimeException $e) {
View::redirect("/invoices/{$id}", 'Fehler beim Senden: ' . $e->getMessage(), 'error');
}
}
public function pdf(array $params): void
{
$invoice = Invoice::find((int)$params['id']);

View file

@ -5,6 +5,7 @@ declare(strict_types=1);
namespace Steuerkram\Controllers;
use Steuerkram\Models\Setting;
use Steuerkram\Services\EmailService;
use Steuerkram\View;
class SettingsController
@ -89,6 +90,44 @@ 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');
$settings = Setting::get();
$to = $settings['email'] ?? '';
if (empty($to)) {
echo json_encode(['error' => 'Keine Firmen-E-Mail hinterlegt (Einstellungen → Firmendaten).']);
return;
}
try {
(new EmailService())->sendTestMail($settings, $to);
echo json_encode(['ok' => true]);
} catch (\RuntimeException $e) {
echo json_encode(['error' => $e->getMessage()]);
}
}
public function deleteLogo(array $params): void
{
Setting::deleteLogo();

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

@ -36,6 +36,10 @@ class Setting
payment_days_default=?, test_mode=?, ki_vorschlag_aktiv=?,
nextcloud_url=?, nextcloud_user=?, nextcloud_pass=?,
nextcloud_folder=?, nextcloud_done=?,
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',
@ -68,6 +72,15 @@ class Setting
$data['nextcloud_pass'] ?: null,
$data['nextcloud_folder'] ?: '/Belege/Import',
$data['nextcloud_done'] ?: null,
$data['mail_smtp_host'] ?: '',
(int)($data['mail_smtp_port'] ?? 587),
in_array($data['mail_smtp_encryption'] ?? '', ['tls', 'ssl', 'none']) ? $data['mail_smtp_encryption'] : 'tls',
$data['mail_smtp_user'] ?: '',
$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

@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
namespace Steuerkram\Services;
class EmailService
{
/**
* Sendet eine Rechnung als PDF-Anhang an alle Empfänger.
*
* @param string[] $recipients E-Mail-Adressen (1–3)
* @throws \RuntimeException
*/
public function sendInvoice(array $invoice, array $lines, array $settings, array $recipients): void
{
$mailer = $this->buildMailer($settings);
$pdfService = new PdfInvoiceService();
$pdfContent = $pdfService->renderToString($invoice, $lines, $settings);
$nr = $invoice['rechnungs_nr'] ?? 'Entwurf';
$filename = 'Rechnung-' . $nr . '.pdf';
$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"
. "%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);
}
/**
* Sendet eine Test-Mail an die angegebene Adresse.
*
* @throws \RuntimeException
*/
public function sendTestMail(array $settings, string $to): void
{
$mailer = $this->buildMailer($settings);
$mailer->send(
[$to],
'Steuerkram – SMTP-Test',
"Die SMTP-Konfiguration funktioniert korrekt.\n\nDiese Mail wurde von Steuerkram gesendet."
);
}
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'] ?? '';
if (empty($host)) {
throw new \RuntimeException('SMTP-Host ist nicht konfiguriert.');
}
return new SmtpMailer(
host: $host,
port: (int)($settings['mail_smtp_port'] ?? 587),
encryption: $settings['mail_smtp_encryption'] ?? 'tls',
user: $settings['mail_smtp_user'] ?? '',
pass: $settings['mail_smtp_pass'] ?? '',
fromEmail: $settings['mail_from_email'] ?: ($settings['email'] ?? ''),
fromName: $settings['mail_from_name'] ?: ($settings['firma'] ?? ''),
);
}
}

View file

@ -8,7 +8,24 @@ use Mpdf\Mpdf;
class PdfInvoiceService
{
public function renderToString(array $invoice, array $lines, array $settings): string
{
return $this->generate($invoice, $lines, $settings);
}
public function render(array $invoice, array $lines, array $settings): void
{
$pdfContent = $this->generate($invoice, $lines, $settings);
$filename = 'Rechnung-' . ($invoice['rechnungs_nr'] ?? 'Entwurf') . '.pdf';
header('Content-Type: application/pdf');
header('Content-Disposition: attachment; filename="' . $filename . '"');
header('Content-Length: ' . strlen($pdfContent));
echo $pdfContent;
exit;
}
private function generate(array $invoice, array $lines, array $settings): string
{
$html = $this->buildHtml($invoice, $lines, $settings);
@ -40,18 +57,11 @@ class PdfInvoiceService
$mpdf->WriteHTML($this->buildCss(), \Mpdf\HTMLParserMode::HEADER_CSS);
$mpdf->WriteHTML($html, \Mpdf\HTMLParserMode::HTML_BODY);
$filename = 'Rechnung-' . ($invoice['rechnungs_nr'] ?? 'Entwurf') . '.pdf';
$pdfContent = $mpdf->Output('', 'S');
// ZUGFeRD EN16931-XML einbetten (E-Rechnung)
$zugferd = new ZugferdService();
$pdfContent = $zugferd->attachToPdf($pdfContent, $invoice, $lines, $settings);
header('Content-Type: application/pdf');
header('Content-Disposition: attachment; filename="' . $filename . '"');
header('Content-Length: ' . strlen($pdfContent));
echo $pdfContent;
exit;
return $zugferd->attachToPdf($pdfContent, $invoice, $lines, $settings);
}
private function buildHtml(array $inv, array $lines, array $settings): string

237
src/Services/SmtpMailer.php Normal file
View file

@ -0,0 +1,237 @@
<?php
declare(strict_types=1);
namespace Steuerkram\Services;
/**
* Schlanker SMTP-Client ohne externe Abhängigkeiten.
* Unterstützt STARTTLS (port 587), SSL/TLS (port 465) und unverschlüsselt.
* AUTH LOGIN und AUTH PLAIN. Bis zu n Empfänger. Einen Anhang (binary string).
*/
class SmtpMailer
{
/** @var resource */
private $sock;
public function __construct(
private readonly string $host,
private readonly int $port,
private readonly string $encryption, // 'tls' | 'ssl' | 'none'
private readonly string $user,
private readonly string $pass,
private readonly string $fromEmail,
private readonly string $fromName,
) {}
/**
* @param string[] $to Liste von Empfänger-Adressen (max. 3)
* @param string $subject
* @param string $body Plain-Text-Body
* @param string|null $attachContent Binärer Inhalt des Anhangs
* @param string|null $attachName Dateiname des Anhangs
* @throws \RuntimeException
*/
public function send(
array $to,
string $subject,
string $body,
?string $attachContent = null,
?string $attachName = null,
): void {
$this->connect();
$this->authenticate();
$this->sendMessage($to, $subject, $body, $attachContent, $attachName);
$this->cmd('QUIT');
fclose($this->sock);
}
// ── Verbindung ───────────────────────────────────────────────
private function connect(): void
{
$address = $this->encryption === 'ssl'
? 'ssl://' . $this->host
: 'tcp://' . $this->host;
$ctx = stream_context_create([
'ssl' => [
'verify_peer' => true,
'verify_peer_name' => true,
'allow_self_signed' => false,
],
]);
$sock = stream_socket_client(
$address . ':' . $this->port,
$errno, $errstr, 15,
STREAM_CLIENT_CONNECT,
$ctx
);
if (!$sock) {
throw new \RuntimeException("SMTP-Verbindung fehlgeschlagen: {$errstr} ({$errno})");
}
$this->sock = $sock;
stream_set_timeout($this->sock, 15);
$this->expect(220);
$this->ehlo();
if ($this->encryption === 'tls') {
$this->cmd('STARTTLS');
$this->expect(220);
if (!stream_socket_enable_crypto($this->sock, true, STREAM_CRYPTO_METHOD_TLS_CLIENT)) {
throw new \RuntimeException('STARTTLS-Aushandlung fehlgeschlagen.');
}
$this->ehlo();
}
}
private function ehlo(): void
{
$this->cmd('EHLO ' . (gethostname() ?: 'localhost'));
$this->expectMulti(250);
}
// ── Authentifizierung ────────────────────────────────────────
private function authenticate(): void
{
$this->cmd('AUTH LOGIN');
$this->expect(334);
$this->cmd(base64_encode($this->user));
$this->expect(334);
$this->cmd(base64_encode($this->pass));
$this->expect(235);
}
// ── Nachricht ────────────────────────────────────────────────
private function sendMessage(
array $to,
string $subject,
string $body,
?string $attachContent,
?string $attachName,
): void {
$this->cmd('MAIL FROM:<' . $this->fromEmail . '>');
$this->expect(250);
foreach ($to as $addr) {
$this->cmd('RCPT TO:<' . $addr . '>');
$this->expect(250);
}
$this->cmd('DATA');
$this->expect(354);
$raw = $this->buildMessage($to, $subject, $body, $attachContent, $attachName);
// Dot-Stuffing: Zeilen, die mit '.' beginnen, bekommen einen zweiten Punkt
$raw = preg_replace('/^\.$/m', '..', $raw);
fwrite($this->sock, $raw . "\r\n.\r\n");
$this->expect(250);
}
private function buildMessage(
array $to,
string $subject,
string $body,
?string $attachContent,
?string $attachName,
): string {
$date = date('r');
$fromHdr = $this->encodeHeader($this->fromName) . ' <' . $this->fromEmail . '>';
$toHdr = implode(', ', $to);
$subjHdr = $this->encodeHeader($subject);
$msgId = '<' . uniqid('sk', true) . '@' . (gethostname() ?: 'localhost') . '>';
if ($attachContent === null) {
return implode("\r\n", [
'Date: ' . $date,
'Message-ID: ' . $msgId,
'From: ' . $fromHdr,
'To: ' . $toHdr,
'Subject: ' . $subjHdr,
'MIME-Version: 1.0',
'Content-Type: text/plain; charset=UTF-8',
'Content-Transfer-Encoding: quoted-printable',
'',
$this->quotedPrintable($body),
]);
}
$boundary = '----=_Part_' . bin2hex(random_bytes(8));
$b64 = chunk_split(base64_encode($attachContent), 76, "\r\n");
$safeName = $this->encodeHeader($attachName ?? 'Anhang.pdf');
return implode("\r\n", [
'Date: ' . $date,
'Message-ID: ' . $msgId,
'From: ' . $fromHdr,
'To: ' . $toHdr,
'Subject: ' . $subjHdr,
'MIME-Version: 1.0',
'Content-Type: multipart/mixed; boundary="' . $boundary . '"',
'',
'--' . $boundary,
'Content-Type: text/plain; charset=UTF-8',
'Content-Transfer-Encoding: quoted-printable',
'',
$this->quotedPrintable($body),
'',
'--' . $boundary,
'Content-Type: application/pdf; name="' . $safeName . '"',
'Content-Transfer-Encoding: base64',
'Content-Disposition: attachment; filename="' . $safeName . '"',
'',
$b64,
'--' . $boundary . '--',
]);
}
// ── Hilfsmethoden ────────────────────────────────────────────
private function cmd(string $line): void
{
fwrite($this->sock, $line . "\r\n");
}
private function expect(int $code): string
{
$response = fgets($this->sock, 512);
if ($response === false) {
throw new \RuntimeException('SMTP: Keine Antwort vom Server.');
}
if ((int)$response !== $code) {
throw new \RuntimeException('SMTP: Unerwartete Antwort: ' . trim($response));
}
return $response;
}
/** Liest mehrzeilige SMTP-Antworten (z.B. nach EHLO) */
private function expectMulti(int $code): void
{
do {
$line = fgets($this->sock, 512);
if ($line === false) throw new \RuntimeException('SMTP: Verbindung unterbrochen.');
if ((int)$line !== $code) throw new \RuntimeException('SMTP: ' . trim($line));
} while ($line[3] === '-');
}
private function encodeHeader(string $value): string
{
if (mb_detect_encoding($value, 'ASCII', true)) {
return $value;
}
return '=?UTF-8?B?' . base64_encode($value) . '?=';
}
private function quotedPrintable(string $text): string
{
return quoted_printable_encode($text);
}
}

View file

@ -13,6 +13,7 @@ $badgeClass = match($invoice['status']) {
'Bezahlt' => 'badge-green',
'Storniert' => 'badge-red',
'Gesendet' => 'badge-yellow',
'Finalisiert' => 'badge-blue',
default => 'badge-gray',
};
?>
@ -31,12 +32,16 @@ $badgeClass = match($invoice['status']) {
</form>
<?php endif; ?>
<?php if ($invoice['status'] !== 'Entwurf'): ?>
<a href="/invoices/<?= $h($invoice['id']) ?>/pdf" class="btn btn-primary">PDF herunterladen</a>
<a href="/invoices/<?= $h($invoice['id']) ?>/pdf" class="btn btn-secondary">PDF herunterladen</a>
<?php endif; ?>
<?php if (!in_array($invoice['status'], ['Entwurf', 'Storniert'])): ?>
<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 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>
@ -311,3 +316,31 @@ $badgeClass = match($invoice['status']) {
}
}());
</script>
<!-- E-Mail-Senden-Modal -->
<div id="send-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:420px;max-width:95vw;box-shadow:0 8px 32px rgba(0,0,0,.2)">
<h3 style="margin:0 0 1rem">Rechnung per E-Mail senden</h3>
<form method="post" action="/invoices/<?= $h($invoice['id']) ?>/send">
<div class="form-group">
<label>Empfänger *</label>
<input type="email" name="email_to" required
value="<?= $h($invoice['email'] ?? '') ?>"
placeholder="empfaenger@example.com">
</div>
<div class="form-group">
<label>CC 1 <span style="font-weight:normal;color:#888">(optional)</span></label>
<input type="email" name="email_cc1" placeholder="cc1@example.com">
</div>
<div class="form-group">
<label>CC 2 <span style="font-weight:normal;color:#888">(optional)</span></label>
<input type="email" name="email_cc2" placeholder="cc2@example.com">
</div>
<div style="display:flex;gap:.75rem;justify-content:flex-end;margin-top:1.25rem">
<button type="button" class="btn btn-secondary"
onclick="document.getElementById('send-modal').style.display='none'">Abbrechen</button>
<button type="submit" class="btn btn-primary">Senden</button>
</div>
</form>
</div>
</div>

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>
<!-- Bankdaten + Logo + Nummernkreise -->
<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>
<!-- 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,60 +260,57 @@
</div>
</div>
<!-- E-Mail-Versand -->
<div class="card mb-2">
<div class="card-header"><div class="card-title">Bankdaten</div></div>
<div class="card-header"><div class="card-title">E-Mail-Versand (SMTP)</div></div>
<div class="card-body">
<div class="form-group">
<label>Bankname</label>
<input type="text" name="bank_name" value="<?= $h($settings['bank_name'] ?? '') ?>">
<label>SMTP-Host</label>
<input type="text" name="mail_smtp_host" value="<?= $h($settings['mail_smtp_host'] ?? '') ?>" placeholder="smtp.example.com">
</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">
<label>Port</label>
<input type="number" name="mail_smtp_port" value="<?= $h($settings['mail_smtp_port'] ?? 587) ?>" min="1" max="65535">
</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">
<label>Verschlüsselung</label>
<select name="mail_smtp_encryption">
<option value="tls" <?= ($settings['mail_smtp_encryption'] ?? 'tls') === 'tls' ? 'selected' : '' ?>>STARTTLS (Port 587)</option>
<option value="ssl" <?= ($settings['mail_smtp_encryption'] ?? '') === 'ssl' ? 'selected' : '' ?>>SSL/TLS (Port 465)</option>
<option value="none" <?= ($settings['mail_smtp_encryption'] ?? '') === 'none' ? 'selected' : '' ?>>Keine</option>
</select>
</div>
</div>
<div class="form-grid form-grid-2">
<div class="form-group">
<label>SMTP-Benutzername</label>
<input type="text" name="mail_smtp_user" value="<?= $h($settings['mail_smtp_user'] ?? '') ?>" autocomplete="off">
</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">
<label>SMTP-Passwort</label>
<input type="password" name="mail_smtp_pass" value="<?= $h($settings['mail_smtp_pass'] ?? '') ?>" autocomplete="new-password">
</div>
</div>
<div class="form-grid form-grid-2">
<div class="form-group">
<label>Absendername</label>
<input type="text" name="mail_from_name" value="<?= $h($settings['mail_from_name'] ?? '') ?>">
</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">
<label>Absender-E-Mail</label>
<input type="email" name="mail_from_email" value="<?= $h($settings['mail_from_email'] ?? '') ?>">
</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 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>
<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">
@ -334,8 +381,60 @@
</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) {
@ -369,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();