Skip to content

Commit

Permalink
feat(apple pay): populate billingContact with form address fields
Browse files Browse the repository at this point in the history
Apple Pay on the Web version 10 added support for supplying a
`billingContact` when creating the session. If provided, it would
populate over the customer's default billing contact on the payment
sheet.

If the `form` option is supplied, use the address fields that are
populated to populate the `billingContact`.

This also cleans up the interaction between the `form` and the payment
card when the client is not on version 10. Currently, if any of the
fields on the `form` are present, we prefer that address instead of what
is presented and modified on the payment card. This changes that
functionality to always prefer the `billingContact` from the payment
card when tokenizing the card as that is what the customer sees when
authorizing the payment.
  • Loading branch information
cbarton committed Mar 27, 2023
1 parent 266d931 commit 8e00517
Show file tree
Hide file tree
Showing 8 changed files with 302 additions and 165 deletions.
25 changes: 16 additions & 9 deletions lib/recurly/apple-pay/apple-pay.braintree.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
83 changes: 39 additions & 44 deletions lib/recurly/apple-pay/apple-pay.js
Original file line number Diff line number Diff line change
@@ -1,25 +1,16 @@
import Emitter from 'component-emitter';
import errors from '../errors';
import { Pricing } from '../pricing';
import { FIELDS } from '../token';
import { ADDRESS_FIELDS, NON_ADDRESS_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 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',
Expand Down Expand Up @@ -75,7 +66,15 @@ export class ApplePay extends Emitter {

debug('Creating new Apple Pay session', this._paymentRequest);

const session = new window.ApplePaySession(MINIMUM_SUPPORTED_VERSION, this._paymentRequest);
const { billingContact } = this._paymentRequest;
const paymentRequest = {
...(!billingContact && this.config.form && {
billingContact: transformAddress(normalizeForm(this.config.form), { to: 'contact' }),
}),
...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);
Expand Down Expand Up @@ -247,6 +246,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,
Expand Down Expand Up @@ -297,20 +298,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,
Expand Down Expand Up @@ -356,30 +351,30 @@ 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]];
mapPaymentData (event) {
const formFields = normalizeForm(this.config.form, NON_ADDRESS_FIELDS);
const {
billingContact,
token: { paymentData, paymentMethod },
} = event.payment;

return {
paymentData,
paymentMethod,
...formFields,
...transformAddress(billingContact, { to: 'address', except: ['emailAddress'] }),
};
}
}

// address lines are an array from Apple Pay
if (field === 'address1') tokenData = tokenData[0];
else if (field === 'address2') tokenData = tokenData[1];
function normalizeForm (form, fields = ADDRESS_FIELDS) {
if (!form) return {};

inputs[field] = tokenData;
});
}
return normalize(form, fields, { parseCard: false }).values;
}
54 changes: 54 additions & 0 deletions lib/recurly/apple-pay/util/transform-address.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import isEmpty from 'lodash.isempty';

const ADDRESS_MAP = {
address1: { field: 'addressLines', index: 0 },
address2: { field: 'addressLines', index: 1 },
city: 'locality',
state: 'administrativeArea',
postal_code: 'postalCode',
country: 'countryCode',
};

const CONTACT_MAP = {
email: 'emailAddress',
phone: 'phoneNumber',
first_name: 'givenName',
last_name: 'familyName',
...ADDRESS_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 default function transformAddress (source, { to = 'address', except = [] }) {
if (isEmpty(source)) return {};

const target = {};

for (const [addressField, contactField] of Object.entries(CONTACT_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;
}
26 changes: 17 additions & 9 deletions lib/recurly/token.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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,
];

/**
Expand Down
13 changes: 13 additions & 0 deletions test/types/apple-pay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
});

Expand Down
Loading

0 comments on commit 8e00517

Please sign in to comment.