Commit a58e2452 authored by mattwire's avatar mattwire

Implement IPN/Webhooks

parent 4128a82d
......@@ -7,15 +7,7 @@ use \JohnConde\Authnet\AuthnetApiFactory as AuthnetApiFactory;
class CRM_AuthorizeNet_Webhook {
use CRM_AuthorizeNet_WebhookTrait;
/**
* Get the constant for test/live mode when using JohnConde\Authnet library
*
* @return int
*/
protected function getIsTestMode() {
return isset($this->_paymentProcessor['is_test']) && $this->_paymentProcessor['is_test'] ? AuthnetApiFactory::USE_DEVELOPMENT_SERVER : AuthnetApiFactory::USE_PRODUCTION_SERVER;
}
use CRM_Core_Payment_AuthorizeNetTrait;
/**
* CRM_AuthorizeNet_Webhook constructor.
......@@ -26,20 +18,76 @@ class CRM_AuthorizeNet_Webhook {
$this->_paymentProcessor = $paymentProcessor;
}
/**
* Get a request handler for authnet webhooks
*
* @return \JohnConde\Authnet\AuthnetWebhooksRequest
* @throws \ErrorException
* @throws \JohnConde\Authnet\AuthnetInvalidCredentialsException
* @throws \JohnConde\Authnet\AuthnetInvalidServerException
*/
public function getRequest() {
return AuthnetApiFactory::getWebhooksHandler(
CRM_Core_Payment_AuthorizeNetCommon::getApiLoginId($this->_paymentProcessor),
CRM_Core_Payment_AuthorizeNetCommon::getTransactionKey($this->_paymentProcessor),
$this->getIsTestMode());
$this->getIsTestMode() ? AuthnetApiFactory::USE_DEVELOPMENT_SERVER : AuthnetApiFactory::USE_PRODUCTION_SERVER);
}
/**
* Get a list of configured webhooks
*
* @return \JohnConde\Authnet\AuthnetWebhooksResponse
* @throws \ErrorException
* @throws \JohnConde\Authnet\AuthnetCurlException
* @throws \JohnConde\Authnet\AuthnetInvalidCredentialsException
* @throws \JohnConde\Authnet\AuthnetInvalidJsonException
* @throws \JohnConde\Authnet\AuthnetInvalidServerException
*/
public function getWebhooks() {
$request = $this->getRequest();
return $request->getWebhooks();
}
/**
* Create a new webhook
*
* @throws \ErrorException
* @throws \JohnConde\Authnet\AuthnetCurlException
* @throws \JohnConde\Authnet\AuthnetInvalidCredentialsException
* @throws \JohnConde\Authnet\AuthnetInvalidJsonException
* @throws \JohnConde\Authnet\AuthnetInvalidServerException
*/
public function createWebhook() {
$request = $this->getRequest();
$request->createWebhooks(self::getDefaultEnabledEvents(), self::getWebhookPath(TRUE, $this->_paymentProcessor['id']), 'active');
$request->createWebhooks(self::getDefaultEnabledEvents(), self::getWebhookPath($this->_paymentProcessor['id']), 'active');
}
/**
* Check and update existing webhook
*
* @param array $webhook
*/
/**
* @param \JohnConde\Authnet\AuthnetWebhooksResponse $webhook
*
* @throws \ErrorException
* @throws \JohnConde\Authnet\AuthnetCurlException
* @throws \JohnConde\Authnet\AuthnetInvalidCredentialsException
* @throws \JohnConde\Authnet\AuthnetInvalidJsonException
* @throws \JohnConde\Authnet\AuthnetInvalidServerException
*/
public function checkAndUpdateWebhook($webhook) {
$update = FALSE;
if ($webhook->getStatus() !== 'active') {
$update = TRUE;
}
if (array_diff(self::getDefaultEnabledEvents(), $webhook->getEventTypes())) {
$update = TRUE;
}
if ($update) {
$request = $this->getRequest();
$request->updateWebhook([$webhook->getWebhooksId()], self::getWebhookPath($this->_paymentProcessor['id']), self::getDefaultEnabledEvents(),'active');
}
}
/**
......@@ -48,6 +96,14 @@ class CRM_AuthorizeNet_Webhook {
* for now, avoid having false alerts that will annoy people).
*
* @see hook_civicrm_check()
*
* @return array
* @throws \CiviCRM_API3_Exception
* @throws \ErrorException
* @throws \JohnConde\Authnet\AuthnetCurlException
* @throws \JohnConde\Authnet\AuthnetInvalidCredentialsException
* @throws \JohnConde\Authnet\AuthnetInvalidJsonException
* @throws \JohnConde\Authnet\AuthnetInvalidServerException
*/
public static function check() {
$checkMessage = [
......@@ -60,10 +116,11 @@ class CRM_AuthorizeNet_Webhook {
]);
foreach ($result['values'] as $paymentProcessor) {
$webhook_path = self::getWebhookPath(TRUE, $paymentProcessor['id']);
$webhook_path = self::getWebhookPath($paymentProcessor['id']);
try {
$webhookHandler = new CRM_AuthorizeNet_Webhook($paymentProcessor);
/** @var JohnConde\Authnet\AuthnetWebhooksResponse $webhooks */
$webhooks = $webhookHandler->getWebhooks();
}
catch (Exception $e) {
......@@ -83,18 +140,18 @@ class CRM_AuthorizeNet_Webhook {
continue;
}
$found_wh = FALSE;
foreach ($webhooks->data as $wh) {
if ($wh->url == $webhook_path) {
$found_wh = TRUE;
$foundWebhook = FALSE;
foreach ($webhooks->getWebhooks() as $webhook) {
if ($webhook->getURL() == $webhook_path) {
$foundWebhook = TRUE;
// Check and update webhook
$webhookHandler->checkAndUpdateWebhook($wh);
$webhookHandler->checkAndUpdateWebhook($webhook);
}
}
if (!$found_wh) {
if (!$foundWebhook) {
try {
$webhookHandler->createWebhook($paymentProcessor['id']);
$webhookHandler->createWebhook();
}
catch (Exception $e) {
$messages[] = new CRM_Utils_Check_Message(
......@@ -123,13 +180,22 @@ class CRM_AuthorizeNet_Webhook {
* @return array
*/
public static function getDefaultEnabledEvents() {
// See https://developer.authorize.net/api/reference/features/webhooks.html#Event_Types_and_Payloads
return [
'net.authorize.payment.authorization.created',
'net.authorize.payment.capture.created',
'net.authorize.payment.authcapture.created',
'net.authorize.payment.priorAuthCapture.created',
'net.authorize.payment.refund.created',
'net.authorize.payment.void.created'
'net.authorize.payment.authcapture.created', // Notifies you that an authorization and capture transaction was created.
'net.authorize.payment.refund.created', // Notifies you that a successfully settled transaction was refunded.
'net.authorize.payment.void.created', // Notifies you that an unsettled transaction was voided.
//'net.authorize.customer.subscription.created', // Notifies you that a subscription was created.
//'net.authorize.customer.subscription.updated', // Notifies you that a subscription was updated.
//'net.authorize.customer.subscription.suspended',// Notifies you that a subscription was suspended.
'net.authorize.customer.subscription.terminated',// Notifies you that a subscription was terminated.
'net.authorize.customer.subscription.cancelled', // Notifies you that a subscription was cancelled.
//'net.authorize.customer.subscription.expiring', // Notifies you when a subscription has only one recurrence left to be charged.
'net.authorize.payment.fraud.held', // Notifies you that a transaction was held as suspicious.
'net.authorize.payment.fraud.approved', // Notifies you that a previously held transaction was approved.
'net.authorize.payment.fraud.declined', // Notifies you that a previously held transaction was declined.
];
}
......
......@@ -18,17 +18,17 @@ trait CRM_AuthorizeNet_WebhookTrait {
*
* @return string
*/
public static function getWebhookPath($includeBaseUrl = TRUE, $paymentProcessorId = 'NN') {
public static function getWebhookPath($paymentProcessorId = 'NN') {
// Assuming frontend URL because that's how the function behaved before.
// @fixme this doesn't return the right webhook path on Wordpress (often includes an extra path between .com and ? eg. abc.com/xxx/?page=CiviCRM
// We can't use CRM_Utils_System::url('civicrm/payment/ipn/' . $paymentProcessorId, NULL, $includeBaseUrl, NULL, FALSE, TRUE);
// because it returns the query string urlencoded and the base URL non urlencoded so we can't use to match existing webhook URLs
$UFWebhookPaths = [
/*$UFWebhookPaths = [
"Drupal" => "civicrm/payment/ipn/{$paymentProcessorId}",
"Joomla" => "?option=com_civicrm&task=civicrm/payment/ipn/{$paymentProcessorId}",
"WordPress" => "?page=CiviCRM&q=civicrm/payment/ipn/{$paymentProcessorId}"
];
];*/
$basePage = '';
$config = CRM_Core_Config::singleton();
......@@ -37,10 +37,8 @@ trait CRM_AuthorizeNet_WebhookTrait {
$basePage = (substr($config->wpBasePage, -1) == '/') ? $config->wpBasePage : "$config->wpBasePage/";
}
// Use Drupal path as default if the UF isn't in the map above
$UFWebhookPath = (array_key_exists(CIVICRM_UF, $UFWebhookPaths)) ? $UFWebhookPaths[CIVICRM_UF] : $UFWebhookPaths['Drupal'];
if ($includeBaseUrl) {
return CRM_Utils_System::baseURL() . $basePage . $UFWebhookPath;
}
//$UFWebhookPath = (array_key_exists(CIVICRM_UF, $UFWebhookPaths)) ? $UFWebhookPaths[CIVICRM_UF] : $UFWebhookPaths['Drupal'];
$UFWebhookPath = CRM_Utils_System::url('civicrm/payment/ipn/' . $paymentProcessorId, NULL, TRUE, NULL, FALSE, TRUE);
return $UFWebhookPath;
}
......
<?php
/*
* @file
* Handle Twocheckout Webhooks for recurring payments.
/**
* https://civicrm.org/licensing
*/
use CRM_AuthNetEcheck_ExtensionUtil as E;
use \JohnConde\Authnet\AuthnetWebhook as AuthnetWebhook;
use \JohnConde\Authnet\AuthnetApiFactory as AuthnetApiFactory;
use \JohnConde\Authnet\AuthnetWebhooksResponse as AuthnetWebhooksResponse;
class CRM_Core_Payment_AuthNetIPN extends CRM_Core_Payment_BaseIPN {
use CRM_Core_Payment_AuthNetIPNTrait;
use CRM_Core_Payment_AuthorizeNetTrait;
use CRM_Core_Payment_AuthNetIPNTrait;
/**
* Authorize.net webhook transaction ID
......@@ -19,17 +21,22 @@ class CRM_Core_Payment_AuthNetIPN extends CRM_Core_Payment_BaseIPN {
*/
private $trxnId;
/**
* Get the transaction ID
* @return string
*/
private function getTransactionId() {
return $this->trxnId;
}
/**
* CRM_Core_Payment_TwocheckoutIPN constructor.
* CRM_Core_Payment_AuthNetIPN constructor.
*
* @param $ipnData
* @param bool $verify
* @param string $ipnData
* @param array $headers
*
* @throws \CRM_Core_Exception
* @throws \JohnConde\Authnet\AuthnetInvalidCredentialsException
* @throws \JohnConde\Authnet\AuthnetInvalidJsonException
*/
public function __construct($ipnData, $headers) {
$this->_params = $ipnData;
......@@ -44,84 +51,182 @@ class CRM_Core_Payment_AuthNetIPN extends CRM_Core_Payment_BaseIPN {
}
/**
* @throws \CRM_Core_Exception
* @return bool
* @throws \CiviCRM_API3_Exception
* @throws \ErrorException
* @throws \JohnConde\Authnet\AuthnetInvalidCredentialsException
* @throws \JohnConde\Authnet\AuthnetInvalidServerException
*/
public function main() {
// Here you can get more information about the transaction
$request = AuthnetApiFactory::getJsonApiHandler(CRM_Core_Payment_AuthorizeNetCommon::getApiLoginId($this->_paymentProcessor), CRM_Core_Payment_AuthorizeNetCommon::getTransactionKey($this->_paymentProcessor));
$request = AuthnetApiFactory::getJsonApiHandler(
CRM_Core_Payment_AuthorizeNetCommon::getApiLoginId($this->_paymentProcessor),
CRM_Core_Payment_AuthorizeNetCommon::getTransactionKey($this->_paymentProcessor),
$this->getIsTestMode() ? AuthnetApiFactory::USE_DEVELOPMENT_SERVER : AuthnetApiFactory::USE_PRODUCTION_SERVER
);
/** @var AuthnetWebhooksResponse $response */
$response = $request->getTransactionDetailsRequest(['transId' => $this->getTransactionId()]);
/* You can put these response values in the database or whatever your business logic dictates.
$response-&gt;transaction-&gt;transactionType
$response-&gt;transaction-&gt;transactionStatus
$response-&gt;transaction-&gt;authCode
$response-&gt;transaction-&gt;AVSResponse
*/
$verify = Twocheckout_Notification::check($this->_params, $this->getSecretWord());
if ($verify['response_code'] !== 'Success') {
$this->handleError($verify['response_code'], $verify['response_message']);
return FALSE;
if ($response->messages->resultCode !== 'Ok') {
$this->exception('Bad response from getTransactionDetailsRequest in IPN handler');
}
// We need a contribution ID - from the transactionID (invoice ID)
try {
// Same approach as api repeattransaction.
$contribution = civicrm_api3('contribution', 'getsingle', [
'return' => ['id', 'contribution_status_id', 'total_amount', 'trxn_id'],
'contribution_test' => $this->getIsTestMode(),
'options' => ['limit' => 1, 'sort' => 'id DESC'],
'trxn_id' => $this->getParam('invoice_id'),
]);
$contributionId = $contribution['id'];
// Set parameters required for IPN functions
if ($this->getParamFromResponse($response, 'is_recur')) {
$this->contribution_recur_id = $this->getRecurringContributionIDFromSubscriptionID($this->getParamFromResponse($response, 'subscription_id'));
}
catch (Exception $e) {
$this->exception('Cannot find any contributions with invoice ID: ' . $this->getParam('invoice_id') . '. ' . $e->getMessage());
}
// See https://www.2checkout.com/documentation/notifications
switch ($this->getParam('message_type')) {
case 'FRAUD_STATUS_CHANGED':
switch ($this->getParam('fraud_status')) {
case 'pass':
// Do something when sale passes fraud review.
// The last one was not completed, so complete it.
civicrm_api3('Contribution', 'completetransaction', array(
'id' => $contributionId,
'payment_processor_id' => $this->_paymentProcessor['id'],
'is_email_receipt' => $this->getSendEmailReceipt(),
));
break;
case 'fail':
// Do something when sale fails fraud review.
$this->failtransaction([
'id' => $contributionId,
'payment_processor_id' => $this->_paymentProcessor['id']
]);
break;
case 'wait':
// Do something when sale requires additional fraud review.
// Do nothing, we'll remain in Pending.
break;
$this->event_type = $response->transaction->transactionType;
// Process the event
switch ($response->transaction->transactionType) {
case 'net.authorize.payment.authcapture.created':
// Notifies you that an authorization and capture transaction was created.
if ($this->getParamFromResponse($response, 'is_recur')) {
$params = [
'contribution_id' => $this->getContributionIDFromInvoiceID($this->getParamFromResponse($response, 'invoice_id')),
'contribution_recur_id' => $this->contribution_recur_id,
'payment_processor_transaction_id' => $this->getParamFromResponse($response, 'transaction_id'),
];
$this->recordCompleted($params);
}
else {
$params = [
'contribution_id' => $this->getContributionIDFromInvoiceID($this->getParamFromResponse($response, 'invoice_id')),
];
}
$this->recordCompleted($params);
break;
case 'REFUND_ISSUED':
// To be implemented
case 'net.authorize.payment.refund.created':
// Notifies you that a successfully settled transaction was refunded.
$params = [
'contribution_id' => $this->getContributionIDFromInvoiceID($this->getParamFromResponse($response, 'invoice_id')),
'total_amount' => $this->getParamFromResponse($response, 'refund_amount'),
];
$this->recordRefund($params);
break;
case 'net.authorize.payment.void.created':
// Notifies you that an unsettled transaction was voided.
$params = [
'contribution_id' => $this->getContributionIDFromInvoiceID($this->getParamFromResponse($response, 'invoice_id')),
];
$this->recordCancelled($params);
break;
case 'net.authorize.payment.fraud.held':
// Notifies you that a transaction was held as suspicious.
$params = [
'contribution_id' => $this->getContributionIDFromInvoiceID($this->getParamFromResponse($response, 'invoice_id')),
];
$this->recordPending($params);
break;
case 'net.authorize.payment.fraud.approved':
// Notifies you that a previously held transaction was approved.
$params = [
'contribution_id' => $this->getContributionIDFromInvoiceID($this->getParamFromResponse($response, 'invoice_id')),
];
$this->recordCompleted($params);
break;
case 'net.authorize.payment.fraud.declined':
// Notifies you that a previously held transaction was declined.
$params = [
'contribution_id' => $this->getContributionIDFromInvoiceID($this->getParamFromResponse($response, 'invoice_id')),
];
$this->recordFailed($params);
break;
// Now the "subscription" (recurring) ones
// case 'net.authorize.customer.subscription.created':
// Notifies you that a subscription was created.
// case 'net.authorize.customer.subscription.updated':
// Notifies you that a subscription was updated.
// case 'net.authorize.customer.subscription.suspended':
// Notifies you that a subscription was suspended.
case 'net.authorize.customer.subscription.terminated':
// Notifies you that a subscription was terminated.
case 'net.authorize.customer.subscription.cancelled':
// Notifies you that a subscription was cancelled.
$this->recordSubscriptionCancelled();
break;
// case 'net.authorize.customer.subscription.expiring':
// Notifies you when a subscription has only one recurrence left to be charged.
}
// Unhandled event type.
return TRUE;
}
public function exception($message) {
$errorMessage = $this->getPaymentProcessorLabel() . ' Exception: Event: ' . $this->event_type . ' Error: ' . $message;
Civi::log()->debug($errorMessage);
http_response_code(400);
exit(1);
/**
* Retrieve parameters from IPN response
*
* @param AuthnetWebhooksResponse $response
* @param string $param
*
* @return mixed
*/
protected function getParamFromResponse($response, $param) {
switch ($param) {
case 'transaction_id':
return $response->transaction->transId;
case 'invoice_id':
return $response->transaction->order->invoiceNumber;
case 'refund_amount':
// @todo: Check that this is the correct parameter?
return $response->transaction->refundAmount;
case 'is_recur':
return $response->transaction->recurringBilling;
case 'subscription_id':
return $response->transaction->subscription->id;
case 'subscription_payment_number':
return $response->transaction->subscription->payNum;
}
}
/**
* Get the contribution ID from the paymentprocessor invoiceID.
* For AuthorizeNet we save the 20character invoice ID into the contribution trxn_id
*
* @param string $invoiceID
*
* @return int
* @throws \CiviCRM_API3_Exception
*/
protected function getContributionIDFromInvoiceID($invoiceID) {
$contribution = civicrm_api3('Contribution', 'get', [
'trxn_id' => ['LIKE' => "{$invoiceID}%"],
'options' => ['limit' => 1, 'sort' => "id DESC"],
]);
if (empty($contribution['id'])) {
$this->exception("Could not find matching contribution for invoice ID: {$invoiceID}");
}
return $contribution['id'];
}
/**
* @param $subscriptionID
*
* @return int
* @throws \CiviCRM_API3_Exception
*/
protected function getRecurringContributionIDFromSubscriptionID($subscriptionID) {
$contributionRecur = civicrm_api3('ContributionRecur', 'get', [
'trxn_id' => $subscriptionID,
]);
if (empty($contributionRecur['id'])) {
$this->exception("Could not find matching contribution for invoice ID: {$subscriptionID}");
}
return $contributionRecur['id'];
}
}
<?php
/**
* https://civicrm.org/licensing
*/
/**
* Shared payment IPN functions that should one day be migrated to CiviCRM core
*/
trait CRM_Core_Payment_AuthNetIPNTrait {
/**********************
* Version 20190524
* MJW_Core_Payment_IPNTrait: 20190707
* @requires MJW_Payment_Api: 20190707
*********************/
/**
......@@ -13,11 +18,6 @@ trait CRM_Core_Payment_AuthNetIPNTrait {
*/
private $_paymentProcessor;
/**
* @var array Params sent by IPN callback
*/
private $_params;
/**
* Do we send an email receipt for each contribution?
*
......@@ -25,6 +25,18 @@ trait CRM_Core_Payment_AuthNetIPNTrait {
*/
protected $is_email_receipt = NULL;
/**
* The recurring contribution ID associated with the transaction
* @var int
*/
protected $contribution_recur_id = NULL;
/**
* The IPN event type
* @var string
*/
protected $event_type = NULL;
/**
* Set the value of is_email_receipt to use when a new contribution is received for a recurring contribution
* If not set, we respect the value set on the ContributionRecur entity.
......@@ -85,6 +97,7 @@ trait CRM_Core_Payment_AuthNetIPNTrait {
}
/**
* @deprecated Use recordCancelled()
* Mark a contribution as cancelled and update related entities
*
* @param array $params [ 'id' -> contribution_id, 'payment_processor_id' -> payment_processor_id]
......@@ -97,6 +110,7 @@ trait CRM_Core_Payment_AuthNetIPNTrait {
}
/**
* @deprecated - Use recordFailed()
* Mark a contribution as failed and update related entities
*
* @param array $params [ 'id' -> contribution_id, 'payment_processor_id' -> payment_processor_id]
......@@ -109,6 +123,7 @@ trait CRM_Core_Payment_AuthNetIPNTrait {
}
/**
* @deprecated - Use recordXX methods
* Handler for failtransaction and canceltransaction - do not call directly
*
* @param array $params
......@@ -157,7 +172,171 @@ trait CRM_Core_Payment_AuthNetIPNTrait {
default:
throw new CiviCRM_API3_Exception('Unknown incomplete transaction type: ' . $mode);
}
}
protected function recordPending($params) {
// Nothing to do
// @todo Maybe in the future record things like the pending reason if a payment is temporarily held?
}
/**
* Record a completed (successful) contribution
* @param array $params
*
* @throws \CiviCRM_API3_Exception
*/
protected function recordCompleted($params) {
$description = 'recordCompleted';
$this->checkRequiredParams($description, ['contribution_id'], $params);
$contributionStatusID = CRM_Core_PseudoConstant::getKey('CRM_Contribute_BAO_Contribution', 'contribution_status_id', 'Completed');
if (!empty($params['contribution_recur_id'])) {
$this->recordRecur($params, $description, $contributionStatusID);
}
else {
$params['id'] = $params['contribution_id'];
$pendingStatusId = CRM_Core_PseudoConstant::getKey('CRM_Contribute_BAO_Contribution', 'contribution_status_id', 'Pending');
if (civicrm_api3('Contribution', 'getvalue', [
'return' => "contribution_status_id",
'id' => $params['id']
]) == $pendingStatusId) {
civicrm_api3('Contribution', 'completetransaction', $params);
}
}
}
/**
* Record a failed contribution
* @param array $params
*
* @throws \CiviCRM_API3_Exception
*/
protected function recordFailed($params) {
$description = 'recordFailed';
$this->checkRequiredParams($description, ['contribution_id'], $params);
$contributionStatusID = CRM_Core_PseudoConstant::getKey('CRM_Contribute_BAO_Contribution', 'contribution_status_id', 'Failed');