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:
+61
-2
@@ -148,6 +148,8 @@ function paypal_match_participant(PDO $pdo, int $tenantId, string $payerName, ?s
|
|||||||
* - Dedup ueber den Transaktionscode (bereits verarbeitet -> uebersprungen).
|
* - Dedup ueber den Transaktionscode (bereits verarbeitet -> uebersprungen).
|
||||||
* - Eindeutiger Match -> automatisch als Netto-Einzahlung gebucht.
|
* - Eindeutiger Match -> automatisch als Netto-Einzahlung gebucht.
|
||||||
* - Kein eindeutiger Match -> in der Warteschlange ('unmatched') abgelegt.
|
* - 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()
|
* @param array $parsed Ergebnis von paypal_parse_notification()
|
||||||
* @return array{status:string, payment_id?:int, participant?:string}
|
* @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();
|
$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.
|
// Eindeutiger Match -> automatisch buchen.
|
||||||
$participant = paypal_match_participant($pdo, $tenantId, (string) ($parsed['payer_name'] ?? ''), $parsed['note'] ?? null);
|
$participant = paypal_match_participant($pdo, $tenantId, (string) ($parsed['payer_name'] ?? ''), $parsed['note'] ?? null);
|
||||||
if ($participant === 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']];
|
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,
|
* Verarbeitet eine rohe PayPal-Mail vollstaendig: Tenant per Plus-Token,
|
||||||
* Absenderpruefung, Parsing, Abgleich. Fuer den IMAP-Cron und Tests.
|
* 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
|
* @param string $rawBody (dekodierter) Mail-Body
|
||||||
* @return array{status:string, tenant_id?:int}
|
* @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)) {
|
if (!preg_match('/@paypal\.(de|com)/i', $fromHeader)) {
|
||||||
return ['status' => 'not_from_paypal'];
|
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'];
|
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;
|
$result['tenant_id'] = $tenantId;
|
||||||
|
|
||||||
return $result;
|
return $result;
|
||||||
|
|||||||
@@ -186,3 +186,21 @@ weiterhin ausschließlich seinen eigenen Mandanten. Ein zweiter,
|
|||||||
eingeloggter, aber nicht privilegierter Testnutzer bekommt auf
|
eingeloggter, aber nicht privilegierter Testnutzer bekommt auf
|
||||||
`backoffice.php` korrekt `403 Forbidden`. Alle Testdaten anschließend
|
`backoffice.php` korrekt `403 Forbidden`. Alle Testdaten anschließend
|
||||||
vollständig entfernt.
|
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.
|
||||||
|
|||||||
@@ -236,6 +236,36 @@ dieselbe Mailbox zustellen** (Catch-All), damit die pro Mandant
|
|||||||
generierten Adressen `zahlungen+<token>@…` ankommen. IMAP-Zugangsdaten in
|
generierten Adressen `zahlungen+<token>@…` ankommen. IMAP-Zugangsdaten in
|
||||||
`env.local.php` eintragen und die `imap`-Extension auf dem Host prüfen.
|
`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+<token>@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
|
## Deploy-Ablauf für ein Update
|
||||||
|
|
||||||
1. Lokal committen und pushen.
|
1. Lokal committen und pushen.
|
||||||
|
|||||||
@@ -0,0 +1,193 @@
|
|||||||
|
<?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 € EUR</td></tr><tr><td>Summe</td><td>{$summe} € EUR</td></tr>"
|
||||||
|
: '';
|
||||||
|
|
||||||
|
return '<html><body>'
|
||||||
|
. '<p>Hallo,</p>'
|
||||||
|
. "<p>{$payer} hat dir {$betrag} € EUR gesendet</p>"
|
||||||
|
. "<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, '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";
|
||||||
@@ -12,7 +12,13 @@ declare(strict_types=1);
|
|||||||
* 2. Absender gegen paypal.de/.com pruefen.
|
* 2. Absender gegen paypal.de/.com pruefen.
|
||||||
* 3. Body parsen, Netto ermitteln, per Transaktionscode deduplizieren.
|
* 3. Body parsen, Netto ermitteln, per Transaktionscode deduplizieren.
|
||||||
* 4. Eindeutiger Namens-Match -> automatisch als Einzahlung buchen,
|
* 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):
|
* Konfiguration ausschliesslich ueber Server-/Env-Einstellungen (Betreiber):
|
||||||
* PAYPAL_INBOX_BASE z. B. zahlungen@kaffeeliste.de
|
* PAYPAL_INBOX_BASE z. B. zahlungen@kaffeeliste.de
|
||||||
@@ -47,7 +53,7 @@ if (isset($opts['file'])) {
|
|||||||
}
|
}
|
||||||
$recipient = (string) ($opts['recipient'] ?? '');
|
$recipient = (string) ($opts['recipient'] ?? '');
|
||||||
$from = (string) ($opts['from'] ?? 'service@paypal.de');
|
$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";
|
echo 'Ergebnis: ' . json_encode($result, JSON_UNESCAPED_UNICODE) . "\n";
|
||||||
exit(0);
|
exit(0);
|
||||||
}
|
}
|
||||||
@@ -123,6 +129,7 @@ $messageCount = imap_num_msg($mbox);
|
|||||||
$processed = 0;
|
$processed = 0;
|
||||||
$booked = 0;
|
$booked = 0;
|
||||||
$queued = 0;
|
$queued = 0;
|
||||||
|
$parked = 0;
|
||||||
$skipped = 0;
|
$skipped = 0;
|
||||||
|
|
||||||
for ($msgno = 1; $msgno <= $messageCount; $msgno++) {
|
for ($msgno = 1; $msgno <= $messageCount; $msgno++) {
|
||||||
@@ -147,21 +154,34 @@ for ($msgno = 1; $msgno <= $messageCount; $msgno++) {
|
|||||||
$from = (string) ($overview->from ?? '');
|
$from = (string) ($overview->from ?? '');
|
||||||
|
|
||||||
$body = paypal_imap_fetch_body($mbox, $msgno);
|
$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']) {
|
switch ($result['status']) {
|
||||||
case 'booked':
|
case 'booked':
|
||||||
|
case 'would_book':
|
||||||
$booked++;
|
$booked++;
|
||||||
break;
|
break;
|
||||||
case 'unmatched':
|
case 'unmatched':
|
||||||
|
case 'would_queue':
|
||||||
$queued++;
|
$queued++;
|
||||||
break;
|
break;
|
||||||
|
case 'parked':
|
||||||
|
case 'would_park':
|
||||||
|
// PayPal ist fuer den Mandanten abgeschaltet: gespeichert, aber
|
||||||
|
// bewusst nicht gebucht.
|
||||||
|
$parked++;
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
$skipped++;
|
$skipped++;
|
||||||
}
|
}
|
||||||
|
|
||||||
echo "Mail #{$msgno}: {$result['status']}"
|
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.
|
// Als gelesen markieren, damit sie nicht erneut verarbeitet wird.
|
||||||
if (!$dryRun) {
|
if (!$dryRun) {
|
||||||
@@ -171,5 +191,6 @@ for ($msgno = 1; $msgno <= $messageCount; $msgno++) {
|
|||||||
|
|
||||||
imap_close($mbox);
|
imap_close($mbox);
|
||||||
|
|
||||||
echo "Fertig: {$processed} verarbeitet, {$booked} gebucht, {$queued} in Warteschlange, {$skipped} uebersprungen"
|
echo "Fertig: {$processed} verarbeitet, {$booked} gebucht, {$queued} in Warteschlange, "
|
||||||
. ($dryRun ? ' (dry-run, nichts als gelesen markiert)' : '') . ".\n";
|
. "{$parked} geparkt (PayPal abgeschaltet), {$skipped} uebersprungen"
|
||||||
|
. ($dryRun ? ' (Probelauf: nichts gespeichert, nichts gebucht, nichts als gelesen markiert)' : '') . ".\n";
|
||||||
|
|||||||
Reference in New Issue
Block a user