jahresauswertung.php verband sich bisher mit fest codierten (kaputten) Zugangsdaten selbst zur Datenbank statt ueber config.php, hatte keine Zugriffskontrolle und kein CSRF, und verteilte bei jedem Aufruf sofort einen hart codierten Bonus-Topf (490 Striche a 0,20 Euro) per PHPMailer (dessen Quelldateien im Repo fehlen) mit AOK-spezifischem Mailtext. Nach Abstimmung mit dem Kunden als generisches, mandantenfaehiges Feature neu gebaut statt nur deaktiviert oder rein lesend umgesetzt: - Admin gibt einen frei waehlbaren Gesamtbetrag ein, das System verteilt ihn proportional zu den Jahresstrichen auf alle aktiven Mitglieder. - Standardmaessig aktive Dry-Run-Checkbox zeigt die Verteilung, ohne zu buchen oder Mails zu verschicken. - Bestaetigter Lauf bucht ueber dasselbe Zweig-Muster wie ueberall (Default-Mandant Dual-Write, andere Mandanten ledger_record_payment) und verschickt personalisierte Mails ueber saas_send_mail(), protokolliert im outbound_emails-Versandlog. - Zugriffskontrolle ergaenzt (owner/admin/treasurer + Legacy-Fallback). - http-smoke.php: jahresauswertung.php jetzt regulaerer Check statt uebersprungenem unsicherem Aufruf; damit sind keine Seiten mehr uebersprungen oder als bekannter offener Punkt markiert (26/26 gruen). Live getestet: Dry-Run mit korrekter proportionaler Verteilung (Summe ergibt exakt den Gesamtbetrag), Live-Lauf bucht und versendet korrekt, Testdaten anschliessend vollstaendig entfernt. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
269 lines
8.0 KiB
PHP
269 lines
8.0 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
require_once __DIR__ . '/bootstrap.php';
|
|
|
|
function saas_mail_transport(): string
|
|
{
|
|
$default = app_is_dev() ? 'log' : 'mail';
|
|
|
|
return strtolower((string)app_env('APP_MAIL_TRANSPORT', $default));
|
|
}
|
|
|
|
function saas_mail_from(): string
|
|
{
|
|
return (string)app_env('APP_MAIL_FROM', 'noreply@kaffeeliste.local');
|
|
}
|
|
|
|
function saas_mail_from_name(): string
|
|
{
|
|
return (string)app_env('APP_MAIL_FROM_NAME', 'Kaffeeliste');
|
|
}
|
|
|
|
function saas_app_base_url(): string
|
|
{
|
|
$configured = app_env('APP_BASE_URL');
|
|
if ($configured !== null) {
|
|
return rtrim($configured, '/');
|
|
}
|
|
|
|
if (PHP_SAPI !== 'cli' && !empty($_SERVER['HTTP_HOST'])) {
|
|
$scheme = app_is_https() ? 'https' : 'http';
|
|
|
|
return $scheme . '://' . rtrim((string)$_SERVER['HTTP_HOST'], '/');
|
|
}
|
|
|
|
return 'http://localhost';
|
|
}
|
|
|
|
function saas_app_url(string $path): string
|
|
{
|
|
return saas_app_base_url() . '/' . ltrim($path, '/');
|
|
}
|
|
|
|
function saas_mail_headers(): string
|
|
{
|
|
$fromName = str_replace(["\r", "\n"], '', saas_mail_from_name());
|
|
$from = str_replace(["\r", "\n"], '', saas_mail_from());
|
|
|
|
return implode("\r\n", [
|
|
"From: {$fromName} <{$from}>",
|
|
"Reply-To: {$from}",
|
|
'Content-Type: text/plain; charset=UTF-8',
|
|
]);
|
|
}
|
|
|
|
function saas_log_mail(string $to, string $subject, string $body): array
|
|
{
|
|
$logDir = (string)app_env('APP_MAIL_LOG_DIR', APP_ROOT . '/var/mail');
|
|
if (!is_dir($logDir)) {
|
|
@mkdir($logDir, 0700, true);
|
|
}
|
|
|
|
if (!is_dir($logDir) || !is_writable($logDir)) {
|
|
return [
|
|
'ok' => false,
|
|
'transport' => 'log',
|
|
'error' => 'mail log directory is not writable',
|
|
];
|
|
}
|
|
|
|
$file = rtrim($logDir, '/') . '/mail_' . date('Ymd_His') . '_' . bin2hex(random_bytes(6)) . '.txt';
|
|
$content = "To: {$to}\nSubject: {$subject}\n\n{$body}\n";
|
|
|
|
if (file_put_contents($file, $content) === false) {
|
|
return [
|
|
'ok' => false,
|
|
'transport' => 'log',
|
|
'error' => 'mail log write failed',
|
|
];
|
|
}
|
|
|
|
return [
|
|
'ok' => true,
|
|
'transport' => 'log',
|
|
'path' => $file,
|
|
];
|
|
}
|
|
|
|
function saas_send_mail(string $to, string $subject, string $body): array
|
|
{
|
|
$to = trim($to);
|
|
if (!filter_var($to, FILTER_VALIDATE_EMAIL)) {
|
|
return [
|
|
'ok' => false,
|
|
'transport' => saas_mail_transport(),
|
|
'error' => 'invalid recipient',
|
|
];
|
|
}
|
|
|
|
if (saas_mail_transport() === 'log') {
|
|
return saas_log_mail($to, $subject, $body);
|
|
}
|
|
|
|
$sent = mail($to, $subject, $body, saas_mail_headers());
|
|
|
|
return [
|
|
'ok' => $sent,
|
|
'transport' => 'mail',
|
|
'error' => $sent ? null : 'mail transport failed',
|
|
];
|
|
}
|
|
|
|
function saas_send_password_reset_mail(string $to, string $token): array
|
|
{
|
|
$link = saas_app_url('passwort-zuruecksetzen.php?token=' . urlencode($token));
|
|
$body = implode("\n", [
|
|
'Hallo,',
|
|
'',
|
|
'für dein Kaffeeliste-Konto wurde ein Passwort-Reset angefordert.',
|
|
'Öffne diesen Link, um ein neues Passwort zu setzen:',
|
|
'',
|
|
$link,
|
|
'',
|
|
'Der Link ist zeitlich begrenzt und kann nur einmal verwendet werden.',
|
|
'Falls du den Reset nicht angefordert hast, kannst du diese Nachricht ignorieren.',
|
|
'',
|
|
'Deine Kaffeeliste',
|
|
]);
|
|
|
|
return saas_send_mail($to, 'Kaffeeliste Passwort zurücksetzen', $body);
|
|
}
|
|
|
|
/**
|
|
* Renders the tenant-generic balance notification body. Replaces the old
|
|
* mailversenden.php text, which hardcoded one customer's SMTP host,
|
|
* PayPal link and FAQ URL and therefore only ever worked for that one
|
|
* tenant.
|
|
*/
|
|
function saas_render_balance_mail_body(string $displayName, int $balanceCents, array $tenantSettings, string $dashboardUrl): string
|
|
{
|
|
$amount = saas_format_money_cents(abs($balanceCents)) . ' €';
|
|
$lines = ["Hallo {$displayName},", ''];
|
|
|
|
if ($balanceCents < 0) {
|
|
$lines[] = "dein aktueller Kaffeelisten-Stand ist im Minus: {$amount}.";
|
|
$lines[] = "Bitte gleiche den Betrag bei Gelegenheit aus.";
|
|
if ((int)($tenantSettings['paypal_enabled'] ?? 0) === 1 && trim((string)($tenantSettings['paypal_url_template'] ?? '')) !== '') {
|
|
$paypalLink = trim((string)$tenantSettings['paypal_url_template']) . number_format(abs($balanceCents) / 100, 2, '.', '');
|
|
$lines[] = '';
|
|
$lines[] = "Direkt bezahlen: {$paypalLink}";
|
|
}
|
|
} else {
|
|
$lines[] = "dein aktuelles Guthaben in der Kaffeeliste beträgt {$amount}.";
|
|
}
|
|
|
|
$lines[] = '';
|
|
$lines[] = "Deinen aktuellen Stand findest du hier: {$dashboardUrl}";
|
|
$lines[] = '';
|
|
$lines[] = 'Deine Kaffeeliste';
|
|
|
|
return implode("\n", $lines);
|
|
}
|
|
|
|
function saas_log_outbound_email(
|
|
PDO $pdo,
|
|
int $tenantId,
|
|
?int $participantId,
|
|
string $template,
|
|
string $subject,
|
|
string $status,
|
|
?string $error,
|
|
?int $createdByUserId
|
|
): int {
|
|
$stmt = $pdo->prepare(
|
|
'INSERT INTO outbound_emails
|
|
(tenant_id, participant_id, template, subject, status, sent_at, error, created_by_user_id)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
|
|
);
|
|
$stmt->execute([
|
|
$tenantId,
|
|
$participantId,
|
|
$template,
|
|
$subject,
|
|
$status,
|
|
in_array($status, ['sent', 'failed'], true) ? date('Y-m-d H:i:s') : null,
|
|
$error,
|
|
$createdByUserId,
|
|
]);
|
|
|
|
return (int)$pdo->lastInsertId();
|
|
}
|
|
|
|
/**
|
|
* @return list<array{id: int, participant_id: ?int, display_name: ?string, template: string, subject: string, status: string, sent_at: ?string, error: ?string, created_at: string}>
|
|
*/
|
|
function saas_fetch_outbound_email_log(PDO $pdo, int $tenantId, int $limit = 50): array
|
|
{
|
|
$limit = max(1, min($limit, 200));
|
|
$stmt = $pdo->prepare(
|
|
"SELECT o.id, o.participant_id, p.display_name, o.template, o.subject, o.status, o.sent_at, o.error, o.created_at
|
|
FROM outbound_emails o
|
|
LEFT JOIN participants p ON p.id = o.participant_id
|
|
WHERE o.tenant_id = ?
|
|
ORDER BY o.created_at DESC, o.id DESC
|
|
LIMIT {$limit}"
|
|
);
|
|
$stmt->execute([$tenantId]);
|
|
|
|
return $stmt->fetchAll();
|
|
}
|
|
|
|
function saas_render_year_end_bonus_mail_body(string $displayName, int $yearMarks, int $bonusCents, string $dashboardUrl): string
|
|
{
|
|
$bonus = saas_format_money_cents($bonusCents) . ' €';
|
|
$lines = [
|
|
"Hallo {$displayName},",
|
|
'',
|
|
"vielen Dank für deine Nutzung der Kaffeeliste in diesem Jahr!",
|
|
"Du hast dieses Jahr {$yearMarks} Striche gemacht.",
|
|
"Dafür wurden dir {$bonus} auf deinem Kaffeelisten-Konto gutgeschrieben.",
|
|
'',
|
|
"Deinen aktuellen Stand findest du hier: {$dashboardUrl}",
|
|
'',
|
|
'Deine Kaffeeliste',
|
|
];
|
|
|
|
return implode("\n", $lines);
|
|
}
|
|
|
|
function saas_send_invite_mail(string $to, string $tenantName, string $role, string $token): array
|
|
{
|
|
$link = saas_app_url('passwort-zuruecksetzen.php?token=' . urlencode($token));
|
|
$body = implode("\n", [
|
|
'Hallo,',
|
|
'',
|
|
"du wurdest für \"{$tenantName}\" in der Kaffeeliste als {$role} eingeladen.",
|
|
'Öffne diesen Link, um ein Passwort zu setzen und dich anzumelden:',
|
|
'',
|
|
$link,
|
|
'',
|
|
'Der Link ist zeitlich begrenzt und kann nur einmal verwendet werden.',
|
|
'Falls du diese Einladung nicht erwartet hast, kannst du diese Nachricht ignorieren.',
|
|
'',
|
|
'Deine Kaffeeliste',
|
|
]);
|
|
|
|
return saas_send_mail($to, 'Einladung zur Kaffeeliste', $body);
|
|
}
|
|
|
|
function saas_send_email_verification_mail(string $to, string $token): array
|
|
{
|
|
$link = saas_app_url('email-verifizieren.php?token=' . urlencode($token));
|
|
$body = implode("\n", [
|
|
'Hallo,',
|
|
'',
|
|
'bitte bestätige deine E-Mail-Adresse für Kaffeeliste.',
|
|
'Öffne dazu diesen Link:',
|
|
'',
|
|
$link,
|
|
'',
|
|
'Der Link ist zeitlich begrenzt und kann nur einmal verwendet werden.',
|
|
'',
|
|
'Deine Kaffeeliste',
|
|
]);
|
|
|
|
return saas_send_mail($to, 'Kaffeeliste E-Mail bestätigen', $body);
|
|
}
|