*/ 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'); } 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 * - legacy_mitarbeiter_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('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.'); } } 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 */ 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; }