195 lines
6.8 KiB
PHP
195 lines
6.8 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
/**
|
|
* Holt PayPal-Zahlungseingangs-Mails aus dem zentralen IMAP-Postfach ab und
|
|
* verbucht sie automatisch (fuer Cron gedacht, z. B. alle paar Minuten).
|
|
*
|
|
* Ablauf pro ungelesener Mail:
|
|
* 1. Mandant ueber den Plus-Token der Empfaengeradresse bestimmen
|
|
* (zahlungen+<token>@…). Der Absender wird bewusst nicht geprueft -
|
|
* Zahlungsmails kommen auch ueber manuelle Weiterleitungen an.
|
|
* 2. Body parsen, Netto ermitteln, per Transaktionscode deduplizieren.
|
|
* 3. Eindeutiger Namens-Match -> automatisch als Einzahlung buchen,
|
|
* sonst in die Warteschlange legen. Ist PayPal fuer den Mandanten
|
|
* abgeschaltet, wird die Zahlung nur geparkt (gespeichert, nicht
|
|
* gebucht).
|
|
*
|
|
* Mit --dry-run laeuft alles bis zur Vorschau: Verbindung, Zuordnung und
|
|
* Ergebnis werden gemeldet, aber nichts gespeichert, gebucht oder als
|
|
* gelesen markiert. Fuer den ersten Testlauf gedacht.
|
|
*
|
|
* Konfiguration ausschliesslich ueber Server-/Env-Einstellungen (Betreiber):
|
|
* PAYPAL_INBOX_BASE z. B. zahlungen@kaffeeliste.de
|
|
* PAYPAL_IMAP_HOST Mailserver-Host
|
|
* PAYPAL_IMAP_PORT (Standard 993)
|
|
* PAYPAL_IMAP_FLAGS (Standard "/imap/ssl")
|
|
* PAYPAL_IMAP_USER Postfach-Benutzer
|
|
* PAYPAL_IMAP_PASS Postfach-Passwort
|
|
* PAYPAL_IMAP_MAILBOX (Standard "INBOX")
|
|
*
|
|
* Testmodus ohne IMAP (eine Rohmail aus einer Datei verarbeiten):
|
|
* php scripts/fetch-paypal-payments.php --file=mail.html \
|
|
* --recipient=zahlungen+abc123@kaffeeliste.de
|
|
*/
|
|
|
|
if (PHP_SAPI !== 'cli') {
|
|
http_response_code(403);
|
|
exit("Nur fuer die Kommandozeile.\n");
|
|
}
|
|
|
|
require_once __DIR__ . '/../app/paypal-inbox.php';
|
|
|
|
$pdo = app_db_pdo();
|
|
$opts = getopt('', ['file:', 'recipient:', 'dry-run']);
|
|
|
|
// ---- Testmodus: eine Datei verarbeiten (ohne IMAP) --------------------------
|
|
if (isset($opts['file'])) {
|
|
$body = @file_get_contents($opts['file']);
|
|
if ($body === false) {
|
|
fwrite(STDERR, "Datei nicht lesbar: {$opts['file']}\n");
|
|
exit(1);
|
|
}
|
|
$recipient = (string) ($opts['recipient'] ?? '');
|
|
$result = paypal_process_raw($pdo, $recipient, $body, isset($opts['dry-run']));
|
|
echo 'Ergebnis: ' . json_encode($result, JSON_UNESCAPED_UNICODE) . "\n";
|
|
exit(0);
|
|
}
|
|
|
|
// ---- IMAP-Modus -------------------------------------------------------------
|
|
if (!function_exists('imap_open')) {
|
|
fwrite(STDERR, "Die PHP-IMAP-Erweiterung ist nicht installiert. Auf dem Server 'php-imap' aktivieren.\n");
|
|
exit(1);
|
|
}
|
|
|
|
$host = app_env('PAYPAL_IMAP_HOST');
|
|
$user = app_env('PAYPAL_IMAP_USER');
|
|
$pass = app_env('PAYPAL_IMAP_PASS');
|
|
if ($host === null || $user === null || $pass === null) {
|
|
fwrite(STDERR, "PAYPAL_IMAP_HOST/USER/PASS sind nicht gesetzt.\n");
|
|
exit(1);
|
|
}
|
|
$port = (int) (app_env('PAYPAL_IMAP_PORT', '993'));
|
|
$flags = app_env('PAYPAL_IMAP_FLAGS', '/imap/ssl');
|
|
$mailboxName = app_env('PAYPAL_IMAP_MAILBOX', 'INBOX');
|
|
$dryRun = isset($opts['dry-run']);
|
|
|
|
$ref = '{' . $host . ':' . $port . $flags . '}';
|
|
$mbox = @imap_open($ref . $mailboxName, $user, $pass);
|
|
if ($mbox === false) {
|
|
fwrite(STDERR, 'IMAP-Verbindung fehlgeschlagen: ' . imap_last_error() . "\n");
|
|
exit(1);
|
|
}
|
|
|
|
/**
|
|
* Liefert den bevorzugten Textkoerper (HTML, sonst Plain) einer Mail als
|
|
* dekodierten String.
|
|
*/
|
|
function paypal_imap_fetch_body($mbox, int $msgno): string
|
|
{
|
|
$structure = imap_fetchstructure($mbox, $msgno);
|
|
// Einfache Mail ohne Parts.
|
|
if (!isset($structure->parts) || !is_array($structure->parts)) {
|
|
$raw = imap_body($mbox, $msgno);
|
|
return paypal_imap_decode_part($raw, $structure->encoding ?? 0);
|
|
}
|
|
$htmlBody = null;
|
|
$plainBody = null;
|
|
foreach ($structure->parts as $i => $part) {
|
|
$partNo = (string) ($i + 1);
|
|
$data = imap_fetchbody($mbox, $msgno, $partNo);
|
|
$decoded = paypal_imap_decode_part($data, $part->encoding ?? 0);
|
|
$subtype = strtoupper((string) ($part->subtype ?? ''));
|
|
if ($subtype === 'HTML') {
|
|
$htmlBody = $decoded;
|
|
} elseif ($subtype === 'PLAIN') {
|
|
$plainBody = $decoded;
|
|
}
|
|
}
|
|
|
|
return $htmlBody ?? $plainBody ?? '';
|
|
}
|
|
|
|
function paypal_imap_decode_part(string $data, int $encoding): string
|
|
{
|
|
// 3 = BASE64, 4 = QUOTED-PRINTABLE (imap-Konstanten)
|
|
if ($encoding === 3) {
|
|
return (string) base64_decode($data, false);
|
|
}
|
|
if ($encoding === 4) {
|
|
return quoted_printable_decode($data);
|
|
}
|
|
|
|
return $data;
|
|
}
|
|
|
|
$messageCount = imap_num_msg($mbox);
|
|
$processed = 0;
|
|
$booked = 0;
|
|
$queued = 0;
|
|
$parked = 0;
|
|
$skipped = 0;
|
|
|
|
for ($msgno = 1; $msgno <= $messageCount; $msgno++) {
|
|
$overview = imap_fetch_overview($mbox, (string) $msgno, 0)[0] ?? null;
|
|
if ($overview === null || (int) ($overview->seen ?? 0) === 1) {
|
|
continue; // nur ungelesene
|
|
}
|
|
$processed++;
|
|
|
|
// Empfaengeradressen (inkl. Weiterleitungs-Header) nach dem Plus-Token
|
|
// durchsuchen.
|
|
$rawHeader = imap_fetchheader($mbox, $msgno);
|
|
$recipient = '';
|
|
if (preg_match('/^(?:Delivered-To|X-Original-To|To|X-Forwarded-To):\s*(.+)$/im', $rawHeader, $mm)) {
|
|
$recipient = trim($mm[1]);
|
|
}
|
|
// Falls mehrere Empfaengerzeilen: alle zusammenfuehren, damit der Token
|
|
// sicher gefunden wird.
|
|
if (preg_match_all('/^(?:Delivered-To|X-Original-To|To|X-Forwarded-To):\s*(.+)$/im', $rawHeader, $all)) {
|
|
$recipient = implode(' ', $all[1]);
|
|
}
|
|
|
|
$body = paypal_imap_fetch_body($mbox, $msgno);
|
|
// Im Probelauf wird nichts gespeichert und nichts gebucht - die Vorschau
|
|
// sagt nur, was passieren wuerde.
|
|
$result = paypal_process_raw($pdo, $recipient, $body, $dryRun);
|
|
|
|
switch ($result['status']) {
|
|
case 'booked':
|
|
case 'would_book':
|
|
$booked++;
|
|
break;
|
|
case 'unmatched':
|
|
case 'would_queue':
|
|
$queued++;
|
|
break;
|
|
case 'parked':
|
|
case 'would_park':
|
|
// PayPal ist fuer den Mandanten abgeschaltet: gespeichert, aber
|
|
// bewusst nicht gebucht.
|
|
$parked++;
|
|
break;
|
|
default:
|
|
$skipped++;
|
|
}
|
|
|
|
echo "Mail #{$msgno}: {$result['status']}"
|
|
. (isset($result['tenant_id']) ? " (Mandant {$result['tenant_id']})" : '')
|
|
. (isset($result['participant']) ? " -> {$result['participant']}" : '')
|
|
. (isset($result['net_cents']) ? ' ' . number_format($result['net_cents'] / 100, 2, ',', '.') . ' EUR' : '')
|
|
. "\n";
|
|
|
|
// Als gelesen markieren, damit sie nicht erneut verarbeitet wird.
|
|
if (!$dryRun) {
|
|
imap_setflag_full($mbox, (string) $msgno, '\\Seen');
|
|
}
|
|
}
|
|
|
|
imap_close($mbox);
|
|
|
|
echo "Fertig: {$processed} verarbeitet, {$booked} gebucht, {$queued} in Warteschlange, "
|
|
. "{$parked} geparkt (PayPal abgeschaltet), {$skipped} uebersprungen"
|
|
. ($dryRun ? ' (Probelauf: nichts gespeichert, nichts gebucht, nichts als gelesen markiert)' : '') . ".\n";
|