291 lines
17 KiB
PHP
291 lines
17 KiB
PHP
<?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 € EUR</td></tr><tr><td>Summe</td><td>{$summe} € 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} € EUR gesendet</p>"
|
||
. $adresszeile
|
||
. "<p>Mitteilung von {$payer} {$mitteilung}</p>"
|
||
. "<table><tr><td>Erhaltener Betrag</td><td>{$betrag} € 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');
|
||
|
||
// 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 € 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";
|