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

/*
URL parameters are follows...

(any screen)
 type = browse|message|terms|details|pay|finish (the screen type) [required]
 type_code (selected product)
 id (default purchase ID; some hooks only)
 include_message = 0|1 (merge the message step's message into the next available non-message screen, allows skipping a screen)
 redirect (a URL to redirect to after the finish screen)

'browse' (choose) screen only
 filter (a prefix filter on product codenames)
 type_filter (an exact string match filter on product codenames)
 must_support_money = 0|1 (whether to only show products that take money payment)
 must_support_points = 0|1 (whether to only show products that take points payment)
 use_categorisation = 0|1 (whether to use categorisation to fold products together, for hooks that define that)
 category (a particular category to filter to)

'finish' screen only
 message (a message passed from a payment gateway)
 from (a payment gateway hook name, e.g. paypal)
 points = 0|1 (whether to do an immediate points transaction)
 cancel = 0|1 (whether this is a user cancellation)

URL parameters all will automatically propagate through. Hooks may use these to prepopulate get_needed_fields forms.
*/

/**
 * Module page class.
 */
class Module_purchase
{
    /**
     * Find details of the module.
     *
     * @return ?array Map of module info (null: module is disabled)
     */
    public function info() : ?array
    {
        $info = [];
        $info['author'] = 'Chris Graham';
        $info['organisation'] = 'Composr';
        $info['hacked_by'] = null;
        $info['hack_version'] = null;
        $info['version'] = 8;
        $info['update_require_upgrade'] = true;
        $info['locked'] = false;
        $info['min_cms_version'] = 11.0;
        $info['addon'] = 'ecommerce';
        return $info;
    }

    /**
     * Uninstall the module.
     */
    public function uninstall()
    {
        $tables = [
            'ecom_transactions',
            'ecom_trans_expecting',
            'ecom_trans_addresses',
            'ecom_sales_expecting',

            'ecom_sales',
            'ecom_prods_prices',
            'ecom_prods_custom',
            'ecom_prods_permissions',
        ];
        $GLOBALS['SITE_DB']->drop_table_if_exists($tables);

        delete_privilege('access_ecommerce_in_test_mode');

        $cpf = [
            'currency',
            'payment_cardholder_name',
            'payment_card_type',
            'payment_card_number',
            'payment_card_start_date',
            'payment_card_expiry_date',
        ];
        foreach ($cpf as $_cpf) {
            $GLOBALS['FORUM_DRIVER']->install_delete_custom_field($_cpf);
        }

        require_code('files');
        if (!$GLOBALS['DEV_MODE']) {
            deldir_contents(get_custom_file_base() . '/uploads/ecommerce', true);
        }
    }

    /**
     * Install the module.
     *
     * @param  ?integer $upgrade_from What version we're upgrading from (null: new install)
     * @param  ?integer $upgrade_from_hack What hack version we're upgrading from (null: new-install/not-upgrading-from-a-hacked-version)
     */
    public function install(?int $upgrade_from = null, ?int $upgrade_from_hack = null)
    {
        require_lang('ecommerce');

        if ($upgrade_from === null) {
            add_privilege('ECOMMERCE', 'access_ecommerce_in_test_mode', false);

            $GLOBALS['SITE_DB']->create_table('ecom_trans_expecting', [ // Used to lock in and track transactions as they go through the payment gateway
                'id' => '*ID_TEXT', // NB: This is often different from the 'ecom_transactions.id' field
                'e_type_code' => 'ID_TEXT',
                'e_purchase_id' => 'ID_TEXT',
                'e_item_name' => 'SHORT_TEXT',
                'e_member_id' => 'MEMBER',
                'e_session_id' => 'ID_TEXT',
                'e_ip_address' => 'IP',
                'e_price' => 'REAL',
                'e_tax_derivation' => 'LONG_TEXT',
                'e_tax' => 'REAL',
                'e_tax_tracking' => 'LONG_TEXT',
                'e_shipping' => 'REAL',
                'e_currency' => 'ID_TEXT',
                'e_price_points' => 'INTEGER', // This is supplementary, not an alternative; if it is only points then no ecom_trans_expecting record will be created
                'e_time' => 'TIME',
                'e_length' => '?INTEGER',
                'e_length_units' => 'ID_TEXT',
                'e_memo' => 'LONG_TEXT',
                'e_invoicing_breakdown' => 'LONG_TEXT',
            ]);

            require_code('currency');

            $GLOBALS['FORUM_DRIVER']->install_create_custom_field('currency', 3, /*locked=*/0, /*viewable=*/0, /*settable=*/1, /*required=*/0, '', 'list', 0, '', 'default=CURRENCY', 0, 0, '', '', '', /*autofill_type=*/'transaction-currency');
            $GLOBALS['FORUM_DRIVER']->install_create_custom_field('payment_cardholder_name', 100, /*locked=*/0, /*viewable=*/0, /*settable=*/1, /*required=*/0, '', 'short_text', 1, '', '', 0, 0, '', '', '', /*autofill_type=*/'cc-name');
            $GLOBALS['FORUM_DRIVER']->install_create_custom_field('payment_card_type', 26, /*locked=*/0, /*viewable=*/0, /*settable=*/1, /*required=*/0, '', 'list', 1, 'American Express|Delta|Diners Card|JCB|Master Card|Solo|Switch|Visa', '', 0, 0, '', '', '', /*autofill_type=*/'cc-type');
            $GLOBALS['FORUM_DRIVER']->install_create_custom_field('payment_card_number', 19, /*locked=*/0, /*viewable=*/0, /*settable=*/1, /*required=*/0, '', 'short_text', 1, '', 'pattern=^\d{13,19}$,pattern_error=' . do_lang('INVALID_CC_NUMBER'), 0, 0, '', '', '', /*autofill_type=*/'cc-number');
            $GLOBALS['FORUM_DRIVER']->install_create_custom_field('payment_card_start_date', 5, /*locked=*/0, /*viewable=*/0, /*settable=*/1, /*required=*/0, '', 'year_month', 1, 'mm/yy');
            $GLOBALS['FORUM_DRIVER']->install_create_custom_field('payment_card_expiry_date', 5, /*locked=*/0, /*viewable=*/0, /*settable=*/1, /*required=*/0, '', 'year_month', 1, 'mm/yy', '', 0, 0, '', '', '', /*autofill_type=*/'cc-exp');

            $GLOBALS['SITE_DB']->create_table('ecom_transactions', [
                'id' => '*ID_TEXT', // Often referenced as txn_id in code
                't_type_code' => 'ID_TEXT',
                't_purchase_id' => 'ID_TEXT',
                't_status' => 'SHORT_TEXT', // Pending|Completed|SModified|SCancelled
                't_reason' => 'SHORT_TEXT',
                't_price' => 'REAL',
                't_tax_derivation' => 'LONG_TEXT',
                't_tax' => 'REAL',
                't_tax_tracking' => 'LONG_TEXT',
                't_shipping' => 'REAL',
                't_transaction_fee' => 'REAL',
                't_currency' => 'ID_TEXT',
                't_parent_txn_id' => 'ID_TEXT',
                't_time' => '*TIME',
                't_pending_reason' => 'SHORT_TEXT',
                't_memo' => 'LONG_TEXT',
                't_payment_gateway' => 'ID_TEXT',
                't_invoicing_breakdown' => 'LONG_TEXT',
                't_member_id' => 'MEMBER', // Of the paying member
                't_session_id' => 'ID_TEXT', // Of the paying user
            ]);

            $GLOBALS['SITE_DB']->create_table('ecom_prods_prices', [
                'name' => '*ID_TEXT',
                'price' => '?REAL',
                'tax_code' => 'ID_TEXT',
                'price_points' => '?INTEGER',
            ]);

            $GLOBALS['SITE_DB']->create_table('ecom_sales', [
                'id' => '*AUTO',
                'date_and_time' => 'TIME',
                'member_id' => 'MEMBER',
                'details' => 'SHORT_TEXT',
                'details2' => 'SHORT_TEXT',
                'txn_id' => 'ID_TEXT',
            ]);

            // Custom
            $GLOBALS['SITE_DB']->create_table('ecom_prods_custom', [
                'id' => '*AUTO',
                'c_title' => 'SHORT_TRANS',
                'c_description' => 'LONG_TRANS__COMCODE',
                'c_image_url' => 'URLPATH',
                'c_mail_subject' => 'SHORT_TRANS',
                'c_mail_body' => 'LONG_TRANS',
                'c_enabled' => 'BINARY',
                'c_price' => '?REAL',
                'c_tax_code' => 'ID_TEXT',
                'c_shipping_cost' => 'REAL',
                'c_price_points' => '?INTEGER',
                'c_one_per_member' => 'BINARY',
            ]);
            // Permissions
            $GLOBALS['SITE_DB']->create_table('ecom_prods_permissions', [
                'id' => '*AUTO',
                'p_title' => 'SHORT_TRANS',
                'p_description' => 'LONG_TRANS__COMCODE',
                'p_mail_subject' => 'SHORT_TRANS',
                'p_mail_body' => 'LONG_TRANS',
                'p_enabled' => 'BINARY',
                'p_price' => '?REAL',
                'p_tax_code' => 'ID_TEXT',
                'p_price_points' => '?INTEGER',
                'p_hours' => '?INTEGER',
                'p_type' => 'ID_TEXT', // member_privileges,member_category_access,member_page_access,member_zone_access
                'p_privilege' => 'ID_TEXT', // privilege only
                'p_zone' => 'ID_TEXT', // zone and page only
                'p_page' => 'ID_TEXT', // page and ?privilege only
                'p_module' => 'ID_TEXT', // category and ?privilege only
                'p_category' => 'ID_TEXT', // category and ?privilege only
            ]);
        }

        if (($upgrade_from !== null) && ($upgrade_from < 6)) { // LEGACY
            $GLOBALS['SITE_DB']->add_table_field('trans_expecting', 'e_currency', 'ID_TEXT', get_option('currency'));

            $GLOBALS['SITE_DB']->alter_table_field('transactions', 'purchase_id', 'ID_TEXT', 't_purchase_id');
            $GLOBALS['SITE_DB']->alter_table_field('transactions', 'status', 'SHORT_TEXT', 't_status');
            $GLOBALS['SITE_DB']->alter_table_field('transactions', 'reason', 'SHORT_TEXT', 't_reason');
            $GLOBALS['SITE_DB']->alter_table_field('transactions', 'amount', 'REAL', 't_amount');
            $GLOBALS['SITE_DB']->alter_table_field('transactions', 'linked', 'ID_TEXT', 't_parent_txn_id');
            $GLOBALS['SITE_DB']->alter_table_field('transactions', 'item', 'ID_TEXT', 't_type_code');
            $GLOBALS['SITE_DB']->alter_table_field('transactions', 'pending_reason', 'SHORT_TEXT', 't_pending_reason');

            $GLOBALS['SITE_DB']->add_table_field('pstore_permissions', 'p_mail_subject', 'SHORT_TRANS');
            $GLOBALS['SITE_DB']->add_table_field('pstore_permissions', 'p_mail_body', 'LONG_TRANS');

            $GLOBALS['SITE_DB']->add_table_field('pstore_customs', 'c_mail_subject', 'SHORT_TRANS');
            $GLOBALS['SITE_DB']->add_table_field('pstore_customs', 'c_mail_body', 'LONG_TRANS');

            rename_config_option('text', 'community_billboard');
            rename_config_option('is_on_flagrant_buy', 'is_on_community_billboard_buy');

            $GLOBALS['SITE_DB']->alter_table_field('pstore_permissions', 'p_hours', '?INTEGER');
        }

        if (($upgrade_from === null) || ($upgrade_from < 7)) {
            // This is used to store purchase details where there's too much data not stored anywhere else to use directly as a purchase ID
            $GLOBALS['SITE_DB']->create_table('ecom_sales_expecting', [
                'id' => '*AUTO', // Used as a unique purchase ID
                'e_details' => 'LONG_TEXT', // JSON encoded data
                'e_time' => 'TIME',
            ]);

            $GLOBALS['SITE_DB']->create_table('ecom_trans_addresses', [
                // These are filled after an order is made (maybe via what comes back from IPN, maybe from what is set for a local payment), and presented in the admin orders UI
                'id' => '*AUTO',
                'a_trans_expecting_id' => 'ID_TEXT',
                'a_txn_id' => 'ID_TEXT',
                'a_firstname' => 'SHORT_TEXT', // NB: May be full-name, or include company name
                'a_lastname' => 'SHORT_TEXT',
                'a_street_address' => 'LONG_TEXT',
                'a_city' => 'SHORT_TEXT',
                'a_county' => 'SHORT_TEXT',
                'a_state' => 'SHORT_TEXT',
                'a_post_code' => 'SHORT_TEXT',
                'a_country' => 'SHORT_TEXT',
                'a_email' => 'SHORT_TEXT',
                'a_phone' => 'SHORT_TEXT',
            ]);
            $GLOBALS['SITE_DB']->create_index('ecom_trans_addresses', 'trans_expecting_id', ['a_trans_expecting_id']);
            $GLOBALS['SITE_DB']->create_index('ecom_trans_addresses', 'txn_id', ['a_txn_id']);
        }

        if (($upgrade_from !== null) && ($upgrade_from < 7)) { // LEGACY
            if (get_forum_type() == 'cns') {
                require_code('cns_members');
                $cf_id = find_cms_cpf_field_id('cms_payment_card_issue_number');
                if ($cf_id !== null) {
                    $GLOBALS['FORUM_DB']->query_update('f_custom_fields', ['cf_type' => 'integer'], ['id' => $cf_id]);
                    $GLOBALS['FORUM_DB']->delete_index_if_exists('f_member_custom_fields', 'mcf_ft_' . strval($cf_id));
                    $GLOBALS['FORUM_DB']->query_update('f_member_custom_fields', ['field_' . strval($cf_id) => '0'], ['field_' . strval($cf_id) => '']);
                    $GLOBALS['FORUM_DB']->alter_table_field('f_member_custom_fields', 'field_' . strval($cf_id), '?INTEGER');
                    $GLOBALS['FORUM_DB']->query_update('f_member_custom_fields', ['field_' . strval($cf_id) => null], ['field_' . strval($cf_id) => 0]);
                }
            }

            rename_config_option('ipn', 'payment_gateway_username');
            rename_config_option('ipn_password', 'payment_gateway_password');
            rename_config_option('ipn_digest', 'payment_gateway_digest');
            rename_config_option('vpn_username', 'payment_gateway_vpn_username');
            rename_config_option('vpn_password', 'payment_gateway_vpn_password');
            rename_config_option('callback_password', 'payment_gateway_callback_password');

            $GLOBALS['SITE_DB']->rename_table('trans_expecting', 'ecom_trans_expecting');
            $GLOBALS['SITE_DB']->alter_table_field('ecom_trans_expecting', 'e_amount', 'REAL', 'e_price');
            $GLOBALS['SITE_DB']->add_table_field('ecom_trans_expecting', 'e_memo', 'LONG_TEXT', '');
            $GLOBALS['SITE_DB']->add_table_field('ecom_trans_expecting', 'e_tax_derivation', 'LONG_TEXT', '');
            $GLOBALS['SITE_DB']->add_table_field('ecom_trans_expecting', 'e_tax', 'REAL', 0.00);
            $GLOBALS['SITE_DB']->add_table_field('ecom_trans_expecting', 'e_tax_tracking', 'LONG_TEXT', '');
            $GLOBALS['SITE_DB']->add_table_field('ecom_trans_expecting', 'e_price_points', 'INTEGER', 0);
            $GLOBALS['SITE_DB']->add_table_field('ecom_trans_expecting', 'e_invoicing_breakdown', 'LONG_TEXT', '');
            $GLOBALS['SITE_DB']->add_table_field('ecom_trans_expecting', 'e_type_code', 'ID_TEXT', '');

            $GLOBALS['SITE_DB']->rename_table('transactions', 'ecom_transactions');
            $GLOBALS['SITE_DB']->alter_table_field('ecom_transactions', 't_via', 'ID_TEXT', 't_payment_gateway');
            $GLOBALS['SITE_DB']->alter_table_field('ecom_transactions', 't_amount', 'REAL');
            $GLOBALS['SITE_DB']->add_table_field('ecom_transactions', 't_tax_derivation', 'LONG_TEXT', '');
            $GLOBALS['SITE_DB']->add_table_field('ecom_transactions', 't_tax', 'REAL', 0.00);
            $GLOBALS['SITE_DB']->add_table_field('ecom_transactions', 't_tax_tracking', 'LONG_TEXT', '');
            $GLOBALS['SITE_DB']->add_table_field('ecom_transactions', 't_invoicing_breakdown', 'LONG_TEXT', '');
            $GLOBALS['SITE_DB']->add_table_field('ecom_transactions', 't_member_id', 'MEMBER', $GLOBALS['FORUM_DRIVER']->get_guest_id());
            $GLOBALS['SITE_DB']->add_table_field('ecom_transactions', 't_session_id', 'ID_TEXT', '');

            if ($GLOBALS['SITE_DB']->table_exists('prices')) {
                $GLOBALS['SITE_DB']->rename_table('prices', 'ecom_prods_prices');
                $GLOBALS['SITE_DB']->alter_table_field('ecom_prods_prices', 'price', '?INTEGER', 'price_points');
                $GLOBALS['SITE_DB']->add_table_field('ecom_prods_prices', 'price', '?REAL', null);
                $GLOBALS['SITE_DB']->add_table_field('ecom_prods_prices', 'tax_code', 'ID_TEXT', '0%');

                $GLOBALS['SITE_DB']->rename_table('pstore_customs', 'ecom_prods_custom');
                $GLOBALS['SITE_DB']->alter_table_field('ecom_prods_custom', 'c_cost', '?INTEGER', 'c_price_points');
                $GLOBALS['SITE_DB']->add_table_field('ecom_prods_custom', 'c_image_url', 'URLPATH');
                $GLOBALS['SITE_DB']->add_table_field('ecom_prods_custom', 'c_price', '?REAL', null);
                $GLOBALS['SITE_DB']->add_table_field('ecom_prods_custom', 'c_tax_code', 'ID_TEXT', '0%');
                $GLOBALS['SITE_DB']->add_table_field('ecom_prods_custom', 'c_shipping_cost', 'REAL', 0.00);

                $GLOBALS['SITE_DB']->rename_table('pstore_permissions', 'ecom_prods_permissions');
                $GLOBALS['SITE_DB']->alter_table_field('ecom_prods_permissions', 'p_cost', '?INTEGER', 'p_price_points');
                $GLOBALS['SITE_DB']->add_table_field('ecom_prods_permissions', 'p_price', '?REAL', null);
                $GLOBALS['SITE_DB']->add_table_field('ecom_prods_permissions', 'p_tax_code', 'ID_TEXT', '0%');

                $GLOBALS['SITE_DB']->rename_table('sales', 'ecom_sales');
                $GLOBALS['SITE_DB']->add_table_field('ecom_sales', 'txn_id', 'ID_TEXT', '');
                $GLOBALS['SITE_DB']->alter_table_field('ecom_sales', 'memberid', 'MEMBER', 'member_id');
                $sales = $GLOBALS['SITE_DB']->query_select('ecom_sales', ['*']);
                foreach ($sales as $sale) {
                    $type_code = '';
                    switch ($sale['purchasetype']) {
                        case 'banner':
                            $type_code = 'banners';
                            break;
                        case 'pop3':
                            $type_code = 'pop3';
                            break;
                        case 'forw':
                            $type_code = 'forw';
                            break;
                        case 'PURCHASE_CUSTOM_PRODUCT':
                            $type_code = 'custom';
                            break;
                        case 'PURCHASE_PERMISSION_PRODUCT':
                            $type_code = 'permission';
                            break;
                        case 'NAME_HIGHLIGHTING':
                            $type_code = 'highlight_name';
                            break;
                        case 'GAMBLING':
                            $type_code = 'gambling';
                            break;
                        case 'TOPIC_PINNING':
                            $type_code = 'topic_pin';
                            break;
                    }
                    $txn_id = 'manual-' . substr(uniqid('', true), 0, 10);
                    $GLOBALS['SITE_DB']->query_insert('ecom_transactions', [
                        'id' => $txn_id,
                        't_type_code' => $type_code,
                        't_purchase_id' => strval($sale['id']),
                        't_status' => 'Completed',
                        't_reason' => '',
                        't_amount' => 0.00,
                        't_tax_derivation' => '',
                        't_tax' => 0.00,
                        't_tax_tracking' => '',
                        't_shipping' => 0.00,
                        't_transaction_fee' => 0.00,
                        't_currency' => 'points',
                        't_parent_txn_id' => '',
                        't_time' => $sale['date_and_time'],
                        't_pending_reason' => '',
                        't_memo' => '',
                        't_payment_gateway' => '',
                        't_invoicing_breakdown' => '',
                        't_member_id' => get_member(),
                        't_session_id' => get_session_id(),
                    ], true);
                    $GLOBALS['SITE_DB']->query_update('ecom_sales', ['txn_id' => $txn_id], ['id' => $sale['id']], '', 1);
                }
                $GLOBALS['SITE_DB']->delete_table_field('ecom_sales', 'purchasetype');
            }
        }

        if (($upgrade_from !== null) && ($upgrade_from < 8)) { // LEGACY
            if ($GLOBALS['SITE_DB']->table_exists('ecom_prods_prices')) {
                $GLOBALS['SITE_DB']->query_update('ecom_prods_permissions', ['p_hours' => null], ['p_hours' => 40000]);
            }

            $GLOBALS['SITE_DB']->add_table_field('ecom_trans_expecting', 'e_shipping', 'REAL', 0.00);
            $GLOBALS['SITE_DB']->add_table_field('ecom_transactions', 't_shipping', 'REAL', 0.00);
            $GLOBALS['SITE_DB']->add_table_field('ecom_transactions', 't_transaction_fee', 'REAL', 0.00);

            $GLOBALS['SITE_DB']->alter_table_field('ecom_transactions', 't_amount', 'REAL', 't_price');

            $GLOBALS['FORUM_DRIVER']->install_edit_custom_field('payment_card_number', 'payment_card_number', 19, /*locked=*/0, /*viewable=*/0, /*settable=*/1, /*required=*/0, '', 'short_text', 1, '', 'pattern=^\d{13,19}$,pattern_error=' . do_lang('INVALID_CC_NUMBER'), 0, 0, '', '', '', /*autofill_type=*/'cc-number');
            $GLOBALS['FORUM_DRIVER']->install_delete_custom_field('payment_card_issue_number'); // Obsolete as of 2010

            // Calculate transaction fees for every transaction with 0 as the fee (uses either set config or the gateway's fallback)
            if ($GLOBALS['SITE_DB']->table_exists('ecom_transactions')) {
                require_code('ecommerce');
                $start = 0;
                do {
                    $rows = $GLOBALS['SITE_DB']->query_select('ecom_transactions', ['*'], ['t_transaction_fee' => 0.00], '', 300, $start);

                    foreach ($rows as $row) {
                        $new_transaction_fee = get_transaction_fee(($row['t_price'] + $row['t_tax'] + $row['t_shipping']), $row['t_type_code'], $row['t_payment_gateway'], true);
                        $GLOBALS['SITE_DB']->query_update('ecom_transactions', ['t_transaction_fee' => $new_transaction_fee]);
                    }

                    $start += 300;
                } while (count($rows) > 0);
            }

            // We got rid of primary_paypal_email, so we must move its value if using the PayPal gateway
            $gateway = get_option('payment_gateway');
            $paypal_email = get_option('primary_paypal_email', true);
            if (($gateway == 'paypal') && ($paypal_email !== null) && ($paypal_email != '')) {
                $username = get_option('payment_gateway_username', true);
                $vpn_username = get_option('payment_gateway_vpn_username', true);
                if (($username === null) || ($username == '')) { // Merge into username if no value is set
                    set_option('payment_gateway_username', $paypal_email);
                } elseif (($vpn_username === null) || ($vpn_username == '')) { // Merge into vpn_username if no value is set
                    set_option('payment_gateway_vpn_username', $paypal_email);
                }
            }
            delete_config_option('primary_paypal_email');

            // We are no longer using semi-colon to separate live from testing values; change to our new format if applicable.
            $username_had_format = false;
            $config_options = [
                'payment_gateway_username',
                'payment_gateway_password',
                'payment_gateway_digest',
                'payment_gateway_vpn_username',
                'payment_gateway_vpn_password',
                'payment_gateway_special_identifier',
            ];
            foreach ($config_options as $config_option) {
                $config_value = get_option($config_option, true);
                if (($config_value !== null) && ($config_value != '')) {
                    $_config_bits = explode(';', $config_value);
                    if (count($_config_bits) > 1) {
                        if ($config_option == 'payment_gateway_username') {
                            $username_had_format = true;
                        }
                        set_option($config_value, 'live=' . $_config_bits[0] . ',testing=' . $_config_bits[1]);
                    }
                }
            }

            // We got rid of payment_gateway_test_username; merge its value as the testing value of payment_gateway_username
            if (!$username_had_format) {
                $config_value = get_option('payment_gateway_username', true);
                $test_username = get_option('payment_gateway_test_username', true);
                if (($test_username !== null) && ($test_username != '')) {
                    $_config_bits = explode(';', $test_username);
                    if (count($_config_bits) > 1) {
                        set_option('payment_gateway_username', 'live=' . $_config_bits[0] . ',testing=' . $_config_bits[1]);
                    } elseif (($config_value !== null) && ($config_value != '')) {
                        set_option('payment_gateway_username', 'live=' . $config_value . ',testing=' . $test_username);
                    }
                }
            }
            delete_config_option('payment_gateway_test_username');
        }

        if (($upgrade_from === null) || ($upgrade_from < 7)) {
            $GLOBALS['SITE_DB']->create_index('ecom_transactions', 't_time', ['t_time']);
            $GLOBALS['SITE_DB']->create_index('ecom_transactions', 't_type_code', ['t_type_code']);
            $GLOBALS['SITE_DB']->create_index('ecom_sales', 'member_id', ['member_id']);
        }
    }

    /**
     * Find entry-points available within this module.
     *
     * @param  boolean $check_perms Whether to check permissions
     * @param  ?MEMBER $member_id The member to check permissions as (null: current user)
     * @param  boolean $support_crosslinks Whether to allow cross links to other modules (identifiable via a full-page-link rather than a screen-name)
     * @param  boolean $be_deferential Whether to avoid any entry-point (or even return null to disable the page in the Sitemap) if we know another module, or page_group, is going to link to that entry-point. Note that "!" and "browse" entry points are automatically merged with container page nodes (likely called by page-groupings) as appropriate.
     * @return ?array A map of entry points (screen-name=>language-code/string or screen-name=>[language-code/string, icon-theme-image]) (null: disabled)
     */
    public function get_entry_points(bool $check_perms = true, ?int $member_id = null, bool $support_crosslinks = true, bool $be_deferential = false) : ?array
    {
        if (!addon_installed('ecommerce')) {
            return null;
        }

        return [
            'browse' => ['PURCHASING', 'menu/rich_content/ecommerce/purchase'],
        ];
    }

    public $title;

    /**
     * Module pre-run function. Allows us to know metadata for <head> before we start streaming output.
     *
     * @return ?Tempcode Tempcode indicating some kind of exceptional output (null: none)
     */
    public function pre_run() : ?object
    {
        $error_msg = new Tempcode();
        if (!addon_installed__messaged('ecommerce', $error_msg)) {
            return $error_msg;
        }

        $type = get_param_string('type', 'browse');

        require_code('ecommerce');

        if ($type == 'browse') {
            if (get_param_string('category', null) === null) {
                breadcrumb_set_self(do_lang_tempcode('ECOM_PURCHASE_STAGE_browse'));

                $this->title = get_screen_title('PURCHASING_TITLE', true, [do_lang_tempcode('ECOM_PURCHASE_STAGE_' . $type), '1', '6']);
            } else {
                $hook = get_param_string('category', null);
                require_code('hooks/systems/ecommerce/' . filter_naughty_harsh($hook));
                $product_object = object_factory('Hook_ecommerce_' . filter_naughty_harsh($hook), true);
                $product_category = $product_object->get_product_category();
                breadcrumb_set_self($product_category['category_name']);
                breadcrumb_set_parents([['_SELF:_SELF:browse', do_lang_tempcode('ECOM_PURCHASE_STAGE_browse')]]);

                $this->title = get_screen_title('PURCHASING_TITLE', true, [do_lang_tempcode('ECOM_PURCHASE_STAGE_category'), '2', '6']);
            }
        }
        if ($type == 'receipt') {
            breadcrumb_set_self(do_lang_tempcode('RECEIPT'));
        }
        if ($type == 'sales') {
            $member_id = get_param_integer('id', get_member());
            $member_username = $GLOBALS['FORUM_DRIVER']->get_username($member_id);

            if (get_forum_type() == 'cns') {
                $breadcrumbs = [['_SEARCH:members', do_lang_tempcode('MEMBERS')], ['_SEARCH:members:view:' . strval($member_id), escape_html($member_username)]];
                breadcrumb_set_parents($breadcrumbs);
            }

            breadcrumb_set_self(do_lang_tempcode('PURCHASES'));
            $this->title = get_screen_title('PURCHASES');
        }
        if ($type == 'message') {
            breadcrumb_set_self(do_lang_tempcode('ECOM_PURCHASE_STAGE_message'));
        }
        if ($type == 'terms') {
            breadcrumb_set_self(do_lang_tempcode('ECOM_PURCHASE_STAGE_terms'));
        }
        if ($type == 'details') {
            breadcrumb_set_self(do_lang_tempcode('ECOM_PURCHASE_STAGE_details'));
        }
        if ($type == 'pay') {
            breadcrumb_set_self(do_lang_tempcode('ECOM_PURCHASE_STAGE_pay'));
        }
        if ($type == 'finish') {
            if (get_param_integer('cancel', 0) == 1) {
                breadcrumb_set_self(do_lang_tempcode('ECOM_PURCHASE_STAGE_cancelled'));
            } else {
                breadcrumb_set_self(do_lang_tempcode('ECOM_PURCHASE_STAGE_finish'));
            }
        }

        if (($type != 'browse') && ($type != 'receipt') && ($type != 'sales')) {
            $test = do_lang('ECOM_PURCHASE_STAGE_' . $type, null, null, null, null, false);
            if ($test !== null) {
                $type_code = get_param_string('type_code', null);
                if ($type_code !== null) {
                    $breadcrumbs = [];
                    list(, $product_object) = find_product_details($type_code);
                    if ($product_object === null) {
                        warn_exit(do_lang_tempcode('MISSING_RESOURCE'));
                    }
                    $steps = get_product_purchase_steps($product_object, $type_code, true);
                    $step_at = 0;
                    foreach ($steps as $i => $step) {
                        if (($step[1] == $type) && (($step[1] != 'browse') || ($steps[$i + 1][1] != 'browse') || (get_param_string('category', null) === null))) {
                            $step_at = $i;
                            break;
                        }
                        $breadcrumbs[] = [$step[0], $step[2]];
                    }
                    breadcrumb_set_parents($breadcrumbs);

                    $this->title = get_screen_title('PURCHASING_TITLE', true, [do_lang_tempcode('ECOM_PURCHASE_STAGE_' . $type), escape_html(integer_format($step_at + 1)), escape_html(integer_format(count($steps)))]);
                } else {
                    $this->title = get_screen_title('PURCHASING_TITLE', true, [do_lang_tempcode('ECOM_PURCHASE_STAGE_' . $type), '?', '6']);
                }
            }
        }

        return null;
    }

    /**
     * Execute the module.
     *
     * @return Tempcode The result of execution
     */
    public function run() : object
    {
        @ignore_user_abort(true); // Must keep going till completion

        // Static cache issue? (We can disable static caching for particular pages to avoid this)
        if (get_session_id() == '') {
            access_denied('NOT_AS_GUEST');
        }

        require_lang('config');
        require_css('ecommerce');
        require_code('currency');

        global $ECOMMERCE_SPECIAL_SUCCESS_MESSAGE;
        $ECOMMERCE_SPECIAL_SUCCESS_MESSAGE = null;

        // Kill switch
        if ((ecommerce_test_mode()) && (!$GLOBALS['IS_ACTUALLY_ADMIN']) && (!has_privilege(get_member(), 'access_ecommerce_in_test_mode'))) {
            warn_exit(do_lang_tempcode('PURCHASE_DISABLED'));
        }

        $type = get_param_string('type', 'browse');

        // Recognise join operations
        $new_username = post_param_string('username', null, INPUT_FILTER_POST_IDENTIFIER);
        $new_password = post_param_string('password', null, INPUT_FILTER_PASSWORD);
        if (($new_username !== null) && ($new_password !== null)) {
            require_code('cns_join');
            // TODO: GDPR Need a method to require declaration acceptance before an account is created
            list($messages) = cns_join_actual('', true, false, false, true, null, null, null, null);
            if (is_guest()) {
                if (!$messages->is_empty()) {
                    return inform_screen($this->title, $messages);
                }
            }
        }

        // Normal processing
        if ($type == 'browse') {
            return $this->choose();
        }
        if ($type == 'sales') {
            return $this->sales();
        }
        if ($type == 'message') {
            return $this->message();
        }
        if ($type == 'terms') {
            return $this->terms();
        }
        if ($type == 'details') {
            return $this->details();
        }
        if ($type == 'pay') {
            return $this->pay();
        }
        if ($type == 'finish') {
            return $this->finish();
        }
        return new Tempcode();
    }

    /**
     * Wrap-up so as to remove redundancy in templates.
     *
     * @param  Tempcode $content To wrap
     * @param  Tempcode $title The title to use
     * @param  ?mixed $url URL (null: no next URL)
     * @param  array $js_function_calls Array of JavaScript function calls to use
     * @param  boolean $get Whether it is a GET form
     * @param  ?Tempcode $submit_name Submit button label to use (null: default)
     * @param  string $icon CSS icon label to use
     * @return Tempcode Wrapped
     */
    protected function _wrap(object $content, object $title, $url = null, array $js_function_calls = [], bool $get = false, ?object $submit_name = null, string $icon = 'buttons/proceed') : object
    {
        if ($url === null) {
            $url = '';
        }

        require_javascript('checking');

        if ($submit_name === null) {
            $submit_name = do_lang_tempcode('PROCEED');
        }

        return do_template('ECOM_PURCHASE_SCREEN', [
            '_GUID' => 'a32c99acc28e8ad05fd9b5e2f2cda029',
            'TITLE' => $title,
            'CONTENT' => $content,
            'GET' => $get ? true : null,
            'URL' => $url,
            'JS_FUNCTION_CALLS' => $js_function_calls,
            'SUBMIT_NAME' => $submit_name,
            'ICON' => $icon,
        ]);
    }

    /**
     * Choose-product step.
     *
     * @return Tempcode The result of execution
     */
    public function choose() : object
    {
        $filter = get_param_string('filter', '');
        $type_filter = get_param_integer('type_filter', null);
        $use_categorisation = (get_param_integer('use_categorisation', 1) == 1);
        $category = get_param_string('category', null);
        $must_support_money = (get_param_integer('must_support_money', 0) == 1);
        $must_support_points = (get_param_integer('must_support_points', 0) == 1);

        $money_involved = false;
        $points_involved = false;

        $_products = find_all_products();

        $products = [];

        $categories_done = [];

        foreach ($_products as $type_code => $details) {
            if (!is_string($type_code)) {
                $type_code = strval($type_code);
            }
            $product_object = $details['product_object'];

            // Category folding?
            if (($category === null) && ($use_categorisation)) {
                $this_category = preg_replace('#^Hook_ecommerce_#', '', get_class($product_object));

                if (isset($categories_done[$this_category])) {
                    continue;
                }

                if (method_exists($product_object, 'get_product_category')) {
                    $product_category = $product_object->get_product_category();
                    $categories_done[$this_category] = true;

                    if ($product_category === null) {
                        continue;
                    }

                    $image_url = $product_category['category_image_url'];
                    if ($image_url != '') {
                        if (url_is_local($image_url)) {
                            $image_url = get_custom_base_url() . '/' . $image_url;
                        }
                    }

                    $url = build_url(['page' => '_SELF', 'type' => 'browse', 'category' => $this_category], '_SELF', [], true);
                    $can_purchase = false;
                    $supports_money = false;
                    $supports_points = false;
                    $num_products_in_category = 0;
                    $num_products_in_category_available = 0;
                    foreach ($_products as $_type_code => $_details) {
                        if (!is_string($_type_code)) {
                            $_type_code = strval($_type_code);
                        }

                        if (preg_replace('#^Hook_ecommerce_#', '', get_class($_details['product_object'])) == $this_category) {
                            if ($_details['price'] !== null) {
                                $supports_money = true;
                                $money_involved = true;
                            }
                            if ($_details['price_points'] !== null) {
                                $supports_points = true;
                                $points_involved = true;
                            }

                            if ($this->_is_filtered_out($product_object, $_type_code, $_details, $filter, $type_filter, $must_support_money, $must_support_points)) {
                                continue;
                            }

                            list($_discounted_price, $_discounted_tax_code, $_points_for_discount) = get_discounted_price($_details);
                            $_can_purchase = ((($_discounted_price !== null) && (!$must_support_money)) || (($_details['price'] !== null) && (!$must_support_points)));
                            if ($_can_purchase) {
                                $can_purchase = true;

                                $num_products_in_category_available++;
                            }

                            $num_products_in_category++;
                        }
                    }

                    if (($must_support_money) && (!$supports_money)) {
                        continue;
                    }

                    if (($must_support_points) && (!$supports_points)) {
                        continue;
                    }

                    $products[] = [
                        'ITEM_NAME' => $product_category['category_name'],
                        'DESCRIPTION' => $product_category['category_description'],
                        'URL' => $url,
                        'IMAGE_URL' => $image_url,
                        'CAN_PURCHASE' => $can_purchase,
                        'IS_CATEGORY' => true,
                        'NUM_PRODUCTS_IN_CATEGORY' => strval($num_products_in_category),
                        'NUM_PRODUCTS_IN_CATEGORY_AVAILABLE' => strval($num_products_in_category_available),

                        'WRITTEN_PRICE' => null,

                        'FULL_PRICE' => null,
                        'DISCOUNTED_PRICE' => null,

                        '_FULL_PRICE' => null,
                        '_DISCOUNTED_PRICE' => null,
                        '_CURRENCY' => null,
                        '_PRICE_POINTS' => null,
                        '_DISCOUNT_POINTS__NUM_POINTS' => null,
                        '_DISCOUNT_POINTS__PRICE_REDUCTION' => null,

                        'TYPE_SPECIAL_DETAILS_LENGTH' => null,
                        'TYPE_SPECIAL_DETAILS_LENGTH_UNITS' => null,
                    ];

                    continue;
                }
            }
            if ($category !== null) {
                $this_category = preg_replace('#^Hook_ecommerce_#', '', get_class($product_object));
                if ($this_category != $category) {
                    continue;
                }
            }

            if ($this->_is_filtered_out($product_object, $type_code, $details, $filter, $type_filter, $must_support_money, $must_support_points)) {
                continue;
            }

            $currency = isset($details['currency']) ? $details['currency'] : get_option('currency');

            if ($details['price'] === null) {
                $full_price = null;
                $_full_price = do_lang('NA');
            } else {
                $full_price = $details['price'];
                $_full_price = currency_convert_wrap($full_price, $currency);
            }

            list($discounted_price, $discounted_tax_code, $points_for_discount) = get_discounted_price($details);
            $can_purchase = (($discounted_price !== null) || ($details['price'] !== null));

            if ($details['price'] !== null) {
                $money_involved = true;
            }
            if ($details['price_points'] !== null) {
                $points_involved = true;
            }

            if (!$can_purchase) {
                $_discounted_price = do_lang('NA');
                $written_price = do_lang_tempcode('NA_EM');
            } elseif ($full_price === 0.00/*free without any need for discount*/) {
                $_discounted_price = do_lang('NA');
                $written_price = do_lang_tempcode('ECOMMERCE_PRODUCT_PRICING_FOR_FREE');
            } elseif ($discounted_price === 0.00/*discounted via points to zero*/) {
                $_discounted_price = currency_convert_wrap(0.00, $currency);
                $written_price = do_lang_tempcode('ECOMMERCE_PRODUCT_PRICING_FOR_FREE_WITH_POINTS', $_discounted_price, $_full_price, [escape_html(integer_format($points_for_discount))]);
            } elseif ($discounted_price !== null/*discounted via points*/) {
                $_discounted_price = currency_convert_wrap($discounted_price, $currency);
                $written_price = do_lang_tempcode('ECOMMERCE_PRODUCT_PRICING_WITH_DISCOUNT', $_discounted_price, $_full_price, [escape_html(integer_format($points_for_discount))]);
            } else {
                $_discounted_price = do_lang('NA');
                $written_price = do_lang_tempcode('ECOMMERCE_PRODUCT_PRICING_FULL_PRICE', $_full_price);
            }

            if ($can_purchase) {
                $next_purchase_step = get_next_purchase_step($product_object, $type_code, 'browse');
                $url = build_url(['page' => '_SELF', 'type' => $next_purchase_step, 'type_code' => $type_code], '_SELF', [], true);
            } else {
                $url = null;
            }

            $image_url = $details['item_image_url'];
            if ($image_url != '') {
                if (url_is_local($image_url)) {
                    $image_url = get_custom_base_url() . '/' . $image_url;
                }
            }

            $products[] = [
                'ITEM_NAME' => $details['item_name'],
                'DESCRIPTION' => $details['item_description'],
                'URL' => $url,
                'IMAGE_URL' => $image_url,
                'CAN_PURCHASE' => $can_purchase,
                'IS_CATEGORY' => false,
                'NUM_PRODUCTS_IN_CATEGORY' => '',
                'NUM_PRODUCTS_IN_CATEGORY_AVAILABLE' => '',

                'WRITTEN_PRICE' => $written_price,

                'FULL_PRICE' => $_full_price,
                'DISCOUNTED_PRICE' => $_discounted_price,

                '_FULL_PRICE' => ($full_price === null) ? '' : float_to_raw_string($full_price),
                '_DISCOUNTED_PRICE' => ($discounted_price === null) ? '' : float_to_raw_string($discounted_price),
                '_CURRENCY' => $details['currency'],
                '_PRICE_POINTS' => ($details['price_points'] === null) ? null : strval($details['price_points']),
                '_DISCOUNT_POINTS__NUM_POINTS' => ($details['discount_points__num_points'] === null) ? null : strval($details['discount_points__num_points']),
                '_DISCOUNT_POINTS__PRICE_REDUCTION' => ($details['discount_points__price_reduction'] === null) ? null : strval($details['discount_points__price_reduction']),

                'TYPE_SPECIAL_DETAILS_LENGTH' => isset($details['special_details_length']['length']) ? strval($details['special_details_length']['length']) : null,
                'TYPE_SPECIAL_DETAILS_LENGTH_UNITS' => isset($details['special_details_length']['length_units']) ? $details['special_details_length']['length_units'] : null,
            ];
        }

        if ($category === null/*we assume category products are already sorted in the way desired*/) {
            sort_maps_by($products, 'ITEM_NAME', false, true);
        }

        $result = do_template('ECOM_PURCHASE_STAGE_CHOOSE', [
            '_GUID' => '47c22d48313ff50e6323f05a78342eae',
            'TITLE' => $this->title,
            'PRODUCTS' => $products,

            'CATEGORY' => $category,
            'MUST_SUPPORT_MONEY' => $must_support_money,
            'MUST_SUPPORT_POINTS' => $must_support_points,

            'POINTS_INVOLVED' => $points_involved,
            'MONEY_INVOLVED' => $money_involved,
        ]);
        return $this->_wrap($result, $this->title, null, [], true);
    }

    /**
     * See if a product is filtered out.
     *
     * @param  object $product_object The product object
     * @param  ID_TEXT $type_code The product codename
     * @param  array $details The product details
     * @param  string $filter Filter prefix
     * @param  ?integer $type_filter Filter by eCommerce type (null: no filter)
     * @param  boolean $must_support_money Filter out products that don't support money
     * @param  boolean $must_support_points Filter out products that don't support points
     * @return boolean Is filtered
     */
    protected function _is_filtered_out(object $product_object, string $type_code, array $details, string $filter, ?int $type_filter, bool $must_support_money, bool $must_support_points) : bool
    {
        // Explicit filtering...

        if ($filter != '') {
            if ((!is_string($type_code)) || (substr($type_code, 0, strlen($filter)) != $filter)) {
                return true;
            }
        }

        if ($type_filter !== null) {
            if ($details['type'] != $type_filter) {
                return true;
            }
        }

        // Implicit filtering...

        $purchasing_module_supported = in_array($details['type'], [PRODUCT_PURCHASE, PRODUCT_SUBSCRIPTION, PRODUCT_CATALOGUE]);
        if (!$purchasing_module_supported) {
            return true;
        }

        $is_available = false; // Anything without is_available is not meant to be purchased directly
        if (method_exists($product_object, 'is_available')) {
            $availability_status = $product_object->is_available($type_code, get_member(), 1, true);
            $is_available = ($availability_status == ECOMMERCE_PRODUCT_AVAILABLE) || ($availability_status == ECOMMERCE_PRODUCT_NO_GUESTS);
        }
        if (!$is_available) {
            return true;
        }

        if (($must_support_money) && ($details['price'] === null)) {
            return true;
        }

        if (($must_support_points) && ($details['price_points'] === null)) {
            return true;
        }

        return false;
    }

    /**
     * Message about product step.
     * Note that by using a 'include_message' parameter to a 'details' or 'pay' step you include the message on that other step and skip this step.
     *
     * @return Tempcode The result of execution
     */
    public function message() : object
    {
        $type_code = get_param_string('type_code');

        $text = new Tempcode();
        list($details, $product_object) = find_product_details($type_code);
        if ($product_object === null) {
            warn_exit(do_lang_tempcode('MISSING_RESOURCE'));
        }

        $test = $this->_check_availability($type_code);
        if ($test !== null) {
            return $test;
        }

        // Work out what next step is
        $next_purchase_step = get_next_purchase_step($product_object, $type_code, 'message');
        $url = build_url(['page' => '_SELF', 'type' => $next_purchase_step, 'type_code' => $type_code], '_SELF', ['include_message' => true], true);

        if (method_exists($product_object, 'get_message')) {
            $message = $product_object->get_message($type_code);
        } else {
            $message = null;
        }
        if ($message === null) {
            // Ah, not even a message to show - jump ahead
            return redirect_screen($this->title, $url);
        }
        $text->attach($message);

        $result = do_template('ECOM_PURCHASE_STAGE_MESSAGE', [
            '_GUID' => '8667b6b544c4cea645a52bb4d087f816',
            'TITLE' => $this->title,
            'ITEM_NAME' => $details['item_name'],
            'TYPE_CODE' => $type_code,
            'TEXT' => $text,
        ]);
        return $this->_wrap($result, $this->title, $url);
    }

    /**
     * Terms and conditions step.
     *
     * @return Tempcode The result of execution
     */
    public function terms() : object
    {
        $type_code = get_param_string('type_code');

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

        $test = $this->_check_availability($type_code);
        if ($test !== null) {
            return $test;
        }

        // Work out what next step is
        $next_purchase_step = get_next_purchase_step($product_object, $type_code, 'terms');
        $url = build_url(['page' => '_SELF', 'type' => $next_purchase_step, 'accepted' => 1], '_SELF', [], true);

        if (method_exists($product_object, 'get_terms')) {
            $terms = $product_object->get_terms($type_code);
        } else {
            $terms = '';
        }
        if ($terms === '') {
            // Ah, not even any terms to show - jump ahead
            return redirect_screen($this->title, $url);
        }

        $result = do_template('ECOM_PURCHASE_STAGE_TERMS', [
            '_GUID' => '55c7bc550bb327535db1aebdac9d85f2',
            'TITLE' => $this->title,
            'ITEM_NAME' => $details['item_name'],
            'TYPE_CODE' => $type_code,
            'URL' => $url,
            'TERMS' => $terms,
        ]);
        return $this->_wrap($result, $this->title);
    }

    /**
     * Details about purchase step (including a possible form).
     *
     * @return Tempcode The result of execution
     */
    public function details() : object
    {
        if (get_param_integer('accepted', 0) == 1) {
            attach_message(do_lang_tempcode('LICENCE_WAS_ACCEPTED'), 'inform');
        }

        $type_code = get_param_string('type_code');

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

        $test = $this->_check_availability($type_code);
        if ($test !== null) {
            return $test;
        }

        list($discounted_price, $discounted_tax_code, $points_for_discount) = get_discounted_price($details);
        if ($discounted_price === null) {
            $price = $details['price'];
            if ($price === null) {
                warn_exit(do_lang_tempcode('INTERNAL_ERROR'));
            }
        } else {
            $price = $discounted_price;
        }

        // Work out what next step is
        $next_purchase_step = get_next_purchase_step($product_object, $type_code, 'details');
        $url = build_url(['page' => '_SELF', 'type' => $next_purchase_step], '_SELF', ['include_message' => true], true);

        require_code('form_templates');
        list($fields, $hidden, $text, $js_function_calls) = get_needed_fields($type_code, false, false, $price == 0.0);

        if (get_param_integer('include_message', 0) == 1) {
            // Request to show message on the details screen (we would have been hot-linked straight to here)
            if ($text === null) {
                $text = new Tempcode();
            }
            if (method_exists($product_object, 'get_message')) {
                $text->attach($product_object->get_message($type_code));
            }
        }

        if ($text === null) {
            $text = do_lang_tempcode('FILL_IN_PRODUCT_OPTIONS');
        }

        if ($fields === null) {
            $url = build_url(['page' => '_SELF', 'type' => $next_purchase_step], '_SELF', [], true);

            // Ah, not even any fields to show - jump ahead
            return redirect_screen($this->title, $url);
        }

        $result = do_template('ECOM_PURCHASE_STAGE_DETAILS', [
            '_GUID' => '7fcbb0be5e90e52163bfec01f22f4ea0',
            'TITLE' => $this->title,
            'ITEM_NAME' => $details['item_name'],
            'TYPE_CODE' => $type_code,
            'TEXT' => $text,
            'FIELDS' => $fields,
            'HIDDEN' => $hidden,
        ]);
        return $this->_wrap($result, $this->title, $url, $js_function_calls);
    }

    /**
     * Payment step.
     *
     * @return Tempcode The result of execution
     */
    public function pay() : object
    {
        $type_code = get_param_string('type_code');

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

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

        $test = $this->_check_availability($type_code);
        if ($test !== null) {
            return $test;
        }

        $item_name = $details['item_name'];

        if (method_exists($product_object, 'process_needed_fields')) {
            list($purchase_id, $confirmation_box) = $product_object->process_needed_fields($type_code);
        } else {
            $purchase_id = strval(get_member());
            $confirmation_box = null;
        }

        list($discounted_price, $discounted_tax_code, $points_for_discount) = get_discounted_price($details);
        if ($discounted_price === null) {
            $price = $details['price'];
            if ($price === null) {
                warn_exit(do_lang_tempcode('INTERNAL_ERROR'));
            }
        } else {
            $price = $discounted_price;
        }

        $shipping_cost = calculate_shipping_cost($details, $details['shipping_cost'], $details['product_weight'], $details['product_length'], $details['product_width'], $details['product_height']);

        if ($details['type'] == PRODUCT_INVOICE) {
            // Tax details are locked in in advance for an invoice
            $tax_details = $GLOBALS['SITE_DB']->query_select('ecom_invoices', ['i_tax_code', 'i_tax_derivation', 'i_tax'], ['id' => intval($purchase_id)], '', 1);
            $tax_code = $tax_details[0]['i_tax_code'];
            $tax_derivation = ($tax_details[0]['i_tax_derivation'] == '') ? [] : json_decode($tax_details[0]['i_tax_derivation'], true);
            $tax = $tax_details[0]['i_tax'];
            $tax_tracking = ($tax_details[0]['i_tax_tracking'] == '') ? [] : json_decode($tax_details[0]['i_tax_tracking'], true);
            $shipping_tax = 0.00;
        } else {
            if ($discounted_price === null) {
                list($tax_derivation, $tax, $tax_tracking, $shipping_tax) = calculate_tax_due($details, $details['tax_code'], $price, $shipping_cost);
            } else {
                list($tax_derivation, $tax, $tax_tracking, $shipping_tax) = calculate_tax_due($details, $discounted_tax_code, $discounted_price, $shipping_cost);
            }
        }

        $currency = isset($details['currency']) ? $details['currency'] : get_option('currency');

        if ($details['type'] == PRODUCT_SUBSCRIPTION) {
            // For a subscription we need to add in the subscription record in advance of payment. This will become our $purchase_id
            $_purchase_id = $GLOBALS['SITE_DB']->query_select_value_if_there('ecom_subscriptions', 'id', [
                's_type_code' => $type_code,
                's_member_id' => get_member(),
                's_state' => 'new',
            ]);
            if ($_purchase_id === null) {
                $purchase_id = strval($GLOBALS['SITE_DB']->query_insert('ecom_subscriptions', [
                    's_type_code' => $type_code,
                    's_member_id' => get_member(),
                    's_state' => 'new',
                    's_price' => $price,
                    's_tax_code' => $details['tax_code'],
                    's_tax_derivation' => json_encode($tax_derivation, defined('JSON_PRESERVE_ZERO_FRACTION') ? JSON_PRESERVE_ZERO_FRACTION : 0),
                    's_tax' => $tax,
                    's_tax_tracking' => json_encode($tax_tracking, defined('JSON_PRESERVE_ZERO_FRACTION') ? JSON_PRESERVE_ZERO_FRACTION : 0),
                    's_currency' => $currency,
                    's_purchase_id' => $purchase_id,
                    's_time' => time(),
                    's_auto_fund_source' => '',
                    's_auto_fund_key' => '',
                    's_payment_gateway' => $payment_gateway,
                    's_length' => $details['type_special_details']['length'],
                    's_length_units' => $details['type_special_details']['length_units'],
                ], true));
            } else {
                $purchase_id = strval($_purchase_id);
            }

            $length = array_key_exists('length', $details['type_special_details']) ? $details['type_special_details']['length'] : 1;
            $length_units = array_key_exists('length_units', $details['type_special_details']) ? $details['type_special_details']['length_units'] : 'm';
        } else {
            $length = null;
            $length_units = '';
        }

        $text = null;

        if (get_param_integer('include_message', 0) == 1) {
            // Request to show message on the payment screen (we would have been hot-linked straight to here)
            $text = new Tempcode();
            if (method_exists($product_object, 'get_message')) {
                $text->attach($product_object->get_message($type_code));
            }
        }

        if ($price == 0.00) { // Free/point-based product
            if ($confirmation_box === null) {
                if ($points_for_discount !== null) {
                    if (!addon_installed('points')) {
                        warn_exit(do_lang_tempcode('INTERNAL_ERROR'));
                    }

                    $confirmation_box = do_lang_tempcode('BUYING_FOR_POINTS_CONFIRMATION', escape_html($item_name), escape_html(integer_format($points_for_discount)));

                    require_css('points');
                    $icon = 'menu/social/points';
                } else {
                    $confirmation_box = do_lang_tempcode('BUYING_FOR_FREE_CONFIRMATION', escape_html($item_name));

                    $icon = 'buttons/proceed';
                }
            }

            // No form
            $result = do_template('ECOM_PURCHASE_STAGE_TRANSACT', [
                '_GUID' => '76ecddb8d2429ed02412943e47dff5d9',
                'TITLE' => $this->title,
                'ITEM_NAME' => $item_name,
                'TYPE_CODE' => $type_code,
                'PURCHASE_ID' => $purchase_id,
                'FIELDS' => null,
                'HIDDEN' => '',
                'LOGOS' => '',
                'PAYMENT_PROCESSOR_LINKS' => '',
                'TEXT' => $text,
                'CONFIRMATION_BOX' => $confirmation_box,
            ]);

            $next_purchase_step = get_next_purchase_step($product_object, $type_code, 'pay');
            $finish_url = build_url(['page' => '_SELF', 'type' => $next_purchase_step, 'points' => 1, 'purchase_id' => $purchase_id, 'type_code' => $type_code], '_SELF', ['include_message' => null], true);
            $submit_name = do_lang_tempcode('MAKE_PAYMENT');
        } elseif (perform_local_payment()) { // Handle the transaction internally
            if ($confirmation_box === null) {
                $_price = currency_convert_wrap($price, $currency);
                $_tax = currency_convert_wrap($tax, $currency);
                $confirmation_box = do_lang_tempcode('BUYING_FOR_MONEY_CONFIRMATION', escape_html($item_name), $_price, [$_tax, do_lang(get_option('tax_system'))]);
            }

            $needs_shipping_address = !empty($details['needs_shipping_address']);

            list($fields, $hidden, $logos, $payment_processor_links) = get_transaction_form_fields(
                $type_code,
                $item_name,
                $purchase_id,
                $price,
                $tax_derivation,
                $tax,
                $tax_tracking,
                $shipping_cost,
                $shipping_tax,
                $currency,
                ($points_for_discount === null) ? 0 : $points_for_discount,
                ($details['type'] == PRODUCT_SUBSCRIPTION) ? intval($length) : null,
                ($details['type'] == PRODUCT_SUBSCRIPTION) ? $length_units : '',
                $payment_gateway,
                $needs_shipping_address
            );

            // Credit card form
            $result = do_template('ECOM_PURCHASE_STAGE_TRANSACT', [
                '_GUID' => '15cbba9733f6ff8610968418d8ab527e',
                'TITLE' => $this->title,
                'ITEM_NAME' => $item_name,
                'TYPE_CODE' => $type_code,
                'PURCHASE_ID' => $purchase_id,
                'FIELDS' => $fields,
                'HIDDEN' => $hidden,
                'LOGOS' => $logos,
                'PAYMENT_PROCESSOR_LINKS' => $payment_processor_links,
                'TEXT' => $text,
                'CONFIRMATION_BOX' => $confirmation_box,
            ]);

            $next_purchase_step = get_next_purchase_step($product_object, $type_code, 'pay');
            $finish_url = build_url(['page' => '_SELF', 'type' => $next_purchase_step, 'type_code' => $type_code], '_SELF', ['include_message' => true], true);
            $submit_name = do_lang_tempcode('MAKE_PAYMENT');
            $icon = 'menu/rich_content/ecommerce/purchase';
        } else { // Pass through to the gateway's HTTP server
            if ($confirmation_box === null) {
                $_price = currency_convert_wrap($price, $currency);
                $_tax = currency_convert_wrap($tax, $currency);
                $_shipping = currency_convert_wrap($shipping_cost, $currency);
                $confirmation_box = do_lang_tempcode('BUYING_FOR_MONEY_CONFIRMATION', escape_html($item_name), $_price, [$_tax, do_lang(get_option('tax_system')), $_shipping]);
            }

            switch ($details['type']) {
                case PRODUCT_SUBSCRIPTION:
                    $transaction_button = make_subscription_button(
                        $type_code,
                        $item_name,
                        $purchase_id,
                        $price,
                        $tax_derivation,
                        $tax,
                        $tax_tracking,
                        $currency,
                        ($points_for_discount === null) ? 0 : $points_for_discount,
                        $length,
                        $length_units,
                        $payment_gateway
                    );
                    break;
                case PRODUCT_ORDERS:
                    if (!addon_installed('shopping')) {
                        warn_exit(do_lang_tempcode('INTERNAL_ERROR'));
                    }

                    require_code('shopping');
                    $order_id = intval(preg_replace('#^CART_ORDER_#', '', $type_code));
                    $transaction_button = make_cart_payment_button($order_id, $currency, ($points_for_discount === null) ? 0 : $points_for_discount);
                    break;
                case PRODUCT_PURCHASE:
                case PRODUCT_INVOICE:
                case PRODUCT_CATALOGUE:
                default:
                    $transaction_button = make_transaction_button(
                        $type_code,
                        $item_name,
                        $purchase_id,
                        $price,
                        $tax_derivation,
                        $tax,
                        $tax_tracking,
                        $shipping_cost,
                        $shipping_tax,
                        $currency,
                        ($points_for_discount === null) ? 0 : $points_for_discount,
                        $payment_gateway
                    );
                    break;
            }

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

            // Form with pay button on
            $result = do_template('ECOM_PURCHASE_STAGE_PAY', [
                '_GUID' => '1e7471ec76d3adbc0b38613edb815f3d',
                'TITLE' => $this->title,
                'TRANSACTION_BUTTON' => $transaction_button,
                'CURRENCY' => $currency,
                'ITEM_NAME' => $item_name,
                'TYPE_CODE' => $type_code,
                'PURCHASE_ID' => $purchase_id,
                'LENGTH' => ($length === null) ? '' : strval($length),
                'LENGTH_UNITS' => $length_units,
                'PRICE' => float_to_raw_string($price),
                'TEXT' => $text,
                'CONFIRMATION_BOX' => $confirmation_box,
                'LOGOS' => $logos,
                'PAYMENT_PROCESSOR_LINKS' => $payment_processor_links,
            ]);

            $finish_url = null; // The embedded button will take the user through to the payment gateway
            $submit_name = null;
            $icon = 'buttons/proceed';
        }

        return $this->_wrap($result, $this->title, $finish_url, [], false, $submit_name, $icon);
    }

    /**
     * Finish step.
     *
     * @return Tempcode The result of execution
     */
    public function finish() : object
    {
        $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));

        // Note: Some payment gateways do not support passing in your own finish / redirect URL with a type_code parameter in the button form
        $type_code = get_param_string('type_code', null);

        if (get_param_integer('cancel', 0) == 1) {
            $subtype = 'cancel';
        } elseif (get_param_integer('points', 0) == 1) {
            $subtype = 'points_payment';
        } elseif (perform_local_payment()) {
            $subtype = 'local_payment';
        } else {
            $subtype = 'pdt_ipn_return';
        }

        $message = get_param_string('message', null);
        if ($message === null) {
            if (method_exists($payment_gateway_object, 'get_callback_url_message')) {
                $message = $payment_gateway_object->get_callback_url_message();
            }
        }

        if ($subtype == 'cancel') {
            delete_pending_orders_for_current_user();

            if ($message !== null) {
                $result = do_template('ECOM_PURCHASE_STAGE_FINISH', [
                    '_GUID' => '859c31e8f0f02a2a46951be698dd22cf',
                    'TITLE' => $this->title,
                    'TYPE_CODE' => $type_code,
                    'MESSAGE' => $message,
                    'SUCCESS' => false,
                ]);
                return $this->_wrap($result, $this->title);
            }

            return inform_screen($this->title, do_lang_tempcode('PRODUCT_PURCHASE_CANCEL'), true);
        }

        if ($subtype == 'points_payment') { // No eCommerce payment required
            list($details, $product_object) = find_product_details($type_code);
            $item_name = $details['item_name'];

            $purchase_id = get_param_string('purchase_id');

            list($discounted_price, $discounted_tax_code, $points_for_discount) = get_discounted_price($details);
            if (($discounted_price === null) && ($details['price'] !== 0.00)) {
                warn_exit(do_lang_tempcode('INTERNAL_ERROR')); // Not discounted to free and not free, we should not be here
            }

            if ($points_for_discount !== null) {
                // Paying with points
                $message = do_lang_tempcode('POINTS_PURCHASE', escape_html(integer_format($points_for_discount)));
                $memo = post_param_string('memo', do_lang('POINTS'));
                $currency = 'points';
            } else {
                // Completely free
                $message = do_lang_tempcode('FREE_PURCHASE');
                $memo = post_param_string('memo', do_lang('FREE'));
                $currency = get_option('currency');
            }
            $price = 0.00;

            $status = 'Completed';
            $reason = '';
            $pending_reason = '';
            $txn_id = 'manual-' . substr(uniqid('', true), 0, 10);
            $parent_txn_id = '';
            $is_subscription = ($details['type'] == PRODUCT_SUBSCRIPTION);
            if ($is_subscription) {
                $period = cms_strtolower_ascii(strval($details['type_special_details']['length']) . ' ' . $details['type_special_details']['length_units']);
            } else {
                $period = '';
            }

            handle_confirmed_transaction(null, $txn_id, $type_code, $item_name, $purchase_id, $is_subscription, $status, $reason, $price, 0.00, 0.00, 0.00, $currency, true, $parent_txn_id, $pending_reason, $memo, $period, get_member(), 'manual', false, true);

            global $ECOMMERCE_SPECIAL_SUCCESS_MESSAGE;
            if ($ECOMMERCE_SPECIAL_SUCCESS_MESSAGE !== null) {
                $message = $ECOMMERCE_SPECIAL_SUCCESS_MESSAGE;
            }
        }

        if ($subtype == 'local_payment') { // We need to try and run the transaction
            list($success, $message, $message_raw) = do_local_transaction($payment_gateway, $payment_gateway_object);
            if (!$success) {
                return warn_screen($this->title, $message);
            }
        }

        // We know success at this point...

        if (($type_code !== null) && (($subtype == 'pdt_ipn_return') || ($subtype == 'local_payment'))) {
            if (addon_installed('stats')) {
                require_code('stats');
                log_stats_event(do_lang('ECOMMERCE', null, null, null, get_site_default_lang()) . '-' . $type_code);
            }
        }

        if (($subtype == 'pdt_ipn_return')) {
            handle_pdt_ipn_transaction_script();
        }

        $redirect = get_param_string('redirect', null, INPUT_FILTER_URL_GENERAL);

        if (($type_code !== null) && ($redirect === null)) {
            list(, $product_object) = find_product_details($type_code);
            if (method_exists($product_object, 'get_finish_url')) {
                $redirect = $product_object->get_finish_url($type_code, $message);
            }
        }

        global $ECOMMERCE_SPECIAL_SUCCESS_MESSAGE;
        if ($ECOMMERCE_SPECIAL_SUCCESS_MESSAGE !== null) {
            $message = $ECOMMERCE_SPECIAL_SUCCESS_MESSAGE;
        }

        if ($redirect !== null) {
            return redirect_screen($this->title, $redirect, $message);
        }

        $result = do_template('ECOM_PURCHASE_STAGE_FINISH', [
            '_GUID' => '43f706793719ea893c280604efffacfe',
            'TITLE' => $this->title,
            'TYPE_CODE' => $type_code,
            'MESSAGE' => $message,
            'SUCCESS' => true,
        ]);
        return $this->_wrap($result, $this->title);
    }

    /**
     * UI for a member's sales.
     *
     * @return Tempcode the UI
     */
    public function sales() : object
    {
        require_code('ecommerce_reports');
        require_lang('ecommerce');

        $sales_table = new Tempcode();
        $pagination = new Tempcode();

        $member_id_of = get_param_integer('id', get_member());
        $filter_txn_id = get_param_integer('filter_txn_id', null);

        $filters = ['member_id' => $member_id_of];
        if ($filter_txn_id !== null) {
            $filters['txn_id'] = $filter_txn_id;
        }
        $tmp = build_sales_table($filters, ($member_id_of != get_member()), has_actual_page_access(get_member(), 'admin_ecommerce_reports'), 50, false);

        if ($tmp !== null) {
            $sales_table = $tmp[0];
            $pagination = $tmp[1];
        }

        $screen = do_template('CNS_MEMBER_ECOMMERCE_REPORTS', ['_GUID' => 'e490d230b9521415616be0c610434d93', 'CONTENT' => $sales_table, 'PAGINATION' => $pagination]);

        return $this->_wrap($screen, $this->title, null);
    }

    /**
     * Check to see if a product is available to the current user.
     *
     * @param  ID_TEXT $type_code The product code
     * @return ?Tempcode Error screen (null: no error)
     */
    protected function _check_availability(string $type_code) : ?object
    {
        list(, $product_object) = find_product_details($type_code);
        if (!method_exists($product_object, 'is_available')) {
            return warn_screen($this->title, do_lang_tempcode('INTERNAL_ERROR'));
        }

        $availability_status = $product_object->is_available($type_code, get_member(), 1, true);

        switch ($availability_status) {
            case ECOMMERCE_PRODUCT_ALREADY_HAS:
                return warn_screen($this->title, do_lang_tempcode('ECOMMERCE_PRODUCT_ALREADY_HAS'), true, true);

            case ECOMMERCE_PRODUCT_DISABLED:
                return warn_screen($this->title, do_lang_tempcode('ECOMMERCE_PRODUCT_DISABLED'), true, true);

            case ECOMMERCE_PRODUCT_PROHIBITED:
                return warn_screen($this->title, do_lang_tempcode('ECOMMERCE_PRODUCT_PROHIBITED'), true, true);

            case ECOMMERCE_PRODUCT_OUT_OF_STOCK:
                return warn_screen($this->title, do_lang_tempcode('ECOMMERCE_PRODUCT_OUT_OF_STOCK'));

            case ECOMMERCE_PRODUCT_MISSING:
                return warn_screen($this->title, do_lang_tempcode('ECOMMERCE_PRODUCT_MISSING'));

            case ECOMMERCE_PRODUCT_INTERNAL_ERROR:
                return warn_screen($this->title, do_lang_tempcode('INTERNAL_ERROR'));

            case ECOMMERCE_PRODUCT_NO_GUESTS:
                if ((is_guest()) && (get_forum_type() != 'cns')) {
                    access_denied('NOT_AS_GUEST');
                }

                require_code('cns_join');

                check_joining_allowed();

                $url = get_self_url();

                $form = cns_join_form($url, true, false, false);

                $hidden = build_keep_post_fields();

                $join_screen = do_template('ECOM_PURCHASE_STAGE_GUEST', [
                    '_GUID' => 'accf475a1457f73d7280b14d774acc6e',
                    'TEXT' => do_lang_tempcode('PURCHASE_NOT_LOGGED_IN', escape_html(get_site_name())),
                    'FORM' => $form,
                    'HIDDEN' => $hidden,
                ]);

                return $this->_wrap($join_screen, $this->title);
        }

        return $this->_check_can_afford($type_code);
    }

    /**
     * Check to see if the current user can afford a product.
     *
     * @param  ID_TEXT $type_code The product code
     * @return ?Tempcode Error screen (null: no error)
     */
    protected function _check_can_afford(string $type_code) : ?object
    {
        list($details) = find_product_details($type_code);
        list($discounted_price, $discounted_tax_code, $points_for_discount) = get_discounted_price($details);

        if ($points_for_discount !== null) {
            if (!addon_installed('points')) {
                warn_exit(do_lang_tempcode('INTERNAL_ERROR'));
            }

            // Can't afford the points?
            require_code('points');
            $points_balance = points_balance(get_member());
            if (($points_balance < $points_for_discount) && (!has_privilege(get_member(), 'send_points_to_self'))) {
                return warn_screen($this->title, do_lang_tempcode('CANT_AFFORD', escape_html(integer_format($points_for_discount)), escape_html(integer_format($points_balance))));
            }
        }

        if (($discounted_price === null) && ($details['price'] === null)) {
            return warn_screen($this->title, do_lang_tempcode('INTERNAL_ERROR')); // Cannot buy with money and not available with points for some reason
        }

        return null;
    }
}
