PluginProbe
WPFunnels – Funnel Builder for WooCommerce with Checkout & One Click Upsell / 3.12.2
WPFunnels – Funnel Builder for WooCommerce with Checkout & One Click Upsell v3.12.2
3.13.3 3.13.2 3.13.1 3.13.0 3.12.13 3.12.12 3.12.11 3.12.10 3.12.9 3.12.8 3.12.7 3.12.6 3.12.5 3.12.4 3.12.3 3.12.1 3.12.2 3.12.0 3.11.1 3.11.0 3.10.9 3.10.8 3.10.7 3.10.6 2.8.16 All 261 releases
wpfunnels / includes / core / gateways / class-wpfnl-gateway-stripe.php

class-wpfnl-gateway-stripe.php in WPFunnels – Funnel Builder for WooCommerce with Checkout & One Click Upsell 3.12.2, at includes/core/gateways/class-wpfnl-gateway-stripe.php

1,165 lines 45.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace WPFunnels\Gateway;
4
5 use WC_Stripe_API;
6 use WC_Stripe_Helper;
7 use WPFunnels\Wpfnl_functions;
8
9 class Wpfnl_Stripe_payment_process {
10
11 public $key = 'stripe';
12
13 public $refund_support;
14
15 public function __construct() {
16 $this->refund_support = true;
17
18 add_filter( 'wc_stripe_force_save_source', array( $this, 'should_tokenize_stripe' ), 9999);
19 add_filter( 'wc_stripe_force_save_payment_method', array( $this, 'should_save_payment_method_for_offer' ), 9999, 2 );
20 add_filter( 'wc_stripe_3ds_source', array( $this, 'may_be_modify_3ds_param' ), 9999, 2);
21 add_action( 'wc_gateway_stripe_process_response', array( $this, 'handle_redirection' ), 9999, 2 );
22
23 add_action( 'wp_ajax_wpfunnels_stripe_sca_check', array( $this, 'check_stripe_sca' ),9999);
24 add_action( 'wp_ajax_nopriv_wpfunnels_stripe_sca_check', array( $this, 'check_stripe_sca' ),9999);
25
26 add_action( 'wpfunnels/child_order_created_' . $this->key, array( $this, 'add_capture_meta_to_child_order' ), 9999, 3 );
27
28 add_action( 'wpfunnels/subscription_created', array( $this, 'add_offer_subscription_meta' ), 9999, 3 );
29
30 add_action( 'woocommerce_checkout_after_order_review', array( $this, 'add_stripe_hidden_field' ), 99 );
31
32 // Support for Stripe Express Checkout Element (Apple Pay / Google Pay) which uses the WC Store API.
33 add_action( 'woocommerce_store_api_checkout_update_order_from_request', array( $this, 'save_funnel_meta_for_express_checkout' ), 10, 2 );
34
35 // Force setup_future_usage on checkout PaymentIntents for funnel orders so the Express Checkout
36 // PaymentMethod (pm_xxx) gets attached to the Stripe customer. Without this, guests using
37 // Google Pay / Apple Pay produce a single-use pm_ that cannot be reused for offer charges.
38 // wc_stripe_force_save_payment_method is ignored for guests (is_user_logged_in guard in WC Stripe),
39 // so we must inject directly into the PI request instead.
40 add_filter( 'wc_stripe_generate_create_intent_request', array( $this, 'force_save_pm_for_funnel_order' ), 10, 2 );
41
42 }
43
44
45 /**
46 * wpfnl_stripe_tokenization
47 * If required then tokenize to save source of payment
48 *
49 * @param bool $save_source force save source.
50 *
51 */
52 public function should_tokenize_stripe( $save_source )
53 {
54 $checkout_id = Wpfnl_functions::get_checkout_id_from_post_data();
55 // Get checkout id if not found in post data.
56 $checkout_id = !$checkout_id ? get_the_ID() : $checkout_id;
57 $funnel_id = Wpfnl_functions::get_funnel_id_from_step( $checkout_id );
58
59 if ( $checkout_id && $funnel_id ) {
60
61 if ( Wpfnl_functions::is_offer_exists_in_funnel($funnel_id) ) {
62 $save_source = true;
63 }
64 }
65
66 return $save_source;
67 }
68
69
70 /**
71 * Force save payment method for funnel orders with offers.
72 * Handles the newer wc_stripe_force_save_payment_method filter used by the UPE gateway.
73 * This is needed for Stripe Express Checkout (Apple Pay/Google Pay) which uses the Store API
74 * and doesn't go through the classic $_POST checkout flow.
75 *
76 * @param bool $force_save Whether to force save the payment method.
77 * @param string|int $order_id The WooCommerce order ID (may be empty during intent creation).
78 * @return bool
79 */
80 public function should_save_payment_method_for_offer( $force_save, $order_id ) {
81 if ( $force_save ) {
82 return $force_save;
83 }
84
85 // For Store API / ECE: funnel meta is already saved on the order by
86 // save_funnel_meta_for_express_checkout before payment processing runs.
87 if ( $order_id ) {
88 $order = wc_get_order( $order_id );
89 if ( $order ) {
90 $funnel_id = Wpfnl_functions::get_funnel_id_from_order( $order );
91 if ( $funnel_id && Wpfnl_functions::is_offer_exists_in_funnel( $funnel_id ) ) {
92 return true;
93 }
94 }
95 }
96
97 return $force_save;
98 }
99
100
101 /**
102 * Save WPFunnels funnel metadata to the order when Stripe Express Checkout
103 * (Apple Pay / Google Pay) processes via the WooCommerce Store API.
104 *
105 * Classic checkout sends _wpfunnels_checkout_id via $_POST, but the Stripe
106 * Express Checkout Element submits to /wp-json/wc/store/v1/checkout (Blocks
107 * Store API) where $_POST is not populated. We recover the checkout step ID
108 * from the HTTP Referer header instead.
109 *
110 * @param \WC_Order $order The WooCommerce order being processed.
111 * @param \WP_REST_Request $request The REST API request.
112 * @return void
113 */
114 public function save_funnel_meta_for_express_checkout( $order, $request ) {
115 // Only handle Stripe payments (including express methods like Apple Pay and Google Pay).
116 $payment_method = $request->get_param( 'payment_method' );
117
118 // Check if this is a Stripe payment method:
119 // - 'stripe' (main gateway)
120 // - 'stripe_*' (split UPE gateways)
121 // - 'google_pay', 'apple_pay', 'link' (express checkout methods)
122 $is_stripe_payment = (
123 'stripe' === $payment_method ||
124 0 === strpos( $payment_method, 'stripe_' ) ||
125 in_array( $payment_method, [ 'google_pay', 'apple_pay', 'link' ], true )
126 );
127
128 if ( ! $is_stripe_payment ) {
129 return;
130 }
131
132 // Skip if funnel metadata was already saved (classic checkout path).
133 if ( $order->get_meta( '_wpfunnels_funnel_id' ) ) {
134 return;
135 }
136
137 // Determine the checkout page from the HTTP Referer.
138 $referer = $request->get_header( 'referer' );
139 if ( ! $referer ) {
140 $referer = isset( $_SERVER['HTTP_REFERER'] ) ? esc_url_raw( wp_unslash( $_SERVER['HTTP_REFERER'] ) ) : '';
141 }
142
143 if ( ! $referer ) {
144 return;
145 }
146
147 // Strip query string and get the slug from the URL.
148 $referer_path = strtok( $referer, '?' );
149 $page_id = url_to_postid( $referer_path );
150
151 // If url_to_postid fails (returns 0), try to find the page by slug.
152 // This is needed for custom post types with custom rewrite rules.
153 if ( ! $page_id ) {
154
155 // Extract the slug from the URL (last segment of the path).
156 $parsed_url = wp_parse_url( $referer_path );
157 $path = isset( $parsed_url['path'] ) ? trim( $parsed_url['path'], '/' ) : '';
158
159 if ( $path ) {
160 $path_parts = explode( '/', $path );
161 $slug = end( $path_parts );
162
163
164 // Try to find a wpfunnel_steps post with this slug.
165 $args = array(
166 'name' => $slug,
167 'post_type' => 'wpfunnel_steps',
168 'post_status' => 'publish',
169 'posts_per_page' => 1,
170 );
171
172 $posts = get_posts( $args );
173
174 if ( ! empty( $posts ) ) {
175 $page_id = $posts[0]->ID;
176 }
177 }
178 }
179
180 if ( ! $page_id ) {
181 return;
182 }
183
184 // Confirm this is a WPFunnels checkout step.
185 $is_checkout_step = Wpfnl_functions::check_if_this_is_step_type_by_id( $page_id, 'checkout' );
186 if ( ! $is_checkout_step ) {
187 return;
188 }
189
190 $funnel_id = Wpfnl_functions::get_funnel_id_from_step( $page_id );
191 if ( ! $funnel_id ) {
192 return;
193 }
194
195 $order->update_meta_data( '_wpfunnels_checkout_id', $page_id );
196 $order->update_meta_data( '_wpfunnels_funnel_id', $funnel_id );
197 $order->update_meta_data( '_wpfunnels_order', 'yes' );
198 $order->save();
199
200 // Mark this session as the order owner so is_valid_order_owner() passes
201 // for subsequent upsell/downsell AJAX calls in the same browser session.
202 if ( WC()->session ) {
203 $session_handler = new \WC_Session_Handler();
204 WC()->session->set( 'wpfnl_order_owner', $session_handler->generate_customer_id() );
205 }
206 }
207
208 /**
209 * Force setup_future_usage = 'off_session' on the checkout PaymentIntent for
210 * WPFunnels funnel orders that use Stripe Express Checkout (Google Pay / Apple Pay).
211 *
212 * WC Stripe 10.x uses "confirmation tokens" for Express Checkout. In that flow
213 * setup_future_usage is only added when has_subscription = true. For regular
214 * funnel checkouts (guest or logged-in) the PM would otherwise be single-use
215 * and cannot be reused for the subsequent offer/upsell charge.
216 *
217 * WC_Stripe_Helper::should_force_save_payment_method() has an is_user_logged_in()
218 * hard-guard meaning the wc_stripe_force_save_payment_method filter never fires
219 * for guests. Injecting directly into the PI request via this filter is the only
220 * reliable approach.
221 *
222 * @param array $request The Stripe PaymentIntent request parameters.
223 * @param WC_Order $order The WooCommerce order.
224 * @return array
225 */
226 public function force_save_pm_for_funnel_order( $request, $order ) {
227 // Only apply when there is a Stripe customer to attach the PM to.
228 if ( empty( $request['customer'] ) ) {
229 return $request;
230 }
231
232 // Only apply to payment creation requests (must have either a payment_method or confirmation_token).
233 $has_pm = ! empty( $request['payment_method'] );
234 $has_ct = ! empty( $request['confirmation_token'] );
235 if ( ! $has_pm && ! $has_ct ) {
236 return $request;
237 }
238
239 // Identify the order object — $order may be null in some call paths.
240 if ( ! $order instanceof \WC_Order ) {
241 return $request;
242 }
243
244 $funnel_id = Wpfnl_functions::get_funnel_id_from_order( $order );
245 if ( ! $funnel_id ) {
246 return $request;
247 }
248
249 if ( ! Wpfnl_functions::is_offer_exists_in_funnel( $funnel_id ) ) {
250 return $request;
251 }
252
253 if ( $has_ct ) {
254 // Confirmation token flow (ECE with Optimised Checkout Element):
255 // setup_future_usage must be set inside payment_method_options.
256 $pm_type = 'card';
257 if ( ! empty( $request['payment_method_types'] ) && is_array( $request['payment_method_types'] ) ) {
258 $pm_type = reset( $request['payment_method_types'] );
259 }
260 if ( ! isset( $request['payment_method_options'] ) ) {
261 $request['payment_method_options'] = [];
262 }
263 if ( empty( $request['payment_method_options'][ $pm_type ]['setup_future_usage'] ) ) {
264 $request['payment_method_options'][ $pm_type ]['setup_future_usage'] = 'off_session';
265 }
266 } else {
267 // Payment method flow (ECE with classic pm_ or saved token):
268 // setup_future_usage is set at the top level of the PI request.
269 if ( empty( $request['setup_future_usage'] ) ) {
270 $request['setup_future_usage'] = 'off_session';
271 }
272 }
273
274 return $request;
275 }
276
277
278 /**
279 * @param $funnel_id
280 * @param $node_found
281 * @return bool
282 */
283 public function go_to_output_1($funnel_id, $node_found) {
284 $funnel_json = get_post_meta($funnel_id, '_funnel_data', true);
285 if ($funnel_json) {
286 $node_data = $funnel_json['drawflow']['Home']['data'];
287 foreach ($node_data as $node_key => $node_value) {
288 if ($node_value['id'] == $node_found) {
289 $next_node = $node_value['outputs']['output_1']['connections'][0]['node'];
290 return $next_node;
291 }
292 }
293 return false;
294 }
295 }
296
297
298 /**
299 * @param $funnel_id
300 * @param $node_found
301 * @return bool
302 */
303 public function go_to_output_2($funnel_id, $node_found) {
304 $funnel_json = get_post_meta($funnel_id, '_funnel_data', true);
305 if ($funnel_json) {
306 $node_data = $funnel_json['drawflow']['Home']['data'];
307 foreach ($node_data as $node_key => $node_value) {
308 if ($node_value['id'] == $node_found) {
309 $next_node = $node_value['outputs']['output_2']['connections'][0]['node'];
310 return $next_node;
311 }
312 }
313 return false;
314 }
315 }
316
317
318 /**
319 * save 3ds source data for offers
320 *
321 * @param $post_data
322 * @param $order
323 * @return mixed
324 */
325 public function may_be_modify_3ds_param( $post_data, $order ) {
326 if ( $order && Wpfnl_functions::check_if_offer_exists($order) ) {
327 $order->update_meta_data( '_wpfunnels_stripe_source_id', $post_data['three_d_secure']['card'] );
328 $order->save();
329 }
330 return $post_data;
331 }
332
333
334 /**
335 * Redirection to order received url
336 *
337 * @param $response
338 * @param $order
339 */
340 public function handle_redirection( $response, $order ) {
341 if ( 1 === did_action( 'wpfunnels/offer_funnel_started' ) && 1 === did_action( 'wc_gateway_stripe_process_redirect_payment' ) ) {
342 $order_received_url = $order->get_checkout_order_received_url();
343 wp_safe_redirect( $order_received_url );
344 exit();
345 }
346 }
347
348 /**
349 * wpfnl_stripe_maybe_hide_save_payment
350 */
351 public function wpfnl_stripe_maybe_hide_save_payment( $is_show ) {
352 return $is_show;
353 }
354
355
356 /**
357 * wpfnl_stripe_verify_sca
358 * Verify if payment type is SCA or not
359 *
360 * @throws \WC_Stripe_Exception
361 */
362 public function check_stripe_sca() {
363 $security = filter_input( INPUT_POST, 'security', FILTER_SANITIZE_STRING );
364 if ( ! wp_verify_nonce( $security, 'wpfnl_stripe_sca_check_nonce' ) ) {
365 return;
366 }
367
368 global $woocommerce;
369
370 $step_id = isset( $_POST['step_id'] ) ? intval( $_POST['step_id'] ) : 0;
371 $order_id = isset( $_POST['order_id'] ) ? intval($_POST['order_id']) : 0;
372 $offer_type = isset( $_POST['offer_type'] ) ? sanitize_text_field( wp_unslash( $_POST['offer_type'] ) ) : '';
373 $offer_action = isset( $_POST['offer_action'] ) ? sanitize_text_field( wp_unslash( $_POST['offer_action'] ) ) : '';
374 $product_id = isset( $_POST['product_id'] ) ? intval( $_POST['product_id'] ) : '';
375
376 if( !empty($_POST['attr']) && Wpfnl_functions::is_wc_active() ){
377 $variation_id = (new \WC_Product_Data_Store_CPT())->find_matching_product_variation(
378 new \WC_Product($product_id),
379 $_POST['attr']
380 );
381 if( $variation_id ){
382 $product_id = $variation_id;
383 }
384 }
385
386 $order = wc_get_order( $order_id );
387
388 // Get offer product data - use offer class method if available, otherwise use pro functions
389 if ( class_exists( '\WPFunnels\Offer\Wpfnl_Offer' ) ) {
390 $offer_product = \WPFunnels\Offer\Wpfnl_Offer::get_offer_product_data( $step_id, $product_id, 0, $order_id );
391 } elseif ( class_exists( '\WPFunnelsPro\Wpfnl_Pro_functions' ) ) {
392 $offer_product = \WPFunnelsPro\Wpfnl_Pro_functions::get_offer_product_data( $step_id, $product_id, 0, $order_id );
393 } else {
394 wp_send_json(array(
395 'result' => 'fail',
396 'message' => __('Offer functionality not available', 'wpfnl'),
397 ));
398 return;
399 }
400
401 if ( isset($offer_product['price']) && (floatval(0) === floatval( $offer_product['price'] ) || '' === trim($offer_product['price'])) ) {
402 wp_send_json(array(
403 'result' => 'fail',
404 'message' => __('Product price is less than 0', 'wpfnl-pro'),
405 ));
406 } else {
407 $gateways = $woocommerce->payment_gateways->payment_gateways();
408 $gateway = $gateways['stripe'];
409 if ( $gateway ) {
410 $order_source = $gateway->prepare_order_source( $order );
411
412 $main_settings = get_option( 'woocommerce_stripe_settings' );
413 $testmode = ( ! empty( $main_settings['testmode'] ) && 'yes' === $main_settings['testmode'] );
414 $publishable_key = $testmode
415 ? ( ! empty( $main_settings['test_publishable_key'] ) ? $main_settings['test_publishable_key'] : '' )
416 : ( ! empty( $main_settings['publishable_key'] ) ? $main_settings['publishable_key'] : '' );
417
418 $offer_settings = Wpfnl_functions::get_offer_settings();
419
420 // Express Checkout (pm_xxx PaymentMethod): no client-side 3DS step needed.
421 // Return without intent_secret so JS routes to wpfunnels_process_offer directly,
422 // where process_payment() handles the full charge server-side.
423 if ( ! empty( $order_source->source ) && 0 === strpos( $order_source->source, 'pm_' ) ) {
424 wp_send_json( array( 'result' => 'success' ) );
425 return;
426 }
427
428 $is_3ds = isset( $order_source->source_object->card->three_d_secure ) ? $order_source->source_object->card->three_d_secure : false;
429 $_3ds_array = [ 'optional', 'not_supported' ];
430
431 // check if 3ds is active or not
432 if ( isset($is_3ds) && !in_array( $is_3ds,$_3ds_array ) ) {
433
434 $intent = $this->create_intent( $order, $order_source, $offer_product );
435
436 // Confirm the intent after locking the order to make sure webhooks will not interfere.
437 if ( empty( $intent->error ) ) {
438 $intent = $this->confirm_stripe_intent( $intent, $order, $order_source );
439 }
440
441 // Use get_latest_charge_from_intent() to support both old charges->data
442 // and new latest_charge (required since Stripe API 2022-11-15).
443 $response = $gateway->get_latest_charge_from_intent( $intent );
444
445 if ( ! empty( $response->balance_transaction ) ) {
446 $order->update_meta_data( '_stripe_balance_transaction_' . $step_id, $response->balance_transaction );
447 if ( isset( $offer_settings['offer_orders'] ) && 'main-order' === $offer_settings['offer_orders'] ) {
448 self::update_stripe_fees( $order, $response->balance_transaction );
449 $this->store_offer_transaction( $order, $response, $offer_product );
450 }
451 }
452
453 if ( $order ) {
454 $order->update_meta_data( '_stripe_intent_id_' . $step_id, $intent->id );
455 $order->save();
456 }
457
458 wp_send_json( array(
459 'result' => 'success',
460 'redirect' => $gateway->get_return_url( $order ),
461 'intent_secret' => $intent->client_secret,
462 'stripe_pk' => $publishable_key,
463 ) );
464 }
465 }
466
467 wp_send_json(array(
468 'result' => 'fail',
469 'message' => 'No 3ds payment',
470 ));
471 }
472 }
473
474
475 /**
476 * Confirms an intent if it is the `requires_confirmation` state.
477 *
478 * @param object $intent The intent to confirm.
479 * @param WC_Order $order The order that the intent is associated with.
480 * @param object $prepared_source The source that is being charged.
481 * @return object Either an error or the updated intent.
482 *
483 * @since 2.2.3
484 */
485 public function confirm_stripe_intent( $intent, $order, $prepared_source ) {
486 if ( 'requires_confirmation' !== $intent->status ) {
487 return $intent;
488 }
489
490 // Try to confirm the intent & capture the charge (if 3DS is not required).
491 $confirm_request = WC_Stripe_Helper::add_payment_method_to_request_array( $prepared_source->source, [] );
492
493 $level3_data = $this->get_level3_data_from_order( $order );
494 $confirmed_intent = WC_Stripe_API::request_with_level3_data(
495 $confirm_request,
496 "payment_intents/$intent->id/confirm",
497 $level3_data,
498 $order
499 );
500
501 if ( ! empty( $confirmed_intent->error ) ) {
502 return $confirmed_intent;
503 }
504
505 return $confirmed_intent;
506 }
507
508
509
510 /**
511 * Create the level 3 data array to send to Stripe when making a purchase.
512 *
513 * @param WC_Order $order The order that is being paid for.
514 * @return array The level 3 data to send to Stripe.
515 *
516 * @since 2.2.3
517 */
518 public function get_level3_data_from_order( $order ) {
519 // Get the order items. Don't need their keys, only their values.
520 // Order item IDs are used as keys in the original order items array.
521 $order_items = array_values( $order->get_items( [ 'line_item', 'fee' ] ) );
522 $currency = $order->get_currency();
523
524 $stripe_line_items = array_map(
525 function( $item ) use ( $currency ) {
526 if ( is_a( $item, 'WC_Order_Item_Product' ) ) {
527 $product_id = $item->get_variation_id()
528 ? $item->get_variation_id()
529 : $item->get_product_id();
530 $subtotal = $item->get_subtotal();
531 } else {
532 $product_id = substr( sanitize_title( $item->get_name() ), 0, 12 );
533 $subtotal = $item->get_total();
534 }
535 $product_description = substr( $item->get_name(), 0, 26 );
536 $quantity = $item->get_quantity();
537 $unit_cost = WC_Stripe_Helper::get_stripe_amount( ( $subtotal / $quantity ), $currency );
538 $tax_amount = WC_Stripe_Helper::get_stripe_amount( $item->get_total_tax(), $currency );
539 $discount_amount = WC_Stripe_Helper::get_stripe_amount( $subtotal - $item->get_total(), $currency );
540
541 return (object) [
542 'product_code' => (string) $product_id, // Up to 12 characters that uniquely identify the product.
543 'product_description' => $product_description, // Up to 26 characters long describing the product.
544 'unit_cost' => $unit_cost, // Cost of the product, in cents, as a non-negative integer.
545 'quantity' => $quantity, // The number of items of this type sold, as a non-negative integer.
546 'tax_amount' => $tax_amount, // The amount of tax this item had added to it, in cents, as a non-negative integer.
547 'discount_amount' => $discount_amount, // The amount an item was discounted—if there was a sale,for example, as a non-negative integer.
548 ];
549 },
550 $order_items
551 );
552
553 $level3_data = [
554 'merchant_reference' => $order->get_id(), // An alphanumeric string of up to characters in length. This unique value is assigned by the merchant to identify the order. Also known as an “Order ID”.
555 'shipping_amount' => WC_Stripe_Helper::get_stripe_amount( (float) $order->get_shipping_total() + (float) $order->get_shipping_tax(), $currency ), // The shipping cost, in cents, as a non-negative integer.
556 'line_items' => $stripe_line_items,
557 ];
558
559 // The customer’s U.S. shipping ZIP code.
560 $shipping_address_zip = $order->get_shipping_postcode();
561 if ( $this->is_valid_us_zip_code( $shipping_address_zip ) ) {
562 $level3_data['shipping_address_zip'] = $shipping_address_zip;
563 }
564
565 // The merchant’s U.S. shipping ZIP code.
566 $store_postcode = get_option( 'woocommerce_store_postcode' );
567 if ( $this->is_valid_us_zip_code( $store_postcode ) ) {
568 $level3_data['shipping_from_zip'] = $store_postcode;
569 }
570
571 return $level3_data;
572 }
573
574
575 /**
576 * Verifies whether a certain ZIP code is valid for the US, incl. 4-digit extensions.
577 *
578 * @param string $zip The ZIP code to verify.
579 * @return boolean
580 *
581 * @since 2.2.3
582 */
583 public function is_valid_us_zip_code( $zip ) {
584 return ! empty( $zip ) && preg_match( '/^\d{5,5}(-\d{4,4})?$/', $zip );
585 }
586
587
588 /**
589 * @param $order
590 * @param $order_source
591 * @param $product
592 * @return array|\stdClass
593 * @throws \WC_Stripe_Exception
594 */
595 public function create_intent($order, $order_source, $product)
596 {
597 // The request for a charge contains metadata for the intent.
598 $full_request = $this->generate_payment_request($order, $order_source, $product);
599 $request = [
600
601 'amount' => \WC_Stripe_Helper::get_stripe_amount($product['price']),
602 'currency' => strtolower($order->get_currency()),
603 'description' => $full_request['description'],
604 'metadata' => $full_request['metadata'],
605 'statement_descriptor' => \WC_Stripe_Helper::clean_statement_descriptor($full_request['statement_descriptor']),
606 'capture_method' => (isset($full_request['capture']) && 'true' === $full_request['capture']) ? 'automatic' : 'manual',
607 'payment_method_types' => [
608 'card',
609 ],
610 'customer' => $order_source->customer,
611 ];
612
613 $request = \WC_Stripe_Helper::add_payment_method_to_request_array( $order_source->source, $request );
614
615 if ($order_source->customer) {
616 $request['customer'] = $order_source->customer;
617 }
618
619 if (!empty($full_request['statement_descriptor_suffix'])) {
620 $request['statement_descriptor_suffix'] = $full_request['statement_descriptor_suffix'];
621 }
622
623 // Express Checkout Element (Apple Pay / Google Pay) stores a PaymentMethod (pm_xxx).
624 // Confirm immediately (on-session: the customer is present on the offer page).
625 // Force capture_method=automatic so the confirmed intent settles immediately.
626 if ( 0 === strpos( $order_source->source, 'pm_' ) ) {
627 // Use string 'true' not PHP bool: wp_safe_remote_post encodes via http_build_query
628 // which converts PHP true -> '1', but Stripe expects the string 'true'.
629 $request['confirm'] = 'true';
630 $request['capture_method'] = 'automatic';
631
632 if ( ! empty( $order_source->customer ) && ! empty( $order_source->source ) ) {
633 $pm_object = \WC_Stripe_API::get_payment_method( $order_source->source );
634 if ( ! empty( $pm_object->error ) ) {
635 return $pm_object;
636 }
637
638 if ( empty( $pm_object->customer ) ) {
639 // PM is not yet attached — attach it now before creating the offer intent.
640 $attach_result = \WC_Stripe_API::attach_payment_method_to_customer( $order_source->customer, $order_source->source );
641 if ( ! empty( $attach_result->error ) ) {
642 return $attach_result;
643 }
644 }
645
646 // Use the actual PM type so Stripe doesn't reject for type mismatch (e.g. 'link' vs 'card').
647 if ( ! empty( $pm_object->type ) ) {
648 $request['payment_method_types'] = [ $pm_object->type ];
649 }
650
651 // PM is attached (either already was or we just attached it): charge off-session.
652 $request['off_session'] = 'true';
653 }
654 }
655
656 // Create an intent that awaits an action.
657 $intent = \WC_Stripe_API::request( $request, 'payment_intents' );
658
659 if ( ! empty( $intent->error ) ) {
660 return $intent;
661 }
662
663 $order_id = $order->get_id();
664
665 // Get step_id from POST data (for AJAX calls) or from product data (for direct calls).
666 $step_id = filter_input(INPUT_POST, 'step_id', FILTER_VALIDATE_INT);
667 if ( ! $step_id && isset( $product['step_id'] ) ) {
668 $step_id = intval( $product['step_id'] );
669 }
670
671 // Save the intent ID to the order.
672 if ( $step_id ) {
673 update_post_meta($order_id, '_stripe_intent_id_' . $step_id, $intent->id);
674 }
675
676 return $intent;
677 }
678
679
680
681
682 /**
683 * Retrieves intent from Stripe API by intent id.
684 *
685 * @param string $intent_type Either 'payment_intents' or 'setup_intents'.
686 * @param string $intent_id Intent id.
687 * @return object|bool Either the intent object or `false`.
688 * @throws Exception Throws exception for unknown $intent_type.
689 */
690 public function get_intent( $intent_type, $intent_id ) {
691 if ( ! in_array( $intent_type, [ 'payment_intents', 'setup_intents' ], true ) ) {
692 throw new \Exception( "Failed to get intent of type $intent_type. Type is not allowed" );
693 }
694
695 $response = \WC_Stripe_API::request( [], "$intent_type/$intent_id?expand[]=payment_method", 'GET' );
696
697 if ( $response && isset( $response->{ 'error' } ) ) {
698 return false;
699 }
700
701 return $response;
702 }
703
704
705 /**
706 * check if token is present in the order
707 *
708 * @param $order
709 */
710 private function has_token( $order ) {
711 if( false === is_a( $order, 'WC_Order' ) ){
712 return false;
713 }
714 $token = $order->get_meta('_wpfunnels_stripe_source_id');
715 if ( empty( $token ) ) {
716 $token = $order->get_meta('_stripe_source_id');
717 }
718 if ( ! empty( $token ) ) {
719 return true;
720 }
721 return false;
722 }
723
724 /**
725 * process the offer payment
726 *
727 * @param $order
728 * @param $offer_product
729 * @return bool
730 * @throws \WC_Stripe_Exception
731 */
732 public function process_payment( $order, $offer_product ) {
733 $result = array(
734 'is_success' => false,
735 'message' => ''
736 );
737 if ( ! $this->has_token( $order ) ) {
738 return $result;
739 }
740 $gateway = $this->get_wc_gateway();
741 $order_source = $gateway->prepare_order_source( $order );
742
743 // Express Checkout Element (Apple Pay / Google Pay) stores a PaymentMethod (pm_xxx).
744 // These must be charged using Payment Intents, not the legacy Charges API.
745 if ( 0 === strpos( $order_source->source, 'pm_' ) ) {
746 $intent = $this->create_intent( $order, $order_source, $offer_product );
747
748 if ( ! empty( $intent->error ) ) {
749 $result['message'] = $intent->error->message;
750 return $result;
751 }
752
753 // Payment Intents are confirmed immediately when created with confirm=true.
754 // Check if the charge was successful.
755 // Use get_latest_charge_from_intent() to support both old charges->data
756 // and new latest_charge (required since Stripe API 2022-11-15).
757 $response = $gateway->get_latest_charge_from_intent( $intent );
758
759 if ( ! $response || empty( $response->id ) ) {
760 $result['message'] = __( 'Payment intent created but no charge found', 'wpfnl' );
761 return $result;
762 }
763
764 if ( ! empty( $response->balance_transaction ) ) {
765 $this->update_stripe_payout_details( $order, $response );
766 }
767
768 $result['is_success'] = true;
769 $this->store_offer_transaction( $order, $response, $offer_product );
770
771 return $result;
772 }
773
774 // Legacy flow: Use Charges API for Sources (src_xxx) and cards (card_xxx).
775 $response = \WC_Stripe_API::request( $this->generate_payment_request( $order, $order_source, $offer_product ) );
776
777 if ( ! is_wp_error( $response ) ) {
778 if ( ! empty( $response->error ) ) {
779 $result['message'] = $response->error->message;
780 } else {
781 $result['is_success'] = true;
782 $this->update_stripe_payout_details( $order, $response );
783 $this->store_offer_transaction( $order, $response, $offer_product );
784 }
785 }
786 return $result;
787 }
788
789 /**
790 * generate payment request post data
791 *
792 * @param $order
793 * @param $order_source
794 * @param $product
795 * @return mixed|void
796 */
797 public function generate_payment_request($order, $order_source, $product) {
798
799 $settings = get_option( 'woocommerce_stripe_settings', [] );
800 $is_short_statement_descriptor_enabled = ! empty( $settings['is_short_statement_descriptor_enabled'] ) && 'yes' === $settings['is_short_statement_descriptor_enabled'];
801 $capture = ! empty( $settings['capture'] ) && 'yes' === $settings['capture'] ? true : false;
802 $post_data = [];
803
804 if ( \WC_Stripe_Helper::payment_method_allows_manual_capture( $order->get_payment_method() ) ) {
805 $post_data['capture'] = $capture ? 'true' : 'false';
806 if ( $is_short_statement_descriptor_enabled ) {
807 $post_data['statement_descriptor_suffix'] = WC_Stripe_Helper::get_dynamic_statement_descriptor_suffix( $order );
808 }
809 }
810
811 $post_data['currency'] = strtolower($order ? $order->get_currency() : get_woocommerce_currency());
812 $post_data['amount'] = \WC_Stripe_Helper::get_stripe_amount($product['price'], $post_data['currency']);
813 /* translators: %1s site name */
814 $post_data['description'] = sprintf(__('%1$s - Order %2$s - One Time offer', 'wpfnl'), wp_specialchars_decode(get_bloginfo('name'), ENT_QUOTES), $order->get_order_number());
815
816 /* translators: %1s order number */
817 $post_data['statement_descriptor'] = apply_filters( 'wpfunnels/stripe_descriptor_text_modifiaction', sprintf(__('Order %1$s-OTO', 'wpfnl'), $order->get_order_number()), $order );
818
819 $billing_first_name = $order->get_billing_first_name();
820 $billing_last_name = $order->get_billing_last_name();
821 $billing_email = $order->get_billing_email();
822
823 if (!empty($billing_email) && apply_filters('wc_stripe_send_stripe_receipt', false)) {
824 $post_data['receipt_email'] = $billing_email;
825 }
826
827 $metadata = [
828 __('customer_name', 'wpfnl') => sanitize_text_field($billing_first_name) . ' ' . sanitize_text_field($billing_last_name),
829 __('customer_email', 'wpfnl') => sanitize_email($billing_email),
830 'order_id' => apply_filters( 'wpfunnels/stripe_descriptor_text_modifiaction', sprintf(__('Order %1$s-OTO', 'wpfnl'), $order->get_order_number()), $order ) . '_' . $product['id'],
831 ];
832
833 $post_data['expand[]'] = 'balance_transaction';
834 $post_data['metadata'] = apply_filters('wc_stripe_payment_metadata', $metadata, $order, $order_source);
835
836 if ($order_source->customer) {
837 $post_data['customer'] = $order_source->customer;
838 }
839
840 if ($order_source->source) {
841 $source_3ds = $order->get_meta('_wpfunnels_stripe_source_id', true);
842
843 $post_data['source'] = ('' !== $source_3ds) ? $source_3ds : $order_source->source;
844 }
845 $post_data['source'] = $order_source->source;
846 return $post_data;
847 }
848
849
850 /**
851 * @param $order
852 * @param $response
853 */
854 public function update_stripe_payout_details($order, $response)
855 {
856 $fee = !empty($response->balance_transaction->fee) ? \WC_Stripe_Helper::format_balance_fee($response->balance_transaction, 'fee') : 0;
857 $net = !empty($response->balance_transaction->net) ? \WC_Stripe_Helper::format_balance_fee($response->balance_transaction, 'net') : 0;
858
859 $fee = $fee + \WC_Stripe_Helper::get_stripe_fee($order);
860 $net = $net + \WC_Stripe_Helper::get_stripe_net($order);
861
862 \WC_Stripe_Helper::update_stripe_fee($order, $fee);
863 \WC_Stripe_Helper::update_stripe_net($order, $net);
864 }
865
866
867 /**
868 * Updates Stripe fees/net.
869 * e.g usage would be after a refund.
870 *
871 * @since 4.0.0
872 * @version 4.0.6
873 * @param object $order The order object
874 * @param int $balance_transaction_id
875 */
876 public static function update_stripe_fees( $order, $balance_transaction_id ) {
877 $balance_transaction = WC_Stripe_API::retrieve( 'balance/history/' . $balance_transaction_id );
878
879 if ( empty( $balance_transaction->error ) ) {
880 if ( isset( $balance_transaction ) && isset( $balance_transaction->fee ) ) {
881 // Fees and Net needs to both come from Stripe to be accurate as the returned
882 // values are in the local currency of the Stripe account, not from WC.
883 $fee_refund = ! empty( $balance_transaction->fee ) ? WC_Stripe_Helper::format_balance_fee( $balance_transaction, 'fee' ) : 0;
884 $net_refund = ! empty( $balance_transaction->net ) ? WC_Stripe_Helper::format_balance_fee( $balance_transaction, 'net' ) : 0;
885
886 // Current data fee & net.
887 $fee_current = WC_Stripe_Helper::get_stripe_fee( $order );
888 $net_current = WC_Stripe_Helper::get_stripe_net( $order );
889
890 // Calculation.
891 $fee = (float) $fee_current + (float) $fee_refund;
892 $net = (float) $net_current + (float) $net_refund;
893
894 WC_Stripe_Helper::update_stripe_fee( $order, $fee );
895 WC_Stripe_Helper::update_stripe_net( $order, $net );
896
897 $currency = ! empty( $balance_transaction->currency ) ? strtoupper( $balance_transaction->currency ) : null;
898 WC_Stripe_Helper::update_stripe_currency( $order, $currency );
899
900 if ( is_callable( [ $order, 'save' ] ) ) {
901 $order->save();
902 }
903 }
904 }
905 }
906
907
908 /**
909 * @param $order
910 * @param $response
911 * @param $product
912 */
913 public function store_offer_transaction( $order, $response, $product )
914 {
915 $order->update_meta_data('_wpfunnels_offer_txn_resp_' . $product['step_id'], $response->id);
916 $order->save();
917 }
918
919
920 /**
921 * create child order reference with the parent order
922 *
923 * @param $parent_order
924 * @param $product_data
925 * @param string $type
926 * @return bool|\WC_Order|\WP_Error
927 * @throws \WC_Data_Exception
928 */
929 public function create_child_order( $parent_order, $product_data, $type = 'upsell' )
930 {
931 $order = false;
932
933 if (!empty($parent_order)) {
934 $parent_order_id = $parent_order->get_id();
935 $parent_order_billing = $parent_order->get_address('billing');
936 $funnel_id = $parent_order->get_meta('_wpfunnels_funnel_id');
937
938 if (!empty($parent_order_billing['email'])) {
939 $customer_id = $parent_order->get_customer_id();
940
941 $order = wc_create_order(
942 [
943 'customer_id' => $customer_id,
944 'status' => 'wc-pending',
945 'parent' => $parent_order_id,
946 ]
947 );
948
949 /* Set Order type */
950 $order->update_meta_data('_wpfunnels_offer', 'yes' );
951 $order->update_meta_data('_wpfunnels_offer_type', $type );
952 $order->update_meta_data('_wpfunnels_parent_funnel_id', $funnel_id);
953 $order->update_meta_data('_wpfunnels_offer_step_id', $product_data['step_id'] );
954 $order->update_meta_data('_wpfunnels_offer_parent_id', $parent_order_id );
955
956 $item_id = $order->add_product(wc_get_product($product_data['id']), $product_data['qty'], $product_data['args']);
957
958 if( $item_id ){
959 wc_add_order_item_meta( $item_id, "_wpfunnels_{$type}", 'yes' );
960 wc_add_order_item_meta( $item_id, '_wpfunnels_step_id', $product_data['step_id']);
961 }
962
963 $order->set_address($parent_order->get_address('billing'), 'billing');
964 $order->set_address($parent_order->get_address('shipping'), 'shipping');
965
966 // Set shipping data.
967 $order->set_payment_method($parent_order->get_payment_method());
968 $order->set_payment_method_title($parent_order->get_payment_method_title());
969
970 if (!wc_tax_enabled()) {
971 // Reports won't track orders fix.
972 $order->set_shipping_tax(0);
973 $order->set_cart_tax(0);
974 }
975
976 $order->calculate_totals();
977
978 $offer_orders_meta = $parent_order->get_meta('_wpfunnels_offer_child_orders');
979
980 if (!is_array($offer_orders_meta)) {
981 $offer_orders_meta = [];
982 }
983
984 $offer_orders_meta[$order->get_id()] = ['type' => $type];
985
986 $parent_order->update_meta_data('_wpfunnels_offer_child_orders', $offer_orders_meta);
987
988 // Save the order.
989 $parent_order->save();
990 }
991 }
992
993 if ($order) {
994 $transaction_id = $parent_order->get_transaction_id();
995
996 $this->payment_complete($order, $transaction_id);
997
998 $order->set_transaction_id($transaction_id);
999 $order->save();
1000
1001 $transaction_id_note = '';
1002
1003 if (!empty($transaction_id)) {
1004 $transaction_id_note = sprintf(' (Transaction ID: %s)', $transaction_id);
1005 }
1006
1007 $order->add_order_note('Offer Accepted | ' . $type . ' | Step ID - ' . $product_data['step_id'] . ' | ' . $transaction_id_note);
1008 return $order;
1009 }
1010 return false;
1011 }
1012
1013
1014 /**
1015 * payment_complete
1016 * Complete the payment
1017 *
1018 * @param WC_Order $order Parent order detail
1019 * @param String $transaction_id Transaction id.
1020 *
1021 */
1022
1023 public function payment_complete($order, $transaction_id = '')
1024 {
1025 $payment_method = $order->get_payment_method();
1026
1027 if ('cod' === $payment_method) {
1028 $order->set_status('processing');
1029 wc_reduce_stock_levels($order);
1030 } elseif ('bacs' === $payment_method) {
1031 $order->set_status('on-hold');
1032 wc_reduce_stock_levels($order);
1033 } else {
1034 $order->payment_complete($transaction_id);
1035 }
1036 }
1037
1038
1039 /**
1040 * Get WooCommerce payment geteways.
1041 *
1042 * @return array
1043 */
1044 public function get_wc_gateway() {
1045
1046 global $woocommerce;
1047 $gateways = $woocommerce->payment_gateways->payment_gateways();
1048
1049 return $gateways[ $this->key ];
1050 }
1051
1052
1053 /**
1054 * process refund offer
1055 *
1056 * @param $order
1057 * @param $data
1058 * @return bool
1059 * @throws \WC_Stripe_Exception
1060 */
1061 public function process_refund_offer( $order, $data ) {
1062
1063 $transaction_id = $data['transaction_id'];
1064 $amount = $data['amount'];
1065 $currency = $order->get_currency( $order );
1066
1067 $request = array();
1068 $response_id = false;
1069
1070 if ( ! is_null( $amount ) && class_exists( 'WC_Stripe_Helper' ) ) {
1071 $request['amount'] = WC_Stripe_Helper::get_stripe_amount( $amount, $currency );
1072 }
1073 if ( ! is_null( $amount ) && class_exists( 'WC_Stripe_API' ) ) {
1074 $request['charge'] = $transaction_id;
1075 $response = WC_Stripe_API::request( $request, 'refunds' );
1076 if ( ! empty( $response->error ) || ! $response ) {
1077 $response_id = false;
1078 } else {
1079 $this->get_wc_gateway()->update_fees( $order, $response->balance_transaction );
1080 $response_id = isset( $response->id ) ? $response->id : true;
1081 }
1082 }
1083 return $response_id;
1084 }
1085
1086
1087 /**
1088 * add required meta for refund
1089 *
1090 * @param $parent_order
1091 * @param $child_order
1092 * @param $transaction_id
1093 */
1094 public function add_capture_meta_to_child_order( $parent_order, $child_order, $transaction_id ) {
1095 $child_order->update_meta_data("_stripe_charge_captured", "yes");
1096
1097 $stripe_source_id = $parent_order->get_meta( '_stripe_source_id', true );
1098 $stripe_customer_id = $parent_order->get_meta( '_stripe_customer_id', true );
1099 if (function_exists("wcs_order_contains_subscription") && wcs_order_contains_subscription($child_order)) {
1100 $subscription = wcs_get_subscriptions_for_order($child_order->get_id());
1101 if (!empty($subscription)) {
1102 foreach ( $subscription as $sub ) {
1103 $sub->update_meta_data( '_stripe_source_id', $stripe_source_id );
1104 $sub->update_meta_data( '_stripe_customer_id', $stripe_customer_id );
1105 $sub->save();
1106 }
1107 }
1108 }
1109 }
1110
1111
1112 /**
1113 * add subscription offer meta to order
1114 *
1115 * @param $subscription
1116 * @param $offer_product
1117 * @param $order
1118 */
1119 public function add_offer_subscription_meta( $subscription, $offer_product, $order ) {
1120 if ( 'stripe' === $order->get_payment_method() ) {
1121 // Use CRUD methods instead of update_post_meta().
1122 $stripe_source_id = $order->get_meta( '_stripe_source_id', true );
1123 $stripe_customer_id = $order->get_meta( '_stripe_customer_id', true );
1124
1125 if ( $stripe_source_id ) {
1126 $subscription->update_meta_data( '_stripe_source_id', $stripe_source_id );
1127 }
1128
1129 if ( $stripe_customer_id ) {
1130 $subscription->update_meta_data( '_stripe_customer_id', $stripe_customer_id );
1131 }
1132
1133 // Save the subscription after updating meta.
1134 $subscription->save();
1135 }
1136 }
1137
1138
1139 /**
1140 * Add hidden field for stripe
1141 *
1142 * @return void
1143 * @since 2.2.3
1144 */
1145 public function add_stripe_hidden_field(){
1146 // Get funnel checkout ID from post data.
1147 $checkout_id = Wpfnl_functions::get_checkout_id_from_post($_POST);
1148
1149 // Get checkout id if not found in post data.
1150 $checkout_id = !$checkout_id ? get_the_ID() : $checkout_id;
1151
1152 // Get funnel ID from checkout ID.
1153 $funnel_id = Wpfnl_functions::get_funnel_id_from_step( $checkout_id );
1154
1155 // Check if checkout ID and funnel ID exists.
1156 if ( $checkout_id && $funnel_id ) {
1157 // Check if offer exists in funnel.
1158 if ( Wpfnl_functions::is_offer_exists_in_funnel($funnel_id) ) {
1159 // Add hidden field for stripe.
1160 echo '<input type="hidden" name="wc-stripe-new-payment-method" value="1">';
1161 }
1162 }
1163 }
1164 }
1165