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:
+140
-75
@@ -5,17 +5,6 @@ require_once __DIR__ . "/app/ledger.php";
|
||||
require_once __DIR__ . "/app/saas-mail.php";
|
||||
require_once __DIR__ . "/app/audit.php";
|
||||
app_require_csrf();
|
||||
include "header.php";
|
||||
include "headerline.php";
|
||||
include "nav.php";
|
||||
|
||||
?>
|
||||
|
||||
<!-- Banner -->
|
||||
<section id="banner">
|
||||
<div class="content">
|
||||
|
||||
<?php
|
||||
|
||||
$pdo = app_db_pdo();
|
||||
$saasUser = saas_current_user($pdo);
|
||||
@@ -35,22 +24,32 @@ if (!$hasAccess && $saasUser === null && checkKaffeelisteAdmin($conn, $mailadres
|
||||
}
|
||||
}
|
||||
|
||||
if (!$hasAccess) {
|
||||
echo "<h2>Kein Zugriff</h2>";
|
||||
include "footer.php";
|
||||
exit;
|
||||
}
|
||||
|
||||
$jahr = (int)date('Y');
|
||||
$teilnehmer = ledger_fetch_participant_summaries($pdo, $tenantId, ['active_only' => true, 'year' => $jahr]);
|
||||
$gesamtJahresstriche = array_sum(array_column($teilnehmer, 'year_marks'));
|
||||
|
||||
$fehler = null;
|
||||
$ergebnisse = null;
|
||||
$dryRun = true;
|
||||
$gesamtbetragEingabe = '';
|
||||
$gebuchtFlash = false;
|
||||
|
||||
if ($_SERVER["REQUEST_METHOD"] === "POST") {
|
||||
// Ergebnis einer vorangegangenen echten Buchung (Post-Redirect-Get): nach dem
|
||||
// Buchen wird auf diese Seite zurueckgeleitet, damit ein Browser-Refresh die
|
||||
// Verteilung nicht ein zweites Mal ausloest.
|
||||
if ($hasAccess && isset($_SESSION['flash_year_end'])) {
|
||||
$flash = $_SESSION['flash_year_end'];
|
||||
unset($_SESSION['flash_year_end']);
|
||||
if ((int)($flash['tenant_id'] ?? 0) === $tenantId) {
|
||||
$ergebnisse = $flash['ergebnisse'] ?? null;
|
||||
$dryRun = false;
|
||||
$gebuchtFlash = true;
|
||||
}
|
||||
}
|
||||
|
||||
$teilnehmer = $hasAccess
|
||||
? ledger_fetch_participant_summaries($pdo, $tenantId, ['active_only' => true, 'year' => $jahr])
|
||||
: [];
|
||||
$gesamtJahresstriche = array_sum(array_column($teilnehmer, 'year_marks'));
|
||||
|
||||
if ($hasAccess && $_SERVER["REQUEST_METHOD"] === "POST") {
|
||||
$dryRun = !empty($_POST['dry_run']);
|
||||
$gesamtbetragEingabe = (string)($_POST['gesamtbetrag'] ?? '');
|
||||
$gesamtbetrag = filter_var(str_replace(',', '.', $gesamtbetragEingabe), FILTER_VALIDATE_FLOAT);
|
||||
@@ -61,90 +60,150 @@ if ($_SERVER["REQUEST_METHOD"] === "POST") {
|
||||
} elseif ($gesamtJahresstriche <= 0) {
|
||||
$fehler = "Für {$jahr} gibt es noch keine Striche, daher kann nichts verteilt werden.";
|
||||
} else {
|
||||
$ergebnisse = [];
|
||||
$settings = saas_fetch_tenant_settings($pdo, $tenantId);
|
||||
$dashboardUrl = saas_app_url('index.php');
|
||||
$createdByUserId = $saasUser['user_id'] ?? null;
|
||||
|
||||
// Legacy-Verknuepfung je Teilnehmer vorab laden (gleiches Zweig-Muster
|
||||
// wie bei Sammelerfassung und CSV-Import: Default-Mandant bucht
|
||||
// zusaetzlich in kl_Einzahlungen, alle anderen Mandanten rein Ledger-nativ).
|
||||
$legacyMap = [];
|
||||
$stmt = $pdo->prepare('SELECT id, legacy_mitarbeiter_id FROM participants WHERE tenant_id = ?');
|
||||
$stmt->execute([$tenantId]);
|
||||
foreach ($stmt->fetchAll() as $row) {
|
||||
$legacyMap[(int)$row['id']] = $row['legacy_mitarbeiter_id'] !== null ? (int)$row['legacy_mitarbeiter_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,
|
||||
];
|
||||
}
|
||||
|
||||
try {
|
||||
if (!$dryRun) {
|
||||
$pdo->beginTransaction();
|
||||
if ($dryRun) {
|
||||
$ergebnisse = array_map(static function (array $z): array {
|
||||
return [
|
||||
'name' => $z['person']['display_name'],
|
||||
'year_marks' => $z['year_marks'],
|
||||
'anteil' => $z['anteil'],
|
||||
'bonus_cents' => $z['bonus_cents'],
|
||||
'status' => 'vorschau',
|
||||
];
|
||||
}, $zuteilung);
|
||||
} else {
|
||||
// Legacy-Verknuepfung je Teilnehmer vorab laden (gleiches Zweig-Muster
|
||||
// wie bei Sammelerfassung und CSV-Import: Default-Mandant bucht
|
||||
// zusaetzlich in kl_Einzahlungen, alle anderen Mandanten rein Ledger-nativ).
|
||||
$legacyMap = [];
|
||||
$stmt = $pdo->prepare('SELECT id, legacy_mitarbeiter_id FROM participants WHERE tenant_id = ?');
|
||||
$stmt->execute([$tenantId]);
|
||||
foreach ($stmt->fetchAll() as $row) {
|
||||
$legacyMap[(int)$row['id']] = $row['legacy_mitarbeiter_id'] !== null ? (int)$row['legacy_mitarbeiter_id'] : null;
|
||||
}
|
||||
$insertLegacy = $pdo->prepare('INSERT INTO kl_Einzahlungen (MitarbeiterID, Betrag, Datum) VALUES (?, ?, ?)');
|
||||
$jetzt = date('Y-m-d H:i:s');
|
||||
|
||||
foreach ($teilnehmer as $person) {
|
||||
$yearMarks = (int)$person['year_marks'];
|
||||
if ($yearMarks <= 0) {
|
||||
continue;
|
||||
}
|
||||
$ergebnisse = [];
|
||||
$zuBenachrichtigen = [];
|
||||
try {
|
||||
$pdo->beginTransaction();
|
||||
$insertLegacy = $pdo->prepare('INSERT INTO kl_Einzahlungen (MitarbeiterID, Betrag, Datum) VALUES (?, ?, ?)');
|
||||
$jetzt = date('Y-m-d H:i:s');
|
||||
|
||||
$anteil = $yearMarks / $gesamtJahresstriche;
|
||||
$bonusCents = (int)round($totalCents * $anteil);
|
||||
if ($bonusCents <= 0) {
|
||||
continue;
|
||||
}
|
||||
foreach ($zuteilung as $z) {
|
||||
$person = $z['person'];
|
||||
$bonusCents = $z['bonus_cents'];
|
||||
if ($bonusCents <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$status = 'vorschau';
|
||||
|
||||
if (!$dryRun) {
|
||||
$legacyMitarbeiterId = $legacyMap[$person['participant_id']] ?? null;
|
||||
if ($legacyMitarbeiterId !== null) {
|
||||
$insertLegacy->execute([$legacyMitarbeiterId, $bonusCents / 100, $jetzt]);
|
||||
$legacyPaymentId = (int)$pdo->lastInsertId();
|
||||
ledger_mirror_legacy_payment($pdo, $tenantId, $legacyPaymentId);
|
||||
} else {
|
||||
ledger_record_payment($pdo, $tenantId, $person['participant_id'], $bonusCents, 'year_end_bonus');
|
||||
ledger_record_payment($pdo, $tenantId, (int)$person['participant_id'], $bonusCents, 'year_end_bonus', $createdByUserId);
|
||||
}
|
||||
$status = 'gebucht';
|
||||
|
||||
$email = trim((string)($person['email'] ?? ''));
|
||||
if (filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
$subject = "Kaffeeliste - Dein Jahresbonus {$jahr}";
|
||||
$body = saas_render_year_end_bonus_mail_body($person['display_name'], $yearMarks, $bonusCents, $dashboardUrl);
|
||||
$sendResult = saas_send_mail($email, $subject, $body);
|
||||
saas_log_outbound_email(
|
||||
$pdo, $tenantId, $person['participant_id'], 'year_end_bonus', $subject,
|
||||
$sendResult['ok'] ? 'sent' : 'failed', $sendResult['error'] ?? null, $createdByUserId
|
||||
);
|
||||
$zuBenachrichtigen[] = [
|
||||
'participant_id' => (int)$person['participant_id'],
|
||||
'email' => $email,
|
||||
'name' => (string)$person['display_name'],
|
||||
'year_marks' => $z['year_marks'],
|
||||
'bonus_cents' => $bonusCents,
|
||||
];
|
||||
}
|
||||
|
||||
$ergebnisse[] = [
|
||||
'name' => $person['display_name'],
|
||||
'year_marks' => $z['year_marks'],
|
||||
'anteil' => $z['anteil'],
|
||||
'bonus_cents' => $bonusCents,
|
||||
'status' => 'gebucht',
|
||||
];
|
||||
}
|
||||
|
||||
$ergebnisse[] = [
|
||||
'name' => $person['display_name'],
|
||||
'year_marks' => $yearMarks,
|
||||
'anteil' => $anteil,
|
||||
'bonus_cents' => $bonusCents,
|
||||
'status' => $status,
|
||||
];
|
||||
$pdo->commit();
|
||||
} catch (Throwable $e) {
|
||||
if ($pdo->inTransaction()) {
|
||||
$pdo->rollBack();
|
||||
}
|
||||
$fehler = 'Die Verteilung konnte nicht gespeichert werden.';
|
||||
$ergebnisse = null;
|
||||
}
|
||||
|
||||
if (!$dryRun) {
|
||||
$pdo->commit();
|
||||
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;
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
if ($pdo->inTransaction()) {
|
||||
$pdo->rollBack();
|
||||
}
|
||||
$fehler = 'Die Verteilung konnte nicht gespeichert werden.';
|
||||
$ergebnisse = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
include "header.php";
|
||||
include "headerline.php";
|
||||
include "nav.php";
|
||||
?>
|
||||
|
||||
<!-- Banner -->
|
||||
<section id="banner">
|
||||
<div class="content">
|
||||
|
||||
<?php if (!$hasAccess): ?>
|
||||
<h2>Kein Zugriff</h2>
|
||||
<?php else: ?>
|
||||
|
||||
<h2>Jahresabschluss <?php echo (int)$jahr; ?></h2>
|
||||
<p>Verteilt einen frei wählbaren Gesamtbetrag proportional zu den in diesem
|
||||
Jahr gemachten Strichen als Guthaben an alle aktiven Mitglieder und
|
||||
@@ -157,7 +216,11 @@ es wird nichts gebucht und keine Mail verschickt.</p>
|
||||
<div class="hint-box error"><p><?php echo saas_html($fehler); ?></p></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<form method="post" action="<?php echo htmlspecialchars($_SERVER["PHP_SELF"]); ?>">
|
||||
<?php if ($gebuchtFlash): ?>
|
||||
<div class="hint-box success"><p>Der Jahresbonus wurde gebucht und die Mitglieder wurden benachrichtigt.</p></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<form method="post" action="jahresauswertung.php">
|
||||
<?php echo app_csrf_field(); ?>
|
||||
<label for="gesamtbetrag">Gesamtbetrag (€):</label>
|
||||
<input type="text" name="gesamtbetrag" id="gesamtbetrag" value="<?php echo saas_html($gesamtbetragEingabe); ?>" required>
|
||||
@@ -183,6 +246,8 @@ es wird nichts gebucht und keine Mail verschickt.</p>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</table>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php endif; ?>
|
||||
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user