diff --git a/app/paypal-inbox.php b/app/paypal-inbox.php index 5f7a718..6f3c989 100644 --- a/app/paypal-inbox.php +++ b/app/paypal-inbox.php @@ -334,6 +334,52 @@ function paypal_fetch_unmatched(PDO $pdo, int $tenantId): array * * @return array{ok:bool, error?:string} */ +/** + * Soll der Zahlername als PayPal-Name beim Mitglied hinterlegt werden? + * Liefert den zu speichernden Namen oder null, wenn nichts zu lernen ist. + * + * Zurueckhaltend absichtlich: ein bereits gepflegter PayPal-Name wird nie + * ueberschrieben, ein Name gleich dem Anzeigenamen bringt nichts (der wird + * ohnehin geprueft), und wenn der Name im Mandanten nicht eindeutig waere, + * wuerde das Merken die automatische Zuordnung sogar blockieren - dann + * lieber weiter von Hand zuordnen. + */ +function paypal_remember_payer_name(PDO $pdo, int $tenantId, int $participantId, string $payerName): ?string +{ + $payerName = trim($payerName); + if ($payerName === '') { + return null; + } + + $stmt = $pdo->prepare('SELECT display_name, paypal_name FROM participants WHERE id = ? AND tenant_id = ?'); + $stmt->execute([$participantId, $tenantId]); + $participant = $stmt->fetch(); + if ($participant === false) { + return null; + } + if (trim((string) ($participant['paypal_name'] ?? '')) !== '') { + return null; + } + if (strcasecmp(trim((string) $participant['display_name']), $payerName) === 0) { + return null; + } + + // Wuerde der Name auf ein weiteres Mitglied passen, bliebe die Zuordnung + // mehrdeutig (imports_find_participant verlangt genau einen Treffer). + $stmt = $pdo->prepare( + 'SELECT COUNT(*) FROM participants + WHERE tenant_id = ? + AND id <> ? + AND (LOWER(paypal_name) = LOWER(?) OR LOWER(display_name) = LOWER(?))' + ); + $stmt->execute([$tenantId, $participantId, $payerName, $payerName]); + if ((int) $stmt->fetchColumn() > 0) { + return null; + } + + return $payerName; +} + function paypal_assign_payment(PDO $pdo, int $tenantId, int $paymentId, int $participantId, ?int $actorUserId): array { $stmt = $pdo->prepare("SELECT id, net_cents, status, payer_name, note, paid_at FROM paypal_payments WHERE id = ? AND tenant_id = ?"); @@ -349,11 +395,22 @@ function paypal_assign_payment(PDO $pdo, int $tenantId, int $paymentId, int $par return ['ok' => false, 'error' => 'Das gewählte Mitglied wurde nicht gefunden.']; } + // Aus der Handarbeit lernen: heisst jemand bei PayPal anders als in der + // Kaffeeliste, greift die automatische Zuordnung nie - denn sie verlangt + // exakte Uebereinstimmung. Nach der ersten manuellen Zuordnung wird der + // Zahlername deshalb beim Mitglied hinterlegt, damit die naechste Zahlung + // von allein ankommt. + $gemerkterName = paypal_remember_payer_name($pdo, $tenantId, $participantId, (string) $payment['payer_name']); + try { $pdo->beginTransaction(); $ledgerId = paypal_book_payment($pdo, $tenantId, $participant, (int) $payment['net_cents'], $actorUserId, paypal_booking_note($payment)); $pdo->prepare("UPDATE paypal_payments SET participant_id = ?, ledger_entry_id = ?, status = 'booked' WHERE id = ?") ->execute([$participantId, $ledgerId, $paymentId]); + if ($gemerkterName !== null) { + $pdo->prepare('UPDATE participants SET paypal_name = ? WHERE id = ? AND tenant_id = ?') + ->execute([$gemerkterName, $participantId, $tenantId]); + } $pdo->commit(); } catch (Throwable $e) { if ($pdo->inTransaction()) { @@ -366,9 +423,10 @@ function paypal_assign_payment(PDO $pdo, int $tenantId, int $paymentId, int $par app_audit_log($pdo, $tenantId, $actorUserId, 'paypal_import.manual_assigned', 'paypal_payment', $paymentId, [ 'participant_id' => $participantId, 'net_cents' => (int) $payment['net_cents'], + 'learned_paypal_name' => $gemerkterName, ]); - return ['ok' => true]; + return ['ok' => true, 'learned_paypal_name' => $gemerkterName]; } /** diff --git a/docs/backoffice.md b/docs/backoffice.md index 2cf6aa0..adeb11e 100644 --- a/docs/backoffice.md +++ b/docs/backoffice.md @@ -204,3 +204,21 @@ automatisch gutgeschrieben. - 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. + +### PayPal-Name lernen + +Die automatische Zuordnung verlangt **exakte** Übereinstimmung des +Zahlernamens mit `display_name` oder `paypal_name` des Mitglieds +(`imports_find_participant()`, genau ein Treffer). Heißt jemand bei PayPal +anders — Zweitname, Firmenkonto, anderer Nachname —, landet jede Zahlung in +der Warteschlange, auch die zehnte. + +`paypal_assign_payment()` hinterlegt den Zahlernamen deshalb beim Mitglied, +sobald eine Zahlung von Hand zugeordnet wurde; die nächste Zahlung desselben +Zahlers wird automatisch gebucht. Bewusst zurückhaltend +(`paypal_remember_payer_name()`): ein bereits gepflegter PayPal-Name wird nie +überschrieben, ein Name gleich dem Anzeigenamen bringt nichts, und passt der +Name auch auf ein anderes Mitglied, wird nichts gelernt — sonst wäre die +Zuordnung mehrdeutig und würde gar nicht mehr greifen. Was gelernt wurde, +steht in der Erfolgsmeldung und im Audit-Log +(`paypal_import.manual_assigned`, Feld `learned_paypal_name`). diff --git a/paypal-zuordnung.php b/paypal-zuordnung.php index 24398e0..f7165de 100644 --- a/paypal-zuordnung.php +++ b/paypal-zuordnung.php @@ -108,8 +108,16 @@ if ($hasAccess && $_SERVER['REQUEST_METHOD'] === 'POST') { $flash = ['type' => 'error', 'text' => 'Bitte ein Mitglied auswählen.']; } else { $result = paypal_assign_payment($pdo, $tenantId, $paymentId, $participantId, $actorUserId); + $erfolgstext = 'Zahlung wurde zugeordnet und als Einzahlung gebucht.'; + // Wurde dabei der PayPal-Name gelernt, gehoert das in die Meldung - + // sonst wirkt die stillschweigende Aenderung am Mitglied wie Magie. + if (($result['learned_paypal_name'] ?? null) !== null) { + $erfolgstext .= ' Der PayPal-Name „' . $result['learned_paypal_name'] + . '" ist jetzt bei diesem Mitglied hinterlegt – künftige Zahlungen' + . ' werden automatisch zugeordnet.'; + } $flash = $result['ok'] - ? ['type' => 'success', 'text' => 'Zahlung wurde zugeordnet und als Einzahlung gebucht.'] + ? ['type' => 'success', 'text' => $erfolgstext] : ['type' => 'error', 'text' => $result['error'] ?? 'Die Zuordnung ist fehlgeschlagen.']; } } elseif ($aktion === 'ignorieren') { diff --git a/scripts/check-paypal-inbox-flow.php b/scripts/check-paypal-inbox-flow.php index 45b6540..dbb2a8c 100644 --- a/scripts/check-paypal-inbox-flow.php +++ b/scripts/check-paypal-inbox-flow.php @@ -129,6 +129,35 @@ try { 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); + // --- 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, 'service@paypal.de', $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, 'service@paypal.de', $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, 'service@paypal.de', $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);