*/ 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 */ 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'); } /** * Bemerkung fuer einen Journaleintrag saeubern: leere Angaben werden zu NULL, * die Laenge auf die Spaltenbreite (1000) begrenzt. Zeilenumbrueche werden zu * Leerzeichen, damit die Notiz in Tabellen und im PDF einzeilig bleibt. */ function ledger_normalize_note(?string $note): ?string { if ($note === null) { return null; } $note = preg_replace('/\s+/u', ' ', $note) ?? $note; $note = trim($note); if ($note === '') { return null; } if (strlen($note) > 1000) { // Byteweise kuerzen und danach eine ggf. angeschnittene Multibyte- // Sequenz am Ende wegnehmen (preg_match mit /u schlaegt bei // ungueltigem UTF-8 fehl). 1000 Bytes sind hoechstens 1000 Zeichen, // passen also immer in die Spalte. $note = substr($note, 0, 1000); while ($note !== '' && preg_match('//u', $note) !== 1) { $note = substr($note, 0, -1); } } return $note; } 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 * - email_norms: list * * @return list */ 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('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); } if (array_key_exists('user_ids', $options)) { $userIds = is_array($options['user_ids']) ? ledger_normalize_ids($options['user_ids']) : []; if ($userIds === []) { return []; } $where[] = 'p.user_id IN (' . implode(',', array_fill(0, count($userIds), '?')) . ')'; array_push($params, ...$userIds); } $sql = " SELECT p.id AS participant_id, p.tenant_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.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'], '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, 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; } function ledger_is_default_tenant(PDO $pdo, int $tenantId): bool { $tenant = ledger_fetch_default_tenant($pdo); return $tenant !== null && (int)$tenant['id'] === $tenantId; } /** * @return list */ function ledger_fetch_participants_for_admin(PDO $pdo, int $tenantId): array { $stmt = $pdo->prepare( "SELECT p.id AS participant_id, p.display_name, p.email, p.email_norm, p.paypal_name, p.active, p.user_id, tm.role, tm.status AS membership_status FROM participants p LEFT JOIN tenant_memberships tm ON tm.tenant_id = p.tenant_id AND tm.user_id = p.user_id WHERE p.tenant_id = ? ORDER BY p.display_name, p.id" ); $stmt->execute([$tenantId]); $rows = []; while ($row = $stmt->fetch()) { $rows[] = [ 'participant_id' => (int)$row['participant_id'], '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, 'user_id' => $row['user_id'] !== null ? (int)$row['user_id'] : null, 'role' => $row['role'] !== null ? (string)$row['role'] : null, 'membership_status' => $row['membership_status'] !== null ? (string)$row['membership_status'] : null, ]; } return $rows; } /** * Creates a tenant-scoped participant. * * @throws Throwable on constraint violations (e.g. duplicate email) */ function ledger_create_participant(PDO $pdo, int $tenantId, string $displayName, string $email, ?string $paypalName, bool $active): int { $displayName = trim($displayName); $email = trim($email); $emailNorm = strtolower($email); $paypalName = $paypalName !== null ? trim($paypalName) : null; $paypalName = $paypalName !== '' ? $paypalName : null; $stmt = $pdo->prepare( 'INSERT INTO participants (tenant_id, display_name, email, email_norm, paypal_name, active) VALUES (?, ?, ?, ?, ?, ?)' ); $stmt->execute([$tenantId, $displayName, $email, $emailNorm, $paypalName, $active ? 1 : 0]); return (int)$pdo->lastInsertId(); } function ledger_update_participant(PDO $pdo, int $tenantId, int $participantId, string $displayName, string $email, ?string $paypalName, bool $active): bool { $displayName = trim($displayName); $email = trim($email); $emailNorm = strtolower($email); $paypalName = $paypalName !== null ? trim($paypalName) : null; $paypalName = $paypalName !== '' ? $paypalName : null; $stmt = $pdo->prepare( 'UPDATE participants SET display_name = ?, email = ?, email_norm = ?, paypal_name = ?, active = ? WHERE id = ? AND tenant_id = ?' ); $stmt->execute([$displayName, $email, $emailNorm, $paypalName, $active ? 1 : 0, $participantId, $tenantId]); return $stmt->rowCount() >= 0; } function ledger_set_participant_active(PDO $pdo, int $tenantId, int $participantId, bool $active): bool { $stmt = $pdo->prepare('UPDATE participants SET active = ? WHERE id = ? AND tenant_id = ?'); $stmt->execute([$active ? 1 : 0, $participantId, $tenantId]); return true; } /** * Anonymizes a participant (GDPR-style erasure) instead of deleting them: * name and email are replaced with a non-identifying placeholder and the * participant is deactivated, but their ledger history stays intact for * the tenant's bookkeeping. */ function ledger_anonymize_participant(PDO $pdo, int $tenantId, int $participantId): bool { $placeholderName = 'Gelöschter Teilnehmer #' . $participantId; $stmt = $pdo->prepare( 'UPDATE participants SET display_name = ?, email = NULL, email_norm = NULL, paypal_name = NULL, active = 0, user_id = NULL WHERE id = ? AND tenant_id = ?' ); $stmt->execute([$placeholderName, $participantId, $tenantId]); return $stmt->rowCount() > 0; } /** * Storniert einen Journaleintrag anhand seiner Ledger-ID - immer auf den * Mandanten eingegrenzt, damit ueber eine untergeschobene ID nicht in fremden * Mandanten storniert werden kann. Markiert den Eintrag als storniert statt * ihn zu loeschen, damit Korrekturen nachvollziehbar bleiben. Idempotent: ein * bereits stornierter oder fehlender Eintrag ergibt false. * * @param string|null $expectedType Absichern, dass z. B. ueber das * Einzahlungs-Formular kein Strich storniert * werden kann. */ function ledger_void_entry(PDO $pdo, int $tenantId, int $entryId, ?string $expectedType = null): bool { $stmt = $pdo->prepare( "SELECT id, type FROM ledger_entries WHERE id = ? AND tenant_id = ? AND voided_at IS NULL LIMIT 1" ); $stmt->execute([$entryId, $tenantId]); $entry = $stmt->fetch(); if ($entry === false) { return false; } if ($expectedType !== null && (string)$entry['type'] !== $expectedType) { return false; } $stmt = $pdo->prepare('UPDATE ledger_entries SET voided_at = NOW() WHERE id = ? AND tenant_id = ? AND voided_at IS NULL'); $stmt->execute([$entryId, $tenantId]); return $stmt->rowCount() > 0; } /** * Storniert den juengsten, noch nicht stornierten Web-Selbsteintrag eines * Teilnehmers (Quelle 'self_entry'). Bewusst nur eigene Web-Eintraege - vom * Kassenwart per Sammelerfassung gesetzte Striche ('manual_bulk') bleiben * unangetastet. * * @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 FROM ledger_entries WHERE tenant_id = ? AND participant_id = ? AND type = 'consumption' AND voided_at IS NULL AND source = 'self_entry' 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(); $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. */ function ledger_record_consumption(PDO $pdo, int $tenantId, int $participantId, int $marks, int $unitPriceCents, string $source, ?int $createdByUserId = null): int { $stmt = $pdo->prepare( "INSERT INTO ledger_entries (tenant_id, participant_id, type, amount_cents, marks_count, unit_price_cents, booked_at, source, created_by_user_id) SELECT ?, id, 'consumption', ?, ?, ?, NOW(), ?, ? FROM participants WHERE id = ? AND tenant_id = ?" ); $stmt->execute([$tenantId, -($marks * $unitPriceCents), $marks, $unitPriceCents, $source, $createdByUserId, $participantId, $tenantId]); if ($stmt->rowCount() !== 1) { throw new RuntimeException('Der Strich-Eintrag konnte nicht gespeichert werden.'); } return (int)$pdo->lastInsertId(); } /** * Bucht eine Einzahlung. $amountCents darf negativ sein (Abzug, Auszahlung, * Korrektur) - fuer Tippfehler ist aber ledger_void_entry der richtige Weg, * damit die Auswertung nicht durch Gegenbuchungen aufgeblaeht wird. */ function ledger_record_payment(PDO $pdo, int $tenantId, int $participantId, int $amountCents, string $source, ?int $createdByUserId = null, ?string $note = null): int { $note = ledger_normalize_note($note); $stmt = $pdo->prepare( "INSERT INTO ledger_entries (tenant_id, participant_id, type, amount_cents, booked_at, source, created_by_user_id, note) SELECT ?, id, 'payment', ?, NOW(), ?, ?, ? FROM participants WHERE id = ? AND tenant_id = ?" ); $stmt->execute([$tenantId, $amountCents, $source, $createdByUserId, $note, $participantId, $tenantId]); if ($stmt->rowCount() !== 1) { throw new RuntimeException('Die Einzahlung konnte nicht gespeichert werden.'); } return (int)$pdo->lastInsertId(); } /** * "100-Tage-Liste" Vorder-/Rueckseiten-Aufteilung: aktive Teilnehmer, deren * summierte Striche im nachlaufenden Fenster die Zehner-Schwelle erreichen * ($atLeastTen) oder darunter liegen. * * @return list */ function ledger_fetch_participants_by_window_marks(PDO $pdo, int $tenantId, int $windowDays, bool $atLeastTen): array { $stmt = $pdo->prepare( "SELECT p.id FROM participants p LEFT JOIN ledger_entries le ON le.tenant_id = p.tenant_id AND le.participant_id = p.id AND le.type = 'consumption' AND le.voided_at IS NULL AND le.booked_at >= DATE_SUB(NOW(), INTERVAL ? DAY) WHERE p.tenant_id = ? AND p.active = 1 GROUP BY p.id HAVING COALESCE(SUM(le.marks_count), 0) " . ($atLeastTen ? '>= 10' : '< 10') ); $stmt->execute([$windowDays, $tenantId]); $ids = array_map('intval', $stmt->fetchAll(PDO::FETCH_COLUMN)); if ($ids === []) { return []; } return ledger_fetch_participant_summaries($pdo, $tenantId, ['participant_ids' => $ids]); } /** * Options: * - participant_id: int * - type: string * - include_voided: bool * - limit: int * * @return list */ /** * Verbrauch eines Teilnehmers pro Monat eines Jahres (nur nicht stornierte * Consumption-Buchungen). Fuer die Monatsuebersicht im Mitglieder-Dashboard. * * @return array 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; $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.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, 'voided_at' => $row['voided_at'] !== null ? (string)$row['voided_at'] : null, ]; } return $entries; }