Files
kaffeekasse-saas/jahresauswertung.php
T
clemens ef5d0e1822 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).
2026-07-20 21:54:16 +02:00

257 lines
8.9 KiB
PHP

<?php
include "functions.php";
require_once __DIR__ . "/app/ledger.php";
require_once __DIR__ . "/app/saas-mail.php";
require_once __DIR__ . "/app/audit.php";
app_require_csrf();
$pdo = app_db_pdo();
$saasUser = saas_current_user($pdo);
$tenantId = 0;
$hasAccess = false;
if ($saasUser !== null && saas_user_has_role(['owner', 'admin', 'treasurer'], $saasUser)) {
$tenantId = (int)$saasUser['tenant_id'];
$hasAccess = true;
}
if (!$hasAccess && $saasUser === null && checkKaffeelisteAdmin($conn, $mailadress)) {
$tenant = ledger_fetch_default_tenant($pdo);
if ($tenant !== null) {
$tenantId = (int)$tenant['id'];
$hasAccess = true;
}
}
$jahr = (int)date('Y');
$fehler = null;
$ergebnisse = null;
$dryRun = true;
$gesamtbetragEingabe = '';
$gebuchtFlash = false;
// 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);
$totalCents = $gesamtbetrag !== false ? (int)round($gesamtbetrag * 100) : 0;
if ($totalCents <= 0) {
$fehler = 'Bitte einen Gesamtbetrag größer 0 eingeben.';
} elseif ($gesamtJahresstriche <= 0) {
$fehler = "Für {$jahr} gibt es noch keine Striche, daher kann nichts verteilt werden.";
} else {
$createdByUserId = $saasUser['user_id'] ?? null;
// Nur Teilnehmer mit Strichen in diesem Jahr erhalten einen Anteil.
$empfaenger = array_values(array_filter(
$teilnehmer,
static fn(array $p): bool => (int)$p['year_marks'] > 0
));
$anzahlEmpfaenger = count($empfaenger);
// Bonus proportional zu den Strichen. Damit die Summe exakt dem
// eingegebenen Gesamtbetrag entspricht, erhaelt der letzte Empfaenger
// den verbleibenden Restcent statt einer eigenen Rundung.
$zuteilung = [];
$bereitsVerteilt = 0;
foreach ($empfaenger as $index => $person) {
$yearMarks = (int)$person['year_marks'];
if ($index === $anzahlEmpfaenger - 1) {
$bonusCents = $totalCents - $bereitsVerteilt;
} else {
$bonusCents = (int)round($totalCents * $yearMarks / $gesamtJahresstriche);
$bereitsVerteilt += $bonusCents;
}
$zuteilung[] = [
'person' => $person,
'year_marks' => $yearMarks,
'anteil' => $yearMarks / $gesamtJahresstriche,
'bonus_cents' => $bonusCents,
];
}
if ($dryRun) {
$ergebnisse = array_map(static function (array $z): array {
return [
'name' => $z['person']['display_name'],
'year_marks' => $z['year_marks'],
'anteil' => $z['anteil'],
'bonus_cents' => $z['bonus_cents'],
'status' => 'vorschau',
];
}, $zuteilung);
} else {
// Legacy-Verknuepfung je Teilnehmer vorab laden (gleiches Zweig-Muster
// wie bei Sammelerfassung und CSV-Import: Default-Mandant bucht
// zusaetzlich in kl_Einzahlungen, alle anderen Mandanten rein Ledger-nativ).
$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;
}
$ergebnisse = [];
$zuBenachrichtigen = [];
try {
$pdo->beginTransaction();
$insertLegacy = $pdo->prepare('INSERT INTO kl_Einzahlungen (MitarbeiterID, Betrag, Datum) VALUES (?, ?, ?)');
$jetzt = date('Y-m-d H:i:s');
foreach ($zuteilung as $z) {
$person = $z['person'];
$bonusCents = $z['bonus_cents'];
if ($bonusCents <= 0) {
continue;
}
$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, (int)$person['participant_id'], $bonusCents, 'year_end_bonus', $createdByUserId);
}
$email = trim((string)($person['email'] ?? ''));
if (filter_var($email, FILTER_VALIDATE_EMAIL)) {
$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',
];
}
$pdo->commit();
} catch (Throwable $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
$fehler = 'Die Verteilung konnte nicht gespeichert werden.';
$ergebnisse = null;
}
if ($fehler === null) {
// Mails erst nach erfolgreichem Commit versenden - sonst waeren
// bei einem spaeteren Rollback bereits Mails draussen, deren
// Buchung nie stattgefunden hat.
$settings = saas_fetch_tenant_settings($pdo, $tenantId);
$dashboardUrl = saas_app_url('index.php');
foreach ($zuBenachrichtigen as $mail) {
$subject = "Kaffeeliste - Dein Jahresbonus {$jahr}";
$body = saas_render_year_end_bonus_mail_body($mail['name'], $mail['year_marks'], $mail['bonus_cents'], $dashboardUrl);
$sendResult = saas_send_mail($mail['email'], $subject, $body);
saas_log_outbound_email(
$pdo, $tenantId, $mail['participant_id'], 'year_end_bonus', $subject,
$sendResult['ok'] ? 'sent' : 'failed', $sendResult['error'] ?? null, $createdByUserId
);
}
app_audit_log($pdo, $tenantId, $createdByUserId, 'year_end_bonus.distributed', 'tenant', $tenantId, ['jahr' => $jahr, 'gesamtbetrag_cents' => $totalCents, 'empfaenger' => count($ergebnisse)]);
// Post-Redirect-Get: Ergebnis in die Session legen und per GET
// erneut anzeigen, damit ein Refresh nicht doppelt bucht.
$_SESSION['flash_year_end'] = ['tenant_id' => $tenantId, 'ergebnisse' => $ergebnisse];
header('Location: jahresauswertung.php');
exit;
}
}
}
}
include "header.php";
include "headerline.php";
include "nav.php";
?>
<!-- Banner -->
<section id="banner">
<div class="content">
<?php if (!$hasAccess): ?>
<h2>Kein Zugriff</h2>
<?php else: ?>
<h2>Jahresabschluss <?php echo (int)$jahr; ?></h2>
<p>Verteilt einen frei wählbaren Gesamtbetrag proportional zu den in diesem
Jahr gemachten Strichen als Guthaben an alle aktiven Mitglieder und
benachrichtigt sie per Mail. Im Dry-Run wird nur eine Vorschau berechnet,
es wird nichts gebucht und keine Mail verschickt.</p>
<p>Jahresstriche gesamt: <?php echo number_format($gesamtJahresstriche, 0, ',', '.'); ?></p>
<?php if ($fehler !== null): ?>
<div class="hint-box error"><p><?php echo saas_html($fehler); ?></p></div>
<?php endif; ?>
<?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>
<br>
<div class="form-check">
<input class="form-check-input" type="checkbox" name="dry_run" id="dry_run" <?php echo $dryRun ? 'checked' : ''; ?>>
<label class="form-check-label" for="dry_run">Dry-Run (nur Vorschau, nichts buchen, keine Mails)</label>
</div>
<button type="submit">Berechnen</button>
</form>
<?php if ($ergebnisse !== null): ?>
<h3><?php echo $dryRun ? 'Vorschau' : 'Gebucht'; ?></h3>
<table>
<tr><th>Name</th><th>Jahresstriche</th><th>Anteil</th><th>Bonus</th><th>Status</th></tr>
<?php foreach ($ergebnisse as $eintrag): ?>
<tr>
<td><?php echo saas_html($eintrag['name']); ?></td>
<td><?php echo number_format($eintrag['year_marks'], 0, ',', '.'); ?></td>
<td><?php echo number_format($eintrag['anteil'] * 100, 1, ',', '.'); ?> %</td>
<td><?php echo saas_html(saas_format_money_cents($eintrag['bonus_cents'])); ?> €</td>
<td><?php echo $eintrag['status'] === 'gebucht' ? 'Gebucht' : 'Vorschau'; ?></td>
</tr>
<?php endforeach; ?>
</table>
<?php endif; ?>
<?php endif; ?>
</div>
</section>
<?php include "footer.php"; ?>