From 9f9e4c7cd0eb0e2dc8a10ce6876112b20c009eb5 Mon Sep 17 00:00:00 2001 From: Clemens Creutzburg Date: Fri, 21 Aug 2026 20:43:58 +0200 Subject: [PATCH] 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 --- app/paypal-inbox.php | 63 ++++++++- docs/backoffice.md | 18 +++ docs/deployment.md | 30 +++++ scripts/check-paypal-inbox-flow.php | 193 ++++++++++++++++++++++++++++ scripts/fetch-paypal-payments.php | 33 ++++- 5 files changed, 329 insertions(+), 8 deletions(-) create mode 100644 scripts/check-paypal-inbox-flow.php diff --git a/app/paypal-inbox.php b/app/paypal-inbox.php index 0c74335..5f7a718 100644 --- a/app/paypal-inbox.php +++ b/app/paypal-inbox.php @@ -148,6 +148,8 @@ function paypal_match_participant(PDO $pdo, int $tenantId, string $payerName, ?s * - Dedup ueber den Transaktionscode (bereits verarbeitet -> uebersprungen). * - Eindeutiger Match -> automatisch als Netto-Einzahlung gebucht. * - Kein eindeutiger Match -> in der Warteschlange ('unmatched') abgelegt. + * - PayPal fuer den Mandanten abgeschaltet -> geparkt ('parked'): gespeichert, + * aber ohne automatische Buchung. * * @param array $parsed Ergebnis von paypal_parse_notification() * @return array{status:string, payment_id?:int, participant?:string} @@ -185,6 +187,17 @@ function paypal_reconcile(PDO $pdo, int $tenantId, array $parsed, ?int $actorUse } $paymentId = (int) $pdo->lastInsertId(); + // Ist PayPal fuer diesen Mandanten abgeschaltet - vom Betreiber oder vom + // Mandanten selbst -, wird die Zahlung nur geparkt: gespeichert, aber + // nicht automatisch gutgeschrieben. Wegwerfen waere schlechter (eine + // echte Zahlung ginge unbemerkt verloren), automatisch buchen ebenfalls + // (es soll ja gerade nichts ueber PayPal laufen). Die Zuordnungsseite + // bleibt fuer offene Zahlungen erreichbar, dort entscheidet der Mandant. + require_once __DIR__ . '/features.php'; + if (!app_feature_available($pdo, $tenantId, 'paypal_inbox')) { + return ['status' => 'parked', 'payment_id' => $paymentId]; + } + // Eindeutiger Match -> automatisch buchen. $participant = paypal_match_participant($pdo, $tenantId, (string) ($parsed['payer_name'] ?? ''), $parsed['note'] ?? null); if ($participant === null) { @@ -218,6 +231,50 @@ function paypal_reconcile(PDO $pdo, int $tenantId, array $parsed, ?int $actorUse return ['status' => 'booked', 'payment_id' => $paymentId, 'participant' => (string) $participant['display_name']]; } +/** + * Vorschau ohne jeden Schreibzugriff: was wuerde mit dieser Zahlung + * passieren? Gedacht fuer den Probelauf des Abrufskripts (--dry-run). Der + * Probelauf soll wirklich nichts anfassen - eine Vorschau, die im + * Hintergrund bucht, waere schlimmer als gar keine. + * + * @param array $parsed Ergebnis von paypal_parse_notification() + * @return array{status:string, participant?:string, net_cents?:int} + */ +function paypal_preview(PDO $pdo, int $tenantId, array $parsed): array +{ + $code = (string) ($parsed['transaction_code'] ?? ''); + if ($code === '') { + return ['status' => 'no_code']; + } + $netCents = (int) ($parsed['net_cents'] ?? 0); + if ($netCents <= 0) { + return ['status' => 'no_amount']; + } + + // Dedup wie beim echten Lauf: der Transaktionscode ist global eindeutig. + $stmt = $pdo->prepare('SELECT id FROM paypal_payments WHERE transaction_code = ? LIMIT 1'); + $stmt->execute([$code]); + if ($stmt->fetchColumn() !== false) { + return ['status' => 'duplicate']; + } + + require_once __DIR__ . '/features.php'; + if (!app_feature_available($pdo, $tenantId, 'paypal_inbox')) { + return ['status' => 'would_park', 'net_cents' => $netCents]; + } + + $participant = paypal_match_participant($pdo, $tenantId, (string) ($parsed['payer_name'] ?? ''), $parsed['note'] ?? null); + if ($participant === null) { + return ['status' => 'would_queue', 'net_cents' => $netCents]; + } + + return [ + 'status' => 'would_book', + 'participant' => (string) $participant['display_name'], + 'net_cents' => $netCents, + ]; +} + /** * Verarbeitet eine rohe PayPal-Mail vollstaendig: Tenant per Plus-Token, * Absenderpruefung, Parsing, Abgleich. Fuer den IMAP-Cron und Tests. @@ -227,7 +284,7 @@ function paypal_reconcile(PDO $pdo, int $tenantId, array $parsed, ?int $actorUse * @param string $rawBody (dekodierter) Mail-Body * @return array{status:string, tenant_id?:int} */ -function paypal_process_raw(PDO $pdo, string $recipient, string $fromHeader, string $rawBody): array +function paypal_process_raw(PDO $pdo, string $recipient, string $fromHeader, string $rawBody, bool $previewOnly = false): array { if (!preg_match('/@paypal\.(de|com)/i', $fromHeader)) { return ['status' => 'not_from_paypal']; @@ -245,7 +302,9 @@ function paypal_process_raw(PDO $pdo, string $recipient, string $fromHeader, str return ['status' => 'not_a_payment']; } - $result = paypal_reconcile($pdo, $tenantId, $parsed); + $result = $previewOnly + ? paypal_preview($pdo, $tenantId, $parsed) + : paypal_reconcile($pdo, $tenantId, $parsed); $result['tenant_id'] = $tenantId; return $result; diff --git a/docs/backoffice.md b/docs/backoffice.md index 376b438..2cf6aa0 100644 --- a/docs/backoffice.md +++ b/docs/backoffice.md @@ -186,3 +186,21 @@ weiterhin ausschließlich seinen eigenen Mandanten. Ein zweiter, eingeloggter, aber nicht privilegierter Testnutzer bekommt auf `backoffice.php` korrekt `403 Forbidden`. Alle Testdaten anschließend vollständig entfernt. + +## Eingehende PayPal-Mails bei abgeschalteter Funktion + +Stand: 2026-08-21. Die Mail-Verarbeitung +(`scripts/fetch-paypal-payments.php` → `paypal_reconcile()`) prüft +dieselben zwei Ebenen wie das Menü. Ist PayPal für den Mandanten +abgeschaltet — vom Betreiber (`paypal_inbox`) oder vom Mandanten selbst +(`paypal_enabled`) —, wird eine eingehende Zahlung **geparkt**: sie wird +gespeichert (Status `unmatched`, Ergebnis `parked`), aber nicht +automatisch gutgeschrieben. + +- **Warum nicht wegwerfen:** eine echte Zahlung würde unbemerkt + verschwinden; das Geld ist ja trotzdem angekommen. +- **Warum nicht buchen:** es soll gerade nichts über PayPal laufen — die + Entscheidung gehört dem Mandanten. +- Die Zuordnungsseite bleibt für offene Zahlungen erreichbar (siehe + `paypal_inbox_accessible()`), der Mandant sieht die geparkte Zahlung + also und kann sie zuordnen oder abhaken. diff --git a/docs/deployment.md b/docs/deployment.md index 5896719..6d17e45 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -236,6 +236,36 @@ dieselbe Mailbox zustellen** (Catch-All), damit die pro Mandant generierten Adressen `zahlungen+@…` ankommen. IMAP-Zugangsdaten in `env.local.php` eintragen und die `imap`-Extension auf dem Host prüfen. +### Erster Testlauf + +`scripts/fetch-paypal-payments.php --dry-run` fasst nichts an: es meldet +Verbindung, erkannten Mandanten und was mit jeder Mail passieren *würde* +(`would_book` / `would_queue` / `would_park` / `duplicate`), speichert +aber nichts, bucht nichts und markiert keine Mail als gelesen. Erst der +Lauf ohne `--dry-run` verbucht. + +Reihenfolge für die Inbetriebnahme: + +1. Eingangsadresse des Mandanten holen — sie steht auf + `paypal-zuordnung.php` („PayPal-Zahlungen"). +2. Eine echte PayPal-Zahlungsmail dorthin weiterleiten. +3. Task mit `--dry-run` auslösen, Ausgabe kontrollieren. +4. Task ohne `--dry-run` auslösen, Ergebnis in der App prüfen + (Journal-Vorschau bzw. Warteschlange). +5. Erst danach den Task auf den regelmäßigen Takt stellen. + +Ohne IMAP lässt sich die Verarbeitung auch mit einer gespeicherten Mail +prüfen: + +``` +php scripts/fetch-paypal-payments.php --file=mail.html \ + --recipient=zahlungen+@kaffeeliste.de --from=service@paypal.de [--dry-run] +``` + +Die Verarbeitungskette selbst (Absenderprüfung, Token, Parser, Zuordnung, +Buchung, Dedup, Parken) deckt `scripts/check-paypal-inbox-flow.php` als +Regressionstest ab — alles außer der IMAP-Verbindung. + ## Deploy-Ablauf für ein Update 1. Lokal committen und pushen. diff --git a/scripts/check-paypal-inbox-flow.php b/scripts/check-paypal-inbox-flow.php new file mode 100644 index 0000000..45b6540 --- /dev/null +++ b/scripts/check-paypal-inbox-flow.php @@ -0,0 +1,193 @@ +Gebühr-0,35 € EURSumme{$summe} € EUR" + : ''; + + return '' + . '

Hallo,

' + . "

{$payer} hat dir {$betrag} € EUR gesendet

" + . "

Mitteilung von {$payer} {$mitteilung}

" + . "" + . $summeZeile + . '
Erhaltener Betrag{$betrag} € EUR
Transaktionsdatum21. August 2026
' + . '

Details ansehen

' + . '

Transaktionscode ' . $code . '

' + . ''; +} + +$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', 'Newsletter von PayPal'); + 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"; diff --git a/scripts/fetch-paypal-payments.php b/scripts/fetch-paypal-payments.php index b5f0f9d..c5265ae 100644 --- a/scripts/fetch-paypal-payments.php +++ b/scripts/fetch-paypal-payments.php @@ -12,7 +12,13 @@ declare(strict_types=1); * 2. Absender gegen paypal.de/.com pruefen. * 3. Body parsen, Netto ermitteln, per Transaktionscode deduplizieren. * 4. Eindeutiger Namens-Match -> automatisch als Einzahlung buchen, - * sonst in die Warteschlange legen. + * 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 @@ -47,7 +53,7 @@ if (isset($opts['file'])) { } $recipient = (string) ($opts['recipient'] ?? ''); $from = (string) ($opts['from'] ?? 'service@paypal.de'); - $result = paypal_process_raw($pdo, $recipient, $from, $body); + $result = paypal_process_raw($pdo, $recipient, $from, $body, isset($opts['dry-run'])); echo 'Ergebnis: ' . json_encode($result, JSON_UNESCAPED_UNICODE) . "\n"; exit(0); } @@ -123,6 +129,7 @@ $messageCount = imap_num_msg($mbox); $processed = 0; $booked = 0; $queued = 0; +$parked = 0; $skipped = 0; for ($msgno = 1; $msgno <= $messageCount; $msgno++) { @@ -147,21 +154,34 @@ for ($msgno = 1; $msgno <= $messageCount; $msgno++) { $from = (string) ($overview->from ?? ''); $body = paypal_imap_fetch_body($mbox, $msgno); - $result = paypal_process_raw($pdo, $recipient, $from, $body); + // Im Probelauf wird nichts gespeichert und nichts gebucht - die Vorschau + // sagt nur, was passieren wuerde. + $result = paypal_process_raw($pdo, $recipient, $from, $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']})" : '') . "\n"; + . (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) { @@ -171,5 +191,6 @@ for ($msgno = 1; $msgno <= $messageCount; $msgno++) { imap_close($mbox); -echo "Fertig: {$processed} verarbeitet, {$booked} gebucht, {$queued} in Warteschlange, {$skipped} uebersprungen" - . ($dryRun ? ' (dry-run, nichts als gelesen markiert)' : '') . ".\n"; +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";