PluginProbe
King Addons for Elementor – 100+ Elementor Widgets, 4 000+ Elementor Templates, WooCommerce Builder, Mega Menu, Popup Builder / 51.1.86
King Addons for Elementor – 100+ Elementor Widgets, 4 000+ Elementor Templates, WooCommerce Builder, Mega Menu, Popup Builder v51.1.86
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 / Payment_Confirm.php

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

539 lines 19.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Confirms Form Builder payments after the visitor returns from Stripe or PayPal.
4 *
5 * Checkout URLs are not trusted on their own. Status is taken from the
6 * provider API or a signed Stripe webhook.
7 *
8 * @package King_Addons
9 */
10
11 namespace King_Addons;
12
13 if (!defined('ABSPATH')) {
14 exit;
15 }
16
17 /**
18 * Pending submissions, webhooks, PayPal capture, Stripe session retrieve.
19 */
20 class Form_Payment_Confirm
21 {
22 public const META_STATUS = 'king_addons_payment_status';
23 public const META_PROVIDER = 'king_addons_payment_provider';
24 public const META_TXN = 'king_addons_payment_txn_id';
25 public const META_AMOUNT = 'king_addons_payment_amount';
26 public const META_CURRENCY = 'king_addons_payment_currency';
27 public const META_TIME = 'king_addons_payment_time';
28 public const META_EVENTS = 'king_addons_payment_events';
29 public const META_PAYPAL_ORDER = 'king_addons_paypal_order_id';
30 public const META_STRIPE_SESSION = 'king_addons_stripe_session_id';
31 public const META_EXPECTED_AMOUNT = 'king_addons_payment_expected_amount';
32 public const META_EXPECTED_CURRENCY = 'king_addons_payment_expected_currency';
33
34 /**
35 * Register REST and return-URL handlers.
36 */
37 public function __construct()
38 {
39 add_action('rest_api_init', [$this, 'register_routes']);
40 add_action('template_redirect', [$this, 'handle_return']);
41 }
42
43 /**
44 * REST routes.
45 *
46 * @return void
47 */
48 public function register_routes(): void
49 {
50 register_rest_route('king-addons/v1', '/stripe-webhook', [
51 'methods' => 'POST',
52 'callback' => [$this, 'handle_stripe_webhook'],
53 'permission_callback' => '__return_true',
54 ]);
55 }
56
57 /**
58 * Public webhook URL shown on the settings screen.
59 *
60 * @return string
61 */
62 public static function stripe_webhook_url(): string
63 {
64 return rest_url('king-addons/v1/stripe-webhook');
65 }
66
67 /**
68 * Create or reuse a submission and mark the payment pending.
69 *
70 * An existing submission is reused only when the request presents the
71 * secret issued at create time. The form id on that submission must match
72 * the form being charged.
73 *
74 * @param array<string,mixed> $config Payment settings.
75 * @param float $amount Amount to charge.
76 * @param string $provider stripe|paypal.
77 * @param array<string,mixed> $request Posted form bits.
78 *
79 * @return int Submission ID, or 0 when the named id must not be touched.
80 */
81 public static function ensure_pending_submission(array $config, float $amount, string $provider, array $request): int
82 {
83 $submission_id = absint($request['submission_id'] ?? 0);
84 if ($submission_id) {
85 if ('king-addons-fb-sub' !== get_post_type($submission_id)) {
86 $submission_id = 0;
87 } else {
88 $secret = (string) ($request['access_secret'] ?? '');
89 if (!class_exists('King_Addons\\Create_Submission') || !Create_Submission::verify_access_secret($submission_id, $secret)) {
90 return 0;
91 }
92
93 $stored_form_id = sanitize_key((string) get_post_meta($submission_id, 'king_addons_form_id', true));
94 $request_form_id = sanitize_key((string) ($request['form_id'] ?? ''));
95 if ('' === $stored_form_id || $stored_form_id !== $request_form_id) {
96 return 0;
97 }
98
99 if ('paid' === (string) get_post_meta($submission_id, self::META_STATUS, true)) {
100 return 0;
101 }
102
103 self::stamp_pending($submission_id, $config, $amount, $provider);
104 return $submission_id;
105 }
106 }
107
108 $page_id = absint($request['form_page_id'] ?? 0);
109 if ($page_id && class_exists('King_Addons\\Form_Builder_Security') && !Form_Builder_Security::is_valid_submission_page($page_id)) {
110 $page_id = 0;
111 }
112
113 $created = 0;
114 if (class_exists('King_Addons\\Create_Submission')) {
115 $created = Create_Submission::insert_submission([
116 'form_name' => (string) ($request['form_name'] ?? ''),
117 'form_id' => (string) ($request['form_id'] ?? ''),
118 'form_page' => (string) ($request['form_page'] ?? ''),
119 'form_page_id' => $page_id,
120 'form_content' => isset($request['form_content']) && is_array($request['form_content'])
121 ? $request['form_content']
122 : [],
123 ]);
124 }
125
126 if ($created) {
127 self::stamp_pending($created, $config, $amount, $provider);
128 }
129
130 return $created;
131 }
132
133 /**
134 * Write pending payment meta.
135 *
136 * @param int $submission_id Submission.
137 * @param array<string,mixed> $config Settings.
138 * @param float $amount Amount.
139 * @param string $provider Provider.
140 *
141 * @return void
142 */
143 public static function stamp_pending(int $submission_id, array $config, float $amount, string $provider): void
144 {
145 $currency = strtoupper(substr((string) ($config['currency'] ?? 'USD'), 0, 3));
146 update_post_meta($submission_id, self::META_STATUS, 'pending');
147 update_post_meta($submission_id, self::META_PROVIDER, sanitize_key($provider));
148 update_post_meta($submission_id, self::META_AMOUNT, $amount);
149 update_post_meta($submission_id, self::META_CURRENCY, $currency);
150 update_post_meta($submission_id, self::META_EXPECTED_AMOUNT, $amount);
151 update_post_meta($submission_id, self::META_EXPECTED_CURRENCY, $currency);
152 update_post_meta($submission_id, self::META_TIME, time());
153 }
154
155 /**
156 * Apply a confirmed status. Never trust the browser for this.
157 *
158 * @param int $submission_id Submission.
159 * @param string $status pending|paid|failed|cancelled|refunded.
160 * @param string $txn_id Provider transaction id.
161 * @param string $event_id Idempotency key (event or capture id).
162 *
163 * @return bool False when this event was already applied.
164 */
165 public static function apply_status(int $submission_id, string $status, string $txn_id = '', string $event_id = ''): bool
166 {
167 $allowed = ['pending', 'paid', 'failed', 'cancelled', 'refunded'];
168 if (!in_array($status, $allowed, true) || !$submission_id) {
169 return false;
170 }
171
172 if ('' !== $event_id) {
173 $events = get_post_meta($submission_id, self::META_EVENTS, true);
174 $events = is_array($events) ? $events : [];
175 if (in_array($event_id, $events, true)) {
176 return false;
177 }
178 $events[] = $event_id;
179 update_post_meta($submission_id, self::META_EVENTS, $events);
180 }
181
182 update_post_meta($submission_id, self::META_STATUS, $status);
183 if ('' !== $txn_id) {
184 update_post_meta($submission_id, self::META_TXN, sanitize_text_field($txn_id));
185 }
186 update_post_meta($submission_id, self::META_TIME, time());
187
188 return true;
189 }
190
191 /**
192 * Find a submission by stored provider reference.
193 *
194 * @param string $meta_key Meta key.
195 * @param string $value Value.
196 *
197 * @return int
198 */
199 public static function find_by_meta(string $meta_key, string $value): int
200 {
201 $value = trim($value);
202 if ('' === $value) {
203 return 0;
204 }
205
206 $found = get_posts([
207 'post_type' => 'king-addons-fb-sub',
208 'post_status' => 'any',
209 'posts_per_page' => 1,
210 'fields' => 'ids',
211 'meta_key' => $meta_key,
212 'meta_value' => $value,
213 ]);
214
215 return $found ? (int) $found[0] : 0;
216 }
217
218 /**
219 * Stripe webhook.
220 *
221 * @param \WP_REST_Request $request Request.
222 *
223 * @return \WP_REST_Response
224 */
225 public function handle_stripe_webhook(\WP_REST_Request $request): \WP_REST_Response
226 {
227 $payload = $request->get_body();
228 $signature = (string) $request->get_header('stripe-signature');
229 $secret = (string) get_option('king_addons_stripe_webhook_secret', '');
230
231 if ('' === $secret || !self::verify_stripe_signature($payload, $signature, $secret)) {
232 return new \WP_REST_Response(['error' => 'invalid-signature'], 400);
233 }
234
235 $event = json_decode($payload, true);
236 if (!is_array($event) || empty($event['type']) || empty($event['id'])) {
237 return new \WP_REST_Response(['error' => 'invalid-payload'], 400);
238 }
239
240 $type = (string) $event['type'];
241 $event_id = (string) $event['id'];
242 $session = $event['data']['object'] ?? [];
243 if (!is_array($session)) {
244 return new \WP_REST_Response(['ok' => true], 200);
245 }
246
247 $submission_id = self::submission_from_stripe_session($session);
248 if (!$submission_id) {
249 return new \WP_REST_Response(['ok' => true], 200);
250 }
251
252 $txn = (string) ($session['payment_intent'] ?? $session['id'] ?? '');
253
254 switch ($type) {
255 case 'checkout.session.completed':
256 if ('paid' === ($session['payment_status'] ?? '')) {
257 if (self::stripe_charge_matches($submission_id, $session)) {
258 self::apply_status($submission_id, 'paid', $txn, $event_id);
259 } else {
260 self::apply_status($submission_id, 'failed', $txn, $event_id . '-mismatch');
261 }
262 }
263 break;
264 case 'checkout.session.async_payment_succeeded':
265 if (self::stripe_charge_matches($submission_id, $session)) {
266 self::apply_status($submission_id, 'paid', $txn, $event_id);
267 } else {
268 self::apply_status($submission_id, 'failed', $txn, $event_id . '-mismatch');
269 }
270 break;
271 case 'checkout.session.async_payment_failed':
272 self::apply_status($submission_id, 'failed', $txn, $event_id);
273 break;
274 case 'checkout.session.expired':
275 self::apply_status($submission_id, 'cancelled', $txn, $event_id);
276 break;
277 default:
278 break;
279 }
280
281 return new \WP_REST_Response(['ok' => true], 200);
282 }
283
284 /**
285 * HMAC check for Stripe-Signature. Five minute skew.
286 *
287 * @param string $payload Raw body.
288 * @param string $header Stripe-Signature header.
289 * @param string $secret Signing secret.
290 *
291 * @return bool
292 */
293 public static function verify_stripe_signature(string $payload, string $header, string $secret): bool
294 {
295 $parts = [];
296 foreach (explode(',', $header) as $piece) {
297 $piece = trim($piece);
298 if (false === strpos($piece, '=')) {
299 continue;
300 }
301 [$name, $value] = explode('=', $piece, 2);
302 $parts[$name][] = $value;
303 }
304
305 $timestamp = isset($parts['t'][0]) ? (int) $parts['t'][0] : 0;
306 $signatures = $parts['v1'] ?? [];
307 if ($timestamp < 1 || empty($signatures)) {
308 return false;
309 }
310
311 if (abs(time() - $timestamp) > 300) {
312 return false;
313 }
314
315 $expected = hash_hmac('sha256', $timestamp . '.' . $payload, $secret);
316 foreach ($signatures as $signature) {
317 if (hash_equals($expected, $signature)) {
318 return true;
319 }
320 }
321
322 return false;
323 }
324
325 /**
326 * Visitor came back from Stripe or PayPal.
327 *
328 * @return void
329 */
330 public function handle_return(): void
331 {
332 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- provider return URL.
333 $flag = isset($_GET['ka-payment']) ? sanitize_key(wp_unslash($_GET['ka-payment'])) : '';
334 if ('' === $flag) {
335 return;
336 }
337
338 if ('cancelled' === $flag) {
339 // phpcs:ignore WordPress.Security.NonceVerification.Recommended
340 $token = isset($_GET['token']) ? sanitize_text_field(wp_unslash($_GET['token'])) : '';
341 if ('' !== $token) {
342 $submission_id = self::find_by_meta(self::META_PAYPAL_ORDER, $token);
343 if ($submission_id) {
344 self::apply_status($submission_id, 'cancelled', $token, 'cancel-' . $token);
345 }
346 }
347 self::queue_return_notice(
348 'warning',
349 __('The payment was cancelled. No charge was made.', 'king-addons')
350 );
351 return;
352 }
353
354 if ('success' !== $flag) {
355 return;
356 }
357
358 $confirmed = false;
359
360 // phpcs:ignore WordPress.Security.NonceVerification.Recommended
361 $session_id = isset($_GET['session_id']) ? sanitize_text_field(wp_unslash($_GET['session_id'])) : '';
362 if ('' !== $session_id) {
363 $confirmed = self::confirm_stripe_session($session_id);
364 } else {
365 // phpcs:ignore WordPress.Security.NonceVerification.Recommended
366 $token = isset($_GET['token']) ? sanitize_text_field(wp_unslash($_GET['token'])) : '';
367 if ('' !== $token) {
368 $result = Form_Payments::capture_paypal_order($token);
369 $confirmed = is_array($result);
370 }
371 }
372
373 if ($confirmed) {
374 self::queue_return_notice(
375 'success',
376 __('Payment received. Thank you.', 'king-addons')
377 );
378 return;
379 }
380
381 self::queue_return_notice(
382 'info',
383 __('We could not confirm this payment yet. If you were charged, your submission will update shortly.', 'king-addons')
384 );
385 }
386
387 /**
388 * Print a banner after Stripe/PayPal send the visitor back.
389 *
390 * @param string $tone success|warning|info.
391 * @param string $message Visitor-facing copy.
392 *
393 * @return void
394 */
395 private static function queue_return_notice(string $tone, string $message): void
396 {
397 $tone = in_array($tone, ['success', 'warning', 'info'], true) ? $tone : 'info';
398 $message = (string) $message;
399 if ('' === $message) {
400 return;
401 }
402
403 add_action('wp_enqueue_scripts', static function () use ($tone): void {
404 $css = '.king-addons-payment-return{max-width:720px;margin:16px auto;padding:12px 16px;font:15px/1.4 sans-serif;border:1px solid}'
405 . '.king-addons-payment-return--success{background:#DCFCE7;border-color:#15803D;color:#14532D}'
406 . '.king-addons-payment-return--warning{background:#FDE68A;border-color:#B45309;color:#92400E}'
407 . '.king-addons-payment-return--info{background:#E5E7EB;border-color:#6B7280;color:#1F2937}';
408 wp_register_style('king-addons-payment-return', false, [], defined('KING_ADDONS_VERSION') ? KING_ADDONS_VERSION : '1');
409 wp_enqueue_style('king-addons-payment-return');
410 wp_add_inline_style('king-addons-payment-return', $css);
411 }, 5);
412
413 $print = static function () use ($tone, $message): void {
414 static $done = false;
415 if ($done) {
416 return;
417 }
418 $done = true;
419 echo '<div class="king-addons-payment-return king-addons-payment-return--' . esc_attr($tone) . '" role="status">';
420 echo esc_html($message);
421 echo '</div>';
422 };
423
424 add_action('wp_body_open', $print, 5);
425 add_action('wp_footer', $print, 5);
426 }
427
428 /**
429 * Ask Stripe for the session so the thank-you page does not wait for the webhook.
430 *
431 * @param string $session_id Checkout session id.
432 *
433 * @return bool True when Stripe reported the session paid.
434 */
435 private static function confirm_stripe_session(string $session_id): bool
436 {
437 $secret = (string) get_option('king_addons_stripe_secret_key', '');
438 if ('' === $secret) {
439 return false;
440 }
441
442 $url = Form_Payments::public_endpoint('stripe_session', 'https://api.stripe.com/v1/checkout/sessions/' . rawurlencode($session_id));
443 $response = wp_remote_get($url, [
444 'timeout' => 20,
445 'headers' => [
446 'Authorization' => 'Bearer ' . $secret,
447 ],
448 ]);
449
450 if (is_wp_error($response)) {
451 return false;
452 }
453
454 $session = json_decode((string) wp_remote_retrieve_body($response), true);
455 if (!is_array($session)) {
456 return false;
457 }
458
459 $submission_id = self::submission_from_stripe_session($session);
460 if (!$submission_id) {
461 return false;
462 }
463
464 $txn = (string) ($session['payment_intent'] ?? $session['id'] ?? '');
465 $event_id = 'session-' . (string) ($session['id'] ?? $session_id);
466
467 if ('paid' === ($session['payment_status'] ?? '')) {
468 if (!self::stripe_charge_matches($submission_id, $session)) {
469 self::apply_status($submission_id, 'failed', $txn, $event_id . '-mismatch');
470 return false;
471 }
472 self::apply_status($submission_id, 'paid', $txn, $event_id);
473 return true;
474 }
475
476 if ('unpaid' === ($session['payment_status'] ?? '')) {
477 self::apply_status($submission_id, 'pending', $txn, $event_id . '-unpaid');
478 }
479
480 return false;
481 }
482
483 /**
484 * Submission id from a Stripe session object.
485 *
486 * @param array<string,mixed> $session Session.
487 *
488 * @return int
489 */
490 private static function submission_from_stripe_session(array $session): int
491 {
492 $from_ref = absint($session['client_reference_id'] ?? 0);
493 if ($from_ref && 'king-addons-fb-sub' === get_post_type($from_ref)) {
494 return $from_ref;
495 }
496
497 $from_meta = absint($session['metadata']['ka_submission'] ?? 0);
498 if ($from_meta && 'king-addons-fb-sub' === get_post_type($from_meta)) {
499 return $from_meta;
500 }
501
502 $session_id = (string) ($session['id'] ?? '');
503
504 return self::find_by_meta(self::META_STRIPE_SESSION, $session_id);
505 }
506
507 /**
508 * Stripe charged amount and currency must match what the form stored.
509 *
510 * @param int $submission_id Submission.
511 * @param array<string,mixed> $session Stripe Checkout session.
512 *
513 * @return bool
514 */
515 public static function stripe_charge_matches(int $submission_id, array $session): bool
516 {
517 $expected_amount = (float) get_post_meta($submission_id, self::META_EXPECTED_AMOUNT, true);
518 $expected_currency = strtoupper((string) get_post_meta($submission_id, self::META_EXPECTED_CURRENCY, true));
519 if ($expected_amount <= 0 || 3 !== strlen($expected_currency)) {
520 return false;
521 }
522
523 if (!isset($session['amount_total']) || !is_numeric($session['amount_total'])) {
524 return false;
525 }
526
527 $paid_currency = strtoupper((string) ($session['currency'] ?? ''));
528 if ('' === $paid_currency) {
529 return false;
530 }
531
532 $expected_minor = class_exists('King_Addons\\Form_Payments')
533 ? Form_Payments::to_provider_minor_units($expected_amount, $expected_currency)
534 : (int) round($expected_amount * 100);
535
536 return ((int) $session['amount_total'] === $expected_minor) && ($paid_currency === $expected_currency);
537 }
538 }
539