Sicherheits-/Korrektheits-Fixes und neue Funktionen

Bugfixes aus dem Code-Review:
- XSS: unescaptes $_SERVER['PHP_SELF'] in csvupload.php und
  letzteneintraege.php durch feste Seitennamen ersetzt.
- Stripe-Webhook: Event-Deduplizierung (neue Tabelle stripe_webhook_events)
  gegen doppelte Verarbeitung/Dolibarr-Rechnungen bei Retry-Zustellung.
- Stripe-Webhook: Tarifwechsel aus dem Kundenportal wird lokal nachgezogen
  (plan_code aus dem Preis-lookup_key bei subscription.updated).
- Post-Redirect-Get fuer jahresauswertung, mailversenden und die
  Selbst-Stricheintragung - verhindert Doppelbuchung/Doppelversand per
  Browser-Refresh.
- Jahresbonus: Mails erst nach erfolgreichem Commit; Restcent-Ausgleich
  beim letzten Empfaenger, damit die Summe exakt stimmt.
- Verschachtelte HTML-Dokumente in csvupload/einzahlung/stricheintragen
  entfernt (Layout kommt aus header.php).
- Rate-Limit fuer den Versand von E-Mail-Verifizierungslinks.

Neue Funktionen:
- Mitglieder koennen ihren zuletzt selbst eingetragenen Strich wieder
  stornieren (nur eigene Web-Eintraege, Kassenwart-Eintraege bleiben).
- Monatsuebersicht des eigenen Verbrauchs im Mitglieder-Dashboard.
- Automatische Zahlungserinnerung: opt-in pro Mandant ab der
  Warnschwelle, mit Intervall; Cron-Skript scripts/send-payment-reminders.php.
- CSV-Import fuer Mitgliederlisten inkl. herunterladbarer Vorlage
  (mitglieder-vorlage.php), Semikolon-/Komma- und BOM-Erkennung.
- Logo als PDF-Wasserzeichen pro Mandant (Upload in den Mandant-
  Einstellungen, geschuetzt unter var/tenant_logos/, ersetzt den
  Text-Wasserzeichen im Ausdruck).

Robusterer Teilnehmer-Lookup im Dashboard ueber user_id (Fallback E-Mail).
This commit is contained in:
2026-07-20 21:54:16 +02:00
parent c4cb12b26e
commit ef5d0e1822
23 changed files with 918 additions and 160 deletions
+40
View File
@@ -111,6 +111,46 @@ function billing_update(PDO $pdo, int $tenantId, array $fields): void
->execute($params);
}
/**
* Ordnet einen Stripe-Preis (per lookup_key) wieder einem Plan-Code zu. Wird
* gebraucht, um einen Tarifwechsel aus dem Stripe-Kundenportal lokal
* nachzuziehen. Liefert null, wenn kein Plan zum lookup_key passt.
*/
function billing_plan_code_by_lookup_key(string $lookupKey): ?string
{
if ($lookupKey === '') {
return null;
}
foreach (billing_plans() as $code => $plan) {
if (($plan['stripe_lookup_key'] ?? null) === $lookupKey) {
return $code;
}
}
return null;
}
/**
* Idempotenz-Schranke fuer den Stripe-Webhook: reserviert die Event-ID per
* INSERT IGNORE. Liefert true, wenn das Event bereits verarbeitet wurde (also
* uebersprungen werden soll), sonst false (frisch reserviert, darf verarbeitet
* werden). Eine leere Event-ID gilt als "neu", damit ein unerwartetes Payload
* nicht faelschlich als Duplikat durchrutscht.
*/
function billing_webhook_event_seen(PDO $pdo, string $eventId, string $eventType): bool
{
if ($eventId === '') {
return false;
}
$stmt = $pdo->prepare(
'INSERT IGNORE INTO stripe_webhook_events (event_id, event_type) VALUES (?, ?)'
);
$stmt->execute([$eventId, $eventType]);
return $stmt->rowCount() === 0;
}
function billing_find_tenant_id_by_stripe_customer(PDO $pdo, string $stripeCustomerId): ?int
{
$stmt = $pdo->prepare('SELECT tenant_id FROM tenant_billing WHERE stripe_customer_id = ?');
+48
View File
@@ -263,3 +263,51 @@ function imports_commit_batch(PDO $pdo, int $tenantId, int $batchId): array
return ['imported' => $imported];
}
/**
* Parst eine Mitglieder-CSV (Vorlage: Name, E-Mail, PayPal-Name, Aktiv). Erste
* Zeile ist die Kopfzeile und wird uebersprungen. Trennzeichen wird anhand der
* Kopfzeile automatisch erkannt (Komma oder Semikolon - Excel im deutschen
* Gebietsschema exportiert Semikolon).
*
* @return list<array{row_number:int, name:string, email:string, paypal_name:string, active:bool}>
*/
function imports_parse_member_csv(string $filePath): array
{
$handle = fopen($filePath, 'r');
if ($handle === false) {
throw new RuntimeException('Die CSV-Datei konnte nicht gelesen werden.');
}
$firstLine = fgets($handle);
if ($firstLine === false) {
fclose($handle);
return [];
}
// BOM entfernen, Trennzeichen erkennen.
$firstLine = preg_replace('/^\xEF\xBB\xBF/', '', $firstLine);
$delimiter = (substr_count($firstLine, ';') > substr_count($firstLine, ',')) ? ';' : ',';
$rows = [];
$rowNumber = 0;
while (($data = fgetcsv($handle, 2000, $delimiter)) !== false) {
// Komplett leere Zeilen ueberspringen.
if ($data === [null] || (count($data) === 1 && trim((string)$data[0]) === '')) {
continue;
}
$rowNumber++;
$aktivRaw = strtolower(trim((string)($data[3] ?? 'ja')));
$active = !in_array($aktivRaw, ['nein', 'no', '0', 'false', 'inaktiv'], true);
$rows[] = [
'row_number' => $rowNumber,
'name' => trim((string)($data[0] ?? '')),
'email' => trim((string)($data[1] ?? '')),
'paypal_name' => trim((string)($data[2] ?? '')),
'active' => $active,
];
}
fclose($handle);
return $rows;
}
+88
View File
@@ -621,6 +621,60 @@ function ledger_void_entry_by_legacy_id(PDO $pdo, int $tenantId, string $legacyT
return $stmt->rowCount() > 0;
}
/**
* Storniert den juengsten, noch nicht stornierten Web-Selbsteintrag eines
* Teilnehmers (Quelle 'self_entry' bei SaaS-nativen Mandanten bzw. 'legacy_web'
* beim migrierten Default-Mandanten). Bewusst nur eigene Web-Eintraege - vom
* Kassenwart per Sammelerfassung gesetzte Striche ('manual_bulk'/'legacy_manual')
* bleiben unangetastet. Beim Default-Mandanten wird zusaetzlich die
* kl_Kaffeeverbrauch-Zeile geloescht, damit sie nicht erneut gespiegelt wird
* und auf den Legacy-Sammelseiten verschwindet.
*
* @return array{ok: bool, marks?: int, error?: string}
*/
function ledger_void_own_self_entry(PDO $pdo, int $tenantId, int $participantId): array
{
$stmt = $pdo->prepare(
"SELECT id, marks_count, legacy_table, legacy_id
FROM ledger_entries
WHERE tenant_id = ?
AND participant_id = ?
AND type = 'consumption'
AND voided_at IS NULL
AND source IN ('self_entry', 'legacy_web')
ORDER BY booked_at DESC, id DESC
LIMIT 1"
);
$stmt->execute([$tenantId, $participantId]);
$entry = $stmt->fetch();
if ($entry === false) {
return ['ok' => false, 'error' => 'Es gibt keinen selbst eingetragenen Strich, der storniert werden könnte.'];
}
try {
$pdo->beginTransaction();
if ($entry['legacy_table'] === 'kl_Kaffeeverbrauch' && $entry['legacy_id'] !== null) {
$pdo->prepare('DELETE FROM kl_Kaffeeverbrauch WHERE VerbrauchID = ?')
->execute([(int)$entry['legacy_id']]);
}
$pdo->prepare("UPDATE ledger_entries SET voided_at = NOW() WHERE id = ? AND voided_at IS NULL")
->execute([(int)$entry['id']]);
$pdo->commit();
} catch (Throwable $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
return ['ok' => false, 'error' => 'Der Strich konnte nicht storniert werden.'];
}
return ['ok' => true, 'marks' => (int)($entry['marks_count'] ?? 0)];
}
/**
* Records a consumption entry straight into the ledger, without any legacy
* kl_Kaffeeverbrauch row. Used for tenants that have no legacy shadow table
@@ -709,6 +763,40 @@ function ledger_fetch_participants_by_window_marks(PDO $pdo, int $tenantId, int
*
* @return list<array{id: int, tenant_id: int, participant_id: int, display_name: string, type: string, amount_cents: int, marks_count: ?int, unit_price_cents: ?int, booked_at: string, source: string, note: ?string, legacy_table: ?string, legacy_id: ?int, voided_at: ?string}>
*/
/**
* Verbrauch eines Teilnehmers pro Monat eines Jahres (nur nicht stornierte
* Consumption-Buchungen). Fuer die Monatsuebersicht im Mitglieder-Dashboard.
*
* @return array<int, array{marks: int, cost_cents: int}> Schluessel 1..12
*/
function ledger_fetch_monthly_consumption(PDO $pdo, int $tenantId, int $participantId, int $year): array
{
$stmt = $pdo->prepare(
"SELECT MONTH(booked_at) AS m,
COALESCE(SUM(marks_count), 0) AS marks,
COALESCE(SUM(-amount_cents), 0) AS cost_cents
FROM ledger_entries
WHERE tenant_id = ?
AND participant_id = ?
AND type = 'consumption'
AND voided_at IS NULL
AND YEAR(booked_at) = ?
GROUP BY MONTH(booked_at)"
);
$stmt->execute([$tenantId, $participantId, $year]);
$months = [];
for ($m = 1; $m <= 12; $m++) {
$months[$m] = ['marks' => 0, 'cost_cents' => 0];
}
foreach ($stmt->fetchAll() as $row) {
$m = (int)$row['m'];
$months[$m] = ['marks' => (int)$row['marks'], 'cost_cents' => (int)$row['cost_cents']];
}
return $months;
}
function ledger_fetch_recent_entries(PDO $pdo, int $tenantId, array $options = []): array
{
$limit = isset($options['limit']) ? (int)$options['limit'] : 100;
+20 -4
View File
@@ -278,7 +278,10 @@ function saas_fetch_tenant_settings(PDO $pdo, int $tenantId): ?array
ts.pdf_split_mode,
ts.pdf_row_height_px,
ts.pdf_watermark_text,
ts.pdf_footer_text
ts.pdf_footer_text,
ts.payment_reminder_enabled,
ts.payment_reminder_interval_days,
ts.pdf_watermark_logo
FROM tenants t
LEFT JOIN tenant_settings ts ON ts.tenant_id = t.id
WHERE t.id = ?
@@ -316,6 +319,9 @@ function saas_fetch_tenant_settings(PDO $pdo, int $tenantId): ?array
'pdf_row_height_px' => (int)$settings['pdf_row_height_px'],
'pdf_watermark_text' => (string)$settings['pdf_watermark_text'],
'pdf_footer_text' => (string)$settings['pdf_footer_text'],
'payment_reminder_enabled' => (int)$settings['payment_reminder_enabled'],
'payment_reminder_interval_days' => (int)$settings['payment_reminder_interval_days'],
'pdf_watermark_logo' => (string)$settings['pdf_watermark_logo'],
];
}
@@ -333,6 +339,7 @@ function saas_update_tenant_settings(PDO $pdo, int $tenantId, array $input): arr
$pdfRowHeightPx = filter_var($input['pdf_row_height_px'] ?? null, FILTER_VALIDATE_INT);
$pdfWatermarkText = trim((string)($input['pdf_watermark_text'] ?? ''));
$pdfFooterText = trim((string)($input['pdf_footer_text'] ?? ''));
$paymentReminderIntervalDays = filter_var($input['payment_reminder_interval_days'] ?? null, FILTER_VALIDATE_INT);
$errors = [];
if (strlen($tenantName) < 3 || strlen($tenantName) > 255) {
@@ -371,6 +378,9 @@ function saas_update_tenant_settings(PDO $pdo, int $tenantId, array $input): arr
if ($pdfRowHeightPx === false || $pdfRowHeightPx < 10 || $pdfRowHeightPx > 60) {
$errors[] = 'Die Zeilenhöhe im Ausdruck muss zwischen 10 und 60 Pixel liegen.';
}
if ($paymentReminderIntervalDays === false || $paymentReminderIntervalDays < 1 || $paymentReminderIntervalDays > 90) {
$errors[] = 'Das Erinnerungs-Intervall muss zwischen 1 und 90 Tagen liegen.';
}
if ($errors !== []) {
return ['ok' => false, 'errors' => $errors];
@@ -379,6 +389,7 @@ function saas_update_tenant_settings(PDO $pdo, int $tenantId, array $input): arr
$selfEntryEnabled = !empty($input['self_entry_enabled']) ? 1 : 0;
$paypalEnabled = !empty($input['paypal_enabled']) ? 1 : 0;
$pdfShowEmptyRows = !empty($input['pdf_show_empty_rows']) ? 1 : 0;
$paymentReminderEnabled = !empty($input['payment_reminder_enabled']) ? 1 : 0;
try {
$pdo->beginTransaction();
@@ -395,8 +406,9 @@ function saas_update_tenant_settings(PDO $pdo, int $tenantId, array $input): arr
(tenant_id, mark_price_cents, self_entry_enabled, paypal_enabled,
paypal_url_template, sheet_window_days, negative_warning_cents,
pdf_show_empty_rows, pdf_split_mode, pdf_row_height_px,
pdf_watermark_text, pdf_footer_text)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
pdf_watermark_text, pdf_footer_text,
payment_reminder_enabled, payment_reminder_interval_days)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
mark_price_cents = VALUES(mark_price_cents),
self_entry_enabled = VALUES(self_entry_enabled),
@@ -408,7 +420,9 @@ function saas_update_tenant_settings(PDO $pdo, int $tenantId, array $input): arr
pdf_split_mode = VALUES(pdf_split_mode),
pdf_row_height_px = VALUES(pdf_row_height_px),
pdf_watermark_text = VALUES(pdf_watermark_text),
pdf_footer_text = VALUES(pdf_footer_text)'
pdf_footer_text = VALUES(pdf_footer_text),
payment_reminder_enabled = VALUES(payment_reminder_enabled),
payment_reminder_interval_days = VALUES(payment_reminder_interval_days)'
);
$stmt->execute([
$tenantId,
@@ -423,6 +437,8 @@ function saas_update_tenant_settings(PDO $pdo, int $tenantId, array $input): arr
(int)$pdfRowHeightPx,
$pdfWatermarkText,
$pdfFooterText,
$paymentReminderEnabled,
(int)$paymentReminderIntervalDays,
]);
$pdo->commit();
+56
View File
@@ -266,3 +266,59 @@ function saas_send_email_verification_mail(string $to, string $token): array
return saas_send_mail($to, 'Kaffeeliste E-Mail bestätigen', $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;
}
+90
View File
@@ -0,0 +1,90 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/bootstrap.php';
/**
* Geschuetztes Verzeichnis fuer Mandanten-Logos (per .htaccess von aussen
* gesperrt, siehe var/-Regel). Wird beim ersten Upload angelegt.
*/
function tenant_logo_dir(): string
{
return APP_ROOT . '/var/tenant_logos';
}
/**
* Vollstaendiger Pfad zu einer gespeicherten Logo-Datei oder null, wenn kein
* Logo hinterlegt ist bzw. die Datei fehlt.
*/
function tenant_logo_path(string $filename): ?string
{
$filename = basename(trim($filename));
if ($filename === '') {
return null;
}
$path = tenant_logo_dir() . '/' . $filename;
return is_file($path) ? $path : null;
}
/**
* Nimmt ein hochgeladenes Logo (PNG oder JPEG) entgegen, prueft und speichert
* es unter einem festen Namen pro Mandant.
*
* @param array $file Eintrag aus $_FILES
* @return array{ok: bool, filename?: string, error?: string}
*/
function tenant_logo_store(int $tenantId, array $file): array
{
if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
return ['ok' => false, 'error' => 'Das Logo konnte nicht hochgeladen werden.'];
}
if (($file['size'] ?? 0) <= 0 || $file['size'] > 2 * 1024 * 1024) {
return ['ok' => false, 'error' => 'Das Logo ist leer oder größer als 2 MB.'];
}
$tmp = (string)($file['tmp_name'] ?? '');
if (!is_uploaded_file($tmp)) {
return ['ok' => false, 'error' => 'Die hochgeladene Datei konnte nicht verifiziert werden.'];
}
$info = @getimagesize($tmp);
if ($info === false || !in_array($info[2], [IMAGETYPE_PNG, IMAGETYPE_JPEG], true)) {
return ['ok' => false, 'error' => 'Es sind nur PNG- oder JPEG-Bilder erlaubt.'];
}
$ext = $info[2] === IMAGETYPE_PNG ? 'png' : 'jpg';
$dir = tenant_logo_dir();
if (!is_dir($dir)) {
@mkdir($dir, 0700, true);
}
if (!is_dir($dir) || !is_writable($dir)) {
return ['ok' => false, 'error' => 'Das Logo-Verzeichnis ist nicht beschreibbar.'];
}
// Alte Varianten (andere Endung) desselben Mandanten entfernen.
foreach (['png', 'jpg'] as $altExt) {
$old = $dir . '/logo_' . $tenantId . '.' . $altExt;
if (is_file($old)) {
@unlink($old);
}
}
$filename = 'logo_' . $tenantId . '.' . $ext;
if (!move_uploaded_file($tmp, $dir . '/' . $filename)) {
return ['ok' => false, 'error' => 'Das Logo konnte nicht gespeichert werden.'];
}
return ['ok' => true, 'filename' => $filename];
}
/**
* Loescht eine hinterlegte Logo-Datei (falls vorhanden).
*/
function tenant_logo_delete(string $filename): void
{
$path = tenant_logo_path($filename);
if ($path !== null) {
@unlink($path);
}
}
+1 -12
View File
@@ -194,15 +194,6 @@ $vorschau = $vorschauBatchId !== null ? imports_fetch_batch($pdo, $tenantId, $vo
?>
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CSV Verarbeitung</title>
</head>
<body>
<h2>CSV-Import</h2>
<?php if ($meldung !== null): ?>
@@ -248,15 +239,13 @@ $vorschau = $vorschauBatchId !== null ? imports_fetch_batch($pdo, $tenantId, $vo
<br>
<?php endif; ?>
<form action="<?php echo $_SERVER["PHP_SELF"]; ?>" method="post" enctype="multipart/form-data">
<form action="csvupload.php" method="post" enctype="multipart/form-data">
<?php echo app_csrf_field(); ?>
<input type="hidden" name="aktion" value="hochladen">
<label for="csv_file">CSV-Datei auswählen:</label>
<input type="file" name="csv_file" accept=".csv" required>
<button type="submit">Datei hochladen</button>
</form>
</body>
</html>
</div>
</section>
@@ -0,0 +1,9 @@
-- Idempotenz fuer den Stripe-Webhook: verarbeitete Event-IDs werden hier
-- festgehalten, damit eine Wiederholte Zustellung (Stripe retryt bei Timeout)
-- nicht ein zweites Mal gebucht wird oder eine doppelte Dolibarr-Rechnung
-- erzeugt.
CREATE TABLE IF NOT EXISTS stripe_webhook_events (
event_id VARCHAR(255) NOT NULL PRIMARY KEY,
event_type VARCHAR(100) NOT NULL,
processed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
@@ -0,0 +1,6 @@
-- Automatische Zahlungserinnerung: opt-in pro Mandant. Als Schwelle dient die
-- bereits vorhandene negative_warning_cents; das Intervall verhindert, dass ein
-- Mitglied taeglich erinnert wird.
ALTER TABLE tenant_settings
ADD COLUMN payment_reminder_enabled TINYINT(1) NOT NULL DEFAULT 0 AFTER pdf_footer_text,
ADD COLUMN payment_reminder_interval_days SMALLINT NOT NULL DEFAULT 7 AFTER payment_reminder_enabled;
@@ -0,0 +1,6 @@
-- Optionales Logo als PDF-Wasserzeichen (Dateiname im geschuetzten Verzeichnis
-- var/tenant_logos/, leer = kein Logo). Wird ausschliesslich ueber die
-- Mandant-Einstellungen gesetzt, nicht ueber saas_update_tenant_settings, damit
-- der normale Settings-Speichervorgang das Logo nicht ueberschreibt.
ALTER TABLE tenant_settings
ADD COLUMN pdf_watermark_logo VARCHAR(255) NOT NULL DEFAULT '' AFTER payment_reminder_interval_days;
-12
View File
@@ -141,15 +141,6 @@ $mitarbeiter = einzahlung_fetch_participants($pdo, $tenantId, $action);
?>
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Kaffeeliste - Einzahlung</title>
</head>
<body>
<h2>Einzahlungen für alle Mitarbeiter</h2>
<?php if ($hatGespeichert): ?>
@@ -213,9 +204,6 @@ $mitarbeiter = einzahlung_fetch_participants($pdo, $tenantId, $action);
<button type="submit" name="submit" value="Speichern" >Eintragen</button>
</form>
</body>
</html>
</div>
</section>
+12
View File
@@ -2,6 +2,7 @@
require_once __DIR__ . '/functions.php';
require_once __DIR__ . '/app/saas-mail.php';
require_once __DIR__ . '/app/rate-limit.php';
$pdo = app_db_pdo();
$user = saas_require_login();
@@ -11,6 +12,16 @@ $verificationLink = null;
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
app_require_csrf();
// Begrenzt den Versand von Verifizierungsmails pro Konto und IP, damit ein
// eingeloggter Nutzer den Mailversand nicht durch wiederholtes Absenden
// missbrauchen kann.
$userBucketOk = app_rate_limit_check($pdo, 'emailverify_user:' . (int)$user['user_id'], 5, 3600);
$ipBucketOk = app_rate_limit_check($pdo, 'emailverify_ip:' . app_client_ip(), 10, 3600);
if (!$userBucketOk || !$ipBucketOk) {
$errors[] = 'Es wurden bereits mehrere Verifizierungslinks angefordert. Bitte prüfe dein Postfach und versuche es später erneut.';
} else {
$result = saas_request_email_verification($pdo, (int)$user['user_id'], (int)$user['tenant_id']);
if ($result['ok']) {
@@ -28,6 +39,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
} else {
$errors = $result['errors'];
}
}
}
include 'header.php';
+15 -1
View File
@@ -2,6 +2,7 @@
include "functions.php";
require_once __DIR__ . "/app/ledger.php";
require_once __DIR__ . "/app/tenant-logo.php";
$pdo = app_db_pdo();
$saasUser = saas_current_user($pdo);
@@ -34,6 +35,7 @@ $splitMode = (string)($settings['pdf_split_mode'] ?? 'drinking_behavior');
$adminRowHeightPx = (int)($settings['pdf_row_height_px'] ?? 16);
$watermarkText = trim((string)($settings['pdf_watermark_text'] ?? ''));
$footerText = trim((string)($settings['pdf_footer_text'] ?? ''));
$watermarkLogoPath = tenant_logo_path((string)($settings['pdf_watermark_logo'] ?? '')) ?? '';
// Ab dieser Mitgliederzahl wird auf zwei Seiten (Vorder-/Rueckseite) statt
// einer einzelnen Seite gedruckt.
@@ -117,10 +119,13 @@ require_once(__DIR__ . '/TCPDF/tcpdf.php');
class MyCustomPDFWithWatermark extends TCPDF {
public string $watermarkText = '';
public string $watermarkLogoPath = '';
public string $footerText = '';
public function Header() {
if (trim($this->watermarkText) === '') {
$hasLogo = $this->watermarkLogoPath !== '' && is_file($this->watermarkLogoPath);
$hasText = trim($this->watermarkText) !== '';
if (!$hasLogo && !$hasText) {
return;
}
@@ -130,6 +135,13 @@ class MyCustomPDFWithWatermark extends TCPDF {
$x = $this->x;
$y = $this->y;
if ($hasLogo) {
// Logo mittig, dezent transparent als Wasserzeichen. Hoehe 0 =
// Seitenverhaeltnis automatisch aus der Breite.
$this->SetAlpha(0.12);
$this->Image($this->watermarkLogoPath, 45, 95, 120, 0, '', '', '', false, 300, '', false, false, 0);
$this->SetAlpha(1);
} else {
$this->SetAlpha(0.35);
$this->SetFont('helvetica', '', 14);
$this->SetTextColor(0, 0, 0);
@@ -139,6 +151,7 @@ class MyCustomPDFWithWatermark extends TCPDF {
$this->SetAlpha(1);
$this->SetFont('helvetica', '', 9.5);
$this->SetTextColor(0, 0, 0);
}
// MultiCell(..., $ln=1, ...) verschiebt den Seiten-Cursor als
// Nebeneffekt - ohne dieses Zuruecksetzen wuerde der eigentliche
@@ -248,6 +261,7 @@ function export_render_page(
$pdf = new MyCustomPDFWithWatermark(PDF_PAGE_ORIENTATION, 'mm', 'A4', true, 'UTF-8', false);
$pdf->watermarkText = $watermarkText;
$pdf->watermarkLogoPath = $watermarkLogoPath;
$pdf->footerText = $footerText;
$pdf->SetHeaderData("", 0, "Kaffeestrichliste", "");
$pdf->SetPrintFooter(true);
+65 -7
View File
@@ -63,6 +63,19 @@ $settings = $hasAccess ? saas_fetch_tenant_settings($pdo, $tenantId) : null;
$entryMessage = null;
$entryError = null;
// Ergebnis einer vorangegangenen Aktion (Post-Redirect-Get): nach dem Buchen
// bzw. Stornieren wird auf index.php zurueckgeleitet, damit ein Browser-Refresh
// die Aktion nicht ein zweites Mal ausloest.
if (isset($_SESSION['flash_dashboard'])) {
$flash = $_SESSION['flash_dashboard'];
unset($_SESSION['flash_dashboard']);
if (($flash['type'] ?? '') === 'success') {
$entryMessage = (string)$flash['text'];
} else {
$entryError = (string)$flash['text'];
}
}
function dashboard_money(int $cents): string
{
return saas_format_money_cents($cents) . ' €';
@@ -203,17 +216,29 @@ function dashboard_render_consumption(array $entries): void
}
if ($_SERVER["REQUEST_METHOD"] === "POST") {
$flash = ['type' => 'error', 'text' => 'Die Aktion konnte nicht ausgeführt werden.'];
if ($hasAccess && $participant !== null && $settings !== null) {
$aktion = (string)($_POST['aktion'] ?? 'strich_eintragen');
if ($aktion === 'strich_stornieren') {
$result = ledger_void_own_self_entry($pdo, $tenantId, (int)$participant['participant_id']);
$flash = $result['ok']
? ['type' => 'success', 'text' => 'Dein zuletzt selbst eingetragener Strich wurde storniert.']
: ['type' => 'error', 'text' => $result['error'] ?? 'Der Strich konnte nicht storniert werden.'];
} else {
$result = dashboard_record_self_entry($pdo, $participant, $settings, $_POST["anzahlStriche"] ?? null, $saasUser['user_id'] ?? null);
if ($result['ok']) {
$entryMessage = $result['message'] ?? null;
$participant = ledger_fetch_participant_summary($pdo, $tenantId, (int)$participant['participant_id']);
} else {
$entryError = $result['error'] ?? 'Die Stricheintragung konnte nicht gespeichert werden.';
$flash = $result['ok']
? ['type' => 'success', 'text' => $result['message'] ?? 'Stricheintragung wurde erfolgreich eingetragen.']
: ['type' => 'error', 'text' => $result['error'] ?? 'Die Stricheintragung konnte nicht gespeichert werden.'];
}
} else {
$entryError = 'Die Stricheintragung konnte nicht gespeichert werden.';
}
// Post-Redirect-Get: Ergebnis in die Session legen und per GET erneut
// anzeigen, damit ein Refresh die Buchung/Stornierung nicht wiederholt.
$_SESSION['flash_dashboard'] = $flash;
header('Location: index.php');
exit;
}
$paymentEntries = $participant !== null
@@ -274,6 +299,29 @@ if ($hasAccess && $participant !== null && $settings !== null) {
Gesamtstriche im aktuellen Jahr: <?php echo number_format((int)$participant['year_marks'], 0, ',', '.'); ?><br>
<p>Gesamteinzahlung im aktuellen Jahr: <?php echo saas_html(dashboard_money((int)$participant['year_payments_cents'])); ?></p>
<?php
$aktuellesJahr = (int)date('Y');
$monatsdaten = ledger_fetch_monthly_consumption($pdo, $tenantId, (int)$participant['participant_id'], $aktuellesJahr);
$monatsnamen = [1 => 'Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember'];
$aktuellerMonat = (int)date('n');
if ((int)$participant['year_marks'] > 0):
?>
<h2>Monatsübersicht <?php echo $aktuellesJahr; ?></h2>
<table>
<tr><th style="width:160px">Monat</th><th>Striche</th><th>Verbrauch</th></tr>
<?php
for ($m = 1; $m <= $aktuellerMonat; $m++):
$md = $monatsdaten[$m];
?>
<tr>
<td><?php echo saas_html($monatsnamen[$m]); ?></td>
<td><?php echo number_format($md['marks'], 0, ',', '.'); ?></td>
<td><?php echo saas_html(dashboard_money($md['cost_cents'])); ?></td>
</tr>
<?php endfor; ?>
</table>
<?php endif; ?>
<?php if ($selfEntryEnabled): ?>
<h2>Eintrag in die Strichliste</h2>
<b>Hier kannst du einen oder zwei Striche für dich in der Kaffeeliste eintragen. <br>Dafür benötigst du keinen Eintrag auf der Liste durchführen.</b><br>
@@ -282,6 +330,7 @@ if ($hasAccess && $participant !== null && $settings !== null) {
<li>
<form method="post" action="index.php">
<button type="submit">Einen Strich eintragen</button>
<input type="hidden" name="aktion" value="strich_eintragen">
<input type="hidden" name="anzahlStriche" value="1">
<?php echo app_csrf_field(); ?>
</form>
@@ -289,11 +338,20 @@ if ($hasAccess && $participant !== null && $settings !== null) {
<li>
<form method="post" action="index.php">
<button type="submit">Zwei Striche eintragen</button>
<input type="hidden" name="aktion" value="strich_eintragen">
<input type="hidden" name="anzahlStriche" value="2">
<?php echo app_csrf_field(); ?>
</form>
</li>
<li>
<form method="post" action="index.php" onsubmit="return confirm('Deinen zuletzt selbst eingetragenen Strich stornieren?');">
<button type="submit" class="alt">Letzten Strich stornieren</button>
<input type="hidden" name="aktion" value="strich_stornieren">
<?php echo app_csrf_field(); ?>
</form>
</li>
</ul>
<small>Falsch getippt? Mit „Letzten Strich stornieren" nimmst du deinen jüngsten selbst eingetragenen Strich wieder zurück. Vom Kassenwart eingetragene Striche bleiben davon unberührt.</small>
<?php endif; ?>
<?php if ($paypalEnabled): ?>
+119 -54
View File
@@ -5,17 +5,6 @@ require_once __DIR__ . "/app/ledger.php";
require_once __DIR__ . "/app/saas-mail.php";
require_once __DIR__ . "/app/audit.php";
app_require_csrf();
include "header.php";
include "headerline.php";
include "nav.php";
?>
<!-- Banner -->
<section id="banner">
<div class="content">
<?php
$pdo = app_db_pdo();
$saasUser = saas_current_user($pdo);
@@ -35,22 +24,32 @@ if (!$hasAccess && $saasUser === null && checkKaffeelisteAdmin($conn, $mailadres
}
}
if (!$hasAccess) {
echo "<h2>Kein Zugriff</h2>";
include "footer.php";
exit;
}
$jahr = (int)date('Y');
$teilnehmer = ledger_fetch_participant_summaries($pdo, $tenantId, ['active_only' => true, 'year' => $jahr]);
$gesamtJahresstriche = array_sum(array_column($teilnehmer, 'year_marks'));
$fehler = null;
$ergebnisse = null;
$dryRun = true;
$gesamtbetragEingabe = '';
$gebuchtFlash = false;
if ($_SERVER["REQUEST_METHOD"] === "POST") {
// Ergebnis einer vorangegangenen echten Buchung (Post-Redirect-Get): nach dem
// Buchen wird auf diese Seite zurueckgeleitet, damit ein Browser-Refresh die
// Verteilung nicht ein zweites Mal ausloest.
if ($hasAccess && isset($_SESSION['flash_year_end'])) {
$flash = $_SESSION['flash_year_end'];
unset($_SESSION['flash_year_end']);
if ((int)($flash['tenant_id'] ?? 0) === $tenantId) {
$ergebnisse = $flash['ergebnisse'] ?? null;
$dryRun = false;
$gebuchtFlash = true;
}
}
$teilnehmer = $hasAccess
? ledger_fetch_participant_summaries($pdo, $tenantId, ['active_only' => true, 'year' => $jahr])
: [];
$gesamtJahresstriche = array_sum(array_column($teilnehmer, 'year_marks'));
if ($hasAccess && $_SERVER["REQUEST_METHOD"] === "POST") {
$dryRun = !empty($_POST['dry_run']);
$gesamtbetragEingabe = (string)($_POST['gesamtbetrag'] ?? '');
$gesamtbetrag = filter_var(str_replace(',', '.', $gesamtbetragEingabe), FILTER_VALIDATE_FLOAT);
@@ -61,11 +60,47 @@ if ($_SERVER["REQUEST_METHOD"] === "POST") {
} elseif ($gesamtJahresstriche <= 0) {
$fehler = "Für {$jahr} gibt es noch keine Striche, daher kann nichts verteilt werden.";
} else {
$ergebnisse = [];
$settings = saas_fetch_tenant_settings($pdo, $tenantId);
$dashboardUrl = saas_app_url('index.php');
$createdByUserId = $saasUser['user_id'] ?? null;
// Nur Teilnehmer mit Strichen in diesem Jahr erhalten einen Anteil.
$empfaenger = array_values(array_filter(
$teilnehmer,
static fn(array $p): bool => (int)$p['year_marks'] > 0
));
$anzahlEmpfaenger = count($empfaenger);
// Bonus proportional zu den Strichen. Damit die Summe exakt dem
// eingegebenen Gesamtbetrag entspricht, erhaelt der letzte Empfaenger
// den verbleibenden Restcent statt einer eigenen Rundung.
$zuteilung = [];
$bereitsVerteilt = 0;
foreach ($empfaenger as $index => $person) {
$yearMarks = (int)$person['year_marks'];
if ($index === $anzahlEmpfaenger - 1) {
$bonusCents = $totalCents - $bereitsVerteilt;
} else {
$bonusCents = (int)round($totalCents * $yearMarks / $gesamtJahresstriche);
$bereitsVerteilt += $bonusCents;
}
$zuteilung[] = [
'person' => $person,
'year_marks' => $yearMarks,
'anteil' => $yearMarks / $gesamtJahresstriche,
'bonus_cents' => $bonusCents,
];
}
if ($dryRun) {
$ergebnisse = array_map(static function (array $z): array {
return [
'name' => $z['person']['display_name'],
'year_marks' => $z['year_marks'],
'anteil' => $z['anteil'],
'bonus_cents' => $z['bonus_cents'],
'status' => 'vorschau',
];
}, $zuteilung);
} else {
// Legacy-Verknuepfung je Teilnehmer vorab laden (gleiches Zweig-Muster
// wie bei Sammelerfassung und CSV-Import: Default-Mandant bucht
// zusaetzlich in kl_Einzahlungen, alle anderen Mandanten rein Ledger-nativ).
@@ -76,63 +111,50 @@ if ($_SERVER["REQUEST_METHOD"] === "POST") {
$legacyMap[(int)$row['id']] = $row['legacy_mitarbeiter_id'] !== null ? (int)$row['legacy_mitarbeiter_id'] : null;
}
$ergebnisse = [];
$zuBenachrichtigen = [];
try {
if (!$dryRun) {
$pdo->beginTransaction();
}
$insertLegacy = $pdo->prepare('INSERT INTO kl_Einzahlungen (MitarbeiterID, Betrag, Datum) VALUES (?, ?, ?)');
$jetzt = date('Y-m-d H:i:s');
foreach ($teilnehmer as $person) {
$yearMarks = (int)$person['year_marks'];
if ($yearMarks <= 0) {
continue;
}
$anteil = $yearMarks / $gesamtJahresstriche;
$bonusCents = (int)round($totalCents * $anteil);
foreach ($zuteilung as $z) {
$person = $z['person'];
$bonusCents = $z['bonus_cents'];
if ($bonusCents <= 0) {
continue;
}
$status = 'vorschau';
if (!$dryRun) {
$legacyMitarbeiterId = $legacyMap[$person['participant_id']] ?? null;
if ($legacyMitarbeiterId !== null) {
$insertLegacy->execute([$legacyMitarbeiterId, $bonusCents / 100, $jetzt]);
$legacyPaymentId = (int)$pdo->lastInsertId();
ledger_mirror_legacy_payment($pdo, $tenantId, $legacyPaymentId);
} else {
ledger_record_payment($pdo, $tenantId, $person['participant_id'], $bonusCents, 'year_end_bonus');
ledger_record_payment($pdo, $tenantId, (int)$person['participant_id'], $bonusCents, 'year_end_bonus', $createdByUserId);
}
$status = 'gebucht';
$email = trim((string)($person['email'] ?? ''));
if (filter_var($email, FILTER_VALIDATE_EMAIL)) {
$subject = "Kaffeeliste - Dein Jahresbonus {$jahr}";
$body = saas_render_year_end_bonus_mail_body($person['display_name'], $yearMarks, $bonusCents, $dashboardUrl);
$sendResult = saas_send_mail($email, $subject, $body);
saas_log_outbound_email(
$pdo, $tenantId, $person['participant_id'], 'year_end_bonus', $subject,
$sendResult['ok'] ? 'sent' : 'failed', $sendResult['error'] ?? null, $createdByUserId
);
}
$zuBenachrichtigen[] = [
'participant_id' => (int)$person['participant_id'],
'email' => $email,
'name' => (string)$person['display_name'],
'year_marks' => $z['year_marks'],
'bonus_cents' => $bonusCents,
];
}
$ergebnisse[] = [
'name' => $person['display_name'],
'year_marks' => $yearMarks,
'anteil' => $anteil,
'year_marks' => $z['year_marks'],
'anteil' => $z['anteil'],
'bonus_cents' => $bonusCents,
'status' => $status,
'status' => 'gebucht',
];
}
if (!$dryRun) {
$pdo->commit();
app_audit_log($pdo, $tenantId, $createdByUserId, 'year_end_bonus.distributed', 'tenant', $tenantId, ['jahr' => $jahr, 'gesamtbetrag_cents' => $totalCents, 'empfaenger' => count($ergebnisse)]);
}
} catch (Throwable $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
@@ -140,11 +162,48 @@ if ($_SERVER["REQUEST_METHOD"] === "POST") {
$fehler = 'Die Verteilung konnte nicht gespeichert werden.';
$ergebnisse = null;
}
if ($fehler === null) {
// Mails erst nach erfolgreichem Commit versenden - sonst waeren
// bei einem spaeteren Rollback bereits Mails draussen, deren
// Buchung nie stattgefunden hat.
$settings = saas_fetch_tenant_settings($pdo, $tenantId);
$dashboardUrl = saas_app_url('index.php');
foreach ($zuBenachrichtigen as $mail) {
$subject = "Kaffeeliste - Dein Jahresbonus {$jahr}";
$body = saas_render_year_end_bonus_mail_body($mail['name'], $mail['year_marks'], $mail['bonus_cents'], $dashboardUrl);
$sendResult = saas_send_mail($mail['email'], $subject, $body);
saas_log_outbound_email(
$pdo, $tenantId, $mail['participant_id'], 'year_end_bonus', $subject,
$sendResult['ok'] ? 'sent' : 'failed', $sendResult['error'] ?? null, $createdByUserId
);
}
app_audit_log($pdo, $tenantId, $createdByUserId, 'year_end_bonus.distributed', 'tenant', $tenantId, ['jahr' => $jahr, 'gesamtbetrag_cents' => $totalCents, 'empfaenger' => count($ergebnisse)]);
// Post-Redirect-Get: Ergebnis in die Session legen und per GET
// erneut anzeigen, damit ein Refresh nicht doppelt bucht.
$_SESSION['flash_year_end'] = ['tenant_id' => $tenantId, 'ergebnisse' => $ergebnisse];
header('Location: jahresauswertung.php');
exit;
}
}
}
}
include "header.php";
include "headerline.php";
include "nav.php";
?>
<!-- Banner -->
<section id="banner">
<div class="content">
<?php if (!$hasAccess): ?>
<h2>Kein Zugriff</h2>
<?php else: ?>
<h2>Jahresabschluss <?php echo (int)$jahr; ?></h2>
<p>Verteilt einen frei wählbaren Gesamtbetrag proportional zu den in diesem
Jahr gemachten Strichen als Guthaben an alle aktiven Mitglieder und
@@ -157,7 +216,11 @@ es wird nichts gebucht und keine Mail verschickt.</p>
<div class="hint-box error"><p><?php echo saas_html($fehler); ?></p></div>
<?php endif; ?>
<form method="post" action="<?php echo htmlspecialchars($_SERVER["PHP_SELF"]); ?>">
<?php if ($gebuchtFlash): ?>
<div class="hint-box success"><p>Der Jahresbonus wurde gebucht und die Mitglieder wurden benachrichtigt.</p></div>
<?php endif; ?>
<form method="post" action="jahresauswertung.php">
<?php echo app_csrf_field(); ?>
<label for="gesamtbetrag">Gesamtbetrag (€):</label>
<input type="text" name="gesamtbetrag" id="gesamtbetrag" value="<?php echo saas_html($gesamtbetragEingabe); ?>" required>
@@ -183,6 +246,8 @@ es wird nichts gebucht und keine Mail verschickt.</p>
</tr>
<?php endforeach; ?>
</table>
<?php endif; ?>
<?php endif; ?>
</div>
+2 -2
View File
@@ -115,7 +115,7 @@ $stmtEinzahlungen = sqlsrv_query($conn, $sqlEinzahlungen);
echo "<td>{$betrag}</td>";
echo "<td>{$datum}</td>";
echo "<td>";
echo "<form method='post' action='{$_SERVER["PHP_SELF"]}'>";
echo "<form method='post' action='letzteneintraege.php'>";
echo "<input type='hidden' name='aktion' value='loescheneinzahlung'>";
echo "<input type='hidden' name='einzahlungID' value='{$einzahlungID}'>";
echo app_csrf_field();
@@ -209,7 +209,7 @@ $stmtStriche = sqlsrv_query($conn, $sqlStriche);
echo "<td>{$betrag}</td>";
echo "<td>{$datum}</td>";
echo "<td>";
echo "<form method='post' action='{$_SERVER["PHP_SELF"]}'>";
echo "<form method='post' action='letzteneintraege.php'>";
echo "<input type='hidden' name='aktion' value='loeschen'>";
echo "<input type='hidden' name='strichID' value='{$strichID}'>";
echo app_csrf_field();
+41 -23
View File
@@ -5,20 +5,6 @@ require_once __DIR__ . "/app/ledger.php";
require_once __DIR__ . "/app/saas-mail.php";
require_once __DIR__ . "/app/audit.php";
app_require_csrf();
include "header.php";
include "headerline.php";
include "nav.php";
?>
<!-- Banner -->
<section id="banner">
<div class="content">
<?php
$pdo = app_db_pdo();
$saasUser = saas_current_user($pdo);
@@ -38,16 +24,24 @@ if (!$hasAccess && $saasUser === null && checkKaffeelisteAdmin($conn, $mailadres
}
}
if (!$hasAccess) {
echo "<h2>Kein Zugriff</h2>";
include "footer.php";
exit;
}
$ergebnisse = null;
$dryRun = true;
$liveFlash = false;
if ($_SERVER["REQUEST_METHOD"] === "POST") {
// Ergebnis eines vorangegangenen Live-Versands (Post-Redirect-Get): nach dem
// echten Versand wird auf diese Seite zurueckgeleitet, damit ein Refresh nicht
// erneut an alle Mitglieder mailt.
if ($hasAccess && isset($_SESSION['flash_mailversand'])) {
$flash = $_SESSION['flash_mailversand'];
unset($_SESSION['flash_mailversand']);
if ((int)($flash['tenant_id'] ?? 0) === $tenantId) {
$ergebnisse = $flash['ergebnisse'] ?? null;
$dryRun = false;
$liveFlash = true;
}
}
if ($hasAccess && $_SERVER["REQUEST_METHOD"] === "POST") {
$dryRun = !empty($_POST['dry_run']);
$settings = saas_fetch_tenant_settings($pdo, $tenantId);
$dashboardUrl = saas_app_url('index.php');
@@ -89,10 +83,16 @@ if ($_SERVER["REQUEST_METHOD"] === "POST") {
if (!$dryRun) {
app_audit_log($pdo, $tenantId, $createdByUserId, 'mail_broadcast.sent', 'tenant', $tenantId, ['empfaenger' => count($ergebnisse)]);
// Post-Redirect-Get: Ergebnis in die Session legen und per GET erneut
// anzeigen, damit ein Browser-Refresh nicht erneut versendet.
$_SESSION['flash_mailversand'] = ['tenant_id' => $tenantId, 'ergebnisse' => $ergebnisse];
header('Location: mailversenden.php');
exit;
}
}
$log = saas_fetch_outbound_email_log($pdo, $tenantId, 20);
$log = $hasAccess ? saas_fetch_outbound_email_log($pdo, $tenantId, 20) : [];
function mailversenden_status_label(string $status): string
{
@@ -105,13 +105,29 @@ function mailversenden_status_label(string $status): string
};
}
include "header.php";
include "headerline.php";
include "nav.php";
?>
<!-- Banner -->
<section id="banner">
<div class="content">
<?php if (!$hasAccess): ?>
<h2>Kein Zugriff</h2>
<?php else: ?>
<h2>Info-Mail versenden</h2>
<p>Verschickt an alle aktiven Mitglieder eine E-Mail mit dem aktuellen
Kaffeelisten-Stand. Im Dry-Run wird nichts wirklich versendet, aber jeder
Empfänger wird im Versandlog unten protokolliert.</p>
<?php if ($liveFlash): ?>
<div class="hint-box success"><p>Die Info-Mails wurden versendet.</p></div>
<?php endif; ?>
<?php if ($ergebnisse !== null): ?>
<h3>Ergebnis dieses Laufs (<?php echo $dryRun ? 'Dry-Run' : 'Live-Versand'; ?>)</h3>
<table>
@@ -126,7 +142,7 @@ Empfänger wird im Versandlog unten protokolliert.</p>
</table>
<?php endif; ?>
<form method="post" action="<?php echo htmlspecialchars($_SERVER["PHP_SELF"]); ?>">
<form method="post" action="mailversenden.php">
<?php echo app_csrf_field(); ?>
<div class="form-check">
<input class="form-check-input" type="checkbox" name="dry_run" id="dry_run" checked>
@@ -152,6 +168,8 @@ Empfänger wird im Versandlog unten protokolliert.</p>
<?php endforeach; ?>
</table>
<?php endif; ?>
</div>
</section>
+38 -1
View File
@@ -3,6 +3,7 @@
require_once __DIR__ . '/functions.php';
require_once __DIR__ . '/app/audit.php';
require_once __DIR__ . '/app/billing.php';
require_once __DIR__ . '/app/tenant-logo.php';
$pdo = app_db_pdo();
$user = saas_require_login();
@@ -19,6 +20,20 @@ if ($canManageSettings) {
$saved = true;
$settings = $result['settings'];
app_audit_log($pdo, (int)$user['tenant_id'], (int)$user['user_id'], 'tenant_settings.updated', 'tenant', (int)$user['tenant_id']);
$tenantIdLogo = (int)$user['tenant_id'];
if (!empty($_POST['pdf_watermark_logo_remove'])) {
tenant_logo_delete((string)($settings['pdf_watermark_logo'] ?? ''));
$pdo->prepare("UPDATE tenant_settings SET pdf_watermark_logo = '' WHERE tenant_id = ?")->execute([$tenantIdLogo]);
$settings['pdf_watermark_logo'] = '';
} elseif (isset($_FILES['pdf_watermark_logo_file']) && ($_FILES['pdf_watermark_logo_file']['error'] ?? UPLOAD_ERR_NO_FILE) === UPLOAD_ERR_OK) {
$logoResult = tenant_logo_store($tenantIdLogo, $_FILES['pdf_watermark_logo_file']);
if ($logoResult['ok']) {
$pdo->prepare('UPDATE tenant_settings SET pdf_watermark_logo = ? WHERE tenant_id = ?')->execute([$logoResult['filename'], $tenantIdLogo]);
$settings['pdf_watermark_logo'] = $logoResult['filename'];
} else {
$errors[] = $logoResult['error'];
}
}
} else {
$errors = $result['errors'];
$settings = [
@@ -37,6 +52,8 @@ if ($canManageSettings) {
'pdf_row_height_px' => $_POST['pdf_row_height_px'] ?? 16,
'pdf_watermark_text' => $_POST['pdf_watermark_text'] ?? '',
'pdf_footer_text' => $_POST['pdf_footer_text'] ?? '',
'payment_reminder_enabled' => !empty($_POST['payment_reminder_enabled']) ? 1 : 0,
'payment_reminder_interval_days' => $_POST['payment_reminder_interval_days'] ?? 7,
];
}
} else {
@@ -76,7 +93,7 @@ include 'nav.php';
</div>
<?php endif; ?>
<form method="post" action="mandant-einstellungen.php">
<form method="post" action="mandant-einstellungen.php" enctype="multipart/form-data">
<?php echo app_csrf_field(); ?>
<div class="row">
<div class="col-6 col-12-small">
@@ -138,10 +155,30 @@ include 'nav.php';
<label for="pdf_watermark_text">Wasserzeichen-Text im Ausdruck (leer = kein Wasserzeichen)</label>
<input type="text" name="pdf_watermark_text" id="pdf_watermark_text" maxlength="500" value="<?php echo saas_html($settings['pdf_watermark_text']); ?>">
</div>
<div class="col-12">
<label for="pdf_watermark_logo_file">Logo als Wasserzeichen (PNG/JPEG, ersetzt den Text im Ausdruck)</label>
<input type="file" name="pdf_watermark_logo_file" id="pdf_watermark_logo_file" accept="image/png,image/jpeg">
<?php if (trim((string)($settings['pdf_watermark_logo'] ?? '')) !== ''): ?>
<div class="form-check">
<input class="form-check-input" type="checkbox" name="pdf_watermark_logo_remove" id="pdf_watermark_logo_remove" value="1">
<label class="form-check-label" for="pdf_watermark_logo_remove">Hinterlegtes Logo entfernen (wieder Text-Wasserzeichen verwenden)</label>
</div>
<?php else: ?>
<small>Aktuell kein Logo hinterlegt.</small>
<?php endif; ?>
</div>
<div class="col-12">
<label for="pdf_footer_text">Hinweis in der Fußzeile des Ausdrucks (leer = keine Fußzeile)</label>
<input type="text" name="pdf_footer_text" id="pdf_footer_text" maxlength="500" value="<?php echo saas_html($settings['pdf_footer_text']); ?>">
</div>
<div class="col-6 col-12-small">
<input type="checkbox" id="payment_reminder_enabled" name="payment_reminder_enabled" value="1" <?php echo (int)$settings['payment_reminder_enabled'] === 1 ? 'checked' : ''; ?>>
<label for="payment_reminder_enabled">Automatische Zahlungserinnerung ab Warnschwelle</label>
</div>
<div class="col-6 col-12-small">
<label for="payment_reminder_interval_days">Erinnerungs-Intervall in Tagen</label>
<input type="number" name="payment_reminder_interval_days" id="payment_reminder_interval_days" min="1" max="90" value="<?php echo saas_html($settings['payment_reminder_interval_days']); ?>">
</div>
</div>
<ul class="actions">
<li><button type="submit">Speichern</button></li>
+66
View File
@@ -6,6 +6,7 @@ require_once __DIR__ . "/app/ledger.php";
require_once __DIR__ . "/app/saas-mail.php";
require_once __DIR__ . "/app/audit.php";
require_once __DIR__ . "/app/billing.php";
require_once __DIR__ . "/app/imports.php";
app_require_csrf();
include "header.php";
include "headerline.php";
@@ -155,8 +156,59 @@ if($hasAccess){
} else {
$fehler = 'Das Mitglied konnte nicht anonymisiert werden.';
}
} elseif ($aktion === 'csv_import') {
$file = $_FILES['mitglieder_csv'] ?? null;
if ($file === null || ($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
$fehler = 'Bitte eine CSV-Datei auswählen.';
} elseif (($file['size'] ?? 0) <= 0 || $file['size'] > 2 * 1024 * 1024) {
$fehler = 'Die CSV-Datei ist leer oder größer als 2 MB.';
} elseif (strtolower(pathinfo((string)$file['name'], PATHINFO_EXTENSION)) !== 'csv') {
$fehler = 'Es sind nur CSV-Dateien erlaubt.';
} elseif (!is_uploaded_file((string)$file['tmp_name'])) {
$fehler = 'Die hochgeladene Datei konnte nicht verifiziert werden.';
} else {
try {
$zeilen = imports_parse_member_csv((string)$file['tmp_name']);
$angelegt = 0;
$uebersprungen = 0;
$inaktivWegenKapazitaet = 0;
$fehlerhaft = 0;
foreach ($zeilen as $z) {
if ($z['name'] === '' || !filter_var($z['email'], FILTER_VALIDATE_EMAIL)) {
$fehlerhaft++;
continue;
}
$aktiv = $z['active'];
if ($aktiv && !billing_has_capacity_for($pdo, $tenantId)) {
$aktiv = false;
$inaktivWegenKapazitaet++;
}
try {
$neueId = ledger_create_participant($pdo, $tenantId, $z['name'], $z['email'], $z['paypal_name'] !== '' ? $z['paypal_name'] : null, $aktiv);
app_audit_log($pdo, $tenantId, $actorUserId, 'participant.created', 'participant', $neueId, ['name' => $z['name'], 'email' => $z['email'], 'quelle' => 'csv_import']);
$angelegt++;
} catch (Throwable $e) {
$uebersprungen++;
}
}
$teile = ["{$angelegt} angelegt"];
if ($uebersprungen > 0) {
$teile[] = "{$uebersprungen} übersprungen (bereits vorhanden)";
}
if ($inaktivWegenKapazitaet > 0) {
$teile[] = "{$inaktivWegenKapazitaet} als inaktiv angelegt (Tarif-Kapazität erschöpft)";
}
if ($fehlerhaft > 0) {
$teile[] = "{$fehlerhaft} fehlerhaft (Name/E-Mail ungültig)";
}
$meldung = 'CSV-Import: ' . implode(', ', $teile) . '.';
} catch (Throwable $e) {
$fehler = 'Die CSV-Datei konnte nicht verarbeitet werden.';
}
}
}
}
$mitglieder = ledger_fetch_participants_for_admin($pdo, $tenantId);
$billingInfo = billing_fetch_or_init($pdo, $tenantId);
@@ -246,6 +298,20 @@ if($hasAccess){
<button type="submit">Mitglied anlegen</button>
</form>
<h3>Mitglieder per CSV importieren</h3>
<p>Lade zunächst die Vorlage herunter, trage eure Mitglieder ein (Name, E-Mail,
optional PayPal-Name und „ja/nein" für aktiv) und lade die Datei anschließend
wieder hoch. Bereits vorhandene E-Mail-Adressen werden übersprungen.</p>
<p><a class="button" href="mitglieder-vorlage.php">Vorlage herunterladen</a></p>
<form method="post" action="<?php echo htmlspecialchars($_SERVER["PHP_SELF"]); ?>" enctype="multipart/form-data">
<?php echo app_csrf_field(); ?>
<input type="hidden" name="aktion" value="csv_import">
<label for="mitglieder_csv">CSV-Datei mit Mitgliedern:</label>
<input type="file" name="mitglieder_csv" id="mitglieder_csv" accept=".csv" required>
<button type="submit">Mitglieder importieren</button>
</form>
<p>Name und E-Mail dienen der Kaffeeliste und Benachrichtigungen. Ein Login-Zugang
ist davon unabhängig und wird separat je Mitglied gewährt oder entzogen.</p>
+34
View File
@@ -0,0 +1,34 @@
<?php
// Liefert eine leere Mitglieder-CSV-Vorlage zum Download. Nur fuer Verwalter
// (owner/admin) bzw. den Legacy-Admin - dieselbe Zugriffslogik wie die
// Mitgliederverwaltung.
require_once __DIR__ . '/functions.php';
require_once __DIR__ . '/app/ledger.php';
$pdo = app_db_pdo();
$saasUser = saas_current_user($pdo);
$hasAccess = false;
if ($saasUser !== null && saas_user_has_role(['owner', 'admin'], $saasUser)) {
$hasAccess = true;
} elseif ($saasUser === null && checkKaffeelisteAdmin($conn, $mailadress)) {
$hasAccess = true;
}
if (!$hasAccess) {
http_response_code(403);
exit('Kein Zugriff.');
}
// UTF-8-BOM, damit Excel Umlaute korrekt erkennt; Semikolon als Trenner
// (deutsches Excel-Standardformat).
$zeilen = [
'Name;E-Mail;PayPal-Name;Aktiv',
'Max Mustermann;max@example.com;;ja',
'Erika Beispiel;erika@example.com;Erika B.;ja',
];
header('Content-Type: text/csv; charset=UTF-8');
header('Content-Disposition: attachment; filename="kaffeeliste-mitglieder-vorlage.csv"');
echo "\xEF\xBB\xBF" . implode("\r\n", $zeilen) . "\r\n";
+106
View File
@@ -0,0 +1,106 @@
<?php
declare(strict_types=1);
/**
* Automatische Zahlungserinnerung (fuer Cron gedacht, z. B. taeglich).
*
* Fuer jeden aktiven Mandanten mit aktivierter Erinnerung (Mandant-
* Einstellung "payment_reminder_enabled") wird jedem aktiven Mitglied, dessen
* offener Betrag die Warnschwelle (negative_warning_cents) erreicht, eine
* Erinnerungsmail geschickt - aber hoechstens alle N Tage
* (payment_reminder_interval_days).
*
* Aufruf:
* php scripts/send-payment-reminders.php (echter Versand)
* php scripts/send-payment-reminders.php --dry-run (nur anzeigen)
*/
if (PHP_SAPI !== 'cli') {
http_response_code(403);
exit("Dieses Skript ist nur fuer die Kommandozeile gedacht.\n");
}
require_once __DIR__ . '/../app/bootstrap.php';
require_once __DIR__ . '/../app/database.php';
require_once __DIR__ . '/../app/ledger.php';
require_once __DIR__ . '/../app/saas-auth.php';
require_once __DIR__ . '/../app/saas-mail.php';
require_once __DIR__ . '/../app/audit.php';
$dryRun = in_array('--dry-run', $argv, true);
$pdo = app_db_pdo();
$dashboardUrl = saas_app_url('index.php');
$tenants = $pdo->query("SELECT id FROM tenants WHERE status = 'active'")->fetchAll(PDO::FETCH_COLUMN);
$gesamtGesendet = 0;
$gesamtUebersprungen = 0;
foreach ($tenants as $tenantIdRaw) {
$tenantId = (int)$tenantIdRaw;
$settings = saas_fetch_tenant_settings($pdo, $tenantId);
if ($settings === null || (int)$settings['payment_reminder_enabled'] !== 1) {
continue;
}
$schwelleCents = (int)$settings['negative_warning_cents']; // <= 0
$intervallTage = max(1, (int)$settings['payment_reminder_interval_days']);
$jetzt = new DateTimeImmutable('now');
$teilnehmer = ledger_fetch_participant_summaries($pdo, $tenantId, ['active_only' => true]);
$letzteErinnerungen = saas_fetch_last_payment_reminders($pdo, $tenantId);
foreach ($teilnehmer as $person) {
$balanceCents = (int)$person['balance_cents'];
// Nur echte Schuld oberhalb der Schwelle (balance ist negativ = Schulden).
if ($balanceCents >= 0 || $balanceCents > $schwelleCents) {
continue;
}
$email = trim((string)($person['email'] ?? ''));
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
continue;
}
$participantId = (int)$person['participant_id'];
if (isset($letzteErinnerungen[$participantId])) {
try {
$letzte = new DateTimeImmutable($letzteErinnerungen[$participantId]);
if ($letzte->add(new DateInterval("P{$intervallTage}D")) > $jetzt) {
$gesamtUebersprungen++;
continue;
}
} catch (Throwable $e) {
// Unparsebares Datum: sicherheitshalber ueberspringen.
$gesamtUebersprungen++;
continue;
}
}
$debtCents = abs($balanceCents);
$subject = 'Kaffeeliste - Erinnerung an deinen offenen Betrag';
$body = saas_render_payment_reminder_mail_body((string)$person['display_name'], $debtCents, $settings, $dashboardUrl);
if ($dryRun) {
echo "[dry-run] Mandant {$tenantId}: {$person['display_name']} <{$email}> offen " . saas_format_money_cents($debtCents) . " EUR\n";
$gesamtGesendet++;
continue;
}
$sendResult = saas_send_mail($email, $subject, $body);
saas_log_outbound_email(
$pdo, $tenantId, $participantId, 'payment_reminder', $subject,
$sendResult['ok'] ? 'sent' : 'failed', $sendResult['error'] ?? null, null
);
if ($sendResult['ok']) {
$gesamtGesendet++;
}
}
if (!$dryRun) {
app_audit_log($pdo, $tenantId, null, 'payment_reminder.batch', 'tenant', $tenantId, ['gesendet' => $gesamtGesendet]);
}
}
echo ($dryRun ? "[dry-run] " : "") . "Fertig: {$gesamtGesendet} Erinnerung(en) " . ($dryRun ? "waeren versendet worden" : "versendet") . ", {$gesamtUebersprungen} wegen Intervall uebersprungen.\n";
-12
View File
@@ -161,15 +161,6 @@ $kostenproStrichAnzeige = number_format(($settings['mark_price_cents'] ?? 20) /
?>
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Kaffeeliste - Anzahl der Striche für alle Mitarbeiter</title>
</head>
<body>
<h2>Anzahl der Striche für alle Mitarbeiter</h2>
<?php if ($hatGespeichert): ?>
@@ -229,9 +220,6 @@ $kostenproStrichAnzeige = number_format(($settings['mark_price_cents'] ?? 20) /
<button type="submit">Eintragen</button>
</form>
</body>
</html>
</div>
</section>
+26 -2
View File
@@ -33,6 +33,15 @@ if ($event === null) {
$pdo = app_db_pdo();
$object = $event['data'];
// Idempotenz: bereits verarbeitete Events (Stripe-Retry bei Timeout) werden
// mit 200 bestaetigt, aber nicht erneut gebucht - verhindert u. a. doppelte
// Dolibarr-Rechnungen bei wiederholtem invoice.paid.
if (billing_webhook_event_seen($pdo, $event['id'], $event['type'])) {
http_response_code(200);
echo 'ok (bereits verarbeitet)';
exit;
}
switch ($event['type']) {
case 'checkout.session.completed':
$tenantId = isset($object['metadata']['tenant_id']) ? (int)$object['metadata']['tenant_id'] : null;
@@ -57,11 +66,26 @@ switch ($event['type']) {
$periodEnd = isset($object['current_period_end'])
? date('Y-m-d H:i:s', (int)$object['current_period_end'])
: null;
billing_update($pdo, $tenantId, [
$updateFields = [
'subscription_status' => $status,
'current_period_end' => $periodEnd,
];
// Tarifwechsel aus dem Stripe-Kundenportal lokal nachziehen: der
// aktuelle Preis steckt am Subscription-Item, per lookup_key wieder
// auf unseren Plan-Code abbildbar. Nur bei aktiver Subscription und
// eindeutiger Zuordnung, sonst bleibt der bisherige Plan bestehen.
$lookupKey = (string)($object['items']['data'][0]['price']['lookup_key'] ?? '');
$mappedPlan = billing_plan_code_by_lookup_key($lookupKey);
if ($mappedPlan !== null && in_array($status, ['active', 'trialing', 'past_due'], true)) {
$updateFields['plan_code'] = $mappedPlan;
}
billing_update($pdo, $tenantId, $updateFields);
app_audit_log($pdo, $tenantId, null, 'billing.subscription_updated', 'tenant', $tenantId, [
'status' => $status,
'plan_code' => $updateFields['plan_code'] ?? null,
]);
app_audit_log($pdo, $tenantId, null, 'billing.subscription_updated', 'tenant', $tenantId, ['status' => $status]);
}
break;