243 lines
8.7 KiB
PHP
243 lines
8.7 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
/**
|
|
* Parser fuer PayPal-Zahlungseingangs-Mails ("Du hast eine Zahlung erhalten").
|
|
*
|
|
* Bewusst tolerant gegenueber dem konkreten Waehrungssymbol (in weitergeleiteten
|
|
* Mails oft falsch kodiert) und der HTML-Struktur: der Text wird zunaechst von
|
|
* Tags befreit, Entities/NBSP normalisiert, dann ueber robuste Marker
|
|
* ("… hat dir … EUR gesendet", "Transaktionscode", "Erhaltener Betrag",
|
|
* "Gebuehr", "Summe", "Transaktionsdatum") ausgewertet. Der Transaktionscode
|
|
* wird primaer aus der Detail-URL /activities/details/<CODE> gezogen, weil der
|
|
* dort am zuverlaessigsten steht.
|
|
*/
|
|
|
|
/**
|
|
* Wandelt den (ggf. UTF-16- oder Windows-1252-kodierten) Mail-Body in
|
|
* normalisierten UTF-8-Text um: Skript/Style raus, Tags durch Leerzeichen
|
|
* ersetzt, Entities dekodiert, NBSP und Mehrfach-Whitespace zusammengefasst.
|
|
*/
|
|
function paypal_mail_to_text(string $body): string
|
|
{
|
|
// UTF-16-BOM erkennen und nach UTF-8 wandeln (weitergeleitete .htm-Exporte).
|
|
if (str_starts_with($body, "\xFF\xFE") || str_starts_with($body, "\xFE\xFF")) {
|
|
$converted = @iconv('UTF-16', 'UTF-8//IGNORE', $body);
|
|
if ($converted !== false) {
|
|
$body = $converted;
|
|
}
|
|
} elseif (preg_match('//u', $body) !== 1) {
|
|
// Outlook/Hotmail kennzeichnet weitergeleitete Nachrichten teils als
|
|
// UTF-8, liefert den Body aber tatsaechlich in Windows-1252. Dann
|
|
// schlagen alle folgenden Unicode-Regulaerausdruecke still fehl. Das
|
|
// Eurozeichen (0x80) ist zugleich ein typisches Erkennungsmerkmal fuer
|
|
// Windows-1252 statt ISO-8859-1.
|
|
$converted = @iconv('Windows-1252', 'UTF-8//IGNORE', $body);
|
|
if ($converted !== false) {
|
|
$body = $converted;
|
|
}
|
|
}
|
|
|
|
$body = preg_replace('#<(script|style)[^>]*>.*?</\1>#is', ' ', $body) ?? $body;
|
|
$text = preg_replace('#<[^>]+>#', ' ', $body) ?? $body;
|
|
$text = html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
|
$text = str_replace(["\xC2\xA0", "\xEF\xBB\xBF"], ' ', $text); // NBSP, BOM
|
|
$text = preg_replace('/\s+/u', ' ', $text) ?? $text;
|
|
|
|
return trim($text);
|
|
}
|
|
|
|
/**
|
|
* Deutscher Betrag ("1.234,56") -> Cent. Null bei ungueltigem Format.
|
|
*/
|
|
function paypal_amount_to_cents(string $raw): ?int
|
|
{
|
|
$raw = trim($raw);
|
|
$raw = str_replace('.', '', $raw); // Tausenderpunkte
|
|
$raw = str_replace(',', '.', $raw);
|
|
if (!preg_match('/^\d+(\.\d{1,2})?$/', $raw)) {
|
|
return null;
|
|
}
|
|
|
|
return (int) round(((float) $raw) * 100);
|
|
}
|
|
|
|
/**
|
|
* Deutsches Datum ("7. Juli 2026") -> "Y-m-d". Null, wenn nicht erkennbar.
|
|
*/
|
|
function paypal_german_date_to_iso(string $raw): ?string
|
|
{
|
|
$months = [
|
|
'januar' => 1, 'februar' => 2, 'märz' => 3, 'maerz' => 3, 'april' => 4,
|
|
'mai' => 5, 'juni' => 6, 'juli' => 7, 'august' => 8, 'september' => 9,
|
|
'oktober' => 10, 'november' => 11, 'dezember' => 12,
|
|
];
|
|
if (!preg_match('/(\d{1,2})\.\s*([\p{L}]+)\s+(\d{4})/u', $raw, $m)) {
|
|
return null;
|
|
}
|
|
$day = (int) $m[1];
|
|
$monthName = mb_strtolower_compat($m[2]);
|
|
$year = (int) $m[3];
|
|
if (!isset($months[$monthName])) {
|
|
return null;
|
|
}
|
|
|
|
return sprintf('%04d-%02d-%02d', $year, $months[$monthName], $day);
|
|
}
|
|
|
|
/**
|
|
* Kleinschreibung ohne mbstring-Abhaengigkeit (nur fuer deutsche Monatsnamen
|
|
* inkl. Umlaut noetig).
|
|
*/
|
|
function mb_strtolower_compat(string $s): string
|
|
{
|
|
if (function_exists('mb_strtolower')) {
|
|
return mb_strtolower($s, 'UTF-8');
|
|
}
|
|
$s = strtr($s, ['Ä' => 'ä', 'Ö' => 'ö', 'Ü' => 'ü']);
|
|
|
|
return strtolower($s);
|
|
}
|
|
|
|
/**
|
|
* Betrag in Cent aus dem Text nach einem Label ("Erhaltener Betrag" etc.).
|
|
* Waehrungssymbol zwischen Zahl und "EUR" wird ignoriert.
|
|
*/
|
|
function paypal_extract_labeled_amount(string $text, string $labelRegex): ?int
|
|
{
|
|
if (preg_match('/' . $labelRegex . '\s*([\d.]+,\d{2})\s*\S*\s*EUR/u', $text, $m)) {
|
|
return paypal_amount_to_cents($m[1]);
|
|
}
|
|
|
|
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,
|
|
* net_cents: int,
|
|
* date: ?string,
|
|
* note: ?string
|
|
* }|null Null, wenn es keine erkennbare Zahlungseingangs-Mail ist.
|
|
*/
|
|
function paypal_parse_notification(string $body): ?array
|
|
{
|
|
$text = paypal_mail_to_text($body);
|
|
|
|
// Kernmarker: "<Name> hat dir <Betrag> … EUR gesendet". Fehlt er, ist es
|
|
// keine Zahlungseingangs-Mail (z. B. Werbung, Sicherheitsmail).
|
|
if (!preg_match('/([\p{L}\p{M}.\'\- ]{2,60}?)\s+hat dir\s+([\d.]+,\d{2})\s*\S*\s*EUR\s+gesendet/u', $text, $m)) {
|
|
return null;
|
|
}
|
|
$payer = trim($m[1]);
|
|
$grossCents = paypal_amount_to_cents($m[2]) ?? 0;
|
|
|
|
// Transaktionscode primaer aus der Detail-URL (steht dort am zuverlaessigsten),
|
|
// sonst nach dem Label.
|
|
$code = null;
|
|
if (preg_match('#/activities/details/([A-Z0-9]{12,25})#', $body, $mc)) {
|
|
$code = $mc[1];
|
|
} elseif (preg_match('/Transaktionscode\s+([A-Z0-9]{12,25})/u', $text, $mc)) {
|
|
$code = $mc[1];
|
|
}
|
|
|
|
$receivedCents = paypal_extract_labeled_amount($text, 'Erhaltener Betrag');
|
|
$feeCents = paypal_extract_labeled_amount($text, 'Geb(?:ü|ue)hr');
|
|
$summeCents = paypal_extract_labeled_amount($text, 'Summe');
|
|
|
|
// Netto = tatsaechlich angekommener Betrag: bei Waren & Dienstleistungen die
|
|
// "Summe" (nach Abzug der Gebuehr), bei Freunde & Familie der erhaltene
|
|
// Betrag (keine Gebuehr, keine Summe-Zeile), im Zweifel der Sende-Betrag.
|
|
$netCents = $summeCents ?? $receivedCents ?? $grossCents;
|
|
|
|
$date = null;
|
|
if (preg_match('/Transaktionsdatum\s+(\d{1,2}\.\s*[\p{L}]+\s+\d{4})/u', $text, $md)) {
|
|
$date = paypal_german_date_to_iso($md[1]);
|
|
}
|
|
|
|
// Mitteilung des Zahlers ("Mitteilung von <Name> <Text>") bis zum naechsten
|
|
// bekannten Abschnitt. Der Zahlername dient als Anker, da er Leerzeichen
|
|
// enthalten kann.
|
|
$note = null;
|
|
$noteRegex = '/Mitteilung von\s+' . preg_quote($payer, '/')
|
|
. '\s+(.+?)\s*(?:Geb(?:ü|ue)hr|Summe|Erhaltener Betrag|Transaktions|Lieferadresse|Du siehst|Bist du|Copyright|$)/u';
|
|
if (preg_match($noteRegex, $text, $mn)) {
|
|
$note = trim($mn[1]);
|
|
}
|
|
|
|
return [
|
|
'payer_name' => $payer,
|
|
'payer_email' => paypal_extract_payer_email($text, $payer),
|
|
'transaction_code' => $code,
|
|
'gross_cents' => $grossCents,
|
|
'fee_cents' => $feeCents,
|
|
'net_cents' => $netCents,
|
|
'date' => $date,
|
|
'note' => $note,
|
|
];
|
|
}
|