Files
kaffeekasse-saas/app/paypal-inbox.php
T
clemensandClaude Opus 4.8 eec7a2fef2 Legacy-Abbau Schritt 1: Dual-Write in die kl_*-Tabellen entfernt
Die Migration in participants/ledger_entries ist abgeschlossen: kein
Teilnehmer traegt noch eine legacy_mitarbeiter_id, kein Journaleintrag eine
legacy_table. Damit war der jeweils zweite Zweig ("Default-Mandant schreibt
zusaetzlich nach kl_Einzahlungen/kl_Kaffeeverbrauch/kl_Mitarbeiter") in
sechs Dateien nicht mehr erreichbar - er musste aber bei jeder Aenderung
mitgepflegt werden, zuletzt bei der Bemerkung fuer Einzahlungen.

Entfernt:
- Dual-Write beim Buchen: einzahlung.php, stricheintragen.php, index.php,
  jahresauswertung.php, app/imports.php, app/paypal-inbox.php
- Dual-Write in der Mitgliederverwaltung: ledger_create_participant,
  ledger_update_participant, ledger_set_participant_active,
  ledger_anonymize_participant
- die verwaisten Spiegelfunktionen ledger_mirror_legacy_payment,
  ledger_mirror_legacy_consumption und ledger_void_entry_by_legacy_id

Nebenbei behoben: kaffeeliste.php hat die Teilnehmer-Detailseite nur fuer
Mitglieder mit legacy_mitarbeiter_id verlinkt - die hat seit der Migration
niemand mehr, die Seite war also fuer alle unerreichbar. Verlinkt und
adressiert wird jetzt ueber participant_id; "user_id" bleibt als Alias
erhalten. Der Mandanten-Isolationstest prueft jetzt ledger_void_entry, also
den Pfad, den letzteneintraege.php tatsaechlich nutzt.

Pruefskripte unveraendert gegenueber der Baseline vor dem Umbau
(http-smoke 27/6, role-matrix 55/0); die Isolationspruefung steigt von
9 auf 11 PASS bei gleichem vorbestehendem Fehler.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 20:57:49 +02:00

331 lines
11 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 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, 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']];
}
/**
* 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, 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.'];
}
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]);
$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.'];
}