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:
commit
586d39f260
16 changed files with 680 additions and 60 deletions
8
migrations/039_smtp_settings.sql
Normal file
8
migrations/039_smtp_settings.sql
Normal 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;
|
||||||
3
migrations/040_invoice_status_finalisiert.sql
Normal file
3
migrations/040_invoice_status_finalisiert.sql
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
ALTER TABLE invoices
|
||||||
|
MODIFY COLUMN status ENUM('Entwurf','Finalisiert','Gesendet','Bezahlt','Storniert')
|
||||||
|
NOT NULL DEFAULT 'Entwurf';
|
||||||
3
migrations/041_mail_templates.sql
Normal file
3
migrations/041_mail_templates.sql
Normal 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;
|
||||||
|
|
@ -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}/delete', fn($p) => (new InvoiceController())->destroy($p));
|
||||||
$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));
|
||||||
|
|
||||||
// ── Anlagevermögen ───────────────────────────────────────────
|
// ── Anlagevermögen ───────────────────────────────────────────
|
||||||
$router->get('/assets', fn($p) => (new AssetController())->index($p));
|
$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 ────────────────────────────────────────────
|
// ── Einstellungen ────────────────────────────────────────────
|
||||||
$router->get('/settings', fn($p) => (new SettingsController())->show($p));
|
$router->get('/settings', fn($p) => (new SettingsController())->show($p));
|
||||||
$router->post('/settings', fn($p) => (new SettingsController())->update($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', fn($p) => (new SettingsController())->uploadLogo($p));
|
||||||
$router->post('/settings/logo/delete', fn($p) => (new SettingsController())->deleteLogo($p));
|
$router->post('/settings/logo/delete', fn($p) => (new SettingsController())->deleteLogo($p));
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ use Steuerkram\Models\Beleg;
|
||||||
use Steuerkram\Models\Booking;
|
use Steuerkram\Models\Booking;
|
||||||
use Steuerkram\Models\Account;
|
use Steuerkram\Models\Account;
|
||||||
use Steuerkram\Models\Setting;
|
use Steuerkram\Models\Setting;
|
||||||
|
use Steuerkram\Services\EmailService;
|
||||||
use Steuerkram\Services\PdfInvoiceService;
|
use Steuerkram\Services\PdfInvoiceService;
|
||||||
use Steuerkram\View;
|
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
|
public function pdf(array $params): void
|
||||||
{
|
{
|
||||||
$invoice = Invoice::find((int)$params['id']);
|
$invoice = Invoice::find((int)$params['id']);
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||||
namespace Steuerkram\Controllers;
|
namespace Steuerkram\Controllers;
|
||||||
|
|
||||||
use Steuerkram\Models\Setting;
|
use Steuerkram\Models\Setting;
|
||||||
|
use Steuerkram\Services\EmailService;
|
||||||
use Steuerkram\View;
|
use Steuerkram\View;
|
||||||
|
|
||||||
class SettingsController
|
class SettingsController
|
||||||
|
|
@ -89,6 +90,44 @@ class SettingsController
|
||||||
View::redirect('/settings', 'Logo hochgeladen.');
|
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
|
public function deleteLogo(array $params): void
|
||||||
{
|
{
|
||||||
Setting::deleteLogo();
|
Setting::deleteLogo();
|
||||||
|
|
|
||||||
|
|
@ -150,7 +150,7 @@ class Invoice
|
||||||
$fy = FiscalYear::findOrCreateByYear($year);
|
$fy = FiscalYear::findOrCreateByYear($year);
|
||||||
|
|
||||||
Database::execute(
|
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]
|
[$nr, $fy['id'], $id]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,10 @@ class Setting
|
||||||
payment_days_default=?, test_mode=?, ki_vorschlag_aktiv=?,
|
payment_days_default=?, test_mode=?, ki_vorschlag_aktiv=?,
|
||||||
nextcloud_url=?, nextcloud_user=?, nextcloud_pass=?,
|
nextcloud_url=?, nextcloud_user=?, nextcloud_pass=?,
|
||||||
nextcloud_folder=?, nextcloud_done=?,
|
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_max_width=?, logo_max_height=?,
|
||||||
logo_position=?, logo_margin_side=?, logo_margin_top=?
|
logo_position=?, logo_margin_side=?, logo_margin_top=?
|
||||||
WHERE id=1',
|
WHERE id=1',
|
||||||
|
|
@ -68,6 +72,15 @@ class Setting
|
||||||
$data['nextcloud_pass'] ?: null,
|
$data['nextcloud_pass'] ?: null,
|
||||||
$data['nextcloud_folder'] ?: '/Belege/Import',
|
$data['nextcloud_folder'] ?: '/Belege/Import',
|
||||||
$data['nextcloud_done'] ?: null,
|
$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(50, (int)($data['logo_max_width'] ?? 600)),
|
||||||
max(20, (int)($data['logo_max_height'] ?? 100)),
|
max(20, (int)($data['logo_max_height'] ?? 100)),
|
||||||
$logoPosition,
|
$logoPosition,
|
||||||
|
|
|
||||||
81
src/Services/EmailService.php
Normal file
81
src/Services/EmailService.php
Normal 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'] ?? ''),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -8,7 +8,24 @@ use Mpdf\Mpdf;
|
||||||
|
|
||||||
class PdfInvoiceService
|
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
|
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);
|
$html = $this->buildHtml($invoice, $lines, $settings);
|
||||||
|
|
||||||
|
|
@ -40,18 +57,11 @@ class PdfInvoiceService
|
||||||
$mpdf->WriteHTML($this->buildCss(), \Mpdf\HTMLParserMode::HEADER_CSS);
|
$mpdf->WriteHTML($this->buildCss(), \Mpdf\HTMLParserMode::HEADER_CSS);
|
||||||
$mpdf->WriteHTML($html, \Mpdf\HTMLParserMode::HTML_BODY);
|
$mpdf->WriteHTML($html, \Mpdf\HTMLParserMode::HTML_BODY);
|
||||||
|
|
||||||
$filename = 'Rechnung-' . ($invoice['rechnungs_nr'] ?? 'Entwurf') . '.pdf';
|
|
||||||
$pdfContent = $mpdf->Output('', 'S');
|
$pdfContent = $mpdf->Output('', 'S');
|
||||||
|
|
||||||
// ZUGFeRD EN16931-XML einbetten (E-Rechnung)
|
// ZUGFeRD EN16931-XML einbetten (E-Rechnung)
|
||||||
$zugferd = new ZugferdService();
|
$zugferd = new ZugferdService();
|
||||||
$pdfContent = $zugferd->attachToPdf($pdfContent, $invoice, $lines, $settings);
|
return $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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private function buildHtml(array $inv, array $lines, array $settings): string
|
private function buildHtml(array $inv, array $lines, array $settings): string
|
||||||
|
|
|
||||||
237
src/Services/SmtpMailer.php
Normal file
237
src/Services/SmtpMailer.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -10,10 +10,11 @@ $customerName = !empty($invoice['firma'])
|
||||||
$lzHtml = $h($invoice['leistungszeitraum'] ?? '');
|
$lzHtml = $h($invoice['leistungszeitraum'] ?? '');
|
||||||
|
|
||||||
$badgeClass = match($invoice['status']) {
|
$badgeClass = match($invoice['status']) {
|
||||||
'Bezahlt' => 'badge-green',
|
'Bezahlt' => 'badge-green',
|
||||||
'Storniert' => 'badge-red',
|
'Storniert' => 'badge-red',
|
||||||
'Gesendet' => 'badge-yellow',
|
'Gesendet' => 'badge-yellow',
|
||||||
default => 'badge-gray',
|
'Finalisiert' => 'badge-blue',
|
||||||
|
default => 'badge-gray',
|
||||||
};
|
};
|
||||||
?>
|
?>
|
||||||
|
|
||||||
|
|
@ -31,12 +32,16 @@ $badgeClass = match($invoice['status']) {
|
||||||
</form>
|
</form>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<?php if ($invoice['status'] !== 'Entwurf'): ?>
|
<?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 endif; ?>
|
||||||
<?php if ($invoice['status'] === 'Gesendet'): ?>
|
<?php if ($invoice['status'] === 'Gesendet'): ?>
|
||||||
<a href="/invoices/<?= $h($invoice['id']) ?>/book" class="btn btn-success">Zahlungseingang erfassen</a>
|
<a href="/invoices/<?= $h($invoice['id']) ?>/book" class="btn btn-success">Zahlungseingang erfassen</a>
|
||||||
<?php endif; ?>
|
<?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"
|
<form method="post" action="/invoices/<?= $h($invoice['id']) ?>/cancel"
|
||||||
data-confirm="Rechnung stornieren?">
|
data-confirm="Rechnung stornieren?">
|
||||||
<button type="submit" class="btn btn-danger">Stornieren</button>
|
<button type="submit" class="btn btn-danger">Stornieren</button>
|
||||||
|
|
@ -311,3 +316,31 @@ $badgeClass = match($invoice['status']) {
|
||||||
}
|
}
|
||||||
}());
|
}());
|
||||||
</script>
|
</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>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,10 @@
|
||||||
<?php use Steuerkram\View; $h = fn($v) => View::h($v); ?>
|
<?php use Steuerkram\View; $h = fn($v) => View::h($v); ?>
|
||||||
|
|
||||||
<form id="settings-form" method="post" action="/settings">
|
<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">
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1.5rem;align-items:start">
|
||||||
|
|
||||||
<!-- Firmendaten -->
|
<!-- Firmendaten -->
|
||||||
|
|
@ -81,9 +85,55 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Bankdaten + Logo + Nummernkreise -->
|
<!-- rechte Spalte -->
|
||||||
<div>
|
<div>
|
||||||
<!-- Betriebsmodus -->
|
<!-- Betriebsmodus -->
|
||||||
<div class="card mb-2" style="border:2px solid <?= ($settings['test_mode'] ?? 1) ? 'var(--color-warning,#f59e0b)' : 'var(--color-success,#22c55e)' ?>">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- E-Mail-Versand -->
|
||||||
<div class="card mb-2">
|
<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="card-body">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Bankname</label>
|
<label>SMTP-Host</label>
|
||||||
<input type="text" name="bank_name" value="<?= $h($settings['bank_name'] ?? '') ?>">
|
<input type="text" name="mail_smtp_host" value="<?= $h($settings['mail_smtp_host'] ?? '') ?>" placeholder="smtp.example.com">
|
||||||
</div>
|
</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-grid form-grid-2">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Rechnungs-Präfix</label>
|
<label>Port</label>
|
||||||
<input type="text" name="invoice_nr_prefix" value="<?= $h($settings['invoice_nr_prefix'] ?? 'AR') ?>" maxlength="10">
|
<input type="number" name="mail_smtp_port" value="<?= $h($settings['mail_smtp_port'] ?? 587) ?>" min="1" max="65535">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Beleg-Präfix</label>
|
<label>Verschlüsselung</label>
|
||||||
<input type="text" name="beleg_nr_prefix" value="<?= $h($settings['beleg_nr_prefix'] ?? 'BLG') ?>" maxlength="10">
|
<select name="mail_smtp_encryption">
|
||||||
</div>
|
<option value="tls" <?= ($settings['mail_smtp_encryption'] ?? 'tls') === 'tls' ? 'selected' : '' ?>>STARTTLS (Port 587)</option>
|
||||||
<div class="form-group">
|
<option value="ssl" <?= ($settings['mail_smtp_encryption'] ?? '') === 'ssl' ? 'selected' : '' ?>>SSL/TLS (Port 465)</option>
|
||||||
<label>Anlagen-Präfix</label>
|
<option value="none" <?= ($settings['mail_smtp_encryption'] ?? '') === 'none' ? 'selected' : '' ?>>Keine</option>
|
||||||
<input type="text" name="anlage_nr_prefix" value="<?= $h($settings['anlage_nr_prefix'] ?? 'ANL') ?>" maxlength="10">
|
</select>
|
||||||
</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>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-grid form-grid-2">
|
||||||
<label>Standard-Zahlungsziel (Tage)</label>
|
<div class="form-group">
|
||||||
<input type="number" name="payment_days_default" value="<?= $h($settings['payment_days_default'] ?? 14) ?>" min="0">
|
<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>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>Absender-E-Mail</label>
|
||||||
|
<input type="email" name="mail_from_email" value="<?= $h($settings['mail_from_email'] ?? '') ?>">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<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>
|
</div>
|
||||||
|
|
||||||
</div>
|
<!-- Logo -->
|
||||||
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1.5rem;margin-top:0">
|
|
||||||
<div></div>
|
|
||||||
<div>
|
|
||||||
<div class="card mb-2">
|
<div class="card mb-2">
|
||||||
<div class="card-header"><div class="card-title">Logo</div></div>
|
<div class="card-header"><div class="card-title">Logo</div></div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
|
|
@ -334,9 +381,61 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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 · <code>%FNAME%</code> = Firmenname · <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>
|
||||||
</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>
|
<script>
|
||||||
function updateModeLabel(cb) {
|
function updateModeLabel(cb) {
|
||||||
const track = document.getElementById('toggle_track');
|
const track = document.getElementById('toggle_track');
|
||||||
|
|
@ -369,4 +468,50 @@ function logoPositionChanged() {
|
||||||
document.getElementById('logo-margin-fields').style.display =
|
document.getElementById('logo-margin-fields').style.display =
|
||||||
(pos === 'left' || pos === 'right') ? 'block' : 'none';
|
(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>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -63,7 +63,7 @@ class InvoiceBuchungTest extends TestCase
|
||||||
$this->assertNotEmpty($nr, 'Rechnung wurde finalisiert (Rechnungsnummer erhalten)');
|
$this->assertNotEmpty($nr, 'Rechnung wurde finalisiert (Rechnungsnummer erhalten)');
|
||||||
|
|
||||||
$invoice = \Steuerkram\Models\Invoice::find($invoiceId);
|
$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)
|
// Buchungen erzeugen (EÜR: Einnahme)
|
||||||
$belegNr = \Steuerkram\Models\Beleg::generateBelegNr();
|
$belegNr = \Steuerkram\Models\Beleg::generateBelegNr();
|
||||||
|
|
|
||||||
|
|
@ -62,7 +62,7 @@ class InvoiceToReportsTest extends TestCase
|
||||||
$this->assertNotEmpty($rechnungsNr, 'Rechnungsnummer nach Finalisierung vorhanden');
|
$this->assertNotEmpty($rechnungsNr, 'Rechnungsnummer nach Finalisierung vorhanden');
|
||||||
|
|
||||||
$invoice = \Steuerkram\Models\Invoice::find($invoiceId);
|
$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) ──
|
// ── Beleg und Buchungen anlegen (wie InvoiceController::createBelegAndBookings) ──
|
||||||
$belegNr = \Steuerkram\Models\Beleg::generateBelegNr();
|
$belegNr = \Steuerkram\Models\Beleg::generateBelegNr();
|
||||||
|
|
|
||||||
|
|
@ -65,7 +65,7 @@ class IstVersteuerungTest extends TestCase
|
||||||
$this->assertNotEmpty($nr, 'Rechnungsnummer vergeben');
|
$this->assertNotEmpty($nr, 'Rechnungsnummer vergeben');
|
||||||
|
|
||||||
$invoice = \Steuerkram\Models\Invoice::find($invoiceId);
|
$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)
|
// Beleg anlegen (wie in InvoiceController::createBeleg)
|
||||||
$belegNr = \Steuerkram\Models\Beleg::generateBelegNr();
|
$belegNr = \Steuerkram\Models\Beleg::generateBelegNr();
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue