<?php //phpcs:ignore /** * Class for handling WC subscriptions. * * @package WC_Klarna_Payments/Classes */ if ( ! defined( 'ABSPATH' ) ) { exit; } /** * Class for handling subscriptions. */ class KP_Subscription { public const GATEWAY_ID = 'klarna_payments'; public const RECURRING_TOKEN = '_' . self::GATEWAY_ID . '_recurring_token'; /** * Register hooks. */ public function __construct() { add_action( 'woocommerce_scheduled_subscription_payment_' . self::GATEWAY_ID, array( $this, 'process_scheduled_payment' ), 10, 2 ); add_action( 'woocommerce_subscription_cancelled_' . self::GATEWAY_ID, array( $this, 'cancel_scheduled_payment' ) ); // Set the purchase intent to 'tokenize' for trial subscriptions. The 'buy_and_tokenize' intent is not allowed for 0 order amounts. add_filter( 'wc_klarna_payments_create_session_args', array( $this, 'set_tokenize_intent' ) ); add_filter( 'wc_klarna_payments_place_order_args', array( $this, 'set_tokenize_intent' ) ); add_filter( 'wc_klarna_payments_create_customer_token_args', array( $this, 'set_tokenize_intent' ) ); add_filter( 'wc_klarna_payments_update_session_args', array( $this, 'set_tokenize_intent' ) ); // For free or trial subscription, we set the order as captured to prevent KOM from setting the order to on-hold when the merchant set the order to "Completed". add_filter( 'woocommerce_payment_complete', array( $this, 'set_subscription_as_captured' ) ); // Override the redirect URLs to redirect back to the change payment method page on failure or to the subscription view on success. add_filter( 'wc_klarna_payments_create_hpp_args', array( $this, 'set_subscription_order_redirect_urls' ) ); // Override the subscription cost when change payment method. add_filter( 'wc_klarna_payments_create_session_args', array( $this, 'set_subscription_to_free' ) ); // On successful payment method change, the customer is redirected back to the subscription view page. We need to handle the redirect and create a recurring token. add_action( 'woocommerce_account_view-subscription_endpoint', array( $this, 'handle_redirect_from_change_payment_method' ) ); // Show the recurring token on the subscription page in the billing fields. add_action( 'woocommerce_admin_order_data_after_billing_address', array( $this, 'show_recurring_token' ) ); // Ensure wp_safe_redirect do not redirect back to default dashboard or home page. add_filter( 'allowed_redirect_hosts', array( $this, 'extend_allowed_domains_list' ) ); } /** * Flags a free or trial subscription parent order as captured. * * This is required to prevent Klarna Order Management from attempting to process the order for capture when the customer sets the order to completed as there is nothing to capture. * * @param int $order_id WooCommerce order ID. * @return int WooCommerce order ID. */ public function set_subscription_as_captured( $order_id ) { $order = wc_get_order( $order_id ); if ( self::GATEWAY_ID !== $order->get_payment_method() ) { return $order_id; } if ( self::order_has_subscription( $order ) && 0.0 === floatval( $order->get_total() ) ) { $order->update_meta_data( '_wc_klarna_capture_id', 'trial' ); $order->save(); } return $order_id; } /** * Process subscription renewal. * * @param float $amount_to_charge * @param WC_Order $renewal_order The WooCommerce order that will be created as a result of the renewal. * @return void */ public function process_scheduled_payment( $amount_to_charge, $renewal_order ) { $recurring_token = $this->get_recurring_tokens( $renewal_order->get_id() ); $response = KP_WC()->api->create_recurring_order( kp_get_klarna_country( $renewal_order ), $recurring_token, $renewal_order->get_id() ); if ( ! is_wp_error( $response ) ) { $klarna_order_id = $response['order_id']; $renewal_order->add_order_note( sprintf( __( 'Subscription payment made with Klarna. Klarna order id: %s', 'klarna-payments-for-woocommerce' ), $klarna_order_id ) ); kp_save_order_meta_data( $renewal_order, $response ); } else { $error_message = $response->get_error_message(); // Translators: Error message. $renewal_order->add_order_note( sprintf( __( 'Subscription payment failed with Klarna. Reason: %1$s', 'klarna-payments-for-woocommerce' ), $error_message ) ); } $subscriptions = wcs_get_subscriptions_for_renewal_order( $renewal_order->get_id() ); foreach ( $subscriptions as $subscription ) { if ( isset( $klarna_order_id ) ) { $subscription->payment_complete( $klarna_order_id ); } else { $subscription->payment_failed(); } // Save to the subscription. self::save_recurring_token( $subscription->get_id(), $recurring_token ); } // Save to the WC order. self::save_recurring_token( $renewal_order->get_id(), $recurring_token ); } /** * Cancel the customer token to prevent further payments using the token. * * Note: When changing payment method, WC Subscriptions will cancel the subscription with existing payment gateway (which triggers this functions), and create a new one. Thus the new subscription must generate a new customer token. * * @see WC_Subscriptions_Change_Payment_Gateway::update_payment_method * * @param mixed $subscription WC_Subscription. * @return void */ public function cancel_scheduled_payment( $subscription ) { // Prevent a recursion of this function when we save the subscription. if ( did_action( 'woocommerce_subscription_cancelled_' . self::GATEWAY_ID ) > 1 ) { return; } $recurring_token = $this->get_recurring_tokens( $subscription->get_id() ); $response = KP_WC()->api->cancel_recurring_order( kp_get_klarna_country( $subscription ), $recurring_token ); if ( ! is_wp_error( $response ) ) { $subscription->add_order_note( __( 'Subscription cancelled with Klarna Payments.', 'klarna-payments-for-woocommerce' ) ); } else { $error_message = $response->get_error_message(); // Translators: Error message. $subscription->add_order_note( sprintf( __( 'Subscription cancellation failed with Klarna Payments. Reason: %1$s', 'klarna-payments-for-woocommerce' ), $error_message ) ); } // The session data must be deleted since Klarna doesn't allow reusing a session when generating a new customer token to change payment method. $subscription->delete_meta_data( '_kp_session_data' ); $subscription->save(); } /** * Set the redirect URLs for the hosted payment page. * * Used for changing payment method. * * @param array $request The Klarna request. * @return array */ public function set_subscription_order_redirect_urls( $request ) { if ( ! self::is_change_payment_method() ) { return $request; } $key = filter_input( INPUT_GET, 'key', FILTER_SANITIZE_SPECIAL_CHARS ); $order_id = wc_get_order_id_by_order_key( $key ); $subscription = wc_get_order( $order_id ); $body = json_decode( $request['body'], true ); $success_url = add_query_arg( array( 'authorization_token' => '{{authorization_token}}', ), $subscription->get_view_order_url() ); $body['merchant_urls'] = array( 'success' => $success_url, 'cancel' => $subscription->get_change_payment_method_url(), 'back' => $subscription->get_change_payment_method_url(), 'failure' => $subscription->get_change_payment_method_url(), 'error' => $subscription->get_change_payment_method_url(), ); $request['body'] = wp_json_encode( $body ); return $request; } /** * Handle the redirect from the hosted payment page. * * Used for changing payment method. * * @param int $subscription_id The subscription ID. * @return void */ public function handle_redirect_from_change_payment_method( $subscription_id ) { $auth_token = filter_input( INPUT_GET, 'authorization_token', FILTER_SANITIZE_SPECIAL_CHARS ); if ( ! isset( $auth_token ) ) { return; } $subscription = wcs_get_subscription( $subscription_id ); $response = KP_WC()->api->create_customer_token( kp_get_klarna_country( $subscription ), $auth_token, $subscription_id ); if ( is_wp_error( $response ) ) { $message = sprintf( /* translators: Error message. */ __( 'Failed to create recurring token. Reason: %s', 'klarna-payments-for-woocommerce' ), $response->get_error_message() ); } else { $message = sprintf( /* translators: Recurring token. */ __( 'Recurring token created: %s', 'klarna-payments-for-woocommerce' ), $response['token_id'] ); self::save_recurring_token( $subscription_id, $response['token_id'] ); } $subscription->add_order_note( $message ); $subscription->save(); } /** * Set the subscription cost to 0. * This is required when changing payment method. * * @param array $request The Klarna request. * @return array */ public function set_subscription_to_free( $request ) { if ( ! self::is_change_payment_method() ) { return $request; } $body = json_decode( $request['body'], true ); foreach ( $body['order_lines'] as $item => $order_line ) { $body['order_lines'][ $item ]['unit_price'] = 0; $body['order_lines'][ $item ]['total_amount'] = 0; } // 0 order amounts are allowed if the purchase intent is 'tokenize'. On the intent 'buy_and_tokenize' 0 order amounts are not allowed. $body['intent'] = 'tokenize'; $request['body'] = wp_json_encode( $body ); return $request; } /** * Set the purchase intent to 'tokenize' for trial subscriptions. * * The 'buy_and_tokenize' intent is not allowed for 0 order amounts. * * @param array $request The Klarna request. * @return array */ public function set_tokenize_intent( $request ) { $body = json_decode( $request['body'], true ); $body['intent'] = 'buy'; if ( self::cart_has_subscription() ) { $body['intent'] = 'buy_and_tokenize'; // Only allow free orders if the cart contains a subscription (not limited to trial subscription as a subscription can become free if a 100% discount coupon is applied). if ( 0.0 === floatval( $body['order_amount'] ) ) { $body['intent'] = 'tokenize'; } } $request['body'] = wp_json_encode( $body ); return $request; } /** * Save the payment and recurring token to the order and its subscription(s). * * @param string $order_id The WooCommerce order id. * @param string $recurring_token The recurring token ("customer token"). * @return void */ public static function save_recurring_token( $order_id, $recurring_token ) { $order = wc_get_order( $order_id ); $order->update_meta_data( self::RECURRING_TOKEN, $recurring_token ); foreach ( wcs_get_subscriptions_for_order( $order, array( 'order_type' => 'any' ) ) as $subscription ) { $subscription->update_meta_data( self::RECURRING_TOKEN, $recurring_token ); $subscription->save(); } $order->save(); } /** * Retrieve the necessary tokens required for subscriptions (unattended) payments. * * @param int $order_id The WooCommerce order id. * @return string The recurring token. If none is found, an empty string is returned. */ public static function get_recurring_tokens( $order_id ) { $order = wc_get_order( $order_id ); $recurring_token = $order->get_meta( self::RECURRING_TOKEN ); if ( empty( $recurring_token ) ) { $subscriptions = wcs_get_subscriptions_for_renewal_order( $order_id ); foreach ( $subscriptions as $subscription ) { $parent_order = $subscription->get_parent(); $recurring_token = $parent_order->get_meta( self::RECURRING_TOKEN ); if ( ! empty( $recurring_token ) ) { break; } } } return $recurring_token; } /** * Get a subscription's parent order. * * @param int $order_id The WooCommerce order id. * @return WC_Order|false The parent order or false if none is found. */ public static function get_parent_order( $order_id ) { $subscriptions = wcs_get_subscriptions_for_renewal_order( $order_id ); foreach ( $subscriptions as $subscription ) { $parent_order = $subscription->get_parent(); return $parent_order; } return false; } /** * Check if the current request is for changing the payment method. * * @return bool */ public static function is_change_payment_method() { return isset( $_GET['change_payment_method'] ); } /** * Check if an order contains a subscription. * * @param WC_Order $order The WooCommerce order or leave empty to use the cart (default). * @return bool */ public static function order_has_subscription( $order ) { if ( empty( $order ) ) { return false; } return function_exists( 'wcs_order_contains_subscription' ) && wcs_order_contains_subscription( $order, array( 'parent', 'resubscribe', 'switch', 'renewal' ) ); } /** * Check if a cart contains a subscription. * * @return bool */ public static function cart_has_subscription() { if ( ! is_checkout() ) { return false; } return ( class_exists( 'WC_Subscriptions_Cart' ) && WC_Subscriptions_Cart::cart_contains_subscription() ) || ( function_exists( 'wcs_cart_contains_failed_renewal_order_payment' ) && wcs_cart_contains_failed_renewal_order_payment() ); } /** * Add Klarna hosted payment page as allowed external url for wp_safe_redirect. * We do this because WooCommerce Subscriptions use wp_safe_redirect when processing a payment method change request (from v5.1.0). * * @param array $hosts Domains that are allowed when wp_safe_redirect is used. * @return array */ public function extend_allowed_domains_list( $hosts ) { $hosts[] = 'pay.playground.klarna.com'; $hosts[] = 'pay.klarna.com'; return $hosts; } /** * Shows the recurring token for the order. * * @param WC_Order $order The WooCommerce order. * @return void */ public function show_recurring_token( $order ) { if ( 'shop_subscription' === $order->get_type() ) { $recurring_token = $order->get_meta( self::RECURRING_TOKEN ); ?> <div class="order_data_column" style="clear:both; float:none; width:100%;"> <div class="address"> <p> <strong><?php echo esc_html( 'Klarna recurring token' ); ?>:</strong><?php echo esc_html( $recurring_token ); ?> </p> </div> <div class="edit_address"> <?php woocommerce_wp_text_input( array( 'id' => self::RECURRING_TOKEN, 'label' => __( 'Klarna recurring token', 'klarna-payments-for-woocommerce' ), 'wrapper_class' => '_billing_company_field', 'value' => $recurring_token, ) ); ?> </div> </div> <?php } } }