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 / 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.81, at includes/widgets/Form_Builder/helpers/Payment_Confirm.php

472 lines 16.4 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 * @param array<string,mixed> $config Payment settings.
71 * @param float $amount Amount to charge.
72 * @param string $provider stripe|paypal.
73 * @param array<string,mixed> $request Posted form bits.
74 *
75 * @return int Submission ID.
76 */
77 public static function ensure_pending_submission(array $config, float $amount, string $provider, array $request): int
78 {
79 $submission_id = absint($request['submission_id'] ?? 0);
80 if ($submission_id && 'king-addons-fb-sub' === get_post_type($submission_id)) {
81 self::stamp_pending($submission_id, $config, $amount, $provider);
82 return $submission_id;
83 }
84
85 $page_id = absint($request['form_page_id'] ?? 0);
86 if ($page_id && class_exists('King_Addons\\Form_Builder_Security') && !Form_Builder_Security::is_valid_submission_page($page_id)) {
87 $page_id = 0;
88 }
89
90 $created = 0;
91 if (class_exists('King_Addons\\Create_Submission')) {
92 $created = Create_Submission::insert_submission([
93 'form_name' => (string) ($request['form_name'] ?? ''),
94 'form_id' => (string) ($request['form_id'] ?? ''),
95 'form_page' => (string) ($request['form_page'] ?? ''),
96 'form_page_id' => $page_id,
97 'form_content' => isset($request['form_content']) && is_array($request['form_content'])
98 ? $request['form_content']
99 : [],
100 ]);
101 }
102
103 if ($created) {
104 self::stamp_pending($created, $config, $amount, $provider);
105 }
106
107 return $created;
108 }
109
110 /**
111 * Write pending payment meta.
112 *
113 * @param int $submission_id Submission.
114 * @param array<string,mixed> $config Settings.
115 * @param float $amount Amount.
116 * @param string $provider Provider.
117 *
118 * @return void
119 */
120 public static function stamp_pending(int $submission_id, array $config, float $amount, string $provider): void
121 {
122 $currency = strtoupper(substr((string) ($config['currency'] ?? 'USD'), 0, 3));
123 update_post_meta($submission_id, self::META_STATUS, 'pending');
124 update_post_meta($submission_id, self::META_PROVIDER, sanitize_key($provider));
125 update_post_meta($submission_id, self::META_AMOUNT, $amount);
126 update_post_meta($submission_id, self::META_CURRENCY, $currency);
127 update_post_meta($submission_id, self::META_EXPECTED_AMOUNT, $amount);
128 update_post_meta($submission_id, self::META_EXPECTED_CURRENCY, $currency);
129 update_post_meta($submission_id, self::META_TIME, time());
130 }
131
132 /**
133 * Apply a confirmed status. Never trust the browser for this.
134 *
135 * @param int $submission_id Submission.
136 * @param string $status pending|paid|failed|cancelled|refunded.
137 * @param string $txn_id Provider transaction id.
138 * @param string $event_id Idempotency key (event or capture id).
139 *
140 * @return bool False when this event was already applied.
141 */
142 public static function apply_status(int $submission_id, string $status, string $txn_id = '', string $event_id = ''): bool
143 {
144 $allowed = ['pending', 'paid', 'failed', 'cancelled', 'refunded'];
145 if (!in_array($status, $allowed, true) || !$submission_id) {
146 return false;
147 }
148
149 if ('' !== $event_id) {
150 $events = get_post_meta($submission_id, self::META_EVENTS, true);
151 $events = is_array($events) ? $events : [];
152 if (in_array($event_id, $events, true)) {
153 return false;
154 }
155 $events[] = $event_id;
156 update_post_meta($submission_id, self::META_EVENTS, $events);
157 }
158
159 update_post_meta($submission_id, self::META_STATUS, $status);
160 if ('' !== $txn_id) {
161 update_post_meta($submission_id, self::META_TXN, sanitize_text_field($txn_id));
162 }
163 update_post_meta($submission_id, self::META_TIME, time());
164
165 return true;
166 }
167
168 /**
169 * Find a submission by stored provider reference.
170 *
171 * @param string $meta_key Meta key.
172 * @param string $value Value.
173 *
174 * @return int
175 */
176 public static function find_by_meta(string $meta_key, string $value): int
177 {
178 $value = trim($value);
179 if ('' === $value) {
180 return 0;
181 }
182
183 $found = get_posts([
184 'post_type' => 'king-addons-fb-sub',
185 'post_status' => 'any',
186 'posts_per_page' => 1,
187 'fields' => 'ids',
188 'meta_key' => $meta_key,
189 'meta_value' => $value,
190 ]);
191
192 return $found ? (int) $found[0] : 0;
193 }
194
195 /**
196 * Stripe webhook.
197 *
198 * @param \WP_REST_Request $request Request.
199 *
200 * @return \WP_REST_Response
201 */
202 public function handle_stripe_webhook(\WP_REST_Request $request): \WP_REST_Response
203 {
204 $payload = $request->get_body();
205 $signature = (string) $request->get_header('stripe-signature');
206 $secret = (string) get_option('king_addons_stripe_webhook_secret', '');
207
208 if ('' === $secret || !self::verify_stripe_signature($payload, $signature, $secret)) {
209 return new \WP_REST_Response(['error' => 'invalid-signature'], 400);
210 }
211
212 $event = json_decode($payload, true);
213 if (!is_array($event) || empty($event['type']) || empty($event['id'])) {
214 return new \WP_REST_Response(['error' => 'invalid-payload'], 400);
215 }
216
217 $type = (string) $event['type'];
218 $event_id = (string) $event['id'];
219 $session = $event['data']['object'] ?? [];
220 if (!is_array($session)) {
221 return new \WP_REST_Response(['ok' => true], 200);
222 }
223
224 $submission_id = self::submission_from_stripe_session($session);
225 if (!$submission_id) {
226 return new \WP_REST_Response(['ok' => true], 200);
227 }
228
229 $txn = (string) ($session['payment_intent'] ?? $session['id'] ?? '');
230
231 switch ($type) {
232 case 'checkout.session.completed':
233 if ('paid' === ($session['payment_status'] ?? '')) {
234 self::apply_status($submission_id, 'paid', $txn, $event_id);
235 }
236 break;
237 case 'checkout.session.async_payment_succeeded':
238 self::apply_status($submission_id, 'paid', $txn, $event_id);
239 break;
240 case 'checkout.session.async_payment_failed':
241 self::apply_status($submission_id, 'failed', $txn, $event_id);
242 break;
243 case 'checkout.session.expired':
244 self::apply_status($submission_id, 'cancelled', $txn, $event_id);
245 break;
246 default:
247 break;
248 }
249
250 return new \WP_REST_Response(['ok' => true], 200);
251 }
252
253 /**
254 * HMAC check for Stripe-Signature. Five minute skew.
255 *
256 * @param string $payload Raw body.
257 * @param string $header Stripe-Signature header.
258 * @param string $secret Signing secret.
259 *
260 * @return bool
261 */
262 public static function verify_stripe_signature(string $payload, string $header, string $secret): bool
263 {
264 $parts = [];
265 foreach (explode(',', $header) as $piece) {
266 $piece = trim($piece);
267 if (false === strpos($piece, '=')) {
268 continue;
269 }
270 [$name, $value] = explode('=', $piece, 2);
271 $parts[$name][] = $value;
272 }
273
274 $timestamp = isset($parts['t'][0]) ? (int) $parts['t'][0] : 0;
275 $signatures = $parts['v1'] ?? [];
276 if ($timestamp < 1 || empty($signatures)) {
277 return false;
278 }
279
280 if (abs(time() - $timestamp) > 300) {
281 return false;
282 }
283
284 $expected = hash_hmac('sha256', $timestamp . '.' . $payload, $secret);
285 foreach ($signatures as $signature) {
286 if (hash_equals($expected, $signature)) {
287 return true;
288 }
289 }
290
291 return false;
292 }
293
294 /**
295 * Visitor came back from Stripe or PayPal.
296 *
297 * @return void
298 */
299 public function handle_return(): void
300 {
301 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- provider return URL.
302 $flag = isset($_GET['ka-payment']) ? sanitize_key(wp_unslash($_GET['ka-payment'])) : '';
303 if ('' === $flag) {
304 return;
305 }
306
307 if ('cancelled' === $flag) {
308 // phpcs:ignore WordPress.Security.NonceVerification.Recommended
309 $token = isset($_GET['token']) ? sanitize_text_field(wp_unslash($_GET['token'])) : '';
310 if ('' !== $token) {
311 $submission_id = self::find_by_meta(self::META_PAYPAL_ORDER, $token);
312 if ($submission_id) {
313 self::apply_status($submission_id, 'cancelled', $token, 'cancel-' . $token);
314 }
315 }
316 self::queue_return_notice(
317 'warning',
318 __('The payment was cancelled. No charge was made.', 'king-addons')
319 );
320 return;
321 }
322
323 if ('success' !== $flag) {
324 return;
325 }
326
327 $confirmed = false;
328
329 // phpcs:ignore WordPress.Security.NonceVerification.Recommended
330 $session_id = isset($_GET['session_id']) ? sanitize_text_field(wp_unslash($_GET['session_id'])) : '';
331 if ('' !== $session_id) {
332 $confirmed = self::confirm_stripe_session($session_id);
333 } else {
334 // phpcs:ignore WordPress.Security.NonceVerification.Recommended
335 $token = isset($_GET['token']) ? sanitize_text_field(wp_unslash($_GET['token'])) : '';
336 if ('' !== $token) {
337 $result = Form_Payments::capture_paypal_order($token);
338 $confirmed = is_array($result);
339 }
340 }
341
342 if ($confirmed) {
343 self::queue_return_notice(
344 'success',
345 __('Payment received. Thank you.', 'king-addons')
346 );
347 return;
348 }
349
350 self::queue_return_notice(
351 'info',
352 __('We could not confirm this payment yet. If you were charged, your submission will update shortly.', 'king-addons')
353 );
354 }
355
356 /**
357 * Print a banner after Stripe/PayPal send the visitor back.
358 *
359 * @param string $tone success|warning|info.
360 * @param string $message Visitor-facing copy.
361 *
362 * @return void
363 */
364 private static function queue_return_notice(string $tone, string $message): void
365 {
366 $tone = in_array($tone, ['success', 'warning', 'info'], true) ? $tone : 'info';
367 $message = (string) $message;
368 if ('' === $message) {
369 return;
370 }
371
372 add_action('wp_enqueue_scripts', static function () use ($tone): void {
373 $css = '.king-addons-payment-return{max-width:720px;margin:16px auto;padding:12px 16px;font:15px/1.4 sans-serif;border:1px solid}'
374 . '.king-addons-payment-return--success{background:#DCFCE7;border-color:#15803D;color:#14532D}'
375 . '.king-addons-payment-return--warning{background:#FDE68A;border-color:#B45309;color:#92400E}'
376 . '.king-addons-payment-return--info{background:#E5E7EB;border-color:#6B7280;color:#1F2937}';
377 wp_register_style('king-addons-payment-return', false, [], defined('KING_ADDONS_VERSION') ? KING_ADDONS_VERSION : '1');
378 wp_enqueue_style('king-addons-payment-return');
379 wp_add_inline_style('king-addons-payment-return', $css);
380 }, 5);
381
382 $print = static function () use ($tone, $message): void {
383 static $done = false;
384 if ($done) {
385 return;
386 }
387 $done = true;
388 echo '<div class="king-addons-payment-return king-addons-payment-return--' . esc_attr($tone) . '" role="status">';
389 echo esc_html($message);
390 echo '</div>';
391 };
392
393 add_action('wp_body_open', $print, 5);
394 add_action('wp_footer', $print, 5);
395 }
396
397 /**
398 * Ask Stripe for the session so the thank-you page does not wait for the webhook.
399 *
400 * @param string $session_id Checkout session id.
401 *
402 * @return bool True when Stripe reported the session paid.
403 */
404 private static function confirm_stripe_session(string $session_id): bool
405 {
406 $secret = (string) get_option('king_addons_stripe_secret_key', '');
407 if ('' === $secret) {
408 return false;
409 }
410
411 $url = Form_Payments::public_endpoint('stripe_session', 'https://api.stripe.com/v1/checkout/sessions/' . rawurlencode($session_id));
412 $response = wp_remote_get($url, [
413 'timeout' => 20,
414 'headers' => [
415 'Authorization' => 'Bearer ' . $secret,
416 ],
417 ]);
418
419 if (is_wp_error($response)) {
420 return false;
421 }
422
423 $session = json_decode((string) wp_remote_retrieve_body($response), true);
424 if (!is_array($session)) {
425 return false;
426 }
427
428 $submission_id = self::submission_from_stripe_session($session);
429 if (!$submission_id) {
430 return false;
431 }
432
433 $txn = (string) ($session['payment_intent'] ?? $session['id'] ?? '');
434 $event_id = 'session-' . (string) ($session['id'] ?? $session_id);
435
436 if ('paid' === ($session['payment_status'] ?? '')) {
437 self::apply_status($submission_id, 'paid', $txn, $event_id);
438 return true;
439 }
440
441 if ('unpaid' === ($session['payment_status'] ?? '')) {
442 self::apply_status($submission_id, 'pending', $txn, $event_id . '-unpaid');
443 }
444
445 return false;
446 }
447
448 /**
449 * Submission id from a Stripe session object.
450 *
451 * @param array<string,mixed> $session Session.
452 *
453 * @return int
454 */
455 private static function submission_from_stripe_session(array $session): int
456 {
457 $from_ref = absint($session['client_reference_id'] ?? 0);
458 if ($from_ref && 'king-addons-fb-sub' === get_post_type($from_ref)) {
459 return $from_ref;
460 }
461
462 $from_meta = absint($session['metadata']['ka_submission'] ?? 0);
463 if ($from_meta && 'king-addons-fb-sub' === get_post_type($from_meta)) {
464 return $from_meta;
465 }
466
467 $session_id = (string) ($session['id'] ?? '');
468
469 return self::find_by_meta(self::META_STRIPE_SESSION, $session_id);
470 }
471 }
472