PluginProbe
King Addons for Elementor – 80+ Elementor Widgets, 4 000+ Elementor Templates, WooCommerce, Mega Menu, Popup Builder / 51.1.83
King Addons for Elementor – 80+ Elementor Widgets, 4 000+ Elementor Templates, WooCommerce, Mega Menu, Popup Builder v51.1.83
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 51.1.46 51.1.47 51.1.49 All 37 releases
king-addons / includes / widgets / Form_Builder / helpers / Payments.php

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

819 lines 29.8 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 * The site nonce is a CSRF check only. An existing submission is charged
166 * only when the request presents the secret issued when it was created,
167 * and the price is taken from that submission's stored form — never from
168 * a form id the caller names independently.
169 *
170 * @return void
171 */
172 public static function handle(): void
173 {
174 $nonce = isset($_POST['nonce']) ? sanitize_text_field(wp_unslash($_POST['nonce'])) : '';
175 if (!wp_verify_nonce($nonce, 'king-addons-js')) {
176 wp_send_json_error(['message' => 'invalid-nonce', 'status' => 'error']);
177 }
178
179 if (class_exists('King_Addons\\Form_Builder_Security')) {
180 Form_Builder_Security::guard_spam();
181 }
182
183 $requested_form_id = isset($_POST['king_addons_form_id'])
184 ? sanitize_key(wp_unslash($_POST['king_addons_form_id']))
185 : '';
186 if ('' === $requested_form_id && isset($_POST['form_id'])) {
187 $requested_form_id = sanitize_key(wp_unslash($_POST['form_id']));
188 }
189
190 $post_id = absint($_POST['form_page_id'] ?? 0);
191 $named_id = absint($_POST['submission_id'] ?? 0);
192 $access_secret = isset($_POST['access_secret'])
193 ? sanitize_text_field(wp_unslash($_POST['access_secret']))
194 : '';
195 $form_id = $requested_form_id;
196
197 if ($named_id) {
198 if ('king-addons-fb-sub' !== get_post_type($named_id)) {
199 $named_id = 0;
200 } else {
201 if (!class_exists('King_Addons\\Create_Submission') || !Create_Submission::verify_access_secret($named_id, $access_secret)) {
202 wp_send_json_error([
203 'action' => 'king_addons_form_builder_payment',
204 'status' => 'error',
205 'message' => esc_html__('This payment could not be authorised.', 'king-addons'),
206 ]);
207 }
208
209 $stored_form_id = sanitize_key((string) get_post_meta($named_id, 'king_addons_form_id', true));
210 $stored_page_id = absint(get_post_meta($named_id, 'king_addons_form_page_id', true));
211 if ('' === $stored_form_id) {
212 wp_send_json_error([
213 'action' => 'king_addons_form_builder_payment',
214 'status' => 'error',
215 'message' => esc_html__('This payment could not be authorised.', 'king-addons'),
216 ]);
217 }
218
219 if ('' !== $requested_form_id && $requested_form_id !== $stored_form_id) {
220 wp_send_json_error([
221 'action' => 'king_addons_form_builder_payment',
222 'status' => 'error',
223 'message' => esc_html__('This payment does not match the original form.', 'king-addons'),
224 ]);
225 }
226
227 $form_id = $stored_form_id;
228 if ($stored_page_id > 0) {
229 $post_id = $stored_page_id;
230 }
231 }
232 }
233
234 $config = '' === $form_id ? [] : self::get_settings($form_id, $post_id);
235 $provider = (string) ($config['provider'] ?? 'none');
236
237 if ('none' === $provider) {
238 wp_send_json_success([
239 'action' => 'king_addons_form_builder_payment',
240 'status' => 'success',
241 'message' => esc_html__('No payment is configured for this form.', 'king-addons'),
242 ]);
243 }
244
245 // phpcs:ignore WordPress.Security.NonceVerification.Missing -- checked above.
246 $raw = isset($_POST['form_content']) && is_array($_POST['form_content']) ? wp_unslash($_POST['form_content']) : [];
247 $amount = self::resolve_amount($config, $raw);
248
249 // Amount first: an over-max total should be refused even when Stripe
250 // or PayPal keys are empty, otherwise leftover QA (and a mis-set
251 // ceiling) only ever sees "not configured".
252 if (null === $amount || $amount <= 0) {
253 $max = (float) ($config['max_amount'] ?? 0);
254 $computed = self::compute_amount($config, $raw);
255 $over_max = $max > 0 && null !== $computed && $computed > $max;
256 wp_send_json_error([
257 'action' => 'king_addons_form_builder_payment',
258 'status' => 'error',
259 'message' => $over_max
260 ? esc_html__('This amount is over the maximum allowed for this form.', 'king-addons')
261 : esc_html__('The amount to charge could not be worked out.', 'king-addons'),
262 ]);
263 }
264
265 if ('stripe' === $provider && '' === (string) get_option('king_addons_stripe_secret_key', '')) {
266 wp_send_json_error([
267 'action' => 'king_addons_form_builder_payment',
268 'status' => 'error',
269 'message' => esc_html__('Stripe is not configured. Add a secret key in King Addons → Settings.', 'king-addons'),
270 ]);
271 }
272
273 if ('paypal' === $provider && ('' === (string) get_option('king_addons_paypal_client_id', '') || '' === (string) get_option('king_addons_paypal_secret', ''))) {
274 wp_send_json_error([
275 'action' => 'king_addons_form_builder_payment',
276 'status' => 'error',
277 'message' => esc_html__('PayPal is not configured. Add a client ID and secret in King Addons → Settings.', 'king-addons'),
278 ]);
279 }
280
281 $submission_id = 0;
282 if (class_exists('King_Addons\\Form_Payment_Confirm')) {
283 $submission_id = Form_Payment_Confirm::ensure_pending_submission($config, $amount, $provider, [
284 'submission_id' => $named_id,
285 'access_secret' => $access_secret,
286 'form_id' => $form_id,
287 'form_name' => sanitize_text_field(wp_unslash($_POST['form_name'] ?? '')),
288 'form_page' => sanitize_text_field(wp_unslash($_POST['form_page'] ?? '')),
289 'form_page_id' => $post_id,
290 'form_content' => $raw,
291 ]);
292 }
293
294 if ($named_id && !$submission_id) {
295 wp_send_json_error([
296 'action' => 'king_addons_form_builder_payment',
297 'status' => 'error',
298 'message' => esc_html__('This payment could not be authorised.', 'king-addons'),
299 ]);
300 }
301
302 $result = 'stripe' === $provider
303 ? self::create_stripe_session($config, $amount, $submission_id)
304 : self::create_paypal_order($config, $amount, $submission_id);
305
306 // Both providers answer with ['url' => …] when they accepted the
307 // payment, and a plain string naming the problem when they did not.
308 if (!is_array($result) || empty($result['url'])) {
309 wp_send_json_error([
310 'action' => 'king_addons_form_builder_payment',
311 'status' => 'error',
312 'message' => esc_html__('The payment could not be started.', 'king-addons'),
313 'reason' => is_string($result) ? $result : 'unknown',
314 ]);
315 }
316
317 wp_send_json_success([
318 'action' => 'king_addons_form_builder_payment',
319 'status' => 'success',
320 'message' => esc_html__('Redirecting to payment.', 'king-addons'),
321 'redirect' => (string) $result['url'],
322 'amount' => $amount,
323 ]);
324 }
325
326 /**
327 * Work out what to charge.
328 *
329 * @param array<string,mixed> $config Stored settings.
330 * @param array<mixed> $raw Submitted form content.
331 *
332 * @return float|null
333 */
334 private static function resolve_amount(array $config, array $raw): ?float
335 {
336 $amount = self::compute_amount($config, $raw);
337 if (null === $amount) {
338 return null;
339 }
340
341 $max = (float) ($config['max_amount'] ?? 0);
342 if ($max > 0 && $amount > $max) {
343 // A ceiling the author set: better to refuse than to charge a
344 // number that came out of a formula fed by the visitor.
345 return null;
346 }
347
348 return round((float) $amount, 2);
349 }
350
351 /**
352 * Amount before the max-amount ceiling is applied.
353 *
354 * @param array<string,mixed> $config Stored settings.
355 * @param array<mixed> $raw Submitted form content.
356 *
357 * @return float|null
358 */
359 private static function compute_amount(array $config, array $raw): ?float
360 {
361 $fixed = (float) ($config['fixed_amount'] ?? 0);
362 $formula = (string) ($config['amount_formula'] ?? '');
363
364 if ('' === $formula) {
365 return $fixed > 0 ? $fixed : null;
366 }
367
368 $values = [];
369
370 foreach ($raw as $key => $entry) {
371 if (!is_array($entry) || !isset($entry[1]) || is_array($entry[1])) {
372 continue;
373 }
374
375 $id = str_replace('form_field-', '', sanitize_text_field((string) $key));
376 $values[$id] = sanitize_text_field((string) $entry[1]);
377 }
378
379 $amount = class_exists('King_Addons\\Form_Formula')
380 ? Form_Formula::evaluate($formula, $values)
381 : null;
382
383 return null === $amount ? null : (float) $amount;
384 }
385
386 /**
387 * Turn an amount into the units the provider expects.
388 *
389 * @param float $amount Amount.
390 * @param string $currency Currency code.
391 *
392 * @return int
393 */
394 public static function to_provider_minor_units(float $amount, string $currency): int
395 {
396 return self::to_minor_units($amount, $currency);
397 }
398
399 /**
400 * Turn an amount into the units the provider expects.
401 *
402 * @param float $amount Amount.
403 * @param string $currency Currency code.
404 *
405 * @return int
406 */
407 private static function to_minor_units(float $amount, string $currency): int
408 {
409 if (in_array(strtoupper($currency), self::ZERO_DECIMAL, true)) {
410 return (int) round($amount);
411 }
412
413 return (int) round($amount * 100);
414 }
415
416 /**
417 * A URL the visitor is sent to after paying, always on this site.
418 *
419 * @param string $url Configured URL.
420 * @param string $fallback Fallback path.
421 *
422 * @return string
423 */
424 private static function return_url(string $url, string $fallback): string
425 {
426 $url = trim($url);
427
428 if ('' === $url) {
429 return home_url($fallback);
430 }
431
432 // Keep the round trip on this site: an off-site return URL would let a
433 // form send people anywhere after payment.
434 $host = wp_parse_url($url, PHP_URL_HOST);
435 $home = wp_parse_url(home_url(), PHP_URL_HOST);
436
437 return ($host && $host === $home) ? $url : home_url($fallback);
438 }
439
440 /**
441 * Public wrapper so return-URL handlers can reuse the filterable endpoints.
442 *
443 * @param string $provider Provider key.
444 * @param string $url Default URL.
445 *
446 * @return string
447 */
448 public static function public_endpoint(string $provider, string $url): string
449 {
450 return self::endpoint($provider, $url);
451 }
452
453 /**
454 * The endpoint a provider is called on. Filterable for proxies and tests.
455 *
456 * @param string $provider Provider key.
457 * @param string $url Default URL.
458 *
459 * @return string
460 */
461 private static function endpoint(string $provider, string $url): string
462 {
463 /**
464 * Filters the endpoint a Form Builder payment provider is called on.
465 *
466 * @param string $url Endpoint URL.
467 * @param string $provider Provider key.
468 */
469 return (string) apply_filters('king_addons/form_builder/payment_endpoint', $url, $provider);
470 }
471
472 /**
473 * Create a Stripe Checkout session.
474 *
475 * @param array<string,mixed> $config Settings.
476 * @param float $amount Amount.
477 * @param int $submission_id Linked submission.
478 *
479 * @return array{url:string}|string The URL to send the visitor to, or a reason.
480 */
481 private static function create_stripe_session(array $config, float $amount, int $submission_id = 0)
482 {
483 $secret = (string) get_option('king_addons_stripe_secret_key', '');
484 if ('' === $secret) {
485 return 'no-stripe-key';
486 }
487
488 $currency = strtolower((string) ($config['currency'] ?? 'usd'));
489 $description = (string) ($config['description'] ?? '');
490 if ('' === $description) {
491 $description = esc_html__('Form submission', 'king-addons');
492 }
493
494 $success = self::return_url((string) ($config['success_url'] ?? ''), '/?ka-payment=success');
495 $success .= (false === strpos($success, '?') ? '?' : '&') . 'session_id={CHECKOUT_SESSION_ID}';
496
497 $body = [
498 'mode' => 'payment',
499 'success_url' => $success,
500 'cancel_url' => self::return_url((string) ($config['cancel_url'] ?? ''), '/?ka-payment=cancelled'),
501 'line_items[0][quantity]' => 1,
502 'line_items[0][price_data][currency]' => $currency,
503 'line_items[0][price_data][unit_amount]' => self::to_minor_units($amount, $currency),
504 'line_items[0][price_data][product_data][name]' => $description,
505 ];
506
507 if ($submission_id) {
508 $body['client_reference_id'] = (string) $submission_id;
509 $body['metadata[ka_submission]'] = (string) $submission_id;
510 }
511
512 $response = wp_remote_post(self::endpoint('stripe', 'https://api.stripe.com/v1/checkout/sessions'), [
513 'timeout' => 20,
514 'headers' => [
515 'Authorization' => 'Bearer ' . $secret,
516 'Content-Type' => 'application/x-www-form-urlencoded',
517 ],
518 'body' => $body,
519 ]);
520
521 if (is_wp_error($response)) {
522 return 'request-failed';
523 }
524
525 $data = json_decode((string) wp_remote_retrieve_body($response), true);
526
527 if (!is_array($data) || empty($data['url'])) {
528 return 'http-' . (int) wp_remote_retrieve_response_code($response);
529 }
530
531 if ($submission_id && !empty($data['id'])) {
532 update_post_meta($submission_id, Form_Payment_Confirm::META_STRIPE_SESSION, sanitize_text_field((string) $data['id']));
533 }
534
535 return ['url' => (string) $data['url']];
536 }
537
538 /**
539 * Create a PayPal order and return its approval link.
540 *
541 * @param array<string,mixed> $config Settings.
542 * @param float $amount Amount.
543 * @param int $submission_id Linked submission.
544 *
545 * @return array{url:string}|string The URL to send the visitor to, or a reason.
546 */
547 private static function create_paypal_order(array $config, float $amount, int $submission_id = 0)
548 {
549 $client_id = (string) get_option('king_addons_paypal_client_id', '');
550 $secret = (string) get_option('king_addons_paypal_secret', '');
551
552 if ('' === $client_id || '' === $secret) {
553 return 'no-paypal-keys';
554 }
555
556 $base = 'live' === get_option('king_addons_paypal_environment', 'sandbox')
557 ? 'https://api-m.paypal.com'
558 : 'https://api-m.sandbox.paypal.com';
559
560 $token = self::paypal_token($base, $client_id, $secret);
561 if (!is_string($token) || '' === $token) {
562 return 'paypal-auth-failed';
563 }
564
565 $currency = strtoupper((string) ($config['currency'] ?? 'USD'));
566 $unit = [
567 'amount' => [
568 'currency_code' => $currency,
569 'value' => number_format($amount, 2, '.', ''),
570 ],
571 'description' => (string) ($config['description'] ?? ''),
572 ];
573 if ($submission_id) {
574 $unit['custom_id'] = (string) $submission_id;
575 $unit['invoice_id'] = 'ka-' . $submission_id . '-' . time();
576 }
577
578 $response = wp_remote_post(self::endpoint('paypal_order', $base . '/v2/checkout/orders'), [
579 'timeout' => 20,
580 'headers' => [
581 'Authorization' => 'Bearer ' . $token,
582 'Content-Type' => 'application/json',
583 ],
584 'body' => wp_json_encode([
585 'intent' => 'CAPTURE',
586 'purchase_units' => [$unit],
587 'application_context' => [
588 'return_url' => self::return_url((string) ($config['success_url'] ?? ''), '/?ka-payment=success'),
589 'cancel_url' => self::return_url((string) ($config['cancel_url'] ?? ''), '/?ka-payment=cancelled'),
590 ],
591 ]),
592 ]);
593
594 if (is_wp_error($response)) {
595 return 'request-failed';
596 }
597
598 $data = json_decode((string) wp_remote_retrieve_body($response), true);
599
600 if (!is_array($data) || empty($data['links'])) {
601 return 'http-' . (int) wp_remote_retrieve_response_code($response);
602 }
603
604 if ($submission_id && !empty($data['id'])) {
605 update_post_meta($submission_id, Form_Payment_Confirm::META_PAYPAL_ORDER, sanitize_text_field((string) $data['id']));
606 }
607
608 foreach ($data['links'] as $link) {
609 if (is_array($link) && 'approve' === ($link['rel'] ?? '') && !empty($link['href'])) {
610 return ['url' => (string) $link['href']];
611 }
612 }
613
614 return 'no-approval-link';
615 }
616
617 /**
618 * Capture a PayPal order after the buyer returns. Safe to call twice.
619 *
620 * @param string $order_id PayPal order id from the return URL token.
621 *
622 * @return array<string,mixed>|string
623 */
624 public static function capture_paypal_order(string $order_id)
625 {
626 $order_id = sanitize_text_field($order_id);
627 if ('' === $order_id) {
628 return 'missing-order';
629 }
630
631 $submission_id = class_exists('King_Addons\\Form_Payment_Confirm')
632 ? Form_Payment_Confirm::find_by_meta(Form_Payment_Confirm::META_PAYPAL_ORDER, $order_id)
633 : 0;
634
635 $client_id = (string) get_option('king_addons_paypal_client_id', '');
636 $secret = (string) get_option('king_addons_paypal_secret', '');
637 if ('' === $client_id || '' === $secret) {
638 return 'no-paypal-keys';
639 }
640
641 $base = 'live' === get_option('king_addons_paypal_environment', 'sandbox')
642 ? 'https://api-m.paypal.com'
643 : 'https://api-m.sandbox.paypal.com';
644
645 $token = self::paypal_token($base, $client_id, $secret);
646 if (!is_string($token) || '' === $token) {
647 return 'paypal-auth-failed';
648 }
649
650 $existing = self::paypal_get_order($base, $token, $order_id);
651 if (is_array($existing) && 'COMPLETED' === ($existing['status'] ?? '')) {
652 $txn = self::paypal_capture_id($existing) ?: $order_id;
653 if ($submission_id && !self::paypal_amount_matches($submission_id, $existing)) {
654 Form_Payment_Confirm::apply_status($submission_id, 'failed', $txn, 'paypal-mismatch-' . $order_id);
655 return 'amount-mismatch';
656 }
657 if ($submission_id) {
658 Form_Payment_Confirm::apply_status($submission_id, 'paid', $txn, 'paypal-completed-' . $order_id);
659 }
660 return $existing;
661 }
662
663 $response = wp_remote_post(self::endpoint('paypal_capture', $base . '/v2/checkout/orders/' . rawurlencode($order_id) . '/capture'), [
664 'timeout' => 20,
665 'headers' => [
666 'Authorization' => 'Bearer ' . $token,
667 'Content-Type' => 'application/json',
668 'Prefer' => 'return=representation',
669 ],
670 'body' => '{}',
671 ]);
672
673 if (is_wp_error($response)) {
674 return 'request-failed';
675 }
676
677 $data = json_decode((string) wp_remote_retrieve_body($response), true);
678 $code = (int) wp_remote_retrieve_response_code($response);
679
680 if (!is_array($data)) {
681 return 'http-' . $code;
682 }
683
684 $name = (string) ($data['name'] ?? '');
685 if ('INSTRUMENT_DECLINED' === $name) {
686 if ($submission_id) {
687 Form_Payment_Confirm::apply_status($submission_id, 'failed', $order_id, 'paypal-declined-' . $order_id);
688 }
689 $approve = '';
690 foreach (($data['links'] ?? []) as $link) {
691 if (is_array($link) && 'approve' === ($link['rel'] ?? '') && !empty($link['href'])) {
692 $approve = (string) $link['href'];
693 break;
694 }
695 }
696 if ('' !== $approve && !headers_sent()) {
697 wp_safe_redirect($approve);
698 exit;
699 }
700 return $data;
701 }
702
703 if ('COMPLETED' !== ($data['status'] ?? '')) {
704 if ($submission_id && in_array($data['status'] ?? '', ['VOIDED', 'DECLINED'], true)) {
705 Form_Payment_Confirm::apply_status($submission_id, 'failed', $order_id, 'paypal-' . strtolower((string) $data['status']) . '-' . $order_id);
706 }
707 return $data;
708 }
709
710 if ($submission_id && !self::paypal_amount_matches($submission_id, $data)) {
711 Form_Payment_Confirm::apply_status($submission_id, 'failed', $order_id, 'paypal-mismatch-' . $order_id);
712 return 'amount-mismatch';
713 }
714
715 $txn = self::paypal_capture_id($data) ?: $order_id;
716 if ($submission_id) {
717 Form_Payment_Confirm::apply_status($submission_id, 'paid', $txn, 'paypal-capture-' . $order_id);
718 }
719
720 return $data;
721 }
722
723 /**
724 * GET a PayPal order.
725 *
726 * @param string $base API base.
727 * @param string $token Access token.
728 * @param string $order_id Order id.
729 *
730 * @return array<string,mixed>|null
731 */
732 private static function paypal_get_order(string $base, string $token, string $order_id): ?array
733 {
734 $response = wp_remote_get(self::endpoint('paypal_order_get', $base . '/v2/checkout/orders/' . rawurlencode($order_id)), [
735 'timeout' => 20,
736 'headers' => [
737 'Authorization' => 'Bearer ' . $token,
738 ],
739 ]);
740
741 if (is_wp_error($response)) {
742 return null;
743 }
744
745 $data = json_decode((string) wp_remote_retrieve_body($response), true);
746
747 return is_array($data) ? $data : null;
748 }
749
750 /**
751 * Capture id from a PayPal order/capture payload.
752 *
753 * @param array<string,mixed> $data Payload.
754 *
755 * @return string
756 */
757 private static function paypal_capture_id(array $data): string
758 {
759 $captures = $data['purchase_units'][0]['payments']['captures'][0]['id'] ?? '';
760
761 return is_string($captures) ? $captures : '';
762 }
763
764 /**
765 * Captured amount and currency must match what the form stored.
766 *
767 * @param int $submission_id Submission.
768 * @param array<string,mixed> $data PayPal payload.
769 *
770 * @return bool
771 */
772 private static function paypal_amount_matches(int $submission_id, array $data): bool
773 {
774 $expected_amount = (float) get_post_meta($submission_id, Form_Payment_Confirm::META_EXPECTED_AMOUNT, true);
775 $expected_currency = strtoupper((string) get_post_meta($submission_id, Form_Payment_Confirm::META_EXPECTED_CURRENCY, true));
776 $amount = $data['purchase_units'][0]['payments']['captures'][0]['amount']['value']
777 ?? $data['purchase_units'][0]['amount']['value']
778 ?? '';
779 $currency = $data['purchase_units'][0]['payments']['captures'][0]['amount']['currency_code']
780 ?? $data['purchase_units'][0]['amount']['currency_code']
781 ?? '';
782
783 if ('' === $amount || '' === $currency) {
784 return false;
785 }
786
787 return abs((float) $amount - $expected_amount) < 0.009 && strtoupper((string) $currency) === $expected_currency;
788 }
789
790 /**
791 * Exchange the PayPal credentials for an access token.
792 *
793 * @param string $base API base URL.
794 * @param string $client_id Client id.
795 * @param string $secret Secret.
796 *
797 * @return string|null
798 */
799 private static function paypal_token(string $base, string $client_id, string $secret): ?string
800 {
801 $response = wp_remote_post(self::endpoint('paypal_token', $base . '/v1/oauth2/token'), [
802 'timeout' => 20,
803 'headers' => [
804 'Authorization' => 'Basic ' . base64_encode($client_id . ':' . $secret),
805 'Content-Type' => 'application/x-www-form-urlencoded',
806 ],
807 'body' => ['grant_type' => 'client_credentials'],
808 ]);
809
810 if (is_wp_error($response)) {
811 return null;
812 }
813
814 $data = json_decode((string) wp_remote_retrieve_body($response), true);
815
816 return (is_array($data) && !empty($data['access_token'])) ? (string) $data['access_token'] : null;
817 }
818 }
819