Files
kaffeekasse-saas/app/ledger.php
T
clemensandClaude Opus 4.8 eec7a2fef2 Legacy-Abbau Schritt 1: Dual-Write in die kl_*-Tabellen entfernt
Die Migration in participants/ledger_entries ist abgeschlossen: kein
Teilnehmer traegt noch eine legacy_mitarbeiter_id, kein Journaleintrag eine
legacy_table. Damit war der jeweils zweite Zweig ("Default-Mandant schreibt
zusaetzlich nach kl_Einzahlungen/kl_Kaffeeverbrauch/kl_Mitarbeiter") in
sechs Dateien nicht mehr erreichbar - er musste aber bei jeder Aenderung
mitgepflegt werden, zuletzt bei der Bemerkung fuer Einzahlungen.

Entfernt:
- Dual-Write beim Buchen: einzahlung.php, stricheintragen.php, index.php,
  jahresauswertung.php, app/imports.php, app/paypal-inbox.php
- Dual-Write in der Mitgliederverwaltung: ledger_create_participant,
  ledger_update_participant, ledger_set_participant_active,
  ledger_anonymize_participant
- die verwaisten Spiegelfunktionen ledger_mirror_legacy_payment,
  ledger_mirror_legacy_consumption und ledger_void_entry_by_legacy_id

Nebenbei behoben: kaffeeliste.php hat die Teilnehmer-Detailseite nur fuer
Mitglieder mit legacy_mitarbeiter_id verlinkt - die hat seit der Migration
niemand mehr, die Seite war also fuer alle unerreichbar. Verlinkt und
adressiert wird jetzt ueber participant_id; "user_id" bleibt als Alias
erhalten. Der Mandanten-Isolationstest prueft jetzt ledger_void_entry, also
den Pfad, den letzteneintraege.php tatsaechlich nutzt.

Pruefskripte unveraendert gegenueber der Baseline vor dem Umbau
(http-smoke 27/6, role-matrix 55/0); die Isolationspruefung steigt von
9 auf 11 PASS bei gleichem vorbestehendem Fehler.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 20:57:49 +02:00

746 lines
28 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');
}
/**
* 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<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);
}
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.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_is_default_tenant(PDO $pdo, int $tenantId): bool
{
$tenant = ledger_fetch_default_tenant($pdo);
return $tenant !== null && (int)$tenant['id'] === $tenantId;
}
/**
* @return list<array{participant_id: int, display_name: string, email: ?string, email_norm: ?string, paypal_name: ?string, active: bool, legacy_mitarbeiter_id: ?int, user_id: ?int, role: ?string, membership_status: ?string}>
*/
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.legacy_mitarbeiter_id,
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,
'legacy_mitarbeiter_id' => $row['legacy_mitarbeiter_id'] !== null ? (int)$row['legacy_mitarbeiter_id'] : null,
'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;
}
/**
* 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.
*/
/**
* Storniert einen Journaleintrag anhand seiner Ledger-ID - immer auf den
* Mandanten eingegrenzt, damit ueber eine untergeschobene ID nicht in fremden
* Mandanten gebucht werden kann. Haengt am Eintrag noch eine Legacy-Zeile,
* wird sie mitgeloescht, damit der Mirror den Eintrag nicht wieder auferstehen
* laesst.
*
* @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
{
// Nur bekannte Legacy-Tabellen, damit der Tabellenname nie ungeprueft in
// ein DELETE wandert.
$legacyKeys = [
'kl_Einzahlungen' => 'EinzahlungsID',
'kl_Kaffeeverbrauch' => 'VerbrauchID',
];
$stmt = $pdo->prepare(
"SELECT id, type, legacy_table, legacy_id
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;
}
try {
$pdo->beginTransaction();
$pdo->prepare('UPDATE ledger_entries SET voided_at = NOW() WHERE id = ? AND tenant_id = ? AND voided_at IS NULL')
->execute([$entryId, $tenantId]);
$legacyTable = $entry['legacy_table'] !== null ? (string)$entry['legacy_table'] : null;
if ($legacyTable !== null && $entry['legacy_id'] !== null && isset($legacyKeys[$legacyTable])) {
$pdo->prepare("DELETE FROM {$legacyTable} WHERE {$legacyKeys[$legacyTable]} = ?")
->execute([(int)$entry['legacy_id']]);
}
$pdo->commit();
} catch (Throwable $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
return false;
}
return true;
}
/**
* 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
* (every tenant except the migrated default tenant).
*/
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();
}
/**
* Records a payment entry straight into the ledger, without any legacy
* kl_Einzahlungen row. Used for tenants that have no legacy shadow table.
*/
/**
* 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();
}
/**
* Ledger-native equivalent of the legacy "100-Tage-Liste" front/back split:
* active participants whose summed marks within the trailing window are at
* or above the ten-mark threshold ($atLeastTen), or below it. Used for
* tenants without a legacy kl_Kaffeeverbrauch history to filter against.
*
* @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_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<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;
$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;
}