<?php
/**
 * @brief		Braintree Gateway
 * @author		<a href='https://www.invisioncommunity.com'>Invision Power Services, Inc.</a>
 * @copyright	(c) Invision Power Services, Inc.
 * @license		https://www.invisioncommunity.com/legal/standards/
 * @package		Invision Community
 * @subpackage	Nexus
 * @since		5 Dec 2018
 */

namespace IPS\nexus\Gateway;

/* To prevent PHP errors (extending class does not exist) revealing path */
if ( !\defined( '\IPS\SUITE_UNIQUE_KEY' ) )
{
	header( ( isset( $_SERVER['SERVER_PROTOCOL'] ) ? $_SERVER['SERVER_PROTOCOL'] : 'HTTP/1.0' ) . ' 403 Forbidden' );
	exit;
}

/**
 * Braintree Gateway
 * @todo	Prevent people from vaulting duplicate cards/PayPal/Venmo accounts (both at checkout and in account)
 */
class _Braintree extends \IPS\nexus\Gateway
{
	/* !Features */
	
	const SUPPORTS_REFUNDS = TRUE;
	const SUPPORTS_PARTIAL_REFUNDS = TRUE;
	
	/**
	 * Check the gateway can process this...
	 *
	 * @param	$amount			\IPS\nexus\Money		The amount
	 * @param	$billingAddress	\IPS\GeoLocation|NULL	The billing address, which may be NULL if one if not provided
	 * @param	$customer		\IPS\nexus\Customer		The customer (Default NULL value is for backwards compatibility - it should always be provided.)
	 * @param	array			$recurrings				Details about recurring costs
	 * @return	bool
	 */
	public function checkValidity( \IPS\nexus\Money $amount, \IPS\GeoLocation $billingAddress = NULL, \IPS\nexus\Customer $customer = NULL, $recurrings = array() )
	{		
		$settings = json_decode( $this->settings, TRUE );
		
		/* Check we have a merchant account id for the desired currency */
		if ( !array_key_exists( $amount->currency, $settings['merchant_accounts'] ) )
		{
			return FALSE;
		}
		
		/* Method specific checks */
		switch ( $settings['type'] )
		{			
			/* Credit cards */
			case 'card':
				/* Check some methods are accepted */
				if ( !$settings['merchant_accounts'][ $amount->currency ]['cardTypes'] )
				{
					return FALSE;
				}
				break;
				
			/* Venmo */
			case 'venmo':
				/* Requires USD */
				if ( $amount->currency !== 'USD' )
				{
					return FALSE;
				}
				
				/* Requires browser support (unless they already have vaulted accounts) */
				if ( isset( \IPS\Request::i()->cookie['VenmoSupported'] ) and !\IPS\Request::i()->cookie['VenmoSupported'] and !\IPS\Db::i()->select( 'COUNT(*)', 'nexus_customer_cards', array( 'card_member=? AND card_method=?', $customer ? $customer->member_id : \IPS\nexus\Customer::loggedIn()->member_id, $this->id ) )->first() )
				{
					return FALSE;
				}
				break;
				
			/* Apple Pay */
			case 'applepay':
				/* Requires browser support  */
				if ( isset( \IPS\Request::i()->cookie['ApplePaySupported'] ) and !\IPS\Request::i()->cookie['ApplePaySupported'] )
				{
					return FALSE;
				}
				break;
		}
		
		/* If we're still here, we're good */
		return parent::checkValidity( $amount, $billingAddress, $customer, $recurrings );
	}
	
	/**
	 * Can store cards?
	 *
	 * @param	bool	$adminCreatableOnly	If TRUE, will only return gateways where the admin (opposed to the user) can create a new option
	 * @return	bool
	 */
	public function canStoreCards( $adminCreatableOnly = FALSE )
	{
		$settings = json_decode( $this->settings, TRUE );
		
		switch ( $settings['type'] )
		{
			case 'card':
				return (bool) $settings['cards'];
			case 'paypal':
				return $adminCreatableOnly ? FALSE : ( (bool) $settings['paypal_vault'] );
			case 'venmo':
				return $adminCreatableOnly ? FALSE : ( (bool) $settings['venmo_vault'] );
		}
		
		return FALSE;
	}
	
	/**
	 * Admin can manually charge using this gateway?
	 *
	 * @param	\IPS\nexus\Customer	$customer	The customer we're wanting to charge
	 * @return	bool
	 */
	public function canAdminCharge( \IPS\nexus\Customer $customer )
	{
		$settings = json_decode( $this->settings, TRUE );
		
		switch ( $settings['type'] )
		{
			case 'paypal':
			case 'venmo':
				return (bool) \IPS\Db::i()->select( 'COUNT(*)', 'nexus_customer_cards', array( 'card_member=? AND card_method=?', $customer->member_id, $this->id ) )->first();
			
			case 'card':
				return TRUE;
		}
		
		return FALSE;
	}
	
	/* !Payment Gateway */
	
	/**
	 * Should the submit button show when this payment method is shown?
	 *
	 * @return	bool
	 */
	public function showSubmitButton()
	{
		$settings = json_decode( $this->settings, TRUE );
		return !\in_array( $settings['type'], array( 'paypal', 'venmo', 'applepay', 'googlepay' ) );
	}
	
	/**
	 * Payment Screen Fields
	 *
	 * @param	\IPS\nexus\Invoice		$invoice	Invoice
	 * @param	\IPS\nexus\Money		$amount		The amount to pay now
	 * @param	\IPS\nexus\Customer		$member		The member the payment screen is for (if in the ACP charging to a member's card) or NULL for currently logged in member
	 * @param	array					$recurrings	Details about recurring costs
	 * @param	bool					$type		'checkout' means the cusotmer is doing this on the normal checkout screen, 'admin' means the admin is doing this in the ACP, 'card' means the user is just adding a card
	 * @return	array
	 */
	public function paymentScreen( \IPS\nexus\Invoice $invoice, \IPS\nexus\Money $amount, \IPS\nexus\Customer $member = NULL, $recurrings = array(), $type = 'checkout' )
	{				
		$settings = json_decode( $this->settings, TRUE );		
		$clientToken = $this->gateway()->clientToken()->generate( array( 'merchantAccountId' => $settings['merchant_accounts'][ $amount->currency ]['id'], 'version' => '3' ) );
		
		$member = $member ?: \IPS\nexus\Customer::loggedIn();
				
		if ( $settings['type'] === 'paypal' or $settings['type'] === 'venmo' or $settings['type'] === 'applepay' or $settings['type'] === 'googlepay' )
		{
			$vaultAccounts = NULL;
			if ( ( ( $settings['type'] === 'paypal' and $settings['paypal_vault'] ) or ( $settings['type'] === 'venmo' and $settings['venmo_vault'] ) ) and $type != 'card' )
			{
				$vaultAccounts = array();
				if ( $member->member_id )
				{
					foreach( \IPS\Db::i()->select( '*', 'nexus_customer_cards', array( 'card_member=? AND card_method=?', $member->member_id, $this->id ) ) as $vaultAccount )
					{				
						try
						{
							$vaultAccount = \IPS\nexus\Customer\CreditCard::constructFromData( $vaultAccount );
							$vaultAccount->card; // This is just to make the API call now and cache the response so we can catch the exception if one is thrown
							$vaultAccounts[ $vaultAccount->id ] = $vaultAccount;
						}
						catch ( \Exception $e ) {}
					}
				}
			}
			
			if ( $type === 'admin' )
			{
				$options = array();
				foreach ( $vaultAccounts as $id => $account )
				{
					$options[ $account->data ] = $account->card->number;
				}
				$element = new \IPS\Helpers\Form\Radio( $this->id . '_card', NULL, FALSE, array( 'options' => $options ), NULL, NULL, NULL, $this->id . '_card' );
				$element->label = \IPS\Member::loggedIn()->language()->addToStack('braintree_account');
				return array( 'braintree' => $element );
			}
			else
			{
				return array( 'braintree' => new \IPS\Helpers\Form\Custom( $this->id . '_card', NULL, FALSE, array(
					'rowHtml'	=> function( $field ) use ( $clientToken, $vaultAccounts, $invoice, $amount, $settings, $type )
					{
						$shippingAddress = NULL;
						if ( $invoice->shipaddress )
						{
							$shippingAddress = json_encode( array(
								'line1'			=> isset( $invoice->shipaddress->addressLines[0] ) ? $invoice->shipaddress->addressLines[0] : NULL,
								'line2'			=> isset( $invoice->shipaddress->addressLines[1] ) ? $invoice->shipaddress->addressLines[1] : NULL,
								'city'			=> $invoice->shipaddress->city,
								'state'			=> $invoice->shipaddress->region,
								'postalCode'	=> $invoice->shipaddress->postalCode,
								'countryCode'	=> $invoice->shipaddress->country,
								'phone'			=> $invoice->member->cm_phone,
								'recipientName'	=> $invoice->member->cm_name,
							) );
						}
						
						return \IPS\Theme::i()->getTemplate( 'forms', 'nexus', 'global' )->braintree( $clientToken, $settings['type'], $field, $vaultAccounts, $invoice, $amount, $this, $shippingAddress, isset( $settings['venmo_profile'] ) ? $settings['venmo_profile'] : NULL, $type, ( $settings['advanced_fraud'] and $type != 'admin' ), ( $settings['type'] === 'paypal' and $settings['paypal_credit'] and \in_array( $amount->currency, array( 'USD', 'GBP' ) ) and $type === 'checkout' ), $settings['googlepay_merchant'] );
					}
				), NULL, NULL, NULL, $this->id . '_card' ) );
			}
		}
		elseif ( $settings['type'] === 'card' )
		{
			if ( isset( \IPS\Request::i()->convertTokenToNonce ) )
			{
				try
				{
					$card = \IPS\nexus\Customer\CreditCard::load( \IPS\Request::i()->convertTokenToNonce );					
					if ( $card->member->member_id and $card->member->member_id == \IPS\Member::loggedIn()->member_id )
					{
						\IPS\Output::i()->json( array( 'success' => true, 'nonce' => $this->gateway()->paymentMethodNonce()->create( $card->data )->paymentMethodNonce->nonce ) );
					}
					else
					{
						\IPS\Output::i()->json( array( 'success' => false ) );
					}
				}
				catch ( \Exception $e )
				{
					\IPS\Output::i()->json( array( 'success' => false, 'message' => $e->getMessage() ) );
				}
			}
						
			return array( 'card' => new \IPS\nexus\Form\CreditCard( $this->id . '_card', NULL, FALSE, array(
				'types' 		=> $settings['merchant_accounts'][ $amount->currency ]['cardTypes'],
				'attr'			=> array(
					'data-controller'		=> 'nexus.global.gateways.braintree',
					'data-clientToken'		=> $clientToken,
					'data-method'			=> 'card',
					'data-id'				=> $this->id,
					'data-amount'			=> $amount->amount,
					'data-3dsecure'			=> ( $settings['3d_secure'] and $type === 'checkout' ) ? 'true' : 'false',
					'data-email'			=> $member->email,
					'data-billingAddress'	=> json_encode( array(
						'firstName'				=> $invoice->member->cm_first_name,
						'lastName'				=> $invoice->member->cm_last_name,
						'streetAddress'			=> $invoice->billaddress ? implode( ', ', $invoice->billaddress->addressLines ) : NULL,
						'locality'				=> $invoice->billaddress ? $invoice->billaddress->city : NULL,
						'region'				=> $invoice->billaddress ? $invoice->billaddress->region : NULL,
						'postalCode'			=> $invoice->billaddress ? $invoice->billaddress->postalCode : NULL,
						'countryCodeAlpha2'		=> $invoice->billaddress ? $invoice->billaddress->country : NULL,
						'phoneNumber'			=> preg_replace( '/[^0-9]/', '', $invoice->member->cm_phone ),
					) ),
					'data-advanced-fraud'	=> ( $settings['advanced_fraud'] and $type != 'admin' ) ? 'true' : 'false',
				),
				'jsRequired'	=> TRUE,
				'names'			=> FALSE,
				'dummy'			=> TRUE,
				'save'			=> ( $settings['cards'] ) ? $this : NULL,
				'member'		=> $member,
				'loading'		=> TRUE
			) ) );
		}
	}
	
	/**
	 * Authorize
	 *
	 * @param	\IPS\nexus\Transaction					$transaction	Transaction
	 * @param	array|\IPS\nexus\Customer\CreditCard	$values			Values from form OR a stored card object if this gateway supports them
	 * @param	\IPS\nexus\Fraud\MaxMind\Request|NULL	$maxMind		*If* MaxMind is enabled, the request object will be passed here so gateway can additional data before request is made	
	 * @param	array									$recurrings		Details about recurring costs
	 * @param	string|NULL								$source			'checkout' if the customer is doing this at a normal checkout, 'renewal' is an automatically generated renewal invoice, 'manual' is admin manually charging. NULL is unknown
	 * @return	\IPS\DateTime|NULL						Auth is valid until or NULL to indicate auth is good forever
	 * @throws	\LogicException							Message will be displayed to user
	 */
	public function auth( \IPS\nexus\Transaction $transaction, $values, \IPS\nexus\Fraud\MaxMind\Request $maxMind = NULL, $recurrings = array(), $source = NULL )
	{
		$settings = json_decode( $this->settings, TRUE );
						
		/* We need a transaction ID */
		$transaction->save();
		
		/* Do we have a customer profile in Braintree already? */
		$customerId = \IPS\Settings::i()->site_secret_key . '-' . $transaction->member->member_id;

		if( \strlen( $customerId ) > 36 )
		{
			$customerId = substr( $customerId, -36 );
		}

		try
		{
			$braintreeCustomer = $this->gateway()->customer()->find( $customerId );
			
			$customerUpdate = array();
			foreach ( array(
				'email'		=> $transaction->member->email,
				'firstName'	=> $transaction->member->cm_first_name,
				'id'		=> $customerId,
				'lastName'	=> $transaction->member->cm_last_name,
				'phone'		=> $transaction->member->cm_phone
			) as $k => $v ) {
				if ( $braintreeCustomer->$k != $v )
				{
					$customerUpdate[ $k ] = $v;
				}
			}
			
			if ( $customerUpdate )
			{
				$this->gateway()->customer()->update( $customerId, $customerUpdate );
			}
			
		}
		catch ( \Braintree\Exception\NotFound $e )
		{
			$braintreeCustomer = NULL;
		}
				
		/* Init basic data */
		$profiles = $transaction->member->cm_profiles;
		$data = array(
			'merchantAccountId'	=> $settings['merchant_accounts'][ $transaction->amount->currency ]['id'],
			'amount'			=> $transaction->amount->amount,
			'options'			=> array(
				'paypal'							=> array(),
				'submitForSettlement'				=> FALSE,
			),
			'orderId'			=> \substr( \IPS\Settings::i()->site_secret_key, 0, 20 ) . '-' . $transaction->id,
		);
		
		/* Venmo profile */
		if ( $settings['type'] === 'venmo' and isset( $settings['venmo_profile'] ) and $settings['venmo_profile'] )
		{
			$data['options']['venmo']['profileId'] = $settings['venmo_profile'];
		}
		
		/* Add addresses */
		if ( $transaction->invoice->billaddress )
		{
			$billingAddress = array(
				'countryCodeAlpha2'		=> $transaction->invoice->billaddress->country,
				'firstName'				=> $transaction->invoice->member->cm_first_name,
				'lastName'				=> $transaction->invoice->member->cm_last_name,
				'locality'				=> $transaction->invoice->billaddress->city,
				'postalCode'			=> $transaction->invoice->billaddress->postalCode,
				'region'				=> $transaction->invoice->billaddress->region,
				'streetAddress'			=> implode( ', ', $transaction->invoice->billaddress->addressLines )
			);
			
			if ( $braintreeCustomer )
			{
				foreach ( $braintreeCustomer->addresses as $address )
				{
					foreach ( $billingAddress as $k => $v )
					{
						if ( $billingAddress[ $k ] != $address->$k )
						{
							continue 2;
						}
					}
					
					$data['billingAddressId'] = $address->id;
					break;
				}
			}
			
			if ( !isset( $data['billingAddressId'] ) )
			{
				$data['billing'] = $billingAddress;
			}
		}		
		if ( $transaction->invoice->shipaddress )
		{
			$shippingAddress = array(
				'countryCodeAlpha2'		=> $transaction->invoice->shipaddress->country,
				'firstName'				=> $transaction->invoice->member->cm_first_name,
				'lastName'				=> $transaction->invoice->member->cm_last_name,
				'locality'				=> $transaction->invoice->shipaddress->city,
				'postalCode'			=> $transaction->invoice->shipaddress->postalCode,
				'region'				=> $transaction->invoice->shipaddress->region,
				'streetAddress'			=> implode( ', ', $transaction->invoice->shipaddress->addressLines )
			);
			
			if ( $braintreeCustomer )
			{
				foreach ( $braintreeCustomer->addresses as $address )
				{
					foreach ( $shippingAddress as $k => $v )
					{
						if ( $shippingAddress[ $k ] != $address->$k )
						{
							continue 2;
						}
					}
					
					$data['shippingAddressId'] = $address->id;
					break;
				}
			}
			
			if ( !isset( $data['shippingAddressId'] ) )
			{
				$data['shipping'] = $shippingAddress;
			}
		}
		
		/* Add device data and transaction source */
		if ( $source === 'checkout' )
		{
			$data['transactionSource'] = $recurrings ? 'recurring_first' : 'moto';
			if ( \is_array( $_POST[ $this->id . '_card' ] ) and isset( $_POST[ $this->id . '_card' ]['device'] ) )
			{
				$data['deviceData'] = $_POST[ $this->id . '_card' ]['device'];
			}
			if ( isset( $_SERVER['HTTP_USER_AGENT'] ) and mb_strlen( $_SERVER['HTTP_USER_AGENT'] ) <= 255 )
			{
				$data['riskData']['customerBrowser'] = $_SERVER['HTTP_USER_AGENT'];
			}
			if ( $transaction->ip )
			{
				$data['riskData']['customerIp'] = $transaction->ip;
			}
		}
		elseif ( $source === 'renewal' )
		{
			$data['transactionSource'] = 'recurring';
		}
		elseif ( $source === 'manual' )
		{
			$data['transactionSource'] = 'unscheduled';
		}
		
		/* If we're paying the whole invoice, we can add item data... */
		if ( $transaction->amount->amount->compare( $transaction->invoice->total->amount ) === 0 )
		{
			$summary = $transaction->invoice->summary();
			foreach ( $summary['items'] as $item )
			{
				$unitTaxAmount = $item->tax ? $item->price->amount->multiply( new \IPS\Math\Number( number_format( "{$item->tax->rate( $transaction->invoice->billaddress )}", \IPS\nexus\Money::numberOfDecimalsForCurrency( $transaction->amount->currency ), '.', '' ) ) ) : new \IPS\Math\Number('0');
				$data['lineItems'][] = array(
					'description'	=> mb_strlen( $item->name ) > 127 ? mb_substr( $item->name, 0, 124 ) . '...' : $item->name,
					'kind'			=> $item->price->amount->isGreaterThanZero() ? 'debit' : 'credit',
					'name'			=> mb_strlen( $item->name ) > 35 ? mb_substr( $item->name, 0, 32 ) . '...' : $item->name,
					'productCode'	=> $item->id,
					'quantity'		=> $item->quantity,
					'taxAmount'		=> ( new \IPS\nexus\Money( $unitTaxAmount->multiply( new \IPS\Math\Number("{$item->quantity}") ), $transaction->amount->currency ) )->amountAsString(),
					'totalAmount'	=> $item->price->amount->multiply( new \IPS\Math\Number("{$item->quantity}") )->absolute(),
					'unitAmount'	=> $item->price->amount->absolute(),
					'unitTaxAmount'	=> ( new \IPS\nexus\Money( $unitTaxAmount, $transaction->amount->currency ) )->amountAsString()
				);
			}
			
			$data['shippingAmount'] = $summary['shippingTotal']->amountAsString();
			$data['taxAmount'] = $summary['taxTotal']->amountAsString();
			
			$data['options']['paypal']['description'] = mb_strlen( $transaction->invoice->title ) > 127 ? mb_substr( $transaction->invoice->title, 0, 124 ) . '...' : $transaction->invoice->title;
		}
		else
		{
			$data['options']['paypal']['description'] = sprintf( $transaction->member->language()->get('partial_payment_desc'), $transaction->invoice->id );
		}
				
		/* Figure out what we're dealing with */
		$token = NULL;
		$nonce = NULL;
		$save = FALSE;
		if ( \is_string( $values[ $this->id . '_card' ] ) ) // Admin manually charging PayPal/Venmo account
		{
			$token = $values[ $this->id . '_card' ];
		}
		elseif ( $values[ $this->id . '_card' ] instanceof \IPS\nexus\Gateway\Braintree\CreditCard ) // Stored card or PayPal/Venmo account initiated by automatic renewal
		{
			$token = $values[ $this->id . '_card' ]->data;
		}
		elseif ( $values[ $this->id . '_card' ] instanceof \IPS\nexus\CreditCard ) // New card
		{
			$nonce = $values[ $this->id . '_card' ]->token;
			$save = $values[ $this->id . '_card' ]->save;
		}
		elseif ( isset( $values[ $this->id . '_card' ]['token'] ) ) // Apple Pay and Google Pay
		{
			$nonce = $values[ $this->id . '_card' ]['token'];
		}
		elseif ( isset( $values[ $this->id . '_card' ]['stored'] ) ) // PayPal/Venmo (maybe stored or maybe new) initiated by normal checkout 
		{
			if ( mb_substr( $values[ $this->id . '_card' ]['stored'], 0, 6 ) === 'NONCE:' ) // New
			{
				$exploded = explode( ':', $values[ $this->id . '_card' ]['stored'] );
				$nonce = $exploded[2];
				$save = (bool) \intval( $exploded[1] );
			}
			else // Stored
			{
				$token = $values[ $this->id . '_card' ]['stored'];
			}
		}
		else
		{
			throw new \RuntimeException;
		}
				
		/* If we're supposed to be using 3DSecure, check it was verified */
		if ( $source == 'checkout' and $settings['type'] === 'card' and $settings['3d_secure'] )
		{
			try
			{
				$details = $this->gateway()->paymentMethodNonce()->find( $nonce );
				if ( !$details->threeDSecureInfo or ( !$details->threeDSecureInfo->liabilityShifted and $details->threeDSecureInfo->liabilityShiftPossible ) )
				{
					throw new \LogicException();
				}
			}
			catch ( \Braintree\Exception\NotFound $e )
			{
				throw new \LogicException();
			}
		}

		/* Put that into the right places */
		if ( $token )
		{
			$data['paymentMethodToken'] = $token;
		}
		else
		{
			$data['paymentMethodNonce'] = $nonce;

			if ( $save )
			{
				if ( $braintreeCustomer )
				{
					$data['customerId'] = $customerId;
				}
				else
				{
					$data['customer'] = array(
						'email'		=> $transaction->member->email,
						'firstName'	=> $transaction->member->cm_first_name,
						'id'		=> $customerId,
						'lastName'	=> $transaction->member->cm_last_name,
						'phone'		=> $transaction->member->cm_phone
					);
				}
				$data['options']['storeInVaultOnSuccess'] = TRUE;
			}
		}
		
		if ( isset( $data['customer'] ) or isset( $data['customerId'] ) )
		{
			$options['addBillingAddressToPaymentMethod'] = TRUE;
			$options['storeShippingAddressInVault'] = TRUE;
		}
		
		/* Do it! */
		$result = $this->gateway()->transaction()->sale( $data );
		if ( $result->success )
		{
			/* Save the customer ID */
			if ( isset( $result->transaction->customer ) and isset( $result->transaction->customer['id'] ) and !isset( $profiles[ $this->id ] ) )
			{
				$profiles[ $this->id ] = $result->transaction->customer['id'];
				$transaction->member->cm_profiles = $profiles;
				$transaction->member->save();
			}
			
			/* Save the payment method if we've chosen to do so */
			if ( $nonce and $save )
			{
				$storedCard = new \IPS\nexus\Gateway\Braintree\CreditCard;
				$storedCard->member = $transaction->member;
				$storedCard->method = $this;
				$storedCard->data = ( $settings['type'] === 'paypal' ) ? $result->transaction->paypal['token'] : $result->transaction->creditCard['token'];
				$storedCard->save();
			}
			
			/* Set MaxMind data */
			if ( $maxMind )
			{
				if ( $result->transaction->paymentInstrumentType === 'credit_card' )
				{
					if ( $result->transaction->creditCardDetails )
					{
						$maxMind->setCard( $result->transaction->creditCardDetails->maskedNumber );
					}
					else
					{
						$maxMind->setTransactionType('creditcard');
					}
				}
				elseif ( $result->transaction->paymentInstrumentType === 'paypal_account' )
				{
					$maxMind->setTransactionType('paypal');
				}
				else
				{
					$maxMind->setTransactionType('other');
				}
				
				if ( $result->transaction->avsStreetAddressResponseCode )
				{
					$maxMind->setAVS( $result->transaction->avsStreetAddressResponseCode );
				}
				elseif ( $result->transaction->avsPostalCodeResponseCode )
				{
					$maxMind->setAVS( $result->transaction->avsPostalCodeResponseCode );
				}
				
				if ( $result->transaction->cvvResponseCode and \in_array( $result->transaction->cvvResponseCode, array( 'M', 'N' ) ) )
				{
					$maxMind->setCVV( $result->transaction->cvvResponseCode === 'M' );
				}
			}
			
			/* Return */
			$transaction->gw_id = $result->transaction->id;
			return \IPS\DateTime::ts( $result->transaction->authorizationExpiresAt->getTimestamp() );
		}
		else
		{
			if ( isset( $result->transaction ) and isset( $result->transaction->id ) )
			{
				$transaction->gw_id = $result->transaction->id;
			}
			$transaction->status = $transaction::STATUS_REFUSED;
			$extra = $transaction->extra;
			$extra['history'][] = array( 's' => \IPS\nexus\Transaction::STATUS_REFUSED, 'noteRaw' => $result->message );
			$transaction->extra = $extra;
			$transaction->save();
			
			throw new \DomainException( \IPS\Member::loggedIn()->language()->get( static::errorMessageFromCode( ( isset( $result->transaction ) and isset( $result->transaction->processorResponseCode ) ) ? $result->transaction->processorResponseCode : '' ) ) );
		}
	}	
	/**
	 * Void
	 *
	 * @param	\IPS\nexus\Transaction	$transaction	Transaction
	 * @return	void
	 * @throws	\Exception
	 */
	public function void( \IPS\nexus\Transaction $transaction )
	{
		$result = $this->gateway()->transaction()->void( $transaction->gw_id );
		
		if ( !$result->success )
		{
			throw new \LogicException( $result->message );
		}
	}
	
	/**
	 * Capture
	 *
	 * @param	\IPS\nexus\Transaction	$transaction	Transaction
	 * @return	void
	 * @throws	\LogicException
	 */
	public function capture( \IPS\nexus\Transaction $transaction )
	{
		$result = $this->gateway()->transaction()->submitForSettlement( $transaction->gw_id );
		
		if ( !$result->success )
		{
			throw new \LogicException( $result->message );
		}
	}
	
	/**
	 * Refund
	 *
	 * @param	\IPS\nexus\Transaction	$transaction	Transaction to be refunded
	 * @param	float|NULL				$amount			Amount to refund (NULL for full amount - always in same currency as transaction)
	 * @param	string|NULL				$reason			Reason for refund, if applicable
	 * @return	mixed									Gateway reference ID for refund, if applicable
	 * @throws	\Exception
 	 */
	public function refund( \IPS\nexus\Transaction $transaction, $amount = NULL, $reason = NULL )
	{
		$result = $this->gateway()->transaction()->refund( $transaction->gw_id, $amount );
		
		if ( !$result->success )
		{
			throw new \LogicException( $result->message );
		}
	}
	
	/**
	 * Extra data to show on the ACP transaction page
	 *
	 * @param	\IPS\nexus\Transaction	$transaction	Transaction
	 * @param	string					$type			"short" or "full"
	 * @return	string
 	 */
	public function extraData( \IPS\nexus\Transaction $transaction, $type = 'short' )
	{
		if ( !$transaction->gw_id )
		{
			return NULL;
		}
		
		try
		{
			$braintreeData = $this->gateway()->transaction()->find( $transaction->gw_id );
		}
		catch ( \Exception $e )
		{
			return \IPS\Theme::i()->getTemplate( 'transactions', 'nexus', 'admin' )->braintreeData( NULL, 'error' );
		}
		
		return \IPS\Theme::i()->getTemplate( 'transactions', 'nexus', 'admin' )->braintreeData( $braintreeData );
	}
	
	/**
	 * Extra data to show on the ACP transaction page for a dispute
	 *
	 * @param	\IPS\nexus\Transaction	$transaction	Transaction
	 * @param	array					$ref			Dispute log data
	 * @return	string
 	 */
	public function disputeData( \IPS\nexus\Transaction $transaction, $log )
	{
		if ( isset( $log['ref'] ) )
		{
			try
			{
				$response = $this->gateway()->dispute()->find( $log['ref'] );
			}
			catch ( \Exception $e )
			{
				return \IPS\Theme::i()->getTemplate( 'transactions', 'nexus', 'admin' )->braintreeDispute( $transaction, $log, NULL, TRUE );
			}
			return \IPS\Theme::i()->getTemplate( 'transactions', 'nexus', 'admin' )->braintreeDispute( $transaction, $log, $response );
		}
	}

	/**
	 * Run any gateway-specific anti-fraud checks and return status for transaction
	 * This is only called if our local anti-fraud rules have not matched
	 *
	 * @param	\IPS\nexus\Transaction	$transaction	Transaction
	 * @return	string
	 */
	public function fraudCheck( \IPS\nexus\Transaction $transaction )
	{
		try
		{
			$response = $this->gateway()->transaction()->find( $transaction->gw_id );
			if ( isset( $response->riskData ) and isset( $response->riskData->decision ) and $response->riskData->decision === 'Review' )
			{
				return $transaction::STATUS_HELD;
			}
			return $transaction::STATUS_PAID;
		}
		catch ( \Exception $e )
		{
			return $transaction::STATUS_PAID;
		}
	}
	
	/**
	 * URL to view transaction in gateway
	 *
	 * @param	\IPS\nexus\Transaction	$transaction	Transaction
	 * @return	\IPS\Http\Url|NULL
 	 */
	public function gatewayUrl( \IPS\nexus\Transaction $transaction )
	{
		$settings = json_decode( $this->settings, TRUE );
		return \IPS\Http\Url::external( "https://" . ( \IPS\NEXUS_TEST_GATEWAYS ? 'sandbox.' : '' ) . "braintreegateway.com/merchants/{$settings['merchant_id']}/transactions/{$transaction->gw_id}" );
	}
	
	/* !ACP Configuration */
	
	/**
	 * [Node] Add/Edit Form
	 *
	 * @param	\IPS\Helpers\Form	$form	The form
	 * @return	void
	 */
	public function form( &$form )
	{
		$form->addHeader('braintree_basic_settings');
		$form->add( new \IPS\Helpers\Form\Translatable( 'paymethod_name', NULL, TRUE, array( 'app' => 'nexus', 'key' => "nexus_paymethod_{$this->id}" ) ) );
		$form->add( new \IPS\Helpers\Form\Select( 'paymethod_countries', ( $this->id and $this->countries !== '*' ) ? explode( ',', $this->countries ) : '*', FALSE, array( 'options' => array_map( function( $val )
		{
			return "country-{$val}";
		}, array_combine( \IPS\GeoLocation::$countries, \IPS\GeoLocation::$countries ) ), 'multiple' => TRUE, 'unlimited' => '*', 'unlimitedLang' => 'no_restriction' ) ) );
		$this->settings( $form );
	}
	
	/**
	 * Settings
	 *
	 * @param	\IPS\Helpers\Form	$form	The form
	 * @return	void
	 */
	public function settings( &$form )
	{
		$settings = json_decode( $this->settings, TRUE );

		\IPS\Output::i()->jsFiles = array_merge( \IPS\Output::i()->jsFiles, \IPS\Output::i()->js( 'admin_payments.js', 'nexus', 'admin' ) );
		$form->attributes['data-controller'] = 'nexus.admin.payments.braintreeSetup';
		if ( isset( \IPS\Request::i()->_getClientToken ) )
		{
			try
			{
				$_settings = array( 'merchant_id' => \IPS\Request::i()->merchant_id, 'public_key' => \IPS\Request::i()->public_key, 'private_key' => \IPS\Request::i()->private_key );

				$tokens = array();
				foreach ( $this->gateway( $_settings )->merchantAccount()->all() as $merchantAccount )
				{
					$tokens[ (string) $merchantAccount->currencyIsoCode ] = $this->gateway()->clientToken()->generate( array( 'merchantAccountId' => $merchantAccount->id ) );
				}
				
				\IPS\Output::i()->json( array( 'success' => true, 'tokens' => $tokens ) );
			}
			catch ( \Exception $e )
			{
				\IPS\Output::i()->json( array( 'success' => false, 'message' => \IPS\Member::loggedIn()->language()->addToStack('braintree_auth_error') ) );
			}
		}
				
		$form->addHeader('braintree_keys');
		$form->addMessage('braintree_keys_blurb');
		$form->add( new \IPS\Helpers\Form\Text( 'braintree_merchant_id', $settings ? $settings['merchant_id'] : NULL, TRUE ) );
		$form->add( new \IPS\Helpers\Form\Text( 'braintree_public_key', $settings ? $settings['public_key'] : NULL, TRUE ) );
		$form->add( new \IPS\Helpers\Form\Text( 'braintree_private_key', $settings ? $settings['private_key'] : NULL, TRUE ) );
		
		$form->addHeader('braintree_type_header');
		$formId = $this->id ? "form_{$this->id}" : 'gw';
		$form->add( new \IPS\Helpers\Form\Radio( 'braintree_type', isset( $settings['type'] ) ? $settings['type'] : 'paypal', TRUE, array(
			'options'	=> array(
				'paypal'		=> 'braintree_type_paypal',
				'card'			=> 'braintree_type_card',
				'venmo'			=> 'braintree_type_venmo',
				'applepay'		=> 'braintree_type_applepay',
				'googlepay'		=> 'braintree_type_googlepay',
			),
			'toggles'	=> array(
				'paypal'		=> array( 'braintree_paypal_vault', 'braintree_paypal_credit' ),
				'card'			=> array( 'braintree_cards', 'braintree_3d_secure', 'braintree_advanced_fraud' ),
				'venmo'			=> array( 'braintree_venmo_vault', 'braintree_venmo_profile' ),
				'applepay'		=> array( 'braintree_advanced_fraud' ),
				'googlepay'		=> array( 'braintree_googlepay_merchant', 'braintree_advanced_fraud' ),
			)
		) ) );
		$form->add( new \IPS\Helpers\Form\YesNo( 'braintree_paypal_vault', ( $settings and isset( $settings['paypal_vault'] ) ) ? $settings['paypal_vault'] : TRUE, FALSE, array(), NULL, NULL, NULL, 'braintree_paypal_vault' ) );
		$form->add( new \IPS\Helpers\Form\YesNo( 'braintree_paypal_credit', ( $settings and isset( $settings['paypal_credit'] ) ) ? $settings['paypal_credit'] : FALSE, FALSE, array(), NULL, NULL, NULL, 'braintree_paypal_credit' ) );
		$form->add( new \IPS\Helpers\Form\YesNo( 'braintree_venmo_vault', ( $settings and isset( $settings['venmo_vault'] ) ) ? $settings['venmo_vault'] : TRUE, FALSE, array(), NULL, NULL, NULL, 'braintree_venmo_vault' ) );
		$form->add( new \IPS\Helpers\Form\Text( 'braintree_venmo_profile', ( $settings and isset( $settings['venmo_profile'] ) ) ? $settings['venmo_profile'] : NULL, FALSE, array( 'nullLang' => 'braintree_venmo_profile_null' ), NULL, NULL, NULL, 'braintree_venmo_profile' ) );
		$form->add( new \IPS\Helpers\Form\Text( 'braintree_googlepay_merchant', ( $settings and isset( $settings['googlepay_merchant'] ) ) ? $settings['googlepay_merchant'] : NULL, NULL, array(), NULL, NULL, NULL, 'braintree_googlepay_merchant' ) );
		$form->add( new \IPS\Helpers\Form\YesNo( 'braintree_cards', ( $settings and isset( $settings['cards'] ) ) ? $settings['cards'] : TRUE, FALSE, array(), NULL, NULL, NULL, 'braintree_cards' ) );
		$form->add( new \IPS\Helpers\Form\YesNo( 'braintree_3d_secure', isset( $settings['3d_secure'] ) ? $settings['3d_secure'] : TRUE, FALSE, array(), NULL, NULL, NULL, 'braintree_3d_secure' ) );
		$form->add( new \IPS\Helpers\Form\YesNo( 'braintree_advanced_fraud', isset( $settings['advanced_fraud'] ) ? $settings['advanced_fraud'] : FALSE, FALSE, array(), NULL, NULL, NULL, 'braintree_advanced_fraud' ) );
		
		$form->addHeader('braintree_webhook_header');
		$form->addMessage( 'braintree_webhook', '', TRUE, 'braintree_webhook' );
		\IPS\Member::loggedIn()->language()->words["braintree_webhook"] = sprintf( \IPS\Member::loggedIn()->language()->get('braintree_webhook'), (string) \IPS\Http\Url::internal( 'applications/nexus/interface/gateways/braintree.php', 'interface' ) );
	}
		
	/**
	 * Test Settings
	 *
	 * @param	array	$settings	Settings
	 * @return	array
	 * @throws	\InvalidArgumentException
	 */
	public function testSettings( $settings )
	{
		parse_str( urldecode( \IPS\Request::i()->braintree_features ), $features );
		
		try
		{
			$merchantAccounts = $this->gateway( $settings )->merchantAccount()->all();
			
			foreach ( $merchantAccounts as $merchantAccount )
			{
				if ( $settings['type'] != 'paypal' or isset( $features[ (string) $merchantAccount->currencyIsoCode ] ) and $features[ (string) $merchantAccount->currencyIsoCode ]['paypal'] === 'true' )
				{					
					$settings['merchant_accounts'][ (string) $merchantAccount->currencyIsoCode ]['id'] = $merchantAccount->id;
					
					if ( $settings['type'] == 'card' )
					{
						$settings['merchant_accounts'][ (string) $merchantAccount->currencyIsoCode ]['cardTypes'] = array();
						
						foreach ( $features[ (string) $merchantAccount->currencyIsoCode ]['cardTypes'] as $type )
						{				
							switch ( $type )
							{
								case 'Visa':
									$settings['merchant_accounts'][ (string) $merchantAccount->currencyIsoCode ]['cardTypes'][] = \IPS\nexus\CreditCard::TYPE_VISA;
									break;
								case 'American Express':
									$settings['merchant_accounts'][ (string) $merchantAccount->currencyIsoCode ]['cardTypes'][] =  \IPS\nexus\CreditCard::TYPE_AMERICAN_EXPRESS;
									break;
								case 'MasterCard':
									$settings['merchant_accounts'][ (string) $merchantAccount->currencyIsoCode ]['cardTypes'][] = \IPS\nexus\CreditCard::TYPE_MASTERCARD;
									break;
								case 'Discover':
									$settings['merchant_accounts'][ (string) $merchantAccount->currencyIsoCode ]['cardTypes'][] =  \IPS\nexus\CreditCard::TYPE_DISCOVER;
									break;
								case 'JCB':
									$settings['merchant_accounts'][ (string) $merchantAccount->currencyIsoCode ]['cardTypes'][] =  \IPS\nexus\CreditCard::TYPE_JCB;
									break;
							}
						}
					}
				}
			}
		}
		catch ( \Exception $e )
		{
			throw new \InvalidArgumentException( \IPS\Member::loggedIn()->language()->addToStack('braintree_auth_error') );
		}		
		
		return $settings;
	}
	
	/* !Utility Methods */
	
	/**
	 * @brief	$gateway	Braintree gateway
	 */
	protected $_gateway;
	
	/**
	 * Get Braintree gateway
	 *
	 * @return	\Braintree_Gateway
	 */
	public function gateway( $settings = NULL )
	{
		if ( !$this->_gateway )
		{
			require_once \IPS\ROOT_PATH . '/applications/nexus/sources/Gateway/Braintree/lib/autoload.php';
			
			$settings = $settings ?: json_decode( $this->settings, TRUE );
			
			$this->_gateway = new \Braintree_Gateway( array(
				'environment'	=> \IPS\NEXUS_TEST_GATEWAYS ? 'sandbox' : 'production',
				'merchantId'	=> $settings['merchant_id'],
				'publicKey'		=> $settings['public_key'],
				'privateKey'	=> $settings['private_key']
			) );
		}
		
		return $this->_gateway;
	}
	
	/**
	 * Customer-readable error message from processor code
	 *
	 * @param	string	$code	Processor response code
	 * @return	string
	 */
	public static function errorMessageFromCode( $code )
	{
		$errorMessage = 'gateway_err';
		if ( \in_array( $code, array( '2000', '2001', '2002', '2003', '2012', '2013', '2014', '2015', '2019', '2020', '2021', '2027', '2038', '2039', '2041', '2043', '2044', '2046', '2050', '2053', '2054', '2057', '2059', '2060', '2071', '2074', '2075', '2082', '2083', '2086', '2098', '2099' ) ) )
		{
			$errorMessage = 'card_refused';
		}
		elseif ( \in_array( $code, array( '2004', '2005', '2006', '2008', '2010', '2072', '2093' ) ) )
		{
			$errorMessage = 'card_information_invalid';
		}
		elseif ( \in_array( $code, array( '2007', '2009', '2022', '2029', '2032', '2047', '2051', '2061', '2062', '2076', '2084' ) ) )
		{
			$errorMessage = 'payment_refused';
		}
		return $errorMessage;
	}

	
}