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:
2026-07-21 18:33:49 +02:00
co-authored by Claude Opus 4.8
parent c16b105f00
commit ea7d9a4714
7 changed files with 868 additions and 0 deletions
+326
View File
@@ -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.'];
}
+173
View File
@@ -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,
];
}