diff --git a/app/billing.php b/app/billing.php index 85b37ad..694dbd4 100644 --- a/app/billing.php +++ b/app/billing.php @@ -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 = ?'); diff --git a/app/imports.php b/app/imports.php index 768805b..37bfdab 100644 --- a/app/imports.php +++ b/app/imports.php @@ -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 + */ +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; +} diff --git a/app/ledger.php b/app/ledger.php index 0a0abe3..fe995f4 100644 --- a/app/ledger.php +++ b/app/ledger.php @@ -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 */ +/** + * Verbrauch eines Teilnehmers pro Monat eines Jahres (nur nicht stornierte + * Consumption-Buchungen). Fuer die Monatsuebersicht im Mitglieder-Dashboard. + * + * @return array 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; diff --git a/app/saas-auth.php b/app/saas-auth.php index c6ab1d4..8e94f2f 100644 --- a/app/saas-auth.php +++ b/app/saas-auth.php @@ -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(); diff --git a/app/saas-mail.php b/app/saas-mail.php index b8964dd..edd330f 100644 --- a/app/saas-mail.php +++ b/app/saas-mail.php @@ -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 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; +} diff --git a/app/tenant-logo.php b/app/tenant-logo.php new file mode 100644 index 0000000..c877af8 --- /dev/null +++ b/app/tenant-logo.php @@ -0,0 +1,90 @@ + 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); + } +} diff --git a/csvupload.php b/csvupload.php index 674e195..ce0f49c 100644 --- a/csvupload.php +++ b/csvupload.php @@ -194,15 +194,6 @@ $vorschau = $vorschauBatchId !== null ? imports_fetch_batch($pdo, $tenantId, $vo ?> - - - - - - CSV Verarbeitung - - -

CSV-Import

@@ -248,15 +239,13 @@ $vorschau = $vorschauBatchId !== null ? imports_fetch_batch($pdo, $tenantId, $vo
-
" method="post" enctype="multipart/form-data"> +
- - diff --git a/database/migrations/0018_stripe_webhook_events.sql b/database/migrations/0018_stripe_webhook_events.sql new file mode 100644 index 0000000..0c5648a --- /dev/null +++ b/database/migrations/0018_stripe_webhook_events.sql @@ -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; diff --git a/database/migrations/0019_payment_reminders.sql b/database/migrations/0019_payment_reminders.sql new file mode 100644 index 0000000..ad87276 --- /dev/null +++ b/database/migrations/0019_payment_reminders.sql @@ -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; diff --git a/database/migrations/0020_pdf_watermark_logo.sql b/database/migrations/0020_pdf_watermark_logo.sql new file mode 100644 index 0000000..9e9f220 --- /dev/null +++ b/database/migrations/0020_pdf_watermark_logo.sql @@ -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; diff --git a/einzahlung.php b/einzahlung.php index a9b4ad1..ec528a5 100644 --- a/einzahlung.php +++ b/einzahlung.php @@ -141,15 +141,6 @@ $mitarbeiter = einzahlung_fetch_participants($pdo, $tenantId, $action); ?> - - - - - - Kaffeeliste - Einzahlung - - -

Einzahlungen für alle Mitarbeiter

@@ -213,9 +204,6 @@ $mitarbeiter = einzahlung_fetch_participants($pdo, $tenantId, $action); - - - diff --git a/email-verifikation-senden.php b/email-verifikation-senden.php index 0b91189..b2046cd 100644 --- a/email-verifikation-senden.php +++ b/email-verifikation-senden.php @@ -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,7 +12,17 @@ $verificationLink = null; if ($_SERVER['REQUEST_METHOD'] === 'POST') { app_require_csrf(); - $result = saas_request_email_verification($pdo, (int)$user['user_id'], (int)$user['tenant_id']); + + // 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']) { if (!empty($result['already_verified'])) { @@ -28,6 +39,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { } else { $errors = $result['errors']; } + } } include 'header.php'; diff --git a/exportKaffeeliste.php b/exportKaffeeliste.php index 330d589..15a103a 100644 --- a/exportKaffeeliste.php +++ b/exportKaffeeliste.php @@ -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,15 +135,23 @@ class MyCustomPDFWithWatermark extends TCPDF { $x = $this->x; $y = $this->y; - $this->SetAlpha(0.35); - $this->SetFont('helvetica', '', 14); - $this->SetTextColor(0, 0, 0); - foreach ([40, 140, 240] as $wy) { - $this->MultiCell(90, 0, $this->watermarkText, 0, 'L', false, 1, 110, $wy); + 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); + foreach ([40, 140, 240] as $wy) { + $this->MultiCell(90, 0, $this->watermarkText, 0, 'L', false, 1, 110, $wy); + } + $this->SetAlpha(1); + $this->SetFont('helvetica', '', 9.5); + $this->SetTextColor(0, 0, 0); } - $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); diff --git a/index.php b/index.php index beda22a..c0cc03a 100644 --- a/index.php +++ b/index.php @@ -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) { - $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']); + $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 { - $entryError = $result['error'] ?? 'Die Stricheintragung konnte nicht gespeichert werden.'; + $result = dashboard_record_self_entry($pdo, $participant, $settings, $_POST["anzahlStriche"] ?? null, $saasUser['user_id'] ?? null); + $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:

Gesamteinzahlung im aktuellen Jahr:

+ 'Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember']; + $aktuellerMonat = (int)date('n'); + if ((int)$participant['year_marks'] > 0): +?> +

Monatsübersicht

+ + + + + + + + + +
MonatStricheVerbrauch
+ +

Eintrag in die Strichliste

Hier kannst du einen oder zwei Striche für dich in der Kaffeeliste eintragen.
Dafür benötigst du keinen Eintrag auf der Liste durchführen.

@@ -282,6 +330,7 @@ if ($hasAccess && $participant !== null && $settings !== null) {
  • +
    @@ -289,11 +338,20 @@ if ($hasAccess && $participant !== null && $settings !== null) {
  • +
  • +
  • +
    + + + +
    +
  • + 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. diff --git a/jahresauswertung.php b/jahresauswertung.php index 9ce1d0d..2b0dd79 100644 --- a/jahresauswertung.php +++ b/jahresauswertung.php @@ -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"; - -?> - - - diff --git a/stripe-webhook.php b/stripe-webhook.php index 82ed3c5..1e3b815 100644 --- a/stripe-webhook.php +++ b/stripe-webhook.php @@ -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;