StackPay.js

import templateStyles from './cardTemplateStyles'

/**
 *
 * @type {{setCCHtml: (function(*): *), apiKey: string, getNameValidity: (function(): boolean), getExpirationValidity: (function(): boolean), visaRegex: RegExp, masterCardRegex: RegExp, getApiUrl: (function(): string), apiUrl: string, setExpirationValidity: (function(*): *), cvvValid: boolean, dinersRegex: RegExp, getApiKey: (function(): string), readENVTextFile: (function(String): Promise<unknown>), zipCodeRegex: RegExp, setEnv: Window.StackPay.setEnv, setAPIUrl: (function(*): *), ccHtml: string, getCvvValidity: (function(): boolean), setZipCodeValidity: (function(*): *), nameRegex: RegExp, amexRegex: RegExp, nameValid: boolean, getCardValidity: (function(): boolean), zipCodeValid: boolean, getCardType: (function(): string), fetchHtml: (function(string): Promise<HTMLMarkup>), expirationValid: boolean, s3Url: string, cardIsValid: boolean, setCvvValidity: (function(*): *), monthYearRegex: RegExp, setApiKey: (function(string): Promise<String>), verifyKey: (function(string, string): Promise<void>), jcbRegex: RegExp, setCardValidity: (function(*): *), getCCHtml: (function(): string), cvvAmex: RegExp, appendCardHtml: (function(string): Promise<HTMLObjectElement>), cvvRegex: RegExp, cardType: string, discoverRegex: RegExp, getPaymentMethodToken: (function(Object): Promise<Object>), env: string, getZipCodeValidity: (function(): boolean), getS3Url: (function(): string), setS3Url: (function(*): *), serializeFormData: (function(Object): {zipcode: *, cvv: *, name: *, cardType: *, expiration: *, cardNumber: *}), elements: {createAchElement: (function(): Promise<HTMLObjectElement>), validateZipCode: (function(string): Promise<Boolean>), validateVisa: (function(string): boolean), expirationValidDigits: (function(string): *), validateMasterCard: (function(string): boolean), validateJCB: (function(string): boolean), validateName: (function(string): Promise<Boolean>), validateDinersCard: (function(string): boolean), validateCardNumber: (function(string): Promise<Object>), validateCVV: (function(string, string): Promise<Boolean>), determineFormValidity: (function(): *), create: (function(string, Object=): Promise<void>), validateDiscover: (function(string): boolean), validateCardExpiration: (function(string): Promise<unknown>), validateAmex: (function(string): boolean), createCardElement: (function(): Promise<HTMLObjectElement>)}, setCardType: (function(*): *), setNameValidity: (function(*): *)}}
 */
export class StackPay {
	constructor() {

		/**
		 * the API Public Key furnished by Stack Pay
		 * @type {string}
		 */
		this.apiKey =  ""

		/**
		 * The environment local, sandbox, or production
		 * @type {string}
		 */
		this.env =  ''

		/**
		 * S3 bucket URL
		 * @type {string}
		 */
		this.s3Url = ''

		/**
		 * URL for Stack Pay API
		 * @type {string}
		 */
		this.apiUrl = ''

		/**
		 * Contains the HTML for the card template
		 * @type {string}
		 */
		this.ccHtml = ''

		/**
		 * Variable containing Card Type
		 * String containing type of card being passed into the form
		 * @type {string}
		 */
		this.cardType = ''

		/**
		 * Boolean that receives the name validation result
		 * @type {boolean}
		 */
		this.nameValid = false

		/**
		 * Boolean that receives the card validation result
		 * @type {boolean}
		 */
		this.cardIsValid = false

		/**
		 * Boolean that receives the card expiration validation result
		 * @type {boolean}
		 */
		this.expirationValid = false

		/**
		 * Boolean that receives the CVV validation result
		 * @type {boolean}
		 */
		this.cvvValid = false

		/**
		 * Boolean that receives the Zip Code validation result
		 * @type {boolean}
		 */
		this.zipCodeValid = false

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

		/**
		 * Regex that validates user first and last name
		 * @type {RegExp}
		 */
		this.nameRegex= /^[a-zA-Z]{2 }(?!.*--)[a-zA-Z- ]*[a-zA-Z]$/i

		/**
		 * Regex that validates expiry date
		 * Further validation exists in validateCardExpiration()
		 * @type {RegExp}
		 */
		this.monthYearRegex= /^\d\d\d\d$/

		/**
		 * Regex that validates CVV
		 * @type {RegExp}
		 */
		this.cvvRegex= /^[0-9]{3}$/

		/**
		 * Regex that validates CVV for Amex cards
		 * @type {RegExp}
		 */
		this.cvvAmex= /^[0-9]{4}$/

		/**
		 * Regex that validates Zip Codes
		 * @type {RegExp}
		 */
		this.zipCodeRegex= /^\d{5}$|^\d{5}-\d{4}$/

		/**
		 * Regex that validates Visa cards
		 * @type {RegExp}
		 */
		this.visaRegex= /^4[0-9]{12}(?:[0-9]{3})?/

		/**
		 * Regex that validates Mastercard
		 * @type {RegExp}
		 */
		this.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}$/

		/**
		 * Regex that validates Amex cards
		 * @type {RegExp}
		 */
		this.amexRegex= /^3[47][0-9]{13}$/i

		/**
		 * Regex that validates Discover cards
		 * @type {RegExp}
		 */
		this.discoverRegex= /^6(?:011|5[0-9]{2})[0-9]{12}$/

		/**
		 * Regex that validates Diners cards
		 * @type {RegExp}
		 */
		this.dinersRegex= /^3(?:0[0-5]|[68][0-9])[0-9]{11}$/i

		/**
		 * Regex that validates JCB cards
		 * @type {RegExp}
		 */
		this.jcbRegex= /^(?:2131|1800|35\d{3})\d{11}$/
	}

	/**
	 * 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)))
		})
	}

	/**
	 * Set the S3 Bucket URL
	 * @param url
	 * @returns {string}
	 */
	setS3Url = (url) => {
		return StackPay.s3Url = url
	}

	/**
	 * Gets the S3 Bucket URL
	 * @returns {string}
	 */
	getS3Url = () => {
		return StackPay.s3Url
	}

	/**
	 * Set Stack Pay API URL
	 * @param apiUrl
	 * @returns {string}
	 */
	setAPIUrl = (apiUrl) => {
		return StackPay.apiUrl = apiUrl
	}

	/**
	 * Returns the Stack Pay API URL
	 * @returns {string}
	 */
	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)
		})
	}

	/**
	 * Returns the API Public Key that was passed into the verifyKey()
	 * @returns {string}
	 */
	getApiKey = () => {
		return StackPay.apiKey
	}

	/**
	 * Set the Credit Card Template HTML in order to load the CC
	 * inputs into the card-element
	 * @param html
	 * @returns {HTMLElement}
	 */
	setCCHtml = (html) => {
		return StackPay.ccHtml = html
	}

	/**
	 * Returns the Credit Card HTML inputs
	 * @returns {HTMLElement}
	 */
	getCCHtml = () => {
		return StackPay.ccHtml
	}

	/**
	 * Set boolean result of name after validation
	 * @param validity
	 * @returns {boolean}
	 */
	setNameValidity = (validity) => {
		return StackPay.nameValid = validity
	}

	/**
	 * Returns boolean result of name validity
	 * @returns {boolean}
	 */
	getNameValidity = () => {
		return StackPay.nameValid
	}

	/**
	 * Sets the credit card type being entered into the form
	 * @param type
	 * @returns {string}
	 */
	setCardType = (type) => {
		return StackPay.cardType = type
	}

	/**
	 * Returns credit card type
	 * @returns {string}
	 */
	getCardType = () => {
		return StackPay.cardType
	}

	/**
	 * Sets boolean result of credit card validation
	 * @param validity
	 * @returns {boolean}
	 */
	setCardValidity = (validity) => {
		return StackPay.cardIsValid = validity
	}

	/**
	 * Returns boolean representing whether card is valid or not
	 * @returns {boolean}
	 */
	getCardValidity = () => {
		return StackPay.cardIsValid
	}

	/**
	 * Sets boolean result of expiration validity
	 * @param validity
	 * @returns {boolean}
	 */
	setExpirationValidity = (validity) => {
		return StackPay.expirationValid = validity
	}

	/**
	 * Returns boolean result representing expiration date validity
	 * @returns {boolean}
	 */
	getExpirationValidity = () => {
		return StackPay.expirationValid
	}

	/**
	 * Set boolean value representing CVV validity
	 * @param validity
	 * @returns {boolean}
	 */
	setCvvValidity = (validity) => {
		return StackPay.cvvValid = validity
	}

	/**
	 * Returns boolean representing CVV validity
	 * @returns {boolean}
	 */
	getCvvValidity = () => {
		return StackPay.cvvValid
	}

	/**
	 * Set boolean value representing zip code validity
	 * @param validity
	 * @returns {boolean}
	 */
	setZipCodeValidity = (validity) => {
		return StackPay.zipCodeValid = validity
	}

	/**
	 * Returns boolean value representing zip code validity
	 * @returns {boolean}
	 */
	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))
		})

	}



	/**
	 * 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()
	}

}

export default StackPay