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:
@@ -111,6 +111,46 @@ function billing_update(PDO $pdo, int $tenantId, array $fields): void
|
|||||||
->execute($params);
|
->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
|
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 = ?');
|
$stmt = $pdo->prepare('SELECT tenant_id FROM tenant_billing WHERE stripe_customer_id = ?');
|
||||||
|
|||||||
@@ -263,3 +263,51 @@ function imports_commit_batch(PDO $pdo, int $tenantId, int $batchId): array
|
|||||||
|
|
||||||
return ['imported' => $imported];
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -621,6 +621,60 @@ function ledger_void_entry_by_legacy_id(PDO $pdo, int $tenantId, string $legacyT
|
|||||||
return $stmt->rowCount() > 0;
|
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
|
* Records a consumption entry straight into the ledger, without any legacy
|
||||||
* kl_Kaffeeverbrauch row. Used for tenants that have no legacy shadow table
|
* 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}>
|
* @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
|
function ledger_fetch_recent_entries(PDO $pdo, int $tenantId, array $options = []): array
|
||||||
{
|
{
|
||||||
$limit = isset($options['limit']) ? (int)$options['limit'] : 100;
|
$limit = isset($options['limit']) ? (int)$options['limit'] : 100;
|
||||||
|
|||||||
+20
-4
@@ -278,7 +278,10 @@ function saas_fetch_tenant_settings(PDO $pdo, int $tenantId): ?array
|
|||||||
ts.pdf_split_mode,
|
ts.pdf_split_mode,
|
||||||
ts.pdf_row_height_px,
|
ts.pdf_row_height_px,
|
||||||
ts.pdf_watermark_text,
|
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
|
FROM tenants t
|
||||||
LEFT JOIN tenant_settings ts ON ts.tenant_id = t.id
|
LEFT JOIN tenant_settings ts ON ts.tenant_id = t.id
|
||||||
WHERE 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_row_height_px' => (int)$settings['pdf_row_height_px'],
|
||||||
'pdf_watermark_text' => (string)$settings['pdf_watermark_text'],
|
'pdf_watermark_text' => (string)$settings['pdf_watermark_text'],
|
||||||
'pdf_footer_text' => (string)$settings['pdf_footer_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);
|
$pdfRowHeightPx = filter_var($input['pdf_row_height_px'] ?? null, FILTER_VALIDATE_INT);
|
||||||
$pdfWatermarkText = trim((string)($input['pdf_watermark_text'] ?? ''));
|
$pdfWatermarkText = trim((string)($input['pdf_watermark_text'] ?? ''));
|
||||||
$pdfFooterText = trim((string)($input['pdf_footer_text'] ?? ''));
|
$pdfFooterText = trim((string)($input['pdf_footer_text'] ?? ''));
|
||||||
|
$paymentReminderIntervalDays = filter_var($input['payment_reminder_interval_days'] ?? null, FILTER_VALIDATE_INT);
|
||||||
|
|
||||||
$errors = [];
|
$errors = [];
|
||||||
if (strlen($tenantName) < 3 || strlen($tenantName) > 255) {
|
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) {
|
if ($pdfRowHeightPx === false || $pdfRowHeightPx < 10 || $pdfRowHeightPx > 60) {
|
||||||
$errors[] = 'Die Zeilenhöhe im Ausdruck muss zwischen 10 und 60 Pixel liegen.';
|
$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 !== []) {
|
if ($errors !== []) {
|
||||||
return ['ok' => false, 'errors' => $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;
|
$selfEntryEnabled = !empty($input['self_entry_enabled']) ? 1 : 0;
|
||||||
$paypalEnabled = !empty($input['paypal_enabled']) ? 1 : 0;
|
$paypalEnabled = !empty($input['paypal_enabled']) ? 1 : 0;
|
||||||
$pdfShowEmptyRows = !empty($input['pdf_show_empty_rows']) ? 1 : 0;
|
$pdfShowEmptyRows = !empty($input['pdf_show_empty_rows']) ? 1 : 0;
|
||||||
|
$paymentReminderEnabled = !empty($input['payment_reminder_enabled']) ? 1 : 0;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$pdo->beginTransaction();
|
$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,
|
(tenant_id, mark_price_cents, self_entry_enabled, paypal_enabled,
|
||||||
paypal_url_template, sheet_window_days, negative_warning_cents,
|
paypal_url_template, sheet_window_days, negative_warning_cents,
|
||||||
pdf_show_empty_rows, pdf_split_mode, pdf_row_height_px,
|
pdf_show_empty_rows, pdf_split_mode, pdf_row_height_px,
|
||||||
pdf_watermark_text, pdf_footer_text)
|
pdf_watermark_text, pdf_footer_text,
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
payment_reminder_enabled, payment_reminder_interval_days)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
ON DUPLICATE KEY UPDATE
|
ON DUPLICATE KEY UPDATE
|
||||||
mark_price_cents = VALUES(mark_price_cents),
|
mark_price_cents = VALUES(mark_price_cents),
|
||||||
self_entry_enabled = VALUES(self_entry_enabled),
|
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_split_mode = VALUES(pdf_split_mode),
|
||||||
pdf_row_height_px = VALUES(pdf_row_height_px),
|
pdf_row_height_px = VALUES(pdf_row_height_px),
|
||||||
pdf_watermark_text = VALUES(pdf_watermark_text),
|
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([
|
$stmt->execute([
|
||||||
$tenantId,
|
$tenantId,
|
||||||
@@ -423,6 +437,8 @@ function saas_update_tenant_settings(PDO $pdo, int $tenantId, array $input): arr
|
|||||||
(int)$pdfRowHeightPx,
|
(int)$pdfRowHeightPx,
|
||||||
$pdfWatermarkText,
|
$pdfWatermarkText,
|
||||||
$pdfFooterText,
|
$pdfFooterText,
|
||||||
|
$paymentReminderEnabled,
|
||||||
|
(int)$paymentReminderIntervalDays,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$pdo->commit();
|
$pdo->commit();
|
||||||
|
|||||||
@@ -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);
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
@@ -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>
|
<h2>CSV-Import</h2>
|
||||||
|
|
||||||
<?php if ($meldung !== null): ?>
|
<?php if ($meldung !== null): ?>
|
||||||
@@ -248,15 +239,13 @@ $vorschau = $vorschauBatchId !== null ? imports_fetch_batch($pdo, $tenantId, $vo
|
|||||||
<br>
|
<br>
|
||||||
<?php endif; ?>
|
<?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(); ?>
|
<?php echo app_csrf_field(); ?>
|
||||||
<input type="hidden" name="aktion" value="hochladen">
|
<input type="hidden" name="aktion" value="hochladen">
|
||||||
<label for="csv_file">CSV-Datei auswählen:</label>
|
<label for="csv_file">CSV-Datei auswählen:</label>
|
||||||
<input type="file" name="csv_file" accept=".csv" required>
|
<input type="file" name="csv_file" accept=".csv" required>
|
||||||
<button type="submit">Datei hochladen</button>
|
<button type="submit">Datei hochladen</button>
|
||||||
</form>
|
</form>
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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;
|
||||||
@@ -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>
|
<h2>Einzahlungen für alle Mitarbeiter</h2>
|
||||||
|
|
||||||
<?php if ($hatGespeichert): ?>
|
<?php if ($hatGespeichert): ?>
|
||||||
@@ -213,9 +204,6 @@ $mitarbeiter = einzahlung_fetch_participants($pdo, $tenantId, $action);
|
|||||||
<button type="submit" name="submit" value="Speichern" >Eintragen</button>
|
<button type="submit" name="submit" value="Speichern" >Eintragen</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
require_once __DIR__ . '/functions.php';
|
require_once __DIR__ . '/functions.php';
|
||||||
require_once __DIR__ . '/app/saas-mail.php';
|
require_once __DIR__ . '/app/saas-mail.php';
|
||||||
|
require_once __DIR__ . '/app/rate-limit.php';
|
||||||
|
|
||||||
$pdo = app_db_pdo();
|
$pdo = app_db_pdo();
|
||||||
$user = saas_require_login();
|
$user = saas_require_login();
|
||||||
@@ -11,6 +12,16 @@ $verificationLink = null;
|
|||||||
|
|
||||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
app_require_csrf();
|
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']);
|
$result = saas_request_email_verification($pdo, (int)$user['user_id'], (int)$user['tenant_id']);
|
||||||
|
|
||||||
if ($result['ok']) {
|
if ($result['ok']) {
|
||||||
@@ -29,6 +40,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
$errors = $result['errors'];
|
$errors = $result['errors'];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
include 'header.php';
|
include 'header.php';
|
||||||
include 'headerline.php';
|
include 'headerline.php';
|
||||||
|
|||||||
+15
-1
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
include "functions.php";
|
include "functions.php";
|
||||||
require_once __DIR__ . "/app/ledger.php";
|
require_once __DIR__ . "/app/ledger.php";
|
||||||
|
require_once __DIR__ . "/app/tenant-logo.php";
|
||||||
|
|
||||||
$pdo = app_db_pdo();
|
$pdo = app_db_pdo();
|
||||||
$saasUser = saas_current_user($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);
|
$adminRowHeightPx = (int)($settings['pdf_row_height_px'] ?? 16);
|
||||||
$watermarkText = trim((string)($settings['pdf_watermark_text'] ?? ''));
|
$watermarkText = trim((string)($settings['pdf_watermark_text'] ?? ''));
|
||||||
$footerText = trim((string)($settings['pdf_footer_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
|
// Ab dieser Mitgliederzahl wird auf zwei Seiten (Vorder-/Rueckseite) statt
|
||||||
// einer einzelnen Seite gedruckt.
|
// einer einzelnen Seite gedruckt.
|
||||||
@@ -117,10 +119,13 @@ require_once(__DIR__ . '/TCPDF/tcpdf.php');
|
|||||||
|
|
||||||
class MyCustomPDFWithWatermark extends TCPDF {
|
class MyCustomPDFWithWatermark extends TCPDF {
|
||||||
public string $watermarkText = '';
|
public string $watermarkText = '';
|
||||||
|
public string $watermarkLogoPath = '';
|
||||||
public string $footerText = '';
|
public string $footerText = '';
|
||||||
|
|
||||||
public function Header() {
|
public function Header() {
|
||||||
if (trim($this->watermarkText) === '') {
|
$hasLogo = $this->watermarkLogoPath !== '' && is_file($this->watermarkLogoPath);
|
||||||
|
$hasText = trim($this->watermarkText) !== '';
|
||||||
|
if (!$hasLogo && !$hasText) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,6 +135,13 @@ class MyCustomPDFWithWatermark extends TCPDF {
|
|||||||
$x = $this->x;
|
$x = $this->x;
|
||||||
$y = $this->y;
|
$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->SetAlpha(0.35);
|
||||||
$this->SetFont('helvetica', '', 14);
|
$this->SetFont('helvetica', '', 14);
|
||||||
$this->SetTextColor(0, 0, 0);
|
$this->SetTextColor(0, 0, 0);
|
||||||
@@ -139,6 +151,7 @@ class MyCustomPDFWithWatermark extends TCPDF {
|
|||||||
$this->SetAlpha(1);
|
$this->SetAlpha(1);
|
||||||
$this->SetFont('helvetica', '', 9.5);
|
$this->SetFont('helvetica', '', 9.5);
|
||||||
$this->SetTextColor(0, 0, 0);
|
$this->SetTextColor(0, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
// MultiCell(..., $ln=1, ...) verschiebt den Seiten-Cursor als
|
// MultiCell(..., $ln=1, ...) verschiebt den Seiten-Cursor als
|
||||||
// Nebeneffekt - ohne dieses Zuruecksetzen wuerde der eigentliche
|
// 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 = new MyCustomPDFWithWatermark(PDF_PAGE_ORIENTATION, 'mm', 'A4', true, 'UTF-8', false);
|
||||||
$pdf->watermarkText = $watermarkText;
|
$pdf->watermarkText = $watermarkText;
|
||||||
|
$pdf->watermarkLogoPath = $watermarkLogoPath;
|
||||||
$pdf->footerText = $footerText;
|
$pdf->footerText = $footerText;
|
||||||
$pdf->SetHeaderData("", 0, "Kaffeestrichliste", "");
|
$pdf->SetHeaderData("", 0, "Kaffeestrichliste", "");
|
||||||
$pdf->SetPrintFooter(true);
|
$pdf->SetPrintFooter(true);
|
||||||
|
|||||||
@@ -63,6 +63,19 @@ $settings = $hasAccess ? saas_fetch_tenant_settings($pdo, $tenantId) : null;
|
|||||||
$entryMessage = null;
|
$entryMessage = null;
|
||||||
$entryError = 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
|
function dashboard_money(int $cents): string
|
||||||
{
|
{
|
||||||
return saas_format_money_cents($cents) . ' €';
|
return saas_format_money_cents($cents) . ' €';
|
||||||
@@ -203,17 +216,29 @@ function dashboard_render_consumption(array $entries): void
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($_SERVER["REQUEST_METHOD"] === "POST") {
|
if ($_SERVER["REQUEST_METHOD"] === "POST") {
|
||||||
|
$flash = ['type' => 'error', 'text' => 'Die Aktion konnte nicht ausgeführt werden.'];
|
||||||
|
|
||||||
if ($hasAccess && $participant !== null && $settings !== null) {
|
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);
|
$result = dashboard_record_self_entry($pdo, $participant, $settings, $_POST["anzahlStriche"] ?? null, $saasUser['user_id'] ?? null);
|
||||||
if ($result['ok']) {
|
$flash = $result['ok']
|
||||||
$entryMessage = $result['message'] ?? null;
|
? ['type' => 'success', 'text' => $result['message'] ?? 'Stricheintragung wurde erfolgreich eingetragen.']
|
||||||
$participant = ledger_fetch_participant_summary($pdo, $tenantId, (int)$participant['participant_id']);
|
: ['type' => 'error', 'text' => $result['error'] ?? 'Die Stricheintragung konnte nicht gespeichert werden.'];
|
||||||
} else {
|
|
||||||
$entryError = $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
|
$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>
|
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>
|
<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): ?>
|
<?php if ($selfEntryEnabled): ?>
|
||||||
<h2>Eintrag in die Strichliste</h2>
|
<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>
|
<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>
|
<li>
|
||||||
<form method="post" action="index.php">
|
<form method="post" action="index.php">
|
||||||
<button type="submit">Einen Strich eintragen</button>
|
<button type="submit">Einen Strich eintragen</button>
|
||||||
|
<input type="hidden" name="aktion" value="strich_eintragen">
|
||||||
<input type="hidden" name="anzahlStriche" value="1">
|
<input type="hidden" name="anzahlStriche" value="1">
|
||||||
<?php echo app_csrf_field(); ?>
|
<?php echo app_csrf_field(); ?>
|
||||||
</form>
|
</form>
|
||||||
@@ -289,11 +338,20 @@ if ($hasAccess && $participant !== null && $settings !== null) {
|
|||||||
<li>
|
<li>
|
||||||
<form method="post" action="index.php">
|
<form method="post" action="index.php">
|
||||||
<button type="submit">Zwei Striche eintragen</button>
|
<button type="submit">Zwei Striche eintragen</button>
|
||||||
|
<input type="hidden" name="aktion" value="strich_eintragen">
|
||||||
<input type="hidden" name="anzahlStriche" value="2">
|
<input type="hidden" name="anzahlStriche" value="2">
|
||||||
<?php echo app_csrf_field(); ?>
|
<?php echo app_csrf_field(); ?>
|
||||||
</form>
|
</form>
|
||||||
</li>
|
</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>
|
</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 endif; ?>
|
||||||
|
|
||||||
<?php if ($paypalEnabled): ?>
|
<?php if ($paypalEnabled): ?>
|
||||||
|
|||||||
+119
-54
@@ -5,17 +5,6 @@ require_once __DIR__ . "/app/ledger.php";
|
|||||||
require_once __DIR__ . "/app/saas-mail.php";
|
require_once __DIR__ . "/app/saas-mail.php";
|
||||||
require_once __DIR__ . "/app/audit.php";
|
require_once __DIR__ . "/app/audit.php";
|
||||||
app_require_csrf();
|
app_require_csrf();
|
||||||
include "header.php";
|
|
||||||
include "headerline.php";
|
|
||||||
include "nav.php";
|
|
||||||
|
|
||||||
?>
|
|
||||||
|
|
||||||
<!-- Banner -->
|
|
||||||
<section id="banner">
|
|
||||||
<div class="content">
|
|
||||||
|
|
||||||
<?php
|
|
||||||
|
|
||||||
$pdo = app_db_pdo();
|
$pdo = app_db_pdo();
|
||||||
$saasUser = saas_current_user($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');
|
$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;
|
$fehler = null;
|
||||||
$ergebnisse = null;
|
$ergebnisse = null;
|
||||||
$dryRun = true;
|
$dryRun = true;
|
||||||
$gesamtbetragEingabe = '';
|
$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']);
|
$dryRun = !empty($_POST['dry_run']);
|
||||||
$gesamtbetragEingabe = (string)($_POST['gesamtbetrag'] ?? '');
|
$gesamtbetragEingabe = (string)($_POST['gesamtbetrag'] ?? '');
|
||||||
$gesamtbetrag = filter_var(str_replace(',', '.', $gesamtbetragEingabe), FILTER_VALIDATE_FLOAT);
|
$gesamtbetrag = filter_var(str_replace(',', '.', $gesamtbetragEingabe), FILTER_VALIDATE_FLOAT);
|
||||||
@@ -61,11 +60,47 @@ if ($_SERVER["REQUEST_METHOD"] === "POST") {
|
|||||||
} elseif ($gesamtJahresstriche <= 0) {
|
} elseif ($gesamtJahresstriche <= 0) {
|
||||||
$fehler = "Für {$jahr} gibt es noch keine Striche, daher kann nichts verteilt werden.";
|
$fehler = "Für {$jahr} gibt es noch keine Striche, daher kann nichts verteilt werden.";
|
||||||
} else {
|
} else {
|
||||||
$ergebnisse = [];
|
|
||||||
$settings = saas_fetch_tenant_settings($pdo, $tenantId);
|
|
||||||
$dashboardUrl = saas_app_url('index.php');
|
|
||||||
$createdByUserId = $saasUser['user_id'] ?? null;
|
$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
|
// Legacy-Verknuepfung je Teilnehmer vorab laden (gleiches Zweig-Muster
|
||||||
// wie bei Sammelerfassung und CSV-Import: Default-Mandant bucht
|
// wie bei Sammelerfassung und CSV-Import: Default-Mandant bucht
|
||||||
// zusaetzlich in kl_Einzahlungen, alle anderen Mandanten rein Ledger-nativ).
|
// 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;
|
$legacyMap[(int)$row['id']] = $row['legacy_mitarbeiter_id'] !== null ? (int)$row['legacy_mitarbeiter_id'] : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$ergebnisse = [];
|
||||||
|
$zuBenachrichtigen = [];
|
||||||
try {
|
try {
|
||||||
if (!$dryRun) {
|
|
||||||
$pdo->beginTransaction();
|
$pdo->beginTransaction();
|
||||||
}
|
|
||||||
$insertLegacy = $pdo->prepare('INSERT INTO kl_Einzahlungen (MitarbeiterID, Betrag, Datum) VALUES (?, ?, ?)');
|
$insertLegacy = $pdo->prepare('INSERT INTO kl_Einzahlungen (MitarbeiterID, Betrag, Datum) VALUES (?, ?, ?)');
|
||||||
$jetzt = date('Y-m-d H:i:s');
|
$jetzt = date('Y-m-d H:i:s');
|
||||||
|
|
||||||
foreach ($teilnehmer as $person) {
|
foreach ($zuteilung as $z) {
|
||||||
$yearMarks = (int)$person['year_marks'];
|
$person = $z['person'];
|
||||||
if ($yearMarks <= 0) {
|
$bonusCents = $z['bonus_cents'];
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$anteil = $yearMarks / $gesamtJahresstriche;
|
|
||||||
$bonusCents = (int)round($totalCents * $anteil);
|
|
||||||
if ($bonusCents <= 0) {
|
if ($bonusCents <= 0) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$status = 'vorschau';
|
|
||||||
|
|
||||||
if (!$dryRun) {
|
|
||||||
$legacyMitarbeiterId = $legacyMap[$person['participant_id']] ?? null;
|
$legacyMitarbeiterId = $legacyMap[$person['participant_id']] ?? null;
|
||||||
if ($legacyMitarbeiterId !== null) {
|
if ($legacyMitarbeiterId !== null) {
|
||||||
$insertLegacy->execute([$legacyMitarbeiterId, $bonusCents / 100, $jetzt]);
|
$insertLegacy->execute([$legacyMitarbeiterId, $bonusCents / 100, $jetzt]);
|
||||||
$legacyPaymentId = (int)$pdo->lastInsertId();
|
$legacyPaymentId = (int)$pdo->lastInsertId();
|
||||||
ledger_mirror_legacy_payment($pdo, $tenantId, $legacyPaymentId);
|
ledger_mirror_legacy_payment($pdo, $tenantId, $legacyPaymentId);
|
||||||
} else {
|
} 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'] ?? ''));
|
$email = trim((string)($person['email'] ?? ''));
|
||||||
if (filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
if (filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||||
$subject = "Kaffeeliste - Dein Jahresbonus {$jahr}";
|
$zuBenachrichtigen[] = [
|
||||||
$body = saas_render_year_end_bonus_mail_body($person['display_name'], $yearMarks, $bonusCents, $dashboardUrl);
|
'participant_id' => (int)$person['participant_id'],
|
||||||
$sendResult = saas_send_mail($email, $subject, $body);
|
'email' => $email,
|
||||||
saas_log_outbound_email(
|
'name' => (string)$person['display_name'],
|
||||||
$pdo, $tenantId, $person['participant_id'], 'year_end_bonus', $subject,
|
'year_marks' => $z['year_marks'],
|
||||||
$sendResult['ok'] ? 'sent' : 'failed', $sendResult['error'] ?? null, $createdByUserId
|
'bonus_cents' => $bonusCents,
|
||||||
);
|
];
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$ergebnisse[] = [
|
$ergebnisse[] = [
|
||||||
'name' => $person['display_name'],
|
'name' => $person['display_name'],
|
||||||
'year_marks' => $yearMarks,
|
'year_marks' => $z['year_marks'],
|
||||||
'anteil' => $anteil,
|
'anteil' => $z['anteil'],
|
||||||
'bonus_cents' => $bonusCents,
|
'bonus_cents' => $bonusCents,
|
||||||
'status' => $status,
|
'status' => 'gebucht',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!$dryRun) {
|
|
||||||
$pdo->commit();
|
$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) {
|
} catch (Throwable $e) {
|
||||||
if ($pdo->inTransaction()) {
|
if ($pdo->inTransaction()) {
|
||||||
$pdo->rollBack();
|
$pdo->rollBack();
|
||||||
@@ -140,11 +162,48 @@ if ($_SERVER["REQUEST_METHOD"] === "POST") {
|
|||||||
$fehler = 'Die Verteilung konnte nicht gespeichert werden.';
|
$fehler = 'Die Verteilung konnte nicht gespeichert werden.';
|
||||||
$ergebnisse = null;
|
$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>
|
<h2>Jahresabschluss <?php echo (int)$jahr; ?></h2>
|
||||||
<p>Verteilt einen frei wählbaren Gesamtbetrag proportional zu den in diesem
|
<p>Verteilt einen frei wählbaren Gesamtbetrag proportional zu den in diesem
|
||||||
Jahr gemachten Strichen als Guthaben an alle aktiven Mitglieder und
|
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>
|
<div class="hint-box error"><p><?php echo saas_html($fehler); ?></p></div>
|
||||||
<?php endif; ?>
|
<?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(); ?>
|
<?php echo app_csrf_field(); ?>
|
||||||
<label for="gesamtbetrag">Gesamtbetrag (€):</label>
|
<label for="gesamtbetrag">Gesamtbetrag (€):</label>
|
||||||
<input type="text" name="gesamtbetrag" id="gesamtbetrag" value="<?php echo saas_html($gesamtbetragEingabe); ?>" required>
|
<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>
|
</tr>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
</table>
|
</table>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ $stmtEinzahlungen = sqlsrv_query($conn, $sqlEinzahlungen);
|
|||||||
echo "<td>{$betrag}</td>";
|
echo "<td>{$betrag}</td>";
|
||||||
echo "<td>{$datum}</td>";
|
echo "<td>{$datum}</td>";
|
||||||
echo "<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='aktion' value='loescheneinzahlung'>";
|
||||||
echo "<input type='hidden' name='einzahlungID' value='{$einzahlungID}'>";
|
echo "<input type='hidden' name='einzahlungID' value='{$einzahlungID}'>";
|
||||||
echo app_csrf_field();
|
echo app_csrf_field();
|
||||||
@@ -209,7 +209,7 @@ $stmtStriche = sqlsrv_query($conn, $sqlStriche);
|
|||||||
echo "<td>{$betrag}</td>";
|
echo "<td>{$betrag}</td>";
|
||||||
echo "<td>{$datum}</td>";
|
echo "<td>{$datum}</td>";
|
||||||
echo "<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='aktion' value='loeschen'>";
|
||||||
echo "<input type='hidden' name='strichID' value='{$strichID}'>";
|
echo "<input type='hidden' name='strichID' value='{$strichID}'>";
|
||||||
echo app_csrf_field();
|
echo app_csrf_field();
|
||||||
|
|||||||
+41
-23
@@ -5,20 +5,6 @@ require_once __DIR__ . "/app/ledger.php";
|
|||||||
require_once __DIR__ . "/app/saas-mail.php";
|
require_once __DIR__ . "/app/saas-mail.php";
|
||||||
require_once __DIR__ . "/app/audit.php";
|
require_once __DIR__ . "/app/audit.php";
|
||||||
app_require_csrf();
|
app_require_csrf();
|
||||||
include "header.php";
|
|
||||||
include "headerline.php";
|
|
||||||
include "nav.php";
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
?>
|
|
||||||
|
|
||||||
|
|
||||||
<!-- Banner -->
|
|
||||||
<section id="banner">
|
|
||||||
<div class="content">
|
|
||||||
|
|
||||||
<?php
|
|
||||||
|
|
||||||
$pdo = app_db_pdo();
|
$pdo = app_db_pdo();
|
||||||
$saasUser = saas_current_user($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;
|
$ergebnisse = null;
|
||||||
$dryRun = true;
|
$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']);
|
$dryRun = !empty($_POST['dry_run']);
|
||||||
$settings = saas_fetch_tenant_settings($pdo, $tenantId);
|
$settings = saas_fetch_tenant_settings($pdo, $tenantId);
|
||||||
$dashboardUrl = saas_app_url('index.php');
|
$dashboardUrl = saas_app_url('index.php');
|
||||||
@@ -89,10 +83,16 @@ if ($_SERVER["REQUEST_METHOD"] === "POST") {
|
|||||||
|
|
||||||
if (!$dryRun) {
|
if (!$dryRun) {
|
||||||
app_audit_log($pdo, $tenantId, $createdByUserId, 'mail_broadcast.sent', 'tenant', $tenantId, ['empfaenger' => count($ergebnisse)]);
|
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
|
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>
|
<h2>Info-Mail versenden</h2>
|
||||||
<p>Verschickt an alle aktiven Mitglieder eine E-Mail mit dem aktuellen
|
<p>Verschickt an alle aktiven Mitglieder eine E-Mail mit dem aktuellen
|
||||||
Kaffeelisten-Stand. Im Dry-Run wird nichts wirklich versendet, aber jeder
|
Kaffeelisten-Stand. Im Dry-Run wird nichts wirklich versendet, aber jeder
|
||||||
Empfänger wird im Versandlog unten protokolliert.</p>
|
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): ?>
|
<?php if ($ergebnisse !== null): ?>
|
||||||
<h3>Ergebnis dieses Laufs (<?php echo $dryRun ? 'Dry-Run' : 'Live-Versand'; ?>)</h3>
|
<h3>Ergebnis dieses Laufs (<?php echo $dryRun ? 'Dry-Run' : 'Live-Versand'; ?>)</h3>
|
||||||
<table>
|
<table>
|
||||||
@@ -126,7 +142,7 @@ Empfänger wird im Versandlog unten protokolliert.</p>
|
|||||||
</table>
|
</table>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
<form method="post" action="<?php echo htmlspecialchars($_SERVER["PHP_SELF"]); ?>">
|
<form method="post" action="mailversenden.php">
|
||||||
<?php echo app_csrf_field(); ?>
|
<?php echo app_csrf_field(); ?>
|
||||||
<div class="form-check">
|
<div class="form-check">
|
||||||
<input class="form-check-input" type="checkbox" name="dry_run" id="dry_run" checked>
|
<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; ?>
|
<?php endforeach; ?>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
require_once __DIR__ . '/functions.php';
|
require_once __DIR__ . '/functions.php';
|
||||||
require_once __DIR__ . '/app/audit.php';
|
require_once __DIR__ . '/app/audit.php';
|
||||||
require_once __DIR__ . '/app/billing.php';
|
require_once __DIR__ . '/app/billing.php';
|
||||||
|
require_once __DIR__ . '/app/tenant-logo.php';
|
||||||
|
|
||||||
$pdo = app_db_pdo();
|
$pdo = app_db_pdo();
|
||||||
$user = saas_require_login();
|
$user = saas_require_login();
|
||||||
@@ -19,6 +20,20 @@ if ($canManageSettings) {
|
|||||||
$saved = true;
|
$saved = true;
|
||||||
$settings = $result['settings'];
|
$settings = $result['settings'];
|
||||||
app_audit_log($pdo, (int)$user['tenant_id'], (int)$user['user_id'], 'tenant_settings.updated', 'tenant', (int)$user['tenant_id']);
|
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 {
|
} else {
|
||||||
$errors = $result['errors'];
|
$errors = $result['errors'];
|
||||||
$settings = [
|
$settings = [
|
||||||
@@ -37,6 +52,8 @@ if ($canManageSettings) {
|
|||||||
'pdf_row_height_px' => $_POST['pdf_row_height_px'] ?? 16,
|
'pdf_row_height_px' => $_POST['pdf_row_height_px'] ?? 16,
|
||||||
'pdf_watermark_text' => $_POST['pdf_watermark_text'] ?? '',
|
'pdf_watermark_text' => $_POST['pdf_watermark_text'] ?? '',
|
||||||
'pdf_footer_text' => $_POST['pdf_footer_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 {
|
} else {
|
||||||
@@ -76,7 +93,7 @@ include 'nav.php';
|
|||||||
</div>
|
</div>
|
||||||
<?php endif; ?>
|
<?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(); ?>
|
<?php echo app_csrf_field(); ?>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-6 col-12-small">
|
<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>
|
<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']); ?>">
|
<input type="text" name="pdf_watermark_text" id="pdf_watermark_text" maxlength="500" value="<?php echo saas_html($settings['pdf_watermark_text']); ?>">
|
||||||
</div>
|
</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">
|
<div class="col-12">
|
||||||
<label for="pdf_footer_text">Hinweis in der Fußzeile des Ausdrucks (leer = keine Fußzeile)</label>
|
<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']); ?>">
|
<input type="text" name="pdf_footer_text" id="pdf_footer_text" maxlength="500" value="<?php echo saas_html($settings['pdf_footer_text']); ?>">
|
||||||
</div>
|
</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>
|
</div>
|
||||||
<ul class="actions">
|
<ul class="actions">
|
||||||
<li><button type="submit">Speichern</button></li>
|
<li><button type="submit">Speichern</button></li>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ require_once __DIR__ . "/app/ledger.php";
|
|||||||
require_once __DIR__ . "/app/saas-mail.php";
|
require_once __DIR__ . "/app/saas-mail.php";
|
||||||
require_once __DIR__ . "/app/audit.php";
|
require_once __DIR__ . "/app/audit.php";
|
||||||
require_once __DIR__ . "/app/billing.php";
|
require_once __DIR__ . "/app/billing.php";
|
||||||
|
require_once __DIR__ . "/app/imports.php";
|
||||||
app_require_csrf();
|
app_require_csrf();
|
||||||
include "header.php";
|
include "header.php";
|
||||||
include "headerline.php";
|
include "headerline.php";
|
||||||
@@ -155,8 +156,59 @@ if($hasAccess){
|
|||||||
} else {
|
} else {
|
||||||
$fehler = 'Das Mitglied konnte nicht anonymisiert werden.';
|
$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);
|
$mitglieder = ledger_fetch_participants_for_admin($pdo, $tenantId);
|
||||||
$billingInfo = billing_fetch_or_init($pdo, $tenantId);
|
$billingInfo = billing_fetch_or_init($pdo, $tenantId);
|
||||||
@@ -246,6 +298,20 @@ if($hasAccess){
|
|||||||
<button type="submit">Mitglied anlegen</button>
|
<button type="submit">Mitglied anlegen</button>
|
||||||
</form>
|
</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
|
<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>
|
ist davon unabhängig und wird separat je Mitglied gewährt oder entzogen.</p>
|
||||||
|
|
||||||
|
|||||||
@@ -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";
|
||||||
@@ -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";
|
||||||
@@ -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>
|
<h2>Anzahl der Striche für alle Mitarbeiter</h2>
|
||||||
|
|
||||||
<?php if ($hatGespeichert): ?>
|
<?php if ($hatGespeichert): ?>
|
||||||
@@ -229,9 +220,6 @@ $kostenproStrichAnzeige = number_format(($settings['mark_price_cents'] ?? 20) /
|
|||||||
<button type="submit">Eintragen</button>
|
<button type="submit">Eintragen</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
+26
-2
@@ -33,6 +33,15 @@ if ($event === null) {
|
|||||||
$pdo = app_db_pdo();
|
$pdo = app_db_pdo();
|
||||||
$object = $event['data'];
|
$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']) {
|
switch ($event['type']) {
|
||||||
case 'checkout.session.completed':
|
case 'checkout.session.completed':
|
||||||
$tenantId = isset($object['metadata']['tenant_id']) ? (int)$object['metadata']['tenant_id'] : null;
|
$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'])
|
$periodEnd = isset($object['current_period_end'])
|
||||||
? date('Y-m-d H:i:s', (int)$object['current_period_end'])
|
? date('Y-m-d H:i:s', (int)$object['current_period_end'])
|
||||||
: null;
|
: null;
|
||||||
billing_update($pdo, $tenantId, [
|
$updateFields = [
|
||||||
'subscription_status' => $status,
|
'subscription_status' => $status,
|
||||||
'current_period_end' => $periodEnd,
|
'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;
|
break;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user