PluginProbe
King Addons for Elementor – 100+ Elementor Widgets, 4 000+ Elementor Templates, WooCommerce Builder, Mega Menu, Popup Builder / 51.1.81
King Addons for Elementor – 100+ Elementor Widgets, 4 000+ Elementor Templates, WooCommerce Builder, Mega Menu, Popup Builder v51.1.81
51.1.86 51.1.84 51.1.85 51.1.83 51.1.82 51.1.81 51.1.79 51.1.78 51.1.77 51.1.76 51.1.74 51.1.75 51.1.65 51.1.64 51.1.63 trunk 51.1.14 51.1.2 51.1.35 51.1.36 51.1.37 51.1.38 51.1.39 51.1.44 51.1.45 All 40 releases
king-addons / includes / widgets / Form_Builder / helpers / Payments.php

Payments.php in King Addons for Elementor – 100+ Elementor Widgets, 4 000+ Elementor Templates, WooCommerce Builder, Mega Menu, Popup Builder 51.1.81, at includes/widgets/Form_Builder/helpers/Payments.php

745 lines 26.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Payments for Form Builder: Stripe Checkout and PayPal Orders.
4 *
5 * Both work the same way: the server creates the payment on the provider and
6 * hands the browser a URL to send the visitor to. No card details ever reach
7 * this site.
8 *
9 * @package King_Addons
10 */
11
12 namespace King_Addons;
13
14 if (!defined('ABSPATH')) {
15 exit;
16 }
17
18 /**
19 * Creates a payment for a submitted form.
20 */
21 class Form_Payments
22 {
23 /**
24 * Option prefix for the per-form configuration.
25 */
26 private const OPTION_PREFIX = 'king_addons_payment_';
27
28 /**
29 * Currencies with no minor unit, where Stripe expects whole numbers.
30 *
31 * @var array<int,string>
32 */
33 private const ZERO_DECIMAL = ['BIF', 'CLP', 'DJF', 'GNF', 'JPY', 'KMF', 'KRW', 'MGA', 'PYG', 'RWF', 'UGX', 'VND', 'VUV', 'XAF', 'XOF', 'XPF'];
34
35 /**
36 * Providers offered in the panel.
37 *
38 * @return array<string,string>
39 */
40 public static function providers(): array
41 {
42 return [
43 'none' => esc_html__('None', 'king-addons'),
44 'stripe' => esc_html__('Stripe Checkout', 'king-addons'),
45 'paypal' => esc_html__('PayPal', 'king-addons'),
46 ];
47 }
48
49 /**
50 * Register the endpoint.
51 */
52 public function __construct()
53 {
54 add_action('wp_ajax_king_addons_form_builder_payment', [self::class, 'handle']);
55 add_action('wp_ajax_nopriv_king_addons_form_builder_payment', [self::class, 'handle']);
56 new Form_Payment_Confirm();
57 }
58
59 /**
60 * Store a form's payment settings.
61 *
62 * The amount is never taken from the request: either it is fixed here, or
63 * it is worked out again on the server from the same formula the browser
64 * used.
65 *
66 * @param string $form_id Form element id.
67 * @param array<string,mixed> $settings Widget settings.
68 * @param int $post_id Page that owns the widget.
69 *
70 * @return void
71 */
72 public static function save_settings(string $form_id, array $settings, int $post_id = 0): void
73 {
74 $provider = (string) ($settings['payment_provider'] ?? 'none');
75 if (!array_key_exists($provider, self::providers())) {
76 $provider = 'none';
77 }
78
79 $formula = '';
80 $amount_field = trim((string) ($settings['payment_amount_field'] ?? ''));
81
82 // Find the calculation field the author nominated and keep its formula,
83 // so the price can be recomputed rather than trusted.
84 if ('' !== $amount_field && !empty($settings['form_fields']) && is_array($settings['form_fields'])) {
85 foreach ($settings['form_fields'] as $field) {
86 if (!is_array($field)) {
87 continue;
88 }
89
90 $field_key = class_exists('King_Addons\\Form_Builder')
91 ? Form_Builder::resolve_field_key($field)
92 : trim((string) ($field['field_id'] ?? ''));
93
94 if ($field_key !== $amount_field) {
95 continue;
96 }
97
98 if ('calculation' === ($field['field_type'] ?? '')) {
99 $formula = (string) ($field['calc_formula'] ?? '');
100 }
101
102 break;
103 }
104 }
105
106 update_option(self::option_key($form_id, $post_id), [
107 'provider' => $provider,
108 'currency' => strtoupper(substr((string) ($settings['payment_currency'] ?? 'USD'), 0, 3)),
109 'fixed_amount' => (float) ($settings['payment_fixed_amount'] ?? 0),
110 'amount_field' => $amount_field,
111 'amount_formula' => $formula,
112 'max_amount' => (float) ($settings['payment_max_amount'] ?? 0),
113 'description' => (string) ($settings['payment_description'] ?? ''),
114 'success_url' => (string) ($settings['payment_success_url'] ?? ''),
115 'cancel_url' => (string) ($settings['payment_cancel_url'] ?? ''),
116 ], false);
117 }
118
119 /**
120 * Option name for a form's payment settings.
121 *
122 * Keyed by page and widget so two forms that happen to share an Elementor
123 * id (common when pages are duplicated) do not overwrite each other.
124 *
125 * @param string $form_id Form element id.
126 * @param int $post_id Page that owns the widget.
127 *
128 * @return string
129 */
130 private static function option_key(string $form_id, int $post_id = 0): string
131 {
132 $form_id = sanitize_text_field($form_id);
133 $post_id = $post_id > 0 ? $post_id : (int) get_the_ID();
134
135 if ($post_id > 0) {
136 return self::OPTION_PREFIX . $post_id . '_' . $form_id;
137 }
138
139 return self::OPTION_PREFIX . $form_id;
140 }
141
142 /**
143 * A form's payment settings.
144 *
145 * @param string $form_id Form element id.
146 * @param int $post_id Page that owns the widget.
147 *
148 * @return array<string,mixed>
149 */
150 public static function get_settings(string $form_id, int $post_id = 0): array
151 {
152 $key = self::option_key($form_id, $post_id);
153 $config = get_option($key, null);
154
155 if (!is_array($config)) {
156 $config = get_option(self::OPTION_PREFIX . $form_id, []);
157 }
158
159 return is_array($config) ? $config : [];
160 }
161
162 /**
163 * Create the payment and return where to send the visitor.
164 *
165 * @return void
166 */
167 public static function handle(): void
168 {
169 $nonce = isset($_POST['nonce']) ? sanitize_text_field(wp_unslash($_POST['nonce'])) : '';
170 if (!wp_verify_nonce($nonce, 'king-addons-js')) {
171 wp_send_json_error(['message' => 'invalid-nonce', 'status' => 'error']);
172 }
173
174 if (class_exists('King_Addons\\Form_Builder_Security')) {
175 Form_Builder_Security::guard_spam();
176 }
177
178 $form_id = isset($_POST['king_addons_form_id'])
179 ? sanitize_text_field(wp_unslash($_POST['king_addons_form_id']))
180 : '';
181 if ('' === $form_id && isset($_POST['form_id'])) {
182 $form_id = sanitize_text_field(wp_unslash($_POST['form_id']));
183 }
184
185 $post_id = absint($_POST['form_page_id'] ?? 0);
186 $config = '' === $form_id ? [] : self::get_settings($form_id, $post_id);
187 $provider = (string) ($config['provider'] ?? 'none');
188
189 if ('none' === $provider) {
190 wp_send_json_success([
191 'action' => 'king_addons_form_builder_payment',
192 'status' => 'success',
193 'message' => esc_html__('No payment is configured for this form.', 'king-addons'),
194 ]);
195 }
196
197 // phpcs:ignore WordPress.Security.NonceVerification.Missing -- checked above.
198 $raw = isset($_POST['form_content']) && is_array($_POST['form_content']) ? wp_unslash($_POST['form_content']) : [];
199 $amount = self::resolve_amount($config, $raw);
200
201 // Amount first: an over-max total should be refused even when Stripe
202 // or PayPal keys are empty, otherwise leftover QA (and a mis-set
203 // ceiling) only ever sees "not configured".
204 if (null === $amount || $amount <= 0) {
205 $max = (float) ($config['max_amount'] ?? 0);
206 $computed = self::compute_amount($config, $raw);
207 $over_max = $max > 0 && null !== $computed && $computed > $max;
208 wp_send_json_error([
209 'action' => 'king_addons_form_builder_payment',
210 'status' => 'error',
211 'message' => $over_max
212 ? esc_html__('This amount is over the maximum allowed for this form.', 'king-addons')
213 : esc_html__('The amount to charge could not be worked out.', 'king-addons'),
214 ]);
215 }
216
217 if ('stripe' === $provider && '' === (string) get_option('king_addons_stripe_secret_key', '')) {
218 wp_send_json_error([
219 'action' => 'king_addons_form_builder_payment',
220 'status' => 'error',
221 'message' => esc_html__('Stripe is not configured. Add a secret key in King Addons → Settings.', 'king-addons'),
222 ]);
223 }
224
225 if ('paypal' === $provider && ('' === (string) get_option('king_addons_paypal_client_id', '') || '' === (string) get_option('king_addons_paypal_secret', ''))) {
226 wp_send_json_error([
227 'action' => 'king_addons_form_builder_payment',
228 'status' => 'error',
229 'message' => esc_html__('PayPal is not configured. Add a client ID and secret in King Addons → Settings.', 'king-addons'),
230 ]);
231 }
232
233 $submission_id = 0;
234 if (class_exists('King_Addons\\Form_Payment_Confirm')) {
235 $submission_id = Form_Payment_Confirm::ensure_pending_submission($config, $amount, $provider, [
236 'submission_id' => absint($_POST['submission_id'] ?? 0),
237 'form_id' => $form_id !== '' ? $form_id : sanitize_text_field(wp_unslash($_POST['form_id'] ?? '')),
238 'form_name' => sanitize_text_field(wp_unslash($_POST['form_name'] ?? '')),
239 'form_page' => sanitize_text_field(wp_unslash($_POST['form_page'] ?? '')),
240 'form_page_id' => absint($_POST['form_page_id'] ?? 0),
241 'form_content' => $raw,
242 ]);
243 }
244
245 $result = 'stripe' === $provider
246 ? self::create_stripe_session($config, $amount, $submission_id)
247 : self::create_paypal_order($config, $amount, $submission_id);
248
249 // Both providers answer with ['url' => …] when they accepted the
250 // payment, and a plain string naming the problem when they did not.
251 if (!is_array($result) || empty($result['url'])) {
252 wp_send_json_error([
253 'action' => 'king_addons_form_builder_payment',
254 'status' => 'error',
255 'message' => esc_html__('The payment could not be started.', 'king-addons'),
256 'reason' => is_string($result) ? $result : 'unknown',
257 ]);
258 }
259
260 wp_send_json_success([
261 'action' => 'king_addons_form_builder_payment',
262 'status' => 'success',
263 'message' => esc_html__('Redirecting to payment.', 'king-addons'),
264 'redirect' => (string) $result['url'],
265 'amount' => $amount,
266 ]);
267 }
268
269 /**
270 * Work out what to charge.
271 *
272 * @param array<string,mixed> $config Stored settings.
273 * @param array<mixed> $raw Submitted form content.
274 *
275 * @return float|null
276 */
277 private static function resolve_amount(array $config, array $raw): ?float
278 {
279 $amount = self::compute_amount($config, $raw);
280 if (null === $amount) {
281 return null;
282 }
283
284 $max = (float) ($config['max_amount'] ?? 0);
285 if ($max > 0 && $amount > $max) {
286 // A ceiling the author set: better to refuse than to charge a
287 // number that came out of a formula fed by the visitor.
288 return null;
289 }
290
291 return round((float) $amount, 2);
292 }
293
294 /**
295 * Amount before the max-amount ceiling is applied.
296 *
297 * @param array<string,mixed> $config Stored settings.
298 * @param array<mixed> $raw Submitted form content.
299 *
300 * @return float|null
301 */
302 private static function compute_amount(array $config, array $raw): ?float
303 {
304 $fixed = (float) ($config['fixed_amount'] ?? 0);
305 $formula = (string) ($config['amount_formula'] ?? '');
306
307 if ('' === $formula) {
308 return $fixed > 0 ? $fixed : null;
309 }
310
311 $values = [];
312
313 foreach ($raw as $key => $entry) {
314 if (!is_array($entry) || !isset($entry[1]) || is_array($entry[1])) {
315 continue;
316 }
317
318 $id = str_replace('form_field-', '', sanitize_text_field((string) $key));
319 $values[$id] = sanitize_text_field((string) $entry[1]);
320 }
321
322 $amount = class_exists('King_Addons\\Form_Formula')
323 ? Form_Formula::evaluate($formula, $values)
324 : null;
325
326 return null === $amount ? null : (float) $amount;
327 }
328
329 /**
330 * Turn an amount into the units the provider expects.
331 *
332 * @param float $amount Amount.
333 * @param string $currency Currency code.
334 *
335 * @return int
336 */
337 private static function to_minor_units(float $amount, string $currency): int
338 {
339 if (in_array(strtoupper($currency), self::ZERO_DECIMAL, true)) {
340 return (int) round($amount);
341 }
342
343 return (int) round($amount * 100);
344 }
345
346 /**
347 * A URL the visitor is sent to after paying, always on this site.
348 *
349 * @param string $url Configured URL.
350 * @param string $fallback Fallback path.
351 *
352 * @return string
353 */
354 private static function return_url(string $url, string $fallback): string
355 {
356 $url = trim($url);
357
358 if ('' === $url) {
359 return home_url($fallback);
360 }
361
362 // Keep the round trip on this site: an off-site return URL would let a
363 // form send people anywhere after payment.
364 $host = wp_parse_url($url, PHP_URL_HOST);
365 $home = wp_parse_url(home_url(), PHP_URL_HOST);
366
367 return ($host && $host === $home) ? $url : home_url($fallback);
368 }
369
370 /**
371 * Public wrapper so return-URL handlers can reuse the filterable endpoints.
372 *
373 * @param string $provider Provider key.
374 * @param string $url Default URL.
375 *
376 * @return string
377 */
378 public static function public_endpoint(string $provider, string $url): string
379 {
380 return self::endpoint($provider, $url);
381 }
382
383 /**
384 * The endpoint a provider is called on. Filterable for proxies and tests.
385 *
386 * @param string $provider Provider key.
387 * @param string $url Default URL.
388 *
389 * @return string
390 */
391 private static function endpoint(string $provider, string $url): string
392 {
393 /**
394 * Filters the endpoint a Form Builder payment provider is called on.
395 *
396 * @param string $url Endpoint URL.
397 * @param string $provider Provider key.
398 */
399 return (string) apply_filters('king_addons/form_builder/payment_endpoint', $url, $provider);
400 }
401
402 /**
403 * Create a Stripe Checkout session.
404 *
405 * @param array<string,mixed> $config Settings.
406 * @param float $amount Amount.
407 * @param int $submission_id Linked submission.
408 *
409 * @return array{url:string}|string The URL to send the visitor to, or a reason.
410 */
411 private static function create_stripe_session(array $config, float $amount, int $submission_id = 0)
412 {
413 $secret = (string) get_option('king_addons_stripe_secret_key', '');
414 if ('' === $secret) {
415 return 'no-stripe-key';
416 }
417
418 $currency = strtolower((string) ($config['currency'] ?? 'usd'));
419 $description = (string) ($config['description'] ?? '');
420 if ('' === $description) {
421 $description = esc_html__('Form submission', 'king-addons');
422 }
423
424 $success = self::return_url((string) ($config['success_url'] ?? ''), '/?ka-payment=success');
425 $success .= (false === strpos($success, '?') ? '?' : '&') . 'session_id={CHECKOUT_SESSION_ID}';
426
427 $body = [
428 'mode' => 'payment',
429 'success_url' => $success,
430 'cancel_url' => self::return_url((string) ($config['cancel_url'] ?? ''), '/?ka-payment=cancelled'),
431 'line_items[0][quantity]' => 1,
432 'line_items[0][price_data][currency]' => $currency,
433 'line_items[0][price_data][unit_amount]' => self::to_minor_units($amount, $currency),
434 'line_items[0][price_data][product_data][name]' => $description,
435 ];
436
437 if ($submission_id) {
438 $body['client_reference_id'] = (string) $submission_id;
439 $body['metadata[ka_submission]'] = (string) $submission_id;
440 }
441
442 $response = wp_remote_post(self::endpoint('stripe', 'https://api.stripe.com/v1/checkout/sessions'), [
443 'timeout' => 20,
444 'headers' => [
445 'Authorization' => 'Bearer ' . $secret,
446 'Content-Type' => 'application/x-www-form-urlencoded',
447 ],
448 'body' => $body,
449 ]);
450
451 if (is_wp_error($response)) {
452 return 'request-failed';
453 }
454
455 $data = json_decode((string) wp_remote_retrieve_body($response), true);
456
457 if (!is_array($data) || empty($data['url'])) {
458 return 'http-' . (int) wp_remote_retrieve_response_code($response);
459 }
460
461 if ($submission_id && !empty($data['id'])) {
462 update_post_meta($submission_id, Form_Payment_Confirm::META_STRIPE_SESSION, sanitize_text_field((string) $data['id']));
463 }
464
465 return ['url' => (string) $data['url']];
466 }
467
468 /**
469 * Create a PayPal order and return its approval link.
470 *
471 * @param array<string,mixed> $config Settings.
472 * @param float $amount Amount.
473 * @param int $submission_id Linked submission.
474 *
475 * @return array{url:string}|string The URL to send the visitor to, or a reason.
476 */
477 private static function create_paypal_order(array $config, float $amount, int $submission_id = 0)
478 {
479 $client_id = (string) get_option('king_addons_paypal_client_id', '');
480 $secret = (string) get_option('king_addons_paypal_secret', '');
481
482 if ('' === $client_id || '' === $secret) {
483 return 'no-paypal-keys';
484 }
485
486 $base = 'live' === get_option('king_addons_paypal_environment', 'sandbox')
487 ? 'https://api-m.paypal.com'
488 : 'https://api-m.sandbox.paypal.com';
489
490 $token = self::paypal_token($base, $client_id, $secret);
491 if (!is_string($token) || '' === $token) {
492 return 'paypal-auth-failed';
493 }
494
495 $currency = strtoupper((string) ($config['currency'] ?? 'USD'));
496 $unit = [
497 'amount' => [
498 'currency_code' => $currency,
499 'value' => number_format($amount, 2, '.', ''),
500 ],
501 'description' => (string) ($config['description'] ?? ''),
502 ];
503 if ($submission_id) {
504 $unit['custom_id'] = (string) $submission_id;
505 $unit['invoice_id'] = 'ka-' . $submission_id . '-' . time();
506 }
507
508 $response = wp_remote_post(self::endpoint('paypal_order', $base . '/v2/checkout/orders'), [
509 'timeout' => 20,
510 'headers' => [
511 'Authorization' => 'Bearer ' . $token,
512 'Content-Type' => 'application/json',
513 ],
514 'body' => wp_json_encode([
515 'intent' => 'CAPTURE',
516 'purchase_units' => [$unit],
517 'application_context' => [
518 'return_url' => self::return_url((string) ($config['success_url'] ?? ''), '/?ka-payment=success'),
519 'cancel_url' => self::return_url((string) ($config['cancel_url'] ?? ''), '/?ka-payment=cancelled'),
520 ],
521 ]),
522 ]);
523
524 if (is_wp_error($response)) {
525 return 'request-failed';
526 }
527
528 $data = json_decode((string) wp_remote_retrieve_body($response), true);
529
530 if (!is_array($data) || empty($data['links'])) {
531 return 'http-' . (int) wp_remote_retrieve_response_code($response);
532 }
533
534 if ($submission_id && !empty($data['id'])) {
535 update_post_meta($submission_id, Form_Payment_Confirm::META_PAYPAL_ORDER, sanitize_text_field((string) $data['id']));
536 }
537
538 foreach ($data['links'] as $link) {
539 if (is_array($link) && 'approve' === ($link['rel'] ?? '') && !empty($link['href'])) {
540 return ['url' => (string) $link['href']];
541 }
542 }
543
544 return 'no-approval-link';
545 }
546
547 /**
548 * Capture a PayPal order after the buyer returns. Safe to call twice.
549 *
550 * @param string $order_id PayPal order id from the return URL token.
551 *
552 * @return array<string,mixed>|string
553 */
554 public static function capture_paypal_order(string $order_id)
555 {
556 $order_id = sanitize_text_field($order_id);
557 if ('' === $order_id) {
558 return 'missing-order';
559 }
560
561 $submission_id = class_exists('King_Addons\\Form_Payment_Confirm')
562 ? Form_Payment_Confirm::find_by_meta(Form_Payment_Confirm::META_PAYPAL_ORDER, $order_id)
563 : 0;
564
565 $client_id = (string) get_option('king_addons_paypal_client_id', '');
566 $secret = (string) get_option('king_addons_paypal_secret', '');
567 if ('' === $client_id || '' === $secret) {
568 return 'no-paypal-keys';
569 }
570
571 $base = 'live' === get_option('king_addons_paypal_environment', 'sandbox')
572 ? 'https://api-m.paypal.com'
573 : 'https://api-m.sandbox.paypal.com';
574
575 $token = self::paypal_token($base, $client_id, $secret);
576 if (!is_string($token) || '' === $token) {
577 return 'paypal-auth-failed';
578 }
579
580 $existing = self::paypal_get_order($base, $token, $order_id);
581 if (is_array($existing) && 'COMPLETED' === ($existing['status'] ?? '')) {
582 $txn = self::paypal_capture_id($existing) ?: $order_id;
583 if ($submission_id) {
584 Form_Payment_Confirm::apply_status($submission_id, 'paid', $txn, 'paypal-completed-' . $order_id);
585 }
586 return $existing;
587 }
588
589 $response = wp_remote_post(self::endpoint('paypal_capture', $base . '/v2/checkout/orders/' . rawurlencode($order_id) . '/capture'), [
590 'timeout' => 20,
591 'headers' => [
592 'Authorization' => 'Bearer ' . $token,
593 'Content-Type' => 'application/json',
594 'Prefer' => 'return=representation',
595 ],
596 'body' => '{}',
597 ]);
598
599 if (is_wp_error($response)) {
600 return 'request-failed';
601 }
602
603 $data = json_decode((string) wp_remote_retrieve_body($response), true);
604 $code = (int) wp_remote_retrieve_response_code($response);
605
606 if (!is_array($data)) {
607 return 'http-' . $code;
608 }
609
610 $name = (string) ($data['name'] ?? '');
611 if ('INSTRUMENT_DECLINED' === $name) {
612 if ($submission_id) {
613 Form_Payment_Confirm::apply_status($submission_id, 'failed', $order_id, 'paypal-declined-' . $order_id);
614 }
615 $approve = '';
616 foreach (($data['links'] ?? []) as $link) {
617 if (is_array($link) && 'approve' === ($link['rel'] ?? '') && !empty($link['href'])) {
618 $approve = (string) $link['href'];
619 break;
620 }
621 }
622 if ('' !== $approve && !headers_sent()) {
623 wp_safe_redirect($approve);
624 exit;
625 }
626 return $data;
627 }
628
629 if ('COMPLETED' !== ($data['status'] ?? '')) {
630 if ($submission_id && in_array($data['status'] ?? '', ['VOIDED', 'DECLINED'], true)) {
631 Form_Payment_Confirm::apply_status($submission_id, 'failed', $order_id, 'paypal-' . strtolower((string) $data['status']) . '-' . $order_id);
632 }
633 return $data;
634 }
635
636 if ($submission_id && !self::paypal_amount_matches($submission_id, $data)) {
637 Form_Payment_Confirm::apply_status($submission_id, 'failed', $order_id, 'paypal-mismatch-' . $order_id);
638 return 'amount-mismatch';
639 }
640
641 $txn = self::paypal_capture_id($data) ?: $order_id;
642 if ($submission_id) {
643 Form_Payment_Confirm::apply_status($submission_id, 'paid', $txn, 'paypal-capture-' . $order_id);
644 }
645
646 return $data;
647 }
648
649 /**
650 * GET a PayPal order.
651 *
652 * @param string $base API base.
653 * @param string $token Access token.
654 * @param string $order_id Order id.
655 *
656 * @return array<string,mixed>|null
657 */
658 private static function paypal_get_order(string $base, string $token, string $order_id): ?array
659 {
660 $response = wp_remote_get(self::endpoint('paypal_order_get', $base . '/v2/checkout/orders/' . rawurlencode($order_id)), [
661 'timeout' => 20,
662 'headers' => [
663 'Authorization' => 'Bearer ' . $token,
664 ],
665 ]);
666
667 if (is_wp_error($response)) {
668 return null;
669 }
670
671 $data = json_decode((string) wp_remote_retrieve_body($response), true);
672
673 return is_array($data) ? $data : null;
674 }
675
676 /**
677 * Capture id from a PayPal order/capture payload.
678 *
679 * @param array<string,mixed> $data Payload.
680 *
681 * @return string
682 */
683 private static function paypal_capture_id(array $data): string
684 {
685 $captures = $data['purchase_units'][0]['payments']['captures'][0]['id'] ?? '';
686
687 return is_string($captures) ? $captures : '';
688 }
689
690 /**
691 * Captured amount and currency must match what the form stored.
692 *
693 * @param int $submission_id Submission.
694 * @param array<string,mixed> $data PayPal payload.
695 *
696 * @return bool
697 */
698 private static function paypal_amount_matches(int $submission_id, array $data): bool
699 {
700 $expected_amount = (float) get_post_meta($submission_id, Form_Payment_Confirm::META_EXPECTED_AMOUNT, true);
701 $expected_currency = strtoupper((string) get_post_meta($submission_id, Form_Payment_Confirm::META_EXPECTED_CURRENCY, true));
702 $amount = $data['purchase_units'][0]['payments']['captures'][0]['amount']['value']
703 ?? $data['purchase_units'][0]['amount']['value']
704 ?? '';
705 $currency = $data['purchase_units'][0]['payments']['captures'][0]['amount']['currency_code']
706 ?? $data['purchase_units'][0]['amount']['currency_code']
707 ?? '';
708
709 if ('' === $amount || '' === $currency) {
710 return false;
711 }
712
713 return abs((float) $amount - $expected_amount) < 0.009 && strtoupper((string) $currency) === $expected_currency;
714 }
715
716 /**
717 * Exchange the PayPal credentials for an access token.
718 *
719 * @param string $base API base URL.
720 * @param string $client_id Client id.
721 * @param string $secret Secret.
722 *
723 * @return string|null
724 */
725 private static function paypal_token(string $base, string $client_id, string $secret): ?string
726 {
727 $response = wp_remote_post(self::endpoint('paypal_token', $base . '/v1/oauth2/token'), [
728 'timeout' => 20,
729 'headers' => [
730 'Authorization' => 'Basic ' . base64_encode($client_id . ':' . $secret),
731 'Content-Type' => 'application/x-www-form-urlencoded',
732 ],
733 'body' => ['grant_type' => 'client_credentials'],
734 ]);
735
736 if (is_wp_error($response)) {
737 return null;
738 }
739
740 $data = json_decode((string) wp_remote_retrieve_body($response), true);
741
742 return (is_array($data) && !empty($data['access_token'])) ? (string) $data['access_token'] : null;
743 }
744 }
745