Files

392 lines
13 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
require_once __DIR__ . '/bootstrap.php';
require_once __DIR__ . '/legal.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 {
$pdo->exec('DELETE FROM outbound_emails WHERE created_at < DATE_SUB(NOW(), INTERVAL 180 DAY)');
$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);
}
function saas_send_registration_contract_mail(string $to, string $tenantName, string $tenantSlug, string $customerType): array
{
$body = implode("\n", [
'Vertragsbestätigung Kaffeeliste',
'',
'Kunde: ' . $tenantName,
'Kundenkürzel: ' . $tenantSlug,
'Kundentyp: ' . ($customerType === 'consumer' ? 'Verbraucher' : 'Unternehmer'),
'Tarif: Kostenlos, 0,00 € pro Monat, unbefristet, jederzeit kündbar',
'Vertragsschluss: ' . date('d.m.Y H:i') . ' Uhr (Europe/Berlin)',
'',
'Nachfolgend erhältst du die bei Vertragsschluss geltenden Texte auf einem dauerhaften Datenträger.',
'',
'================ AGB ================',
app_legal_document_text('terms'),
'',
'================ AVV ================',
app_legal_document_text('dpa'),
'',
'========== WIDERRUFSBELEHRUNG ==========',
app_legal_document_text('withdrawal_information'),
]);
return saas_send_mail($to, 'Kaffeeliste Vertragsbestätigung kostenloser Tarif', $body);
}
function saas_send_paid_contract_confirmation(PDO $pdo, int $tenantId, string $planCode): array
{
require_once __DIR__ . '/billing.php';
$stmt = $pdo->prepare(
"SELECT t.name, t.slug, t.customer_type, u.email
FROM tenants t
JOIN tenant_memberships tm ON tm.tenant_id = t.id AND tm.role = 'owner' AND tm.status = 'active'
JOIN users u ON u.id = tm.user_id AND u.status = 'active'
WHERE t.id = ? ORDER BY tm.id LIMIT 1"
);
$stmt->execute([$tenantId]);
$contract = $stmt->fetch();
$plan = billing_plans()[$planCode] ?? null;
if ($contract === false || $plan === null) {
return ['ok' => false, 'transport' => saas_mail_transport(), 'error' => 'contract confirmation data missing'];
}
$body = implode("\n", [
'Vertragsbestätigung Kaffeeliste kostenpflichtiger Tarif',
'',
'Kunde: ' . $contract['name'],
'Kundenkürzel: ' . $contract['slug'],
'Kundentyp: ' . ($contract['customer_type'] === 'consumer' ? 'Verbraucher' : 'Unternehmer'),
'Tarif: ' . $plan['label'],
'Gesamtpreis: ' . number_format(((int)$plan['price_cents']) / 100, 2, ',', '') . ' € pro Monat',
'Laufzeit: unbefristet; monatliche Abrechnung; keine längere Mindestlaufzeit',
'Kündigung: jederzeit zum Ende der laufenden Abrechnungsperiode',
'Bestätigung: ' . date('d.m.Y H:i') . ' Uhr (Europe/Berlin)',
'',
'================ AGB ================',
app_legal_document_text('terms'),
'',
'========== WIDERRUFSBELEHRUNG ==========',
app_legal_document_text('withdrawal_information'),
]);
return saas_send_mail((string)$contract['email'], 'Kaffeeliste Vertragsbestätigung ' . $plan['label'], $body);
}
/**
* Textkoerper der automatischen Zahlungserinnerung an ein Mitglied mit
* offenem Betrag oberhalb der Warnschwelle des Mandanten.
*/
function saas_render_payment_reminder_mail_body(string $displayName, int $debtCents, array $tenantSettings, string $dashboardUrl): string
{
$amount = saas_format_money_cents($debtCents) . ' €';
$lines = [
"Hallo {$displayName},",
'',
"auf deinem Kaffeelisten-Konto ist aktuell ein offener Betrag von {$amount} aufgelaufen.",
"Bitte gleiche ihn 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($debtCents / 100, 2, '.', '');
$lines[] = '';
$lines[] = "Direkt bezahlen: {$paypalLink}";
}
$lines[] = '';
$lines[] = "Deinen aktuellen Stand findest du hier: {$dashboardUrl}";
$lines[] = '';
$lines[] = 'Deine Kaffeeliste';
return implode("\n", $lines);
}
/**
* Liefert das Datum der letzten Zahlungserinnerung je Teilnehmer eines
* Mandanten (nur tatsaechlich versendete). Fuer die Intervall-Pruefung im
* Erinnerungs-Cron, damit niemand haeufiger als eingestellt erinnert wird.
*
* @return array<int, string> participant_id => letztes sent_at ('Y-m-d H:i:s')
*/
function saas_fetch_last_payment_reminders(PDO $pdo, int $tenantId): array
{
$stmt = $pdo->prepare(
"SELECT participant_id, MAX(sent_at) AS last_sent
FROM outbound_emails
WHERE tenant_id = ?
AND template = 'payment_reminder'
AND status = 'sent'
AND participant_id IS NOT NULL
GROUP BY participant_id"
);
$stmt->execute([$tenantId]);
$map = [];
foreach ($stmt->fetchAll() as $row) {
$map[(int)$row['participant_id']] = (string)$row['last_sent'];
}
return $map;
}