<?php

const CCBILL_TIME_OFFSET = -8 * 3600;

/**
 * @table paysystems
 * @id ccbill
 * @title ccBill
 * @hidden_link https://www.ccbill.com/online-merchants/index.php?utm_campaign=Integration%20Partner%20Tracking&utm_medium=Partner%20Page&utm_source=aMember%20Professional
 * @visible_link http://www.ccbill.com/
 * @recurring paysystem
 * @logo_url ccbill.png
 * @country US
 * @fixed_products 1
 * @adult 1
 * @am_payment_api 6.0
 */
class Am_Paysystem_Ccbill extends Am_Paysystem_Abstract
{
    const PLUGIN_STATUS = self::STATUS_PRODUCTION;
    const PLUGIN_REVISION = '6.3.43';

    protected $defaultTitle = 'ccBill';
    protected $defaultDescription = 'Pay by credit card/debit card';

    const CCBILL_CC = 'ccbill_cc';
    const CCBILL_900 = 'ccbill_900';
    const CCBILL_CHECK = 'ccbill_check';
    const URL = 'https://bill.ccbill.com/jpost/signup.cgi';
    const CASCADE_URL = 'https://bill.ccbill.com/jpost/billingCascade.cgi';
    const CCBILL_LAST_RUN = 'ccbill_datalink_last_run';
    const DATALINK_URL = 'https://datalink.ccbill.com/data/main.cgi';
    const DATALINK_SUBSCR_MANAGEMENT = 'https://datalink.ccbill.com/utils/subscriptionManagement.cgi';
    protected $currency_codes = [
        'USD' => '840',
        'AUD' => '036',
        'EUR' => '978',
        'GBP' => '826',
        'JPY' => '392',
        'CAD' => '124'
    ];

    public function init()
    {
        parent::init();
        $this->getDi()->billingPlanTable->customFields()
            ->add(new Am_CustomFieldText('ccbill_product_id', "ccBill Product ID",
                    "you must create the same product in ccbill for CC billing. Enter pricegroup here"));
        $this->getDi()->billingPlanTable->customFields()
            ->add(new Am_CustomFieldText('ccbill_subaccount_id', "ccBill Subaccount ID",
                    "keep empty to use default value (from config)"));
        $this->getDi()->billingPlanTable->customFields()
            ->add(new Am_CustomFieldText('ccbill_form_id', "ccBill Form ID",
                    "enter ccBill Form ID"));
        $this->getDi()->billingPlanTable->customFields()
            ->add(new Am_CustomFieldText('ccbill_flexform_id', "ccBill FlexForm ID",
                    'like "32be552a-7f5b-417c-b458-611e955927fd"'));
    }

    public function canAutoCreate()
    {
        return true;
    }

    public function _initSetupForm(Am_Form_Setup $form)
    {
        $form->addText('account')->setLabel("Your Account Id in ccbill\n" . 'your account number on ccBill, like 112233');
        $form->addText('subaccount_id')->setLabel("Subaccount number\n" . 'like 0001 or 0002');
        $form->addText('datalink_user')->setLabel("DataLink Username\n" . 'read ccBill plugin readme (11) about');
        $form->addSecretText('datalink_pass')->setLabel("DataLink Password\n" . 'read ccBill plugin readme (11) about');
        $form->addAdvCheckbox('dynamic_pricing')->setLabel('Allow Dynamic Pricing');
        $form->addSecretText('salt', ['class' => 'am-el-wide'])
            ->setLabel("Salt\n" .
                'Contact ccBill client support and receive the salt value, ' .
                'OR Create your own salt value (up to 32 alphanumeric ' .
                'characters) and provide it to ccBill client support.');
        $form->addText('flexform_id', ['class' => 'am-el-wide'])
            ->setLabel("ccBill FlexForm ID\n"
                . "if you want to use the same FlexForm ID for all products specify it here.\n"
                . "Leave empty if you do not use FlexForms functionality \n"
                . "or have separate form for each product");

        $fs = $form->addFieldset("advanced")->setLabel(___('Advanced Functionality'));
        $fs->addHTML()->setHTML(___('This functionality will work only if you set up to access the CCBill Data Link Extract System.'));
        $fs->addCheckbox('cancel_discounts')->setLabel(___('Enable Cancellation Discounts
        When user attempts  to cancel, plugin checks whenever discount is available for subscription
        If there is a discount, user will be offered to pay lower price.
        Discount should be already configured for subscription in ccBill
        '));

        $fs->addTextarea('cancel_discount_offer', ['class' => 'am-el-wide', 'rows' => 5])->setLabel(___('Offer that will be displayed for user on cancellation
        You can use &#37;amount&#37; placeholder in text(this is amount of discount)
        '));

        $fs->addAdvCheckbox('attempt-to-charge')->setLabel(___('Attempt to charge existing customer without redirect
        aMember will try to charge customer by previous subscription first.
        If fails, user will be sent to ccBill
        '));

        $form->addScript()->setScript(<<<CUT
        jQuery(document).ready(function(){
            jQuery("input[name$='cancel_discounts']").on('change', function(){
                jQuery("textarea[name$='cancel_discount_offer']").closest(".am-row").toggle(jQuery(this).is(":checked"))
            }).change();
        });
CUT
);
        $form->setDefault('cancel_discount_offer', $this->discountOffer());
    }

    function discountOffer()
    {
        return $this->getConfig('cancel_discount_offer',  ___("We would like to offer you a $%amount% discount if you decide to stay with our services!"));
    }

    function supportsChangeRebillDate()
    {
        return true;
    }

    function changeRebillDate(Invoice $invoice, DateTime $rebill_date)
    {
        $oldDate = new DateTime($invoice->rebill_date);
        $daysDiff = $rebill_date->diff($oldDate)->days;

        if ($daysDiff < 1) {
            throw new Am_Exception_InputError("Can't set rebill date in the past");
        }
        $this->smsApiRequest($invoice, function($subaccountId, $subscriptionId) use ($daysDiff) {
            return [
                'clientAccnum' => $this->getConfig('account'),
                'clientSubacc' => $subaccountId,
                'usingSubacc' => $subaccountId,
                'returnXML' => 1,
                'action' => 'extendSubscription',
                'extendLength' => $daysDiff,
                'subscriptionId' => $subscriptionId,
                'username' => $this->getConfig('datalink_user'),
                'password' => $this->getConfig('datalink_pass')
            ];
        });

        parent::changeRebillDate($invoice, $rebill_date);
    }

    function getDays($period)
    {
        $period = new Am_Period($period);
        switch($period->getUnit()){
            case Am_Period::DAY:
                return $period->getCount();
            case Am_Period::MONTH:
                return $period->getCount()*30;
            case Am_Period::YEAR:
                return $period->getCount()*365;
            case Am_Period::FIXED:
            case Am_Period::MAX_SQL_DATE:
                return 365; //actual value in this case does not matter
        }
    }

    function tryToCharge(Invoice $invoice, Am_Paysystem_Result $result)
    {
        $oldInvoiceId = $this->getDi()->db->selectCell(
            "SELECT invoice_id FROM ?_invoice WHERE user_id=? AND paysys_id=? AND status>1 ORDER BY invoice_id DESC LIMIT 1",
            $invoice->getUser()->pk(),
            $this->getId()
        );

        if (!$oldInvoiceId) {
            return false;
        }
        $oldInvoice = $this->getDi()->invoiceTable->load($oldInvoiceId);

        if (!$this->getConfig('datalink_user') || !$this->getConfig('datalink_pass')) {
            $this->getDi()->logger->error("ccBill plugin error: Datalink is not configured!");
            return false;
        }
        $payments  = $oldInvoice->getPaymentRecords();

        $subscriptionId = $payments[0]->transaction_id;

        $vars = [
            'clientAccnum' => $this->getConfig('account'),
//    	      'clientSubacc' =>$this->getConfig('subaccount_id'),
            'usingSubacc' => $this->getConfig('subaccount_id'),
            'subscriptionId' => $subscriptionId,
            'newClientAccnum' => $this->getConfig('account'),
            'newClientSubacc' => $this->getConfig('subaccount_id'),
            'sharedAuthentication' => 1,
            'action' => 'chargeByPreviousTransactionId',
            'currencyCode' => $this->currency_codes[$invoice->currency],
            'initialPrice' => $invoice->first_total,
            'initialPeriod' => $this->getDays($invoice->first_period),
            'returnXML' => 1,
            'username' => $this->getConfig('datalink_user'),
            'password' => $this->getConfig('datalink_pass'),
        ];

        if ($invoice->rebill_times) {
            $vars['recurringPrice'] = $invoice->second_total;
            $vars['recurringPeriod'] = $this->getDays($invoice->second_period);
            $vars['rebills'] = min(99, $invoice->rebill_times);
        } else {
            $vars['recurringPrice'] = 0;
            $vars['recurringPeriod'] = 0;
            $vars['rebills'] = 0;
        }

        try {
            $r = new Am_HttpRequest($requestString = "https://bill.ccbill.com/jpost/billingApi.cgi?" . http_build_query($vars, '', '&'));
            $response = $r->send();

            if (!$response) {
                throw new Am_Exception_InternalError('ccBill Billing API  error: Unable to contact datalink server');
            }

            $resp = $response->getBody();

            $this->getDi()->logger->error(sprintf("ccBill billing API  debug:\n%s\n%s", $requestString, $resp));
            $xml = simplexml_load_string($resp);

            if ((string)$xml->approved != "1")
                throw new Am_Exception_InternalError('ccBill Subscription Management error: Incorrect response received while attempting to charge  subscription!');
        } catch (Exception $ex){
            $this->getDi()->logger->error($ex->getMessage());
            return false;
        }

        $tr = new Am_Paysystem_Transaction_Ccbill_Upgrade($this, $xml);
        $tr->setInvoice($invoice);
        $result->setSuccess($tr);
        return true;
    }

    public function _process($invoice, $request, $result)
    {
        if ($this->getConfig('attempt-to-charge')){
            if ($this->tryToCharge($invoice, $result)) {
                return $result;
            }
        }
        $user = $invoice->getUser();

        $subaccount_id = $invoice->getItem(0)->getBillingPlanData("ccbill_subaccount_id")
            ? $invoice->getItem(0)->getBillingPlanData("ccbill_subaccount_id")
            : $this->getConfig('subaccount_id');
        $cascade_id = $invoice->getItem(0)->getBillingPlanData("ccbill_cascade_id");
        $a = new Am_Paysystem_Action_Redirect($cascade_id ? self::CASCADE_URL : self::URL);
        if ($cascade_id)
            $a->cascadeId = $cascade_id;
        $a->clientAccnum = $this->getConfig('account');
        $a->clientSubacc = $subaccount_id;
        $a->formName = $invoice->getItem(0)->getBillingPlanData("ccbill_form_id");
        $a->username = $user->login;
        $a->email = $invoice->getEmail();
        $a->customer_fname = $invoice->getFirstName();
        $a->customer_lname = $invoice->getLastName();
        $a->address1 = $invoice->getStreet();
        $a->city = $invoice->getCity();
        $a->state = $invoice->getState();
        $a->zipcode = $invoice->getZip();
        $a->phone_number = $invoice->getPhone();
        $a->payment_id = $invoice->public_id;
        $a->customVar1 = $invoice->public_id;
        $a->invoice = $invoice->getSecureId("THANKS");
        $a->referer = $invoice->getUser()->aff_id;
        if (
            ($flexform_id = $invoice->getItem(0)->getBillingPlanData("ccbill_flexform_id"))
            || ($flexform_id = $this->getConfig('flexform_id'))
        ) {
            unset($a->formName); // causes error on ccBill flexform url
            $a->setUrl('https://api.ccbill.com/wap-frontflex/flexforms/'.$flexform_id);
            //because the lifetime pricing option cannot be calculated dynamically, we
            //created a dedicated subscriptionTypeId (subscriptionTypeId is unique and is explicitly
            //linked to the specific account).
            if (
                $invoice->first_period == Am_Period::MAX_SQL_DATE
                && count($invoice->getItems()) == 1
                && ($subscriptionTypeId = $invoice->getItem(0)->getBillingPlanData("ccbill_product_id"))
            ) {
                $a->subscriptionTypeId = $subscriptionTypeId;
            }
            $a->initialPrice = $invoice->first_total;
            $a->initialPeriod = $this->getDays($invoice->first_period);
            $a->currencyCode = $this->currency_codes[$invoice->currency];
            if ($invoice->rebill_times) {
                $a->recurringPrice = $invoice->second_total;
                $a->recurringPeriod = $this->getDays($invoice->second_period);
                $a->numRebills = min(99, $invoice->rebill_times);
                $a->formDigest = md5($a->initialPrice.$a->initialPeriod.$a->recurringPrice.$a->recurringPeriod.$a->numRebills.$a->currencyCode.$this->getConfig('salt'));
            } else {
                $a->formDigest = md5($a->initialPrice.$a->initialPeriod.$a->currencyCode.$this->getConfig('salt'));
            }
            $a->declineURL = $this->getCancelUrl();
        } elseif ($this->getConfig('dynamic_pricing')) {
            $a->country = $invoice->getCountry();
            $a->formPrice = $invoice->first_total;
            $a->formPeriod = $this->getDays($invoice->first_period);
            $a->currencyCode = $this->currency_codes[$invoice->currency];
            if ($invoice->rebill_times) {
                $a->formRecurringPrice = $invoice->second_total;
                $a->formRecurringPeriod = $this->getDays($invoice->second_period);
                $a->formRebills = min(99, $invoice->rebill_times);
                $a->formDigest = md5($a->formPrice.$a->formPeriod.$a->formRecurringPrice.$a->formRecurringPeriod.$a->formRebills.$a->currencyCode.$this->getConfig('salt'));
            } else {
                $a->formDigest = md5($a->formPrice.$a->formPeriod.$a->currencyCode.$this->getConfig('salt'));
            }
        } else {
            $a->country = $invoice->getCountry();
            $a->subscriptionTypeId = $invoice->getItem(0)->getBillingPlanData("ccbill_product_id");
            $a->allowedTypes = $invoice->getItem(0)->getBillingPlanData("ccbill_product_id");
            $a->allowedCurrencies = $this->currency_codes[$invoice->currency];
        }
        $result->setAction($a);
    }

    function cancelAction(Invoice $invoice, $actionName, Am_Paysystem_Result $result)
    {
        $this->smsApiRequest($invoice, function($subaccountId, $subscriptionId) {
            return [
                'clientAccnum' => $this->getConfig('account'),
                'clientSubacc' => $subaccountId,
                'usingSubacc' => $subaccountId,
                'returnXML' => 1,
                'action' => 'cancelSubscription',
                'subscriptionId' => $subscriptionId,
                'username' => $this->getConfig('datalink_user'),
                'password' => $this->getConfig('datalink_pass')
            ];
        });

        $invoice->setCancelled(true);
        $result->setSuccess();
    }

    function doUpgrade(Invoice $invoice, InvoiceItem $item, Invoice $newInvoice, ProductUpgrade $upgrade)
    {
        if (!$this->getConfig('datalink_user') || !$this->getConfig('datalink_pass')) {
            $this->getDi()->logger->error("ccBill plugin error: Datalink is not configured!");
            return;
        }
        $payments = $invoice->getPaymentRecords();

        $subscriptionId = $payments[0]->transaction_id;
        $subaccount_id = $invoice->getItem(0)->getBillingPlanData("ccbill_subaccount_id") ?
            $invoice->getItem(0)->getBillingPlanData("ccbill_subaccount_id") : $this->getConfig('subaccount_id');
        $vars = [
            'clientAccnum' => $this->getConfig('account'),
            'usingSubacc' => $subaccount_id,
            'subscriptionId' => $subscriptionId,
            'newClientAccnum' => $this->getConfig('account'),
            'newClientSubacc' => $this->getConfig('subaccount_id'),
            'sharedAuthentication' => 1,
            'action' => 'chargeByPreviousTransactionId',
            'currencyCode' => $this->currency_codes[$invoice->currency],
            'initialPrice' => $newInvoice->first_total,
            'initialPeriod' => $this->getDays($newInvoice->first_period),
            'returnXML' => 1,
            'username' => $this->getConfig('datalink_user'),
            'password' => $this->getConfig('datalink_pass'),
        ];

        if ($newInvoice->rebill_times) {
            $vars['recurringPrice'] = $newInvoice->second_total;
            $vars['recurringPeriod'] = $this->getDays($newInvoice->second_period);
            $vars['rebills'] = min(99, $newInvoice->rebill_times);
        } else {
            $vars['recurringPrice'] = 0;
            $vars['recurringPeriod'] = 0;
            $vars['rebills'] = 0;
        }

        $r = new Am_HttpRequest($requestString = "https://bill.ccbill.com/jpost/billingApi.cgi?" . http_build_query($vars, '', '&'));
        $response = $r->send();
        if (!$response) {
            $this->getDi()->logger->error('ccBill Billing API  error: Unable to contact datalink server');
            throw new Am_Exception_InternalError('ccBill Billing API  error: Unable to contact datalink server');
        }

        $resp = $response->getBody();

        // Log datalink requests;
        $this->getDi()->logger->error(sprintf("ccBill billing API  debug:\n%s\n%s", $requestString, $resp));
        $xml = simplexml_load_string($resp);

        if ((string)$xml->approved != "1")
            throw new Am_Exception_InternalError('ccBill Subscription Management error: Incorrect response received while attempting to upgrade subscription!');

        $tr = new Am_Paysystem_Transaction_Ccbill_Upgrade($this, $xml);
        // Add payment to new invocie;
        $newInvoice->addPayment($tr);

        $this->smsApiRequest($invoice, function($subaccountId, $subscriptionId) {
            return [
                'clientAccnum' => $this->getConfig('account'),
                'clientSubacc' => $subaccountId,
                'usingSubacc' => $subaccountId,
                'returnXML' => 1,
                'action' => 'cancelSubscription',
                'subscriptionId' => $subscriptionId,
                'username' => $this->getConfig('datalink_user'),
                'password' => $this->getConfig('datalink_pass')
            ];
        });

        // Cancel old one locally after remote cancellation succeeds.
        $invoice->setCancelled(true);
    }
    public function getSupportedCurrencies()
    {
        return array_keys($this->currency_codes);
    }

    public function createTransaction($request, $response, array $invokeArgs)
    {
        return new Am_Paysystem_Transaction_Ccbill($this, $request, $response, $invokeArgs);
    }

    public function createThanksTransaction($request, $response, array $invokeArgs)
    {
        return new Am_Paysystem_Transaction_Ccbill_Thanks($this, $request, $response, $invokeArgs);
    }

    public function getRecurringType()
    {
        return self::REPORTS_REBILL;
    }

    public function getReadme()
    {
        return <<<CUT
<i>When you have the plugin configured according to documentation below, use the following link to  
<a href="http://example.com/amember/payment/ccbill/debug" class="ccbill_debug">test datalink</a></i>

<div id="ccbill_debug"></div>
<script>
jQuery(".ccbill_debug").click(function(event)
{
    event.stopPropagation();
    var link = this;
    jQuery("#ccbill_debug").dialog({
        autoOpen: true
        ,width: 500
        ,title: "Sending test request to ccbill"
        ,modal: true
        ,buttons: {
            "OK" : function(){
                jQuery(this).dialog("close");
            }
        }
    });
    jQuery.ajax({
      type: 'GET'
      ,url: link.href
      ,success: function(data, textStatus, request)
      {
        if (data.ok)
        {
            jQuery("#ccbill_debug").html('<font color="green">No any problems found</font>');
        } else {
            jQuery("#ccbill_debug").html(data.msg);
        }
      }
    });

    return false;
});
</script>

</div>

CUT;
    }

    function dateToSQL($date)
    {
        if (preg_match('/^\d{14}$/', $date)) {
            return substr($date, 0, 4) . '-' . substr($date, 4, 2) . '-' . substr($date, 6, 2);
        } else {
            $tm = strtotime($date);
            return date('Y-m-d', $tm);
        }
    }

    function timeToSQL($date)
    {
        $s = substr($date, 0, 4) . '-' .
            substr($date, 4, 2) . '-' .
            substr($date, 6, 2) . ' ' .
            substr($date, 8, 2) . ':' .
            substr($date, 10, 2) . ':' .
            substr($date, 12, 2) . '';
        return $s;
    }

    // Datalink request here;
    function onHourly()
    {
        if (!$this->getConfig('datalink_user') || !$this->getConfig('datalink_pass')) {
            $this->getDi()->logger->error("ccBill plugin error: Datalink is not configured!");
            return;
        }
        //ccbill_subaccount_id
        $subaccounts = $this->getDi()->db->selectCol(
            "SELECT `value` FROM ?_data WHERE `key` = 'ccbill_subaccount_id' AND `table` = 'billing_plan'"
        );
        $subaccounts[] = $this->getConfig('subaccount_id');
        $subaccounts = array_filter(array_unique($subaccounts));

        foreach ($subaccounts as $subaccount) {
            $last_run = $this->getDi()->store->get(self::CCBILL_LAST_RUN."-".$subaccount);
            if (!$last_run) {
                $last_run = $this->getDi()->store->get(self::CCBILL_LAST_RUN);
            }

            if (!$last_run || ($last_run < 19700101033324 ))
                $last_run = gmdate('YmdHis', time() - 15 * 3600 * 24 + CCBILL_TIME_OFFSET);

            $now_run = gmdate('YmdHis', time() + CCBILL_TIME_OFFSET);
            $last_run_tm = strtotime($this->timeToSQL($last_run));
            $now_run_tm = strtotime($this->timeToSQL($now_run));

            //ccBill allows to query data for last 24 hours only;
            if (($now_run_tm - $last_run_tm) > 3600 * 24)
                $now_run_tm = $last_run_tm + 3600 * 24;

            $now_run = date('YmdHis', $now_run_tm);

            //ccBill allow to execute datalink once in an hour only.
            if (($now_run_tm - $last_run_tm) <= 3600)
                continue;

            $vars = [
                'startTime' => $last_run,
                'endTime' => $now_run,
                'transactionTypes' => 'REBILL,REFUND,CHARGEBACK', //EXPIRE,
                'clientAccnum' => $this->getConfig('account'),
                'clientSubacc' => $subaccount,
                'username' => $this->getConfig('datalink_user'),
                'password' => $this->getConfig('datalink_pass')
            ];

            $r = new Am_HttpRequest($requestString = self::DATALINK_URL . '?' . http_build_query($vars, '', '&'));
            $log = $this->logRequest($r, 'Datalink');
            $response = $r->send();
            $log->add($response);
            $log->toggleMask();
            $log->add([
                $last_run, $now_run
            ]);
            if (!$response) {
                $this->getDi()->logger->error('ccBill Datalink error: Unable to contact datalink server');
                continue;
            }
            $resp = $response->getBody();

            if (preg_match('/Error:(.+)/m', $resp, $regs)) {
                $e = $regs[1];
                $this->getDi()->logger->error('ccBill Datalink error: ' . $e);
                continue;
            }

            if ($resp == 1) {
                // Nothing to handle;
            } else {
                foreach (preg_split('/[\r\n]+/', $resp) as $line_orig) {
                    $line = trim($line_orig);

                    if (!strlen($line))
                        continue;

                    $line = preg_split('/,/', $line);
                    foreach ($line as $k => $v)
                        $line[$k] = preg_replace('/^\s*"(.+?)"\s*$/', '\1', $v);

                    $public_id = $line[3];
                    $invoice = $this->getDi()->invoiceTable->findByReceiptIdAndPlugin($line[3], $this->getId());
                    if (!$invoice) {
                        // In case of free trial there is no payment. So try to find invoice by external_id

                        $invoice = $this->getDi()->invoiceTable->findFirstByData('external_id', $line[3]);
                        if (!$invoice || ($invoice->paysys_id != $this->getId())) {
                            $this->getDi()->logger->error('ccBill Datalink error: unable to find invoice for this record:  ' . $line_orig);
                            continue;
                        }
                    }
// "REBILL","434344","0001","0312112601000035671","2012-05-21","0112142105000024275","5.98"
// "REBILL","545455","0001","0312112601000035867","2012-05-21","0112142105000024293","6.10"
                    $transaction = null;
                    switch ($line[0]) {
                        case 'EXPIRE':
                            $transaction = new Am_Paysystem_Transaction_Ccbill_Datalink_Expire($this, $line);
                            break;
                        case 'REFUND':
                        case 'CHARGEBACK':
                            $transaction = new Am_Paysystem_Transaction_Ccbill_Datalink_Refund($this, $line);
                            break;
                        case 'RENEW':
                        case 'REBILL':
                        case 'REBill':
                            $transaction = new Am_Paysystem_Transaction_Ccbill_Datalink_Rebill($this, $line);
                            break;
                        default:
                            $this->getDi()->logger->error('ccBill Datalink error: unknown record: ' . $line_orig);
                    }
                    if (is_null($transaction))
                        continue;

                    $transaction->setInvoice($invoice);
                    try {
                        $transaction->process();
                    } catch (Am_Exception $e) {
                        $this->getDi()->errorLogTable->logException($e);
                        $this->getDi()->logger->error(sprintf('ccBill Datalink Error: %s while handling line: %s', $e->getMessage(), $line_orig));
                    }
                }
            }
            $this->getDi()->store->set(self::CCBILL_LAST_RUN.'-'.$subaccount, $now_run);
        }
    }

    function sendTest()
    {
        $last_run = $this->getDi()->store->get(self::CCBILL_LAST_RUN);
        if (!$last_run || ($last_run < 19700101033324 ))
            $last_run = gmdate('YmdHis', time() - 15 * 3600 * 24 + CCBILL_TIME_OFFSET);
        $now_run = gmdate('YmdHis', time() + CCBILL_TIME_OFFSET);
        $last_run_tm = strtotime($this->timeToSQL($last_run));
        $now_run_tm = strtotime($this->timeToSQL($now_run));
        if (($now_run_tm - $last_run_tm) > 3600 * 24)
            $now_run_tm = $last_run_tm + 3600 * 24;
        $now_run = date('YmdHis', $now_run_tm);
        $vars = [
            'startTime' => $last_run,
            'endTime' => $now_run,
            'transactionTypes' => 'REBILL,REFUND,EXPIRE,CHARGEBACK',
            'clientAccnum' => $this->getConfig('account'),
            'clientSubacc' => $this->getConfig('subaccount_id'),
            'username' => $this->getConfig('datalink_user'),
            'password' => $this->getConfig('datalink_pass')
        ];
        $r = new Am_HttpRequest($requestString = self::DATALINK_URL . '?' . http_build_query($vars, '', '&'));
        $response = $r->send();
        //global problems with connection
        if (!$response) {
            return '<font color="red">ccBill Datalink error: Unable to contact datalink server</font>';
        }
        $resp = $response->getBody();
        $this->getDi()->logger->error(sprintf("ccBill Datalink debug (%s, %s):\n%s\n%s", $last_run, $now_run, $requestString, $resp));
        if (preg_match('/Error:(.+)/m', $resp, $regs)) {
            $e = $regs[1];
            //some useful instruction if error like 'authentication error'
            if (preg_match('/auth/i',$e)) {
                $r_ip = new Am_HttpRequest('https://www.amember.com/get_ip.php');
                $ip = $r_ip->send();
                return '<font color="red">ccBill Datalink error: ' . $e.'</font><br><br>
                    Usually it happens because ccBill has wrongly <br>
                    configured your server IP address.<br><br>
                    IP of your webserver is:'.$ip->getBody().'<br><br>
                    Please copy it down, contact ccBill support <br>
                    and provide them with this IP as a correct IP for your website.<br>
                    Once ccBill reports everything is fixed<br>
                    click on the link again and make sure the change was actually applied.';
            } else {
                return '<font color="red">ccBill Datalink error: ' . $e . '</font>';
            }
        }
    }

    public function debugAction(Am_Mvc_Request $request, Am_Mvc_Response $response, array $invokeArgs)
    {
        //requires admin to use this tool
        $admin = $this->getDi()->authAdmin->getUser();
        if (!$admin)
            return;
        //plugin is not configured
        if (!$this->getConfig('datalink_user') || !$this->getConfig('datalink_pass')) {
            $response->ajaxResponse(['ok' => false, 'msg' => '<font color="red">ccBill plugin error: Datalink is not configured!</font>']);
            return;
        }
        $error = $this->sendTest();
        if ($request->isXmlHttpRequest()) {
            if (empty($error)) {
                $response->ajaxResponse(['ok' => true]);
            } else {
                $response->ajaxResponse(['ok' => false, 'msg' => $error]);
            }
        } else {
            echo $error;
        }
    }

    /**
     * @param Invoice $invoice
     * @param callable $getParams => function($subaccountId, $subscriptionId): Array
     * @throws Am_Exception_InputError
     * @throws Am_Exception_InternalError
     * @throws HTTP_Request2_Exception
     */
    function smsApiRequest(Invoice $invoice, callable $getParams)
    {
        if (!$this->getConfig('datalink_user') || !$this->getConfig('datalink_pass')) {
            throw new Am_Exception_InputError("ccBill plugin error: Datalink is not configured!");
        }
        $payments = $invoice->getPaymentRecords();

        $subscriptionId = $payments[0]->transaction_id;

        $subaccount_id = $invoice->getItem(0)->getBillingPlanData("ccbill_subaccount_id")
            ? $invoice->getItem(0)->getBillingPlanData("ccbill_subaccount_id")
            : $this->getConfig('subaccount_id');

        $vars = $getParams($subaccount_id, $subscriptionId);

        $r = new Am_HttpRequest(self::DATALINK_SUBSCR_MANAGEMENT . '?' . http_build_query($vars, '', '&'));

        $log = $this->logOther("SMS Request", $r);
        $log->setInvoice($invoice);

        $response = $r->send();
        if (!$response) {
            throw new Am_Exception_InternalError('ccBill Subscription Management error: Unable to contact datalink server');
        }
        $log->add($response);
        $resp = $response->getBody();

        $xml = simplexml_load_string($resp);

        if ((string)$xml != "1")
            throw new Am_Exception_InternalError('ccBill Subscription Management error: Incorrect response!');

        return json_decode(json_encode($xml), true);
    }

    function getDiscount(Invoice $invoice)
    {
        try {
            $ret = $this->smsApiRequest($invoice, function($subaccountId, $subscriptionId) {
                return [
                    'clientAccnum' => $this->getConfig('account'),
                    'clientSubacc' => $subaccountId,
                    'usingSubacc' => $subaccountId,
                    'returnXML' => 1,
                    'action' => 'viewDiscountInfo',
                    'subscriptionId' => $subscriptionId,
                    'username' => $this->getConfig('datalink_user'),
                    'password' => $this->getConfig('datalink_pass')
                ];
            });
        } catch (Exception $ex){
            return false;
        }

        if (!empty($ret['discountInfo'])) {
            $data = empty($ret['discountInfo'][0]) ? [$ret['discountInfo']] : $ret['discountInfo'];
            foreach ($data as $discount) {
                if ($discount['type'] == 'CANCEL') {
                    return $discount;
                }
            }
        }
        return false;
    }

    function applyDiscount(Invoice $invoice)
    {
        $this->smsApiRequest($invoice, function($subaccountId, $subscriptionId) {
            return [
                'clientAccnum' => $this->getConfig('account'),
                'clientSubacc' => $subaccountId,
                'usingSubacc' => $subaccountId,
                'returnXML' => 1,
                'action' => 'applyDiscount',
                'discountType' => 'cancel',
                'username' => $this->getConfig('datalink_user'),
                'password' => $this->getConfig('datalink_pass')
            ];
        });
    }

    public function directAction($request, $response, array $invokeArgs)
    {
        $actionName = $request->getActionName();
        if ($actionName == 'debug') {
            $this->debugAction($request, $response, $invokeArgs);
        } elseif ($actionName == 'cancel') {
            if ($id = $request->getFiltered('customVar1')) {
                if ($invoice = $this->getDi()->invoiceTable->findFirstBy(['public_id' => $id]))
                    return $response->setRedirect($this->getDi()->surl("cancel", ['id' => $invoice->getSecureId("CANCEL")], false));
            } elseif ($this->getConfig('cancel_discounts')) {
                $invoice = $this->getDi()->invoiceTable->findBySecureId($request->getParam('id'), 'STOP' . $this->getId());
                if ($invoice && (($discount = $this->getDiscount($invoice)) !== false)) {
                    if ($request->getParam('r')) {
                        if ($request->getParam('r') == 'yes') {
                            $this->applyDiscount($invoice);
                            return $response->setRedirect($this->getDi()->surl('member'));
                        }
                    } else {
                        $view = $this->getDi()->view;
                        $view->title = ___('Cancel Subscripton');
                        $offer = new Am_SimpleTemplate();
                        $offer->assign('amount', $discount['amount']);
                        $txt = $offer->render($this->discountOffer());
                        $yesUrl = $this->getPluginUrl('cancel', ['id' => $request->getParam('id'), 'r' => 'yes']);
                        $noUrl = $this->getPluginUrl('cancel', ['id' => $request->getParam('id'), 'r' => 'no']);
                        $view->content = <<<CUT
<div class="am-ccbill-discount-offer">
{$txt}
</div>
<div style="display:flex; justify-content: center; width:100%; gap: 2em; padding-top: 2em; align-items: center">
    <div><button onClick="document.location.href='{$yesUrl}'">YES, I would like to apply discount!</button></div>
    <div><a href="{$noUrl}">NO, Please cancel subscription!</a></div>
</div>
CUT;
                        $view->display('member/layout.phtml');
                        return;
                    }
                }
                parent::directAction($request, $response, $invokeArgs);
            } else {
                parent::directAction($request, $response, $invokeArgs);
            }
        } else {
            parent::directAction($request, $response, $invokeArgs);
        }
    }
}

class Am_Paysystem_Transaction_Ccbill_Upgrade extends Am_Paysystem_Transaction_Abstract
{
    protected $xml;

    function __construct(Am_Paysystem_Abstract $plugin, $xml)
    {
        parent::__construct($plugin);
        $this->xml = $xml;
    }

    public function getUniqId()
    {
        return (string)$this->xml->subscriptionId;
    }
}

class Am_Paysystem_Transaction_Ccbill_datalink extends Am_Paysystem_Transaction_Abstract
{
    protected $vars;

    function __construct(Am_Paysystem_Abstract $plugin, $vars)
    {
        parent::__construct($plugin);
        $this->vars = $vars;
    }

    public function getAmount()
    {
        return $this->vars[6];
    }

    public function getUniqId()
    {
        return $this->vars[5];
    }
}

class Am_Paysystem_Transaction_Ccbill_Datalink_Rebill extends Am_Paysystem_Transaction_Ccbill_datalink
{
    public function processValidated()
    {
        $this->invoice->addPayment($this);
    }
}

class Am_Paysystem_Transaction_Ccbill_Datalink_Refund extends Am_Paysystem_Transaction_Ccbill_datalink
{
    public function getUniqId()
    {
        return $this->vars[3] . '-RFND';
    }

    public function getAmount()
    {
        return $this->vars[5];
    }

    public function processValidated()
    {
        $this->invoice->addRefund($this, $this->vars[3]);
    }
}

class Am_Paysystem_Transaction_Ccbill_Datalink_Expire extends Am_Paysystem_Transaction_Ccbill_datalink
{
    function processValidated()
    {
        $this->invoice->stopAccess($this);
    }
}

class Am_Paysystem_Transaction_Ccbill extends Am_Paysystem_Transaction_Incoming
{
    protected $_autoCreateMap = [
        'login' => 'username',
        'pass' => 'password',
        'name_f' => ['customer_fname',  'firstName'],
        'name_l' => ['customer_lname', 'lastName'],
        'country' => 'country',
        'state' => 'state',
        'email' => 'email',
        'city' => 'city',
        'street' => 'address1',
        'user_external_id' => 'email',
        'invoice_external_id' => ['originalSubscriptionId', 'subscription_id', 'subscriptionId'],
    ];

    public function autoCreateGetProducts()
    {
        $cbId = $this->request->getFiltered('productId') ? $this->request->getFiltered('productId') : intval($this->request->getFiltered('typeId'));
        if (empty($cbId))
            $cbId = $this->request->getFiltered('subscriptionTypeId');

        if (empty($cbId)) return;

        $pl = $this->getPlugin()->getDi()->billingPlanTable->findFirstByData('ccbill_product_id', $cbId);
        if (!$pl) return;
        $pr = $pl->getProduct();
        if (!$pr) return;
        return [$pr];
    }

    public function findInvoiceId()
    {
        return $this->request->get('payment_id');
    }

    public function getUniqId()
    {
        return $this->request->get('subscription_id', $this->request->get('subscriptionId'));
    }

    public function validateSource()
    {
        if ($this->request->get('clientAccnum') != $this->getPlugin()->getConfig('account'))
            throw new Am_Exception_Paysystem_TransactionSource(sprintf('Incorrect CCBILL account number: [%s] instead of [%s]', $this->request->get('clientAccnum'), $this->getPlugin()->getConfig('account')));

        if ($host = gethostbyaddr($addr = $this->request->getClientIp())) {
            if (!strlen($host) || $addr == $host) {
                //   ccbill_error("Cannot resolve host: ($addr=$host)\n");
                // let is go, as some hosts are just unable to resolve names
            } elseif (!preg_match('/ccbill\.com$/', $host)) {
                throw new Am_Exception_Paysystem_TransactionSource("POST is not from ccbill.com, it is from ($addr=$host)\n");
            }
        }
        return true;
    }

    public function validateStatus()
    {
        if (strlen($this->request->get('reasonForDecline')) > 0)
            return false;
        return true;
    }

    public function validateTerms()
    {
        if ($this->getPlugin()->getConfig('dynamic_pricing')) return true;
        if ($this->invoice->getItem(0)->getBillingPlanData("ccbill_flexform_id") || $this->getPlugin()->getConfig('flexform_id')) return true;
        if (intval($this->invoice->getItem(0)->getBillingPlanData("ccbill_product_id")) != intval($this->request->get('typeId'))) {
            throw new Am_Exception_Paysystem_TransactionInvalid(
                sprintf(
                    "Product ID doesn't match: %s and %s",
                    intval($this->invoice->getItem(0)->getBillingPlanData("ccbill_product_id")),
                    intval($this->request->get('typeId'))
                )
            );
        }

        return true;
    }

    public function processValidated()
    {
        if (!count($this->invoice->getAccessRecords()) && (floatval($this->invoice->first_total) == 0)) {
            if (
                !$this->invoice->data()->get('external_id')
                && $this->request->get('subscription_id', $this->request->get('subscriptionId'))
            ) {
                $this->invoice
                    ->data()
                    ->set('external_id', $this->request->get('subscription_id', $this->request->get('subscriptionId')))
                    ->update();
            }
            $this->invoice->addAccessPeriod($this);
        } else {
            $this->invoice->addPayment($this);
        }

        foreach([
            'country' => 'country',
            'state' => 'state',
            'city' => 'city',
            'street' => 'address1'
                ] as $k=> $v)
        {
            if (
                !$this->invoice->getUser()->get($k)
                && $this->request->get($v)
            ) {
                $this->invoice->getUser()->set($k, $this->request->get($v));
            }
        }
        $this->invoice->getUser()->update();
    }
}

class Am_Paysystem_Transaction_Ccbill_Thanks extends Am_Paysystem_Transaction_Incoming_Thanks
{
    public function process()
    {
        //redirect to thanks page only
        $this->invoice = $this->loadInvoice($this->request->get('customVar1'));
    }

    public function getUniqId()
    {
    }

    public function validateSource()
    {
    }

    public function validateStatus()
    {
    }

    public function validateTerms()
    {
    }
}
