Files
kaffeekasse-saas/app/paypal-inbox.php
T
clemensandClaude Opus 5 43d0fc5578 Absenderpruefung erweitern und Postfach auflisten koennen
Die Pruefung suchte woertlich nach "@paypal.de" oder "@paypal.com". Mails
aus einer Versand-Subdomain (e.paypal.de) oder einer Landesvariante
(paypal.at, paypal.co.uk) fielen damit als not_from_paypal durch. Geprueft
wird jetzt die Domain der Absenderadresse an ihrem Ende - "paypal.de.
beispiel.com" ist damit weiterhin kein PayPal, waere bei einer Textsuche
aber durchgerutscht.

check-imap-support.php listet zusaetzlich die letzten 15 Mails mit
Absender, Empfaengerzeilen und Betreff auf und sagt je Mail, ob der
Absender als PayPal gilt. Damit laesst sich ein not_from_paypal in einem
Lauf klaeren statt zu raten. Rein lesend: OP_READONLY, kein \Seen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 22:35:51 +02:00

613 lines
21 KiB
PHP

<?php
declare(strict_types=1);
require_once __DIR__ . '/bootstrap.php';
require_once __DIR__ . '/database.php';
require_once __DIR__ . '/ledger.php';
require_once __DIR__ . '/imports.php';
require_once __DIR__ . '/audit.php';
require_once __DIR__ . '/paypal-mail-parser.php';
/**
* Basis-Eingangsadresse (Catch-all), an die Admins ihre PayPal-Mails
* weiterleiten - z. B. "zahlungen@kaffeeliste.de". Nur ueber Server-/Env-
* Einstellung durch den Betreiber setzbar (PAYPAL_INBOX_BASE).
*/
function paypal_inbox_base_address(): ?string
{
$base = app_env('PAYPAL_INBOX_BASE');
return ($base !== null && str_contains($base, '@')) ? $base : null;
}
/**
* Baut die mandantenspezifische Plus-Adresse aus Basisadresse und Token,
* z. B. "zahlungen+ab12cd@kaffeeliste.de". Null, wenn keine Basis konfiguriert.
*/
function paypal_inbox_address_for_token(string $token): ?string
{
$base = paypal_inbox_base_address();
if ($base === null || $token === '') {
return null;
}
[$local, $domain] = explode('@', $base, 2);
return $local . '+' . $token . '@' . $domain;
}
/**
* Liefert (und erzeugt bei Bedarf) den eindeutigen Inbox-Token eines Mandanten.
*/
function paypal_inbox_ensure_token(PDO $pdo, int $tenantId): string
{
$stmt = $pdo->prepare('SELECT paypal_inbox_token FROM tenants WHERE id = ?');
$stmt->execute([$tenantId]);
$token = (string) ($stmt->fetchColumn() ?: '');
if ($token !== '') {
return $token;
}
for ($try = 0; $try < 5; $try++) {
$candidate = bin2hex(random_bytes(6)); // 12 hex-Zeichen
try {
$pdo->prepare('UPDATE tenants SET paypal_inbox_token = ? WHERE id = ?')
->execute([$candidate, $tenantId]);
return $candidate;
} catch (Throwable $e) {
// Kollision (unwahrscheinlich) - erneut versuchen.
}
}
throw new RuntimeException('Es konnte kein eindeutiger Inbox-Token erzeugt werden.');
}
/**
* Ermittelt den Mandanten anhand des Plus-Adress-Tokens (aus der Empfaenger-
* adresse der weitergeleiteten Mail). Null, wenn kein Mandant passt.
*/
function paypal_inbox_resolve_tenant(PDO $pdo, string $token): ?int
{
$token = trim($token);
if ($token === '') {
return null;
}
$stmt = $pdo->prepare("SELECT id FROM tenants WHERE paypal_inbox_token = ? AND status = 'active' LIMIT 1");
$stmt->execute([$token]);
$id = $stmt->fetchColumn();
return $id === false ? null : (int) $id;
}
/**
* Zieht den Plus-Token aus einer Empfaengeradresse ("zahlungen+ab12cd@host").
* Null, wenn kein Token enthalten ist.
*/
function paypal_inbox_extract_token(string $address): ?string
{
if (preg_match('/\+([A-Za-z0-9]{6,32})@/', $address, $m)) {
return $m[1];
}
return null;
}
/**
* Bucht eine PayPal-Netto-Zahlung als Einzahlung ins Ledger. Beim migrierten
* Default-Mandanten wird zusaetzlich in die Legacy-Tabelle geschrieben und
* gespiegelt (gleiches Muster wie Sammelerfassung/CSV-Import).
*
* @return int Ledger-Entry-ID
*/
function paypal_book_payment(PDO $pdo, int $tenantId, array $participant, int $netCents, ?int $actorUserId, ?string $note = null): int
{
return ledger_record_payment($pdo, $tenantId, (int) $participant['participant_id'], $netCents, 'paypal_import', $actorUserId, $note);
}
/**
* Bemerkung fuer eine aus PayPal uebernommene Buchung: Zahler und Datum, damit
* im Journal nachvollziehbar bleibt, woher die Gutschrift stammt.
*/
function paypal_booking_note(array $payment): string
{
$note = 'PayPal: ' . trim((string) ($payment['payer_name'] ?? ''));
if (!empty($payment['paid_at'])) {
$note .= ' vom ' . date('d.m.Y', strtotime((string) $payment['paid_at']));
}
$mitteilung = trim((string) ($payment['note'] ?? ''));
if ($mitteilung !== '') {
$note .= ' ("' . $mitteilung . '")';
}
return $note;
}
/**
* Sucht den passenden Teilnehmer zu einer PayPal-Zahlung: zuerst ueber die
* Mailadresse des Zahlers, dann ueber den Zahlernamen
* (paypal_name/display_name), zuletzt ueber die Mitteilung (Mitglied schreibt
* dort teils seinen Namen). Liefert null, wenn nicht eindeutig.
*
* @return array{participant_id:int, display_name:string}|null
*/
function paypal_match_participant(PDO $pdo, int $tenantId, string $payerName, ?string $note, ?string $payerEmail = null): ?array
{
// Die Mailadresse zuerst: sie ist im Mandanten eindeutig (Unique Key auf
// tenant_id + email_norm) und aendert sich nicht, wenn jemand bei PayPal
// unter einem anderen Namen auftritt.
$byEmail = paypal_find_participant_by_email($pdo, $tenantId, $payerEmail);
if ($byEmail !== null) {
return $byEmail;
}
$byName = imports_find_participant($pdo, $tenantId, $payerName);
if ($byName !== null) {
return $byName;
}
if ($note !== null && trim($note) !== '') {
return imports_find_participant($pdo, $tenantId, trim($note));
}
return null;
}
/**
* Stammt die Mail wirklich von PayPal? Geprueft wird die Domain der
* Absenderadresse - inklusive Landesvarianten (paypal.at, paypal.co.uk) und
* Versand-Subdomains (e.paypal.de), die PayPal tatsaechlich benutzt.
*
* Der Vergleich haengt bewusst am Ende der Domain: "paypal.de.beispiel.com"
* ist NICHT PayPal, wuerde bei einer simplen Textsuche nach "@paypal.de"
* aber durchrutschen - und damit koennte jemand mit Kenntnis der
* Eingangsadresse Zahlungen erfinden.
*/
function paypal_from_is_paypal(string $fromHeader): bool
{
if (!preg_match_all('/[\w.+-]+@([\w-]+(?:\.[\w-]+)+)/u', strtolower($fromHeader), $treffer)) {
return false;
}
foreach ($treffer[1] as $domain) {
if (preg_match('/(^|\.)paypal\.[a-z]{2,}(\.[a-z]{2,})?$/', $domain)) {
return true;
}
}
return false;
}
/**
* Gehoeren zwei Adressen zum selben Postfach? Plus-Adressierung wird dabei
* ignoriert, "zahlungen+ab12cd@host" ist also dasselbe Postfach wie
* "zahlungen@host". $kandidat darf mehrere Adressen enthalten (die
* Empfaengerzeilen einer weitergeleiteten Mail).
*/
function paypal_same_mailbox(string $adresse, string $kandidat): bool
{
$adresse = strtolower(trim($adresse));
if ($adresse === '' || !str_contains($adresse, '@')) {
return false;
}
[$lokal, $domain] = explode('@', $adresse, 2);
$lokal = explode('+', $lokal)[0];
if (!preg_match_all('/[\w.+-]+@[\w-]+(?:\.[\w-]+)+/u', strtolower($kandidat), $treffer)) {
return false;
}
foreach ($treffer[0] as $andere) {
[$andereLokal, $andereDomain] = explode('@', $andere, 2);
if (explode('+', $andereLokal)[0] === $lokal && $andereDomain === $domain) {
return true;
}
}
return false;
}
/**
* Mitglied ueber seine Mailadresse finden. Null, wenn keine Adresse vorliegt
* oder kein Mitglied dazu passt.
*/
function paypal_find_participant_by_email(PDO $pdo, int $tenantId, ?string $email): ?array
{
$email = strtolower(trim((string) $email));
if ($email === '') {
return null;
}
$stmt = $pdo->prepare(
'SELECT id, display_name
FROM participants
WHERE tenant_id = ? AND email_norm = ?
ORDER BY id
LIMIT 2'
);
$stmt->execute([$tenantId, $email]);
$rows = $stmt->fetchAll();
if (count($rows) !== 1) {
return null;
}
return [
'participant_id' => (int) $rows[0]['id'],
'display_name' => (string) $rows[0]['display_name'],
];
}
/**
* Verarbeitet eine geparste PayPal-Zahlung fuer einen Mandanten:
* - 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}
*/
function paypal_reconcile(PDO $pdo, int $tenantId, array $parsed, ?int $actorUserId = null): 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: Transaktionscode ist global eindeutig. INSERT IGNORE als Schranke.
$insert = $pdo->prepare(
'INSERT IGNORE INTO paypal_payments
(tenant_id, transaction_code, payer_name, payer_email, note, gross_cents, fee_cents, net_cents, paid_at, status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
);
$insert->execute([
$tenantId,
$code,
(string) ($parsed['payer_name'] ?? ''),
(string) ($parsed['payer_email'] ?? ''),
$parsed['note'] ?? null,
(int) ($parsed['gross_cents'] ?? 0),
$parsed['fee_cents'] ?? null,
$netCents,
$parsed['date'] ?? null,
'unmatched',
]);
if ($insert->rowCount() === 0) {
return ['status' => 'duplicate'];
}
$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,
$parsed['payer_email'] ?? null
);
if ($participant === null) {
return ['status' => 'unmatched', 'payment_id' => $paymentId];
}
try {
$pdo->beginTransaction();
$ledgerId = paypal_book_payment($pdo, $tenantId, $participant, $netCents, $actorUserId, paypal_booking_note([
'payer_name' => $parsed['payer_name'] ?? '',
'paid_at' => $parsed['date'] ?? null,
'note' => $parsed['note'] ?? null,
]));
$pdo->prepare("UPDATE paypal_payments SET participant_id = ?, ledger_entry_id = ?, status = 'booked' WHERE id = ?")
->execute([(int) $participant['participant_id'], $ledgerId, $paymentId]);
$pdo->commit();
} catch (Throwable $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
// Buchung fehlgeschlagen -> in der Warteschlange belassen.
return ['status' => 'unmatched', 'payment_id' => $paymentId];
}
app_audit_log($pdo, $tenantId, $actorUserId, 'paypal_import.auto_booked', 'paypal_payment', $paymentId, [
'transaction_code' => $code,
'net_cents' => $netCents,
'participant_id' => (int) $participant['participant_id'],
]);
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,
$parsed['payer_email'] ?? 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.
*
* @param string $recipient Empfaengeradresse mit Plus-Token
* @param string $fromHeader From-Header der Mail
* @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, bool $previewOnly = false): array
{
if (!paypal_from_is_paypal($fromHeader)) {
return ['status' => 'not_from_paypal'];
}
$token = paypal_inbox_extract_token($recipient);
if ($token === null) {
return ['status' => 'no_token'];
}
$tenantId = paypal_inbox_resolve_tenant($pdo, $token);
if ($tenantId === null) {
return ['status' => 'unknown_tenant'];
}
$parsed = paypal_parse_notification($rawBody);
if ($parsed === null) {
return ['status' => 'not_a_payment'];
}
// Die eigene Eingangsadresse steht in jeder weitergeleiteten Mail und darf
// niemals als Adresse des Zahlers durchgehen - sonst wuerde eine Zahlung
// dem Mitglied gutgeschrieben, dem diese Adresse gehoert. Die Pruefung
// haengt bewusst an der tatsaechlichen Empfaengeradresse und nicht nur an
// der Konfiguration.
if (paypal_same_mailbox((string) ($parsed['payer_email'] ?? ''), $recipient)) {
$parsed['payer_email'] = null;
}
$result = $previewOnly
? paypal_preview($pdo, $tenantId, $parsed)
: paypal_reconcile($pdo, $tenantId, $parsed);
$result['tenant_id'] = $tenantId;
return $result;
}
/**
* Offene (noch nicht zugeordnete) PayPal-Zahlungen eines Mandanten.
*
* @return list<array>
*/
function paypal_fetch_unmatched(PDO $pdo, int $tenantId): array
{
$stmt = $pdo->prepare(
"SELECT id, transaction_code, payer_name, payer_email, note, net_cents, paid_at, created_at
FROM paypal_payments
WHERE tenant_id = ? AND status = 'unmatched'
ORDER BY created_at DESC, id DESC"
);
$stmt->execute([$tenantId]);
return $stmt->fetchAll();
}
/**
* Ordnet eine Zahlung aus der Warteschlange manuell einem Teilnehmer zu und
* bucht den Netto-Betrag als Einzahlung.
*
* @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 = ?");
$stmt->execute([$paymentId, $tenantId]);
$payment = $stmt->fetch();
if ($payment === false || (string) $payment['status'] !== 'unmatched') {
return ['ok' => false, 'error' => 'Diese Zahlung ist nicht (mehr) offen.'];
}
$summaries = ledger_fetch_participant_summaries($pdo, $tenantId, ['participant_ids' => [$participantId]]);
$participant = $summaries[0] ?? null;
if ($participant === null) {
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()) {
$pdo->rollBack();
}
return ['ok' => false, 'error' => 'Die Zahlung konnte nicht gebucht werden.'];
}
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, 'learned_paypal_name' => $gemerkterName];
}
/**
* Verwirft eine offene Zahlung aus der Warteschlange (z. B. Fehleingang, keine
* Kaffeelisten-Zahlung).
*/
function paypal_ignore_payment(PDO $pdo, int $tenantId, int $paymentId, ?int $actorUserId): array
{
$stmt = $pdo->prepare("UPDATE paypal_payments SET status = 'ignored' WHERE id = ? AND tenant_id = ? AND status = 'unmatched'");
$stmt->execute([$paymentId, $tenantId]);
if ($stmt->rowCount() === 1) {
app_audit_log($pdo, $tenantId, $actorUserId, 'paypal_import.ignored', 'paypal_payment', $paymentId);
return ['ok' => true];
}
return ['ok' => false, 'error' => 'Diese Zahlung ist nicht (mehr) offen.'];
}
/**
* Anzahl der offenen Zahlungen in der Warteschlange. Bewusst als eigene
* Zaehl-Abfrage neben paypal_fetch_unmatched(): die Navigation braucht nur
* die Information "gibt es noch etwas zu tun", nicht die Datensaetze.
*/
function paypal_count_unmatched(PDO $pdo, int $tenantId): int
{
static $cache = [];
if (array_key_exists($tenantId, $cache)) {
return $cache[$tenantId];
}
try {
$stmt = $pdo->prepare(
"SELECT COUNT(*) FROM paypal_payments WHERE tenant_id = ? AND status = 'unmatched'"
);
$stmt->execute([$tenantId]);
$cache[$tenantId] = (int)$stmt->fetchColumn();
} catch (Throwable $e) {
// Fehlt die Tabelle, gibt es auch keine Warteschlange - die
// Navigation darf daran nicht scheitern.
$cache[$tenantId] = 0;
}
return $cache[$tenantId];
}
/**
* Darf die Zuordnungsseite geoeffnet werden? Die Funktion kennt drei Faelle:
* der Betreiber hat gesperrt (nein), der Mandant bietet PayPal an (ja) - und
* dazwischen der Fall, dass der Mandant PayPal gerade abgeschaltet hat,
* aber noch Zahlungen unzugeordnet in der Warteschlange liegen. Die waeren
* sonst weder ueber das Menue noch ueber die URL erreichbar; die Seite bleibt
* deshalb offen, bis die Warteschlange leer ist.
*/
function paypal_inbox_accessible(PDO $pdo, int $tenantId): bool
{
require_once __DIR__ . '/features.php';
if (!app_feature_enabled($pdo, $tenantId, 'paypal_inbox')) {
return false;
}
if (app_feature_available($pdo, $tenantId, 'paypal_inbox')) {
return true;
}
return paypal_count_unmatched($pdo, $tenantId) > 0;
}