diff --git a/app/saas-mail.php b/app/saas-mail.php
index 6e147a7..b8964dd 100644
--- a/app/saas-mail.php
+++ b/app/saas-mail.php
@@ -210,6 +210,24 @@ function saas_fetch_outbound_email_log(PDO $pdo, int $tenantId, int $limit = 50)
return $stmt->fetchAll();
}
+function saas_render_year_end_bonus_mail_body(string $displayName, int $yearMarks, int $bonusCents, string $dashboardUrl): string
+{
+ $bonus = saas_format_money_cents($bonusCents) . ' €';
+ $lines = [
+ "Hallo {$displayName},",
+ '',
+ "vielen Dank für deine Nutzung der Kaffeeliste in diesem Jahr!",
+ "Du hast dieses Jahr {$yearMarks} Striche gemacht.",
+ "Dafür wurden dir {$bonus} auf deinem Kaffeelisten-Konto gutgeschrieben.",
+ '',
+ "Deinen aktuellen Stand findest du hier: {$dashboardUrl}",
+ '',
+ 'Deine Kaffeeliste',
+ ];
+
+ return implode("\n", $lines);
+}
+
function saas_send_invite_mail(string $to, string $tenantName, string $role, string $token): array
{
$link = saas_app_url('passwort-zuruecksetzen.php?token=' . urlencode($token));
diff --git a/docs/m6-import-export-mail.md b/docs/m6-import-export-mail.md
index a3a1b5f..c514175 100644
--- a/docs/m6-import-export-mail.md
+++ b/docs/m6-import-export-mail.md
@@ -149,10 +149,58 @@ Live-Versand erzeugt für jeden Empfänger eine Log-Datei mit korrekt
personalisiertem Inhalt (Guthaben- und Schuldenfall geprüft), Versandlog in
der UI zeigt die Einträge. Testdaten anschließend entfernt.
+## Jahresabschluss
+
+Umgesetzte Dateien:
+
+```text
+app/saas-mail.php (Mailvorlage)
+jahresauswertung.php
+scripts/http-smoke.php
+```
+
+Ziel: Ein generisches, mandantenfähiges Feature statt der ursprünglichen
+AOK-spezifischen Alt-Kunden-Logik.
+
+Hintergrund: `jahresauswertung.php` verband sich bisher mit fest codierten
+(kaputten) Zugangsdaten selbst zur Datenbank statt über `config.php`, hatte
+keinerlei Zugriffskontrolle und kein CSRF, verteilte bei jedem Aufruf sofort
+einen hart codierten Bonus-Topf (490 Striche à 0,20 €) und verschickte
+dabei über PHPMailer (dessen Quelldateien im Repo fehlen) Mails mit
+AOK-spezifischem Text. Mit dem Nutzer wurde abgestimmt, daraus ein echtes,
+wiederverwendbares Feature zu bauen statt es nur zu deaktivieren oder rein
+lesend umzusetzen.
+
+Umfang:
+
+- Admin gibt einen frei wählbaren Gesamtbetrag ein; das System verteilt ihn
+ proportional zu den in diesem Kalenderjahr gemachten Strichen
+ (`year_marks` aus `ledger_fetch_participant_summaries()`) auf alle
+ aktiven Mitglieder.
+- Standardmäßig aktive Dry-Run-Checkbox zeigt Name, Jahresstriche, Anteil
+ und berechneten Bonus, ohne zu buchen oder Mails zu verschicken.
+- Bei bestätigtem Lauf wird pro Mitglied mit einem Anteil > 0 eine
+ Zahlung gebucht (gleiches Zweig-Muster wie überall: Default-Mandant per
+ Dual-Write nach `kl_Einzahlungen` plus Spiegelung, andere Mandanten
+ direkt über `ledger_record_payment()` mit `source = year_end_bonus`) und
+ eine personalisierte Mail über `saas_send_mail()` verschickt und im
+ Versandlog (`outbound_emails`, Vorlage `year_end_bonus`) protokolliert.
+- Zugriffskontrolle ergänzt (owner/admin/treasurer + Legacy-Fallback).
+- `scripts/http-smoke.php`: `jahresauswertung.php` ist jetzt ein regulärer
+ Check statt eines übersprungenen unsicheren Aufrufs, da GET keine
+ Seiteneffekte mehr hat.
+
+Live getestet: Dry-Run mit 100 € (korrekte proportionale Verteilung,
+Summe der Einzelbeträge ergibt exakt den Gesamtbetrag, keine Buchungen oder
+Mails), anschließender Live-Lauf bucht korrekt (Dual-Write und
+Ledger-Spiegelung stimmen mit den berechneten Beträgen überein) und
+verschickt personalisierte Mails; Versandlog zeigt alle Einträge korrekt.
+Testdaten anschließend vollständig entfernt.
+
## Prüfstatus
- Golden Master: grün mit 104 Assertions.
- M4 Ledger-Migration: grün mit 73 Assertions.
- M4 Ledger-Service: grün mit 115 Assertions.
-- HTTP-Smoke: grün mit 25 geprüften Seiten (PDF-Export und Mailversand
- jetzt regulär statt bekannter offener Punkte).
+- HTTP-Smoke: grün mit 26 geprüften Seiten, keine übersprungenen oder
+ bekannten offenen Punkte mehr.
diff --git a/docs/saas-umstrukturierungsplan.md b/docs/saas-umstrukturierungsplan.md
index e7df204..e06ed78 100644
--- a/docs/saas-umstrukturierungsplan.md
+++ b/docs/saas-umstrukturierungsplan.md
@@ -306,7 +306,7 @@ Nicht tun:
| M3 | SaaS-Basis | Abgeschlossen: Tenants, User, Registrierung, Login, Rollen, Mail-Links und zentrale Mandantenauswahl funktionieren |
| M4 | Datenmigration | Gestartet: Ledger-Tabelle, Legacy-Backfill, Paritätscheck, Ledger-Service und Preview sind umgesetzt |
| M5 | App-Kern | Weit fortgeschritten: Kernseiten lesen und schreiben tenant-sicher gegen das Ledger, inklusive Hinweise und rollenbasiertem Zugang; offen ist ein eigener Zahlungs-Screen |
-| M6 | Betriebsflows | Weit fortgeschritten: CSV-Import, PDF-Export und Mailversand stehen; Jahresprozesse offen |
+| M6 | Betriebsflows | Abgeschlossen: Import, Export, Mail und Jahresabschluss sind auditierbar |
| M7 | Landingpage | Public-Seite und Auth-Seiten sind im gemeinsamen Stil nutzbar; spätere Ausbaustufen folgen |
| M8 | Härtung | Betrieb, Datenschutz, Monitoring und Isolation sind geprüft |
| M9 | Cutover | Produktivumstellung ist vorbereitet und Legacy ist read-only |
@@ -585,13 +585,21 @@ Schritte:
PayPal-Link, FAQ-URL). Ersetzt durch die bestehende `saas_send_mail()`-
Abstraktion aus M3, eine neue `outbound_emails`-Tabelle als Versandlog
und eine standardmäßig aktive Dry-Run-Option.
-- Jahresauswertung beziehungsweise Jahresbuchungen tenant-sicher abbilden.
+- Jahresauswertung beziehungsweise Jahresbuchungen tenant-sicher abbilden:
+ erledigt. Die ursprüngliche Seite verband sich mit fest codierten,
+ kaputten Zugangsdaten selbst zur Datenbank (an `config.php` vorbei),
+ hatte keine Zugriffskontrolle und verteilte bei jedem Aufruf sofort einen
+ hart codierten Bonus-Topf mit AOK-spezifischem Mailtext. Nach Abstimmung
+ mit dem Kunden als generisches Feature umgesetzt: frei wählbarer
+ Gesamtbetrag, proportionale Verteilung nach Jahresstrichen, Dry-Run,
+ Buchung und Mailversand mit Protokoll.
Ergebnis:
- Kassenverwaltung ist für reale Betriebsabläufe vollständig.
- Import/Export/Mail sind auditierbar.
-- Stand: gestartet. Dokumentation: `docs/m6-import-export-mail.md`.
+- Stand: abgeschlossen für den M6-Scope. Dokumentation:
+ `docs/m6-import-export-mail.md`.
Abhängigkeiten:
diff --git a/jahresauswertung.php b/jahresauswertung.php
index ef2f317..185f118 100644
--- a/jahresauswertung.php
+++ b/jahresauswertung.php
@@ -1,177 +1,189 @@
"database",
- "Uid" => "user",
- "PWD" => "password",
- "TrustServerCertificate"=>true
-);
-$stricheAnzupassen = 490; // Anzahl der neuen Striche
-$betragProStrich = 0.20; // Betrag pro Strich in Euro
+include "functions.php";
+require_once __DIR__ . "/app/ledger.php";
+require_once __DIR__ . "/app/saas-mail.php";
+app_require_csrf();
+include "header.php";
+include "headerline.php";
+include "nav.php";
-// Testmodus aktivieren
-$testmodus = false;
-
-
-
-use PHPMailer\PHPMailer\PHPMailer;
-use PHPMailer\PHPMailer\SMTP;
-use PHPMailer\PHPMailer\Exception;
-
-require 'PHPMailer/src/Exception.php';
-require 'PHPMailer/src/PHPMailer.php';
-require 'PHPMailer/src/SMTP.php';
-
-// PHPMailer konfigurieren
-function sendeMail($empfaenger, $betreff, $inhalt, $testmodus)
-{
- $mail = new PHPMailer(true);
-
- try {
- // Server-Einstellungen
- $mail->isSMTP();
- $mail->Host = 'smtpv.aoknds.aok'; // SMTP-Server
- $mail->Timeout = 180;
- $mail->SMTPAuth = false;
- $mail->Port = 25;
-
- // Absender
- $mail->setFrom('kaffeelistesb3@nds.aok.de', 'Kaffeeliste');
-
- // Empfänger
- if ($testmodus) {
- $mail->addAddress('kaffeelistesb3@nds.aok.de'); // Testadresse
- } else {
- $mail->addAddress($empfaenger); // Tatsächlicher Empfänger
- }
-
- // Inhalt
- $mail->isHTML(true);
- $mail->Subject = $betreff;
- $mail->Body = utf8_decode($inhalt);
-
- // Senden
- $mail->send();
- echo "E-Mail erfolgreich gesendet an: " . ($testmodus ? 'kaffeelistesb3@nds.aok.de' : $empfaenger) . "\n";
- } catch (Exception $e) {
- echo "E-Mail konnte nicht gesendet werden. Fehler: {$mail->ErrorInfo}\n";
- }
-}
-
-// Verbindung herstellen
-$conn = sqlsrv_connect($serverName, $connectionOptions);
-if ($conn === false) {
- die(print_r(sqlsrv_errors(), true));
-}
-
-// Aktuelles Jahr ermitteln
-$currentYear = date("Y");
-
-// SQL-Abfrage: Gesamtanzahl der Striche pro Mitarbeiter im aktuellen Jahr mit Namen und E-Mail
-$sql = "
- SELECT
- m.MitarbeiterID,
- m.Name,
- m.Email,
- SUM(v.AnzahlStriche) AS GesamtStriche
- FROM kl_Kaffeeverbrauch v
- JOIN kl_Mitarbeiter m ON v.MitarbeiterID = m.MitarbeiterID
- WHERE YEAR(v.Datum) = ? AND m.aktiv = 1
- GROUP BY m.MitarbeiterID, m.Name, m.Email
-";
-$params = [$currentYear];
-$stmt = sqlsrv_query($conn, $sql, $params);
-
-if ($stmt === false) {
- die(print_r(sqlsrv_errors(), true));
-}
-
-// Ergebnisse verarbeiten
-$mitarbeiterDaten = [];
-$gesamtStriche = 0;
-
-while ($row = sqlsrv_fetch_array($stmt, SQLSRV_FETCH_ASSOC)) {
- $mitarbeiterDaten[] = $row;
- $gesamtStriche += $row['GesamtStriche'];
-}
-
-echo $gesamtStriche;
-
-// Neue Striche proportional verteilen
-$verteilung = [];
-
-foreach ($mitarbeiterDaten as $mitarbeiter) {
- $mitarbeiterID = $mitarbeiter['MitarbeiterID'];
- $anteil = $mitarbeiter['GesamtStriche'] / $gesamtStriche;
- $neueStriche = round($anteil * $stricheAnzupassen);
- $betrag = $neueStriche * $betragProStrich;
-
- $verteilung[] = [
- 'MitarbeiterID' => $mitarbeiterID,
- 'Name' => $mitarbeiter['Name'],
- 'Email' => $mitarbeiter['Email'],
- 'JahrStriche' => $mitarbeiter['GesamtStriche'],
- 'NeueStriche' => $neueStriche,
- 'Betrag' => $betrag
- ];
-
- // SQL-Befehl vorbereiten
- $insertSql = "
- INSERT INTO kl_Einzahlungen (MitarbeiterID, Betrag, Datum)
- VALUES (?, ?, GETDATE())
- ";
- $insertParams = [$mitarbeiterID, $betrag];
-
- if ($testmodus) {
- // SQL-Befehl und Parameter anzeigen
- echo "SQL-Befehl: $insertSql\n";
- echo "Parameter: " . json_encode($insertParams) . "\n";
- // E-Mail vorbereiten
-
- } else {
- // SQL-Befehl ausführen
- $insertStmt = sqlsrv_query($conn, $insertSql, $insertParams);
- if ($insertStmt === false) {
- die(print_r(sqlsrv_errors(), true));
- }
- // E-Mail vorbereiten
-
- }
-
-
-
- $betreff = "Kaffeeliste - Weihnachten";
- $inhalt = "
-
Hallo {$mitarbeiter['Name']},
- vielen Dank für deine Nutzung der Kaffeeliste in diesem Jahr!
- Du hast dieses Jahr {$mitarbeiter['GesamtStriche']} Kaffee bezogen.
- Deswegen wurden dir $betrag Euro auf deinem Konto gutgeschrieben.
-
- Wir wünschen dir eine frohe Weihnachtszeit und einen guten Rutsch ins neue Jahr.
- Deine ARGE Kaffeeliste
- ";
- $empfaenger = $mitarbeiter['Email'];
- // E-Mail senden
- sendeMail($empfaenger, $betreff, $inhalt, $testmodus);
-
-
-}
-
-
-
-// Sortiere die Verteilung nach JahrStriche (absteigend)
-usort($verteilung, function ($a, $b) {
- return $b['JahrStriche'] <=> $a['JahrStriche'];
-});
-
-// Ergebnisse ausgeben
-header('Content-Type: application/json');
-#echo json_encode($verteilung, JSON_PRETTY_PRINT);
-
-// Verbindung schließen
-sqlsrv_close($conn);
?>
+
+
+
+
+Kein Zugriff";
+ 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 = '';
+
+if ($_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 {
+ $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;
+ }
+
+ try {
+ if (!$dryRun) {
+ $pdo->beginTransaction();
+ }
+ $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;
+ }
+
+ $anteil = $yearMarks / $gesamtJahresstriche;
+ $bonusCents = (int)round($totalCents * $anteil);
+ 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');
+ }
+ $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
+ );
+ }
+ }
+
+ $ergebnisse[] = [
+ 'name' => $person['display_name'],
+ 'year_marks' => $yearMarks,
+ 'anteil' => $anteil,
+ 'bonus_cents' => $bonusCents,
+ 'status' => $status,
+ ];
+ }
+
+ if (!$dryRun) {
+ $pdo->commit();
+ }
+ } catch (Throwable $e) {
+ if ($pdo->inTransaction()) {
+ $pdo->rollBack();
+ }
+ $fehler = 'Die Verteilung konnte nicht gespeichert werden.';
+ $ergebnisse = null;
+ }
+ }
+}
+
+?>
+
+
Jahresabschluss
+
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.
+
+
Jahresstriche gesamt:
+
+
+
+
+
+
+
+
+
+
+ | Name | Jahresstriche | Anteil | Bonus | Status |
+
+
+ |
+ |
+ % |
+ € |
+ |
+
+
+
+
+
+
+
+
+
diff --git a/scripts/http-smoke.php b/scripts/http-smoke.php
index 1f35677..18a82e1 100644
--- a/scripts/http-smoke.php
+++ b/scripts/http-smoke.php
@@ -142,16 +142,17 @@ $checks = [
'path' => 'mailversenden.php',
'contains' => ['Info-Mail versenden', 'Dry-Run', 'Versandlog'],
],
+ [
+ 'label' => 'Jahresabschluss',
+ 'path' => 'jahresauswertung.php',
+ 'contains' => ['Jahresabschluss', 'Dry-Run', 'Jahresstriche gesamt'],
+ ],
];
$knownIssueChecks = [
];
$skippedUnsafe = [
- [
- 'path' => 'jahresauswertung.php',
- 'reason' => 'GET kann Jahresbuchungen schreiben und E-Mails versenden; PHPMailer-Abhängigkeit ist zudem unvollständig.',
- ],
];
function smoke_fetch(string $url): array