<?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_ccbill
{
    // https://ccbill.com/doc/background-post
    // https://ccbill.com/doc/dynamic-pricing-user-guide
    // Requires:
    //  you have to contact support to enable dynamic pricing and generate the encryption key for your account
    //  You must set the "Approval URL" and "Denial URL" in Sub-account basic info for BOTH sub-accounts (see tut_ecommerce documentation)
    //  the "Account ID" (a 6-digit number given to you) is the "Gateway username"
    //  the two 4-digit "Subaccount IDs" are the "Gateway VPN username" (comma-delimited with single transactions first, recurring transactions second).
    //  your Salt key is the "Gateway digest code".
    //  your Flexform ID is the "Special identifier". You can optionally enter two values separated by a comma; the first one will be used for simple transactions and the second for recurring transactions.

    // CCBill rates depend on how high of risk a website's business is. You should probably set the correct rates as per https://ccbill.com/doc/pricing-and-fees .
    // CCBill will typically return how much the merchant receives for a transaction which the software will therefore calculate the transaction fee
    protected $usd_flat_fee = 0.55;
    protected $percentage_fee = 0.039;
    protected $percentage_subscription_fee = 0.02;

    protected $length_unit_to_days = [
        'd' => 1,
        'w' => 7,
        'm' => 30,
        'y' => 365,
    ];

    protected $currency_numeric_to_alphabetic_code = [
        // Currencies supported by CCBill
        840 => 'USD',
        978 => 'EUR',
        826 => 'GBP',
        124 => 'CAD',
        36 => 'AUD',
        392 => 'JPY',
    ];

    protected $currency_alphabetic_to_numeric_code = [
        // Currencies supported by CCBill
        'USD' => 840,
        'EUR' => 978,
        'GBP' => 826,
        'CAD' => 124,
        'AUD' => 36,
        'JPY' => 392,
    ];

    /**
     * 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'));

        $transaction_fee = ($this->percentage_fee * $amount) + $flat_fee;

        // CCBill charges an additional 2% for recurring transactions (subscriptions)
        list($details, $product_object) = find_product_details($type_code);
        if ($details !== null && $details['type'] == PRODUCT_SUBSCRIPTION) {
            $transaction_fee += ($amount * $this->percentage_subscription_fee);
        }

        return round($transaction_fee, 2);
    }

    /**
     * Get the CCBill account ID.
     *
     * @return string The answer
     */
    protected function get_account_id() : string
    {
        return get_ecommerce_option('payment_gateway_username');
    }

    /**
     * Get the CCBill sub-account ID.
     *
     * @param  boolean $recurring Whether to get the sub account for recurring transactions
     * @return string The answer
     */
    protected function get_sub_account_id(bool $recurring = false) : string
    {
        $accounts = explode(',', get_ecommerce_option('payment_gateway_vpn_username'));
        if ($recurring && array_key_exists(1, $accounts)) {
            return $accounts[1];
        }
        return $accounts[0];
    }

    /**
     * Get the CCBill Flexform ID.
     *
     * @param  boolean $recurring Whether to get the Flexform for recurring transactions
     * @return string The answer
     */
    protected function get_flexform_id(bool $recurring = false) : string
    {
        $flexforms = explode(',', get_ecommerce_option('payment_gateway_special_identifier'));
        if ($recurring && array_key_exists(1, $flexforms)) {
            return $flexforms[1];
        }
        return $flexforms[0];
    }

    /**
     * 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
    {
        if (!isset($this->currency_alphabetic_to_numeric_code[$currency])) {
            warn_exit(do_lang_tempcode('UNRECOGNISED_CURRENCY', 'ccbill', escape_html($currency)));
        }
        $currency = strval($this->currency_alphabetic_to_numeric_code[$currency]);

        $payment_address = $this->get_account_id();
        $form_url = 'https://api.ccbill.com/wap-frontflex/flexforms/' . $this->get_flexform_id();
        $account_num = $this->get_account_id();
        $subaccount_num = $this->get_sub_account_id();
        $form_name = $this->get_flexform_id();

        // CCBill oddly requires us to pass this parameter for single transactions,
        // this will show up as a confusing "$X.XX for 99 days" message to customers on the CCBill form.
        // To fix this - you need to set up a "custom dynamic description" which removes that message, by contacting CCBill support.
        $form_period = '99';
        $digest = md5(float_to_raw_string($price + $tax + $shipping_cost) . $form_period . $currency . get_ecommerce_option('payment_gateway_digest'));

        return do_template('ECOM_TRANSACTION_BUTTON_VIA_CCBILL', [
            '_GUID' => '24a0560541cedd4c45898f4d19e99249',
            'TYPE_CODE' => $type_code,
            'ITEM_NAME' => $item_name,
            'PURCHASE_ID' => $purchase_id,
            'TRANS_EXPECTING_ID' => $trans_expecting_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),
            'CURRENCY' => $currency,
            'PAYMENT_ADDRESS' => $payment_address,
            'FORM_URL' => $form_url,
            'MEMBER_ADDRESS' => $this->_build_member_address(),
            'ACCOUNT_NUM' => $account_num,
            'SUBACCOUNT_NUM' => $subaccount_num,
            'FORM_NAME' => $form_name,
            'FORM_PERIOD' => $form_period,
            'DIGEST' => $digest,
        ]);
    }

    /**
     * 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
    {
        if (!isset($this->currency_alphabetic_to_numeric_code[$currency])) {
            warn_exit(do_lang_tempcode('UNRECOGNISED_CURRENCY', 'ccbill', escape_html($currency)));
        }
        $currency = strval($this->currency_alphabetic_to_numeric_code[$currency]);

        $payment_address = $this->get_account_id();
        $form_url = 'https://api.ccbill.com/wap-frontflex/flexforms/' . $this->get_flexform_id(true);
        $account_num = $this->get_account_id();
        $subaccount_num = $this->get_sub_account_id(true);
        $form_name = $this->get_flexform_id(true);

        $form_period = strval($length * $this->length_unit_to_days[$length_units]);
        $digest = md5(float_to_raw_string($price + $tax) . $form_period . float_to_raw_string($price + $tax) . $form_period . '99' . $currency . get_ecommerce_option('payment_gateway_digest')); // formPrice.formPeriod.formRecurringPrice.formRecurringPeriod.formRebills.currencyCode.salt

        return do_template('ECOM_SUBSCRIPTION_BUTTON_VIA_CCBILL', [
            '_GUID' => 'f8c174f38ae06536833f1510027ba233',
            'TYPE_CODE' => $type_code,
            'ITEM_NAME' => $item_name,
            'PURCHASE_ID' => $purchase_id,
            'TRANS_EXPECTING_ID' => $trans_expecting_id,
            'LENGTH' => strval($length),
            'LENGTH_UNITS' => $length_units,
            'PRICE' => float_to_raw_string($price),
            'TAX' => float_to_raw_string($tax),
            'AMOUNT' => float_to_raw_string($price + $tax),
            'CURRENCY' => $currency,
            'PAYMENT_ADDRESS' => $payment_address,
            'FORM_URL' => $form_url,
            'MEMBER_ADDRESS' => $this->_build_member_address(),
            'ACCOUNT_NUM' => $account_num,
            'SUBACCOUNT_NUM' => $subaccount_num,
            'FORM_NAME' => $form_name,
            'FORM_PERIOD' => $form_period,
            'DIGEST' => $digest,
        ]);
    }

    /**
     * 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
    {
        $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['customer_fname'] = $shipping_firstname;
        $member_address['customer_lname'] = $shipping_lastname;
        $member_address['address1'] = $street_address;
        $member_address['city'] = $city;
        $member_address['state'] = $state;
        $member_address['zipcode'] = $post_code;
        $member_address['country'] = $country;
        $member_address['email'] = $shipping_email;
        $member_address['username'] = is_guest() ? '' : $GLOBALS['FORUM_DRIVER']->get_username(get_member());

        return $member_address;
    }

    /**
     * Make a subscription cancellation button.
     * This is for IPN-style transactions (as opposed to local transactions).
     *
     * @param  ID_TEXT $purchase_id The purchase ID
     * @return Tempcode The button
     */
    public function make_cancel_button(string $purchase_id) : object
    {
        return do_template('ECOM_SUBSCRIPTION_CANCEL_BUTTON_VIA_CCBILL', ['_GUID' => 'f1aaed809380c3fdca22728393eaef75', '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
    {
        // Do not use IPN for CCBill unless PDT is disabled
        if (get_value('enable_ccbill_pdt', '0') == '1') {
            return null;
        }

        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/ccbill)->handle_ipn_transaction: Called' . "\n");
            fwrite($myfile, "\n\n");
            flock($myfile, LOCK_UN);
            fclose($myfile);
        }

        $event_type = get_param_string('eventType');
        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/ccbill)->handle_ipn_transaction: eventType is ' . $event_type . "\n");
            fwrite($myfile, "\n\n");
            flock($myfile, LOCK_UN);
            fclose($myfile);
        }

        if ($event_type != 'NewSaleSuccess') {
            return null; // Failed transaction
        }

        // Load transaction information
        $trans_expecting_id = post_param_string('X-customPurchaseId');
        $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'];

        // Read in additional post parameters
        $is_subscription = (post_param_integer('X-customIsSubscription') == 1);
        $status = 'Completed';
        $reason = post_param_integer('reasonForDeclineCode', 0);
        $pending_reason = '';
        $memo = '';
        $_amount = post_param_string('billedInitialPrice');
        $_recurring = post_param_string('billedRecurringPrice');
        $amount = ($_amount == '') ? null : floatval($_amount);
        $_currency = post_param_integer('billedCurrencyCode', 0);
        $currency = ($_currency === 0) ? get_option('currency') : $this->currency_numeric_to_alphabetic_code[$_currency];
        $txn_id = post_param_string('transactionId', $this->generate_trans_id());
        $parent_txn_id = '';
        $period = post_param_string('initialPeriod', '99');

        // SECURITY: Digest Validation
        if (get_ecommerce_option('payment_gateway_digest') != '') {
            $response_digest = post_param_string('dynamicPricingValidationDigest', null);

            // This is supposed to work according to CCBill, but their response digest is actually the same as our form digest
            /*
            $subscription_id = post_param_string('subscriptionId', '');
            $denial_id = post_param_string('denialId', '');
            $success_response_digest = md5($subscription_id . '1' . get_ecommerce_option('payment_gateway_digest'));
            $denial_response_digest = md5($denial_id . '0' . get_ecommerce_option('payment_gateway_digest'));
            $success = ($success_response_digest === $response_digest);
            if (($response_digest !== $success_response_digest) && ($response_digest !== $denial_response_digest)) {
            */
            if ($is_subscription) {
                $form_digest = md5($_amount . $period . $_recurring . $period . '99' . strval($_currency) . get_ecommerce_option('payment_gateway_digest'));
            } else {
                $form_digest = md5($_amount . $period . strval($_currency) . get_ecommerce_option('payment_gateway_digest'));
            }

            if ($response_digest != $form_digest) {
                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/ccbill)->handle_ipn_transaction: FATAL: Response digest mismatch. Possibly a hacker?' . "\n");
                    // fwrite($myfile, 'Response digest: Got ' . (($response_digest !== null) ? $response_digest : 'NULL') . ', but expected ' . $success_response_digest . ' (success) or ' . $denial_response_digest . ' (denial)' . "\n");
                    fwrite($myfile, 'Response digest: Got ' . (($response_digest !== null) ? $response_digest : 'NULL') . ', but expected ' . $form_digest . "\n");
                    fwrite($myfile, 'IP address: ' . get_ip_address() . "\n");
                    fwrite($myfile, "\n\n");
                    flock($myfile, LOCK_UN);
                    fclose($myfile);
                }
                if ($silent_fail) {
                    return null;
                }
                fatal_ipn_exit(do_lang('PDT_IPN_UNVERIFIED')); // Hacker?!!!
            }
        } else {
            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/ccbill)->handle_ipn_transaction: WARN: skipped digest validation because Gateway digest code was not set.' . "\n");
                fwrite($myfile, "\n\n");
                flock($myfile, LOCK_UN);
                fclose($myfile);
            }
        }

        // Determine transaction fee: accountingInitialPrice is how much the merchant will actually receive, so subtract that from the transaction amount
        $_accounting_amount = post_param_string('accountingInitialPrice', null);
        if (($_accounting_amount !== null) && ($_accounting_amount != '')) {
            $transaction_fee = ($amount) - floatval($_accounting_amount);
        } else {
            $transaction_fee = null;
        }

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

        // CCBill does not return tax / shipping information; we must manually remove this from $amount using the information from the transaction
        $tax = $transaction_row['e_tax'];
        $shipping = $transaction_row['e_shipping'];
        $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];
    }

    /**
     * Handle PDT's. The function may produce output, which would be returned to the Payment Gateway. The function may do transaction verification.
     *
     * @return ?array A long tuple of collected data. Emulates some of the key variables of the PayPal PDT response (null: no transaction, error, or PDT not configured).
     */
    public function handle_pdt_transaction() : ?array
    {
        // Do not use PDT for CCBill unless explicitly enabled; it is unreliable as we cannot check for duplicate transactions
        if (get_value('enable_ccbill_pdt', '0') != '1') {
            return null;
        }

        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/ccbill)->handle_pdt_transaction: Called' . "\n");
            fwrite($myfile, "\n\n");
            flock($myfile, LOCK_UN);
            fclose($myfile);
        }

        $subscription_id = get_param_string('subscriptionId', '');
        $denial_id = get_param_string('denialId', '');
        if ($denial_id != '') {
            return null; // Failed transaction
        }

        // Load transaction information
        $trans_expecting_id = get_param_string('customPurchaseId');
        $transaction_rows = $GLOBALS['SITE_DB']->query_select('ecom_trans_expecting', ['*'], ['id' => $trans_expecting_id], '', 1);
        if (!array_key_exists(0, $transaction_rows)) {
            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'];

        // Read in additional post parameters
        $is_subscription = (get_param_integer('customIsSubscription') == 1);
        $status = 'Completed';
        $reason = get_param_integer('reasonForDeclineCode', 0);
        $pending_reason = '';
        $memo = '';
        $_amount = get_param_string('initialPrice');
        $amount = ($_amount == '') ? null : floatval($_amount);
        $_recurring = get_param_string('recurringPrice', '');
        $_currency = get_param_integer('currencyCode', 0);
        $currency = ($_currency === 0) ? get_option('currency') : $this->currency_numeric_to_alphabetic_code[$_currency];
        $txn_id = $trans_expecting_id; // Workaround for duplicate ID detection since CCBill does not pass IDs in PDT
        $parent_txn_id = '';
        $period = get_param_string('initialPeriod', '99');

        // SECURITY: Digest Validation
        if (get_ecommerce_option('payment_gateway_digest') != '') {
            $response_digest = get_param_string('formDigest', null);

            // This is supposed to work according to CCBill, but their response digest is actually the same as our form digest
            /*
            $success_response_digest = md5($subscription_id . '1' . get_ecommerce_option('payment_gateway_digest'));
            $denial_response_digest = md5($denial_id . '0' . get_ecommerce_option('payment_gateway_digest'));
            $success = ($success_response_digest === $response_digest);
            if (($response_digest !== $success_response_digest) && ($response_digest !== $denial_response_digest)) {
            */
            if ($is_subscription) {
                $form_digest = md5($_amount . $period . $_recurring . $period . '99' . strval($_currency) . get_ecommerce_option('payment_gateway_digest'));
            } else {
                $form_digest = md5($_amount . $period . strval($_currency) . get_ecommerce_option('payment_gateway_digest'));
            }

            if ($response_digest != $form_digest) {
                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/ccbill)->handle_ipn_transaction: FATAL: Response digest mismatch. Possibly a hacker?' . "\n");
                    // fwrite($myfile, 'Response digest: Got ' . (($response_digest !== null) ? $response_digest : 'NULL') . ', but expected ' . $success_response_digest . ' (success) or ' . $denial_response_digest . ' (denial)' . "\n");
                    fwrite($myfile, 'Response digest: Got ' . (($response_digest !== null) ? $response_digest : 'NULL') . ', but expected ' . $form_digest . "\n");
                    fwrite($myfile, 'IP address: ' . get_ip_address() . "\n");
                    fwrite($myfile, "\n\n");
                    flock($myfile, LOCK_UN);
                    fclose($myfile);
                }
                fatal_ipn_exit(do_lang('PDT_IPN_UNVERIFIED')); // Hacker?!!!
            }
        } else {
            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/ccbill)->handle_ipn_transaction: WARN: skipped digest validation because Gateway digest code was not set.' . "\n");
                fwrite($myfile, "\n\n");
                flock($myfile, LOCK_UN);
                fclose($myfile);
            }
        }

        // Determine transaction fee: accountingInitialPrice is how much the merchant will actually receive, so subtract that from the transaction amount
        $_accounting_amount = get_param_string('accountingInitialPrice', null);
        if (($_accounting_amount !== null) && ($_accounting_amount != '')) {
            $transaction_fee = ($amount) - floatval($_accounting_amount);
        } else {
            $transaction_fee = null;
        }

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

        // CCBill does not return tax / shipping information; we must manually remove this from $amount using the information from the transaction
        $tax = $transaction_row['e_tax'];
        $shipping = $transaction_row['e_shipping'];
        $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' => post_param_string('customer_fname', ''),
            'a_lastname' => post_param_string('customer_lname', ''),
            'a_street_address' => trim(post_param_string('address1', '') . "\n" . post_param_string('address2', '')),
            'a_city' => post_param_string('city', ''),
            'a_county' => '',
            'a_state' => post_param_string('state', ''),
            'a_post_code' => post_param_string('zipcode', ''),
            'a_country' => post_param_string('country', ''),
            'a_email' => post_param_string('email', '', INPUT_FILTER_POST_IDENTIFIER),
            'a_phone' => post_param_string('phone_number', ''),
        ];
        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
    {
        // https://www.ccbill.com/cs/manuals/Custom_Cancellation_Software.pdf
        // Can't do it because we don't have customer's username and password ("login_id", "password")

        return false;
    }
}
