<?php /*

 Composr
 Copyright (c) Christopher Graham, 2004-2024

 See docs/LICENSE.md for full licensing information.


 NOTE TO PROGRAMMERS:
   Do not edit this file. If you need to make changes, save your changed file to the appropriate *_custom folder
   **** If you ignore this advice, then your website upgrades (e.g. for bug fixes) will likely kill your changes ****

*/

/**
 * @license    http://opensource.org/licenses/cpal_1.0 Common Public Attribution License
 * @copyright  Christopher Graham
 * @package    ecommerce
 */

/**
 * Hook class.
 */
class Hook_payment_gateway_authorize
{
    // Requires:
    //  the API login ID is the "Gateway username" option
    //  the signature key is the "Callback password" option; it may be blank ; if they are different for the live and testing logins then separate them with ";"
    //  the API transaction key is the "Gateway password" option; it may be blank ; if they are different for the live and testing logins then separate them with ";"
    //  the customer ID (Verified Merchant Seal) is the "Gateway VPN username" option ; not applicable for a testing account
    // The subscription button isn't great. The merchant needs to manually go into the Authorize.Net backend and configure the subscription details for the transaction. That's an API limitation. Probably best to use PayPal to be honest, or go through a full PCI compliance and do local payments (which works well).

    protected $api_parameters = null;
    protected $url = null;

    // Note: Authorize.net does not tell us how much it charged for a transaction fee in the API. Set these values as appropriate for your plan.
    // Note: Authorize.net charges an additional $25 monthly gateway fee which is not covered in the software (we only handle transaction fees).
    protected $usd_flat_fee = 0.3;
    protected $percentage_fee = 0.029;

    /**
     * Get a standardised config map.
     *
     * @return array The config
     */
    public function get_config() : array
    {
        return [
            'supports_remote_memo' => false,
            'local_only' => false,
        ];
    }

    /**
     * Calculate the transaction fee for this payment gateway in the currency of the store.
     * This is only used if the payment gateway does not return the fee and transaction fee config options are not set.
     *
     * @param  float $amount The total transaction amount
     * @param  ID_TEXT $type_code The transaction type code
     * @return float The fee
     */
    public function get_transaction_fee(float $amount, string $type_code) : float
    {
        require_code('currency');
        $flat_fee = currency_convert($this->usd_flat_fee, 'USD', get_option('currency'));

        return round((($this->percentage_fee * $amount) + $flat_fee), 2);
    }

    /**
     * Get authorize access detail.
     *
     * @return array A pair: login username, transaction key, signature key
     */
    protected function _get_access_details() : array
    {
        return [get_ecommerce_option('payment_gateway_username'), get_ecommerce_option('payment_gateway_password'), get_ecommerce_option('payment_gateway_digest')];
    }

    /**
     * Calculate fingerprint for form.
     *
     * @param  string $login_id Login ID
     * @param  string $x_tran_key Transaction key
     * @param  float $amount A transaction amount
     * @param  integer $sequence Sequence number
     * @param  integer $timestamp Timestamp
     * @param  ID_TEXT $currency The currency to use
     * @return string Fingerprint
     */
    protected function _get_finger_print(string $login_id, string $x_tran_key, float $amount, int $sequence, int $timestamp, string $currency) : string
    {
        return $this->_hmac($x_tran_key, $login_id . '^' . strval($sequence) . '^' . strval($timestamp) . '^' . float_to_raw_string($amount) . '^' . $currency);
    }

    /**
     * HMAC helper function.
     *
     * @param  string $key Transaction key
     * @param  string $data Data
     * @return string Encrypted data
     */
    protected function _hmac(string $key, string $data) : string
    {
        // RFC 2104 HMAC implementation for php.
        // Creates an md5 HMAC.
        // Eliminates the need to install mhash to compute a HMAC
        // Hacked by Lance Rushing
        $b = 64; // byte length for md5
        if (strlen($key) > $b) {
            $key = pack('H*', md5($key));
        }
        $key = str_pad($key, $b, chr(0x00));
        $ipad = str_pad('', $b, chr(0x36));
        $opad = str_pad('', $b, chr(0x5c));
        $k_ipad = $key ^ $ipad;
        $k_opad = $key ^ $opad;

        return md5($k_opad . pack('H*', md5($k_ipad . $data)));
    }

    /**
     * Get the remote form URL.
     *
     * @return URLPATH The remote form URL
     */
    protected function _get_remote_form_url() : string
    {
        return ecommerce_test_mode() ? 'https://test.authorize.net/gateway/transact.dll' : 'https://secure.authorize.net/gateway/transact.dll';
    }

    /**
     * Get the card/gateway logos and other gateway-required details.
     *
     * @return Tempcode The stuff
     */
    public function get_logos() : object
    {
        $customer_id = get_ecommerce_option('payment_gateway_vpn_username');
        return do_template('ECOM_LOGOS_AUTHORIZE', ['_GUID' => '5b3254b330b3b1719d66d2b754c7a8c8', 'CUSTOMER_ID' => $customer_id]);
    }

    /**
     * Get additional gateway details we want to display.
     *
     * @return Tempcode The stuff
     */
    public function get_payment_processor_links() : object
    {
        return do_template('ECOM_PAYMENT_PROCESSOR_LINKS_AUTHORIZE', ['_GUID' => '563254b330b3b1719d66d2b754c7a8c8']);
    }

    /**
     * Generate a transaction ID / trans-expecting ID.
     *
     * @return string A transaction ID
     */
    public function generate_trans_id() : string
    {
        require_code('crypt');
        return get_secure_random_string();
    }

    /**
     * Make a transaction (payment) button.
     * This is for IPN-style transactions (as opposed to local transactions).
     *
     * @param  ID_TEXT $trans_expecting_id Our internal temporary transaction ID
     * @param  ID_TEXT $type_code The product codename
     * @param  SHORT_TEXT $item_name The human-readable product title
     * @param  ID_TEXT $purchase_id The purchase ID
     * @param  float $price Transaction price in money
     * @param  float $tax Transaction tax in money
     * @param  float $shipping_cost Shipping cost
     * @param  ID_TEXT $currency The currency to use
     * @return Tempcode The button
     */
    public function make_transaction_button(string $trans_expecting_id, string $type_code, string $item_name, string $purchase_id, float $price, float $tax, float $shipping_cost, string $currency) : object
    {
        // Uses Authorize.net's SIM http://www.authorize.net/content/dam/authorize/documents/SIM_guide.pdf

        list($login_id, $transaction_key) = $this->_get_access_details();

        $form_url = $this->_get_remote_form_url();

        $timestamp = time();
        require_code('crypt');
        $sequence = get_secure_random_number();
        $fingerprint = $this->_get_finger_print($login_id, $transaction_key, $price + $tax + $shipping_cost, $sequence, $timestamp, $currency);

        return do_template('ECOM_TRANSACTION_BUTTON_VIA_AUTHORIZE', [
            '_GUID' => 'a21d1d60969eea89a3d77037375221e6',
            'TYPE_CODE' => $type_code,
            'ITEM_NAME' => $item_name,
            'PURCHASE_ID' => $purchase_id,
            'TRANS_EXPECTING_ID' => $trans_expecting_id,
            'FORM_URL' => $form_url,
            'MEMBER_ADDRESS' => $this->_build_member_address(),
            'SEQUENCE' => strval($sequence),
            'TIMESTAMP' => strval($timestamp),
            'FINGERPRINT' => $fingerprint,
            'LOGIN_ID' => $login_id,
            'PRICE' => float_to_raw_string($price),
            'TAX' => float_to_raw_string($tax),
            'SHIPPING_COST' => float_to_raw_string($shipping_cost),
            'AMOUNT' => float_to_raw_string($price + $tax + $shipping_cost), // Authorize.net requires tax and shipping be included in the total
            'IS_TEST' => ecommerce_test_mode(),
            'CUST_ID' => strval(get_member()),
            'CURRENCY' => $currency,
        ]);
    }

    /**
     * Make a subscription (payment) button.
     * This is for IPN-style transactions (as opposed to local transactions).
     *
     * @param  ID_TEXT $trans_expecting_id Our internal temporary transaction ID
     * @param  ID_TEXT $type_code The product codename
     * @param  SHORT_TEXT $item_name The human-readable product title
     * @param  ID_TEXT $purchase_id The purchase ID
     * @param  float $price Transaction price in money
     * @param  float $tax Transaction tax in money
     * @param  ID_TEXT $currency The currency to use
     * @param  integer $length The subscription length in the units
     * @param  ID_TEXT $length_units The length units
     * @set d w m y
     * @return Tempcode The button
     */
    public function make_subscription_button(string $trans_expecting_id, string $type_code, string $item_name, string $purchase_id, float $price, float $tax, string $currency, int $length, string $length_units) : object
    {
        // Uses Authorize.net's DPM http://www.authorize.net/content/dam/authorize/documents/SIM_guide.pdf (DPM not SIM, see Appendix C)

        list($login_id, $transaction_key) = $this->_get_access_details();

        $form_url = $this->_get_remote_form_url();

        $timestamp = time();
        require_code('crypt');
        $sequence = get_secure_random_number();
        $fingerprint = $this->_get_finger_print($login_id, $transaction_key, $price + $tax, $sequence, $timestamp, $currency);

        return do_template('ECOM_SUBSCRIPTION_BUTTON_VIA_AUTHORIZE', [
            '_GUID' => '8c8b9ce1f60323e118da1bef416adff3',
            'TYPE_CODE' => $type_code,
            'ITEM_NAME' => $item_name,
            'PURCHASE_ID' => $purchase_id,
            'TRANS_EXPECTING_ID' => $trans_expecting_id,
            'FORM_URL' => $form_url,
            'MEMBER_ADDRESS' => $this->_build_member_address(),
            'SEQUENCE' => strval($sequence),
            'TIMESTAMP' => strval($timestamp),
            'FINGERPRINT' => $fingerprint,
            'LOGIN_ID' => $login_id,
            'PRICE' => float_to_raw_string($price),
            'TAX' => float_to_raw_string($tax),
            'AMOUNT' => float_to_raw_string($price + $tax),
            'IS_TEST' => ecommerce_test_mode(),
            'CUST_ID' => strval(get_member()),
            'CURRENCY' => $currency,
            'LENGTH' => strval($length),
            'LENGTH_UNITS' => $length_units,
        ]);
    }

    /**
     * Get a member address/etc for use in payment buttons.
     *
     * @return array A map of member address details (form field name => address value)
     */
    protected function _build_member_address() : array
    {
        $member_address = [];

        $shipping_email = '';
        $shipping_phone = '';
        $shipping_firstname = '';
        $shipping_lastname = '';
        $shipping_street_address = '';
        $shipping_city = '';
        $shipping_county = '';
        $shipping_state = '';
        $shipping_post_code = '';
        $shipping_country = '';
        $shipping_email = '';
        $shipping_phone = '';
        $cardholder_name = '';
        $card_type = '';
        $card_number = null;
        $card_start_date_year = null;
        $card_start_date_month = null;
        $card_expiry_date_year = null;
        $card_expiry_date_month = null;
        $card_cv2 = null;
        $billing_street_address = '';
        $billing_city = '';
        $billing_county = '';
        $billing_state = '';
        $billing_post_code = '';
        $billing_country = '';
        get_default_ecommerce_fields(null, $shipping_email, $shipping_phone, $shipping_firstname, $shipping_lastname, $shipping_street_address, $shipping_city, $shipping_county, $shipping_state, $shipping_post_code, $shipping_country, $cardholder_name, $card_type, $card_number, $card_start_date_year, $card_start_date_month, $card_expiry_date_year, $card_expiry_date_month, $card_cv2, $billing_street_address, $billing_city, $billing_county, $billing_state, $billing_post_code, $billing_country, false, false);

        if ($shipping_street_address == '') {
            $street_address = $billing_street_address;
            $city = $billing_city;
            $county = $billing_county;
            $state = $billing_state;
            $post_code = $billing_post_code;
            $country = $billing_country;
        } else {
            $street_address = $shipping_street_address;
            $city = $shipping_city;
            $county = $shipping_county;
            $state = $shipping_state;
            $post_code = $shipping_post_code;
            $country = $shipping_country;
        }

        $member_address = [];
        $member_address['x_first_name'] = $shipping_firstname;
        $member_address['x_last_name'] = $shipping_lastname;
        $member_address['x_address'] = $street_address;
        $member_address['x_city'] = $city;
        $member_address['x_state'] = $state;
        $member_address['x_zip'] = $post_code;
        $member_address['x_country'] = $country;
        $member_address['x_email'] = $shipping_email;
        $member_address['x_phone'] = $shipping_phone;

        return $member_address;
    }

    /**
     * Make a subscription cancellation button.
     *
     * @param  ID_TEXT $purchase_id The purchase ID
     * @return Tempcode The button
     */
    public function make_cancel_button(string $purchase_id) : object
    {
        $cancel_url = build_url(['page' => 'subscriptions', 'type' => 'cancel', 'id' => $purchase_id], get_module_zone('subscriptions'));
        return do_template('ECOM_SUBSCRIPTION_CANCEL_BUTTON_VIA_AUTHORIZE', ['_GUID' => '191d7449161eb5c4f6129cf89e5e8e7e', 'CANCEL_URL' => $cancel_url, 'PURCHASE_ID' => $purchase_id]);
    }

    /**
     * Handle IPN's. The function may produce output, which would be returned to the Payment Gateway. The function may do transaction verification.
     *
     * @param  boolean $silent_fail Return null on failure rather than showing any error message. Used when not sure a valid & finalised transaction is in the POST environment, but you want to try just in case (e.g. on a redirect back from the gateway).
     * @return ?array A long tuple of collected data. Emulates some of the key variables of the PayPal IPN response (null: no transaction; will only return null when $silent_fail is set).
     */
    public function handle_ipn_transaction(bool $silent_fail) : ?array
    {
        if ((file_exists(get_custom_file_base() . '/data_custom/ecommerce.log')) && (cms_is_writable(get_custom_file_base() . '/data_custom/ecommerce.log'))) {
            require_code('files');
            $myfile = cms_fopen_text_write(get_custom_file_base() . '/data_custom/ecommerce.log', true, 'ab');
            fwrite($myfile, loggable_date() . "\n");
            fwrite($myfile, '(hooks/systems/payment_gateway/authorize)->handle_ipn_transaction: called' . "\n");
            fwrite($myfile, "\n\n");
            flock($myfile, LOCK_UN);
            fclose($myfile);
        }

        $trans_expecting_id = preg_replace('# .*$#', '', post_param_string('x_description'));
        $transaction_rows = $GLOBALS['SITE_DB']->query_select('ecom_trans_expecting', ['*'], ['id' => $trans_expecting_id], '', 1);
        if (!array_key_exists(0, $transaction_rows)) {
            if ($silent_fail) {
                return null;
            }
            warn_exit(do_lang_tempcode('MISSING_RESOURCE'));
        }
        $transaction_row = $transaction_rows[0];

        $member_id = $transaction_row['e_member_id'];
        $type_code = $transaction_row['e_type_code'];
        $item_name = $transaction_row['e_item_name'];
        $purchase_id = $transaction_row['e_purchase_id'];

        list($login_id, $transaction_key, $signature_key) = $this->_get_access_details();

        $success = (post_param_string('x_response_code', '') == '1');
        $response_text = post_param_string('x_response_reason_text', '');
        $subscription_id = post_param_string('x_subscription_id', '');
        $is_subscription = ($subscription_id != '');
        $period = '';
        $_txn_id = post_param_string('x_trans_id');
        $reason = post_param_string('x_response_reason_code', '');

        $_amount = post_param_string('x_amount');
        $amount = floatval($_amount);
        $_tax = post_param_string('x_tax');
        $tax = floatval($_tax);
        $_shipping = post_param_string('x_freight');
        $shipping = floatval($_shipping);

        $currency = post_param_string('x_currency_code', get_option('currency'));
        $parent_txn_id = '';
        $pending_reason = '';
        $memo = $transaction_row['e_memo'];
        if ($subscription_id != '') {
            $status = $success ? 'Completed' : 'SCancelled';
        } else {
            $status = $success ? 'Completed' : 'Failed';
        }
        if ($status == 'Failed') {
            return null;
        }

        $txn_id = ($subscription_id != '') ? $subscription_id : $_txn_id;

        // SECURITY: Check hash
        if ($signature_key != '') {
            $hash = post_param_string('x_SHA2_Hash');

            // https://www.authorize.net/content/dam/anet-redesign/documents/SIM_guide.pdf page 73
            $hash_order = [
                post_param_string('x_trans_id', ''),
                post_param_string('x_test_request', ''),
                post_param_string('x_response_code', ''),
                post_param_string('x_auth_code', ''),
                post_param_string('x_cvv2_resp_code', ''),
                post_param_string('x_cavv_response', ''),
                post_param_string('x_avs_code', ''),
                post_param_string('x_method', ''),
                post_param_string('x_account_number', ''),
                post_param_string('x_amount', ''),
                post_param_string('x_company', ''),
                post_param_string('x_first_name', ''),
                post_param_string('x_last_name', ''),
                post_param_string('x_address', ''),
                post_param_string('x_city', ''),
                post_param_string('x_state', ''),
                post_param_string('x_zip', ''),
                post_param_string('x_country', ''),
                post_param_string('x_phone', ''),
                post_param_string('x_fax', ''),
                post_param_string('x_email', ''),
                post_param_string('x_ship_to_company', ''),
                post_param_string('x_ship_to_first_name', ''),
                post_param_string('x_ship_to_last_name', ''),
                post_param_string('x_ship_to_address', ''),
                post_param_string('x_ship_to_city', ''),
                post_param_string('x_ship_to_state', ''),
                post_param_string('x_ship_to_zip', ''),
                post_param_string('x_ship_to_country', ''),
                post_param_string('x_invoice_num', ''),
            ];
            $hash_string = '^' . implode('^', $hash_order) . '^';

            $expected_hash = cms_mb_strtoupper(hash_hmac('sha512', $hash_string, hex2bin($signature_key))); // Authorize.net returns their hash in upper case
            if ($hash != $expected_hash) {
                if ((file_exists(get_custom_file_base() . '/data_custom/ecommerce.log')) && (cms_is_writable(get_custom_file_base() . '/data_custom/ecommerce.log'))) {
                    require_code('files');
                    $myfile = cms_fopen_text_write(get_custom_file_base() . '/data_custom/ecommerce.log', true, 'ab');
                    fwrite($myfile, loggable_date() . "\n");
                    fwrite($myfile, '(hooks/systems/payment_gateway/authorize)->handle_ipn_transaction: FATAL: SHA2 hashing algorithm mismatch. Possibly a hacker?' . "\n");
                    fwrite($myfile, 'Expected hash ' . $expected_hash . ' but instead got ' . $hash . "\n");
                    fwrite($myfile, 'Hash string: ' . $hash_string . "\n");
                    fwrite($myfile, "\n\n");
                    flock($myfile, LOCK_UN);
                    fclose($myfile);
                }

                if ($silent_fail) {
                    return null;
                }
                fatal_ipn_exit(do_lang('PDT_IPN_UNVERIFIED') . ' - ' . json_encode($_POST));
            }
        }

        // Authorize.net always uses 0 for transaction_id in sandbox. But we need a unique ID.
        if ($txn_id == '0') {
            require_code('crypt');
            $txn_id = 'tx-' . str_replace('-', '', get_secure_v1_guid()); // Must keep under 40 characters
        }

        $this->store_shipping_address($trans_expecting_id, $txn_id);

        $transaction_fee = null; // Authorize.net does not tell us how much it will charge for a transaction fee

        // Because Authorize.net returns tax and shipping in the amount (required), we must subtract it.
        $amount -= $tax + $shipping;

        return [$trans_expecting_id, $txn_id, $type_code, $item_name, $purchase_id, $is_subscription, $status, $reason, $amount, $tax, $shipping, $transaction_fee, $currency, $parent_txn_id, $pending_reason, $memo, $period, $member_id];
    }

    /**
     * Store shipping address for a transaction.
     *
     * @param  ID_TEXT $trans_expecting_id Expected transaction ID
     * @param  ID_TEXT $txn_id Transaction ID
     * @return AUTO_LINK Address ID
     */
    public function store_shipping_address(string $trans_expecting_id, string $txn_id) : int
    {
        $shipping_address = [
            'a_firstname' => trim(post_param_string('x_ship_to_first_name', '') . ', ' . post_param_string('x_ship_to_company', ''), ' ,'),
            'a_lastname' => post_param_string('x_ship_to_last_name', ''),
            'a_street_address' => post_param_string('x_ship_to_address', ''),
            'a_city' => post_param_string('x_ship_to_city', ''),
            'a_county' => '',
            'a_state' => post_param_string('x_ship_to_state', ''),
            'a_post_code' => post_param_string('x_ship_to_zip', ''),
            'a_country' => post_param_string('x_ship_to_country', ''),
            'a_email' => post_param_string('x_email', ''),
            'a_phone' => post_param_string('x_phone', ''),
        ];
        return store_shipping_address($trans_expecting_id, $txn_id, $shipping_address);
    }

    /**
     * Find whether the hook auto-cancels (if it does, auto cancel the given subscription).
     *
     * @param  AUTO_LINK $subscription_id ID of the subscription to cancel
     * @return ?boolean True: yes. False: no. (null: cancels via a user-URL-directioning)
     */
    public function auto_cancel(int $subscription_id) : ?bool
    {
        $temp = $GLOBALS['SITE_DB']->query_select_value('ecom_subscriptions', 's_auto_fund_key', ['id' => $subscription_id]);
        $data = unserialize($temp);
        $authorize_subscription_id = $data['id'];

        $this->_set_cancellation_api_parameters($authorize_subscription_id);

        $response = http_get_contents($this->url, ['convert_to_internal_encoding' => true, 'trigger_error' => false, 'post_params' => $this->api_parameters, 'timeout' => 12.0]);

        if ($response !== null) {
            list($result_code, $code, $text, $subscription_id) = $this->_parse_arb_return($response);
            $success = ($result_code == 'OK');
        } else {
            $success = false;
        }

        return $success;
    }

    /**
     * This function defines the parameters needed to make a cancellation API call.
     *
     * @param  AUTO_LINK $subscription_id Subscription ID
     */
    protected function _set_cancellation_api_parameters(int $subscription_id)
    {
        list($login_id, $transaction_key) = $this->_get_access_details();

        if (ecommerce_test_mode()) {
            // URL for test account
            $this->url = 'https://apitest.authorize.net/xml/v1/request.api';
        } else {
            // URL for live account
            $this->url = 'https://api.authorize.net/xml/v1/request.api';
        }

        $this->api_parameters =
            '<ARBCancelSubscriptionRequest xmlns="AnetApi/xml/v1/schema/AnetApiSchema.xsd">' .
                '<merchantAuthentication>' .
                    '<name>' . $login_id . '</name>' .
                    '<transactionKey>' . $transaction_key . '</transactionKey>' .
                '</merchantAuthentication>' .
                '<subscriptionId>' . strval($subscription_id) . '</subscriptionId>' .
            '</ARBCancelSubscriptionRequest>';
    }

    /**
     * Perform a local transaction (a transaction where details are taken in locally and sent to the payment API invisibly-to-the-user).
     *
     * @param  ID_TEXT $trans_expecting_id The transaction ID we have generated for this transaction
     * @param  SHORT_TEXT $cardholder_name Cardholder name
     * @param  SHORT_TEXT $card_type Card Type
     * @set "Visa" "Master Card" "Switch" "UK Maestro" "Maestro" "Solo" "Delta" "American Express" "Diners Card" "JCB"
     * @param  SHORT_TEXT $card_number Card number
     * @param  SHORT_TEXT $card_start_date Card Start date (blank: none)
     * @param  SHORT_TEXT $card_expiry_date Card Expiry date (blank: none)
     * @param  SHORT_TEXT $card_cv2 Card CV2 number (security number)
     * @param  float $amount Transaction amount
     * @param  ID_TEXT $currency The currency to use
     * @param  LONG_TEXT $billing_street_address Street address (billing, i.e. AVS)
     * @param  SHORT_TEXT $billing_city Town/City (billing, i.e. AVS)
     * @param  SHORT_TEXT $billing_county County (billing, i.e. AVS)
     * @param  SHORT_TEXT $billing_state State (billing, i.e. AVS)
     * @param  SHORT_TEXT $billing_post_code Postcode/Zip (billing, i.e. AVS)
     * @param  SHORT_TEXT $billing_country Country (billing, i.e. AVS)
     * @param  SHORT_TEXT $shipping_firstname First name (shipping)
     * @param  SHORT_TEXT $shipping_lastname Last name (shipping)
     * @param  LONG_TEXT $shipping_street_address Street address (shipping)
     * @param  SHORT_TEXT $shipping_city Town/City (shipping)
     * @param  SHORT_TEXT $shipping_county County (shipping)
     * @param  SHORT_TEXT $shipping_state State (shipping)
     * @param  SHORT_TEXT $shipping_post_code Postcode/Zip (shipping)
     * @param  SHORT_TEXT $shipping_country Country (shipping)
     * @param  SHORT_TEXT $shipping_email E-mail address (shipping)
     * @param  SHORT_TEXT $shipping_phone Phone number (shipping)
     * @param  ?integer $length The subscription length in the units. (null: not a subscription)
     * @param  ?ID_TEXT $length_units The length units. (null: not a subscription)
     * @set d w m y
     * @return array A tuple: success (boolean), message (string), raw message (string), transaction ID (string)
     */
    public function do_local_transaction(string $trans_expecting_id, string $cardholder_name, string $card_type, string $card_number, string $card_start_date, string $card_expiry_date, string $card_cv2, float $amount, string $currency, string $billing_street_address, string $billing_city, string $billing_county, string $billing_state, string $billing_post_code, string $billing_country, string $shipping_firstname = '', string $shipping_lastname = '', string $shipping_street_address = '', string $shipping_city = '', string $shipping_county = '', string $shipping_state = '', string $shipping_post_code = '', string $shipping_country = '', string $shipping_email = '', string $shipping_phone = '', ?int $length = null, ?string $length_units = null) : array
    {
        // Uses Authorize.net's AIM
        $result = [false, null, null, null]; // Default until re-set

        $cardholder_name_parts = explode(' ', $cardholder_name);
        if (count($cardholder_name_parts) > 1) {
            $billing_lastname = $cardholder_name_parts[count($cardholder_name_parts) - 1];
            unset($cardholder_name_parts[count($cardholder_name_parts) - 1]);
        } else {
            $billing_lastname = '';
        }
        $billing_firstname = implode(' ', $cardholder_name_parts);

        if ($length === null) {
            // Direct transaction...

            $this->_set_aim_parameters($card_type, $card_number, $card_start_date, $card_expiry_date, $card_cv2, $trans_expecting_id, $amount, $billing_firstname, $billing_lastname, $billing_street_address, $billing_city, $billing_state, $billing_post_code, $billing_country, $shipping_firstname, $shipping_lastname, $shipping_street_address, $shipping_city, $shipping_state, $shipping_post_code, $shipping_country, $shipping_email, $shipping_phone);

            $response_data = http_get_contents($this->url, ['convert_to_internal_encoding' => true, 'trigger_error' => false, 'post_params' => $this->api_parameters, 'timeout' => 12.0]);

            if ($response_data !== null) {
                $response_result = explode($this->api_parameters['x_delim_char'], $response_data);

                /*
                Response Format, a big list of details, 68 in total, but these are the key ones...
                $response_result[0] = Response Code. 1=Approved, 2=Declined, 3=Error
                $response_result[1] = Response Subcode. A code used by the system for internal transaction tracking.
                $response_result[2] = Response Reason Code. A code representing more details about the result of the transaction.
                $response_result[3] = Response Reason Text. Brief description of the result, which corresponds with the Response Reason Code.
                $response_result[6] = Transaction ID
                */

                if (!array_key_exists(1, $response_result)) {
                    $response_result[1] = '';
                }
                if (!array_key_exists(2, $response_result)) {
                    $response_result[2] = '';
                }
                if (!array_key_exists(3, $response_result)) {
                    $response_result[3] = '';
                }
                if (!array_key_exists(6, $response_result)) {
                    $response_result[6] = '';
                }

                $success = ($response_result[0] == 1);
                $message_raw = ($success) ? $response_result[6] : $response_result[2];
                $message = $success ? do_lang_tempcode('ACCEPTED_MESSAGE', do_lang('SUCCESS')) : do_lang_tempcode('DECLINED_MESSAGE', escape_html($response_result[3]));
                $result = [$success, $message, $message_raw, $trans_expecting_id];
            }
        } else {
            // Subscription...

            /*
            Response Format, XML...
            */

            if ($length_units == 'm') {
                $length_units = 'months';
            }
            if ($length_units == 'd') {
                $length_units = 'days';
            }
            if ($length_units == 'y') {
                $length_units = 'months';
                $length = 12 * $length;
            }

            $start_date = date('Y-m-d');

            $this->_set_arb_parameters($card_type, $card_number, $card_start_date, $card_expiry_date, $card_cv2, $start_date, $length, $length_units, $trans_expecting_id, $amount, $billing_firstname, $billing_lastname, $billing_street_address, $billing_city, $billing_state, $billing_post_code, $billing_country, $shipping_firstname, $shipping_lastname, $shipping_street_address, $shipping_city, $shipping_state, $shipping_post_code, $shipping_country, $shipping_email, $shipping_phone);

            $response_data = http_get_contents($this->url, ['convert_to_internal_encoding' => true, 'trigger_error' => false, 'post_params' => $this->api_parameters, 'timeout' => 30.0]);

            if ($response_data !== null) {
                list($result_code, $code, $text, $authorizedotnet_subscription_id) = $this->_parse_arb_return($response_data);

                $success = ($result_code == 'OK');
                $message = $success ? do_lang_tempcode('ACCEPTED_MESSAGE', do_lang('SUCCESS')) : do_lang_tempcode('DECLINED_MESSAGE', escape_html($text));
                $message_raw = $code;

                $transaction_fee = null; // Authorize.net does not tell us how much it will charge for a transaction fee

                $result = [$success, $message, $message_raw, $trans_expecting_id, $transaction_fee];
            }
        }

        return $result;
    }

    /**
     * This function defines the parameters needed to make an Advanced Integration Method (AIM) call.
     *
     * @param  SHORT_TEXT $card_type Card Type
     * @set "Visa" "Master Card" "Switch" "UK Maestro" "Maestro" "Solo" "Delta" "American Express" "Diners Card" "JCB"
     * @param  SHORT_TEXT $card_number Card number
     * @param  SHORT_TEXT $card_start_date Card Start date (blank: none)
     * @param  SHORT_TEXT $card_expiry_date Card Expiry date (blank: none)
     * @param  SHORT_TEXT $card_cv2 Card CV2 number (security number)
     * @param  ID_TEXT $trans_expecting_id Transaction ID
     * @param  float $amount Transaction amount
     * @param  SHORT_TEXT $billing_firstname Cardholder first name
     * @param  SHORT_TEXT $billing_lastname Cardholder last name
     * @param  LONG_TEXT $billing_street_address Street address (billing, i.e. AVS)
     * @param  SHORT_TEXT $billing_city Town/City (billing, i.e. AVS)
     * @param  SHORT_TEXT $billing_state State (billing, i.e. AVS)
     * @param  SHORT_TEXT $billing_post_code Postcode/Zip (billing, i.e. AVS)
     * @param  SHORT_TEXT $billing_country Country (billing, i.e. AVS)
     * @param  SHORT_TEXT $shipping_firstname First name (shipping)
     * @param  SHORT_TEXT $shipping_lastname Last name (shipping)
     * @param  LONG_TEXT $shipping_street_address Street address (shipping)
     * @param  SHORT_TEXT $shipping_city Town/City (shipping)
     * @param  SHORT_TEXT $shipping_state State (shipping)
     * @param  SHORT_TEXT $shipping_post_code Postcode/Zip (shipping)
     * @param  SHORT_TEXT $shipping_country Country (shipping)
     * @param  SHORT_TEXT $shipping_email E-mail address (shipping)
     * @param  SHORT_TEXT $shipping_phone Phone number (shipping)
     */
    protected function _set_aim_parameters(string $card_type, string $card_number, string $card_start_date, string $card_expiry_date, string $card_cv2, string $trans_expecting_id, float $amount, string $billing_firstname, string $billing_lastname, string $billing_street_address, string $billing_city, string $billing_state, string $billing_post_code, string $billing_country, string $shipping_firstname, string $shipping_lastname, string $shipping_street_address, string $shipping_city, string $shipping_state, string $shipping_post_code, string $shipping_country, string $shipping_email, string $shipping_phone)
    {
        // http://www.authorize.net/content/dam/authorize/documents/AIM_guide.pdf

        $this->api_parameters = [];

        list($login_id, $transaction_key) = $this->_get_access_details();

        $this->url = ecommerce_test_mode() ? 'https://test.authorize.net/gateway/transact.dll' : 'https://secure.authorize.net/gateway/transact.dll';

        $this->api_parameters['x_login'] = $login_id;
        $this->api_parameters['x_tran_key'] = $transaction_key;
        $this->api_parameters['x_version'] = '3.1';
        $this->api_parameters['x_type'] = 'AUTH_CAPTURE';
        $this->api_parameters['x_method'] = 'CC';
        $this->api_parameters['x_card_num'] = $card_number;
        $this->api_parameters['x_exp_date'] = $card_expiry_date;
        $this->api_parameters['x_description'] = $trans_expecting_id;
        $this->api_parameters['x_delim_data'] = true;
        $this->api_parameters['x_delim_char'] = '|';
        $this->api_parameters['x_relay_response'] = false;
        $this->api_parameters['x_amount'] = float_to_raw_string($amount);
        $this->api_parameters['x_card_code'] = $card_cv2;
        $this->api_parameters['x_customer_ip'] = get_ip_address();

        if ($billing_firstname != '') {
            $this->api_parameters['x_first_name'] = $billing_firstname;
        }
        if ($billing_lastname != '') {
            $this->api_parameters['x_last_name'] = $billing_lastname;
        }
        if ($billing_city != '') {
            $this->api_parameters['x_city'] = $billing_city;
        }
        if ($billing_state != '') {
            $this->api_parameters['x_state'] = $billing_state;
        }
        if ($billing_post_code != '') {
            $this->api_parameters['x_zip'] = $billing_post_code;
        }
        if ($billing_country != '') {
            $this->api_parameters['x_country'] = $billing_country;
        }

        if ($shipping_firstname != '') {
            $this->api_parameters['x_first_name'] = $shipping_firstname;
        }
        if ($shipping_lastname != '') {
            $this->api_parameters['x_last_name'] = $shipping_lastname;
        }
        if ($shipping_city != '') {
            $this->api_parameters['x_city'] = $shipping_city;
        }
        if ($shipping_state != '') {
            $this->api_parameters['x_state'] = $shipping_state;
        }
        if ($shipping_post_code != '') {
            $this->api_parameters['x_zip'] = $shipping_post_code;
        }
        if ($shipping_country != '') {
            $this->api_parameters['x_country'] = $shipping_country;
        }
        if ($shipping_email != '') {
            $this->api_parameters['x_email'] = $shipping_email;
        }

        require_code('character_sets');
        foreach ($this->api_parameters as $key => &$val) {
            $val = convert_to_internal_encoding($val, get_charset(), 'utf-8');
        }
    }

    /**
     * This function defines the parameters needed to make an ARB (Automated Recurring Billing) call.
     *
     * @param  SHORT_TEXT $card_type Card Type
     * @set "Visa" "Master Card" "Switch" "UK Maestro" "Maestro" "Solo" "Delta" "American Express" "Diners Card" "JCB"
     * @param  SHORT_TEXT $card_number Card number
     * @param  SHORT_TEXT $card_start_date Card Start date (blank: none)
     * @param  SHORT_TEXT $card_expiry_date Card Expiry date (blank: none)
     * @param  SHORT_TEXT $card_cv2 Card CV2 number (security number)
     * @param  SHORT_TEXT $start_date Start date
     * @param  ?integer $length The subscription length in the units. (null: not a subscription)
     * @param  ?ID_TEXT $length_units The length units. (null: not a subscription)
     * @set d w m y
     * @param  ID_TEXT $trans_expecting_id Transaction ID
     * @param  float $amount Transaction amount
     * @param  SHORT_TEXT $billing_firstname Cardholder first name
     * @param  SHORT_TEXT $billing_lastname Cardholder last name
     * @param  LONG_TEXT $billing_street_address Street address (billing, i.e. AVS)
     * @param  SHORT_TEXT $billing_city Town/City (billing, i.e. AVS)
     * @param  SHORT_TEXT $billing_state State (billing, i.e. AVS)
     * @param  SHORT_TEXT $billing_post_code Postcode/Zip (billing, i.e. AVS)
     * @param  SHORT_TEXT $billing_country Country (billing, i.e. AVS)
     * @param  SHORT_TEXT $shipping_firstname First name (shipping)
     * @param  SHORT_TEXT $shipping_lastname Last name (shipping)
     * @param  LONG_TEXT $shipping_street_address Street address (shipping)
     * @param  SHORT_TEXT $shipping_city Town/City (shipping)
     * @param  SHORT_TEXT $shipping_state State (shipping)
     * @param  SHORT_TEXT $shipping_post_code Postcode/Zip (shipping)
     * @param  SHORT_TEXT $shipping_country Country (shipping)
     * @param  SHORT_TEXT $shipping_email E-mail address (shipping)
     * @param  SHORT_TEXT $shipping_phone Phone number (shipping)
     */
    protected function _set_arb_parameters(string $card_type, string $card_number, string $card_start_date, string $card_expiry_date, string $card_cv2, string $start_date, ?int $length, ?string $length_units, string $trans_expecting_id, float $amount, string $billing_firstname, string $billing_lastname, string $billing_street_address, string $billing_city, string $billing_state, string $billing_post_code, string $billing_country, string $shipping_firstname, string $shipping_lastname, string $shipping_street_address, string $shipping_city, string $shipping_state, string $shipping_post_code, string $shipping_country, string $shipping_email, string $shipping_phone)
    {
        // http://www.authorize.net/content/dam/authorize/documents/ARB_guide.pdf

        list($login_id, $transaction_key) = $this->_get_access_details();

        if (ecommerce_test_mode()) {
            // URL for test account
            $this->url = 'https://apitest.authorize.net/xml/v1/request.api';
        } else {
            // URL for live account
            $this->url = 'https://api.authorize.net/xml/v1/request.api';
        }

        $this->api_parameters =
        '<ARBCreateSubscriptionRequest xmlns="AnetApi/xml/v1/schema/AnetApiSchema.xsd">' .
            '<merchantAuthentication>' .
                '<name>' . $login_id . '</name>' .
                '<transactionKey>' . $transaction_key . '</transactionKey>' .
            '</merchantAuthentication>' .
            '<subscription>' .
                '<name>' . $trans_expecting_id . '</name>' .

                '<customer>' .
                    '<id>' . strval(get_member()) . '</id>' .
                    '<email>' . $shipping_email . '</email>' .
                '</customer>' .

                '<paymentSchedule>' .
                    '<interval>' .
                        '<length>' . strval($length) . '</length>' .
                        '<unit>' . $length_units . '</unit>' .
                    '</interval>' .
                    '<startDate>' . $start_date . '</startDate>' .
                    '<totalOccurrences>9999</totalOccurrences>' .
                '</paymentSchedule>' .

                '<amount>' . float_to_raw_string($amount) . '</amount>' .

                '<payment>' .
                    '<creditCard>' .
                        '<cardNumber>' . $card_number . '</cardNumber>' .
                        '<expirationDate>' . $card_expiry_date . '</expirationDate>' .
                        '<cardCode>' . $card_cv2 . '</cardCode>' .
                    '</creditCard>' .
                '</payment>';

        $this->api_parameters .=
                '<billTo>';
        if ($billing_firstname != '') {
            $this->api_parameters .= '<firstName>' . $billing_firstname . '</firstName>';
        }
        if ($billing_lastname != '') {
            $this->api_parameters .= '<lastName>' . $billing_lastname . '</lastName>';
        }
        if ($billing_street_address != '') {
            $this->api_parameters .= '<address>' . $billing_street_address . '</address>';
        }
        if ($billing_city != '') {
            $this->api_parameters .= '<city>' . $billing_city . '</city>';
        }
        if ($billing_state != '') {
            $this->api_parameters .= '<state>' . $billing_state . '</state>';
        }
        if ($billing_post_code != '') {
            $this->api_parameters .= '<zip>' . $billing_post_code . '</zip>';
        }
        if ($billing_country != '') {
            $this->api_parameters .= '<country>' . $billing_country . '</country>';
        }
        $this->api_parameters .=
                '</billTo>';

        $this->api_parameters .=
                '<shipTo>';
        if ($shipping_firstname != '') {
            $this->api_parameters .= '<firstName>' . $shipping_firstname . '</firstName>';
        }
        if ($shipping_lastname != '') {
            $this->api_parameters .= '<lastName>' . $shipping_lastname . '</lastName>';
        }
        if ($shipping_street_address != '') {
            $this->api_parameters .= '<address>' . $shipping_street_address . '</address>';
        }
        if ($shipping_city != '') {
            $this->api_parameters .= '<city>' . $shipping_city . '</city>';
        }
        if ($shipping_state != '') {
            $this->api_parameters .= '<state>' . $shipping_state . '</state>';
        }
        if ($shipping_post_code != '') {
            $this->api_parameters .= '<zip>' . $shipping_post_code . '</zip>';
        }
        if ($shipping_country != '') {
            $this->api_parameters .= '<country>' . $shipping_country . '</country>';
        }
        $this->api_parameters .=
                '</shipTo>';

        $this->api_parameters .=
            '</subscription>' .
        '</ARBCreateSubscriptionRequest>';

        require_code('character_sets');
        $this->api_parameters = convert_to_internal_encoding($this->api_parameters, get_charset(), 'utf-8');
    }

    /**
     * Function to parse ARB Authorize.net response.
     *
     * @param  string $response The response
     * @return array A tuple: Result code (e.g. "OK"), Status Code, Text, Subscription ID
     */
    protected function _parse_arb_return(string $response) : array
    {
        $result_code  = $this->_substring_between($response, '<resultCode>', '</resultCode>');
        $code = $this->_substring_between($response, '<code>', '</code>'); // in <message>
        $text = $this->_substring_between($response, '<text>', '</text>'); // in <message>
        $subscription_id = $this->_substring_between($response, '<subscriptionId>', '</subscriptionId>');

        return [cms_strtoupper_ascii($result_code), $code, $text, $subscription_id];
    }

    /**
     * Helper function for parsing response, gets a substring between two text strings.
     *
     * @param  string $haystack The response
     * @param  string $start Start text
     * @param  string $end End text
     * @return ?string The substring (null: error)
     */
    protected function _substring_between(string $haystack, string $start, string $end) : ?string
    {
        if (strpos($haystack, $start) === false || strpos($haystack, $end) === false) {
            return null;
        }

        $start_position = strpos($haystack, $start) + strlen($start);
        $end_position = strpos($haystack, $end);

        return substr($haystack, $start_position, $end_position - $start_position);
    }
}
