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): int { $legacyMitarbeiterId = isset($participant['legacy_mitarbeiter_id']) && $participant['legacy_mitarbeiter_id'] !== null ? (int) $participant['legacy_mitarbeiter_id'] : 0; if ($legacyMitarbeiterId > 0) { $stmt = $pdo->prepare('INSERT INTO kl_Einzahlungen (MitarbeiterID, Betrag, Datum) VALUES (?, ?, ?)'); $stmt->execute([$legacyMitarbeiterId, $netCents / 100, date('Y-m-d H:i:s')]); $legacyPaymentId = (int) $pdo->lastInsertId(); ledger_mirror_legacy_payment($pdo, $tenantId, $legacyPaymentId); $idStmt = $pdo->prepare( "SELECT id FROM ledger_entries WHERE tenant_id = ? AND legacy_table = 'kl_Einzahlungen' AND legacy_id = ? LIMIT 1" ); $idStmt->execute([$tenantId, $legacyPaymentId]); return (int) $idStmt->fetchColumn(); } return ledger_record_payment($pdo, $tenantId, (int) $participant['participant_id'], $netCents, 'paypal_import', $actorUserId); } /** * Sucht den passenden Teilnehmer zu einer PayPal-Zahlung: zuerst ueber den * Zahlernamen (paypal_name/display_name), sonst ueber die Mitteilung (Mitglied * schreibt dort teils seinen Namen). Liefert null, wenn nicht eindeutig. * * @return array{participant_id:int, legacy_mitarbeiter_id:?int, display_name:string}|null */ function paypal_match_participant(PDO $pdo, int $tenantId, string $payerName, ?string $note): ?array { $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; } /** * 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. * * @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, note, gross_cents, fee_cents, net_cents, paid_at, status) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)' ); $insert->execute([ $tenantId, $code, (string) ($parsed['payer_name'] ?? ''), $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(); // Eindeutiger Match -> automatisch buchen. $participant = paypal_match_participant($pdo, $tenantId, (string) ($parsed['payer_name'] ?? ''), $parsed['note'] ?? null); if ($participant === null) { return ['status' => 'unmatched', 'payment_id' => $paymentId]; } try { $pdo->beginTransaction(); $ledgerId = paypal_book_payment($pdo, $tenantId, $participant, $netCents, $actorUserId); $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']]; } /** * 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): array { if (!preg_match('/@paypal\.(de|com)/i', $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']; } $result = paypal_reconcile($pdo, $tenantId, $parsed); $result['tenant_id'] = $tenantId; return $result; } /** * Offene (noch nicht zugeordnete) PayPal-Zahlungen eines Mandanten. * * @return list */ function paypal_fetch_unmatched(PDO $pdo, int $tenantId): array { $stmt = $pdo->prepare( "SELECT id, transaction_code, payer_name, 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} */ function paypal_assign_payment(PDO $pdo, int $tenantId, int $paymentId, int $participantId, ?int $actorUserId): array { $stmt = $pdo->prepare("SELECT id, net_cents, status 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.']; } try { $pdo->beginTransaction(); $ledgerId = paypal_book_payment($pdo, $tenantId, $participant, (int) $payment['net_cents'], $actorUserId); $pdo->prepare("UPDATE paypal_payments SET participant_id = ?, ledger_entry_id = ?, status = 'booked' WHERE id = ?") ->execute([$participantId, $ledgerId, $paymentId]); $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'], ]); return ['ok' => true]; } /** * 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.']; }