diff --git a/app/paypal-inbox.php b/app/paypal-inbox.php index 6f3c989..a649805 100644 --- a/app/paypal-inbox.php +++ b/app/paypal-inbox.php @@ -124,14 +124,23 @@ function paypal_booking_note(array $payment): string } /** - * 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. + * 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): ?array +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; @@ -143,6 +152,64 @@ function paypal_match_participant(PDO $pdo, int $tenantId, string $payerName, ?s 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). @@ -168,13 +235,14 @@ function paypal_reconcile(PDO $pdo, int $tenantId, array $parsed, ?int $actorUse // 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 (?, ?, ?, ?, ?, ?, ?, ?, ?)' + (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, @@ -199,7 +267,13 @@ function paypal_reconcile(PDO $pdo, int $tenantId, array $parsed, ?int $actorUse } // Eindeutiger Match -> automatisch buchen. - $participant = paypal_match_participant($pdo, $tenantId, (string) ($parsed['payer_name'] ?? ''), $parsed['note'] ?? null); + $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]; } @@ -263,7 +337,13 @@ function paypal_preview(PDO $pdo, int $tenantId, array $parsed): array return ['status' => 'would_park', 'net_cents' => $netCents]; } - $participant = paypal_match_participant($pdo, $tenantId, (string) ($parsed['payer_name'] ?? ''), $parsed['note'] ?? null); + $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]; } @@ -302,6 +382,15 @@ function paypal_process_raw(PDO $pdo, string $recipient, string $fromHeader, str 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); @@ -318,7 +407,7 @@ function paypal_process_raw(PDO $pdo, string $recipient, string $fromHeader, str 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 + "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" diff --git a/app/paypal-mail-parser.php b/app/paypal-mail-parser.php index 9c607ea..2487ccc 100644 --- a/app/paypal-mail-parser.php +++ b/app/paypal-mail-parser.php @@ -103,11 +103,69 @@ function paypal_extract_labeled_amount(string $text, string $labelRegex): ?int return null; } +/** + * Mailadresse des Zahlers aus der Benachrichtigung. + * + * Bewusst eng gefasst: In einer weitergeleiteten Mail stehen mehrere + * Adressen - die von PayPal selbst, die Eingangsadresse der Kaffeeliste und + * je nach Weiterleitung auch die des urspruenglichen Empfaengers. Wuerde + * einfach die erste beste genommen, koennte eine Zahlung beim Kassenwart + * statt beim Zahler landen. Genommen wird deshalb nur eine Adresse, die + * nahe am Namen des Zahlers steht (PayPal schreibt sie dort hin), und auch + * die erst nach Abzug aller bekannten Fremdadressen. + * + * @param string $text Bereits per paypal_mail_to_text() normalisierter Text + * @param string $payer Erkannter Name des Zahlers + */ +function paypal_extract_payer_email(string $text, string $payer): ?string +{ + if (!preg_match_all('/[\w.+-]+@[\w-]+(?:\.[\w-]+)+/u', $text, $treffer, PREG_OFFSET_CAPTURE)) { + return null; + } + + // Eingangsadresse der Kaffeeliste (zahlungen+@…) und alles von + // PayPal selbst scheiden aus. + // Der Parser wird auch aus Testskripten ohne geladene Umgebung benutzt. + $inboxBase = function_exists('app_env') ? (string) (app_env('PAYPAL_INBOX_BASE') ?? '') : ''; + $inboxLokalteil = $inboxBase !== '' ? strtolower(explode('@', $inboxBase)[0]) : ''; + + $namePosition = stripos($text, $payer); + + $kandidaten = []; + foreach ($treffer[0] as [$adresse, $position]) { + $adresse = strtolower(rtrim($adresse, '.')); + $domain = substr($adresse, strpos($adresse, '@') + 1); + $lokalteil = substr($adresse, 0, strpos($adresse, '@')); + + if (preg_match('/(^|\.)paypal\.[a-z.]+$/', $domain)) { + continue; + } + if ($inboxLokalteil !== '' && str_starts_with($lokalteil, $inboxLokalteil)) { + continue; + } + + $abstand = $namePosition === false ? PHP_INT_MAX : abs($position - $namePosition); + $kandidaten[$adresse] = min($kandidaten[$adresse] ?? PHP_INT_MAX, $abstand); + } + + if ($kandidaten === []) { + return null; + } + + asort($kandidaten); + $beste = array_key_first($kandidaten); + + // Steht keine Adresse in der Naehe des Zahlernamens, ist die Zuordnung zu + // unsicher - dann lieber keine liefern als die falsche. + return $kandidaten[$beste] <= 400 ? $beste : null; +} + /** * Parst eine PayPal-Zahlungseingangs-Mail. * * @return array{ * payer_name: string, + * payer_email: ?string, * transaction_code: ?string, * gross_cents: int, * fee_cents: ?int, @@ -163,6 +221,7 @@ function paypal_parse_notification(string $body): ?array return [ 'payer_name' => $payer, + 'payer_email' => paypal_extract_payer_email($text, $payer), 'transaction_code' => $code, 'gross_cents' => $grossCents, 'fee_cents' => $feeCents, diff --git a/database/migrations/0028_paypal_payer_email.sql b/database/migrations/0028_paypal_payer_email.sql new file mode 100644 index 0000000..e190461 --- /dev/null +++ b/database/migrations/0028_paypal_payer_email.sql @@ -0,0 +1,8 @@ +-- Mailadresse des Zahlers aus der PayPal-Benachrichtigung. +-- +-- Die automatische Zuordnung ging bisher nur ueber den Namen; die Adresse +-- ist das deutlich verlaesslichere Merkmal (Mitglieder sind im Mandanten +-- ueber tenant_id + email_norm eindeutig). Leer, wenn die Mail keine +-- brauchbare Adresse enthaelt - dann bleibt es beim Namensvergleich. +ALTER TABLE paypal_payments + ADD COLUMN payer_email VARCHAR(255) NOT NULL DEFAULT '' AFTER payer_name; diff --git a/docs/backoffice.md b/docs/backoffice.md index adeb11e..16c2264 100644 --- a/docs/backoffice.md +++ b/docs/backoffice.md @@ -222,3 +222,31 @@ Name auch auf ein anderes Mitglied, wird nichts gelernt — sonst wäre die Zuordnung mehrdeutig und würde gar nicht mehr greifen. Was gelernt wurde, steht in der Erfolgsmeldung und im Audit-Log (`paypal_import.manual_assigned`, Feld `learned_paypal_name`). + +### Zuordnung über die Mailadresse + +Stand: 2026-08-21. `paypal_match_participant()` prüft in dieser Reihenfolge: + +1. **Mailadresse des Zahlers** gegen `participants.email_norm` — im + Mandanten eindeutig (Unique Key auf `tenant_id + email_norm`) und + unabhängig davon, wie jemand bei PayPal heißt. +2. Zahlername gegen `paypal_name`/`display_name` (genau ein Treffer). +3. Mitteilung des Zahlers gegen dieselben Felder. + +Die Adresse zieht `paypal_extract_payer_email()` aus der Mail und +`paypal_payments.payer_email` (Migration `0028`) hält sie fest; in der +Warteschlange steht sie unter dem Zahlernamen. + +Das Heikle daran ist nicht das Finden, sondern das Aussortieren: In einer +weitergeleiteten Mail stehen mehrere Adressen — die von PayPal, die +Eingangsadresse der Kaffeeliste, je nach Weiterleitung die des +ursprünglichen Empfängers. Würde davon die falsche genommen, bekäme das +Mitglied hinter dieser Adresse fremdes Geld gutgeschrieben. Deshalb: + +- alles auf `paypal.*` fällt raus, +- die eigene Eingangsadresse fällt raus — geprüft gegen die tatsächliche + Empfängeradresse der Mail (`paypal_same_mailbox()`, Plus-Adressierung + wird ignoriert), nicht nur gegen `PAYPAL_INBOX_BASE`, +- übrig bleibt nur eine Adresse, die **nahe am Namen des Zahlers** steht; + ist keine in Reichweite, wird keine genommen und es bleibt beim + Namensvergleich. diff --git a/paypal-zuordnung.php b/paypal-zuordnung.php index f7165de..72888a6 100644 --- a/paypal-zuordnung.php +++ b/paypal-zuordnung.php @@ -90,6 +90,7 @@ function paypal_payment_matches_query(array $payment, string $query): bool $text = imports_normalize_match_text( (string)$payment['payer_name'] . ' ' + . (string)($payment['payer_email'] ?? '') . ' ' . (string)($payment['note'] ?? '') . ' ' . (string)($payment['transaction_code'] ?? '') ); @@ -206,6 +207,9 @@ include "nav.php"; + +
+
diff --git a/scripts/check-paypal-inbox-flow.php b/scripts/check-paypal-inbox-flow.php index dbb2a8c..f774491 100644 --- a/scripts/check-paypal-inbox-flow.php +++ b/scripts/check-paypal-inbox-flow.php @@ -41,15 +41,18 @@ function paypal_flow_assert(string $label, bool $condition, array &$failures, in * ankommt: HTML mit Detail-Link (daraus kommt der Transaktionscode) und den * Labels, an denen der Parser sich orientiert. */ -function paypal_flow_mail(string $payer, string $betrag, string $code, string $mitteilung, ?string $summe = null): string +function paypal_flow_mail(string $payer, string $betrag, string $code, string $mitteilung, ?string $summe = null, ?string $payerEmail = null): string { $summeZeile = $summe !== null ? "Gebühr-0,35 € EURSumme{$summe} € EUR" : ''; + // PayPal schreibt die Adresse des Zahlers direkt neben seinen Namen. + $adresszeile = $payerEmail !== null ? "

{$payer} ({$payerEmail})

" : ''; return '' . '

Hallo,

' . "

{$payer} hat dir {$betrag} € EUR gesendet

" + . $adresszeile . "

Mitteilung von {$payer} {$mitteilung}

" . "" . $summeZeile @@ -129,6 +132,46 @@ try { paypal_flow_assert('unbekannter Zahler landet in der Warteschlange', ($ergebnisB['status'] ?? '') === 'unmatched', $failures, $passes); paypal_flow_assert('Warteschlange enthaelt die Zahlung', paypal_count_unmatched($pdo, $tenantA) === 1, $failures, $passes); + // --- Zuordnung ueber die Mailadresse: der Zahler heisst bei PayPal ganz + // anders, benutzt aber die Adresse, die beim Mitglied hinterlegt ist. + $perMailId = paypal_flow_participant($pdo, $tenantA, "Karin Kassierer {$namensSuffix}", $suffix . 'm'); + $perMailAdresse = strtolower("karin-kassierer-{$namensSuffix}") . '-' . $suffix . 'm@test.local'; + $codeM = 'TXNMAIL' . strtoupper(bin2hex(random_bytes(4))); + $mailM = paypal_flow_mail('Voellig Anderer Name', '6,00', $codeM, 'per Adresse', null, $perMailAdresse); + $ergebnisM = paypal_process_raw($pdo, $empfaengerA, 'service@paypal.de', $mailM); + paypal_flow_assert('Zahlung wird ueber die Mailadresse zugeordnet', ($ergebnisM['status'] ?? '') === 'booked', $failures, $passes); + paypal_flow_assert('Betrag landet beim Mitglied mit dieser Adresse', paypal_flow_balance($pdo, $perMailId) === 600, $failures, $passes); + + // Die Adresse wird auch gespeichert, damit in der Warteschlange sichtbar + // ist, worauf die Zuordnung beruht. + $gespeicherteAdresse = $pdo->prepare('SELECT payer_email FROM paypal_payments WHERE transaction_code = ?'); + $gespeicherteAdresse->execute([$codeM]); + paypal_flow_assert('Mailadresse des Zahlers wird gespeichert', (string) $gespeicherteAdresse->fetchColumn() === $perMailAdresse, $failures, $passes); + + // Fremde Adressen (PayPal selbst, eigene Eingangsadresse) duerfen nie als + // Adresse des Zahlers durchgehen - sonst bekaeme das Mitglied hinter der + // Eingangsadresse fremdes Geld gutgeschrieben. + putenv('PAYPAL_INBOX_BASE=zahlungen@kaffeeliste.de'); + $nurFremdadressen = paypal_parse_notification( + '

Unbekannt Zahler hat dir 1,00 € EUR gesendet

' + . '

service@paypal.de – zahlungen+abc123@kaffeeliste.de

' + . '

Details

' + ); + paypal_flow_assert('PayPal- und Eingangsadresse werden nicht als Zahleradresse genommen', ($nurFremdadressen['payer_email'] ?? null) === null, $failures, $passes); + putenv('PAYPAL_INBOX_BASE'); + + // Auch ohne gesetzte Basisadresse: die tatsaechliche Empfaengeradresse der + // Mail wird verworfen. + $codeSelf = 'TXNSELF' . strtoupper(bin2hex(random_bytes(4))); + $mailSelf = paypal_flow_mail('Noch Ein Zahler', '1,00', $codeSelf, 'eigene Adresse', null, $empfaengerA); + $ergebnisSelf = paypal_process_raw($pdo, $empfaengerA, 'service@paypal.de', $mailSelf); + paypal_flow_assert('eigene Empfaengeradresse wird nicht zur Zahleradresse', ($ergebnisSelf['status'] ?? '') === 'unmatched', $failures, $passes); + $selfAdresse = $pdo->prepare('SELECT payer_email FROM paypal_payments WHERE transaction_code = ?'); + $selfAdresse->execute([$codeSelf]); + paypal_flow_assert('eigene Empfaengeradresse wird nicht gespeichert', (string) $selfAdresse->fetchColumn() === '', $failures, $passes); + paypal_flow_assert('Plus-Adressierung wird beim Vergleich ignoriert', paypal_same_mailbox('zahlungen@kaffeeliste.de', 'zahlungen+ab12cd@kaffeeliste.de'), $failures, $passes); + paypal_flow_assert('fremde Adresse gilt nicht als eigenes Postfach', !paypal_same_mailbox('erika@example.com', 'zahlungen+ab12cd@kaffeeliste.de'), $failures, $passes); + // --- Der PayPal-Name wird aus der manuellen Zuordnung gelernt: beim // naechsten Mal soll dieselbe Person automatisch getroffen werden. $abweichend = paypal_flow_participant($pdo, $tenantA, "Bernd Beispiel {$namensSuffix}", $suffix . 'x');
Erhaltener Betrag{$betrag} € EUR