<?php
namespace XF\Payment;
use XF\Entity\PaymentProfile;
use XF\Entity\PurchaseRequest;
use XF\Mvc\Controller;
use XF\Purchasable\Purchase;
use function is_array;
class TwoCheckout extends AbstractProvider
{
public function getTitle()
{
return '2Checkout';
}
public function getApiEndpoint()
{
if (\XF::config('enableLivePayments'))
{
return 'https://2checkout.com/api';
}
else
{
return 'https://sandbox.2checkout.com/api';
}
}
public function verifyConfig(array &$options, &$errors = [])
{
if (!$options['account_number'] || !$options['secret_word'])
{
$errors[] = \XF::phrase('you_must_provide_account_number_and_secret_word_to_set_up_this_profile');
return false;
}
return true;
}
protected function getPaymentParams(PurchaseRequest $purchaseRequest, Purchase $purchase)
{
return [
'purchaseRequest' => $purchaseRequest,
'paymentProfile' => $purchase->paymentProfile,
'purchaser' => $purchase->purchaser,
'purchase' => $purchase,
'purchasableTypeId' => $purchase->purchasableTypeId,
'purchasableId' => $purchase->purchasableId,
'recurrence' => $purchase->recurring ? ($purchase->lengthAmount . ' ' . ucfirst($purchase->lengthUnit)) : false
];
}
public function initiatePayment(Controller $controller, PurchaseRequest $purchaseRequest, Purchase $purchase)
{
$viewParams = $this->getPaymentParams($purchaseRequest, $purchase);
return $controller->view('XF:Purchase\TwoCheckoutInitiate', 'payment_initiate_twocheckout', $viewParams);
}
public function processPayment(Controller $controller, PurchaseRequest $purchaseRequest, PaymentProfile $paymentProfile, Purchase $purchase)
{
$input = $controller->filter([
'sid' => 'str',
'key' => 'str',
'order_number' => 'str',
'credit_card_processed' => 'str',
'merchant_order_id' => 'str',
'demo' => 'str'
]);
$options = $paymentProfile->options;
$md5Hash = strtoupper(md5(
$options['secret_word'] .
$options['account_number'] .
($input['demo'] === 'Y' ? 1 : $input['order_number']) .
$purchaseRequest->cost_amount
));
if ($md5Hash !== $input['key'])
{
throw $controller->exception($controller->error('Could not verify 2Checkout hash.'));
}
if ($input['credit_card_processed'] === 'Y')
{
$state = new CallbackState();
$state->transactionId = $input['key'];
$state->paymentResult = CallbackState::PAYMENT_RECEIVED;
$state->purchaseRequest = $purchaseRequest;
$state->paymentProfile = $paymentProfile;
// If this is a recurring purchase, and an API username/password have been set in the profile
// then we need to grab the line item IDs and store them as the subscriber ID.
// At such a time that the user may wish to cancel their subscription, we can
// cancel the recurring payment for each line item.
if ($purchase->recurring && $options['api_username'] && $options['api_password'])
{
try
{
$client = \XF::app()->http()->client();
$saleResponse = $client->get($this->getApiEndpoint() . '/sales/detail_sale', [
'headers' => ['Accept' => 'application/json'],
'auth' => [$options['api_username'], $options['api_password']],
'query' => ['sale_id' => $input['order_number']]
]);
$sale = \GuzzleHttp\json_decode($saleResponse->getBody()->getContents(), true);
$lineItemIds = [];
if (isset($sale['sale']['invoices']) && is_array($sale['sale']['invoices']))
{
foreach ($sale['sale']['invoices'] AS $invoice)
{
foreach ($invoice['lineitems'] AS $lineItem)
{
$lineItemIds[] = $lineItem['lineitem_id'];
}
}
}
$purchaseRequest->provider_metadata = \GuzzleHttp\json_encode($lineItemIds);
$purchaseRequest->save(false);
}
catch (\GuzzleHttp\Exception\RequestException $e) {}
}
$this->completeTransaction($state);
$this->log($state);
}
return $controller->redirect($purchase->returnUrl, '');
}
public function renderCancellationTemplate(PurchaseRequest $purchaseRequest)
{
return $this->renderCancellationDefault($purchaseRequest);
}
public function processCancellation(Controller $controller, PurchaseRequest $purchaseRequest, PaymentProfile $paymentProfile)
{
$lineItemIds = \GuzzleHttp\json_decode($purchaseRequest->provider_metadata, true);
if (!$lineItemIds)
{
return $controller->error('This purchase cannot be cancelled.');
}
$hasError = false;
$options = $paymentProfile->options;
$client = \XF::app()->http()->client();
try
{
foreach ($lineItemIds AS $lineItemId)
{
$cancelResponse = $client->post($this->getApiEndpoint() . '/sales/stop_lineitem_recurring', [
'headers' => ['Accept' => 'application/json'],
'auth' => [$options['api_username'], $options['api_password']],
'form_params' => [
'vendor_id' => $options['account_number'],
'lineitem_id' => $lineItemId
]
]);
$cancellation = \GuzzleHttp\json_decode($cancelResponse->getBody()->getContents(), true);
if (!$cancellation || $cancellation['response_code'] != 'OK')
{
$hasError = true;
}
}
}
catch (\GuzzleHttp\Exception\RequestException $e) {}
if ($hasError)
{
throw $controller->exception($controller->error('This subscription cannot be cancelled. It may already be cancelled.'));
}
return $controller->redirect($controller->getDynamicRedirect(), '2Checkout subscription cancelled successfully');
}
public function setupCallback(\XF\Http\Request $request)
{
$state = new CallbackState();
$state->messageType = $request->filter('message_type', 'str');
$state->timestamp = $request->filter('timestamp', 'str');
$state->md5Hash = $request->filter('md5_hash', 'str');
$state->fraudStatus = $request->filter('fraud_status', 'str');
$state->recurring = $request->filter('recurring', 'bool');
$state->saleId = $request->filter('sale_id', 'str');
$state->invoiceId = $request->filter('invoice_id', 'str');
$state->itemCount = $request->filter('item_count', 'uint');
$i = 0;
$total = 0;
while ($i < $state->itemCount)
{
$i++;
$key = 'item_list_amount_' . $i;
$itemTotal = $request->filter($key, 'str');
$state->{$key} = $itemTotal;
$total += $itemTotal;
}
$state->currency = $request->filter('list_currency', 'str');
$state->cost = $total;
// Subscription messages are identical, including message ID, even for retries, so concatenate with a hash of the timestamp.
$state->transactionId = $request->filter('message_id', 'uint') . '_' . md5($state->timestamp);
$state->requestKey = $request->filter('vendor_order_id', 'str');
return $state;
}
public function validateCallback(CallbackState $state)
{
$purchaseRequest = $state->getPurchaseRequest();
if (!$state->requestKey || !$purchaseRequest)
{
$state->logType = 'error';
$state->logMessage = 'Notification does not contain a recognised purchase request.';
return false;
}
$paymentProfile = $state->getPaymentProfile();
$options = $paymentProfile->options;
$md5Hash = strtoupper(md5(
$state->saleId .
$options['account_number'] .
$state->invoiceId .
$options['secret_word']
));
if ($md5Hash !== $state->md5Hash)
{
$state->logType = 'error';
$state->logMessage = 'Could not verify 2Checkout hash.';
return false;
}
return true;
}
public function validateTransaction(CallbackState $state)
{
if (!$state->transactionId)
{
$state->logType = 'info';
$state->logMessage = 'No transaction ID. No action to take.';
return false;
}
return parent::validateTransaction($state);
}
public function validateCost(CallbackState $state)
{
$purchaseRequest = $state->getPurchaseRequest();
switch ($state->messageType)
{
case 'RECURRING_INSTALLMENT_SUCCESS':
$costValidated = (
$state->cost === $purchaseRequest->cost_amount
&& $state->currency === $purchaseRequest->cost_currency
);
if (!$costValidated)
{
$state->logType = 'error';
$state->logMessage = 'Invalid cost amount';
return false;
}
break;
}
return true;
}
public function getPaymentResult(CallbackState $state)
{
switch ($state->messageType)
{
case 'RECURRING_INSTALLMENT_SUCCESS':
$state->paymentResult = CallbackState::PAYMENT_RECEIVED;
break;
case 'FRAUD_STATUS_CHANGED':
if ($state->fraudStatus == 'pass')
{
$state->paymentResult = CallbackState::PAYMENT_REINSTATED;
}
else if ($state->fraudStatus == 'fail' || $state->fraudStatus == 'wait')
{
$state->paymentResult = CallbackState::PAYMENT_REVERSED;
}
break;
case 'REFUND_ISSUED':
$state->paymentResult = CallbackState::PAYMENT_REVERSED;
break;
case 'ORDER_CREATED':
if ($state->fraudStatus == 'fail' || $state->fraudStatus == 'wait')
{
$state->paymentResult = CallbackState::PAYMENT_REVERSED;
}
else
{
// We process the purchase on return from payment so ignore unless the fraud status indicates a problem.
}
break;
}
}
public function prepareLogData(CallbackState $state)
{
$state->logDetails = [
'_GET' => $_GET,
'_POST' => $_POST
];
}
public function supportsRecurring(PaymentProfile $paymentProfile, $unit, $amount, &$result = self::ERR_NO_RECURRING)
{
// The minimum unit for 2Checkout is 1 week.
if ($unit == 'day')
{
$result = self::ERR_INVALID_RECURRENCE;
return false;
}
return parent::supportsRecurring($paymentProfile, $unit, $amount, $result);
}
}