Files
wp-multisite-waas/inc/gateways/class-paypal-gateway.php
2025-02-08 13:57:32 -07:00

1639 lines
47 KiB
PHP

<?php
/**
* PayPal Gateway.
*
* @package WP_Ultimo
* @subpackage Gateways
* @since 2.0.0
*/
namespace WP_Ultimo\Gateways;
use WP_Ultimo\Gateways\Base_Gateway;
use WP_Ultimo\Database\Payments\Payment_Status;
use WP_Ultimo\Database\Memberships\Membership_Status;
// Exit if accessed directly
defined('ABSPATH') || exit;
/**
* PayPal Payments Gateway
*
* @since 2.0.0
*/
class PayPal_Gateway extends Base_Gateway {
/**
* @var string
*/
public $error_message;
/**
* @var string
*/
public $webhook_event_id;
/**
* @var \WP_Ultimo\Models\Payment
*/
public $payment;
/**
* Holds the ID of a given gateway.
*
* @since 2.0.0
* @var string
*/
protected $id = 'paypal';
/**
* Holds if we are in test mode.
*
* @since 2.0.0
* @var boolean
*/
protected $test_mode = true;
/**
* The API endpoint. Depends on the test mode.
*
* @since 2.0.0
* @var string
*/
protected $api_endpoint;
/**
* Checkout URL.
*
* @since 2.0.0
* @var string
*/
protected $checkout_url;
/**
* PayPal username.
*
* @since 2.0.0
* @var string
*/
protected $username;
/**
* PayPal password.
*
* @since 2.0.0
* @var string
*/
protected $password;
/**
* PayPal signature.
*
* @since 2.0.0
* @var string
*/
protected $signature;
/**
* Backwards compatibility for the old notify ajax url.
*
* @since 2.0.4
* @var bool|string
*/
protected $backwards_compatibility_v1_id = 'paypal';
/**
* Declares support to recurring payments.
*
* Manual payments need to be manually paid,
* so we return false here.
*
* @since 2.0.0
* @return false
*/
public function supports_recurring(): bool {
return true;
}
/**
* Declares support to subscription amount updates.
*
* @since 2.1.2
* @return true
*/
public function supports_amount_update(): bool {
return true;
}
/**
* Adds the necessary hooks for the manual gateway.
*
* @since 2.0.0
* @return void
*/
public function hooks() {}
/**
* Initialization code.
*
* @since 2.0.0
* @return void
*/
public function init(): void {
/*
* Checks if we are in test mode or not,
* based on the PayPal Setting.
* As the toggle return a string with a int value,
* we need to convert this first to int then to bool.
*/
$this->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-ultimo'),
'desc' => __('Use the settings section below to configure PayPal Express as a payment method.', 'wp-ultimo'),
'type' => 'header',
'show_as_submenu' => true,
'require' => [
'active_gateways' => 'paypal',
],
]
);
wu_register_settings_field(
'payment-gateways',
'paypal_sandbox_mode',
[
'title' => __('PayPal Sandbox Mode', 'wp-ultimo'),
'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-ultimo'),
'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-ultimo'),
'desc' => '',
'tooltip' => __('Make sure you are placing the TEST username, not the live one.', 'wp-ultimo'),
'placeholder' => __('e.g. username_api1.username.co', 'wp-ultimo'),
'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-ultimo'),
'desc' => '',
'tooltip' => __('Make sure you are placing the TEST password, not the live one.', 'wp-ultimo'),
'placeholder' => __('e.g. IUOSABK987HJG88N', 'wp-ultimo'),
'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-ultimo'),
'desc' => '',
'tooltip' => __('Make sure you are placing the TEST signature, not the live one.', 'wp-ultimo'),
'placeholder' => __('e.g. AFcpSSRl31ADOdqnHNv4KZdVHEQzdMEEsWxV21C7fd0v3bYYYRCwYxqo', 'wp-ultimo'),
'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-ultimo'),
'desc' => '',
'tooltip' => __('Make sure you are placing the LIVE username, not the test one.', 'wp-ultimo'),
'placeholder' => __('e.g. username_api1.username.co', 'wp-ultimo'),
'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-ultimo'),
'desc' => '',
'tooltip' => __('Make sure you are placing the LIVE password, not the test one.', 'wp-ultimo'),
'placeholder' => __('e.g. IUOSABK987HJG88N', 'wp-ultimo'),
'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-ultimo'),
'desc' => '',
'tooltip' => __('Make sure you are placing the LIVE signature, not the test one.', 'wp-ultimo'),
'placeholder' => __('e.g. AFcpSSRl31ADOdqnHNv4KZdVHEQzdMEEsWxV21C7fd0v3bYYYRCwYxqo', 'wp-ultimo'),
'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-ultimo'));
}
$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-ultimo'));
}
/**
* 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', __('Subscription', 'wp-ultimo')) . ': ' . 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-ultimo'),
'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-ultimo') . ' ' . $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-ultimo'), $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 ($type === 'downgrade') {
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-ultimo'), $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-ultimo'), $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-ultimo'), $recurring_total_format, $desc);
} else {
$notes[] = sprintf(__('After this payment you will be billed %1$s %2$s.', 'wp-ultimo'), $recurring_total_format, $desc);
}
} elseif ($is_trial_setup) {
$notes[] = sprintf(__('From that date, you will be billed %1$s %2$s.', 'wp-ultimo'), $recurring_total_format, $desc);
} else {
$notes[] = sprintf(__('After this payment you will be billed %1$s.', 'wp-ultimo'), $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'] = $args['PAYMENTREQUEST_0_ITEMAMT'] + $sub_total;
$args['PAYMENTREQUEST_0_TAXAMT'] = $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-ultimo');
$args = array_merge(
$args,
[
"L_PAYMENTREQUEST_0_NAME{$product_index}" => __('Account credit and other discounts', 'wp-ultimo'),
"L_PAYMENTREQUEST_0_AMT{$product_index}" => $discounts_total,
"L_PAYMENTREQUEST_0_QTY{$product_index}" => 1,
]
);
$args['PAYMENTREQUEST_0_ITEMAMT'] = $args['PAYMENTREQUEST_0_ITEMAMT'] + $discounts_total;
$args['PAYMENTREQUEST_0_AMT'] = $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-ultimo'));
}
/**
* 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-ultimo'));
}
$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 ($refund_type === 'Partial') {
$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-ultimo'));
}
/**
* 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-ultimo');
return sprintf('<p class="wu-p-4 wu-bg-yellow-200">%s</p>', $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-ultimo'));
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-ultimo'));
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-ultimo'));
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-ultimo'));
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-ultimo'));
}
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 ($amount !== false) {
$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-ultimo'), $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-ultimo');
$membership->add_note(sprintf(__('Transaction ID %1$s is pending in PayPal for reason: %2$s', 'wp-ultimo'), $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-ultimo'));
$this->error_message = __('Initial payment failed.', 'wp-ultimo');
} 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-ultimo'));
}
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) || $profile_status === 'ActiveProfile' || $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 === Payment_Status::COMPLETED) {
$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-ultimo'),
__('Error', 'wp-ultimo'),
[
'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-ultimo'),
__('Error', 'wp-ultimo'),
[
'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-ultimo');
// translators: %s is the paypal error message.
return '<p>' . sprintf(__('An unexpected PayPal error occurred. Error message: %s.', 'wp-ultimo'), $error) . '</p>';
}
/*
* 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);
}
}