index.js

import templateStyles from './cardTemplateStyles'

window.StackPay = {

	apiKey: "",
	env: '',
	s3Url: ``,
	apiUrl: ``,
	ccHtml: "",
	cardType: '',
	nameValid: false,
	cardIsValid: false,
	expirationValid: false,
	cvvValid: false,
	zipCodeValid: false,

	/************** INPUT REGEX STRINGS ****************/

	nameRegex: /^[a-zA-Z]{2,}(?!.*--)[a-zA-Z- ]*[a-zA-Z]$/i,
	monthYearRegex: /^\d\d\d\d$/,
	cvvRegex: /^[0-9]{3}$/,
	cvvAmex: /^[0-9]{4}$/,
	zipCodeRegex: /^\d{5}$|^\d{5}-\d{4}$/,

	/************** CREDIT CARD REGEX STRINGS *******************/

	visaRegex: /^4[0-9]{12}(?:[0-9]{3})?/,
	masterCardRegex: /^(?:5[1-5][0-9]{2}|222[1-9]|22[3-9][0-9]|2[3-6][0-9]{2}|27[01][0-9]|2720)[0-9]{12}$/,
	amexRegex: /^3[47][0-9]{13}$/i,
	discoverRegex: /^6(?:011|5[0-9]{2})[0-9]{12}$/,
	dinersRegex: /^3(?:0[0-5]|[68][0-9])[0-9]{11}$/i,
	jcbRegex: /^(?:2131|1800|35\d{3})\d{11}$/,

	/************** VERIFY PROVIDED PUBLIC KEY ************/

	/**
	 * Verifies public key and delegates environment
	 * @param {string} publicKey - supplied by Stack Pay
	 * @param {string} mode - either local, sandbox, or production
	 * @returns {Promise<void>}
	 */
	verifyKey: (publicKey, mode) => {
		return new Promise((resolve, reject) => {
			if (publicKey === '' || publicKey === null) {
				reject('Public Key is required. ');
			}

			StackPay.setApiKey(publicKey).then(() => resolve(StackPay.setEnv(mode)))
		})
	},

	/************** Getters and Setters ************/

	setS3Url: (url) => {
		return StackPay.s3Url = url
	},

	getS3Url: () => {
		return StackPay.s3Url
	},

	setAPIUrl: (apiUrl) => {
		return StackPay.apiUrl = apiUrl
	},

	getApiUrl: () => {
		return StackPay.apiUrl
	},

	/**
	 * Evaluates entered public key, and returns a promise with API Key String
	 * @param {string} key
	 * @returns {Promise<String>}
	 */
	setApiKey: (key) => {
		return new Promise((resolve, reject) => {
			StackPay.apiKey = key
			resolve(StackPay.apiKey)
		})
	},

	getApiKey: () => {
		return StackPay.apiKey
	},

	setCCHtml: (html) => {
		return StackPay.ccHtml = html
	},

	getCCHtml: () => {
		return StackPay.ccHtml
	},

	/********************* Getters / Setters for  VALIDITY OF INPUTS ******************/

	setNameValidity: (validity) => {
		return StackPay.nameValid = validity
	},

	getNameValidity: () => {
		return StackPay.nameValid
	},

	setCardType: (type) => {
		return StackPay.cardType = type
	},

	getCardType: () => {
		return StackPay.cardType
	},

	setCardValidity: (validity) => {
		return StackPay.cardIsValid = validity
	},

	getCardValidity: () => {
		return StackPay.cardIsValid
	},

	setExpirationValidity: (validity) => {
		return StackPay.expirationValid = validity
	},

	getExpirationValidity: () => {
		return StackPay.expirationValid
	},

	setCvvValidity: (validity) => {
		return StackPay.cvvValid = validity
	},

	getCvvValidity: () => {
		return StackPay.cvvValid
	},

	setZipCodeValidity: (validity) => {
		return StackPay.zipCodeValid = validity
	},

	getZipCodeValidity: () => {
		return StackPay.zipCodeValid
	},

	/**
	 * Reads the env file that is designated by the mode parameter in validateKey() method.
	 * Resolves an object containing key & value of each line in the ENV file.
	 * @param {String} fileUrl
	 * @returns {Promise<Object>}
	 */
	readENVTextFile: (fileUrl) => {
		return new Promise(async (resolve, reject) => {

			const utf8Decoder = new TextDecoder('utf-8')
			const response =  await fetch(fileUrl)
			const reader = response.body.getReader()

			let { value: textContent, done: readerDone } = await reader.read()
			textContent = textContent ? utf8Decoder.decode(textContent) : ''

			// Split ENV file by line break
			const re = /\n|\r|\r\n/gm
			let result = re.exec(textContent).input.split("\n")
			let envVariables = []

			for await (let line of result) {
				if (line === '') {
					continue;
				}

				// Split each line by key / value
				let splitLine = line.split('=')
				envVariables.push({
					key: splitLine[0],
					value: splitLine[1]
				})
			}

			resolve(envVariables)
		}).catch(error => console.log('Error Reading selected ENV: ', error))

	},

	/**
	 * Sets ENV variable, and assigns the proper value to the StackPay.apiUrl
	 * @param {string} mode
	 * @returns {Promise<void>}
	 */
	setEnv: async (mode) => {
		StackPay.env = mode
		await StackPay.readENVTextFile('https://s3-us-west-2.amazonaws.com/js.stackpay.com/v1/.env.' + mode)
			.then(result => {
				StackPay.setS3Url(result[0].value)
				StackPay.setAPIUrl(result[1].value)

			})
		console.log('S3 URL: ', StackPay.getS3Url())
		console.log('API URL: ', StackPay.getApiUrl())
	},

	/**
	 * Fetch HTML from designated template files in order to inject them within the
	 * payment form
	 * @param {string} url
	 * @returns Promise<HTMLMarkup>
	 */
	fetchHtml: (url) => {
		return new Promise(async (resolve, reject) => {
			await fetch(url, {
				headers: { 'Access-Control-Allow-Origin': '*' }
			})
				.then(response => response.text()) // convert the result to text in order to parse as HTML
				.then(html => {

					// Initialize the DOM parser
					let parser = new DOMParser()

					// Parse the text
					let parsedHTML = parser.parseFromString(html, "text/html")

					resolve(parsedHTML.body)
				})
				.catch(err => {
					console.log('Failed to fetch HTML from template: ', err)
					reject(err)
				})
		})
	},

	/**
	 *
	 * @param {string} containerElementId
	 * @returns {Promise<HTMLObjectElement>}
	 */
	appendCardHtml: (containerElementId) => {
		return new Promise((resolve, reject) => {
			let html = StackPay.getCCHtml();

			if (html === '' || html === null || typeof html !== 'object') {

				reject('Cannot append Card HTML to form')
			}

			let container = document.getElementById(containerElementId)
			resolve(container.append (html))
		})

	},

	/**
	 * Takes the serialized form data and returns a json object so
	 * that it can be consumed by getPaymentMethodToken()
	 * @param {object} options
	 * @returns {object}
	 */
	serializeFormData: (options) => {
		return 	{
			name: options[0]['value'],
			cardNumber: options[1]['value'],
			cardType: options[2]['value'],
			expiration: options[3]['value'],
			cvv: options[4]['value'],
			zipcode: options[5]['value']
		}
	},

	/**
	 * Return Payment Method Token from Stack Pay API
	 * @param {object} options
	 * @returns Promise<Object>
	 */
	getPaymentMethodToken: (options) => {
		return new Promise((resolve, reject) => {

			let data = StackPay.serializeFormData(options)
			let apiKey = StackPay.getApiKey()

			return fetch(StackPay.apiUrl +`/api/token`, {
				method: 'POST',
				headers: {
					Accept: 'application/json',
					'Content-Type': 'application/json;charset=utf-8',
					Authorization: `Bearer ${apiKey}`
				},
				body: JSON.stringify({
					Order: {
						Account: {
							Type:  data.cardType,
							Number: data.cardNumber.replace(/\s+/g, ''),
							Cvv2: data.cvv,
							ExpireDate: data.expiration.replace(/\//g, '')
						},
						AccountHolder: {
							Name: data.name,
							BillingAddress: {
								Address1: '1234 Payment Method Dr',
								City: 'Frisco',
								State: 'Texas',
								Zip: data.zipcode
							}
						}
					}
				})
			})
				.then(result => {
					if (result.status === 200) {
						return result.json()

					} else {
						throw new Error('You are unauthorized')
					}
				})
				.then(data => {
					resolve({
						hash: data.Header.Security.Hash,
						hashMethod: data.Header.Security,
						status: data.Body.Status === 1
							? 'success'
							: 'failure',
						token: data.Body.Token
					} )

				}).catch(error => reject(error))
		})

	},

	/**
	 * Method related to the input elements
	 */
	elements: {

		/**
		 * Create Element
		 * @param {string} elType either 'card' or 'ach'
		 * @param {object} styles json object containing style configuration
		 * @returns {Promise<void>}
		 */
		create: (elType, styles = {}) => {
			return new Promise((resolve, reject) => {
				let sp = StackPay.elements
				switch (elType) {
					case 'card':
						sp.createCardElement()
							.then(card => {
								StackPay.setCCHtml(card)
								templateStyles(styles)

								resolve()
							})
					break;
					case 'ach':
						sp.createAchElement()
							.then(ach => {
								StackPay.setCCHtml(
									StackPay.elements.injectStyles(ach)
								)

								resolve()
							})
						break;
				}
			})
		},

		/**
		 * Create Card Element and fetch Required HTML
		 * @returns {Promise<HTMLObjectElement>}
		 */
		createCardElement: () => {
			return new Promise((resolve, reject) => {
				StackPay.fetchHtml(StackPay.getS3Url() + '/card.template.html')
					.then(html => resolve(html))
					.catch(error => reject(error))
			})
		},

		/**
		 * Create ACH Element and fetch required HTML
		 * @returns {Promise<HTMLObjectElement>}
		 */
		createAchElement: () => {
			return new Promise((resolve, reject) => {
				StackPay.fetchHtml(StackPay.getS3Url() + '/ach.template.html')
					.then(html => resolve(html))
					.catch(error => reject(error))
			})
		},

		/**
		 * Name can have letters, spaces, dashes, comma, and period, with no numbers, and no special characters.
		 * At least 3 letters.
		 * @param {string} text
		 * @returns {Promise<Boolean>}
		 */
		validateName: (text) => {
			return new Promise((resolve, reject) => {
				let result = StackPay.nameRegex.test(text)

				// Set name validity in order to determine whether to submit the API call for Payment Token
				StackPay.setNameValidity(result)
				resolve(result)
			})
		},

		/**
		 * Using Regex strings to validate designated card numbers
		 * @param {string} cardNumber
		 * @returns {Promise<Object>}
		 */
		validateCardNumber: (cardNumber) => {
			return new Promise((resolve, reject) => {
				if (StackPay.elements.validateVisa(cardNumber)) {
					resolve({
						type: 'visa',
						result: true
					})
				} else if (StackPay.elements.validateMasterCard(cardNumber)) {
					resolve({
						type: 'mastercard',
						result: true
					})
				} else if (StackPay.elements.validateAmex(cardNumber)) {
					resolve({
						type: 'amex',
						result: true
					})
				} else if (StackPay.elements.validateDinersCard(cardNumber)) {
					resolve({
						type: 'diners',
						result: true
					})
				} else if (StackPay.elements.validateDiscover(cardNumber)) {
					resolve({
						type: 'discover',
						result: true
					})
				} else if (StackPay.elements.validateJCB(cardNumber)) {
					resolve({
						type: 'jcb',
						result: true
					})
				} else {
					resolve({
						type: undefined,
						result: false
					})
				}

			})

		},

		/**
		 * Validates Visa cards and returns boolean
		 * @param {string} cardNumber
		 * @returns {boolean}
		 */
		validateVisa: (cardNumber) => {
			return StackPay.visaRegex.test(cardNumber)
		},

		/**
		 * Validates MasterCard cards and returns boolean
		 * @param {string} cardNumber
		 * @returns {boolean}
		 */
		validateMasterCard: (cardNumber) => {
			return StackPay.masterCardRegex.test(cardNumber)
		},

		/**
		 * Validates Amex cards and returns boolean
		 * @param {string} cardNumber
		 * @returns {boolean}
		 */
		validateAmex: (cardNumber) => {
			return StackPay.amexRegex.test(cardNumber)
		},

		/**
		 * Validates Discover cards and returns boolean
		 * @param {string} cardNumber
		 * @returns {boolean}
		 */
		validateDiscover: (cardNumber) => {
			return StackPay.discoverRegex.test(cardNumber)
		},

		/**
		 * Validates Diners cards and returns boolean
		 * @param {string} cardNumber
		 * @returns {boolean}
		 */
		validateDinersCard: (cardNumber) => {
			return StackPay.dinersRegex.test(cardNumber)
		},

		/**
		 * Validates JCB cards and returns boolean
		 * @param {string} cardNumber
		 * @returns {boolean}
		 */
		validateJCB: (cardNumber) => {
			return StackPay.jcbRegex.test(cardNumber)
		},

		/**
		 * Expiration date validation
		 * month must be between 1 & 12 inclusive
		 * year must be greater than current year
		 * Month must be greater than current month if entered year === current year
		 * Entered Month can be less than current month as long as Entered Year is greater than Current Year		 *
		 * @param {string} expiration
		 * @returns {Promise<Boolean>}
		 */
		validateCardExpiration: (expiration) => {
			return new Promise((resolve, reject) => {

				const extracted = () => {
					// check that month & year are digits
					let validated = StackPay.elements.expirationValidDigits(expiration)

					// obtain the entered month
					let enteredMonth = validated.valid
						? parseInt(validated.result.substring(0, 2), 10)
						: 0

					let currentMonth = new Date().getMonth()

					// obtain entered year
					let enteredYear = parseInt('20' + validated.result.substring(2, 4), 10)
					let currentYear = new Date().getFullYear()

					// if Entered Year > Current Year, then Entered Month === Current Month
					let sameMonthGreaterEnteredYear = enteredYear > currentYear && enteredMonth === currentMonth

					// if Entered Year > Current Year && Entered Month < Current Month
					let greaterEnteredYearLesserEnteredMonth = enteredYear > currentYear && enteredMonth < currentMonth

					// If Entered Year >= Current Year, then Entered Month > Current Month
					let greaterEnteredYearGreaterMonth = enteredYear >= currentYear && enteredMonth > currentMonth

					// check that month is between 1 & 12 inclusive
					let monthBetweenOneAndTwelve = enteredMonth >= 1 && enteredMonth <= 12

					let result = sameMonthGreaterEnteredYear ||
						greaterEnteredYearLesserEnteredMonth ||
						greaterEnteredYearGreaterMonth &&
						monthBetweenOneAndTwelve

					return result;
				}

				let result = extracted();

				// Set validity of entered expiration in order to determine whether to submit API call
				StackPay.setExpirationValidity(result)

				resolve(result)
			}).catch(error => console.log('Caught Exception validateCardExpiration(): ', error))
		},

		/**
		 * Expiration Date must be 4 numerical digits
		 * @param {string} expiration
		 * @returns {Object}
		 */
		expirationValidDigits: (expiration) => {
			return StackPay.monthYearRegex.test(expiration)
				? {
					result: expiration,
					valid: true
				}
				: {
					result: null,
					valid: false
				}
		},

		/**
		 * CVV Must be either 3 or 4 (if AMEX ) numerical digits
		 * @param {string} cvv
		 * @param {string} cardType
		 * @returns {Promise<Boolean>}
		 */
		validateCVV: (cvv, cardType) => {
			let validated;
			return new Promise((resolve, reject) => {
				let cardTypeExists = cardType !== '' && cardType !== null
				let cvvExists = cvv !== '' && cvv !== null
				let exists = cvvExists && cardTypeExists

				if (!exists) {
					reject('Missing CVV & Card Type')
				}

				validated = cardType === 'amex'
					? StackPay.cvvAmex.test(cvv)
					: StackPay.cvvRegex.test(cvv)

				// Set CVV Validity in order to determine whether to submit API call for payment token
				StackPay.setCvvValidity(validated)

				resolve(validated)
			})
		},

		/**
		 * Determine validity of zip code using regex string
		 * Sets also zipCodeValid variable.
		 * @param {string} zipcode
		 * @returns {Promise<Boolean>}
		 */
		validateZipCode: (zipcode) => {
			return new Promise((resolve, reject) => {

				// Set Zip Code Validity in order to determine whether to submit API call for payment token
				StackPay.setZipCodeValidity(StackPay.zipCodeRegex.test(zipcode))
				resolve(StackPay.zipCodeRegex.test(zipcode))

			})
		},

		/**
		 * Method to use within the Payment Method Token Form
		 * This determines whether we can submit the form.
		 * If all inputs are invalid, then the form must not be submitted
		 * @returns {boolean}
		 */
		determineFormValidity: () => {
			return StackPay.getNameValidity() &&
				StackPay.getCardValidity() &&
				StackPay.getExpirationValidity() &&
				StackPay.getCvvValidity() &&
				StackPay.getZipCodeValidity()
		}

	},

}