<?php if ( ! defined('BASEPATH')) exit('No direct script access allowed');

/*
 * CI-Merchant Library
 *
 * Copyright (c) 2011-2012 Adrian Macneil
 *
 * Permission is hereby granted, free of charge, to any person obtaining a copy
 * of this software and associated documentation files (the "Software"), to deal
 * in the Software without restriction, including without limitation the rights
 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
 * copies of the Software, and to permit persons to whom the Software is
 * furnished to do so, subject to the following conditions:

 * The above copyright notice and this permission notice shall be included in
 * all copies or substantial portions of the Software.

 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
 * THE SOFTWARE.
 */

// Support legacy drivers which extend the CI_Driver class
// All drivers should be updated to extend Merchant_driver instead
// This will be removed in a future version!
if ( ! class_exists('CI_Driver')) get_instance()->load->library('driver');

define('MERCHANT_CONFIG_PATH', realpath(dirname(__FILE__).'/../config'));
define('MERCHANT_DRIVER_PATH', realpath(dirname(__FILE__).'/merchant'));
define('MERCHANT_VENDOR_PATH', realpath(dirname(__FILE__).'/../vendor'));

/**
 * Merchant Class
 *
 * Payment processing for CodeIgniter
 */
class Merchant
{
	public static $CURRENCIES_WITHOUT_DECIMALS = array('JPY');

	public static $NUMERIC_CURRENCY_CODES = array(
		'AUD' => '036',
		'CAD' => '124',
		'EUR' => '978',
		'GBP' => '826',
		'NZD' => '554',
		'USD' => '840',
	);

	private $_driver;

	public function __construct($driver = NULL)
	{
		if ( ! empty($driver))
		{
			$this->load($driver);
		}
	}

	public function __call($function, $arguments)
	{
		if ( ! empty($this->_driver))
		{
			return call_user_func_array(array($this->_driver, $function), $arguments);
		}
	}

	/**
	 * Load the specified driver
	 */
	public function load($driver)
	{
		$this->_driver = $this->_create_instance($driver);
		return $this->_driver !== FALSE;
	}

	/**
	 * Returns the name of the currently loaded driver
	 */
	public function active_driver()
	{
		$class_name = get_class($this->_driver);
		if ($class_name === FALSE) return FALSE;
		return str_replace('Merchant_', '', $class_name);
	}

	/**
	 * Load and create a new instance of a driver.
	 * $driver can be specified either as a class name (Merchant_paypal) or a short name (paypal)
	 */
	private function _create_instance($driver)
	{
		if (stripos($driver, 'merchant_') === 0)
		{
			$driver_class = ucfirst(strtolower($driver));
		}
		else
		{
			$driver_class = 'Merchant_'.strtolower($driver);
		}

		if ( ! class_exists($driver_class))
		{
			// attempt to load driver file
			$driver_path = MERCHANT_DRIVER_PATH.'/'.strtolower($driver_class).'.php';
			if ( ! file_exists($driver_path)) return FALSE;
			require_once($driver_path);

			// did the driver file implement the class?
			if ( ! class_exists($driver_class)) return FALSE;
		}

		// ensure class is not abstract
		$reflection_class = new ReflectionClass($driver_class);
		if ($reflection_class->isAbstract()) return FALSE;

		return new $driver_class();
	}

	public function valid_drivers()
	{
		static $valid_drivers = array();

		if (empty($valid_drivers))
		{
			foreach (scandir(MERCHANT_DRIVER_PATH) as $file_name)
			{
				$driver_path = MERCHANT_DRIVER_PATH.'/'.$file_name;
				if (stripos($file_name, 'merchant_') === 0 AND is_file($driver_path))
				{
					require_once($driver_path);

					// does the file implement an appropriately named class?
					$driver_class = ucfirst(str_replace('.php', '', $file_name));
					if ( ! class_exists($driver_class)) continue;

					// ensure class is not abstract
					$reflection_class = new ReflectionClass($driver_class);
					if ($reflection_class->isAbstract()) continue;

					$valid_drivers[] = str_replace('Merchant_', '', $driver_class);
				}
			}
		}

		return $valid_drivers;
	}

	public function authorize($params)
	{
		return $this->_do_authorize_or_purchase('authorize', $params);
	}

	public function authorize_return($params)
	{
		return $this->_do_authorize_or_purchase('authorize_return', $params);
	}

	public function capture($params)
	{
		return $this->_do_capture_or_refund('capture', $params);
	}

	public function purchase($params)
	{
		return $this->_do_authorize_or_purchase('purchase', $params);
	}

	public function purchase_return($params)
	{
		return $this->_do_authorize_or_purchase('purchase_return', $params);
	}

	public function refund($params)
	{
		return $this->_do_capture_or_refund('refund', $params);
	}

	private function _do_authorize_or_purchase($method, $params)
	{
		$this->_normalize_card_params($params);
		$this->_normalize_currency_params($params);

		try
		{
			// load driver params
			$this->_driver->set_params($params);

			// all payments require amount and currency
			$this->_driver->require_params('amount', 'currency');

			// support drivers using deprecated required_fields array
			if (($method == 'authorize' OR $method == 'purchase') AND
				! empty($this->_driver->required_fields))
			{
				$this->_driver->require_params($this->_driver->required_fields);
			}

			// validate card_no
			$this->_driver->validate_card();

			// begin the actual processing
			return $this->_driver->$method();
		}
		catch (Merchant_exception $e)
		{
			return new Merchant_response(Merchant_response::FAILED, $e->getMessage());
		}
	}

	private function _do_capture_or_refund($method, $params)
	{
		$this->_normalize_currency_params($params);

		try
		{
			// load driver params
			$this->_driver->set_params($params);

			// begin the actual processing
			return $this->_driver->$method();
		}
		catch (Merchant_exception $e)
		{
			return new Merchant_response(Merchant_response::FAILED, $e->getMessage());
		}
	}

	private function _normalize_card_params(&$params)
	{
		// normalize months to 2 digits and years to 4
		if ( ! empty($params['exp_month'])) $params['exp_month'] = sprintf('%02d', (int)$params['exp_month']);
		if ( ! empty($params['exp_year'])) $params['exp_year'] = sprintf('%04d', (int)$params['exp_year']);
		if ( ! empty($params['start_month'])) $params['start_month'] = sprintf('%02d', (int)$params['start_month']);
		if ( ! empty($params['start_year'])) $params['start_year'] = sprintf('%04d', (int)$params['start_year']);

		// normalize card_type to lowercase
		if (isset($params['card_type'])) $params['card_type'] = strtolower($params['card_type']);

		// support deprecated card_name parameter
		if (isset($params['card_name']))
		{
			$params['name'] = $params['card_name'];
		}
		if (isset($params['name']))
		{
			$params['card_name'] = $params['name'];
			// split first/last names
			if (empty($params['first_name']) AND empty($params['last_name']))
			{
				$names = explode(' ', (string)$params['name'], 2);
				$params['first_name'] = $names[0];
				$params['last_name'] = isset($names[1]) ? $names[1] : '';
			}
		}

		// automatically fix incorrect 'uk' country code (which is hard coded in EE)
		if (isset($params['country']) AND strtolower($params['country']) == 'uk')
		{
			$params['country'] = 'gb';
		}

		// support deprecated address parameter
		if (isset($params['address']))
		{
			$params['address1'] = $params['address'];
		}
		elseif (isset($params['address1']))
		{
			$params['address'] = $params['address1'];
		}
	}

	private function _normalize_currency_params(&$params)
	{
		// support deprecated currency_code parameter
		if (isset($params['currency_code']))
		{
			$params['currency'] = $params['currency_code'];
		}
		if (isset($params['currency']))
		{
			// currency should always be uppercase
			$params['currency'] = strtoupper($params['currency']);
			$params['currency_code'] = $params['currency'];
		}
	}

	/**
	 * Deprecated. Please use Merchant_driver::get_request() or
	 * Merchant_driver::post_request() instead.
	 */
	public static function curl_helper($url, $post_data = NULL, $username = NULL, $password = NULL)
	{
		// errors are now thrown as Merchant_exception()
		$response = array('error' => NULL);

		if (is_null($post_data))
		{
			$response['data'] = get_instance()->merchant->get_request($url, $username, $password);
		}
		else
		{
			$response['data'] = get_instance()->merchant->post_request($url, $post_data, $username, $password);
		}

		return $response;
	}

	/**
	 * Redirect the user's browser to a URL.
	 *
	 * @param string $url
	 */
	public static function redirect($url)
	{
		get_instance()->load->helper('url');
		redirect($url);
	}

	/**
	 * Redirect the user's browser to a URL using a POST request.
	 *
	 * @param string $url
	 * @param array $data
	 * @param string $message
	 */
	public static function post_redirect($url, $data, $message = NULL)
	{
		if (empty($message))
		{
			$message = lang('merchant_payment_redirect');
		}

		?><!DOCTYPE html>
<html>
<head><title>Redirecting...</title></head>
<body onload="document.forms[0].submit();">
	<form name="payment" action="<?php echo htmlspecialchars($url); ?>" method="post">
		<p><?php echo htmlspecialchars($message); ?></p>
		<p>
			<?php foreach ($data as $key => $value): ?>
				<input type="hidden" name="<?php echo htmlspecialchars($key); ?>" value="<?php echo htmlspecialchars($value); ?>" />
			<?php endforeach ?>
			<input type="submit" value="Continue" />
		</p>
	</form>
</body>
</html>
<?php
		exit();
	}
}

abstract class Merchant_driver
{
	protected $CI;

	/**
	 * Settings related to the payment gateway.
	 * Accessing this array directly is deprecated.
	 *
	 * @var array
	 */
	protected $settings = array();

	/**
	 * Parameters related to the current payment.
	 *
	 * @var array
	 */
	private $_params = array();

	public function __construct()
	{
		$this->CI =& get_instance();

		// initialize default settings
		foreach ($this->default_settings() as $key => $default)
		{
			if (is_array($default))
			{
				$this->settings[$key] = isset($default['default']) ? $default['default'] : NULL;
			}
			else
			{
				$this->settings[$key] = $default;
			}
		}
	}

	/**
	 * Default settings. This should be overridden by the driver.
	 */
	public abstract function default_settings();

	/**
	 * Initialize the driver settings
	 */
	public function initialize($settings)
	{
		foreach ($this->default_settings() as $key => $default)
		{
			if (isset($settings[$key]))
			{
				// boolean settings must remain booleans
				$this->settings[$key] = is_bool($default) ? (bool)$settings[$key] : $settings[$key];
			}
		}
	}

	/**
	 * All driver settings
	 *
	 * @return array
	 */
	public function settings()
	{
		return $this->settings;
	}

	/**
	 * Setting
	 *
	 * @return  mixed
	 */
	public function setting($key)
	{
		return isset($this->settings[$key]) ? $this->settings[$key] : FALSE;
	}

	public function can_authorize()
	{
		$method = new ReflectionMethod($this, 'authorize');
		return $method->getDeclaringClass()->name !== __CLASS__;
	}

	public function can_capture()
	{
		$method = new ReflectionMethod($this, 'capture');
		return $method->getDeclaringClass()->name !== __CLASS__;
	}

	public function can_refund()
	{
		$method = new ReflectionMethod($this, 'refund');
		return $method->getDeclaringClass()->name !== __CLASS__;
	}

	public function can_return()
	{
		$method = new ReflectionMethod($this, 'purchase_return');
		if ($method->getDeclaringClass()->name !== __CLASS__) return TRUE;

		// try calling deprecated process_return() method instead
		if (method_exists($this, 'process_return')) return TRUE;
		if (method_exists($this, '_process_return')) return TRUE;

		return FALSE;
	}

	public function authorize()
	{
		throw new BadMethodCallException(lang('merchant_invalid_method'));
	}

	public function authorize_return()
	{
		throw new BadMethodCallException(lang('merchant_invalid_method'));
	}

	public function capture()
	{
		throw new BadMethodCallException(lang('merchant_invalid_method'));
	}

	public function purchase()
	{
		// try calling deprecated process() method instead
		if (method_exists($this, 'process'))
		{
			return $this->process($this->_params);
		}

		if (method_exists($this, '_process'))
		{
			return $this->_process($this->_params);
		}

		throw new BadMethodCallException(lang('merchant_invalid_method'));
	}

	public function purchase_return()
	{
		// try calling deprecated process_return() method instead
		if (method_exists($this, 'process_return'))
		{
			return $this->process_return($this->_params);
		}

		if (method_exists($this, '_process_return'))
		{
			return $this->_process_return($this->_params);
		}

		throw new BadMethodCallException(lang('merchant_invalid_method'));
	}

	public function refund()
	{
		throw new BadMethodCallException(lang('merchant_invalid_method'));
	}

	public function param($name)
	{
		return isset($this->_params[$name]) ? $this->_params[$name] : FALSE;
	}

	public function set_params($params)
	{
		$this->_params = array_merge($this->_params, $params);
	}

	public function require_params()
	{
		$args = func_get_args();
		if (empty($args)) return;

		// also accept an array instead of multiple parameters
		if (count($args) == 1 AND is_array($args[0])) $args = $args[0];

		foreach ($args as $name)
		{
			if (empty($this->_params[$name]))
			{
				throw new Merchant_exception(str_replace('%s', lang("merchant_$name"), lang('merchant_required')));
			}
		}
	}

	public function validate_card()
	{
		// skip validation if card_no is empty
		if (empty($this->_params['card_no'])) return;

		if ( ! $this->secure_request())
		{
			throw new Merchant_exception(lang('merchant_insecure_connection'));
		}

		// strip any non-digits from card_no
		$this->_params['card_no'] = preg_replace('/\D/', '', $this->_params['card_no']);

		if ($this->validate_luhn($this->_params['card_no']) == FALSE)
		{
			throw new Merchant_exception(lang('merchant_invalid_card_no'));
		}

		if ($this->param('exp_month') AND $this->param('exp_year') AND
			$this->validate_expiry($this->param('exp_month'), $this->param('exp_year')) == FALSE)
		{
			throw new Merchant_exception(lang('merchant_card_expired'));
		}
	}

	/**
	 * Luhn algorithm number checker - (c) 2005-2008 shaman - www.planzero.org
	 * This code has been released into the public domain, however please
	 * give credit to the original author where possible.
	 *
	 * @return boolean TRUE if the number is valid
	 */
	protected function validate_luhn($number)
	{
		// Set the string length and parity
		$number_length = strlen($number);
		$parity = $number_length % 2;

		// Loop through each digit and do the maths
		$total = 0;
		for ($i = 0; $i < $number_length; $i++)
		{
			$digit = $number[$i];
			// Multiply alternate digits by two
			if ($i % 2 == $parity)
			{
				$digit *= 2;
				// If the sum is two digits, add them together (in effect)
				if ($digit > 9)
				{
					$digit -= 9;
				}
			}
			// Total up the digits
			$total += $digit;
		}

		// If the total mod 10 equals 0, the number is valid
		return ($total % 10 == 0) ? TRUE : FALSE;
	}

	/**
	 * Check whether an expiry date has already passed
	 *
	 * @return bool TRUE if the expiry date is valid
	 */
	protected function validate_expiry($month, $year)
	{
		// subtract 12 hours from current GMT time to avoid potential timezone issues
		// in this rare case we will leave it up to the payment gateway to decide
		$date = getdate(gmmktime() - 43200); // 12*60*60

		if ($year < $date['year'])
		{
			return FALSE;
		}

		if ($year == $date['year'] AND $month < $date['mon'])
		{
			return FALSE;
		}

		return TRUE;
	}

	/**
	 * Returns TRUE if the current request was made using HTTPS
	 */
	protected function secure_request()
	{
		if (isset($_SERVER['HTTP_X_FORWARDED_PROTO']) AND $_SERVER['HTTP_X_FORWARDED_PROTO'] == 'https')
		{
			return TRUE;
		}
		if (empty($_SERVER['HTTPS']) OR strtolower($_SERVER['HTTPS']) == 'off')
		{
			return FALSE;
		}

		return TRUE;
	}

	protected function amount_dollars()
	{
		if (in_array($this->param('currency'), Merchant::$CURRENCIES_WITHOUT_DECIMALS))
		{
			return round($this->param('amount'));
		}

		return sprintf('%01.2f', $this->param('amount'));
	}

	protected function amount_cents()
	{
		if (in_array($this->param('currency'), Merchant::$CURRENCIES_WITHOUT_DECIMALS))
		{
			return round($this->param('amount'));
		}

		return round($this->param('amount') * 100);
	}

	protected function currency()
	{
		return $this->param('currency');
	}

	protected function currency_numeric()
	{
		$code = $this->param('currency');
		return isset(Merchant::$NUMERIC_CURRENCY_CODES[$code]) ? Merchant::$NUMERIC_CURRENCY_CODES[$code] : 0;
	}

	/**
	 * Make a standard HTTP GET request.
	 * This method is only public to support the deprecated Merchant::curl_helper() method,
	 * and will be marked as protected in a future version.
	 *
	 * @param string $url The URL to request
	 * @param string $username
	 * @param string $password
	 * @param array $extra_headers
	 */
	public function get_request($url, $username = NULL, $password = NULL, $extra_headers = NULL)
	{
		$ch = curl_init($url);
		return $this->_do_curl_request($ch, $username, $password, $extra_headers);
	}

	/**
	 * Make a standard HTTP POST request.
	 * This method is only public to support the deprecated Merchant::curl_helper() method,
	 * and will be marked as protected in a future version.
	 *
	 * @param string $url The URL to request
	 * @param mixed $data An optional string or array of form data which will be appended to the URL
	 * @param string $username
	 * @param string $password
	 * @param array $extra_headers
	 */
	public function post_request($url, $data = NULL, $username = NULL, $password = NULL, $extra_headers = NULL)
	{
		$ch = curl_init($url);

		if (is_array($data))
		{
			$data = http_build_query($data);
		}

		curl_setopt($ch, CURLOPT_POST, TRUE);
		curl_setopt($ch, CURLOPT_POSTFIELDS, $data);

		return $this->_do_curl_request($ch, $username, $password, $extra_headers);
	}

	private function _do_curl_request($ch, $username, $password, $extra_headers)
	{
		curl_setopt($ch, CURLOPT_HEADER, FALSE);
		curl_setopt($ch, CURLOPT_RETURNTRANSFER, TRUE);
		curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, TRUE);
		curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2);
		curl_setopt($ch, CURLOPT_CAINFO, MERCHANT_CONFIG_PATH.'/cacert.pem');

		if ($username !== NULL)
		{
			curl_setopt($ch, CURLOPT_USERPWD, $username.':'.$password);
		}

		if ( ! empty($extra_headers))
		{
			curl_setopt($ch, CURLOPT_HTTPHEADER, $extra_headers);
		}

		$response = curl_exec($ch);
		$error = curl_error($ch);

		if ( ! empty($error))
		{
			throw new Merchant_exception($error);
		}

		curl_close($ch);
		return $response;
	}

	/**
	 * Redirect the user's browser to a URL.
	 */
	protected function redirect($url)
	{
		return Merchant::redirect($url);
	}

	/**
	 * Redirect the user's browser to a URL using a POST request.
	 */
	protected function post_redirect($url, $data, $message = NULL)
	{
		return Merchant::post_redirect($url, $data, $message);
	}
}

class Merchant_exception extends Exception {}

class Merchant_response
{
	const AUTHORIZED = 'authorized';
	const COMPLETE = 'complete';
	const FAILED = 'failed';
	const REDIRECT = 'redirect';
	const REFUNDED = 'refunded';

	protected $_status;
	protected $_message;
	protected $_reference;
	protected $_data;
	protected $_redirect_url;
	protected $_redirect_method = 'GET';
	protected $_redirect_message;
	protected $_redirect_data;

	public function __construct($status, $message = NULL, $reference = NULL)
	{
		// support deprecated 'declined' status
		if ($status == 'declined') $status = self::FAILED;

		// always require a valid status
		if ( ! in_array($status, array(self::AUTHORIZED, self::COMPLETE, self::FAILED, self::REDIRECT, self::REFUNDED)))
		{
			throw new InvalidArgumentException(lang('merchant_invalid_status'));
		}

		$this->_status = $status;
		$this->_message = $message;
		$this->_reference = $reference;
	}

	/**
	 * The response status.
	 * One of self::AUTHORIZED, self::COMPLETE, self::FAILED, self::REDIRECT, or self::REFUNDED
	 */
	public function status()
	{
		return $this->_status;
	}

	/**
	 * Whether the request was successful.
	 */
	public function success()
	{
		return $this->_status !== self::FAILED;
	}

	/**
	 * A plain text message returned by the payment gateway.
	 */
	public function message()
	{
		return $this->_message;
	}

	/**
	 * A transaction reference generated by the payment gateway.
	 */
	public function reference()
	{
		return $this->_reference;
	}

	/**
	 * The raw response data returned by the payment gateway.
	 */
	public function data()
	{
		return $this->_data;
	}

	/**
	 * Does this response require a redirect?
	 */
	public function is_redirect()
	{
		return $this->_status === self::REDIRECT;
	}

	/**
	 * If this response requires a redirect, the URL which must be redirected to.
	 */
	public function redirect_url()
	{
		return $this->_redirect_url;
	}

	/**
	 * The HTTP redirect method required (either "GET" or "POST").
	 */
	public function redirect_method()
	{
		return $this->_redirect_method;
	}

	/**
	 * A message to display while redirecting using the POST method.
	 */
	public function redirect_message()
	{
		return $this->_redirect_message;
	}

	/**
	 * If this response requires a POST redirect, the HTTP form data which must be submitted.
	 */
	public function redirect_data()
	{
		return $this->_redirect_data;
	}

	/**
	 * Perform the required redirect. If no redirect is required, returns FALSE.
	 */
	public function redirect()
	{
		if ($this->is_redirect() == FALSE) return FALSE;

		if ('POST' == strtoupper($this->redirect_method()))
		{
			return Merchant::post_redirect($this->redirect_url(), $this->redirect_data(), $this->redirect_message());
		}

		return Merchant::redirect($this->redirect_url());
	}
}

/* End of file ./libraries/merchant/merchant.php */