db = mysqli). */ final class Gateway { /** @var object Shop-base-Objekt mit ->db (mysqli) */ private object $base; public function __construct(object $base) { $this->base = $base; } /** * Legt eine PayPal-Order an, speichert die Order-ID am Shop-Auftrag und * gibt die Approve-URL zurueck, zu der der Kunde weitergeleitet wird. */ public function createOrderAndGetApprovalUrl(int $shopOrderId, float $charged, string $orderNumber): string { $shopBase = \rtrim((string) Config::get('shop_base_url', 'https://' . ($_SERVER['SERVER_NAME'] ?? '')), '/'); $returnUrl = $shopBase . '/core/paypal_v2/return.php'; $cancelUrl = $shopBase . '/core/paypal_v2/return.php?cancel=1'; $orders = new Orders(); $res = $orders->create($charged, $orderNumber, (string) $shopOrderId, $returnUrl, $cancelUrl); // PayPal-Order-ID am Auftrag ablegen (vorhandene Spalte wiederverwendet) $order = new \Order($this->base); $order->set_order_paypal_token($res['id'], $shopOrderId); // Exakt berechneten Betrag fuer den spaeteren Abgleich sichern (best effort; // funktioniert nur, wenn die Spalte paypal_v2_expected_amount existiert). $this->storeExpectedAmount($shopOrderId, $charged); return $res['approve_url']; } /** * Schliesst die Zahlung anhand der PayPal-Order-ID ab (Return-Flow). * * @return array{ok:bool,order_number:string,order_id:int,message:string} */ public function finalizeByPaypalOrderId(string $paypalOrderId): array { $shopOrder = $this->findOrderByToken($paypalOrderId); if ($shopOrder === null) { return $this->result(false, '', 0, 'Bestellung zu dieser Zahlung nicht gefunden.'); } $orderId = (int) $shopOrder->id; // Bereits bezahlt? -> idempotent OK if ((string) $shopOrder->payment_status === '2') { return $this->result(true, (string) $shopOrder->order_number, $orderId, 'bereits bezahlt'); } $cap = (new Orders())->capture($paypalOrderId); if ($cap['capture_status'] !== 'COMPLETED') { return $this->result( false, (string) $shopOrder->order_number, $orderId, 'Zahlung nicht abgeschlossen (' . ($cap['capture_status'] ?: $cap['order_status']) . ').' ); } if (!$this->amountMatches($shopOrder, $cap['amount'], $cap['currency'], $orderId, $paypalOrderId)) { return $this->result(false, (string) $shopOrder->order_number, $orderId, 'Betrag stimmt nicht ueberein.'); } $this->markPaid($orderId, $cap); return $this->result(true, (string) $shopOrder->order_number, $orderId, 'ok'); } /** * Bestaetigt eine Zahlung aus einem verifizierten Webhook-Event * (PAYMENT.CAPTURE.COMPLETED). Idempotent. * * @param array $resource Das "resource"-Objekt des Webhook-Events (die Capture) */ public function confirmFromWebhookCapture(array $resource): bool { $shopOrderId = (int) ($resource['custom_id'] ?? 0); if ($shopOrderId <= 0) { return false; } $shopOrder = $this->findOrderById($shopOrderId); if ($shopOrder === null) { return false; } if ((string) $shopOrder->payment_status === '2') { return true; // schon bezahlt } if (($resource['status'] ?? '') !== 'COMPLETED') { return false; } $amount = (string) ($resource['amount']['value'] ?? ''); $currency = (string) ($resource['amount']['currency_code'] ?? ''); if (!$this->amountMatches($shopOrder, $amount, $currency, $shopOrderId, 'webhook')) { return false; } $this->markPaid($shopOrderId, [ 'capture_id' => (string) ($resource['id'] ?? ''), 'payer_email' => '', 'payer_id' => '', ]); return true; } // ----------------------------------------------------------------- intern /** @param array{capture_id?:string,payer_email?:string,payer_id?:string} $cap */ private function markPaid(int $orderId, array $cap): void { $db = $this->base->db; $order = new \Order($this->base); // payment_status = 2 (bezahlt) – identisch zur Legacy-Semantik $order->set_billing_status('2', $orderId); $order->set_payment_date(\date('Y-m-d'), $orderId); $capId = $db->real_escape_string((string) ($cap['capture_id'] ?? '')); $email = $db->real_escape_string((string) ($cap['payer_email'] ?? '')); $payer = $db->real_escape_string((string) ($cap['payer_id'] ?? '')); $sql = "UPDATE orders SET paypal_transaction_id='$capId'"; if ($email !== '') { $sql .= ", paypal_email='$email'"; } if ($payer !== '') { $sql .= ", paypal_payerid='$payer'"; } $sql .= ' WHERE id=' . $orderId; $db->query($sql); } /** * Prueft, ob der gezahlte Betrag zum erwarteten Betrag des Auftrags passt. * Erwartet wird primaer paypal_v2_expected_amount, ersatzweise orders.order_total. */ private function amountMatches(object $shopOrder, string $paidAmount, string $paidCurrency, int $orderId, string $ref): bool { $expectedRaw = $shopOrder->paypal_v2_expected_amount ?? null; if ($expectedRaw === null || $expectedRaw === '') { $expectedRaw = $shopOrder->order_total; } $expected = \number_format((float) $expectedRaw, 2, '.', ''); $paid = \number_format((float) $paidAmount, 2, '.', ''); $currency = (string) Config::get('currency', 'EUR'); $ok = ($paid === $expected) && (\strtoupper($paidCurrency) === \strtoupper($currency)); if (!$ok) { \error_log( "PayPal v2 BETRAGS-MISMATCH (order $orderId, ref $ref): " . "erwartet $expected $currency, erhalten $paid $paidCurrency" ); } return $ok; } private function findOrderByToken(string $paypalOrderId): ?object { $db = $this->base->db; $token = $db->real_escape_string($paypalOrderId); $res = $db->query( "SELECT id, order_number, order_total, payment_status, " . $this->expectedAmountSelect() . " FROM orders WHERE paypal_token = '$token' LIMIT 1" ); return ($res && $res->num_rows > 0) ? $res->fetch_object() : null; } private function findOrderById(int $orderId): ?object { $db = $this->base->db; $res = $db->query( "SELECT id, order_number, order_total, payment_status, " . $this->expectedAmountSelect() . " FROM orders WHERE id = $orderId LIMIT 1" ); return ($res && $res->num_rows > 0) ? $res->fetch_object() : null; } /** * Liefert das SELECT-Fragment fuer den erwarteten Betrag. Existiert die * optionale Spalte nicht, wird NULL selektiert (Fallback auf order_total). */ private function expectedAmountSelect(): string { return $this->columnExists('paypal_v2_expected_amount') ? 'paypal_v2_expected_amount' : 'NULL AS paypal_v2_expected_amount'; } private function storeExpectedAmount(int $orderId, float $charged): void { if (!$this->columnExists('paypal_v2_expected_amount')) { return; } $db = $this->base->db; $val = \number_format($charged, 2, '.', ''); $db->query("UPDATE orders SET paypal_v2_expected_amount='$val' WHERE id=$orderId"); } /** @var array */ private static array $colCache = []; private function columnExists(string $column): bool { if (isset(self::$colCache[$column])) { return self::$colCache[$column]; } $db = $this->base->db; $col = $db->real_escape_string($column); $res = $db->query("SHOW COLUMNS FROM orders LIKE '$col'"); return self::$colCache[$column] = ($res && $res->num_rows > 0); } /** * @return array{ok:bool,order_number:string,order_id:int,message:string} */ private function result(bool $ok, string $orderNumber, int $orderId, string $message): array { return ['ok' => $ok, 'order_number' => $orderNumber, 'order_id' => $orderId, 'message' => $message]; } }