<?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
 */

/*
See codebook_1b for a technical overview. Follows are some notes.


systems/ecommerce hooks are structured like this...

config                                      (optional, for hooks with configurable products within admin_ecommerce module)
save_config                                 (optional, for hooks with configurable products within admin_ecommerce module)
get_product_category                        (optional, used if multiple products are in the hook and they want grouping on the purchasing wizard)
get_products                                (required)
is_available                                (optional but advisable)
get_available_quantity                      (optional and only for cart items)
get_message                                 (optional but advisable)
get_catalogue_template_parameters           (only for catalogue items)
get_terms                                   (optional)
get_needed_fields                           (optional)
get_purchase_id_manual_field_inputter       (optional)
process_needed_fields                       (optional)
actualiser                                  (required if automatic actualisation will be a feature)
reduce_stock                                (optional and only for cart items)
member_for                                  (required if point payment is supported, except for invoices and subscriptions)
get_product_dispatch_type                   (optional and only for cart items)

systems/payment_gateways hooks are structured like this...

get_config                                  (required)
get_transaction_fee                         (optional)
get_logos                                   (optional)
generate_trans_id                           (required)
make_transaction_button                     (required if it provides remote payment)
make_cart_transaction_button                (optional)
make_subscription_button                    (required if it provides remote payment)
make_cancel_button                          (optional)
handle_ipn_transaction                      (required if it provides remote payment)
handle_pdt_transaction                      (required if it provides remote payment and auto returns payment / transaction data upon redirection back to the store)
create_selection_list_card_types            (optional, only for providing local payment but also optional then)
do_local_transaction                        (required if it provides local payment)
store_shipping_address                      (optional)
get_callback_url_message                    (optional)
auto_cancel                                 (required, but may just return false)


Notifications and e-mailing...

SITUATION                                                                           NOTIFICATION CODE
---------                                                                           -----------------
raw transaction gone through (to user)                                              framework: payment_received
raw transaction gone through (to staff)                                             framework: payment_received_staff
transaction gone through, stuff sent for individual hooks (to user or to staff)     per product hook: ecom_product_request_*, new_order, paid_subscription_messages, subscriptions_staff
invoice created (to user)                                                           manual trigger: invoice
invoice created (to staff)                                                          (none, staff know they created it)
subscription cancel request, needs manual attention (to user)                       (none, user knows they just did this)
subscription cancel request, needs manual attention (to staff)                      manual trigger: subscriptions_staff
order manually dispatched (to user)                                                 per product hook: ecom_product_?, order_dispatched
order manually dispatched (to staff)                                                (none, staff know they just did this)

As you can see we usually send based on manual triggers, or in individual product hooks.
We send generic messages about payments received to users and staff (as cover-all reassurance in case product hooks do not and to supplement what the payment gateway will send).
We don't send e-mails for pending transactions or reversed transactions, we leave that to the payment gateway.
We don't try and e-mail guest users. We assume the payment gateway sends them a sensible message, or guest purchasing is disabled (inline joining is supported), or manual e-mails are sent to the contact details gathered by the payment gateway.


Shipping addresses...

Shipping addresses are only gathered for cart purchases.
It is assumed shipping details for other products will not be required, or will be requested manually, or will be found out from member profile details.
*/

/**
 * Standard code module initialisation function.
 *
 * @ignore
 */
function init__ecommerce()
{
    if (!defined('PRODUCT_PURCHASE')) {
        define('PRODUCT_PURCHASE', 0);
        define('PRODUCT_INVOICE', 1);
        define('PRODUCT_SUBSCRIPTION', 2);
        define('PRODUCT_OTHER', 3);
        define('PRODUCT_CATALOGUE', 4); // used only with the cart
        define('PRODUCT_ORDERS', 5); // shopping cart contents purchase

        define('ECOMMERCE_PRODUCT_AVAILABLE', 0);
        define('ECOMMERCE_PRODUCT_NO_GUESTS', 1); // Only used if current user really is a Guest
        define('ECOMMERCE_PRODUCT_ALREADY_HAS', 2);
        define('ECOMMERCE_PRODUCT_DISABLED', 3);
        define('ECOMMERCE_PRODUCT_PROHIBITED', 4);
        define('ECOMMERCE_PRODUCT_OUT_OF_STOCK', 5);
        define('ECOMMERCE_PRODUCT_MISSING', 6);
        define('ECOMMERCE_PRODUCT_INTERNAL_ERROR', 7);
    }

    require_lang('ecommerce');

    require_code('locations');
    require_code('ecommerce_tax');
    require_code('ecommerce_shipping');
}

/**
 * Automatically calculates a discount for a product based on points.
 *
 * @param  array $details The product details
 * @return array The amended product details
 */
function automatic_discount_calculation(array $details) : array
{
    if (!addon_installed('points')) { // No discount if the points addon is not installed
        $details['discount_points__num_points'] = null;
        $details['discount_points__price_reduction'] = null;
        return $details;
    }

    if ($details['discount_points__num_points'] !== null) {
        // Already has discount
        return $details;
    }

    if ($details['price'] === null) {
        // Only via points
        return $details;
    }

    if ($details['price_points'] === null) {
        // Only via money
        return $details;
    }

    // Default maximum discount is 50% if not otherwise specified
    if (!isset($details['discount_points__percentile']) || $details['discount_points__percentile'] === null) {
        $details['discount_points__percentile'] = 0.5;
    }

    $details['discount_points__num_points'] = intval(round(floatval($details['price_points']) * $details['discount_points__percentile']));
    $details['discount_points__price_reduction'] = $details['price'] * $details['discount_points__percentile'];

    return $details;
}

/**
 * Find the next step for the purchasing module.
 *
 * @param  object $product_object The product object
 * @param  ID_TEXT $type_code The product type
 * @param  ID_TEXT $step_before The step prior to the next step
 * @return ?ID_TEXT The next step (null: error)
 */
function get_next_purchase_step(object $product_object, string $type_code, string $step_before) : ?string
{
    $steps = get_product_purchase_steps($product_object, $type_code, false);
    foreach ($steps as $i => $step) {
        if ($step[1] == $step_before) {
            if (!isset($steps[$i + 1])) {
                return null;
            }

            return $steps[$i + 1][1];
        }
    }

    if ($step_before != 'browse') { // Maybe the step requested isn't even in the cycle, so find the first non-browse step
        return get_next_purchase_step($product_object, $type_code, 'browse');
    }

    return null;
}

/**
 * Find the purchasing module breadcrumb steps for a product.
 *
 * @param  object $product_object The product object
 * @param  ID_TEXT $type_code Type code for product
 * @param  boolean $consider_categories Whether to consider a category screen
 * @return array A structure describing the steps
 */
function get_product_purchase_steps(object $product_object, string $type_code, bool $consider_categories) : array
{
    $steps = [];

    $more_params = '';
    foreach ($_GET as $key => $val) {
        if ((is_string($key)) && (is_string($val)) && (!in_array($key, ['page', 'type', 'category', 'type_code', 'purchase_id', 'points', 'cancel', 'from', 'message']))) {
            $more_params .= ':' . $key . '=' . cms_urlencode($val);
        }
    }

    $steps[] = ['_SELF:_SELF:browse' . $more_params, 'browse', do_lang_tempcode('ECOM_PURCHASE_STAGE_browse')];

    if ($consider_categories) {
        if (method_exists($product_object, 'get_product_category')) {
            $product_category = $product_object->get_product_category();
            $steps[] = ['_SELF:_SELF:browse:category=' . preg_replace('#^Hook_ecommerce_#', '', get_class($product_object)) . ':' . $more_params, 'browse', $product_category['category_name']];
        }
    }

    $more_params .= ':type_code=' . cms_urlencode($type_code);

    $message = method_exists($product_object, 'get_message') ? $product_object->get_message($type_code) : null;
    $has_message = ($message !== null) && (!$message->is_empty());
    if (($has_message) && (get_param_integer('include_message', 0) == 0)) {
        $steps[] = ['_SELF:_SELF:message' . ':' . $more_params, 'message', do_lang_tempcode('ECOM_PURCHASE_STAGE_message')];
    }

    $terms = method_exists($product_object, 'get_terms') ? $product_object->get_terms($type_code) : '';
    $has_terms = ($terms != '');
    if ($has_terms) {
        $steps[] = ['_SELF:_SELF:terms' . ':' . $more_params, 'terms', do_lang_tempcode('ECOM_PURCHASE_STAGE_terms')];
    }

    if (has_needed_fields($type_code)) {
        $steps[] = ['_SELF:_SELF:details' . ':' . $more_params, 'details', do_lang_tempcode('ECOM_PURCHASE_STAGE_details')];
    }

    $purchase_id = get_param_string('purchase_id', null);
    if ($purchase_id !== null) {
        $more_params .= ':purchase_id=' . cms_urlencode($purchase_id);
    }

    $steps[] = ['_SELF:_SELF:pay' . ':' . $more_params, 'pay', do_lang_tempcode('ECOM_PURCHASE_STAGE_pay')];

    $steps[] = ['_SELF:_SELF:finish' . ':' . $more_params, 'finish', do_lang_tempcode('ECOM_PURCHASE_STAGE_finish')];

    return $steps;
}

/**
 * Find if there are fields needing to filled in in the purchasing module.
 *
 * @param  ID_TEXT $type_code The product codename
 * @param  boolean $force_extended Show all possible input fields
 * @return boolean Whether there are
 */
function has_needed_fields(string $type_code, bool $force_extended = false) : bool
{
    if ($force_extended) {
        return true;
    }

    if (is_guest()) {
        return true;
    }

    if ($GLOBALS['FORUM_DRIVER']->get_member_email_address(get_member()) == '') {
        return true;
    }

    if (get_option('tax_detailed') == '1') {
        return true;
    }

    list($details, $product_object) = find_product_details($type_code);

    if ($details['needs_shipping_address']) {
        return true;
    }

    return false;
}

/**
 * Get fields that need to be filled in in the purchasing module.
 *
 * @param  ID_TEXT $type_code The product codename
 * @param  boolean $force_extended Show all possible input fields
 * @param  boolean $from_admin Whether this is being called from the Admin Zone. If so, optionally different fields may be used, including a purchase_id field for direct purchase ID input.
 * @param  boolean $points_purchase Whether this is a points purchase
 * @return array A triple: The fields (use null for none), Hidden fields (use null for none), The text (use null for none), array of JavaScript function calls
 */
function get_needed_fields(string $type_code, bool $force_extended = false, bool $from_admin = false, bool $points_purchase = false) : array
{
    list($details, $product_object) = find_product_details($type_code);

    if ($details === null) {
        warn_exit(do_lang_tempcode('MISSING_RESOURCE'));
    }

    $fields = null;
    $hidden = null;
    $text = null;
    $js_function_calls = [];

    if (method_exists($product_object, 'get_needed_fields')) {
        list($fields, $hidden, $text, $js_function_calls) = $product_object->get_needed_fields($type_code, $from_admin);
    }

    $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);

    $require_all_details = ($details['needs_shipping_address']) || ((get_option('tax_detailed') == '1') && (!$points_purchase));
    if (($require_all_details) || ($force_extended)) {
        if ($fields === null) {
            $fields = new Tempcode();
        }
        if ($hidden === null) {
            $hidden = new Tempcode();
        }

        $fields->attach(do_template('FORM_SCREEN_FIELD_SPACER', ['_GUID' => '6d9d9d785a2f776aa10fa0a8a26fc405', 'TITLE' => do_lang_tempcode($details['needs_shipping_address'] ? 'SHIPPING_ADDRESS' : 'INVOICING_ADDRESS')]));

        list($_fields, $_hidden) = get_shipping_address_fields($shipping_email, $shipping_phone, $shipping_firstname, $shipping_lastname, $shipping_street_address, $shipping_city, $shipping_county, $shipping_state, $shipping_post_code, $shipping_country, $require_all_details);
        $fields->attach($_fields);
        $hidden->attach($_hidden);

        if ((!is_guest()) && (get_forum_type() == 'cns') && (get_option('store_credit_card_numbers') == '1')) {
            $fields->attach(form_input_tick(do_lang_tempcode('SAVE_TO_ACCOUNT'), '', 'shipping_save_to_account', get_cms_cpf('firstname') == ''));
        }
    } elseif ((is_guest()) || ($GLOBALS['FORUM_DRIVER']->get_member_email_address(get_member()) == '')) { // We need an alternative way to contact the user (no member details)
        $require_all_details = true;

        if ($fields === null) {
            $fields = new Tempcode();
        }

        $fields->attach(get_shipping_name_fields($shipping_firstname, $shipping_lastname, $require_all_details));
        $fields->attach(get_shipping_contact_fields($shipping_email, $shipping_phone, $require_all_details));
    }

    return [$fields, $hidden, $text, $js_function_calls];
}

/**
 * Check whether the system is in test mode (normally, not).
 *
 * @return boolean The answer
 */
function ecommerce_test_mode() : bool
{
    if (($GLOBALS['DEV_MODE']) && (get_param_integer('keep_ecommerce_local_test', 0) == 1)) {
        return true;
    }
    return (get_option('ecommerce_test_mode') == '1');
}

/**
 * Get the symbol of the currency we're trading in.
 *
 * @param  ?ID_TEXT $currency The currency (null: configured)
 * @return ID_TEXT The currency symbol
 */
function ecommerce_get_currency_symbol(?string $currency = null) : string
{
    if ($currency === null) {
        $currency = get_option('currency');
    }
    if ($currency == 'points') {
        return '';
    }
    require_code('currency');
    list($symbol,) = get_currency_symbol($currency);
    return $symbol;
}

/**
 * Find a transaction fee from a transaction amount. Regular fees aren't taken into account.
 *
 * @param  REAL $amount A total transaction amount
 * @param  ID_TEXT $type_code A transaction type code
 * @param  ID_TEXT $payment_gateway The payment gateway the payment went via
 * @param  boolean $fail_ok Return 0.00 if an error occurred instead of exiting
 * @return REAL The fee
 */
function get_transaction_fee(float $amount, string $type_code, string $payment_gateway, bool $fail_ok = false) : float
{
    // No transaction fee for manual nor point transactions
    if (in_array($payment_gateway, ['', 'manual', 'points'])) {
        return 0.00;
    }

    // Configuration takes priority if set
    if (get_option('transaction_flat_fee') . get_option('transaction_percentage_fee') != '') {
        $fee = 0.00;
        if (get_option('transaction_flat_fee') != '') {
            $fee += floatval(get_option('transaction_flat_fee'));
        }
        if (get_option('transaction_percentage_fee') != '') {
            $fee += floatval(get_option('transaction_percentage_fee')) / 100.0 * $amount;
        }
        return round($fee, 2);
    }

    // Fall back to payment gateway hooks
    $payment_gateway_object = get_hook_ob('systems', 'payment_gateway', $payment_gateway, 'Hook_payment_gateway_', $fail_ok);
    if (($payment_gateway_object !== null) && (method_exists($payment_gateway_object, 'get_transaction_fee'))) {
        return $payment_gateway_object->get_transaction_fee($amount, $type_code);
    }

    return 0.00;
}

/**
 * Get a URL to a product.
 *
 * @param  ID_TEXT $type_code The product codename
 * @param  boolean $post_purchase_access_url Access a post-purchase URL appropriate to the buyer
 * @param  ?MEMBER $member_id Member ID that this is for (null: unknown)
 * @param  boolean $email_safe Whether to avoid keep_* parameters as it's going in an e-mail
 * @return Tempcode Product URL
 */
function get_product_details_url(string $type_code, bool $post_purchase_access_url = false, ?int $member_id = null, bool $email_safe = false) : object
{
    static $permission_product_rows = null;
    if ($permission_product_rows === null) {
        $permission_product_rows = list_to_map('id', $GLOBALS['SITE_DB']->query_select('ecom_prods_permissions', ['id', 'p_module', 'p_category']));
    }

    $product_details_url = null;

    $matches = [];
    if (($post_purchase_access_url) && (preg_match('#^PERMISSION_(\d+)$#', $type_code, $matches) != 0)) {
        $permission_product_id = intval($matches[1]);
        if (isset($permission_product_rows[$permission_product_id])) {
            $module = $permission_product_rows[$permission_product_id]['p_module'];
            if ($module != '') {
                $category_id = $permission_product_rows[$permission_product_id]['p_category'];

                $resource_type = convert_cms_type_codes('module', $module, 'content_type');
                if ($resource_type != '') {
                    $content_type_ob = get_content_object($resource_type);
                    $cma_info = $content_type_ob->info();
                    if (!$cma_info['is_category']) {
                        $resource_type = $cma_info['parent_category_meta_aware_type'];
                    }

                    list(, , $cma_info) = content_get_details($resource_type, $category_id);

                    $page_link = str_replace('_WILD', $category_id, $cma_info['view_page_link_pattern']);
                    $product_details_url = page_link_to_tempcode_url($page_link, $email_safe);
                }
            }
        }
    } elseif (($post_purchase_access_url) && (preg_match('#^CART_ORDER_(\d+)$#', $type_code, $matches) != 0)) {
        if (has_actual_page_access(get_member(), 'admin_shopping')) {
            $product_details_url = build_url(['page' => 'admin_shopping', 'type' => 'order_details', 'id' => $matches[1]], get_module_zone('admin_shopping'), [], false, false, $email_safe);
        } else {
            $product_details_url = build_url(['page' => 'shopping', 'type' => 'order_details', 'id' => $matches[1]], get_module_zone('shopping'), [], false, false, $email_safe);
        }
    } elseif (is_numeric($type_code)) {
        $product_details_url = build_url(['page' => 'catalogues', 'type' => 'entry', 'id' => $type_code], get_module_zone('catalogues'), [], false, false, $email_safe);
    } elseif (($member_id !== null) && ($post_purchase_access_url) && (preg_match('#^USERGROUP(\d+)$#', $type_code, $matches) != 0)) {
        $product_details_url = build_url(['page' => 'subscriptions', 'id' => ($member_id == get_member()) ? null : $member_id], get_module_zone('subscriptions'), [], false, false, $email_safe);
    } elseif (($member_id !== null) && ($post_purchase_access_url) && ($type_code == 'work')) {
        $product_details_url = build_url(['page' => 'invoices', 'id' => ($member_id == get_member()) ? [] : $member_id], get_module_zone('invoices'), [], false, false, $email_safe);
    }

    if ($product_details_url === null) {
        $product_details_url = build_url(['page' => 'purchase', 'type' => 'message', 'type_code' => $type_code], get_module_zone('purchase'), [], false, false, $email_safe);
    }

    return $product_details_url;
}

/**
 * Build a transaction linker. Will link to a transaction if there is access, or show a tooltip of details.
 * Used by the sales and order logs.
 *
 * @param  ID_TEXT $txn_id Transaction ID
 * @param  boolean $awaiting_payment If payment is still required
 * @param  ?array $transaction_row The transaction database row (null: look it up)
 * @param  boolean $missing_ok Whether to return null instead of throwing an error if the transaction was not found
 * @return ?Tempcode The linker (null: Transaction not found and $missing_ok was set to true)
 */
function build_transaction_linker(string $txn_id, bool $awaiting_payment, ?array $transaction_row = null, bool $missing_ok = false) : ?object
{
    if (($txn_id != '') && (!$awaiting_payment)) {
        if ($transaction_row === null) {
            $transaction_row = get_transaction_row($txn_id, $missing_ok);
        }
        if ($transaction_row === null) {
            return null;
        }

        if (has_actual_page_access(get_member(), 'admin_ecommerce_reports')) {
            $transaction_details_url = build_url(['page' => 'admin_ecommerce_reports', 'type' => 'logs', 'filter_type_code' => $transaction_row['t_type_code'], 'filter_txn_id' => $transaction_row['id']], get_module_zone('admin_ecommerce_reports'));
            $transaction_link = hyperlink($transaction_details_url, $txn_id, false, true);
        } else {
            $transaction_link = make_string_tempcode(escape_html($txn_id));
        }

        require_code('templates_map_table');

        $transaction_fields = [
            'TRANSACTION' => $txn_id,
            'PURCHASE_ID' => $transaction_row['t_purchase_id'],
            'PARENT' => $transaction_row['t_parent_txn_id'],
            'PRICE' => float_format($transaction_row['t_price']),
            'TAX' => float_format($transaction_row['t_tax']),
            'SHIPPING' => float_format($transaction_row['t_shipping']),
            'TRANSACTION_FEES' => float_format($transaction_row['t_transaction_fee']),
            'CURRENCY' => $transaction_row['t_currency'],
            'STATUS' => get_transaction_status_string($transaction_row['t_status']),
            'REASON' => trim($transaction_row['t_reason'] . '; ' . $transaction_row['t_pending_reason'], '; '),
            'NOTES' => $transaction_row['t_memo'],
        ];
        $_transaction_fields = new Tempcode();
        foreach ($transaction_fields as $key => $val) {
            if ($val != '') {
                $_transaction_fields->attach(map_table_field(do_lang_tempcode($key), $val, true));
            }
        }
        $map_table = do_template('MAP_TABLE', ['_GUID' => '1fd78e5f75f9f838b468fc6151a2da02', 'FIELDS' => $_transaction_fields, 'RESPONSIVE' => true]);

        require_code('templates_tooltip');
        return tooltip($transaction_link, $map_table, true);
    } else {
        $transaction_linker = do_lang_tempcode('PAYMENT_STATE_new');
    }
    return $transaction_linker;
}

/**
 * Make a transaction (payment) button.
 * This is for IPN-style transactions (as opposed to local transactions).
 *
 * @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  REAL $price Transaction price in money (excluding tax and shipping)
 * @param  array $tax_derivation Transaction tax derivation
 * @param  REAL $tax Transaction tax in money (including shipping tax)
 * @param  array $tax_tracking Transaction tax tracking ID
 * @param  REAL $shipping_cost Transaction shipping cost in money
 * @param  REAL $shipping_tax Transaction shipping tax in money
 * @param  ID_TEXT $currency The currency to use
 * @param  integer $price_points Transaction price in points
 * @param  ?ID_TEXT $payment_gateway The payment gateway the payment will go via (null: autodetect) (false: none, usually used for manual transactions)
 * @return ?Tempcode The button (null: $payment_gateway was false, so payment is handled manually)
 */
function make_transaction_button(string $type_code, string $item_name, string $purchase_id, float $price, array $tax_derivation, float $tax, array $tax_tracking, float $shipping_cost, float $shipping_tax, string $currency, int $price_points = 0, ?string $payment_gateway = null) : ?object
{
    if ($payment_gateway === null) {
        $payment_gateway = get_option('payment_gateway');
    } elseif ($payment_gateway !== false) {
        require_code('hooks/systems/payment_gateway/' . filter_naughty_harsh($payment_gateway));
        $payment_gateway_object = object_factory('Hook_payment_gateway_' . filter_naughty_harsh($payment_gateway));
        $trans_expecting_id = $payment_gateway_object->generate_trans_id();
    } else {
        require_code('crypt');
        $trans_expecting_id = get_secure_random_string();
    }

    $invoicing_breakdown = generate_invoicing_breakdown($type_code, $item_name, $purchase_id, $price, $tax, $shipping_cost, $shipping_tax);

    $GLOBALS['SITE_DB']->query_insert('ecom_trans_expecting', [
        'id' => $trans_expecting_id,
        'e_type_code' => $type_code,
        'e_purchase_id' => $purchase_id,
        'e_item_name' => $item_name,
        'e_member_id' => get_member(),
        'e_session_id' => get_session_id(),
        'e_price' => $price,
        'e_tax_derivation' => json_encode($tax_derivation, defined('JSON_PRESERVE_ZERO_FRACTION') ? JSON_PRESERVE_ZERO_FRACTION : 0),
        'e_tax' => $tax,
        'e_tax_tracking' => json_encode($tax_tracking, defined('JSON_PRESERVE_ZERO_FRACTION') ? JSON_PRESERVE_ZERO_FRACTION : 0),
        'e_shipping' => $shipping_cost,
        'e_currency' => $currency,
        'e_price_points' => $price_points,
        'e_ip_address' => get_ip_address(),
        'e_time' => time(),
        'e_length' => null,
        'e_length_units' => '',
        'e_memo' => post_param_string('memo', ''),
        'e_invoicing_breakdown' => json_encode($invoicing_breakdown, defined('JSON_PRESERVE_ZERO_FRACTION') ? JSON_PRESERVE_ZERO_FRACTION : 0),
    ]);
    store_shipping_address($trans_expecting_id);

    if ($payment_gateway === false) {
        return null;
    }

    return $payment_gateway_object->make_transaction_button($trans_expecting_id, $type_code, $item_name, $purchase_id, $price, $tax, $shipping_cost, $currency);
}

/**
 * Make a subscription (payment) button.
 * This is for IPN-style transactions (as opposed to local transactions).
 *
 * @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  REAL $price Transaction price in money
 * @param  array $tax_derivation Transaction tax derivation
 * @param  REAL $tax Transaction tax in money
 * @param  array $tax_tracking Transaction tax tracking ID
 * @param  ID_TEXT $currency The currency to use
 * @param  integer $price_points Transaction price in points (only for first transaction)
 * @param  integer $length The subscription length in the units
 * @param  ID_TEXT $length_units The length units
 * @set d w m y
 * @param  ?ID_TEXT $payment_gateway The payment gateway the payment will go via (null: autodetect)
 * @return Tempcode The button
 */
function make_subscription_button(string $type_code, string $item_name, string $purchase_id, float $price, array $tax_derivation, float $tax, array $tax_tracking, string $currency, int $price_points, int $length, string $length_units, ?string $payment_gateway = null) : object
{
    if ($payment_gateway === null) {
        $payment_gateway = get_option('payment_gateway');
    }
    require_code('hooks/systems/payment_gateway/' . filter_naughty_harsh($payment_gateway));
    $payment_gateway_object = object_factory('Hook_payment_gateway_' . filter_naughty_harsh($payment_gateway));

    $invoicing_breakdown = generate_invoicing_breakdown($type_code, $item_name, $purchase_id, $price, $tax);

    $trans_expecting_id = $payment_gateway_object->generate_trans_id();
    $GLOBALS['SITE_DB']->query_insert('ecom_trans_expecting', [
        'id' => $trans_expecting_id,
        'e_type_code' => $type_code,
        'e_purchase_id' => $purchase_id,
        'e_item_name' => $item_name,
        'e_member_id' => get_member(),
        'e_session_id' => get_session_id(),
        'e_price' => $price,
        'e_tax_derivation' => json_encode($tax_derivation, defined('JSON_PRESERVE_ZERO_FRACTION') ? JSON_PRESERVE_ZERO_FRACTION : 0),
        'e_tax' => $tax,
        'e_tax_tracking' => json_encode($tax_tracking, defined('JSON_PRESERVE_ZERO_FRACTION') ? JSON_PRESERVE_ZERO_FRACTION : 0),
        'e_shipping' => 0.0,
        'e_currency' => $currency,
        'e_price_points' => $price_points,
        'e_ip_address' => get_ip_address(),
        'e_time' => time(),
        'e_length' => $length,
        'e_length_units' => $length_units,
        'e_memo' => post_param_string('memo', ''),
        'e_invoicing_breakdown' => json_encode($invoicing_breakdown, defined('JSON_PRESERVE_ZERO_FRACTION') ? JSON_PRESERVE_ZERO_FRACTION : 0),
    ]);
    store_shipping_address($trans_expecting_id);

    return $payment_gateway_object->make_subscription_button($trans_expecting_id, $type_code, $item_name, $purchase_id, $price, $tax, $currency, $length, $length_units);
}

/**
 * Make a subscription cancellation button.
 * This is for IPN-style transactions (as opposed to local transactions).
 *
 * @param  ID_TEXT $purchase_id The purchase ID
 * @param  ID_TEXT $payment_gateway The payment gateway the payment will go via
 * @return ?Tempcode The button (null: no special cancellation -- just delete the subscription row to stop the software regularly re-charging)
 */
function make_cancel_button(string $purchase_id, string $payment_gateway) : ?object
{
    if (in_array($payment_gateway, ['', 'manual', 'points'])) {
        return null;
    }
    require_code('hooks/systems/payment_gateway/' . filter_naughty_harsh($payment_gateway));
    $payment_gateway_object = object_factory('Hook_payment_gateway_' . filter_naughty_harsh($payment_gateway));
    if (!method_exists($payment_gateway_object, 'make_cancel_button')) {
        return null;
    }
    return $payment_gateway_object->make_cancel_button($purchase_id);
}

/**
 * Find all products, except ones from hooks that might have too many to list (so don't rely on this for important backend tasks).
 *
 * @return array A list of maps of product details. 'product_object' is added to each detail map.
 */
function find_all_products() : array
{
    $_hooks = find_all_hook_obs('systems', 'ecommerce', 'Hook_ecommerce_');
    $products = [];
    foreach ($_hooks as $hook => $product_object) {
        $_products = $product_object->get_products();
        foreach ($_products as $type_code => $details) {
            $details['product_object'] = $product_object;
            $products[$type_code] = $details;
        }
    }
    sort_maps_by($products, 'item_name', false, true);
    return $products;
}

/**
 * Find product info row and other details.
 *
 * @param  ID_TEXT $search The product codename (passed by reference; semantic codes could be modified by ecommerce hooks)
 * @return array A triple: The product info row, the product object (all will be null if not found)
 */
function find_product_details(string &$search) : array
{
    static $cache = [];
    if (isset($cache[$search])) {
        return $cache[$search];
    }

    static $hook_products_cache = [];
    $has_cache = (!empty($hook_products_cache));

    $_hooks = find_all_hooks('systems', 'ecommerce');
    foreach (array_keys($_hooks) as $hook) {
        if ((is_numeric($search)) && ($hook != 'catalogue_items')) { // Optimisation
            continue;
        }

        $product_object = get_hook_ob('systems', 'ecommerce', filter_naughty_harsh($hook), 'Hook_ecommerce_', true);
        if ($product_object === null) {
            continue;
        }

        if (isset($hook_products_cache[$hook])) {
            $_products = $hook_products_cache[$hook];
        } else {
            $_products = $product_object->get_products($search);
            $hook_products_cache[$hook] = $_products;
        }

        $type_code = null;
        foreach ($_products as $type_code => $details) {
            if (is_integer($type_code)) {
                $type_code = strval($type_code);
            }

            if ($type_code == $search) {
                $ret = [$details, $product_object];
                $cache[$search] = $ret;
                return $ret;
            }
        }
    }

    // Cache miss?
    if ($has_cache) {
        $hook_products_cache = [];
        return find_product_details($search);
    }

    $ret = [null, null];
    $cache[$search] = $ret;
    return $ret;
}

/**
 * Find whether local payment will be performed.
 *
 * @return boolean Whether local payment will be performed
 */
function perform_local_payment() : bool
{
    if (($GLOBALS['DEV_MODE']) && (get_param_integer('keep_ecommerce_local_test', 0) == 1)) {
        require_code('config2');
        set_option('payment_gateway', 'authorize');
        return true;
    }

    $payment_gateway = get_option('payment_gateway');
    require_code('hooks/systems/payment_gateway/' . filter_naughty_harsh($payment_gateway));
    $payment_gateway_object = object_factory('Hook_payment_gateway_' . filter_naughty_harsh($payment_gateway));
    $payment_gateway_config = $payment_gateway_object->get_config();
    return ((($payment_gateway_config['local_only'] === true) || (get_option('use_local_payment') == '1')) && (method_exists($payment_gateway_object, 'do_local_transaction')));
}

/**
 * Get a form for transacting local payments.
 *
 * @param  ID_TEXT $type_code The product codename
 * @param  SHORT_TEXT $item_name The item name
 * @param  ID_TEXT $purchase_id The purchase ID
 * @param  REAL $price Transaction price in money (excluding tax and shipping)
 * @param  array $tax_derivation Transaction tax derivation
 * @param  REAL $tax Transaction tax in money (including shipping tax)
 * @param  array $tax_tracking Transaction tax tracking ID
 * @param  REAL $shipping_cost Transaction shipping cost in money
 * @param  REAL $shipping_tax Transaction shipping tax in money
 * @param  ID_TEXT $currency The currency to use
 * @param  integer $price_points Transaction price in points (only for first transaction)
 * @param  ?integer $length The length (null: not a subscription)
 * @param  ID_TEXT $length_units The length units
 * @param  ?ID_TEXT $payment_gateway The payment gateway the payment will go via (null: autodetect)
 * @param  boolean $needs_shipping_address Whether a shipping address is needed
 * @return array A tuple: The form fields, Hidden fields, Confidence logos, Payment processor links
 */
function get_transaction_form_fields(string $type_code, string $item_name, string $purchase_id, float $price, array $tax_derivation, float $tax, array $tax_tracking, float $shipping_cost, float $shipping_tax, string $currency, int $price_points, ?int $length, string $length_units, ?string $payment_gateway = null, bool $needs_shipping_address = false) : array
{
    if ((!tacit_https()) && (!ecommerce_test_mode())) {
        warn_exit(do_lang_tempcode('NO_SSL_SETUP'));
    }

    if ($payment_gateway === null) {
        $payment_gateway = get_option('payment_gateway');
    }

    require_code('hooks/systems/payment_gateway/' . filter_naughty_harsh($payment_gateway));
    $payment_gateway_object = object_factory('Hook_payment_gateway_' . filter_naughty_harsh($payment_gateway));

    if (!method_exists($payment_gateway_object, 'do_local_transaction')) {
        warn_exit(do_lang_tempcode('LOCAL_PAYMENT_NOT_SUPPORTED', escape_html($payment_gateway)));
    }

    $invoicing_breakdown = generate_invoicing_breakdown($type_code, $item_name, $purchase_id, $price, $tax, $shipping_cost, $shipping_tax);

    $trans_expecting_id = $payment_gateway_object->generate_trans_id(); // gateway-compatible, probably random, transaction ID

    $GLOBALS['SITE_DB']->query_insert('ecom_trans_expecting', [
        'id' => $trans_expecting_id,
        'e_type_code' => $type_code,
        'e_purchase_id' => $purchase_id,
        'e_item_name' => $item_name,
        'e_member_id' => get_member(),
        'e_session_id' => get_session_id(),
        'e_price' => $price,
        'e_tax_derivation' => json_encode($tax_derivation, defined('JSON_PRESERVE_ZERO_FRACTION') ? JSON_PRESERVE_ZERO_FRACTION : 0),
        'e_tax' => $tax,
        'e_tax_tracking' => json_encode($tax_tracking, defined('JSON_PRESERVE_ZERO_FRACTION') ? JSON_PRESERVE_ZERO_FRACTION : 0),
        'e_shipping' => $shipping_cost,
        'e_currency' => $currency,
        'e_price_points' => $price_points,
        'e_ip_address' => get_ip_address(),
        'e_time' => time(),
        'e_length' => $length,
        'e_length_units' => $length_units,
        'e_memo' => post_param_string('memo', ''),
        'e_invoicing_breakdown' => json_encode($invoicing_breakdown, defined('JSON_PRESERVE_ZERO_FRACTION') ? JSON_PRESERVE_ZERO_FRACTION : 0),
    ]);
    store_shipping_address($trans_expecting_id);

    require_code('form_templates');

    $fields = new Tempcode();
    $hidden = new Tempcode();

    $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);

    // Card fields...

    $fields->attach(do_template('FORM_SCREEN_FIELD_SPACER', ['_GUID' => '04537d33c50cda8aaee4029cbe05b023', 'TITLE' => do_lang_tempcode('PAYMENT_DETAILS')]));

    $fields->attach(form_input_line(do_lang_cpf('payment_cardholder_name'), do_lang_tempcode('DESCRIPTION_CARDHOLDER_NAME'), 'payment_cardholder_name', $cardholder_name, true));
    if (method_exists($payment_gateway_object, 'create_selection_list_card_types')) {
        $fields->attach(form_input_list(do_lang_cpf('payment_card_type'), '', 'payment_card_type', $payment_gateway_object->create_selection_list_card_types($card_type)));
    }
    $fields->attach(form_input_line(do_lang_cpf('payment_card_number'), do_lang_tempcode('DESCRIPTION_CARD_NUMBER'), 'payment_card_number', $card_number, true, null, 19, 'text', null, '^\d{13,19}$', do_lang('INVALID_CC_NUMBER')));
    $fields->attach(form_input_date_components(do_lang_cpf('payment_card_start_date'), do_lang_tempcode('DESCRIPTION_CARD_START_DATE'), 'payment_card_start_date', true, true, false, intval(date('Y')) - 16, intval(date('Y')), $card_start_date_year, $card_start_date_month, null, false));
    $fields->attach(form_input_date_components(do_lang_cpf('payment_card_expiry_date'), do_lang_tempcode('DESCRIPTION_CARD_EXPIRY_DATE'), 'payment_card_expiry_date', true, true, false, intval(date('Y')), intval(date('Y')) + 16, $card_expiry_date_year, $card_expiry_date_month, null, true));
    $fields->attach(form_input_line(do_lang_tempcode('CARD_CV2'), do_lang_tempcode('DESCRIPTION_CARD_CV2'), 'payment_card_cv2', $card_cv2, true, null, 4));

    if ((!is_guest()) && (get_forum_type() == 'cns') && (get_option('store_credit_card_numbers') == '1')) {
        $fields->attach(form_input_tick(do_lang_tempcode('SAVE_TO_ACCOUNT'), '', 'payment_save_to_account', get_cms_cpf('payment_cardholder_name') == ''));
    }

    // Billing address fields...

    if (post_param_string('billing_firstname', null) === null) {
        $fields->attach(do_template('FORM_SCREEN_FIELD_SPACER', ['_GUID' => 'e0771f0a6c479b01a4a29ae9aa7fef3a', 'TITLE' => do_lang_tempcode('BILLING_ADDRESS')]));

        list($_fields, $_hidden) = get_address_fields('billing_', $billing_street_address, $billing_city, $billing_county, $billing_state, $billing_post_code, $billing_country);
        $fields->attach($_fields);
        $hidden->attach($_hidden);

        if ((!is_guest()) && (get_forum_type() == 'cns') && (get_option('store_credit_card_numbers') == '1')) {
            $fields->attach(form_input_tick(do_lang_tempcode('SAVE_TO_ACCOUNT'), '', 'billing_save_to_account', get_cms_cpf('billing_street_address') == ''));
        }
    }

    // Shipping address fields...

    if (($needs_shipping_address) && (post_param_string('shipping_firstname', null) === null)) {
        $fields->attach(do_template('FORM_SCREEN_FIELD_SPACER', ['_GUID' => '656bf860685c8f63434bca7423d5c52a', 'TITLE' => do_lang_tempcode('SHIPPING_ADDRESS')]));

        list($_fields, $_hidden) = get_shipping_address_fields($shipping_email, $shipping_phone, $shipping_firstname, $shipping_lastname, $shipping_street_address, $shipping_city, $shipping_county, $shipping_state, $shipping_post_code, $shipping_country);
        $fields->attach($_fields);
        $hidden->attach($_hidden);

        if ((!is_guest()) && (get_forum_type() == 'cns') && (get_option('store_credit_card_numbers') == '1')) {
            $fields->attach(form_input_tick(do_lang_tempcode('SAVE_TO_ACCOUNT'), '', 'shipping_save_to_account', get_cms_cpf('firstname') == ''));
        }
    }

    // Store transaction ID in hidden field...

    $hidden->attach(form_input_hidden('trans_id', $trans_expecting_id));
    $hidden->attach(build_keep_post_fields());

    // ---

    $logos = method_exists($payment_gateway_object, 'get_logos') ? $payment_gateway_object->get_logos() : new Tempcode();
    $payment_processor_links = method_exists($payment_gateway_object, 'get_payment_processor_links') ? $payment_gateway_object->get_payment_processor_links() : new Tempcode();

    require_javascript('ecommerce');

    return [$fields, $hidden, $logos, $payment_processor_links];
}

/**
 * Get form fields for an address.
 *
 * @param  string $prefix The prefix for the address input field
 * @param  string $street_address Street address
 * @param  string $city Town/City
 * @param  string $county County
 * @param  string $state State
 * @param  string $post_code Postcode/Zip
 * @param  string $country Country
 * @param  boolean $require_all_details Whether to require all details to be input
 * @return array A pair: Address fields, Address hidden fields
 */
function get_address_fields(string $prefix, string $street_address, string $city, string $county, string $state, string $post_code, string $country, bool $require_all_details = true) : array
{
    $fields = new Tempcode();

    $hidden = new Tempcode();

    $fields->attach(form_input_text(do_lang_cpf('street_address'), '', $prefix . 'address1', $street_address, $require_all_details, false));

    $fields->attach(form_input_line(do_lang_cpf('city'), '', $prefix . 'city', $city, $require_all_details));

    if (get_option('cpf_enable_county') == '1') {
        $fields->attach(form_input_line(do_lang_cpf('county'), '', $prefix . 'county', $county, false));
    } else {
        $hidden->attach(form_input_hidden($prefix . 'county', ''));
    }

    $definitely_usa = (get_option('cpf_enable_country') == '0') && (get_option('business_country') == 'US');
    if ((get_option('cpf_enable_state') == '1') || (get_option('business_country') == 'US')) {
        if ((get_option('business_country') == 'US') && (get_option('cpf_enable_country') == '0')) { // Some tax APIs may need exact states, and Americans are a bit pampered, so show an explicit list
            $state_list = new Tempcode();
            if (!$definitely_usa) {
                $state_list->attach(form_input_list_entry('', '' == $state, do_lang_tempcode('NA_EM'))); // Need to provide an N/A option if different countries may be selected
            }
            $state_list->attach(create_usa_state_selection_list([$state]));
            $fields->attach(form_input_list(do_lang_cpf('state'), '', $prefix . 'state', $state_list, null, false, false));
        } else {
            $fields->attach(form_input_line(do_lang_cpf('state'), '', $prefix . 'state', $state, false));
        }
    } else {
        $hidden->attach(form_input_hidden($prefix . 'state', ''));
    }

    if ((get_option('cpf_enable_post_code') == '1') || (get_option('business_country') == 'US')) {
        $fields->attach(form_input_line(do_lang_cpf('post_code'), '', $prefix . 'postalcode', $post_code, $definitely_usa, null, $definitely_usa ? 10 : 12, 'text', null, $definitely_usa ? '^[0-9]{5}(-[0-9]{4})?$' : null, null, $definitely_usa ? 5 : 8));
    } else {
        $hidden->attach(form_input_hidden($prefix . 'postalcode', ''));
    }

    if (get_option('cpf_enable_country') == '1') {
        $countries = new Tempcode();
        $countries->attach(form_input_list_entry('', $country == ''));
        $countries->attach(create_country_selection_list([$country]));
        $fields->attach(form_input_list(do_lang_cpf('country'), '', $prefix . 'country', $countries, null, false, $require_all_details));
    } else {
        $hidden->attach(form_input_hidden($prefix . 'country', ''));
    }

    return [$fields, $hidden];
}

/**
 * Fetch default eCommerce fields for a form (returns by reference).
 *
 * @param  ?MEMBER $member_id The member this is for (null: current member)
 * @param  EMAIL $shipping_email Shipping e-mail address (blank: unknown)
 * @param  string $shipping_phone Shipping phone number (blank: unknown)
 * @param  string $shipping_firstname Shipping first name (blank: unknown)
 * @param  string $shipping_lastname Shipping last name (blank: unknown)
 * @param  string $shipping_street_address Shipping street address (blank: unknown)
 * @param  string $shipping_city Shipping city (blank: unknown)
 * @param  string $shipping_county Shipping county (blank: unknown)
 * @param  string $shipping_state Shipping state (blank: unknown)
 * @param  string $shipping_post_code Shipping postcode (blank: unknown)
 * @param  string $shipping_country Shipping country (blank: unknown)
 * @param  string $cardholder_name Cardholder name (blank: unknown)
 * @param  string $card_type Card type (blank: unknown)
 * @param  ?string $card_number Card number (null: unknown)
 * @param  ?integer $card_start_date_year Card start year (null: unknown)
 * @param  ?integer $card_start_date_month Card start month (null: unknown)
 * @param  ?integer $card_expiry_date_year Card expiry year (null: unknown)
 * @param  ?integer $card_expiry_date_month Card expiry month (null: unknown)
 * @param  ?string $card_cv2 Card CV2 number (null: unknown)
 * @param  string $billing_street_address Billing street address (blank: unknown)
 * @param  string $billing_city Billing city (blank: unknown)
 * @param  string $billing_county Billing county (blank: unknown)
 * @param  string $billing_state Billing state (blank: unknown)
 * @param  string $billing_post_code Billing postcode (blank: unknown)
 * @param  string $billing_country Billing country (blank: unknown)
 * @param  boolean $default_to_store Default to the store address if we don't know the shipping address. Useful for a default tax calculation.
 * @param  boolean $do_checking Check for accuracy of data. If $default_to_store is false, you probably want to set this to false also, as data may not be set properly.
 */
function get_default_ecommerce_fields(?int $member_id = null, string &$shipping_email = '', string &$shipping_phone = '', 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 &$cardholder_name = '', string &$card_type = '', ?string &$card_number = null, ?int &$card_start_date_year = null, ?int &$card_start_date_month = null, ?int &$card_expiry_date_year = null, ?int &$card_expiry_date_month = null, ?string &$card_cv2 = null, string &$billing_street_address = '', string &$billing_city = '', string &$billing_county = '', string &$billing_state = '', string &$billing_post_code = '', string &$billing_country = '', bool $default_to_store = false, bool $do_checking = true)
{
    if ($member_id === null) {
        $member_id = get_member();
    }

    $shipping_email = $GLOBALS['FORUM_DRIVER']->get_member_email_address($member_id);

    if (ecommerce_test_mode()) {
        $cardholder_name = $GLOBALS['FORUM_DRIVER']->get_username($member_id);
        $card_type = 'Visa';
        $card_number = '4444333322221111';
        $card_start_date_year = intval(date('Y', utctime_to_usertime(time() - 60 * 60 * 24 * 365)));
        $card_start_date_month = intval(date('m', utctime_to_usertime(time() - 60 * 60 * 24 * 365)));
        $card_expiry_date_year = intval(date('Y', utctime_to_usertime(time() + 60 * 60 * 24 * 365)));
        $card_expiry_date_month = intval(date('m', utctime_to_usertime(time() + 60 * 60 * 24 * 365)));
        $card_cv2 = '012';

        $shipping_firstname = 'John';
        $shipping_lastname = 'Doe';
        $shipping_street_address = '114 W 3rd St';
        $shipping_city = 'Prophetstown';
        $shipping_county = '';
        $shipping_state = 'IL';
        $shipping_post_code = '61277';
        $shipping_country = 'US';
        if ($shipping_email == '') {
            $shipping_email = 'test@example.com';
        }
        $shipping_phone = '01234 56789';

        $billing_street_address = '114 W 3rd St';
        $billing_city = 'Prophetstown';
        $billing_county = '';
        $billing_state = 'IL';
        $billing_post_code = '61277';
        $billing_country = 'US';
    } else {
        $_cardholder_name = get_cms_cpf('payment_cardholder_name');
        $cardholder_name = empty($_cardholder_name) ? '' : $_cardholder_name;

        $_card_type = get_cms_cpf('payment_card_type');
        $card_type = empty($_card_type) ? '' : $_card_type;

        $_card_number = get_cms_cpf('payment_card_number');
        $card_number = empty($_card_number) ? null : $_card_number;

        $_card_start_date = get_cms_cpf('payment_card_start_date');
        list($_card_start_date_month, $_card_start_date_year) = empty($_card_start_date) ? ['', ''] : explode('/', $_card_start_date);
        $card_start_date_year = ($_card_start_date_year == '' || $_card_start_date_year == 'yy') ? null : intval($_card_start_date_year);
        $card_start_date_month = ($_card_start_date_month == '' || $_card_start_date_month == 'mm') ? null : intval($_card_start_date_month);

        $_card_expiry_date = get_cms_cpf('payment_card_expiry_date');
        list($_card_expiry_date_month, $_card_expiry_date_year) = empty($_card_expiry_date) ? ['', ''] : explode('/', $_card_expiry_date);
        $card_expiry_date_year = ($_card_expiry_date_year == '' || $_card_expiry_date_year == 'yy') ? null : intval($_card_expiry_date_year);
        $card_expiry_date_month = ($_card_expiry_date_month == '' || $_card_expiry_date_month == 'mm') ? null : intval($_card_expiry_date_month);

        $card_cv2 = null; // CV2 should never be stored anywhere or it defeats its purpose

        $shipping_firstname = get_cms_cpf('firstname');
        $shipping_lastname = get_cms_cpf('lastname');
        $shipping_street_address = get_cms_cpf('street_address');
        $shipping_city = get_cms_cpf('city');
        $shipping_county = get_cms_cpf('county');
        $shipping_state = get_cms_cpf('state');
        $shipping_post_code = get_cms_cpf('post_code');
        $shipping_country = get_cms_cpf('country');
        $shipping_email = $GLOBALS['FORUM_DRIVER']->get_member_email_address($member_id);
        $shipping_phone = get_cms_cpf('mobile_phone_number');

        $billing_street_address = get_cms_cpf('billing_street_address');
        $billing_city = get_cms_cpf('billing_city');
        $billing_county = get_cms_cpf('billing_county');
        $billing_state = get_cms_cpf('billing_state');
        $billing_post_code = get_cms_cpf('billing_post_code');
        $billing_country = get_cms_cpf('billing_country');

        if (is_guest()) {
            if (addon_installed('stats')) {
                $country = geolocate_ip();
                if ($country !== null) {
                    $shipping_country = $country;
                    $billing_country = $country;
                }
            }
        }
    }

    $shipping_email = post_param_string('shipping_email', $shipping_email, INPUT_FILTER_POST_IDENTIFIER);
    $shipping_phone = post_param_string('shipping_phone', $shipping_phone);
    $shipping_firstname = post_param_string('shipping_firstname', $shipping_firstname);
    $shipping_lastname = post_param_string('shipping_lastname', $shipping_lastname);
    $shipping_street_address = post_param_string('shipping_address1', $shipping_street_address);
    $shipping_city = post_param_string('shipping_city', $shipping_city);
    $shipping_county = post_param_string('shipping_county', $shipping_county);
    $shipping_state = post_param_string('shipping_state', $shipping_state);
    $shipping_post_code = post_param_string('shipping_postalcode', $shipping_post_code);
    $shipping_country = post_param_string('shipping_country', $shipping_country);
    $cardholder_name = post_param_string('payment_cardholder_name', $cardholder_name);
    $card_type = post_param_string('payment_card_type', $card_type);
    $card_number = post_param_string('payment_card_number', ($card_number === null) ? '' : $card_number, INPUT_FILTER_POST_IDENTIFIER);
    $card_start_date_year = post_param_integer('payment_card_start_date_year', $card_start_date_year);
    $card_start_date_month = post_param_integer('payment_card_start_date_month', $card_start_date_month);
    $card_expiry_date_year = post_param_integer('payment_card_expiry_date_year', $card_expiry_date_year);
    $card_expiry_date_month = post_param_integer('payment_card_expiry_date_month', $card_expiry_date_month);
    $card_cv2 = post_param_string('payment_card_cv2', $card_cv2);
    $billing_street_address = post_param_string('billing_address1', $billing_street_address);
    $billing_city = post_param_string('billing_city', $billing_city);
    $billing_county = post_param_string('billing_county', $billing_county);
    $billing_state = post_param_string('billing_state', $billing_state);
    $billing_post_code = post_param_string('billing_postalcode', $billing_post_code);
    $billing_country = post_param_string('billing_country', $billing_country);

    if ($default_to_store) {
        if ($shipping_street_address == '') {
            $shipping_street_address = get_option('business_street_address');
            if ($shipping_city == '') {
                $shipping_city = get_option('business_city');
            }
            if ($shipping_county == '') {
                $shipping_county = get_option('business_county');
            }
            if ($shipping_state == '') {
                $shipping_state = get_option('business_state');
            }
            if ($shipping_post_code == '') {
                $shipping_post_code = get_option('business_post_code');
            }
        }
        if ($shipping_country == '') {
            $shipping_country = get_option('business_country');
        }
    }

    if (get_option('cpf_enable_country') == '0') {
        if ($shipping_country == '') {
            $shipping_country = get_option('business_country');
        }

        if ($billing_country == '') {
            $billing_country = get_option('business_country');
        }
    }

    if (!$do_checking) {
        return;
    }

    global $USA_STATE_LIST;

    if (($shipping_country == 'US') && ($shipping_state == '')) {
        warn_exit(do_lang_tempcode('STATE_NEEDED_FOR_USA'));
    }
    if (($shipping_country == '') && ($billing_country == 'US') && ($billing_state == '')) {
        warn_exit(do_lang_tempcode('STATE_NEEDED_FOR_USA'));
    }

    if (($shipping_country == 'US') && ($shipping_state != '') && (!array_key_exists($shipping_state, $USA_STATE_LIST))) {
        warn_exit(do_lang_tempcode('INVALID_STATE_FOR_USA', escape_html($shipping_state)));
    }
    if (($shipping_country == '') && ($billing_country == 'US') && ($billing_state != '') && (!array_key_exists($billing_state, $USA_STATE_LIST))) {
        warn_exit(do_lang_tempcode('INVALID_STATE_FOR_USA', escape_html($billing_state)));
    }

    // We make sure a non-USA country does not select a USA state as the mistake is 'easy' to make in the default list
    // We only do it if it is a list though, hence the business_country check
    if (($shipping_country != 'US') && (get_option('business_country') == 'US') && ($shipping_state != '') && (array_key_exists($shipping_state, $USA_STATE_LIST))) {
        warn_exit(do_lang_tempcode('INVALID_STATE_FOR_NON_USA', escape_html($shipping_country), escape_html($shipping_state)));
    }
    if (($shipping_country == '') && ($billing_country != 'US') && (get_option('business_country') == 'US') && ($billing_state != '') && (array_key_exists($billing_state, $USA_STATE_LIST))) {
        warn_exit(do_lang_tempcode('INVALID_STATE_FOR_NON_USA', escape_html($shipping_country), escape_html($billing_state)));
    }

    if (($shipping_country == 'US') && ($shipping_post_code == '')) {
        warn_exit(do_lang_tempcode('ZIP_NEEDED_FOR_USA'));
    }
    if (($shipping_country == '') && ($billing_country == 'US') && ($billing_post_code == '')) {
        warn_exit(do_lang_tempcode('ZIP_NEEDED_FOR_USA'));
    }

    if (($shipping_country == '') && ($billing_country == 'US') && (preg_match('#^[0-9]{5}(-[0-9]{4})?$#', $billing_post_code) == 0)) {
        warn_exit(do_lang_tempcode('INVALID_ZIP_FOR_USA', escape_html($billing_post_code)));
    }
    if (($shipping_country == 'US') && (preg_match('#^[0-9]{5}(-[0-9]{4})?$#', $shipping_post_code) == 0)) {
        warn_exit(do_lang_tempcode('INVALID_ZIP_FOR_USA', escape_html($shipping_post_code)));
    }

    // Credit cards are generally 15 or 16 digits, but they can be between 13 and 19 digits. We do not do advanced validation as companies may change their patterns.
    if (($card_number != '') && (preg_match('#^\d{13,19}$#', $card_number) == 0)) {
        warn_exit(do_lang_tempcode('INVALID_CC_NUMBER'));
    }
    if (($card_cv2 != '') && (preg_match('#^\d{3,4}$#', $card_cv2) == 0)) {
        warn_exit(do_lang_tempcode('INVALID_CV2_NUMBER'));
    }
}

/**
 * Attach a memo field to a product's input fields if needed.
 *
 * @param  ?Tempcode $fields The fields to attach to (null: create new Tempcode object if needed)
 */
function ecommerce_attach_memo_field_if_needed(?object &$fields)
{
    if (get_option('payment_memos') == '1') {
        if ((perform_local_payment()) || (get_page_name() == 'admin_ecommerce_reports')) {
            $get_memo = true;
        } else {
            $payment_gateway = get_option('payment_gateway');
            require_code('hooks/systems/payment_gateway/' . filter_naughty_harsh($payment_gateway));
            $payment_gateway_object = object_factory('Hook_payment_gateway_' . filter_naughty_harsh($payment_gateway));
            $config = $payment_gateway_object->get_config();
            $get_memo = !$config['supports_remote_memo'];
        }
        if ($get_memo) {
            if ($fields === null) {
                $fields = new Tempcode();
            }
            $fields->attach(form_input_text(do_lang_tempcode('NOTES'), do_lang_tempcode('TRANSACTION_NOTES'), 'memo', '', false, false));
        }
    }
}

/**
 * Get a CPF label, for re-use as a general form input label. Remove any CPF name prefixing.
 *
 * @param  ID_TEXT $cpf_name The CPF name
 * @return string The CPF label
 */
function do_lang_cpf(string $cpf_name) : string
{
    require_lang('cns_special_cpf');

    $ret = do_lang('SPECIAL_CPF__cms_' . $cpf_name);
    $ret = preg_replace('#.*: #', '', $ret);
    return $ret;
}

/**
 * Handle a particular local transaction (a transaction where details are taken in locally and sent to the payment API invisibly-to-the-user), as determined by the POST request.
 *
 * @param  ID_TEXT $payment_gateway The payment gateway
 * @param  object $payment_gateway_object The payment gateway object
 * @return array A triple: success status, formatted status message, raw status message
 */
function do_local_transaction(string $payment_gateway, object $payment_gateway_object) : array
{
    // Grab transaction details...

    $trans_expecting_id = post_param_string('trans_id');

    $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];

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

    $tax = $transaction_row['e_tax'];
    $shipping_cost = $transaction_row['e_shipping'];
    $price = $transaction_row['e_price'];
    $currency = $transaction_row['e_currency'];

    $length = $transaction_row['e_length'];
    $length_units = $transaction_row['e_length_units'];
    $is_subscription = ($length !== null);

    $memo = $transaction_row['e_memo'];

    // Read in address fields...

    $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);

    $card_start_date = ($card_start_date_year === null || $card_start_date_month === null) ? '' : (strval($card_start_date_year) . '/' . strval($card_start_date_month));
    $card_expiry_date = ($card_expiry_date_year === null || $card_expiry_date_month === null) ? '' : (strval($card_expiry_date_year) . '/' . strval($card_expiry_date_month));

    // Save shipping address...

    store_shipping_address($trans_expecting_id);

    // Save into CPFs...

    if ((get_param_integer('payment_save_to_account', 0) == 1) && (get_forum_type() == 'cns') && (get_option('store_credit_card_numbers') == '1')) {
        require_code('cns_members_action2');
        $changes = [];
        $changes += cns_set_custom_field(get_member(), find_cms_cpf_field_id('cms_payment_cardholder_name'), $cardholder_name, null, true);
        $changes += cns_set_custom_field(get_member(), find_cms_cpf_field_id('cms_payment_card_type'), $card_type, null, true);
        //$changes += cns_set_custom_field(get_member(), find_cms_cpf_field_id('cms_payment_card_number'), $card_number, null, true);   PCI rules mean we can't save this without encrypting it and obfuscating when displayed; too onerous to force encryption keys that aren't backed up, so let's save everything but this
        $changes += cns_set_custom_field(get_member(), find_cms_cpf_field_id('cms_payment_card_start_date'), $card_start_date, null, true);
        $changes += cns_set_custom_field(get_member(), find_cms_cpf_field_id('cms_payment_card_expiry_date'), $card_expiry_date, null, true);
        if (!empty($changes)) {
            $GLOBALS['FORUM_DB']->query_update('f_member_custom_fields', $changes, ['mf_member_id' => get_member()], '', 1);
        }
    }

    if ((get_param_integer('billing_save_to_account', 0) == 1) && (get_forum_type() == 'cns') && (get_option('store_credit_card_numbers') == '1')) {
        require_code('cns_members_action2');
        $changes = [];
        $changes += cns_set_custom_field(get_member(), find_cms_cpf_field_id('cms_billing_street_address'), $billing_street_address, null, true);
        $changes += cns_set_custom_field(get_member(), find_cms_cpf_field_id('cms_billing_city'), $billing_city, null, true);
        $changes += cns_set_custom_field(get_member(), find_cms_cpf_field_id('cms_billing_county'), $billing_county, null, true);
        $changes += cns_set_custom_field(get_member(), find_cms_cpf_field_id('cms_billing_state'), $billing_state, null, true);
        $changes += cns_set_custom_field(get_member(), find_cms_cpf_field_id('cms_billing_post_code'), $billing_post_code, null, true);
        $changes += cns_set_custom_field(get_member(), find_cms_cpf_field_id('cms_billing_country'), $billing_country, null, true);
        if (!empty($changes)) {
            $GLOBALS['FORUM_DB']->query_update('f_member_custom_fields', $changes, ['mf_member_id' => get_member()], '', 1);
        }
    }

    if ((get_param_integer('shipping_save_to_account', 0) == 1) && (get_forum_type() == 'cns') && (get_option('store_credit_card_numbers') == '1')) {
        require_code('cns_members_action2');
        $changes = [];
        $changes += cns_set_custom_field(get_member(), find_cms_cpf_field_id('cms_firstname'), $shipping_firstname, null, true);
        $changes += cns_set_custom_field(get_member(), find_cms_cpf_field_id('cms_lastname'), $shipping_lastname, null, true);
        $changes += cns_set_custom_field(get_member(), find_cms_cpf_field_id('cms_street_address'), $shipping_street_address, null, true);
        $changes += cns_set_custom_field(get_member(), find_cms_cpf_field_id('cms_city'), $shipping_city, null, true);
        $changes += cns_set_custom_field(get_member(), find_cms_cpf_field_id('cms_county'), $shipping_county, null, true);
        $changes += cns_set_custom_field(get_member(), find_cms_cpf_field_id('cms_state'), $shipping_state, null, true);
        $changes += cns_set_custom_field(get_member(), find_cms_cpf_field_id('cms_post_code'), $shipping_post_code, null, true);
        $changes += cns_set_custom_field(get_member(), find_cms_cpf_field_id('cms_country'), $shipping_country, null, true);
        $changes += cns_set_custom_field(get_member(), find_cms_cpf_field_id('cms_mobile_phone_number'), $shipping_phone, null, true);
        if (!empty($changes)) {
            $GLOBALS['FORUM_DB']->query_update('f_member_custom_fields', $changes, ['mf_member_id' => get_member()], '', 1);
        }
    }

    // Process order...

    list($success, $message, $message_raw, $txn_id, $transaction_fee) = $payment_gateway_object->do_local_transaction($trans_expecting_id, $cardholder_name, $card_type, $card_number, $card_start_date, $card_expiry_date, $card_cv2, $price, $currency, $billing_street_address, $billing_city, $billing_county, $billing_state, $billing_post_code, $billing_country, $shipping_firstname, $shipping_lastname, $shipping_street_address, $shipping_city, $shipping_county, $shipping_state, $shipping_post_code, $shipping_country, $shipping_email, $shipping_phone, $length, $length_units);

    $GLOBALS['SITE_DB']->query_update('ecom_trans_addresses', ['a_txn_id' => $txn_id, 'a_trans_expecting_id' => ''], ['a_trans_expecting_id' => $trans_expecting_id], '', 1);

    if (($success) || ($length !== null)) {
        $status = (($length !== null) && (!$success)) ? 'SCancelled' : 'Completed';
        $period = ($length === null) ? '' : cms_strtolower_ascii(strval($length) . ' ' . $length_units);
        handle_confirmed_transaction($trans_expecting_id, $txn_id, $type_code, $item_name, $purchase_id, $is_subscription, $status, $message_raw, $price, $tax, $shipping_cost, $transaction_fee, $currency, true, '', '', $memo, $period, get_member(), $payment_gateway, false, true);
    }

    // Return...

    if (is_numeric($message)) {
        // Not usable
        $message = null;
    }

    return [$success, $message, $message_raw];
}

/**
 * Handle PDT's and IPN's.
 *
 * @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).
 * @param  boolean $send_notifications Whether to send notifications. Set to false if this is not the primary payment handling (e.g. a POST redirect rather than the real IPN).
 * @return ?ID_TEXT The ID of the purchase-type (meaning depends on item_name) (null: unknown)
 */
function handle_pdt_ipn_transaction_script(bool $silent_fail = false, bool $send_notifications = true) : ?string
{
    if (!addon_installed('ecommerce')) {
        warn_exit(do_lang_tempcode('MISSING_ADDON', escape_html('ecommerce')));
    }

    @header('X-Robots-Tag: noindex');

    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, 'handle_pdt_ipn_transaction_script' . "\n");
        fwrite($myfile, 'POST: ' . serialize($_POST) . "\n");
        fwrite($myfile, 'GET: ' . serialize($_GET) . "\n");
        fwrite($myfile, "\n\n");
        flock($myfile, LOCK_UN);
        fclose($myfile);
    }

    $payment_gateway = get_param_string('from', get_option('payment_gateway'));
    require_code('hooks/systems/payment_gateway/' . filter_naughty_harsh($payment_gateway));
    $payment_gateway_object = object_factory('Hook_payment_gateway_' . filter_naughty_harsh($payment_gateway));

    ob_start();

    $result = null;

    if (has_interesting_post_fields()) { // IPN
        $result = $payment_gateway_object->handle_ipn_transaction($silent_fail);
    } elseif (method_exists($payment_gateway_object, 'handle_pdt_transaction')) { // PDT
        $result = $payment_gateway_object->handle_pdt_transaction($silent_fail);
    }

    if ($result === 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, 'handle_pdt_ipn_transaction_script: WARN: Did not handle this transaction' . "\n");
            fwrite($myfile, "\n\n");
            flock($myfile, LOCK_UN);
            fclose($myfile);
        }
        return null;
    }

    list($trans_expecting_id, $txn_id, $type_code, $item_name, $purchase_id, $is_subscription, $status, $reason, $price, $tax, $shipping_cost, $transaction_fee, $currency, $parent_txn_id, $pending_reason, $memo, $period, $member_id) = $result;

    list($type_code, $member_id) = handle_confirmed_transaction($trans_expecting_id, $txn_id, $type_code, $item_name, $purchase_id, $is_subscription, $status, $reason, $price, $tax, $shipping_cost, $transaction_fee, $currency, true, $parent_txn_id, $pending_reason, $memo, $period, $member_id, $payment_gateway, $silent_fail, $send_notifications);

    if (($type_code !== null) && method_exists($payment_gateway_object, 'show_payment_response')) {
        echo $payment_gateway_object->show_payment_response($type_code, $purchase_id);
    }

    return $purchase_id;
}

/**
 * Handle IPN's that have been confirmed as backed up by real money.
 * Variables largely emulate PayPal's IPN API.
 *
 * @param  ?ID_TEXT $trans_expecting_id Our internal temporary transaction ID (null: an immediate transaction that didn't require this table). For a live payment you should always pass a $trans_expecting_id in case tax rates or price changes over the interim, which can cause a mismatch or tax filing errors.
 * @param  ?ID_TEXT $txn_id The transaction ID (null: randomised - for debugging only)
 * @param  ?ID_TEXT $type_code The product codename (null: lookup from $trans_expecting_id - for debugging only)
 * @param  ?SHORT_TEXT $item_name The human-readable product title (blank: doing a subscription cancellation, unknown item name; but can get from $found) (null: lookup from $trans_expecting_id - for debugging only)
 * @param  ?ID_TEXT $purchase_id The ID of the purchase-type (meaning depends on item_name) (null: lookup from $trans_expecting_id - for debugging only)
 * @param  boolean $is_subscription Whether this is a subscription
 * @param  ID_TEXT $status The status this transaction is telling of
 * @set Pending Completed SModified SCancelled
 * @param  SHORT_TEXT $reason A reason for the transaction's status (blank: unknown or N/A)
 * @param  ?REAL $price Transaction price paid excluding tax, shipping, and transaction fees (null: lookup from $trans_expecting_id - for debugging only)
 * @param  ?REAL $tax Transaction tax (null: not separated out, find the tax due and take it out of $price)
 * @param  ?REAL $shipping Transaction shipping (null: not separated out, find the shipping due and take it out of $price)
 * @param  ?REAL $transaction_fee The transaction fee (null: we do not know; run get_transaction_fee to determine this)
 * @param  ?ID_TEXT $currency The currency (points: was done fully with points) (null: lookup from $trans_expecting_id - for debugging only)
 * @param  boolean $check_amounts Check the amounts related to this transaction; if not set no points will be charged
 * @param  ID_TEXT $parent_txn_id The ID of the parent transaction (blank: unknown or N/A)
 * @param  SHORT_TEXT $pending_reason The reason it is in pending status (if it is) (blank: unknown or N/A)
 * @param  LONG_TEXT $memo A note attached to the transaction (blank: not set)
 * @param  string $period The subscription period (blank: N/A or unknown because trust is checked on the gateway's code)
 * @param  ?MEMBER $member_id_paying The member ID of who is doing the transaction (null: unknown)
 * @param  ID_TEXT $payment_gateway The payment gateway (manual: was a manual transaction, not through a real gateway)
 * @param  boolean $silent_fail Always 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).
 * @param  boolean $send_notifications Whether to send notifications. Set to false if this is not the primary payment handling (e.g. a POST redirect rather than the real IPN).
 * @return ?array ID_TEXT A pair: The product purchased, The purchasing member ID (or null) (null: silent error)
 */
function handle_confirmed_transaction(?string $trans_expecting_id, ?string $txn_id = null, ?string $type_code = null, ?string $item_name = null, ?string $purchase_id = null, bool $is_subscription = false, string $status = 'Completed', string $reason = '', ?float $price = null, ?float $tax = null, ?float $shipping = null, ?float $transaction_fee = null, ?string $currency = null, bool $check_amounts = true, string $parent_txn_id = '', string $pending_reason = '', string $memo = '', string $period = '', ?int $member_id_paying = null, string $payment_gateway = '', bool $silent_fail = false, bool $send_notifications = true) : ?array
{
    if ($txn_id === null) {
        require_code('crypt');
        $txn_id = 'tx-' . str_replace('-', '', get_secure_v1_guid()); // Must keep under 40 characters
        $t_status = null;
    } else {
        // Grab the t_status of the $txn_id, mainly used for determining when the transaction was already processed.
        $t_status = $GLOBALS['SITE_DB']->query_select_value_if_there('ecom_transactions', 't_status', ['id' => $txn_id]);
    }

    if ($payment_gateway == '') {
        $payment_gateway = get_option('payment_gateway');
    }

    // We need to grab these from somewhere
    $expected_amount = null;
    $expected_shipping = null;
    $expected_tax_derivation = [];
    $expected_tax_tracking = [];
    $expected_tax = null;
    $expected_price_points = null;
    $invoicing_breakdown = '';
    $expected_currency = null;
    $sale_timestamp = time();
    $session_id = get_session_id();

    // Grab expected price and tax: Locked in in advance in ecom_trans_expecting transaction (this is the standard case for an IPN)
    if ($trans_expecting_id !== null) {
        $transaction_rows = $GLOBALS['SITE_DB']->query_select('ecom_trans_expecting', ['*'], ['id' => $trans_expecting_id], '', 1);
        if (array_key_exists(0, $transaction_rows)) {
            $transaction_row = $transaction_rows[0];

            if (time() - $transaction_rows[0]['e_time'] < 24 * 60 * 60 * intval(get_option('ecom_price_honour_time'))) {
                $expected_amount = $transaction_row['e_price'];
                $expected_tax_derivation = ($transaction_row['e_tax_derivation'] == '') ? [] : json_decode($transaction_row['e_tax_derivation'], true);
                $expected_tax = $transaction_row['e_tax'];
                $expected_price_points = $transaction_row['e_price_points'];
                $expected_shipping = $transaction_row['e_shipping'];
            }
            $expected_tax_tracking = ($transaction_row['e_tax_tracking'] == '') ? [] : json_decode($transaction_row['e_tax_tracking'], true);
            $sale_timestamp = $transaction_row['e_time']; // Important: as sales tax should be locked in at the time of it all being generated, not when the payment came through
            $session_id = $transaction_row['e_session_id'];
            $member_id_paying = $transaction_row['e_member_id'];
            $invoicing_breakdown = $transaction_row['e_invoicing_breakdown'];
            if ($memo == '') {
                $memo = $transaction_row['e_memo'];
            }

            if ($type_code === null) {
                $type_code = $transaction_row['e_type_code'];
            }
            if ($item_name === null) {
                $item_name = $transaction_row['e_item_name'];
            }
            if ($purchase_id === null) {
                $purchase_id = $transaction_row['e_purchase_id'];
            }
            if ($price === null) {
                $price = $transaction_row['e_price'];
            }
            if ($tax === null) {
                $tax = $transaction_row['e_tax'];
            }
            if ($shipping === null) {
                $shipping = $transaction_row['e_shipping'];
            }
            if ($currency === null) {
                $currency = $transaction_row['e_currency'];
            }
        }
    }

    // Check we have what we need
    if ($type_code === null || $item_name === null || $purchase_id === null || $price === null || $tax === null || $shipping === null || $currency === null) {
        warn_exit(do_lang_tempcode('INTERNAL_ERROR'));
    }

    // Try and locate the product
    list($found, $product_object) = find_product_details($type_code);
    if ($found === null) {
        if ($silent_fail) {
            return null;
        }
        fatal_ipn_exit(do_lang('PRODUCT_NO_SUCH', $item_name, $type_code), true/*no logged error because we can't assume all transactions are generated by the software*/);
    }
    if ($is_subscription) { // Subscription
        // Check subscription length
        if (($found !== null) && ($period != '')) {
            $length = array_key_exists('length', $found['type_special_details']) ? strval($found['type_special_details']['length']) : '1';
            $length_units = array_key_exists('length_units', $found['type_special_details']) ? $found['type_special_details']['length_units'] : 'm';
            if (cms_strtolower_ascii($period) != cms_strtolower_ascii($length . ' ' . $length_units)) {
                if ($silent_fail) {
                    return null;
                }
                fatal_ipn_exit(do_lang('IPN_SUB_PERIOD_WRONG'));
            }
        }
    } else {
        if ($found['type'] == PRODUCT_SUBSCRIPTION) {
            exit(); // We ignore separate payment signal for subscriptions (for Paypal it is web_accept). Or, it could be a hacker, which we must block
        }
    }

    // Missing data, but we can find it
    if ($item_name == '') {
        $item_name = $found['item_name'];
    }

    // Find which member the transaction was for, if we can (otherwise we leave it as who did the transaction, or null if that's not set)
    $member_id = $member_id_paying;
    if (method_exists($product_object, 'member_for')) {
        $member_id = $product_object->member_for($type_code, $purchase_id);
    } else {
        if ($member_id === null) {
            if ($found['type'] == PRODUCT_SUBSCRIPTION) {
                $member_id = $GLOBALS['SITE_DB']->query_select_value_if_there('ecom_subscriptions', 's_member_id', ['id' => intval($purchase_id)]);
            } elseif ($found['type'] == PRODUCT_INVOICE) {
                $member_id = $GLOBALS['SITE_DB']->query_select_value_if_there('ecom_invoices', 'i_member_id', ['id' => intval($purchase_id)]);
            }
        }
    }
    if ($member_id == $GLOBALS['FORUM_DRIVER']->get_guest_id()) {
        $member_id = null;
    }
    if ($member_id_paying === null) {
        $member_id_paying = $member_id;
    }

    // Grab expected price and tax: Invoice
    if (($found['type'] == PRODUCT_INVOICE) && ($expected_amount === null)) {
        $invoice_rows = $GLOBALS['SITE_DB']->query_select('ecom_invoices', ['*'], ['id' => intval($purchase_id)], '', 1);
        if (!array_key_exists(0, $invoice_rows)) {
            warn_exit(do_lang_tempcode('MISSING_RESOURCE'));
        }
        $expected_amount = $invoice_rows[0]['i_price'];
        $expected_tax_derivation = ($invoice_rows[0]['i_tax_derivation'] == '') ? [] : json_decode($invoice_rows[0]['i_tax_derivation'], true);
        $expected_tax = $invoice_rows[0]['i_tax'];
        $expected_tax_tracking = ($invoice_rows[0]['i_tax_tracking'] == '') ? [] : json_decode($invoice_rows[0]['i_tax_tracking'], true);
        $expected_price_points = 0;
        $expected_currency = $invoice_rows[0]['i_currency'];
        $expected_shipping = 0.00;
    }

    // Grab expected price and tax: Subscription
    if (($found['type'] == PRODUCT_SUBSCRIPTION) && ($expected_amount === null)) {
        $subscription_rows = $GLOBALS['SITE_DB']->query_select('ecom_subscriptions', ['*'], ['id' => intval($purchase_id)], '', 1);
        if (!array_key_exists(0, $subscription_rows)) {
            warn_exit(do_lang_tempcode('MISSING_RESOURCE'));
        }
        $expected_amount = $subscription_rows[0]['s_price'];
        $expected_tax_derivation = ($subscription_rows[0]['s_tax_derivation'] == '') ? [] : json_decode($subscription_rows[0]['s_tax_derivation'], true);
        $expected_tax = $subscription_rows[0]['s_tax'];
        $expected_tax_tracking = ($subscription_rows[0]['s_tax_tracking'] == '') ? [] : json_decode($subscription_rows[0]['s_tax_tracking'], true);
        $expected_price_points = 0;
        $expected_currency = $subscription_rows[0]['s_currency'];
        $expected_shipping = 0.00; // No shipping on subscriptions
    }

    // Grab expected price and tax: Other
    if (($found['type'] != PRODUCT_INVOICE) && ($found['type'] != PRODUCT_SUBSCRIPTION) && ($expected_amount === null)) {
        if ($found['price'] === null /* Implies price_points is not null */) {
            // Will be paying with points only
            $expected_tax = 0.00;
            $expected_amount = 0.00;
            $expected_shipping = 0.00;
            $expected_price_points = $found['price_points'];
        } else {
            // Will be paying with money (or possibly money & points)
            $expected_amount = $found['price'];
            $expected_shipping = calculate_shipping_cost($found, $found['shipping_cost'], $found['product_weight'], $found['product_length'], $found['product_width'], $found['product_height'], $member_id_paying);
            list($expected_tax_derivation, $expected_tax, $expected_tax_tracking, $expected_shipping_tax) = calculate_tax_due($found, $found['tax_code'], $found['price'], $expected_shipping, $member_id_paying);
            $expected_price_points = 0;
            if (!paid_amount_matches($price, $tax, $shipping, $expected_amount, $expected_tax, $expected_shipping)) {
                // Maybe a discount then
                list($discounted_price, $discounted_tax_code, $points_for_discount) = get_discounted_price($found, $member_id_paying);
                if ($discounted_price !== null) {
                    $discount_tax_details = calculate_tax_due($found, $discounted_tax_code, $discounted_price, $expected_shipping, $member_id_paying);
                    if (paid_amount_matches($price, $tax, $shipping, $discounted_price, $discount_tax_details[1], $expected_shipping)) {
                        $expected_amount = $discounted_price;
                        list($expected_tax_derivation, $expected_tax, $expected_tax_tracking, $expected_shipping_tax) = $discount_tax_details;
                        $expected_price_points = $points_for_discount;
                    }
                }
            }
        }
    }

    // Grab expected currency
    if ($expected_currency === null) {
        $expected_currency = isset($found['currency']) ? $found['currency'] : get_option('currency');
    }
    if ($trans_expecting_id !== null) {
        $transaction_rows = $GLOBALS['SITE_DB']->query_select('ecom_trans_expecting', ['*'], ['id' => $trans_expecting_id], '', 1);
        if ((array_key_exists(0, $transaction_rows)) && (time() - $transaction_rows[0]['e_time'] < 24 * 60 * 60 * intval(get_option('ecom_price_honour_time')))) {
            $transaction_row = $transaction_rows[0];
            $expected_currency = $transaction_row['e_currency'];
        }
    }

    if ($tax === null) {
        $tax = $expected_tax;
    }
    if ($shipping === null) {
        $shipping = $expected_shipping;
    }

    // Check price and currency
    if (($status == 'Completed') && ($check_amounts)) {
        if (!paid_amount_matches($price, $tax, $shipping, $expected_amount, $expected_tax, $expected_shipping)) {
            if ($silent_fail) {
                return null;
            }
            fatal_ipn_exit(do_lang('PURCHASE_WRONG_PRICE', $item_name, float_format($price + $tax + $shipping, 2), float_format($expected_amount + $expected_tax + $expected_shipping)));
        }

        if (($currency != $expected_currency) && (($expected_amount + $expected_tax + $expected_shipping) != 0.00)) {
            if ($silent_fail) {
                return null;
            }
            fatal_ipn_exit(do_lang('PURCHASE_WRONG_CURRENCY', $item_name, $currency, $expected_currency));
        }
    }

    // Charge points if required
    if ((addon_installed('points')) && ($t_status === null) && ($status == 'Completed') && ($check_amounts) && ($expected_price_points !== null) && ($expected_price_points != 0)) {
        require_code('points2');
        points_debit_member($member_id_paying, do_lang(($expected_amount == 0.00) ? 'FREE_ECOMMERCE_PRODUCT' : 'DISCOUNTED_ECOMMERCE_PRODUCT', $item_name), $expected_price_points, 0, 0, false, 0, 'ecommerce', 'purchase', strval($txn_id), null, true/*should force so if the member tries to cheat by spending points, at least they'll end up in the negative*/);
    }
    $price_points = (($expected_price_points !== null) ? $expected_price_points : 0);

    // Figure out the transaction fee if we do not know
    if ($transaction_fee === null) {
        $transaction_fee = get_transaction_fee(($price + $tax + $shipping), $type_code, $payment_gateway);
    }

    if ($t_status === null) {
        // Store
        $GLOBALS['SITE_DB']->query_insert('ecom_transactions', [
            'id' => $txn_id,
            't_memo' => $memo,
            't_type_code' => $type_code,
            't_purchase_id' => $purchase_id,
            't_status' => $status,
            't_pending_reason' => $pending_reason,
            't_reason' => $reason,
            't_price' => $price,
            't_tax_derivation' => json_encode($expected_tax_derivation, defined('JSON_PRESERVE_ZERO_FRACTION') ? JSON_PRESERVE_ZERO_FRACTION : 0),
            't_tax' => $tax,
            't_tax_tracking' => json_encode($expected_tax_tracking, defined('JSON_PRESERVE_ZERO_FRACTION') ? JSON_PRESERVE_ZERO_FRACTION : 0),
            't_shipping' => $shipping,
            't_transaction_fee' => $transaction_fee,
            't_currency' => $currency,
            't_parent_txn_id' => $parent_txn_id,
            't_time' => $sale_timestamp,
            't_payment_gateway' => $payment_gateway,
            't_invoicing_breakdown' => $invoicing_breakdown,
            't_member_id' => $member_id_paying,
            't_session_id' => $session_id,
        ]);

        // Mark tax collected with external services as required
        foreach ($expected_tax_tracking as $tracking_service => $tracking_id) {
            declare_completed($tracking_service, $tracking_id, $txn_id, $member_id_paying, $session_id);
        }

        // Add in extra details to $found, so actualisers can track things better
        $found['TXN_ID'] = $txn_id;
        $found['STATUS'] = $status;

        // Pending: We do some book-keeping then stop
        if ($status == 'Pending') {
            $found['ORDER_STATUS'] = 'ORDER_STATUS_awaiting_payment';

            if ($found['type'] == PRODUCT_INVOICE) { // Invoices have special support for tracking the order status
                $GLOBALS['SITE_DB']->query_update('ecom_invoices', ['i_state' => 'pending'], ['id' => intval($purchase_id)], '', 1);
            } elseif ($found['type'] == PRODUCT_SUBSCRIPTION) { // Subscriptions have special support for tracking the order status
                $GLOBALS['SITE_DB']->query_update('ecom_subscriptions', ['s_state' => 'pending'], ['id' => intval($purchase_id)], '', 1);
            }

            // Call actualiser code
            if (method_exists($found, 'actualiser')) {
                $product_object->actualiser($type_code, $purchase_id, $found);
            }

            // Pending transactions stop here
            if ($silent_fail) {
                return null;
            }

            // Pending transactions stop here
            fatal_ipn_exit(do_lang('TRANSACTION_NOT_COMPLETE', $type_code . ':' . strval($purchase_id), $status), true);
        }

        /*
        At this point we know our transaction is good -- or a subscription cancellation.
        Possible statuses: Completed|SModified|SCancelled
        */

        // Subscription: Completed (Made active)
        if (($status == 'Completed') && ($found['type'] == PRODUCT_SUBSCRIPTION)) {
            $GLOBALS['SITE_DB']->query_update('ecom_subscriptions', ['s_auto_fund_source' => $payment_gateway, 's_auto_fund_key' => $txn_id, 's_state' => 'active'], ['id' => intval($purchase_id)], '', 1);
        }

        // Subscription: Modified
        if (($status == 'SModified') && ($found['type'] == PRODUCT_SUBSCRIPTION)) {
            // No special action needed
        }

        // Subscription: Cancelled
        if (($status == 'SCancelled') && ($found['type'] == PRODUCT_SUBSCRIPTION)) {
            $GLOBALS['SITE_DB']->query_update('ecom_subscriptions', ['s_auto_fund_source' => $payment_gateway, 's_auto_fund_key' => $txn_id, 's_state' => 'cancelled'], ['id' => intval($purchase_id)], '', 1);
        }

        // Invoice handling
        if (($status == 'Completed') && ($found['type'] == PRODUCT_INVOICE)) {
            $GLOBALS['SITE_DB']->query_update('ecom_invoices', ['i_state' => 'paid'], ['id' => intval($purchase_id)], '', 1);
        }

        // Set order dispatch status
        if ($status == 'Completed') {
            if (!method_exists($product_object, 'get_product_dispatch_type')) { // If hook does not have dispatch method setting take dispatch method as automatic
                $found['ORDER_STATUS'] = 'ORDER_STATUS_dispatched';
            } elseif ($product_object->get_product_dispatch_type($purchase_id) == 'automatic') {
                $found['ORDER_STATUS'] = 'ORDER_STATUS_dispatched';
            } else {
                $found['ORDER_STATUS'] = 'ORDER_STATUS_payment_received'; // Dispatch has to happen manually still
            }
        }

        // Call actualiser code
        if (method_exists($product_object, 'actualiser')) {
            $automatic_setup = $product_object->actualiser($type_code, $purchase_id, $found);
        } else {
            $automatic_setup = false;
        }

        // Notifications
        if ($status == 'Completed') {
            if ($send_notifications) {
                send_transaction_mails($txn_id, $item_name, $found['needs_shipping_address'], $automatic_setup, $member_id, $price, $tax, $shipping, $currency, $price_points, $memo);
            }
        }
    }

    // Cleanup very old data
    $GLOBALS['SITE_DB']->query('DELETE FROM ' . get_table_prefix() . 'ecom_sales_expecting WHERE e_time<' . strval(time() - 60 * 60 * 24 * 90));

    return [$type_code, $member_id];
}

/**
 * Send transaction e-mails.
 *
 * @param  ID_TEXT $txn_id Transaction ID
 * @param  string $item_name Item name
 * @param  boolean $shipped Whether the item will be shipped
 * @param  boolean $automatic_setup Whether the product was automatically setup (i.e. is ready now).
 * @param  ?MEMBER $member_id Member ID transaction is for (null: unknown)
 * @param  REAL $price Transaction price
 * @param  REAL $tax Transaction tax amount
 * @param  REAL $shipping Transaction shipping amount
 * @param  ID_TEXT $currency The currency
 * @param  integer $price_points Points charge paid
 * @param  string $memo Customer memo
 */
function send_transaction_mails(string $txn_id, string $item_name, bool $shipped, bool $automatic_setup, ?int $member_id, float $price, float $tax, float $shipping, string $currency, int $price_points, string $memo = '')
{
    require_code('notifications');

    if ($currency == 'points') {
        $_currency = do_lang('POINTS');
        $_price = integer_format($price_points);
        $currency_symbol = '';
    } else {
        $_currency = $currency;
        $_price = float_format($price);
        $currency_symbol = ecommerce_get_currency_symbol($currency);
    }

    $receipt = display_receipt($txn_id);

    // Prepare for notification
    $existing = $GLOBALS['SITE_DB']->query_select('ecom_trans_addresses', ['*'], ['a_txn_id' => $txn_id], '', 1);
    if (array_key_exists(0, $existing)) {
        $address = $existing[0];
    } else {
        $address = [
            'a_firstname' => '',
            'a_lastname' => '',
            'a_street_address' => '',
            'a_city' => '',
            'a_county' => '',
            'a_state' => '',
            'a_post_code' => '',
            'a_country' => '',
            'a_email' => '',
            'a_phone' => '',
        ];
    }
    $address_parts = [
        'name' => trim($address['a_firstname'] . ' ' . $address['a_lastname']),
        'street_address' => $address['a_street_address'],
        'city' => $address['a_city'],
        'county' => $address['a_county'],
        'state' => $address['a_state'],
        'post_code' => $address['a_post_code'],
        'country' => $address['a_country'],
        'email' => $address['a_email'],
        'phone' => $address['a_phone'],
    ];
    $parameter_map = [
        'AUTOMATIC_SETUP' => $automatic_setup,
        'PRICE' => $_price,
        'CURRENCY' => $_currency,
        'ITEM_NAME' => $item_name,
        'TAX' => float_format($tax),
        'SHIPPING' => float_format($shipping),
        'CURRENCY_SYMBOL' => $currency_symbol,
        'RECEIPT' => escape_html_in_comcode($receipt),
        'MEMO' => $memo,
        'SHIPPED' => $shipped,

        // Shipping
        'SHIPPING_FIRSTNAME' => $address['a_firstname'],
        'SHIPPING_LASTNAME' => $address['a_lastname'],
        'SHIPPING_STREET_ADDRESS' => $address['a_street_address'],
        'SHIPPING_CITY' => $address['a_city'],
        'SHIPPING_COUNTY' => $address['a_county'],
        'SHIPPING_STATE' => $address['a_state'],
        'SHIPPING_POST_CODE' => $address['a_post_code'],
        'SHIPPING_COUNTRY' => find_country_name_from_iso($address['a_country']),
        'SHIPPING_EMAIL' => $address['a_email'],
        'SHIPPING_PHONE' => $address['a_phone'],
        'SHIPPING_FORMATTED_ADDRESS' => get_formatted_address($address_parts),
    ];

    // Send completed notification to user
    if (($member_id === null) || (is_guest($member_id))) {
        if (array_key_exists(0, $existing)) {
            $e = $existing[0];
            $email = $e['a_email'];
            if ($email != '') {
                $to_name = trim($e['a_firstname'] . ' ' . $e['a_lastname']);
                if ($to_name == '') {
                    $to_name = null;
                }

                $subject = do_lang('PAYMENT_SENT_SUBJECT', $txn_id, $item_name, null, get_site_default_lang());

                $_body = do_template('ECOM_PAYMENT_SENT_MAIL', $parameter_map, get_site_default_lang(), false, null, '.txt', 'text');
                $body = $_body->evaluate(get_site_default_lang());

                require_code('mail');
                dispatch_mail($subject, $body, '', [$email], $to_name);
            }
        }
    } else {
        $subject = do_lang('PAYMENT_SENT_SUBJECT', $txn_id, $item_name, null, get_lang($member_id));

        $_body = do_notification_template('ECOM_PAYMENT_SENT_MAIL', $parameter_map, get_lang($member_id), false, null, '.txt', 'text');
        $body = $_body->evaluate(get_lang($member_id));

        dispatch_notification('payment_received', null, $subject, $body, [$member_id], A_FROM_SYSTEM_PRIVILEGED);
    }

    // Send completed notification to staff...

    $subject = do_lang('PAYMENT_RECEIVED_SUBJECT', $txn_id, $item_name, null, get_site_default_lang());

    $_body = do_notification_template('ECOM_PAYMENT_RECEIVED_MAIL', $parameter_map, get_site_default_lang(), false, null, '.txt', 'text');
    $body = $_body->evaluate(get_site_default_lang());

    dispatch_notification('payment_received_staff', null, $subject, $body, null, A_FROM_SYSTEM_PRIVILEGED);
}

/**
 * See if the transaction is for the correct amount.
 * We cannot check for transaction fee because we cannot reliably predict it.
 *
 * @param  REAL $price Transaction price (this should exclude tax, shipping, and fees)
 * @param  REAL $tax Transaction tax amount
 * @param  REAL $shipping Transaction shipping amount
 * @param  REAL $expected_price Transaction price expected (this should exclude tax and shipping)
 * @param  REAL $expected_tax Transaction tax amount expected
 * @param  REAL $expected_shipping Transaction shipping amount expected
 * @return boolean Whether it is
 */
function paid_amount_matches(float $price, float $tax, float $shipping, float $expected_price, float $expected_tax, float $expected_shipping) : bool
{
    if (($expected_price + $expected_tax + $expected_shipping) > 0.10) {
        // A 1 cent tolerance for possible rounding errors
        $amount_matches = ((($price + $tax + $shipping + 0.01) > ($expected_price + $expected_tax + $expected_shipping)) && (($price + $tax + $shipping - 0.01) < ($expected_price + $expected_tax + $expected_shipping)));
    } else {
        // Transaction too low for a tolerance
        $amount_matches = (($price + $tax + $shipping) == ($expected_price + $expected_tax + $expected_shipping));
    }

    return $amount_matches;
}

/**
 * Exit the software and write to the error log file.
 *
 * @param  string $error The message
 * @param  boolean $dont_trigger Dont trigger an error
 * @exits
 */
function fatal_ipn_exit(string $error, bool $dont_trigger = false)
{
    if (current_fatalistic() > 0) {
        fatal_exit($error);
    }

    echo $error . "\n";
    if (!$dont_trigger) {
        trigger_error($error, E_USER_NOTICE);
    }
    exit();
}

/**
 * Find the 'discounted' price for a product (check the return values carefully).
 *
 * @param  array $details Product details
 * @param  ?MEMBER $member_id The member who this is for (null: current member)
 * @return array A tuple: Discounted price (null is no discount), Discounted price tax code, Points required to get discount (null is no discount), Whether this is a discount
 */
function get_discounted_price(array $details, ?int $member_id = null) : array
{
    // No discount for guests (because guests do not use points) or if points is not installed
    if ((!addon_installed('points')) || (is_guest())) {
        return [
            null,
            $details['tax_code'],
            null,
            false,
        ];
    }

    if ($member_id === null) {
        $member_id = get_member();
    }

    if (($details['price'] === null/*has to be points as no monetary-price*/) || (($details['discount_points__price_reduction'] !== null) && (float_to_raw_string($details['discount_points__price_reduction'], 2) == float_to_raw_string($details['price'])))) {
        return [
            0.00,
            '0.00',
            $details['price_points'],
            false,
        ];
    }

    if (($details['discount_points__num_points'] !== null) && ($details['discount_points__price_reduction'] !== null)) {
        require_code('points');
        if ((points_balance($member_id) >= $details['discount_points__num_points'])) {
            $discounted_price = max(0.00, $details['price'] - $details['discount_points__price_reduction']);
            return [
                $discounted_price,
                tax_multiplier($details['tax_code'], ($discounted_price / $details['price'])),
                $details['discount_points__num_points'],
                true,
            ];
        }
    }

    return [
        null,
        $details['tax_code'],
        null,
        false,
    ];
}

/**
 * Get a transaction row.
 *
 * @param  ID_TEXT $txn_id Transaction ID
 * @param  boolean $missing_ok Whether to not warn_exit if the transaction is missing and return null instead
 * @return ?array Row (null: not found and $missing_ok was true)
 */
function get_transaction_row(string $txn_id, bool $missing_ok = false) : ?array
{
    $transaction_rows = $GLOBALS['SITE_DB']->query_select('ecom_transactions', ['*'], ['id' => $txn_id], '', 1);
    if (!array_key_exists(0, $transaction_rows)) {
        if (!$missing_ok) {
            warn_exit(do_lang_tempcode('MISSING_RESOURCE'));
        } else {
            return null;
        }
    }
    return $transaction_rows[0];
}

/**
 * Get transaction status.
 *
 * @param  ID_TEXT $_status PayPal-style transaction status
 * @return string The status
 */
function get_transaction_status_string(string $_status) : string
{
    $status = '';
    switch ($_status) {
        case 'Pending':
            $status = do_lang('PAYMENT_STATE_pending');
            break;

        case 'Completed':
            $status = do_lang('PAYMENT_STATE_paid');
            break;

        case 'SCancelled':
            $status = do_lang('PAYMENT_STATE_cancelled');
            break;

        case 'SModified':
            $status = do_lang('PAYMENT_STATE_smodified');
            break;

        default:
            $status = do_lang($status);
    }
    return $status;
}

/**
 * Return list entry of common transaction statuses of transactions.
 *
 * @return Tempcode Order status list entries
 */
function get_transaction_status_list() : object
{
    $status = [
        'Pending' => do_lang_tempcode('PAYMENT_STATE_pending'),
        'Completed' => do_lang_tempcode('PAYMENT_STATE_paid'),
        'SCancelled' => do_lang_tempcode('PAYMENT_STATE_cancelled'),
        'SModified' => do_lang_tempcode('PAYMENT_STATE_smodified'),
    ];

    $status_list = new Tempcode();

    $status_list->attach(form_input_list_entry('', false, do_lang_tempcode('NA_EM')));

    foreach ($status as $key => $string) {
        $status_list->attach(form_input_list_entry($key, false, $string));
    }
    return $status_list;
}

/**
 * Get the full business address.
 *
 * @return string Full business address
 */
function get_full_business_address() : string
{
    $_address_parts = [
        'name',
        'street_address',
        'city',
        'county',
        'state',
        'post_code',
        'country',
    ];
    $address_parts = [];
    foreach ($_address_parts as $address_part_codename) {
        $address_part = get_option('business_' . $address_part_codename);
        $address_parts[$address_part_codename] = $address_part;
    }
    return get_formatted_address($address_parts);
}

/**
 * Get a formatted address.
 *
 * @param  array $address_parts A map of address parts (see comment inside function for structure)
 * @return string Formatted address
 */
function get_formatted_address(array $address_parts) : string
{
    // Expected format of $address_parts (codename as indicated below => value)
    /*
    $address_parts = [
        'name' => '',
        'street_address' => '',
        'city' => '',
        'county' => '',
        'state' => '', // 2-letter code
        'post_code' => '',
        'country' => '', // 2-letter code
    ];
    */

    $address = '';
    foreach ($address_parts as $address_part_codename => $address_part) {
        if ($address_part != '') {
            if ($address_part_codename == 'country') {
                // We want the full written country name
                $test = find_country_name_from_iso($address_part);
                if ($test !== null) {
                    $address_part = $test;
                }
            }

            // For US, put city/county/state/zip on the same line and commas between city/county/state
            if ((!empty($address_parts['country'])) && ($address_parts['country'] == 'US')) {
                if (($address_part_codename == 'county') || ($address_part_codename == 'state') || ($address_part_codename == 'post_code')) {
                    $address = rtrim($address);
                    if ($address_part_codename != 'post_code') {
                        $address .= ', ';
                    } else {
                        $address .= ' ';
                    }
                }
            }

            $address .= $address_part . "\n";
        }
    }

    return rtrim($address); // Remove the last line break
}

/**
 * Split a linebreak-separated street address into individual parts.
 *
 * @param  string $compound_street_address Linebreak-separated street address
 * @param  integer $num_parts Number of parts to split into (the last will have comma-separated parts of whatever is remaining)
 * @param  boolean $find_company_name The first part will be for a company name, which we'll try and detect intelligently but will be blank if we're not sure
 * @return array Parts
 */
function split_street_address(string $compound_street_address, int $num_parts, bool $find_company_name = false) : array
{
    $parts = explode("\n", trim($compound_street_address), $num_parts);
    $parts[count($parts) - 1] = str_replace("\n", ", ", $parts[count($parts) - 1]);

    if ($find_company_name) {
        if ((preg_match('#\d#', $parts[0]) != 0) && (preg_match('# (GmbH|Ent|ENT|Enterprises|ENTERPRISES|Llp|LLP|Partners|PARTNERS|Co|CO|Corp|CORP|Corporation|Inc|INC|Ltd|LTD|Plc|PLC|Pty|PTY|Llc|LLC)\.?$#', $parts[0]) == 0)) {
            // First part looks more like a street address than a company name, so we'll make the company name blank
            $parts = explode("\n", trim($compound_street_address), $num_parts - 1);
            $parts[count($parts) - 1] = str_replace("\n", ", ", $parts[count($parts) - 1]);
            $parts = array_merge([''], $parts);
        }
    }

    for ($i = 0; $i < $num_parts; $i++) {
        if (!array_key_exists($i, $parts)) {
            $parts[$i] = '';
        }
    }

    return $parts;
}

/**
 * Display a receipt.
 *
 * @param  ID_TEXT $txn_id Transaction ID
 * @param  ?MEMBER $member_id_viewing The member viewing the receipt for access checking (null: Do not check access)
 * @return Tempcode Receipt UI
 */
function display_receipt(string $txn_id, ?int $member_id_viewing = null) : object
{
    $transaction_row = get_transaction_row($txn_id);

    // Members should not be allowed to view receipts of other members
    if (($member_id_viewing !== null) && ($transaction_row['t_member_id'] != $member_id_viewing)) {
        if (!has_privilege($member_id_viewing, 'assume_any_member')) {
            access_denied('I_ERROR');
        }
    }

    require_css('ecommerce');
    require_code('locations');
    require_code('ecommerce_tax');

    $address_rows = $GLOBALS['SITE_DB']->query_select('ecom_trans_addresses', ['*'], ['a_trans_expecting_id' => $txn_id], '', 1);

    $trans_address = '';
    if (array_key_exists(0, $address_rows)) {
        $address_row = $address_rows[0];

        $address_parts = [
            'name' => $address_row['a_firstname'] . ' ' . $address_row['a_lastname'],
            'street_address' => $address_row['a_street_address'],
            'city' => $address_row['a_city'],
            'county' => $address_row['a_county'],
            'state' => $address_row['a_state'],
            'post_code' => $address_row['a_post_code'],
            'country' => $address_row['a_country'],
        ];
        $trans_address = get_formatted_address($address_parts);
    }

    $items = ($transaction_row['t_invoicing_breakdown'] == '') ? [] : json_decode($transaction_row['t_invoicing_breakdown'], true);
    $invoicing_breakdown = [];
    foreach ($items as $item) {
        $invoicing_breakdown[] = [
            'IDENTIFIER' => $item['type_code'],
            'TYPE_CODE' => $item['type_code'],
            'ITEM_NAME' => $item['item_name'],
            'QUANTITY' => (($item['quantity'] !== null) ? integer_format($item['quantity']) : ''),
            'UNIT_PRICE' => float_format($item['unit_price']),
            'PRICE' => float_format($item['unit_price'] * $item['quantity']),
            'TAX' => float_format($item['tax']),
            'TAX_RATE' => float_format(backcalculate_tax_rate(($item['unit_price'] * $item['quantity']), $item['tax']), 1, true),
        ];
    }

    if (empty($invoicing_breakdown)) {
        // We don't have a break-down so at least find a single line-item

        list($details) = find_product_details($transaction_row['t_type_code']);
        if ($details !== null) {
            $item_name = $details['item_name'];
        } else {
            $item_name = $transaction_row['t_type_code'];
        }

        $invoicing_breakdown[] = [
            'IDENTIFIER' => $transaction_row['t_type_code'],
            'TYPE_CODE' => $transaction_row['t_type_code'],
            'ITEM_NAME' => $item_name,
            'QUANTITY' => integer_format(1),
            'UNIT_PRICE' => float_format($transaction_row['t_price']),
            'PRICE' => float_format($transaction_row['t_price']),
            'TAX' => float_format($transaction_row['t_tax']),
            'TAX_RATE' => float_format(backcalculate_tax_rate($transaction_row['t_price'], $transaction_row['t_tax']), 1, true),
        ];
    }

    $status = get_transaction_status_string($transaction_row['t_status']);

    return do_template('ECOM_INVOICE_OR_RECEIPT', [
        '_GUID' => 'b121a0c6d92193e2816599958728631a',
        'INVOICE_ID' => '',
        'TXN_ID' => $txn_id,
        '_DATE' => strval($transaction_row['t_time']),
        'DATE' => get_timezoned_date_time($transaction_row['t_time'], false),
        'TRANS_ADDRESS' => $trans_address,
        'ITEMS' => $invoicing_breakdown,
        'CURRENCY' => $transaction_row['t_currency'],
        'SUBTOTAL' => float_format($transaction_row['t_price'] + $transaction_row['t_shipping']),
        'TOTAL_TAX' => float_format($transaction_row['t_tax']),
        'TOTAL_AMOUNT' => float_format($transaction_row['t_price'] + $transaction_row['t_tax'] + $transaction_row['t_shipping']),
        'PURCHASE_ID' => $transaction_row['t_purchase_id'],
        'STATUS' => $status,
    ]);
}

/**
 * Display an invoice.
 *
 * @param  AUTO_LINK $id Invoice ID
 * @return Tempcode Invoice UI
 */
function display_invoice(int $id) : object
{
    require_code('ecommerce_tax');

    $rows = $GLOBALS['SITE_DB']->query_select('ecom_invoices', ['*'], ['id' => $id], '', 1);
    if (!array_key_exists(0, $rows)) {
        warn_exit(do_lang_tempcode('MISSING_RESOURCE'));
    }
    $row = $rows[0];

    $txn_id = $GLOBALS['SITE_DB']->query_select_value_if_there('ecom_transactions', 'id', ['t_purchase_id' => strval($id), 't_type_code' => $row['i_type_code'], 't_status' => 'Completed']);

    list($details) = find_product_details($row['i_type_code']);
    if ($row['i_item_name'] != '') {
        $item_name = $row['i_item_name'];
    } elseif ($details !== null) {
        $item_name = $details['item_name'];
    } else {
        $item_name = $row['i_type_code'];
    }

    $invoicing_breakdown = [];
    $invoicing_breakdown[] = [
        'IDENTIFIER' => strval($row['id']),
        'TYPE_CODE' => $row['i_type_code'],
        'ITEM_NAME' => $item_name,
        'QUANTITY' => integer_format(1),
        'UNIT_PRICE' => float_format($row['i_price']),
        'PRICE' => float_format($row['i_price']),
        'TAX' => float_format($row['i_tax']),
        'TAX_RATE' => float_format(backcalculate_tax_rate($row['i_price'], $row['i_tax']), 1, true),
    ];

    return do_template('ECOM_INVOICE_OR_RECEIPT', [
        '_GUID' => 'a85dec2751d55de86a5eb806fbfd0bc9',
        'INVOICE_ID' => strval($row['id']),
        'TXN_ID' => (($txn_id !== null) ? $txn_id : ''),
        '_DATE' => strval($row['i_time']),
        'DATE' => get_timezoned_date_time($row['i_time'], false),
        'TRANS_ADDRESS' => '',
        'ITEMS' => $invoicing_breakdown,
        'CURRENCY' => $row['i_currency'],
        'SUBTOTAL' => float_format($row['i_price']),
        'TOTAL_TAX' => float_format($row['i_tax']),
        'TOTAL_AMOUNT' => float_format($row['i_price'] + $row['i_tax']),
        'PURCHASE_ID' => strval($row['id']),
        'STATUS' => $row['i_state'],
    ]);
}

/**
 * Generate a URL to view the details of an order / invoice / subscription.
 *
 * @param  ID_TEXT $type_code The product type code
 * @param  ID_TEXT $id The ID to view (order ID for orders / catalogues, invoice ID for invoices, else transaction ID) (blank: no ID available, therefore do not link)
 * @param  ?MEMBER $member_id The member viewing this (null: unknown)
 * @param  ?ID_TEXT $page_link The page_link from which we are viewing (zone:page:type) (null: look it up)
 * @return ?Tempcode The build_url Tempcode (null: no details link available)
 */
function ecom_details_url(string $type_code, string $id = '', ?int $member_id = null, ?string $page_link = null) : ?object
{
    $ret = null;

    // No link if no ID was specified
    if ($id == '') {
        return $ret;
    }

    list($details) = find_product_details($type_code);
    if ($details === null) {
        return $ret; // Could not find product, so do not provide a link
    }

    if ($page_link === null) {
        $page_link = get_zone_name() . ':' . get_page_name() . ':' . get_param_string('type', '');
    }

    if (preg_match('#^adminzone:#', $page_link) != 0) { // Admin Zone
        switch ($details['type']) {
            case PRODUCT_ORDERS:
            case PRODUCT_CATALOGUE:
                $ret = build_url(['page' => 'admin_shopping', 'type' => 'order_details', 'id' => $id], get_module_zone('admin_shopping'));
                break;
            case PRODUCT_INVOICE:
                $ret = build_url(['page' => 'admin_invoices', 'type' => 'view', 'filter_id' => $id], get_module_zone('admin_invoices'));
                break;
            case PRODUCT_PURCHASE:
            case PRODUCT_SUBSCRIPTION:
                if ($page_link === get_module_zone('admin_ecommerce_reports') . ':admin_ecommerce_reports:sales') { // Do not link to itself if we are already on the sales page
                    $ret = get_product_details_url($type_code, false, null, false);
                } else {
                    $ret = build_url(['page' => 'admin_ecommerce_reports', 'type' => 'sales', 'filter_txn_id' => $id], get_module_zone('admin_ecommerce_reports'));
                }
                break;
            case PRODUCT_OTHER:
                // No link for other products
                break;
        }
    } else { // Not the Admin Zone
        switch ($details['type']) {
            case PRODUCT_ORDERS:
            case PRODUCT_CATALOGUE:
                $ret = build_url(['page' => 'shopping', 'type' => 'order_details', 'id' => $id], get_module_zone('shopping'));
                break;
            case PRODUCT_INVOICE:
                $ret = build_url(['page' => 'invoices', 'type' => 'browse', 'filter_id' => $id], get_module_zone('invoices'));
                break;
            case PRODUCT_PURCHASE:
            case PRODUCT_SUBSCRIPTION:
                if ($page_link === get_module_zone('shopping') . ':shopping:sales') { // Do not link to itself if we are already on the sales page
                    $ret = get_product_details_url($type_code, true, $member_id, false);
                } else {
                    $ret = build_url(['page' => 'purchase', 'type' => 'sales', 'filter_txn_id' => $id], get_module_zone('purchase'));
                }
                break;
            case PRODUCT_OTHER:
                // No link for other products
                break;
        }
    }

    return $ret;
}

/**
 * Get either the live or the testing value of an eCommerce config option.
 * This wraps get_option.
 *
 * @param  ID_TEXT $config_option The name of the configuration option
 * @param  boolean $missing_ok Whether to accept a missing option (and return null)
 * @param  ?boolean $return_testing Return the testing value instead of the live value (null: returns testing value if ecommerce_test_mode(), else returns live value)
 * @return ?string The configuration value (null: configuration not found and $missing_ok set to true)
 */
function get_ecommerce_option(string $config_option, bool $missing_ok = false, ?bool $return_testing = null) : ?string
{
    $config_value = get_option($config_option, $missing_ok);
    if (($config_value === null) || ($config_value == '')) {
        return $config_value;
    }

    if ($return_testing === null) {
        $return_testing = ecommerce_test_mode();
    }

    // Determine our live and testing values
    $matches = [];
    if (preg_match('#^(live|testing)=(.*?)(,(live|testing)=(.*?))?$#', $config_value, $matches) != 0) {
        if (count($matches) >= 6) { // We have two or more parameters specified
            switch ($matches[1]) {
                case 'live':
                    $live_value = $matches[2];
                    break;

                case 'testing':
                    $testing_value = $matches[2];
                    break;
            }
            switch ($matches[4]) {
                case 'live':
                    $live_value = $matches[5];
                    break;

                case 'testing':
                    $testing_value = $matches[5];
                    break;
            }
        } elseif (count($matches) >= 3) { // We have only one parameter; use its value for both live and testing
            $live_value = $matches[2];
            $testing_value = $matches[2];
        }
    } else { // No parameters; use the literal string for both live and testing
        $live_value = $config_value;
        $testing_value = $config_value;
    }

    // Return either the live or the testing value depending on what we want
    return $return_testing ? trim($testing_value) : trim($live_value);
}
