PayPal-Zahlungen auch ueber die Mailadresse zuordnen

Bisher verglich die Zuordnung ausschliesslich Namen: der Parser las die
Adresse des Zahlers gar nicht aus, und die Suche kannte nur paypal_name und
display_name. Eine Zahlung von genau der Adresse, die beim Mitglied
hinterlegt ist, blieb deshalb liegen.

Die Adresse wird jetzt ausgelesen (neue Spalte paypal_payments.payer_email),
in der Warteschlange angezeigt und zuerst geprueft - sie ist im Mandanten
eindeutig und aendert sich nicht, wenn jemand bei PayPal anders heisst.

Der heikle Teil ist das Aussortieren: In einer weitergeleiteten Mail stehen
mehrere Adressen. Die falsche zu nehmen wuerde fremdes Geld dem Mitglied
hinter dieser Adresse gutschreiben - typischerweise dem Kassenwart. Deshalb
fallen paypal.*-Adressen und die eigene Eingangsadresse raus (geprueft
gegen die tatsaechliche Empfaengeradresse, Plus-Adressierung ignoriert),
und es zaehlt nur eine Adresse in unmittelbarer Naehe des Zahlernamens.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-21 22:17:48 +02:00
co-authored by Claude Opus 5
parent 25bdb6f34b
commit d7de0d92cf
6 changed files with 241 additions and 10 deletions
+98 -9
View File
@@ -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"
+59
View File
@@ -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+<token>@…) 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,