Vier Luecken geschlossen, die im Alltag sofort aufgefallen waeren: - Passwort aendern war im eingeloggten Zustand gar nicht moeglich; es gab nur den Reset per Mail-Link. saas_change_password() prueft das aktuelle Passwort, verlangt ein tatsaechlich anderes und erneuert danach die Session-ID. Formular in konto.php. - mandant-auswahl.php war nur direkt nach dem Login erreichbar. Wer bei mehreren Mandanten Mitglied ist, musste sich zum Wechseln abmelden. Die Seite bedient jetzt beide Wege, die Mandantenpruefung bleibt unveraendert ueber saas_identity_for_user_tenant(). Menuepunkt ab zwei Mitgliedschaften. - email_verified_at wurde nirgends geprueft, nur angezeigt - bei offener Selbstregistrierung konnte sich jemand mit fremder Adresse anmelden und alles nutzen. Erzwungen wird jetzt gezielt dort, wo eine Aktion nach aussen wirkt: Einladung, Info-Mail, Jahresabschluss. Der Login selbst bleibt bewusst frei, sonst waeren alle migrierten Bestandsnutzer mit NULL-Verifikation ausgesperrt. Zusaetzlich setzt der Passwort-Reset die Verifikation mit, weil der Mail-Link den Postfachzugriff nachweist - sonst blieben eingeladene Mitglieder dauerhaft unbestaetigt. - Login landete auf konto.php statt auf dem Dashboard. Landingpage: die gruene Vertrauenszeile auf "DSGVO-konform" gekuerzt, die Eintraege zu Paragraf 19 UStG und "Bestehende Ablaeufe bleiben" entfernt. Geprueft: neues scripts/check-konto-und-mandantenwechsel.php mit 18 Assertions gruen, Passwortformular zusaetzlich manuell inkl. CSRF (419). Bestehende Suiten unveraendert gruen: HTTP-Smoke 34 Seiten, Rollenmatrix 55, Mandanten-Isolation 12, M3-Auth 9, M3-Settings 15. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
242 lines
8.3 KiB
PHP
242 lines
8.3 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;
|
|
$verifikationHinweis = null;
|
|
|
|
// 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;
|
|
|
|
// Die echte Verteilung bucht nicht nur, sie mailt auch jedem Teilnehmer
|
|
// seinen Anteil. Ohne bestaetigte eigene Adresse bleibt nur die Vorschau.
|
|
if (!$dryRun && !saas_email_verified($saasUser)) {
|
|
$dryRun = true;
|
|
$verifikationHinweis = 'Es wurde nur eine Vorschau berechnet: Für die echte Buchung muss deine E-Mail-Adresse bestätigt sein. Den Link kannst du unter "Kundenkonto" erneut anfordern.';
|
|
}
|
|
|
|
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 ($verifikationHinweis !== null): ?>
|
|
<div class="hint-box error"><p><?php echo saas_html($verifikationHinweis); ?></p></div>
|
|
<?php endif; ?>
|
|
|
|
<?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"; ?>
|