Merge trunk

This commit is contained in:
Emili Castells Guasch 2025-06-20 15:30:04 +02:00
commit 4a20d6a00c
No known key found for this signature in database
40 changed files with 1458 additions and 305 deletions

View file

@ -682,6 +682,11 @@ return array(
'GB' => $default_currencies,
'US' => $default_currencies,
'NO' => $default_currencies,
'YT' => $default_currencies,
'RE' => $default_currencies,
'GP' => $default_currencies,
'GF' => $default_currencies,
'MQ' => $default_currencies,
)
);
},

View file

@ -135,12 +135,12 @@ class BillingAgreementsEndpoint {
);
} finally {
$this->is_request_logging_enabled = true;
set_transient( 'ppcp_reference_transaction_enabled', true, MONTH_IN_SECONDS );
}
set_transient( 'ppcp_reference_transaction_enabled', true, MONTH_IN_SECONDS );
return true;
} catch ( Exception $exception ) {
delete_transient( 'ppcp_reference_transaction_enabled' );
set_transient( 'ppcp_reference_transaction_enabled', false, HOUR_IN_SECONDS );
return false;
}
}

View file

@ -58,20 +58,22 @@ class PartnerAttribution {
}
/**
* Initializes the BN Code if not already set.
*
* This method ensures that the BN Code is only stored once during the initial setup.
* Initializes or updates the BN Code.
*
* @param string $installation_path The installation path used to determine the BN Code.
* @param bool $force_update Whether to force an update of the BN code if it already exists.
*/
public function initialize_bn_code( string $installation_path ) : void {
public function initialize_bn_code( string $installation_path, bool $force_update = false ) : void {
$selected_bn_code = $this->bn_codes[ $installation_path ] ?? '';
if ( ! $selected_bn_code || get_option( $this->bn_code_option_name ) ) {
if ( ! $selected_bn_code ) {
return;
}
$existing_bn_code = get_option( $this->bn_code_option_name );
if ( $existing_bn_code && ! $force_update ) {
return;
}
// This option is permanent and should not change.
update_option( $this->bn_code_option_name, $selected_bn_code );
}

View file

@ -11,6 +11,7 @@ namespace WooCommerce\PayPalCommerce\Applepay;
use WC_Payment_Gateway;
use Automattic\WooCommerce\Blocks\Payments\PaymentMethodRegistry;
use WooCommerce\PayPalCommerce\ApiClient\Factory\ExperienceContextBuilder;
use WooCommerce\PayPalCommerce\Applepay\Assets\ApplePayButton;
use WooCommerce\PayPalCommerce\Applepay\Assets\AppleProductStatus;
use WooCommerce\PayPalCommerce\Applepay\Assets\PropertiesDictionary;
@ -198,6 +199,31 @@ class ApplepayModule implements ServiceModule, ExtendingModule, ExecutableModule
}
);
add_filter(
'ppcp_create_order_request_body_data',
static function ( array $data, string $payment_method, array $request ) use ( $c ) : array {
if ( $payment_method !== ApplePayGateway::ID ) {
return $data;
}
$experience_context_builder = $c->get( 'wcgateway.builder.experience-context' );
assert( $experience_context_builder instanceof ExperienceContextBuilder );
$data['payment_source'] = array(
'apple_pay' => array(
'experience_context' => $experience_context_builder
->with_endpoint_return_urls()
->build()->to_array(),
),
);
return $data;
},
10,
3
);
return true;
}

View file

@ -392,50 +392,6 @@ class CreateOrderEndpoint implements EndpointInterface {
return false;
}
/**
* Once the checkout has been validated we execute this method.
*
* @param array $data The data.
* @param \WP_Error $errors The errors, which occurred.
*
* @return array
* @throws Exception On Error.
*/
public function after_checkout_validation( array $data, \WP_Error $errors ): array {
if ( ! $errors->errors ) {
try {
$order = $this->create_paypal_order();
} catch ( Exception $exception ) {
$this->logger->error( 'Order creation failed: ' . $exception->getMessage() );
throw $exception;
}
/**
* In case we are onboarded and everything is fine with the \WC_Order
* we want this order to be created. We will intercept it and leave it
* in the "Pending payment" status though, which than later will change
* during the "onApprove"-JS callback or the webhook listener.
*/
if ( ! $this->early_order_handler->should_create_early_order() ) {
wp_send_json_success( $this->make_response( $order ) );
}
$this->early_order_handler->register_for_order( $order );
return $data;
}
$this->logger->error( 'Checkout validation failed: ' . $errors->get_error_message() );
wp_send_json_error(
array(
'name' => '',
'message' => $errors->get_error_message(),
'code' => (int) $errors->get_error_code(),
'details' => array(),
)
);
return $data;
}
/**
* Creates the order in the PayPal, uses data from WC order if provided.
*
@ -485,8 +441,13 @@ class CreateOrderEndpoint implements EndpointInterface {
}
}
$payment_source_key = 'paypal';
if ( in_array( $funding_source, array( 'venmo' ), true ) ) {
$payment_source_key = $funding_source;
}
$payment_source = new PaymentSource(
'paypal',
$payment_source_key,
(object) array(
'experience_context' => $this->experience_context_builder
->with_default_paypal_config( $shipping_preference, $action )

View file

@ -12,6 +12,7 @@ namespace WooCommerce\PayPalCommerce\CardFields;
use DomainException;
use Psr\Log\LoggerInterface;
use WooCommerce\PayPalCommerce\ApiClient\Entity\Order;
use WooCommerce\PayPalCommerce\ApiClient\Factory\ExperienceContextBuilder;
use WooCommerce\PayPalCommerce\CardFields\Service\CardCaptureValidator;
use WooCommerce\PayPalCommerce\Vendor\Inpsyde\Modularity\Module\ExecutableModule;
use WooCommerce\PayPalCommerce\Vendor\Inpsyde\Modularity\Module\ExtendingModule;
@ -150,6 +151,15 @@ class CardFieldsModule implements ServiceModule, ExtendingModule, ExecutableModu
$settings = $c->get( 'wcgateway.settings' );
assert( $settings instanceof Settings );
$experience_context_builder = $c->get( 'wcgateway.builder.experience-context' );
assert( $experience_context_builder instanceof ExperienceContextBuilder );
$payment_source_data = array(
'experience_context' => $experience_context_builder
->with_endpoint_return_urls()
->build()->to_array(),
);
$three_d_secure_contingency =
$settings->has( '3d_secure_contingency' )
? apply_filters( 'woocommerce_paypal_payments_three_d_secure_contingency', $settings->get( '3d_secure_contingency' ) )
@ -159,15 +169,15 @@ class CardFieldsModule implements ServiceModule, ExtendingModule, ExecutableModu
$three_d_secure_contingency === 'SCA_ALWAYS'
|| $three_d_secure_contingency === 'SCA_WHEN_REQUIRED'
) {
$data['payment_source']['card'] = array(
'attributes' => array(
'verification' => array(
'method' => $three_d_secure_contingency,
),
$payment_source_data['attributes'] = array(
'verification' => array(
'method' => $three_d_secure_contingency,
),
);
}
$data['payment_source'] = array( 'card' => $payment_source_data );
return $data;
},
10,

View file

@ -71,6 +71,7 @@ class CompatModule implements ServiceModule, ExtendingModule, ExecutableModule {
$this->migrate_pay_later_settings( $c );
$this->migrate_smart_button_settings( $c );
$this->migrate_three_d_secure_setting();
$this->fix_page_builders();
$this->exclude_cache_plugins_js_minification( $c );
@ -274,6 +275,35 @@ class CompatModule implements ServiceModule, ExtendingModule, ExecutableModule {
);
}
/**
* Migrates the old Three D Secure setting located in PaymentSettings to the new location in SettingsModel.
*
* The migration will be done on plugin update if it hasn't already done.
*/
protected function migrate_three_d_secure_setting(): void {
add_action(
'woocommerce_paypal_payments_gateway_migrate_on_update',
function () {
$payment_settings = get_option( 'woocommerce-ppcp-data-payment' ) ?: array();
$data_settings = get_option( 'woocommerce-ppcp-data-settings' ) ?: array();
// Skip if payment settings don't have the setting but data settings do.
if ( ! isset( $payment_settings['three_d_secure'] ) && isset( $data_settings['three_d_secure'] ) ) {
return;
}
// Move the setting.
$data_settings['three_d_secure'] = $payment_settings['three_d_secure'];
unset( $payment_settings['three_d_secure'] );
// Save both.
update_option( 'woocommerce-ppcp-data-settings', $data_settings );
update_option( 'woocommerce-ppcp-data-payment', $payment_settings );
}
);
}
/**
* Changes the button rendering place for page builders
* that do not work well with our default places.

View file

@ -11,7 +11,6 @@ namespace WooCommerce\PayPalCommerce\Compat\Settings;
use WooCommerce\PayPalCommerce\Axo\Gateway\AxoGateway;
use WooCommerce\PayPalCommerce\Settings\Data\AbstractDataModel;
use WooCommerce\PayPalCommerce\Settings\Data\PaymentSettings;
use WooCommerce\PayPalCommerce\WcGateway\Gateway\CreditCardGateway;
/**
@ -22,15 +21,6 @@ use WooCommerce\PayPalCommerce\WcGateway\Gateway\CreditCardGateway;
*/
class PaymentMethodSettingsMapHelper {
/**
* A map of new to old 3d secure values.
*/
protected const THREE_D_SECURE_VALUES_MAP = array(
'no-3d-secure' => 'NO_3D_SECURE',
'only-required-3d-secure' => 'SCA_WHEN_REQUIRED',
'always-3d-secure' => 'SCA_ALWAYS',
);
/**
* Maps old setting keys to new payment method settings names.
*
@ -38,9 +28,8 @@ class PaymentMethodSettingsMapHelper {
*/
public function map(): array {
return array(
'dcc_enabled' => CreditCardGateway::ID,
'axo_enabled' => AxoGateway::ID,
'3d_secure_contingency' => 'three_d_secure',
'dcc_enabled' => CreditCardGateway::ID,
'axo_enabled' => AxoGateway::ID,
);
}
@ -52,25 +41,13 @@ class PaymentMethodSettingsMapHelper {
* @return mixed The value of the mapped setting, (null if not found).
*/
public function mapped_value( string $old_key, ?AbstractDataModel $payment_settings ) {
switch ( $old_key ) {
case '3d_secure_contingency':
if ( is_null( $payment_settings ) ) {
return null;
}
$payment_method = $this->map()[ $old_key ] ?? false;
assert( $payment_settings instanceof PaymentSettings );
$selected_three_d_secure = $payment_settings->get_three_d_secure();
return self::THREE_D_SECURE_VALUES_MAP[ $selected_three_d_secure ] ?? null;
default:
$payment_method = $this->map()[ $old_key ] ?? false;
if ( ! $payment_method ) {
return null;
}
return $this->is_gateway_enabled( $payment_method );
if ( ! $payment_method ) {
return null;
}
return $this->is_gateway_enabled( $payment_method );
}
/**

View file

@ -23,6 +23,17 @@ class SettingsTabMapHelper {
use ContextTrait;
/**
* A map of new to old 3d secure values.
*
* @var array<string, string>
*/
protected const THREE_D_SECURE_VALUES_MAP = array(
'no-3d-secure' => 'NO_3D_SECURE',
'only-required-3d-secure' => 'SCA_WHEN_REQUIRED',
'always-3d-secure' => 'SCA_ALWAYS',
);
/**
* Maps old setting keys to new setting keys.
*
@ -43,6 +54,7 @@ class SettingsTabMapHelper {
'blocks_final_review_enabled' => 'enable_pay_now',
'logging_enabled' => 'enable_logging',
'vault_enabled' => 'save_paypal_and_venmo',
'3d_secure_contingency' => 'threeDSecure',
);
}
@ -69,11 +81,30 @@ class SettingsTabMapHelper {
case 'blocks_final_review_enabled':
return $this->mapped_pay_now_value( $settings_model );
case '3d_secure_contingency':
return $this->mapped_3d_secure_value( $settings_model );
default:
return $settings_model[ $new_key ] ?? null;
}
}
/**
* Retrieves the mapped value for the '3d_secure_contingency' from the new settings.
*
* @param array $settings_model The new settings model data.
* @return string|null The mapped '3d_secure_contingency' setting value.
*/
protected function mapped_3d_secure_value( array $settings_model ): ?string {
$three_d_secure = $settings_model['threeDSecure'] ?? null;
if ( ! is_string( $three_d_secure ) ) {
return null;
}
return self::THREE_D_SECURE_VALUES_MAP[ $three_d_secure ] ?? null;
}
/**
* Retrieves the mapped value for the 'mismatch_behavior' from the new settings.
*

View file

@ -11,6 +11,7 @@ namespace WooCommerce\PayPalCommerce\Googlepay;
use Automattic\WooCommerce\Blocks\Payments\PaymentMethodRegistry;
use WC_Payment_Gateway;
use WooCommerce\PayPalCommerce\ApiClient\Factory\ExperienceContextBuilder;
use WooCommerce\PayPalCommerce\Button\Assets\ButtonInterface;
use WooCommerce\PayPalCommerce\Button\Assets\SmartButtonInterface;
use WooCommerce\PayPalCommerce\Googlepay\Endpoint\UpdatePaymentDataEndpoint;
@ -261,6 +262,15 @@ class GooglepayModule implements ServiceModule, ExtendingModule, ExecutableModul
$settings = $c->get( 'wcgateway.settings' );
assert( $settings instanceof Settings );
$experience_context_builder = $c->get( 'wcgateway.builder.experience-context' );
assert( $experience_context_builder instanceof ExperienceContextBuilder );
$payment_source_data = array(
'experience_context' => $experience_context_builder
->with_endpoint_return_urls()
->build()->to_array(),
);
$three_d_secure_contingency =
$settings->has( '3d_secure_contingency' )
? apply_filters( 'woocommerce_paypal_payments_three_d_secure_contingency', $settings->get( '3d_secure_contingency' ) )
@ -270,15 +280,15 @@ class GooglepayModule implements ServiceModule, ExtendingModule, ExecutableModul
$three_d_secure_contingency === 'SCA_ALWAYS'
|| $three_d_secure_contingency === 'SCA_WHEN_REQUIRED'
) {
$data['payment_source']['google_pay'] = array(
'attributes' => array(
'verification' => array(
'method' => $three_d_secure_contingency,
),
$payment_source_data['attributes'] = array(
'verification' => array(
'method' => $three_d_secure_contingency,
),
);
}
$data['payment_source'] = array( 'google_pay' => $payment_source_data );
return $data;
},
10,

View file

@ -78,6 +78,11 @@ return array(
'SE',
'GB',
'US',
'YT',
'RE',
'GP',
'GF',
'MQ',
)
);
},

View file

@ -115,87 +115,67 @@ class SavePaymentMethodsModule implements ServiceModule, ExtendingModule, Execut
function ( array $data, string $payment_method, array $request_data ) use ( $c ): array {
$settings = $c->get( 'wcgateway.settings' );
assert( $settings instanceof Settings );
$new_attributes = array(
'vault' => array(
'store_in_vault' => 'ON_SUCCESS',
),
);
$target_customer_id = get_user_meta( get_current_user_id(), '_ppcp_target_customer_id', true );
if ( ! $target_customer_id ) {
$target_customer_id = get_user_meta( get_current_user_id(), 'ppcp_customer_id', true );
}
if ( $target_customer_id ) {
$new_attributes['customer'] = array(
'id' => $target_customer_id,
);
}
$funding_source = (string) ( $request_data['funding_source'] ?? '' );
if ( $payment_method === CreditCardGateway::ID ) {
if ( ! $settings->has( 'vault_enabled_dcc' ) || ! $settings->get( 'vault_enabled_dcc' ) ) {
return $data;
}
$save_payment_method = $request_data['save_payment_method'] ?? false;
if ( $save_payment_method ) {
$data['payment_source'] = array(
'card' => array(
'attributes' => array(
'vault' => array(
'store_in_vault' => 'ON_SUCCESS',
),
),
),
);
$target_customer_id = get_user_meta( get_current_user_id(), '_ppcp_target_customer_id', true );
if ( ! $target_customer_id ) {
$target_customer_id = get_user_meta( get_current_user_id(), 'ppcp_customer_id', true );
}
if ( $target_customer_id ) {
$data['payment_source']['card']['attributes']['customer'] = array(
'id' => $target_customer_id,
);
}
if ( ! $save_payment_method ) {
return $data;
}
}
if ( $payment_method === PayPalGateway::ID ) {
} elseif ( $payment_method === PayPalGateway::ID ) {
if ( ! $settings->has( 'vault_enabled' ) || ! $settings->get( 'vault_enabled' ) ) {
return $data;
}
$funding_source = $request_data['funding_source'] ?? null;
if ( $funding_source && $funding_source === 'venmo' ) {
$data['payment_source'] = array(
'venmo' => array(
'attributes' => array(
'vault' => array(
'store_in_vault' => 'ON_SUCCESS',
'usage_type' => 'MERCHANT',
'permit_multiple_payment_tokens' => apply_filters( 'woocommerce_paypal_payments_permit_multiple_payment_tokens', false ),
),
),
),
);
} elseif ( $funding_source && $funding_source === 'apple_pay' ) {
$data['payment_source'] = array(
'apple_pay' => array(
'stored_credential' => array(
'payment_initiator' => 'CUSTOMER',
'payment_type' => 'RECURRING',
),
'attributes' => array(
'vault' => array(
'store_in_vault' => 'ON_SUCCESS',
),
),
),
);
} else {
$data['payment_source'] = array(
'paypal' => array(
'attributes' => array(
'vault' => array(
'store_in_vault' => 'ON_SUCCESS',
'usage_type' => 'MERCHANT',
'permit_multiple_payment_tokens' => apply_filters( 'woocommerce_paypal_payments_permit_multiple_payment_tokens', false ),
),
),
),
);
if ( ! in_array( $funding_source, array( 'paypal', 'venmo' ), true ) ) {
return $data;
}
$new_attributes['vault']['usage_type'] = 'MERCHANT';
$new_attributes['vault']['permit_multiple_payment_tokens'] = apply_filters( 'woocommerce_paypal_payments_permit_multiple_payment_tokens', false );
} else {
return $data;
}
$payment_source = (array) ( $data['payment_source'] ?? array() );
$key = array_key_first( $payment_source );
if ( ! is_string( $key ) || empty( $key ) ) {
$key = $payment_method;
if ( $payment_method === PayPalGateway::ID && $funding_source ) {
$key = $funding_source;
}
$payment_source[ $key ] = array();
}
$payment_source[ $key ] = (array) $payment_source[ $key ];
$attributes = (array) ( $payment_source[ $key ]['attributes'] ?? array() );
$payment_source[ $key ]['attributes'] = array_merge( $attributes, $new_attributes );
$data['payment_source'] = $payment_source;
return $data;
},
10,
20,
3
);

View file

@ -69,3 +69,23 @@
margin-top: var(--block-action-gap, 16px);
}
}
.ppcp--notice {
display: block;
padding: 10px;
margin: 10px 0;
line-height: 1.5714285714;
font-size: 0.8125rem;
background: var(--notice-background);
color: var(--notice-text);
&.type--info {
--notice-background: var(--color-success-background);
--notice-text: var(--color-success-text);
}
&.type--error {
--notice-background: var(--color-failure-background);
--notice-text: var(--color-failure-text);
}
}

View file

@ -12,6 +12,10 @@
.ppcp-r-inner-container {
max-width: var(--max-width-onboarding-content);
&.ppcp--wide {
--max-width-onboarding-content: none;
}
}
.ppcp-r-payment-method--separator {

View file

@ -0,0 +1,17 @@
import classNames from 'classnames';
const Notice = ( { children, type = 'info', className = '' } ) => {
if ( ! children ) {
return null;
}
const elementClasses = classNames(
'ppcp--notice',
`type--${ type }`,
className
);
return <span className={ elementClasses }>{ children }</span>;
};
export default Notice;

View file

@ -9,6 +9,7 @@ export { default as ContentWrapper } from './ContentWrapper';
export { default as Description } from './Description';
export { default as Header } from './Header';
export { default as LearnMore } from './LearnMore';
export { default as Notice } from './Notice';
export { default as Separator } from './Separator';
export { default as Title } from './Title';
export { default as TitleExtra } from './TitleExtra';

View file

@ -1,10 +1,19 @@
import { Button } from '@wordpress/components';
import { useEffect, useCallback } from '@wordpress/element';
import { useEffect } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
import classNames from 'classnames';
import { OpenSignup } from '../../../ReusableComponents/Icons';
import { useHandleOnboardingButton } from '../../../../hooks/useHandleConnections';
import { OnboardingHooks } from '../../../../data/onboarding/hooks';
import BusyStateWrapper from '../../../ReusableComponents/BusyStateWrapper';
import { Notice } from '../../../ReusableComponents/Elements';
const useIsFirefox = () => {
if ( typeof window === 'undefined' ) {
return false;
}
return window.navigator.userAgent.toLowerCase().indexOf( 'firefox' ) > -1;
};
/**
* Button component that outputs a placeholder button when no onboardingUrl is present yet - the
@ -27,6 +36,8 @@ const ButtonOrPlaceholder = ( {
children,
onClick,
} ) => {
const isFirefox = useIsFirefox();
const buttonProps = {
className,
variant,
@ -40,6 +51,20 @@ const ButtonOrPlaceholder = ( {
buttonProps[ 'data-paypal-onboard-button' ] = 'true';
}
if ( isFirefox ) {
return (
<>
<Button { ...buttonProps }>{ children }</Button>
<Notice type={ 'error' }>
{ __(
'This button may not work in Firefox. Please use another browser, like Chrome, to complete this step.',
'woocommerce-paypal-payments'
) }
</Notice>
</>
);
}
return <Button { ...buttonProps }>{ children }</Button>;
};

View file

@ -16,7 +16,7 @@ const StepCompleteSetup = () => {
'woocommerce-paypal-payments'
) }
/>
<div className="ppcp-r-inner-container">
<div className="ppcp-r-inner-container ppcp--wide">
<div className="ppcp-r-onboarding-header__description">
<ConnectionButton
title={ __(

View file

@ -12,12 +12,8 @@ import { PaymentHooks } from '../../../../../data';
const Modal = ( { method, setModalIsVisible, onSave } ) => {
const { all: paymentMethods } = PaymentHooks.usePaymentMethods();
const {
paypalShowLogo,
threeDSecure,
fastlaneCardholderName,
fastlaneDisplayWatermark,
} = PaymentHooks.usePaymentMethodsModal();
const { paypalShowLogo, fastlaneCardholderName, fastlaneDisplayWatermark } =
PaymentHooks.usePaymentMethodsModal();
const [ settings, setSettings ] = useState( () => {
if ( ! method?.id ) {
@ -44,7 +40,6 @@ const Modal = ( { method, setModalIsVisible, onSave } ) => {
} );
initialSettings.paypalShowLogo = paypalShowLogo;
initialSettings.threeDSecure = threeDSecure;
initialSettings.fastlaneCardholderName = fastlaneCardholderName;
initialSettings.fastlaneDisplayWatermark = fastlaneDisplayWatermark;

View file

@ -1,13 +1,19 @@
import { __ } from '@wordpress/i18n';
import Accordion from '../../../../../ReusableComponents/AccordionSection';
import SettingsBlock from '../../../../../ReusableComponents/SettingsBlock';
import { ControlSelect } from '../../../../../ReusableComponents/Controls';
import {
ControlSelect,
ControlRadioGroup,
} from '../../../../../ReusableComponents/Controls';
import { SettingsHooks } from '../../../../../../data';
const OtherSettings = () => {
const { disabledCards, setDisabledCards } = SettingsHooks.useSettings();
const { disabledCards, setDisabledCards, threeDSecure, setThreeDSecure } =
SettingsHooks.useSettings();
const disabledCardChoices = window.ppcpSettings.disabledCardsChoices;
const threeDSecureOptions = window.ppcpSettings.threeDSecureOptions;
return (
<Accordion
title={ __(
@ -40,6 +46,19 @@ const OtherSettings = () => {
) }
/>
</SettingsBlock>
<SettingsBlock
title={ __( '3D Secure', 'woocommerce-paypal-payments' ) }
description={ __(
'Authenticate cardholders through their card issuers to reduce fraud and improve transaction security. Successful 3D Secure authentication can shift liability for fraudulent chargebacks to the card issuer.',
'woocommerce-paypal-payments'
) }
>
<ControlRadioGroup
options={ threeDSecureOptions }
value={ threeDSecure }
onChange={ setThreeDSecure }
/>
</SettingsBlock>
</Accordion>
);
};

View file

@ -129,7 +129,6 @@ export const usePaymentMethodsModal = () => {
const { usePersistent } = useStoreData();
const [ paypalShowLogo ] = usePersistent( 'paypalShowLogo' );
const [ threeDSecure ] = usePersistent( 'threeDSecure' );
const [ fastlaneCardholderName ] = usePersistent(
'fastlaneCardholderName'
);
@ -139,7 +138,6 @@ export const usePaymentMethodsModal = () => {
return {
paypalShowLogo,
threeDSecure,
fastlaneCardholderName,
fastlaneDisplayWatermark,
};

View file

@ -68,6 +68,8 @@ const useHooks = () => {
const [ disabledCards, setDisabledCards ] =
usePersistent( 'disabledCards' );
const [ threeDSecure, setThreeDSecure ] = usePersistent( 'threeDSecure' );
return {
invoicePrefix,
setInvoicePrefix,
@ -97,6 +99,8 @@ const useHooks = () => {
setButtonLanguage,
disabledCards,
setDisabledCards,
threeDSecure,
setThreeDSecure,
};
};
@ -143,6 +147,8 @@ export const useSettings = () => {
setButtonLanguage,
disabledCards,
setDisabledCards,
threeDSecure,
setThreeDSecure,
} = useHooks();
return {
@ -174,5 +180,7 @@ export const useSettings = () => {
setButtonLanguage,
disabledCards,
setDisabledCards,
threeDSecure,
setThreeDSecure,
};
};

View file

@ -34,6 +34,7 @@ const defaultPersistent = Object.freeze( {
subtotalAdjustment: 'no_details', // [correction|no_details] Handling for subtotal mismatches
landingPage: 'any', // [any|login|guest_checkout] PayPal checkout landing page
buttonLanguage: '', // Language for PayPal buttons
threeDSecure: 'only-required-3d-secure', // [no-3d-secure|only-required-3d-secure|always-3d-secure] 3D Secure settings
// Boolean flags.
authorizeOnly: false, // Whether to only authorize payments initially

View file

@ -120,8 +120,12 @@ return array(
return new PaymentSettings();
},
'settings.data.settings' => static function ( ContainerInterface $container ) : SettingsModel {
$environment = $container->get( 'settings.environment' );
assert( $environment instanceof Environment );
return new SettingsModel(
$container->get( 'settings.service.sanitizer' )
$container->get( 'settings.service.sanitizer' ),
$environment->is_sandbox() ? $container->get( 'wcgateway.settings.invoice-prefix-random' ) : $container->get( 'wcgateway.settings.invoice-prefix' )
);
},
'settings.data.paylater-messaging' => static function ( ContainerInterface $container ) : array {

View file

@ -240,40 +240,7 @@ class PaymentMethodsDefinition {
'title' => __( 'Advanced Credit and Debit Card Payments', 'woocommerce-paypal-payments' ),
'description' => __( "Present custom credit and debit card fields to your payers so they can pay with credit and debit cards using your site's branding.", 'woocommerce-paypal-payments' ),
'icon' => 'payment-method-advanced-cards',
'fields' => array(
'threeDSecure' => array(
'type' => 'radio',
'default' => $this->settings->get_three_d_secure(),
'label' => __( '3D Secure', 'woocommerce-paypal-payments' ),
'description' => __(
'Authenticate cardholders through their card issuers to reduce fraud and improve transaction security. Successful 3D Secure authentication can shift liability for fraudulent chargebacks to the card issuer.',
'woocommerce-paypal-payments'
),
'options' => array(
array(
'label' => __(
'No 3D Secure',
'woocommerce-paypal-payments'
),
'value' => 'no-3d-secure',
),
array(
'label' => __(
'Only when required',
'woocommerce-paypal-payments'
),
'value' => 'only-required-3d-secure',
),
array(
'label' => __(
'Always require 3D Secure',
'woocommerce-paypal-payments'
),
'value' => 'always-3d-secure',
),
),
),
),
'fields' => array(),
);
$group[] = array(
'id' => AxoGateway::ID,

View file

@ -38,7 +38,6 @@ class PaymentSettings extends AbstractDataModel {
protected function get_defaults() : array {
return array(
'paypal_show_logo' => false,
'three_d_secure' => 'no-3d-secure',
'fastlane_cardholder_name' => false,
'fastlane_display_watermark' => false,
'venmo_enabled' => false,
@ -158,15 +157,6 @@ class PaymentSettings extends AbstractDataModel {
return (bool) $this->data['paypal_show_logo'];
}
/**
* Get 3DSecure.
*
* @return string
*/
public function get_three_d_secure() : string {
return $this->data['three_d_secure'];
}
/**
* Get Fastlane cardholder name.
*
@ -213,16 +203,6 @@ class PaymentSettings extends AbstractDataModel {
$this->data['paypal_show_logo'] = $value;
}
/**
* Set 3DSecure.
*
* @param string $value The value.
* @return void
*/
public function set_three_d_secure( string $value ) : void {
$this->data['three_d_secure'] = $value;
}
/**
* Set Fastlane cardholder name.
*

View file

@ -40,6 +40,13 @@ class SettingsModel extends AbstractDataModel {
*/
public const LANDING_PAGE_OPTIONS = array( 'any', 'login', 'guest_checkout' );
/**
* Valid options for 3D Secure.
*
* @var array
*/
public const THREE_D_SECURE_OPTIONS = array( 'no-3d-secure', 'only-required-3d-secure', 'always-3d-secure' );
/**
* Data sanitizer service.
*
@ -47,14 +54,24 @@ class SettingsModel extends AbstractDataModel {
*/
protected DataSanitizer $sanitizer;
/**
* Invoice prefix.
*
* @var string
*/
private string $invoice_prefix;
/**
* Constructor.
*
* @param DataSanitizer $sanitizer Data sanitizer service.
* @param string $invoice_prefix Invoice prefix.
* @throws RuntimeException If the OPTION_KEY is not defined in the child class.
*/
public function __construct( DataSanitizer $sanitizer ) {
$this->sanitizer = $sanitizer;
public function __construct( DataSanitizer $sanitizer, string $invoice_prefix ) {
$this->sanitizer = $sanitizer;
$this->invoice_prefix = $invoice_prefix;
parent::__construct();
}
@ -66,7 +83,7 @@ class SettingsModel extends AbstractDataModel {
protected function get_defaults() : array {
return array(
// Free-form string values.
'invoice_prefix' => '',
'invoice_prefix' => $this->invoice_prefix,
'brand_name' => '',
'soft_descriptor' => '',
@ -74,6 +91,7 @@ class SettingsModel extends AbstractDataModel {
'subtotal_adjustment' => 'correction', // Options: [correction|no_details].
'landing_page' => 'any', // Options: [any|login|guest_checkout].
'button_language' => '', // empty or a language locale code.
'three_d_secure' => 'no-3d-secure', // Options: [no-3d-secure|only-required-3d-secure|always-3d-secure].
// Boolean flags.
'authorize_only' => false,
@ -200,6 +218,24 @@ class SettingsModel extends AbstractDataModel {
$this->data['button_language'] = $this->sanitizer->sanitize_text( $language );
}
/**
* Gets the 3D Secure setting.
*
* @return string The 3D Secure setting.
*/
public function get_three_d_secure() : string {
return $this->data['three_d_secure'];
}
/**
* Sets the 3D Secure setting.
*
* @param string $setting The 3D Secure setting to set.
*/
public function set_three_d_secure( string $setting ) : void {
$this->data['three_d_secure'] = $this->sanitizer->sanitize_enum( $setting, self::THREE_D_SECURE_OPTIONS );
}
/**
* Gets the authorize only setting.
*

View file

@ -77,10 +77,6 @@ class PaymentRestEndpoint extends RestEndpoint {
'js_name' => 'paypalShowLogo',
'sanitize' => 'to_boolean',
),
'three_d_secure' => array(
'js_name' => 'threeDSecure',
'sanitize' => 'sanitize_text_field',
),
'fastlane_cardholder_name' => array(
'js_name' => 'fastlaneCardholderName',
'sanitize' => 'to_boolean',
@ -207,7 +203,6 @@ class PaymentRestEndpoint extends RestEndpoint {
}
$gateway_settings['paypalShowLogo'] = $this->payment_settings->get_paypal_show_logo();
$gateway_settings['threeDSecure'] = $this->payment_settings->get_three_d_secure();
$gateway_settings['fastlaneCardholderName'] = $this->payment_settings->get_fastlane_cardholder_name();
$gateway_settings['fastlaneDisplayWatermark'] = $this->payment_settings->get_fastlane_display_watermark();

View file

@ -93,6 +93,10 @@ class SettingsRestEndpoint extends RestEndpoint {
'disabled_cards' => array(
'js_name' => 'disabledCards',
),
'three_d_secure' => array(
'js_name' => 'threeDSecure',
'sanitize' => 'sanitize_text_field',
),
);
/**

View file

@ -176,7 +176,35 @@ class ScriptDataHandler {
'label' => _x( 'Hiper', 'Name of credit card', 'woocommerce-paypal-payments' ),
),
);
$transformed_button_choices = array_map(
$three_d_secure_options = array(
array(
'value' => 'no-3d-secure',
'label' => __( 'No 3D Secure', 'woocommerce-paypal-payments' ),
'description' => __(
'Do not use 3D Secure authentication for any transactions.',
'woocommerce-paypal-payments'
),
),
array(
'value' => 'only-required-3d-secure',
'label' => __( 'Only when required', 'woocommerce-paypal-payments' ),
'description' => __(
'Use 3D Secure when required by the card issuer or payment processor.',
'woocommerce-paypal-payments'
),
),
array(
'value' => 'always-3d-secure',
'label' => __( 'Always require 3D Secure', 'woocommerce-paypal-payments' ),
'description' => __(
'Always authenticate transactions with 3D Secure when available.',
'woocommerce-paypal-payments'
),
),
);
$transformed_button_choices = array_map(
function( $key, $value ) {
return array(
'value' => $key,
@ -198,6 +226,7 @@ class ScriptDataHandler {
'storeCountry' => $this->store_country,
'buttonLanguageChoices' => $transformed_button_choices,
'disabledCardsChoices' => $disabled_cards_choices,
'threeDSecureOptions' => $three_d_secure_options,
);
if ( $is_pay_later_configurator_available ) {
@ -230,5 +259,3 @@ class ScriptDataHandler {
wp_dequeue_script( 'ppcp-paypal-subscription' );
}
}

View file

@ -269,7 +269,6 @@ class SettingsDataManager {
// Enable BCDC for business sellers without ACDC.
$this->payment_methods->toggle_method_state( CardButtonGateway::ID, true );
}
/**
* Allow plugins to modify apm payment gateway states before saving.
*

View file

@ -31,6 +31,7 @@ use WooCommerce\PayPalCommerce\Settings\Data\OnboardingProfile;
use WooCommerce\PayPalCommerce\Settings\Data\SettingsModel;
use WooCommerce\PayPalCommerce\Settings\Data\TodosModel;
use WooCommerce\PayPalCommerce\Settings\Endpoint\RestEndpoint;
use WooCommerce\PayPalCommerce\Settings\Enum\InstallationPathEnum;
use WooCommerce\PayPalCommerce\Settings\Handler\ConnectionListener;
use WooCommerce\PayPalCommerce\Settings\Service\BrandedExperience\PathRepository;
use WooCommerce\PayPalCommerce\Settings\Service\GatewayRedirectService;
@ -576,7 +577,7 @@ class SettingsModule implements ServiceModule, ExecutableModule {
// Enable APMs after onboarding if the country is compatible.
add_action(
'woocommerce_paypal_payments_toggle_payment_gateways_apms',
function ( PaymentSettings $payment_methods, array $methods_apm ) use ( $container ) {
function ( PaymentSettings $payment_methods, array $methods_apm, ConfigurationFlagsDTO $flags ) use ( $container ) {
$general_settings = $container->get( 'settings.data.general' );
assert( $general_settings instanceof GeneralSettings );
@ -586,6 +587,11 @@ class SettingsModule implements ServiceModule, ExecutableModule {
// Enable all APM methods.
foreach ( $methods_apm as $method ) {
if ( $flags->use_card_payments === false ) {
$payment_methods->toggle_method_state( $method['id'], $flags->use_card_payments );
continue;
}
// Skip PayUponInvoice if merchant is not in Germany.
if ( PayUponInvoiceGateway::ID === $method['id'] && 'DE' !== $merchant_country ) {
continue;
@ -606,7 +612,7 @@ class SettingsModule implements ServiceModule, ExecutableModule {
}
},
10,
2
3
);
// Toggle payment gateways after onboarding based on flags.
@ -635,6 +641,25 @@ class SettingsModule implements ServiceModule, ExecutableModule {
}
);
// Migration code to update BN code of merchants that are on whitelabel mode (own_brand_only false) to use the whitelabel BN code (direct).
add_action(
'woocommerce_paypal_payments_gateway_migrate_on_update',
static function() use ( $container ) {
$general_settings = $container->get( 'settings.data.general' );
assert( $general_settings instanceof GeneralSettings );
$partner_attribution = $container->get( 'api.helper.partner-attribution' );
assert( $partner_attribution instanceof PartnerAttribution );
$own_brand_only = $general_settings->own_brand_only();
$installation_path = $general_settings->get_installation_path();
if ( ! $own_brand_only && $installation_path !== InstallationPathEnum::DIRECT ) {
$partner_attribution->initialize_bn_code( InstallationPathEnum::DIRECT, true );
}
}
);
return true;
}

View file

@ -2021,6 +2021,49 @@ return array(
return new TaskRegistrar();
},
'wcgateway.settings.wc-tasks.pay-later-task-config' => static function( ContainerInterface $container ): array {
$section_id = PayPalGateway::ID;
$pay_later_tab_id = Settings::PAY_LATER_TAB_ID;
if ( $container->has( 'paylater-configurator.is-available' ) && $container->get( 'paylater-configurator.is-available' ) ) {
return array(
array(
'id' => 'pay-later-messaging-task',
'title' => __( 'Configure PayPal Pay Later messaging', 'woocommerce-paypal-payments' ),
'description' => __( 'Decide where you want dynamic Pay Later messaging to show up and how you want it to look on your site.', 'woocommerce-paypal-payments' ),
'redirect_url' => admin_url( "admin.php?page=wc-settings&tab=checkout&section={$section_id}&ppcp-tab={$pay_later_tab_id}" ),
),
);
}
return array();
},
'wcgateway.settings.wc-tasks.connect-task-config' => static function( ContainerInterface $container ): array {
$is_connected = $container->get( 'settings.flag.is-connected' );
$is_current_country_send_only = $container->get( 'wcgateway.is-send-only-country' );
if ( ! $is_connected && ! $is_current_country_send_only ) {
return array(
array(
'id' => 'connect-to-paypal-task',
'title' => __( 'Connect PayPal to complete setup', 'woocommerce-paypal-payments' ),
'description' => __( 'PayPal Payments is almost ready. To get started, connect your account with the Activate PayPal Payments button.', 'woocommerce-paypal-payments' ),
'redirect_url' => admin_url( 'admin.php?page=wc-settings&tab=checkout&section=ppcp-gateway&ppcp-tab=' . Settings::CONNECTION_TAB_ID ),
),
);
}
return array();
},
'wcgateway.settings.wc-tasks.task-config-services' => static function(): array {
return array(
'wcgateway.settings.wc-tasks.pay-later-task-config',
'wcgateway.settings.wc-tasks.connect-task-config',
);
},
/**
* A configuration for simple redirect wc tasks.
*
@ -2032,18 +2075,14 @@ return array(
* }>
*/
'wcgateway.settings.wc-tasks.simple-redirect-tasks-config' => static function( ContainerInterface $container ): array {
$section_id = PayPalGateway::ID;
$pay_later_tab_id = Settings::PAY_LATER_TAB_ID;
$list_of_config = array();
$task_config_services = $container->get( 'wcgateway.settings.wc-tasks.task-config-services' );
if ( $container->has( 'paylater-configurator.is-available' ) && $container->get( 'paylater-configurator.is-available' ) ) {
$list_of_config[] = array(
'id' => 'pay-later-messaging-task',
'title' => __( 'Configure PayPal Pay Later messaging', 'woocommerce-paypal-payments' ),
'description' => __( 'Decide where you want dynamic Pay Later messaging to show up and how you want it to look on your site.', 'woocommerce-paypal-payments' ),
'redirect_url' => admin_url( "admin.php?page=wc-settings&tab=checkout&section={$section_id}&ppcp-tab={$pay_later_tab_id}" ),
);
foreach ( $task_config_services as $service_id ) {
if ( $container->has( $service_id ) ) {
$task_config = $container->get( $service_id );
$list_of_config = array_merge( $list_of_config, $task_config );
}
}
return $list_of_config;
@ -2092,4 +2131,29 @@ return array(
'wcgateway.settings.admin-settings-enabled' => static function( ContainerInterface $container ): bool {
return $container->has( 'settings.url' ) && ! SettingsModule::should_use_the_old_ui();
},
/**
* Returns a prefix for the site, ensuring the same site always gets the same prefix (unless the URL changes).
*/
'wcgateway.settings.invoice-prefix' => static function( ContainerInterface $container ): string {
$site_url = get_site_url( get_current_blog_id() );
$hash = md5( $site_url );
$letters = preg_replace( '~\d~', '', $hash ) ?? '';
$prefix = substr( $letters, 0, 6 );
return $prefix ? $prefix . '-' : '';
},
/**
* Returns random 6 characters length alphabetic prefix, followed by a hyphen.
*/
'wcgateway.settings.invoice-prefix-random' => static function( ContainerInterface $container ): string {
$characters = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
$prefix = '';
for ( $i = 0; $i < 6; $i++ ) {
$prefix .= $characters[ wp_rand( 0, strlen( $characters ) - 1 ) ];
}
return $prefix . '-';
},
);

View file

@ -69,7 +69,7 @@ class ConnectAdminNotice {
$message = sprintf(
/* translators: %1$s the gateway name. */
__(
'PayPal Payments is almost ready. To get started, connect your account with the <b>Activate PayPal</b> button <a href="%1$s">on the Account Setup page</a>.',
'PayPal Payments is almost ready. To get started, connect your account with the <b>Activate PayPal Payments</b> button <a href="%1$s">on the Account Setup page</a>.',
'woocommerce-paypal-payments'
),
admin_url( 'admin.php?page=wc-settings&tab=checkout&section=ppcp-gateway&ppcp-tab=' . Settings::CONNECTION_TAB_ID )
@ -77,6 +77,16 @@ class ConnectAdminNotice {
return new Message( $message, 'warning' );
}
/**
* Returns whether the current page is plugins.php.
*
* @return bool
*/
private function is_current_page_plugins_page(): bool {
global $pagenow;
return isset( $pagenow ) && $pagenow === 'plugins.php';
}
/**
* Whether the message should display.
*
@ -87,6 +97,8 @@ class ConnectAdminNotice {
* @return bool
*/
protected function should_display(): bool {
return ! $this->is_connected && ! $this->is_current_country_send_only;
return $this->is_current_page_plugins_page()
&& ! $this->is_connected
&& ! $this->is_current_country_send_only;
}
}

View file

@ -49,6 +49,9 @@ return function ( ContainerInterface $container, array $fields ): array {
$onboarding_send_only_notice_renderer = $container->get( 'onboarding.render-send-only-notice' );
assert( $onboarding_send_only_notice_renderer instanceof OnboardingSendOnlyNoticeRenderer );
$environment = $container->get( 'settings.environment' );
assert( $environment instanceof Environment );
$is_send_only_country = $container->get( 'wcgateway.is-send-only-country' );
$onboarding_elements_class = $is_send_only_country ? 'hide' : 'ppcp-onboarding-element';
$send_only_country_notice_class = $is_send_only_country ? 'ppcp-onboarding-element' : 'hide';
@ -510,13 +513,7 @@ return function ( ContainerInterface $container, array $fields ): array {
'custom_attributes' => array(
'pattern' => '[a-zA-Z_\\-]+',
),
'default' => ( static function (): string {
$site_url = get_site_url( get_current_blog_id() );
$hash = md5( $site_url );
$letters = preg_replace( '~\d~', '', $hash ) ?? '';
$prefix = substr( $letters, 0, 6 );
return $prefix ? $prefix . '-' : '';
} )(),
'default' => $environment->is_sandbox() ? $container->get( 'wcgateway.settings.invoice-prefix-random' ) : $container->get( 'wcgateway.settings.invoice-prefix' ),
'screens' => array(
State::STATE_START,
State::STATE_ONBOARDED,

View file

@ -0,0 +1,335 @@
<?php
declare(strict_types=1);
namespace WooCommerce\PayPalCommerce\Tests\Integration;
use WC_Order;
use WC_Order_Item_Product;
use WC_Payment_Token_CC;
use WC_Subscription;
use WooCommerce\PayPalCommerce\ApiClient\Endpoint\OrderEndpoint;
use WooCommerce\PayPalCommerce\ApiClient\Entity\Capture;
use WooCommerce\PayPalCommerce\ApiClient\Entity\CaptureStatus;
use WooCommerce\PayPalCommerce\ApiClient\Entity\Order;
use WooCommerce\PayPalCommerce\ApiClient\Entity\OrderStatus;
use WooCommerce\PayPalCommerce\ApiClient\Entity\Payments;
use WooCommerce\PayPalCommerce\ApiClient\Entity\PaymentSource;
use WooCommerce\PayPalCommerce\ApiClient\Entity\PurchaseUnit;
use WooCommerce\PayPalCommerce\Helper\RedirectorStub;
use Mockery\Adapter\Phpunit\MockeryPHPUnitIntegration;
use WooCommerce\PayPalCommerce\PPCP;
use WooCommerce\PayPalCommerce\Vendor\Inpsyde\Modularity\Module\ExecutableModule;
use WooCommerce\PayPalCommerce\Vendor\Inpsyde\Modularity\Module\ModuleClassNameIdTrait;
use WooCommerce\PayPalCommerce\Vendor\Inpsyde\Modularity\Module\ServiceModule;
use WooCommerce\PayPalCommerce\Vendor\Psr\Container\ContainerInterface;
class IntegrationMockedTestCase extends TestCase
{
use MockeryPHPUnitIntegration;
public function setUp(): void
{
parent::setUp();
$this->default_product_id = $this->createAProductIfNotProvided();
}
/**
* @param int $customer_id
* @param string $payment_method
* @param int $product_id
* @param bool $set_paid
* @return \WC_Order|\WP_Error
* @throws \WC_Data_Exception
*/
public function getMockedOrder(int $customer_id, string $payment_method, int $product_id, bool $set_paid = true)
{
$order = wc_create_order([
'customer_id' => $customer_id,
'set_paid' => $set_paid,
'billing' => [
'first_name' => 'John',
'last_name' => 'Doe',
'address_1' => '969 Market',
'address_2' => '',
'city' => 'San Francisco',
'state' => 'CA',
'postcode' => '94103',
'country' => 'US',
'email' => 'john.doe@example.com',
'phone' => '(555) 555-5555'
],
'line_items' => [
[
'product_id' => $product_id,
'quantity' => 1
]
],
]);
$order->set_payment_method($payment_method);
// Make sure the order is properly saved
$order->save();
// Add the product to the order
$item = new WC_Order_Item_Product();
$item->set_props([
'product_id' => $product_id,
'quantity' => 1,
'subtotal' => 10,
'total' => 10,
]);
$order->add_item($item);
$order->calculate_totals();
$order->save();
return $order;
}
/**
* @param string $sku
* @return int
*/
public function createAProductIfNotProvided(string $sku = 'DUMMY SUB SKU'): int
{
$product_id = wc_get_product_id_by_sku($sku);
if (!$product_id) {
$product = new \WC_Product_Subscription();
$product->set_props([
'name' => 'Dummy Subscription Product',
'regular_price' => 10,
'price' => 10,
'sku' => 'DUMMY SUB SKU',
'manage_stock' => false,
'tax_status' => 'taxable',
'downloadable' => false,
'virtual' => false,
'stock_status' => 'instock',
'weight' => '1.1',
// Subscription-specific properties
'subscription_period' => 'month',
'subscription_period_interval' => 1,
'subscription_length' => 0, // 0 means unlimited
'subscription_trial_period' => '',
'subscription_trial_length' => 0,
'subscription_price' => 10,
'subscription_sign_up_fee' => 0,
]);
$product->save();
$product_id = $product->get_id();
}
return $product_id;
}
/**
* @param array<string, callable> $overriddenServices
* @return ContainerInterface
*/
protected function bootstrapModule(array $overriddenServices = []): ContainerInterface
{
$overriddenServices = array_merge([
'http.redirector' => function () {
return new RedirectorStub();
}
], $overriddenServices);
$module = new class ($overriddenServices) implements ServiceModule, ExecutableModule {
use ModuleClassNameIdTrait;
public function __construct(array $services)
{
$this->services = $services;
}
public function services(): array
{
return $this->services;
}
public function run(ContainerInterface $c): bool
{
return true;
}
};
$rootDir = ROOT_DIR;
$bootstrap = require("$rootDir/bootstrap.php");
$appContainer = $bootstrap($rootDir, [], [$module]);
PPCP::init($appContainer);
return $appContainer;
}
public function createCustomerIfNotExists(int $customer_id= 1): int
{
$customer = new \WC_Customer($customer_id);
if ( empty($customer->get_email() )) {
$customer->set_email('customer'. $customer_id. '@example.com');
$customer->set_first_name('John');
$customer->set_last_name('Doe');
$customer->save();
}
return $customer->get_id();
}
/**
* Creates a payment token for a customer.
*
* @param int $customer_id The customer ID.
* @return WC_Payment_Token_CC The created payment token.
* @throws \Exception
*/
public function createAPaymentTokenForTheCustomer(int $customer_id = 1, $gateway_id = 'ppcp-gateway'): WC_Payment_Token_CC
{
$this->createCustomerIfNotExists($customer_id);
$token = new WC_Payment_Token_CC();
$token->set_token('test_token_' . uniqid()); // Unique token ID
$token->set_gateway_id($gateway_id);
$token->set_user_id($customer_id);
// These fields are required for WC_Payment_Token_CC
$token->set_card_type('visa'); // lowercase is often expected
$token->set_last4('1234');
$token->set_expiry_month('12');
$token->set_expiry_year('2030'); // Missing expiry year in your original code
$result = $token->save();
if (!$result || is_wp_error($result)) {
throw new \Exception('Failed to save payment token: ' .
(is_wp_error($result) ? $result->get_error_message() : 'Unknown error'));
}
$saved_token = \WC_Payment_Tokens::get($token->get_id());
if (!$saved_token || $saved_token->get_id() !== $token->get_id()) {
throw new \Exception('Token was not saved correctly');
}
return $token;
}
/**
* Helper method to create a subscription for testing.
*
* @param int $customer_id The customer ID
* @param string $payment_method The payment method
* @param string $sku
* @return WC_Subscription
* @throws \WC_Data_Exception
*/
public function createSubscription(int $customer_id = 1, string $payment_method = 'ppcp-gateway', $sku = 'DUMMY SUB SKU'): WC_Subscription
{
// Create a product if not provided
$product_id = $this->createAProductIfNotProvided($sku);
$order = $this->getMockedOrder($customer_id, $payment_method, $product_id, $set_paid = true);
$subscription = new WC_Subscription();
$subscription->set_customer_id($customer_id);
$subscription->set_payment_method($payment_method);
$subscription->set_status('active');
$subscription->set_parent_id($order->get_id());
$subscription->set_billing_period('month');
$subscription->set_billing_interval(1);
// Add a product to the subscription
$subscription_item = new WC_Order_Item_Product();
$subscription_item->set_props([
'product_id' => $product_id,
'quantity' => 1,
'subtotal' => 10,
'total' => 10,
]);
$subscription->add_item($subscription_item);
$subscription->set_date_created(current_time('mysql'));
$subscription->set_start_date(current_time('mysql'));
$subscription->set_next_payment_date(date('Y-m-d H:i:s', strtotime('+1 month', current_time('timestamp'))));
$subscription->save();
return $subscription;
}
/**
* Creates a renewal order for testing
*
* @param int $customer_id
* @param string $gateway_id
* @param int $subscription_id
* @return WC_Order
*/
protected function createRenewalOrder(int $customer_id, string $gateway_id, int $subscription_id): WC_Order
{
$renewal_order = $this->getMockedOrder($customer_id, $gateway_id, $this->default_product_id, false);
$renewal_order->update_meta_data('_subscription_renewal', $subscription_id);
$renewal_order->save();
return $renewal_order;
}
/**
* Mocks the OrderEndpoint to return a successful/failed order.
*
* @param string $intent The order intent (CAPTURE or AUTHORIZE)
* @param bool $success Whether the order was successful
* @return object The mocked OrderEndpoint
*/
public function mockOrderEndpoint(string $intent = 'CAPTURE', bool $success = true): object
{
$order_endpoint = \Mockery::mock(OrderEndpoint::class)->shouldIgnoreMissing();
$order = \Mockery::mock(Order::class)->shouldIgnoreMissing();
$order->shouldReceive('id')->andReturn('TEST-ORDER-' . uniqid());
$order->shouldReceive('intent')->andReturn($intent);
$order_status = \Mockery::mock(OrderStatus::class)->shouldIgnoreMissing();
$order_status->shouldReceive('is')->andReturn($success);
$order_status->shouldReceive('name')->andReturn($success ? 'COMPLETED' : 'FAILED');
$order->shouldReceive('status')->andReturn($order_status);
$payment_source = \Mockery::mock(PaymentSource::class)->shouldIgnoreMissing();
$payment_source->shouldReceive('name')->andReturn('card');
$order->shouldReceive('payment_source')->andReturn($payment_source);
$purchase_unit = \Mockery::mock(PurchaseUnit::class)->shouldIgnoreMissing();
$payments = \Mockery::mock(Payments::class)->shouldIgnoreMissing();
$capture = \Mockery::mock(Capture::class)->shouldIgnoreMissing();
$capture->shouldReceive('id')->andReturn('TEST-CAPTURE-' . uniqid());
$capture_status = \Mockery::mock(CaptureStatus::class)->shouldIgnoreMissing();
$capture_status->shouldReceive('name')->andReturn($success ? 'COMPLETED' : 'DECLINED');
$capture->shouldReceive('status')->andReturn($capture_status);
// Mock authorizations for AUTHORIZE intent
if ($intent === 'AUTHORIZE') {
$authorization = \Mockery::mock(\WooCommerce\PayPalCommerce\ApiClient\Entity\Authorization::class)->shouldIgnoreMissing();
$authorization->shouldReceive('id')->andReturn('TEST-AUTH-' . uniqid());
$auth_status = \Mockery::mock(\WooCommerce\PayPalCommerce\ApiClient\Entity\AuthorizationStatus::class)->shouldIgnoreMissing();
$auth_status->shouldReceive('name')->andReturn($success ? 'CREATED' : 'DENIED');
$auth_status->shouldReceive('is')->andReturn($success);
$authorization->shouldReceive('status')->andReturn($auth_status);
$payments->shouldReceive('authorizations')->andReturn([$authorization]);
$payments->shouldReceive('captures')->andReturn([]);
} else {
// For CAPTURE intent, set up captures but no authorizations
$payments->shouldReceive('captures')->andReturn([$capture]);
$payments->shouldReceive('authorizations')->andReturn([]);
}
$purchase_unit->shouldReceive('payments')->andReturn($payments);
$order->shouldReceive('purchase_units')->andReturn([$purchase_unit]);
// Set up the order endpoint methods
$order_endpoint->shouldReceive('create')->andReturn($order);
if ($intent === 'AUTHORIZE') {
$order_endpoint->shouldReceive('authorize')->andReturn($order);
} else {
$order_endpoint->shouldReceive('capture')->andReturn($order);
}
$order_endpoint->shouldReceive('order')->andReturn($order);
return $order_endpoint;
}
}

View file

@ -2,79 +2,390 @@
namespace WooCommerce\PayPalCommerce\Tests\Integration;
use Psr\Log\LoggerInterface;
use WC_Product_Simple;
use WooCommerce\PayPalCommerce\PayPalSubscriptions\RenewalHandler;
/**
* @group subscriptions
* @group subscription-paypal
* @group skip-ci
*/
class PayPalSubscriptionsRenewalTest extends TestCase {
public function test_renewal_order_is_not_created_just_after_receiving_webhook() {
$c = $this->getContainer();
$handler = new RenewalHandler( $c->get( 'woocommerce.logger.woocommerce' ) );
class PayPalSubscriptionsRenewalTest extends TestCase
{
/**
* Tests that renewal orders are not created for recent subscriptions.
*
* GIVEN a subscription created 1 minute ago
* WHEN the process method is called with this subscription
* THEN no renewal order should be created
*/
public function test_renewal_order_is_not_created_just_after_receiving_webhook()
{
$c = $this->getContainer();
$handler = new RenewalHandler($c->get('woocommerce.logger.woocommerce'));
// Simulates receiving webhook 1 minute after subscription start.
$subscription = $this->createSubscription( '-1 minute' );
$subscription = $this->createSubscription('-1 minute');
$handler->process( [ $subscription ], 'TRANSACTION-ID' );
$renewal = $subscription->get_related_orders( 'ids', array( 'renewal' ) );
$this->assertEquals( count( $renewal ), 0 );
$handler->process([$subscription], 'TRANSACTION-ID');
$renewal = $subscription->get_related_orders('ids', array('renewal'));
$this->assertEquals(0, count($renewal), 'No renewal order should be created for a subscription that is only 1 minute old');
}
public function test_renewal_order_is_created_when_receiving_webhook_nine_hours_later() {
$c = $this->getContainer();
$handler = new RenewalHandler( $c->get( 'woocommerce.logger.woocommerce' ) );
/**
* Tests that renewal orders are created for subscriptions older than 8 hours.
*
* GIVEN a subscription created 9 hours ago
* WHEN the process method is called with this subscription
* THEN a renewal order should be created
*/
public function test_renewal_order_is_created_when_receiving_webhook_nine_hours_later()
{
$c = $this->getContainer();
$handler = new RenewalHandler($c->get('woocommerce.logger.woocommerce'));
// Simulates receiving webhook 9 hours after subscription start.
$subscription = $this->createSubscription( '-9 hour' );
$subscription = $this->createSubscription('-9 hour');
$handler->process( [ $subscription ], 'TRANSACTION-ID' );
$renewal = $subscription->get_related_orders( 'ids', array( 'renewal' ) );
$this->assertEquals( count( $renewal ), 1 );
$handler->process([$subscription], 'TRANSACTION-ID');
$renewal = $subscription->get_related_orders('ids', array('renewal'));
$this->assertEquals(1, count($renewal), 'A renewal order should be created for a subscription that is 9 hours old');
}
private function createSubscription( string $startDate ) {
$order = wc_create_order( [
'customer_id' => 1,
'set_paid' => true,
/**
* Tests that renewal orders are created when subscription has renewal meta.
*
* GIVEN a subscription created 5 minutes ago
* AND the subscription has the _ppcp_is_subscription_renewal meta set to 'true'
* WHEN the process method is called with this subscription
* THEN a renewal order should be created
*/
public function test_renewal_order_is_created_when_subscription_has_renewal_meta()
{
$c = $this->getContainer();
$handler = new RenewalHandler($c->get('woocommerce.logger.woocommerce'));
// Create a subscription that's only 5 minutes old (would normally not trigger renewal)
$subscription = $this->createSubscription('-5 minute');
// But mark it as needing renewal
$subscription->update_meta_data('_ppcp_is_subscription_renewal', 'true');
$subscription->save_meta_data();
$handler->process([$subscription], 'TRANSACTION-ID');
$renewal = $subscription->get_related_orders('ids', array('renewal'));
$this->assertEquals(1, count($renewal), 'A renewal order should be created when subscription has _ppcp_is_subscription_renewal meta set to true, regardless of age');
}
/**
* Tests that renewal order payment method matches the subscription.
*
* GIVEN a subscription created 9 hours ago
* AND the subscription has a specific payment method
* WHEN the process method is called with this subscription
* THEN a renewal order should be created
* AND the renewal order should have the same payment method as the subscription
*/
public function test_renewal_order_payment_method_matches_subscription()
{
$c = $this->getContainer();
$handler = new RenewalHandler($c->get('woocommerce.logger.woocommerce'));
$subscription = $this->createSubscription('-9 hour');
$payment_method = 'ppcp-gateway';
$subscription->set_payment_method($payment_method);
$subscription->save();
$handler->process([$subscription], 'TRANSACTION-ID');
$renewal_ids = $subscription->get_related_orders('ids', array('renewal'));
$this->assertEquals(1, count($renewal_ids), 'A renewal order should be created for a subscription that is 9 hours old');
$renewal_order = wc_get_order(reset($renewal_ids));
$this->assertEquals($payment_method, $renewal_order->get_payment_method(), 'The renewal order should have the same payment method as the subscription');
}
/**
* Tests that renewal orders are marked as paid.
*
* GIVEN a subscription created 9 hours ago
* WHEN the process method is called with this subscription
* THEN a renewal order should be created
* AND the renewal order should be marked as paid
*/
public function test_renewal_order_is_marked_as_paid()
{
$c = $this->getContainer();
$handler = new RenewalHandler($c->get('woocommerce.logger.woocommerce'));
$subscription = $this->createSubscription('-9 hour');
$handler->process([$subscription], 'TRANSACTION-ID');
$renewal_ids = $subscription->get_related_orders('ids', array('renewal'));
$this->assertEquals(1, count($renewal_ids), 'A renewal order should be created for a subscription that is 9 hours old');
$renewal_order = wc_get_order(reset($renewal_ids));
$this->assertTrue($renewal_order->is_paid(), 'The renewal order should be marked as paid');
}
/**
* Tests that transaction ID is set on renewal orders.
*
* GIVEN a subscription created 9 hours ago
* AND a unique transaction ID
* WHEN the process method is called with this subscription and transaction ID
* THEN a renewal order should be created
* AND the renewal order should have the transaction ID set
*/
public function test_transaction_id_is_set_on_renewal_order()
{
$c = $this->getContainer();
$handler = new RenewalHandler($c->get('woocommerce.logger.woocommerce'));
$subscription = $this->createSubscription('-9 hour');
$transaction_id = 'TEST-TRANSACTION-ID-' . uniqid();
$handler->process([$subscription], $transaction_id);
$renewal_ids = $subscription->get_related_orders('ids', array('renewal'));
$this->assertEquals(1, count($renewal_ids), 'A renewal order should be created for a subscription that is 9 hours old');
$renewal_order = wc_get_order(reset($renewal_ids));
$this->assertEquals($transaction_id, $renewal_order->get_transaction_id(), 'The renewal order should have the transaction ID set correctly');
}
/**
* Tests that subscription status is set to on-hold before renewal.
*
* GIVEN a subscription created 9 hours ago with 'active' status
* WHEN the process method is called with this subscription
* THEN the subscription status should be changed to 'on-hold'
*/
public function test_subscription_status_is_set_to_on_hold_before_renewal()
{
$c = $this->getContainer();
$handler = new RenewalHandler($c->get('woocommerce.logger.woocommerce'));
$subscription = $this->createSubscription('-9 hour');
$initial_status = $subscription->get_status();
$this->assertEquals('active', $initial_status, 'The subscription should start with active status');
$handler->process([$subscription], 'TRANSACTION-ID');
// Status should be on-hold before the renewal order is created
$this->assertEquals('on-hold', $subscription->get_status(), 'The subscription status should be changed to on-hold before renewal');
}
/**
* Tests that transaction ID is set on parent order when no renewal is created.
*
* GIVEN a subscription created 1 minute ago
* AND a unique transaction ID
* WHEN the process method is called with this subscription and transaction ID
* THEN no renewal order should be created
* AND the transaction ID should be set on the parent order
*/
public function test_transaction_id_is_set_on_parent_order_when_no_renewal()
{
$c = $this->getContainer();
$handler = new RenewalHandler($c->get('woocommerce.logger.woocommerce'));
$subscription = $this->createSubscription('-1 minute');
$transaction_id = 'PARENT-TRANSACTION-ID-' . uniqid();
$parent_order_id = $subscription->get_parent_id();
$handler->process([$subscription], $transaction_id);
// No renewal order should be created
$renewal = $subscription->get_related_orders('ids', array('renewal'));
$this->assertEquals(0, count($renewal), 'No renewal order should be created for a subscription that is only 1 minute old');
//use latest order to get the updated status
$parent_order = wc_get_order($parent_order_id);
// Transaction ID should be set on parent order
$this->assertEquals($transaction_id, $parent_order->get_transaction_id(), 'The transaction ID should be set on the parent order when no renewal is created');
}
/**
* Tests that subscription meta is set when processing parent order.
*
* GIVEN a subscription created 1 minute ago
* AND the subscription has no _ppcp_is_subscription_renewal meta
* WHEN the process method is called with this subscription
* THEN the _ppcp_is_subscription_renewal meta should be set to 'true'
*/
public function test_subscription_meta_is_set_when_processing_parent_order()
{
$c = $this->getContainer();
$handler = new RenewalHandler($c->get('woocommerce.logger.woocommerce'));
$subscription = $this->createSubscription('-1 minute');
// Meta should not exist before processing
$this->assertEmpty($subscription->get_meta('_ppcp_is_subscription_renewal'), 'The subscription should not have _ppcp_is_subscription_renewal meta before processing');
$handler->process([$subscription], 'TRANSACTION-ID');
// Meta should be set after processing
$this->assertEquals('true', $subscription->get_meta('_ppcp_is_subscription_renewal'), 'The _ppcp_is_subscription_renewal meta should be set to true after processing');
}
/**
* Tests handling subscriptions without valid parent orders.
*
* GIVEN a subscription created 9 hours ago
* AND the parent order is not available
* WHEN the process method is called with this subscription
* THEN a renewal order should still be created
* AND the renewal order should be properly set up with transaction ID
* AND the subscription status should be set to 'on-hold'
*/
public function test_subscription_without_valid_parent_order()
{
$c = $this->getContainer();
$handler = new RenewalHandler($c->get('woocommerce.logger.woocommerce'));
$subscription = $this->createSubscription('-9 hour');
$transaction_id = 'TEST-TRANSACTION-ID-' . uniqid();
// Simulate a scenario where the parent order doesn't exist or is not a WC_Order
// Mock wc_get_order to return false instead of a WC_Order instance
add_filter('woocommerce_get_shop_order_args', function ($args) use ($subscription) {
if (isset($args['id']) && $args['id'] === $subscription->get_parent_id()) {
return ['return' => false]; // This causes wc_get_order to return false
}
return $args;
});
// Process should not throw any errors
$handler->process([$subscription], $transaction_id);
// Verify that a renewal order was created (as the subscription is 9 hours old)
$renewal_ids = $subscription->get_related_orders('ids', array('renewal'));
$this->assertEquals(1, count($renewal_ids), 'A renewal order should be created even when the parent order is not available');
// Verify the renewal order was properly set up
$renewal_order = wc_get_order(reset($renewal_ids));
$this->assertTrue($renewal_order->is_paid(), 'The renewal order should be marked as paid even when the parent order is not available');
$this->assertEquals($transaction_id, $renewal_order->get_transaction_id(), 'The renewal order should have the transaction ID set correctly even when the parent order is not available');
// Verify no errors occurred due to invalid parent order
$this->assertEquals('on-hold', $subscription->get_status(), 'The subscription status should be set to on-hold even when the parent order is not available');
// Remove the filter
remove_all_filters('woocommerce_get_shop_order_args');
}
/**
* Tests that parent order transaction ID is updated for non-renewal subscriptions.
*
* GIVEN a subscription created 1 minute ago
* AND the parent order has no transaction ID
* WHEN the process method is called with this subscription and a unique transaction ID
* THEN the parent order's transaction ID should be updated
* AND the subscription should be marked for future renewal
* AND no renewal order should be created
*/
public function test_parent_order_transaction_id_is_updated_when_processing_non_renewal_subscription()
{
$c = $this->getContainer();
$logger = $c->get('woocommerce.logger.woocommerce');
$handler = new RenewalHandler($logger);
// Create a subscription that's not ready for renewal
$subscription = $this->createSubscription('-1 minute');
// Get the parent order
$parent_order_id = $subscription->get_parent_id();
$parent_order = wc_get_order($parent_order_id);
$this->assertEmpty($parent_order->get_transaction_id(), 'The parent order should not have a transaction ID before processing');
$transaction_id = 'PARENT-ORDER-TRANSACTION-' . uniqid();
$handler->process([$subscription], $transaction_id);
$parent_order = wc_get_order($parent_order_id);
$this->assertEquals($transaction_id, $parent_order->get_transaction_id(), 'The parent order transaction ID should be updated correctly');
$this->assertEquals('true', $subscription->get_meta('_ppcp_is_subscription_renewal'), 'The subscription should be marked for future renewal after processing');
$renewal_orders = $subscription->get_related_orders('ids', array('renewal'));
$this->assertEquals(0, count($renewal_orders), 'No renewal order should be created for an empty array of subscriptions');
}
/**
* Tests that the RenewalHandler correctly handles an empty array of subscriptions.
*
* GIVEN the RenewalHandler with a mocked logger
* WHEN the process method is called with an empty array of subscriptions
* THEN no exceptions should be thrown
* AND the logger should not be called
*/
public function test_process_empty_subscriptions_array()
{
// Create a logger mock that expects no operations if no subscriptions
$logger_mock = \Mockery::mock(LoggerInterface::class);
// The logger should not be called at all with an empty array
$logger_mock->shouldNotReceive('info');
$handler = new RenewalHandler($logger_mock);
$transaction_id = 'TEST-TRANSACTION-EMPTY-ARRAY';
// Process an empty array of subscriptions
$handler->process([], $transaction_id);
// Test is successful if no exceptions are thrown
// and the mock expectations are met (logger not called)
$this->assertTrue(true, 'No exceptions were thrown when processing an empty array of subscriptions');
}
private function createSubscription(string $startDate)
{
$order = wc_create_order([
'customer_id' => 1,
'set_paid' => true,
'payment_method' => 'ppcp-gateway',
'billing' => [
'billing' => [
'first_name' => 'John',
'last_name' => 'Doe',
'address_1' => '969 Market',
'address_2' => '',
'city' => 'San Francisco',
'state' => 'CA',
'postcode' => '94103',
'country' => 'US',
'email' => 'john.doe@example.com',
'phone' => '(555) 555-5555'
'last_name' => 'Doe',
'address_1' => '969 Market',
'address_2' => '',
'city' => 'San Francisco',
'state' => 'CA',
'postcode' => '94103',
'country' => 'US',
'email' => 'john.doe@example.com',
'phone' => '(555) 555-5555'
],
'line_items' => [
'line_items' => [
[
'product_id' => 42,
'quantity' => 1
'quantity' => 1
]
],
] );
]);
// Make sure the order is properly saved
$order->save();
$product = new WC_Product_Simple();
$product->set_props([
'name' => 'Dummy Product',
'name' => 'Dummy Product',
'regular_price' => 10,
'price' => 10,
'sku' => 'DUMMY SKU',
'manage_stock' => false,
'tax_status' => 'taxable',
'downloadable' => false,
'virtual' => false,
'stock_status' => 'instock',
'weight' => '1.1',
'price' => 10,
'sku' => 'DUMMY SKU',
'manage_stock' => false,
'tax_status' => 'taxable',
'downloadable' => false,
'virtual' => false,
'stock_status' => 'instock',
'weight' => '1.1',
]);
return wcs_create_subscription([
'start_date' => gmdate( 'Y-m-d H:i:s', strtotime($startDate) ),
'parent_id' => $order->get_id(),
$subscription = wcs_create_subscription([
'start_date' => gmdate('Y-m-d H:i:s', strtotime($startDate)),
'order_id' => $order->get_id(),
'customer_id' => 1,
'status' => 'active',
'billing_period' => 'day',
@ -87,5 +398,9 @@ class PayPalSubscriptionsRenewalTest extends TestCase {
]
],
]);
// Make sure the subscription is properly saved
$subscription->save();
return $subscription;
}
}

View file

@ -0,0 +1,263 @@
<?php
namespace WooCommerce\PayPalCommerce\Tests\Integration;
use WC_Payment_Token;
use WooCommerce\PayPalCommerce\ApiClient\Authentication\Bearer;
use WooCommerce\PayPalCommerce\ApiClient\Endpoint\BillingAgreementsEndpoint;
use WooCommerce\PayPalCommerce\ApiClient\Endpoint\OrderEndpoint;
use WooCommerce\PayPalCommerce\ApiClient\Endpoint\PaymentTokensEndpoint;
use WooCommerce\PayPalCommerce\ApiClient\Entity\PaymentSource;
use WooCommerce\PayPalCommerce\ApiClient\Entity\Token;
use WooCommerce\PayPalCommerce\Onboarding\State;
use WooCommerce\PayPalCommerce\Vendor\Psr\Container\ContainerInterface;
use WooCommerce\PayPalCommerce\WcGateway\Gateway\PayPalGateway;
use WooCommerce\PayPalCommerce\WcGateway\Gateway\CreditCardGateway;
/**
* @group subscriptions
* @group subscription-vaulting
* @group skip-ci
*/
class VaultingSubscriptionsTest extends IntegrationMockedTestCase
{
public function setUp(): void
{
parent::setUp();
// Common mock setup
$this->mockPaymentTokensEndpoint = \Mockery::mock(PaymentTokensEndpoint::class);
// Create customer and default product that can be reused
$this->customer_id = $this->createCustomerIfNotExists();
$this->default_product_id = $this->createAProductIfNotProvided();
}
/**
* Sets up a test container with common mocks
*
* @param OrderEndpoint $orderEndpoint
* @param array $additionalServices Additional services to override
* @return ContainerInterface
*/
protected function setupTestContainer(OrderEndpoint $orderEndpoint, array $additionalServices = []): ContainerInterface
{
$services = [
'api.endpoint.order' => function () use ($orderEndpoint) {
return $orderEndpoint;
},
'api.endpoint.payment-tokens' => function () {
return $this->mockPaymentTokensEndpoint;
}
];
return $this->bootstrapModule(array_merge($services, $additionalServices));
}
/**
* Creates a payment token and configures the mock endpoint to return it
*
* @param int $customer_id
* @param string $gateway_id
* @return WC_Payment_Token
*/
protected function setupPaymentToken(int $customer_id, string $gateway_id = PayPalGateway::ID): WC_Payment_Token
{
$paymentToken = $this->createAPaymentTokenForTheCustomer($customer_id, $gateway_id);
$this->mockPaymentTokensEndpoint->shouldReceive('payment_tokens_for_customer')
->andReturn([
[
'id' => $paymentToken->get_token(),
'payment_source' => new PaymentSource(
'card',
(object)[
'last_digits' => $paymentToken->get_last4(),
'brand' => $paymentToken->get_card_type(),
'expiry' => $paymentToken->get_expiry_year() . '-' . $paymentToken->get_expiry_month()
]
)
]
]);
return $paymentToken;
}
/**
* Tests that vaulting is automatically enabled when subscription mode is set to vaulting_api.
*
* GIVEN a PayPal account with Reference Transactions enabled
* WHEN the subscription mode is set to "vaulting_api"
* THEN vaulting should be automatically enabled for the PayPal gateway
*/
public function test_vaulting_is_enabled_when_subscription_mode_is_vaulting_api()
{
$user_has_cap_callback = function ($allcaps, $caps, $args) {
if (isset($args[0]) && $args[0] === 'manage_woocommerce') {
$allcaps['manage_woocommerce'] = true;
}
return $allcaps;
};
add_filter('user_has_cap', $user_has_cap_callback, 10, 3);
// Convert to Mockery mocks
$billing_agreements_endpoint_mock = \Mockery::mock(BillingAgreementsEndpoint::class);
$billing_agreements_endpoint_mock->shouldReceive('reference_transaction_enabled')
->andReturn(true);
$state_mock = \Mockery::mock(State::class);
$state_mock->shouldReceive('current_state')
->andReturn(State::STATE_ONBOARDED);
$token_mock = \Mockery::mock(Token::class);
$token_mock->shouldReceive('vaulting_available')
->andReturn(true);
$bearer_mock = \Mockery::mock(Bearer::class);
$bearer_mock->shouldReceive('bearer')
->andReturn($token_mock);
// Create and configure the SettingsListener
$c = $this->bootstrapModule([
'api.endpoint.billing-agreements' => function () use ($billing_agreements_endpoint_mock) {
return $billing_agreements_endpoint_mock;
},
'onboarding.state' => function () use ($state_mock) {
return $state_mock;
},
'wcgateway.current-ppcp-settings-page-id' => function () {
return '123';
},
'api.bearer' => function () use ($bearer_mock) {
return $bearer_mock;
},
]);
$settings = $c->get('wcgateway.settings');
// Store original settings to restore later
$original_subscription_mode = $settings->get('subscriptions_mode');
$original_vault_enabled = $settings->get('vault_enabled');
try {
$settings_listener = $c->get('wcgateway.settings.listener');
$settings_listener->listen_for_vaulting_enabled();
$_POST['ppcp'] = [
'subscriptions_mode' => 'vaulting_api',
'vault_enabled' => '0' // Explicitly set to disabled
];
$_REQUEST['_wpnonce'] = wp_create_nonce('ppcp-settings');
$settings_listener->listen_for_vaulting_enabled();
// THEN vaulting should be automatically enabled for the PayPal gateway
$this->assertTrue(
get_option('woocommerce-ppcp-settings')['vault_enabled'],
'Vaulting should be automatically enabled when subscription mode is set to vaulting_api'
);
} finally {
unset($_POST['ppcp']);
$settings->set('subscriptions_mode', $original_subscription_mode);
$settings->set('vault_enabled', $original_vault_enabled);
$settings->persist();
remove_filter('user_has_cap', $user_has_cap_callback, 10);
}
}
/**
* Data provider for payment gateway tests
*/
public function paymentGatewayProvider(): array
{
return [
'PayPal Gateway' => [PayPalGateway::ID],
'Credit Card Gateway' => [CreditCardGateway::ID]
];
}
/**
* Tests PayPal renewal payment processing.
*
* GIVEN a subscription with a saved PayPal payment token due for renewal
* WHEN the renewal process is triggered
* THEN a new PayPal order should be created using the customer token
*
* @dataProvider paymentGatewayProvider
*/
public function test_renewal_payment_processing(string $gateway_id)
{
$mockOrderEndpoint = $this->mockOrderEndpoint('CAPTURE', true);
$c = $this->setupTestContainer($mockOrderEndpoint);
$this->setupPaymentToken($this->customer_id, $gateway_id);
$subscription = $this->createSubscription($this->customer_id, $gateway_id);
$renewal_order = $this->createRenewalOrder($this->customer_id, $gateway_id, $subscription->get_id());
$renewal_handler = $c->get('wc-subscriptions.renewal-handler');
$renewal_handler->renew($renewal_order);
// Check that the order was processed
$this->assertEquals('processing', $renewal_order->get_status(), 'The renewal order should be processing after successful payment');
$this->assertNotEmpty($renewal_order->get_transaction_id(), 'The renewal order should have a transaction ID');
}
/**
* Tests that renewal processing handles failed payments correctly.
*
* GIVEN a subscription due for renewal
* WHEN the payment process fails with an exception
* THEN the renewal order should be marked as failed
*/
public function test_renewal_handles_failed_payment()
{
$mockOrderEndpoint = $this->mockOrderEndpoint('CAPTURE', false);
$c = $this->setupTestContainer($mockOrderEndpoint);
$this->setupPaymentToken($this->customer_id);
$subscription = $this->createSubscription($this->customer_id, PayPalGateway::ID);
$renewal_order = $this->createRenewalOrder($this->customer_id, PayPalGateway::ID, $subscription->get_id());
$renewal_handler = $c->get('wc-subscriptions.renewal-handler');
$renewal_handler->renew($renewal_order);
// Check that the order status is failed
$this->assertEquals('failed', $renewal_order->get_status(), 'The renewal order should be marked as failed when payment fails');
}
/**
* Tests authorization-only subscription renewals.
*
* GIVEN the payment intent is set to "AUTHORIZE"
* WHEN a subscription renewal payment is processed
* THEN the payment should be authorized but not captured
*/
public function test_authorize_only_subscription_renewal()
{
// Mock the OrderEndpoint with AUTHORIZE intent
$mockOrderEndpoint = $this->mockOrderEndpoint('AUTHORIZE', true);
$c = $this->setupTestContainer($mockOrderEndpoint);
// Setup payment token and subscription
$this->setupPaymentToken($this->customer_id);
$subscription = $this->createSubscription($this->customer_id, PayPalGateway::ID);
$renewal_order = $this->createRenewalOrder($this->customer_id, PayPalGateway::ID, $subscription->get_id());
// Override the intent setting to ensure it's set to AUTHORIZE
$settings = $c->get('wcgateway.settings');
$original_intent = $settings->get('intent');
$settings->set('intent', 'authorize');
$settings->persist();
try {
// Process the renewal
$renewal_handler = $c->get('wc-subscriptions.renewal-handler');
$renewal_handler->renew($renewal_order);
// Check that the order was processed with authorization
$this->assertEquals('on-hold', $renewal_order->get_status(), 'The renewal order should be on-hold after successful authorization');
$this->assertNotEmpty($renewal_order->get_transaction_id(), 'The renewal order should have a transaction ID');
$this->assertEquals('AUTHORIZE', $mockOrderEndpoint->order('')->intent(), 'The order intent should be AUTHORIZE');
} finally {
// Restore original settings
$settings->set('intent', $original_intent);
$settings->persist();
}
}
}

View file

@ -21,3 +21,6 @@ define('WP_ROOT_DIR', $wpRootDir);
$_SERVER['HTTP_HOST'] = ''; // just to avoid a warning
require_once WP_ROOT_DIR . '/wp-load.php';
// Ensure the TestCase class is loaded
require_once __DIR__ . '/TestCase.php';
require_once __DIR__ . '/IntegrationMockedTestCase.php';

View file

@ -96,6 +96,8 @@ function clear_plugin_branding( ContainerInterface $container ) : void {
*/
delete_option( 'woocommerce_paypal_branded' );
delete_option( 'ppcp_bn_code' );
try {
$general_settings = $container->get( 'settings.data.general' );
assert( $general_settings instanceof GeneralSettings );