Files
kaffeekasse-saas/scripts/check-paypal-inbox-flow.php
T

313 lines
18 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
/**
* Regressionstest fuer die Verarbeitung eingehender PayPal-Mails - alles
* ausser der IMAP-Verbindung selbst: Mandant per Plus-Token der
* Empfangsadresse, Parser, Zuordnung (Mailadresse, Name, Mitteilung),
* Buchung, Dedup, das Parken bei abgeschalteter PayPal-Funktion und die
* schreibfreie Vorschau (--dry-run).
*
* Der IMAP-Teil laesst sich nur gegen ein echtes Postfach pruefen (siehe
* docs/deployment.md, Abschnitt PayPal-Postfach).
*/
if (PHP_SAPI !== 'cli') {
http_response_code(403);
exit("Dieses Skript ist nur fuer die Kommandozeile gedacht.\n");
}
require __DIR__ . '/dev-db.php';
require_once __DIR__ . '/../app/paypal-inbox.php';
$failures = [];
$passes = 0;
function paypal_flow_assert(string $label, bool $condition, array &$failures, int &$passes): void
{
if ($condition) {
$passes++;
echo "PASS {$label}\n";
return;
}
$failures[] = $label;
echo "FAIL {$label}\n";
}
/**
* Baut eine PayPal-Zahlungseingangs-Mail nach, wie sie weitergeleitet
* 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 $payerEmail = null): string
{
$summeZeile = $summe !== null
? "<tr><td>Gebühr</td><td>-0,35&nbsp;&euro; EUR</td></tr><tr><td>Summe</td><td>{$summe}&nbsp;&euro; EUR</td></tr>"
: '';
// PayPal schreibt die Adresse des Zahlers direkt neben seinen Namen.
$adresszeile = $payerEmail !== null ? "<p>{$payer} ({$payerEmail})</p>" : '';
return '<html><body>'
. '<p>Hallo,</p>'
. "<p>{$payer} hat dir {$betrag}&nbsp;&euro; EUR gesendet</p>"
. $adresszeile
. "<p>Mitteilung von {$payer} {$mitteilung}</p>"
. "<table><tr><td>Erhaltener Betrag</td><td>{$betrag}&nbsp;&euro; EUR</td></tr>"
. $summeZeile
. '<tr><td>Transaktionsdatum</td><td>21. August 2026</td></tr></table>'
. '<p><a href="https://www.paypal.com/activities/details/' . $code . '">Details ansehen</a></p>'
. '<p>Transaktionscode ' . $code . '</p>'
. '</body></html>';
}
$pdo = dev_pdo();
$suffix = bin2hex(random_bytes(4));
// Fuer Personennamen: der Parser erkennt nur Buchstaben, Punkt, Apostroph und
// Bindestrich als Namensbestandteile - ein Testname mit Ziffern wuerde die
// Mail faelschlich als "keine Zahlung" durchfallen lassen.
$namensSuffix = strtr($suffix, '0123456789', 'abcdefghij');
$tenantIds = [];
function paypal_flow_tenant(PDO $pdo, string $suffix, string $key, array &$tenantIds): int
{
$pdo->prepare('INSERT INTO tenants (slug, name, status) VALUES (?, ?, ?)')
->execute(["ppflow-{$key}-{$suffix}", "PayPal Flow {$key}", 'active']);
$tenantId = (int) $pdo->lastInsertId();
$tenantIds[] = $tenantId;
$pdo->prepare('INSERT INTO tenant_settings (tenant_id, paypal_enabled) VALUES (?, 1)')->execute([$tenantId]);
return $tenantId;
}
function paypal_flow_participant(PDO $pdo, int $tenantId, string $name, string $suffix): int
{
$mail = strtolower(str_replace(' ', '-', $name)) . "-{$suffix}@test.local";
$pdo->prepare('INSERT INTO participants (tenant_id, display_name, email, email_norm, active) VALUES (?, ?, ?, ?, 1)')
->execute([$tenantId, $name, $mail, $mail]);
return (int) $pdo->lastInsertId();
}
function paypal_flow_balance(PDO $pdo, int $participantId): int
{
$stmt = $pdo->prepare('SELECT COALESCE(SUM(amount_cents), 0) FROM ledger_entries WHERE participant_id = ?');
$stmt->execute([$participantId]);
return (int) $stmt->fetchColumn();
}
try {
// --- Fall 1: alles freigeschaltet, Name eindeutig -> automatisch buchen
$tenantA = paypal_flow_tenant($pdo, $suffix, 'a', $tenantIds);
$participantA = paypal_flow_participant($pdo, $tenantA, "Erika Musterfrau {$namensSuffix}", $suffix);
$tokenA = paypal_inbox_ensure_token($pdo, $tenantA);
$empfaengerA = "zahlungen+{$tokenA}@kaffeeliste.de";
$codeA = 'TXNAUTO' . strtoupper(bin2hex(random_bytes(4)));
$mailA = paypal_flow_mail("Erika Musterfrau {$namensSuffix}", '12,50', $codeA, 'Kaffeekasse', '12,15');
// Outlook/Hotmail liefert weitergeleitete HTML-Mails mitunter als
// Windows-1252, obwohl die umgebende Mail UTF-8 behauptet. Der Parser muss
// insbesondere Umlaute und das dort einbyteige Eurozeichen konvertieren,
// bevor seine Unicode-Regulaerausdruecke laufen.
$codeWindows = 'TXNWIN' . strtoupper(bin2hex(random_bytes(4)));
$mailWindows = str_replace(
'&euro;',
'€',
paypal_flow_mail('Jürgen Müller', '36,54', $codeWindows, 'Vielen Dank', null)
);
$mailWindows = (string) iconv('UTF-8', 'Windows-1252//TRANSLIT', $mailWindows);
$parsedWindows = paypal_parse_notification($mailWindows);
paypal_flow_assert('Windows-1252-Mail wird erkannt', $parsedWindows !== null, $failures, $passes);
paypal_flow_assert(
'Windows-1252-Mail liefert Name, Betrag und Transaktionscode',
($parsedWindows['payer_name'] ?? '') === 'Jürgen Müller'
&& ($parsedWindows['net_cents'] ?? 0) === 3654
&& ($parsedWindows['transaction_code'] ?? '') === $codeWindows,
$failures,
$passes
);
// Erst die Vorschau: sie darf nichts anlegen.
$vorschau = paypal_process_raw($pdo, $empfaengerA, $mailA, true);
paypal_flow_assert('Vorschau meldet die geplante Buchung', ($vorschau['status'] ?? '') === 'would_book', $failures, $passes);
paypal_flow_assert('Vorschau nennt das Mitglied', str_contains((string) ($vorschau['participant'] ?? ''), 'Erika'), $failures, $passes);
$zahlungenNachVorschau = (int) $pdo->query('SELECT COUNT(*) FROM paypal_payments')->fetchColumn();
$ergebnisA = paypal_process_raw($pdo, $empfaengerA, $mailA);
paypal_flow_assert('eindeutiger Name wird automatisch gebucht', ($ergebnisA['status'] ?? '') === 'booked', $failures, $passes);
paypal_flow_assert('Vorschau hat nichts gespeichert', $zahlungenNachVorschau + 1 === (int) $pdo->query('SELECT COUNT(*) FROM paypal_payments')->fetchColumn(), $failures, $passes);
// Gebucht wird die "Summe" (nach Gebuehr), nicht der Sendebetrag.
paypal_flow_assert('gebucht wird der Netto-Betrag (12,15 EUR)', paypal_flow_balance($pdo, $participantA) === 1215, $failures, $passes);
// --- Dedup: dieselbe Mail ein zweites Mal
$ergebnisDoppelt = paypal_process_raw($pdo, $empfaengerA, $mailA);
paypal_flow_assert('dieselbe Transaktion wird nicht doppelt gebucht', ($ergebnisDoppelt['status'] ?? '') === 'duplicate', $failures, $passes);
paypal_flow_assert('Guthaben bleibt nach der Dublette unveraendert', paypal_flow_balance($pdo, $participantA) === 1215, $failures, $passes);
// --- Fall 2: unbekannter Zahler -> Warteschlange
$codeB = 'TXNQUEUE' . strtoupper(bin2hex(random_bytes(4)));
$mailB = paypal_flow_mail('Unbekannter Zahler', '5,00', $codeB, 'ohne Zuordnung');
$ergebnisB = paypal_process_raw($pdo, $empfaengerA, $mailB);
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, $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(
'<html><body><p>Unbekannt Zahler hat dir 1,00&nbsp;&euro; EUR gesendet</p>'
. '<p>service@paypal.de zahlungen+abc123@kaffeeliste.de</p>'
. '<p><a href="https://www.paypal.com/activities/details/TXNONLYFOREIGN01">Details</a></p></body></html>'
);
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, $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');
$codeE = 'TXNLERN' . strtoupper(bin2hex(random_bytes(4)));
$zahlername = "Bernd B. Beispiel-Handel {$namensSuffix}";
$mailE = paypal_flow_mail($zahlername, '4,00', $codeE, 'erste Zahlung');
$ergebnisE = paypal_process_raw($pdo, $empfaengerA, $mailE);
paypal_flow_assert('abweichender Zahlername landet zunaechst in der Warteschlange', ($ergebnisE['status'] ?? '') === 'unmatched', $failures, $passes);
$zuordnung = paypal_assign_payment($pdo, $tenantA, (int) $ergebnisE['payment_id'], $abweichend, null);
paypal_flow_assert('manuelle Zuordnung bucht die Zahlung', ($zuordnung['ok'] ?? false) === true, $failures, $passes);
paypal_flow_assert('manuelle Zuordnung merkt sich den PayPal-Namen', ($zuordnung['learned_paypal_name'] ?? null) === $zahlername, $failures, $passes);
$codeF = 'TXNAUTO2' . strtoupper(bin2hex(random_bytes(4)));
$mailF = paypal_flow_mail($zahlername, '4,00', $codeF, 'zweite Zahlung');
$ergebnisF = paypal_process_raw($pdo, $empfaengerA, $mailF);
paypal_flow_assert('zweite Zahlung desselben Zahlers wird automatisch gebucht', ($ergebnisF['status'] ?? '') === 'booked', $failures, $passes);
paypal_flow_assert('beide Zahlungen sind gutgeschrieben', paypal_flow_balance($pdo, $abweichend) === 800, $failures, $passes);
// Ein bereits gepflegter PayPal-Name wird nicht ueberschrieben.
$codeG = 'TXNKEEP' . strtoupper(bin2hex(random_bytes(4)));
$mailG = paypal_flow_mail('Ganz Anderer Name', '2,00', $codeG, 'fremder Name');
$ergebnisG = paypal_process_raw($pdo, $empfaengerA, $mailG);
$zuordnungG = paypal_assign_payment($pdo, $tenantA, (int) $ergebnisG['payment_id'], $abweichend, null);
paypal_flow_assert('gepflegter PayPal-Name bleibt unveraendert', ($zuordnungG['learned_paypal_name'] ?? null) === null, $failures, $passes);
$gespeichert = $pdo->prepare('SELECT paypal_name FROM participants WHERE id = ?');
$gespeichert->execute([$abweichend]);
paypal_flow_assert('gespeichert ist weiterhin der erste Zahlername', (string) $gespeichert->fetchColumn() === $zahlername, $failures, $passes);
// --- Fall 3: Mandant hat PayPal abgeschaltet -> parken, nicht buchen
$tenantB = paypal_flow_tenant($pdo, $suffix, 'b', $tenantIds);
$participantB = paypal_flow_participant($pdo, $tenantB, "Max Mustermann {$namensSuffix}", $suffix);
$tokenB = paypal_inbox_ensure_token($pdo, $tenantB);
$empfaengerB = "zahlungen+{$tokenB}@kaffeeliste.de";
$pdo->prepare('UPDATE tenant_settings SET paypal_enabled = 0 WHERE tenant_id = ?')->execute([$tenantB]);
$codeC = 'TXNPARK' . strtoupper(bin2hex(random_bytes(4)));
$mailC = paypal_flow_mail("Max Mustermann {$namensSuffix}", '7,00', $codeC, 'trotzdem gezahlt');
$vorschauC = paypal_process_raw($pdo, $empfaengerB, $mailC, true);
paypal_flow_assert('Vorschau meldet das Parken', ($vorschauC['status'] ?? '') === 'would_park', $failures, $passes);
$ergebnisC = paypal_process_raw($pdo, $empfaengerB, $mailC);
paypal_flow_assert('abgeschaltetes PayPal parkt die Zahlung', ($ergebnisC['status'] ?? '') === 'parked', $failures, $passes);
paypal_flow_assert('geparkte Zahlung wird nicht gebucht', paypal_flow_balance($pdo, $participantB) === 0, $failures, $passes);
paypal_flow_assert('geparkte Zahlung steht in der Warteschlange', paypal_count_unmatched($pdo, $tenantB) === 1, $failures, $passes);
// --- Fall 4: Betreiber hat die Funktion gesperrt -> ebenfalls parken
$tenantC = paypal_flow_tenant($pdo, $suffix, 'c', $tenantIds);
paypal_flow_participant($pdo, $tenantC, "Lisa Beispiel {$namensSuffix}", $suffix);
$tokenC = paypal_inbox_ensure_token($pdo, $tenantC);
$pdo->prepare('INSERT INTO tenant_features (tenant_id, feature_key, enabled) VALUES (?, ?, 0)')
->execute([$tenantC, 'paypal_inbox']);
$codeD = 'TXNLOCK' . strtoupper(bin2hex(random_bytes(4)));
$mailD = paypal_flow_mail("Lisa Beispiel {$namensSuffix}", '3,00', $codeD, 'gesperrt');
$ergebnisD = paypal_process_raw($pdo, "zahlungen+{$tokenC}@kaffeeliste.de", $mailD);
paypal_flow_assert('Betreiber-Sperre parkt die Zahlung ebenfalls', ($ergebnisD['status'] ?? '') === 'parked', $failures, $passes);
// --- Fall 5: alles, was gar nicht erst verarbeitet werden darf
// Der Absender wird bewusst nicht geprueft: eine von Hand weitergeleitete
// Mail traegt die Adresse des Weiterleitenden, soll aber ankommen.
$codeW = 'TXNFWD' . strtoupper(bin2hex(random_bytes(4)));
$mailW = paypal_flow_mail("Erika Musterfrau {$namensSuffix}", '3,50', $codeW, 'weitergeleitet');
$weitergeleitet = paypal_process_raw($pdo, $empfaengerA, $mailW);
paypal_flow_assert('weitergeleitete Mail wird unabhaengig vom Absender verarbeitet', ($weitergeleitet['status'] ?? '') === 'booked', $failures, $passes);
// Die Absendererkennung dient nur noch der Diagnose-Anzeige.
foreach ([
'"PayPal" <service@paypal.de>' => true,
'service@paypal.com' => true,
'PayPal <service@e.paypal.de>' => true,
'noreply@paypal.at' => true,
'service@paypal.co.uk' => true,
'betrug@paypal.de.beispiel.com' => false,
'paypal@beispiel.de' => false,
'clemens@beispiel.de' => false,
'' => false,
] as $absender => $erwartet) {
paypal_flow_assert(
'Absender "' . ($absender === '' ? '(leer)' : $absender) . '" gilt ' . ($erwartet ? 'als' : 'nicht als') . ' PayPal',
paypal_from_is_paypal($absender) === $erwartet,
$failures,
$passes
);
}
$ohneToken = paypal_process_raw($pdo, 'zahlungen@kaffeeliste.de', $mailA);
paypal_flow_assert('Adresse ohne Token wird abgelehnt', ($ohneToken['status'] ?? '') === 'no_token', $failures, $passes);
$falscherToken = paypal_process_raw($pdo, 'zahlungen+gibtesnicht@kaffeeliste.de', $mailA);
paypal_flow_assert('unbekannter Token wird abgelehnt', ($falscherToken['status'] ?? '') === 'unknown_tenant', $failures, $passes);
$keineZahlung = paypal_process_raw($pdo, $empfaengerA, '<html><body>Newsletter von PayPal</body></html>');
paypal_flow_assert('Werbemail wird nicht als Zahlung erkannt', ($keineZahlung['status'] ?? '') === 'not_a_payment', $failures, $passes);
} finally {
foreach ($tenantIds as $tenantId) {
$pdo->prepare('DELETE FROM ledger_entries WHERE tenant_id = ?')->execute([$tenantId]);
$pdo->prepare('DELETE FROM paypal_payments WHERE tenant_id = ?')->execute([$tenantId]);
$pdo->prepare('DELETE FROM participants WHERE tenant_id = ?')->execute([$tenantId]);
$pdo->prepare('DELETE FROM tenant_features WHERE tenant_id = ?')->execute([$tenantId]);
$pdo->prepare('DELETE FROM tenant_settings WHERE tenant_id = ?')->execute([$tenantId]);
$pdo->prepare('DELETE FROM audit_log WHERE tenant_id = ?')->execute([$tenantId]);
$pdo->prepare('DELETE FROM tenants WHERE id = ?')->execute([$tenantId]);
}
}
if ($failures !== []) {
echo "\nPayPal inbox flow check failed with " . count($failures) . " failure(s):\n";
foreach ($failures as $failure) {
echo "- {$failure}\n";
}
exit(1);
}
echo "\nPayPal inbox flow check passed with {$passes} assertions.\n";