UNPKG

@rnw-community/react-native-payments

Version:

Accept Payments with Apple Pay and Android Pay using the Payment Request API.

502 lines 24.5 kB
/* eslint-disable max-lines */ import { Platform } from 'react-native'; import uuidModule from 'react-native-uuid'; import { emptyFn, isDefined, isEmptyArray, isError, isNotEmptyArray, isNotEmptyString } from '@rnw-community/shared'; import { AndroidPaymentMethodTokenizationType } from '../../@standard/android/enum/android-payment-method-tokenization-type.enum.js'; import { defaultAndroidPaymentDataRequest } from '../../@standard/android/request/android-payment-data-request.js'; import { defaultAndroidPaymentMethod } from '../../@standard/android/request/android-payment-method.js'; import { defaultAndroidTransactionInfo } from '../../@standard/android/request/android-transaction-info.js'; import { IOSPKContactField } from '../../@standard/ios/enum/ios-pk-contact-field.enum.js'; import { IosPKMerchantCapability } from '../../@standard/ios/enum/ios-pk-merchant-capability.enum.js'; import { IosPKPaymentNetworksEnum } from '../../@standard/ios/enum/ios-pk-payment-networks.enum.js'; import { PaymentMethodNameEnum } from '../../enum/payment-method-name.enum.js'; import { PaymentsErrorEnum } from '../../enum/payments-error.enum.js'; import { SupportedNetworkEnum } from '../../enum/supported-networks.enum.js'; import { ConstructorError } from '../../error/constructor.error.js'; import { DOMException } from '../../error/dom.exception.js'; import { PaymentsError } from '../../error/payments.error.js'; import { getNativePaymentsEventEmitter } from '../../util/get-native-payments-event-emitter/get-native-payments-event-emitter.util.js'; import { isNativeUserCancellation } from '../../util/is-native-user-cancellation.util.js'; import { resolvePaymentDetailsModifier } from '../../util/resolve-payment-details-modifier.util.js'; import { validateAndroidTransactionInfo } from '../../util/validate-android-transaction-info.util.js'; import { validateDetailsUpdate } from '../../util/validate-details-update.util.js'; import { validateDisplayItems } from '../../util/validate-display-items.util.js'; import { validateModifiers } from '../../util/validate-modifiers.util.js'; import { validatePaymentMethods } from '../../util/validate-payment-methods.util.js'; import { validateShippingOptions } from '../../util/validate-shipping-options.util.js'; import { validateShippingType } from '../../util/validate-shipping-type.util.js'; import { validateTotal } from '../../util/validate-total.util.js'; import { warnChangeEventError } from '../../util/warn-change-event-error.util.js'; import { ChangeEventDispatcher } from '../change-event-dispatcher/change-event-dispatcher.js'; import { NativePayments } from '../native-payments/native-payments.js'; import { AndroidPaymentResponse } from '../payment-response/android-payment-response.js'; import { IosPaymentResponse } from '../payment-response/ios-payment-response.js'; const uuid = uuidModule; /* * HINT: Troubleshooting: https://developers.google.com/pay/api/android/support/troubleshooting * HINT: Google Pay API Errors: https://developers.google.com/pay/api/web/reference/error-objects */ export class PaymentRequest { constructor(methodData, details) { this.methodData = methodData; this.details = details; this.updating = false; this.state = 'created'; this.couponCode = null; this.shippingAddress = null; this.shippingOption = null; this.eventRegistrations = new Map(); this.pendingDispatchers = new Set(); this.attributeHandlers = new Map(); this.attributeHandlerWrappers = new Map(); this.eventGeneration = 0; this.isNativeEventsSynced = false; this.acceptPromiseRejecter = emptyFn; // 3. Establish the request's id: if (!isNotEmptyString(details.id)) { // TODO: Can we avoid using external lib? Use Math.random? details.id = uuid.v4(); } this.id = details.id; this.validateConstructorInputs(methodData, details); // 17. Set request.[[serializedMethodData]] to serializedMethodData. */ this.platformMethodData = this.findPlatformPaymentMethodData(); const nativePlatformMethodData = Platform.OS === 'android' ? this.getAndroidPaymentMethodData(this.platformMethodData, this.resolveEffectiveDetails(details).total) : this.getIosPaymentMethodData(this.platformMethodData); this.serializedMethodData = JSON.stringify(nativePlatformMethodData); } // https://www.w3.org/TR/payment-request/#dom-paymentrequest-onshippingaddresschange get onshippingaddresschange() { return this.getAttributeHandler('shippingaddresschange'); } // https://www.w3.org/TR/payment-request/#dom-paymentrequest-onshippingoptionchange get onshippingoptionchange() { return this.getAttributeHandler('shippingoptionchange'); } // https://www.w3.org/TR/payment-request/#dom-paymentrequest-onpaymentmethodchange get onpaymentmethodchange() { return this.getAttributeHandler('paymentmethodchange'); } // couponcodechange is a PassKit extension: https://developer.apple.com/documentation/passkit/pkpaymentrequest/3801275-couponcode?language=objc get oncouponcodechange() { return this.getAttributeHandler('couponcodechange'); } set onshippingaddresschange(listener) { this.setAttributeHandler('shippingaddresschange', listener); } set onshippingoptionchange(listener) { this.setAttributeHandler('shippingoptionchange', listener); } set onpaymentmethodchange(listener) { this.setAttributeHandler('paymentmethodchange', listener); } set oncouponcodechange(listener) { this.setAttributeHandler('couponcodechange', listener); } // https://www.w3.org/TR/payment-request/#canmakepayment-method async canMakePayment() { if (this.state !== 'created') { throw new DOMException(PaymentsErrorEnum.InvalidStateError); } return NativePayments.canMakePayments(this.serializedMethodData); } // https://www.w3.org/TR/payment-request/#hasenrolledinstrument-method async hasEnrolledInstrument() { if (this.state !== 'created') { throw new DOMException(PaymentsErrorEnum.InvalidStateError); } return NativePayments.hasEnrolledInstrument(this.serializedMethodData); } // https://www.w3.org/TR/payment-request/#show-method show() { if (this.state !== 'created') { return Promise.reject(new DOMException(PaymentsErrorEnum.InvalidStateError)); } this.state = 'interactive'; this.syncActiveEvents(); const resolvedDetails = this.resolveEffectiveDetails(this.details); // HINT: We need to pass Android environment configuration to native module via details const details = Platform.OS === 'android' ? { ...this.details, environment: this.platformMethodData.environment, ...resolvedDetails, } : { ...this.details, ...resolvedDetails }; return new Promise((resolve, reject) => { this.acceptPromiseRejecter = reject; NativePayments.show(this.id, this.serializedMethodData, details) .then(jsonDetails => { const paymentResponse = this.handleAccept(jsonDetails); this.closeRequest(); resolve(paymentResponse); return void 0; }) .catch((error) => { this.closeRequest(); if (isNativeUserCancellation(error)) { reject(new DOMException(PaymentsErrorEnum.AbortError)); return; } reject(isError(error) ? error : new PaymentsError(`Failed showing PaymentRequest`)); }); }); } // https://www.w3.org/TR/payment-request/#abort-method async abort() { if (this.state !== 'interactive') { throw new DOMException(PaymentsErrorEnum.InvalidStateError); } await NativePayments.abort().catch(() => { throw new PaymentsError(`Failed aborting PaymentRequest`); }); this.closeRequest(); this.acceptPromiseRejecter(new DOMException(PaymentsErrorEnum.AbortError)); } addEventListener(type, eventListener) { if (this.state === 'closed') { return; } const listener = eventListener; const registration = this.eventRegistrations.get(type); if (isDefined(registration)) { if (!registration.listeners.includes(listener)) { registration.listeners.push(listener); } return; } const listeners = [listener]; this.eventRegistrations.set(type, { listeners, subscription: this.subscribeToNativeEvent(type, listeners) }); this.syncActiveEvents(); } removeEventListener(type, eventListener) { const registration = this.eventRegistrations.get(type); if (!isDefined(registration)) { return; } this.forgetListener(registration, eventListener); if (isNotEmptyArray(registration.listeners)) { return; } this.dropRegistration(type, registration); } closeRequest() { this.state = 'closed'; this.clearEventRegistrations(); } validateConstructorInputs(methodData, details) { // 4. Process payment methods validatePaymentMethods(methodData); validateAndroidTransactionInfo(methodData, ConstructorError); validateShippingType(methodData, ConstructorError); // 5. Process the total validateTotal(details.total, ConstructorError); // 6. If the displayItems member of details is present, then for each item in details.displayItems: validateDisplayItems(ConstructorError, details.displayItems); validateShippingOptions(ConstructorError, details.shippingOptions); validateModifiers(ConstructorError, details.modifiers); } getAttributeHandler(type) { return this.attributeHandlers.get(type) ?? null; } setAttributeHandler(type, listener) { if (!isDefined(listener)) { this.attributeHandlers.delete(type); this.removeAttributeHandlerWrapper(type); return; } this.attributeHandlers.set(type, listener); this.ensureAttributeHandlerWrapper(type); } ensureAttributeHandlerWrapper(type) { if (this.attributeHandlerWrappers.has(type)) { return; } const wrapper = event => this.dispatchToAttributeHandler(type, event); this.attributeHandlerWrappers.set(type, wrapper); this.addEventListener(type, wrapper); } removeAttributeHandlerWrapper(type) { const wrapper = this.attributeHandlerWrappers.get(type); if (!isDefined(wrapper)) { return; } this.attributeHandlerWrappers.delete(type); this.removeEventListener(type, wrapper); } dispatchToAttributeHandler(type, event) { return this.attributeHandlers.get(type)(event); } handleAccept(details) { return Platform.OS === 'android' ? new AndroidPaymentResponse(this.id, PaymentMethodNameEnum.AndroidPay, details, this.shippingOption) : new IosPaymentResponse(this.id, PaymentMethodNameEnum.ApplePay, details, this.shippingOption); } subscribeToNativeEvent(type, listeners) { const eventEmitter = getNativePaymentsEventEmitter(); if (!isDefined(eventEmitter)) { return null; } return eventEmitter.addListener(type, (payload) => { this.handleChangeEvent(type, listeners, payload).catch(warnChangeEventError); }); } dropRegistration(type, registration) { if (isDefined(registration.subscription)) { registration.subscription.remove(); } this.eventRegistrations.delete(type); this.syncActiveEvents(); } syncActiveEvents() { const eventNames = [...this.eventRegistrations.keys()]; const { setActiveEvents } = NativePayments; if (!isDefined(setActiveEvents) || (isEmptyArray(eventNames) && !this.isNativeEventsSynced)) { return; } this.isNativeEventsSynced = isNotEmptyArray(eventNames); setActiveEvents(this.id, eventNames).catch(emptyFn); } clearEventRegistrations() { this.eventGeneration += 1; this.pendingDispatchers.forEach(dispatcher => { dispatcher.abandon(); }); this.pendingDispatchers.clear(); this.eventRegistrations.forEach(registration => { if (isDefined(registration.subscription)) { registration.subscription.remove(); } }); this.eventRegistrations.clear(); this.syncActiveEvents(); } forgetListener(registration, listener) { const listenerIndex = registration.listeners.indexOf(listener); if (listenerIndex >= 0) { registration.listeners.splice(listenerIndex, 1); } } async handleChangeEvent(type, listeners, payload) { const generation = this.eventGeneration; if (payload.requestId !== this.id || !this.isDispatchActive(generation)) { return; } this.applyEventPayload(payload); if (this.updating) { await this.sendDetailsUpdate(type, payload.eventId, null, generation); return; } this.updating = true; try { await this.dispatchChangeEvent(type, listeners, payload, generation); } finally { this.updating = false; } } isDispatchActive(generation) { return this.state === 'interactive' && this.eventGeneration === generation; } async dispatchChangeEvent(type, listeners, payload, generation) { const dispatcher = new ChangeEventDispatcher(type, payload, () => this.isDispatchActive(generation)); this.pendingDispatchers.add(dispatcher); try { const detailsUpdate = await this.resolveDetailsUpdate(dispatcher, listeners); await this.sendDetailsUpdate(type, payload.eventId, detailsUpdate, generation); } finally { this.pendingDispatchers.delete(dispatcher); } } applyEventPayload(payload) { if (isDefined(payload.shippingAddress)) { this.shippingAddress = payload.shippingAddress; } if (isDefined(payload.shippingOption)) { this.shippingOption = payload.shippingOption; } if (isDefined(payload.couponCode)) { this.couponCode = payload.couponCode; } } async resolveDetailsUpdate(dispatcher, listeners) { try { const detailsUpdate = await dispatcher.dispatch(listeners); if (isDefined(detailsUpdate)) { validateDetailsUpdate(detailsUpdate); } return detailsUpdate; } catch (error) { warnChangeEventError(error); return null; } } async sendDetailsUpdate(type, eventId, detailsUpdate, generation) { const { updatePaymentDetails } = NativePayments; if (!this.isDispatchActive(generation) || !isDefined(updatePaymentDetails)) { return; } const updatedDetails = this.getUpdatedDetails(detailsUpdate); const resolvedDetails = this.resolveEffectiveDetails(updatedDetails); const update = { error: detailsUpdate?.error ?? '', eventName: type, requestId: this.id, total: resolvedDetails.total, ...(isDefined(eventId) && { eventId }), }; await updatePaymentDetails(update, resolvedDetails.displayItems, updatedDetails.shippingOptions ?? []); this.details = updatedDetails; } getUpdatedDetails(detailsUpdate) { if (!isDefined(detailsUpdate)) { return this.details; } return { ...this.details, ...(isDefined(detailsUpdate.total) && { total: detailsUpdate.total }), ...(isDefined(detailsUpdate.displayItems) && { displayItems: detailsUpdate.displayItems }), ...(isDefined(detailsUpdate.shippingOptions) && { shippingOptions: detailsUpdate.shippingOptions }), ...(isDefined(detailsUpdate.modifiers) && { modifiers: detailsUpdate.modifiers }), }; } resolveEffectiveDetails(details) { return resolvePaymentDetailsModifier(this.getPlatformSupportedMethod(), details.total, details.displayItems, details.modifiers); } getPlatformSupportedMethod() { return Platform.OS === 'ios' ? PaymentMethodNameEnum.ApplePay : PaymentMethodNameEnum.AndroidPay; } findPlatformPaymentMethodData() { const platformSupportedMethod = this.getPlatformSupportedMethod(); const platformMethod = this.methodData.find(paymentMethodData => paymentMethodData.supportedMethods === platformSupportedMethod); if (!isDefined(platformMethod)) { throw new DOMException(PaymentsErrorEnum.NotSupportedError); } return platformMethod.data; } getAndroidPaymentMethodData(methodData, total) { const isBillingRequired = methodData.requestBillingAddress === true || methodData.requestPayerName === true || methodData.requestPayerPhone === true; const totalPriceStatus = methodData.totalPriceStatus ?? defaultAndroidTransactionInfo.totalPriceStatus; return { ...defaultAndroidPaymentDataRequest, merchantInfo: { merchantName: total.label, }, transactionInfo: { ...defaultAndroidTransactionInfo, currencyCode: methodData.currencyCode, totalPrice: total.amount.value, totalPriceLabel: total.label, countryCode: methodData.countryCode, totalPriceStatus, ...(isDefined(methodData.checkoutOption) && { checkoutOption: methodData.checkoutOption }), ...(isDefined(methodData.transactionId) && { transactionId: methodData.transactionId }), }, allowedPaymentMethods: [ { ...defaultAndroidPaymentMethod, parameters: { ...defaultAndroidPaymentMethod.parameters, allowedCardNetworks: methodData.supportedNetworks.map(network => network.toUpperCase()), allowedAuthMethods: methodData.allowedAuthMethods ?? defaultAndroidPaymentMethod.parameters.allowedAuthMethods, ...(isBillingRequired && { billingAddressRequired: true, billingAddressParameters: { format: methodData.requestBillingAddress === true ? 'FULL' : 'MIN', phoneNumberRequired: methodData.requestPayerPhone === true, }, }), }, ...(isDefined(methodData.gatewayConfig) && { tokenizationSpecification: { parameters: methodData.gatewayConfig, type: AndroidPaymentMethodTokenizationType.PAYMENT_GATEWAY, }, }), ...(isDefined(methodData.directConfig) && { tokenizationSpecification: { parameters: methodData.directConfig, type: AndroidPaymentMethodTokenizationType.DIRECT, }, }), }, ], ...(methodData.requestPayerEmail === true && { emailRequired: true }), ...(methodData.requestShipping === true && { shippingAddressRequired: true, shippingAddressParameters: { phoneNumberRequired: methodData.requestPayerPhone === true, }, }), }; } getIosPaymentMethodData(methodData) { // TODO: Add mappings for other systems if needed const supportedNetworkMap = { [SupportedNetworkEnum.Amex]: IosPKPaymentNetworksEnum.PKPaymentNetworkAmex, [SupportedNetworkEnum.Mastercard]: IosPKPaymentNetworksEnum.PKPaymentNetworkMasterCard, [SupportedNetworkEnum.Visa]: IosPKPaymentNetworksEnum.PKPaymentNetworkVisa, [SupportedNetworkEnum.Discover]: IosPKPaymentNetworksEnum.PKPaymentNetworkDiscover, [SupportedNetworkEnum.Bancontact]: IosPKPaymentNetworksEnum.PKPaymentNetworkBancontact, [SupportedNetworkEnum.CartesBancaires]: IosPKPaymentNetworksEnum.PKPaymentNetworkCartesBancaires, [SupportedNetworkEnum.ChinaUnionPay]: IosPKPaymentNetworksEnum.PKPaymentNetworkChinaUnionPay, [SupportedNetworkEnum.Dankort]: IosPKPaymentNetworksEnum.PKPaymentNetworkDankort, [SupportedNetworkEnum.Eftpos]: IosPKPaymentNetworksEnum.PKPaymentNetworkEftpos, [SupportedNetworkEnum.Electron]: IosPKPaymentNetworksEnum.PKPaymentNetworkElectron, [SupportedNetworkEnum.Elo]: IosPKPaymentNetworksEnum.PKPaymentNetworkElo, [SupportedNetworkEnum.Girocard]: IosPKPaymentNetworksEnum.PKPaymentNetworkGirocard, [SupportedNetworkEnum.Interac]: IosPKPaymentNetworksEnum.PKPaymentNetworkInterac, [SupportedNetworkEnum.Jcb]: IosPKPaymentNetworksEnum.PKPaymentNetworkJCB, [SupportedNetworkEnum.Mada]: IosPKPaymentNetworksEnum.PKPaymentNetworkMada, [SupportedNetworkEnum.Maestro]: IosPKPaymentNetworksEnum.PKPaymentNetworkMaestro, [SupportedNetworkEnum.Mir]: IosPKPaymentNetworksEnum.PKPaymentNetworkMir, [SupportedNetworkEnum.PrivateLabel]: IosPKPaymentNetworksEnum.PKPaymentNetworkPrivateLabel, [SupportedNetworkEnum.Vpay]: IosPKPaymentNetworksEnum.PKPaymentNetworkVPay, }; const defaultMerchantCapabilities = [ IosPKMerchantCapability.PKMerchantCapability3DS, IosPKMerchantCapability.PKMerchantCapabilityDebit, IosPKMerchantCapability.PKMerchantCapabilityCredit, ]; const requestedShippingFields = this.getRequestedShippingFields(methodData); const isShippingRequested = requestedShippingFields.length > 0; return { countryCode: methodData.countryCode, currencyCode: methodData.currencyCode, merchantIdentifier: methodData.merchantIdentifier, supportedNetworks: methodData.supportedNetworks.map(network => supportedNetworkMap[network]), merchantCapabilities: isNotEmptyArray(methodData.merchantCapabilities) ? methodData.merchantCapabilities : defaultMerchantCapabilities, ...(methodData.requestBillingAddress === true && { requiredBillingContactFields: [IOSPKContactField.PKContactFieldPostalAddress], }), ...(isShippingRequested && { requiredShippingContactFields: requestedShippingFields }), ...(isDefined(methodData.applicationData) && { applicationData: methodData.applicationData }), ...(isNotEmptyString(methodData.couponCode) && { couponCode: methodData.couponCode }), ...(isDefined(methodData.shippingType) && { shippingType: methodData.shippingType }), }; } getRequestedShippingFields(methodData) { const requiredShippingFields = []; if (methodData.requestPayerEmail ?? false) { requiredShippingFields.push(IOSPKContactField.PKContactFieldEmailAddress); } if (methodData.requestPayerName ?? false) { requiredShippingFields.push(IOSPKContactField.PKContactFieldName); } if (methodData.requestPayerPhone ?? false) { requiredShippingFields.push(IOSPKContactField.PKContactFieldPhoneNumber); } if (methodData.requestShipping ?? false) { requiredShippingFields.push(IOSPKContactField.PKContactFieldPostalAddress); } return requiredShippingFields; } } //# sourceMappingURL=payment-request.js.map