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 showOrder($orderId) { $accessToken = $this->generateAccessToken(); if (empty($accessToken)) { return false; } else { $ch = curl_init(); curl_setopt($ch, CURLOPT_URL, $this->paypalAPI . '/payments/authorizations/' . $orderId ); 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 showOrder(' . $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) { $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')); } /** * 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'); } // 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 = (strpos($this->paypalAPI, 'sandbox') !== false) ? 'https://api-m.sandbox.paypal.com' : 'https://api-m.paypal.com'; $url = $base . '/v2/payments/captures/' . rawurlencode($captureId) . '/refund'; $headers = [ 'Content-Type: application/json', 'Authorization: Bearer ' . $accessToken, ]; 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), // (Optionnel) pour loguer le PayPal-Debug-Id: // CURLOPT_HEADER => true, ]); $resp = curl_exec($ch); $curlerr = curl_error($ch); $httpcode = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); if ($curlerr) throw new \Exception('PayPal refund error (cURL): ' . $curlerr); $data = json_decode($resp, true); if ($httpcode >= 200 && $httpcode < 300) { 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); } public function getRefundDetails(string $refundId): array { if ($refundId === '') throw new \InvalidArgumentException('refundId requis'); $accessToken = $this->generateAccessToken(); if (empty($accessToken)) throw new \Exception('Impossible de générer le token d’accès PayPal'); $url = rtrim($this->paypalAPI, '/') . '/payments/refunds/' . rawurlencode($refundId); $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 getRefundDetails error (cURL): ' . $curlerr); $data = json_decode($resp, true); if ($httpcode >= 200 && $httpcode < 300) return $data ?: []; throw new \Exception('PayPal getRefundDetails failed (HTTP ' . $httpcode . '): ' . ($resp ?: 'no body')); } 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 = (strpos($this->paypalAPI, 'sandbox') !== false) ? 'https://api-m.sandbox.paypal.com' : 'https://api-m.paypal.com'; $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')); } /** * 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 $t0 = new \DateTime($tCreate); $tU = new \DateTime($tUpdate); $startIso = $t0->sub(new \DateInterval('PT'.max(0,$hoursBefore).'H'))->format(\DateTime::ATOM); $endIso = $tU->add(new \DateInterval('PT'.max(1,$hoursAfter).'H'))->format(\DateTime::ATOM); // OAuth + base api-m $token = $this->generateAccessToken(); if (!$token) throw new \Exception('Token PayPal indisponible'); $base = (strpos((string)$this->paypalAPI, 'sandbox') !== false) ? 'https://api-m.sandbox.paypal.com' : 'https://api-m.paypal.com'; // Fetch paginé /v1/reporting/transactions $fetchTx = function(array $params) use ($base, $token): array { $page = 1; $pageSize = $params['page_size'] ?? 200; $all = []; do { $params['page'] = $page; $url = $base . '/v1/reporting/transactions?' . http_build_query($params); $ch = curl_init($url); curl_setopt_array($ch, [ CURLOPT_HTTPHEADER => ['Authorization: Bearer '.$token, 'Accept: application/json'], 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 (selon tenants) $refundCodes = ['T1107','T1108','T1109','T1110','T0407']; $refunds = []; $seen = []; // dédoublonnage par refund_id 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'] ?? ''; // souvent = capture_id $inv = $ti['invoice_id'] ?? ''; // Relations additionnelles $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']; } // 1) Doit ressembler à un refund $looksLikeRefund = in_array($ev, $refundCodes, true) || strpos($subj, 'refund') !== false; if (!$looksLikeRefund) continue; // 2) ANCRAGE OBLIGATOIRE : capture_id OU (si dispo) invoice_id $strongMatch = ($pref === $capId) || in_array($capId, $parents, true) || ($invId !== '' && $inv === $invId); if (!$strongMatch) continue; // ← plus de match “montant négatif seul” // 3) Normalisation $rid = $eid; if ($rid && !isset($seen[$rid])) { $seen[$rid] = true; $refunds[] = [ 'refund_id' => $rid, 'amount' => abs((float)($amt ?? 0)), 'currency' => $cur ?: $capCur, 'status' => $stat, // S/P/… 'time' => $ts, 'paypal_reference_id' => $pref, 'event_code' => $ev, ]; } } // Totaux $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, ]; } public function debugReportAroundCapture(array $capture, int $hoursBefore = 24, int $hoursAfter = 120): array { $capId = $capture['id'] ?? ''; $invId = $capture['invoice_id'] ?? ''; $tCreate = $capture['create_time'] ?? ''; $tUpdate = $capture['update_time'] ?? $tCreate; $t0 = new \DateTime($tCreate); $tU = new \DateTime($tUpdate); $startIso = $t0->sub(new \DateInterval('PT'.max(0,$hoursBefore).'H'))->format(\DateTime::ATOM); $endIso = $tU->add(new \DateInterval('PT'.max(1,$hoursAfter).'H'))->format(\DateTime::ATOM); $token = $this->generateAccessToken(); $base = (strpos((string)$this->paypalAPI, 'sandbox') !== false) ? 'https://api-m.sandbox.paypal.com' : 'https://api-m.paypal.com'; $url = $base.'/v1/reporting/transactions?'.http_build_query([ 'start_date' => $startIso, 'end_date' => $endIso, 'fields' => 'all', 'page_size' => 200, 'balance_affecting_records_only' => 'N', ]); $ch = curl_init($url); curl_setopt_array($ch, [ CURLOPT_HTTPHEADER => ['Authorization: Bearer '.$token, 'Accept: application/json'], CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 60, ]); $res = curl_exec($ch); $http = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); if ($http !== 200) throw new \Exception("Transaction Search HTTP $http: ".($res ?: '')); $json = json_decode($res, true); $rows = $json['transaction_details'] ?? []; $out = []; foreach ($rows as $row) { $ti = $row['transaction_info'] ?? []; $out[] = [ 'id' => $ti['transaction_id'] ?? '', 'event' => $ti['transaction_event_code'] ?? '', 'subject' => $ti['transaction_subject'] ?? '', 'amount' => $ti['transaction_amount']['value'] ?? '', 'currency' => $ti['transaction_amount']['currency_code'] ?? '', 'D/C' => $ti['debit_or_credit'] ?? '', 'ref' => $ti['paypal_reference_id'] ?? '', 'invoice' => $ti['invoice_id'] ?? '', 'status' => $ti['transaction_status'] ?? '', 'time' => $ti['transaction_updated_date'] ?? ($ti['transaction_initiation_date'] ?? ''), ]; } return ['capture_id'=>$capId,'invoice_id'=>$invId,'window'=>[$startIso,$endIso],'rows'=>$out]; } }