Files
kaffeekasse-saas/app/ledger.php
T
clemensandClaude Sonnet 5 de3dcee7ec M5: Mitgliederverwaltung tenant- und rollenbasiert umstellen
- mitarbeiterverwalten.php war rein ueber Legacy-Admin gesperrt und haette
  neue SaaS-Mandanten ausgeschlossen; jetzt Rollen-/Legacy-Fallback wie in
  kaffeeliste.php (owner/admin).
- Anlegen/Bearbeiten/Aktivieren/Deaktivieren spiegeln transaktional nach
  participants (ledger_mirror_legacy_participant neu ergaenzt), damit
  Ledger-Ansichten sofort den aktuellen Mitgliederstand zeigen.
- Gespeicherte XSS-Luecke behoben: Name/E-Mail waren in Formular und Liste
  ungeschuetzt ausgegeben, jetzt ueber saas_html().
- Admin-Rollenvergabe bleibt bewusst Legacy-only (kein automatischer
  Login-Account ohne Einladungsflow); dokumentiert als offener Punkt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-14 23:49:33 +02:00

499 lines
18 KiB
PHP

<?php
declare(strict_types=1);
require_once __DIR__ . '/bootstrap.php';
/**
* @return list<string>
*/
function ledger_normalize_email_norms(array $emails): array
{
$normalized = [];
foreach ($emails as $email) {
$emailNorm = strtolower(trim((string)$email));
if ($emailNorm !== '') {
$normalized[$emailNorm] = true;
}
}
return array_keys($normalized);
}
/**
* @return list<int>
*/
function ledger_normalize_ids(array $ids): array
{
$normalized = [];
foreach ($ids as $id) {
$id = (int)$id;
if ($id > 0) {
$normalized[$id] = true;
}
}
return array_keys($normalized);
}
function ledger_current_year(): int
{
return (int)date('Y');
}
function ledger_default_tenant_slug(): string
{
$tenantSlug = app_env('M4_DEFAULT_TENANT_SLUG');
if ($tenantSlug === null || trim($tenantSlug) === '') {
$tenantSlug = app_env('M3_DEFAULT_TENANT_SLUG', 'default');
}
return trim((string)$tenantSlug) !== '' ? trim((string)$tenantSlug) : 'default';
}
/**
* @return array{id: int, name: string, slug: string}|null
*/
function ledger_fetch_tenant_by_slug(PDO $pdo, string $slug): ?array
{
$stmt = $pdo->prepare('SELECT id, name, slug FROM tenants WHERE slug = ? LIMIT 1');
$stmt->execute([trim($slug)]);
$tenant = $stmt->fetch();
if ($tenant === false) {
return null;
}
return [
'id' => (int)$tenant['id'],
'name' => (string)$tenant['name'],
'slug' => (string)$tenant['slug'],
];
}
/**
* @return array{id: int, name: string, slug: string}|null
*/
function ledger_fetch_default_tenant(PDO $pdo): ?array
{
return ledger_fetch_tenant_by_slug($pdo, ledger_default_tenant_slug());
}
/**
* @return array{total_payments_cents: int, total_costs_cents: int, total_marks: int, balance_cents: int, year_payments_cents: int, year_costs_cents: int, year_marks: int}
*/
function ledger_empty_totals(): array
{
return [
'total_payments_cents' => 0,
'total_costs_cents' => 0,
'total_marks' => 0,
'balance_cents' => 0,
'year_payments_cents' => 0,
'year_costs_cents' => 0,
'year_marks' => 0,
];
}
/**
* @return array{total_payments_cents: int, total_costs_cents: int, total_marks: int, balance_cents: int, year_payments_cents: int, year_costs_cents: int, year_marks: int}
*/
function ledger_fetch_tenant_totals(PDO $pdo, int $tenantId, ?int $year = null): array
{
$year = $year ?? ledger_current_year();
$stmt = $pdo->prepare(
"SELECT
COALESCE(SUM(CASE WHEN type = 'payment' THEN amount_cents ELSE 0 END), 0) AS total_payments_cents,
COALESCE(SUM(CASE WHEN type = 'consumption' THEN -amount_cents ELSE 0 END), 0) AS total_costs_cents,
COALESCE(SUM(CASE WHEN type = 'consumption' THEN marks_count ELSE 0 END), 0) AS total_marks,
COALESCE(SUM(amount_cents), 0) AS balance_cents,
COALESCE(SUM(CASE WHEN type = 'payment' AND YEAR(booked_at) = ? THEN amount_cents ELSE 0 END), 0) AS year_payments_cents,
COALESCE(SUM(CASE WHEN type = 'consumption' AND YEAR(booked_at) = ? THEN -amount_cents ELSE 0 END), 0) AS year_costs_cents,
COALESCE(SUM(CASE WHEN type = 'consumption' AND YEAR(booked_at) = ? THEN marks_count ELSE 0 END), 0) AS year_marks
FROM ledger_entries
WHERE tenant_id = ?
AND voided_at IS NULL"
);
$stmt->execute([$year, $year, $year, $tenantId]);
$row = $stmt->fetch();
if ($row === false) {
return ledger_empty_totals();
}
return [
'total_payments_cents' => (int)$row['total_payments_cents'],
'total_costs_cents' => (int)$row['total_costs_cents'],
'total_marks' => (int)$row['total_marks'],
'balance_cents' => (int)$row['balance_cents'],
'year_payments_cents' => (int)$row['year_payments_cents'],
'year_costs_cents' => (int)$row['year_costs_cents'],
'year_marks' => (int)$row['year_marks'],
];
}
/**
* Options:
* - year: int
* - active_only: bool|null
* - participant_ids: list<int>
* - legacy_mitarbeiter_ids: list<int>
* - email_norms: list<string>
*
* @return list<array{participant_id: int, tenant_id: int, legacy_mitarbeiter_id: ?int, display_name: string, email: ?string, email_norm: ?string, paypal_name: ?string, active: bool, total_payments_cents: int, total_costs_cents: int, total_marks: int, balance_cents: int, year_payments_cents: int, year_costs_cents: int, year_marks: int}>
*/
function ledger_fetch_participant_summaries(PDO $pdo, int $tenantId, array $options = []): array
{
$year = isset($options['year']) ? (int)$options['year'] : ledger_current_year();
$where = ['p.tenant_id = ?'];
$params = [$year, $year, $year, $tenantId];
if (array_key_exists('active_only', $options) && $options['active_only'] !== null) {
$where[] = 'p.active = ?';
$params[] = $options['active_only'] ? 1 : 0;
}
if (array_key_exists('participant_ids', $options)) {
$participantIds = is_array($options['participant_ids'])
? ledger_normalize_ids($options['participant_ids'])
: [];
if ($participantIds === []) {
return [];
}
$where[] = 'p.id IN (' . implode(',', array_fill(0, count($participantIds), '?')) . ')';
array_push($params, ...$participantIds);
}
if (array_key_exists('legacy_mitarbeiter_ids', $options)) {
$legacyIds = is_array($options['legacy_mitarbeiter_ids'])
? ledger_normalize_ids($options['legacy_mitarbeiter_ids'])
: [];
if ($legacyIds === []) {
return [];
}
$where[] = 'p.legacy_mitarbeiter_id IN (' . implode(',', array_fill(0, count($legacyIds), '?')) . ')';
array_push($params, ...$legacyIds);
}
if (array_key_exists('email_norms', $options)) {
$emailNorms = is_array($options['email_norms'])
? ledger_normalize_email_norms($options['email_norms'])
: [];
if ($emailNorms === []) {
return [];
}
$where[] = 'p.email_norm IN (' . implode(',', array_fill(0, count($emailNorms), '?')) . ')';
array_push($params, ...$emailNorms);
}
$sql = "
SELECT
p.id AS participant_id,
p.tenant_id,
p.legacy_mitarbeiter_id,
p.display_name,
p.email,
p.email_norm,
p.paypal_name,
p.active,
COALESCE(SUM(CASE WHEN le.type = 'payment' THEN le.amount_cents ELSE 0 END), 0) AS total_payments_cents,
COALESCE(SUM(CASE WHEN le.type = 'consumption' THEN -le.amount_cents ELSE 0 END), 0) AS total_costs_cents,
COALESCE(SUM(CASE WHEN le.type = 'consumption' THEN le.marks_count ELSE 0 END), 0) AS total_marks,
COALESCE(SUM(le.amount_cents), 0) AS balance_cents,
COALESCE(SUM(CASE WHEN le.type = 'payment' AND YEAR(le.booked_at) = ? THEN le.amount_cents ELSE 0 END), 0) AS year_payments_cents,
COALESCE(SUM(CASE WHEN le.type = 'consumption' AND YEAR(le.booked_at) = ? THEN -le.amount_cents ELSE 0 END), 0) AS year_costs_cents,
COALESCE(SUM(CASE WHEN le.type = 'consumption' AND YEAR(le.booked_at) = ? THEN le.marks_count ELSE 0 END), 0) AS year_marks
FROM participants p
LEFT JOIN ledger_entries le
ON le.tenant_id = p.tenant_id
AND le.participant_id = p.id
AND le.voided_at IS NULL
WHERE " . implode(' AND ', $where) . "
GROUP BY p.id, p.tenant_id, p.legacy_mitarbeiter_id, p.display_name, p.email, p.email_norm, p.paypal_name, p.active
ORDER BY p.display_name, p.id";
$stmt = $pdo->prepare($sql);
$stmt->execute($params);
$summaries = [];
while ($row = $stmt->fetch()) {
$summaries[] = [
'participant_id' => (int)$row['participant_id'],
'tenant_id' => (int)$row['tenant_id'],
'legacy_mitarbeiter_id' => $row['legacy_mitarbeiter_id'] !== null ? (int)$row['legacy_mitarbeiter_id'] : null,
'display_name' => (string)$row['display_name'],
'email' => $row['email'] !== null ? (string)$row['email'] : null,
'email_norm' => $row['email_norm'] !== null ? (string)$row['email_norm'] : null,
'paypal_name' => $row['paypal_name'] !== null ? (string)$row['paypal_name'] : null,
'active' => (int)$row['active'] === 1,
'total_payments_cents' => (int)$row['total_payments_cents'],
'total_costs_cents' => (int)$row['total_costs_cents'],
'total_marks' => (int)$row['total_marks'],
'balance_cents' => (int)$row['balance_cents'],
'year_payments_cents' => (int)$row['year_payments_cents'],
'year_costs_cents' => (int)$row['year_costs_cents'],
'year_marks' => (int)$row['year_marks'],
];
}
return $summaries;
}
/**
* @return array{participant_id: int, tenant_id: int, legacy_mitarbeiter_id: ?int, display_name: string, email: ?string, email_norm: ?string, paypal_name: ?string, active: bool, total_payments_cents: int, total_costs_cents: int, total_marks: int, balance_cents: int, year_payments_cents: int, year_costs_cents: int, year_marks: int}|null
*/
function ledger_fetch_participant_summary(PDO $pdo, int $tenantId, int $participantId, ?int $year = null): ?array
{
$summaries = ledger_fetch_participant_summaries($pdo, $tenantId, [
'participant_ids' => [$participantId],
'year' => $year ?? ledger_current_year(),
]);
return $summaries[0] ?? null;
}
/**
* @return array{participant_id: int, tenant_id: int, legacy_mitarbeiter_id: ?int, display_name: string, email: ?string, email_norm: ?string, paypal_name: ?string, active: bool, total_payments_cents: int, total_costs_cents: int, total_marks: int, balance_cents: int, year_payments_cents: int, year_costs_cents: int, year_marks: int}|null
*/
function ledger_fetch_participant_summary_by_legacy_id(PDO $pdo, int $tenantId, int $legacyMitarbeiterId, ?int $year = null): ?array
{
$summaries = ledger_fetch_participant_summaries($pdo, $tenantId, [
'legacy_mitarbeiter_ids' => [$legacyMitarbeiterId],
'year' => $year ?? ledger_current_year(),
]);
return $summaries[0] ?? null;
}
function ledger_mirror_legacy_consumption(PDO $pdo, int $tenantId, int $legacyConsumptionId): void
{
$stmt = $pdo->prepare(
"INSERT INTO ledger_entries
(tenant_id, participant_id, type, amount_cents, marks_count,
unit_price_cents, booked_at, source, note, legacy_table, legacy_id)
SELECT
p.tenant_id,
p.id,
'consumption',
-CAST(ROUND(v.Kosten * 100) AS SIGNED),
v.AnzahlStriche,
CAST(ROUND(v.KostenproStrich * 100) AS SIGNED),
v.Datum,
CASE
WHEN v.Eintragsart = 2 THEN 'legacy_web'
ELSE 'legacy_manual'
END,
CASE
WHEN v.Eintragsart IS NULL THEN NULL
ELSE CONCAT('Legacy Eintragsart: ', v.Eintragsart)
END,
'kl_Kaffeeverbrauch',
v.VerbrauchID
FROM kl_Kaffeeverbrauch v
JOIN participants p
ON p.tenant_id = ?
AND p.legacy_mitarbeiter_id = v.MitarbeiterID
WHERE v.VerbrauchID = ?
ON DUPLICATE KEY UPDATE
participant_id = VALUES(participant_id),
type = VALUES(type),
amount_cents = VALUES(amount_cents),
marks_count = VALUES(marks_count),
unit_price_cents = VALUES(unit_price_cents),
booked_at = VALUES(booked_at),
source = VALUES(source),
note = VALUES(note)"
);
$stmt->execute([$tenantId, $legacyConsumptionId]);
$check = $pdo->prepare(
"SELECT COUNT(*)
FROM ledger_entries
WHERE tenant_id = ?
AND legacy_table = 'kl_Kaffeeverbrauch'
AND legacy_id = ?"
);
$check->execute([$tenantId, $legacyConsumptionId]);
if ((int)$check->fetchColumn() !== 1) {
throw new RuntimeException('Der Legacy-Strich konnte nicht ins Ledger gespiegelt werden.');
}
}
/**
* Mirrors a single kl_Mitarbeiter row into participants, so member changes
* made through the legacy admin UI are immediately visible to ledger-backed
* reads instead of waiting for the next scripts/backfill-default-tenant.php run.
*/
function ledger_mirror_legacy_participant(PDO $pdo, int $tenantId, int $legacyMitarbeiterId): void
{
$stmt = $pdo->prepare(
"INSERT INTO participants
(tenant_id, display_name, email, email_norm, paypal_name, active, legacy_mitarbeiter_id)
SELECT
?,
NULLIF(TRIM(m.Name), ''),
NULLIF(TRIM(m.Email), ''),
NULLIF(LOWER(TRIM(m.Email)), ''),
NULLIF(TRIM(m.paypalname), ''),
m.aktiv,
m.MitarbeiterID
FROM kl_Mitarbeiter m
WHERE m.MitarbeiterID = ?
ON DUPLICATE KEY UPDATE
display_name = COALESCE(VALUES(display_name), participants.display_name),
email = VALUES(email),
email_norm = VALUES(email_norm),
paypal_name = VALUES(paypal_name),
active = VALUES(active)"
);
$stmt->execute([$tenantId, $legacyMitarbeiterId]);
}
function ledger_mirror_legacy_payment(PDO $pdo, int $tenantId, int $legacyPaymentId): void
{
$stmt = $pdo->prepare(
"INSERT INTO ledger_entries
(tenant_id, participant_id, type, amount_cents, marks_count,
unit_price_cents, booked_at, source, note, legacy_table, legacy_id)
SELECT
p.tenant_id,
p.id,
'payment',
CAST(ROUND(e.Betrag * 100) AS SIGNED),
NULL,
NULL,
e.Datum,
'legacy_payment',
NULL,
'kl_Einzahlungen',
e.EinzahlungsID
FROM kl_Einzahlungen e
JOIN participants p
ON p.tenant_id = ?
AND p.legacy_mitarbeiter_id = e.MitarbeiterID
WHERE e.EinzahlungsID = ?
ON DUPLICATE KEY UPDATE
participant_id = VALUES(participant_id),
type = VALUES(type),
amount_cents = VALUES(amount_cents),
marks_count = VALUES(marks_count),
unit_price_cents = VALUES(unit_price_cents),
booked_at = VALUES(booked_at),
source = VALUES(source),
note = VALUES(note)"
);
$stmt->execute([$tenantId, $legacyPaymentId]);
$check = $pdo->prepare(
"SELECT COUNT(*)
FROM ledger_entries
WHERE tenant_id = ?
AND legacy_table = 'kl_Einzahlungen'
AND legacy_id = ?"
);
$check->execute([$tenantId, $legacyPaymentId]);
if ((int)$check->fetchColumn() !== 1) {
throw new RuntimeException('Die Legacy-Einzahlung konnte nicht ins Ledger gespiegelt werden.');
}
}
/**
* Marks the ledger entry mirrored from a legacy row as voided instead of
* deleting it, so corrections stay auditable. Idempotent: voiding an
* already-voided or missing entry is a no-op and returns false.
*/
function ledger_void_entry_by_legacy_id(PDO $pdo, int $tenantId, string $legacyTable, int $legacyId): bool
{
$stmt = $pdo->prepare(
"UPDATE ledger_entries
SET voided_at = NOW()
WHERE tenant_id = ?
AND legacy_table = ?
AND legacy_id = ?
AND voided_at IS NULL"
);
$stmt->execute([$tenantId, $legacyTable, $legacyId]);
return $stmt->rowCount() > 0;
}
/**
* Options:
* - participant_id: int
* - type: string
* - include_voided: bool
* - limit: 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}>
*/
function ledger_fetch_recent_entries(PDO $pdo, int $tenantId, array $options = []): array
{
$limit = isset($options['limit']) ? (int)$options['limit'] : 100;
$limit = max(1, min($limit, 500));
$where = ['le.tenant_id = ?'];
$params = [$tenantId];
if (empty($options['include_voided'])) {
$where[] = 'le.voided_at IS NULL';
}
if (!empty($options['participant_id'])) {
$where[] = 'le.participant_id = ?';
$params[] = (int)$options['participant_id'];
}
if (!empty($options['type'])) {
$where[] = 'le.type = ?';
$params[] = trim((string)$options['type']);
}
$sql = "
SELECT
le.id,
le.tenant_id,
le.participant_id,
p.display_name,
le.type,
le.amount_cents,
le.marks_count,
le.unit_price_cents,
le.booked_at,
le.source,
le.note,
le.legacy_table,
le.legacy_id,
le.voided_at
FROM ledger_entries le
JOIN participants p
ON p.tenant_id = le.tenant_id
AND p.id = le.participant_id
WHERE " . implode(' AND ', $where) . "
ORDER BY le.booked_at DESC, le.id DESC
LIMIT {$limit}";
$stmt = $pdo->prepare($sql);
$stmt->execute($params);
$entries = [];
while ($row = $stmt->fetch()) {
$entries[] = [
'id' => (int)$row['id'],
'tenant_id' => (int)$row['tenant_id'],
'participant_id' => (int)$row['participant_id'],
'display_name' => (string)$row['display_name'],
'type' => (string)$row['type'],
'amount_cents' => (int)$row['amount_cents'],
'marks_count' => $row['marks_count'] !== null ? (int)$row['marks_count'] : null,
'unit_price_cents' => $row['unit_price_cents'] !== null ? (int)$row['unit_price_cents'] : null,
'booked_at' => (string)$row['booked_at'],
'source' => (string)$row['source'],
'note' => $row['note'] !== null ? (string)$row['note'] : null,
'legacy_table' => $row['legacy_table'] !== null ? (string)$row['legacy_table'] : null,
'legacy_id' => $row['legacy_id'] !== null ? (int)$row['legacy_id'] : null,
'voided_at' => $row['voided_at'] !== null ? (string)$row['voided_at'] : null,
];
}
return $entries;
}