Die Pruefung suchte woertlich nach "@paypal.de" oder "@paypal.com". Mails aus einer Versand-Subdomain (e.paypal.de) oder einer Landesvariante (paypal.at, paypal.co.uk) fielen damit als not_from_paypal durch. Geprueft wird jetzt die Domain der Absenderadresse an ihrem Ende - "paypal.de. beispiel.com" ist damit weiterhin kein PayPal, waere bei einer Textsuche aber durchgerutscht. check-imap-support.php listet zusaetzlich die letzten 15 Mails mit Absender, Empfaengerzeilen und Betreff auf und sagt je Mail, ob der Absender als PayPal gilt. Damit laesst sich ein not_from_paypal in einem Lauf klaeren statt zu raten. Rein lesend: OP_READONLY, kein \Seen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
613 lines
21 KiB
PHP
613 lines
21 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 die
|
|
* Mailadresse des Zahlers, dann ueber den Zahlernamen
|
|
* (paypal_name/display_name), zuletzt ueber die Mitteilung (Mitglied schreibt
|
|
* dort teils seinen Namen). Liefert null, wenn nicht eindeutig.
|
|
*
|
|
* @return array{participant_id:int, display_name:string}|null
|
|
*/
|
|
function paypal_match_participant(PDO $pdo, int $tenantId, string $payerName, ?string $note, ?string $payerEmail = null): ?array
|
|
{
|
|
// Die Mailadresse zuerst: sie ist im Mandanten eindeutig (Unique Key auf
|
|
// tenant_id + email_norm) und aendert sich nicht, wenn jemand bei PayPal
|
|
// unter einem anderen Namen auftritt.
|
|
$byEmail = paypal_find_participant_by_email($pdo, $tenantId, $payerEmail);
|
|
if ($byEmail !== null) {
|
|
return $byEmail;
|
|
}
|
|
|
|
$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;
|
|
}
|
|
|
|
/**
|
|
* Stammt die Mail wirklich von PayPal? Geprueft wird die Domain der
|
|
* Absenderadresse - inklusive Landesvarianten (paypal.at, paypal.co.uk) und
|
|
* Versand-Subdomains (e.paypal.de), die PayPal tatsaechlich benutzt.
|
|
*
|
|
* Der Vergleich haengt bewusst am Ende der Domain: "paypal.de.beispiel.com"
|
|
* ist NICHT PayPal, wuerde bei einer simplen Textsuche nach "@paypal.de"
|
|
* aber durchrutschen - und damit koennte jemand mit Kenntnis der
|
|
* Eingangsadresse Zahlungen erfinden.
|
|
*/
|
|
function paypal_from_is_paypal(string $fromHeader): bool
|
|
{
|
|
if (!preg_match_all('/[\w.+-]+@([\w-]+(?:\.[\w-]+)+)/u', strtolower($fromHeader), $treffer)) {
|
|
return false;
|
|
}
|
|
|
|
foreach ($treffer[1] as $domain) {
|
|
if (preg_match('/(^|\.)paypal\.[a-z]{2,}(\.[a-z]{2,})?$/', $domain)) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Gehoeren zwei Adressen zum selben Postfach? Plus-Adressierung wird dabei
|
|
* ignoriert, "zahlungen+ab12cd@host" ist also dasselbe Postfach wie
|
|
* "zahlungen@host". $kandidat darf mehrere Adressen enthalten (die
|
|
* Empfaengerzeilen einer weitergeleiteten Mail).
|
|
*/
|
|
function paypal_same_mailbox(string $adresse, string $kandidat): bool
|
|
{
|
|
$adresse = strtolower(trim($adresse));
|
|
if ($adresse === '' || !str_contains($adresse, '@')) {
|
|
return false;
|
|
}
|
|
[$lokal, $domain] = explode('@', $adresse, 2);
|
|
$lokal = explode('+', $lokal)[0];
|
|
|
|
if (!preg_match_all('/[\w.+-]+@[\w-]+(?:\.[\w-]+)+/u', strtolower($kandidat), $treffer)) {
|
|
return false;
|
|
}
|
|
foreach ($treffer[0] as $andere) {
|
|
[$andereLokal, $andereDomain] = explode('@', $andere, 2);
|
|
if (explode('+', $andereLokal)[0] === $lokal && $andereDomain === $domain) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Mitglied ueber seine Mailadresse finden. Null, wenn keine Adresse vorliegt
|
|
* oder kein Mitglied dazu passt.
|
|
*/
|
|
function paypal_find_participant_by_email(PDO $pdo, int $tenantId, ?string $email): ?array
|
|
{
|
|
$email = strtolower(trim((string) $email));
|
|
if ($email === '') {
|
|
return null;
|
|
}
|
|
|
|
$stmt = $pdo->prepare(
|
|
'SELECT id, display_name
|
|
FROM participants
|
|
WHERE tenant_id = ? AND email_norm = ?
|
|
ORDER BY id
|
|
LIMIT 2'
|
|
);
|
|
$stmt->execute([$tenantId, $email]);
|
|
$rows = $stmt->fetchAll();
|
|
if (count($rows) !== 1) {
|
|
return null;
|
|
}
|
|
|
|
return [
|
|
'participant_id' => (int) $rows[0]['id'],
|
|
'display_name' => (string) $rows[0]['display_name'],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
* - PayPal fuer den Mandanten abgeschaltet -> geparkt ('parked'): gespeichert,
|
|
* aber ohne automatische Buchung.
|
|
*
|
|
* @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, payer_email, note, gross_cents, fee_cents, net_cents, paid_at, status)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
|
|
);
|
|
$insert->execute([
|
|
$tenantId,
|
|
$code,
|
|
(string) ($parsed['payer_name'] ?? ''),
|
|
(string) ($parsed['payer_email'] ?? ''),
|
|
$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();
|
|
|
|
// Ist PayPal fuer diesen Mandanten abgeschaltet - vom Betreiber oder vom
|
|
// Mandanten selbst -, wird die Zahlung nur geparkt: gespeichert, aber
|
|
// nicht automatisch gutgeschrieben. Wegwerfen waere schlechter (eine
|
|
// echte Zahlung ginge unbemerkt verloren), automatisch buchen ebenfalls
|
|
// (es soll ja gerade nichts ueber PayPal laufen). Die Zuordnungsseite
|
|
// bleibt fuer offene Zahlungen erreichbar, dort entscheidet der Mandant.
|
|
require_once __DIR__ . '/features.php';
|
|
if (!app_feature_available($pdo, $tenantId, 'paypal_inbox')) {
|
|
return ['status' => 'parked', 'payment_id' => $paymentId];
|
|
}
|
|
|
|
// Eindeutiger Match -> automatisch buchen.
|
|
$participant = paypal_match_participant(
|
|
$pdo,
|
|
$tenantId,
|
|
(string) ($parsed['payer_name'] ?? ''),
|
|
$parsed['note'] ?? null,
|
|
$parsed['payer_email'] ?? 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']];
|
|
}
|
|
|
|
/**
|
|
* Vorschau ohne jeden Schreibzugriff: was wuerde mit dieser Zahlung
|
|
* passieren? Gedacht fuer den Probelauf des Abrufskripts (--dry-run). Der
|
|
* Probelauf soll wirklich nichts anfassen - eine Vorschau, die im
|
|
* Hintergrund bucht, waere schlimmer als gar keine.
|
|
*
|
|
* @param array $parsed Ergebnis von paypal_parse_notification()
|
|
* @return array{status:string, participant?:string, net_cents?:int}
|
|
*/
|
|
function paypal_preview(PDO $pdo, int $tenantId, array $parsed): 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 wie beim echten Lauf: der Transaktionscode ist global eindeutig.
|
|
$stmt = $pdo->prepare('SELECT id FROM paypal_payments WHERE transaction_code = ? LIMIT 1');
|
|
$stmt->execute([$code]);
|
|
if ($stmt->fetchColumn() !== false) {
|
|
return ['status' => 'duplicate'];
|
|
}
|
|
|
|
require_once __DIR__ . '/features.php';
|
|
if (!app_feature_available($pdo, $tenantId, 'paypal_inbox')) {
|
|
return ['status' => 'would_park', 'net_cents' => $netCents];
|
|
}
|
|
|
|
$participant = paypal_match_participant(
|
|
$pdo,
|
|
$tenantId,
|
|
(string) ($parsed['payer_name'] ?? ''),
|
|
$parsed['note'] ?? null,
|
|
$parsed['payer_email'] ?? null
|
|
);
|
|
if ($participant === null) {
|
|
return ['status' => 'would_queue', 'net_cents' => $netCents];
|
|
}
|
|
|
|
return [
|
|
'status' => 'would_book',
|
|
'participant' => (string) $participant['display_name'],
|
|
'net_cents' => $netCents,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 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, bool $previewOnly = false): array
|
|
{
|
|
if (!paypal_from_is_paypal($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'];
|
|
}
|
|
|
|
// Die eigene Eingangsadresse steht in jeder weitergeleiteten Mail und darf
|
|
// niemals als Adresse des Zahlers durchgehen - sonst wuerde eine Zahlung
|
|
// dem Mitglied gutgeschrieben, dem diese Adresse gehoert. Die Pruefung
|
|
// haengt bewusst an der tatsaechlichen Empfaengeradresse und nicht nur an
|
|
// der Konfiguration.
|
|
if (paypal_same_mailbox((string) ($parsed['payer_email'] ?? ''), $recipient)) {
|
|
$parsed['payer_email'] = null;
|
|
}
|
|
|
|
$result = $previewOnly
|
|
? paypal_preview($pdo, $tenantId, $parsed)
|
|
: 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, payer_email, 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}
|
|
*/
|
|
/**
|
|
* Soll der Zahlername als PayPal-Name beim Mitglied hinterlegt werden?
|
|
* Liefert den zu speichernden Namen oder null, wenn nichts zu lernen ist.
|
|
*
|
|
* Zurueckhaltend absichtlich: ein bereits gepflegter PayPal-Name wird nie
|
|
* ueberschrieben, ein Name gleich dem Anzeigenamen bringt nichts (der wird
|
|
* ohnehin geprueft), und wenn der Name im Mandanten nicht eindeutig waere,
|
|
* wuerde das Merken die automatische Zuordnung sogar blockieren - dann
|
|
* lieber weiter von Hand zuordnen.
|
|
*/
|
|
function paypal_remember_payer_name(PDO $pdo, int $tenantId, int $participantId, string $payerName): ?string
|
|
{
|
|
$payerName = trim($payerName);
|
|
if ($payerName === '') {
|
|
return null;
|
|
}
|
|
|
|
$stmt = $pdo->prepare('SELECT display_name, paypal_name FROM participants WHERE id = ? AND tenant_id = ?');
|
|
$stmt->execute([$participantId, $tenantId]);
|
|
$participant = $stmt->fetch();
|
|
if ($participant === false) {
|
|
return null;
|
|
}
|
|
if (trim((string) ($participant['paypal_name'] ?? '')) !== '') {
|
|
return null;
|
|
}
|
|
if (strcasecmp(trim((string) $participant['display_name']), $payerName) === 0) {
|
|
return null;
|
|
}
|
|
|
|
// Wuerde der Name auf ein weiteres Mitglied passen, bliebe die Zuordnung
|
|
// mehrdeutig (imports_find_participant verlangt genau einen Treffer).
|
|
$stmt = $pdo->prepare(
|
|
'SELECT COUNT(*) FROM participants
|
|
WHERE tenant_id = ?
|
|
AND id <> ?
|
|
AND (LOWER(paypal_name) = LOWER(?) OR LOWER(display_name) = LOWER(?))'
|
|
);
|
|
$stmt->execute([$tenantId, $participantId, $payerName, $payerName]);
|
|
if ((int) $stmt->fetchColumn() > 0) {
|
|
return null;
|
|
}
|
|
|
|
return $payerName;
|
|
}
|
|
|
|
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.'];
|
|
}
|
|
|
|
// Aus der Handarbeit lernen: heisst jemand bei PayPal anders als in der
|
|
// Kaffeeliste, greift die automatische Zuordnung nie - denn sie verlangt
|
|
// exakte Uebereinstimmung. Nach der ersten manuellen Zuordnung wird der
|
|
// Zahlername deshalb beim Mitglied hinterlegt, damit die naechste Zahlung
|
|
// von allein ankommt.
|
|
$gemerkterName = paypal_remember_payer_name($pdo, $tenantId, $participantId, (string) $payment['payer_name']);
|
|
|
|
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]);
|
|
if ($gemerkterName !== null) {
|
|
$pdo->prepare('UPDATE participants SET paypal_name = ? WHERE id = ? AND tenant_id = ?')
|
|
->execute([$gemerkterName, $participantId, $tenantId]);
|
|
}
|
|
$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'],
|
|
'learned_paypal_name' => $gemerkterName,
|
|
]);
|
|
|
|
return ['ok' => true, 'learned_paypal_name' => $gemerkterName];
|
|
}
|
|
|
|
/**
|
|
* 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.'];
|
|
}
|
|
|
|
/**
|
|
* Anzahl der offenen Zahlungen in der Warteschlange. Bewusst als eigene
|
|
* Zaehl-Abfrage neben paypal_fetch_unmatched(): die Navigation braucht nur
|
|
* die Information "gibt es noch etwas zu tun", nicht die Datensaetze.
|
|
*/
|
|
function paypal_count_unmatched(PDO $pdo, int $tenantId): int
|
|
{
|
|
static $cache = [];
|
|
|
|
if (array_key_exists($tenantId, $cache)) {
|
|
return $cache[$tenantId];
|
|
}
|
|
|
|
try {
|
|
$stmt = $pdo->prepare(
|
|
"SELECT COUNT(*) FROM paypal_payments WHERE tenant_id = ? AND status = 'unmatched'"
|
|
);
|
|
$stmt->execute([$tenantId]);
|
|
$cache[$tenantId] = (int)$stmt->fetchColumn();
|
|
} catch (Throwable $e) {
|
|
// Fehlt die Tabelle, gibt es auch keine Warteschlange - die
|
|
// Navigation darf daran nicht scheitern.
|
|
$cache[$tenantId] = 0;
|
|
}
|
|
|
|
return $cache[$tenantId];
|
|
}
|
|
|
|
/**
|
|
* Darf die Zuordnungsseite geoeffnet werden? Die Funktion kennt drei Faelle:
|
|
* der Betreiber hat gesperrt (nein), der Mandant bietet PayPal an (ja) - und
|
|
* dazwischen der Fall, dass der Mandant PayPal gerade abgeschaltet hat,
|
|
* aber noch Zahlungen unzugeordnet in der Warteschlange liegen. Die waeren
|
|
* sonst weder ueber das Menue noch ueber die URL erreichbar; die Seite bleibt
|
|
* deshalb offen, bis die Warteschlange leer ist.
|
|
*/
|
|
function paypal_inbox_accessible(PDO $pdo, int $tenantId): bool
|
|
{
|
|
require_once __DIR__ . '/features.php';
|
|
|
|
if (!app_feature_enabled($pdo, $tenantId, 'paypal_inbox')) {
|
|
return false;
|
|
}
|
|
|
|
if (app_feature_available($pdo, $tenantId, 'paypal_inbox')) {
|
|
return true;
|
|
}
|
|
|
|
return paypal_count_unmatched($pdo, $tenantId) > 0;
|
|
}
|