Files
clemensandClaude Opus 4.8 4acd4a2d40 Legacy-Abbau Schritt 3+4: kl_*-Tabellen und legacy_*-Spalten entfernt
Abschluss des Legacy-Abbaus. Die Migration in participants/ledger_entries war
laut Dev-DB vollstaendig (keine legacy_mitarbeiter_id, kein legacy_table), die
kl_*-Tabellen enthielten nur noch Golden-Master-Testfixtures. Da kein echter
Altbestand mehr importiert werden muss (Prod entsteht aus der jetzigen Dev-DB),
kommt die Altwelt komplett raus.

Laufzeit-Code (Schritt 3a):
- ledger.php: Option legacy_mitarbeiter_ids, die Spalten aus allen SELECTs und
  Rueckgaben, ledger_fetch_participant_summary_by_legacy_id sowie das Loeschen
  gespiegelter Legacy-Zeilen in ledger_void_entry/ledger_void_own_self_entry
  entfernt
- imports.php, paypal-inbox.php: legacy_mitarbeiter_id aus Ergebnis und
  Typannotationen entfernt
- ledger-preview.php: Spalte "Legacy" entfernt

Skripte (Schritt 3b/3c):
- die acht reinen Legacy-/Golden-Master-Skripte entfernt (backfill-*,
  seed-golden-master, golden-master-data, check-golden-master, check-m4-*,
  check-m3-saas-basis)
- init-mysql-dev.php legt jetzt einen SaaS-Mandanten mit Owner-Login,
  Teilnehmer, Journalbuchungen und Hinweis an statt kl_*-Zeilen

Schema (Schritt 4, Migration 0024):
- participants.legacy_mitarbeiter_id + uq_participants_tenant_legacy
- ledger_entries.legacy_table/legacy_id + uq_ledger_entries_tenant_legacy
- DROP der Tabellen kl_Einzahlungen, kl_Kaffeeverbrauch, kl_hinweise,
  kl_config, kl_Mitarbeiter

Verifikation unveraendert gruen: http-smoke 33/0, role-matrix 55/0,
tenant-isolation 11/1 (Altfehler), m3-auth/tenant-resolution/billing gruen.
check-m3-settings-flow 7/6 ist vorbestehend (schon bei 3e24910 rot, mit
spaeteren Settings-Feldern nicht synchron) und unabhaengig von diesem Abbau.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 08:31:08 +02:00

331 lines
11 KiB
PHP

<?php
declare(strict_types=1);
require_once __DIR__ . '/bootstrap.php';
require_once __DIR__ . '/database.php';
require_once __DIR__ . '/ledger.php';
require_once __DIR__ . '/imports.php';
require_once __DIR__ . '/audit.php';
require_once __DIR__ . '/paypal-mail-parser.php';
/**
* Basis-Eingangsadresse (Catch-all), an die Admins ihre PayPal-Mails
* weiterleiten - z. B. "zahlungen@kaffeeliste.de". Nur ueber Server-/Env-
* Einstellung durch den Betreiber setzbar (PAYPAL_INBOX_BASE).
*/
function paypal_inbox_base_address(): ?string
{
$base = app_env('PAYPAL_INBOX_BASE');
return ($base !== null && str_contains($base, '@')) ? $base : null;
}
/**
* Baut die mandantenspezifische Plus-Adresse aus Basisadresse und Token,
* z. B. "zahlungen+ab12cd@kaffeeliste.de". Null, wenn keine Basis konfiguriert.
*/
function paypal_inbox_address_for_token(string $token): ?string
{
$base = paypal_inbox_base_address();
if ($base === null || $token === '') {
return null;
}
[$local, $domain] = explode('@', $base, 2);
return $local . '+' . $token . '@' . $domain;
}
/**
* Liefert (und erzeugt bei Bedarf) den eindeutigen Inbox-Token eines Mandanten.
*/
function paypal_inbox_ensure_token(PDO $pdo, int $tenantId): string
{
$stmt = $pdo->prepare('SELECT paypal_inbox_token FROM tenants WHERE id = ?');
$stmt->execute([$tenantId]);
$token = (string) ($stmt->fetchColumn() ?: '');
if ($token !== '') {
return $token;
}
for ($try = 0; $try < 5; $try++) {
$candidate = bin2hex(random_bytes(6)); // 12 hex-Zeichen
try {
$pdo->prepare('UPDATE tenants SET paypal_inbox_token = ? WHERE id = ?')
->execute([$candidate, $tenantId]);
return $candidate;
} catch (Throwable $e) {
// Kollision (unwahrscheinlich) - erneut versuchen.
}
}
throw new RuntimeException('Es konnte kein eindeutiger Inbox-Token erzeugt werden.');
}
/**
* Ermittelt den Mandanten anhand des Plus-Adress-Tokens (aus der Empfaenger-
* adresse der weitergeleiteten Mail). Null, wenn kein Mandant passt.
*/
function paypal_inbox_resolve_tenant(PDO $pdo, string $token): ?int
{
$token = trim($token);
if ($token === '') {
return null;
}
$stmt = $pdo->prepare("SELECT id FROM tenants WHERE paypal_inbox_token = ? AND status = 'active' LIMIT 1");
$stmt->execute([$token]);
$id = $stmt->fetchColumn();
return $id === false ? null : (int) $id;
}
/**
* Zieht den Plus-Token aus einer Empfaengeradresse ("zahlungen+ab12cd@host").
* Null, wenn kein Token enthalten ist.
*/
function paypal_inbox_extract_token(string $address): ?string
{
if (preg_match('/\+([A-Za-z0-9]{6,32})@/', $address, $m)) {
return $m[1];
}
return null;
}
/**
* Bucht eine PayPal-Netto-Zahlung als Einzahlung ins Ledger. Beim migrierten
* Default-Mandanten wird zusaetzlich in die Legacy-Tabelle geschrieben und
* gespiegelt (gleiches Muster wie Sammelerfassung/CSV-Import).
*
* @return int Ledger-Entry-ID
*/
function paypal_book_payment(PDO $pdo, int $tenantId, array $participant, int $netCents, ?int $actorUserId, ?string $note = null): int
{
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;
}
/**
* Sucht den passenden Teilnehmer zu einer PayPal-Zahlung: zuerst ueber den
* Zahlernamen (paypal_name/display_name), sonst ueber die Mitteilung (Mitglied
* schreibt dort teils seinen Namen). Liefert null, wenn nicht eindeutig.
*
* @return array{participant_id:int, display_name:string}|null
*/
function paypal_match_participant(PDO $pdo, int $tenantId, string $payerName, ?string $note): ?array
{
$byName = imports_find_participant($pdo, $tenantId, $payerName);
if ($byName !== null) {
return $byName;
}
if ($note !== null && trim($note) !== '') {
return imports_find_participant($pdo, $tenantId, trim($note));
}
return null;
}
/**
* Verarbeitet eine geparste PayPal-Zahlung fuer einen Mandanten:
* - Dedup ueber den Transaktionscode (bereits verarbeitet -> uebersprungen).
* - Eindeutiger Match -> automatisch als Netto-Einzahlung gebucht.
* - Kein eindeutiger Match -> in der Warteschlange ('unmatched') abgelegt.
*
* @param array $parsed Ergebnis von paypal_parse_notification()
* @return array{status:string, payment_id?:int, participant?:string}
*/
function paypal_reconcile(PDO $pdo, int $tenantId, array $parsed, ?int $actorUserId = null): array
{
$code = (string) ($parsed['transaction_code'] ?? '');
if ($code === '') {
return ['status' => 'no_code'];
}
$netCents = (int) ($parsed['net_cents'] ?? 0);
if ($netCents <= 0) {
return ['status' => 'no_amount'];
}
// Dedup: Transaktionscode ist global eindeutig. INSERT IGNORE als Schranke.
$insert = $pdo->prepare(
'INSERT IGNORE INTO paypal_payments
(tenant_id, transaction_code, payer_name, note, gross_cents, fee_cents, net_cents, paid_at, status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)'
);
$insert->execute([
$tenantId,
$code,
(string) ($parsed['payer_name'] ?? ''),
$parsed['note'] ?? null,
(int) ($parsed['gross_cents'] ?? 0),
$parsed['fee_cents'] ?? null,
$netCents,
$parsed['date'] ?? null,
'unmatched',
]);
if ($insert->rowCount() === 0) {
return ['status' => 'duplicate'];
}
$paymentId = (int) $pdo->lastInsertId();
// Eindeutiger Match -> automatisch buchen.
$participant = paypal_match_participant($pdo, $tenantId, (string) ($parsed['payer_name'] ?? ''), $parsed['note'] ?? null);
if ($participant === null) {
return ['status' => 'unmatched', 'payment_id' => $paymentId];
}
try {
$pdo->beginTransaction();
$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();
} catch (Throwable $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
// Buchung fehlgeschlagen -> in der Warteschlange belassen.
return ['status' => 'unmatched', 'payment_id' => $paymentId];
}
app_audit_log($pdo, $tenantId, $actorUserId, 'paypal_import.auto_booked', 'paypal_payment', $paymentId, [
'transaction_code' => $code,
'net_cents' => $netCents,
'participant_id' => (int) $participant['participant_id'],
]);
return ['status' => 'booked', 'payment_id' => $paymentId, 'participant' => (string) $participant['display_name']];
}
/**
* Verarbeitet eine rohe PayPal-Mail vollstaendig: Tenant per Plus-Token,
* Absenderpruefung, Parsing, Abgleich. Fuer den IMAP-Cron und Tests.
*
* @param string $recipient Empfaengeradresse mit Plus-Token
* @param string $fromHeader From-Header der Mail
* @param string $rawBody (dekodierter) Mail-Body
* @return array{status:string, tenant_id?:int}
*/
function paypal_process_raw(PDO $pdo, string $recipient, string $fromHeader, string $rawBody): array
{
if (!preg_match('/@paypal\.(de|com)/i', $fromHeader)) {
return ['status' => 'not_from_paypal'];
}
$token = paypal_inbox_extract_token($recipient);
if ($token === null) {
return ['status' => 'no_token'];
}
$tenantId = paypal_inbox_resolve_tenant($pdo, $token);
if ($tenantId === null) {
return ['status' => 'unknown_tenant'];
}
$parsed = paypal_parse_notification($rawBody);
if ($parsed === null) {
return ['status' => 'not_a_payment'];
}
$result = paypal_reconcile($pdo, $tenantId, $parsed);
$result['tenant_id'] = $tenantId;
return $result;
}
/**
* Offene (noch nicht zugeordnete) PayPal-Zahlungen eines Mandanten.
*
* @return list<array>
*/
function paypal_fetch_unmatched(PDO $pdo, int $tenantId): array
{
$stmt = $pdo->prepare(
"SELECT id, transaction_code, payer_name, note, net_cents, paid_at, created_at
FROM paypal_payments
WHERE tenant_id = ? AND status = 'unmatched'
ORDER BY created_at DESC, id DESC"
);
$stmt->execute([$tenantId]);
return $stmt->fetchAll();
}
/**
* Ordnet eine Zahlung aus der Warteschlange manuell einem Teilnehmer zu und
* bucht den Netto-Betrag als Einzahlung.
*
* @return array{ok:bool, error?:string}
*/
function paypal_assign_payment(PDO $pdo, int $tenantId, int $paymentId, int $participantId, ?int $actorUserId): array
{
$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') {
return ['ok' => false, 'error' => 'Diese Zahlung ist nicht (mehr) offen.'];
}
$summaries = ledger_fetch_participant_summaries($pdo, $tenantId, ['participant_ids' => [$participantId]]);
$participant = $summaries[0] ?? null;
if ($participant === null) {
return ['ok' => false, 'error' => 'Das gewählte Mitglied wurde nicht gefunden.'];
}
try {
$pdo->beginTransaction();
$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();
} catch (Throwable $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
return ['ok' => false, 'error' => 'Die Zahlung konnte nicht gebucht werden.'];
}
app_audit_log($pdo, $tenantId, $actorUserId, 'paypal_import.manual_assigned', 'paypal_payment', $paymentId, [
'participant_id' => $participantId,
'net_cents' => (int) $payment['net_cents'],
]);
return ['ok' => true];
}
/**
* Verwirft eine offene Zahlung aus der Warteschlange (z. B. Fehleingang, keine
* Kaffeelisten-Zahlung).
*/
function paypal_ignore_payment(PDO $pdo, int $tenantId, int $paymentId, ?int $actorUserId): array
{
$stmt = $pdo->prepare("UPDATE paypal_payments SET status = 'ignored' WHERE id = ? AND tenant_id = ? AND status = 'unmatched'");
$stmt->execute([$paymentId, $tenantId]);
if ($stmt->rowCount() === 1) {
app_audit_log($pdo, $tenantId, $actorUserId, 'paypal_import.ignored', 'paypal_payment', $paymentId);
return ['ok' => true];
}
return ['ok' => false, 'error' => 'Diese Zahlung ist nicht (mehr) offen.'];
}