diff --git a/lib/recurly/apple-pay/apple-pay.braintree.js b/lib/recurly/apple-pay/apple-pay.braintree.js index 2c6e778e6..beeb84800 100644 --- a/lib/recurly/apple-pay/apple-pay.braintree.js +++ b/lib/recurly/apple-pay/apple-pay.braintree.js @@ -59,22 +59,29 @@ export class ApplePayBraintree extends ApplePay { .catch(err => this.error(err)); } - token (event, applePayPayment) { + token (event) { debug('Creating token'); this.braintree.applePay .tokenize({ token: event.payment.token }) - .then(tokenizePayload => super.token(event, { - type: 'braintree', - payload: { - deviceData: this.braintree.dataCollector.deviceData, - applePayPayment, - tokenizePayload - } - })) + .then(braintreeToken => { + event.payment.gatewayToken = braintreeToken; + return super.token(event); + }) .catch(err => this.error('apple-pay-payment-failure', err)); } + mapPaymentData (event) { + return { + type: 'braintree', + payload: { + deviceData: this.braintree.dataCollector.deviceData, + tokenizePayload: event.payment.gatewayToken, + applePayPayment: super.mapPaymentData(event), + }, + }; + } + onCancel (event) { debug('Teardown payment', event); diff --git a/lib/recurly/apple-pay/apple-pay.js b/lib/recurly/apple-pay/apple-pay.js index 0c8765fc6..9c3c8a145 100644 --- a/lib/recurly/apple-pay/apple-pay.js +++ b/lib/recurly/apple-pay/apple-pay.js @@ -1,25 +1,14 @@ import Emitter from 'component-emitter'; import errors from '../errors'; import { Pricing } from '../pricing'; -import { FIELDS } from '../token'; import PricingPromise from '../pricing/promise'; -import { normalize } from '../../util/normalize'; -import buildApplePayPaymentRequest from './util/build-apple-pay-payment-request'; import { lineItem } from './util/apple-pay-line-item'; +import buildApplePayPaymentRequest from './util/build-apple-pay-payment-request'; +import { transformAddress, normalizeForm } from './util/transform-address'; const debug = require('debug')('recurly:apple-pay'); const MINIMUM_SUPPORTED_VERSION = 4; -const APPLE_PAY_ADDRESS_MAP = { - first_name: 'givenName', - last_name: 'familyName', - address1: 'addressLines', - address2: 'addressLines', - city: 'locality', - state: 'administrativeArea', - postal_code: 'postalCode', - country: 'countryCode' -}; const I18N = { subtotalLineItemLabel: 'Subtotal', @@ -72,10 +61,26 @@ export class ApplePay extends Emitter { */ get session () { if (this._session) return this._session; + let paymentRequest = this._paymentRequest; + + if (this.config.form) { + const { billingContact, shippingContact } = paymentRequest; + const { + billingContact: formBillingContact, + shippingContact: formShippingContact, tokenData, + } = normalizeForm(this.config.form); + this.tokenData = tokenData; + + paymentRequest = { + ...paymentRequest, + ...(!billingContact && formBillingContact && { billingContact: formBillingContact }), + ...(!shippingContact && formShippingContact && { shippingContact: formShippingContact }), + }; + } - debug('Creating new Apple Pay session', this._paymentRequest); + debug('Creating new Apple Pay session', paymentRequest); - const session = new window.ApplePaySession(MINIMUM_SUPPORTED_VERSION, this._paymentRequest); + const session = new window.ApplePaySession(MINIMUM_SUPPORTED_VERSION, paymentRequest); session.onvalidatemerchant = this.onValidateMerchant.bind(this); session.onshippingcontactselected = this.onShippingContactSelected.bind(this); session.onshippingmethodselected = this.onShippingMethodSelected.bind(this); @@ -247,6 +252,8 @@ export class ApplePay extends Emitter { onPaymentMethodSelected (event) { debug('Payment method selected', event); + this.emit('paymentMethodSelected', event); + this.session.completePaymentMethodSelection({ newTotal: this.finalTotalLineItem, newLineItems: this.lineItems, @@ -297,20 +304,14 @@ export class ApplePay extends Emitter { onPaymentAuthorized (event) { debug('Payment authorization received', event); - let data = {}; - - if (this.config.form) { - data = normalize(this.config.form, FIELDS, { parseCard: false }).values; - } - this.emit('paymentAuthorized', event); - this.mapPaymentData(data, event.payment); - - return this.token(event, data); + return this.token(event); } - token (event, data) { + token (event) { + const data = this.mapPaymentData(event); + this.recurly.request.post({ route: '/apple_pay/token', data, @@ -356,30 +357,23 @@ export class ApplePay extends Emitter { return err; } - - /** - * Maps data from the Apple Pay token into the inputs - * object that is sent to RA for tokenization + /* + * Maps data from the Apple Pay token into the token input + * object that is sent to RA for toenization * * @private */ - mapPaymentData (inputs, data) { - inputs.paymentData = data.token.paymentData; - inputs.paymentMethod = data.token.paymentMethod; - - if (!data.billingContact) return; - if (Object.keys(APPLE_PAY_ADDRESS_MAP).some(field => inputs[field])) return; - - FIELDS.forEach(field => { - if (!APPLE_PAY_ADDRESS_MAP[field]) return; - - let tokenData = data.billingContact[APPLE_PAY_ADDRESS_MAP[field]]; - - // address lines are an array from Apple Pay - if (field === 'address1') tokenData = tokenData[0]; - else if (field === 'address2') tokenData = tokenData[1]; - - inputs[field] = tokenData; - }); + mapPaymentData (event) { + const { + billingContact, + token: { paymentData, paymentMethod }, + } = event.payment; + + return { + paymentData, + paymentMethod, + ...(this.tokenData && this.tokenData), + ...transformAddress(billingContact, { to: 'address', except: ['emailAddress'] }), + }; } } diff --git a/lib/recurly/apple-pay/util/transform-address.js b/lib/recurly/apple-pay/util/transform-address.js new file mode 100644 index 000000000..59bd69a0f --- /dev/null +++ b/lib/recurly/apple-pay/util/transform-address.js @@ -0,0 +1,75 @@ +import isEmpty from 'lodash.isempty'; +import { normalize } from '../../../util/normalize'; +import { ADDRESS_FIELDS, NON_ADDRESS_FIELDS } from '../../token'; + +const BILLING_CONTACT_MAP = { + first_name: 'givenName', + last_name: 'familyName', + address1: { field: 'addressLines', index: 0 }, + address2: { field: 'addressLines', index: 1 }, + city: 'locality', + state: 'administrativeArea', + postal_code: 'postalCode', + country: 'countryCode', +}; + +const SHIPPING_CONTACT_MAP = { + email: 'emailAddress', + phone: 'phoneNumber', +}; + +const CONTACT_MAP = { + ...BILLING_CONTACT_MAP, + ...SHIPPING_CONTACT_MAP, +}; + +/** + * Transforms a source address type to another address type. + * @param {Object} source either an Address or ApplePayPaymentContact + * @param {Object} options transform options + * @param {string} options.to either 'contact' or 'address' + * @param {string} options.except properties to exclude + * @return {Object} the transform result + */ +export function transformAddress (source, { to = 'contact', except = [], map = CONTACT_MAP }) { + if (isEmpty(source)) return {}; + + const target = {}; + + for (const [addressField, contactField] of Object.entries(map)) { + const sourceField = to === 'contact' ? addressField : contactField; + const targetField = to === 'address' ? addressField : contactField; + + if (except.includes(sourceField)) continue; + + const sourceValue = typeof sourceField === 'object' + ? source[sourceField.field]?.[sourceField.index] + : source[sourceField]; + if (!sourceValue) continue; + + if (typeof targetField === 'object') { + const { field, index } = targetField; + target[field] = target[field] || []; + target[field][index] = sourceValue; + } else { + target[targetField] = sourceValue; + } + } + + return target; +} + +export function normalizeForm (form) { + if (!form) return {}; + + const address = normalize(form, ADDRESS_FIELDS, { parseCard: false }).values; + const billingContact = transformAddress(address, { map: BILLING_CONTACT_MAP }); + const shippingContact = transformAddress(address, { map: SHIPPING_CONTACT_MAP }); + const tokenData = normalize(form, NON_ADDRESS_FIELDS, { parseCard: false }).values; + + return { + billingContact, + shippingContact, + tokenData, + }; +} diff --git a/lib/recurly/token.js b/lib/recurly/token.js index 58622c775..c616455d2 100644 --- a/lib/recurly/token.js +++ b/lib/recurly/token.js @@ -8,14 +8,7 @@ import { validateCardInputs } from './validate'; const debug = require('debug')('recurly:token'); -/** - * Fields that are sent to API. - * - * @type {Array} - * @private - */ - -export const FIELDS = [ +export const ADDRESS_FIELDS = [ 'first_name', 'last_name', 'address1', @@ -26,11 +19,26 @@ export const FIELDS = [ 'state', 'postal_code', 'phone', +]; + +export const NON_ADDRESS_FIELDS = [ 'vat_number', 'tax_identifier', 'tax_identifier_type', 'fraud_session_id', - 'token' + 'token', +]; + +/** + * Fields that are sent to API. + * + * @type {Array} + * @private + */ + +export const FIELDS = [ + ...ADDRESS_FIELDS, + ...NON_ADDRESS_FIELDS, ]; /** diff --git a/test/types/apple-pay.ts b/test/types/apple-pay.ts index 8d288d7a4..18d4da3e3 100644 --- a/test/types/apple-pay.ts +++ b/test/types/apple-pay.ts @@ -13,6 +13,19 @@ export default function applePay() { total: { label: 'My Subscription', amount: '29.00' }, lineItems: [{ label: 'Subtotal', amount: '1.00' }], requiredShippingContactFields: ['email', 'phone'], + billingContact: { + givenName: 'Emmet', + familyName: 'Brown', + addressLines: ['1640 Riverside Drive', 'Suite 1'], + locality: 'Hill Valley', + administrativeArea: 'CA', + postalCode: '91103', + countryCode: 'US' + }, + shippingContact: { + phoneNumber: '1231231234', + emailAddress: 'ebrown@example.com' + }, pricing: window.recurly.Pricing.Checkout() }); diff --git a/test/unit/apple-pay.test.js b/test/unit/apple-pay.test.js index 44fe80656..48257efbe 100644 --- a/test/unit/apple-pay.test.js +++ b/test/unit/apple-pay.test.js @@ -481,6 +481,112 @@ function applePayTest (integrationType, requestMethod) { }); }); + describe('billingContact', function () { + const billingContact = { + givenName: 'Emmet', + familyName: 'Brown', + addressLines: ['1640 Riverside Drive', 'Suite 1'], + locality: 'Hill Valley', + administrativeArea: 'CA', + postalCode: '91103', + countryCode: 'US' + }; + + const billingAddress = { + first_name: billingContact.givenName, + last_name: billingContact.familyName, + address1: billingContact.addressLines[0], + address2: billingContact.addressLines[1], + city: billingContact.locality, + state: billingContact.administrativeArea, + postal_code: billingContact.postalCode, + country: billingContact.countryCode, + }; + + it('populates with the form address fields when available', function (done) { + const applePay = this.recurly.ApplePay(merge({}, validOpts, { form: billingAddress })); + applePay.ready(() => { + assert.deepEqual(applePay.session.billingContact, billingContact); + assert.deepEqual(applePay.session.shippingContact, {}); + done(); + }); + }); + + it('allows an override', function (done) { + const applePay = this.recurly.ApplePay(merge({}, validOpts, { billingContact })); + applePay.ready(() => { + assert.deepEqual(applePay.session.billingContact, billingContact); + assert.deepEqual(applePay.session.shippingContact, {}); + done(); + }); + }); + + it('prefers the configuration if provided and the form is populated', function (done) { + const form = { + first_name: 'Bobby', + last_name: 'Brown', + city: 'Mill Valley', + }; + + const applePay = this.recurly.ApplePay(merge({}, validOpts, { form, billingContact })); + applePay.ready(() => { + assert.deepEqual(applePay.session.billingContact, billingContact); + assert.deepEqual(applePay.session.shippingContact, {}); + done(); + }); + }); + + it('omits if there is no form or override', function (done) { + const applePay = this.recurly.ApplePay(validOpts); + applePay.ready(() => { + assert.deepEqual(applePay.session.billingContact, {}); + done(); + }); + }); + }); + + describe('shippingContact', function () { + const shippingContact = { phoneNumber: '5555555555', }; + const shippingAddress = { phone: '5555555555', }; + + it('populates with the form address fields when available', function (done) { + const applePay = this.recurly.ApplePay(merge({}, validOpts, { form: shippingAddress })); + applePay.ready(() => { + assert.deepEqual(applePay.session.shippingContact, shippingContact); + assert.deepEqual(applePay.session.billingContact, {}); + done(); + }); + }); + + it('allows an override', function (done) { + const applePay = this.recurly.ApplePay(merge({}, validOpts, { shippingContact })); + applePay.ready(() => { + assert.deepEqual(applePay.session.shippingContact, shippingContact); + done(); + }); + }); + + it('prefers the configuration if provided and the form is populated', function (done) { + const form = { + phone: '3333333333', + }; + + const applePay = this.recurly.ApplePay(merge({}, validOpts, { form, shippingContact })); + applePay.ready(() => { + assert.deepEqual(applePay.session.shippingContact, shippingContact); + done(); + }); + }); + + it('omits if there is no form or override', function (done) { + const applePay = this.recurly.ApplePay(validOpts); + applePay.ready(() => { + assert.deepEqual(applePay.session.shippingContact, {}); + done(); + }); + }); + }); + it('emits ready when done', function (done) { this.recurly.ApplePay(validOpts).on('ready', done); }); @@ -611,105 +717,6 @@ function applePayTest (integrationType, requestMethod) { }); }); - describe('mapPaymentData', function () { - let applePayData = { - token: { - paymentData: 'apple pay token', - paymentMethod: 'card info' - }, - billingContact: { - givenName: 'Emmet', - familyName: 'Brown', - addressLines: ['1640 Riverside Drive', 'Suite 1'], - locality: 'Hill Valley', - administrativeArea: 'CA', - postalCode: '91103', - countryCode: 'us' - } - }; - let inputsDefault = { - first_name: '', - last_name: '', - address1: '', - address2: '', - city: '', - state: '', - postal_code: '', - country: '', - tax_identifier: '', - tax_identifier_type: '', - }; - const inputAddressFields = { - first_name: 'Marty', - last_name: 'McFly', - address1: 'Av 1', - address2: 'Av 2', - city: 'Versalles', - state: 'Paris', - postal_code: '123', - country: 'fr', - }; - const inputNotAddressFields = { - tax_identifier: 'tax123', - tax_identifier_type: 'cpf', - }; - - it('maps the apple pay token and address info into the inputs', function () { - let applePay = this.recurly.ApplePay(); - let data = clone(applePayData); - let inputs = clone(inputsDefault); - applePay.mapPaymentData(inputs, data); - assert.equal('apple pay token', inputs.paymentData); - assert.equal('card info', inputs.paymentMethod); - assert.equal('Emmet', inputs.first_name); - assert.equal('Brown', inputs.last_name); - assert.equal('1640 Riverside Drive', inputs.address1); - assert.equal('Suite 1', inputs.address2); - assert.equal('Hill Valley', inputs.city); - assert.equal('CA', inputs.state); - assert.equal('91103', inputs.postal_code); - assert.equal('us', inputs.country); - assert.equal('', inputs.tax_identifier); - assert.equal('', inputs.tax_identifier_type); - }); - - it('prioritizes existing input data from the payment form when contains any address info', function () { - const applePay = this.recurly.ApplePay(); - const data = clone(applePayData); - const addressFields = Object.keys(inputAddressFields); - - addressFields.forEach((key) => { - const inputs = clone(inputsDefault); - inputs[key] = inputAddressFields[key]; - applePay.mapPaymentData(inputs, data); - - assert.equal('apple pay token', inputs.paymentData); - assert.equal('card info', inputs.paymentMethod); - addressFields.forEach(k => assert.equal(k === key ? inputAddressFields[k] : '', inputs[k])); - }); - }); - - it('maps the apple pay data into the inputs when do not contains address info', function () { - const applePay = this.recurly.ApplePay(); - const data = clone(applePayData); - const inputs = clone(inputNotAddressFields); - applePay.mapPaymentData(inputs, data); - - assert.equal('apple pay token', inputs.paymentData); - assert.equal('card info', inputs.paymentMethod); - assert.equal('Emmet', inputs.first_name); - assert.equal('Brown', inputs.last_name); - assert.equal('1640 Riverside Drive', inputs.address1); - assert.equal('Suite 1', inputs.address2); - assert.equal('Hill Valley', inputs.city); - assert.equal('CA', inputs.state); - assert.equal('91103', inputs.postal_code); - assert.equal('us', inputs.country); - assert.equal('tax123', inputs.tax_identifier); - assert.equal('cpf', inputs.tax_identifier_type); - }); - }); - describe('internal event handlers', function () { beforeEach(function (done) { this.applePay = this.recurly.ApplePay(validOpts); @@ -835,11 +842,38 @@ function applePayTest (integrationType, requestMethod) { }); describe('onPaymentAuthorized', function () { + const billingContact = { + givenName: 'Emmet', + familyName: 'Brown', + addressLines: ['1640 Riverside Drive', 'Suite 1'], + locality: 'Hill Valley', + administrativeArea: 'CA', + postalCode: '91103', + countryCode: 'US', + }; + + const billingAddress = { + first_name: billingContact.givenName, + last_name: billingContact.familyName, + address1: billingContact.addressLines[0], + address2: billingContact.addressLines[1], + city: billingContact.locality, + state: billingContact.administrativeArea, + postal_code: billingContact.postalCode, + country: billingContact.countryCode, + }; + + const inputNotAddressFields = { + tax_identifier: 'tax123', + tax_identifier_type: 'cpf', + }; + const validAuthorizeEvent = { payment: { + billingContact: billingContact, token: { paymentData: 'valid-payment-data', - paymentMethod: 'valid-payment-method' + paymentMethod: 'valid-payment-method', } } }; @@ -879,6 +913,25 @@ function applePayTest (integrationType, requestMethod) { assert.deepEqual(args.data, { paymentData: 'valid-payment-data', paymentMethod: 'valid-payment-method', + ...billingAddress, + }); + done(); + }); + }); + + it('passes the non address parameters to create the token', function (done) { + this.spyTokenRequest = this.sandbox.spy(this.recurly.request, 'post'); + this.applePay.config.form = clone(inputNotAddressFields); + this.applePay.begin(); // the form has changed! + + this.applePay.session.onpaymentauthorized(clone(validAuthorizeEvent)); + this.applePay.on('token', () => { + const args = this.spyTokenRequest.getCall(0).args[0]; + assert.deepEqual(args.data, { + paymentData: 'valid-payment-data', + paymentMethod: 'valid-payment-method', + ...inputNotAddressFields, + ...billingAddress, }); done(); }); @@ -900,6 +953,7 @@ function applePayTest (integrationType, requestMethod) { applePayPayment: { paymentData: 'valid-payment-data', paymentMethod: 'valid-payment-method', + ...billingAddress, }, } }); @@ -908,7 +962,7 @@ function applePayTest (integrationType, requestMethod) { }); } - describe('when payment data is invalid', function (done) { + describe('when payment data is invalid', function () { const invalidAuthorizeEvent = { payment: { token: { diff --git a/types/lib/apple-pay/index.d.ts b/types/lib/apple-pay/index.d.ts index 0a562346f..372d9b418 100644 --- a/types/lib/apple-pay/index.d.ts +++ b/types/lib/apple-pay/index.d.ts @@ -1,6 +1,6 @@ import { Emitter } from '../emitter'; import { CheckoutPricingInstance, CheckoutPricingPromise } from '../pricing/checkout'; -import { ApplePayPaymentRequest } from './native'; +import { ApplePayPaymentRequest, ApplePayLineItem } from './native'; export type ApplePayConfig = { /** @@ -21,7 +21,7 @@ export type ApplePayConfig = { /** * Total cost to display in the Apple Pay payment sheet. Required if `options.pricing` is not provided. */ - total?: string; + total?: string | ApplePayLineItem; /** * If provided, will override `options.total` and provide the current total price on the CheckoutPricing instance diff --git a/types/lib/apple-pay/native.d.ts b/types/lib/apple-pay/native.d.ts index 7385c3c05..8c2640693 100644 --- a/types/lib/apple-pay/native.d.ts +++ b/types/lib/apple-pay/native.d.ts @@ -22,6 +22,48 @@ export type ApplePayContactField = | 'postalAddress' | 'phoneticName'; +/** + * Contact information fields to use for billing and shipping contact information. + */ +export type ApplePayPaymentContact = { + /** + * A phone number for the contact. + */ + phoneNumber?: string; + /** + * An email address for the contact. + */ + emailAddress?: string; + /** + * The contact’s given (first) name. + */ + givenName?: string; + /** + * The contact’s family (last) name. + */ + familyName?: string; + /** + * The street portion of the address for the contact. + */ + addressLines?: string[]; + /** + * The city for the contact. + */ + locality?: string; + /** + * The zip code or postal code for the contact. + */ + postalCode?: string; + /** + * The state for the contact. + */ + administrativeArea?: string; + /** + * The contact’s two-letter ISO 3166 country code. + */ + countryCode?: string; +}; + export type ApplePayLineItem = { /** * A required value that’s a short, localized description of the line item. @@ -65,11 +107,21 @@ export type ApplePayPaymentRequest = { */ total: ApplePayLineItem; + /** + * Billing contact information for the user. + */ + billingContact: ApplePayPaymentContact; + /** * The fields of shipping information the user must provide to fulfill the order. */ requiredShippingContactFields?: ApplePayContactField[]; + /** + * Shipping contact information for the user. + */ + shippingContact: ApplePayPaymentContact; + /** * A set of line items that explain recurring payments and additional charges and discounts. */