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:
2026-07-20 21:54:16 +02:00
parent c4cb12b26e
commit ef5d0e1822
23 changed files with 918 additions and 160 deletions
+88
View File
@@ -621,6 +621,60 @@ function ledger_void_entry_by_legacy_id(PDO $pdo, int $tenantId, string $legacyT
return $stmt->rowCount() > 0;
}
/**
* Storniert den juengsten, noch nicht stornierten Web-Selbsteintrag eines
* Teilnehmers (Quelle 'self_entry' bei SaaS-nativen Mandanten bzw. 'legacy_web'
* beim migrierten Default-Mandanten). Bewusst nur eigene Web-Eintraege - vom
* Kassenwart per Sammelerfassung gesetzte Striche ('manual_bulk'/'legacy_manual')
* bleiben unangetastet. Beim Default-Mandanten wird zusaetzlich die
* kl_Kaffeeverbrauch-Zeile geloescht, damit sie nicht erneut gespiegelt wird
* und auf den Legacy-Sammelseiten verschwindet.
*
* @return array{ok: bool, marks?: int, error?: string}
*/
function ledger_void_own_self_entry(PDO $pdo, int $tenantId, int $participantId): array
{
$stmt = $pdo->prepare(
"SELECT id, marks_count, legacy_table, legacy_id
FROM ledger_entries
WHERE tenant_id = ?
AND participant_id = ?
AND type = 'consumption'
AND voided_at IS NULL
AND source IN ('self_entry', 'legacy_web')
ORDER BY booked_at DESC, id DESC
LIMIT 1"
);
$stmt->execute([$tenantId, $participantId]);
$entry = $stmt->fetch();
if ($entry === false) {
return ['ok' => false, 'error' => 'Es gibt keinen selbst eingetragenen Strich, der storniert werden könnte.'];
}
try {
$pdo->beginTransaction();
if ($entry['legacy_table'] === 'kl_Kaffeeverbrauch' && $entry['legacy_id'] !== null) {
$pdo->prepare('DELETE FROM kl_Kaffeeverbrauch WHERE VerbrauchID = ?')
->execute([(int)$entry['legacy_id']]);
}
$pdo->prepare("UPDATE ledger_entries SET voided_at = NOW() WHERE id = ? AND voided_at IS NULL")
->execute([(int)$entry['id']]);
$pdo->commit();
} catch (Throwable $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
return ['ok' => false, 'error' => 'Der Strich konnte nicht storniert werden.'];
}
return ['ok' => true, 'marks' => (int)($entry['marks_count'] ?? 0)];
}
/**
* Records a consumption entry straight into the ledger, without any legacy
* kl_Kaffeeverbrauch row. Used for tenants that have no legacy shadow table
@@ -709,6 +763,40 @@ function ledger_fetch_participants_by_window_marks(PDO $pdo, int $tenantId, int
*
* @return list<array{id: int, tenant_id: int, participant_id: int, display_name: string, type: string, amount_cents: int, marks_count: ?int, unit_price_cents: ?int, booked_at: string, source: string, note: ?string, legacy_table: ?string, legacy_id: ?int, voided_at: ?string}>
*/
/**
* Verbrauch eines Teilnehmers pro Monat eines Jahres (nur nicht stornierte
* Consumption-Buchungen). Fuer die Monatsuebersicht im Mitglieder-Dashboard.
*
* @return array<int, array{marks: int, cost_cents: int}> Schluessel 1..12
*/
function ledger_fetch_monthly_consumption(PDO $pdo, int $tenantId, int $participantId, int $year): array
{
$stmt = $pdo->prepare(
"SELECT MONTH(booked_at) AS m,
COALESCE(SUM(marks_count), 0) AS marks,
COALESCE(SUM(-amount_cents), 0) AS cost_cents
FROM ledger_entries
WHERE tenant_id = ?
AND participant_id = ?
AND type = 'consumption'
AND voided_at IS NULL
AND YEAR(booked_at) = ?
GROUP BY MONTH(booked_at)"
);
$stmt->execute([$tenantId, $participantId, $year]);
$months = [];
for ($m = 1; $m <= 12; $m++) {
$months[$m] = ['marks' => 0, 'cost_cents' => 0];
}
foreach ($stmt->fetchAll() as $row) {
$m = (int)$row['m'];
$months[$m] = ['marks' => (int)$row['marks'], 'cost_cents' => (int)$row['cost_cents']];
}
return $months;
}
function ledger_fetch_recent_entries(PDO $pdo, int $tenantId, array $options = []): array
{
$limit = isset($options['limit']) ? (int)$options['limit'] : 100;