From bbc3bb894666bbacc2fe14b722dc1668d162e723 Mon Sep 17 00:00:00 2001 From: Prappo Prince Date: Wed, 7 Aug 2024 11:41:46 +0000 Subject: [PATCH] Sync from pro --- languages/stripe.pot | 211 +++- .../Payment/AbstractPaymentCreateRoute.php | 77 ++ .../Internal/Payment/AbstractPaymentRoute.php | 83 ++ .../Payment/Exception/ValidationException.php | 23 + .../Payment/LitePaymentCreateRoute.php | 307 +++++ .../Payment/Traits/CheckoutSessionTrait.php | 110 ++ .../Internal/Payment/Traits/CustomerTrait.php | 274 +++++ .../Internal/Payment/Traits/InvoiceTrait.php | 185 +++ .../Payment/Traits/PaymentIntentTrait.php | 188 +++ .../Payment/Traits/SubscriptionTrait.php | 578 ++++++++++ .../Internal/Payment/Utils/CouponUtils.php | 213 ++++ .../Payment/Utils/FeeRecoveryUtils.php | 216 ++++ .../Payment/Utils/PaymentRequestUtils.php | 1010 +++++++++++++++++ .../Payment/Utils/SchemaSanitizationUtils.php | 39 + .../Internal/Payment/Utils/SchemaUtils.php | 621 ++++++++++ .../Payment/Utils/SchemaValidationUtils.php | 524 +++++++++ .../Internal/Payment/Utils/TaxUtils.php | 166 +++ .../Payment/Utils/TokenValidationUtils.php | 212 ++++ 18 files changed, 5032 insertions(+), 5 deletions(-) create mode 100644 src/RestApi/Internal/Payment/AbstractPaymentCreateRoute.php create mode 100644 src/RestApi/Internal/Payment/AbstractPaymentRoute.php create mode 100644 src/RestApi/Internal/Payment/Exception/ValidationException.php create mode 100644 src/RestApi/Internal/Payment/LitePaymentCreateRoute.php create mode 100644 src/RestApi/Internal/Payment/Traits/CheckoutSessionTrait.php create mode 100644 src/RestApi/Internal/Payment/Traits/CustomerTrait.php create mode 100644 src/RestApi/Internal/Payment/Traits/InvoiceTrait.php create mode 100644 src/RestApi/Internal/Payment/Traits/PaymentIntentTrait.php create mode 100644 src/RestApi/Internal/Payment/Traits/SubscriptionTrait.php create mode 100644 src/RestApi/Internal/Payment/Utils/CouponUtils.php create mode 100644 src/RestApi/Internal/Payment/Utils/FeeRecoveryUtils.php create mode 100644 src/RestApi/Internal/Payment/Utils/PaymentRequestUtils.php create mode 100644 src/RestApi/Internal/Payment/Utils/SchemaSanitizationUtils.php create mode 100644 src/RestApi/Internal/Payment/Utils/SchemaUtils.php create mode 100644 src/RestApi/Internal/Payment/Utils/SchemaValidationUtils.php create mode 100644 src/RestApi/Internal/Payment/Utils/TaxUtils.php create mode 100644 src/RestApi/Internal/Payment/Utils/TokenValidationUtils.php diff --git a/languages/stripe.pot b/languages/stripe.pot index a7608954..95f73aaa 100644 --- a/languages/stripe.pot +++ b/languages/stripe.pot @@ -9,7 +9,7 @@ msgstr "" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" -"POT-Creation-Date: 2024-08-07T11:33:34+00:00\n" +"POT-Creation-Date: 2024-08-07T11:40:25+00:00\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "X-Generator: WP-CLI 2.10.0\n" "X-Domain: stripe\n" @@ -10343,6 +10343,7 @@ msgid "" msgstr "" #: includes/core/payments/customer.php:213 +#: src/RestApi/Internal/Payment/Traits/CustomerTrait.php:219 msgid "Please select a valid Tax ID type." msgstr "" @@ -11695,6 +11696,7 @@ msgid "Disabling CAPTCHA will make your site more vulnerable to spam and fraudul msgstr "" #: includes/core/rest-api/class-controller.php:104 +#: src/RestApi/Internal/Payment/LitePaymentCreateRoute.php:72 msgid "Sorry, you have made too many requests. Please try again later." msgstr "" @@ -11704,6 +11706,8 @@ msgstr "" #: includes/core/rest-api/v2/class-paymentintent-controller.php:143 #: src/AntiSpam/EmailVerification.php:578 #: src/AntiSpam/EmailVerification.php:672 +#: src/RestApi/Internal/Payment/Traits/PaymentIntentTrait.php:142 +#: src/RestApi/Internal/Payment/Utils/CouponUtils.php:119 msgid "Invalid request. Please try again." msgstr "" @@ -12832,6 +12836,7 @@ msgid "Request failed." msgstr "" #: src/Block/ManageSubscriptionsBlock.php:63 +#: src/RestApi/Internal/Payment/LitePaymentCreateRoute.php:82 #: src/RestApi/Internal/SubscriptionsManagement/SendSubscriptions.php:146 msgid "Invalid CAPTCHA. Please try again." msgstr "" @@ -13275,6 +13280,206 @@ msgstr "" msgid "Counting results via a query is not allowed. Use the ::count() method." msgstr "" +#: src/RestApi/Internal/Payment/Traits/InvoiceTrait.php:138 +#: views/smart-tag-receipt.php:159 +msgid "Processing fee" +msgstr "" + +#: src/RestApi/Internal/Payment/Traits/SubscriptionTrait.php:518 +msgid "Plan Setup Fee" +msgstr "" + +#: src/RestApi/Internal/Payment/Traits/SubscriptionTrait.php:519 +msgid "Initial Setup Fee" +msgstr "" + +#: src/RestApi/Internal/Payment/Utils/CouponUtils.php:58 +#: src/RestApi/Internal/Payment/Utils/CouponUtils.php:68 +#: src/RestApi/Internal/Payment/Utils/CouponUtils.php:85 +msgid "Sorry, this coupon not valid." +msgstr "" + +#: src/RestApi/Internal/Payment/Utils/CouponUtils.php:105 +msgid "Sorry, this coupon not valid for the selected currency." +msgstr "" + +#: src/RestApi/Internal/Payment/Utils/CouponUtils.php:133 +#: src/RestApi/Internal/Payment/Utils/CouponUtils.php:143 +msgid "Sorry, this coupon puts the total below the required minimum amount." +msgstr "" + +#. translators: %1$s Coupon code. %2$s discount amount. +#: src/RestApi/Internal/Payment/Utils/CouponUtils.php:155 +msgid "%1$s: %2$s off" +msgstr "" + +#: src/RestApi/Internal/Payment/Utils/SchemaUtils.php:35 +msgid "The payment form ID to use for the payment." +msgstr "" + +#: src/RestApi/Internal/Payment/Utils/SchemaUtils.php:62 +msgid "The payment form values to use for the payment." +msgstr "" + +#: src/RestApi/Internal/Payment/Utils/SchemaUtils.php:92 +msgid "A security token (usually from a CAPTCHA service) to verify the payment request." +msgstr "" + +#: src/RestApi/Internal/Payment/Utils/SchemaUtils.php:117 +msgid "The ID of the price to use for the payment." +msgstr "" + +#: src/RestApi/Internal/Payment/Utils/SchemaUtils.php:145 +msgid "The purchase quantity for the payment." +msgstr "" + +#: src/RestApi/Internal/Payment/Utils/SchemaUtils.php:172 +msgid "The custom amount for the payment." +msgstr "" + +#: src/RestApi/Internal/Payment/Utils/SchemaUtils.php:203 +msgid "The currency for the payment." +msgstr "" + +#: src/RestApi/Internal/Payment/Utils/SchemaUtils.php:232 +msgid "If the user has opted in to a recurring payment." +msgstr "" + +#: src/RestApi/Internal/Payment/Utils/SchemaUtils.php:256 +msgid "If the user has opted in to pay processing fees." +msgstr "" + +#: src/RestApi/Internal/Payment/Utils/SchemaUtils.php:280 +msgid "The coupon code to apply to the payment." +msgstr "" + +#: src/RestApi/Internal/Payment/Utils/SchemaUtils.php:310 +msgid "The subtotal of the payment." +msgstr "" + +#: src/RestApi/Internal/Payment/Utils/SchemaUtils.php:334 +msgid "The customers's billing address." +msgstr "" + +#: src/RestApi/Internal/Payment/Utils/SchemaUtils.php:341 +msgid "Name." +msgstr "" + +#: src/RestApi/Internal/Payment/Utils/SchemaUtils.php:367 +msgid "The customers's shipping address." +msgstr "" + +#: src/RestApi/Internal/Payment/Utils/SchemaUtils.php:374 +msgid "Recipient name." +msgstr "" + +#: src/RestApi/Internal/Payment/Utils/SchemaUtils.php:378 +msgid "Recipient phone number." +msgstr "" + +#: src/RestApi/Internal/Payment/Utils/SchemaUtils.php:404 +msgid "The payment method type used to make a payment." +msgstr "" + +#: src/RestApi/Internal/Payment/Utils/SchemaUtils.php:431 +msgid "The payment's customer." +msgstr "" + +#: src/RestApi/Internal/Payment/Utils/SchemaUtils.php:455 +msgid "The payment's subscription ID." +msgstr "" + +#: src/RestApi/Internal/Payment/Utils/SchemaUtils.php:479 +msgid "The payment's SetupIntent ID." +msgstr "" + +#: src/RestApi/Internal/Payment/Utils/SchemaUtils.php:503 +msgid "The payment method's ID." +msgstr "" + +#: src/RestApi/Internal/Payment/Utils/SchemaUtils.php:527 +msgid "The payment's subscription key." +msgstr "" + +#: src/RestApi/Internal/Payment/Utils/SchemaUtils.php:551 +msgid "The payment object to update." +msgstr "" + +#: src/RestApi/Internal/Payment/Utils/SchemaUtils.php:575 +msgid "The payment's tax calculation." +msgstr "" + +#: src/RestApi/Internal/Payment/Utils/SchemaUtils.php:595 +msgid "Address." +msgstr "" + +#: src/RestApi/Internal/Payment/Utils/SchemaUtils.php:599 +msgid "Apartment, suite, etc." +msgstr "" + +#: src/RestApi/Internal/Payment/Utils/SchemaUtils.php:603 +msgid "City." +msgstr "" + +#: src/RestApi/Internal/Payment/Utils/SchemaUtils.php:607 +msgid "State/County code, or name of the state, county, province, or district." +msgstr "" + +#: src/RestApi/Internal/Payment/Utils/SchemaUtils.php:611 +msgid "Postal code." +msgstr "" + +#: src/RestApi/Internal/Payment/Utils/SchemaUtils.php:615 +msgid "Country/Region code in ISO 3166-1 alpha-2 format." +msgstr "" + +#: src/RestApi/Internal/Payment/Utils/SchemaValidationUtils.php:382 +msgid "The provided form ID is invalid." +msgstr "" + +#. translators: %s is replaced with the required field. +#: src/RestApi/Internal/Payment/Utils/SchemaValidationUtils.php:410 +msgid "The required field \"%s\" is missing." +msgstr "" + +#. translators: %s is replaced with the required field. +#: src/RestApi/Internal/Payment/Utils/SchemaValidationUtils.php:416 +msgid "The required field \"%s\" cannot be empty." +msgstr "" + +#: src/RestApi/Internal/Payment/Utils/SchemaValidationUtils.php:424 +msgid "Tax ID and Tax ID Type are required fields." +msgstr "" + +#: src/RestApi/Internal/Payment/Utils/SchemaValidationUtils.php:435 +msgid "Tax ID and Tax ID Type cannot be empty." +msgstr "" + +#. translators: %s is replaced with the address type (billing or shipping). +#: src/RestApi/Internal/Payment/Utils/SchemaValidationUtils.php:446 +msgid "The %s address name is required." +msgstr "" + +#. translators: %s is replaced with the address type (billing or shipping). +#: src/RestApi/Internal/Payment/Utils/SchemaValidationUtils.php:451 +msgid "The %s address country is required." +msgstr "" + +#. translators: %s is replaced with the address type (billing or shipping). +#: src/RestApi/Internal/Payment/Utils/SchemaValidationUtils.php:456 +msgid "The %s address postal code is required." +msgstr "" + +#. translators: %s is replaced with the field type (email, customer_name, or telephone). +#: src/RestApi/Internal/Payment/Utils/SchemaValidationUtils.php:466 +msgid "The %s field is required." +msgstr "" + +#. translators: %s is replaced with the field type (email, customer_name, or telephone). +#: src/RestApi/Internal/Payment/Utils/SchemaValidationUtils.php:475 +msgid "The %s field can not be empty." +msgstr "" + #: src/RestApi/Internal/Report/GrossVolumePeriodOverPeriodReport.php:152 #: src/RestApi/Internal/Report/SuccessfulPaymentsPeriodOverPeriodReport.php:142 msgid "Current period" @@ -14054,10 +14259,6 @@ msgstr "" msgid "Discount" msgstr "" -#: views/smart-tag-receipt.php:159 -msgid "Processing fee" -msgstr "" - #: views/smart-tag-receipt.php:179 msgid "Tax" msgstr "" diff --git a/src/RestApi/Internal/Payment/AbstractPaymentCreateRoute.php b/src/RestApi/Internal/Payment/AbstractPaymentCreateRoute.php new file mode 100644 index 00000000..39c2bb36 --- /dev/null +++ b/src/RestApi/Internal/Payment/AbstractPaymentCreateRoute.php @@ -0,0 +1,77 @@ +application_fee = $application_fee; + } + + /** + * Determines if the current request should be able to create a payment. + * + * This occurs _before_ argument validation is done. This should be where + * user authentication permission checks are done. + * + * @since 4.7.0 + * + * @param \WP_REST_Request $request The payment request. + * @return bool + */ + public function create_payment_permissions_check( $request ) { + return true; + } + + /** + * Creates a payment for the given request. + * + * @since 4.7.0 + * + * @param \WP_REST_Request $request The payment request. + * @return \WP_REST_Response + */ + abstract public function create_payment( $request ); + +} diff --git a/src/RestApi/Internal/Payment/AbstractPaymentRoute.php b/src/RestApi/Internal/Payment/AbstractPaymentRoute.php new file mode 100644 index 00000000..381a3126 --- /dev/null +++ b/src/RestApi/Internal/Payment/AbstractPaymentRoute.php @@ -0,0 +1,83 @@ + 'register_route', + ); + } + + /** + * Registers the REST API routes for the endpoint. + * + * @since 4.7.0 + * + * @return void + */ + abstract public function register_route(); + + /** + * Determines if the REST API request is valid based on the current rate limit. + * + * @since 4.7.0 + * + * @param \WP_REST_Request $request The payment request. + * @return bool + */ + protected function validate_rate_limit( $request ) { + if ( current_user_can( 'manage_options' ) ) { + return true; + } + + $has_exceeded_rate_limit = false; + + /** + * Filters if the current IP address has exceeded the rate limit. + * + * @since 3.9.5 + * @since 4.7.0 Added $request parameter. + * + * @param bool $has_exceeded_rate_limit + * @param \WP_REST_Request $request The payment request. + */ + $has_exceeded_rate_limit = apply_filters( + 'simpay_has_exceeded_rate_limit', + $has_exceeded_rate_limit, + $request + ); + + return ! $has_exceeded_rate_limit; + } + +} diff --git a/src/RestApi/Internal/Payment/Exception/ValidationException.php b/src/RestApi/Internal/Payment/Exception/ValidationException.php new file mode 100644 index 00000000..221330b1 --- /dev/null +++ b/src/RestApi/Internal/Payment/Exception/ValidationException.php @@ -0,0 +1,23 @@ + WP_REST_Server::CREATABLE, + 'callback' => array( $this, 'create_payment' ), + 'permission_callback' => array( + $this, + 'create_payment_permissions_check', + ), + 'args' => array( + 'form_id' => SchemaUtils::get_form_id_schema(), + 'price_id' => SchemaUtils::get_price_id_schema(), + 'quantity' => SchemaUtils::get_quantity_schema(), + 'token' => SchemaUtils::get_token_schema(), + ), + ); + + register_rest_route( + $this->namespace, + $this->route, + $create_item_route + ); + } + + /** + * {@inheritdoc} + * + * @throws \Simplepay\Core\RestApi\Internal\Payment\Exception\ValidationException If a validation error occurs. + */ + public function create_payment( $request ) { + try { + // Check rate limit. + // This is done here to avoid double increments (in authorization callback) + // or non-human-friendly error messages (in API argument validation). + if ( false === $this->validate_rate_limit( $request ) ) { + throw new ValidationException( + __( + 'Sorry, you have made too many requests. Please try again later.', + 'stripe' + ) + ); + } + + // Check form token. + if ( false === TokenValidationUtils::validate_token( $request ) ) { + throw new ValidationException( + __( 'Invalid CAPTCHA. Please try again.', 'stripe' ) + ); + } + + $payment = $this->create_checkout_session( + $request, + $this->get_checkout_session_args( $request, null ) + ); + + return new WP_REST_Response( + array( + 'redirect' => $payment->url, + ) + ); + } catch ( ValidationException $e ) { + return new WP_REST_Response( + array( + 'message' => Utils\handle_exception_message( $e ), + ), + rest_authorization_required_code() + ); + } catch ( Exception $e ) { + return new WP_REST_Response( + array( + 'message' => Utils\handle_exception_message( $e ), + ), + 400 + ); + } + } + + /** + * Returns arguments used to create a Checkout Session. + * + * These arguments are available in both Lite and Pro. + * + * @since 4.7.0 + * + * @param \WP_REST_Request $request The payment request. + * @param null|\SimplePay\Vendor\Stripe\Customer $customer The Stripe customer. + * @return array + */ + protected function get_checkout_session_args( $request, $customer ) { + $form = PaymentRequestUtils::get_form( $request ); + $price = PaymentRequestUtils::get_price( $request ); + $quantity = PaymentRequestUtils::get_quantity( $request ); + + $session_args = array( + 'customer_creation' => 'always', + 'locale' => $form->locale, + 'metadata' => array( + 'simpay_form_id' => $form->id, + ), + 'mode' => 'payment', + ); + + // Collect Billing Address. + if ( true === $form->enable_billing_address ) { + $session_args['billing_address_collection'] = 'required'; + } else { + $session_args['billing_address_collection'] = 'auto'; + } + + // Collect Shipping Address. + if ( true === $form->enable_shipping_address ) { + $session_args['shipping_address_collection'] = array( + 'allowed_countries' => i18n\get_available_shipping_address_countries(), + ); + } + + // Success URL. + $session_args['success_url'] = add_query_arg( + 'session_id', + '{CHECKOUT_SESSION_ID}', + PaymentRequestUtils::get_return_url( $request ) + ); + + // Cancel URL. + $session_args['cancel_url'] = PaymentRequestUtils::get_cancel_url( $request ); + + // Submit type. + if ( ! empty( $form->checkout_submit_type ) ) { + $session_args['submit_type'] = $form->checkout_submit_type; + } + + // Phone number. + $enable_phone = 'yes' === simpay_get_saved_meta( + $form->id, + '_enable_phone', + 'no' + ); + + if ( true === $enable_phone ) { + $session_args['phone_number_collection'] = array( + 'enabled' => true, + ); + } + + // Line item. + $item = array( + 'price' => $price->id, + 'quantity' => $quantity, + ); + + $enable_quantity = 'yes' === simpay_get_saved_meta( + $form->id, + '_enable_quantity', + 'no' + ); + + if ( $enable_quantity ) { + $item['adjustable_quantity'] = array( + 'enabled' => true, + 'minimum' => 1, + ); + } + + $session_args['line_items'] = array( $item ); + + // Payment method types. + /** @var array>> $payment_methods */ + $payment_methods = simpay_get_saved_meta( + $form->id, + '_payment_methods', + array() + ); + + if ( empty( $payment_methods ) || ! isset( $payment_methods['stripe-checkout'] ) ) { + $session_args['payment_method_types'] = array( 'card' ); + } else { + $session_args['payment_method_types'] = array_keys( + $payment_methods['stripe-checkout'] + ); + } + + // Build additional data used to create the underlying Payment Intent. + $payment_intent_data = PaymentRequestUtils::get_payment_intent_data( + $request + ); + + // ... add an application fee, if needed. + if ( $this->application_fee->has_application_fee() ) { + $payment_intent_data['application_fee_amount'] = + $this->application_fee->get_application_fee_amount( + $price->unit_amount + ); + } + + $session_args['payment_intent_data'] = $payment_intent_data; + + $session_args['custom_fields'] = $this->get_custom_fields( $request ); + + return $session_args; + } + + /** + * Returns a list of custom fields to be added to the Checkout Session. + * + * @since 4.7.7 + * + * @param \WP_REST_Request $request The payment request. + * @return array> + */ + private function get_custom_fields( $request ) { + $form = PaymentRequestUtils::get_form( $request ); + $fields = $form->custom_fields; + + $fields = array_filter( + $fields, + function( $field ) { + return 'payment_button' !== $field['type']; + } + ); + + if ( empty( $fields ) ) { + return array(); + } + + $custom_fields = array(); + + foreach ( $fields as $k => $field ) { + $type = $field['type']; + + // Use the label as the key, if it exists, or create one from the type. + $label = ! empty( $field['label'] ) + ? $field['label'] + : sprintf( '%s-%d', $type, $k ); + + // Create a key from the label. + $key = preg_replace( "/[^a-zA-Z0-9]/", '', $label ); + + $args = array( + 'key' => $key, + 'label' => array( + 'type' => 'custom', + 'custom' => $label, + ), + 'type' => 'number' === $type ? 'numeric' : $type, + 'optional' => ! isset( $field['required'] ), + ); + + // Add dropdown options, if needed. + if ( 'dropdown' === $type ) { + $options = $field['options']; + $options = explode( simpay_list_separator(), $options ); + $options = array_map( 'trim', $options ); + $options = array_filter( $options ); + + $args['dropdown']['options'] = array_map( + function( $option ) { + return array( + 'label' => $option, + 'value' => preg_replace( "/[^a-zA-Z0-9]/", '', $option ), + ); + }, + $options + ); + } + + $custom_fields[] = $args; + } + + return $custom_fields; + } + +} diff --git a/src/RestApi/Internal/Payment/Traits/CheckoutSessionTrait.php b/src/RestApi/Internal/Payment/Traits/CheckoutSessionTrait.php new file mode 100644 index 00000000..2f78abb9 --- /dev/null +++ b/src/RestApi/Internal/Payment/Traits/CheckoutSessionTrait.php @@ -0,0 +1,110 @@ + $session_args Checkout Session arguments. + * @return \SimplePay\Vendor\Stripe\Checkout\Session + */ + private function create_checkout_session( $request, $session_args ) { + $form = PaymentRequestUtils::get_form( $request ); + $form_values = PaymentRequestUtils::get_form_values( $request ); + + /** @var string|null $customer_id */ + $customer_id = isset( $session_args['customer'] ) + ? $session_args['customer'] + : null; + + /** + * Filters arguments used to create a Checkout Session from a payment form request. + * + * @since 3.6.0 + * + * @param array $session_args Checkout Session arguments. + * @param \SimplePay\Core\Abstracts\Form $form Form instance. + * @param array $form_data Deprecated. + * @param array $form_values Form values. + * @param string|null $customer_id Customer ID, if being used. + */ + $session_args = apply_filters( + 'simpay_get_session_args_from_payment_form_request', + $session_args, + $form, + array(), + $form_values, + $customer_id + ); + + /** + * Allows processing before a Checkout\Session is created from a payment form request. + * + * @since 3.6.0 + * + * @param array $session_args Checkout Session arguments. + * @param \SimplePay\Core\Abstracts\Form $form Form instance. + * @param array $form_data Deprecated. + * @param array $form_values Form values. + * @param string|null $customer_id Customer ID, if being used. + */ + do_action( + 'simpay_before_checkout_session_from_payment_form_request', + $session_args, + $form, + array(), + $form_values, + $customer_id + ); + + $session = API\CheckoutSessions\create( + $session_args, + $form->get_api_request_args() + ); + + /** + * Allows further processing after a Checkout\Session is created from a payment form request. + * + * @since 3.6.0 + * + * @param \SimplePay\Vendor\Stripe\Checkout\Session $session Checkout Session. + * @param \SimplePay\Core\Abstracts\Form $form Form instance. + * @param array $form_data Deprecated. + * @param array $form_values Form values. + * @param string|null $customer_id Customer ID, if being used. + */ + do_action( + 'simpay_after_checkout_session_from_payment_form_request', + $session, + $form, + array(), + $form_values, + $customer_id + ); + + return $session; + } + +} diff --git a/src/RestApi/Internal/Payment/Traits/CustomerTrait.php b/src/RestApi/Internal/Payment/Traits/CustomerTrait.php new file mode 100644 index 00000000..b30a0c00 --- /dev/null +++ b/src/RestApi/Internal/Payment/Traits/CustomerTrait.php @@ -0,0 +1,274 @@ +get_display_type() ) { + return true; + } + + /** @var array> $custom_fields */ + $custom_fields = simpay_get_saved_meta( + PaymentRequestUtils::get_form( $request )->id, + '_custom_fields', + array() + ); + + return ( + array_key_exists( 'customer_name', $custom_fields ) || + array_key_exists( 'email', $custom_fields ) || + array_key_exists( 'telephone', $custom_fields ) || + array_key_exists( 'address', $custom_fields ) || + array_key_exists( 'coupon', $custom_fields ) + ); + } + + /** + * Returns the customer arguments for the given request. + * + * @since 4.7.0 + * + * @param \WP_REST_Request $request The payment request. + * @return null|\SimplePay\Vendor\Stripe\Customer + */ + private function get_customer( $request ) { + if ( false === $this->is_using_customer( $request ) ) { + return null; + } + + // @todo in the future we may allow using an existing customer. + return $this->create_customer( $request ); + } + + /** + * Creates a Customer for the given request. + * + * @since 4.7.0 + * + * @param \WP_REST_Request $request The payment request. + * @return \SimplePay\Vendor\Stripe\Customer + */ + private function create_customer( $request ) { + $form = PaymentRequestUtils::get_form( $request ); + $form_values = PaymentRequestUtils::get_form_values( $request ); + $customer_args = $this->get_customer_args( $request ); + + /** + * Filters arguments used to create a Customer from a payment form request. + * + * @since 3.6.0 + * + * @param array $customer_args Customer arguments. + * @param \SimplePay\Core\Abstracts\Form $form Form instance. + * @param array $arg2 Deprecated. + * @param array $form_values Form values. + * @return array + */ + $customer_args = apply_filters( + 'simpay_get_customer_args_from_payment_form_request', + $customer_args, + $form, + array(), + $form_values + ); + + /** + * Allow further processing before a Customer is created from a posted form. + * + * @since 3.6.0 + * + * @param array $customer_args Customer arguments. + * @param \SimplePay\Core\Abstracts\Form $form Form instance. + * @param array $arg2 Deprecated. + * @param array $form_values Form values. + */ + do_action( + 'simpay_before_customer_from_payment_form_request', + $customer_args, + $form, + array(), + $form_values + ); + + $customer = API\Customers\create( + $customer_args, + $form->get_api_request_args() + ); + + /** + * Allow further processing after a Customer is created from a posted form. + * + * @since 3.6.0 + * + * @param \SimplePay\Vendor\Stripe\Customer $customer Customer. + * @param \SimplePay\Core\Abstracts\Form $form Form instance. + * @param array $form_data Deprecated. + * @param array $form_values Form values. + */ + do_action( + 'simpay_after_customer_from_payment_form_request', + $customer, + $form, + array(), + $form_values + ); + + return $customer; + } + + /** + * Returns arguments for creating a Customer for the given request. + * + * @since 4.7.0 + * + * @param \WP_REST_Request $request The payment request. + * @return array + * @throws \Exception If the tax ID is not valid. + */ + private function get_customer_args( $request ) { + $form = PaymentRequestUtils::get_form( $request ); + $form_values = PaymentRequestUtils::get_form_values( $request ); + + $customer_args = array( + 'name' => null, + 'phone' => null, + 'email' => null, + 'metadata' => array( + 'simpay_form_id' => $form->id, + ), + 'tax' => array( + 'ip_address' => Utils\get_current_ip_address(), + ), + ); + + // Attach coupon to metadata. + if ( ! empty( $request->get_param( 'coupon_code' ) ) ) { + $customer_args['coupon'] = $request->get_param( 'coupon_code' ); + + // Clear Stripe object cache so dynamic values are available. + // @todo implement cache clearing within Stripe_Object_Query_Trait + // when it is available in this namespace. + delete_transient( 'simpay_stripe_' . $customer_args['coupon'] ); + } + + // Attach email. + if ( isset( $form_values['simpay_email'] ) ) { + /** @var string $email */ + $email = $form_values['simpay_email']; + $customer_args['email'] = sanitize_text_field( $email ); + } + + // Attach name. + if ( isset( $form_values['simpay_customer_name'] ) ) { + /** @var string $name */ + $name = $form_values['simpay_customer_name']; + $customer_args['name'] = sanitize_text_field( $name ); + } + + // Attach phone number. + if ( isset( $form_values['simpay_telephone'] ) ) { + /** @var string $phone */ + $phone = $form_values['simpay_telephone']; + $customer_args['phone'] = sanitize_text_field( $phone ); + } + + // Attach a Tax ID. + if ( isset( $form_values['simpay_tax_id'] ) ) { + /** @var string $tax_id_type */ + $tax_id_type = isset( $form_values['simpay_tax_id_type'] ) + ? $form_values['simpay_tax_id_type'] + : ''; + + $valid_tax_id_types = i18n\get_stripe_tax_id_types(); + + if ( false === array_key_exists( $tax_id_type, $valid_tax_id_types ) ) { + throw new Exception( + esc_html__( 'Please select a valid Tax ID type.', 'stripe' ) + ); + } + + /** @var string $tax_id */ + $tax_id = $form_values['simpay_tax_id']; + $tax_id = sanitize_text_field( $tax_id ); + + $customer_args['tax_id_data'] = array( + array( + 'type' => $tax_id_type, + 'value' => $tax_id, + ), + ); + } + + // Attach billing address. + /** @var array> */ + $billing_address = $request->get_param( 'billing_address' ); + + if ( $billing_address ) { + $customer_args['address'] = isset( $billing_address['address'] ) + ? $billing_address['address'] + : null; + + if ( isset( $billing_address['name'] ) && ! isset( $customer_args['name'] ) ) { + $customer_args['name'] = $billing_address['name']; + } + } + + // Attach shipping address. + /** @var array> */ + $shipping_address = $request->get_param( 'shipping_address' ); + + if ( $shipping_address ) { + $customer_args['shipping'] = $shipping_address; + + // Set a phone number if available. + $customer_args['shipping']['phone'] = isset( $customer_args['phone'] ) + ? $customer_args['phone'] + : null; + } + + // Remove null values, Stripe doesn't like them. + // Do this before Shipping, because we need a value for Shipping Name. + $customer_args = array_filter( + $customer_args, + function( $var ) { + return ! is_null( $var ); + } + ); + + return $customer_args; + } + +} diff --git a/src/RestApi/Internal/Payment/Traits/InvoiceTrait.php b/src/RestApi/Internal/Payment/Traits/InvoiceTrait.php new file mode 100644 index 00000000..24210dbc --- /dev/null +++ b/src/RestApi/Internal/Payment/Traits/InvoiceTrait.php @@ -0,0 +1,185 @@ +get_param( 'price_ids' ); + $form = PaymentRequestUtils::get_form( $request ); + $fields = $form->custom_fields; + $form_values = PaymentRequestUtils::get_form_values( $request ); + $tax_rates = simpay_get_payment_form_tax_rates( $form ); + $tax_status = get_post_meta( $form->id, '_tax_status', true ); + $tax_rate_ids = ! empty( $tax_rates ) + ? wp_list_pluck( $tax_rates, 'id' ) + : array(); + + $invoice_args = array( + 'customer' => $customer->id, + 'description' => $form->item_description, + 'metadata' => PaymentRequestUtils::get_payment_metadata( $request ), + 'payment_settings' => array( + 'payment_method_types' => PaymentRequestUtils::get_payment_method_types( $request ), + 'payment_method_options' => PaymentRequestUtils::get_payment_method_options( $request ), + ), + ); + + if ( 'fixed-global' === $tax_status ) { + $invoice_args['default_tax_rates'] = $tax_rate_ids; + } + + if ( 'automatic' === $tax_status ) { + $invoice_args['automatic_tax'] = array( + 'enabled' => true, + ); + } + + $invoice = Invoices\create( + $invoice_args, + $form->get_api_request_args() + ); + + $invoice_total = 0; + + foreach ( $items as $item ) { + /** @var array{ + * price_id: string, + * custom_amount: int, + * quantity: int, + * price_data: array{ + * label: string, + * currency: string, + * instance_id: string, + * } + * } $item + */ + + $price_data = new PriceOption( $item['price_data'], $form, $item['price_data']['instance_id'] ); + + $invoice_item_args = array( + 'customer' => $customer->id, + 'quantity' => $item['quantity'], + 'description' => count( $items ) === 1 + ? html_entity_decode( $form->company_name ) + : html_entity_decode( $price_data->get_display_label() ), + 'currency' => $item['price_data']['currency'], + 'invoice' => $invoice->id, + 'metadata' => array( + 'simpay_price_instance_id' => $item['price_data']['instance_id'], + ), + ); + + // if price_id start with 'simpay_' then it is a custom price. + if ( ! simpay_payment_form_prices_is_defined_price( $item['price_id'] ) ) { + $invoice_item_args['unit_amount'] = $item['custom_amount']; + + $invoice_total += $item['custom_amount'] * $item['quantity']; + } else { + $invoice_item_args['price'] = $item['price_id']; + + $invoice_total += $price_data->unit_amount * $item['quantity']; + } + + InvoiceItems\create( + $invoice_item_args, + $form->get_api_request_args() + ); + } + + // Add the fee recovery amount, if avilable. + if ( $form->has_fee_recovery() ) { + $fee_recovery = FeeRecoveryUtils::get_fee_recovery_unit_amount( + $request, + $invoice_total + ); + + $fee_recovery_description = ( + isset( $fields['fee_recovery_label'] ) && + ! empty( $fields['fee_recovery_label'] ) + ) + ? $fields['fee_recovery_label'] + : esc_html__( 'Processing fee', 'stripe' ); + + InvoiceItems\create( + array( + 'customer' => $customer->id, + 'quantity' => 1, + 'description' => $fee_recovery_description, + 'invoice' => $invoice->id, + 'unit_amount' => $fee_recovery, + ), + $form->get_api_request_args() + ); + } + + // Add the application fee, if needed. + if ( $this->application_fee->has_application_fee() ) { + $invoice_args['application_fee_amount'] = + $this->application_fee->get_application_fee_amount( $invoice_total ); + } + + $invoice = $invoice->finalizeInvoice(); + + /** + * Allow further processing after a Invoice is created from a posted form. + * + * @since 4.11.0 + * + * @param \SimplePay\Vendor\Stripe\Invoice $invoice Invoice. + * @param \SimplePay\Core\Abstracts\Form $form Form instance. + * @param array $form_data Deprecated. + * @param array $form_values Form values. + * @param string $customer Customer ID. + */ + do_action( + 'simpay_after_invoice_from_payment_form_request', + $invoice, + $form, + array(), + $form_values, + $customer->id + ); + + return PaymentIntents\retrieve( + (string) $invoice->payment_intent, + $form->get_api_request_args() + ); + } +} diff --git a/src/RestApi/Internal/Payment/Traits/PaymentIntentTrait.php b/src/RestApi/Internal/Payment/Traits/PaymentIntentTrait.php new file mode 100644 index 00000000..eea71db0 --- /dev/null +++ b/src/RestApi/Internal/Payment/Traits/PaymentIntentTrait.php @@ -0,0 +1,188 @@ +id; + + // Start with base arguments that are common to all payment intents, + // regardless of what generates them (Subscription, Checkout Session, etc). + $payment_intent_args = array_merge( + array( + 'metadata' => PaymentRequestUtils::get_payment_metadata( $request ), + 'expand' => array( + 'customer', + ), + ), + PaymentRequestUtils::get_payment_intent_data( $request ) + ); + + // Set the customer. + $payment_intent_args['customer'] = $customer_id; + + // Calculate the base amount, which is the unit amount multiplied by the quantity. + $unit_amount = PaymentRequestUtils::get_amount( $request ); + + // Add the application fee, if needed. + if ( $this->application_fee->has_application_fee() ) { + $application_fee = $this->application_fee->get_application_fee_amount( + $unit_amount + ); + + $payment_intent_args['application_fee_amount'] = $application_fee; + } + + $payment_intent_args['amount'] = $unit_amount; + $payment_intent_args['currency'] = $price->currency; + + // Add the allowed payment method types. + $payment_intent_args['payment_method_types'] = PaymentRequestUtils::get_payment_method_types( + $request + ); + + // Add the payment method options. + $payment_intent_args['payment_method_options'] = PaymentRequestUtils::get_payment_method_options( + $request + ); + + /** + * Filters arguments used to create a PaymentIntent from a payment form request. + * + * @since 3.6.0 + * + * @param array $payment_intent_args PaymentIntent arguments. + * @param \SimplePay\Core\Abstracts\Form $form Form instance. + * @param array $arg2 Deprecated. + * @param array $form_values Form values. + * @param string $customer Customer ID. + * @return array + */ + $payment_intent_args = apply_filters( + 'simpay_get_paymentintent_args_from_payment_form_request', + $payment_intent_args, + $form, + array(), + $form_values, + $customer_id + ); + + /** + * Allows processing before a PaymentIntent is created from a payment form request. + * + * @since 3.6.0 + * + * @param array $payment_intent_args Arguments used to create a PaymentIntent. + * @param \SimplePay\Core\Abstracts\Form $form Form instance. + * @param array $form_data Form data generated by the client. + * @param array $form_values Values of named fields in the payment form. + * @param string $customer_id Stripe Customer ID. + */ + do_action( + 'simpay_before_paymentintent_from_payment_form_request', + $payment_intent_args, + $form, + array(), + $form_values, + $customer_id + ); + + // If we are using automatic tax calculations, verify we have a tax + // calculation we can associate with this payment before creating. + $tax_status = get_post_meta( $form->id, '_tax_status', true ); + $tax_calc_id = PaymentRequestUtils::get_tax_calc_id( $request ); + + if ( 'automatic' === $tax_status && empty( $tax_calc_id ) ) { + throw new Exception( + __( 'Invalid request. Please try again.', 'stripe' ) + ); + } + + $payment_intent = API\PaymentIntents\create( + $payment_intent_args, + $form->get_api_request_args() + ); + + // ... and now associate the tax calculation with the payment via a transaction. + // @link https://stripe.com/docs/api/tax/transactions/create + if ( 'automatic' === $tax_status ) { + Stripe_API::request( + 'Tax\Transaction', + 'createFromCalculation', + array( + 'calculation' => $tax_calc_id, + 'reference' => $payment_intent->id, + ), + $form->get_api_request_args() + ); + } + + /** + * Allows further processing after a PaymentIntent is created from a payment form request. + * + * @since 3.6.0 + * + * @param \SimplePay\Vendor\Stripe\PaymentIntent $paymentintent Stripe PaymentIntent. + * @param \SimplePay\Core\Abstracts\Form $form Form instance. + * @param array $form_data Form data generated by the client. + * @param array $form_values Values of named fields in the payment form. + * @param string $customer_id Stripe Customer ID. + */ + do_action( + 'simpay_after_paymentintent_from_payment_form_request', + $payment_intent, + $form, + array(), + $form_values, + $customer_id + ); + + return $payment_intent; + } + +} diff --git a/src/RestApi/Internal/Payment/Traits/SubscriptionTrait.php b/src/RestApi/Internal/Payment/Traits/SubscriptionTrait.php new file mode 100644 index 00000000..e759c168 --- /dev/null +++ b/src/RestApi/Internal/Payment/Traits/SubscriptionTrait.php @@ -0,0 +1,578 @@ +id; + $form = PaymentRequestUtils::get_form( $request ); + $form_values = PaymentRequestUtils::get_form_values( $request ); + + $subscription_args = $this->get_subscription_args( $request ); + $subscription_args['payment_behavior'] = 'default_incomplete'; + $subscription_args['customer'] = $customer_id; + $subscription_args['items'] = $this->get_subscription_recurring_line_items( $request ); + $subscription_args['add_invoice_items'] = $this->get_subscription_additional_invoice_line_items( $request ); + $subscription_args['off_session'] = true; + $subscription_args['payment_settings'] = array( + 'payment_method_types' => PaymentRequestUtils::get_payment_method_types( $request ), + 'payment_method_options' => PaymentRequestUtils::get_payment_method_options( $request ), + 'save_default_payment_method' => 'on_subscription', + ); + $subscription_args['expand'] = array( + 'latest_invoice.payment_intent', + 'customer', + 'pending_setup_intent', + ); + + // Add the fee recovery line items, if needed. + // Instead of running this in `self::get_subscription_recurring_line_items()` + // and `self::get_subscription_additional_invoice_line_items()`, we run it here + // so we have access to all of the line items at once. + $is_covering_fees = PaymentRequestUtils::is_covering_fees( $request ); + + if ( + $form->has_fee_recovery() && + ( $form->has_forced_fee_recovery() || $is_covering_fees ) + ) { + $subscription_args = FeeRecoveryUtils::add_subscription_fee_recovery_line_items( + $request, + $subscription_args + ); + } + + // Add tax rates to line items, if needed. + + /** @var array $items */ + $items = $subscription_args['items']; + + $subscription_args['items'] = TaxUtils::add_tax_rates_to_line_items( + $request, + $items + ); + + /** @var array $add_invoice_items */ + $add_invoice_items = $subscription_args['add_invoice_items']; + + $subscription_args['add_invoice_items'] = TaxUtils::add_tax_rates_to_line_items( + $request, + $add_invoice_items + ); + + // Add automatic tax collection, if needed. + $subscription_args = TaxUtils::add_automatic_tax_args( + $request, + $subscription_args + ); + + // Add the application fee, if needed. + if ( $this->application_fee->has_application_fee() ) { + $subscription_args['application_fee_percent'] = + $this->application_fee->get_application_fee_percentage(); + } + + /** + * Filters arguments used to generate a Subscription from a payment form request. + * + * @since 3.6.0 + * + * @param array $subscription_args Subscription arguments. + * @param \SimplePay\Core\Abstracts\Form $form Form instance. + * @param array $arg2 Deprecated. + * @param array $form_values Form values. + * @param string $customer Customer ID. + * @return array + */ + $subscription_args = apply_filters( + 'simpay_get_subscription_args_from_payment_form_request', + $subscription_args, + $form, + array(), + $form_values, + $customer_id + ); + + /** + * Allow further processing before a Subscription is created from a posted form. + * + * @since 3.6.0 + * + * @param array $subscription_args Subscription arguments. + * @param \SimplePay\Core\Abstracts\Form $form Form instance. + * @param array $arg2 Deprecated. + * @param array $form_values Form values. + * @param string $customer Customer ID. + */ + do_action( + 'simpay_before_subscription_from_payment_form_request', + $subscription_args, + $form, + array(), + $form_values, + $customer_id + ); + + $subscription = API\Subscriptions\create( + $subscription_args, + $form->get_api_request_args() + ); + + /** + * Allow further processing after a Subscription is created from a posted form. + * + * @since 3.6.0 + * + * @param \SimplePay\Vendor\Stripe\Subscription $subscription Subscription.. + * @param \SimplePay\Core\Abstracts\Form $form Form instance. + * @param array $form_data Deprecated. + * @param array $form_values Form values. + * @param string $customer Customer ID. + */ + do_action( + 'simpay_after_subscription_from_payment_form_request', + $subscription, + $form, + array(), + $form_values, + $customer_id + ); + + return $subscription; + } + + /** + * Returns data for a Subscription for the given request. + * + * This is generic data that applies to a base PaymentIntent, regardless + * of what creates it (Checkout Session, etc). Additional arguments used + * just for Stripe Billing are added in `SubscriptionTrait::create_subscription()`. + * + * @since 4.7.0 + * + * @param \WP_REST_Request $request The payment request. + * @return array> + */ + private function get_subscription_args( $request ) { + $price = PaymentRequestUtils::get_price( $request ); + $form = PaymentRequestUtils::get_form( $request ); + $subscription_data = array(); + + // Set the trial period, if needed. + if ( $form->allows_multiple_line_items() ) { + $trial_period_days = PaymentRequestUtils::get_trial_period_days_from_price_ids( $request ); + if ( $trial_period_days > 0 ) { + $subscription_data['trial_period_days'] = $trial_period_days; + } + } elseif ( $price->recurring && isset( $price->recurring['trial_period_days'] ) ) { + $subscription_data['trial_period_days'] = $price->recurring['trial_period_days']; + } + + // Set the metadata with a combination of the standard payment metadata and + // additional subscription metadata. + $subscription_data['metadata'] = array_merge( + PaymentRequestUtils::get_payment_metadata( $request ), + array( + 'simpay_subscription_key' => $this->get_subscription_key(), + ) + ); + + // Add invoice limit metadata, if needed. + $max_charges = isset( $price->recurring['invoice_limit'] ) + ? $price->recurring['invoice_limit'] + : 0; + + if ( 0 !== $max_charges ) { + $charge_count = isset( $price->recurring['trial_period_days'] ) ? -1 : 0; + + $subscription_data['metadata']['simpay_charge_max'] = $max_charges; + $subscription_data['metadata']['simpay_charge_count'] = $charge_count; + } + + return $subscription_data; + } + + /** + * Returns recurring line items for the given request. + * + * @since 4.7.0 + * + * @param \WP_REST_Request $request The payment request. + * @return array> + */ + private function get_subscription_recurring_line_items( $request ) { + $line_items = array(); + $form = PaymentRequestUtils::get_form( $request ); + $price = PaymentRequestUtils::get_price( $request ); + $quantity = PaymentRequestUtils::get_quantity( $request ); + $custom_amount = PaymentRequestUtils::get_custom_unit_amount( $request ); + $tax_status = get_post_meta( $form->id, '_tax_status', true ); + $tax_behavior = get_post_meta( $form->id, '_tax_behavior', true ); + + // If multiple line items are allowed, then add them. + if ( $form->allows_multiple_line_items() ) { + $prices = PaymentRequestUtils::get_price_ids( $request ); + $price_items = array(); + + foreach ( $prices as $price ) { + // Skip one-time prices. These are added to the first invoice only + // via `self::get_subscription_additional_invoice_line_items()`. + if ( ! PaymentRequestUtils::is_recurring( $request, $price ) ) { + continue; + } + + $price_item = array( + 'quantity' => $price['quantity'], + 'metadata' => array( + 'simpay_price_instance_id' => $price['price_data']['instance_id'], + ), + ); + + if ( + ! is_null( $price['price_data']['recurring'] ) && + isset( + $price['price_data']['recurring']['interval'], + $price['price_data']['recurring']['interval_count'] + ) && + ( + ! simpay_payment_form_prices_is_defined_price( $price['price_id'] ) || + $price['is_optionally_recurring'] + ) + ) { + $price_item['price_data'] = array( + 'unit_amount' => $price['custom_amount'], + 'currency' => $price['price_data']['currency'], + 'product' => $price['price_data']['product_id'], + 'recurring' => array( + 'interval' => $price['price_data']['recurring']['interval'], + 'interval_count' => $price['price_data']['recurring']['interval_count'], + ), + 'tax_behavior' => 'automatic' === $tax_status + ? $tax_behavior + : 'unspecified', + ); + + } else { + $price_item['price'] = $price['price_id']; + } + + $price_items[] = $price_item; + } + + return $price_items; + } + + // Otherwise add a single line item. + $base_item = array( + 'quantity' => $quantity, + 'metadata' => array( + 'simpay_price_instance_id' => $price->instance_id, + ), + ); + + $custom_price_data = array( + 'unit_amount' => $custom_amount, + 'currency' => $price->currency, + 'recurring' => array( + 'interval' => $price->recurring['interval'], + 'interval_count' => $price->recurring['interval_count'], + ), + 'product' => $price->product_id, + 'tax_behavior' => 'automatic' === $tax_status + ? $tax_behavior + : 'unspecified', + ); + + // Set the base line item price when optionally recurring. + if ( true === PaymentRequestUtils::is_optionally_recurring( $request ) ) { + // Optional recurring option is a defined price. + if ( + isset( $price->recurring['id'] ) && + true === simpay_payment_form_prices_is_defined_price( + $price->recurring['id'] + ) + ) { + $base_item['price'] = $price->recurring['id']; + + // Optional recurring option is a custom amount. + } else { + $base_item['price_data'] = $custom_price_data; + } + + // Always recurring custom amount. + } elseif ( false === simpay_payment_form_prices_is_defined_price( $price->id ) ) { + $base_item['price_data'] = $custom_price_data; + + // Always recurring defined price. + } else { + $base_item['price'] = $price->id; + } + + // If this subscription is being created for Stripe Checkout, then check + // if quantity adjustment is allowed. + if ( 'stripe_checkout' === $form->get_display_type() ) { + $enable_quantity = 'yes' === simpay_get_saved_meta( + $form->id, + '_enable_quantity', + 'no' + ); + + if ( $enable_quantity ) { + $base_item['adjustable_quantity'] = array( + 'enabled' => true, + 'minimum' => 1, + ); + } + } + + $line_items[] = $base_item; + + return $line_items; + } + + /** + * Returns one-time line items (first invoice only) for the given request. + * + * Currently this is used for a recurring price option's setup fee(s). + * + * @since 4.7.0 + * + * @param \WP_REST_Request $request The payment request. + * @return array> + */ + private function get_subscription_additional_invoice_line_items( $request ) { + $form = PaymentRequestUtils::get_form( $request ); + + // If multiple line items are allowed, add non-recurring price options, or fees. + if ( $form->allows_multiple_line_items() ) { + $prices = PaymentRequestUtils::get_price_ids( $request ); + $price_items = array(); + + foreach ( $prices as $price ) { + // If the price is recurring, look for Setup Fees. + if ( PaymentRequestUtils::is_recurring( $request, $price ) ) { + + if ( + isset( $price['price_data']['line_items'] ) && + ! empty( $price['price_data']['line_items'] ) + ) { + foreach ( $price['price_data']['line_items'] as $fees ) { + $price_items[] = array( + 'price_data' => array( + 'unit_amount' => $fees['unit_amount'], + 'currency' => $price['price_data']['currency'], + 'product' => $price['price_data']['product_id'], + ), + 'quantity' => 1, + ); + } + } + + // Otherwise, skip. + continue; + } + + $price_item = array( + 'quantity' => $price['quantity'], + ); + + if ( ! simpay_payment_form_prices_is_defined_price( $price['price_id'] ) ) { + $price_item['price_data'] = array( + 'unit_amount' => $price['custom_amount'], + 'currency' => $price['price_data']['currency'], + 'product' => $price['price_data']['product_id'], + ); + } else { + $price_item['price'] = $price['price_id']; + } + + $price_items[] = $price_item; + } + + return $price_items; + } + + $line_items = array(); + + // Add a line item for the legacy per-plan fee, if needed. + $plan_fee = $this->get_subscription_additional_invoice_line_item( + $request, + 'plan' + ); + + if ( ! empty( $plan_fee ) ) { + $line_items[] = $plan_fee; + } + + // Add a line item for the initial setup fee, if needed. + $setup_fee = $this->get_subscription_additional_invoice_line_item( + $request, + 'setup' + ); + + if ( ! empty( $setup_fee ) ) { + $line_items[] = $setup_fee; + } + + return $line_items; + } + + /** + * Returns a one-time line item (first invoice only) for the given request and type. + * + * This is a helper method to make it easier to create a legacy "plan fee" + * alongside the still-supported "setup fee". + * + * @since 4.7.0 + * + * @param \WP_REST_Request $request The payment request. + * @param string $type The type of line item to return. Either "plan" or "setup". + * @return array + */ + private function get_subscription_additional_invoice_line_item( $request, $type ) { + $form = PaymentRequestUtils::get_form( $request ); + $price = PaymentRequestUtils::get_price( $request ); + $tax_status = get_post_meta( $form->id, '_tax_status', true ); + $tax_behavior = get_post_meta( $form->id, '_tax_behavior', true ); + $line_item = array(); + + // Fetch price option line items. + $price_line_items = ! empty( $price->line_items ) + ? $price->line_items + : array(); + + if ( empty( $price_line_items ) ) { + return $line_item; + } + + // Find the line item for the given type. + $line_item_index = 'plan' === $type ? 1 : 0; + $price_line_item = isset( $price_line_items[ $line_item_index ] ) + ? $price_line_items[ $line_item_index ] + : array(); + + if ( empty( $price_line_item ) ) { + return $line_item; + } + + $unit_amount = $price_line_item['unit_amount']; + $currency = $price->currency; + + if ( 0 === $unit_amount ) { + return $line_item; + } + + $line_item_args = array( + 'quantity' => 1, + 'price_data' => array( + 'unit_amount' => $unit_amount, + 'currency' => $currency, + 'tax_behavior' => 'automatic' === $tax_status + ? $tax_behavior + : 'unspecified', + ), + ); + + // Use a dynamically created Product with Stripe Checkout for better + // line item naming since it is displayed on the Stripe Checkout page. + if ( 'stripe_checkout' === $form->get_display_type() ) { + $line_item_args['price_data']['product_data'] = array( + 'name' => 'plan' === $type + ? __( 'Plan Setup Fee', 'stripe' ) + : __( 'Initial Setup Fee', 'stripe' ), + ); + + // Set the dynamic parent product's tax information, if needed. + $tax_status = get_post_meta( $form->id, '_tax_status', true ); + $tax_code = get_post_meta( $form->id, '_tax_code', true ); + $tax_behavior = get_post_meta( $form->id, '_tax_behavior', true ); + + if ( 'automatic' === $tax_status ) { + $line_item_args['price_data']['tax_behavior'] = $tax_behavior; + $line_item_args['price_data']['product_data']['tax_code'] = $tax_code; + } + + // Otherwise set an existing Product. + } else { + $line_item_args['price_data']['product'] = $price->product_id; + } + + $filter_name = 'plan' === $type + ? 'simpay_get_plan_setup_fee_args_from_payment_form_request' + : 'simpay_get_setup_fee_args_from_payment_form_request'; + + /** + * Filters the arguments used to create the additional line item. + * + * @since 3.6.0 + * + * @param array $plan_fee_args Arguments used to create the InvoiceItem. + * @param \SimplePay\Core\Abstracts\Form $form Form instance. + * @param array $form_data Form data generated by the client. + * @param array $form_values Values of named fields in the payment form. + * @param string $customer_id Stripe Customer ID. + */ + $line_item_args = apply_filters( + $filter_name, + $line_item_args, + $form, + array(), + PaymentRequestUtils::get_form_values( $request ), + '' + ); + + return $line_item_args; + } + + /** + * Returns a unique subscription key. + * + * @since 4.7.0 + * + * @return string + */ + private function get_subscription_key() { + $auth_key = defined( 'AUTH_KEY' ) ? AUTH_KEY : ''; + $hash = date( 'Y-m-d H:i:s' ) . $auth_key . uniqid( 'wpsp', true ); // @phpcs:ignore WordPress.DateTime.RestrictedFunctions.date_date + $subscription_key = strtolower( md5( $hash ) ); + + return $subscription_key; + } +} diff --git a/src/RestApi/Internal/Payment/Utils/CouponUtils.php b/src/RestApi/Internal/Payment/Utils/CouponUtils.php new file mode 100644 index 00000000..7c134d80 --- /dev/null +++ b/src/RestApi/Internal/Payment/Utils/CouponUtils.php @@ -0,0 +1,213 @@ + The coupon data. + */ + public static function get_coupon_data( $request, $coupon_code, $amount, $currency ) { + $form = PaymentRequestUtils::get_form( $request ); + + // Look at internal records first to force a sync between modes. + $api_args = $form->get_api_request_args(); + $coupons = new Coupon_Query( + $form->is_livemode(), + $api_args['api_key'] + ); + + $coupon = $coupons->get_by_name( $coupon_code ); + + // Fall back to a direct Stripe check. + if ( ! $coupon instanceof Coupon ) { + try { + $coupon = API\Coupons\retrieve( $coupon_code, $api_args ); + } catch ( Exception $e ) { + return array( + 'error' => esc_html__( + 'Sorry, this coupon not valid.', + 'stripe' + ), + ); + } + } else { + // We can only check for restrictions on an internally tracked coupon. + if ( false === $coupon->applies_to_form( $form->id ) ) { + return array( + 'error' => esc_html__( + 'Sorry, this coupon not valid.', + 'stripe' + ), + ); + } + + // Use just the Stripe object of the internal record for the remaining + // checks to match preexisting direct API usage. + $coupon = $coupon->object; + } + + /** @var \SimplePay\Vendor\Stripe\Coupon $coupon */ + + // Invalid coupon. + if ( ! simpay_is_coupon_valid( $coupon ) ) { + return array( + 'error' => esc_html__( + 'Sorry, this coupon not valid.', + 'stripe' + ), + ); + } + + // Determines the discounted amount if percentage-based. + if ( ! empty( $coupon->percent_off ) ) { + + $discount_percent = ( 100 - $coupon->percent_off ) / 100; + $discount = ( + $amount - round( $amount * $discount_percent ) + ); + $discount_formatted = "$coupon->percent_off%"; + + // Determines the discounted amount if fixed. + } elseif ( ! empty( $coupon->amount_off ) ) { + if ( $coupon->currency !== $currency ) { + return array( + 'error' => esc_html__( + 'Sorry, this coupon not valid for the selected currency.', + 'stripe' + ), + ); + } + + $discount = $coupon->amount_off; + $discount_formatted = simpay_format_currency( + $discount, + $currency + ); + } else { + return array( + 'error' => esc_html__( + 'Invalid request. Please try again.', + 'stripe' + ), + ); + } + + $min = simpay_convert_amount_to_cents( + simpay_global_minimum_amount() + ); + $is_recurring = PaymentRequestUtils::is_recurring( $request ); + // Check if the coupon is not 100% and puts the total below the minimum amount for recurring price. + if ( $is_recurring && $amount > $discount && (float) 100 !== $coupon->percent_off && ( $amount - $discount ) < $min ) { + return array( + 'error' => esc_html__( + 'Sorry, this coupon puts the total below the required minimum amount.', + 'stripe' + ), + ); + } + + // Check if the coupon is not 100% and puts the total below the minimum amount for non-recurring price. + if ( ! $is_recurring && ( $amount - $discount ) < $min ) { + return array( + 'error' => esc_html__( + 'Sorry, this coupon puts the total below the required minimum amount.', + 'stripe' + ), + ); + } + + return array( + 'coupon' => $coupon, + 'discount' => $discount, + 'message' => sprintf( + /* translators: %1$s Coupon code. %2$s discount amount. */ + __( '%1$s: %2$s off', 'stripe' ), + $coupon->id, + $discount_formatted + ), + ); + } + + /** + * Returns the discount amount for the given request, amount, and customer. + * + * @since 4.7.0 + * + * @param \WP_REST_Request $request The payment request. + * @param int $amount_to_discount The amount to calculate the discount amount for. + * @param string|null $customer_id The Stripe Customer ID. If not supplied the discount amount + * will not be validated against the customer. + * @return int + */ + public static function get_discount_unit_amount( $request, $amount_to_discount, $customer_id = null ) { + $coupon = PaymentRequestUtils::get_coupon_code( $request ); + $form = PaymentRequestUtils::get_form( $request ); + + if ( ! $coupon ) { + return 0; + } + + // If we have a customer available, retrieve the coupon from the customer. + if ( $customer_id ) { + $customer = API\Customers\retrieve( + $customer_id, + $form->get_api_request_args() + ); + + $coupon = isset( $customer->discount ) + ? $customer->discount->coupon + : false; + + if ( false === $coupon ) { + return 0; + } + + // Otherwise retrieve the coupon directly. + } else { + $coupon = API\Coupons\retrieve( + $coupon, + $form->get_api_request_args() + ); + } + + if ( $coupon->amount_off ) { + $unit_amount = $coupon->amount_off; + } else { + $percent_off = $coupon->percent_off / 100; + $unit_amount = $amount_to_discount * $percent_off; + } + + return (int) round( $unit_amount ); + } +} diff --git a/src/RestApi/Internal/Payment/Utils/FeeRecoveryUtils.php b/src/RestApi/Internal/Payment/Utils/FeeRecoveryUtils.php new file mode 100644 index 00000000..6feac1b5 --- /dev/null +++ b/src/RestApi/Internal/Payment/Utils/FeeRecoveryUtils.php @@ -0,0 +1,216 @@ + $subscription_args The subscription arguments. + * @return array + */ + public static function add_subscription_fee_recovery_line_items( $request, $subscription_args ) { + $form = PaymentRequestUtils::get_form( $request ); + + if ( $form->allows_multiple_line_items() ) { + $prices = PaymentRequestUtils::get_price_ids( $request ); + $prices = array_map( + function( $price ) use ( $form ) { + return new PriceOption( + $price['price_data'], + $form, + $price['price_data']['instance_id'] + ); + }, + $prices + ); + } else { + $prices = array( PaymentRequestUtils::get_price( $request ) ); + } + + $currency = $prices[0]->currency; + $product_id = $prices[0]->product_id; + + /** + * Calculate the total line items for the given items. + * + * @param int $total The total amount. + * @param array $item The item. + * @return int + */ + $total_line_items = function ( $total, $item ) use ( $form ) { + if ( isset( $item['price_data'] ) ) { + $unit_amount = (int) $item['price_data']['unit_amount']; + } else { + $price = new PriceOption( array( 'id' => $item['price'] ), $form ); + $unit_amount = (int) $price->unit_amount; + } + + $quantity = (int) $item['quantity']; + + return $total + ( $unit_amount * $quantity ); + }; + + $is_trial = false; + + foreach ( $prices as $price ) { + if ( isset( $price->recurring['trial_period_days'] ) ) { + $is_trial = true; + break; + } + } + + /** + * @var array{ + * items: array, + * add_invoice_items: array, + * metadata: array + * } $subscription_args + */ + + $one_time_line_items_total = array_reduce( + $subscription_args['add_invoice_items'], + $total_line_items, + 0 + ); + + $recurring_line_items_total = array_reduce( + $subscription_args['items'], + $total_line_items, + 0 + ); + + $total_due_today = ( + $one_time_line_items_total + ( $is_trial ? 0 : $recurring_line_items_total ) + ); + + $fee_recovery_today = self::get_fee_recovery_unit_amount( + $request, + $total_due_today + ); + + $fee_recovery_recurring = self::get_fee_recovery_unit_amount( + $request, + $recurring_line_items_total + ); + + $subscription_args['items'][] = array( + 'quantity' => 1, + 'price_data' => array( + 'unit_amount' => $fee_recovery_recurring, + 'currency' => $currency, + 'product' => $product_id, + 'recurring' => array( + 'interval' => $price->recurring['interval'], + 'interval_count' => $price->recurring['interval_count'], + ), + ), + ); + + $one_time_fee_recovery_unit_amount = $is_trial + ? $fee_recovery_today + : $fee_recovery_today - $fee_recovery_recurring; + + if ( 0 !== $one_time_fee_recovery_unit_amount ) { + $subscription_args['add_invoice_items'][] = array( + 'quantity' => 1, + 'price_data' => array( + 'unit_amount' => $one_time_fee_recovery_unit_amount, + 'currency' => $currency, + 'product' => $product_id, + ), + ); + + $subscription_args['metadata']['simpay_fee_recovery_initial_unit_amount'] = $one_time_fee_recovery_unit_amount; + } + + // Attach the amount as metadata so we can access it later. This is + // probably not the most obvious spot, but currently it provides + // the most flexibility for updating the payment amount when a failure occurs. + $subscription_args['metadata']['simpay_fee_recovery_unit_amount'] = $is_trial + ? $fee_recovery_recurring + : $fee_recovery_today; + + return $subscription_args; + } + + /** + * Returns the Fee Recovery amount for the given request, and amount. + * + * The payment method type must be available in the request. + * + * @since 4.7.0 + * + * @param \WP_REST_Request $request The payment request. + * @param int $amount_to_recover_for The amount to calculate the Fee Recovery amount for. + * @return int + */ + public static function get_fee_recovery_unit_amount( $request, $amount_to_recover_for ) { + if ( 0 === $amount_to_recover_for ) { + return 0; + } + + $is_covering_fees = $request->get_param( 'is_covering_fees' ); + $form = PaymentRequestUtils::get_form( $request ); + + if ( ! $form->has_forced_fee_recovery() && ! $is_covering_fees ) { + return 0; + } + + $payment_method_settings = Payment_Methods\get_form_payment_method_settings( + $form, + PaymentRequestUtils::get_payment_method_type( $request ) + ); + + if ( + ! isset( + $payment_method_settings['fee_recovery'], + $payment_method_settings['fee_recovery']['enabled'] + ) || + 'yes' !== $payment_method_settings['fee_recovery']['enabled'] + ) { + return 0; + } + + $percent = $payment_method_settings['fee_recovery']['percent']; + $fixed = $payment_method_settings['fee_recovery']['amount']; + + return (int) round( + ( $amount_to_recover_for + $fixed ) + / + ( 1 - ( $percent / 100 ) ) + - + $amount_to_recover_for + ); + } +} diff --git a/src/RestApi/Internal/Payment/Utils/PaymentRequestUtils.php b/src/RestApi/Internal/Payment/Utils/PaymentRequestUtils.php new file mode 100644 index 00000000..adbbfe2d --- /dev/null +++ b/src/RestApi/Internal/Payment/Utils/PaymentRequestUtils.php @@ -0,0 +1,1010 @@ +get_param( 'form_id' ); + + /** @var \SimplePay\Core\Abstracts\Form $form This has already been validated by the schema. IDE helper. */ + $form = simpay_get_form( $form_id ); + + return $form; + } + + /** + * Returns the form values for the given request. + * + * @since 4.7.0 + * + * @param \WP_REST_Request $request The payment request. + * @return array> + */ + public static function get_form_values( $request ) { + /** @var array> $form_values */ + $form_values = $request->get_param( 'form_values' ); + + return $form_values; + } + + /** + * Returns the `PriceOption` for the given request. + * + * @since 4.7.0 + * + * @param \WP_REST_Request $request The payment request. + * @return \SimplePay\Core\PaymentForm\PriceOption + */ + public static function get_price( $request ) { + /** @var string $price_id This has already been validated by the schema. IDE helper. */ + $price_id = $request->get_param( 'price_id' ); + + /** @var \SimplePay\Core\PaymentForm\PriceOption $price This has already been validated by the schema. IDE helper. */ + $price = simpay_payment_form_prices_get_price_by_id( + self::get_form( $request ), + $price_id + ); + + return $price; + } + + /** + * Returns a list of selected price IDs for the given request. + * + * @since 4.11.0 + * + * @param \WP_REST_Request $request The payment request. + * @return array{ + * array{ + * custom_amount: int, + * is_optionally_recurring: bool, + * price_data: array{ + * can_recur: bool, + * currency: string, + * recurring: null|array{ + * id: null|string, + * interval: string, + * interval_count: int, + * }, + * label: string, + * instance_id: string, + * unit_amount: int, + * product_id: string, + * }, + * price_id: string, + * quantity: int, + * } + * } + */ + public static function get_price_ids( $request ) { + /** + * @var array{ + * array{ + * custom_amount: int, + * is_optionally_recurring: bool, + * price_data: array{ + * can_recur: bool, + * currency: string, + * recurring: null|array{ + * id: null|string, + * interval: string, + * interval_count: int, + * }, + * label: string, + * instance_id: string, + * unit_amount: int, + * product_id: string, + * }, + * price_id: string, + * quantity: int, + * } + * } + */ + return $request->get_param( 'price_ids' ); + } + + /** + * Returns the currency for the given request. + * + * @since 4.7.0 + * + * @param \WP_REST_Request $request The payment request. + * @return string + */ + public static function get_currency( $request ) { + return self::get_price( $request )->currency; + } + + /** + * Returns the purchase quantity for the given request. + * + * @since 4.7.0 + * + * @param \WP_REST_Request $request The payment request. + * @return int + */ + public static function get_quantity( $request ) { + /** @var int $quantity This has already been validated by the schema. IDE helper. */ + $quantity = $request->get_param( 'quantity' ); + + return $quantity; + } + + /** + * Returns the custom amount for the given request. + * + * @since 4.7.0 + * + * @param \WP_REST_Request $request The payment request. + * @return int + */ + public static function get_custom_unit_amount( $request ) { + /** @var int $custom_amount This has already been validated by the schema. IDE helper. */ + $custom_amount = $request->get_param( 'custom_amount' ); + + return $custom_amount; + } + + /** + * Returns the coupon code for the given request. + * + * @since 4.7.0 + * + * @param \WP_REST_Request $request The payment request. + * @return string + */ + public static function get_coupon_code( $request ) { + /** @var string $coupon_code This has already been validated by the schema. IDE helper. */ + $coupon_code = $request->get_param( 'coupon_code' ); + + return $coupon_code; + } + + /** + * Returns the tax calculation ID for the given request. + * + * @since 4.7.0 + * + * @param \WP_REST_Request $request The payment request. + * @return string + */ + public static function get_tax_calc_id( $request ) { + /** @var string $tax_calc_id This has already been validated by the schema. IDE helper. */ + $tax_calc_id = $request->get_param( 'tax_calc_id' ); + + return $tax_calc_id; + } + + /** + * Determines if the payment is opted-in to optionally recurring. + * + * @since 4.7.0 + * + * @param \WP_REST_Request $request The payment request. + * @return bool + */ + public static function is_optionally_recurring( $request ) { + /** @var bool $is_optionally_recurring This has already been validated by the schema. IDE helper. */ + $is_optionally_recurring = $request->get_param( + 'is_optionally_recurring' + ); + + return $is_optionally_recurring; + } + + /** + * Determines if the payment is opted-in to covering processing fees. + * + * @since 4.7.0 + * + * @param \WP_REST_Request $request The payment request. + * @return bool + */ + public static function is_covering_fees( $request ) { + /** @var bool $is_covering_fees This has already been validated by the schema. IDE helper. */ + $is_covering_fees = $request->get_param( 'is_covering_fees' ); + + return $is_covering_fees; + } + + /** + * Determines if the given request has a recurring price. + * + * @since 4.11.0 + * @param \WP_REST_Request $request The payment request. + * @return bool + */ + public static function has_recurring_price( $request ) { + $price_ids = self::get_price_ids( $request ); + if ( empty( $price_ids ) ) { + return self::is_recurring( $request ); + } + foreach ( $price_ids as $price_id ) { + $price = (object) $price_id['price_data']; + $is_optionally_recurring = $price_id['is_optionally_recurring']; + + // Price option can recur, and is, so it is recurring. + if ( $price->can_recur && $is_optionally_recurring ) { + return true; + + // Price can recur, but it is not opted in, so it's not. + } elseif ( $price->can_recur && ! $is_optionally_recurring ) { + return false; + } + + // Price option is recurring, so it is recurring. + if ( is_array( $price->recurring ) ) { + return true; + } + } + return false; + } + + /** + * Determines if the payment is recurring. + * + * @phpcs:disable Squiz.Commenting.FunctionComment.MissingParamName + * + * @since 4.7.0 + * + * @param \WP_REST_Request $request The payment request. + * @param null|array{ + * custom_amount: int, + * is_optionally_recurring: bool, + * price_data: array{ + * can_recur: bool, + * currency: string, + * recurring: null|array{ + * id: null|string, + * interval: string, + * interval_count: int, + * }, + * label: string, + * instance_id: string, + * unit_amount: int, + * product_id: string, + * }, + * price_id: string, + * quantity: int, + * } $price_data Optional price data to use instead of the request. + * @return bool + * + * @phpcs:enable Squiz.Commenting.FunctionComment.MissingParamName + */ + public static function is_recurring( $request, $price_data = null ) { + if ( null !== $price_data ) { + $form = self::get_form( $request ); + $price = simpay_payment_form_prices_get_price_by_id( + $form, + $price_data['price_id'] + ); + $is_optionally_recurring = isset( $price_data['is_optionally_recurring'] ) + ? $price_data['is_optionally_recurring'] + : false; + } else { + $price = self::get_price( $request ); + $is_optionally_recurring = self::is_optionally_recurring( $request ); + } + + if ( ! $price ) { + return false; + } + + // Price option can recur. + if ( $price->can_recur ) { + return $is_optionally_recurring; + } + + // Price option is recurring, so it is recurring. + return is_array( $price->recurring ); + } + + /** + * Returns the unit amount for the given request. + * + * If a custom amount is being used, return that. + * + * @since 4.7.0 + * + * @param \WP_REST_Request $request The payment request. + * @return int + */ + public static function get_unit_amount( $request ) { + $price = self::get_price( $request ); + $custom_unit_amount = self::get_custom_unit_amount( $request ); + + if ( false === simpay_payment_form_prices_is_defined_price( $price->id ) ) { + return $custom_unit_amount; + } + + return $price->unit_amount; + } + + /** + * Returns the unit amount for the given request. + * + * If a custom amount is being used, return that. + * + * @since 4.11.0 + * + * @param \WP_REST_Request $request The payment request. + * @return int + */ + public static function get_multiple_line_items_unit_amount( $request ) { + $prices = self::get_price_ids( $request ); + $final_unit_amount = 0; + + foreach ( $prices as $price ) { + $unit_amount = $price['custom_amount']; + $quantity = $price['quantity']; + $unit_amount = $unit_amount * $quantity; + + // Add setup fee(s). + if ( isset( $price['price_data']['line_items'] ) ) { + $unit_amount += array_reduce( + $price['price_data']['line_items'], + function ( $total, $line_item ) { + return $total + (int) $line_item['unit_amount']; + }, + 0 + ); + } + + $final_unit_amount += $unit_amount; + } + + return $final_unit_amount; + } + + /** + * Returns the total amount for a given request. This accounts for quantity, + * discounts, fee recovery, and taxes. + * + * @since 4.7.0 + * + * @param \WP_REST_Request $request The payment request. + * @return int + */ + public static function get_amount( $request ) { + $form = self::get_form( $request ); + $unit_amount = self::get_unit_amount( $request ); + $quantity = self::get_quantity( $request ); + $tax_status = get_post_meta( $form->id, '_tax_status', true ); + $tax_behavior = get_post_meta( $form->id, '_tax_behavior', true ); + + $unit_amount = $unit_amount * $quantity; + + if ( $form->allows_multiple_line_items() ) { + $unit_amount = self::get_multiple_line_items_unit_amount( $request ); + } + + // Add the fee recovery amount, if needed. + if ( $form->has_fee_recovery() ) { + $fee_recovery = FeeRecoveryUtils::get_fee_recovery_unit_amount( + $request, + $unit_amount + ); + $unit_amount = $unit_amount + $fee_recovery; + } + + // Remove the coupon amount, if needed. + $discount = CouponUtils::get_discount_unit_amount( + $request, + $unit_amount, + null + ); + + if ( 0 !== $discount ) { + $unit_amount = $unit_amount - $discount; + } + + // Add the tax amount, if needed. + $tax = TaxUtils::get_tax_unit_amount( $request, $unit_amount ); + + if ( 0 !== $tax ) { + // Automatic tax, and exclusive, so add the amount. + if ( 'automatic' === $tax_status && 'exclusive' === $tax_behavior ) { + $unit_amount = $unit_amount + $tax; + + // Fixed global, add the amount (accounts for inclusive) in calculatinos. + } elseif ( empty( $tax_behavior ) || 'fixed-global' === $tax_behavior ) { + $unit_amount = $unit_amount + $tax; + } + } + + return $unit_amount; + } + + /** + * Returns data for a PaymentIntent for the given request. + * + * This is generic data that applies to a base PaymentIntent, regardless + * of what creates it (Checkout Session, Subscription, etc). + * Additional arguments used just for the PaymentIntent API are added + * in `PaymentIntentTrait::create_payment_intent()`. + * + * @since 4.7.0 + * + * @param \WP_REST_Request $request The payment request. + * @return array|string> + */ + public static function get_payment_intent_data( $request ) { + $form = self::get_form( $request ); + $price = self::get_price( $request ); + $payment_intent_data = array( + 'metadata' => self::get_payment_metadata( $request ), + ); + + // If multiple line items are used, use the form title. + if ( $form->allows_multiple_line_items() && ! empty( $form->company_name ) ) { + $payment_intent_data['description'] = $form->company_name; + + // Use price option label if one is set. + } elseif ( null !== $price->label ) { + $payment_intent_data['description'] = $price->get_display_label(); + + // Fall back to Payment Form title if set. + // This is a change in behavior in 4.1, but matches the Stripe Checkout + // usage that falls back to the Product title (Payment Form title). + } elseif ( ! empty( $form->company_name ) ) { + $payment_intent_data['description'] = $form->company_name; + } + + return $payment_intent_data; + } + + /** + * Returns metadata for the primary payment object for the given request. + * + * This is generic data that applies can be applied to the primary payment + * object, i.e Checkout Session, Subscription, or PaymentIntent. + * + * @since 4.7.0 + * + * @param \WP_REST_Request $request The payment request. + * @return array + */ + public static function get_payment_metadata( $request ) { + $form = self::get_form( $request ); + $form_values = self::get_form_values( $request ); + $metadata = array( + 'simpay_form_id' => $form->id, + ); + + if ( $form->allows_multiple_line_items() ) { + $price_instances = array(); + $items = self::get_price_ids( $request ); + + foreach ( $items as $item ) { + $price_instances[] = sprintf( + '%s:%d:%d', + $item['price_data']['instance_id'], + $item['quantity'], + simpay_payment_form_prices_is_defined_price( $item['price_id'] ) + ? $item['price_data']['unit_amount'] + : $item['custom_amount'] + ); + } + + $price_instances = implode( '|', $price_instances ); + + $subtotal = self::get_multiple_line_items_unit_amount( $request ); + + $metadata['simpay_price_instances'] = $price_instances; + + // Retrieve a single price option. + } else { + $price = self::get_price( $request ); + + $unit_amount = self::get_unit_amount( $request ); + $quantity = self::get_quantity( $request ); + $subtotal = $unit_amount * $quantity; + + $price_instances = sprintf( + '%s:%d', + $price->instance_id, + $quantity + ); + + $metadata['simpay_unit_amount'] = $unit_amount; + $metadata['simpay_quantity'] = $quantity; + $metadata['simpay_price_instances'] = $price_instances; + } + + $license = simpay_get_license(); + + // Add additional metadata for non-lite licenses. + if ( false === $license->is_lite() ) { + // Custom fields. + /** @var array $custom_fields Custom fields. */ + $custom_fields = isset( $form_values['simpay_field'] ) + ? $form_values['simpay_field'] + : array(); + + foreach ( $custom_fields as $key => $value ) { + // Skip empty. + if ( '' === trim( $value ) ) { + continue; + } + + $metadata[ $key ] = $value; + } + + // Fee recovery. + $fee_recovery = FeeRecoveryUtils::get_fee_recovery_unit_amount( + $request, + $subtotal // Safe to use subtotal here because fee recovery does not support taxes or coupons. + ); + + if ( 0 !== $fee_recovery ) { + $metadata['simpay_fee_recovery_unit_amount'] = $fee_recovery; + } + + // Tax. + $total = self::get_amount( $request ); + + $tax_status = get_post_meta( $form->id, '_tax_status', true ); + $tax_behavior = get_post_meta( $form->id, '_tax_behavior', true ); + $tax_unit_amount = TaxUtils::get_tax_unit_amount( + $request, + $subtotal + ); + + switch ( $tax_status ) { + case 'automatic': + // Find the tax percent based on the total amount, and tax amount. + $tax_percent = ( $tax_unit_amount / ( $total - $tax_unit_amount ) ) * 100; + $tax_percent = round( $tax_percent ); + + if ( 'exclusive' === $tax_behavior ) { + $metadata['simpay_tax_percent_exclusive'] = $tax_percent; + $metadata['simpay_tax_unit_amount_exclusive'] = $tax_unit_amount; + } else { + $metadata['simpay_tax_percent_inclusive'] = $tax_percent; + $metadata['simpay_tax_unit_amount_inclusive'] = $tax_unit_amount; + } + + break; + case 'fixed-global': + $metadata['simpay_tax_percent_exclusive'] = simpay_get_payment_form_tax_percentage( + $form, + 'exclusive' + ); + + $discount_amount = CouponUtils::get_discount_unit_amount( + $request, + $subtotal, + null + ); + + $metadata['simpay_tax_unit_amount_exclusive'] = ( + round( $subtotal - $discount_amount ) * + ( $metadata['simpay_tax_percent_exclusive'] / 100 ) + ); + + $metadata['simpay_tax_percent_inclusive'] = simpay_get_payment_form_tax_percentage( + $form, + 'inclusive' + ); + + $metadata['simpay_tax_unit_amount_inclusive'] = 0; + + if ( 0 !== $metadata['simpay_tax_percent_inclusive'] ) { + $metadata['simpay_tax_unit_amount_inclusive'] = round( + ( $total - $subtotal ) - ( $metadata['simpay_tax_percent_inclusive'] / 100 ) + ); + } + + break; + } + + // Coupon. + $coupon_data = CouponUtils::get_coupon_data( + $request, + self::get_coupon_code( $request ), + $subtotal, + self::get_currency( $request ) + ); + + if ( ! isset( $coupon_data['error'] ) ) { + /** @var array $coupon_data */ + $metadata['simpay_coupon_code'] = $coupon_data['coupon']->id; + $metadata['simpay_discount_unit_amount'] = $coupon_data['discount']; + } + } + + // Fill in unchecked checkboxes with "off" value. + /** @var array>> $custom_fields */ + $custom_fields = simpay_get_saved_meta( + $form->id, + '_custom_fields', + array() + ); + + // Checkboxes that aren't checked aren't included in the request. + // We need to add them in manually with a value of "off". + $checkboxes = isset( $custom_fields['checkbox'] ) + ? $custom_fields['checkbox'] + : array(); + + foreach ( $checkboxes as $checkbox ) { + $id = isset( $checkbox['uid'] ) + ? $checkbox['uid'] + : ''; + + $key = isset( $checkbox['metadata'] ) && ! empty( $checkbox['metadata'] ) + ? $checkbox['metadata'] + : sprintf( + 'simpay-form-%s-field-%s', + $form->id, + $id + ); + + if ( ! isset( $metadata[ $key ] ) ) { + $metadata[ $key ] = 'off'; + } + } + + // Sanitize all keys and values. + $_metadata = array(); + + foreach ( $metadata as $key => $value ) { + /** @var string $key */ + /** @var string $value */ + + $key = sanitize_text_field( stripslashes( $key ) ); + $value = sanitize_text_field( stripslashes( $value ) ); + + $key = simpay_truncate_metadata( 'title', $key ); + $value = simpay_truncate_metadata( 'description', $value ); + + $_metadata[ $key ] = $value; + } + + /** @var array $_metadata */ + return $_metadata; + } + + /** + * Returns the payment method types available for the given request. + * + * @since 4.7.0 + * + * @param \WP_REST_Request $request The payment request. + * @return array + */ + public static function get_payment_method_types( $request ) { + $form = self::get_form( $request ); + $currency = self::get_currency( $request ); + + if ( ! $form->allows_multiple_line_items() ) { + $price = self::get_price( $request ); + + $is_recurring = ( + ( null !== $price->recurring && false === $price->can_recur ) || + self::is_optionally_recurring( $request ) + ); + + // Multiple line items should always be considered "recurring" when determining + // which payment methods to display. This is because the Invoices API does not + // support non-recurring payment methods. + } else { + $is_recurring = true; + } + + /** @var array<\SimplePay\Pro\Payment_Methods\Payment_Method> */ + $payment_methods = Payment_Methods\get_form_payment_methods( $form ); + + // Remove Payment Methods that do not support the current currency. + $payment_methods = array_filter( + $payment_methods, + function ( $payment_method ) use ( $currency ) { + return in_array( $currency, $payment_method->currencies, true ); + } + ); + + // Remove Payment Methods that do not support the current recurring options + // if recurring is being used. + if ( $is_recurring ) { + $payment_methods = array_filter( + $payment_methods, + /** + * Determines if the given Payment Method supports recurring payments. + * + * @since unknown + * + * @param \SimplePay\Pro\Payment_Methods\Payment_Method $payment_method The Payment Method. + * @return bool + */ + function ( $payment_method ) { + // Check for Stripe Checkout-specific overrides first. + if ( + is_array( $payment_method->stripe_checkout ) && + isset( $payment_method->stripe_checkout['recurring'] ) + ) { + return true === $payment_method->stripe_checkout['recurring']; + } + + // Check general recurring capabilities. + return true === $payment_method->recurring; + } + ); + } + + $payment_methods = array_map( + function ( $payment_method_id ) { + switch ( $payment_method_id ) { + case 'ach-debit': + return 'us_bank_account'; + default: + return str_replace( '-', '_', $payment_method_id ); + } + }, + array_keys( $payment_methods ) + ); + + // Check the Card configuration and enable Link, if needed. + // Do not add if using Stripe Checkout. + if ( 'stripe_checkout' !== $form->get_display_type() ) { + $custom_fields = get_custom_fields( $form->id ); + + $emails = array_filter( + $custom_fields, + function ( $field ) { + return 'email' === $field['type']; + } + ); + + if ( ! empty( $emails ) ) { + $email = current( $emails ); + + $link_enabled = isset( + $email['link'], + $email['link']['enabled'] + ) + ? 'yes' === $email['link']['enabled'] + : false; + + if ( in_array( 'card', $payment_methods, true ) && $link_enabled ) { + $payment_methods[] = 'link'; + } + } + } + + // If using Affirm, ensure the unit_amount is at least $100. + if ( in_array( 'affirm', $payment_methods, true ) ) { + $unit_amount = self::get_unit_amount( $request ); + + if ( $unit_amount < 10000 ) { + $payment_methods = array_diff( $payment_methods, array( 'affirm' ) ); + } + } + + // Remove Alipay, Klarna, Afterpay, and Affirm if using multiple line items. + // These are not supported by the Invoices API. + if ( $form->allows_multiple_line_items() ) { + $payment_methods = array_diff( + $payment_methods, + array( 'alipay', 'klarna', 'afterpay', 'affirm' ) + ); + } + + return array_values( $payment_methods ); + } + + /** + * Returns the configuration for available payment method types for the given request. + * + * @since 4.7.0 + * + * @param \WP_REST_Request $request The payment request. + * @return array> + */ + public static function get_payment_method_options( $request ) { + $form = self::get_form( $request ); + $is_recurring = self::is_recurring( $request ); + $payment_method_types = self::get_payment_method_types( $request ); + + $payment_method_options = array( + 'card' => array( + 'setup_future_usage' => 'off_session', + ), + 'link' => array( + 'setup_future_usage' => 'off_session', + ), + 'sepa_debit' => array( + 'setup_future_usage' => 'off_session', + ), + 'us_bank_account' => array( + 'setup_future_usage' => 'off_session', + ), + ); + + // Adjust payment method options if multiple line items enabled. + if ( $form->allows_multiple_line_items() ) { + // Subscription does not have `setup_future_usage` field. + // For more information, refer to the Stripe API documentation: + // https://docs.stripe.com/api/subscriptions/create#create_subscription-payment_settings-payment_method_options-card. + $payment_method_options['card'] = array(); + $payment_method_options['link'] = array(); + } + + // If ach-debit is enabled, check if the verification_method.instant + // flag is set. If it is not, force instant verification. + /** @var array<\SimplePay\Pro\Payment_Methods\Payment_Method> */ + $payment_methods = Payment_Methods\get_form_payment_methods( $form ); + + $ach_direct_debit = array_filter( + $payment_methods, + function ( $payment_method ) { + return 'ach-debit' === $payment_method->id; + } + ); + + if ( ! empty( $ach_direct_debit ) ) { + $pm = current( $ach_direct_debit ); + $manual = isset( $pm->config['verification_method'] ) && 'manual' === $pm->config['verification_method']; + + $payment_method_options['us_bank_account']['verification_method'] = $manual + ? 'automatic' + : 'instant'; + } + + // Remove `setup_future_usage` if the form is recurring. This gets set + // at the Subscription's top level `off_session=true` parameter instead. + $payment_method_options = array_map( + function ( $payment_method_options ) use ( $is_recurring ) { + if ( true === $is_recurring ) { + unset( $payment_method_options['setup_future_usage'] ); + } + + return $payment_method_options; + }, + $payment_method_options + ); + + // Filter out payment methods that are not available for the given request. + return array_filter( + $payment_method_options, + function ( $payment_method_type ) use ( $payment_method_types ) { + return in_array( $payment_method_type, $payment_method_types, true ); + }, + ARRAY_FILTER_USE_KEY + ); + } + + /** + * Returns the payment method type for the given request. + * + * @since 4.7.0 + * + * @param \WP_REST_Request $request The payment request. + * @return string + */ + public static function get_payment_method_type( $request ) { + /** @var string $payment_method_type */ + $payment_method_type = $request->get_param( 'payment_method_type' ); + + return $payment_method_type; + } + + /** + * Returns the URL to redirect to after a successful payment. + * + * @since 4.7.0 + * + * @param \WP_REST_Request $request The payment request. + * @return string + */ + public static function get_return_url( $request ) { + $form = self::get_form( $request ); + $return_url = esc_url_raw( $form->payment_success_page ); + + if ( ! wp_http_validate_url( $return_url ) ) { + $return_url = add_query_arg( + array( + 'form_id' => $form->id, + ), + esc_url_raw( home_url() ) + ); + } + + return $return_url; + } + + /** + * Returns the URL to redirect to if a payment is cancelled. + * + * @since 4.7.0 + * + * @param \WP_REST_Request $request The payment request. + * @return string + */ + public static function get_cancel_url( $request ) { + $form = self::get_form( $request ); + $cancel_url = esc_url_raw( $form->payment_cancelled_page ); + + if ( empty( $cancel_url ) ) { + $cancel_url = esc_url_raw( home_url() ); + } + + return $cancel_url; + } + + /** + * Returns the trial period days for the given request. + * + * @since 4.11.0 + * + * @param \WP_REST_Request $request The payment request. + * @return int + */ + public static function get_trial_period_days_from_price_ids( $request ) { + $prices = self::get_price_ids( $request ); + $trial_period_days = array( 0 ); + + foreach ( $prices as $price_data ) { + + $price = (object) $price_data['price_data']; + if ( $price->recurring && isset( $price->recurring['trial_period_days'] ) ) { + array_push( $trial_period_days, $price->recurring['trial_period_days'] ); + } + } + + return max( $trial_period_days ); + } + + /** + * Determines if the form has a trial period. + * + * @since 4.11.0 + * + * @param \WP_REST_Request $request The payment request. + * @return bool + */ + public function has_trial_period( $request ) { + $trial_period_days = self::get_trial_period_days_from_price_ids( $request ); + + return $trial_period_days > 0; + } +} diff --git a/src/RestApi/Internal/Payment/Utils/SchemaSanitizationUtils.php b/src/RestApi/Internal/Payment/Utils/SchemaSanitizationUtils.php new file mode 100644 index 00000000..e8b577cd --- /dev/null +++ b/src/RestApi/Internal/Payment/Utils/SchemaSanitizationUtils.php @@ -0,0 +1,39 @@ +> $value The `form_values` parameter value. + * @return array> The sanitized form values. + */ + public static function sanitize_form_values_arg( $value ) { + foreach ( $value as $key => $val ) { + $value[ sanitize_text_field( $key ) ] = is_array( $val ) + ? array_map( 'sanitize_text_field', $val ) + : sanitize_text_field( $val ); + } + + return $value; + } + +} diff --git a/src/RestApi/Internal/Payment/Utils/SchemaUtils.php b/src/RestApi/Internal/Payment/Utils/SchemaUtils.php new file mode 100644 index 00000000..cf8456b8 --- /dev/null +++ b/src/RestApi/Internal/Payment/Utils/SchemaUtils.php @@ -0,0 +1,621 @@ + $args Argument overrides. + * @return array + */ + public static function get_form_id_schema( $args = array() ) { + return wp_parse_args( + $args, + array( + 'type' => 'integer', + 'required' => true, + 'description' => __( + 'The payment form ID to use for the payment.', + 'stripe' + ), + 'validate_callback' => array( + SchemaValidationUtils::class, + 'validate_form_id_arg', + ), + 'sanitize_callback' => 'rest_sanitize_request_arg', + ) + ); + } + + /** + * Returns the schema for the `form_values` parameter. + * + * @since 4.7.0 + * + * @param array $args Argument overrides. + * @return array + */ + public static function get_form_values_schema( $args = array() ) { + return wp_parse_args( + $args, + array( + 'type' => 'object', + 'required' => true, + 'description' => __( + 'The payment form values to use for the payment.', + 'stripe' + ), + 'validate_callback' => array( + SchemaValidationUtils::class, + 'validate_form_values_arg', + ), + 'sanitize_callback' => array( + SchemaSanitizationUtils::class, + 'sanitize_form_values_arg', + ), + ) + ); + } + + /** + * Returns the schema for the `token` parameter. + * + * @since 4.7.0 + * + * @param array $args Argument overrides. + * @return array + */ + public static function get_token_schema( $args = array() ) { + return wp_parse_args( + $args, + array( + 'type' => 'string', + 'required' => false, + 'description' => __( + 'A security token (usually from a CAPTCHA service) to verify the payment request.', + 'stripe' + ), + 'sanitize_callback' => 'rest_sanitize_request_arg', + // Instead of using a custom `validate_callback` we perform validation manually + // in the payment request so we can display a more specific error message. + 'validate_callback' => 'rest_validate_request_arg', + ) + ); + } + + /** + * Returns the schema for the `price_id` parameter. + * + * @since 4.7.0 + * + * @param array $args Argument overrides. + * @return array + */ + public static function get_price_id_schema( $args = array() ) { + return wp_parse_args( + array( + 'type' => 'string', + 'required' => true, + 'description' => __( + 'The ID of the price to use for the payment.', + 'stripe' + ), + 'validate_callback' => array( + SchemaValidationUtils::class, + 'validate_price_id_arg', + ), + 'sanitize_callback' => 'rest_sanitize_request_arg', + ) + ); + } + + /** + * Returns the schema for the `quantity` parameter. + * + * @since 4.7.0 + * + * @param array $args Argument overrides. + * @return array + */ + public static function get_quantity_schema( $args = array() ) { + return wp_parse_args( + $args, + array( + 'type' => 'integer', + 'minimum' => 1, + 'required' => true, + 'description' => __( + 'The purchase quantity for the payment.', + 'stripe' + ), + 'validate_callback' => array( + SchemaValidationUtils::class, + 'validate_quantity_arg', + ), + 'sanitize_callback' => 'rest_sanitize_request_arg', + ) + ); + } + + /** + * Returns the schema for the `custom_amount` parameter. + * + * @since 4.7.0 + * + * @param array $args Argument overrides. + * @return array + */ + public static function get_custom_amount_schema( $args = array() ) { + return wp_parse_args( + $args, + array( + 'type' => 'integer', + 'required' => false, + 'description' => __( + 'The custom amount for the payment.', + 'stripe' + ), + 'validate_callback' => array( + SchemaValidationUtils::class, + 'validate_custom_amount_arg', + ), + 'sanitize_callback' => 'rest_sanitize_request_arg', + ) + ); + } + + /** + * Returns the schema for the `currency` parameter. + * + * @since 4.7.0 + * + * @param array $args Argument overrides. + * @return array + */ + public static function get_currency_schema( $args = array() ) { + /** @var string $default_currency */ + $default_currency = simpay_get_setting( 'currency', 'USD' ); + $default_currency = strtolower( $default_currency ); + + return wp_parse_args( + $args, + array( + 'type' => 'string', + 'required' => false, + 'description' => __( + 'The currency for the payment.', + 'stripe' + ), + 'sanitize_callback' => 'rest_sanitize_request_arg', + 'validate_callback' => 'rest_validate_request_arg', + 'enum' => array_map( + 'strtolower', + array_keys( simpay_get_currencies() ) + ), + 'default' => $default_currency, + ) + ); + } + + /** + * Returns the schema for the `is_optionally_recurring` parameter. + * + * @since 4.7.0 + * + * @param array $args Argument overrides. + * @return array + */ + public static function get_is_optionally_recurring_schema( $args = array() ) { + return wp_parse_args( + $args, + array( + 'type' => 'boolean', + 'required' => false, + 'description' => __( + 'If the user has opted in to a recurring payment.', + 'stripe' + ), + 'sanitize_callback' => 'rest_sanitize_request_arg', + 'validate_callback' => 'rest_validate_request_arg', + ) + ); + } + + /** + * Returns the schema for the `is_covering_fees` parameter. + * + * @since 4.7.0 + * + * @param array $args Argument overrides. + * @return array + */ + public static function get_is_covering_fees_schema( $args = array() ) { + return wp_parse_args( + $args, + array( + 'type' => 'boolean', + 'required' => false, + 'description' => __( + 'If the user has opted in to pay processing fees.', + 'stripe' + ), + 'sanitize_callback' => 'rest_sanitize_request_arg', + 'validate_callback' => 'rest_validate_request_arg', + ) + ); + } + + /** + * Returns the schema for the `coupon_code` parameter. + * + * @since 4.7.0 + * + * @param array $args Argument overrides. + * @return array + */ + public static function get_coupon_code_schema( $args = array() ) { + return wp_parse_args( + $args, + array( + 'type' => 'string', + 'required' => false, + 'description' => __( + 'The coupon code to apply to the payment.', + 'stripe' + ), + // @todo validate it can apply to form + 'validate_callback' => array( + SchemaValidationUtils::class, + 'validate_coupon_code_arg', + ), + 'sanitize_callback' => 'rest_sanitize_request_arg', + ) + ); + } + + /** + * Returns the schema for the `subtotal` parameter. + * + * Currently this is only used when previewing coupon validation. + * + * @since 4.7.0 + * + * @param array $args Argument overrides. + * @return array + */ + public static function get_subtotal_schema( $args = array() ) { + return wp_parse_args( + $args, + array( + 'type' => 'integer', + 'required' => false, + 'description' => __( + 'The subtotal of the payment.', + 'stripe' + ), + 'validate_callback' => 'rest_validate_request_arg', + 'sanitize_callback' => 'rest_sanitize_request_arg', + ) + ); + } + + /** + * Returns the schema for the `billing_address` parameter. + * + * @since 4.7.0 + * + * @param array $args Argument overrides. + * @return array + */ + public static function get_billing_address_schema( $args = array() ) { + return wp_parse_args( + $args, + array( + 'type' => 'object', + 'required' => false, + 'description' => __( + 'The customers\'s billing address.', + 'stripe' + ), + 'properties' => array_merge( + array( + 'name' => array( + 'description' => __( 'Name.', 'stripe' ), + 'type' => 'string', + ), + ), + self::get_address_fields_schema() + ), + 'sanitize_callback' => 'rest_sanitize_request_arg', + 'validate_callback' => 'rest_validate_request_arg', + ) + ); + } + + /** + * Returns the schema for the `shipping_address` parameter. + * + * @since 4.7.0 + * + * @param array $args Argument overrides. + * @return array + */ + public static function get_shipping_address_schema( $args = array() ) { + return wp_parse_args( + $args, + array( + 'type' => 'object', + 'required' => false, + 'description' => __( + 'The customers\'s shipping address.', + 'stripe' + ), + 'properties' => array_merge( + array( + 'name' => array( + 'description' => __( 'Recipient name.', 'stripe' ), + 'type' => 'string', + ), + 'phone' => array( + 'description' => __( 'Recipient phone number.', 'stripe' ), + 'type' => 'string', + ), + ), + self::get_address_fields_schema() + ), + 'sanitize_callback' => 'rest_sanitize_request_arg', + 'validate_callback' => 'rest_validate_request_arg', + ) + ); + } + + /** + * Returns the schema for the `payment_method_type` parameter. + * + * @since 4.7.0 + * + * @param array $args Argument overrides. + * @return array + */ + public static function get_payment_method_type_schema( $args = array() ) { + return wp_parse_args( + $args, + array( + 'type' => 'string', + 'required' => true, + 'description' => __( + 'The payment method type used to make a payment.', + 'stripe' + ), + 'validate_callback' => array( + SchemaValidationUtils::class, + 'validate_payment_method_type_arg', + ), + 'sanitize_callback' => 'rest_sanitize_request_arg', + ) + ); + } + + /** + * Returns the schema for the `customer_id` parameter. + * + * @since 4.7.0 + * + * @param array $args Argument overrides. + * @return array + */ + public static function get_customer_id_schema( $args = array() ) { + return wp_parse_args( + $args, + array( + 'type' => 'string', + 'required' => true, + 'description' => __( + 'The payment\'s customer.', + 'stripe' + ), + 'validate_callback' => 'rest_validate_request_arg', + 'sanitize_callback' => 'rest_sanitize_request_arg', + ) + ); + } + + /** + * Returns the schema for the `subscription_id` parameter. + * + * @since 4.7.0 + * + * @param array $args Argument overrides. + * @return array + */ + public static function get_subscription_id_schema( $args = array() ) { + return wp_parse_args( + $args, + array( + 'type' => 'string', + 'required' => true, + 'description' => __( + 'The payment\'s subscription ID.', + 'stripe' + ), + 'validate_callback' => 'rest_validate_request_arg', + 'sanitize_callback' => 'rest_sanitize_request_arg', + ) + ); + } + + /** + * Returns the schema for the `setup_intent_id` parameter. + * + * @since 4.7.0 + * + * @param array $args Argument overrides. + * @return array + */ + public static function get_setup_intent_id_schema( $args = array() ) { + return wp_parse_args( + $args, + array( + 'type' => 'string', + 'required' => true, + 'description' => __( + 'The payment\'s SetupIntent ID.', + 'stripe' + ), + 'validate_callback' => 'rest_validate_request_arg', + 'sanitize_callback' => 'rest_sanitize_request_arg', + ) + ); + } + + /** + * Returns the schema for the `payment_method_id` parameter. + * + * @since 4.7.0 + * + * @param array $args Argument overrides. + * @return array + */ + public static function get_payment_method_id_schema( $args = array() ) { + return wp_parse_args( + $args, + array( + 'type' => 'string', + 'required' => true, + 'description' => __( + 'The payment method\'s ID.', + 'stripe' + ), + 'validate_callback' => 'rest_validate_request_arg', + 'sanitize_callback' => 'rest_sanitize_request_arg', + ) + ); + } + + /** + * Returns the schema for the `subscription_key` parameter. + * + * @since 4.7.0 + * + * @param array $args Argument overrides. + * @return array + */ + public static function get_subscription_key_schema( $args = array() ) { + return wp_parse_args( + $args, + array( + 'type' => 'string', + 'required' => true, + 'description' => __( + 'The payment\'s subscription key.', + 'stripe' + ), + 'validate_callback' => 'rest_validate_request_arg', + 'sanitize_callback' => 'rest_sanitize_request_arg', + ) + ); + } + + /** + * Returns the schema for the `object_id` parameter. + * + * @since 4.7.0 + * + * @param array $args Argument overrides. + * @return array + */ + public static function get_object_id_schema( $args = array() ) { + return wp_parse_args( + $args, + array( + 'type' => 'string', + 'required' => true, + 'description' => __( + 'The payment object to update.', + 'stripe' + ), + 'validate_callback' => 'rest_validate_request_arg', + 'sanitize_callback' => 'rest_sanitize_request_arg', + ) + ); + } + + /** + * Returns the schema for the `tax_calc_id` parameter. + * + * @since 4.7.0 + * + * @param array $args Argument overrides. + * @return array + */ + public static function get_tax_calc_id_schema( $args = array() ) { + return wp_parse_args( + $args, + array( + 'type' => 'string', + 'required' => false, + 'description' => __( + 'The payment\'s tax calculation.', + 'stripe' + ), + 'validate_callback' => 'rest_validate_request_arg', + 'sanitize_callback' => 'rest_sanitize_request_arg', + ) + ); + } + + /** + * Returns the schema for addresses. + * + * @since 4.7.0 + * + * @return array + */ + private static function get_address_fields_schema() { + return array( + 'line1' => array( + 'description' => __( 'Address.', 'stripe' ), + 'type' => array( 'string', 'null' ), + ), + 'line2' => array( + 'description' => __( 'Apartment, suite, etc.', 'stripe' ), + 'type' => array( 'string', 'null' ), + ), + 'city' => array( + 'description' => __( 'City.', 'stripe' ), + 'type' => array( 'string', 'null' ), + ), + 'state' => array( + 'description' => __( 'State/County code, or name of the state, county, province, or district.', 'stripe' ), + 'type' => array( 'string', 'null' ), + ), + 'postcode' => array( + 'description' => __( 'Postal code.', 'stripe' ), + 'type' => array( 'string', 'null' ), + ), + 'country' => array( + 'description' => __( 'Country/Region code in ISO 3166-1 alpha-2 format.', 'stripe' ), + 'type' => 'string', + ), + ); + } + +} diff --git a/src/RestApi/Internal/Payment/Utils/SchemaValidationUtils.php b/src/RestApi/Internal/Payment/Utils/SchemaValidationUtils.php new file mode 100644 index 00000000..be2e2ae7 --- /dev/null +++ b/src/RestApi/Internal/Payment/Utils/SchemaValidationUtils.php @@ -0,0 +1,524 @@ +has_available_schedule(); + + if ( false === $available ) { + return false; + } + + // Finally, this form ID can be used. + return true; + } + + /** + * Validates that a `payment_method_type` parameter is valid for the given request. + * + * @since 4.7.0 + * + * @param string $value The `payment_method_type` parameter value. + * @param \WP_REST_Request $request The payment request. + * @param string $param The parameter name. + * @return bool + */ + public static function validate_payment_method_type_arg( $value, $request, $param ) { + // First, validate the argument based on its registered schema. + $validate = rest_validate_request_arg( $value, $request, $param ); + + if ( is_wp_error( $validate ) ) { + return false; + } + + // Next, validate that the form exists. + // When using a parameter inside of a validation function, we do not know + // if it has been validated yet. So we need to validate it again. + /** @var int $form_id */ + $form_id = $request->get_param( 'form_id' ); + $form_id = intval( $form_id ); + $form = simpay_get_form( $form_id ); + + if ( false === $form ) { + return false; + } + + // Next, determine if the payment method is enabled. + $payment_methods_types = Payment_Methods\get_form_payment_method_ids( $form ); + + if ( ! in_array( $value, $payment_methods_types, true ) ) { + return false; + } + + // Finally, this payment method type can be used. + return true; + } + + /** + * Validates that a `price_id` parameter can be transformed into a PriceOption object. + * + * @since 4.7.0 + * + * @param string $value The `price_id parameter value. + * @param \WP_REST_Request $request The payment request. + * @param string $param The parameter name. + * @return bool + */ + public static function validate_price_id_arg( $value, $request, $param ) { + // First, validate the argument based on its registered schema. + $validate = rest_validate_request_arg( $value, $request, $param ); + + if ( is_wp_error( $validate ) ) { + return false; + } + + // Next, validate that the form exists. + // When using a parameter inside of a validation function, we do not know + // if it has been validated yet. So we need to validate it again. + /** @var int $form_id */ + $form_id = $request->get_param( 'form_id' ); + $form_id = intval( $form_id ); + $form = simpay_get_form( $form_id ); + + if ( false === $form ) { + return false; + } + + // Next, validate that the price exists. + $price = simpay_payment_form_prices_get_price_by_id( $form, $value ); + + if ( ! $price instanceof PriceOption ) { + return false; + } + + // Validate that the price IDs are valid. + if ( $form->allows_multiple_line_items() ) { + return self::is_valid_price_ids( $request, $form ); + } + + // Next, determine if a custom amount also needs to be supplied. + // + // We do not require the `custom_amount` parameter at the schema level... + // though we probably could... + + /** @var int $custom_amount */ + $custom_amount = $request->get_param( 'custom_amount' ); + $custom_amount = intval( $custom_amount ); + + if ( $price->unit_amount_min && ! $custom_amount ) { + return false; + } + + // Finally, this price ID can be used. + return true; + } + + /** + * Validates that a `quantity` value is valid stock. + * + * @since 4.7.0 + * + * @param int $value The `quantity` parameter value. + * @param \WP_REST_Request $request The payment request. + * @param string $param The parameter name. + * @return bool + */ + public static function validate_quantity_arg( $value, $request, $param ) { + // First, validate the argument based on its registered schema. + $validate = rest_validate_request_arg( $value, $request, $param ); + + if ( is_wp_error( $validate ) ) { + return false; + } + + // Next, validate that the form exists. + // When using a parameter inside of a validation function, we do not know + // if it has been validated yet. So we need to validate it again. + /** @var int $form_id */ + $form_id = $request->get_param( 'form_id' ); + $form_id = intval( $form_id ); + $form = simpay_get_form( $form_id ); + + if ( false === $form ) { + return false; + } + + // Next, validate that the price exists. + // When using a parameter inside of a validation function, we do not know + // if it has been validated yet. So we need to validate it again. + /** @var string $price_id */ + $price_id = $request->get_param( 'price_id' ); + $price_id = sanitize_text_field( $price_id ); + $price = simpay_payment_form_prices_get_price_by_id( + $form, + $price_id + ); + + if ( ! $price instanceof PriceOption ) { + return false; + } + + // Validate that the price IDs are valid. + if ( $form->allows_multiple_line_items() ) { + return self::is_valid_price_ids( $request, $form ); + } + + // Next, determine if the price still has enough stock remaining. + if ( ! $price->is_in_stock( $value ) ) { + return false; + } + + // Finally, this quantity can be used. + return true; + } + + /** + * Validates that a `custom_amount` parameter is a valid amount. + * + * @since 4.7.0 + * + * @param string $value The `custom_amount` parameter value. + * @param \WP_REST_Request $request The payment request. + * @param string $param The parameter name. + * @return bool + */ + public static function validate_custom_amount_arg( $value, $request, $param ) { + // First, validate the argument based on its registered schema. + $validate = rest_validate_request_arg( $value, $request, $param ); + + if ( is_wp_error( $validate ) ) { + return false; + } + + // Next, validate that the form exists. + // When using a parameter inside of a validation function, we do not know + // if it has been validated yet. So we need to validate it again. + /** @var int $form_id */ + $form_id = $request->get_param( 'form_id' ); + $form_id = intval( $form_id ); + $form = simpay_get_form( $form_id ); + + if ( false === $form ) { + return false; + } + + // Next, validate that the price exists. + // When using a parameter inside of a validation function, we do not know + // if it has been validated yet. So we need to validate it again. + /** @var string $price_id */ + $price_id = $request->get_param( 'price_id' ); + $price_id = sanitize_text_field( $price_id ); + $price = simpay_payment_form_prices_get_price_by_id( + $form, + $price_id + ); + + if ( ! $price instanceof PriceOption ) { + return false; + } + + // Validate that the price IDs are valid. + if ( $form->allows_multiple_line_items() ) { + return self::is_valid_price_ids( $request, $form ); + } + + // Next, validate that the price option is a custom amount. If it is not, + // do not accept a custom amount. + $is_defined_price = simpay_payment_form_prices_is_defined_price( + $price->id + ); + + if ( true === $is_defined_price ) { + return false; + } + + // Next, validate that the custom amount meets the minimum amount. + // Ensure we are comparing integers. I'm not sure why PriceOption + // was not setting this previously. + $unit_amount_min = intval( $price->unit_amount_min ); + + if ( $value < $unit_amount_min ) { + return false; + } + + // Finally, this custom amount can be used. + return true; + } + + /** + * Validates that a `coupon_code` parameter is valid. + * + * @since 4.7.0 + * + * @param string $value The `coupon_code` parameter value. + * @param \WP_REST_Request $request The payment request. + * @param string $param The parameter name. + * @return bool + */ + public static function validate_coupon_code_arg( $value, $request, $param ) { + // First, validate the argument based on its registered schema. + $validate = rest_validate_request_arg( $value, $request, $param ); + + if ( is_wp_error( $validate ) ) { + return false; + } + + // Next, validate that the form exists. + // When using a parameter inside of a validation function, we do not know + // if it has been validated yet. So we need to validate it again. + /** @var int $form_id */ + $form_id = $request->get_param( 'form_id' ); + $form_id = intval( $form_id ); + $form = simpay_get_form( $form_id ); + + if ( false === $form ) { + return false; + } + + // We have already validated that the coupon won't put the amount + // below the minimum amount when applying it via the form. + // + // Someone using the REST API directly could still apply a coupon that + // would put the amount below the minimum amount, but Stripe will reject. + // So we don't need to validate that here, just that it applies to the form. + + // Next, if the coupon was created within WP Simple Pay, check the form restrictions. + $api_args = $form->get_api_request_args(); + $coupons = new Coupon_Query( + $form->is_livemode(), + $api_args['api_key'] + ); + + $coupon = $coupons->get_by_name( $value ); + + // ...the coupon was not created within WP Simple Pay, so it is valid, + // since it cannot have form restrictions if it was created outside of WP Simple Pay. + if ( ! $coupon instanceof Coupon ) { + return true; + } + + if ( + $request->get_param( 'price_id' ) && + false === $coupon->applies_to_form( $form->id ) + ) { + return false; + } + + // Finally, this coupon can be used. + return true; + } + + /** + * Determines if the REST API request contains all required fields. + * + * @since 4.7.0 + * + * @param array> $value The `form_values` parameter value. + * @param \WP_REST_Request $request The payment request. + * @param string $param The parameter name. + * @return bool|\WP_Error True if the form values are valid, \WP_Error otherwise. + */ + public static function validate_form_values_arg( $value, $request, $param ) { + // First, validate the argument based on its registered schema. + $validate = rest_validate_request_arg( $value, $request, $param ); + if ( is_wp_error( $validate ) ) { + return $validate; // Return the original \WP_Error. + } + + // Next, validate that the form exists. + // When using a parameter inside of a validation function, we do not know + // if it has been validated yet. So we need to validate it again. + /** @var int $form_id */ + $form_id = $request->get_param( 'form_id' ); + $form_id = intval( $form_id ); + $form = simpay_get_form( $form_id ); + if ( false === $form ) { + return new \WP_Error( 'invalid_form_id', __( 'The provided form ID is invalid.', 'stripe' ) ); + } + + // Next, check for required fields. + /** @var array> $custom_fields */ + $custom_fields = simpay_get_saved_meta( $form->id, '_custom_fields' ); + /** @var array $form_values */ + $form_values = $value; + $always_required = array( 'email', 'address' ); + + foreach ( $custom_fields as $custom_field_type => $custom_field_types ) { + foreach ( $custom_field_types as $field ) { + /** @var array $field */ + if ( ! in_array( $custom_field_type, $always_required, true ) && ! isset( $field['required'] ) ) { + continue; + } + + // Check custom fields. + if ( isset( $field['metadata'] ) ) { + if ( empty( $field['metadata'] ) ) { + $id = isset( $field['uid'] ) ? $field['uid'] : ''; + $meta_key = 'simpay-form-' . $form->id . '-field-' . $id; + } else { + $meta_key = $field['metadata']; + } + + if ( ! isset( $form_values['simpay_field'][ $meta_key ] ) ) { + /* translators: %s is replaced with the required field. */ + return new \WP_Error( 'missing_required_field', sprintf( __( 'The required field "%s" is missing.', 'stripe' ), $meta_key ) ); + } + + $value = trim( $form_values['simpay_field'][ $meta_key ] ); + if ( empty( $value ) ) { + /* translators: %s is replaced with the required field. */ + return new \WP_Error( 'empty_required_field', sprintf( __( 'The required field "%s" cannot be empty.', 'stripe' ), $meta_key ) ); + } + } + + // Check Customer fields. + switch ( $custom_field_type ) { + case 'tax_id': + if ( ! isset( $form_values['simpay_tax_id'] ) || ! isset( $form_values['simpay_tax_id_type'] ) ) { + return new \WP_Error( 'missing_tax_id', __( 'Tax ID and Tax ID Type are required fields.', 'stripe' ) ); + } + + /** @var string $tax_id */ + $tax_id = $form_values['simpay_tax_id']; + /** @var string $tax_type */ + $tax_type = $form_values['simpay_tax_id_type']; + $tax_id = trim( $tax_id ); + $tax_type = trim( $tax_type ); + + if ( empty( $tax_id ) || empty( $tax_type ) ) { + return new \WP_Error( 'empty_tax_id', __( 'Tax ID and Tax ID Type cannot be empty.', 'stripe' ) ); + } + break; + + case 'address': + $address_type = ( isset( $field['collect-shipping'] ) && 'yes' === $field['collect-shipping'] ) ? 'shipping' : 'billing'; + /** @var array> $address */ + $address = $request->get_param( $address_type . '_address' ); + + if ( ! isset( $address['name'] ) ) { + /* translators: %s is replaced with the address type (billing or shipping).*/ + return new \WP_Error( 'missing_address_name', sprintf( __( 'The %s address name is required.', 'stripe' ), $address_type ) ); + } + + if ( ! isset( $address['address']['country'] ) ) { + /* translators: %s is replaced with the address type (billing or shipping).*/ + return new \WP_Error( 'missing_address_country', sprintf( __( 'The %s address country is required.', 'stripe' ), $address_type ) ); + } + + if ( ! isset( $address['address']['postal_code'] ) ) { + /* translators: %s is replaced with the address type (billing or shipping). */ + return new \WP_Error( 'missing_address_postal_code', sprintf( __( 'The %s address postal code is required.', 'stripe' ), $address_type ) ); + } + break; + + case 'email': + case 'customer_name': + case 'telephone': + $field_name = $field['label']; + if ( ! isset( $form_values[ 'simpay_' . $custom_field_type ] ) ) { + /* translators: %s is replaced with the field type (email, customer_name, or telephone).*/ + return new \WP_Error( 'missing_' . $custom_field_type, sprintf( __( 'The %s field is required.', 'stripe' ), $field_name ) ); + } + + /** @var string $value */ + $value = $form_values[ 'simpay_' . $custom_field_type ]; + $value = trim( $value ); + + if ( empty( $value ) ) { + /* translators: %s is replaced with the field type (email, customer_name, or telephone). */ + return new \WP_Error( 'empty_' . $custom_field_type, sprintf( __( 'The %s field can not be empty.', 'stripe' ), $field_name ) ); + } + break; + } + } + } + + /** + * Finally, these values can be used. + */ + return true; + } + + /** + * Validate line items. + * + * @since 4.11.0 + * @param \WP_REST_Request $request The payment request. + * @param \SimplePay\Core\Abstracts\Form $form The payment form. + * @return bool + */ + public static function is_valid_price_ids( $request, $form ) { + $line_items = PaymentRequestUtils::get_price_ids( $request ); + + $valid = array_filter( + $line_items, + function( $line_item ) use ( $form ) { + /** @var \SimplePay\Core\PaymentForm\PriceOption $price */ + $price = simpay_payment_form_prices_get_price_by_id( $form, $line_item['price_id'] ); + + if ( ! $price instanceof PriceOption ) { + return false; + } + + // Check if the price is custom. + if ( ! simpay_payment_form_prices_is_defined_price( $line_item['price_id'] ) ) { + + // Validate for custom price. + if ( $price->unit_amount_min > $line_item['custom_amount'] ) { + return false; + } + } + + return true; + } + ); + + return count( $valid ) > 0; + } +} diff --git a/src/RestApi/Internal/Payment/Utils/TaxUtils.php b/src/RestApi/Internal/Payment/Utils/TaxUtils.php new file mode 100644 index 00000000..4de48f65 --- /dev/null +++ b/src/RestApi/Internal/Payment/Utils/TaxUtils.php @@ -0,0 +1,166 @@ + $object_args The object arguments. + * @return array + */ + public static function add_automatic_tax_args( $request, $object_args ) { + $form = PaymentRequestUtils::get_form( $request ); + $tax_status = get_post_meta( $form->id, '_tax_status', true ); + + if ( 'automatic' !== $tax_status ) { + return $object_args; + } + + $object_args['automatic_tax'] = array( + 'enabled' => true, + ); + + return $object_args; + } + + /** + * Adds fixed tax rates for the given line items and request. + * + * Unlike `FeeRecoveryUtils::add_subscription_fee_recovery_line_items()`, this + * method needs to work for arbitrary line item parameters. Fee Recovery can + * do this because Fee Recovery is not compatible with Stripe Checkout. + * + * Checkout Session: `line_items=`. + * Subscription: `add_invoice_items=`, and `items=`. + * + * @param \WP_REST_Request $request The payment request. + * @param array $line_items Line items to add tax information. + * @return array + */ + public static function add_tax_rates_to_line_items( $request, $line_items ) { + $form = PaymentRequestUtils::get_form( $request ); + $tax_rates = simpay_get_payment_form_tax_rates( $form ); + $tax_status = get_post_meta( $form->id, '_tax_status', true ); + + // If using fixed tax rates, add them to the line items. + if ( ! ( empty( $tax_status ) || 'fixed-global' === $tax_status ) ) { + return $line_items; + } + + $tax_rates = simpay_get_payment_form_tax_rates( $form ); + $tax_rate_ids = ! empty( $tax_rates ) + ? wp_list_pluck( $tax_rates, 'id' ) + : array(); + + $line_items = array_map( + function( $line_item ) use ( $tax_rate_ids ) { + /** @var array $line_item */ + $line_item['tax_rates'] = $tax_rate_ids; + + return $line_item; + }, + $line_items + ); + + return $line_items; + } + + /** + * Returns the tax amount for the given request, and amount. + * + * @since 4.7.0 + * + * @param \WP_REST_Request $request The payment request. + * @param int $amount_to_tax The amount to calculate the tax amount for. + * @return int + */ + public static function get_tax_unit_amount( $request, $amount_to_tax ) { + $form = PaymentRequestUtils::get_form( $request ); + $tax_status = get_post_meta( $form->id, '_tax_status', true ); + + // No tax. + if ( 'none' === $tax_status ) { + return 0; + } + + // Automatic tax. + $tax_calc_id = PaymentRequestUtils::get_tax_calc_id( $request ); + + if ( 'automatic' === $tax_status && ! empty( $tax_calc_id ) ) { + $tax_line_items = Stripe_API::request( + 'Tax\Calculation', + 'allLineItems', + $tax_calc_id, + $form->get_api_request_args() + ); + + return array_reduce( + $tax_line_items->data, + function( $total, $tax_line_item ) { + return $total + $tax_line_item->amount_tax; + }, + 0 + ); + } + + // Fixed rates. + $tax_rates = simpay_get_payment_form_tax_rates( $form ); + + if ( empty( $tax_rates ) ) { + return 0; + } + + // Remove inclusive tax amount. + $inclusive_tax_amount = array_reduce( + $tax_rates, + function( $amount, $tax_rate ) use ( $amount_to_tax ) { + if ( 'exclusive' === $tax_rate->calculation ) { + return $amount; + } + + return $amount + ( $amount_to_tax * ( $tax_rate->percentage / 100 ) ); + }, + 0 + ); + + $post_inclusive_unit_amount = round( $amount_to_tax - $inclusive_tax_amount ); + + $tax = array_reduce( + $tax_rates, + function( $tax, $tax_rate ) use ( $post_inclusive_unit_amount ) { + if ( 'inclusive' === $tax_rate->calculation ) { + return $tax; + } + + $tax_rate = $tax_rate->percentage / 100; + + return $tax + ( $post_inclusive_unit_amount * $tax_rate ); + }, + 0 + ); + + return (int) round( $tax ); + } + +} diff --git a/src/RestApi/Internal/Payment/Utils/TokenValidationUtils.php b/src/RestApi/Internal/Payment/Utils/TokenValidationUtils.php new file mode 100644 index 00000000..b3e94425 --- /dev/null +++ b/src/RestApi/Internal/Payment/Utils/TokenValidationUtils.php @@ -0,0 +1,212 @@ +get_param( 'token' ); + $existing_recaptcha = simpay_get_setting( 'recaptcha_site_key', '' ); + $default = ! empty( $existing_recaptcha ) + ? 'recaptcha-v3' + : ''; + $type = simpay_get_setting( 'captcha_type', $default ); + + switch ( $type ) { + case 'recaptcha-v3': + return self::validate_recaptcha_v3_token( $token ); + case 'hcaptcha': + return self::validate_hcaptcha_token( $token ); + case 'cloudflare-turnstile': + return self::validate_cloudflare_turnstile_token( $token, $request ); + default: + return true; + } + } + + /** + * Validates a Google reCAPTCHA v3 token. + * + * @since 4.7.0 + * + * @param string $token The CAPTCHA token. + * @return bool + */ + private static function validate_recaptcha_v3_token( $token ) { + $request = wp_remote_post( + 'https://www.google.com/recaptcha/api/siteverify', + array( + 'body' => array( + 'secret' => simpay_get_setting( 'recaptcha_secret_key' ), + 'response' => $token, + 'remoteip' => Utils\get_current_ip_address(), + ), + ) + ); + + // Request fails. + if ( is_wp_error( $request ) ) { + return false; + } + + $response = json_decode( wp_remote_retrieve_body( $request ), true ); + + // No score available. + if ( ! isset( $response['score'] ) ) { + return false; + } + + // Actions do not match. + if ( + isset( $response['action'] ) && + 'simpay_payment' !== $response['action'] + ) { + return false; + } + + $threshold = simpay_get_setting( + 'recaptcha_score_threshold', + 'aggressive' + ); + + switch ( $threshold ) { + case 'aggressive': + $minimum_score = '0.80'; + break; + default: + $minimum_score = '0.50'; + } + + /** + * Filter the minimum score allowed for a reCAPTCHA response to allow form submission. + * + * @since 3.9.6 + * + * @param string $minimum_score Minumum score. + */ + $minimum_score = apply_filters( 'simpay_recpatcha_minimum_score', $minimum_score ); + + return floatval( $response['score'] ) >= floatval( $minimum_score ); + } + + /** + * Validates an hCaptcha token. + * + * @since 4.7.0 + * + * @param string $token The CAPTCHA token. + * @return bool + */ + private static function validate_hcaptcha_token( $token ) { + $request = wp_remote_post( + 'https://hcaptcha.com/siteverify', + array( + 'body' => array( + 'secret' => simpay_get_setting( 'hcaptcha_secret_key', '' ), + 'response' => $token, + 'sitekey' => simpay_get_setting( 'hcaptcha_site_key', '' ), + 'remoteip' => Utils\get_current_ip_address(), + ), + ) + ); + + // Request fails. + if ( is_wp_error( $request ) ) { + return false; + } + + $response = wp_remote_retrieve_body( $request ); + + if ( empty( $response ) ) { + return false; + } + + $response = json_decode( $response ); + + if ( null === $response ) { + return false; + } + + return $response->success; + } + + /** + * Validates a Cloudflare Turnstile token. + * + * @since 4.7.0 + * + * @param string $token The CAPTCHA token. + * @param \WP_REST_Request $payment_request The payment request. + * @return bool + */ + private static function validate_cloudflare_turnstile_token( $token, $payment_request ) { + $request = wp_remote_post( + 'https://challenges.cloudflare.com/turnstile/v0/siteverify', + array( + 'body' => array( + 'secret' => simpay_get_setting( + 'cloudflare_turnstile_secret_key', + '' + ), + 'response' => $token, + 'remoteip' => Utils\get_current_ip_address(), + ), + ) + ); + + // Request fails. + if ( is_wp_error( $request ) ) { + return false; + } + + $response = wp_remote_retrieve_body( $request ); + + if ( empty( $response ) ) { + return false; + } + + $response = json_decode( $response ); + + if ( null === $response ) { + return false; + } + + $form = PaymentRequestUtils::get_form( $payment_request ); + $action = sprintf( 'simpay-form-%d', $form->id ); + + if ( $response->action !== $action ) { + return false; + } + + return $response->success; + } +}