diff --git a/app/helpers/payment_details_helper.rb b/app/helpers/payment_details_helper.rb index 6e61748dd9..3d4a24e8c9 100644 --- a/app/helpers/payment_details_helper.rb +++ b/app/helpers/payment_details_helper.rb @@ -73,7 +73,8 @@ def braintree_form_data threeDSecureEnabled: site_account.payment_gateway_options[:three_ds_enabled], clientToken: braintree_authorization, countriesList: merchant_countries, - billingAddress: billing_address_data + billingAddress: billing_address_data, + ipAddress: ip_address } end @@ -98,4 +99,8 @@ def country_code_for(country_name) merchant_countries.find { |country| country[0] == country_name }&.dig(1) end + + def ip_address + request&.remote_ip + end end diff --git a/app/javascript/packs/braintree_customer_form.ts b/app/javascript/packs/braintree_customer_form.ts index eec6f4cdd7..f0221bd488 100644 --- a/app/javascript/packs/braintree_customer_form.ts +++ b/app/javascript/packs/braintree_customer_form.ts @@ -18,13 +18,14 @@ document.addEventListener('DOMContentLoaded', () => { throw new Error('Braintree data was not provided') } - const { billingAddress, clientToken, countriesList, formActionPath, threeDSecureEnabled } = data + const { billingAddress, clientToken, countriesList, formActionPath, threeDSecureEnabled, ipAddress } = data BraintreeFormWrapper({ billingAddress, clientToken, countriesList, formActionPath, - threeDSecureEnabled + threeDSecureEnabled, + ipAddress }, CONTAINER_ID) }) diff --git a/app/javascript/src/PaymentGateways/braintree/BraintreeForm.tsx b/app/javascript/src/PaymentGateways/braintree/BraintreeForm.tsx index 4c91d8f45b..619c01aff2 100644 --- a/app/javascript/src/PaymentGateways/braintree/BraintreeForm.tsx +++ b/app/javascript/src/PaymentGateways/braintree/BraintreeForm.tsx @@ -15,6 +15,7 @@ const BraintreeForm: FunctionComponent = ({ clientToken, countriesList, formActionPath, + ipAddress, threeDSecureEnabled = false }) => { const [billingAddress, setBillingAddress] = useState(defaultBillingAddress) @@ -45,7 +46,7 @@ const BraintreeForm: FunctionComponent = ({ setSubmitting(true) setSubmitError(undefined) - hostedFields.getNonce(billingAddress) + hostedFields.getNonce({ billingAddress, ipAddress }) .then(nonce => { const input = form.elements.namedItem('braintree[nonce]') as HTMLInputElement input.value = nonce diff --git a/app/javascript/src/PaymentGateways/braintree/types.ts b/app/javascript/src/PaymentGateways/braintree/types.ts index f4953e357e..54017dda10 100644 --- a/app/javascript/src/PaymentGateways/braintree/types.ts +++ b/app/javascript/src/PaymentGateways/braintree/types.ts @@ -17,4 +17,5 @@ export interface BraintreeFormDataset { countriesList: [string, string][]; formActionPath: string; threeDSecureEnabled: boolean; + ipAddress?: string; } diff --git a/app/javascript/src/PaymentGateways/braintree/utils/createHostedFields.ts b/app/javascript/src/PaymentGateways/braintree/utils/createHostedFields.ts index d0942dde31..bed27971dc 100644 --- a/app/javascript/src/PaymentGateways/braintree/utils/createHostedFields.ts +++ b/app/javascript/src/PaymentGateways/braintree/utils/createHostedFields.ts @@ -1,7 +1,7 @@ import { client, hostedFields } from 'braintree-web' import type { HostedFields } from 'braintree-web' -import type { HostedFieldFieldOptions } from 'braintree-web/modules/hosted-fields' +import type { HostedFieldFieldOptions } from 'braintree-web/hosted-fields' /* eslint-disable @typescript-eslint/naming-convention */ const styles = { diff --git a/app/javascript/src/PaymentGateways/braintree/utils/useBraintreeHostedFields.ts b/app/javascript/src/PaymentGateways/braintree/utils/useBraintreeHostedFields.ts index 8adc16f9d3..abba4d6917 100644 --- a/app/javascript/src/PaymentGateways/braintree/utils/useBraintreeHostedFields.ts +++ b/app/javascript/src/PaymentGateways/braintree/utils/useBraintreeHostedFields.ts @@ -8,14 +8,18 @@ import { useEffect, useState } from 'react' import { createHostedFields } from 'PaymentGateways/braintree/utils/createHostedFields' import { createThreeDSecure } from 'PaymentGateways/braintree/utils/createThreeDSecure' -import { verifyCard } from 'PaymentGateways/braintree/utils/verifyCard' import type { BraintreeError } from 'braintree-web' import type { BillingAddress } from 'PaymentGateways/braintree/types' -import type { HostedFields, HostedFieldsFieldDataFields } from 'braintree-web/modules/hosted-fields' +import type { HostedFields, HostedFieldsFieldDataFields } from 'braintree-web/hosted-fields' + +interface GetNonceParams { + billingAddress: BillingAddress; + ipAddress: string | undefined; +} type CustomHostedFields = HostedFields & { - getNonce: (BillingAddress: BillingAddress) => Promise; + getNonce: (getNonceParams: GetNonceParams) => Promise; } const CC_ERROR_MESSAGE = 'An error occurred, please review your CC details or try later.' @@ -64,17 +68,18 @@ const useBraintreeHostedFields = ( const customHostedFields = { ...hostedFieldsInstance, - getNonce: async (billingAddress: BillingAddress): Promise => { - const hostedFieldsTokenizePayload = await hostedFieldsInstance.tokenize() + getNonce: async ({ billingAddress, ipAddress }: GetNonceParams): Promise => { + const { firstName, lastName } = billingAddress + const cardholderName = [firstName, lastName].join(' ') + const hostedFieldsTokenizePayload = await hostedFieldsInstance.tokenize({ cardholderName }) if (!threeDSecureEnabled) { return hostedFieldsTokenizePayload.nonce } - const threeDSecureVerifyPayload = await verifyCard(threeDSecureInstance, { + const threeDSecureVerifyPayload = await threeDSecureInstance.verifyCard({ nonce: hostedFieldsTokenizePayload.nonce, bin: hostedFieldsTokenizePayload.details.bin, - // @ts-expect-error Outdated types. {amount} is a string: https://braintree.github.io/braintree-web/current/ThreeDSecure.html#verifyCard amount: '0.00', billingAddress: { givenName: billingAddress.firstName, @@ -86,7 +91,11 @@ const useBraintreeHostedFields = ( postalCode: billingAddress.zip, countryCodeAlpha2: billingAddress.countryCode }, - challengeRequested: true + collectDeviceData: true, + challengeRequested: true, + additionalInformation: { + ipAddress + } }).catch((verifyCardError: BraintreeError) => { console.error({ verifyCardError }) throw { message: CC_ERROR_MESSAGE } diff --git a/app/javascript/src/PaymentGateways/braintree/utils/verifyCard.ts b/app/javascript/src/PaymentGateways/braintree/utils/verifyCard.ts deleted file mode 100644 index 4e5f98d7f7..0000000000 --- a/app/javascript/src/PaymentGateways/braintree/utils/verifyCard.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type { ThreeDSecureVerifyOptions } from 'braintree-web/modules/three-d-secure' -import type { ThreeDSecure, ThreeDSecureVerifyPayload } from 'braintree-web' - -// HACK: here's a manual promise because ThreeDSecure.verifyCard throws a ts error when awaited. TODO: remove this in a future update. -const verifyCard = (threeDSecureInstance: ThreeDSecure, options: ThreeDSecureVerifyOptions): Promise => { - return new Promise((res, rej) => { - threeDSecureInstance.verifyCard(options, (err, data) => { - if (err) { - rej(err) - } else { - // TODO: confirm nothing is to do here about liabilityShifted - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- Let's assume data exists as long as error doesn't - res(data!) - } - }) - }) -} - -export { verifyCard } diff --git a/app/javascript/src/Types/custom.d.ts b/app/javascript/src/Types/custom.d.ts index d8f47c0a9d..46705695e1 100644 --- a/app/javascript/src/Types/custom.d.ts +++ b/app/javascript/src/Types/custom.d.ts @@ -30,7 +30,4 @@ declare module '*.yaml' { export default data } -// We don't care about missing types from this package. Used only in spec/javascripts/PaymentGateways/braintree/BraintreeForm.spec.tsx -declare module 'braintree-web/hosted-fields'; - declare let __webpack_public_path__: string diff --git a/package.json b/package.json index 2f59e74f10..e83b6652f3 100644 --- a/package.json +++ b/package.json @@ -55,7 +55,7 @@ "@rails/webpacker": "5.4.4", "@stripe/react-stripe-js": "1.16.5", "@stripe/stripe-js": "1.50.0", - "@types/braintree-web": "3.75.22", + "@types/braintree-web": "3.96.11", "@types/c3": "^0.7.8", "@types/codemirror": "^5.60.5", "@types/enzyme": "^3.10.13", @@ -74,7 +74,7 @@ "@types/virtual-dom": "^2.1.1", "babel-loader": "8.2.5", "bootstrap-sass": "3.4.3", - "braintree-web": "3.90.0", + "braintree-web": "3.102.0", "c3": "^0.4.11", "classnames": "^2.2.6", "codemirror": "5.65.9", diff --git a/spec/javascripts/PaymentGateways/braintree/BraintreeForm.spec.tsx b/spec/javascripts/PaymentGateways/braintree/BraintreeForm.spec.tsx index 7bc73a4520..ddc3c255e9 100644 --- a/spec/javascripts/PaymentGateways/braintree/BraintreeForm.spec.tsx +++ b/spec/javascripts/PaymentGateways/braintree/BraintreeForm.spec.tsx @@ -43,7 +43,8 @@ const defaultProps: Props = { }, threeDSecureEnabled: false, formActionPath: '', - countriesList: COUNTRIES_LIST + countriesList: COUNTRIES_LIST, + ipAddress: '123.123.123.123' } const mountWrapper = (props: Partial = {}) => mount() @@ -202,7 +203,7 @@ describe('after hosted fields instantiated', () => { wrapper.find('button[type="submit"]').simulate('submit') - expect(getNonce).toHaveBeenCalledWith(expect.objectContaining({ ...billingAddress, countryCode: 'ES' })) + expect(getNonce).toHaveBeenCalledWith(expect.objectContaining({ billingAddress: { ...billingAddress, country: 'Spain', countryCode: 'ES' }, ipAddress: '123.123.123.123' })) await waitForPromises(wrapper) }) diff --git a/spec/javascripts/PaymentGateways/braintree/utils/useBraintreeClient.spec.tsx b/spec/javascripts/PaymentGateways/braintree/utils/useBraintreeClient.spec.tsx index c9a297460e..7711ef7f27 100644 --- a/spec/javascripts/PaymentGateways/braintree/utils/useBraintreeClient.spec.tsx +++ b/spec/javascripts/PaymentGateways/braintree/utils/useBraintreeClient.spec.tsx @@ -6,7 +6,7 @@ import * as createHostedFields from 'PaymentGateways/braintree/utils/createHoste import type { BillingAddress } from 'PaymentGateways/braintree/types' import type { CustomHostedFields } from 'PaymentGateways/braintree/utils/useBraintreeHostedFields' -import type { HostedFields } from 'braintree-web/modules/hosted-fields' +import type { HostedFields } from 'braintree-web/hosted-fields' import type { FunctionComponent } from 'react' interface Props { @@ -94,7 +94,19 @@ describe('when hosted fields are created', () => { await waitForPromises() - const nonce = await hostedFields!.getNonce({} as BillingAddress) + const nonce = await hostedFields!.getNonce({ billingAddress: {} as BillingAddress, ipAddress: '' }) + expect(nonce).toEqual('This is non-cense') + }) + + it('should pass cardholder name to tokenize', async () => { + let hostedFields: CustomHostedFields | undefined + + mountWrapper({ setHostedFields: (hf) => { hostedFields = hf }, threeDSecureEnabled: false }) + + await waitForPromises() + + const nonce = await hostedFields!.getNonce({ billingAddress: { firstName: 'First', lastName: 'Last' } as BillingAddress, ipAddress: '' }) + expect(hostedFields!.tokenize).toHaveBeenCalledWith({ cardholderName: 'First Last' }) expect(nonce).toEqual('This is non-cense') }) @@ -105,7 +117,7 @@ describe('when hosted fields are created', () => { await waitForPromises() - const nonce = await hostedFields!.getNonce({} as BillingAddress) + const nonce = await hostedFields!.getNonce({ billingAddress: {} as BillingAddress, ipAddress: '' }) expect(nonce).toEqual('This is a 3DS verified transaction') }) }) diff --git a/spec/javascripts/__mocks__/braintree-web.js b/spec/javascripts/__mocks__/braintree-web.js index 27176ab2fa..8d9074aa99 100644 --- a/spec/javascripts/__mocks__/braintree-web.js +++ b/spec/javascripts/__mocks__/braintree-web.js @@ -9,28 +9,25 @@ module.exports = { if (event === 'validityChange') { cb() } if (event === 'lookup-complete') { /* do nothing? */ } }, - tokenize: () => Promise.resolve({ + tokenize: jest.fn(() => Promise.resolve({ details: { bin: 'bin' }, nonce: 'This is non-cense' - }) + })) }) }, threeDSecure: { create: () => Promise.resolve({ on: jest.fn(), - verifyCard: (_options, cb) => { - const payload = { - nonce: 'This is a 3DS verified transaction', - threeDSecureInfo: { - liabilityShifted: true, - liabilityShiftPossible: true, - status: 'authenticate_successful' - } + verifyCard: jest.fn((_options) => Promise.resolve({ + nonce: 'This is a 3DS verified transaction', + threeDSecureInfo: { + liabilityShifted: true, + liabilityShiftPossible: true, + status: 'authenticate_successful' } - cb(undefined, payload) - } + })) }) }, } diff --git a/test/helpers/payment_details_helper_test.rb b/test/helpers/payment_details_helper_test.rb index 2356f4f79d..09c7806bbf 100644 --- a/test/helpers/payment_details_helper_test.rb +++ b/test/helpers/payment_details_helper_test.rb @@ -111,13 +111,15 @@ class PaymentDetailsHelperTest < DeveloperPortal::ActionView::TestCase stubs(:merchant_countries).returns([]) stubs(:braintree_authorization).returns('token') stubs(:billing_address).returns({}) + stubs(:request).returns(ActionDispatch::Request.new('action_dispatch.remote_ip' => '123.123.123.123')) expected = { formActionPath: '/admin/account/braintree_blue/hosted_success', threeDSecureEnabled: true, clientToken: 'token', countriesList: [], - billingAddress: empty_billing_address_data + billingAddress: empty_billing_address_data, + ipAddress: '123.123.123.123' } assert_equal expected, braintree_form_data @@ -128,6 +130,7 @@ class PaymentDetailsHelperTest < DeveloperPortal::ActionView::TestCase stubs(:merchant_countries).returns([]) stubs(:braintree_authorization).returns('token') stubs(:has_billing_address?).returns(true) + stubs(:request).returns(ActionDispatch::Request.new('action_dispatch.remote_ip' => '123.123.123.123')) account = FactoryBot.build(:account) stubs(:current_account).returns(account) @@ -137,7 +140,8 @@ class PaymentDetailsHelperTest < DeveloperPortal::ActionView::TestCase threeDSecureEnabled: true, clientToken: 'token', countriesList: [], - billingAddress: billing_address_data + billingAddress: billing_address_data, + ipAddress: '123.123.123.123' } assert_equal expected, braintree_form_data diff --git a/yarn.lock b/yarn.lock index 2ddd9db5d5..deab27ecfd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1144,21 +1144,16 @@ dependencies: promise-polyfill "^8.1.3" -"@braintree/browser-detection@1.14.0": - version "1.14.0" - resolved "https://registry.yarnpkg.com/@braintree/browser-detection/-/browser-detection-1.14.0.tgz#d1b397b00ccbc7cac12f6cec27c0a413d740332a" - integrity sha512-OsqU+28RhNvSw8Y5JEiUHUrAyn4OpYazFkjSJe8ZVZfkAaRXQc6hsV38MMEpIlkPMig+A68buk/diY+0O8/dMQ== +"@braintree/browser-detection@1.17.1": + version "1.17.1" + resolved "https://registry.yarnpkg.com/@braintree/browser-detection/-/browser-detection-1.17.1.tgz#d151c409dc7245e9307b05f3a06ede254e0f5d1e" + integrity sha512-Mk7jauyp9pD14BTRS7otoy9dqIJGb3Oy0XtxKM/adGD9i9MAuCjH5uRZMyW2iVmJQTaA/PLlWdG7eSDyMWMc8Q== "@braintree/browser-detection@^1.12.1": version "1.16.0" resolved "https://registry.yarnpkg.com/@braintree/browser-detection/-/browser-detection-1.16.0.tgz#5cea154d6f87427f566ba2818381f11044fbfdba" integrity sha512-JVkJ7VmWXWbcCtvNIwHA0yW2+5Dp1zD0ZQK6yeftdBgyrAfphtG8hv7zqEJP5sFeWZWQJJQC/o5MAWarwALQUg== -"@braintree/class-list@0.2.0": - version "0.2.0" - resolved "https://registry.yarnpkg.com/@braintree/class-list/-/class-list-0.2.0.tgz#4c4352ac19c262f61526f93d07d248244b399ec4" - integrity sha512-iLXJT51jnBFuGvyTAQqZ2uwyEVwdyapyz52F5MK1Uoh2ZOiPJ5hoqI0wncyCP2KfqrgyCpOkkEaLMLb/94unGA== - "@braintree/event-emitter@0.4.1": version "0.4.1" resolved "https://registry.yarnpkg.com/@braintree/event-emitter/-/event-emitter-0.4.1.tgz#204eaad8cf84eb7bf81fb288a359d34eda85a396" @@ -1174,10 +1169,10 @@ resolved "https://registry.yarnpkg.com/@braintree/iframer/-/iframer-1.1.0.tgz#7e59b975c2a48bd92616f653367a5214fc2ddd4b" integrity sha512-tVpr7U6u6bqeQlHreEjYMNtnHX62vLnNWziY2kQLqkWhvusPuY5DfuGEIPpWqsd+V/a1slyTQaxK6HWTlH6A/Q== -"@braintree/sanitize-url@6.0.0": - version "6.0.0" - resolved "https://registry.yarnpkg.com/@braintree/sanitize-url/-/sanitize-url-6.0.0.tgz#fe364f025ba74f6de6c837a84ef44bdb1d61e68f" - integrity sha512-mgmE7XBYY/21erpzhexk4Cj1cyTQ9LzvnTxtzM17BJ7ERMNE6W72mQRo0I1Ud8eFJ+RVVIcBNhLFZ3GX4XFz5w== +"@braintree/sanitize-url@7.0.1": + version "7.0.1" + resolved "https://registry.yarnpkg.com/@braintree/sanitize-url/-/sanitize-url-7.0.1.tgz#457233b0a18741b7711855044102b82bae7a070b" + integrity sha512-URg8UM6lfC9ZYqFipItRSxYJdgpU5d2Z4KnjsJ+rj6tgAmGme7E+PQNCiud8g0HDaZKMovu2qjfa0f5Ge0Vlsg== "@braintree/sanitize-url@^5.0.2": version "5.0.2" @@ -2054,10 +2049,10 @@ dependencies: "@babel/types" "^7.3.0" -"@types/braintree-web@3.75.22": - version "3.75.22" - resolved "https://registry.yarnpkg.com/@types/braintree-web/-/braintree-web-3.75.22.tgz#b718c1e415c03b59144dc7b8ce22d84b5583a0fa" - integrity sha512-wvefF6OZkEg8twgEf/TMGPZ6OlLyq59eHe77D5QlQgAApUI4QkHwuK5UldVi4/6gBhwvW6snfNUdaQTfK3auRg== +"@types/braintree-web@3.96.11": + version "3.96.11" + resolved "https://registry.yarnpkg.com/@types/braintree-web/-/braintree-web-3.96.11.tgz#66471c301b601f40eb92728db46766ceb0f4c2e4" + integrity sha512-ss3XCndh5qGFNEAk2bR99o5Iywc+kcM0FzqXPRUa/Y4vcLD5jNuKmNmZBmq744c4va1xF83jg6gvf79owdzPhA== dependencies: "@types/googlepay" "*" "@types/paypal-checkout-components" "*" @@ -3585,23 +3580,22 @@ braces@^3.0.2, braces@~3.0.2: dependencies: fill-range "^7.0.1" -braintree-web@3.90.0: - version "3.90.0" - resolved "https://registry.yarnpkg.com/braintree-web/-/braintree-web-3.90.0.tgz#429a4d0c2c51dda1f1cb91ab94a4b837942d0864" - integrity sha512-jMz0EirJLme+OP0TjYk3/esbkPfpei3tc/pC/7a6WIXFyNcjp0y5k+yw9jQKQW0pGpt3goypPqkO4jRAv2b86g== +braintree-web@3.102.0: + version "3.102.0" + resolved "https://registry.yarnpkg.com/braintree-web/-/braintree-web-3.102.0.tgz#f0d65f80b3d0c7aa12380a51292790768278f926" + integrity sha512-zQzYi1O8+ovLKKpjjaIl5lXg8+cL9u5sYDQucGrTPntqBeGi86psCcymdJPmUn4ASwYN6GKWmHg7e8tZ80aYOg== dependencies: "@braintree/asset-loader" "0.4.4" - "@braintree/browser-detection" "1.14.0" - "@braintree/class-list" "0.2.0" + "@braintree/browser-detection" "1.17.1" "@braintree/event-emitter" "0.4.1" "@braintree/extended-promise" "0.4.1" "@braintree/iframer" "1.1.0" - "@braintree/sanitize-url" "6.0.0" + "@braintree/sanitize-url" "7.0.1" "@braintree/uuid" "0.1.0" "@braintree/wrap-promise" "2.1.0" card-validator "8.1.1" credit-card-type "9.1.0" - framebus "5.2.0" + framebus "5.2.1" inject-stylesheet "5.0.0" promise-polyfill "8.2.3" restricted-input "3.0.5" @@ -6204,10 +6198,10 @@ fragment-cache@^0.2.1: dependencies: map-cache "^0.2.2" -framebus@5.2.0: - version "5.2.0" - resolved "https://registry.yarnpkg.com/framebus/-/framebus-5.2.0.tgz#a1689e8bbd5abf3ae7af8b1139658bb66d808e62" - integrity sha512-hIKt71vBVd/g0emUbuVg8HAeHEjxBwhAE87CKXvxPIy0sCoGWqBulB1k9lWBWUU6ZHXPs0xjXWMwUldWMiqD6A== +framebus@5.2.1: + version "5.2.1" + resolved "https://registry.yarnpkg.com/framebus/-/framebus-5.2.1.tgz#6b7468191c020e28ee339c15561d4bd12864c636" + integrity sha512-K6pw+M2wNBuOhEoFrmMbf1O+fm7PnNDIfA9y0KpAyQzXRIJ420szGgJ/dI2Ikz0XG+5VfspLqA72M6bXhuyKIQ== dependencies: "@braintree/uuid" "^0.1.0"