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>
331 lines
11 KiB
PHP
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.'];
|
|
}
|