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 die * Mailadresse des Zahlers, dann ueber den Zahlernamen * (paypal_name/display_name), zuletzt 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, ?string $payerEmail = null): ?array { // Die Mailadresse zuerst: sie ist im Mandanten eindeutig (Unique Key auf // tenant_id + email_norm) und aendert sich nicht, wenn jemand bei PayPal // unter einem anderen Namen auftritt. $byEmail = paypal_find_participant_by_email($pdo, $tenantId, $payerEmail); if ($byEmail !== null) { return $byEmail; } $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; } /** * Gehoeren zwei Adressen zum selben Postfach? Plus-Adressierung wird dabei * ignoriert, "zahlungen+ab12cd@host" ist also dasselbe Postfach wie * "zahlungen@host". $kandidat darf mehrere Adressen enthalten (die * Empfaengerzeilen einer weitergeleiteten Mail). */ function paypal_same_mailbox(string $adresse, string $kandidat): bool { $adresse = strtolower(trim($adresse)); if ($adresse === '' || !str_contains($adresse, '@')) { return false; } [$lokal, $domain] = explode('@', $adresse, 2); $lokal = explode('+', $lokal)[0]; if (!preg_match_all('/[\w.+-]+@[\w-]+(?:\.[\w-]+)+/u', strtolower($kandidat), $treffer)) { return false; } foreach ($treffer[0] as $andere) { [$andereLokal, $andereDomain] = explode('@', $andere, 2); if (explode('+', $andereLokal)[0] === $lokal && $andereDomain === $domain) { return true; } } return false; } /** * Mitglied ueber seine Mailadresse finden. Null, wenn keine Adresse vorliegt * oder kein Mitglied dazu passt. */ function paypal_find_participant_by_email(PDO $pdo, int $tenantId, ?string $email): ?array { $email = strtolower(trim((string) $email)); if ($email === '') { return null; } $stmt = $pdo->prepare( 'SELECT id, display_name FROM participants WHERE tenant_id = ? AND email_norm = ? ORDER BY id LIMIT 2' ); $stmt->execute([$tenantId, $email]); $rows = $stmt->fetchAll(); if (count($rows) !== 1) { return null; } return [ 'participant_id' => (int) $rows[0]['id'], 'display_name' => (string) $rows[0]['display_name'], ]; } /** * 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. * - PayPal fuer den Mandanten abgeschaltet -> geparkt ('parked'): gespeichert, * aber ohne automatische Buchung. * * @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, payer_email, note, gross_cents, fee_cents, net_cents, paid_at, status) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)' ); $insert->execute([ $tenantId, $code, (string) ($parsed['payer_name'] ?? ''), (string) ($parsed['payer_email'] ?? ''), $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(); // Ist PayPal fuer diesen Mandanten abgeschaltet - vom Betreiber oder vom // Mandanten selbst -, wird die Zahlung nur geparkt: gespeichert, aber // nicht automatisch gutgeschrieben. Wegwerfen waere schlechter (eine // echte Zahlung ginge unbemerkt verloren), automatisch buchen ebenfalls // (es soll ja gerade nichts ueber PayPal laufen). Die Zuordnungsseite // bleibt fuer offene Zahlungen erreichbar, dort entscheidet der Mandant. require_once __DIR__ . '/features.php'; if (!app_feature_available($pdo, $tenantId, 'paypal_inbox')) { return ['status' => 'parked', 'payment_id' => $paymentId]; } // Eindeutiger Match -> automatisch buchen. $participant = paypal_match_participant( $pdo, $tenantId, (string) ($parsed['payer_name'] ?? ''), $parsed['note'] ?? null, $parsed['payer_email'] ?? 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']]; } /** * Vorschau ohne jeden Schreibzugriff: was wuerde mit dieser Zahlung * passieren? Gedacht fuer den Probelauf des Abrufskripts (--dry-run). Der * Probelauf soll wirklich nichts anfassen - eine Vorschau, die im * Hintergrund bucht, waere schlimmer als gar keine. * * @param array $parsed Ergebnis von paypal_parse_notification() * @return array{status:string, participant?:string, net_cents?:int} */ function paypal_preview(PDO $pdo, int $tenantId, array $parsed): 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 wie beim echten Lauf: der Transaktionscode ist global eindeutig. $stmt = $pdo->prepare('SELECT id FROM paypal_payments WHERE transaction_code = ? LIMIT 1'); $stmt->execute([$code]); if ($stmt->fetchColumn() !== false) { return ['status' => 'duplicate']; } require_once __DIR__ . '/features.php'; if (!app_feature_available($pdo, $tenantId, 'paypal_inbox')) { return ['status' => 'would_park', 'net_cents' => $netCents]; } $participant = paypal_match_participant( $pdo, $tenantId, (string) ($parsed['payer_name'] ?? ''), $parsed['note'] ?? null, $parsed['payer_email'] ?? null ); if ($participant === null) { return ['status' => 'would_queue', 'net_cents' => $netCents]; } return [ 'status' => 'would_book', 'participant' => (string) $participant['display_name'], 'net_cents' => $netCents, ]; } /** * 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, bool $previewOnly = false): 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']; } // Die eigene Eingangsadresse steht in jeder weitergeleiteten Mail und darf // niemals als Adresse des Zahlers durchgehen - sonst wuerde eine Zahlung // dem Mitglied gutgeschrieben, dem diese Adresse gehoert. Die Pruefung // haengt bewusst an der tatsaechlichen Empfaengeradresse und nicht nur an // der Konfiguration. if (paypal_same_mailbox((string) ($parsed['payer_email'] ?? ''), $recipient)) { $parsed['payer_email'] = null; } $result = $previewOnly ? paypal_preview($pdo, $tenantId, $parsed) : paypal_reconcile($pdo, $tenantId, $parsed); $result['tenant_id'] = $tenantId; return $result; } /** * Offene (noch nicht zugeordnete) PayPal-Zahlungen eines Mandanten. * * @return list */ function paypal_fetch_unmatched(PDO $pdo, int $tenantId): array { $stmt = $pdo->prepare( "SELECT id, transaction_code, payer_name, payer_email, 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} */ /** * Soll der Zahlername als PayPal-Name beim Mitglied hinterlegt werden? * Liefert den zu speichernden Namen oder null, wenn nichts zu lernen ist. * * Zurueckhaltend absichtlich: ein bereits gepflegter PayPal-Name wird nie * ueberschrieben, ein Name gleich dem Anzeigenamen bringt nichts (der wird * ohnehin geprueft), und wenn der Name im Mandanten nicht eindeutig waere, * wuerde das Merken die automatische Zuordnung sogar blockieren - dann * lieber weiter von Hand zuordnen. */ function paypal_remember_payer_name(PDO $pdo, int $tenantId, int $participantId, string $payerName): ?string { $payerName = trim($payerName); if ($payerName === '') { return null; } $stmt = $pdo->prepare('SELECT display_name, paypal_name FROM participants WHERE id = ? AND tenant_id = ?'); $stmt->execute([$participantId, $tenantId]); $participant = $stmt->fetch(); if ($participant === false) { return null; } if (trim((string) ($participant['paypal_name'] ?? '')) !== '') { return null; } if (strcasecmp(trim((string) $participant['display_name']), $payerName) === 0) { return null; } // Wuerde der Name auf ein weiteres Mitglied passen, bliebe die Zuordnung // mehrdeutig (imports_find_participant verlangt genau einen Treffer). $stmt = $pdo->prepare( 'SELECT COUNT(*) FROM participants WHERE tenant_id = ? AND id <> ? AND (LOWER(paypal_name) = LOWER(?) OR LOWER(display_name) = LOWER(?))' ); $stmt->execute([$tenantId, $participantId, $payerName, $payerName]); if ((int) $stmt->fetchColumn() > 0) { return null; } return $payerName; } 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.']; } // Aus der Handarbeit lernen: heisst jemand bei PayPal anders als in der // Kaffeeliste, greift die automatische Zuordnung nie - denn sie verlangt // exakte Uebereinstimmung. Nach der ersten manuellen Zuordnung wird der // Zahlername deshalb beim Mitglied hinterlegt, damit die naechste Zahlung // von allein ankommt. $gemerkterName = paypal_remember_payer_name($pdo, $tenantId, $participantId, (string) $payment['payer_name']); 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]); if ($gemerkterName !== null) { $pdo->prepare('UPDATE participants SET paypal_name = ? WHERE id = ? AND tenant_id = ?') ->execute([$gemerkterName, $participantId, $tenantId]); } $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'], 'learned_paypal_name' => $gemerkterName, ]); return ['ok' => true, 'learned_paypal_name' => $gemerkterName]; } /** * 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.']; } /** * Anzahl der offenen Zahlungen in der Warteschlange. Bewusst als eigene * Zaehl-Abfrage neben paypal_fetch_unmatched(): die Navigation braucht nur * die Information "gibt es noch etwas zu tun", nicht die Datensaetze. */ function paypal_count_unmatched(PDO $pdo, int $tenantId): int { static $cache = []; if (array_key_exists($tenantId, $cache)) { return $cache[$tenantId]; } try { $stmt = $pdo->prepare( "SELECT COUNT(*) FROM paypal_payments WHERE tenant_id = ? AND status = 'unmatched'" ); $stmt->execute([$tenantId]); $cache[$tenantId] = (int)$stmt->fetchColumn(); } catch (Throwable $e) { // Fehlt die Tabelle, gibt es auch keine Warteschlange - die // Navigation darf daran nicht scheitern. $cache[$tenantId] = 0; } return $cache[$tenantId]; } /** * Darf die Zuordnungsseite geoeffnet werden? Die Funktion kennt drei Faelle: * der Betreiber hat gesperrt (nein), der Mandant bietet PayPal an (ja) - und * dazwischen der Fall, dass der Mandant PayPal gerade abgeschaltet hat, * aber noch Zahlungen unzugeordnet in der Warteschlange liegen. Die waeren * sonst weder ueber das Menue noch ueber die URL erreichbar; die Seite bleibt * deshalb offen, bis die Warteschlange leer ist. */ function paypal_inbox_accessible(PDO $pdo, int $tenantId): bool { require_once __DIR__ . '/features.php'; if (!app_feature_enabled($pdo, $tenantId, 'paypal_inbox')) { return false; } if (app_feature_available($pdo, $tenantId, 'paypal_inbox')) { return true; } return paypal_count_unmatched($pdo, $tenantId) > 0; }