diff --git a/app/ledger.php b/app/ledger.php index fe995f4..1bcb3d2 100644 --- a/app/ledger.php +++ b/app/ledger.php @@ -41,6 +41,35 @@ 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'); @@ -568,7 +597,7 @@ function ledger_mirror_legacy_payment(PDO $pdo, int $tenantId, int $legacyPaymen NULL, e.Datum, 'legacy_payment', - NULL, + e.Bemerkung, 'kl_Einzahlungen', e.EinzahlungsID FROM kl_Einzahlungen e @@ -606,6 +635,66 @@ function ledger_mirror_legacy_payment(PDO $pdo, int $tenantId, int $legacyPaymen * 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; +} + function ledger_void_entry_by_legacy_id(PDO $pdo, int $tenantId, string $legacyTable, int $legacyId): bool { $stmt = $pdo->prepare( @@ -702,16 +791,22 @@ function ledger_record_consumption(PDO $pdo, int $tenantId, int $participantId, * Records a payment entry straight into the ledger, without any legacy * kl_Einzahlungen row. Used for tenants that have no legacy shadow table. */ -function ledger_record_payment(PDO $pdo, int $tenantId, int $participantId, int $amountCents, string $source, ?int $createdByUserId = null): int +/** + * 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) - SELECT ?, id, 'payment', ?, NOW(), ?, ? + (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, $participantId, $tenantId]); + $stmt->execute([$tenantId, $amountCents, $source, $createdByUserId, $note, $participantId, $tenantId]); if ($stmt->rowCount() !== 1) { throw new RuntimeException('Die Einzahlung konnte nicht gespeichert werden.'); diff --git a/app/paypal-inbox.php b/app/paypal-inbox.php index 4e043f0..653b44e 100644 --- a/app/paypal-inbox.php +++ b/app/paypal-inbox.php @@ -100,15 +100,15 @@ function paypal_inbox_extract_token(string $address): ?string * * @return int Ledger-Entry-ID */ -function paypal_book_payment(PDO $pdo, int $tenantId, array $participant, int $netCents, ?int $actorUserId): int +function paypal_book_payment(PDO $pdo, int $tenantId, array $participant, int $netCents, ?int $actorUserId, ?string $note = null): int { $legacyMitarbeiterId = isset($participant['legacy_mitarbeiter_id']) && $participant['legacy_mitarbeiter_id'] !== null ? (int) $participant['legacy_mitarbeiter_id'] : 0; if ($legacyMitarbeiterId > 0) { - $stmt = $pdo->prepare('INSERT INTO kl_Einzahlungen (MitarbeiterID, Betrag, Datum) VALUES (?, ?, ?)'); - $stmt->execute([$legacyMitarbeiterId, $netCents / 100, date('Y-m-d H:i:s')]); + $stmt = $pdo->prepare('INSERT INTO kl_Einzahlungen (MitarbeiterID, Betrag, Bemerkung, Datum) VALUES (?, ?, ?, ?)'); + $stmt->execute([$legacyMitarbeiterId, $netCents / 100, $note, date('Y-m-d H:i:s')]); $legacyPaymentId = (int) $pdo->lastInsertId(); ledger_mirror_legacy_payment($pdo, $tenantId, $legacyPaymentId); @@ -120,7 +120,25 @@ function paypal_book_payment(PDO $pdo, int $tenantId, array $participant, int $n return (int) $idStmt->fetchColumn(); } - return ledger_record_payment($pdo, $tenantId, (int) $participant['participant_id'], $netCents, 'paypal_import', $actorUserId); + return ledger_record_payment($pdo, $tenantId, (int) $participant['participant_id'], $netCents, 'paypal_import', $actorUserId, $note); +} + +/** + * Bemerkung fuer eine aus PayPal uebernommene Buchung: Zahler und Datum, damit + * im Journal nachvollziehbar bleibt, woher die Gutschrift stammt. + */ +function paypal_booking_note(array $payment): string +{ + $note = 'PayPal: ' . trim((string) ($payment['payer_name'] ?? '')); + if (!empty($payment['paid_at'])) { + $note .= ' vom ' . date('d.m.Y', strtotime((string) $payment['paid_at'])); + } + $mitteilung = trim((string) ($payment['note'] ?? '')); + if ($mitteilung !== '') { + $note .= ' ("' . $mitteilung . '")'; + } + + return $note; } /** @@ -193,7 +211,11 @@ function paypal_reconcile(PDO $pdo, int $tenantId, array $parsed, ?int $actorUse try { $pdo->beginTransaction(); - $ledgerId = paypal_book_payment($pdo, $tenantId, $participant, $netCents, $actorUserId); + $ledgerId = paypal_book_payment($pdo, $tenantId, $participant, $netCents, $actorUserId, paypal_booking_note([ + 'payer_name' => $parsed['payer_name'] ?? '', + 'paid_at' => $parsed['date'] ?? null, + 'note' => $parsed['note'] ?? null, + ])); $pdo->prepare("UPDATE paypal_payments SET participant_id = ?, ledger_entry_id = ?, status = 'booked' WHERE id = ?") ->execute([(int) $participant['participant_id'], $ledgerId, $paymentId]); $pdo->commit(); @@ -273,7 +295,7 @@ function paypal_fetch_unmatched(PDO $pdo, int $tenantId): array */ function paypal_assign_payment(PDO $pdo, int $tenantId, int $paymentId, int $participantId, ?int $actorUserId): array { - $stmt = $pdo->prepare("SELECT id, net_cents, status FROM paypal_payments WHERE id = ? AND tenant_id = ?"); + $stmt = $pdo->prepare("SELECT id, net_cents, status, payer_name, note, paid_at FROM paypal_payments WHERE id = ? AND tenant_id = ?"); $stmt->execute([$paymentId, $tenantId]); $payment = $stmt->fetch(); if ($payment === false || (string) $payment['status'] !== 'unmatched') { @@ -288,7 +310,7 @@ function paypal_assign_payment(PDO $pdo, int $tenantId, int $paymentId, int $par try { $pdo->beginTransaction(); - $ledgerId = paypal_book_payment($pdo, $tenantId, $participant, (int) $payment['net_cents'], $actorUserId); + $ledgerId = paypal_book_payment($pdo, $tenantId, $participant, (int) $payment['net_cents'], $actorUserId, paypal_booking_note($payment)); $pdo->prepare("UPDATE paypal_payments SET participant_id = ?, ledger_entry_id = ?, status = 'booked' WHERE id = ?") ->execute([$participantId, $ledgerId, $paymentId]); $pdo->commit(); diff --git a/database/migrations/0023_einzahlung_bemerkung.sql b/database/migrations/0023_einzahlung_bemerkung.sql new file mode 100644 index 0000000..78ec2f2 --- /dev/null +++ b/database/migrations/0023_einzahlung_bemerkung.sql @@ -0,0 +1,10 @@ +-- Bemerkung/Titel fuer Einzahlungen. +-- +-- ledger_entries.note existiert bereits (siehe 0006), wurde bei Einzahlungen +-- aber nie befuellt. Beim Default-Mandanten laeuft die Buchung als Dual-Write +-- ueber die Legacy-Tabelle kl_Einzahlungen, und ledger_mirror_legacy_payment +-- ueberschreibt note beim Re-Sync mit dem Legacy-Wert. Ohne eine Spalte auf +-- der Legacy-Seite wuerde die Bemerkung dort also stillschweigend wieder +-- verschwinden. +ALTER TABLE kl_Einzahlungen + ADD COLUMN Bemerkung VARCHAR(1000) NULL AFTER Betrag; diff --git a/einzahlung.php b/einzahlung.php index ec528a5..18c54d6 100644 --- a/einzahlung.php +++ b/einzahlung.php @@ -83,6 +83,13 @@ function einzahlung_fetch_participants(PDO $pdo, int $tenantId, string $action): $eingetragen = 0; $fehlgeschlagen = false; $hatGespeichert = false; +$validierungsFehler = []; +$eingaben = []; + +// Obergrenze je Einzahlungszeile. Faengt Groessenordnungs-Tippfehler ab +// (z. B. 500 statt 5,00); groessere Betraege lassen sich in mehreren +// Schritten eintragen. +const EINZAHLUNG_MAX_BETRAG = 1000.00; // Verarbeitung des Formulars, wenn es gesendet wurde if ($_SERVER["REQUEST_METHOD"] == "POST" ) { @@ -100,39 +107,114 @@ if ($_SERVER["REQUEST_METHOD"] == "POST" ) { $legacyMap[(int)$row['id']] = $row['legacy_mitarbeiter_id'] !== null ? (int)$row['legacy_mitarbeiter_id'] : null; } - try { - $pdo->beginTransaction(); - $insertLegacy = $pdo->prepare( - "INSERT INTO kl_Einzahlungen (MitarbeiterID, Betrag, Datum) VALUES (?, ?, ?)" - ); - - foreach ($_POST["anzahlBetrag"] ?? [] as $participantId => $anzahlBetrag) { - $participantId = (int)$participantId; - $anzahlBetrag = floatval($anzahlBetrag); - if ($participantId <= 0 || $anzahlBetrag == 0.0 || !array_key_exists($participantId, $legacyMap)) { - continue; - } - - $legacyMitarbeiterId = $legacyMap[$participantId]; - if ($legacyMitarbeiterId !== null) { - $insertLegacy->execute([$legacyMitarbeiterId, $anzahlBetrag, $datum]); - $legacyPaymentId = (int)$pdo->lastInsertId(); - ledger_mirror_legacy_payment($pdo, $tenantId, $legacyPaymentId); - } else { - ledger_record_payment($pdo, $tenantId, $participantId, (int)round($anzahlBetrag * 100), 'manual_bulk'); - } - $eingetragen++; - } - - $pdo->commit(); - } catch (Throwable $e) { - if ($pdo->inTransaction()) { - $pdo->rollBack(); - } - $fehlgeschlagen = true; + // Namen fuer verstaendliche Fehlermeldungen. + $namen = []; + $stmtNamen = $pdo->prepare('SELECT id, display_name FROM participants WHERE tenant_id = ?'); + $stmtNamen->execute([$tenantId]); + foreach ($stmtNamen->fetchAll() as $row) { + $namen[(int)$row['id']] = (string)$row['display_name']; } - $action = 'alle'; + // Erst alle Zeilen einsammeln und pruefen, dann buchen. Schlaegt eine + // Zeile fehl, wird gar nichts gebucht - sonst waere nach einem Tippfehler + // unklar, was schon drin ist und was nicht. + $zuBuchen = []; + foreach ($_POST["anzahlBetrag"] ?? [] as $participantId => $anzahlBetrag) { + $participantId = (int)$participantId; + if ($participantId <= 0 || !array_key_exists($participantId, $legacyMap)) { + continue; + } + + $rohBetrag = trim((string)$anzahlBetrag); + $bemerkung = trim((string)($_POST['bemerkung'][$participantId] ?? '')); + $eingaben[$participantId] = ['betrag' => $rohBetrag, 'bemerkung' => $bemerkung]; + + if ($rohBetrag === '') { + // Leere Zeile: nur meckern, wenn trotzdem eine Bemerkung dransteht + // (sonst hat jemand getippt und den Betrag vergessen). + if ($bemerkung !== '') { + $validierungsFehler[] = sprintf( + '%s: Es wurde eine Bemerkung ohne Betrag angegeben.', + $namen[$participantId] ?? ('Teilnehmer ' . $participantId) + ); + } + continue; + } + + $anzahlBetrag = floatval(str_replace(',', '.', $rohBetrag)); + if ($anzahlBetrag == 0.0) { + continue; + } + + $name = $namen[$participantId] ?? ('Teilnehmer ' . $participantId); + + // Abzuege muessen begruendet werden, sonst ist spaeter nicht mehr + // nachvollziehbar, warum jemandem Geld abgezogen wurde. + if ($anzahlBetrag < 0 && $bemerkung === '') { + $validierungsFehler[] = sprintf( + '%s: Bei einem Abzug (negativer Betrag) ist eine Bemerkung Pflicht.', + $name + ); + continue; + } + + // Groessenordnungs-Tippfehler abfangen (500 statt 5,00). + if (abs($anzahlBetrag) > EINZAHLUNG_MAX_BETRAG) { + $validierungsFehler[] = sprintf( + '%s: %s € übersteigt die Plausibilitätsgrenze von %s €. Bitte prüfen oder in mehreren Schritten eintragen.', + $name, + number_format($anzahlBetrag, 2, ',', '.'), + number_format(EINZAHLUNG_MAX_BETRAG, 2, ',', '.') + ); + continue; + } + + $zuBuchen[$participantId] = [ + 'betrag' => $anzahlBetrag, + 'bemerkung' => $bemerkung !== '' ? $bemerkung : null, + ]; + } + + if ($validierungsFehler !== []) { + $action = 'alle'; + } else { + try { + $pdo->beginTransaction(); + $insertLegacy = $pdo->prepare( + "INSERT INTO kl_Einzahlungen (MitarbeiterID, Betrag, Bemerkung, Datum) VALUES (?, ?, ?, ?)" + ); + + foreach ($zuBuchen as $participantId => $zeile) { + $legacyMitarbeiterId = $legacyMap[$participantId]; + if ($legacyMitarbeiterId !== null) { + $insertLegacy->execute([$legacyMitarbeiterId, $zeile['betrag'], $zeile['bemerkung'], $datum]); + $legacyPaymentId = (int)$pdo->lastInsertId(); + ledger_mirror_legacy_payment($pdo, $tenantId, $legacyPaymentId); + } else { + ledger_record_payment( + $pdo, + $tenantId, + $participantId, + (int)round($zeile['betrag'] * 100), + 'manual_bulk', + $saasUser['user_id'] ?? null, + $zeile['bemerkung'] + ); + } + $eingetragen++; + } + + $pdo->commit(); + $eingaben = []; // Erfolgreich gebucht: Formular wieder leeren. + } catch (Throwable $e) { + if ($pdo->inTransaction()) { + $pdo->rollBack(); + } + $fehlgeschlagen = true; + } + + $action = 'alle'; + } } else { $action = (string)($_GET['action'] ?? 'alle'); } @@ -144,13 +226,27 @@ $mitarbeiter = einzahlung_fetch_participants($pdo, $tenantId, $action);
Es wurde nichts gebucht. Bitte korrigiere zuerst:
+Die Einzahlungen konnten nicht gespeichert werden.
Einträge erfolgreich hinzugefügt.
In der Bemerkung könnt ihr festhalten, worum es bei einer Buchung ging. Ein negativer Betrag bucht einen Abzug (z. B. Auszahlung bei Austritt oder eine Erstattung) und braucht deshalb immer eine Bemerkung.
+Wurde dagegen schlicht falsch eingetragen, ist ein Abzug der falsche Weg: Die Auswertung zählt sonst beide Buchungen als Umsatz. Solche Fehler stattdessen unter Letzte Einträge stornieren.
+