PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.6.5
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.6.5
1.6.6 1.6.5 1.6.4 1.6.3 1.6.2 1.6.1 1.6.0 1.5.4 1.5.5 1.5.3 1.5.2 1.5.1 1.5.0 1.4.2 1.4.1 1.4.0 1.3.28 1.3.27 1.3.26 1.3.25 1.3.23 1.3.22 1.3.21 1.3.20 1.3.19 All 49 releases
fluent-cart / app / Modules / PaymentMethods / AirwallexGateway / Airwallex.php

Airwallex.php in FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler 1.6.5, at app/Modules/PaymentMethods/AirwallexGateway/Airwallex.php

501 lines 17.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace FluentCart\App\Modules\PaymentMethods\AirwallexGateway;
4
5 use FluentCart\App\App;
6 use FluentCart\App\Modules\PaymentMethods\Core\AbstractPaymentGateway;
7 use FluentCart\App\Services\Payments\PaymentInstance;
8 use FluentCart\App\Vite;
9 use FluentCart\Framework\Support\Arr;
10
11 class Airwallex extends AbstractPaymentGateway
12 {
13 public array $supportedFeatures = [
14 'payment',
15 'refund',
16 'webhook'
17 ];
18
19 public function __construct()
20 {
21 parent::__construct(new AirwallexSettingsBase());
22 }
23
24 public function meta(): array
25 {
26 return [
27 'title' => __('Airwallex', 'fluent-cart'),
28 'route' => 'airwallex',
29 'description' => __('Pay securely with Airwallex - Global payment processing', 'fluent-cart'),
30 'logo' => Vite::getAssetUrl("images/payment-methods/airwallex-logo.svg"),
31 'logo_light' => Vite::getAssetUrl("images/payment-methods/airwallex-logo-light.svg"),
32 'icon' => Vite::getAssetUrl("images/payment-methods/airwallex-logo.svg"),
33 'brand_color' => '#6c5ce7',
34 'status' => $this->settings->get('is_active') === 'yes',
35 'upcoming' => true,
36 ];
37 }
38
39 public function boot()
40 {
41 // init IPN related class/actions here
42 add_filter('fluent_cart/payment_methods/airwallex_settings', [$this, 'getSettings'], 10, 2);
43 }
44
45 public function makePaymentFromPaymentInstance(PaymentInstance $paymentInstance)
46 {
47 // todo: implement in future
48 die();
49 }
50
51 public function refund($refundInfo, $order, $transaction)
52 {
53 // todo: implement in future
54 die();
55 }
56
57 public function handleIPN(): void
58 {
59 // Airwallex is not released yet (meta 'upcoming' => true). The event
60 // handlers below are unfinished scaffolding that write order state
61 // directly, outside the transaction/event pipeline every live gateway
62 // uses, and without the cross-checks a real listener needs. Refuse to
63 // process any event while the gateway is upcoming so no forged or
64 // replayed payload can reach them.
65 //
66 // Before removing this guard at GA, the handlers MUST:
67 // - resolve the order by the intent id we stamped at intent creation,
68 // never by attacker-supplied metadata.order_id;
69 // - verify amount, currency and payment mode against the stored order;
70 // - reject stale callbacks (timestamp-age window + per-event dedup) and
71 // guard terminal states (do not flip a paid order to failed);
72 // - route through the shared PaymentHelper/transaction machinery.
73 if ($this->isUpcoming()) {
74 http_response_code(404);
75 exit();
76 }
77
78 $payload = json_decode(file_get_contents('php://input'), true); // will get from request after verification
79 // Verify webhook signature
80 if (!$this->verifyWebhookSignature($payload)) {
81 http_response_code(401);
82 exit('Unauthorized');
83 }
84
85 $eventType = $payload['name'] ?? '';
86
87 switch ($eventType) {
88 case 'payment_intent.succeeded':
89 $this->handlePaymentSucceeded($payload);
90 break;
91 case 'payment_intent.failed':
92 $this->handlePaymentFailed($payload);
93 break;
94 case 'refund.received':
95 $this->handleRefundReceived($payload);
96 break;
97 }
98
99 http_response_code(200);
100 exit('OK');
101 }
102
103 public function getOrderInfo(array $data)
104 {
105 $items = $this->getCheckoutItems();
106
107 $subTotal = 0;
108 foreach ($items as $item) {
109 $subTotal += intval($item['quantity'] * $item['unit_price']);
110 }
111
112 $paymentArgs = [
113 'client_id' => $this->settings->getClientId(),
114 'amount' => $subTotal,
115 'currency' => $this->storeSettings->get('currency'),
116 ];
117
118 wp_send_json([
119 'status' => 'success',
120 'payment_args' => $paymentArgs,
121 'has_subscription' => false
122 ], 200);
123 }
124
125 public function fields()
126 {
127 $webhook_url = Arr::get($this->getListenerUrl(), 'listener_url');
128 $webhook_instructions = sprintf(
129 '<div>
130 <p><b>%1$s</b><code class="copyable-content">%2$s</code></p>
131 <p>%3$s</p>
132 <br>
133 <h4>%4$s</h4>
134 <br>
135 <p>%5$s</p>
136 <p>%6$s <a href="https://www.airwallex.com/app/settings/developer" target="_blank">%7$s</a></p>
137 <p>%8$s <code class="copyable-content">%2$s</code></p>
138 <p>%9$s</p>
139 </div>',
140 __('Webhook URL: ', 'fluent-cart'), // %1$s
141 $webhook_url, // %2$s (reused)
142 __('You should configure your Airwallex webhooks to get all updates of your payments remotely.', 'fluent-cart'), // %3$s
143 __('How to configure?', 'fluent-cart'), // %4$s
144 __('In your Airwallex Console:', 'fluent-cart'), // %5$s
145 __('Go to Developers > Webhooks >', 'fluent-cart'), // %6$s
146 __('Add webhook', 'fluent-cart'), // %7$s
147 __('Enter The Webhook URL: ', 'fluent-cart'), // %8$s
148 __('Select payment events', 'fluent-cart') // %9$s
149 );
150
151 return array(
152 // 'notice' => [
153 // 'value' => $this->getStoreModeNotice(),
154 // 'label' => __('Store Mode notice', 'fluent-cart'),
155 // 'type' => 'notice'
156 // ],
157 'upcoming' => [
158 'value' => $this->isUpcoming(),
159 'label' => __('Payment method is upcoming!', 'fluent-cart'),
160 'type' => 'upcoming'
161 ],
162 'payment_mode' => [
163 'type' => 'tabs',
164 'schema' => [
165 [
166 'type' => 'tab',
167 'label' => __('Test credentials', 'fluent-cart'),
168 'value' => 'test',
169 'schema' => [
170 'test_client_id' => array(
171 'value' => '',
172 'label' => __('Test Client ID', 'fluent-cart'),
173 'type' => 'text',
174 'placeholder' => __('Your test client ID', 'fluent-cart'),
175 'dependency' => [
176 'depends_on' => 'payment_mode',
177 'operator' => '=',
178 'value' => 'test'
179 ]
180 ),
181 'test_api_key' => array(
182 'value' => '',
183 'label' => __('Test API Key', 'fluent-cart'),
184 'type' => 'password',
185 'placeholder' => __('Your test API key', 'fluent-cart'),
186 'dependency' => [
187 'depends_on' => 'payment_mode',
188 'operator' => '=',
189 'value' => 'test'
190 ]
191 ),
192 ],
193 ],
194 [
195 'type' => 'tab',
196 'label' => __('Live credentials', 'fluent-cart'),
197 'value' => 'live',
198 'schema' => [
199 'live_client_id' => array(
200 'value' => '',
201 'label' => __('Live Client ID', 'fluent-cart'),
202 'type' => 'text',
203 'placeholder' => __('Your live client ID', 'fluent-cart'),
204 'dependency' => [
205 'depends_on' => 'payment_mode',
206 'operator' => '=',
207 'value' => 'live'
208 ]
209 ),
210 'live_api_key' => array(
211 'value' => '',
212 'label' => __('Live API Key', 'fluent-cart'),
213 'type' => 'password',
214 'placeholder' => __('Your live API key', 'fluent-cart'),
215 'dependency' => [
216 'depends_on' => 'payment_mode',
217 'operator' => '=',
218 'value' => 'live'
219 ]
220 ),
221 ]
222 ]
223 ]
224 ],
225 'webhook_desc' => array(
226 'value' => $webhook_instructions,
227 'label' => __('Webhook URL', 'fluent-cart'),
228 'type' => 'html_attr'
229 ),
230 );
231 }
232
233 public static function validateSettings($data): array
234 {
235 $clientId = $data['client_id'] ?? '';
236 $apiKey = $data['api_key'] ?? '';
237
238 if (empty($clientId) || empty($apiKey)) {
239 return [
240 'status' => 'failed',
241 'message' => __('Client ID and API Key are required', 'fluent-cart')
242 ];
243 }
244
245 try {
246 $testResponse = static::testApiConnection($clientId, $apiKey, $data['payment_mode'] ?? 'demo');
247
248 return [
249 'status' => 'success',
250 'message' => __('Airwallex settings validated successfully', 'fluent-cart')
251 ];
252 } catch (\Exception $e) {
253 return [
254 'status' => 'failed',
255 'message' => $e->getMessage()
256 ];
257 }
258 }
259
260 public function getEnqueueScriptSrc($hasSubscription = 'no'): array
261 {
262 $mode = $this->settings->getMode();
263 $airwallexJsUrl = $mode === 'production'
264 ? 'https://checkout.airwallex.com/assets/elements.bundle.min.js'
265 : 'https://checkout.airwallex.com/assets/elements.bundle.min.js';
266
267 return [
268 [
269 'handle' => 'airwallex-elements-js',
270 'src' => $airwallexJsUrl,
271 ],
272 [
273 'handle' => 'fluent-cart-airwallex-checkout',
274 'src' => Vite::getEnqueuePath('public/payment-methods/airwallex-checkout.js'),
275 'deps' => ['airwallex-elements-js']
276 ]
277 ];
278 }
279
280 public function getEnqueueStyleSrc(): array
281 {
282 return [
283 [
284 'handle' => 'fluent-cart-airwallex-styles',
285 'src' => Vite::getEnqueuePath('public/payment-methods/airwallex.css'),
286 ]
287 ];
288 }
289
290 private function getAccessToken()
291 {
292 $clientId = $this->settings->getClientId();
293 $apiKey = $this->settings->getApiKey();
294 $baseUrl = $this->getApiBaseUrl();
295
296 $response = wp_remote_post("{$baseUrl}/api/v1/authentication/login", [
297 'headers' => [
298 'Content-Type' => 'application/json'
299 ],
300 'body' => json_encode([
301 'x-client-id' => $clientId,
302 'x-api-key' => $apiKey
303 ]),
304 'timeout' => 30
305 ]);
306
307 if (is_wp_error($response)) {
308 throw new \Exception(esc_html($response->get_error_message()));
309 }
310
311 $body = wp_remote_retrieve_body($response);
312 $data = json_decode($body, true);
313
314 if (wp_remote_retrieve_response_code($response) !== 201) {
315 throw new \Exception(esc_html__('Failed to authenticate with Airwallex', 'fluent-cart'));
316 }
317
318 return $data['token'];
319 }
320
321 private function createAirwallexPaymentIntent($data, $accessToken)
322 {
323 $baseUrl = $this->getApiBaseUrl();
324
325 $response = wp_remote_post("{$baseUrl}/api/v1/pa/payment_intents/create", [
326 'headers' => [
327 'Authorization' => 'Bearer ' . $accessToken,
328 'Content-Type' => 'application/json'
329 ],
330 'body' => json_encode($data),
331 'timeout' => 30
332 ]);
333
334 if (is_wp_error($response)) {
335 throw new \Exception(esc_html($response->get_error_message()));
336 }
337
338 $body = wp_remote_retrieve_body($response);
339 $responseData = json_decode($body, true);
340
341 if (wp_remote_retrieve_response_code($response) !== 201) {
342 $error = $responseData['message'] ?? 'Unknown error';
343 throw new \Exception(esc_html($error));
344 }
345
346 return $responseData;
347 }
348
349 private function processAirwallexRefund($refundData, $accessToken)
350 {
351 $baseUrl = $this->getApiBaseUrl();
352
353 $response = wp_remote_post("{$baseUrl}/api/v1/pa/refunds/create", [
354 'headers' => [
355 'Authorization' => 'Bearer ' . $accessToken,
356 'Content-Type' => 'application/json'
357 ],
358 'body' => json_encode($refundData),
359 'timeout' => 30
360 ]);
361
362 if (is_wp_error($response)) {
363 throw new \Exception(esc_html($response->get_error_message()));
364 }
365
366 $body = wp_remote_retrieve_body($response);
367 $responseData = json_decode($body, true);
368
369 if (wp_remote_retrieve_response_code($response) !== 201) {
370 $error = $responseData['message'] ?? 'Unknown error';
371 throw new \Exception(esc_html($error));
372 }
373
374 return $responseData;
375 }
376
377 private static function testApiConnection($clientId, $apiKey, $mode)
378 {
379 $baseUrl = $mode === 'production'
380 ? 'https://api.airwallex.com'
381 : 'https://api-demo.airwallex.com';
382
383 $response = wp_remote_post("{$baseUrl}/api/v1/authentication/login", [
384 'headers' => [
385 'Content-Type' => 'application/json'
386 ],
387 'body' => json_encode([
388 'x-client-id' => $clientId,
389 'x-api-key' => $apiKey
390 ]),
391 'timeout' => 30
392 ]);
393
394 if (is_wp_error($response)) {
395 throw new \Exception(esc_html($response->get_error_message()));
396 }
397
398 if (wp_remote_retrieve_response_code($response) !== 201) {
399 throw new \Exception(esc_html__('Invalid Airwallex credentials', 'fluent-cart'));
400 }
401
402 return true;
403 }
404
405 private function verifyWebhookSignature($payload)
406 {
407 $serverData = App::request()->server();
408 $signature = Arr::get($serverData, 'HTTP_X_SIGNATURE', '');
409 $timestamp = Arr::get($serverData, 'HTTP_X_TIMESTAMP', '');
410 $webhookSecret = $this->settings->getWebhookSecret();
411
412 if (!$signature || !$timestamp || !$webhookSecret) {
413 return false;
414 }
415
416 $signedPayload = $timestamp . json_encode($payload);
417 $expectedSignature = hash_hmac('sha256', $signedPayload, $webhookSecret);
418
419 return hash_equals($expectedSignature, $signature);
420 }
421
422 private function getApiBaseUrl()
423 {
424 return $this->settings->getMode() === 'production'
425 ? 'https://api.airwallex.com'
426 : 'https://api-demo.airwallex.com';
427 }
428
429 private function getWebhookUrl()
430 {
431 return site_url('?fluent_cart_payment_api_notify=airwallex');
432 }
433
434 public function webHookPaymentMethodName()
435 {
436 return $this->getMeta('route');
437 }
438
439 private function generateRequestId($order, $type = 'payment')
440 {
441 return $order->uuid . '_' . $type . '_' . time();
442 }
443
444 private function handlePaymentSucceeded($payload)
445 {
446 $paymentIntent = $payload['data']['object'] ?? [];
447 $orderId = $paymentIntent['metadata']['order_id'] ?? '';
448
449 if ($orderId) {
450 $order = fluent_cart_get_order($orderId);
451 if ($order) {
452 $this->handlePaymentSuccess($paymentIntent, $order);
453 }
454 }
455 }
456
457 private function handlePaymentFailed($payload)
458 {
459 $paymentIntent = $payload['data']['object'] ?? [];
460 $orderId = $paymentIntent['metadata']['order_id'] ?? '';
461
462 if ($orderId) {
463 $order = fluent_cart_get_order($orderId);
464 if ($order) {
465 $this->handlePaymentFailure($paymentIntent, $order);
466 }
467 }
468 }
469
470 private function handleRefundReceived($payload)
471 {
472 $refund = $payload['data']['object'] ?? [];
473 // Process refund webhook logic here
474 }
475
476 private function handlePaymentSuccess($paymentIntent, $order)
477 {
478 $order->payment_status = 'paid';
479 $order->status = 'processing';
480 $order->vendor_charge_id = $paymentIntent['id'];
481 $order->save();
482
483 do_action('fluent_cart/payment_success', [
484 'order' => $order,
485 'payment_intent' => $paymentIntent
486 ]);
487 }
488
489 private function handlePaymentFailure($paymentIntent, $order)
490 {
491 $order->payment_status = 'failed';
492 $order->status = 'failed';
493 $order->save();
494
495 do_action('fluent_cart/payment_failed', [
496 'order' => $order,
497 'payment_intent' => $paymentIntent
498 ]);
499 }
500 }
501