ArtifactPaymentBuilder.js

const {ArtifactFile} = require("oip-index")
const {Artifact} = require("oip-index")

/**
 * A payment builder that calculates exchange rates, balances, conversion costs, and which coin to use for payment
 */
class ArtifactPaymentBuilder {
	/**
	 * Create a new ArtifactPaymentBuilder
	 * @param  {Wallet} wallet   - A live OIP-HDMW logged in wallet
	 * @param  {Artifact} artifact - The Artifact related to the Payment you wish to make
	 * @param  {ArtifactFile|number} amount - The amount you wish to pay (`tip`), or the ArtifactFile you wish to pay for (`view` & `buy`)
	 * @param  {string} type     - The type of the purchase, either `tip`, `view`, or `buy`
	 * @param  {string} [coin=undefined]   - The Coin you wish to pay with
	 * @param  {string} [fiat="usd"]   - The Fiat you wish to `tip` in (if amount was a number and NOT an ArtifactFile) default: "usd"
	 * @return {ArtifactPaymentBuilder}
	 */
	constructor(wallet, artifact, amount, type, coin = undefined, fiat = "usd"){
		this._wallet = wallet;
		this._type = type;
		this._artifact = artifact;
		this._amount = amount;
		this._coin = coin;
		this._fiat = fiat;
	}
	/**
	 * Get Payment Amount. Uses constructor variables to get payment amount based off ArtifactFile or amount parameter.
	 * @return {Number} payment_amount
	 */
	getPaymentAmount(){
		switch (this._type) {
			case "view":
				if (this._amount instanceof ArtifactFile) {
					return this._amount.getSuggestedPlayCost()
				} else { 
					throw new Error("Must provide valid ArtifactFile");
				}
			case "buy":
				if (this._amount instanceof ArtifactFile) {
					return this._amount.getSuggestedBuyCost()
				} else {
					throw new Error("Must provide valid ArtifactFile");
				}
			case "tip":
				if (typeof this._amount === "number") {
					return this._amount;
				} else {
					throw new Error("Amount must be valid number");
				}
			default:
				throw new Error("Must have type of either 'buy', 'view', or 'tip'")
		}
	}
	/**
	 * Get Artifact Payment Addresses (to know what coins are supported)
	 * @param {Artifact} [artifact] - Get the payment addresses of a given artifact... if no artifact is given, it will use the artifact given in the constructor
	 * @return {Object}
	 * @example
	 * let APB = new ArtifactPaymentBuilder(wallet, artifact, artifactFile, "view")
	 * APB.getPaymentAddresses()
	 *
	 * //returns
	 * { bitcoin: "19HuaNprtc8MpG6bmiPoZigjaEu9xccxps" }
	 */
	getPaymentAddresses(artifact){
		let art = artifact || this._artifact

		let ticker_payment_addresses = art.getPaymentAddresses()

		let name_payment_addresses = {}

		for (let ticker in ticker_payment_addresses){
			let name = this.tickerToName(ticker)

			name_payment_addresses[name] = ticker_payment_addresses[ticker]
		}

		return name_payment_addresses
	}
	/**
	 * Get Artifact Payment Address
	 * @param {string|Array.<string>} coins - A string or array of strings you wish to get the payment addresses for
	 * @param {Artifact} [artifact] - Get the payment addresses of a given artifact... if no artifact is given, it will use the artifact given in the constructor
	 * @return {Object}
	 * @example
	 * let APB = new ArtifactPaymentBuilder(undefined, artifact)
	 * APB.getPaymentAddress(["bitcoin"])
	 *
	 * //returns
	 * { bitcoin: "19HuaNprtc8MpG6bmiPoZigjaEu9xccxps" }
	 */
	getPaymentAddress(coins, artifact){
		let art = artifact || this._artifact

		let ticker_payment_address = art.getPaymentAddress(this.nameToTicker(coins))

		let name_payment_address = {}

		for (let ticker in ticker_payment_address){
			let name = this.tickerToName(ticker)

			name_payment_address[name] = ticker_payment_address[ticker]
		}

		return name_payment_address
	}
	/**
	 * Internal function used for both nameToTicker and tickerToName
	 * @param  {String|Array.<String>} coins - The Coins names/tickers you would like to swap
	 * @param  {String} from_type - Either "ticker" or "name"
	 * @param  {String} to_type - Either "ticker" or "name"
	 * @return {String|Array.<String>} Returns the converted tickers/names
	 */
	_swapCoinTickerName(coins, from_type, to_type){
		// Get all coins supported by the wallet
		let wallet_coins = this._wallet.getCoins()

		// Get all of the networks from those coins
		let coin_networks = []
		for (let w_coin in wallet_coins){
			coin_networks.push(wallet_coins[w_coin].getCoinInfo())
		}

		// Create name/ticker pairs from the networks
		let name_pairs = coin_networks.map((network) => {
			return {
				name: network.name.toLowerCase(), 
				ticker: network.ticker.toLowerCase()
			}
		})

		// Function to swap ticker/name
		let swap = (coin) => {
			for (let pair of name_pairs){
				if (pair[from_type] === coin)
					return pair[to_type]
			}

			return coin
		}

		// Handle if `coins` is an array
		if (Array.isArray(coins)) {
			let swapped_array = []

			for (let coin of coins) {
				swapped_array.push(swap(coin))
			}

			return swapped_array
		}

		// Handle if `coins` is only a string
		return swap(coins)
	}
	/**
	 * Name to Ticker (only supports bitcoin, litecoin, and flo currently
	 * @param {(string|Array.<string>)} coin_names - Names of coins
	 * @return {(string|Array.<string>)}
	 */
	nameToTicker(coin_names){
	   return this._swapCoinTickerName(coin_names, "name", "ticker")
	}
	/**
	 * Ticker to name (only supports btc, ltc, and flo currently
	 * @param {(string|Array.<string>)} coin_tickers - Coin tickers
	 * @return {(string|Array.<string>)}
	 */
	tickerToName(coin_tickers){
		return this._swapCoinTickerName(coin_tickers, "ticker", "name")
	}
	/**
	 * getSupportedCoins retrieves the coins the Artifact accepts as payment
	 * @param {string|Array.<string>} [preferred_coins] - An array of coins you would prefer to use
	 * @param {Artifact} [artifact] - An Artifact to get the addresses from. If nothing is passed in, it will attempt to use the constructor's Artifact.
	 * @returns {string|Array.<string>} An array of coins that the Artifact accepts as payment. If Artifact does not support coin input, an empty array will be returned
	 */
	getSupportedCoins(preferred_coins, artifact) {
		let artifact_payment_addresses = this.getPaymentAddresses(artifact)

		let artifact_supported_coins = [];

		// Add all the coin names to the array
		for (let coin in artifact_payment_addresses) {
			artifact_supported_coins.push(coin)
		}

		if (typeof preferred_coins === "string"){
			// Check if the preferred coin is in the supported coins array, if so, return it
			if (artifact_supported_coins.indexOf(preferred_coins) !== -1)
				return preferred_coins
		} else if (Array.isArray(preferred_coins)){
			// Match preferred coins array
			let supported_preferred = []

			for (let coinname of preferred_coins)
				if (artifact_supported_coins.indexOf(coinname) !== -1)
					supported_preferred.push(coinname)
			
			if (supported_preferred.length >= 1)
				return supported_preferred
		}

		return artifact_supported_coins
	}
	/**
	 * Convert fiat price to crypto price using live exchange_rates
	 * @param {Object} exchange_rates - The exchange rates retreived from the Wallet 
	 * @param {number} fiat_amount    - The amount you wish to get the conversion cost for
	 * @returns {Object} conversion_costs
	 * @example
	 *  let exchange_rates = await APB.getExchangeRates(wallet)
	 *  let conversion_costs = await APB.fiatToCrypto(exchange_rates, .00012);
	 * //returns
	 * {
	 *      "coin_name": expect.any(Number),
	 *      ...
	 * }
	 */
	fiatToCrypto(exchange_rates, fiat_amount){
		let conversion_costs = {};
		for (let coin in exchange_rates) {
			//this filters out coins that don't have a balance
			if (typeof exchange_rates[coin] === "number") {
				//@ToDo: add support for multiple payments (currently only accepts a single amount)
				conversion_costs[coin] = fiat_amount / exchange_rates[coin];
			}
		}
	   
		return conversion_costs
	}
	/**
	 * Picks a coin with enough balance in our wallet to spend (default to flo, then litecoin, then bitcoin last)
	 * @param  {object} coin_balances    - Key value pairs [coin][balance]. See: getBalances()
	 * @param  {object} conversion_costs    - Key value pairs [coin][conversion cost]. See: convertCosts()
	 * @param {string} [preferred_coin] - Preferred coin to pay with
	 * @return {string|Object} - A string with the selected coin that has enough balance to pay with or an object containing an error status and response
	 * @example
	 * let APB = new ArtifactPaymentBuilder(wallet, artifact, artifactFile, "view")
	 * APB.coinPicker(coin_balances, conversion_costs)
	 *
	 * //returns
	 * "bitcoin" || {error: true, response: "function coinPicker could not get coin with sufficient balance"}
	 */
	coinPicker(coin_balances, conversion_costs, preferred_coin){
		let selected_coin;
		let usableCoins = [];

		// Get coins that we can use to send
		for (let coin in coin_balances) {
			if (typeof coin_balances[coin] === "number" && typeof conversion_costs[coin] === "number") {
				if (coin_balances[coin] >= conversion_costs[coin]) {
					usableCoins.push(coin)
				}
			}
		}

		// If no coins were matched, then return an error
		if (!usableCoins.length) {
			return {
				error: true, 
				response: "Unable to find coins that we are able to pay with!"
			}
		}

		// If we are able to use the selected coin, pass back that for use
		if (usableCoins.includes(preferred_coin)) {
			return preferred_coin
		}

		// Next, try to match based on preference order
		let coin_preferences = ["flo", "litecoin", "bitcoin"]

		for (let coin_preference of coin_preferences){
			if (usableCoins.includes(coin_preference))
				return coin_preference
		}

		// If we still haven't matched the coin yet, then just use the coin with the highest balance
		let highestAmount = 0;
		let coinWithHighestAmount;

		for (let coin of usableCoins) {
			if (coin_balances[coin] >= highestAmount) {
				highestAmount = coin_balances[coin];
				coinWithHighestAmount = coin;
			}
		}

		selected_coin = coinWithHighestAmount;

		return selected_coin
	}
	/**
	 * This function is used to get the proper payment address, payment amount (in crypto cost), and the coin to use to pay.
	 * @param {string} [coin] - The coin you want to pay with
	 * @returns {Promise.<Object>} Returns a Promise that resolves to the payment address, payment amount, and payment coin. If it fails, it will return an object with `success: false`, and the error.
	 */
	async getPaymentAddressAndAmount(coin){
		// @ToDo: Save variables to local state
		// @ToDo: Return error objects {error: true, err: e, msg: "message for error"}

		//Step 1.a: Determine amount to pay
		
		// Get the Artifact payment cost
		let payment_amount
		try {
			payment_amount = this.getPaymentAmount();
		} catch (err) {
			return {
				success: false,
				error_type: "ARTIFACT_NO_PAYMENT_AMOUNT",
				msg: "Unable to get amount to pay!",
				err: new Error("Unable to get amount to pay! \n" + err)
			}
		}

		// Check what coins are supported by the Artifact
		let supported_coins 
		try {
			supported_coins = this.getSupportedCoins(coin, this._artifact);
		} catch(err) {
			return {
				success: false,
				error_type: "ARTIFACT_PAYMENT_COINS_LOOKUP",
				msg: "Unable to get Supported Coins!",
				err: new Error("Unable to get Supported Coins! \n" + err)
			}
		}

		// Throw an error if we were unable to get supported coins for the Artifact
		if (!supported_coins.length){
			return {
				success: false,
				error_type: "ARTIFACT_NO_PAYMENT_COINS",
				msg: "No Coins supported by passed Artifact!",
				err: new Error("No Coins supported by passed Artifact!")
			}
		}

		// Step 2: Get exchange rates for supported_coins
		let exchange_rates

		try {
			exchange_rates = await this._wallet.getExchangeRates({
				fiat: this._fiat,
				coins: supported_coins
			})
		} catch (err) {
			return {
				success: false,
				error_type: "WALLET_EXCHANGE_RATE_LOOKUP",
				msg: `Could not get exchange rates from wallet for ${supported_coins}`,
				err: new Error(`Could not get exchange rates from wallet for ${supported_coins} \n` + err)
			}
		}

		// Step 3: Convert the file/tip costs using the exchange_rates
		let conversion_costs
		try {
			conversion_costs = await this.fiatToCrypto(exchange_rates, payment_amount);
		} catch (err) {
			return {
				success: false,
				error_type: "FIAT_TO_CRYPTO",
				msg: "Could not convert Fiat amounts to Crypto amounts",
				err: new Error("Could not convert Fiat amounts to Crypto amounts \n" + err)
			}
		}

		// Step 4 (this step can be running while Step 3 is running)
		let coin_balances
		try {
			// First attempt to check if we have sufficient balance based on what
			// has already been discovered.
			coin_balances = await this._wallet.getCoinBalances({
				discover: false,
				coins: supported_coins
			});
		} catch (err) {
			return {
				success: false,
				error_type: "WALLET_BALANCE_LOOKUP",
				msg: "Could not get current balances from Wallet!",
				err: new Error("Could not get current balances from Wallet! \n" + err)
			}
		}

		// Step 5: Select a coin that the Artifact supports, and that we have enough
		// wallet balance for.
		let payment_coin = this.coinPicker(coin_balances, conversion_costs, coin)

		// Check if we failed to select a coin
		if (!payment_coin || payment_coin.error) {
			try {
				// If we failed to select a coin based on the already discovered balance, 
				// then do a new discovery for the wallet balance
				coin_balances = await this._wallet.getCoinBalances({
					discover: true,
					coins: supported_coins
				});
			} catch (err) {
				return {
					success: false,
					error_type: "WALLET_BALANCE_LOOKUP",
					msg: "Unable to discover balances from Wallet!",
					err: new Error("Unable to discover balances from Wallet! \n" + err)
				}
			}

			// Using the new coin_balances grabbed, try to get a coin to use again
			payment_coin = this.coinPicker(coin_balances, conversion_costs, coin)

			// Check if there is still an error trying to pay
			if (!payment_coin || payment_coin.error) {
				return {
					success: false,
					error_type: "PAYMENT_COIN_SELECT",
					msg: payment_coin.response,
					err: new Error(payment_coin.response)
				}
			}
		}

		// If we were able to select a coin to pay with, grab the matching address
		// from the Artifact
		let payment_address_object = this.getPaymentAddress(payment_coin)
		let payment_address = payment_address_object[payment_coin]

		if (!payment_address){
			return {
				success: false,
				error_type: "NO_PAYMENT_ADDRESS",
				msg: `Unable to get payment address for ${payment_coin}`,
				err: new Error(`Unable to get payment address for ${payment_coin}`)
			}
		}

		// Grab the amount that we should pay in the specific crypto
		const amount_to_pay = conversion_costs[payment_coin];

		if (!amount_to_pay){
			return {
				success: false,
				error_type: "NO_AMOUNT_TO_PAY",
				msg: `Unable to get payment amount for ${payment_coin} from ${conversion_costs}`,
				err: new Error(`Unable to get payment amount for ${payment_coin} from ${conversion_costs}`)
			}
		}

		// Set the variables to local storage
		this.payment_coin = payment_coin
		this.payment_address = payment_address
		this.amount_to_pay = amount_to_pay

		return {
			success: true,
			payment_address, 
			amount_to_pay, 
			payment_coin
		}
	}
	/**
	 * The pay function is used to do the final sending of a payment. If the processing has been preformed (i.e. if getPaymentAddressAndAmount has been called), it will send the payment using the already looked up information
	 * @return {Promise<string>} Returns a Promise that will resolve to the txid of the sent payment.
	 */
	async pay(){
		if (!this.payment_address && !this.amount_to_pay && !this.payment_coin){
			try {
				let response = await this.getPaymentAddressAndAmount()

				if (!response.success){
					throw response.err
				}
			} catch(e) {
				throw new Error("Unable to process payment! \n" + e)
			}
		}

		let txid
		try {
			txid = await this.sendPayment(this.payment_address, this.amount_to_pay, this.payment_coin)
		} catch(err) {
			throw new Error(`Unable to send payment to ${this.payment_address} for ${this.amount_to_pay} using ${this.payment_coin}! \n` + err)
		}

		return txid
	}
	/**
	 * Send the Payment to the Payment Addresses using the selected coin from coinPicker() for the amount calculated
	 * @param {string} payment_address      -The addresses you wish to send money to
	 * @param {number} amount_to_pay                       -The amount you wish to pay in crypto
	 * @param {string} [selected_coin]                     -The coin you wish to spend with. If no coin is given, function will try to match address with a coin.
	 * @returns {Promise} A promise that resolves to a txid if the tx went through or an error if it didn't
	 */
	async sendPayment(payment_address, amount_to_pay, selected_coin){
		// Don't discover since we already did that in the previous methods
		let payment_options = {
			discover: false
		}

		let send_to = {};
		send_to[payment_address] = amount_to_pay;

		payment_options.to = send_to

		if (selected_coin) 
			payment_options.coin = selected_coin

		let txid
		try {
			txid = await this._wallet.sendPayment(payment_options)
		} catch (err) {
			throw new Error("Unable to send payment! \n" + err)
		}

		return txid
	}
}

export default ArtifactPaymentBuilder;