test_mode = (bool) (int) wu_get_setting('paypal_sandbox_mode', true); /* * If we are in test mode * use test mode keys. */ if ($this->test_mode) { $this->api_endpoint = 'https://api-3t.sandbox.paypal.com/nvp'; $this->checkout_url = 'https://www.sandbox.paypal.com/webscr&cmd=_express-checkout&token='; $this->username = wu_get_setting('paypal_test_username', ''); $this->password = wu_get_setting('paypal_test_password', ''); $this->signature = wu_get_setting('paypal_test_signature', ''); return; } /* * Otherwise, set * PayPal live keys. */ $this->api_endpoint = 'https://api-3t.paypal.com/nvp'; $this->checkout_url = 'https://www.paypal.com/webscr&cmd=_express-checkout&token='; $this->username = wu_get_setting('paypal_live_username', ''); $this->password = wu_get_setting('paypal_live_password', ''); $this->signature = wu_get_setting('paypal_live_signature', ''); } /** * Adds the PayPal Gateway settings to the settings screen. * * @since 2.0.0 * @return void */ public function settings(): void { wu_register_settings_field( 'payment-gateways', 'paypal_header', [ 'title' => __('PayPal', 'wp-multisite-waas'), 'desc' => __('Use the settings section below to configure PayPal Express as a payment method.', 'wp-multisite-waas'), 'type' => 'header', 'show_as_submenu' => true, 'require' => [ 'active_gateways' => 'paypal', ], ] ); wu_register_settings_field( 'payment-gateways', 'paypal_sandbox_mode', [ 'title' => __('PayPal Sandbox Mode', 'wp-multisite-waas'), 'desc' => __('Toggle this to put PayPal on sandbox mode. This is useful for testing and making sure PayPal is correctly setup to handle your payments.', 'wp-multisite-waas'), 'type' => 'toggle', 'default' => 0, 'html_attr' => [ 'v-model' => 'paypal_sandbox_mode', ], 'require' => [ 'active_gateways' => 'paypal', ], ] ); wu_register_settings_field( 'payment-gateways', 'paypal_test_username', [ 'title' => __('PayPal Test Username', 'wp-multisite-waas'), 'desc' => '', 'tooltip' => __('Make sure you are placing the TEST username, not the live one.', 'wp-multisite-waas'), 'placeholder' => __('e.g. username_api1.username.co', 'wp-multisite-waas'), 'type' => 'text', 'default' => '', 'capability' => 'manage_api_keys', 'require' => [ 'active_gateways' => 'paypal', 'paypal_sandbox_mode' => 1, ], ] ); wu_register_settings_field( 'payment-gateways', 'paypal_test_password', [ 'title' => __('PayPal Test Password', 'wp-multisite-waas'), 'desc' => '', 'tooltip' => __('Make sure you are placing the TEST password, not the live one.', 'wp-multisite-waas'), 'placeholder' => __('e.g. IUOSABK987HJG88N', 'wp-multisite-waas'), 'type' => 'text', 'default' => '', 'capability' => 'manage_api_keys', 'require' => [ 'active_gateways' => 'paypal', 'paypal_sandbox_mode' => 1, ], ] ); wu_register_settings_field( 'payment-gateways', 'paypal_test_signature', [ 'title' => __('PayPal Test Signature', 'wp-multisite-waas'), 'desc' => '', 'tooltip' => __('Make sure you are placing the TEST signature, not the live one.', 'wp-multisite-waas'), 'placeholder' => __('e.g. AFcpSSRl31ADOdqnHNv4KZdVHEQzdMEEsWxV21C7fd0v3bYYYRCwYxqo', 'wp-multisite-waas'), 'type' => 'text', 'default' => '', 'capability' => 'manage_api_keys', 'require' => [ 'active_gateways' => 'paypal', 'paypal_sandbox_mode' => 1, ], ] ); wu_register_settings_field( 'payment-gateways', 'paypal_live_username', [ 'title' => __('PayPal Live Username', 'wp-multisite-waas'), 'desc' => '', 'tooltip' => __('Make sure you are placing the LIVE username, not the test one.', 'wp-multisite-waas'), 'placeholder' => __('e.g. username_api1.username.co', 'wp-multisite-waas'), 'type' => 'text', 'default' => '', 'capability' => 'manage_api_keys', 'require' => [ 'active_gateways' => 'paypal', 'paypal_sandbox_mode' => 0, ], ] ); wu_register_settings_field( 'payment-gateways', 'paypal_live_password', [ 'title' => __('PayPal Live Password', 'wp-multisite-waas'), 'desc' => '', 'tooltip' => __('Make sure you are placing the LIVE password, not the test one.', 'wp-multisite-waas'), 'placeholder' => __('e.g. IUOSABK987HJG88N', 'wp-multisite-waas'), 'type' => 'text', 'default' => '', 'capability' => 'manage_api_keys', 'require' => [ 'active_gateways' => 'paypal', 'paypal_sandbox_mode' => 0, ], ] ); wu_register_settings_field( 'payment-gateways', 'paypal_live_signature', [ 'title' => __('PayPal Live Signature', 'wp-multisite-waas'), 'desc' => '', 'tooltip' => __('Make sure you are placing the LIVE signature, not the test one.', 'wp-multisite-waas'), 'placeholder' => __('e.g. AFcpSSRl31ADOdqnHNv4KZdVHEQzdMEEsWxV21C7fd0v3bYYYRCwYxqo', 'wp-multisite-waas'), 'type' => 'text', 'default' => '', 'capability' => 'manage_api_keys', 'require' => [ 'active_gateways' => 'paypal', 'paypal_sandbox_mode' => 0, ], ] ); } /** * Reflects membership changes on the gateway. * * By default, this method will process tha cancellation of current gateway subscription * * @since 2.1.3 * * @param \WP_Ultimo\Models\Membership $membership The membership object. * @param \WP_Ultimo\Models\Customer $customer The customer object. * @return bool|\WP_Error true if it's all done or error object if something went wrong. */ public function process_membership_update(&$membership, $customer) { $gateway_subscription_id = $membership->get_gateway_subscription_id(); if (empty($gateway_subscription_id)) { return new \WP_Error('wu_paypal_no_subscription_id', __('Error: No gateway subscription ID found for this membership.', 'wp-multisite-waas')); } $original = $membership->_get_original(); $has_duration_change = $membership->get_duration() !== absint(wu_get_isset($original, 'duration')) || $membership->get_duration_unit() !== wu_get_isset($original, 'duration_unit'); if ($has_duration_change) { return new \WP_Error('wu_paypal_no_duration_change', __('Error: PayPal does not support changing the duration of a subscription.', 'wp-multisite-waas')); } /** * Generate a temporary wu payment so we can get the correct line items and amounts. * It's important to note that we should only get recurring payments so we can correctly update the subscription. */ $temp_payment = wu_membership_create_new_payment($membership, false, true, false); $description = wu_get_setting('company_name', get_network_option(null, 'site_name')) . ': ' . implode(', ', array_map(fn($item) => 'x' . $item->get_quantity() . ' ' . $item->get_title(), $temp_payment->get_line_items())); $args = [ 'USER' => $this->username, 'PWD' => $this->password, 'SIGNATURE' => $this->signature, 'VERSION' => '124', 'METHOD' => 'UpdateRecurringPaymentsProfile', 'PROFILEID' => $gateway_subscription_id, 'NOTE' => __('Membership update', 'wp-multisite-waas'), 'DESC' => $description, 'AMT' => $temp_payment->get_total() - $temp_payment->get_tax_total(), 'TAXAMT' => $temp_payment->get_tax_total(), ]; $request = wp_remote_post( $this->api_endpoint, [ 'timeout' => 45, 'httpversion' => '1.1', 'body' => $args, ] ); /* * Check for wp-error on the request call * * This will catch timeouts and similar errors. * Maybe PayPal is out? We can't be sure. */ if (is_wp_error($request)) { return $request; } $body = wp_remote_retrieve_body($request); if (is_string($body)) { wp_parse_str($body, $body); } if ('failure' === strtolower((string) $body['ACK'])) { return new \WP_Error($body['L_ERRORCODE0'], __('PayPal Error:', 'wp-multisite-waas') . ' ' . $body['L_LONGMESSAGE0']); } return true; } /** * Process a checkout. * * It takes the data concerning * a new checkout and process it. * * @since 2.0.0 * * @param \WP_Ultimo\Models\Payment $payment The payment associated with the checkout. * @param \WP_Ultimo\Models\Membership $membership The membership. * @param \WP_Ultimo\Models\Customer $customer The customer checking out. * @param \WP_Ultimo\Checkout\Cart $cart The cart object. * @param string $type The checkout type. Can be 'new', 'retry', 'upgrade', 'downgrade', 'addon'. * @return void */ public function process_checkout($payment, $membership, $customer, $cart, $type): void { /* * To make our lives easier, let's * set a couple of variables based on the order. */ $initial_amount = $payment->get_total(); $should_auto_renew = $cart->should_auto_renew(); $is_recurring = $cart->has_recurring(); $is_trial_setup = $membership->is_trialing() && empty($payment->get_total()); /* * Get the amount depending on * the auto-renew status. */ $amount = $should_auto_renew ? $payment->get_total() : $cart->get_recurring_total(); /* * Sets the cancel URL. */ $cancel_url = $this->get_cancel_url(); /* * Calculates the return URL * for the intermediary return URL. */ $return_url = $this->get_confirm_url(); /* * Setup variables * * PayPal takes a ***load of variables. * Some of them need to be prepped beforehand. */ $currency = strtoupper((string) $payment->get_currency()); $description = $this->get_subscription_description($cart); $notify_url = $this->get_webhook_listener_url(); /* * This is a special key paypal lets us set. * It contains the payment_id, membership_id and customer_id * in the following format: payment_id|membership_id|customer_id */ $custom_key = sprintf('%s|%s|%s', $payment->get_id(), $membership->get_id(), $customer->get_id()); /* * Now we can build the PayPal * request object, and append the products * later. */ $args = [ 'USER' => $this->username, 'PWD' => $this->password, 'SIGNATURE' => $this->signature, 'VERSION' => '124', 'METHOD' => 'SetExpressCheckout', 'PAYMENTREQUEST_0_SHIPPINGAMT' => 0, 'PAYMENTREQUEST_0_TAXAMT' => 0, 'PAYMENTREQUEST_0_PAYMENTACTION' => 'Sale', 'PAYMENTREQUEST_0_AMT' => 0, 'PAYMENTREQUEST_0_ITEMAMT' => 0, 'PAYMENTREQUEST_0_CURRENCYCODE' => $currency, 'PAYMENTREQUEST_0_DESC' => $description, 'PAYMENTREQUEST_0_CUSTOM' => $custom_key, 'PAYMENTREQUEST_0_NOTIFYURL' => $notify_url, 'EMAIL' => $customer->get_email_address(), 'CANCELURL' => $cancel_url, 'NOSHIPPING' => 1, 'REQCONFIRMSHIPPING' => 0, 'ALLOWNOTE' => 0, 'ADDROVERRIDE' => 0, 'PAGESTYLE' => '', 'SOLUTIONTYPE' => 'Sole', 'LANDINGPAGE' => 'Billing', 'RETURNURL' => $return_url, 'LOGOIMG' => wu_get_network_logo(), ]; $notes = []; if ($is_trial_setup) { $desc = $membership->get_recurring_description(); $date = wp_date(get_option('date_format'), strtotime($membership->get_date_trial_end(), wu_get_current_time('timestamp', true))); $notes[] = sprintf(__('Your trial period will end on %1$s.', 'wp-multisite-waas'), $date); } if ($is_recurring && $should_auto_renew) { $recurring_total = $cart->get_recurring_total(); $cart_total = $cart->get_total(); $args['L_BILLINGAGREEMENTDESCRIPTION0'] = $description; $args['L_BILLINGTYPE0'] = 'RecurringPayments'; $args['MAXAMT'] = $recurring_total > $cart_total ? $recurring_total : $cart_total; $desc = $membership->get_recurring_description(); $recurring_total_format = wu_format_currency($recurring_total, $cart->get_currency()); if ($recurring_total !== $cart_total) { if ('downgrade' === $type) { if ($is_trial_setup) { $notes[] = sprintf(__('Your updated membership will start on $1$s, from that date you will be billed %2$s every month.', 'wp-multisite-waas'), $date, $recurring_total_format); } else { $date_renew = wp_date(get_option('date_format'), strtotime($membership->get_date_expiration(), wu_get_current_time('timestamp', true))); $notes[] = sprintf(__('Your updated membership will start on %1$s, from that date you will be billed %2$s %3$s.', 'wp-multisite-waas'), $date_renew, $recurring_total_format, $desc); } } elseif ($is_trial_setup) { $notes[] = sprintf(__('After the first payment you will be billed %1$s %2$s.', 'wp-multisite-waas'), $recurring_total_format, $desc); } else { $notes[] = sprintf(__('After this payment you will be billed %1$s %2$s.', 'wp-multisite-waas'), $recurring_total_format, $desc); } } elseif ($is_trial_setup) { $notes[] = sprintf(__('From that date, you will be billed %1$s %2$s.', 'wp-multisite-waas'), $recurring_total_format, $desc); } else { $notes[] = sprintf(__('After this payment you will be billed %1$s.', 'wp-multisite-waas'), $desc); } } $args['NOTETOBUYER'] = implode(' ', $notes); /* * After that, we need to add the additional * products. */ $product_index = 0; /* * Loop products and add them to the paypal */ foreach ($cart->get_line_items() as $line_item) { $total = $line_item->get_total(); $sub_total = $line_item->get_subtotal(); $tax_amount = $line_item->get_tax_total(); $product_args = [ "L_PAYMENTREQUEST_0_NAME{$product_index}" => $line_item->get_title(), "L_PAYMENTREQUEST_0_DESC{$product_index}" => $line_item->get_description(), "L_PAYMENTREQUEST_0_AMT{$product_index}" => $sub_total, "L_PAYMENTREQUEST_0_QTY{$product_index}" => $line_item->get_quantity(), "L_PAYMENTREQUEST_0_TAXAMT{$product_index}" => $tax_amount, ]; $args['PAYMENTREQUEST_0_ITEMAMT'] += $sub_total; $args['PAYMENTREQUEST_0_TAXAMT'] += $tax_amount; $args['PAYMENTREQUEST_0_AMT'] = $args['PAYMENTREQUEST_0_AMT'] + $sub_total + $tax_amount; $args = array_merge($args, $product_args); ++$product_index; } $discounts_total = $cart->get_total_discounts(); if ( ! empty($discounts_total)) { __('Account credit and other discounts', 'wp-multisite-waas'); $args = array_merge( $args, [ "L_PAYMENTREQUEST_0_NAME{$product_index}" => __('Account credit and other discounts', 'wp-multisite-waas'), "L_PAYMENTREQUEST_0_AMT{$product_index}" => $discounts_total, "L_PAYMENTREQUEST_0_QTY{$product_index}" => 1, ] ); $args['PAYMENTREQUEST_0_ITEMAMT'] += $discounts_total; $args['PAYMENTREQUEST_0_AMT'] += $discounts_total; ++$product_index; } $request = wp_remote_post( $this->api_endpoint, [ 'timeout' => 45, 'httpversion' => '1.1', 'body' => $args, ] ); $body = wp_remote_retrieve_body($request); $code = wp_remote_retrieve_response_code($request); $message = wp_remote_retrieve_response_message($request); // Add multiple items: https://stackoverflow.com/questions/31957791/paypal-subscription-for-multiple-product-using-paypal-api /* * Check for wp-error on the request call * * This will catch timeouts and similar errors. * Maybe PayPal is out? We can't be sure. */ if (is_wp_error($request)) { throw new \Exception($request->get_error_message(), $request->get_error_code()); } /* * If we get here, we got a 200. * This means we got a valid response from * PayPal. * * Now we need to check for a valid token to * redirect the customer to the checkout page. */ if (200 === absint($code) && 'OK' === $message) { /* * PayPal gives us a URL-formatted string * Urrrrgh! Let's parse it. */ if (is_string($body)) { wp_parse_str($body, $body); } if ('failure' === strtolower((string) $body['ACK']) || 'failurewithwarning' === strtolower((string) $body['ACK'])) { wp_die($body['L_LONGMESSAGE0'], $body['L_ERRORCODE0']); } else { /* * We do have a valid token. * * Redirect to the PayPal checkout URL. */ wp_redirect($this->checkout_url . $body['TOKEN']); exit; } } /* * If we get here, something went wrong. */ throw new \Exception(__('Something has gone wrong, please try again', 'wp-multisite-waas')); } /** * Process a cancellation. * * It takes the data concerning * a membership cancellation and process it. * * @since 2.0.0 * * @param \WP_Ultimo\Models\Membership $membership The membership. * @param \WP_Ultimo\Models\Customer $customer The customer checking out. * @return void|bool */ public function process_cancellation($membership, $customer): void { $profile_id = $membership->get_gateway_subscription_id(); $args = [ 'USER' => $this->username, 'PWD' => $this->password, 'SIGNATURE' => $this->signature, 'VERSION' => '124', 'METHOD' => 'ManageRecurringPaymentsProfileStatus', 'PROFILEID' => $profile_id, 'ACTION' => 'Cancel', ]; $request = wp_remote_post( $this->api_endpoint, [ 'timeout' => 45, 'httpversion' => '1.1', 'body' => $args, ] ); } /** * Process a checkout. * * It takes the data concerning * a refund and process it. * * @since 2.0.0 * * @throws \Exception When something goes wrong. * * @param float $amount The amount to refund. * @param \WP_Ultimo\Models\Payment $payment The payment associated with the checkout. * @param \WP_Ultimo\Models\Membership $membership The membership. * @param \WP_Ultimo\Models\Customer $customer The customer checking out. * @return void|bool */ public function process_refund($amount, $payment, $membership, $customer) { $gateway_payment_id = $payment->get_gateway_payment_id(); if (empty($gateway_payment_id)) { throw new \Exception(__('Gateway payment ID not found. Cannot process refund automatically.', 'wp-multisite-waas')); } $refund_type = 'Partial'; if ($amount >= $payment->get_total()) { $refund_type = 'Full'; } $amount_formatted = number_format($amount, 2); $args = [ 'USER' => $this->username, 'PWD' => $this->password, 'SIGNATURE' => $this->signature, 'VERSION' => '124', 'METHOD' => 'RefundTransaction', 'REFUND_TYPE' => $refund_type, 'TRANSACTIONID' => $gateway_payment_id, 'INVOICEID' => $payment->get_hash(), ]; if ('Partial' === $refund_type) { $args['AMT'] = $amount_formatted; } $request = wp_remote_post( $this->api_endpoint, [ 'timeout' => 45, 'httpversion' => '1.1', 'body' => $args, ] ); $body = wp_remote_retrieve_body($request); $code = wp_remote_retrieve_response_code($request); $message = wp_remote_retrieve_response_message($request); if (is_wp_error($request)) { throw new \Exception($request->get_error_message()); } if (200 === absint($code) && 'OK' === $message) { /* * PayPal gives us a URL-formatted string * Urrrrgh! Let's parse it. */ if (is_string($body)) { wp_parse_str($body, $body); } if ('failure' === strtolower((string) $body['ACK'])) { throw new \Exception($body['L_LONGMESSAGE0']); } /* * All good. */ return true; } throw new \Exception(__('Something went wrong.', 'wp-multisite-waas')); } /** * Adds additional fields to the checkout form for a particular gateway. * * In this method, you can either return an array of fields (that we will display * using our form display methods) or you can return plain HTML in a string, * which will get outputted to the gateway section of the checkout. * * @since 2.0.0 * @return mixed[]|string */ public function fields() { $message = __('You will be redirected to PayPal to complete the purchase.', 'wp-multisite-waas'); return sprintf('

%s

', $message); } /** * Process confirmation. * * Some gateways require user confirmation at some point. * It's the case for PayPal Express, for example. * This method implements the necessary things. * * After a successful payment, redirect to $this->return_url. * * @access public * @return void */ public function process_confirmation(): void { /* * Tries to retrieve the nonce, this part is necessary due EU SCA Compliancy. */ $nonce = wu_request('wu_ppe_confirm_nonce', 'no-nonce'); /* * If the nonce is present and is valid, * we can be sure we have the data we need to process a confirmation * screen. Here we actually finish the payment * and/or create the subscription. */ if (wp_verify_nonce($nonce, 'wu-ppe-confirm-nonce')) { /* * Retrieve the payment details, base on the token. */ $details = $this->get_checkout_details(wu_request('token')); if (empty($details)) { $error = new \WP_Error(__('PayPal token no longer valid.', 'wp-multisite-waas')); wp_die($error); } /* * Tries to get the payment based on the request */ $payment_id = absint(wu_request('payment-id')); $payment = $payment_id ? wu_get_payment($payment_id) : wu_get_payment_by_hash(wu_request('payment')); /* * The pending payment does not exist... * Bail. */ if (empty($payment)) { $error = new \WP_Error(__('Pending payment does not exist.', 'wp-multisite-waas')); wp_die($error); } /* * Now we need to original cart. * * The original cart gets saved with the original * payment. Otherwise, we bail. */ $original_cart = $payment->get_meta('wu_original_cart'); if (empty($original_cart)) { $error = new \WP_Error('no-cart', __('Original cart does not exist.', 'wp-multisite-waas')); wp_die($error); } /* * Set the variables */ $membership = $payment->get_membership(); $customer = $payment->get_customer(); $should_auto_renew = $original_cart->should_auto_renew(); $is_recurring = $original_cart->has_recurring(); if (empty($membership) || empty($customer)) { $error = new \WP_Error('no-membership', __('Missing membership or customer data.', 'wp-multisite-waas')); wp_die($error); } if ($should_auto_renew && $is_recurring) { /* * We need to create the payment profile. * As this is a recurring payment and the * auto-renew option is active. */ $this->create_recurring_profile($details, $original_cart, $payment, $membership, $customer); } else { /* * Otherwise, process * single payment. */ $this->complete_single_payment($details, $original_cart, $payment, $membership, $customer); } } elseif ( ! empty(wu_request('token'))) { // Prints the form $this->confirmation_form(); } } /** * Process webhooks * * @since 2.0.0 */ public function process_webhooks(): bool { wu_log_add('paypal', 'Receiving PayPal IPN webhook...'); $posted = apply_filters('wu_ipn_post', $_POST); $payment = false; $customer = false; $membership = false; $custom = ! empty($posted['custom']) ? explode('|', (string) $posted['custom']) : []; if (is_array($custom) && ! empty($custom)) { $payment = wu_get_payment(absint($custom[0])); $membership = wu_get_payment(absint($custom[1])); $customer = wu_get_payment(absint($custom[2])); } if ( ! empty($posted['recurring_payment_id'])) { $membership = wu_get_membership_by('gateway_subscription_id', $posted['recurring_payment_id']); } if (empty($membership)) { throw new \Exception(__('Exiting PayPal Express IPN - membership ID not found.', 'wp-multisite-waas')); } wu_log_add('paypal', sprintf('Processing IPN for membership #%d.', $membership->get_id())); /* * Base payment data for update * or insertion. */ $payment_data = [ 'status' => Payment_Status::COMPLETED, 'customer_id' => $membership->get_customer_id(), 'membership_id' => $membership->get_id(), 'gateway' => $this->id, ]; $amount = isset($posted['mc_gross']) ? wu_to_float($posted['mc_gross']) : false; if (false !== $amount) { $payment_data['amount'] = $amount; } if ( ! empty($posted['payment_date'])) { $payment_data['date'] = date('Y-m-d H:i:s', strtotime((string) $posted['payment_date'])); // phpcs:ignore } if ( ! empty($posted['txn_id'])) { $payment_data['gateway_payment_id'] = sanitize_text_field($posted['txn_id']); } /* * Deal with each transaction type * accordingly */ switch ($posted['txn_type']) : /* * New recurring profile, aka paypal subscription created. */ case 'recurring_payment_profile_created': /* * Log */ wu_log_add('paypal', 'Processing PayPal Express recurring_payment_profile_created IPN.'); /* * Get the gateway payment ID. * * We'll use this to try to localize the pending * payment and make sure we have a match. */ if (isset($posted['initial_payment_txn_id'])) { $transaction_id = ('Completed' === $posted['initial_payment_status']) ? $posted['initial_payment_txn_id'] : ''; } else { $transaction_id = $posted['ipn_track_id']; } if (empty($transaction_id)) { throw new \Exception('Breaking out of PayPal Express IPN recurring_payment_profile_created. Transaction ID not given.'); } // setup the payment info in an array for storage $payment_data['date'] = date('Y-m-d H:i:s', strtotime($posted['time_created'])); // phpcs:ignore $payment_data['amount'] = wu_to_float($posted['amount']); $payment_data['gateway_payment_id'] = sanitize_text_field($transaction_id); $payment = wu_get_payment_by('gateway_payment_id', $payment_data['gateway_payment_id']); $should_use_subscription = ! $payment && isset($posted['recurring_payment_id']); $payment = $should_use_subscription ? wu_get_payment_by('gateway_payment_id', sanitize_text_field($posted['recurring_payment_id'])) : $payment; /* * In the case that the payment * already exists, update it. */ if ($payment) { $payment->attributes($payment_data); $payment->save(); } else { /* * Payment does not exist. Create it and renew the membership. */ $temp_membership = clone $membership; $temp_membership->set_amount($payment_data['amount']); $payment = wu_membership_create_new_payment($temp_membership, false, true, false); $payment->attributes($payment_data); $payment->save(); } $is_trial_setup = $membership->is_trialing() && empty($payment->get_total()); $status = $is_trial_setup ? Membership_Status::TRIALING : Membership_Status::ACTIVE; $expiration = date('Y-m-d 23:59:59', strtotime((string) $posted['next_payment_date'])); // phpcs:ignore /* * Tell the gateway to do their stuff. */ $this->trigger_payment_processed($payment, $membership); /** * Renewals the membership */ $membership->add_to_times_billed(1); $membership->renew(true, $status, $expiration); break; case 'recurring_payment': wu_log_add('paypal', 'Processing PayPal Express recurring_payment IPN.'); if ('failed' === strtolower((string) $posted['payment_status'])) { // Recurring payment failed. $membership->add_note(sprintf(__('Transaction ID %s failed in PayPal.', 'wp-multisite-waas'), $posted['txn_id'])); die('Subscription payment failed'); } elseif ('pending' === strtolower((string) $posted['payment_status'])) { // Recurring payment pending (such as echeck). $pending_reason = ! empty($posted['pending_reason']) ? $posted['pending_reason'] : __('unknown', 'wp-multisite-waas'); $membership->add_note(sprintf(__('Transaction ID %1$s is pending in PayPal for reason: %2$s', 'wp-multisite-waas'), $posted['txn_id'], $pending_reason)); die('Subscription payment pending'); } $payment_data['transaction_type'] = 'renewal'; $payment = wu_get_payment_by('gateway_payment_id', $payment_data['gateway_payment_id']); /* * In the case that the payment * already exists, update it. */ if ($payment) { $payment->attributes($payment_data); $payment->save(); } else { /* * Payment does not exist. Create it and renew the membership. */ $payment = wu_create_payment($payment_data); $membership->add_to_times_billed(1); } $is_trial_setup = $membership->is_trialing() && empty($payment->get_total()); if ( ! $is_trial_setup) { $membership->renew(true); } else { $membership->save(); } break; case 'recurring_payment_profile_cancel': wu_log_add('paypal', 'Processing PayPal Express recurring_payment_profile_cancel IPN.'); if (isset($posted['initial_payment_status']) && 'Failed' === $posted['initial_payment_status']) { // Initial payment failed, so set the user back to pending. $membership->set_status('pending'); $membership->add_note(__('Initial payment failed in PayPal Express.', 'wp-multisite-waas')); $this->error_message = __('Initial payment failed.', 'wp-multisite-waas'); } else { // If this is a completed payment plan, we can skip any cancellation actions. This is handled in renewals. if ($membership->has_payment_plan() && $membership->at_maximum_renewals()) { wu_log_add('paypal', sprintf('Membership #%d has completed its payment plan - not cancelling.', $membership->get_id())); die('membership payment plan completed'); } // user is marked as cancelled but retains access until end of term $membership->cancel(); $membership->add_note(__('Membership cancelled via PayPal Express IPN.', 'wp-multisite-waas')); } break; case 'recurring_payment_failed': case 'recurring_payment_suspended_due_to_max_failed_payment': // Same case as before wu_log_add('paypal', 'Processing PayPal Express recurring_payment_failed or recurring_payment_suspended_due_to_max_failed_payment IPN.'); if ( ! in_array($membership->get_status(), ['cancelled', 'expired'], true)) { $membership->set_status('expired'); } if ( ! empty($posted['txn_id'])) { $this->webhook_event_id = sanitize_text_field($posted['txn_id']); } elseif ( ! empty($posted['ipn_track_id'])) { $this->webhook_event_id = sanitize_text_field($posted['ipn_track_id']); } break; case 'web_accept': wu_log_add('paypal', sprintf('Processing PayPal Express web_accept IPN. Payment status: %s', $posted['payment_status'])); switch (strtolower((string) $posted['payment_status'])) : case 'completed': if (empty($payment_data['gateway_payment_id'])) { throw new \Exception('Breaking out of PayPal Express IPN recurring_payment_profile_created. Transaction ID not given.'); } $payment = wu_get_payment_by('gateway_payment_id', $payment_data['gateway_payment_id']); /* * In the case that the payment * already exists, update it. */ if ($payment) { $payment->attributes($payment_data); $payment->save(); /* * Payment does not exist. Create it and renew the membership. */ } else { wu_create_payment($payment_data); $membership->add_to_times_billed(1); } // Membership was already activated. break; case 'denied': // all the same case case 'expired': // all the same case case 'failed': // all the same case case 'voided': // all the same case wu_log_add('paypal', sprintf('Membership #%d is not active - not cancelling account.', $membership->get_id())); /* * Cancel active memberships. */ if ($membership->is_active()) { $membership->cancel(); } else { wu_log_add('paypal', sprintf('Membership #%d is not active - not cancelling account.', $membership->get_id())); } break; endswitch; break; endswitch; return true; } /** * Create a recurring profile. * * @since 2.0.0 * * @param array $details The PayPal transaction details. * @param \WP_Ultimo\Checkout\Cart $cart The cart object. * @param \WP_Ultimo\Models\Payment $payment The payment associated with the checkout. * @param \WP_Ultimo\Models\Membership $membership The membership. * @param \WP_Ultimo\Models\Customer $customer The customer checking out. * @return void */ protected function create_recurring_profile($details, $cart, $payment, $membership, $customer) { $args = [ 'USER' => $this->username, 'PWD' => $this->password, 'SIGNATURE' => $this->signature, 'VERSION' => '124', 'TOKEN' => wu_request('token'), 'METHOD' => 'CreateRecurringPaymentsProfile', 'PROFILESTARTDATE' => date('Y-m-d\TH:i:s', strtotime('+' . $cart->get_duration() . ' ' . $cart->get_duration_unit(), wu_get_current_time('timestamp', true))), // phpcs:ignore 'BILLINGPERIOD' => ucwords($cart->get_duration_unit()), 'BILLINGFREQUENCY' => $cart->get_duration(), 'AMT' => $cart->get_recurring_total(), 'INITAMT' => $payment->get_total(), 'CURRENCYCODE' => strtoupper($cart->get_currency()), 'FAILEDINITAMTACTION' => 'CancelOnFailure', 'L_BILLINGTYPE0' => 'RecurringPayments', 'DESC' => $this->get_subscription_description($cart), 'BUTTONSOURCE' => 'WP_Ultimo', ]; if ($args['INITAMT'] < 0) { unset($args['INITAMT']); } $is_trial_setup = $membership->is_trialing() && empty($payment->get_total()); if ($is_trial_setup) { $trial_end = strtotime($membership->get_date_trial_end(), wu_get_current_time('timestamp', true)); $args['PROFILESTARTDATE'] = date('Y-m-d\TH:i:s', $trial_end); // phpcs:ignore $args['TRIALBILLINGPERIOD'] = 'Day'; $args['TRIALBILLINGFREQUENCY'] = floor(($trial_end - time()) / 86400); $args['TRIALAMT'] = $membership->get_initial_amount(); $args['TRIALTOTALBILLINGCYCLES'] = 1; } if ( ! $membership->is_forever_recurring()) { $args['TOTALBILLINGCYCLES'] = $membership->get_billing_cycles() - $membership->get_times_billed(); } $request = wp_remote_post( $this->api_endpoint, [ 'timeout' => 45, 'httpversion' => '1.1', 'body' => $args, ] ); $body = wp_remote_retrieve_body($request); $code = wp_remote_retrieve_response_code($request); $message = wp_remote_retrieve_response_message($request); if (is_wp_error($request)) { wp_die($request); } if (200 === absint($code) && 'OK' === $message) { /* * PayPal gives us a URL-formatted string * Urrrrgh! Let's parse it. */ if (is_string($body)) { wp_parse_str($body, $body); } if ('failure' === strtolower((string) $body['ACK'])) { $error = new \WP_Error($body['L_ERRORCODE0'], $body['L_LONGMESSAGE0']); wp_die($error); } else { /* * We were successful, let's update * the payment. * * First, set the value * and the transaction ID. */ $transaction_id = $body['TRANSACTIONID'] ?? ''; $profile_status = $body['PROFILESTATUS'] ?? ''; // If TRANSACTIONID is not passed we need to wait for webhook $payment_status = Payment_Status::PENDING; if ( ! empty($transaction_id) || 'ActiveProfile' === $profile_status || $is_trial_setup) { $payment_status = Payment_Status::COMPLETED; } /** * If we don't have a transaction ID, let's use the profile ID. */ $transaction_id = empty($transaction_id) && ! empty($body['PROFILEID']) ? $body['PROFILEID'] : $transaction_id; $payment_data = [ 'gateway_payment_id' => $transaction_id, 'status' => $payment_status, ]; /* * Update local payment. * * This will add the transaction id, * if we have it already, and mark it as * complete. * * If we have a pending membership, * and a pending site, for example, * those will be marked as active. */ $payment->attributes($payment_data); $payment->save(); $membership = $payment->get_membership(); $membership->set_gateway_subscription_id($body['PROFILEID']); $membership->set_gateway_customer_id($details['PAYERID']); $membership->set_gateway('paypal'); if (Payment_Status::COMPLETED === $payment_status) { $membership->add_to_times_billed(1); /* * Lets deal with upgrades, downgrades and addons * * Here, we just need to make sure we process * a membership swap. */ if ($cart->get_cart_type() === 'upgrade' || $cart->get_cart_type() === 'addon') { $membership->swap($cart); $membership->renew(true); } elseif ($cart->get_cart_type() === 'downgrade') { $membership->set_auto_renew(true); $membership->schedule_swap($cart); $membership->save(); } elseif ( ! $is_trial_setup) { $membership->renew(true); } else { $membership->save(); } } else { $membership->save(); } $this->payment = $payment; $redirect_url = $this->get_return_url(); wp_redirect($redirect_url); exit; } } else { wp_die( __('Something has gone wrong, please try again', 'wp-multisite-waas'), __('Error', 'wp-multisite-waas'), [ 'back_link' => true, 'response' => '401', ] ); } } /** * Get the subscription description. * * @since 2.0.0 * * @param \WP_Ultimo\Checkout\Cart $cart The cart object. * @return string */ protected function get_subscription_description($cart) { $descriptor = $cart->get_cart_descriptor(); $desc = html_entity_decode(substr($descriptor, 0, 127), ENT_COMPAT, 'UTF-8'); return $desc; } /** * Create a single payment on PayPal. * * @since 2.0.0 * * @param array $details The PayPal transaction details. * @param \WP_Ultimo\Checkout\Cart $cart The cart object. * @param \WP_Ultimo\Models\Payment $payment The payment associated with the checkout. * @param \WP_Ultimo\Models\Membership $membership The membership. * @param \WP_Ultimo\Models\Customer $customer The customer checking out. * @return void */ protected function complete_single_payment($details, $cart, $payment, $membership, $customer) { // One time payment $args = [ 'USER' => $this->username, 'PWD' => $this->password, 'SIGNATURE' => $this->signature, 'VERSION' => '124', 'METHOD' => 'DoExpressCheckoutPayment', 'PAYMENTREQUEST_0_PAYMENTACTION' => 'Sale', 'TOKEN' => wu_request('token'), 'PAYERID' => wu_request('payer_id'), 'PAYMENTREQUEST_0_AMT' => $details['AMT'], 'PAYMENTREQUEST_0_ITEMAMT' => $details['AMT'], 'PAYMENTREQUEST_0_SHIPPINGAMT' => 0, 'PAYMENTREQUEST_0_TAXAMT' => 0, 'PAYMENTREQUEST_0_CURRENCYCODE' => $details['CURRENCYCODE'], 'BUTTONSOURCE' => 'WP_Ultimo', ]; $request = wp_remote_post( $this->api_endpoint, [ 'timeout' => 45, 'httpversion' => '1.1', 'body' => $args, ] ); /* * Retrieve the results of * the API call to PayPal */ $body = wp_remote_retrieve_body($request); $code = wp_remote_retrieve_response_code($request); $message = wp_remote_retrieve_response_message($request); if (is_wp_error($request)) { wp_die($request); } if (200 === absint($code) && 'OK' === $message) { if (is_string($body)) { wp_parse_str($body, $body); } if ('failure' === strtolower((string) $body['ACK'])) { $error = new \WP_Error($body['L_ERRORCODE0'], $body['L_LONGMESSAGE0']); wp_die($error); } else { /* * We were successful, let's update * the payment. * * First, set the value * and the transaction ID. */ $transaction_id = $body['PAYMENTINFO_0_TRANSACTIONID']; $payment_data = [ 'gateway_payment_id' => $transaction_id, 'status' => Payment_Status::COMPLETED, ]; /* * Update local payment. * * This will add the transaction id, * if we have it already, and mark it as * complete. * * If we have a pending membership, * and a pending site, for example, * those will be marked as active. */ $payment->attributes($payment_data); $payment->save(); $membership = $payment->get_membership(); if ($cart->get_total() > 0) { $membership->add_to_times_billed(1); } $is_trial_setup = $membership->is_trialing() && empty($payment->get_total()); /* * Lets deal with upgrades, downgrades and addons * * Here, we just need to make sure we process * a membership swap. */ if ($cart->get_cart_type() === 'upgrade' || $cart->get_cart_type() === 'addon') { $membership->swap($cart); $membership->renew(false); } elseif ($cart->get_cart_type() === 'downgrade') { $membership->schedule_swap($cart); $membership->save(); } elseif ( ! $is_trial_setup) { $membership->renew(false); } else { $membership->save(); } $this->payment = $payment; $redirect_url = $this->get_return_url(); wp_redirect($redirect_url); exit; } } else { wp_die( __('Something has gone wrong, please try again', 'wp-multisite-waas'), __('Error', 'wp-multisite-waas'), [ 'back_link' => true, 'response' => '401', ] ); } } /** * Display the confirmation form. * * @since 2.1 * @return string */ public function confirmation_form() { $token = sanitize_text_field(wu_request('token')); $checkout_details = $this->get_checkout_details($token); if ( ! is_array($checkout_details)) { $error = is_wp_error($checkout_details) ? $checkout_details->get_error_message() : __('Invalid response code from PayPal', 'wp-multisite-waas'); // translators: %s is the paypal error message. return '

' . sprintf(__('An unexpected PayPal error occurred. Error message: %s.', 'wp-multisite-waas'), $error) . '

'; } /* * Compiles the necessary elements. */ $customer = $checkout_details['pending_payment']->get_customer(); // current customer wu_get_template( 'checkout/paypal/confirm', [ 'checkout_details' => $checkout_details, 'customer' => $customer, 'payment' => $checkout_details['pending_payment'], 'membership' => $checkout_details['pending_payment']->get_membership(), ] ); } /** * Get checkout details. * * @param string $token PayPal token. * @return mixed[]|bool|string|\WP_Error */ public function get_checkout_details($token = '') { $args = [ 'TOKEN' => $token, 'USER' => $this->username, 'PWD' => $this->password, 'SIGNATURE' => $this->signature, 'VERSION' => '124', 'METHOD' => 'GetExpressCheckoutDetails', ]; $request = wp_remote_post( $this->api_endpoint, [ 'timeout' => 45, 'httpversion' => '1.1', 'body' => $args, ] ); $body = wp_remote_retrieve_body($request); $code = wp_remote_retrieve_response_code($request); $message = wp_remote_retrieve_response_message($request); if (is_wp_error($request)) { return $request; } elseif (200 === absint($code) && 'OK' === $message) { if (is_string($body)) { wp_parse_str($body, $body); } $payment_id = absint(wu_request('payment-id')); $pending_payment = $payment_id ? wu_get_payment($payment_id) : wu_get_payment_by_hash(wu_request('payment')); if ( ! empty($pending_payment)) { $pending_amount = $pending_payment->get_total(); } $body['pending_payment'] = $pending_payment; $custom = explode('|', (string) $body['PAYMENTREQUEST_0_CUSTOM']); return $body; } return false; } /** * Returns the external link to view the payment on the payment gateway. * * Return an empty string to hide the link element. * * @since 2.0.0 * * @param string $gateway_payment_id The gateway payment id. * @return string. */ public function get_payment_url_on_gateway($gateway_payment_id): string { return ''; } /** * Returns the external link to view the membership on the membership gateway. * * Return an empty string to hide the link element. * * @since 2.0.0 * * @param string $gateway_subscription_id The gateway subscription id. * @return string. */ public function get_subscription_url_on_gateway($gateway_subscription_id): string { $sandbox_prefix = $this->test_mode ? 'sandbox.' : ''; $base_url = 'https://www.%spaypal.com/us/cgi-bin/webscr?cmd=_profile-recurring-payments&encrypted_profile_id=%s'; return sprintf($base_url, $sandbox_prefix, $gateway_subscription_id); } }