From 196d207cf2eb842d604914fcdae3ae73505a02de Mon Sep 17 00:00:00 2001 From: Chris Barton Date: Fri, 31 Mar 2023 07:44:56 -0700 Subject: [PATCH 1/2] feat(apple pay): add support for `recurringPaymentRequest` Can be specified as the option or will be generated if the `total` line item is set to `paymentTiming='recurring'`: ```typescript const total: ApplePayLineItem = { label: 'My Subscription', paymentTiming: 'recurring', amount: '29.00', recurringPaymentIntervalUnit: 'month', recurringPaymentIntervalCount: 1, recurringPaymentStartDate: new Date(), }; const applePay = recurly.ApplePay({ total, recurringPaymentRequest: { paymentDescription: 'A recurring subscription', regularBilling: total, billingAgreement: 'Will recur forever', } }); ``` --- lib/recurly/apple-pay/apple-pay.js | 11 ++ .../util/build-apple-pay-payment-request.js | 17 +- test/server/fixtures/apple_pay/info.json | 3 +- test/types/apple-pay.ts | 18 +- test/unit/apple-pay.test.js | 184 +++++++++++++++--- types/lib/apple-pay/native.d.ts | 39 ++++ 6 files changed, 240 insertions(+), 32 deletions(-) diff --git a/lib/recurly/apple-pay/apple-pay.js b/lib/recurly/apple-pay/apple-pay.js index ff997096d..f449fcbdc 100644 --- a/lib/recurly/apple-pay/apple-pay.js +++ b/lib/recurly/apple-pay/apple-pay.js @@ -79,6 +79,14 @@ export class ApplePay extends Emitter { return this._session = session; } + /** + * @return {Object} recurring payment request for display on payment sheet + * @private + */ + get recurringPaymentRequest () { + return this._paymentRequest?.recurringPaymentRequest; + } + /** * @return {Array} subtotal line items for display on payment sheet * @private @@ -259,6 +267,7 @@ export class ApplePay extends Emitter { this.session.completePaymentMethodSelection({ newTotal: this.finalTotalLineItem, newLineItems: this.lineItems, + ...(this.recurringPaymentRequest && { newRecurringPaymentRequest: this.recurringPaymentRequest }), }); }); } @@ -277,6 +286,7 @@ export class ApplePay extends Emitter { newTotal: this.finalTotalLineItem, newLineItems: this.lineItems, newShippingMethods: [], + ...(this.recurringPaymentRequest && { newRecurringPaymentRequest: this.recurringPaymentRequest }), }); }); } @@ -294,6 +304,7 @@ export class ApplePay extends Emitter { newTotal: this.finalTotalLineItem, newLineItems: this.lineItems, newShippingMethods: [], + ...(this.recurringPaymentRequest && { newRecurringPaymentRequest: this.recurringPaymentRequest }), }); } diff --git a/lib/recurly/apple-pay/util/build-apple-pay-payment-request.js b/lib/recurly/apple-pay/util/build-apple-pay-payment-request.js index 15b7c6050..df5f5579e 100644 --- a/lib/recurly/apple-pay/util/build-apple-pay-payment-request.js +++ b/lib/recurly/apple-pay/util/build-apple-pay-payment-request.js @@ -8,11 +8,17 @@ const REQUIRED_SHIPPING_FIELDS_VERSION = 6; function buildOrder (config, options, paymentRequest) { if (!options.total) return errors('apple-pay-config-missing', { opt: 'total' }); - const { total, lineItems = [] } = options; + const { total, lineItems = [], recurringPaymentRequest } = options; ['total', 'lineItems'].forEach(k => delete options[k]); if (isLineItem(total)) { paymentRequest.total = total; + if (!recurringPaymentRequest && total.paymentTiming === 'recurring') { + paymentRequest.recurringPaymentRequest = { + paymentDescription: total.label, + regularBilling: total, + }; + } } else if (typeof total === 'object') { const missing = (total.label === undefined) ? 'label' : 'amount'; return errors('apple-pay-config-missing', { opt: `total.${missing}` }); @@ -103,6 +109,15 @@ function applyRemoteConfig (paymentRequest, cb) { : info.supportedNetworks; paymentRequest.supportedNetworks = filterSupportedNetworks(supportedNetworks); + const { recurringPaymentRequest } = paymentRequest; + if (recurringPaymentRequest) { + if (!recurringPaymentRequest.managementURL && !info.managementURL) { + return cb(errors('apple-pay-config-invalid', { opt: 'recurringPaymentRequest.managementURL' })); + } else if (!recurringPaymentRequest.managementURL) { + recurringPaymentRequest.managementURL = info.managementURL; + } + } + cb(null, paymentRequest); }; } diff --git a/test/server/fixtures/apple_pay/info.json b/test/server/fixtures/apple_pay/info.json index e7d0c258f..667ff5688 100644 --- a/test/server/fixtures/apple_pay/info.json +++ b/test/server/fixtures/apple_pay/info.json @@ -3,5 +3,6 @@ "countries": ["US", "CA"], "currencies": ["USD", "CAD"], "merchantCapabilities": ["supportsCredit", "supports3DS", "supportsDebit"], - "supportedNetworks": ["visa", "amex"] + "supportedNetworks": ["visa", "amex"], + "managementURL": "https://example.com/account" } diff --git a/test/types/apple-pay.ts b/test/types/apple-pay.ts index 18d4da3e3..45c6560a5 100644 --- a/test/types/apple-pay.ts +++ b/test/types/apple-pay.ts @@ -1,3 +1,5 @@ +import { ApplePayLineItem } from 'lib/apple-pay/native'; + export default function applePay() { const applePayDeprecated = recurly.ApplePay({ country: 'US', @@ -7,10 +9,19 @@ export default function applePay() { pricing: window.recurly.Pricing.Checkout() }); + const total: ApplePayLineItem = { + label: 'My Subscription', + paymentTiming: 'recurring', + amount: '29.00', + recurringPaymentIntervalUnit: 'month', + recurringPaymentIntervalCount: 1, + recurringPaymentStartDate: new Date(), + }; + const applePay = recurly.ApplePay({ country: 'US', currency: 'USD', - total: { label: 'My Subscription', amount: '29.00' }, + total, lineItems: [{ label: 'Subtotal', amount: '1.00' }], requiredShippingContactFields: ['email', 'phone'], billingContact: { @@ -26,6 +37,11 @@ export default function applePay() { phoneNumber: '1231231234', emailAddress: 'ebrown@example.com' }, + recurringPaymentRequest: { + paymentDescription: 'A recurring subscription', + regularBilling: total, + billingAgreement: 'Will recur forever', + }, pricing: window.recurly.Pricing.Checkout() }); diff --git a/test/unit/apple-pay.test.js b/test/unit/apple-pay.test.js index 05370ba25..330838bfe 100644 --- a/test/unit/apple-pay.test.js +++ b/test/unit/apple-pay.test.js @@ -29,21 +29,24 @@ class ApplePaySessionStub extends Emitter { this.merchantSession = ms; this.emit('completeMerchantValidation'); } - completePaymentMethodSelection ({ newTotal: t, newLineItems: li }) { - this.total = t; - this.lineItems = li; + completePaymentMethodSelection ({ newTotal: t, newLineItems: li, newRecurringPaymentRequest: r }) { + this.newTotal = t; + this.newLineItems = li; + this.newRecurringPaymentRequest = r this.emit('completePaymentMethodSelection'); } - completeShippingContactSelection ({ newTotal: t, newLineItems: li, newShippingMethods: sm }) { - this.shippingMethods = sm; - this.total = t; - this.lineItems = li; + completeShippingContactSelection ({ newTotal: t, newLineItems: li, newRecurringPaymentRequest: r, newShippingMethods: sm }) { + this.newShippingMethods = sm; + this.newTotal = t; + this.newLineItems = li; + this.newRecurringPaymentRequest = r this.emit('completeShippingContactSelection'); } - completeShippingMethodSelection ({ newTotal: t, newLineItems: li, newShippingMethods: sm }) { - this.shippingMethods = sm; - this.total = t; - this.lineItems = li; + completeShippingMethodSelection ({ newTotal: t, newLineItems: li, newRecurringPaymentRequest: r, newShippingMethods: sm }) { + this.newShippingMethods = sm; + this.newTotal = t; + this.newLineItems = li; + this.newRecurringPaymentRequest = r this.emit('completeShippingMethodSelection'); } completePayment ({ status }) { @@ -239,6 +242,7 @@ function applePayTest (integrationType, requestMethod) { let applePay = this.recurly.ApplePay(options); applePay.ready(ensureDone(done, () => { assert.equal(applePay.session.total, options.total); + assert.equal(applePay.session.recurringPaymentRequest, undefined); })); }); @@ -423,6 +427,59 @@ function applePayTest (integrationType, requestMethod) { }); }); + describe('recurringPaymentRequest', function () { + it('is configured when the options.total is a recurring line item', function (done) { + const applePay = this.recurly.ApplePay(merge({}, validOpts, { + total: { label: 'Apple Pay testing', amount: '3.00', paymentTiming: 'recurring' }, + })); + + applePay.ready(() => { + assert.deepEqual(applePay.session.recurringPaymentRequest, { + paymentDescription: applePay.session.total.label, + regularBilling: applePay.session.total, + managementURL: infoFixture.managementURL, + }); + done(); + }); + }); + + describe('when options.recurringPaymentRequest is provided', function () { + const recurringPaymentRequest = { + paymentDescription: 'Recurring Test', + regularBilling: { label: 'Total', amount: '3.00', paymentTiming: 'recurring' }, + }; + + it('uses it as the recurringPaymentRequest', function (done) { + const applePay = this.recurly.ApplePay(merge({}, validOpts, { + recurringPaymentRequest: { + ...recurringPaymentRequest, + managementURL: 'https://example.com', + }, + })); + + applePay.ready(() => { + assert.deepEqual(applePay.session.recurringPaymentRequest, { + ...recurringPaymentRequest, + managementURL: 'https://example.com', + }); + done(); + }); + }); + + it('uses the managementURL from the server', function (done) { + const applePay = this.recurly.ApplePay(merge({}, validOpts, { recurringPaymentRequest, })); + + applePay.ready(() => { + assert.deepEqual(applePay.session.recurringPaymentRequest, { + ...recurringPaymentRequest, + managementURL: infoFixture.managementURL, + }); + done(); + }); + }); + }); + }); + it('sets other ApplePayPaymentRequest options and does not include configuration options', function (done) { const applePay = this.recurly.ApplePay(merge({}, validOpts, { requiredShippingContactFields: ['email'], @@ -824,12 +881,35 @@ function applePayTest (integrationType, requestMethod) { describe('onPaymentMethodSelected', function () { it('calls ApplePaySession.completePaymentSelection with a total and line items', function (done) { this.applePay.session.on('completePaymentMethodSelection', ensureDone(done, () => { - assert.deepEqual(this.applePay.session.total, this.applePay.finalTotalLineItem); - assert.deepEqual(this.applePay.session.lineItems, this.applePay.lineItems); + assert.deepEqual(this.applePay.session.newTotal, this.applePay.finalTotalLineItem); + assert.deepEqual(this.applePay.session.newLineItems, this.applePay.lineItems); + assert.equal(this.applePay.session.newRecurringPaymentRequest, undefined); })); this.applePay.session.onpaymentmethodselected({ paymentMethod: { billingContact: { postalCode: '94114' } } }); }); + describe('with options.recurringPaymentRequest set', function () { + beforeEach(function (done) { + this.applePay = this.recurly.ApplePay(merge({}, validOpts, { + total: { label: 'Total', amount: '3.00', paymentTiming: 'recurring' } + })); + + this.applePay.ready(() => { + this.applePay.begin(); + done(); + }); + }); + + it('includes the newRecurringPaymentRequest', function (done) { + this.applePay.session.on('completePaymentMethodSelection', () => { + assert.notEqual(this.applePay.session.newRecurringPaymentRequest, undefined); + assert.deepEqual(this.applePay.session.newRecurringPaymentRequest, this.applePay.recurringPaymentRequest); + done(); + }); + this.applePay.session.onpaymentmethodselected({ paymentMethod: { billingContact: { postalCode: '94114' } } }); + }); + }); + describe('with options.pricing set', function () { beforeEach(function (done) { this.pricing = this.recurly.Pricing.Checkout(); @@ -843,10 +923,10 @@ function applePayTest (integrationType, requestMethod) { const spy = this.sandbox.spy(this.pricing, 'reprice'); this.applePay.session.on('completePaymentMethodSelection', ensureDone(done, () => { assert.deepEqual(this.pricing.items.address, { postal_code: '94110', country: 'US' }); - assert.deepEqual(this.applePay.session.total, this.applePay.finalTotalLineItem); - assert.deepEqual(this.applePay.session.lineItems, this.applePay.lineItems); - assert.equal(this.applePay.session.lineItems[1].label, 'Tax'); - assert.equal(this.applePay.session.lineItems[1].amount, this.pricing.price.now.taxes); + assert.deepEqual(this.applePay.session.newTotal, this.applePay.finalTotalLineItem); + assert.deepEqual(this.applePay.session.newLineItems, this.applePay.lineItems); + assert.equal(this.applePay.session.newLineItems[1].label, 'Tax'); + assert.equal(this.applePay.session.newLineItems[1].amount, this.pricing.price.now.taxes); assert(spy.called, 'should have repriced'); })); @@ -860,10 +940,11 @@ function applePayTest (integrationType, requestMethod) { describe('onShippingContactSelected', function () { it('calls ApplePaySession.completeShippingContactSelection with empty methods, a total, and line items', function (done) { this.applePay.session.on('completeShippingContactSelection', ensureDone(done, () => { - assert(Array.isArray(this.applePay.session.shippingMethods)); - assert.equal(this.applePay.session.shippingMethods.length, 0); - assert.deepEqual(this.applePay.session.total, this.applePay.finalTotalLineItem); - assert.deepEqual(this.applePay.session.lineItems, this.applePay.lineItems); + assert(Array.isArray(this.applePay.session.newShippingMethods)); + assert.equal(this.applePay.session.newShippingMethods.length, 0); + assert.deepEqual(this.applePay.session.newTotal, this.applePay.finalTotalLineItem); + assert.deepEqual(this.applePay.session.newLineItems, this.applePay.lineItems); + assert.equal(this.applePay.session.newRecurringPaymentRequest, undefined); })); this.applePay.session.onshippingcontactselected({}); }); @@ -876,6 +957,28 @@ function applePayTest (integrationType, requestMethod) { this.applePay.session.onshippingcontactselected(example); }); + describe('with options.recurringPaymentRequest set', function () { + beforeEach(function (done) { + this.applePay = this.recurly.ApplePay(merge({}, validOpts, { + total: { label: 'Total', amount: '3.00', paymentTiming: 'recurring' } + })); + + this.applePay.ready(() => { + this.applePay.begin(); + done(); + }); + }); + + it('includes the newRecurringPaymentRequest', function (done) { + this.applePay.session.on('completeShippingContactSelection', () => { + assert.notEqual(this.applePay.session.newRecurringPaymentRequest, undefined); + assert.deepEqual(this.applePay.session.newRecurringPaymentRequest, this.applePay.recurringPaymentRequest); + done(); + }); + this.applePay.session.onshippingcontactselected({}); + }); + }); + describe('with options.pricing set', function () { beforeEach(function (done) { this.pricing = this.recurly.Pricing.Checkout(); @@ -890,10 +993,10 @@ function applePayTest (integrationType, requestMethod) { this.applePay.session.on('completeShippingContactSelection', ensureDone(done, () => { assert.deepEqual(this.pricing.items.shippingAddress, { postal_code: '94110', country: 'US' }); - assert.deepEqual(this.applePay.session.total, this.applePay.finalTotalLineItem); - assert.deepEqual(this.applePay.session.lineItems, this.applePay.lineItems); - assert.equal(this.applePay.session.lineItems[1].label, 'Tax'); - assert.equal(this.applePay.session.lineItems[1].amount, this.pricing.price.now.taxes); + assert.deepEqual(this.applePay.session.newTotal, this.applePay.finalTotalLineItem); + assert.deepEqual(this.applePay.session.newLineItems, this.applePay.lineItems); + assert.equal(this.applePay.session.newLineItems[1].label, 'Tax'); + assert.equal(this.applePay.session.newLineItems[1].amount, this.pricing.price.now.taxes); assert(spy.called, 'should have repriced'); })); @@ -907,10 +1010,11 @@ function applePayTest (integrationType, requestMethod) { describe('onShippingMethodSelected', function () { it('calls ApplePaySession.completeShippingMethodSelection with status, a total, and line items', function (done) { this.applePay.session.on('completeShippingMethodSelection', ensureDone(done, () => { - assert(Array.isArray(this.applePay.session.shippingMethods)); - assert.equal(this.applePay.session.shippingMethods.length, 0); - assert.deepEqual(this.applePay.session.total, this.applePay.finalTotalLineItem); - assert.deepEqual(this.applePay.session.lineItems, this.applePay.lineItems); + assert(Array.isArray(this.applePay.session.newShippingMethods)); + assert.equal(this.applePay.session.newShippingMethods.length, 0); + assert.deepEqual(this.applePay.session.newTotal, this.applePay.finalTotalLineItem); + assert.deepEqual(this.applePay.session.newLineItems, this.applePay.lineItems); + assert.equal(this.applePay.session.newRecurringPaymentRequest, undefined); })); this.applePay.session.onshippingmethodselected(); }); @@ -922,6 +1026,28 @@ function applePayTest (integrationType, requestMethod) { })); this.applePay.session.onshippingmethodselected(example); }); + + describe('with options.recurringPaymentRequest set', function () { + beforeEach(function (done) { + this.applePay = this.recurly.ApplePay(merge({}, validOpts, { + total: { label: 'Total', amount: '3.00', paymentTiming: 'recurring' } + })); + + this.applePay.ready(() => { + this.applePay.begin(); + done(); + }); + }); + + it('includes the newRecurringPaymentRequest', function (done) { + this.applePay.session.on('completeShippingMethodSelection', () => { + assert.notEqual(this.applePay.session.newRecurringPaymentRequest, undefined); + assert.deepEqual(this.applePay.session.newRecurringPaymentRequest, this.applePay.recurringPaymentRequest); + done(); + }); + this.applePay.session.onshippingmethodselected({}); + }); + }); }); describe('onPaymentAuthorized', function () { diff --git a/types/lib/apple-pay/native.d.ts b/types/lib/apple-pay/native.d.ts index 5c157d34e..3b7ab15f7 100644 --- a/types/lib/apple-pay/native.d.ts +++ b/types/lib/apple-pay/native.d.ts @@ -101,6 +101,40 @@ export type ApplePayLineItem = { recurringPaymentEndDate?: Date; }; +/** + * A dictionary that represents a request to set up a subscription. + */ +export type ApplePayRecurringPaymentRequest = { + /** + * A description of the recurring payment that Apple Pay displays to the user in the payment sheet. + */ + paymentDescription: string; + + /** + * The regular billing cycle for the recurring payment, including start and end dates, an interval, and an interval count. + */ + regularBilling: ApplePayLineItem ; + + /** + * The trial billing cycle for the recurring payment. + */ + trialBilling?: ApplePayLineItem ; + + /** + * A localized billing agreement that the payment sheet displays to the user before the user authorizes the payment. + */ + billingAgreement?: string; + + /** + * A URL to a web page where the user can update or delete the payment method for the recurring payment. + * Defaults to the managment URL set in the Recurly Apple Pay configuration. + */ + managementURL?: string; +}; + +/** + * A request for payment, which includes information about payment-processing capabilities, the payment amount, and shipping information + */ export type ApplePayPaymentRequest = { /** * Total cost to display in the Apple Pay payment sheet. Required if `options.pricing` is not provided. @@ -132,4 +166,9 @@ export type ApplePayPaymentRequest = { * A set of line items that explain recurring payments and additional charges and discounts. */ lineItems?: ApplePayLineItem[]; + + /** + * A property that requests a subscription. + */ + recurringPaymentRequest?: ApplePayRecurringPaymentRequest; }; From f630b36cf6ef52f1c7e1cf6ee2cb214984d20ad5 Mon Sep 17 00:00:00 2001 From: Chris Barton Date: Sat, 1 Apr 2023 08:50:34 -0700 Subject: [PATCH 2/2] feat(apple pay): Move payment request properties to `options.paymentRequest` Cleans up the interface for calling into `recurly.ApplePay` so that the properties that drift down to the resulting payment request are not intermixed with the SDK specific parameters. As a result, deprecates `options.requiredShippingContactFields` in favor of reading it off of the `paymentRequest.requiredShippingContactFields`. Adds `options.recurring` that, when provided in combination with `options.total`, will create a total that will include a monthly recurring charge on the payment sheet. `options.paymentRequest` will be treated as the source of truth, except for when `options.pricing` is provided. This maintains the stance that Pricing controls the `total` and `lineItems`. --- lib/recurly/apple-pay/apple-pay.js | 35 ++--- .../apple-pay/util/apple-pay-line-item.js | 17 +-- .../util/build-apple-pay-payment-request.js | 58 ++++----- test/types/apple-pay.ts | 15 ++- test/unit/apple-pay.test.js | 123 ++++++++---------- types/lib/apple-pay/index.d.ts | 62 +++++++-- 6 files changed, 157 insertions(+), 153 deletions(-) diff --git a/lib/recurly/apple-pay/apple-pay.js b/lib/recurly/apple-pay/apple-pay.js index f449fcbdc..c6ef66376 100644 --- a/lib/recurly/apple-pay/apple-pay.js +++ b/lib/recurly/apple-pay/apple-pay.js @@ -29,8 +29,10 @@ const I18N = { * @param {Recurly} options.recurly * @param {String} options.country * @param {String} options.currency - * @param {String|Object} [options.total] either in dollar format, '1.00', or an ApplePayLineItem object that represents the total for the payment. Optional and discarded if 'pricing' is supplied - * @param {String} [options.label] The short, localized description of the total charge. Deprecated, use 'i18n.totalLineItemLabel' if not using an ApplePayLineItem as the total + * @param {String|Object} [options.total] total for the payment in dollar format, eg: '1.00'. Optional and discarded if 'pricing' is supplied + * @param {String} [options.label] The short, localized description of the total charge. Deprecated, use 'i18n.totalLineItemLabel' + * @param {Boolean} [options.recurring] whether or not the total line item is recurring + * @param {Object} [options.paymentRequest] an ApplePayPaymentRequest * @param {HTMLElement} [options.form] to provide additional customer data * @param {Pricing} [options.pricing] to provide line items and total from Pricing * @param {Boolean} [options.enforceVersion] to ensure that the client supports the minimum version to support required fields @@ -92,10 +94,8 @@ export class ApplePay extends Emitter { * @private */ get lineItems () { - if (!this._ready) return []; - // Clone configured line items - return [].concat(this._paymentRequest.lineItems); + return this._paymentRequest?.lineItems ? [].concat(this._paymentRequest.lineItems) : []; } /** @@ -103,9 +103,7 @@ export class ApplePay extends Emitter { * @private */ get totalLineItem () { - if (!this._ready) return {}; - - return this._paymentRequest.total; + return this._paymentRequest?.total ? this._paymentRequest.total : {}; } /** @@ -135,30 +133,23 @@ export class ApplePay extends Emitter { * @private */ configure (options) { - if (options.recurly) { - this.recurly = options.recurly; - delete options.recurly; - } else return this.initError = this.error('apple-pay-factory-only'); - - if (options.form) { - this.config.form = options.form; - delete options.form; - } + if (options.recurly) this.recurly = options.recurly; + else return this.initError = this.error('apple-pay-factory-only'); + + if (options.form) this.config.form = options.form; + if (options.recurring) this.config.recurring = options.recurring; this.config.i18n = { ...I18N, ...(options.label && { totalLineItemLabel: options.label }), ...options.i18n, }; - delete options.label; - delete options.i18n; if (options.pricing instanceof PricingPromise) { this.config.pricing = options.pricing.pricing; } else if (options.pricing instanceof Pricing) { this.config.pricing = options.pricing; } - delete options.pricing; buildApplePayPaymentRequest(this, options, (err, paymentRequest) => { if (err) return this.initError = this.error(err); @@ -198,9 +189,9 @@ export class ApplePay extends Emitter { * @private */ onPricingChange () { - const { pricing } = this.config; + const { pricing, recurring } = this.config; - this._paymentRequest.total = lineItem(this.config.i18n.totalLineItemLabel, pricing.totalNow); + this._paymentRequest.total = lineItem(this.config.i18n.totalLineItemLabel, pricing.totalNow, { recurring }); this._paymentRequest.lineItems = []; if (!pricing.hasPrice) return; diff --git a/lib/recurly/apple-pay/util/apple-pay-line-item.js b/lib/recurly/apple-pay/util/apple-pay-line-item.js index 4e2a9fce9..f16c6afd7 100644 --- a/lib/recurly/apple-pay/util/apple-pay-line-item.js +++ b/lib/recurly/apple-pay/util/apple-pay-line-item.js @@ -6,15 +6,10 @@ import decimalize from '../../../util/decimalize'; * @param {Number} amount * @return {object} */ -export function lineItem (label = '', amount = 0) { - return { label, amount: decimalize(amount) }; -} - -/** - * Determine if the val is a valid ApplePayLineItem - * @param {object} val - * @return {boolean} - */ -export function isLineItem (val) { - return typeof val === 'object' && val.label !== undefined && val.amount !== undefined; +export function lineItem (label = '', amount = 0, { recurring = false } = {}) { + return { + label, + amount: decimalize(amount), + ...(recurring && { paymentTiming: 'recurring' }), + }; } diff --git a/lib/recurly/apple-pay/util/build-apple-pay-payment-request.js b/lib/recurly/apple-pay/util/build-apple-pay-payment-request.js index df5f5579e..21d97378b 100644 --- a/lib/recurly/apple-pay/util/build-apple-pay-payment-request.js +++ b/lib/recurly/apple-pay/util/build-apple-pay-payment-request.js @@ -1,32 +1,25 @@ import intersection from 'intersect'; import errors from '../../errors'; import filterSupportedNetworks from './filter-supported-networks'; -import { lineItem, isLineItem } from './apple-pay-line-item'; +import { lineItem } from './apple-pay-line-item'; const REQUIRED_SHIPPING_FIELDS_VERSION = 6; function buildOrder (config, options, paymentRequest) { - if (!options.total) return errors('apple-pay-config-missing', { opt: 'total' }); - - const { total, lineItems = [], recurringPaymentRequest } = options; - ['total', 'lineItems'].forEach(k => delete options[k]); - - if (isLineItem(total)) { - paymentRequest.total = total; - if (!recurringPaymentRequest && total.paymentTiming === 'recurring') { - paymentRequest.recurringPaymentRequest = { - paymentDescription: total.label, - regularBilling: total, - }; - } - } else if (typeof total === 'object') { - const missing = (total.label === undefined) ? 'label' : 'amount'; - return errors('apple-pay-config-missing', { opt: `total.${missing}` }); - } else { - paymentRequest.total = lineItem(config.i18n.totalLineItemLabel, total); + if (!paymentRequest.total && !options.total) return errors('apple-pay-config-missing', { opt: 'total' }); + + let { total: totalLineItem } = paymentRequest; + if (!totalLineItem) { + const { recurring, total } = options; + paymentRequest.total = totalLineItem = lineItem(config.i18n.totalLineItemLabel, total, { recurring }); } - paymentRequest.lineItems = lineItems; + if (!paymentRequest.recurringPaymentRequest && totalLineItem.paymentTiming === 'recurring') { + paymentRequest.recurringPaymentRequest = { + paymentDescription: totalLineItem.label, + regularBilling: totalLineItem, + }; + } } /** @@ -44,18 +37,17 @@ function buildOrder (config, options, paymentRequest) { */ export default function buildApplePayPaymentRequest (applePay, options, cb) { const { recurly, config } = applePay; - - const { currency: currencyCode, country: countryCode } = options; - if (!currencyCode) return cb(errors('apple-pay-config-missing', { opt: 'currency' })); - if (!countryCode) return cb(errors('apple-pay-config-missing', { opt: 'country' })); - ['currency', 'country'].forEach(k => delete options[k]); - - let paymentRequest = { - currencyCode, - countryCode, + const paymentRequest = { + currencyCode: options.currency, + countryCode: options.country, + requiredShippingContactFields: options.requiredShippingContactFields, // deprecated + ...options.paymentRequest, requiredBillingContactFields: ['postalAddress'], }; + if (!paymentRequest.currencyCode) return cb(errors('apple-pay-config-missing', { opt: 'currency' })); + if (!paymentRequest.countryCode) return cb(errors('apple-pay-config-missing', { opt: 'country' })); + // The order is handled by pricing if set if (!config.pricing) { const error = buildOrder(config, options, paymentRequest); @@ -63,14 +55,8 @@ export default function buildApplePayPaymentRequest (applePay, options, cb) { } if (options.enforceVersion && - options.requiredShippingContactFields && + paymentRequest.requiredShippingContactFields && !window.ApplePaySession.supportsVersion(REQUIRED_SHIPPING_FIELDS_VERSION)) return cb(errors('apple-pay-not-supported')); - delete options.enforceVersion; - - paymentRequest = { - ...options, - ...paymentRequest, - }; recurly.request.get({ route: '/apple_pay/info', diff --git a/test/types/apple-pay.ts b/test/types/apple-pay.ts index 45c6560a5..7fe05c486 100644 --- a/test/types/apple-pay.ts +++ b/test/types/apple-pay.ts @@ -1,7 +1,7 @@ -import { ApplePayLineItem } from 'lib/apple-pay/native'; +import { ApplePayPaymentRequest, ApplePayLineItem } from 'lib/apple-pay/native'; export default function applePay() { - const applePayDeprecated = recurly.ApplePay({ + const applePaySimple = recurly.ApplePay({ country: 'US', currency: 'USD', label: 'My Subscription', @@ -18,9 +18,7 @@ export default function applePay() { recurringPaymentStartDate: new Date(), }; - const applePay = recurly.ApplePay({ - country: 'US', - currency: 'USD', + const paymentRequest: ApplePayPaymentRequest = { total, lineItems: [{ label: 'Subtotal', amount: '1.00' }], requiredShippingContactFields: ['email', 'phone'], @@ -42,7 +40,12 @@ export default function applePay() { regularBilling: total, billingAgreement: 'Will recur forever', }, - pricing: window.recurly.Pricing.Checkout() + }; + + const applePay = recurly.ApplePay({ + country: 'US', + currency: 'USD', + paymentRequest, }); applePay.ready(() => {}); diff --git a/test/unit/apple-pay.test.js b/test/unit/apple-pay.test.js index 330838bfe..6a2dfcac1 100644 --- a/test/unit/apple-pay.test.js +++ b/test/unit/apple-pay.test.js @@ -236,24 +236,15 @@ function applePayTest (integrationType, requestMethod) { })); }); - it('uses options.total as the total line item', function (done) { + it('uses options.paymentRequest.total as the total line item', function (done) { let options = omit(validOpts, 'total'); - options.total = { label: 'Subscription', amount: '10.00' }; + options.paymentRequest = { total: { label: 'Subscription', amount: '10.00' }, }; let applePay = this.recurly.ApplePay(options); applePay.ready(ensureDone(done, () => { - assert.equal(applePay.session.total, options.total); + assert.equal(applePay.session.total, options.paymentRequest.total); assert.equal(applePay.session.recurringPaymentRequest, undefined); })); }); - - it('uses options.lineItems as the line items', function (done) { - let options = clone(validOpts); - options.lineItems = [{ label: 'Taxes', amount: '10.00' }, { label: 'Discount', amount: '-10.00' }]; - let applePay = this.recurly.ApplePay(options); - applePay.ready(ensureDone(done, () => { - assert.equal(applePay.session.lineItems, options.lineItems); - })); - }); }); describe('when given options.pricing', function () { @@ -403,7 +394,7 @@ function applePayTest (integrationType, requestMethod) { it('returns an initError if the browser version for requiredShippingContactFields is not met', function (done) { this.sandbox.stub(ApplePaySessionStub, 'version').value(4); let applePay = this.recurly.ApplePay(merge({}, validOpts, { - enforceVersion: true, requiredShippingContactFields: ['email'] + enforceVersion: true, paymentRequest: { requiredShippingContactFields: ['email'] }, })); applePay.on('error', (err) => { @@ -417,12 +408,11 @@ function applePayTest (integrationType, requestMethod) { it('sets requiredShippingContactFields if the browser version is met', function (done) { this.sandbox.stub(ApplePaySessionStub, 'version').value(14); let applePay = this.recurly.ApplePay(merge({}, validOpts, { - enforceVersion: true, requiredShippingContactFields: ['email'] + enforceVersion: true, paymentRequest: { requiredShippingContactFields: ['email'] }, })); applePay.ready(ensureDone(done, () => { assert.deepEqual(applePay.session.requiredShippingContactFields, ['email']); - assert.equal(applePay.session.enforceVersion, undefined); })); }); }); @@ -430,17 +420,28 @@ function applePayTest (integrationType, requestMethod) { describe('recurringPaymentRequest', function () { it('is configured when the options.total is a recurring line item', function (done) { const applePay = this.recurly.ApplePay(merge({}, validOpts, { - total: { label: 'Apple Pay testing', amount: '3.00', paymentTiming: 'recurring' }, + paymentRequest: { total: { label: 'Apple Pay testing', amount: '3.00', paymentTiming: 'recurring' }, }, })); - applePay.ready(() => { + applePay.ready(ensureDone(done, () => { assert.deepEqual(applePay.session.recurringPaymentRequest, { paymentDescription: applePay.session.total.label, regularBilling: applePay.session.total, managementURL: infoFixture.managementURL, }); - done(); - }); + })); + }); + + it('is configured when the options.recurring is set', function (done) { + const applePay = this.recurly.ApplePay(merge({}, validOpts, { total: '3.00', recurring: true, })); + + applePay.ready(ensureDone(done, () => { + assert.deepEqual(applePay.session.recurringPaymentRequest, { + paymentDescription: applePay.session.total.label, + regularBilling: applePay.session.total, + managementURL: infoFixture.managementURL, + }); + })); }); describe('when options.recurringPaymentRequest is provided', function () { @@ -451,39 +452,41 @@ function applePayTest (integrationType, requestMethod) { it('uses it as the recurringPaymentRequest', function (done) { const applePay = this.recurly.ApplePay(merge({}, validOpts, { - recurringPaymentRequest: { - ...recurringPaymentRequest, - managementURL: 'https://example.com', + paymentRequest: { + recurringPaymentRequest: { + ...recurringPaymentRequest, + managementURL: 'https://example.com', + }, }, })); - applePay.ready(() => { + applePay.ready(ensureDone(done, () => { assert.deepEqual(applePay.session.recurringPaymentRequest, { ...recurringPaymentRequest, managementURL: 'https://example.com', }); - done(); - }); + })); }); it('uses the managementURL from the server', function (done) { - const applePay = this.recurly.ApplePay(merge({}, validOpts, { recurringPaymentRequest, })); + const applePay = this.recurly.ApplePay(merge({}, validOpts, { paymentRequest: { recurringPaymentRequest, } })); - applePay.ready(() => { + applePay.ready(ensureDone(done,() => { assert.deepEqual(applePay.session.recurringPaymentRequest, { ...recurringPaymentRequest, managementURL: infoFixture.managementURL, }); - done(); - }); + })); }); }); }); it('sets other ApplePayPaymentRequest options and does not include configuration options', function (done) { const applePay = this.recurly.ApplePay(merge({}, validOpts, { - requiredShippingContactFields: ['email'], - supportedCountries: ['US'], + paymentRequest: { + requiredShippingContactFields: ['email'], + supportedCountries: ['US'], + }, })); applePay.ready(ensureDone(done, () => { @@ -491,8 +494,6 @@ function applePayTest (integrationType, requestMethod) { assert.deepEqual(applePay.session.supportedCountries, ['US']); assert.equal(applePay.session.currencyCode, validOpts.currency); assert.equal(applePay.session.countryCode, validOpts.country); - assert.equal(applePay.session.currency, undefined); - assert.equal(applePay.session.country, undefined); assert.equal(applePay.session.form, undefined); })); }); @@ -522,7 +523,7 @@ function applePayTest (integrationType, requestMethod) { it('limits the supportedNetworks to the configuration', function (done) { const applePay = this.recurly.ApplePay(merge({}, validOpts, { - supportedNetworks: ['visa'], + paymentRequest: { supportedNetworks: ['visa'], }, })); applePay.ready(ensureDone(done, () => { assert.deepEqual(applePay.session.supportedNetworks, ['visa']); @@ -579,7 +580,7 @@ function applePayTest (integrationType, requestMethod) { }; const pricing = this.recurly.Pricing.Checkout(); pricing.address(form).done(() => { - const applePay = this.recurly.ApplePay(merge({}, validOpts, { form, pricing, billingContact })); + const applePay = this.recurly.ApplePay(merge({}, validOpts, { form, pricing, paymentRequest: { billingContact } })); applePay.ready(ensureDone(done, () => { assert.deepEqual(applePay.session.billingContact, billingContact); assert.equal(applePay.session.shippingContact, undefined); @@ -677,7 +678,7 @@ function applePayTest (integrationType, requestMethod) { const pricing = this.recurly.Pricing.Checkout(); pricing.shippingAddress(form).done(() => { - const applePay = this.recurly.ApplePay(merge({}, validOpts, { form, pricing, shippingContact })); + const applePay = this.recurly.ApplePay(merge({}, validOpts, { form, pricing, paymentRequest: { shippingContact } })); applePay.ready(ensureDone(done, () => { assert.deepEqual(applePay.session.shippingContact, shippingContact); })); @@ -890,22 +891,17 @@ function applePayTest (integrationType, requestMethod) { describe('with options.recurringPaymentRequest set', function () { beforeEach(function (done) { - this.applePay = this.recurly.ApplePay(merge({}, validOpts, { - total: { label: 'Total', amount: '3.00', paymentTiming: 'recurring' } - })); - - this.applePay.ready(() => { + this.applePay = this.recurly.ApplePay(merge({}, validOpts, { recurring: true })); + this.applePay.ready(ensureDone(done, () => { this.applePay.begin(); - done(); - }); + })); }); it('includes the newRecurringPaymentRequest', function (done) { - this.applePay.session.on('completePaymentMethodSelection', () => { + this.applePay.session.on('completePaymentMethodSelection', ensureDone(done, () => { assert.notEqual(this.applePay.session.newRecurringPaymentRequest, undefined); assert.deepEqual(this.applePay.session.newRecurringPaymentRequest, this.applePay.recurringPaymentRequest); - done(); - }); + })); this.applePay.session.onpaymentmethodselected({ paymentMethod: { billingContact: { postalCode: '94114' } } }); }); }); @@ -959,22 +955,17 @@ function applePayTest (integrationType, requestMethod) { describe('with options.recurringPaymentRequest set', function () { beforeEach(function (done) { - this.applePay = this.recurly.ApplePay(merge({}, validOpts, { - total: { label: 'Total', amount: '3.00', paymentTiming: 'recurring' } - })); - - this.applePay.ready(() => { + this.applePay = this.recurly.ApplePay(merge({}, validOpts, { recurring: true })); + this.applePay.ready(ensureDone(done, () => { this.applePay.begin(); - done(); - }); + })); }); it('includes the newRecurringPaymentRequest', function (done) { - this.applePay.session.on('completeShippingContactSelection', () => { + this.applePay.session.on('completeShippingContactSelection', ensureDone(done, () => { assert.notEqual(this.applePay.session.newRecurringPaymentRequest, undefined); assert.deepEqual(this.applePay.session.newRecurringPaymentRequest, this.applePay.recurringPaymentRequest); - done(); - }); + })); this.applePay.session.onshippingcontactselected({}); }); }); @@ -1029,22 +1020,17 @@ function applePayTest (integrationType, requestMethod) { describe('with options.recurringPaymentRequest set', function () { beforeEach(function (done) { - this.applePay = this.recurly.ApplePay(merge({}, validOpts, { - total: { label: 'Total', amount: '3.00', paymentTiming: 'recurring' } - })); - - this.applePay.ready(() => { + this.applePay = this.recurly.ApplePay(merge({}, validOpts, { recurring: true })); + this.applePay.ready(ensureDone(done, () => { this.applePay.begin(); - done(); - }); + })); }); it('includes the newRecurringPaymentRequest', function (done) { - this.applePay.session.on('completeShippingMethodSelection', () => { + this.applePay.session.on('completeShippingMethodSelection', ensureDone(done, () => { assert.notEqual(this.applePay.session.newRecurringPaymentRequest, undefined); assert.deepEqual(this.applePay.session.newRecurringPaymentRequest, this.applePay.recurringPaymentRequest); - done(); - }); + })); this.applePay.session.onshippingmethodselected({}); }); }); @@ -1206,10 +1192,9 @@ function applePayTest (integrationType, requestMethod) { this.pricing = this.recurly.Pricing.Checkout(); this.applePay = this.recurly.ApplePay(merge({}, validOpts, { pricing: this.pricing })); this.pricing[addressType]({ postalCode: '91411', countryCode: 'US' }).done(() => { - this.applePay.ready(() => { + this.applePay.ready(ensureDone(done,() => { this.applePay.begin(); - done(); - }); + })); }); }); diff --git a/types/lib/apple-pay/index.d.ts b/types/lib/apple-pay/index.d.ts index 372d9b418..9713c3c70 100644 --- a/types/lib/apple-pay/index.d.ts +++ b/types/lib/apple-pay/index.d.ts @@ -1,17 +1,45 @@ import { Emitter } from '../emitter'; import { CheckoutPricingInstance, CheckoutPricingPromise } from '../pricing/checkout'; -import { ApplePayPaymentRequest, ApplePayLineItem } from './native'; +import { ApplePayPaymentRequest } from './native'; + +export type I18n = { + /** + * The short, localized description of the subtotal line item + */ + subtotalLineItemLabel: string; + + /** + * The short, localized description of the total line item + */ + totalLineItemLabel: string; + + /** + * The short, localized description of the discount line item + */ + discountLineItemLabel: string; + + /** + * The short, localized description of the tax line item + */ + taxLineItemLabel: string; + + /** + * The short, localized description of the gift card line item + */ + giftCardLineItemLabel: string; +}; export type ApplePayConfig = { /** - * Your ISO 3166 country code (ex: ‘US’). This is your country code as the merchant. + * Your ISO 3166 country code (ex: ‘US’). This is your country code as the merchant. Required if not + * set in `options.paymentRequest.countryCode`. */ - country: string; + country?: string; /** - * ISO 4217 purchase currency (ex: ‘USD’) + * ISO 4217 purchase currency (ex: ‘USD’). Required if not set in `options.paymentRequest.currencyCode`. */ - currency: string; + currency?: string; /** * Purchase description to display in the Apple Pay payment sheet. @@ -19,9 +47,20 @@ export type ApplePayConfig = { label?: string; /** - * Total cost to display in the Apple Pay payment sheet. Required if `options.pricing` is not provided. + * Total cost to display in the Apple Pay payment sheet. Required if `options.pricing` or + * `options.paymentRequest.total` is not provided. + */ + total?: string; + + /** + * Display the recurring payment request on a monthly cadence */ - total?: string | ApplePayLineItem; + recurring?: boolean; + + /** + * `options.pricing` line item descriptions to display in the Apple Pay payment sheet. + */ + i18n?: I18n; /** * If provided, will override `options.total` and provide the current total price on the CheckoutPricing instance @@ -39,7 +78,7 @@ export type ApplePayConfig = { form?: HTMLFormElement; /** - * If `options.requiredShippingContactFields` is present, validate that the browser supports the minimum version required for that option. + * If `requiredShippingContactFields` is specified, validate that the browser supports the minimum version required for that option. */ enforceVersion?: boolean; @@ -49,7 +88,12 @@ export type ApplePayConfig = { braintree?: { clientAuthorization: string; }; -} | ApplePayPaymentRequest; + + /** + * The request for a payment. + */ + paymentRequest?: ApplePayPaymentRequest; +}; export type ApplePayEvent = | 'token'