| @@ -154,9 +154,13 @@ | ||
| 154 | 154 | return; |
| 155 | 155 | } |
| 156 | 156 | |
| 157 | 157 | // 2. Build the radio selector UI, then render marks after it's in the DOM. |
| 158 | + // Hide the radio group if there's only one payment method available. | |
| 158 | 159 | const radioGroup = buildRadioGroup(); |
| 160 | + if ( paymentMethods.size === 1 ) { | |
| 161 | + radioGroup.style.display = 'none'; | |
| 162 | + } | |
| 159 | 163 | cardElement.append( radioGroup ); |
| 160 | 164 | renderMarks(); |
| 161 | 165 | |
| 162 | 166 | // 3. Build a container area for each method's UI (buttons / card fields). |
| @@ -218,9 +222,10 @@ | ||
| 218 | 222 | async function discoverPaymentMethods( opts ) { |
| 219 | 223 | const { cardFieldsAreSupported, buttonsAreEnabled, isRecurring } = opts; |
| 220 | 224 | |
| 221 | 225 | // --- Card Fields --- |
| 222 | - if ( cardFieldsAreSupported ) { | |
| 226 | + // Card fields are not supported for recurring payments | |
| 227 | + if ( cardFieldsAreSupported && ! isRecurring ) { | |
| 223 | 228 | const cardFields = createCardFieldsSDKInstance(); |
| 224 | 229 | if ( cardFields?.isEligible() ) { |
| 225 | 230 | cardFieldsInstance = cardFields; |
| 226 | 231 | registerMethod( 'card', { |
| @@ -297,11 +302,11 @@ | ||
| 297 | 302 | if ( ! buttonsAreEnabled || isRecurring ) { |
| 298 | 303 | return; |
| 299 | 304 | } |
| 300 | 305 | |
| 301 | - // Skip (and avoid the SDK wait) when Google Pay / Apple Pay were not enqueued | |
| 302 | - // server-side, e.g. via the frm_include_google_pay_apple_pay filter or non-SSL. | |
| 303 | - if ( ! frmPayPalVars.includeGooglePayApplePay ) { | |
| 306 | + // Skip (and avoid the SDK wait) when neither wallet was enqueued server-side, | |
| 307 | + // e.g. via the frm_include_google_pay_apple_pay filter or non-SSL. | |
| 308 | + if ( ! frmPayPalVars.includeGooglePay && ! frmPayPalVars.includeApplePay ) { | |
| 304 | 309 | return; |
| 305 | 310 | } |
| 306 | 311 | |
| 307 | 312 | // Resolve both eligibility checks in parallel so the combined wait is bounded by |
| @@ -306,12 +311,14 @@ | ||
| 306 | 311 | |
| 307 | 312 | // Resolve both eligibility checks in parallel so the combined wait is bounded by |
| 308 | 313 | // the slower of the two, then register them in a fixed order (Google Pay, then |
| 309 | 314 | // Apple Pay). Registration happens before the selector is built, so they render |
| 310 | - // together with the other methods. | |
| 315 | + // together with the other methods. A wallet turned off server-side, e.g. via | |
| 316 | + // frm_paypal_commerce_include_google_pay or frm_paypal_commerce_include_apple_pay, | |
| 317 | + // skips its check so it never waits on an SDK that was never enqueued. | |
| 311 | 318 | const [ googlePayEligible, applePayEligible ] = await Promise.all( [ |
| 312 | - resolveGooglePayEligibility(), | |
| 313 | - resolveApplePayEligibility() | |
| 319 | + frmPayPalVars.includeGooglePay ? resolveGooglePayEligibility() : false, | |
| 320 | + frmPayPalVars.includeApplePay ? resolveApplePayEligibility() : false | |
| 314 | 321 | ] ); |
| 315 | 322 | |
| 316 | 323 | if ( googlePayEligible ) { |
| 317 | 324 | registerMethod( 'google_pay', { |
| @@ -702,8 +709,13 @@ | ||
| 702 | 709 | * |
| 703 | 710 | * @return {Object|null} The card fields instance. |
| 704 | 711 | */ |
| 705 | 712 | function createCardFieldsSDKInstance() { |
| 713 | + if ( isRecurring ) { | |
| 714 | + // Credit cards are only supported for one time payments. | |
| 715 | + return null; | |
| 716 | + } | |
| 717 | + | |
| 706 | 718 | try { |
| 707 | 719 | const config = { |
| 708 | 720 | onError, |
| 709 | 721 | style: frmPayPalVars.style, |
| @@ -711,15 +723,10 @@ | ||
| 711 | 723 | onChange: onCardFieldsChange |
| 712 | 724 | } |
| 713 | 725 | }; |
| 714 | 726 | |
| 715 | - if ( isRecurring ) { | |
| 716 | - config.createVaultSetupToken = createVaultSetupToken; | |
| 717 | - config.onApprove = onVaultApprove; | |
| 718 | - } else { | |
| 719 | - config.createOrder = createOrder; | |
| 720 | - config.onApprove = onApprove; | |
| 721 | - } | |
| 727 | + config.createOrder = createOrder; | |
| 728 | + config.onApprove = onApprove; | |
| 722 | 729 | |
| 723 | 730 | return window.paypal.CardFields( config ); |
| 724 | 731 | } catch ( err ) { |
| 725 | 732 | console.error( 'Failed to create CardFields instance', err ); |
| @@ -914,9 +921,9 @@ | ||
| 914 | 921 | * @return {google.payments.api.PaymentsClient} The payments client instance. |
| 915 | 922 | */ |
| 916 | 923 | function getGooglePaymentsClient() { |
| 917 | 924 | return new google.payments.api.PaymentsClient( { |
| 918 | - environment: 'TEST', | |
| 925 | + environment: frmPayPalVars.mode === 'test' ? 'TEST' : 'PRODUCTION', | |
| 919 | 926 | paymentDataCallbacks: { |
| 920 | 927 | onPaymentAuthorized |
| 921 | 928 | } |
| 922 | 929 | } ); |
| @@ -1191,8 +1198,14 @@ | ||
| 1191 | 1198 | if ( approvalStatus === 'PAYER_ACTION_REQUIRED' ) { |
| 1192 | 1199 | await applePayInstance.initiatePayerAction( { orderId } ); |
| 1193 | 1200 | } |
| 1194 | 1201 | |
| 1202 | + if ( approvalStatus !== 'APPROVED' && approvalStatus !== 'COMPLETED' ) { | |
| 1203 | + session.completePayment( ApplePaySession.STATUS_FAILURE ); | |
| 1204 | + sessionCompleted = true; | |
| 1205 | + return; | |
| 1206 | + } | |
| 1207 | + | |
| 1195 | 1208 | session.completePayment( ApplePaySession.STATUS_SUCCESS ); |
| 1196 | 1209 | sessionCompleted = true; |
| 1197 | 1210 | |
| 1198 | 1211 | onApprove( { |
| @@ -1353,73 +1366,11 @@ | ||
| 1353 | 1366 | |
| 1354 | 1367 | return orderData.data.orderID; |
| 1355 | 1368 | } |
| 1356 | 1369 | |
| 1357 | - async function createVaultSetupToken() { | |
| 1358 | - const formData = new FormData( thisForm ); | |
| 1359 | - formData.append( 'action', 'frm_paypal_create_vault_setup_token' ); | |
| 1360 | - formData.append( 'nonce', frmPayPalVars.nonce ); | |
| 1361 | - formData.append( 'payment_source', 'card' ); | |
| 1362 | - | |
| 1363 | - formData.delete( 'frm_action' ); | |
| 1364 | - formData.delete( 'form_key' ); | |
| 1365 | - formData.delete( 'item_key' ); | |
| 1366 | - | |
| 1367 | - const response = await fetch( frmPayPalVars.ajax, { | |
| 1368 | - method: 'POST', | |
| 1369 | - body: formData | |
| 1370 | - } ); | |
| 1371 | - | |
| 1372 | - if ( ! response.ok ) { | |
| 1373 | - throw new Error( 'Failed to create PayPal vault setup token' ); | |
| 1374 | - } | |
| 1375 | - | |
| 1376 | - const tokenData = await response.json(); | |
| 1377 | - | |
| 1378 | - if ( ! tokenData.success || ! tokenData.data.token ) { | |
| 1379 | - console.error( 'Vault setup token response:', tokenData ); | |
| 1380 | - throwServerError( tokenData.data, 'Failed to create PayPal vault setup token', 'create_vault_token' ); | |
| 1381 | - } | |
| 1382 | - | |
| 1383 | - return tokenData.data.token; | |
| 1384 | - } | |
| 1385 | - | |
| 1386 | 1370 | // ---- Payment Callbacks ---- |
| 1387 | 1371 | |
| 1388 | 1372 | /** |
| 1389 | - * Handle vault approval for card field subscriptions. | |
| 1390 | - * Receives the vaultSetupToken, sends it to the server to create | |
| 1391 | - * a payment token and subscription, then submits the form. | |
| 1392 | - * | |
| 1393 | - * @param {Object} data The approval data containing vaultSetupToken. | |
| 1394 | - */ | |
| 1395 | - async function onVaultApprove( data ) { | |
| 1396 | - if ( 'NO' === data.liabilityShift || 'UNKNOWN' === data.liabilityShift ) { | |
| 1397 | - onError( new Error( 'This payment was flagged as possible fraud and has been rejected.' ) ); | |
| 1398 | - return; | |
| 1399 | - } | |
| 1400 | - | |
| 1401 | - try { | |
| 1402 | - let vaultInput = thisForm.querySelector( 'input[name="vault_setup_token"]' ); | |
| 1403 | - if ( ! vaultInput ) { | |
| 1404 | - vaultInput = document.createElement( 'input' ); | |
| 1405 | - vaultInput.type = 'hidden'; | |
| 1406 | - vaultInput.name = 'vault_setup_token'; | |
| 1407 | - thisForm.append( vaultInput ); | |
| 1408 | - } | |
| 1409 | - vaultInput.value = data.vaultSetupToken; | |
| 1410 | - | |
| 1411 | - const subscriptionID = await createSubscription( data ); | |
| 1412 | - await onApprove( { | |
| 1413 | - subscriptionID, | |
| 1414 | - paymentSource: 'card' | |
| 1415 | - } ); | |
| 1416 | - } catch ( err ) { | |
| 1417 | - onError( err ); | |
| 1418 | - } | |
| 1419 | - } | |
| 1420 | - | |
| 1421 | - /** | |
| 1422 | 1373 | * Handle approved payment. |
| 1423 | 1374 | * |
| 1424 | 1375 | * @param {Object} data The approval data containing orderID. |
| 1425 | 1376 | */ |
| @@ -1453,18 +1404,15 @@ | ||
| 1453 | 1404 | paymentSourceInput.value = data.paymentSource || 'card'; |
| 1454 | 1405 | |
| 1455 | 1406 | thisForm.append( paymentSourceInput ); |
| 1456 | 1407 | |
| 1408 | + // If using the PayPal buttons to submit, there will not be a submitEvent. | |
| 1457 | 1409 | if ( ! submitEvent ) { |
| 1458 | 1410 | submitEvent = new Event( 'submit', { cancelable: true, bubbles: true } ); |
| 1459 | 1411 | submitEvent.target = thisForm; |
| 1460 | 1412 | } |
| 1461 | 1413 | |
| 1462 | - if ( typeof frmFrontForm.submitFormManual === 'function' ) { | |
| 1463 | - frmFrontForm.submitFormManual( submitEvent, thisForm ); | |
| 1464 | - } else { | |
| 1465 | - thisForm.submit(); | |
| 1466 | - } | |
| 1414 | + frmFrontForm.submitFormManual( submitEvent, thisForm ); | |
| 1467 | 1415 | } |
| 1468 | 1416 | |
| 1469 | 1417 | /** |
| 1470 | 1418 | * Handle payment errors. |
| @@ -1621,9 +1569,13 @@ | ||
| 1621 | 1569 | return fallback; |
| 1622 | 1570 | } |
| 1623 | 1571 | |
| 1624 | 1572 | if ( 'string' === typeof err ) { |
| 1625 | - return parsePayPalErrorString( err ) || err; | |
| 1573 | + const parsed = parsePayPalErrorString( err ); | |
| 1574 | + if ( parsed ) { | |
| 1575 | + return parsed; | |
| 1576 | + } | |
| 1577 | + return mapPayPalErrorCode( err ) || err; | |
| 1626 | 1578 | } |
| 1627 | 1579 | |
| 1628 | 1580 | // PayPal SDK sometimes nests the payload under `err.data` or `err.response`. |
| 1629 | 1581 | const payloads = [ err, err.data, err.response ].filter( Boolean ); |
| @@ -1634,9 +1586,13 @@ | ||
| 1634 | 1586 | } |
| 1635 | 1587 | } |
| 1636 | 1588 | |
| 1637 | 1589 | if ( err.message ) { |
| 1638 | - return parsePayPalErrorString( err.message ) || err.message; | |
| 1590 | + const parsed = parsePayPalErrorString( err.message ); | |
| 1591 | + if ( parsed ) { | |
| 1592 | + return parsed; | |
| 1593 | + } | |
| 1594 | + return mapPayPalErrorCode( err.message ) || err.message; | |
| 1639 | 1595 | } |
| 1640 | 1596 | |
| 1641 | 1597 | return fallback; |
| 1642 | 1598 | } |
| @@ -1641,8 +1597,25 @@ | ||
| 1641 | 1597 | return fallback; |
| 1642 | 1598 | } |
| 1643 | 1599 | |
| 1644 | 1600 | /** |
| 1601 | + * Map PayPal error codes to user-friendly messages. | |
| 1602 | + * | |
| 1603 | + * @param {string} code The PayPal error code (e.g. INVALID_CVV). | |
| 1604 | + * @return {string} The user-friendly message, or empty string if not mapped. | |
| 1605 | + */ | |
| 1606 | + function mapPayPalErrorCode( code ) { | |
| 1607 | + const codeMap = { | |
| 1608 | + INVALID_CVV: 'Please enter a valid CVV code.', | |
| 1609 | + INVALID_CARD_NUMBER: 'Please enter a valid card number.', | |
| 1610 | + INVALID_EXPIRY: 'Please enter a valid expiry date.', | |
| 1611 | + }; | |
| 1612 | + | |
| 1613 | + const upperCode = code.toUpperCase(); | |
| 1614 | + return codeMap[ upperCode ] || ''; | |
| 1615 | + } | |
| 1616 | + | |
| 1617 | + /** | |
| 1645 | 1618 | * Extract the first `description` from a PayPal `details` array. |
| 1646 | 1619 | * |
| 1647 | 1620 | * @param {Array} details The PayPal error details array. |
| 1648 | 1621 | * @return {string} The description, or an empty string if none. |
| @@ -1806,21 +1779,13 @@ | ||
| 1806 | 1779 | if ( meta.name ) { |
| 1807 | 1780 | submitArgs.cardholderName = meta.name; |
| 1808 | 1781 | } |
| 1809 | 1782 | |
| 1810 | - /* | |
| 1811 | - TODO Add the billing address here as well. | |
| 1812 | - Stripe calls a window.frmProForm.addAddressMeta function. | |
| 1813 | - That's included in frmstrp.js though, so we need to add a script in Pro for PayPal as well. | |
| 1783 | + const billingAddress = getBillingAddress(); | |
| 1814 | 1784 | |
| 1815 | - billingAddress: { | |
| 1816 | - addressLine1: '555 Billing Ave', | |
| 1817 | - adminArea1: 'NY', | |
| 1818 | - adminArea2: 'New York', | |
| 1819 | - postalCode: '10001', | |
| 1820 | - countryCode: 'US' | |
| 1785 | + if ( billingAddress ) { | |
| 1786 | + submitArgs.billingAddress = billingAddress; | |
| 1821 | 1787 | } |
| 1822 | - */ | |
| 1823 | 1788 | |
| 1824 | 1789 | try { |
| 1825 | 1790 | await cardFieldsInstance.submit( submitArgs ); |
| 1826 | 1791 | } catch ( err ) { |
| @@ -1830,8 +1795,101 @@ | ||
| 1830 | 1795 | enableSubmit(); |
| 1831 | 1796 | } |
| 1832 | 1797 | reportErrorToServer( err, 'card_submit' ); |
| 1833 | 1798 | } |
| 1799 | + } | |
| 1800 | + | |
| 1801 | + /** | |
| 1802 | + * Build the billing address for the card fields from the address field mapped in the payment action. | |
| 1803 | + * | |
| 1804 | + * @since 6.34 | |
| 1805 | + * | |
| 1806 | + * @return {Object|null} Billing address for the card fields submit args, or null when no address is available. | |
| 1807 | + */ | |
| 1808 | + function getBillingAddress() { | |
| 1809 | + let addressID = ''; | |
| 1810 | + | |
| 1811 | + getPayPalSettings().forEach( function( setting ) { | |
| 1812 | + if ( setting.address ) { | |
| 1813 | + addressID = setting.address; | |
| 1814 | + } | |
| 1815 | + } ); | |
| 1816 | + | |
| 1817 | + if ( '' === addressID ) { | |
| 1818 | + return null; | |
| 1819 | + } | |
| 1820 | + | |
| 1821 | + let prefix = ''; | |
| 1822 | + let addressContainer = document.querySelector( `#frm_field_${ addressID }_container, .frm_field_${ addressID }_container` ); | |
| 1823 | + | |
| 1824 | + if ( ! addressContainer ) { | |
| 1825 | + const line1Input = document.querySelector( `input[name="item_meta[${ addressID }][line1]"]` ); | |
| 1826 | + if ( line1Input ) { | |
| 1827 | + prefix = `${ addressID }][`; | |
| 1828 | + addressContainer = line1Input.parentNode; | |
| 1829 | + } | |
| 1830 | + } | |
| 1831 | + | |
| 1832 | + if ( ! addressContainer ) { | |
| 1833 | + return null; | |
| 1834 | + } | |
| 1835 | + | |
| 1836 | + const getSubFieldValue = function( name ) { | |
| 1837 | + const input = addressContainer.querySelector( `input[name$="[${ prefix }${ name }]"], select[name$="[${ prefix }${ name }]"]` ); | |
| 1838 | + return input?.value ? input.value : ''; | |
| 1839 | + }; | |
| 1840 | + | |
| 1841 | + const subFieldMapping = { | |
| 1842 | + line1: 'addressLine1', | |
| 1843 | + line2: 'addressLine2', | |
| 1844 | + city: 'adminArea2', | |
| 1845 | + state: 'adminArea1', | |
| 1846 | + zip: 'postalCode' | |
| 1847 | + }; | |
| 1848 | + | |
| 1849 | + const billingAddress = {}; | |
| 1850 | + | |
| 1851 | + Object.keys( subFieldMapping ).forEach( function( name ) { | |
| 1852 | + const value = getSubFieldValue( name ); | |
| 1853 | + if ( value ) { | |
| 1854 | + billingAddress[ subFieldMapping[ name ] ] = value; | |
| 1855 | + } | |
| 1856 | + } ); | |
| 1857 | + | |
| 1858 | + if ( ! billingAddress.addressLine1 ) { | |
| 1859 | + return null; | |
| 1860 | + } | |
| 1861 | + | |
| 1862 | + const countryCode = getCountryCode( addressContainer, prefix ); | |
| 1863 | + | |
| 1864 | + if ( countryCode ) { | |
| 1865 | + billingAddress.countryCode = countryCode; | |
| 1866 | + } | |
| 1867 | + | |
| 1868 | + return billingAddress; | |
| 1869 | + } | |
| 1870 | + | |
| 1871 | + /** | |
| 1872 | + * Get the two letter country code for the filled address. | |
| 1873 | + * The country dropdown holds the code in a data-code attribute. | |
| 1874 | + * US type address fields have a state dropdown and no country field, so US is assumed for them. | |
| 1875 | + * | |
| 1876 | + * @since 6.34 | |
| 1877 | + * | |
| 1878 | + * @param {Element} addressContainer | |
| 1879 | + * @param {string} prefix | |
| 1880 | + * @return {string} Country code, or an empty string when the country is unknown. | |
| 1881 | + */ | |
| 1882 | + function getCountryCode( addressContainer, prefix ) { | |
| 1883 | + const countryDropdown = addressContainer.querySelector( `select[name$="[${ prefix }country]"]` ); | |
| 1884 | + | |
| 1885 | + if ( countryDropdown ) { | |
| 1886 | + const countryOption = countryDropdown.querySelector( `option[value="${ countryDropdown.value }"]` ); | |
| 1887 | + return countryOption?.getAttribute( 'data-code' ) || ''; | |
| 1888 | + } | |
| 1889 | + | |
| 1890 | + const stateDropdown = addressContainer.querySelector( `select[name$="[${ prefix }state]"]` ); | |
| 1891 | + return stateDropdown ? 'US' : ''; | |
| 1834 | 1892 | } |
| 1835 | 1893 | |
| 1836 | 1894 | // ---- Price / Pay Later ---- |
| 1837 | 1895 | |