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>
This commit is contained in:
2026-08-21 20:43:58 +02:00
co-authored by Claude Opus 5
parent 7df9c1fb4e
commit 9f9e4c7cd0
5 changed files with 329 additions and 8 deletions
+61 -2
View File
@@ -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;