<?php /* +--------------------------------------------------------------------+ | Copyright CiviCRM LLC. All rights reserved. | | | | This work is published under the GNU AGPLv3 license with some | | permitted exceptions and without any warranty. For full license | | and copyright information, see https://civicrm.org/licensing | +--------------------------------------------------------------------+ */ use Civi\Api4\Contribution; use Civi\Payment\Exception\PaymentProcessorException; /** * Shared payment IPN functions that should one day be migrated to CiviCRM core * * Trait CRM_Core_Payment_MJWIPNTrait */ trait CRM_Core_Payment_MJWIPNTrait { /** * @var \CRM_Core_Payment Payment processor */ protected $_paymentProcessor; /** * Do we send an email receipt for each contribution? * * @var int|null */ protected $is_email_receipt = NULL; /** * The recurring contribution ID associated with the transaction * @var int */ protected $contribution_recur_id = NULL; /** * The recurring contribution detail (as retrieved by API4) * @var array */ protected $contributionRecur = []; /** * The IPN event type * @var string */ protected $eventType = NULL; /** * The IPN event ID * @var string */ protected $eventID = NULL; /** * Exit on exceptions (TRUE), or just throw them (FALSE). * * @var bool */ protected $exitOnException = TRUE; /** * Should we verify (re-retrieve) the object that we have been given. * Usually TRUE when an IPN request is received. * * @var bool */ protected $verifyData = TRUE; /** * The data provided by the IPN * * @var array|Object|string */ protected $data; /** * * Set the value of is_email_receipt to use when a new contribution is * received for a recurring contribution. If set to NULL, we use CiviCRM core * logic to determine if a receipt should be sent (typically the recurring * contribution setting). * * @param int|null|bool $sendReceipt The value of is_email_receipt */ public function setSendEmailReceipt($sendReceipt) { if (is_null($sendReceipt)) { // Let CiviCRM core decide whether to send a receipt. $this->is_email_receipt = NULL; } elseif ($sendReceipt) { // Explicitly turn on receipts. $this->is_email_receipt = 1; } else { // Explicitly turn off receipts. $this->is_email_receipt = 0; } } /** * Get the value of is_email_receipt to use when a new contribution is received. * If NULL we let CiviCRM core decide whether to send a receipt. * * See setSendEmalReceipt() for more details. * * @return int|null */ public function getSendEmailReceipt() { return $this->is_email_receipt; } /** * Set and initialise the paymentProcessor object * @param int $paymentProcessorID * * @throws \Civi\Payment\Exception\PaymentProcessorException */ public function setPaymentProcessor($paymentProcessorID) { try { $this->_paymentProcessor = \Civi\Payment\System::singleton()->getById($paymentProcessorID); } catch (Exception $e) { $this->exception('Failed to get payment processor'); } } /** * Get the payment processor object */ public function getPaymentProcessor() { return $this->_paymentProcessor; } /** * @param int $recurID * * @return void */ public function setContributionRecurID(int $recurID) { $this->contribution_recur_id = $recurID; } /** * @return int|null */ public function getContributionRecurID() { return $this->contribution_recur_id; } /** * @param bool $verify */ public function setVerifyData($verify) { $this->verifyData = $verify; } /** * @return bool */ public function getVerifyData() { return $this->verifyData; } /** * @param string $eventID */ public function setEventID($eventID) { $this->eventID = $eventID; } /** * @return string */ public function getEventID() { return $this->eventID; } /** * Set the eventType. Returns TRUE if event is supported. FALSE otherwise. * @param string $eventType * * @return bool */ public function setEventType($eventType) { $this->eventType = $eventType; return TRUE; } /** * @return string */ public function getEventType() { return $this->eventType; } /** * @param array|Object|string $data */ public function setData($data) { $this->data = $data; } /** * @return array|Object */ public function getData() { return $this->data; } /** * Check that required params are present * * @param string $description * For error logs * @param array $requiredParams * Array of params that are required * @param array $params * Array of params to check * * @throws \Civi\Payment\Exception\PaymentProcessorException */ protected function checkRequiredParams($description, $requiredParams, $params) { foreach ($requiredParams as $required) { if (!isset($params[$required])) { $this->exception("{$description}: Missing mandatory parameter: {$required}"); } } } /** * Cancel a subscription (recurring contribution) * @param array $params * * @throws \CRM_Core_Exception * @throws \Civi\Payment\Exception\PaymentProcessorException */ protected function updateRecurCancelled($params) { $this->checkRequiredParams('updateRecurCancelled', ['id'], $params); civicrm_api3('ContributionRecur', 'cancel', $params); } /** * Update the subscription (recurring contribution) to a successful status * @param array $params * * @throws \CRM_Core_Exception * @throws \Civi\Payment\Exception\PaymentProcessorException */ private function updateRecurSuccess($params) { $this->checkRequiredParams('updateRecurSuccess', ['id'], $params); $params['failure_count'] = 0; $params['contribution_status_id'] = 'In Progress'; // Successful charge & more to come. civicrm_api3('ContributionRecur', 'create', $params); } /** * Update the subscription (recurring contribution) to a completed status * @param array $params * * @throws \CRM_Core_Exception * @throws \Civi\Payment\Exception\PaymentProcessorException */ private function updateRecurCompleted($params) { $this->checkRequiredParams('updateRecurCompleted', ['id'], $params); \Civi\Api4\ContributionRecur::update(FALSE) ->addWhere('id', '=', $params['id']) ->addValue('contribution_status_id:name', 'Completed') ->execute(); } /** * Update the subscription (recurring contribution) to a failing status * @param array $params * * @throws \CRM_Core_Exception * @throws \Civi\Payment\Exception\PaymentProcessorException */ private function updateRecurFailed($params) { $this->checkRequiredParams('updateRecurFailed', ['id'], $params); $failureCount = civicrm_api3('ContributionRecur', 'getvalue', [ 'id' => $params['id'], 'return' => 'failure_count', ]); $failureCount++; $params['failure_count'] = $failureCount; $params['contribution_status_id'] = 'Failed'; // Change the status of the Recurring and update failed attempts. civicrm_api3('ContributionRecur', 'create', $params); } /** * Repeat a contribution (call the Contribution.repeattransaction API) * * @param array $repeatContributionParams * * @return int The new Contribution ID * @throws \CRM_Core_Exception * @throws \Civi\Payment\Exception\PaymentProcessorException */ private function repeatContribution(array $repeatContributionParams): int { $this->checkRequiredParams( 'repeatContribution', [ 'contribution_status_id', 'contribution_recur_id', // Optional: 'original_contribution_id', 'receive_date', 'order_reference', 'trxn_id', //'total_amount', ], $repeatContributionParams ); // Optional Params: fee_amount // Creat contributionParams for Contribution.repeattransaction and set some values $contributionParams = $repeatContributionParams; // Status should be pending if we have a successful payment switch ($contributionParams['contribution_status_id']) { case CRM_Core_PseudoConstant::getKey('CRM_Contribute_BAO_Contribution', 'contribution_status_id', 'Completed'): // Create a contribution in Pending state using Contribution.repeattransaction and then complete using Payment.create $contributionParams['contribution_status_id'] = 'Pending'; $createPayment = TRUE; break; default: // Failed etc. // For any other status create it directly using Contribution.repeattransaction and don't create a payment $createPayment = FALSE; } // Create the new contribution $contributionParams['trxn_id'] = $repeatContributionParams['order_reference']; // We send a receipt when adding a payment, not now $contributionParams['is_email_receipt'] = FALSE; try { $contribution = reset(civicrm_api3('Contribution', 'repeattransaction', $contributionParams)['values']); } catch (Exception $e) { // We catch, log, throw again so we have debug details in the logs $message = 'MJWIPNTrait call to repeattransaction failed: ' . $e->getMessage() . '; params: ' . print_r($contributionParams, TRUE); \Civi::log()->error($message); throw new PaymentProcessorException($message); } // Get total amount from contribution returned by repeatTransaction (Which came from the recur template Contribution) $repeatContributionParams['total_amount'] = $repeatContributionParams['total_amount'] ?? $contribution['total_amount']; if ($createPayment) { // Start by passing through all params (we may pass in customdata as well in API4 format) $paymentParams = $repeatContributionParams; // Now map contribution keys to payment keys $paymentParamsMap = [ 'receive_date' => 'trxn_date', 'order_reference' => 'order_reference', 'trxn_id' => 'trxn_id', 'total_amount' => 'total_amount', 'fee_amount' => 'fee_amount', ]; foreach ($paymentParamsMap as $contributionKey => $paymentKey) { if (isset($contributionParams[$contributionKey])) { unset($paymentParams[$contributionKey]); $paymentParams[$paymentKey] = $contributionParams[$contributionKey]; } } $paymentParams['contribution_id'] = $contribution['id']; $paymentParams['payment_processor_id'] = $this->getPaymentProcessor()->getID(); $paymentParams['is_send_contribution_notification'] = $this->getSendEmailReceipt(); $paymentParams['skipCleanMoney'] = TRUE; civicrm_api3('Mjwpayment', 'create_payment', $paymentParams); } return $contribution['id']; } /** * @param array $params * * @throws \CRM_Core_Exception * @throws \Civi\Payment\Exception\PaymentProcessorException * * @deprecated Was used in Stripe until 6.11 */ private function updateContribution($params) { $this->checkRequiredParams('updateContribution', ['contribution_id'], $params); $params['id'] = $params['contribution_id']; unset($params['contribution_id']); $params['skipCleanMoney'] = TRUE; $params['skipRecentView'] = TRUE; $params['skipLineItem'] = TRUE; $params['is_post_payment_create'] = TRUE; civicrm_api3('Contribution', 'create', $params); } /** * Complete a pending contribution and update associated entities (recur/membership) * * @param array $contributionParams * * @throws \CRM_Core_Exception * @throws \Civi\Payment\Exception\PaymentProcessorException */ private function updateContributionCompleted(array $contributionParams) { $this->checkRequiredParams('updateContributionCompleted', ['contribution_id', 'trxn_date', 'order_reference', 'trxn_id', 'total_amount'], $contributionParams); // Start by passing through all params (we may pass in customdata as well in API4 format) $paymentParams = $contributionParams; // Now map contribution keys to payment keys $paymentParamsMap = [ 'contribution_id' => 'contribution_id', 'trxn_date' => 'trxn_date', 'order_reference' => 'order_reference', 'trxn_id' => 'trxn_id', 'total_amount' => 'total_amount', 'fee_amount' => 'fee_amount', ]; foreach ($paymentParamsMap as $contributionKey => $paymentKey) { if (isset($contributionParams[$contributionKey])) { unset($paymentParams[$contributionKey]); $paymentParams[$paymentKey] = $contributionParams[$contributionKey]; } } // CiviCRM does not (currently) allow changing contribution_status=Failed to anything else. // But we need to go from Failed to Completed if payment succeeds following a failure. // So we check for Failed status and update to Pending so it will transition to Completed. // Fixed in 5.29 with https://github.com/civicrm/civicrm-core/pull/17943 // Also set cancel_date to NULL because we are setting it for failed payments and UI will still display "greyed // out" if it is set. $failedContributionStatus = (int) CRM_Core_PseudoConstant::getKey('CRM_Contribute_BAO_Contribution', 'contribution_status_id', 'Failed'); if (isset($contributionParams['contribution_status_id']) && ((int) $contributionParams['contribution_status_id'] === $failedContributionStatus)) { $sql = "UPDATE civicrm_contribution SET contribution_status_id=%1,cancel_date=NULL WHERE id=%2"; $queryParams = [ 1 => [CRM_Core_PseudoConstant::getKey('CRM_Contribute_BAO_Contribution', 'contribution_status_id', 'Pending'), 'Positive'], 2 => [$contributionParams['contribution_id'], 'Positive'], ]; CRM_Core_DAO::executeQuery($sql, $queryParams); } $paymentParams['is_send_contribution_notification'] = $this->getSendEmailReceipt(); $paymentParams['skipCleanMoney'] = TRUE; $paymentParams['payment_processor_id'] = $this->getPaymentProcessor()->getID(); civicrm_api3('Mjwpayment', 'create_payment', $paymentParams); } /** * Update a contribution to failed * * @param array $params ['contribution_id', 'order_reference'{, cancel_date, cancel_reason}] * * @throws \CRM_Core_Exception * @throws \Civi\Payment\Exception\PaymentProcessorException */ private function updateContributionFailed($params) { $this->checkRequiredParams('updateContributionFailed', ['contribution_id', 'order_reference'], $params); Contribution::update(FALSE) ->addValue('contribution_status_id:name', 'Failed') ->addValue('trxn_id', $params['order_reference']) ->addValue('cancel_date', $params['cancel_date'] ?? '') ->addValue('cancel_reason', $params['cancel_reason'] ?? '') ->addWhere('id', '=', $params['contribution_id']) ->execute(); // No financial_trxn is created so we don't need to update that. } /** * Record a refund on a contribution * This wraps around the payment.create API to support earlier releases than features were available * * Examples: * $result = civicrm_api3('Payment', 'create', [ * 'contribution_id' => 590, * 'total_amount' => -3, * 'trxn_date' => 20191105200300, * 'trxn_result_code' => "Test a refund with fees", * 'fee_amount' => -0.25, * 'trxn_id' => "abctx123", * 'order_reference' => "abcor123", * ]); * * Returns: * "is_error": 0, * "version": 3, * "count": 1, * "id": 465, * "values": { * "465": { * "id": "465", * "from_financial_account_id": "7", * "to_financial_account_id": "6", * "trxn_date": "20191105200300", * "total_amount": "-3", * "fee_amount": "-0.25", * "net_amount": "", * "currency": "USD", * "is_payment": "1", * "trxn_id": "abctx123", * "trxn_result_code": "Test a refund with fees", * "status_id": "7", * "payment_processor_id": "" * } * } * * @param array $params * * @throws \CRM_Core_Exception */ protected function updateContributionRefund($params) { $this->checkRequiredParams('updateContributionRefund', ['contribution_id', 'total_amount'], $params); $params['payment_processor_id'] = $this->getPaymentProcessor()->getID(); $lock = Civi::lockManager()->acquire('data.contribute.contribution.' . $params['contribution_id']); if (!$lock->isAcquired()) { throw new PaymentProcessorException('Could not acquire lock to record refund for contribution: ' . $params['contribution_id']); } // Check if it was already recorded (in case two processes are running the same time - eg. Refund UI + IPN processing). $refundPayment = civicrm_api3('Payment', 'get', [ 'contribution_id' => $params['contribution_id'], 'total_amount' => $params['total_amount'], 'trxn_id' => $params['trxn_id'] ?? NULL, ]); if (empty($refundPayment['count'])) { civicrm_api3('Mjwpayment', 'create_payment', $params); } $lock->release(); } /** * Switch between "exit on exception" mode and "regular exception handling". * * @param bool $exitOnException Switch between: * - TRUE (default): Exit with HTTP response code 400 when an exception occurs * - FALSE: Just throw the exception regularly */ public function setExceptionMode($exitOnException) { $this->exitOnException = $exitOnException; } /** * Log and throw an IPN exception * * @param string $message * * @throws \Civi\Payment\Exception\PaymentProcessorException */ protected function exception($message) { $label = method_exists($this->getPaymentProcessor(), 'getPaymentProcessorLabel') ? $this->getPaymentProcessor()->getPaymentProcessorLabel() : __CLASS__; $errorMessage = $label . ' Exception: Event: ' . $this->eventType . ' Error: ' . $message; Civi::log()->error($errorMessage); if ($this->exitOnException) { http_response_code(400); exit(1); } else { Throw new PaymentProcessorException($message); } } }