From 069da46d500c455085334b26fc9ebc8fffe6b6f1 Mon Sep 17 00:00:00 2001 From: Mike Letellier Date: Mon, 30 Mar 2026 10:43:24 -0300 Subject: [PATCH 1/4] Working on Apple Pay (wip) --- .../FrmPayPalLiteActionsController.php | 1 + .../FrmPayPalLiteAppController.php | 12 +- paypal/css/frontend.css | 9 + paypal/images/apple-pay.svg | 16 ++ paypal/js/frontend.js | 178 +++++++++++++++++- 5 files changed, 210 insertions(+), 6 deletions(-) create mode 100644 paypal/images/apple-pay.svg diff --git a/paypal/controllers/FrmPayPalLiteActionsController.php b/paypal/controllers/FrmPayPalLiteActionsController.php index d1e2030cf2..a91ff2ebb0 100644 --- a/paypal/controllers/FrmPayPalLiteActionsController.php +++ b/paypal/controllers/FrmPayPalLiteActionsController.php @@ -1051,6 +1051,7 @@ function ( $settings_for_action, $payment_action ) use ( &$payment_action_by_id if ( $include_buttons ) { $components[] = 'buttons'; $components[] = 'googlepay'; + $components[] = 'applepay'; } if ( $include_card_fields ) { diff --git a/paypal/controllers/FrmPayPalLiteAppController.php b/paypal/controllers/FrmPayPalLiteAppController.php index 204d0f48f5..8292048765 100644 --- a/paypal/controllers/FrmPayPalLiteAppController.php +++ b/paypal/controllers/FrmPayPalLiteAppController.php @@ -272,17 +272,19 @@ private static function get_valid_payment_sources() { $sources = array( 'card', 'paypal', - 'mybank', + 'apple_pay', 'bancontact', 'blik', 'eps', + 'giropay', + 'ideal', + 'mybank', 'p24', - 'trustly', - 'satispay', 'sepa', - 'ideal', - 'paylater', + 'sofort', + 'trustly', 'venmo', + 'paylater', 'google_pay', ); diff --git a/paypal/css/frontend.css b/paypal/css/frontend.css index bf1f1b3d3a..702dc51748 100644 --- a/paypal/css/frontend.css +++ b/paypal/css/frontend.css @@ -110,6 +110,15 @@ width: auto; } +.frm-payment-method-apple-pay-icon { + height: 24px; +} + +.frm-payment-method-apple-pay-icon svg { + height: 24px; + width: auto; +} + .frm-payment-method-paylater-wrap { border-bottom: 1px solid #e0e0e0; background: #fff; diff --git a/paypal/images/apple-pay.svg b/paypal/images/apple-pay.svg new file mode 100644 index 0000000000..5733474aa1 --- /dev/null +++ b/paypal/images/apple-pay.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/paypal/js/frontend.js b/paypal/js/frontend.js index 743728b1c8..189b416311 100644 --- a/paypal/js/frontend.js +++ b/paypal/js/frontend.js @@ -37,6 +37,9 @@ /** Cached Google Pay config from paypal.Googlepay().config(). */ let googlePayConfig = null; + /** Cached Apple Pay config from paypal.Applepay().config(). */ + let applePayConfig = null; + // ---- Constants ---- /** @@ -48,6 +51,7 @@ venmo: 'Venmo', paylater: 'Pay Later', google_pay: 'Google Pay', + apple_pay: 'Apple Pay', bancontact: 'Bancontact', blik: 'BLIK', eps: 'EPS', @@ -92,6 +96,16 @@ apiVersionMinor: 0 }; + /** + * Base request object for Apple Pay payment requests. + */ + const applePayBaseRequest = { + countryCode: 'US', + currencyCode: 'USD', + merchantCapabilities: ['supports3DS'], + supportedNetworks: ['visa', 'masterCard', 'amex', 'discover'] + }; + // ---- Initialization ---- /** @@ -255,6 +269,19 @@ } ); } } + + // --- Apple Pay --- + if ( buttonsAreEnabled && ! isRecurring ) { + const applePayEligibilityResult = await checkApplePayEligibility(); + if ( applePayEligibilityResult === '' ) { + registerMethod( 'apple_pay', { + eligible: true, + render: renderApplePayButton + } ); + } else { + console.log( 'Apple Pay not available:', applePayEligibilityResult ); + } + } } /** @@ -342,11 +369,18 @@ } else if ( key === 'google_pay' ) { markWrap.classList.add( 'frm-payment-method-google-pay-icon' ); const img = document.createElement( 'img' ); - const baseUrl = frmPayPalVars.imagesUrl || ''; img.src = `${ baseUrl }gpay.svg`; img.alt = 'Google Pay'; img.height = 24; markWrap.append( img ); + } else if ( key === 'apple_pay' ) { + markWrap.classList.add( 'frm-payment-method-apple-pay-icon' ); + const img = document.createElement( 'img' ); + img.src = `${ baseUrl }apple-pay.svg`; + img.alt = 'Apple Pay'; + img.height = 24; + img.style.width = 'auto'; + markWrap.append( img ); } label.append( radio ); @@ -854,6 +888,115 @@ } } + // ---- Apple Pay ---- + + /** + * Check if Apple Pay is eligible (without rendering). + * + * @return {Promise} An empty string if Apple Pay is supported and ready to accept payments in the current environment, or a string with the reason for ineligibility. + */ + async function checkApplePayEligibility() { + if ( 'function' !== typeof paypal.Applepay ) { + return 'PayPal Apple Pay SDK not loaded'; + } + + if ( ! window.ApplePaySession ) { + return 'Not on Apple device'; + } + + if ( ! ApplePaySession.canMakePayments() ) { + return 'Apple Pay not configured on device'; + } + + if ( ! paypal.FUNDING || ! paypal.FUNDING.APPLEPAY ) { + return 'Apple Pay funding source not available in PayPal SDK'; + } + + // The definitive eligibility check: ask PayPal if an Apple Pay button is eligible. + try { + const testButton = paypal.Buttons( { + fundingSource: paypal.FUNDING.APPLEPAY + } ); + + if ( typeof testButton.isEligible === 'function' && ! testButton.isEligible() ) { + return 'Apple Pay button not eligible'; + } + } catch ( err ) { + return 'Apple Pay eligibility check failed: ' + err.message; + } + + // Load Apple Pay config for later use during rendering. + try { + applePayConfig = await paypal.Applepay().config(); + } catch ( err ) { + return 'Apple Pay config failed: ' + err.message; + } + + return ''; + } + + /** + * Render the Apple Pay button into its method container. + */ + async function renderApplePayButton() { + const method = paymentMethods.get( 'apple_pay' ); + if ( ! method ) { + return; + } + + const container = method.containerEl; + container.innerHTML = ''; + + try { + const applePayButton = createApplePayButton(); + + if ( typeof applePayButton.render !== 'function' ) { + throw new Error( 'Apple Pay button does not have render method' ); + } + + applePayButton.render( `#${ container.id }` ); + } catch ( err ) { + console.error( 'Failed to render Apple Pay button', err ); + container.innerHTML = ''; + } + } + + /** + * Create an Apple Pay button instance. + * + * @return {Object} The Apple Pay button instance. + */ + function createApplePayButton() { + const buttonConfig = { + fundingSource: paypal.FUNDING.APPLEPAY, + onApprove: onApplePayApprove, + onError, + onCancel, + style: { ...frmPayPalVars.buttonStyle }, + }; + + // Apple Pay only supports black or white button colors. + if ( buttonConfig.style.color && ! ['black', 'white'].includes( buttonConfig.style.color ) ) { + delete buttonConfig.style.color; + } + + buttonConfig.createOrder = createOrderForApplePay; + + return paypal.Buttons( buttonConfig ); + } + + /** + * Handle Apple Pay payment approval. + * + * @param {Object} data The approval data containing orderID. + */ + async function onApplePayApprove( data ) { + await onApprove( { + ...data, + paymentSource: 'apple_pay' + } ); + } + // ---- AJAX / Order Creation ---- /** @@ -964,6 +1107,39 @@ return orderData.data.orderID; } + /** + * Create a PayPal order specifically for Apple Pay. + * + * @return {Promise} The PayPal order ID. + */ + async function createOrderForApplePay() { + const formData = new FormData( thisForm ); + formData.append( 'action', 'frm_paypal_create_order' ); + formData.append( 'nonce', frmPayPalVars.nonce ); + formData.append( 'payment_source', 'apple_pay' ); + + formData.delete( 'frm_action' ); + formData.delete( 'form_key' ); + formData.delete( 'item_key' ); + + const response = await fetch( frmPayPalVars.ajax, { + method: 'POST', + body: formData + } ); + + if ( ! response.ok ) { + throw new Error( 'Failed to create PayPal order for Apple Pay' ); + } + + const orderData = await response.json(); + + if ( ! orderData.success || ! orderData.data.orderID ) { + throw new Error( orderData.data || 'Failed to create PayPal order for Apple Pay' ); + } + + return orderData.data.orderID; + } + async function createVaultSetupToken() { const formData = new FormData( thisForm ); formData.append( 'action', 'frm_paypal_create_vault_setup_token' ); From 30e279c7883618547d2c5d6f469a5e9cbab28e77 Mon Sep 17 00:00:00 2001 From: Mike Letellier Date: Mon, 30 Mar 2026 12:24:55 -0300 Subject: [PATCH 2/4] Get apple pay button appearing --- .../FrmPayPalLiteActionsController.php | 16 +- paypal/js/frontend.js | 137 +++++++++++------- 2 files changed, 93 insertions(+), 60 deletions(-) diff --git a/paypal/controllers/FrmPayPalLiteActionsController.php b/paypal/controllers/FrmPayPalLiteActionsController.php index a91ff2ebb0..ff96362408 100644 --- a/paypal/controllers/FrmPayPalLiteActionsController.php +++ b/paypal/controllers/FrmPayPalLiteActionsController.php @@ -1009,7 +1009,7 @@ function ( $settings_for_action, $payment_action ) use ( &$payment_action_by_id 'intent' => $intent, 'currency' => strtoupper( $action->post_content['currency'] ?? 'USD' ), 'merchant-id' => FrmPayPalLiteConnectHelper::get_merchant_id(), - 'enable-funding' => 'venmo', + 'enable-funding' => 'venmo,applepay', ); if ( 'subscription' === $intent ) { @@ -1085,6 +1085,7 @@ function ( $settings_for_action, $payment_action ) use ( &$payment_action_by_id $sdk_url = add_query_arg( $query_args, 'https://www.paypal.com/sdk/js' ); wp_register_script( 'paypal-sdk', $sdk_url, array(), null, false ); + wp_register_script( 'apple-pay-sdk', 'https://applepay.cdn-apple.com/jsapi/1.latest/apple-pay-sdk.js', array(), null, false ); $has_break = FrmAppHelper::pro_is_installed() && (bool) FrmField::get_all_types_in_form( $form_id, 'break' ); @@ -1098,15 +1099,14 @@ function ( $settings_for_action, $payment_action ) use ( &$payment_action_by_id */ function ( $tag, $handle ) use ( $has_break ) { if ( 'paypal-sdk' === $handle ) { - $attributes = ' data-partner-attribution-id="' . esc_attr( FrmPayPalLiteConnectHelper::get_bn_code() ) . '"'; - - if ( $has_break ) { - $attributes .= ' async'; - } - + $attributes = ' async data-partner-attribution-id="' . esc_attr( FrmPayPalLiteConnectHelper::get_bn_code() ) . '"'; return str_replace( ' src=', $attributes . ' src=', $tag ); } + if ( in_array( $handle, array( 'apple-pay-sdk', 'google-pay' ), true ) ) { + return str_replace( ' src=', ' async src=', $tag ); + } + if ( $has_break && 'formidable-paypal' === $handle ) { return str_replace( ' src=', ' async src=', $tag ); } @@ -1124,7 +1124,7 @@ function ( $tag, $handle ) use ( $has_break ) { FrmAppHelper::plugin_version() ); - $dependencies = array( 'paypal-sdk', 'formidable' ); + $dependencies = array( 'paypal-sdk', 'apple-pay-sdk', 'formidable' ); $script_url = FrmPayPalLiteAppHelper::plugin_url() . 'js/frontend.js'; wp_enqueue_script( diff --git a/paypal/js/frontend.js b/paypal/js/frontend.js index 189b416311..9d8b0d704a 100644 --- a/paypal/js/frontend.js +++ b/paypal/js/frontend.js @@ -908,28 +908,18 @@ return 'Apple Pay not configured on device'; } - if ( ! paypal.FUNDING || ! paypal.FUNDING.APPLEPAY ) { - return 'Apple Pay funding source not available in PayPal SDK'; - } - - // The definitive eligibility check: ask PayPal if an Apple Pay button is eligible. + // Use paypal.Applepay().config() as the definitive eligibility check (per PayPal multiparty docs). try { - const testButton = paypal.Buttons( { - fundingSource: paypal.FUNDING.APPLEPAY - } ); + applePayConfig = await paypal.Applepay().config(); + console.log( '[FrmApplePay] config() response:', JSON.stringify( applePayConfig, null, 2 ) ); - if ( typeof testButton.isEligible === 'function' && ! testButton.isEligible() ) { - return 'Apple Pay button not eligible'; + if ( ! applePayConfig || ! applePayConfig.isEligible ) { + console.warn( '[FrmApplePay] Not eligible. isEligible:', applePayConfig?.isEligible, 'Full config:', applePayConfig ); + return 'PayPal reports Apple Pay is not eligible for this merchant/domain'; } } catch ( err ) { - return 'Apple Pay eligibility check failed: ' + err.message; - } - - // Load Apple Pay config for later use during rendering. - try { - applePayConfig = await paypal.Applepay().config(); - } catch ( err ) { - return 'Apple Pay config failed: ' + err.message; + console.error( '[FrmApplePay] config() threw error:', err ); + return 'Apple Pay config check failed: ' + err.message; } return ''; @@ -947,54 +937,97 @@ const container = method.containerEl; container.innerHTML = ''; - try { - const applePayButton = createApplePayButton(); - - if ( typeof applePayButton.render !== 'function' ) { - throw new Error( 'Apple Pay button does not have render method' ); - } + const btn = document.createElement( 'apple-pay-button' ); + btn.setAttribute( 'buttonstyle', 'black' ); + btn.setAttribute( 'type', 'buy' ); + btn.setAttribute( 'locale', 'en' ); + btn.style.width = '100%'; + btn.style.height = '40px'; - applePayButton.render( `#${ container.id }` ); - } catch ( err ) { - console.error( 'Failed to render Apple Pay button', err ); - container.innerHTML = ''; - } + btn.addEventListener( 'click', onApplePayButtonClick ); + container.appendChild( btn ); } /** - * Create an Apple Pay button instance. - * - * @return {Object} The Apple Pay button instance. + * Handle click on the Apple Pay button. + * Creates an ApplePaySession synchronously (required by Apple) and processes the payment via PayPal. */ - function createApplePayButton() { - const buttonConfig = { - fundingSource: paypal.FUNDING.APPLEPAY, - onApprove: onApplePayApprove, - onError, - onCancel, - style: { ...frmPayPalVars.buttonStyle }, + function onApplePayButtonClick() { + if ( ! applePayConfig ) { + console.error( 'Apple Pay config not available' ); + return; + } + + const paymentRequest = { + countryCode: applePayConfig.countryCode, + merchantCapabilities: applePayConfig.merchantCapabilities, + supportedNetworks: applePayConfig.supportedNetworks, + currencyCode: applePayConfig.currencyCode || 'USD', + total: { + label: document.title || 'Payment', + type: 'final', + amount: getFormTotal(), + }, }; - // Apple Pay only supports black or white button colors. - if ( buttonConfig.style.color && ! ['black', 'white'].includes( buttonConfig.style.color ) ) { - delete buttonConfig.style.color; - } + // ApplePaySession MUST be created synchronously inside the click handler. + const session = new ApplePaySession( 4, paymentRequest ); + const applepay = paypal.Applepay(); - buttonConfig.createOrder = createOrderForApplePay; + session.onvalidatemerchant = ( event ) => { + applepay.validateMerchant( { + validationUrl: event.validationURL, + displayName: document.title || 'Payment' + } ) + .then( ( validateResult ) => { + session.completeMerchantValidation( validateResult.merchantSession ); + } ) + .catch( ( validateError ) => { + console.error( 'Apple Pay merchant validation failed', validateError ); + session.abort(); + } ); + }; - return paypal.Buttons( buttonConfig ); + session.onpaymentauthorized = ( event ) => { + createOrderForApplePay() + .then( ( orderId ) => { + return applepay.confirmOrder( { + orderId: orderId, + token: event.payment.token, + billingContact: event.payment.billingContact + } ) + .then( () => { + session.completePayment( ApplePaySession.STATUS_SUCCESS ); + onApprove( { + orderID: orderId, + paymentSource: 'apple_pay' + } ); + } ); + } ) + .catch( ( err ) => { + console.error( 'Apple Pay payment failed', err ); + session.completePayment( ApplePaySession.STATUS_FAILURE ); + } ); + }; + + session.oncancel = () => { + onCancel(); + }; + + session.begin(); } /** - * Handle Apple Pay payment approval. + * Get the form total amount as a string. * - * @param {Object} data The approval data containing orderID. + * @return {string} The total amount. */ - async function onApplePayApprove( data ) { - await onApprove( { - ...data, - paymentSource: 'apple_pay' - } ); + function getFormTotal() { + const totalField = thisForm.querySelector( '[data-frmtotal]' ); + if ( totalField && totalField.value ) { + return parseFloat( totalField.value ).toFixed( 2 ); + } + return '0.00'; } // ---- AJAX / Order Creation ---- From 3039bcc00a474910bb6bde367d39c19bebd8f584 Mon Sep 17 00:00:00 2001 From: Mike Letellier Date: Mon, 30 Mar 2026 12:30:30 -0300 Subject: [PATCH 3/4] Remove some logging --- paypal/js/frontend.js | 3 --- 1 file changed, 3 deletions(-) diff --git a/paypal/js/frontend.js b/paypal/js/frontend.js index 9d8b0d704a..439c436f33 100644 --- a/paypal/js/frontend.js +++ b/paypal/js/frontend.js @@ -911,14 +911,11 @@ // Use paypal.Applepay().config() as the definitive eligibility check (per PayPal multiparty docs). try { applePayConfig = await paypal.Applepay().config(); - console.log( '[FrmApplePay] config() response:', JSON.stringify( applePayConfig, null, 2 ) ); if ( ! applePayConfig || ! applePayConfig.isEligible ) { - console.warn( '[FrmApplePay] Not eligible. isEligible:', applePayConfig?.isEligible, 'Full config:', applePayConfig ); return 'PayPal reports Apple Pay is not eligible for this merchant/domain'; } } catch ( err ) { - console.error( '[FrmApplePay] config() threw error:', err ); return 'Apple Pay config check failed: ' + err.message; } From 36b96404a9000c3b168259030a9188d976c71c2f Mon Sep 17 00:00:00 2001 From: Mike Letellier Date: Mon, 30 Mar 2026 12:35:32 -0300 Subject: [PATCH 4/4] Drop unused var --- paypal/js/frontend.js | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/paypal/js/frontend.js b/paypal/js/frontend.js index 439c436f33..020086b864 100644 --- a/paypal/js/frontend.js +++ b/paypal/js/frontend.js @@ -96,16 +96,6 @@ apiVersionMinor: 0 }; - /** - * Base request object for Apple Pay payment requests. - */ - const applePayBaseRequest = { - countryCode: 'US', - currencyCode: 'USD', - merchantCapabilities: ['supports3DS'], - supportedNetworks: ['visa', 'masterCard', 'amex', 'discover'] - }; - // ---- Initialization ---- /**