Automatische Verbuchung von PayPal-Zahlungen per Mail-Weiterleitung
Mandanten leiten ihre PayPal-Benachrichtigungsmails an ein zentrales IMAP-Postfach weiter. Die Zuordnung zum Mandanten erfolgt ueber Plus-Adressierung (zahlungen+<token>@...), der Token wird pro Mandant erzeugt und im Backend angezeigt. - Parser fuer PayPal-Eingangsmails, tolerant gegenueber falsch kodierten Waehrungssymbolen und HTML-Struktur weitergeleiteter Mails - Gutgeschrieben wird der Nettobetrag, also was tatsaechlich ankam (bei Waren & Dienstleistungen nach Abzug der PayPal-Gebuehr) - Deduplizierung ueber den Transaktionscode, damit doppelt weitergeleitete Mails nicht doppelt buchen - Eindeutiger Namens-Match bucht automatisch, alles andere landet in einer Warteschlange zur manuellen Zuordnung - Absenderpruefung gegen paypal.de/.com, da weitergeleitete Mails keine gueltige SPF/DKIM-Signatur mehr haben - IMAP-Zugang ausschliesslich ueber Server-/Env-Einstellungen, nicht pro Mandant konfigurierbar Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,326 @@
|
|||||||
|
<?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): 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<array>
|
||||||
|
*/
|
||||||
|
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.'];
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parser fuer PayPal-Zahlungseingangs-Mails ("Du hast eine Zahlung erhalten").
|
||||||
|
*
|
||||||
|
* Bewusst tolerant gegenueber dem konkreten Waehrungssymbol (in weitergeleiteten
|
||||||
|
* Mails oft falsch kodiert) und der HTML-Struktur: der Text wird zunaechst von
|
||||||
|
* Tags befreit, Entities/NBSP normalisiert, dann ueber robuste Marker
|
||||||
|
* ("… hat dir … EUR gesendet", "Transaktionscode", "Erhaltener Betrag",
|
||||||
|
* "Gebuehr", "Summe", "Transaktionsdatum") ausgewertet. Der Transaktionscode
|
||||||
|
* wird primaer aus der Detail-URL /activities/details/<CODE> gezogen, weil der
|
||||||
|
* dort am zuverlaessigsten steht.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wandelt den (ggf. UTF-16-kodierten) Mail-Body in normalisierten UTF-8-Text um:
|
||||||
|
* Skript/Style raus, Tags durch Leerzeichen ersetzt, Entities dekodiert, NBSP
|
||||||
|
* und Mehrfach-Whitespace zusammengefasst.
|
||||||
|
*/
|
||||||
|
function paypal_mail_to_text(string $body): string
|
||||||
|
{
|
||||||
|
// UTF-16-BOM erkennen und nach UTF-8 wandeln (weitergeleitete .htm-Exporte).
|
||||||
|
if (str_starts_with($body, "\xFF\xFE") || str_starts_with($body, "\xFE\xFF")) {
|
||||||
|
$converted = @iconv('UTF-16', 'UTF-8//IGNORE', $body);
|
||||||
|
if ($converted !== false) {
|
||||||
|
$body = $converted;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$body = preg_replace('#<(script|style)[^>]*>.*?</\1>#is', ' ', $body) ?? $body;
|
||||||
|
$text = preg_replace('#<[^>]+>#', ' ', $body) ?? $body;
|
||||||
|
$text = html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||||||
|
$text = str_replace(["\xC2\xA0", "\xEF\xBB\xBF"], ' ', $text); // NBSP, BOM
|
||||||
|
$text = preg_replace('/\s+/u', ' ', $text) ?? $text;
|
||||||
|
|
||||||
|
return trim($text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deutscher Betrag ("1.234,56") -> Cent. Null bei ungueltigem Format.
|
||||||
|
*/
|
||||||
|
function paypal_amount_to_cents(string $raw): ?int
|
||||||
|
{
|
||||||
|
$raw = trim($raw);
|
||||||
|
$raw = str_replace('.', '', $raw); // Tausenderpunkte
|
||||||
|
$raw = str_replace(',', '.', $raw);
|
||||||
|
if (!preg_match('/^\d+(\.\d{1,2})?$/', $raw)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (int) round(((float) $raw) * 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deutsches Datum ("7. Juli 2026") -> "Y-m-d". Null, wenn nicht erkennbar.
|
||||||
|
*/
|
||||||
|
function paypal_german_date_to_iso(string $raw): ?string
|
||||||
|
{
|
||||||
|
$months = [
|
||||||
|
'januar' => 1, 'februar' => 2, 'märz' => 3, 'maerz' => 3, 'april' => 4,
|
||||||
|
'mai' => 5, 'juni' => 6, 'juli' => 7, 'august' => 8, 'september' => 9,
|
||||||
|
'oktober' => 10, 'november' => 11, 'dezember' => 12,
|
||||||
|
];
|
||||||
|
if (!preg_match('/(\d{1,2})\.\s*([\p{L}]+)\s+(\d{4})/u', $raw, $m)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$day = (int) $m[1];
|
||||||
|
$monthName = mb_strtolower_compat($m[2]);
|
||||||
|
$year = (int) $m[3];
|
||||||
|
if (!isset($months[$monthName])) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return sprintf('%04d-%02d-%02d', $year, $months[$monthName], $day);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kleinschreibung ohne mbstring-Abhaengigkeit (nur fuer deutsche Monatsnamen
|
||||||
|
* inkl. Umlaut noetig).
|
||||||
|
*/
|
||||||
|
function mb_strtolower_compat(string $s): string
|
||||||
|
{
|
||||||
|
if (function_exists('mb_strtolower')) {
|
||||||
|
return mb_strtolower($s, 'UTF-8');
|
||||||
|
}
|
||||||
|
$s = strtr($s, ['Ä' => 'ä', 'Ö' => 'ö', 'Ü' => 'ü']);
|
||||||
|
|
||||||
|
return strtolower($s);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Betrag in Cent aus dem Text nach einem Label ("Erhaltener Betrag" etc.).
|
||||||
|
* Waehrungssymbol zwischen Zahl und "EUR" wird ignoriert.
|
||||||
|
*/
|
||||||
|
function paypal_extract_labeled_amount(string $text, string $labelRegex): ?int
|
||||||
|
{
|
||||||
|
if (preg_match('/' . $labelRegex . '\s*([\d.]+,\d{2})\s*\S*\s*EUR/u', $text, $m)) {
|
||||||
|
return paypal_amount_to_cents($m[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parst eine PayPal-Zahlungseingangs-Mail.
|
||||||
|
*
|
||||||
|
* @return array{
|
||||||
|
* payer_name: string,
|
||||||
|
* transaction_code: ?string,
|
||||||
|
* gross_cents: int,
|
||||||
|
* fee_cents: ?int,
|
||||||
|
* net_cents: int,
|
||||||
|
* date: ?string,
|
||||||
|
* note: ?string
|
||||||
|
* }|null Null, wenn es keine erkennbare Zahlungseingangs-Mail ist.
|
||||||
|
*/
|
||||||
|
function paypal_parse_notification(string $body): ?array
|
||||||
|
{
|
||||||
|
$text = paypal_mail_to_text($body);
|
||||||
|
|
||||||
|
// Kernmarker: "<Name> hat dir <Betrag> … EUR gesendet". Fehlt er, ist es
|
||||||
|
// keine Zahlungseingangs-Mail (z. B. Werbung, Sicherheitsmail).
|
||||||
|
if (!preg_match('/([\p{L}\p{M}.\'\- ]{2,60}?)\s+hat dir\s+([\d.]+,\d{2})\s*\S*\s*EUR\s+gesendet/u', $text, $m)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$payer = trim($m[1]);
|
||||||
|
$grossCents = paypal_amount_to_cents($m[2]) ?? 0;
|
||||||
|
|
||||||
|
// Transaktionscode primaer aus der Detail-URL (steht dort am zuverlaessigsten),
|
||||||
|
// sonst nach dem Label.
|
||||||
|
$code = null;
|
||||||
|
if (preg_match('#/activities/details/([A-Z0-9]{12,25})#', $body, $mc)) {
|
||||||
|
$code = $mc[1];
|
||||||
|
} elseif (preg_match('/Transaktionscode\s+([A-Z0-9]{12,25})/u', $text, $mc)) {
|
||||||
|
$code = $mc[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
$receivedCents = paypal_extract_labeled_amount($text, 'Erhaltener Betrag');
|
||||||
|
$feeCents = paypal_extract_labeled_amount($text, 'Geb(?:ü|ue)hr');
|
||||||
|
$summeCents = paypal_extract_labeled_amount($text, 'Summe');
|
||||||
|
|
||||||
|
// Netto = tatsaechlich angekommener Betrag: bei Waren & Dienstleistungen die
|
||||||
|
// "Summe" (nach Abzug der Gebuehr), bei Freunde & Familie der erhaltene
|
||||||
|
// Betrag (keine Gebuehr, keine Summe-Zeile), im Zweifel der Sende-Betrag.
|
||||||
|
$netCents = $summeCents ?? $receivedCents ?? $grossCents;
|
||||||
|
|
||||||
|
$date = null;
|
||||||
|
if (preg_match('/Transaktionsdatum\s+(\d{1,2}\.\s*[\p{L}]+\s+\d{4})/u', $text, $md)) {
|
||||||
|
$date = paypal_german_date_to_iso($md[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mitteilung des Zahlers ("Mitteilung von <Name> <Text>") bis zum naechsten
|
||||||
|
// bekannten Abschnitt. Der Zahlername dient als Anker, da er Leerzeichen
|
||||||
|
// enthalten kann.
|
||||||
|
$note = null;
|
||||||
|
$noteRegex = '/Mitteilung von\s+' . preg_quote($payer, '/')
|
||||||
|
. '\s+(.+?)\s*(?:Geb(?:ü|ue)hr|Summe|Erhaltener Betrag|Transaktions|Lieferadresse|Du siehst|Bist du|Copyright|$)/u';
|
||||||
|
if (preg_match($noteRegex, $text, $mn)) {
|
||||||
|
$note = trim($mn[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'payer_name' => $payer,
|
||||||
|
'transaction_code' => $code,
|
||||||
|
'gross_cents' => $grossCents,
|
||||||
|
'fee_cents' => $feeCents,
|
||||||
|
'net_cents' => $netCents,
|
||||||
|
'date' => $date,
|
||||||
|
'note' => $note,
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
-- Automatischer PayPal-Abgleich per weitergeleiteter Benachrichtigungsmail.
|
||||||
|
--
|
||||||
|
-- Jeder Mandant bekommt einen eindeutigen Token fuer die Plus-Adresse
|
||||||
|
-- (z. B. zahlungen+<token>@domain). Der Admin richtet eine Weiterleitung von
|
||||||
|
-- PayPal an diese Adresse ein; der Token ordnet die Mail eindeutig dem
|
||||||
|
-- Mandanten zu. Der Token ist nicht sensibel und wird bei Bedarf erzeugt.
|
||||||
|
ALTER TABLE tenants
|
||||||
|
ADD COLUMN paypal_inbox_token VARCHAR(32) NULL AFTER slug,
|
||||||
|
ADD UNIQUE KEY uq_tenants_paypal_inbox_token (paypal_inbox_token);
|
||||||
|
|
||||||
|
-- Erkannte PayPal-Zahlungen: Dedup ueber den global eindeutigen
|
||||||
|
-- Transaktionscode und gleichzeitig Warteschlange fuer nicht eindeutig
|
||||||
|
-- zuordenbare Zahlungen (participant_id IS NULL, status 'unmatched').
|
||||||
|
CREATE TABLE IF NOT EXISTS paypal_payments (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
tenant_id INT NOT NULL,
|
||||||
|
transaction_code VARCHAR(40) NOT NULL,
|
||||||
|
payer_name VARCHAR(255) NOT NULL DEFAULT '',
|
||||||
|
note VARCHAR(255) NULL,
|
||||||
|
gross_cents INT NOT NULL DEFAULT 0,
|
||||||
|
fee_cents INT NULL,
|
||||||
|
net_cents INT NOT NULL DEFAULT 0,
|
||||||
|
paid_at DATE NULL,
|
||||||
|
participant_id INT NULL,
|
||||||
|
ledger_entry_id INT NULL,
|
||||||
|
status VARCHAR(20) NOT NULL DEFAULT 'unmatched',
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE KEY uq_paypal_payments_txn (transaction_code),
|
||||||
|
KEY idx_paypal_payments_tenant_status (tenant_id, status),
|
||||||
|
CONSTRAINT fk_paypal_payments_tenant
|
||||||
|
FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT fk_paypal_payments_participant
|
||||||
|
FOREIGN KEY (participant_id) REFERENCES participants(id) ON DELETE SET NULL
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
@@ -41,6 +41,22 @@ putenv('STRIPE_WEBHOOK_SECRET=');
|
|||||||
putenv('DOLIBARR_URL=https://verwaltung.ctb-it.de');
|
putenv('DOLIBARR_URL=https://verwaltung.ctb-it.de');
|
||||||
putenv('DOLIBARR_API_KEY=');
|
putenv('DOLIBARR_API_KEY=');
|
||||||
|
|
||||||
|
// Automatische Verbuchung weitergeleiteter PayPal-Zahlungsmails.
|
||||||
|
// PAYPAL_INBOX_BASE ist die zentrale Sammeladresse; jeder Mandant bekommt
|
||||||
|
// daraus per Plus-Adressierung eine eigene Variante (zahlungen+<token>@...),
|
||||||
|
// die im Backend unter "PayPal-Zahlungen" angezeigt wird. Das Postfach muss
|
||||||
|
// Plus-Adressierung an dieselbe Mailbox zustellen (Catch-All).
|
||||||
|
// Die Zugangsdaten sind bewusst Server-/Betreiber-Einstellungen und NICHT
|
||||||
|
// pro Mandant konfigurierbar. Abruf per Cron:
|
||||||
|
// php scripts/fetch-paypal-payments.php
|
||||||
|
putenv('PAYPAL_INBOX_BASE=zahlungen@kaffeeliste.de');
|
||||||
|
putenv('PAYPAL_IMAP_HOST=');
|
||||||
|
putenv('PAYPAL_IMAP_PORT=993');
|
||||||
|
putenv('PAYPAL_IMAP_FLAGS=/imap/ssl');
|
||||||
|
putenv('PAYPAL_IMAP_USER=');
|
||||||
|
putenv('PAYPAL_IMAP_PASS=');
|
||||||
|
putenv('PAYPAL_IMAP_MAILBOX=INBOX');
|
||||||
|
|
||||||
// WICHTIG: DEV_AUTH_EMAIL hier NICHT setzen (auch nicht auskommentiert
|
// WICHTIG: DEV_AUTH_EMAIL hier NICHT setzen (auch nicht auskommentiert
|
||||||
// stehen lassen und aus Versehen aktivieren). Ist diese Variable gesetzt,
|
// stehen lassen und aus Versehen aktivieren). Ist diese Variable gesetzt,
|
||||||
// wird JEDER Besucher ohne echten Login automatisch als dieser Nutzer
|
// wird JEDER Besucher ohne echten Login automatisch als dieser Nutzer
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ $canUseVerwaltungGroup = $saasCanUseLedgerNav || $legacyCanUseAdminNav;
|
|||||||
<span class="menu-group-label">Verwaltung</span>
|
<span class="menu-group-label">Verwaltung</span>
|
||||||
<ul class="menu-group-items">
|
<ul class="menu-group-items">
|
||||||
<li><a href="einzahlung.php">Einzahlung eintragen</a></li>
|
<li><a href="einzahlung.php">Einzahlung eintragen</a></li>
|
||||||
|
<li><a href="paypal-zuordnung.php">PayPal-Zahlungen</a></li>
|
||||||
<li><a href="stricheintragen.php">Striche eintragen</a></li>
|
<li><a href="stricheintragen.php">Striche eintragen</a></li>
|
||||||
<li><a href="kaffeeliste.php">Kaffeeliste anzeigen</a></li>
|
<li><a href="kaffeeliste.php">Kaffeeliste anzeigen</a></li>
|
||||||
<li><a href="ledger-preview.php">Journal-Vorschau</a></li>
|
<li><a href="ledger-preview.php">Journal-Vorschau</a></li>
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
include "functions.php";
|
||||||
|
require_once __DIR__ . "/app/ledger.php";
|
||||||
|
require_once __DIR__ . "/app/paypal-inbox.php";
|
||||||
|
app_require_csrf();
|
||||||
|
|
||||||
|
$pdo = app_db_pdo();
|
||||||
|
$saasUser = saas_current_user($pdo);
|
||||||
|
$tenantId = 0;
|
||||||
|
$hasAccess = false;
|
||||||
|
|
||||||
|
if ($saasUser !== null && saas_user_has_role(['owner', 'admin', 'treasurer'], $saasUser)) {
|
||||||
|
$tenantId = (int)$saasUser['tenant_id'];
|
||||||
|
$hasAccess = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$hasAccess && $saasUser === null && checkKaffeelisteAdmin($conn, $mailadress)) {
|
||||||
|
$tenant = ledger_fetch_default_tenant($pdo);
|
||||||
|
if ($tenant !== null) {
|
||||||
|
$tenantId = (int)$tenant['id'];
|
||||||
|
$hasAccess = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$flash = null;
|
||||||
|
if ($hasAccess && isset($_SESSION['flash_paypal'])) {
|
||||||
|
$flash = $_SESSION['flash_paypal'];
|
||||||
|
unset($_SESSION['flash_paypal']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($hasAccess && $_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
|
$aktion = (string)($_POST['aktion'] ?? '');
|
||||||
|
$paymentId = (int)($_POST['payment_id'] ?? 0);
|
||||||
|
$actorUserId = $saasUser['user_id'] ?? null;
|
||||||
|
|
||||||
|
if ($aktion === 'zuordnen') {
|
||||||
|
$participantId = (int)($_POST['participant_id'] ?? 0);
|
||||||
|
if ($participantId <= 0) {
|
||||||
|
$flash = ['type' => 'error', 'text' => 'Bitte ein Mitglied auswählen.'];
|
||||||
|
} else {
|
||||||
|
$result = paypal_assign_payment($pdo, $tenantId, $paymentId, $participantId, $actorUserId);
|
||||||
|
$flash = $result['ok']
|
||||||
|
? ['type' => 'success', 'text' => 'Zahlung wurde zugeordnet und als Einzahlung gebucht.']
|
||||||
|
: ['type' => 'error', 'text' => $result['error'] ?? 'Die Zuordnung ist fehlgeschlagen.'];
|
||||||
|
}
|
||||||
|
} elseif ($aktion === 'ignorieren') {
|
||||||
|
$result = paypal_ignore_payment($pdo, $tenantId, $paymentId, $actorUserId);
|
||||||
|
$flash = $result['ok']
|
||||||
|
? ['type' => 'success', 'text' => 'Zahlung wurde als erledigt markiert.']
|
||||||
|
: ['type' => 'error', 'text' => $result['error'] ?? 'Die Aktion ist fehlgeschlagen.'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Post-Redirect-Get gegen Doppelbuchung per Refresh.
|
||||||
|
$_SESSION['flash_paypal'] = $flash;
|
||||||
|
header('Location: paypal-zuordnung.php');
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$inboxAddress = null;
|
||||||
|
$offene = [];
|
||||||
|
$mitglieder = [];
|
||||||
|
if ($hasAccess) {
|
||||||
|
$token = paypal_inbox_ensure_token($pdo, $tenantId);
|
||||||
|
$inboxAddress = paypal_inbox_address_for_token($token);
|
||||||
|
$offene = paypal_fetch_unmatched($pdo, $tenantId);
|
||||||
|
$mitglieder = ledger_fetch_participant_summaries($pdo, $tenantId, ['active_only' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
include "header.php";
|
||||||
|
include "headerline.php";
|
||||||
|
include "nav.php";
|
||||||
|
?>
|
||||||
|
|
||||||
|
<section id="banner">
|
||||||
|
<div class="content">
|
||||||
|
|
||||||
|
<?php if (!$hasAccess): ?>
|
||||||
|
<h2>Kein Zugriff</h2>
|
||||||
|
<?php else: ?>
|
||||||
|
|
||||||
|
<h2>PayPal-Zahlungen zuordnen</h2>
|
||||||
|
|
||||||
|
<?php if ($flash !== null): ?>
|
||||||
|
<div class="hint-box <?php echo $flash['type'] === 'success' ? 'success' : 'error'; ?>"><p><?php echo saas_html($flash['text']); ?></p></div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<h3>So richtet ihr die automatische Verbuchung ein</h3>
|
||||||
|
<?php if ($inboxAddress !== null): ?>
|
||||||
|
<p>Leitet die PayPal-Benachrichtigungsmails („Du hast eine Zahlung erhalten") automatisch an eure persönliche Eingangsadresse weiter:</p>
|
||||||
|
<p><b><?php echo saas_html($inboxAddress); ?></b></p>
|
||||||
|
<p>Eingehende Zahlungen werden dann automatisch dem passenden Mitglied gutgeschrieben (gebuchter Betrag = tatsächlich eingegangener Betrag nach PayPal-Gebühr). Zahlungen, die sich nicht eindeutig zuordnen lassen, erscheinen unten zur manuellen Zuordnung.</p>
|
||||||
|
<p><small>Tipp: Am zuverlässigsten trefft ihr die Zuordnung, wenn beim Mitglied der PayPal-Name hinterlegt ist (unter „Mitglieder verwalten").</small></p>
|
||||||
|
<?php else: ?>
|
||||||
|
<div class="hint-box error"><p>Die zentrale Eingangsadresse ist noch nicht konfiguriert. Bitte wende dich an den Betreiber (Server-Einstellung <code>PAYPAL_INBOX_BASE</code>).</p></div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<h3>Offene Zahlungen (<?php echo count($offene); ?>)</h3>
|
||||||
|
<?php if ($offene === []): ?>
|
||||||
|
<p>Keine offenen Zahlungen. Alles automatisch zugeordnet. 🎉</p>
|
||||||
|
<?php else: ?>
|
||||||
|
<table>
|
||||||
|
<tr><th>Eingang</th><th>Zahler (PayPal)</th><th>Mitteilung</th><th>Betrag</th><th>Zuordnen</th><th></th></tr>
|
||||||
|
<?php foreach ($offene as $z): ?>
|
||||||
|
<tr>
|
||||||
|
<td><?php echo saas_html((string)($z['paid_at'] ?? $z['created_at'])); ?></td>
|
||||||
|
<td><?php echo saas_html($z['payer_name']); ?></td>
|
||||||
|
<td><?php echo saas_html((string)($z['note'] ?? '')); ?></td>
|
||||||
|
<td><?php echo saas_html(saas_format_money_cents((int)$z['net_cents'])); ?> €</td>
|
||||||
|
<td>
|
||||||
|
<form method="post" action="paypal-zuordnung.php" style="display:flex;gap:0.5em;align-items:center;margin:0">
|
||||||
|
<?php echo app_csrf_field(); ?>
|
||||||
|
<input type="hidden" name="aktion" value="zuordnen">
|
||||||
|
<input type="hidden" name="payment_id" value="<?php echo (int)$z['id']; ?>">
|
||||||
|
<select name="participant_id" required>
|
||||||
|
<option value="">– Mitglied wählen –</option>
|
||||||
|
<?php foreach ($mitglieder as $m): ?>
|
||||||
|
<option value="<?php echo (int)$m['participant_id']; ?>"><?php echo saas_html($m['display_name']); ?></option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
<button type="submit">Buchen</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<form method="post" action="paypal-zuordnung.php" style="margin:0" onsubmit="return confirm('Diese Zahlung als erledigt/ignoriert markieren?');">
|
||||||
|
<?php echo app_csrf_field(); ?>
|
||||||
|
<input type="hidden" name="aktion" value="ignorieren">
|
||||||
|
<input type="hidden" name="payment_id" value="<?php echo (int)$z['id']; ?>">
|
||||||
|
<button type="submit" class="alt">Ignorieren</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</table>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<?php include "footer.php"; ?>
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Holt PayPal-Zahlungseingangs-Mails aus dem zentralen IMAP-Postfach ab und
|
||||||
|
* verbucht sie automatisch (fuer Cron gedacht, z. B. alle paar Minuten).
|
||||||
|
*
|
||||||
|
* Ablauf pro ungelesener Mail:
|
||||||
|
* 1. Mandant ueber den Plus-Token der Empfaengeradresse bestimmen
|
||||||
|
* (zahlungen+<token>@…).
|
||||||
|
* 2. Absender gegen paypal.de/.com pruefen.
|
||||||
|
* 3. Body parsen, Netto ermitteln, per Transaktionscode deduplizieren.
|
||||||
|
* 4. Eindeutiger Namens-Match -> automatisch als Einzahlung buchen,
|
||||||
|
* sonst in die Warteschlange legen.
|
||||||
|
*
|
||||||
|
* Konfiguration ausschliesslich ueber Server-/Env-Einstellungen (Betreiber):
|
||||||
|
* PAYPAL_INBOX_BASE z. B. zahlungen@kaffeeliste.de
|
||||||
|
* PAYPAL_IMAP_HOST Mailserver-Host
|
||||||
|
* PAYPAL_IMAP_PORT (Standard 993)
|
||||||
|
* PAYPAL_IMAP_FLAGS (Standard "/imap/ssl")
|
||||||
|
* PAYPAL_IMAP_USER Postfach-Benutzer
|
||||||
|
* PAYPAL_IMAP_PASS Postfach-Passwort
|
||||||
|
* PAYPAL_IMAP_MAILBOX (Standard "INBOX")
|
||||||
|
*
|
||||||
|
* Testmodus ohne IMAP (eine Rohmail aus einer Datei verarbeiten):
|
||||||
|
* php scripts/fetch-paypal-payments.php --file=mail.html \
|
||||||
|
* --recipient=zahlungen+abc123@kaffeeliste.de --from=service@paypal.de
|
||||||
|
*/
|
||||||
|
|
||||||
|
if (PHP_SAPI !== 'cli') {
|
||||||
|
http_response_code(403);
|
||||||
|
exit("Nur fuer die Kommandozeile.\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../app/paypal-inbox.php';
|
||||||
|
|
||||||
|
$pdo = app_db_pdo();
|
||||||
|
$opts = getopt('', ['file:', 'recipient:', 'from:', 'dry-run']);
|
||||||
|
|
||||||
|
// ---- Testmodus: eine Datei verarbeiten (ohne IMAP) --------------------------
|
||||||
|
if (isset($opts['file'])) {
|
||||||
|
$body = @file_get_contents($opts['file']);
|
||||||
|
if ($body === false) {
|
||||||
|
fwrite(STDERR, "Datei nicht lesbar: {$opts['file']}\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
$recipient = (string) ($opts['recipient'] ?? '');
|
||||||
|
$from = (string) ($opts['from'] ?? 'service@paypal.de');
|
||||||
|
$result = paypal_process_raw($pdo, $recipient, $from, $body);
|
||||||
|
echo 'Ergebnis: ' . json_encode($result, JSON_UNESCAPED_UNICODE) . "\n";
|
||||||
|
exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- IMAP-Modus -------------------------------------------------------------
|
||||||
|
if (!function_exists('imap_open')) {
|
||||||
|
fwrite(STDERR, "Die PHP-IMAP-Erweiterung ist nicht installiert. Auf dem Server 'php-imap' aktivieren.\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
$host = app_env('PAYPAL_IMAP_HOST');
|
||||||
|
$user = app_env('PAYPAL_IMAP_USER');
|
||||||
|
$pass = app_env('PAYPAL_IMAP_PASS');
|
||||||
|
if ($host === null || $user === null || $pass === null) {
|
||||||
|
fwrite(STDERR, "PAYPAL_IMAP_HOST/USER/PASS sind nicht gesetzt.\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
$port = (int) (app_env('PAYPAL_IMAP_PORT', '993'));
|
||||||
|
$flags = app_env('PAYPAL_IMAP_FLAGS', '/imap/ssl');
|
||||||
|
$mailboxName = app_env('PAYPAL_IMAP_MAILBOX', 'INBOX');
|
||||||
|
$dryRun = isset($opts['dry-run']);
|
||||||
|
|
||||||
|
$ref = '{' . $host . ':' . $port . $flags . '}';
|
||||||
|
$mbox = @imap_open($ref . $mailboxName, $user, $pass);
|
||||||
|
if ($mbox === false) {
|
||||||
|
fwrite(STDERR, 'IMAP-Verbindung fehlgeschlagen: ' . imap_last_error() . "\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liefert den bevorzugten Textkoerper (HTML, sonst Plain) einer Mail als
|
||||||
|
* dekodierten String.
|
||||||
|
*/
|
||||||
|
function paypal_imap_fetch_body($mbox, int $msgno): string
|
||||||
|
{
|
||||||
|
$structure = imap_fetchstructure($mbox, $msgno);
|
||||||
|
// Einfache Mail ohne Parts.
|
||||||
|
if (!isset($structure->parts) || !is_array($structure->parts)) {
|
||||||
|
$raw = imap_body($mbox, $msgno);
|
||||||
|
return paypal_imap_decode_part($raw, $structure->encoding ?? 0);
|
||||||
|
}
|
||||||
|
$htmlBody = null;
|
||||||
|
$plainBody = null;
|
||||||
|
foreach ($structure->parts as $i => $part) {
|
||||||
|
$partNo = (string) ($i + 1);
|
||||||
|
$data = imap_fetchbody($mbox, $msgno, $partNo);
|
||||||
|
$decoded = paypal_imap_decode_part($data, $part->encoding ?? 0);
|
||||||
|
$subtype = strtoupper((string) ($part->subtype ?? ''));
|
||||||
|
if ($subtype === 'HTML') {
|
||||||
|
$htmlBody = $decoded;
|
||||||
|
} elseif ($subtype === 'PLAIN') {
|
||||||
|
$plainBody = $decoded;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $htmlBody ?? $plainBody ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function paypal_imap_decode_part(string $data, int $encoding): string
|
||||||
|
{
|
||||||
|
// 3 = BASE64, 4 = QUOTED-PRINTABLE (imap-Konstanten)
|
||||||
|
if ($encoding === 3) {
|
||||||
|
return (string) base64_decode($data, false);
|
||||||
|
}
|
||||||
|
if ($encoding === 4) {
|
||||||
|
return quoted_printable_decode($data);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
$messageCount = imap_num_msg($mbox);
|
||||||
|
$processed = 0;
|
||||||
|
$booked = 0;
|
||||||
|
$queued = 0;
|
||||||
|
$skipped = 0;
|
||||||
|
|
||||||
|
for ($msgno = 1; $msgno <= $messageCount; $msgno++) {
|
||||||
|
$overview = imap_fetch_overview($mbox, (string) $msgno, 0)[0] ?? null;
|
||||||
|
if ($overview === null || (int) ($overview->seen ?? 0) === 1) {
|
||||||
|
continue; // nur ungelesene
|
||||||
|
}
|
||||||
|
$processed++;
|
||||||
|
|
||||||
|
// Empfaengeradressen (inkl. Weiterleitungs-Header) nach dem Plus-Token
|
||||||
|
// durchsuchen.
|
||||||
|
$rawHeader = imap_fetchheader($mbox, $msgno);
|
||||||
|
$recipient = '';
|
||||||
|
if (preg_match('/^(?:Delivered-To|X-Original-To|To|X-Forwarded-To):\s*(.+)$/im', $rawHeader, $mm)) {
|
||||||
|
$recipient = trim($mm[1]);
|
||||||
|
}
|
||||||
|
// Falls mehrere Empfaengerzeilen: alle zusammenfuehren, damit der Token
|
||||||
|
// sicher gefunden wird.
|
||||||
|
if (preg_match_all('/^(?:Delivered-To|X-Original-To|To|X-Forwarded-To):\s*(.+)$/im', $rawHeader, $all)) {
|
||||||
|
$recipient = implode(' ', $all[1]);
|
||||||
|
}
|
||||||
|
$from = (string) ($overview->from ?? '');
|
||||||
|
|
||||||
|
$body = paypal_imap_fetch_body($mbox, $msgno);
|
||||||
|
$result = paypal_process_raw($pdo, $recipient, $from, $body);
|
||||||
|
|
||||||
|
switch ($result['status']) {
|
||||||
|
case 'booked':
|
||||||
|
$booked++;
|
||||||
|
break;
|
||||||
|
case 'unmatched':
|
||||||
|
$queued++;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
$skipped++;
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "Mail #{$msgno}: {$result['status']}"
|
||||||
|
. (isset($result['tenant_id']) ? " (Mandant {$result['tenant_id']})" : '') . "\n";
|
||||||
|
|
||||||
|
// Als gelesen markieren, damit sie nicht erneut verarbeitet wird.
|
||||||
|
if (!$dryRun) {
|
||||||
|
imap_setflag_full($mbox, (string) $msgno, '\\Seen');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
imap_close($mbox);
|
||||||
|
|
||||||
|
echo "Fertig: {$processed} verarbeitet, {$booked} gebucht, {$queued} in Warteschlange, {$skipped} uebersprungen"
|
||||||
|
. ($dryRun ? ' (dry-run, nichts als gelesen markiert)' : '') . ".\n";
|
||||||
Reference in New Issue
Block a user