Files
kaffeekasse-saas/jahresauswertung.php
clemensandClaude Opus 4.8 3e24910831 Legacy-Abbau Schritt 2: Legacy-Authentifizierung entfernt
Die Zugriffspruefung lief bisher in 19 Dateien auf einen Fallback gegen die
Legacy-Tabelle kl_Mitarbeiter (checkKaffeelisteAdmin/-Access). Der Zugang
haengt jetzt ausschliesslich an der SaaS-Anmeldung.

- Fallback-Bloecke in allen 19 Dateien entfernt, ebenso getUserName/getUserId
- functions.php besteht nur noch aus der SaaS-Anmeldung; die per String
  zusammengebaute Abfrage "WHERE Email like '<eingabe>'" ist damit weg
- config.php baut keine sqlsrv-Verbindung mehr auf, der Kompatibilitaetslayer
  lib/sqlsrv_mysql_compat.php ist verwaist und entfaellt
- Auto-Login per DEV_AUTH_EMAIL gibt es nicht mehr: er meldete jeden Besucher
  an und machte damit den Login-Schutz unpruefbar. Angemeldet wird auch lokal
  ueber login.php; die Variable dient nur noch scripts/init-mysql-dev.php

http-smoke war an die Golden-Master-Daten des Default-Mandanten gebunden und
hatte deshalb sechs dauerhaft rote Pruefungen. Der Test legt sich jetzt einen
eigenen Mandanten mit bekannten Salden an, meldet sich per HTTP an und raeumt
danach auf. Geschuetzte Seiten werden nicht mehr ueber das Wort "Login" im
Text geprueft, sondern ueber die tatsaechliche 302-Umleitung.

  http-smoke        27 PASS / 6 FAIL  ->  33 PASS / 0 FAIL
  role-matrix       55 PASS / 0 FAIL  ->  unveraendert
  tenant-isolation   9 PASS / 1 FAIL  ->  11 PASS / 1 FAIL (Altfehler)

Ausserdem "keine Zugang" -> "keinen Zugang" in zwei Fehlermeldungen; der
Rollentest erkannte die Ablehnung an genau diesem Tippfehler und wurde
mitgezogen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 21:10:09 +02:00

230 lines
7.7 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;
}
$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 {
$ergebnisse = [];
$zuBenachrichtigen = [];
try {
$pdo->beginTransaction();
foreach ($zuteilung as $z) {
$person = $z['person'];
$bonusCents = $z['bonus_cents'];
if ($bonusCents <= 0) {
continue;
}
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"; ?>