|null */ private ?array $settings = null; public function __construct(object $base) { $this->base = $base; $this->db = $base->db; } public function paysAfterRedirect(): bool { return true; } // ------------------------------------------------------------ Einstellungen /** Wert aus der Konfigurationsgruppe des Anbieters (configuration.group_id = module()). */ protected function setting(string $name, string $default = ''): string { if ($this->settings === null) { $this->settings = []; $g = $this->db->real_escape_string($this->module()); $res = $this->db->query("SELECT name, setting FROM configuration WHERE group_id='$g'"); while ($res && ($row = $res->fetch_assoc())) { $this->settings[(string) $row['name']] = (string) $row['setting']; } } $full = $this->module() . '_' . $name; $v = $this->settings[$full] ?? $this->settings[$name] ?? ''; return $v !== '' ? $v : $default; } protected function secret(string $name): string { $v = Secrets::get($this->db, $this->module(), $name); if ($v === null || $v === '') { throw new \RuntimeException($this->label() . ': Zugangsdatum "' . $name . '" ist nicht hinterlegt (Backend > Zahlungsarten).'); } return $v; } protected function shopBaseUrl(): string { if (\defined('MAIN_URL') && MAIN_URL !== '') { return \rtrim((string) MAIN_URL, '/'); } return 'https://' . ($_SERVER['SERVER_NAME'] ?? 'localhost'); } /** Rueckkehr-URLs laufen durch den Cashdesk (Session vorhanden). */ protected function returnUrl(): string { return $this->shopBaseUrl() . '/Kasse/?payment_return=' . $this->module(); } protected function cancelUrl(): string { return $this->shopBaseUrl() . '/Kasse/?payment_cancel=' . $this->module(); } /** Webhook laeuft OHNE Session ueber den Standalone-Endpunkt. */ protected function webhookUrl(): string { return $this->shopBaseUrl() . '/payment_webhook.php?p=' . $this->module(); } // ---------------------------------------------------------------- Vorgang /** * Legt den Vorgang an: Referenz beim Anbieter, Sollbetrag, Session, Approve-URL. * Schreibt zusaetzlich orders.paypal_token (Backend zeigt das Feld bereits an). */ protected function openTransaction(int $orderId, string $reference, float $amount, string $approveUrl, ?array $raw = null): void { $order = $this->orderById($orderId); $stmt = $this->db->prepare( 'INSERT INTO payment_transactions (provider, order_id, order_number, reference, expected_amount, currency, status, approve_url, session_id, customer_id, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,NOW(),NOW()) ON DUPLICATE KEY UPDATE reference=VALUES(reference), expected_amount=VALUES(expected_amount), status=VALUES(status), approve_url=VALUES(approve_url), session_id=VALUES(session_id), updated_at=NOW()' ); if (!$stmt) { throw new \RuntimeException('payment_transactions: ' . $this->db->error); } $module = $this->module(); $number = (string) ($order->order_number ?? ''); $currency = $this->currency(); $status = 'open'; $sid = \function_exists('session_id') ? (string) \session_id() : ''; $cid = (int) ($order->customer_id ?? 0); $stmt->bind_param('sissdssssi', $module, $orderId, $number, $reference, $amount, $currency, $status, $approveUrl, $sid, $cid); $stmt->execute(); $stmt->close(); $ref = $this->db->real_escape_string($reference); $this->db->query("UPDATE orders SET paypal_token='$ref' WHERE id=$orderId"); $this->pendingSet($orderId, $reference, $amount, $approveUrl); $this->event('payment_started', $orderId, $number, $amount, $reference, null, $cid); Log::write($module, 'start', $orderId, $number, $reference, 'open', null, $raw); } protected function transactionByReference(string $reference): ?object { $stmt = $this->db->prepare('SELECT * FROM payment_transactions WHERE provider=? AND reference=? LIMIT 1'); if (!$stmt) { return null; } $m = $this->module(); $stmt->bind_param('ss', $m, $reference); $stmt->execute(); $row = $stmt->get_result()->fetch_object(); $stmt->close(); return $row ?: null; } protected function transactionByOrder(int $orderId): ?object { $stmt = $this->db->prepare('SELECT * FROM payment_transactions WHERE provider=? AND order_id=? ORDER BY id DESC LIMIT 1'); if (!$stmt) { return null; } $m = $this->module(); $stmt->bind_param('si', $m, $orderId); $stmt->execute(); $row = $stmt->get_result()->fetch_object(); $stmt->close(); return $row ?: null; } /** Offene Vorgaenge fuer den Abgleicher. @return list */ protected function openTransactions(int $olderThanMinutes, int $maxAgeHours): array { $stmt = $this->db->prepare( 'SELECT * FROM payment_transactions WHERE provider=? AND status IN ("open","pending") AND created_at < DATE_SUB(NOW(), INTERVAL ? MINUTE) AND created_at > DATE_SUB(NOW(), INTERVAL ? HOUR) ORDER BY id' ); if (!$stmt) { return []; } $m = $this->module(); $stmt->bind_param('sii', $m, $olderThanMinutes, $maxAgeHours); $stmt->execute(); $rows = $stmt->get_result()->fetch_all(MYSQLI_ASSOC); $stmt->close(); return \array_map(static fn($r) => (object) $r, $rows); } protected function setTransactionStatus(string $reference, string $status, ?string $detail = null): void { $stmt = $this->db->prepare('UPDATE payment_transactions SET status=?, detail=?, updated_at=NOW() WHERE provider=? AND reference=?'); if ($stmt) { $m = $this->module(); $stmt->bind_param('ssss', $status, $detail, $m, $reference); $stmt->execute(); $stmt->close(); } } // ------------------------------------------------------------- bezahlt /** * Setzt die Bestellung genau einmal auf bezahlt, gleicht vorher den Betrag ab. * Rueckgabe true, wenn die Bestellung jetzt (oder schon vorher) bezahlt ist. * * Atomar ueber payment_transactions.status: nur wer den Wechsel open/pending -> paid * schafft, loest Mail und Warenkorb aus. Zweite Rueckkehr, Webhook und Abgleicher * laufen dann ins Leere statt in Doppelmails. */ protected function markPaid(string $reference, string $paidAmount, string $paidCurrency, string $captureId, string $payerEmail = '', string $payerId = '', string $source = 'return'): bool { $tx = $this->transactionByReference($reference); if (!$tx) { \error_log($this->label() . ': markPaid ohne Vorgang (' . $reference . ')'); return false; } $orderId = (int) $tx->order_id; if ($tx->status === 'paid') { return true; } if (!$this->amountMatches((string) $tx->expected_amount, $paidAmount, (string) $tx->currency, $paidCurrency)) { $msg = "Betrag weicht ab: erwartet {$tx->expected_amount} {$tx->currency}, gezahlt $paidAmount $paidCurrency"; \error_log($this->label() . " BETRAGS-MISMATCH Order $orderId ($reference): $msg"); $this->setTransactionStatus($reference, 'mismatch', $msg); $this->event('failed', $orderId, (string) $tx->order_number, (float) $paidAmount, $reference, $captureId, (int) $tx->customer_id, $msg); Log::write($this->module(), 'mismatch', $orderId, (string) $tx->order_number, $reference, 'mismatch', null, null, $msg); return false; } // Der eine Schreiber gewinnt. $stmt = $this->db->prepare( 'UPDATE payment_transactions SET status="paid", capture_id=?, paid_amount=?, paid_at=NOW(), updated_at=NOW(), detail=? WHERE provider=? AND reference=? AND status IN ("open","pending")' ); if (!$stmt) { return false; } $m = $this->module(); $amt = (float) $paidAmount; $stmt->bind_param('sdsss', $captureId, $amt, $source, $m, $reference); $stmt->execute(); $won = $stmt->affected_rows === 1; $stmt->close(); if (!$won) { return $this->transactionByReference($reference)?->status === 'paid'; } $cap = $this->db->real_escape_string($captureId); $eml = $this->db->real_escape_string($payerEmail); $pid = $this->db->real_escape_string($payerId); $this->db->query( "UPDATE orders SET payment_status=2, payment_date=CURDATE(), paypal_transaction_id='$cap'" . ($payerEmail !== '' ? ", paypal_email='$eml'" : '') . ($payerId !== '' ? ", paypal_payerid='$pid'" : '') . " WHERE id=$orderId" ); $this->clearCart((string) $tx->session_id); $this->sendOrderConfirmation($orderId, (int) $tx->customer_id); $this->pendingClear(); $this->event('paid', $orderId, (string) $tx->order_number, (float) $paidAmount, $reference, $captureId, (int) $tx->customer_id, 'via ' . $source); return true; } /** Kunde hat abgebrochen oder der Anbieter hat den Vorgang verworfen. */ protected function markCancelled(string $reference, string $why, string $source = 'cancel'): void { $tx = $this->transactionByReference($reference); if (!$tx || $tx->status === 'paid') { return; } $this->setTransactionStatus($reference, 'cancelled', $why); $orderId = (int) $tx->order_id; // 6 = storniert (wie v1-PayPal im Cancel-Zweig) $this->db->query("UPDATE orders SET order_status=6 WHERE id=$orderId AND payment_status<>2"); $this->pendingClear(); $this->event('cancelled', $orderId, (string) $tx->order_number, (float) $tx->expected_amount, $reference, null, (int) $tx->customer_id, $why . ' (' . $source . ')'); Log::write($this->module(), 'cancel', $orderId, (string) $tx->order_number, $reference, 'cancelled', null, null, $why); } protected function amountMatches(string $expected, string $paid, string $expCur, string $paidCur): bool { return \number_format((float) $expected, 2, '.', '') === \number_format((float) $paid, 2, '.', '') && \strtoupper($expCur) === \strtoupper($paidCur); } protected function currency(): string { return \strtoupper($this->setting('currency', 'EUR')); } // --------------------------------------------------------- Shop-Anbindung protected function orderById(int $orderId): object { $res = $this->db->query("SELECT * FROM orders WHERE id=$orderId LIMIT 1"); $row = $res ? $res->fetch_object() : null; if (!$row) { throw new \RuntimeException('Bestellung ' . $orderId . ' nicht gefunden.'); } return $row; } /** Lieferadresse der Bestellung fuer den Anbieter (Kaeufer-/Verkaeuferschutz). */ protected function shippingAddress(object $order): array { $iso = 'DE'; $cid = (int) ($order->shipping_country ?? 0); if ($cid > 0) { $res = $this->db->query("SELECT iso_code_2 FROM countries WHERE id=$cid"); $r = $res ? $res->fetch_object() : null; if ($r && \strlen((string) $r->iso_code_2) === 2) { $iso = \strtoupper((string) $r->iso_code_2); } } return [ 'name' => \trim(((string) ($order->shipping_firstname ?? '')) . ' ' . ((string) ($order->shipping_surname ?? ''))), 'company' => (string) ($order->shipping_company ?? ''), 'street' => \trim(((string) ($order->shipping_street ?? '')) . ' ' . ((string) ($order->shipping_house_number ?? ''))), 'zip' => (string) ($order->shipping_zip_code ?? ''), 'city' => (string) ($order->shipping_city ?? ''), 'country' => $iso, 'email' => (string) ($order->customer_email ?? ''), ]; } /** * Warenkorb leeren -- ueber die Session von damals UND die aktuelle. Der Kunde kann * mit einer neuen Session zurueckkommen (Safari/iOS), der Webhook hat gar keine. */ protected function clearCart(string $originalSessionId): void { $ids = []; if ($originalSessionId !== '') { $ids[$originalSessionId] = true; } if (\function_exists('session_id') && \session_id() !== '') { $ids[\session_id()] = true; } foreach (\array_keys($ids) as $sid) { $s = $this->db->real_escape_string((string) $sid); $this->db->query("DELETE FROM shopping_cart_items WHERE session_id='$s'"); } } /** * Bestellbestaetigung ausloesen (Ticket-Event 3). Fehler werden protokolliert und * geschluckt: das Geld ist eingezogen, eine Ausnahme wuerde dem Kunden eine * Fehlerseite fuer eine erfolgreiche Bestellung zeigen. */ protected function sendOrderConfirmation(int $orderId, int $customerId): void { try { if (!\class_exists('Cs_ticket')) { $docroot = \defined('ROOT_DIR') ? \rtrim((string) ROOT_DIR, '/') : \dirname(__DIR__, 2); require_once $docroot . '/core/cs_ticket.class.php'; } $ticket = new \Cs_ticket($this->base); $ticket->event(3, $customerId, $orderId); Log::write($this->module(), 'mail', $orderId, null, null, 'sent'); } catch (\Throwable $e) { \error_log($this->label() . ': Bestellbestaetigung fuer Order ' . $orderId . ' fehlgeschlagen: ' . $e->getMessage()); Log::write($this->module(), 'mail', $orderId, null, null, 'failed', null, null, $e->getMessage()); } } // ------------------------------------------------------ Session-Riegel /** Offener Vorgang in der Session: Riegel gegen zweite Bestellung per Zurueck-Taste. */ protected function pendingSet(int $orderId, string $reference, float $amount, string $approveUrl): void { if (\session_status() !== PHP_SESSION_ACTIVE) { return; } $_SESSION['easyshop']['payment_pending'] = [ 'module' => $this->module(), 'reference' => $reference, 'order_id' => $orderId, 'total' => \round($amount, 2), 'approve_url' => $approveUrl, 'time' => \time(), ]; } protected function pendingClear(): void { if (\session_status() === PHP_SESSION_ACTIVE) { unset($_SESSION['easyshop']['payment_pending']); } } /** Ist die Bestellung des offenen Vorgangs bezahlt? (fuer den Cashdesk-Riegel) */ public function isPaid(int $orderId): bool { $res = $this->db->query("SELECT payment_status FROM orders WHERE id=$orderId"); $r = $res ? $res->fetch_object() : null; return $r && (string) $r->payment_status === '2'; } // --------------------------------------------------------------- Events protected function event(string $name, ?int $orderId, ?string $orderNumber, ?float $amount, ?string $reference, ?string $transactionId, ?int $customerId, ?string $detail = null): void { if (!\function_exists('shop_event')) { return; } \shop_event($name, [ 'order_id' => $orderId, 'order_number' => $orderNumber, 'payment_method' => $this->paymentMethodId(), 'betrag' => $amount, 'paypal_token' => $reference, 'transaction_id' => $transactionId, 'customer_id' => $customerId, 'detail' => \trim($this->module() . ': ' . (string) $detail), ]); } private ?int $pmId = null; protected function paymentMethodId(): ?int { if ($this->pmId === null) { $m = $this->db->real_escape_string($this->module()); $res = $this->db->query("SELECT id FROM payment_methods WHERE module='$m' LIMIT 1"); $r = $res ? $res->fetch_object() : null; $this->pmId = $r ? (int) $r->id : 0; } return $this->pmId ?: null; } public function secretFields(): array { return []; } // ------------------------------------------------------- Express-Defaults public function supportsExpress(): bool { return false; } public function startExpress(float $amount): string { throw new \RuntimeException($this->label() . ': kein Express-Weg'); } public function onExpressReturn(array $query): ?array { return null; } public function finishExpress(int $orderId, float $amount, string $reference): string { throw new \RuntimeException($this->label() . ': kein Express-Weg'); } /** Express-Rueckkehr laeuft ueber den Cashdesk, ohne Bestellung. */ protected function expressReturnUrl(): string { return $this->shopBaseUrl() . '/Kasse/?payment_express=' . $this->module(); } protected function expressCancelUrl(): string { return $this->shopBaseUrl() . '/Warenkorb/?payment_express_cancel=' . $this->module(); } /** countries.id zu einem ISO-3166-Alpha-2-Code, 0 wenn das Land im Shop nicht lieferbar ist. */ public function countryIdByIso(string $iso): int { $iso = $this->db->real_escape_string(\strtoupper($iso)); $res = $this->db->query("SELECT id FROM countries WHERE iso_code_2='$iso' ORDER BY rang DESC, id LIMIT 1"); $r = $res ? $res->fetch_object() : null; return $r ? (int) $r->id : 0; } /** "Neuer Weg 5a" -> ["Neuer Weg", "5a"]; ohne erkennbare Nummer bleibt alles Strasse. */ public static function splitStreet(string $line): array { $line = \trim($line); if (\preg_match('/^(.*?)[\s,]+(\d+[\s\-\/]?[a-zA-Z0-9\-\/]*)$/u', $line, $m)) { return [\trim($m[1]), \trim($m[2])]; } return [$line, '']; } }