diff --git a/app/ledger.php b/app/ledger.php new file mode 100644 index 0000000..ea1e76e --- /dev/null +++ b/app/ledger.php @@ -0,0 +1,281 @@ + + */ +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'); +} + +/** + * @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); + } + + $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; +} + +/** + * 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; +} diff --git a/docs/m4-data-migration.md b/docs/m4-data-migration.md index 322e818..854db40 100644 --- a/docs/m4-data-migration.md +++ b/docs/m4-data-migration.md @@ -5,7 +5,8 @@ Stand: 2026-07-13 M4 uebernimmt die finanznahen Legacy-Buchungen additiv in das neue tenant-sichere Ledger-Modell. Die bestehenden Legacy-Seiten lesen und schreiben weiterhin die bisherigen `kl_*`-Tabellen; das Ledger dient zunaechst als -vergleichbare, pruefbare Zielstruktur. +vergleichbare, pruefbare Zielstruktur. Erste App-Abfragen koennen nun ueber +einen zentralen Ledger-Service gegen `ledger_entries` laufen. ## Ziel @@ -40,6 +41,13 @@ Ergaenzende Skripte: ```text scripts/backfill-ledger-entries.php scripts/check-m4-ledger-migration.php +scripts/check-m4-ledger-service.php +``` + +Ergaenzende App-Dateien: + +```text +app/ledger.php ``` Ausgefuehrter Dev-Stand: @@ -48,6 +56,8 @@ Ausgefuehrter Dev-Stand: - 9 Legacy-Einzahlungen wurden als `payment` gespiegelt. - 12 Legacy-Kaffeeverbrauch-Zeilen wurden als `consumption` gespiegelt. - Ein zweiter Backfill-Lauf blieb idempotent und erzeugte keine Dubletten. +- `app/ledger.php` stellt zentrale Abfragen fuer Tenant-Summen, + Teilnehmer-Summen, einzelne Teilnehmer und letzte Buchungen bereit. ## Abbildungsregeln @@ -111,6 +121,7 @@ nicht aus den `kl_*`-Tabellen geloescht. php scripts/backfill-default-tenant.php php scripts/backfill-ledger-entries.php php scripts/check-m4-ledger-migration.php +php scripts/check-m4-ledger-service.php php scripts/check-golden-master.php ``` @@ -128,10 +139,15 @@ Installation aus `.local/` verwendet werden. - Web-Striche aus `Eintragsart = 2` bleiben als `source = legacy_web` erkennbar: erfuellt. - Ledger-Summen entsprechen dem Golden Master: erfuellt. +- Zentrale Ledger-Abfragen liefern dieselben Teilnehmer-Summen wie der Golden + Master: erfuellt. +- Tenant-Summen, aktive Teilnehmer, Einzelteilnehmer und letzte Buchungen sind + ueber `app/ledger.php` abrufbar: erfuellt. ## Aktueller Pruefstatus - M4 Ledger-Migration: gruen mit 73 Assertions. +- M4 Ledger-Service: gruen mit 102 Assertions. - M3 SaaS-Basis: weiterhin gruen. - M3 Tenant-Aufloesung: weiterhin gruen. - M3 Passwort/E-Mail: weiterhin gruen. @@ -143,8 +159,8 @@ Installation aus `.local/` verwendet werden. ## Noch offen in M4 -- Neues Repository/Service fuer Ledger-Abfragen der App-Seiten. -- Tenant-sichere Dashboard-, Strich-, Einzahlungs- und Listenqueries auf Basis - des Ledgers. +- Erste read-only App-Seite oder Preview auf Basis von `app/ledger.php`. +- Tenant-sichere Dashboard-, Strich-, Einzahlungs- und Listenqueries schrittweise + von Legacy-Tabellen auf das Ledger umstellen. - Storno-/Reversal-Modell in der UI statt harter Deletes. - Delta-Strategie fuer den finalen Cutover. diff --git a/docs/saas-umstrukturierungsplan.md b/docs/saas-umstrukturierungsplan.md index 775e542..9ec9dea 100644 --- a/docs/saas-umstrukturierungsplan.md +++ b/docs/saas-umstrukturierungsplan.md @@ -284,7 +284,7 @@ Uebersicht: | M1 | Golden Master | Legacy-Ergebnisse sind als Vergleichsbasis eingefroren; HTTP-Smoke prueft sichere Seiten | | M2 | Technisches Fundament | Abgeschlossen: Migrationen, Bootstrap, Session, CSRF-Helper und Legacy-Schreibseitenschutz stehen | | M3 | SaaS-Basis | Abgeschlossen: Tenants, User, Registrierung, Login, Rollen, Mail-Links und zentrale Mandantenauswahl funktionieren | -| M4 | Datenmigration | Gestartet: Ledger-Tabelle, Legacy-Backfill und Paritaetscheck sind umgesetzt | +| M4 | Datenmigration | Gestartet: Ledger-Tabelle, Legacy-Backfill, Paritaetscheck und Ledger-Service sind umgesetzt | | M5 | App-Kern | Dashboard, Striche, Einzahlungen, Mitglieder und Liste laufen | | M6 | Betriebsflows | Import, Export, Mail und Jahresprozesse sind auditierbar | | M7 | Landingpage | Erste werbliche Seite ist oeffentlich nutzbar; spaetere Ausbaustufen folgen | @@ -457,12 +457,16 @@ Schritte: spiegeln: erster Backfill erledigt. - Legacy-IDs speichern: erledigt ueber `legacy_table` und `legacy_id`. - Salden gegen Golden-Master vergleichen: Kontrollskript erledigt. +- Ledger-Service fuer Tenant-Summen, Teilnehmer-Summen und letzte Buchungen: + erledigt. Ergebnis: - Bestehender Datenbestand kann im neuen Modell abgebildet werden. - Saldenparitaet ist ueber `scripts/check-m4-ledger-migration.php` nachweisbar. +- Erste App-Abfragen koennen ueber `app/ledger.php` tenant-sicher aus dem neuen + Modell lesen. - Dokumentation: `docs/m4-data-migration.md`. Abhaengigkeiten: diff --git a/scripts/check-m4-ledger-service.php b/scripts/check-m4-ledger-service.php new file mode 100644 index 0000000..834c4a2 --- /dev/null +++ b/scripts/check-m4-ledger-service.php @@ -0,0 +1,189 @@ +prepare('SELECT id FROM tenants WHERE slug = ?'); + $stmt->execute([$slug]); + $tenantId = $stmt->fetchColumn(); + + if ($tenantId === false) { + throw new RuntimeException("Tenant '{$slug}' not found."); + } + + return (int)$tenantId; +} + +/** + * @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 m4_service_totals_from_summaries(array $summaries): array +{ + $totals = ledger_empty_totals(); + + foreach ($summaries as $summary) { + $totals['total_payments_cents'] += (int)$summary['total_payments_cents']; + $totals['total_costs_cents'] += (int)$summary['total_costs_cents']; + $totals['total_marks'] += (int)$summary['total_marks']; + $totals['balance_cents'] += (int)$summary['balance_cents']; + $totals['year_payments_cents'] += (int)$summary['year_payments_cents']; + $totals['year_costs_cents'] += (int)$summary['year_costs_cents']; + $totals['year_marks'] += (int)$summary['year_marks']; + } + + return $totals; +} + +$pdo = dev_pdo(); +dev_apply_schema($pdo); + +$tenantSlug = m4_service_env('M4_DEFAULT_TENANT_SLUG', m4_service_env('M3_DEFAULT_TENANT_SLUG', 'default')); +$tenantId = m4_service_fetch_tenant_id($pdo, $tenantSlug); +$expected = gm_expected_summaries(); +$emails = array_keys($expected); +$failures = []; +$passes = 0; + +$summaries = ledger_fetch_participant_summaries($pdo, $tenantId, [ + 'email_norms' => $emails, + 'year' => gm_current_year(), +]); + +$summariesByEmail = []; +foreach ($summaries as $summary) { + if ($summary['email_norm'] !== null) { + $summariesByEmail[$summary['email_norm']] = $summary; + } +} + +m4_service_assert_equal('ledger service summary count', count($expected), count($summariesByEmail), $failures, $passes); + +$keyMap = [ + 'total_payments' => 'total_payments_cents', + 'total_costs' => 'total_costs_cents', + 'total_marks' => 'total_marks', + 'balance' => 'balance_cents', + 'year_payments' => 'year_payments_cents', + 'year_costs' => 'year_costs_cents', + 'year_marks' => 'year_marks', +]; + +foreach ($expected as $email => $expectedSummary) { + m4_service_assert("ledger service summary exists {$email}", isset($summariesByEmail[$email]), $failures, $passes); + if (!isset($summariesByEmail[$email])) { + continue; + } + + foreach ($keyMap as $expectedKey => $actualKey) { + m4_service_assert_equal( + "ledger service {$email} {$expectedKey}", + $expectedSummary[$expectedKey], + $summariesByEmail[$email][$actualKey], + $failures, + $passes + ); + } +} + +$allSummaries = ledger_fetch_participant_summaries($pdo, $tenantId, [ + 'year' => gm_current_year(), +]); +$tenantTotals = ledger_fetch_tenant_totals($pdo, $tenantId, gm_current_year()); +foreach (m4_service_totals_from_summaries($allSummaries) as $key => $value) { + m4_service_assert_equal("ledger service tenant total {$key}", $value, $tenantTotals[$key], $failures, $passes); +} + +$stmt = $pdo->prepare('SELECT COUNT(*) FROM participants WHERE tenant_id = ? AND active = 1'); +$stmt->execute([$tenantId]); +$activeExpectedCount = (int)$stmt->fetchColumn(); +$activeSummaries = ledger_fetch_participant_summaries($pdo, $tenantId, ['active_only' => true]); +m4_service_assert_equal('ledger service active participant count', $activeExpectedCount, count($activeSummaries), $failures, $passes); + +$paypalSummary = $summariesByEmail['gm-paypal@test.local'] ?? null; +m4_service_assert('ledger service paypal summary is present', $paypalSummary !== null, $failures, $passes); +if ($paypalSummary !== null) { + $singleSummary = ledger_fetch_participant_summary($pdo, $tenantId, $paypalSummary['participant_id'], gm_current_year()); + m4_service_assert('ledger service single participant summary is present', $singleSummary !== null, $failures, $passes); + if ($singleSummary !== null) { + m4_service_assert_equal('ledger service single participant email', 'gm-paypal@test.local', $singleSummary['email_norm'], $failures, $passes); + m4_service_assert_equal('ledger service single participant balance', 650, $singleSummary['balance_cents'], $failures, $passes); + } + + $paypalEntries = ledger_fetch_recent_entries($pdo, $tenantId, [ + 'participant_id' => $paypalSummary['participant_id'], + 'limit' => 100, + ]); + m4_service_assert_equal('ledger service paypal entry count', 2, count($paypalEntries), $failures, $passes); + foreach ($paypalEntries as $entry) { + m4_service_assert_equal('ledger service paypal entry tenant', $tenantId, $entry['tenant_id'], $failures, $passes); + m4_service_assert_equal('ledger service paypal entry participant', $paypalSummary['participant_id'], $entry['participant_id'], $failures, $passes); + } +} + +$recentEntries = ledger_fetch_recent_entries($pdo, $tenantId, ['limit' => 5]); +m4_service_assert_equal('ledger service recent entry limit', 5, count($recentEntries), $failures, $passes); +$previousBookedAt = null; +foreach ($recentEntries as $entry) { + m4_service_assert_equal('ledger service recent entry tenant', $tenantId, $entry['tenant_id'], $failures, $passes); + m4_service_assert('ledger service recent entry has amount', $entry['amount_cents'] !== 0, $failures, $passes); + if ($previousBookedAt !== null) { + m4_service_assert('ledger service recent entries are sorted', $previousBookedAt >= $entry['booked_at'], $failures, $passes); + } + $previousBookedAt = $entry['booked_at']; +} + +$legacyConsumptionCount = (int)$pdo->query('SELECT COUNT(*) FROM kl_Kaffeeverbrauch')->fetchColumn(); +$consumptionEntries = ledger_fetch_recent_entries($pdo, $tenantId, [ + 'type' => 'consumption', + 'limit' => 100, +]); +m4_service_assert_equal('ledger service consumption entry count', $legacyConsumptionCount, count($consumptionEntries), $failures, $passes); + +m4_service_assert_equal('ledger service empty participant id filter', [], ledger_fetch_participant_summaries($pdo, $tenantId, [ + 'participant_ids' => [], +]), $failures, $passes); +m4_service_assert_equal('ledger service unknown tenant summaries', [], ledger_fetch_participant_summaries($pdo, 0), $failures, $passes); +m4_service_assert_equal('ledger service unknown tenant entries', [], ledger_fetch_recent_entries($pdo, 0), $failures, $passes); +m4_service_assert_equal('ledger service unknown tenant totals', ledger_empty_totals(), ledger_fetch_tenant_totals($pdo, 0), $failures, $passes); + +if ($failures !== []) { + echo "\nM4 ledger service check failed with " . count($failures) . " failure(s):\n"; + foreach ($failures as $failure) { + echo "- {$failure}\n"; + } + exit(1); +} + +echo "\nM4 ledger service check passed with {$passes} assertions.\n";