provider === 'stripe') { return $this->createStripeCheckout($transaction, $goal, $providerConfig, $successUrl, $cancelUrl); } if ($transaction->provider === 'paypal') { return $this->createPayPalCheckout($transaction, $goal, $providerConfig, $successUrl, $cancelUrl); } return [ 'success' => false, 'error' => 'Unsupported payment provider.', ]; } public function capturePayPalOrder(DonationTransaction $transaction, DonationProviderConfig $providerConfig): array { if ($transaction->provider !== 'paypal' || $transaction->mode !== 'one_time') { return [ 'success' => false, 'error' => 'PayPal one-time order capture is only valid for one-time PayPal transactions.', ]; } $orderId = trim((string)$transaction->provider_checkout_id); if ($orderId === '') { return [ 'success' => false, 'error' => 'PayPal order ID is missing on transaction.', ]; } $context = $this->getPayPalRequestContext($providerConfig); if (!($context['ok'] ?? false)) { return [ 'success' => false, 'error' => (string)($context['error'] ?? 'PayPal auth context is unavailable.'), 'status' => (int)($context['status'] ?? 0), 'response' => (string)($context['response'] ?? ''), ]; } $baseUrl = (string)$context['base_url']; $accessToken = (string)$context['access_token']; $captureResponse = $this->httpRequest( 'POST', $baseUrl . '/v2/checkout/orders/' . rawurlencode($orderId) . '/capture', [ 'Authorization: Bearer ' . $accessToken, 'Content-Type: application/json', 'Prefer: return=representation', ], '{}' ); $captureData = json_decode((string)$captureResponse['body'], true); $captureId = $this->extractPayPalCaptureId($captureData); if ($captureResponse['ok']) { return [ 'success' => true, 'payment_id' => $captureId, 'raw' => is_array($captureData) ? $captureData : null, ]; } if ((int)$captureResponse['status'] === 422 && stripos((string)$captureResponse['body'], 'ORDER_ALREADY_CAPTURED') !== false) { $orderResponse = $this->httpRequest( 'GET', $baseUrl . '/v2/checkout/orders/' . rawurlencode($orderId), [ 'Authorization: Bearer ' . $accessToken, 'Content-Type: application/json', ], '' ); $orderData = json_decode((string)$orderResponse['body'], true); if ($orderResponse['ok']) { return [ 'success' => true, 'already_captured' => true, 'payment_id' => $this->extractPayPalCaptureId($orderData), 'raw' => is_array($orderData) ? $orderData : null, ]; } } return [ 'success' => false, 'error' => 'PayPal order capture failed.', 'status' => (int)$captureResponse['status'], 'response' => (string)$captureResponse['body'], ]; } private function createStripeCheckout( DonationTransaction $transaction, DonationGoal $goal, DonationProviderConfig $providerConfig, string $successUrl, string $cancelUrl ): array { $secretKey = trim(ProviderCredentialResolver::resolveStripeSecretKey($providerConfig)); if ($secretKey === '') { return [ 'success' => false, 'error' => 'Stripe secret key is missing.', ]; } $currency = strtolower((string)$transaction->currency); $unitAmount = (int)round(((float)$transaction->amount) * 100); if ($unitAmount < 1) { return [ 'success' => false, 'error' => 'Invalid donation amount.', ]; } $lineItem = [ 'line_items[0][quantity]' => 1, 'line_items[0][price_data][currency]' => $currency, 'line_items[0][price_data][product_data][name]' => (string)$goal->title, 'line_items[0][price_data][unit_amount]' => $unitAmount, ]; if ($transaction->mode === 'recurring') { $payload = array_merge($lineItem, [ 'mode' => 'subscription', 'line_items[0][price_data][recurring][interval]' => 'month', ]); } else { $payload = array_merge($lineItem, [ 'mode' => 'payment', ]); } $payload['success_url'] = $successUrl; $payload['cancel_url'] = $cancelUrl; $payload['metadata[transaction_id]'] = (string)$transaction->id; $payload['metadata[goal_id]'] = (string)$transaction->goal_id; $payload['metadata[contentcontainer_id]'] = (string)$transaction->contentcontainer_id; $response = $this->httpRequest( 'POST', 'https://api.stripe.com/v1/checkout/sessions', [ 'Authorization: Bearer ' . $secretKey, 'Content-Type: application/x-www-form-urlencoded', ], http_build_query($payload, '', '&') ); if (!$response['ok']) { return [ 'success' => false, 'error' => 'Stripe checkout session creation failed.', 'status' => $response['status'], 'response' => $response['body'], ]; } $data = json_decode((string)$response['body'], true); if (!is_array($data) || empty($data['url']) || empty($data['id'])) { return [ 'success' => false, 'error' => 'Stripe response is invalid.', 'response' => $response['body'], ]; } return [ 'success' => true, 'redirect_url' => (string)$data['url'], 'checkout_id' => (string)$data['id'], 'payment_id' => (string)($data['payment_intent'] ?? ''), 'subscription_id' => (string)($data['subscription'] ?? ''), 'customer_id' => (string)($data['customer'] ?? ''), 'raw' => $data, ]; } private function createPayPalCheckout( DonationTransaction $transaction, DonationGoal $goal, DonationProviderConfig $providerConfig, string $successUrl, string $cancelUrl ): array { $clientId = trim(ProviderCredentialResolver::resolvePayPalClientId($providerConfig)); $clientSecret = trim(ProviderCredentialResolver::resolvePayPalClientSecret($providerConfig)); if ($clientId === '' || $clientSecret === '') { return [ 'success' => false, 'error' => 'PayPal client credentials are missing.', ]; } $baseUrl = ProviderCredentialResolver::isSandboxMode($providerConfig) ? 'https://api-m.sandbox.paypal.com' : 'https://api-m.paypal.com'; $tokenResponse = $this->httpRequest( 'POST', $baseUrl . '/v1/oauth2/token', [ 'Authorization: Basic ' . base64_encode($clientId . ':' . $clientSecret), 'Content-Type: application/x-www-form-urlencoded', ], 'grant_type=client_credentials' ); if (!$tokenResponse['ok']) { return [ 'success' => false, 'error' => 'PayPal token request failed.', 'status' => $tokenResponse['status'], 'response' => $tokenResponse['body'], ]; } $accessToken = $this->extractAccessToken($tokenResponse['body']); if ($accessToken === '') { return [ 'success' => false, 'error' => 'PayPal access token missing in response.', ]; } if ($transaction->mode === 'recurring') { return $this->createPayPalRecurringCheckout( $transaction, $goal, $providerConfig, $baseUrl, $accessToken, $successUrl, $cancelUrl ); } $orderPayload = [ 'intent' => 'CAPTURE', 'purchase_units' => [[ 'custom_id' => (string)$transaction->id, 'description' => (string)$goal->title, 'amount' => [ 'currency_code' => strtoupper((string)$transaction->currency), 'value' => number_format((float)$transaction->amount, 2, '.', ''), ], ]], 'application_context' => [ 'return_url' => $successUrl, 'cancel_url' => $cancelUrl, ], ]; $orderResponse = $this->httpRequest( 'POST', $baseUrl . '/v2/checkout/orders', [ 'Authorization: Bearer ' . $accessToken, 'Content-Type: application/json', ], json_encode($orderPayload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ); if (!$orderResponse['ok']) { return [ 'success' => false, 'error' => 'PayPal order creation failed.', 'status' => $orderResponse['status'], 'response' => $orderResponse['body'], ]; } $orderData = json_decode((string)$orderResponse['body'], true); if (!is_array($orderData) || empty($orderData['id'])) { return [ 'success' => false, 'error' => 'PayPal response is invalid.', 'response' => $orderResponse['body'], ]; } $approveUrl = ''; foreach ((array)($orderData['links'] ?? []) as $link) { if (($link['rel'] ?? '') === 'approve') { $approveUrl = (string)($link['href'] ?? ''); break; } } if ($approveUrl === '') { return [ 'success' => false, 'error' => 'PayPal approve URL is missing.', 'response' => $orderResponse['body'], ]; } return [ 'success' => true, 'redirect_url' => $approveUrl, 'checkout_id' => (string)$orderData['id'], 'raw' => $orderData, ]; } private function createPayPalRecurringCheckout( DonationTransaction $transaction, DonationGoal $goal, DonationProviderConfig $providerConfig, string $baseUrl, string $accessToken, string $successUrl, string $cancelUrl ): array { $currency = strtoupper((string)$transaction->currency); $amount = number_format((float)$transaction->amount, 2, '.', ''); $productPayload = [ 'name' => substr((string)$goal->title, 0, 127), 'description' => substr((string)($goal->description ?: $goal->title), 0, 255), 'type' => 'SERVICE', 'category' => 'SOFTWARE', ]; $productResponse = $this->httpRequest( 'POST', $baseUrl . '/v1/catalogs/products', [ 'Authorization: Bearer ' . $accessToken, 'Content-Type: application/json', ], json_encode($productPayload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ); if (!$productResponse['ok']) { return [ 'success' => false, 'error' => 'PayPal product creation failed.', 'status' => $productResponse['status'], 'response' => $productResponse['body'], ]; } $productData = json_decode((string)$productResponse['body'], true); $productId = (string)($productData['id'] ?? ''); if ($productId === '') { return [ 'success' => false, 'error' => 'PayPal product response is invalid.', 'response' => $productResponse['body'], ]; } $planPayload = [ 'product_id' => $productId, 'name' => substr((string)$goal->title . ' Monthly Donation', 0, 127), 'status' => 'ACTIVE', 'billing_cycles' => [[ 'frequency' => [ 'interval_unit' => 'MONTH', 'interval_count' => 1, ], 'tenure_type' => 'REGULAR', 'sequence' => 1, 'total_cycles' => 0, 'pricing_scheme' => [ 'fixed_price' => [ 'value' => $amount, 'currency_code' => $currency, ], ], ]], 'payment_preferences' => [ 'auto_bill_outstanding' => true, 'setup_fee_failure_action' => 'CONTINUE', 'payment_failure_threshold' => 3, ], ]; $planResponse = $this->httpRequest( 'POST', $baseUrl . '/v1/billing/plans', [ 'Authorization: Bearer ' . $accessToken, 'Content-Type: application/json', ], json_encode($planPayload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ); if (!$planResponse['ok']) { return [ 'success' => false, 'error' => 'PayPal plan creation failed.', 'status' => $planResponse['status'], 'response' => $planResponse['body'], ]; } $planData = json_decode((string)$planResponse['body'], true); $planId = (string)($planData['id'] ?? ''); if ($planId === '') { return [ 'success' => false, 'error' => 'PayPal plan response is invalid.', 'response' => $planResponse['body'], ]; } $subscriptionPayload = [ 'plan_id' => $planId, 'custom_id' => (string)$transaction->id, 'application_context' => [ 'brand_name' => 'Rescue Hub', 'user_action' => 'SUBSCRIBE_NOW', 'return_url' => $successUrl, 'cancel_url' => $cancelUrl, ], ]; $subscriptionResponse = $this->httpRequest( 'POST', $baseUrl . '/v1/billing/subscriptions', [ 'Authorization: Bearer ' . $accessToken, 'Content-Type: application/json', ], json_encode($subscriptionPayload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ); if (!$subscriptionResponse['ok']) { return [ 'success' => false, 'error' => 'PayPal subscription creation failed.', 'status' => $subscriptionResponse['status'], 'response' => $subscriptionResponse['body'], ]; } $subscriptionData = json_decode((string)$subscriptionResponse['body'], true); $subscriptionId = (string)($subscriptionData['id'] ?? ''); if ($subscriptionId === '') { return [ 'success' => false, 'error' => 'PayPal subscription response is invalid.', 'response' => $subscriptionResponse['body'], ]; } $approveUrl = ''; foreach ((array)($subscriptionData['links'] ?? []) as $link) { if (($link['rel'] ?? '') === 'approve') { $approveUrl = (string)($link['href'] ?? ''); break; } } if ($approveUrl === '') { return [ 'success' => false, 'error' => 'PayPal subscription approve URL is missing.', 'response' => $subscriptionResponse['body'], ]; } return [ 'success' => true, 'redirect_url' => $approveUrl, 'checkout_id' => $subscriptionId, 'subscription_id' => $subscriptionId, 'raw' => $subscriptionData, ]; } private function extractAccessToken(string $responseBody): string { $data = json_decode($responseBody, true); return (string)($data['access_token'] ?? ''); } private function getPayPalRequestContext(DonationProviderConfig $providerConfig): array { $clientId = trim(ProviderCredentialResolver::resolvePayPalClientId($providerConfig)); $clientSecret = trim(ProviderCredentialResolver::resolvePayPalClientSecret($providerConfig)); if ($clientId === '' || $clientSecret === '') { return [ 'ok' => false, 'error' => 'PayPal client credentials are missing.', 'status' => 0, 'response' => '', ]; } $baseUrl = ProviderCredentialResolver::isSandboxMode($providerConfig) ? 'https://api-m.sandbox.paypal.com' : 'https://api-m.paypal.com'; $tokenResponse = $this->httpRequest( 'POST', $baseUrl . '/v1/oauth2/token', [ 'Authorization: Basic ' . base64_encode($clientId . ':' . $clientSecret), 'Content-Type: application/x-www-form-urlencoded', ], 'grant_type=client_credentials' ); if (!$tokenResponse['ok']) { return [ 'ok' => false, 'error' => 'PayPal token request failed.', 'status' => (int)$tokenResponse['status'], 'response' => (string)$tokenResponse['body'], ]; } $accessToken = $this->extractAccessToken((string)$tokenResponse['body']); if ($accessToken === '') { return [ 'ok' => false, 'error' => 'PayPal access token missing in response.', 'status' => 0, 'response' => (string)$tokenResponse['body'], ]; } return [ 'ok' => true, 'base_url' => $baseUrl, 'access_token' => $accessToken, ]; } private function extractPayPalCaptureId($orderData): string { if (!is_array($orderData)) { return ''; } $units = (array)($orderData['purchase_units'] ?? []); $firstUnit = $units[0] ?? null; if (!is_array($firstUnit)) { return ''; } $captures = (array)($firstUnit['payments']['captures'] ?? []); $firstCapture = $captures[0] ?? null; if (!is_array($firstCapture)) { return ''; } return (string)($firstCapture['id'] ?? ''); } private function httpRequest(string $method, string $url, array $headers, string $body): array { if (!function_exists('curl_init')) { return [ 'ok' => false, 'status' => 0, 'body' => 'cURL extension is not available.', ]; } $ch = curl_init($url); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_CUSTOMREQUEST, strtoupper($method)); curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); curl_setopt($ch, CURLOPT_TIMEOUT, 30); curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 10); if ($body !== '') { curl_setopt($ch, CURLOPT_POSTFIELDS, $body); } $raw = curl_exec($ch); $err = curl_error($ch); $status = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); if ($raw === false) { return [ 'ok' => false, 'status' => $status, 'body' => $err, ]; } return [ 'ok' => $status >= 200 && $status < 300, 'status' => $status, 'body' => (string)$raw, ]; } }