Files
kaffeekasse-saas/scripts/check-paypal-inbox-flow.php
T
clemensandClaude Opus 5 9f9e4c7cd0 PayPal-Mailabruf: Parken statt Buchen, echter Probelauf, Regressionstest
Die Mail-Verarbeitung ignorierte bisher die PayPal-Schalter: eingehende
Zahlungen wurden auch dann automatisch gutgeschrieben, wenn der Betreiber
die Funktion gesperrt oder der Mandant PayPal abgeschaltet hatte. Jetzt
wird die Zahlung in dem Fall geparkt - gespeichert, aber ohne Buchung.
Wegwerfen liesse eine echte Zahlung unbemerkt verschwinden, buchen
widersprache der Abschaltung; die Zuordnungsseite bleibt fuer offene
Zahlungen ja erreichbar.

--dry-run war bisher irrefuehrend: es liess die Verarbeitung samt Buchung
laufen und uebersprang nur das Setzen des Gelesen-Flags - ausgerechnet beim
ersten Testlauf haette es also echtes Geld verbucht. Der Probelauf nutzt
jetzt paypal_preview(), das nichts schreibt und meldet, was passieren
wuerde (would_book/would_queue/would_park/duplicate).

scripts/check-paypal-inbox-flow.php deckt die Kette ohne IMAP ab:
Absenderpruefung, Token, Parser, Zuordnung, Netto-Buchung, Dedup, beide
Park-Faelle und die Schreibfreiheit der Vorschau.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 20:43:58 +02:00

194 lines
10 KiB
PHP

<?php
declare(strict_types=1);
/**
* Regressionstest fuer die Verarbeitung eingehender PayPal-Mails - alles
* ausser der IMAP-Verbindung selbst: Absenderpruefung, Mandant per
* Plus-Token, Parser, Zuordnung, 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
{
$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>"
: '';
return '<html><body>'
. '<p>Hallo,</p>'
. "<p>{$payer} hat dir {$betrag}&nbsp;&euro; EUR gesendet</p>"
. "<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');
// Erst die Vorschau: sie darf nichts anlegen.
$vorschau = paypal_process_raw($pdo, $empfaengerA, 'service@paypal.de', $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, 'service@paypal.de', $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, 'service@paypal.de', $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, 'service@paypal.de', $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);
// --- 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, 'service@paypal.de', $mailC, true);
paypal_flow_assert('Vorschau meldet das Parken', ($vorschauC['status'] ?? '') === 'would_park', $failures, $passes);
$ergebnisC = paypal_process_raw($pdo, $empfaengerB, 'service@paypal.de', $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", 'service@paypal.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
$fremd = paypal_process_raw($pdo, $empfaengerA, 'no-reply@beispiel.de', $mailA);
paypal_flow_assert('Mail von fremdem Absender wird abgelehnt', ($fremd['status'] ?? '') === 'not_from_paypal', $failures, $passes);
$ohneToken = paypal_process_raw($pdo, 'zahlungen@kaffeeliste.de', 'service@paypal.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', 'service@paypal.de', $mailA);
paypal_flow_assert('unbekannter Token wird abgelehnt', ($falscherToken['status'] ?? '') === 'unknown_tenant', $failures, $passes);
$keineZahlung = paypal_process_raw($pdo, $empfaengerA, 'service@paypal.de', '<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";