PluginProbe ʕ •ᴥ•ʔ
Booking for Appointments and Events Calendar – Amelia / trunk
Booking for Appointments and Events Calendar – Amelia vtrunk
2.4.4 2.4.3 2.4.2 2.4.1 2.4 trunk 1.2.1 1.2.10 1.2.11 1.2.12 1.2.13 1.2.14 1.2.15 1.2.16 1.2.17 1.2.18 1.2.19 1.2.2 1.2.20 1.2.21 1.2.22 1.2.23 1.2.24 1.2.25 1.2.26 1.2.27 1.2.28 1.2.29 1.2.3 1.2.30 1.2.31 1.2.32 1.2.33 1.2.34 1.2.35 1.2.36 1.2.37 1.2.38 1.2.4 1.2.5 1.2.6 1.2.7 1.2.8 1.2.9 2.0 2.0.1 2.0.2 2.1 2.1.1 2.1.2 2.1.3 2.2 2.2.1 2.3
ameliabooking / src / Infrastructure / Services / PayPal / PayPalClient.php
ameliabooking / src / Infrastructure / Services / PayPal Last commit date
PayPalClient.php 3 months ago PayPalResponse.php 3 months ago
PayPalClient.php
258 lines
1 <?php
2
3 /**
4 * @copyright © Melograno Ventures. All rights reserved.
5 * @licence See LICENCE.md for license details.
6 */
7
8 namespace AmeliaBooking\Infrastructure\Services\PayPal;
9
10 use Exception;
11
12 /**
13 * Class PayPalClient
14 *
15 * Native cURL-based PayPal REST API v2 client with two-tier OAuth token caching.
16 */
17 class PayPalClient
18 {
19 private const SANDBOX_API_BASE = 'https://api-m.sandbox.paypal.com';
20 private const LIVE_API_BASE = 'https://api-m.paypal.com';
21
22 /**
23 * @var string
24 */
25 private string $clientId;
26
27 /**
28 * @var string
29 */
30 private string $secret;
31
32 /**
33 * @var bool
34 */
35 private bool $testMode;
36
37 /**
38 * @var string
39 */
40 private string $apiBase;
41
42 /**
43 * @var string|null
44 */
45 private ?string $accessToken = null;
46
47 /**
48 * @var int|null
49 */
50 private ?int $tokenExpiry = null;
51
52 /**
53 * PayPalClient constructor.
54 */
55 public function __construct(string $clientId, string $secret, bool $testMode = true)
56 {
57 $this->clientId = $clientId;
58 $this->secret = $secret;
59 $this->testMode = $testMode;
60 $this->apiBase = $testMode ? self::SANDBOX_API_BASE : self::LIVE_API_BASE;
61 }
62
63 /**
64 * Create PayPal order
65 *
66 * @throws Exception
67 */
68 public function createOrder(array $data): array
69 {
70 $payload = [
71 'intent' => 'CAPTURE',
72 'purchase_units' => [
73 [
74 'amount' => [
75 'currency_code' => $data['currency'],
76 'value' => number_format((float)$data['amount'], 2, '.', ''),
77 ],
78 'description' => !empty($data['description'])
79 ? substr($data['description'], 0, 127)
80 : 'Payment',
81 ],
82 ],
83 'application_context' => [
84 'return_url' => $data['returnUrl'],
85 'cancel_url' => $data['cancelUrl'],
86 'user_action' => 'PAY_NOW',
87 ],
88 ];
89
90 return $this->request('POST', '/v2/checkout/orders', $payload);
91 }
92
93 /**
94 * Capture a PayPal order
95 *
96 * @throws Exception
97 */
98 public function captureOrder(string $orderId): array
99 {
100 if (empty($orderId)) {
101 throw new Exception('Order ID is required');
102 }
103
104 return $this->request('POST', '/v2/checkout/orders/' . urlencode($orderId) . '/capture', []);
105 }
106
107 /**
108 * Get order details
109 *
110 * @throws Exception
111 */
112 public function getOrder(string $orderId): array
113 {
114 if (empty($orderId)) {
115 throw new Exception('Order ID is required');
116 }
117
118 return $this->request('GET', '/v2/checkout/orders/' . urlencode($orderId));
119 }
120
121 /**
122 * Refund a captured payment (full or partial).
123 *
124 * @throws Exception
125 */
126 public function refundCapture(string $captureId, array $data = []): array
127 {
128 if (empty($captureId)) {
129 throw new Exception('Capture ID is required');
130 }
131
132 $payload = [];
133
134 if (!empty($data['amount'])) {
135 $payload['amount'] = [
136 'currency_code' => !empty($data['currency']) ? $data['currency'] : 'USD',
137 'value' => number_format((float)$data['amount'], 2, '.', ''),
138 ];
139 }
140
141 return $this->request('POST', '/v2/payments/captures/' . urlencode($captureId) . '/refund', $payload);
142 }
143
144 /**
145 * Get or refresh the OAuth 2.0 access token.
146 *
147 * Uses a two-tier cache: in-memory for the current request lifetime, and a
148 * WordPress transient for cross-request caching (~1-hour TTL), reducing
149 * repeated auth round-trips to a single call per payment cycle.
150 *
151 * @throws Exception
152 */
153 private function getAccessToken(): string
154 {
155 if ($this->accessToken !== null && $this->tokenExpiry !== null && time() < $this->tokenExpiry) {
156 return $this->accessToken;
157 }
158
159 $cacheKey = 'amelia_paypal_token_' . md5($this->clientId . ($this->testMode ? '_sb' : '_live'));
160 $cached = get_transient($cacheKey);
161
162 if (
163 $cached !== false &&
164 !empty($cached['token']) &&
165 !empty($cached['expiry']) &&
166 time() < (int)$cached['expiry']
167 ) {
168 $this->accessToken = $cached['token'];
169 $this->tokenExpiry = (int)$cached['expiry'];
170
171 return $this->accessToken;
172 }
173
174 $response = $this->request('POST', '/v1/oauth2/token', ['grant_type' => 'client_credentials'], true);
175
176 if (empty($response['access_token'])) {
177 throw new Exception('Failed to obtain PayPal access token');
178 }
179
180 $expiresIn = isset($response['expires_in']) ? (int)$response['expires_in'] : 3600;
181
182 $this->accessToken = $response['access_token'];
183 $this->tokenExpiry = time() + $expiresIn - 60;
184
185 set_transient(
186 $cacheKey,
187 ['token' => $this->accessToken, 'expiry' => $this->tokenExpiry],
188 $expiresIn - 120
189 );
190
191 return $this->accessToken;
192 }
193
194 /**
195 * Execute an HTTP request against the PayPal REST API.
196 *
197 * @throws Exception
198 */
199 private function request(string $method, string $endpoint, ?array $data = null, bool $isAuthRequest = false): array
200 {
201 $url = $this->apiBase . $endpoint;
202
203 if ($isAuthRequest) {
204 $headers = [
205 'Content-Type: application/x-www-form-urlencoded',
206 'Accept: application/json',
207 ];
208 } else {
209 $headers = [
210 'Content-Type: application/json',
211 'Accept: application/json',
212 'Authorization: Bearer ' . $this->getAccessToken(),
213 ];
214 }
215
216 $ch = curl_init();
217 curl_setopt($ch, CURLOPT_URL, $url);
218 curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);
219 curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
220 curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
221 curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
222 curl_setopt($ch, CURLOPT_TIMEOUT, 30);
223
224 if ($isAuthRequest) {
225 curl_setopt($ch, CURLOPT_USERPWD, $this->clientId . ':' . $this->secret);
226 }
227
228 if (in_array($method, ['POST', 'PATCH'], true)) {
229 if ($isAuthRequest) {
230 $body = http_build_query($data ?? []);
231 } else {
232 $payload = !empty($data) ? $data : new \stdClass();
233 $body = json_encode($payload);
234 }
235
236 curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
237 }
238
239 $rawResponse = curl_exec($ch);
240 $httpCode = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
241 $curlError = curl_error($ch);
242
243 if ($curlError) {
244 throw new Exception('PayPal API cURL error: ' . $curlError);
245 }
246
247 $decoded = json_decode($rawResponse, true);
248
249 if (!is_array($decoded)) {
250 throw new Exception('Invalid PayPal API response (HTTP ' . $httpCode . '): ' . substr((string)$rawResponse, 0, 200));
251 }
252
253 $decoded['_http_code'] = $httpCode;
254
255 return $decoded;
256 }
257 }
258