paypalAuthAPI); curl_setopt($ch, CURLOPT_HEADER, false); curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); curl_setopt($ch, CURLOPT_POST, true); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_USERPWD, $this->paypalClientID . ":" . $this->paypalSecret); curl_setopt($ch, CURLOPT_POSTFIELDS, "grant_type=client_credentials"); $auth_response = json_decode(curl_exec($ch)); $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); if ($http_code != 200 && !empty($auth_response->error)) { throw new Exception('Failed to generate Access Token: ' . $auth_response->error . ' >>> ' . $auth_response->error_description); } if (!empty($auth_response)) { return $auth_response->access_token; } else { return false; } } public function createOrder($productInfo, $paymentSource) { $accessToken = $this->generateAccessToken(); if (empty($accessToken)) { return false; } else { $postParams = $productInfo; $ch = curl_init(); curl_setopt($ch, CURLOPT_URL, $this->paypalAPI . '/orders/'); curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1); curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, FALSE); curl_setopt($ch, CURLOPT_HTTPHEADER, array('Content-Type: application/json', 'Authorization: Bearer ' . $accessToken)); curl_setopt($ch, CURLOPT_POST, true); curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($postParams)); $api_resp = curl_exec($ch); $api_data = json_decode($api_resp, true); $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); if ($http_code != 200 && $http_code != 201 && $http_code != 422 ) { throw new Exception('Failed to create Order sl (' . $http_code . '): ' . $api_resp . json_encode($postParams)); } return !empty($api_data) && ($http_code == 200 || $http_code == 201 || $http_code == 422) ? $api_data : false; } } public function captureOrder($orderId) { $accessToken = $this->generateAccessToken(); if (empty($accessToken)) { return false; } else { $ch = curl_init(); curl_setopt($ch, CURLOPT_URL, $this->paypalAPI . '/orders/' . $orderId . '/capture'); curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1); curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, FALSE); curl_setopt($ch, CURLOPT_HTTPHEADER, array('Content-Type: application/json', 'Authorization: Bearer ' . $accessToken)); curl_setopt($ch, CURLOPT_POST, true); $api_resp = curl_exec($ch); $api_data = json_decode($api_resp, true); $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE); $curlsl_code = curl_getinfo($ch, CURLINFO_EFFECTIVE_URL); // $result = urldecode ($ch ); $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE); if ($http_code != 200 && $http_code != 201) { throw new Exception('Failed to create Order sl4(' . $http_code . '): ' . '1- ' . $api_resp); } curl_close($ch); return !empty($api_data) && ($http_code == 200 || $http_code == 201) ? $api_data : false; } } public function setInvoiceId($orderId,$invoiceId) { global $db; $accessToken = $this->generateAccessToken(); // même mécanique que createOrder/captureOrder $url = $this->paypalAPI . '/orders/' . rawurlencode($orderId); $my_custom_id = $invoiceId; $ops = [ ['op' => 'add', 'path' => "/purchase_units/@reference_id=='default'/custom_id", 'value' => $my_custom_id], ]; $ch = curl_init($url); curl_setopt_array($ch, [ CURLOPT_CUSTOMREQUEST => 'PATCH', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $accessToken, 'Content-Type: application/json' ], CURLOPT_POSTFIELDS => json_encode($ops), CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 20, ]); $body = curl_exec($ch); $httpcode = curl_getinfo($ch, CURLINFO_HTTP_CODE); $curlerr = curl_error($ch); curl_close($ch); if ($curlerr) { throw new \Exception('PayPal PATCH error (cURL): ' . $curlerr); } // PayPal renvoie 204 No Content quand le PATCH réussit if ($httpcode === 204 || $httpcode === 200) { return true; } // Si échec: on lève une exception avec le code HTTP et, si présent, le corps throw new \Exception('PayPal PATCH /orders failed (HTTP ' . $httpcode . '): ' . ($body ?: 'no body')); } // *****************************************************remboursement public function getCaptureDetails(string $captureId): array { if ($captureId === '') throw new \InvalidArgumentException('captureId requis'); $accessToken = $this->generateAccessToken(); if (empty($accessToken)) throw new \Exception('Impossible de générer le token d’accès PayPal'); // Base API absolue (voir section 2) $base = PAYPAL_BASE_URL; $url = $base . '/v2/payments/captures/' . rawurlencode($captureId); $ch = curl_init(); curl_setopt_array($ch, [ CURLOPT_URL => $url, CURLOPT_RETURNTRANSFER => 1, CURLOPT_SSL_VERIFYPEER => false, CURLOPT_HTTPHEADER => [ 'Content-Type: application/json', 'Authorization: Bearer ' . $accessToken, ], ]); $resp = curl_exec($ch); $curlerr = curl_error($ch); $httpcode = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); if ($curlerr) throw new \Exception('PayPal getCaptureDetails error (cURL): ' . $curlerr); $data = json_decode($resp, true); if ($httpcode >= 200 && $httpcode < 300) return $data ?: []; throw new \Exception('getCaptureDetails failed (HTTP ' . $httpcode . '): ' . ($resp ?: 'no body')); } public function paypaldetails($capId) { error_reporting(E_ALL); ini_set('display_errors', '1'); // --- Guards de classe --- if (!isset($this)) { throw new RuntimeException('PaypalCheckout ($this->paypal) n’est pas initialisé dans la classe.'); } // Helpers pour un retour propre si PayPal ne répond pas (inchangés) $makeUnavailableCapture = function(string $capId, string $rawMsg='') { // essaie d’extraire debug_id du message d’exception PayPal $debugId = ''; if ($rawMsg && preg_match('/"debug_id"\s*:\s*"([^"]+)"/', $rawMsg, $m)) { $debugId = $m[1]; } return [ 'id' => $capId, 'status' => 'NON_DISPONIBLE', '_error' => 'Pas disponible chez PayPal', '_paypal_debug_id' => $debugId, '_raw_error' => $rawMsg, ]; }; $makeEmptyRefunds = function(string $capId, string $msg='Pas disponible chez PayPal') { return [ 'ok' => false, 'capture_id' => $capId, 'invoice_id' => '', 'order_id' => '', 'refunds' => [], 'totals' => [ 'currency' => '', 'captured_total' => 0.00, 'refunded_total' => 0.00, 'remaining' => 0.00, ], 'errors' => [$msg], ]; }; // 1) Détails de la capture (utilise $this->paypal, pas de new/require) try { $delails = $this->getCaptureDetails($capId); } catch (\Throwable $e) { // Retour propre si la ressource n’existe pas (404/INVALID_RESOURCE_ID) $delails = $makeUnavailableCapture($capId, $e->getMessage()); // On renvoie directement le bundle, avec refunds vides return [ 'delails' => $delails, 'refunds' => $makeEmptyRefunds($capId), ]; } // 2) Refunds (si on a des détails valides) try { $Refunds = $this->getRefundsForCapture($delails); } catch (\Throwable $e) { // Si la recherche refunds échoue, on n’explose pas; on renvoie un bloc vide + erreur $Refunds = $makeEmptyRefunds($capId, 'Remboursements non disponibles ('.$e->getMessage().')'); } return [ 'delails' => $delails, 'refunds' => $Refunds, ]; } /** * Retourne les remboursements liés à une capture (tableau, pas d'affichage). * - Cherche via Transaction Search autour de la date de la capture. * - Inclut les écritures NON encore comptabilisées (PENDING). * - Reconnaît plusieurs codes d'événements de refund (pas seulement T1107). * * @param array $capture tableau /v2/payments/captures/{id} * @param int $hoursBefore fenêtre avant create_time (défaut 24h) * @param int $hoursAfter fenêtre après update_time (défaut 120h) * @return array { ok, capture_id, invoice_id, order_id, refunds[], totals{}, errors[] } * @throws Exception */ public function getRefundsForCapture(array $capture, int $hoursBefore = 24, int $hoursAfter = 120): array { $errors = []; $capId = $capture['id'] ?? ''; $invId = $capture['invoice_id'] ?? ''; $ordId = $capture['supplementary_data']['related_ids']['order_id'] ?? ''; $capAmt = isset($capture['amount']['value']) ? (float)$capture['amount']['value'] : 0.0; $capCur = $capture['amount']['currency_code'] ?? ''; $tCreate = $capture['create_time'] ?? ''; $tUpdate = $capture['update_time'] ?? $tCreate; if ($capId === '' || $tCreate === '') { throw new \InvalidArgumentException('Capture id ou create_time manquants'); } // Fenêtre de recherche (UTC strict) $tzUtc = new \DateTimeZone('UTC'); $t0 = (new \DateTime($tCreate))->setTimezone($tzUtc); $tU = (new \DateTime($tUpdate))->setTimezone($tzUtc); $startIso = $t0->sub(new \DateInterval('PT' . max(0, $hoursBefore) . 'H'))->format('Y-m-d\TH:i:s\Z'); $endIso = $tU->add(new \DateInterval('PT' . max(1, $hoursAfter) . 'H'))->format('Y-m-d\TH:i:s\Z'); // OAuth + base api-m $token = $this->generateAccessToken(); if (!$token) throw new \Exception('Token PayPal indisponible'); $base = PAYPAL_BASE_URL; // Vérifie à quel compte appartient le token $urlUserInfo = $base . '/v1/identity/oauth2/userinfo?schema=paypalv1.1'; $ch = curl_init($urlUserInfo); curl_setopt_array($ch, [ CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $token, 'Accept: application/json'], CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 30, ]); $resUser = curl_exec($ch); $httpUser = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); if ($httpUser !== 200) { throw new \Exception("UserInfo HTTP $httpUser: " . ($resUser ?: 'Échec vérification compte.')); } $me = json_decode($resUser, true); if (!empty($this->merchant_payer_id) && (($me['payer_id'] ?? '') !== $this->merchant_payer_id)) { throw new \Exception( "NOT_AUTHORIZED: le token appartient à " . ($me['payer_id'] ?? '?') . " et non au marchand attendu " . $this->merchant_payer_id . ". Vérifie le client_id/secret utilisés." ); } // Prépare éventuellement un header PayPal-Auth-Assertion si besoin d’agir pour un autre marchand $authAssertion = null; if (!empty($this->act_as_payer_id)) { $header = rtrim(strtr(base64_encode('{"alg":"none"}'), '+/', '-_'), '='); $payload = rtrim(strtr(base64_encode(json_encode([ 'iss' => $this->clientId, 'payer_id' => $this->act_as_payer_id ])), '+/', '-_'), '='); $authAssertion = $header . '.' . $payload . '.'; } // Fetch paginé /v1/reporting/transactions $fetchTx = function (array $params) use ($base, $token, $authAssertion): array { $page = 1; $pageSize = $params['page_size'] ?? 200; $all = []; do { $params['page'] = $page; $url = $base . '/v1/reporting/transactions?' . http_build_query($params); $headers = ['Authorization: Bearer ' . $token, 'Accept: application/json']; if ($authAssertion) $headers[] = 'PayPal-Auth-Assertion: ' . $authAssertion; $ch = curl_init($url); curl_setopt_array($ch, [ CURLOPT_HTTPHEADER => $headers, CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 60, ]); $res = curl_exec($ch); $http = curl_getinfo($ch, CURLINFO_HTTP_CODE); $err = curl_error($ch); curl_close($ch); if ($http !== 200) throw new \Exception("Transaction Search HTTP $http: " . ($res ?: $err ?: '')); $json = json_decode($res, true); $rows = $json['transaction_details'] ?? []; $all = array_merge($all, $rows); $page++; } while (count($rows) >= $pageSize); return $all; }; // On inclut aussi les écritures non "balance-affecting" pour capter les PENDING $rows = $fetchTx([ 'start_date' => $startIso, 'end_date' => $endIso, 'fields' => 'all', 'page_size' => 200, 'balance_affecting_records_only' => 'N', ]); // Codes d’événements refund (variantes connues) $refundCodes = [ 'T1107', 'T1108', 'T1109', 'T1110', 'T0407', 'T0700', 'T0701', 'T0702', 'T0703' ]; $refundClassHints = ['REFUND', 'REVERSAL', 'CHARGEBACK']; $refunds = []; $seen = []; foreach ($rows as $row) { $ti = $row['transaction_info'] ?? []; $eid = $ti['transaction_id'] ?? ''; $ev = $ti['transaction_event_code'] ?? ''; $subj = strtolower($ti['transaction_subject'] ?? ''); $amt = isset($ti['transaction_amount']['value']) ? (float)$ti['transaction_amount']['value'] : null; $cur = $ti['transaction_amount']['currency_code'] ?? null; $stat = $ti['transaction_status'] ?? ''; $ts = $ti['transaction_updated_date'] ?? ($ti['transaction_initiation_date'] ?? ''); $pref = $ti['paypal_reference_id'] ?? ''; $ridType = strtoupper($ti['paypal_reference_id_type'] ?? ''); $relId = $ti['related_transaction_id'] ?? ''; $inv = $ti['invoice_id'] ?? ''; $classes = array_map('strtoupper', (array)($ti['transaction_classification'] ?? [])); $parents = []; foreach ((array)($ti['extended_related_transactions'] ?? []) as $rt) { if (!empty($rt['parent_transaction_id'])) $parents[] = $rt['parent_transaction_id']; if (!empty($rt['paypal_reference_id'])) $parents[] = $rt['paypal_reference_id']; } $looksLikeRefund = in_array($ev, $refundCodes, true) || strpos($subj, 'refund') !== false || count(array_intersect($refundClassHints, $classes)) > 0; if (!$looksLikeRefund) continue; $strongMatch = ($pref === $capId) || ($relId === $capId) || ($ridType === 'CAPTURE' && $pref === $capId) || in_array($capId, $parents, true) || ($invId !== '' && $inv === $invId); $fallbackMatch = false; if (!$strongMatch && $amt !== null) { $fallbackMatch = ($amt < 0) && (($cur ?: $capCur) === $capCur) && (($invId !== '' && $inv === $invId) || in_array($ordId, $parents, true)); } if (!($strongMatch || $fallbackMatch)) continue; $rid = $eid; if ($rid && !isset($seen[$rid])) { $seen[$rid] = true; $refunds[] = [ 'refund_id' => $rid, 'amount' => abs((float)($amt ?? 0)), 'currency' => $cur ?: $capCur, 'status' => $stat, 'time' => $ts, 'paypal_reference_id' => $pref, 'reference_type' => $ridType, 'related_transaction' => $relId, 'event_code' => $ev, 'classification' => $classes, 'invoice_id' => $inv, 'refund_api' => ($rid ? ($base . '/v2/payments/refunds/' . $rid) : null), ]; } } $refunded = 0.0; foreach ($refunds as $r) $refunded += (float)$r['amount']; $remaining = max(0, $capAmt - $refunded); return [ 'ok' => true, 'capture_id' => $capId, 'invoice_id' => $invId, 'order_id' => $ordId, 'refunds' => $refunds, 'totals' => [ 'currency' => $capCur, 'captured_total' => (float)number_format($capAmt, 2, '.', ''), 'refunded_total' => (float)number_format($refunded, 2, '.', ''), 'remaining' => (float)number_format($remaining, 2, '.', ''), ], 'errors' => $errors, ]; } /** * Rembourse une capture PayPal (total ou partiel). * * @param string $captureId ID de la capture (ex: $captures[0]['id']) * @param string|null $amount Montant du remboursement (null = remboursement total) * @param string|null $currency Devise (ex: 'CAD'); si null on prend la devise de la classe/config * @param string|null $note Note visible par le payer (optionnel) * @param string|null $invoiceId ID de facture de référence côté PayPal (optionnel) * @param string|null $idempotency Clé unique pour éviter les doubles remboursements (optionnel) * @return array Réponse PayPal décodée * @throws \Exception En cas d’erreur HTTP ou cURL */ public function refundCapture( string $captureId, ?string $amount = null, ?string $currency = null, ?string $note = null, ?string $invoiceId = null, ?string $idempotency = null ): array { if ($captureId === '') { throw new \InvalidArgumentException('captureId requis'); } $accessToken = $this->generateAccessToken(); if (empty($accessToken)) { throw new \Exception('Impossible de générer le token d’accès PayPal'); } global $db; // Devise par défaut if ($currency === null || $currency === '') { $currency = $this->currency ?? (function () { global $vPaypal_devise; return $vPaypal_devise ?? 'CAD'; })(); } $payload = []; if ($amount !== null && $amount !== '') { $payload['amount'] = [ 'value' => number_format((float)$amount, 2, '.', ''), 'currency_code' => $currency, ]; } if ($note) $payload['note_to_payer'] = mb_strimwidth($note, 0, 255, ''); if ($invoiceId) $payload['invoice_id'] = $invoiceId; // ✅ Base API ABSOLUE selon sandbox/live $base = PAYPAL_BASE_URL; $url = $base . '/v2/payments/captures/' . rawurlencode($captureId) . '/refund'; $headers = [ 'Content-Type: application/json', 'Authorization: Bearer ' . $accessToken, 'Prefer: return=representation', // pour obtenir la représentation complète ]; if ($idempotency) $headers[] = 'PayPal-Request-Id: ' . $idempotency; $ch = curl_init(); curl_setopt_array($ch, [ CURLOPT_URL => $url, CURLOPT_RETURNTRANSFER => 1, CURLOPT_SSL_VERIFYPEER => false, CURLOPT_HTTPHEADER => $headers, CURLOPT_POST => true, CURLOPT_POSTFIELDS => json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), CURLOPT_HEADER => true, // pour récupérer les entêtes (PayPal-Debug-Id) ]); $respAll = curl_exec($ch); $curlerr = curl_error($ch); $httpcode = curl_getinfo($ch, CURLINFO_HTTP_CODE); $hdrSize = curl_getinfo($ch, CURLINFO_HEADER_SIZE); curl_close($ch); if ($curlerr) throw new \Exception('PayPal refund error (cURL): ' . $curlerr); // Sépare entêtes et corps $rawHeaders = substr($respAll, 0, $hdrSize); $resp = substr($respAll, $hdrSize); $data = json_decode($resp, true); if ($httpcode >= 200 && $httpcode < 300) { // update bd if (isset($data['status']) && strtoupper($data['status']) === 'COMPLETED') { // PayPal-Debug-Id si présent $paypalDebugId = null; if ($rawHeaders && preg_match('/^PayPal-Debug-Id:\s*([^\r\n]+)/mi', $rawHeaders, $m)) { $paypalDebugId = trim($m[1]); } // Normalisation montants $norm = static function($v) { if ($v === null || $v === '' || !is_numeric($v)) return null; return number_format((float)$v, 2, '.', ''); }; $row = [ 'refund_id' => $data['id'] ?? null, 'capture_id' => $captureId, 'status' => $data['status'] ?? null, 'amount_value' => $norm($data['amount']['value'] ?? null), 'amount_currency' => $data['amount']['currency_code'] ?? null, 'invoice_id' => $data['invoice_id'] ?? null, 'custom_id' => $data['custom_id'] ?? null, 'acquirer_reference_number' => $data['acquirer_reference_number'] ?? null, 'seller_gross' => $norm($data['seller_payable_breakdown']['gross_amount']['value'] ?? null), 'seller_net' => $norm($data['seller_payable_breakdown']['net_amount']['value'] ?? null), 'paypal_fee' => $norm($data['seller_payable_breakdown']['paypal_fee']['value'] ?? null), 'note_to_payer' => isset($data['note_to_payer']) ? mb_strimwidth($data['note_to_payer'], 0, 255, '') : null, 'paypal_debug_id' => $paypalDebugId, 'idempotency_key' => $idempotency ?: null, 'create_time' => isset($data['create_time']) ? date('Y-m-d H:i:s', strtotime($data['create_time'])) : null, 'update_time' => isset($data['update_time']) ? date('Y-m-d H:i:s', strtotime($data['update_time'])) : null, 'raw_json' => $resp, ]; // INSERT via $GLOBALS['db']->fxQuery (remplace le bloc PDO par ceci) $Q = function($v){ return $v === null ? "NULL" : "'" . addslashes($v) . "'"; }; $QN = function($v){ return $v === null ? "NULL" : (string)(float)$v; }; // nombres décimaux $sql = "INSERT INTO inscriptions_panier_remboursement SET refund_id = " . $Q($row['refund_id']) . ", capture_id = " . $Q($row['capture_id']) . ", status = " . $Q($row['status']) . ", amount_value = " . $QN($row['amount_value']) . ", amount_currency = " . $Q($row['amount_currency']) . ", invoice_id = " . $Q($row['invoice_id']) . ", custom_id = " . $Q($row['custom_id']) . ", acquirer_reference_number = " . $Q($row['acquirer_reference_number']) . ", seller_gross = " . $QN($row['seller_gross']) . ", seller_net = " . $QN($row['seller_net']) . ", paypal_fee = " . $QN($row['paypal_fee']) . ", note_to_payer = " . $Q($row['note_to_payer']) . ", paypal_debug_id = " . $Q($row['paypal_debug_id']) . ", idempotency_key = " . $Q($row['idempotency_key']) . ", create_time = " . $Q($row['create_time']) . ", raw_json = " . $Q($row['raw_json']) . " ON DUPLICATE KEY UPDATE /* n’écrase que si une valeur est fournie; sinon on garde l’existante */ capture_id = COALESCE(VALUES(capture_id), capture_id), status = COALESCE(VALUES(status), status), amount_value = COALESCE(VALUES(amount_value), amount_value), amount_currency = COALESCE(VALUES(amount_currency), amount_currency), invoice_id = COALESCE(VALUES(invoice_id), invoice_id), custom_id = COALESCE(VALUES(custom_id), custom_id), acquirer_reference_number = COALESCE(VALUES(acquirer_reference_number), acquirer_reference_number), seller_gross = COALESCE(VALUES(seller_gross), seller_gross), seller_net = COALESCE(VALUES(seller_net), seller_net), paypal_fee = COALESCE(VALUES(paypal_fee), paypal_fee), note_to_payer = COALESCE(VALUES(note_to_payer), note_to_payer), paypal_debug_id = COALESCE(VALUES(paypal_debug_id), paypal_debug_id), idempotency_key = COALESCE(VALUES(idempotency_key), idempotency_key), raw_json = COALESCE(VALUES(raw_json), raw_json), update_time = NOW()"; $res = $db->fxQuery($sql); if (!$res) { if (method_exists($GLOBALS['db'], 'fxError')) { echo "
ERREUR SQL : " . $GLOBALS['db']->fxError() . "
"; } elseif (property_exists($GLOBALS['db'], 'error') && $GLOBALS['db']->error) { echo "
ERREUR SQL : " . $GLOBALS['db']->error . "
"; } else { echo "
ERREUR SQL : requête échouée (aucun message disponible)
"; } } // (Optionnel) si ta lib offre une méthode d’erreur, tu peux logger ici // if(!$res) error_log('paypal_refunds insert failed: ' . $GLOBALS['db']->fxError()); } return $data ?: ['status' => 'OK']; } // Affiche l'erreur JSON si dispo; sinon le body brut $msg = $resp ?: 'no body'; throw new \Exception('PayPal refund failed (HTTP ' . $httpcode . '): ' . $msg); } }