js/PaymentRequest/index.js
// @flow
'use strict';
// Types
import type {
PaymentMethodData,
PaymentDetailsInit,
PaymentDetailsBase,
PaymentDetailsUpdate,
PaymentOptions,
PaymentShippingOption,
PaymentItem,
PaymentAddress,
PaymentShippingType,
PaymentDetailsIOS,
PaymentDetailsIOSRaw,
} from '../types';
import type PaymentResponseType from './PaymentResponse';
// Modules
import { DeviceEventEmitter, Platform } from 'react-native';
import uuid from 'uuid/v1';
import NativePayments from '../NativeBridge';
import PaymentResponse from './PaymentResponse';
import PaymentRequestUpdateEvent from './PaymentRequestUpdateEvent';
// Helpers
import {
isValidDecimalMonetaryValue,
isNegative,
convertDetailAmountsToString,
getPlatformMethodData,
validateTotal,
validatePaymentMethods,
validateDisplayItems,
validateShippingOptions,
getSelectedShippingOption,
hasGatewayConfig,
getGatewayName,
validateGateway
} from './helpers';
import { ConstructorError, GatewayError } from './errors';
// Constants
import {
MODULE_SCOPING,
SHIPPING_ADDRESS_CHANGE_EVENT,
SHIPPING_OPTION_CHANGE_EVENT,
INTERNAL_SHIPPING_ADDRESS_CHANGE_EVENT,
INTERNAL_SHIPPING_OPTION_CHANGE_EVENT,
USER_DISMISS_EVENT,
USER_ACCEPT_EVENT,
GATEWAY_ERROR_EVENT,
SUPPORTED_METHOD_NAME
} from './constants';
const noop = () => {};
const IS_ANDROID = Platform.OS === 'android';
const IS_IOS = Platform.OS === 'ios'
// function processPaymentDetailsModifiers(details, serializedModifierData) {
// let modifiers = [];
// if (details.modifiers) {
// modifiers = details.modifiers;
// modifiers.forEach((modifier) => {
// if (modifier.total && modifier.total.amount && modifier.total.amount.value) {
// // TODO: refactor validateTotal so that we can display proper error messages (should remove construct 'PaymentRequest')
// validateTotal(modifier.total);
// }
// if (modifier.additionalDisplayItems) {
// modifier.additionalDisplayItems.forEach((displayItem) => {
// let value = displayItem && displayItem.amount.value && displayItem.amount.value;
// isValidDecimalMonetaryValue(value);
// });
// }
// let serializedData = modifier.data
// ? JSON.stringify(modifier.data)
// : null;
// serializedModifierData.push(serializedData);
// if (modifier.data) {
// delete modifier.data;
// }
// });
// }
// details.modifiers = modifiers;
// }
export default class PaymentRequest {
_id: string;
_shippingAddress: null | PaymentAddress;
_shippingOption: null | string;
_shippingType: null | PaymentShippingType;
// Internal Slots
_serializedMethodData: string;
_serializedModifierData: string;
_details: Object;
_options: Object;
_state: 'created' | 'interactive' | 'closed';
_updating: boolean;
_acceptPromise: Promise<any>;
_acceptPromiseResolver: (value: any) => void;
_acceptPromiseRejecter: (reason: any) => void;
_shippingAddressChangeSubscription: any; // TODO: - add proper type annotation
_shippingOptionChangeSubscription: any; // TODO: - add proper type annotation
_userDismissSubscription: any; // TODO: - add proper type annotation
_userAcceptSubscription: any; // TODO: - add proper type annotation
_gatewayErrorSubscription: any; // TODO: - add proper type annotation
_shippingAddressChangesCount: number;
_shippingAddressChangeFn: PaymentRequestUpdateEvent => void; // function provided by user
_shippingOptionChangeFn: PaymentRequestUpdateEvent => void; // function provided by user
constructor(
methodData: Array<PaymentMethodData> = [],
details?: PaymentDetailsInit = [],
options?: PaymentOptions = {}
) {
// 1. If the current settings object's responsible document is not allowed to use the feature indicated by attribute name allowpaymentrequest, then throw a " SecurityError" DOMException.
noop();
// 2. Let serializedMethodData be an empty list.
// happens in `processPaymentMethods`
// 3. Establish the request's id:
if (!details.id) {
details.id = uuid();
}
// 4. Process payment methods
const serializedMethodData = validatePaymentMethods(methodData);
// 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(details.displayItems, ConstructorError);
// 7. Let selectedShippingOption be null.
let selectedShippingOption = null;
// 8. Process shipping options
validateShippingOptions(details, ConstructorError);
if (IS_IOS) {
selectedShippingOption = getSelectedShippingOption(details.shippingOptions);
}
// 9. Let serializedModifierData be an empty list.
let serializedModifierData = [];
// 10. Process payment details modifiers:
// TODO
// - Look into how payment details modifiers are used.
// processPaymentDetailsModifiers(details, serializedModifierData)
// 11. Let request be a new PaymentRequest.
// 12. Set request.[[options]] to options.
this._options = options;
// 13. Set request.[[state]] to "created".
this._state = 'created';
// 14. Set request.[[updating]] to false.
this._updating = false;
// 15. Set request.[[details]] to details.
this._details = details;
// 16. Set request.[[serializedModifierData]] to serializedModifierData.
this._serializedModifierData = serializedModifierData;
// 17. Set request.[[serializedMethodData]] to serializedMethodData.
this._serializedMethodData = JSON.stringify(methodData);
// Set attributes (18-20)
this._id = details.id;
// 18. Set the value of request's shippingOption attribute to selectedShippingOption.
this._shippingOption = selectedShippingOption;
// 19. Set the value of the shippingAddress attribute on request to null.
this._shippingAddress = null;
// 20. If options.requestShipping is set to true, then set the value of the shippingType attribute on request to options.shippingType. Otherwise, set it to null.
this._shippingType = IS_IOS && options.requestShipping === true
? options.shippingType
: null;
// React Native Payments specific 👇
// ---------------------------------
// Setup event listeners
this._setupEventListeners();
// Set the amount of times `_handleShippingAddressChange` has been called.
// This is used on iOS to noop the first call.
this._shippingAddressChangesCount = 0;
const platformMethodData = getPlatformMethodData(methodData, Platform.OS);
const normalizedDetails = convertDetailAmountsToString(details);
// Validate gateway config if present
if (hasGatewayConfig(platformMethodData)) {
validateGateway(
getGatewayName(platformMethodData),
NativePayments.supportedGateways
);
}
NativePayments.createPaymentRequest(
platformMethodData,
normalizedDetails,
options
);
}
// initialize acceptPromiseResolver/Rejecter
// mainly for unit tests to work without going through the complete flow.
_acceptPromiseResolver = () => {}
_acceptPromiseRejecter = () => {}
_setupEventListeners() {
// Internal Events
this._userDismissSubscription = DeviceEventEmitter.addListener(
USER_DISMISS_EVENT,
this._closePaymentRequest.bind(this)
);
this._userAcceptSubscription = DeviceEventEmitter.addListener(
USER_ACCEPT_EVENT,
this._handleUserAccept.bind(this)
);
if (IS_IOS) {
this._gatewayErrorSubscription = DeviceEventEmitter.addListener(
GATEWAY_ERROR_EVENT,
this._handleGatewayError.bind(this)
);
// https://www.w3.org/TR/payment-request/#onshippingoptionchange-attribute
this._shippingOptionChangeSubscription = DeviceEventEmitter.addListener(
INTERNAL_SHIPPING_OPTION_CHANGE_EVENT,
this._handleShippingOptionChange.bind(this)
);
// https://www.w3.org/TR/payment-request/#onshippingaddresschange-attribute
this._shippingAddressChangeSubscription = DeviceEventEmitter.addListener(
INTERNAL_SHIPPING_ADDRESS_CHANGE_EVENT,
this._handleShippingAddressChange.bind(this)
);
}
}
_handleShippingAddressChange(postalAddress: PaymentAddress) {
this._shippingAddress = postalAddress;
const event = new PaymentRequestUpdateEvent(
SHIPPING_ADDRESS_CHANGE_EVENT,
this
);
this._shippingAddressChangesCount++;
// On iOS, this event fires when the PKPaymentRequest is initialized.
// So on iOS, we track the amount of times `_handleShippingAddressChange` gets called
// and noop the first call.
if (IS_IOS && this._shippingAddressChangesCount === 1) {
return event.updateWith(this._details);
}
// Eventually calls `PaymentRequestUpdateEvent._handleDetailsUpdate` when
// after a details are returned
this._shippingAddressChangeFn && this._shippingAddressChangeFn(event);
}
_handleShippingOptionChange({ selectedShippingOptionId }: Object) {
// Update the `shippingOption`
this._shippingOption = selectedShippingOptionId;
const event = new PaymentRequestUpdateEvent(
SHIPPING_OPTION_CHANGE_EVENT,
this
);
this._shippingOptionChangeFn && this._shippingOptionChangeFn(event);
}
_getPlatformDetails(details: *) {
return IS_IOS
? this._getPlatformDetailsIOS(details)
: this._getPlatformDetailsAndroid(details);
}
_getPlatformDetailsIOS(details: PaymentDetailsIOSRaw): PaymentDetailsIOS {
const {
paymentData: serializedPaymentData,
billingContact: serializedBillingContact,
shippingContact: serializedShippingContact,
paymentToken,
transactionIdentifier,
paymentMethod
} = details;
const isSimulator = transactionIdentifier === 'Simulated Identifier';
let billingContact = null;
let shippingContact = null;
if (serializedBillingContact && serializedBillingContact !== ""){
try{
billingContact = JSON.parse(serializedBillingContact);
}catch(e){}
}
if (serializedShippingContact && serializedShippingContact !== ""){
try{
shippingContact = JSON.parse(serializedShippingContact);
}catch(e){}
}
return {
paymentData: isSimulator ? null : JSON.parse(serializedPaymentData),
billingContact,
shippingContact,
paymentToken,
transactionIdentifier,
paymentMethod
};
}
_getPlatformDetailsAndroid(details: {
googleTransactionId: string,
payerEmail: string,
paymentDescription: string,
shippingAddress: Object,
}) {
const {
googleTransactionId,
paymentDescription
} = details;
return {
googleTransactionId,
paymentDescription,
// On Android, the recommended flow is to have user's confirm prior to
// retrieving the full wallet.
getPaymentToken: () => NativePayments.getFullWalletAndroid(
googleTransactionId,
getPlatformMethodData(JSON.parse(this._serializedMethodData, Platform.OS)),
convertDetailAmountsToString(this._details)
)
};
}
_handleUserAccept(details: {
transactionIdentifier: string,
paymentData: string,
shippingAddress: Object,
payerEmail: string,
paymentToken?: string,
paymentMethod: Object
}) {
// On Android, we don't have `onShippingAddressChange` events, so we
// set the shipping address when the user accepts.
//
// Developers will only have access to it in the `PaymentResponse`.
if (IS_ANDROID) {
const { shippingAddress } = details;
this._shippingAddress = shippingAddress;
}
const paymentResponse = new PaymentResponse({
requestId: this.id,
methodName: IS_IOS ? 'apple-pay' : 'android-pay',
shippingAddress: this._options.requestShipping ? this._shippingAddress : null,
details: this._getPlatformDetails(details),
shippingOption: IS_IOS ? this._shippingOption : null,
payerName: this._options.requestPayerName ? this._shippingAddress.recipient : null,
payerPhone: this._options.requestPayerPhone ? this._shippingAddress.phone : null,
payerEmail: IS_ANDROID && this._options.requestPayerEmail
? details.payerEmail
: null
});
return this._acceptPromiseResolver(paymentResponse);
}
_handleGatewayError(details: { error: string }) {
return this._acceptPromiseRejecter(new GatewayError(details.error));
}
_closePaymentRequest() {
this._state = 'closed';
this._acceptPromiseRejecter(new Error('AbortError'));
// Remove event listeners before aborting.
this._removeEventListeners();
}
_removeEventListeners() {
// Internal Events
this._userDismissSubscription.remove()
this._userAcceptSubscription.remove()
if (IS_IOS) {
this._shippingAddressChangeSubscription.remove()
this._shippingOptionChangeSubscription.remove()
}
}
// https://www.w3.org/TR/payment-request/#onshippingaddresschange-attribute
// https://www.w3.org/TR/payment-request/#onshippingoptionchange-attribute
addEventListener(
eventName: 'shippingaddresschange' | 'shippingoptionchange',
fn: e => Promise<any>
) {
if (eventName === SHIPPING_ADDRESS_CHANGE_EVENT) {
return (this._shippingAddressChangeFn = fn.bind(this));
}
if (eventName === SHIPPING_OPTION_CHANGE_EVENT) {
return (this._shippingOptionChangeFn = fn.bind(this));
}
}
// https://www.w3.org/TR/payment-request/#id-attribute
get id(): string {
return this._id;
}
// https://www.w3.org/TR/payment-request/#shippingaddress-attribute
get shippingAddress(): null | PaymentAddress {
return this._shippingAddress;
}
// https://www.w3.org/TR/payment-request/#shippingoption-attribute
get shippingOption(): null | string {
return this._shippingOption;
}
// https://www.w3.org/TR/payment-request/#show-method
show(): Promise<PaymentResponseType> {
this._acceptPromise = new Promise((resolve, reject) => {
this._acceptPromiseResolver = resolve;
this._acceptPromiseRejecter = reject;
if (this._state !== 'created') {
return reject(new Error('InvalidStateError'));
}
this._state = 'interactive';
// These arguments are passed because on Android we don't call createPaymentRequest.
const platformMethodData = getPlatformMethodData(JSON.parse(this._serializedMethodData), Platform.OS);
const normalizedDetails = convertDetailAmountsToString(this._details);
const options = this._options;
// Note: resolve will be triggered via _acceptPromiseResolver() from somwhere else
return NativePayments.show(platformMethodData, normalizedDetails, options).catch(reject);
});
return this._acceptPromise;
}
// https://www.w3.org/TR/payment-request/#abort-method
abort(): Promise<void> {
return new Promise((resolve, reject) => {
// We can't abort if the PaymentRequest isn't shown or already closed
if (this._state !== 'interactive') {
return reject(new Error('InvalidStateError'));
}
// Try to dismiss the UI
NativePayments.abort()
.then((_bool) => {
this._closePaymentRequest();
// Return `undefined` as proposed in the spec.
return resolve(undefined);
})
.catch((_err) => reject(new Error('InvalidStateError')));
});
}
// https://www.w3.org/TR/payment-request/#canmakepayment-method
canMakePayments(): Promise<boolean> {
return NativePayments.canMakePayments(
getPlatformMethodData(JSON.parse(this._serializedMethodData), Platform.OS)
);
}
static canMakePaymentsUsingNetworks = NativePayments.canMakePaymentsUsingNetworks;
}