Bemerkung und Abzuege bei Einzahlungen

Einzahlungen koennen jetzt eine Bemerkung tragen und negativ sein, damit
Abzuege (Auszahlung bei Austritt, Erstattung) nachvollziehbar gebucht werden
koennen.

- ledger_entries.note wurde bisher nur beim Import befuellt und wird nun auch
  bei Einzahlungen geschrieben und angezeigt
- Negative Betraege verlangen zwingend eine Bemerkung, sonst ist spaeter nicht
  mehr nachvollziehbar, warum jemandem Geld abgezogen wurde
- Plausibilitaetsgrenze von 1000 EUR gegen Groessenordnungs-Tippfehler; bei
  einem Fehler in einer Zeile wird gar nichts gebucht und die Eingaben bleiben
  stehen
- Legacy-Tabelle kl_Einzahlungen bekommt eine Bemerkung-Spalte, sonst haette
  ledger_mirror_legacy_payment die Notiz beim Re-Sync wieder mit NULL
  ueberschrieben
- PayPal-Buchungen tragen jetzt Zahler, Datum und Mitteilung als Bemerkung

Ausserdem behoben: letzteneintraege.php hat Einzahlungen und Striche direkt
aus den Legacy-Tabellen gelesen, ohne nach Mandant zu filtern. Jeder
Mandanten-Admin sah damit die Buchungen aus den Legacy-Tabellen, und das
Stornieren lief ueber die nicht mandantengetrennten Legacy-IDs. Die Seite
arbeitet jetzt auf dem mandantengebundenen Journal, und ledger_void_entry
prueft Mandant und Buchungsart, bevor es storniert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-21 19:53:10 +02:00
co-authored by Claude Opus 4.8
parent ea7d9a4714
commit dd6e1e77b0
5 changed files with 320 additions and 172 deletions
+100 -5
View File
@@ -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.');