diff --git a/.psalm/wcs.php b/.psalm/wcs.php
index 16a62a9e3..2106378e2 100644
--- a/.psalm/wcs.php
+++ b/.psalm/wcs.php
@@ -904,6 +904,513 @@ class WC_Subscriptions_Admin
public static $option_prefix = 'woocommerce_subscriptions';
}
+/**
+ * Allow for payment dates to be synchronised to a specific day of the week, month or year.
+ */
+class WC_Subscriptions_Synchroniser {
+
+ public static $setting_id;
+ public static $setting_id_proration;
+ public static $setting_id_days_no_fee;
+
+ public static $post_meta_key = '_subscription_payment_sync_date';
+ public static $post_meta_key_day = '_subscription_payment_sync_date_day';
+ public static $post_meta_key_month = '_subscription_payment_sync_date_month';
+
+ public static $sync_field_label;
+ public static $sync_description;
+ public static $sync_description_year;
+
+ public static $billing_period_ranges;
+
+ /**
+ * Bootstraps the class and hooks required actions & filters.
+ */
+ public static function init()
+ {
+ }
+
+ /**
+ * Set default value of 'no' for our options.
+ *
+ * This only sets the default
+ *
+ * @param mixed $default The default value for the option.
+ * @param string $option The option name.
+ * @param bool $passed_default Whether get_option() was passed a default value.
+ *
+ * @return mixed The default option value.
+ */
+ public static function option_default( $default, $option, $passed_default = null )
+ {
+ }
+
+ /**
+ * Sanitize our options when they are saved in the admin area.
+ *
+ * @param mixed $value The value being saved.
+ * @param array $option The option data array.
+ *
+ * @return mixed The sanitized option value.
+ */
+ public static function sanitize_option( $value, $option )
+ {
+ }
+
+ /**
+ * Check if payment syncing is enabled on the store.
+ *
+ * @since 1.5
+ */
+ public static function is_syncing_enabled()
+ {
+ }
+
+ /**
+ * Check if payments can be prorated on the store.
+ *
+ * @since 1.5
+ */
+ public static function is_sync_proration_enabled()
+ {
+ }
+
+ /**
+ * Add sync settings to the Subscription's settings page.
+ *
+ * @since 1.5
+ */
+ public static function add_settings( $settings )
+ {
+ }
+
+ /**
+ * Add the sync setting fields to the Edit Product screen
+ *
+ * @since 1.5
+ */
+ public static function subscription_product_fields()
+ {
+ }
+
+ /**
+ * Add the sync setting fields to the variation section of the Edit Product screen
+ *
+ * @since 1.5
+ */
+ public static function variable_subscription_product_fields( $loop, $variation_data, $variation )
+ {
+ }
+
+ /**
+ * Save sync options when a subscription product is saved
+ *
+ * @since 1.5
+ */
+ public static function save_subscription_meta( $post_id )
+ {
+ }
+
+ /**
+ * Save sync options when a variable subscription product is saved
+ *
+ * @since 1.5
+ */
+ public static function process_product_meta_variable_subscription( $post_id )
+ {
+ }
+
+ /**
+ * Save sync options when a variable subscription product is saved
+ *
+ * @since 1.5
+ */
+ public static function save_product_variation( $variation_id, $index )
+ {
+ }
+
+ /**
+ * Add translated syncing options for our client side script
+ *
+ * @since 1.5
+ */
+ public static function admin_script_parameters( $script_parameters )
+ {
+ }
+
+ /**
+ * Determine whether a product, specified with $product, needs to have its first payment processed on a
+ * specific day (instead of at the time of sign-up).
+ *
+ * @return (bool) True is the product's first payment will be synced to a certain day.
+ * @since 1.5
+ */
+ public static function is_product_synced( $product )
+ {
+ }
+
+ /**
+ * Determine whether a product, specified with $product, should have its first payment processed on a
+ * at the time of sign-up but prorated to the sync day.
+ *
+ * @since 1.5.10
+ *
+ * @param WC_Product $product
+ *
+ * @return bool
+ */
+ public static function is_product_prorated( $product )
+ {
+ }
+
+ /**
+ * Determine whether the payment for a subscription should be the full price upfront.
+ *
+ * This method is particularly concerned with synchronized subscriptions. It will only return
+ * true when the following conditions are met:
+ *
+ * - There is no free trial
+ * - The subscription is synchronized
+ * - The store owner has determined that new subscribers need to pay for their subscription upfront.
+ *
+ * Additionally, if the store owner sets a number of days prior to the synchronization day that do not
+ * require an upfront payment, this method will check to see whether the current date falls within that
+ * period for the given product.
+ *
+ * @param WC_Product $product The product to check.
+ * @param string $from_date Optional. A MySQL formatted date/time string from which to calculate from. The default is an empty string which is today's date/time.
+ *
+ * @return bool Whether an upfront payment is required for the product.
+ */
+ public static function is_payment_upfront( $product, $from_date = '' )
+ {
+ }
+
+ /**
+ * Get the day of the week, month or year on which a subscription's payments should be
+ * synchronised to.
+ *
+ * @return int The day the products payments should be processed, or 0 if the payments should not be sync'd to a specific day.
+ * @since 1.5
+ */
+ public static function get_products_payment_day( $product )
+ {
+ }
+
+ /**
+ * Calculate the first payment date for a synced subscription.
+ *
+ * The date is calculated in UTC timezone.
+ *
+ * @param WC_Product $product A subscription product.
+ * @param string $type (optional) The format to return the first payment date in, either 'mysql' or 'timestamp'. Default 'mysql'.
+ * @param string $from_date (optional) The date to calculate the first payment from in GMT/UTC timzeone. If not set, it will use the current date. This should not include any trial period on the product.
+ * @since 1.5
+ */
+ public static function calculate_first_payment_date( $product, $type = 'mysql', $from_date = '' )
+ {
+ }
+
+ /**
+ * Return an i18n'ified associative array of sync options for 'year' as billing period
+ *
+ * @since 3.0.0
+ */
+ public static function get_year_sync_options()
+ {
+ }
+
+ /**
+ * Return an i18n'ified associative array of all possible subscription periods.
+ *
+ * @since 1.5
+ */
+ public static function get_billing_period_ranges( $billing_period = '' )
+ {
+ }
+
+ /**
+ * Add the first payment date to a products summary section
+ *
+ * @since 1.5
+ */
+ public static function products_first_payment_date( $echo = false )
+ {
+ }
+
+ /**
+ * Return a string explaining when the first payment will be completed for the subscription.
+ *
+ * @since 1.5
+ */
+ public static function get_products_first_payment_date( $product )
+ {
+ }
+
+ /**
+ * If a product is synchronised to a date in the future, make sure that is set as the product's first payment date
+ *
+ * @since 2.0
+ */
+ public static function products_first_renewal_payment_time( $first_renewal_timestamp, $product_id, $from_date, $timezone )
+ {
+ }
+
+ /**
+ * Make sure a synchronised subscription's price includes a free trial, unless it's first payment is today.
+ *
+ * @since 1.5
+ */
+ public static function maybe_set_free_trial( $total = '' )
+ {
+ }
+
+ /**
+ * Make sure a synchronised subscription's price includes a free trial, unless it's first payment is today.
+ *
+ * @since 1.5
+ */
+ public static function maybe_unset_free_trial( $total = '' )
+ {
+ }
+
+ /**
+ * Check if the cart includes a subscription that needs to be synced.
+ *
+ * @return bool Returns true if any item in the cart is a subscription sync request, otherwise, false.
+ * @since 1.5
+ */
+ public static function cart_contains_synced_subscription( $cart = null )
+ {
+ }
+
+ /**
+ * Maybe set the time of a product's trial expiration to be the same as the synced first payment date for products where the first
+ * renewal payment date falls on the same day as the trial expiration date, but the trial expiration time is later in the day.
+ *
+ * When making sure the first payment is after the trial expiration in @see self::calculate_first_payment_date() we only check
+ * whether the first payment day comes after the trial expiration day, because we don't want to pushing the first payment date
+ * a month or year in the future because of a few hours difference between it and the trial expiration. However, this means we
+ * could still end up with a trial end time after the first payment time, even though they are both on the same day because the
+ * trial end time is normally calculated from the start time, which can be any time of day, but the first renewal time is always
+ * set to be 3am in the site's timezone. For example, the first payment date might be calculate to be 3:00 on the 21st April 2017,
+ * while the trial end date is on the same day at 3:01 (or any time after that on the same day). So we need to check both the time and day. We also don't want to make the first payment date/time skip a year because of a few hours difference. That means we need to either modify the trial end time to be 3:00am or make the first payment time occur at the same time as the trial end time. The former is pretty hard to change, but the later will sync'd payments will be at a different times if there is a free trial ending on the same day, which could be confusing. o_0
+ *
+ * Fixes #1328
+ *
+ * @param mixed $trial_expiration_date MySQL formatted date on which the subscription's trial will end, or 0 if it has no trial
+ * @param mixed $product_id The product object or post ID of the subscription product
+ * @return mixed MySQL formatted date on which the subscription's trial is set to end, or 0 if it has no trial
+ * @since 2.0.13
+ */
+ public static function recalculate_product_trial_expiration_date( $trial_expiration_date, $product_id )
+ {
+ }
+
+ /**
+ * Make sure the expiration date is calculated from the synced start date for products where the start date
+ * will be synced.
+ *
+ * @param string $expiration_date MySQL formatted date on which the subscription is set to expire
+ * @param mixed $product_id The product/post ID of the subscription
+ * @param mixed $from_date A MySQL formatted date/time string from which to calculate the expiration date, or empty (default), which will use today's date/time.
+ * @since 1.5
+ */
+ public static function recalculate_product_expiration_date( $expiration_date, $product_id, $from_date )
+ {
+ }
+
+ /**
+ * Check if a given timestamp (in the UTC timezone) is equivalent to today in the site's time.
+ *
+ * @param int $timestamp A time in UTC timezone to compare to today.
+ */
+ public static function is_today( $timestamp )
+ {
+ }
+
+ /**
+ * Filters WC_Subscriptions_Order::get_sign_up_fee() to make sure the sign-up fee for a subscription product
+ * that is synchronised is returned correctly.
+ *
+ * @param float The initial sign-up fee charged when the subscription product in the order was first purchased, if any.
+ * @param mixed $order A WC_Order object or the ID of the order which the subscription was purchased in.
+ * @param int $product_id The post ID of the subscription WC_Product object purchased in the order. Defaults to the ID of the first product purchased in the order.
+ * @return float The initial sign-up fee charged when the subscription product in the order was first purchased, if any.
+ * @since 2.0
+ */
+ public static function get_synced_sign_up_fee( $sign_up_fee, $subscription, $product_id )
+ {
+ }
+
+ /**
+ * Removes the "set_subscription_prices_for_calculation" filter from the WC Product's woocommerce_get_price hook once
+ *
+ * @since 1.5.10
+ *
+ * @param int $price The current price.
+ * @param WC_Product $product The product object.
+ *
+ * @return int
+ */
+ public static function set_prorated_price_for_calculation( $price, $product )
+ {
+ }
+
+ /**
+ * Retrieve the full translated weekday word.
+ *
+ * Week starts on translated Monday and can be fetched
+ * by using 1 (one). So the week starts with 1 (one)
+ * and ends on Sunday with is fetched by using 7 (seven).
+ *
+ * @since 1.5.8
+ * @access public
+ *
+ * @param int $weekday_number 1 for Monday through 7 Sunday
+ * @return string Full translated weekday
+ */
+ public static function get_weekday( $weekday_number )
+ {
+ }
+
+ /**
+ * Override quantities used to lower stock levels by when using synced subscriptions. If it's a synced product
+ * that does not have proration enabled and the payment date is not today, do not lower stock levels.
+ *
+ * @param integer $qty the original quantity that would be taken out of the stock level
+ * @param array $order order data
+ * @param array $item item data for each item in the order
+ *
+ * @return int
+ */
+ public static function maybe_do_not_reduce_stock( $qty, $order, $order_item )
+ {
+ }
+
+ /**
+ * Add subscription meta for subscription that contains a synced product.
+ *
+ * @param WC_Order Parent order for the subscription
+ * @param WC_Subscription new subscription
+ * @since 2.0
+ */
+ public static function maybe_add_subscription_meta( $post_id )
+ {
+ }
+
+ /**
+ * When adding an item to an order/subscription via the Add/Edit Subscription administration interface, check if we should be setting
+ * the sync meta on the subscription.
+ *
+ * @param int The order item ID of an item that was just added to the order
+ * @param array The order item details
+ * @since 2.0
+ */
+ public static function ajax_maybe_add_meta_for_item( $item_id, $item )
+ {
+ }
+
+ /**
+ * When adding a product to an order/subscription via the WC_Subscription::add_product() method, check if we should be setting
+ * the sync meta on the subscription.
+ *
+ * @param int The post ID of a WC_Order or child object
+ * @param int The order item ID of an item that was just added to the order
+ * @param object The WC_Product for which an item was just added
+ * @since 2.0
+ */
+ public static function maybe_add_meta_for_new_product( $subscription_id, $item_id, $product )
+ {
+ }
+
+ /**
+ * Check if a given subscription is synced to a certain day.
+ *
+ * @param int|WC_Subscription Accepts either a subscription object of post id
+ * @return bool
+ * @since 2.0
+ */
+ public static function subscription_contains_synced_product( $subscription_id )
+ {
+ }
+
+ /**
+ * If the cart item is synced, add a '_synced' string to the recurring cart key.
+ *
+ * @since 2.0
+ */
+ public static function add_to_recurring_cart_key( $cart_key, $cart_item )
+ {
+ }
+
+ /**
+ * When adding a product line item to an order/subscription via the WC_Abstract_Order::add_product() method, check if we should be setting
+ * the sync meta on the subscription.
+ *
+ * Attached to WC 3.0+ hooks and uses WC 3.0 methods.
+ *
+ * @param int The new line item id
+ * @param WC_Order_Item
+ * @param int The post ID of a WC_Subscription
+ * @since 2.2.3
+ */
+ public static function maybe_add_meta_for_new_line_item( $item_id, $item, $subscription_id )
+ {
+ }
+
+ /**
+ * Store a synced product's signup fee on the line item on the subscription and order.
+ *
+ * When calculating prorated sign up fees during switches it's necessary to get the sign-up fee paid.
+ * For synced product purchases we cannot rely on the order line item price as that might include a prorated recurring price or no recurring price all.
+ *
+ * Attached to WC 3.0+ hooks and uses WC 3.0 methods.
+ *
+ * @param WC_Order_Item_Product $item The order item object.
+ * @param string $cart_item_key The hash used to identify the item in the cart
+ * @param array $cart_item The cart item's data.
+ * @since 2.3.0
+ */
+ public static function maybe_add_line_item_meta( $item, $cart_item_key, $cart_item )
+ {
+ }
+
+ /**
+ * Store a synced product's signup fee on the line item on the subscription and order.
+ *
+ * This function is a pre WooCommerce 3.0 version of @see WC_Subscriptions_Synchroniser::maybe_add_line_item_meta()
+ *
+ * @param int $item_id The order item ID.
+ * @param array $cart_item The cart item's data.
+ * @since 2.3.0
+ */
+ public static function maybe_add_order_item_meta( $item_id, $cart_item )
+ {
+ }
+
+ /**
+ * Hides synced subscription meta on the edit order and subscription screen on non-debug sites.
+ *
+ * @since 2.6.2
+ * @param array $hidden_meta_keys the list of meta keys hidden on the edit order and subscription screen.
+ * @return array $hidden_meta_keys
+ */
+ public static function hide_order_itemmeta( $hidden_meta_keys )
+ {
+ }
+
+ /**
+ * Gets the number of sign-up grace period days.
+ *
+ * @since 3.0.6
+ * @return int The number of days in the grace period. 0 will be returned if the stroe isn't charging the full recurring price on sign-up -- a prerequiste for setting a grace period.
+ */
+ private static function get_number_of_grace_period_days()
+ {
+ }
+}
+
/**
* Check if a given object is a WC_Subscription (or child class of WC_Subscription), or if a given ID
* belongs to a post with the subscription post type ('shop_subscription')
diff --git a/modules/ppcp-api-client/src/Entity/PaymentToken.php b/modules/ppcp-api-client/src/Entity/PaymentToken.php
index 686813817..51b43e57b 100644
--- a/modules/ppcp-api-client/src/Entity/PaymentToken.php
+++ b/modules/ppcp-api-client/src/Entity/PaymentToken.php
@@ -9,6 +9,7 @@ declare(strict_types=1);
namespace WooCommerce\PayPalCommerce\ApiClient\Entity;
+use stdClass;
use WooCommerce\PayPalCommerce\ApiClient\Exception\RuntimeException;
/**
@@ -42,12 +43,12 @@ class PaymentToken {
/**
* PaymentToken constructor.
*
- * @param string $id The Id.
- * @param string $type The type.
- * @param \stdClass $source The source.
+ * @param string $id The Id.
+ * @param stdClass $source The source.
+ * @param string $type The type.
* @throws RuntimeException When the type is not valid.
*/
- public function __construct( string $id, string $type = self::TYPE_PAYMENT_METHOD_TOKEN, \stdClass $source ) {
+ public function __construct( string $id, stdClass $source, string $type = self::TYPE_PAYMENT_METHOD_TOKEN ) {
if ( ! in_array( $type, self::get_valid_types(), true ) ) {
throw new RuntimeException(
__( 'Not a valid payment source type.', 'woocommerce-paypal-payments' )
diff --git a/modules/ppcp-api-client/src/Exception/PayPalApiException.php b/modules/ppcp-api-client/src/Exception/PayPalApiException.php
index d2752cb72..36e6c1e57 100644
--- a/modules/ppcp-api-client/src/Exception/PayPalApiException.php
+++ b/modules/ppcp-api-client/src/Exception/PayPalApiException.php
@@ -119,4 +119,26 @@ class PayPalApiException extends RuntimeException {
public function status_code(): int {
return $this->status_code;
}
+
+ /**
+ * Return exception details if exists.
+ *
+ * @param string $error The error to return in case no details found.
+ * @return string
+ */
+ public function get_details( string $error ): string {
+ if ( empty( $this->details() ) ) {
+ return $error;
+ }
+
+ $details = '';
+ foreach ( $this->details() as $detail ) {
+ $issue = $detail->issue ?? '';
+ $field = $detail->field ?? '';
+ $description = $detail->description ?? '';
+ $details .= $issue . ' ' . $field . ' ' . $description . '
';
+ }
+
+ return $details;
+ }
}
diff --git a/modules/ppcp-api-client/src/Factory/PaymentTokenFactory.php b/modules/ppcp-api-client/src/Factory/PaymentTokenFactory.php
index 1c2f6de39..50b21a78f 100644
--- a/modules/ppcp-api-client/src/Factory/PaymentTokenFactory.php
+++ b/modules/ppcp-api-client/src/Factory/PaymentTokenFactory.php
@@ -9,6 +9,7 @@ declare(strict_types=1);
namespace WooCommerce\PayPalCommerce\ApiClient\Factory;
+use stdClass;
use WooCommerce\PayPalCommerce\ApiClient\Entity\PaymentToken;
use WooCommerce\PayPalCommerce\ApiClient\Exception\RuntimeException;
@@ -25,7 +26,7 @@ class PaymentTokenFactory {
* @return PaymentToken
* @throws RuntimeException When JSON object is malformed.
*/
- public function from_paypal_response( \stdClass $data ): PaymentToken {
+ public function from_paypal_response( stdClass $data ): PaymentToken {
if ( ! isset( $data->id ) ) {
throw new RuntimeException(
__( 'No id for payment token given', 'woocommerce-paypal-payments' )
@@ -34,8 +35,8 @@ class PaymentTokenFactory {
return new PaymentToken(
$data->id,
- ( isset( $data->type ) ) ? $data->type : PaymentToken::TYPE_PAYMENT_METHOD_TOKEN,
- $data->source
+ $data->source,
+ ( isset( $data->type ) ) ? $data->type : PaymentToken::TYPE_PAYMENT_METHOD_TOKEN
);
}
diff --git a/modules/ppcp-button/resources/js/button.js b/modules/ppcp-button/resources/js/button.js
index b1f19bbac..0e3fae7b0 100644
--- a/modules/ppcp-button/resources/js/button.js
+++ b/modules/ppcp-button/resources/js/button.js
@@ -100,7 +100,8 @@ const bootstrap = () => {
if (PayPalCommerceGateway.mini_cart_buttons_enabled === '1') {
const miniCartBootstrap = new MiniCartBootstap(
PayPalCommerceGateway,
- renderer
+ renderer,
+ errorHandler,
);
miniCartBootstrap.init();
@@ -112,6 +113,7 @@ const bootstrap = () => {
PayPalCommerceGateway,
renderer,
messageRenderer,
+ errorHandler,
);
singleProductBootstrap.init();
@@ -121,6 +123,7 @@ const bootstrap = () => {
const cartBootstrap = new CartBootstrap(
PayPalCommerceGateway,
renderer,
+ errorHandler,
);
cartBootstrap.init();
@@ -131,7 +134,8 @@ const bootstrap = () => {
PayPalCommerceGateway,
renderer,
messageRenderer,
- spinner
+ spinner,
+ errorHandler,
);
checkoutBootstap.init();
@@ -142,7 +146,8 @@ const bootstrap = () => {
PayPalCommerceGateway,
renderer,
messageRenderer,
- spinner
+ spinner,
+ errorHandler,
);
payNowBootstrap.init();
}
diff --git a/modules/ppcp-button/resources/js/modules/ActionHandler/CheckoutActionHandler.js b/modules/ppcp-button/resources/js/modules/ActionHandler/CheckoutActionHandler.js
index 98e14e134..aa456a064 100644
--- a/modules/ppcp-button/resources/js/modules/ActionHandler/CheckoutActionHandler.js
+++ b/modules/ppcp-button/resources/js/modules/ActionHandler/CheckoutActionHandler.js
@@ -59,14 +59,16 @@ class CheckoutActionHandler {
);
} else {
errorHandler.clear();
- if (data.data.details.length > 0) {
+ if (data.data.errors.length > 0) {
+ errorHandler.messages(data.data.errors);
+ } else if (data.data.details.length > 0) {
errorHandler.message(data.data.details.map(d => `${d.issue} ${d.description}`).join('
'), true);
} else {
errorHandler.message(data.data.message, true);
}
}
- throw new Error(data.data.message);
+ throw {type: 'create-order-error', data: data.data};
}
const input = document.createElement('input');
input.setAttribute('type', 'hidden');
@@ -82,9 +84,15 @@ class CheckoutActionHandler {
onCancel: () => {
spinner.unblock();
},
- onError: () => {
- this.errorHandler.genericError();
+ onError: (err) => {
+ console.error(err);
spinner.unblock();
+
+ if (err && err.type === 'create-order-error') {
+ return;
+ }
+
+ this.errorHandler.genericError();
}
}
}
diff --git a/modules/ppcp-button/resources/js/modules/ContextBootstrap/CartBootstap.js b/modules/ppcp-button/resources/js/modules/ContextBootstrap/CartBootstap.js
index d32ca440f..ebc00bfdc 100644
--- a/modules/ppcp-button/resources/js/modules/ContextBootstrap/CartBootstap.js
+++ b/modules/ppcp-button/resources/js/modules/ContextBootstrap/CartBootstap.js
@@ -1,10 +1,10 @@
import CartActionHandler from '../ActionHandler/CartActionHandler';
-import ErrorHandler from '../ErrorHandler';
class CartBootstrap {
- constructor(gateway, renderer) {
+ constructor(gateway, renderer, errorHandler) {
this.gateway = gateway;
this.renderer = renderer;
+ this.errorHandler = errorHandler;
}
init() {
@@ -28,7 +28,7 @@ class CartBootstrap {
render() {
const actionHandler = new CartActionHandler(
PayPalCommerceGateway,
- new ErrorHandler(this.gateway.labels.error.generic),
+ this.errorHandler,
);
this.renderer.render(
diff --git a/modules/ppcp-button/resources/js/modules/ContextBootstrap/CheckoutBootstap.js b/modules/ppcp-button/resources/js/modules/ContextBootstrap/CheckoutBootstap.js
index b491bcb1e..af04b6811 100644
--- a/modules/ppcp-button/resources/js/modules/ContextBootstrap/CheckoutBootstap.js
+++ b/modules/ppcp-button/resources/js/modules/ContextBootstrap/CheckoutBootstap.js
@@ -1,4 +1,3 @@
-import ErrorHandler from '../ErrorHandler';
import CheckoutActionHandler from '../ActionHandler/CheckoutActionHandler';
import {setVisible, setVisibleByClass} from '../Helper/Hiding';
import {
@@ -8,11 +7,12 @@ import {
} from "../Helper/CheckoutMethodState";
class CheckoutBootstap {
- constructor(gateway, renderer, messages, spinner) {
+ constructor(gateway, renderer, messages, spinner, errorHandler) {
this.gateway = gateway;
this.renderer = renderer;
this.messages = messages;
this.spinner = spinner;
+ this.errorHandler = errorHandler;
this.standardOrderButtonSelector = ORDER_BUTTON_SELECTOR;
}
@@ -60,7 +60,7 @@ class CheckoutBootstap {
}
const actionHandler = new CheckoutActionHandler(
PayPalCommerceGateway,
- new ErrorHandler(this.gateway.labels.error.generic),
+ this.errorHandler,
this.spinner
);
diff --git a/modules/ppcp-button/resources/js/modules/ContextBootstrap/MiniCartBootstap.js b/modules/ppcp-button/resources/js/modules/ContextBootstrap/MiniCartBootstap.js
index 35465c12e..443c9afe4 100644
--- a/modules/ppcp-button/resources/js/modules/ContextBootstrap/MiniCartBootstap.js
+++ b/modules/ppcp-button/resources/js/modules/ContextBootstrap/MiniCartBootstap.js
@@ -1,10 +1,10 @@
-import ErrorHandler from '../ErrorHandler';
import CartActionHandler from '../ActionHandler/CartActionHandler';
class MiniCartBootstap {
- constructor(gateway, renderer) {
+ constructor(gateway, renderer, errorHandler) {
this.gateway = gateway;
this.renderer = renderer;
+ this.errorHandler = errorHandler;
this.actionHandler = null;
}
@@ -12,7 +12,7 @@ class MiniCartBootstap {
this.actionHandler = new CartActionHandler(
PayPalCommerceGateway,
- new ErrorHandler(this.gateway.labels.error.generic),
+ this.errorHandler,
);
this.render();
diff --git a/modules/ppcp-button/resources/js/modules/ContextBootstrap/PayNowBootstrap.js b/modules/ppcp-button/resources/js/modules/ContextBootstrap/PayNowBootstrap.js
index 4b2583c98..4d69532eb 100644
--- a/modules/ppcp-button/resources/js/modules/ContextBootstrap/PayNowBootstrap.js
+++ b/modules/ppcp-button/resources/js/modules/ContextBootstrap/PayNowBootstrap.js
@@ -2,8 +2,8 @@ import CheckoutBootstap from './CheckoutBootstap'
import {isChangePaymentPage} from "../Helper/Subscriptions";
class PayNowBootstrap extends CheckoutBootstap {
- constructor(gateway, renderer, messages, spinner) {
- super(gateway, renderer, messages, spinner)
+ constructor(gateway, renderer, messages, spinner, errorHandler) {
+ super(gateway, renderer, messages, spinner, errorHandler)
}
updateUi() {
diff --git a/modules/ppcp-button/resources/js/modules/ContextBootstrap/SingleProductBootstap.js b/modules/ppcp-button/resources/js/modules/ContextBootstrap/SingleProductBootstap.js
index ecb44eabe..7bb515df4 100644
--- a/modules/ppcp-button/resources/js/modules/ContextBootstrap/SingleProductBootstap.js
+++ b/modules/ppcp-button/resources/js/modules/ContextBootstrap/SingleProductBootstap.js
@@ -1,12 +1,12 @@
-import ErrorHandler from '../ErrorHandler';
import UpdateCart from "../Helper/UpdateCart";
import SingleProductActionHandler from "../ActionHandler/SingleProductActionHandler";
class SingleProductBootstap {
- constructor(gateway, renderer, messages) {
+ constructor(gateway, renderer, messages, errorHandler) {
this.gateway = gateway;
this.renderer = renderer;
this.messages = messages;
+ this.errorHandler = errorHandler;
}
@@ -81,7 +81,7 @@ class SingleProductBootstap {
this.messages.hideMessages();
},
document.querySelector('form.cart'),
- new ErrorHandler(this.gateway.labels.error.generic),
+ this.errorHandler,
);
this.renderer.render(
diff --git a/modules/ppcp-button/services.php b/modules/ppcp-button/services.php
index e07b322a0..2e8b86769 100644
--- a/modules/ppcp-button/services.php
+++ b/modules/ppcp-button/services.php
@@ -149,6 +149,7 @@ return array(
$early_order_handler,
$registration_needed,
$container->get( 'wcgateway.settings.card_billing_data_mode' ),
+ $container->get( 'button.early-wc-checkout-validation-enabled' ),
$logger
);
},
@@ -223,6 +224,14 @@ return array(
* The filter allowing to disable the basic client-side validation of the checkout form
* when the PayPal button is clicked.
*/
- return (bool) apply_filters( 'woocommerce_paypal_payments_basic_checkout_validation_enabled', true );
+ return (bool) apply_filters( 'woocommerce_paypal_payments_basic_checkout_validation_enabled', false );
+ },
+ 'button.early-wc-checkout-validation-enabled' => static function ( ContainerInterface $container ): bool {
+ /**
+ * The filter allowing to disable the WC validation of the checkout form
+ * when the PayPal button is clicked.
+ * The validation is triggered in a non-standard way and may cause issues on some sites.
+ */
+ return (bool) apply_filters( 'woocommerce_paypal_payments_early_wc_checkout_validation_enabled', true );
},
);
diff --git a/modules/ppcp-button/src/Assets/SmartButton.php b/modules/ppcp-button/src/Assets/SmartButton.php
index 67d3990c1..a7237c4f0 100644
--- a/modules/ppcp-button/src/Assets/SmartButton.php
+++ b/modules/ppcp-button/src/Assets/SmartButton.php
@@ -373,6 +373,7 @@ class SmartButton implements SmartButtonInterface {
if (
( is_product() || wc_post_content_has_shortcode( 'product_page' ) )
&& ! $not_enabled_on_product_page
+ && ! is_checkout()
) {
add_action(
$this->single_product_renderer_hook(),
@@ -413,6 +414,7 @@ class SmartButton implements SmartButtonInterface {
// TODO: it seems like there is no easy way to properly handle vaulted PayPal free trial,
// so disable the buttons for now everywhere except checkout for free trial.
&& ! $this->is_free_trial_product()
+ && ! is_checkout()
) {
add_action(
$this->single_product_renderer_hook(),
diff --git a/modules/ppcp-button/src/Endpoint/CreateOrderEndpoint.php b/modules/ppcp-button/src/Endpoint/CreateOrderEndpoint.php
index 3e6e5cb9d..b22b851b9 100644
--- a/modules/ppcp-button/src/Endpoint/CreateOrderEndpoint.php
+++ b/modules/ppcp-button/src/Endpoint/CreateOrderEndpoint.php
@@ -12,6 +12,7 @@ namespace WooCommerce\PayPalCommerce\Button\Endpoint;
use Exception;
use Psr\Log\LoggerInterface;
use stdClass;
+use Throwable;
use WooCommerce\PayPalCommerce\ApiClient\Endpoint\OrderEndpoint;
use WooCommerce\PayPalCommerce\ApiClient\Entity\Amount;
use WooCommerce\PayPalCommerce\ApiClient\Entity\ApplicationContext;
@@ -25,6 +26,8 @@ use WooCommerce\PayPalCommerce\ApiClient\Exception\RuntimeException;
use WooCommerce\PayPalCommerce\ApiClient\Factory\PayerFactory;
use WooCommerce\PayPalCommerce\ApiClient\Factory\PurchaseUnitFactory;
use WooCommerce\PayPalCommerce\ApiClient\Factory\ShippingPreferenceFactory;
+use WooCommerce\PayPalCommerce\Button\Exception\ValidationException;
+use WooCommerce\PayPalCommerce\Button\Validation\CheckoutFormValidator;
use WooCommerce\PayPalCommerce\Button\Helper\EarlyOrderHandler;
use WooCommerce\PayPalCommerce\Session\SessionHandler;
use WooCommerce\PayPalCommerce\Subscription\FreeTrialHandlerTrait;
@@ -128,6 +131,13 @@ class CreateOrderEndpoint implements EndpointInterface {
*/
protected $card_billing_data_mode;
+ /**
+ * Whether to execute WC validation of the checkout form.
+ *
+ * @var bool
+ */
+ protected $early_validation_enabled;
+
/**
* The logger.
*
@@ -148,6 +158,7 @@ class CreateOrderEndpoint implements EndpointInterface {
* @param EarlyOrderHandler $early_order_handler The EarlyOrderHandler object.
* @param bool $registration_needed Whether a new user must be registered during checkout.
* @param string $card_billing_data_mode The value of card_billing_data_mode from the settings.
+ * @param bool $early_validation_enabled Whether to execute WC validation of the checkout form.
* @param LoggerInterface $logger The logger.
*/
public function __construct(
@@ -161,6 +172,7 @@ class CreateOrderEndpoint implements EndpointInterface {
EarlyOrderHandler $early_order_handler,
bool $registration_needed,
string $card_billing_data_mode,
+ bool $early_validation_enabled,
LoggerInterface $logger
) {
@@ -174,6 +186,7 @@ class CreateOrderEndpoint implements EndpointInterface {
$this->early_order_handler = $early_order_handler;
$this->registration_needed = $registration_needed;
$this->card_billing_data_mode = $card_billing_data_mode;
+ $this->early_validation_enabled = $early_validation_enabled;
$this->logger = $logger;
}
@@ -233,8 +246,14 @@ class CreateOrderEndpoint implements EndpointInterface {
$this->set_bn_code( $data );
- if ( 'pay-now' === $data['context'] && get_option( 'woocommerce_terms_page_id', '' ) !== '' ) {
- $this->validate_paynow_form( $data['form'] );
+ $form_fields = $data['form'] ?? null;
+
+ if ( $this->early_validation_enabled && is_array( $form_fields ) ) {
+ $this->validate_form( $form_fields );
+ }
+
+ if ( 'pay-now' === $data['context'] && is_array( $form_fields ) && get_option( 'woocommerce_terms_page_id', '' ) !== '' ) {
+ $this->validate_paynow_form( $form_fields );
}
try {
@@ -264,6 +283,13 @@ class CreateOrderEndpoint implements EndpointInterface {
wp_send_json_success( $order->to_array() );
return true;
+ } catch ( ValidationException $error ) {
+ wp_send_json_error(
+ array(
+ 'message' => $error->getMessage(),
+ 'errors' => $error->errors(),
+ )
+ );
} catch ( \RuntimeException $error ) {
$this->logger->error( 'Order creation failed: ' . $error->getMessage() );
@@ -481,16 +507,33 @@ class CreateOrderEndpoint implements EndpointInterface {
return $payment_method;
}
+ /**
+ * Checks whether the form fields are valid.
+ *
+ * @param array $form_fields The form fields.
+ * @throws ValidationException When fields are not valid.
+ */
+ private function validate_form( array $form_fields ): void {
+ try {
+ $v = new CheckoutFormValidator();
+ $v->validate( $form_fields );
+ } catch ( ValidationException $exception ) {
+ throw $exception;
+ } catch ( Throwable $exception ) {
+ $this->logger->error( "Form validation execution failed. {$exception->getMessage()} {$exception->getFile()}:{$exception->getLine()}" );
+ }
+ }
+
/**
* Checks whether the terms input field is checked.
*
* @param array $form_fields The form fields.
- * @throws \RuntimeException When field is not checked.
+ * @throws ValidationException When field is not checked.
*/
- private function validate_paynow_form( array $form_fields ) {
+ private function validate_paynow_form( array $form_fields ): void {
if ( isset( $form_fields['terms-field'] ) && ! isset( $form_fields['terms'] ) ) {
- throw new \RuntimeException(
- __( 'Please read and accept the terms and conditions to proceed with your order.', 'woocommerce-paypal-payments' )
+ throw new ValidationException(
+ array( __( 'Please read and accept the terms and conditions to proceed with your order.', 'woocommerce-paypal-payments' ) )
);
}
}
diff --git a/modules/ppcp-button/src/Exception/ValidationException.php b/modules/ppcp-button/src/Exception/ValidationException.php
new file mode 100644
index 000000000..f8aedf291
--- /dev/null
+++ b/modules/ppcp-button/src/Exception/ValidationException.php
@@ -0,0 +1,47 @@
+errors = $errors;
+
+ if ( ! $message ) {
+ $message = implode( ' ', $errors );
+ }
+
+ parent::__construct( $message );
+ }
+
+ /**
+ * The error messages.
+ *
+ * @return string[]
+ */
+ public function errors(): array {
+ return $this->errors;
+ }
+}
diff --git a/modules/ppcp-button/src/Validation/CheckoutFormValidator.php b/modules/ppcp-button/src/Validation/CheckoutFormValidator.php
new file mode 100644
index 000000000..729125638
--- /dev/null
+++ b/modules/ppcp-button/src/Validation/CheckoutFormValidator.php
@@ -0,0 +1,43 @@
+validate_checkout( $data, $errors );
+
+ if ( $errors->has_errors() ) {
+ throw new ValidationException( $errors->get_error_messages() );
+ }
+ }
+}
diff --git a/modules/ppcp-compat/src/PPEC/SubscriptionsHandler.php b/modules/ppcp-compat/src/PPEC/SubscriptionsHandler.php
index 0881c40a5..8db9808a0 100644
--- a/modules/ppcp-compat/src/PPEC/SubscriptionsHandler.php
+++ b/modules/ppcp-compat/src/PPEC/SubscriptionsHandler.php
@@ -9,6 +9,7 @@ declare(strict_types=1);
namespace WooCommerce\PayPalCommerce\Compat\PPEC;
+use stdClass;
use WooCommerce\PayPalCommerce\Subscription\RenewalHandler;
use WooCommerce\PayPalCommerce\ApiClient\Entity\PaymentToken;
@@ -125,7 +126,7 @@ class SubscriptionsHandler {
$billing_agreement_id = $order->get_meta( '_ppec_billing_agreement_id', true );
if ( $billing_agreement_id ) {
- $token = new PaymentToken( $billing_agreement_id, 'BILLING_AGREEMENT', new \stdClass() );
+ $token = new PaymentToken( $billing_agreement_id, new stdClass(), 'BILLING_AGREEMENT' );
}
}
diff --git a/modules/ppcp-order-tracking/src/Endpoint/OrderTrackingEndpoint.php b/modules/ppcp-order-tracking/src/Endpoint/OrderTrackingEndpoint.php
index b09fcdeae..fb281ecef 100644
--- a/modules/ppcp-order-tracking/src/Endpoint/OrderTrackingEndpoint.php
+++ b/modules/ppcp-order-tracking/src/Endpoint/OrderTrackingEndpoint.php
@@ -171,7 +171,11 @@ class OrderTrackingEndpoint {
throw $error;
}
- update_post_meta( $order_id, '_ppcp_paypal_tracking_number', $data['tracking_number'] ?? '' );
+ $wc_order = wc_get_order( $order_id );
+ if ( is_a( $wc_order, WC_Order::class ) ) {
+ $wc_order->update_meta_data( '_ppcp_paypal_tracking_number', $data['tracking_number'] ?? '' );
+ $wc_order->save();
+ }
do_action( 'woocommerce_paypal_payments_after_tracking_is_added', $order_id, $response );
}
@@ -300,7 +304,11 @@ class OrderTrackingEndpoint {
throw $error;
}
- update_post_meta( $order_id, '_ppcp_paypal_tracking_number', $data['tracking_number'] ?? '' );
+ $wc_order = wc_get_order( $order_id );
+ if ( is_a( $wc_order, WC_Order::class ) ) {
+ $wc_order->update_meta_data( '_ppcp_paypal_tracking_number', $data['tracking_number'] ?? '' );
+ $wc_order->save();
+ }
do_action( 'woocommerce_paypal_payments_after_tracking_is_updated', $order_id, $response );
}
diff --git a/modules/ppcp-subscription/src/FreeTrialHandlerTrait.php b/modules/ppcp-subscription/src/FreeTrialHandlerTrait.php
index 2fe9f7f49..8e309f4ce 100644
--- a/modules/ppcp-subscription/src/FreeTrialHandlerTrait.php
+++ b/modules/ppcp-subscription/src/FreeTrialHandlerTrait.php
@@ -13,6 +13,7 @@ use WC_Order;
use WC_Product;
use WC_Subscription;
use WC_Subscriptions_Product;
+use WC_Subscriptions_Synchroniser;
/**
* Class FreeTrialHandlerTrait
@@ -37,10 +38,7 @@ trait FreeTrialHandlerTrait {
foreach ( $cart->get_cart() as $item ) {
$product = $item['data'] ?? null;
- if ( ! $product instanceof WC_Product ) {
- continue;
- }
- if ( WC_Subscriptions_Product::get_trial_length( $product ) > 0 ) {
+ if ( $product && WC_Subscriptions_Product::is_subscription( $product ) ) {
return true;
}
}
@@ -60,9 +58,22 @@ trait FreeTrialHandlerTrait {
$product = wc_get_product();
- return $product
- && WC_Subscriptions_Product::is_subscription( $product )
- && WC_Subscriptions_Product::get_trial_length( $product ) > 0;
+ if ( ! $product || ! WC_Subscriptions_Product::is_subscription( $product ) ) {
+ return false;
+ }
+
+ if ( WC_Subscriptions_Product::get_trial_length( $product ) > 0 ) {
+ return true;
+ }
+
+ if ( WC_Subscriptions_Synchroniser::is_product_synced( $product ) && ! WC_Subscriptions_Synchroniser::is_payment_upfront( $product ) ) {
+ $date = WC_Subscriptions_Synchroniser::calculate_first_payment_date( $product, 'timestamp' );
+ if ( ! WC_Subscriptions_Synchroniser::is_today( $date ) ) {
+ return true;
+ }
+ }
+
+ return false;
}
/**
@@ -82,13 +93,6 @@ trait FreeTrialHandlerTrait {
$subs = wcs_get_subscriptions_for_order( $wc_order );
- return ! empty(
- array_filter(
- $subs,
- function ( WC_Subscription $sub ): bool {
- return (float) $sub->get_total_initial_payment() <= 0;
- }
- )
- );
+ return ! empty( $subs );
}
}
diff --git a/modules/ppcp-subscription/src/Helper/SubscriptionHelper.php b/modules/ppcp-subscription/src/Helper/SubscriptionHelper.php
index 0d9366a7b..cb4521f37 100644
--- a/modules/ppcp-subscription/src/Helper/SubscriptionHelper.php
+++ b/modules/ppcp-subscription/src/Helper/SubscriptionHelper.php
@@ -120,8 +120,11 @@ class SubscriptionHelper {
* @return bool Whether page is change subscription or not.
*/
public function is_subscription_change_payment(): bool {
- $pay_for_order = filter_input( INPUT_GET, 'pay_for_order', FILTER_SANITIZE_STRING );
- $change_payment_method = filter_input( INPUT_GET, 'change_payment_method', FILTER_SANITIZE_STRING );
- return ( isset( $pay_for_order ) && isset( $change_payment_method ) );
+ // phpcs:ignore WordPress.Security.NonceVerification.Recommended
+ if ( ! isset( $_GET['pay_for_order'] ) || ! isset( $_GET['change_payment_method'] ) ) {
+ return false;
+ }
+
+ return true;
}
}
diff --git a/modules/ppcp-subscription/src/RenewalHandler.php b/modules/ppcp-subscription/src/RenewalHandler.php
index 2b96b2f34..851ea5f7d 100644
--- a/modules/ppcp-subscription/src/RenewalHandler.php
+++ b/modules/ppcp-subscription/src/RenewalHandler.php
@@ -12,6 +12,7 @@ namespace WooCommerce\PayPalCommerce\Subscription;
use WooCommerce\PayPalCommerce\ApiClient\Endpoint\OrderEndpoint;
use WooCommerce\PayPalCommerce\ApiClient\Entity\Order;
use WooCommerce\PayPalCommerce\ApiClient\Entity\PaymentToken;
+use WooCommerce\PayPalCommerce\ApiClient\Exception\PayPalApiException;
use WooCommerce\PayPalCommerce\ApiClient\Factory\PayerFactory;
use WooCommerce\PayPalCommerce\ApiClient\Factory\PurchaseUnitFactory;
use WooCommerce\PayPalCommerce\ApiClient\Factory\ShippingPreferenceFactory;
@@ -141,17 +142,27 @@ class RenewalHandler {
public function renew( \WC_Order $wc_order ) {
try {
$this->process_order( $wc_order );
- } catch ( \Exception $error ) {
- $this->logger->error(
- sprintf(
- 'An error occurred while trying to renew the subscription for order %1$d: %2$s',
- $wc_order->get_id(),
- $error->getMessage()
- )
+ } catch ( \Exception $exception ) {
+ $error = $exception->getMessage();
+ if ( is_a( $exception, PayPalApiException::class ) ) {
+ $error = $exception->get_details( $error );
+ }
+
+ $wc_order->update_status(
+ 'failed',
+ $error
);
+ $error_message = sprintf(
+ 'An error occurred while trying to renew the subscription for order %1$d: %2$s',
+ $wc_order->get_id(),
+ $error
+ );
+ $this->logger->error( $error_message );
+
return;
}
+
$this->logger->info(
sprintf(
'Renewal for order %d is completed.',
diff --git a/modules/ppcp-subscription/src/SubscriptionModule.php b/modules/ppcp-subscription/src/SubscriptionModule.php
index 6f152307b..3ca2cdfa1 100644
--- a/modules/ppcp-subscription/src/SubscriptionModule.php
+++ b/modules/ppcp-subscription/src/SubscriptionModule.php
@@ -100,7 +100,8 @@ class SubscriptionModule implements ModuleInterface {
add_filter(
'ppcp_create_order_request_body_data',
function( array $data ) use ( $c ) {
- $wc_order_action = filter_input( INPUT_POST, 'wc_order_action', FILTER_SANITIZE_STRING ) ?? '';
+ // phpcs:ignore WordPress.Security.NonceVerification.Missing
+ $wc_order_action = wc_clean( wp_unslash( $_POST['wc_order_action'] ?? '' ) );
if (
$wc_order_action === 'wcs_process_renewal'
&& isset( $data['payment_source']['token'] ) && $data['payment_source']['token']['type'] === 'PAYMENT_METHOD_TOKEN'
@@ -175,9 +176,9 @@ class SubscriptionModule implements ModuleInterface {
try {
$tokens = $payment_token_repository->all_for_user_id( $subscription->get_customer_id() );
if ( $tokens ) {
- $subscription_id = $subscription->get_id();
$latest_token_id = end( $tokens )->id() ? end( $tokens )->id() : '';
- update_post_meta( $subscription_id, 'payment_token_id', $latest_token_id, true );
+ $subscription->update_meta_data( 'payment_token_id', $latest_token_id );
+ $subscription->save();
}
} catch ( RuntimeException $error ) {
$message = sprintf(
@@ -216,7 +217,7 @@ class SubscriptionModule implements ModuleInterface {
&& PayPalGateway::ID === $id
&& $subscription_helper->is_subscription_change_payment()
) {
- $tokens = $payment_token_repository->all_for_user_id( get_current_user_id() );
+ $tokens = $payment_token_repository->all_for_user_id( get_current_user_id() );
if ( ! $tokens || ! $payment_token_repository->tokens_contains_paypal( $tokens ) ) {
return esc_html__(
'No PayPal payments saved, in order to use a saved payment you first need to create it through a purchase.',
@@ -224,10 +225,10 @@ class SubscriptionModule implements ModuleInterface {
);
}
- $output = sprintf(
- '