diff --git a/adminpages/orders.php b/adminpages/orders.php index a70fcb917..0e92eb270 100644 --- a/adminpages/orders.php +++ b/adminpages/orders.php @@ -53,6 +53,27 @@ } } +// Check if a token order has been completed. +if ( ! empty( $_REQUEST['token_order'] ) ) { + // Check the nonce. + if ( empty( $_REQUEST['pmpro_orders_nonce'] ) || ! check_admin_referer( 'check_token_order', 'pmpro_orders_nonce' ) ) { + // Nonce failed. + $pmpro_msg = __( 'Nonce failed for checking token order.', 'paid-memberships-pro' ); + $pmpro_msgt = 'error'; + } else { + // Nonce passed. Process the token order. + $completed = pmpro_check_token_order_for_completion( (int) $_REQUEST['token_order'] ); + if ( is_string( $completed ) ) { + // An error string was returned. + $pmpro_msg = __( 'Error checking token order: ', 'paid-memberships-pro' ) . $completed; + $pmpro_msgt = 'error'; + } else { + $pmpro_msg = __( 'The token order has been completed.', 'paid-memberships-pro' ); + $pmpro_msgt = 'success'; + } + } +} + $thisyear = date( 'Y', $now ); // this array stores fields that should be read only diff --git a/classes/class-pmpro-orders-list-table.php b/classes/class-pmpro-orders-list-table.php index 6804553f4..1eb21d790 100644 --- a/classes/class-pmpro-orders-list-table.php +++ b/classes/class-pmpro-orders-list-table.php @@ -1022,6 +1022,30 @@ public function column_order_code( $item ) { ); } + // If the order is in token status and the gateway allows verifying completion, show the action. + // Checking for the status first to avoid loading the gateway object unnecessarily. + if ( 'token' === $item->status && pmpro_can_check_token_order_for_completion( $item->id ) ) { + $actions['check_token_order'] = sprintf( + '%3$s', + esc_attr__( 'Recheck Payment', 'paid-memberships-pro' ), + esc_url( + wp_nonce_url( + add_query_arg( + [ + 'page' => 'pmpro-orders', + 'action' => 'check_token_order', + 'token_order' => $item->id, + ], + admin_url( 'admin.php' ) + ), + 'check_token_order', + 'pmpro_orders_nonce' + ) + ), + esc_html__( 'Recheck', 'paid-memberships-pro' ) + ); + } + /** * Filter the extra actions for this user on this order. * diff --git a/classes/gateways/class.pmprogateway.php b/classes/gateways/class.pmprogateway.php index 546a98c7e..4605c0eca 100644 --- a/classes/gateways/class.pmprogateway.php +++ b/classes/gateways/class.pmprogateway.php @@ -206,4 +206,14 @@ public function update_subscription_info( $subscription ) { // Update the subscription. $subscription->set( $update_array ); } + + /** + * Check whether the payment for a token order has been completed. If so, process the order. + * + * @param MemberOrder $order The order object to check. + * @return true|string True if the payment has been completed and the order processed. A string if an error occurred. + */ + function check_token_order( $order ) { + return __( 'Checking token orders is not supported for this gateway.', 'paid-memberships-pro' ); + } } diff --git a/classes/gateways/class.pmprogateway_stripe.php b/classes/gateways/class.pmprogateway_stripe.php index fcccbd5e2..4e5339578 100644 --- a/classes/gateways/class.pmprogateway_stripe.php +++ b/classes/gateways/class.pmprogateway_stripe.php @@ -78,7 +78,8 @@ function __construct( $gateway = null ) { public static function supports( $feature ) { $supports = array( 'subscription_sync' => true, - 'payment_method_updates' => 'individual' + 'payment_method_updates' => 'individual', + 'check_token_orders' => true, ); if ( empty( $supports[$feature] ) ) { @@ -3886,6 +3887,94 @@ public static function process_refund( $success, $order ) { return $success; } + /** + * Check whether the payment for a token order has been completed. If so, process the order. + * + * @param MemberOrder $order The order object to check. + * @return true|string True if the payment has been completed and the order processed. A string if an error occurred. + */ + function check_token_order( $order ) { + // If this is not a token order, bail. + if ( 'token' !== $order->status ) { + return __( 'This is not a token order.', 'paid-memberships-pro' ); + } + + // Get the checkout session ID for this order. + $checkout_session_id = get_pmpro_membership_order_meta( $order->id, 'stripe_checkout_session_id', true ); + if ( empty( $checkout_session_id ) ) { + return __( 'No checkout session ID found.', 'paid-memberships-pro' ); + } + + // Get the checkout session from Stripe. + try { + $checkout_session = Stripe_Checkout_Session::retrieve( $checkout_session_id ); + } catch ( Stripe\Error\Base $e ) { + return __( 'Could not retrieve checkout session: ', 'paid-memberships-pro' ) . $e->getMessage(); + } catch ( \Throwable $e ) { + return __( 'Could not retrieve checkout session: ', 'paid-memberships-pro' ) . $e->getMessage(); + } catch ( \Exception $e ) { + return __( 'Could not retrieve checkout session: ', 'paid-memberships-pro' ) . $e->getMessage(); + } + + // If the checkout session is pending, this is a delayed notification payment method. We don't handle this yet. Bail. + if ( 'pending' === $checkout_session->payment_status ) { + return __( 'Payment is still pending.', 'paid-memberships-pro' ); + } + + // If the checkout session is not paid, bail. + if ( 'paid' !== $checkout_session->payment_status ) { + return __( 'Checkout session has not yet been completed.', 'paid-memberships-pro' ); + } + + // The order has been paid. Get the payment and subscription IDs. + if ( $checkout_session->mode === 'payment' ) { + // User purchased a one-time payment level. Assign the charge ID to the order. + try { + $payment_intent_args = array( + 'id' => $checkout_session->payment_intent, + 'expand' => array( + 'payment_method', + 'latest_charge', + ), + ); + $payment_intent = \Stripe\PaymentIntent::retrieve( $payment_intent_args ); + $order->payment_transaction_id = $payment_intent->latest_charge->id; + } catch ( \Stripe\Error\Base $e ) { + // Could not get payment intent. We just won't set a payment transaction ID. + } + } elseif ( $checkout_session->mode === 'subscription' ) { + // User purchased a subscription. Assign the subscription ID invoice ID to the order. + $order->subscription_transaction_id = $checkout_session->subscription; + try { + $subscription_args = array( + 'id' => $checkout_session->subscription, + 'expand' => array( + 'latest_invoice', + 'default_payment_method', + ), + ); + $subscription = \Stripe\Subscription::retrieve( $subscription_args ); + if ( ! empty( $subscription->latest_invoice->id ) ) { + $order->payment_transaction_id = $subscription->latest_invoice->id; + } + } catch ( \Stripe\Error\Base $e ) { + // Could not get invoices. We just won't set a payment transaction ID. + } + } + + // Update the amounts paid. + $currency = pmpro_get_currency(); + $currency_unit_multiplier = pow( 10, intval( $currency['decimals'] ) ); + + $order->total = (float) $checkout_session->amount_total / $currency_unit_multiplier; + $order->subtotal = (float) $checkout_session->amount_subtotal / $currency_unit_multiplier; + $order->tax = (float) $checkout_session->total_details->amount_tax / $currency_unit_multiplier; + + // Complete the checkout. + pmpro_pull_checkout_data_from_order( $order ); + return pmpro_complete_async_checkout( $order ); + } + /** * Get the description to send to Stripe for an order. * diff --git a/includes/functions.php b/includes/functions.php index 1da6088ee..22fc44e79 100644 --- a/includes/functions.php +++ b/includes/functions.php @@ -4964,4 +4964,66 @@ function pmpro_method_defined_in_class( $object, $method_name ) { // Check if the method's declaring class is the same as the object's class. return $method->getDeclaringClass()->getName() === $reflection_class->getName(); +} + +/** + * Check if we can check a token order for completion. + * + * @since TBD + * + * @param int $order_id The ID of the order to check. + * @return bool True if we can check the order for completion, false otherwise. + */ +function pmpro_can_check_token_order_for_completion( $order_id ) { + // Get the order object. + $order = new MemberOrder( $order_id ); + + // If the order does not exist, we can't check it. + if ( empty( $order->id ) ) { + return false; + } + + // If the order is not a token order, we can't check it. + if ( 'token' !== $order->status ) { + return false; + } + + // If the order does not have a gateway set, we can't check it. + if ( empty( $order->Gateway ) ) { + return false; + } + + // Check if the order supports checking for completion. + return $order->Gateway->supports( 'check_token_orders' ); +} + +/** + * Check a token order for completion. + * + * @since TBD + * + * @param int $order_id The ID of the order to check. + * @return true|string True if the payment has been completed and the order processed. A string if an error occurred. + */ +function pmpro_check_token_order_for_completion( $order_id ) { + // Get the order object. + $order = new MemberOrder( $order_id ); + + // If the order does not exist, we can't check it. + if ( empty( $order->id ) ) { + return __( 'Order not found.', 'paid-memberships-pro' ); + } + + // If the order is not a token order, we can't check it. + if ( 'token' !== $order->status ) { + return __( 'Order is not a token order.', 'paid-memberships-pro' ); + } + + // If the order does not have a gateway set, we can't check it. + if ( empty( $order->Gateway ) ) { + return __( 'Order gateway not found.', 'paid-memberships-pro' ); + } + + // Check the order for completion. + return $order->Gateway->check_token_order( $order ); } \ No newline at end of file