Skip to content
Snippets Groups Projects

Compare revisions

Changes are shown as if the source revision was being merged into the target revision. Learn more about comparing revisions.

Source

Select target project
No results found

Target

Select target project
  • extensions/mjwshared
  • jitendra/mjwshared
  • scardinius/mjwshared
  • artfulrobot/mjwshared
  • capo/mjwshared
  • agilewarefj/mjwshared
  • JonGold/mjwshared
  • aaron/mjwshared
  • sluc23/mjwshared
  • marsh/mjwshared
  • konadave/mjwshared
  • wmortada/mjwshared
  • jtwyman/mjwshared
  • pradeep/mjwshared
  • AllenShaw/mjwshared
  • jamie/mjwshared
  • JKingsnorth/mjwshared
  • ufundo/mjwshared
  • DaveD/mjwshared
19 results
Show changes
Commits on Source (263)
Showing
with 2048 additions and 257 deletions
......@@ -9,6 +9,9 @@
+--------------------------------------------------------------------+
*/
use Civi\Api4\Contribution;
use Civi\Payment\Exception\PaymentProcessorException;
/**
* Shared payment IPN functions that should one day be migrated to CiviCRM core
*
......@@ -24,7 +27,7 @@ trait CRM_Core_Payment_MJWIPNTrait {
/**
* Do we send an email receipt for each contribution?
*
* @var int
* @var int|null
*/
protected $is_email_receipt = NULL;
......@@ -34,78 +37,180 @@ trait CRM_Core_Payment_MJWIPNTrait {
*/
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;
/**
* 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.
* Should we verify (re-retrieve) the object that we have been given.
* Usually TRUE when an IPN request is received.
*
* @param int $sendReceipt The value of is_email_receipt
* @var bool
*/
public function setSendEmailReceipt($sendReceipt) {
switch ($sendReceipt) {
case 0:
$this->is_email_receipt = 0;
break;
protected $verifyData = TRUE;
case 1:
$this->is_email_receipt = 1;
break;
/**
* The data provided by the IPN
*
* @var array|Object|string
*/
protected $data;
default:
$this->is_email_receipt = 0;
/**
*
* 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 for a recurring contribution
* If not set, we respect the value set on the ContributionRecur entity.
* 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.
*
* @return int
* @throws \CiviCRM_API3_Exception
* See setSendEmalReceipt() for more details.
*
* @return int|null
*/
public function getSendEmailReceipt() {
if (isset($this->is_email_receipt)) {
return (int) $this->is_email_receipt;
}
if (!empty($this->contribution_recur_id)) {
$this->is_email_receipt = civicrm_api3('ContributionRecur', 'getvalue', [
'return' => "is_email_receipt",
'id' => $this->contribution_recur_id,
]);
}
return (int) $this->is_email_receipt;
return $this->is_email_receipt;
}
/**
* Get the payment processor
* The $_GET['processor_id'] value is set by CRM_Core_Payment::handlePaymentMethod.
* Set and initialise the paymentProcessor object
* @param int $paymentProcessorID
*
* @throws \CRM_Core_Exception
* @throws \Civi\Payment\Exception\PaymentProcessorException
*/
protected function getPaymentProcessor() {
$paymentProcessorID = CRM_Utils_Request::retrieveValue('processor_id', 'Positive', NULL, FALSE, 'GET');
if (!$paymentProcessorID) {
$this->exception('Failed to get payment processor ID');
}
public function setPaymentProcessor($paymentProcessorID) {
try {
$this->_paymentProcessor = \Civi\Payment\System::singleton()->getById($paymentProcessorID);
}
catch(Exception $e) {
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
*
......@@ -130,7 +235,7 @@ trait CRM_Core_Payment_MJWIPNTrait {
* Cancel a subscription (recurring contribution)
* @param array $params
*
* @throws \CiviCRM_API3_Exception
* @throws \CRM_Core_Exception
* @throws \Civi\Payment\Exception\PaymentProcessorException
*/
protected function updateRecurCancelled($params) {
......@@ -142,7 +247,7 @@ trait CRM_Core_Payment_MJWIPNTrait {
* Update the subscription (recurring contribution) to a successful status
* @param array $params
*
* @throws \CiviCRM_API3_Exception
* @throws \CRM_Core_Exception
* @throws \Civi\Payment\Exception\PaymentProcessorException
*/
private function updateRecurSuccess($params) {
......@@ -158,21 +263,23 @@ trait CRM_Core_Payment_MJWIPNTrait {
* Update the subscription (recurring contribution) to a completed status
* @param array $params
*
* @throws \CiviCRM_API3_Exception
* @throws \CRM_Core_Exception
* @throws \Civi\Payment\Exception\PaymentProcessorException
*/
private function updateRecurCompleted($params) {
$this->checkRequiredParams('updateRecurCompleted', ['id'], $params);
$params['contribution_status_id'] = 'Completed';
civicrm_api3('ContributionRecur', 'create', $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 \CiviCRM_API3_Exception
* @throws \CRM_Core_Exception
* @throws \Civi\Payment\Exception\PaymentProcessorException
*/
private function updateRecurFailed($params) {
......@@ -194,11 +301,13 @@ trait CRM_Core_Payment_MJWIPNTrait {
/**
* Repeat a contribution (call the Contribution.repeattransaction API)
*
* @param array $params
* @param array $repeatContributionParams
*
* @throws \CiviCRM_API3_Exception
* @return int The new Contribution ID
* @throws \CRM_Core_Exception
* @throws \Civi\Payment\Exception\PaymentProcessorException
*/
private function repeatContribution($params) {
private function repeatContribution(array $repeatContributionParams): int {
$this->checkRequiredParams(
'repeatContribution',
[
......@@ -208,16 +317,16 @@ trait CRM_Core_Payment_MJWIPNTrait {
'receive_date',
'order_reference',
'trxn_id',
'total_amount',
//'total_amount',
],
$params
$repeatContributionParams
);
// Optional Params: fee_amount
// Creat contributionParams for Contribution.repeattransaction and set some values
$contributionParams = $params;
$contributionParams = $repeatContributionParams;
// Status should be pending if we have a successful payment
switch ($params['contribution_status_id']) {
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';
......@@ -227,41 +336,59 @@ trait CRM_Core_Payment_MJWIPNTrait {
default:
// Failed etc.
// For any other status create it directly using Contribution.repeattransaction and don't create a payment
$contributionParams['contribution_status_id'] = $params['contribution_status_id'];
$createPayment = FALSE;
}
// Create the new contribution
$contributionParams['trxn_id'] = $params['order_reference'];
$contributionParams['trxn_id'] = $repeatContributionParams['order_reference'];
// We send a receipt when adding a payment, not now
$contributionParams['is_email_receipt'] = FALSE;
$contribution = civicrm_api3('Contribution', 'repeattransaction', $contributionParams);
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) {
$paymentParamsKeys = [
// 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 ($paymentParamsKeys as $contributionKey => $paymentKey) {
if (isset($params[$contributionKey])) {
$paymentParams[$paymentKey] = $params[$contributionKey];
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 \CiviCRM_API3_Exception
* @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);
......@@ -277,15 +404,18 @@ trait CRM_Core_Payment_MJWIPNTrait {
/**
* Complete a pending contribution and update associated entities (recur/membership)
*
* @param array $params
* @param array $contributionParams
*
* @throws \CiviCRM_API3_Exception
* @throws \CRM_Core_Exception
* @throws \Civi\Payment\Exception\PaymentProcessorException
*/
private function updateContributionCompleted($params) {
$this->checkRequiredParams('updateContributionCompleted', ['contribution_id', 'trxn_date', 'order_reference', 'trxn_id'], $params);
private function updateContributionCompleted(array $contributionParams) {
$this->checkRequiredParams('updateContributionCompleted', ['contribution_id', 'trxn_date', 'order_reference', 'trxn_id', 'total_amount'], $contributionParams);
$paymentParamsKeys = [
// 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',
......@@ -293,9 +423,10 @@ trait CRM_Core_Payment_MJWIPNTrait {
'total_amount' => 'total_amount',
'fee_amount' => 'fee_amount',
];
foreach ($paymentParamsKeys as $contributionKey => $paymentKey) {
if (isset($params[$contributionKey])) {
$paymentParams[$paymentKey] = $params[$contributionKey];
foreach ($paymentParamsMap as $contributionKey => $paymentKey) {
if (isset($contributionParams[$contributionKey])) {
unset($paymentParams[$contributionKey]);
$paymentParams[$paymentKey] = $contributionParams[$contributionKey];
}
}
......@@ -303,18 +434,20 @@ trait CRM_Core_Payment_MJWIPNTrait {
// 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($params['contribution_status_id']) && ((int) $params['contribution_status_id'] === $failedContributionStatus)) {
$sql = "UPDATE civicrm_contribution SET contribution_status_id=%1 WHERE id=%2";
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 => [$params['contribution_id'], '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->_paymentProcessor->getID();
$paymentParams['payment_processor_id'] = $this->getPaymentProcessor()->getID();
civicrm_api3('Mjwpayment', 'create_payment', $paymentParams);
}
......@@ -323,18 +456,18 @@ trait CRM_Core_Payment_MJWIPNTrait {
*
* @param array $params ['contribution_id', 'order_reference'{, cancel_date, cancel_reason}]
*
* @throws \CiviCRM_API3_Exception
* @throws \CRM_Core_Exception
* @throws \Civi\Payment\Exception\PaymentProcessorException
*/
private function updateContributionFailed($params) {
$this->checkRequiredParams('updateContributionFailed', ['contribution_id', 'order_reference'], $params);
civicrm_api3('Contribution', 'create', [
'contribution_status_id' => 'Failed',
'id' => $params['contribution_id'],
'trxn_id' => $params['order_reference'],
'cancel_date' => $params['cancel_date'] ?? NULL,
'cancel_reason' => $params['cancel_reason'] ?? NULL,
]);
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.
}
......@@ -379,12 +512,25 @@ trait CRM_Core_Payment_MJWIPNTrait {
* @param array $params
*
* @throws \CRM_Core_Exception
* @throws \CiviCRM_API3_Exception
*/
protected function updateContributionRefund($params) {
$this->checkRequiredParams('updateContributionRefund', ['contribution_id', 'total_amount'], $params);
$params['payment_processor_id'] = $this->_paymentProcessor->getID();
civicrm_api3('Mjwpayment', 'create_payment', $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();
}
/**
......@@ -406,14 +552,14 @@ trait CRM_Core_Payment_MJWIPNTrait {
* @throws \Civi\Payment\Exception\PaymentProcessorException
*/
protected function exception($message) {
$label = method_exists($this->_paymentProcessor, 'getPaymentProcessorLabel') ? $this->_paymentProcessor->getPaymentProcessorLabel() : __CLASS__;
$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 \Civi\Payment\Exception\PaymentProcessorException($message);
Throw new PaymentProcessorException($message);
}
}
......
This diff is collapsed.
<?php
use CRM_Mjwshared_ExtensionUtil as E;
class CRM_Mjwshared_BAO_PaymentprocessorWebhook extends CRM_Mjwshared_DAO_PaymentprocessorWebhook {
}
......@@ -16,24 +16,102 @@ use CRM_Mjwshared_ExtensionUtil as E;
*/
class CRM_Mjwshared_Check {
public static function checkRequirements(&$messages) {
self::checkExtensionWorldpay($messages);
self::checkExtensionMinifier($messages);
self::checkExtensionContributiontransactlegacy($messages);
}
const MIN_VERSION_SWEETALERT = '1.5';
/**
* @var array
*/
private array $messages;
/**
* CRM_Mjwshared_Check constructor.
*
* @param array $messages
*
* @throws \CiviCRM_API3_Exception
*/
private static function checkExtensionWorldpay(&$messages) {
public function __construct($messages) {
$this->messages = $messages;
}
/**
* @return array
* @throws \CRM_Core_Exception
* @throws \Civi\API\Exception\UnauthorizedException
*/
public function checkRequirements() {
$this->checkExtensionWorldpay();
// $this->checkExtensionMinifier();
$this->checkExtensionContributiontransactlegacy();
$this->checkIfSeparateMembershipPaymentEnabled();
$this->checkExtensionSweetalert();
$this->checkMultidomainJobs();
$this->checkPaymentprocessorWebhooks();
return $this->messages;
}
/**
* @param string $extensionName
* @param string $minVersion
* @param string $actualVersion
*/
private function requireExtensionMinVersion(string $extensionName, string $minVersion, string $actualVersion) {
$actualVersionModified = $actualVersion;
if (substr($actualVersion, -4) === '-dev') {
$actualVersionModified = substr($actualVersion, 0, -4);
$devMessageAlreadyDefined = FALSE;
foreach ($this->messages as $message) {
if ($message->getName() === __FUNCTION__ . $extensionName . '_requirements_dev') {
// Another extension already generated the "Development version" message for this extension
$devMessageAlreadyDefined = TRUE;
}
}
if (!$devMessageAlreadyDefined) {
$message = new \CRM_Utils_Check_Message(
__FUNCTION__ . $extensionName . '_requirements_dev',
E::ts('You are using a development version of %1 extension.',
[1 => $extensionName]),
E::ts('%1: Development version', [1 => $extensionName]),
\Psr\Log\LogLevel::WARNING,
'fa-code'
);
$this->messages[] = $message;
}
}
if (version_compare($actualVersionModified, $minVersion) === -1) {
$message = new \CRM_Utils_Check_Message(
__FUNCTION__ . $extensionName . E::SHORT_NAME . '_requirements',
E::ts('The %1 extension requires the %2 extension version %3 or greater but your system has version %4.',
[
1 => ucfirst(E::SHORT_NAME),
2 => $extensionName,
3 => $minVersion,
4 => $actualVersion
]),
E::ts('%1: Missing Requirements', [1 => ucfirst(E::SHORT_NAME)]),
\Psr\Log\LogLevel::ERROR,
'fa-exclamation-triangle'
);
$message->addAction(
E::ts('Upgrade now'),
NULL,
'href',
['path' => 'civicrm/admin/extensions', 'query' => ['action' => 'update', 'id' => $extensionName, 'key' => $extensionName]]
);
$this->messages[] = $message;
}
}
/**
* @throws \CRM_Core_Exception
*/
private function checkExtensionWorldpay() {
$extensions = civicrm_api3('Extension', 'get', [
'full_name' => 'uk.co.nfpservice.onlineworldpay',
]);
if (!empty($extensions['id']) && ($extensions['values'][$extensions['id']]['status'] === 'installed')) {
$messages[] = new CRM_Utils_Check_Message(
$this->messages[] = new CRM_Utils_Check_Message(
__FUNCTION__ . 'mjwshared_incompatible',
E::ts('You have the uk.co.nfpservice.onlineworldpay extension installed.
There are multiple versions of this extension on various sites and the source code has not been released.
......@@ -46,17 +124,15 @@ class CRM_Mjwshared_Check {
}
/**
* @param array $messages
*
* @throws \CiviCRM_API3_Exception
* @throws \CRM_Core_Exception
*/
private static function checkExtensionMinifier(&$messages) {
private function checkExtensionMinifier() {
$extensionName = 'minifier';
$extensions = civicrm_api3('Extension', 'get', [
'full_name' => $extensionName,
]);
if (empty($extensions['id']) || ($extensions['values'][$extensions['id']]['status'] !== 'installed')) {
if (empty($extensions['count']) || ($extensions['values'][$extensions['id']]['status'] !== 'installed')) {
$message = new CRM_Utils_Check_Message(
__FUNCTION__ . 'mjwshared_recommended',
E::ts('It is recommended that you download and install the <strong><a href="https://civicrm.org/extensions/minifier">minifier</a></strong> extension.
......@@ -71,19 +147,17 @@ class CRM_Mjwshared_Check {
'href',
['path' => 'civicrm/admin/extensions', 'query' => ['action' => 'update', 'id' => $extensionName, 'key' => $extensionName]]
);
$messages[] = $message;
$this->messages[] = $message;
}
}
/**
* @param array $messages
*
* @throws \CiviCRM_API3_Exception
* @throws \CRM_Core_Exception
*/
private static function checkExtensionContributiontransactlegacy(&$messages) {
private function checkExtensionContributiontransactlegacy() {
$extensionName = 'contributiontransactlegacy';
// Only on Drupal - do we have webform_civicrm installed?
if (function_exists('module_exists')) {
// Only on Drupal 7 (webform_civicrm 7.x-5.x) - do we have webform_civicrm installed?
if (function_exists('module_exists') && CRM_Core_Config::singleton()->userFramework === 'Drupal') {
$extensions = civicrm_api3('Extension', 'get', [
'full_name' => $extensionName,
]);
......@@ -104,11 +178,183 @@ class CRM_Mjwshared_Check {
'href',
['path' => 'civicrm/admin/extensions', 'query' => ['action' => 'update', 'id' => $extensionName, 'key' => $extensionName]]
);
$messages[] = $message;
$this->messages[] = $message;
}
}
}
/**
* We don't support "Separate Membership Payment" configuration
*
* @throws \CRM_Core_Exception
* @throws \Civi\API\Exception\UnauthorizedException
*/
private function checkIfSeparateMembershipPaymentEnabled() {
$separateMembershipPaymentNotSupportedProcessors = ['Stripe', 'Globalpayments'];
$membershipBlocks = civicrm_api3('MembershipBlock', 'get', [
'is_separate_payment' => 1,
'is_active' => 1,
]);
if ($membershipBlocks['count'] > 0) {
$contributionPagesToCheck = [];
foreach ($membershipBlocks['values'] as $blockDetails) {
if ($blockDetails['entity_table'] !== 'civicrm_contribution_page') {
continue;
}
$contributionPagesToCheck[] = $blockDetails['entity_id'];
}
$paymentProcessorIDs = \Civi\Api4\PaymentProcessor::get(FALSE)
->addJoin('PaymentProcessorType AS payment_processor_type', 'INNER', ['payment_processor_type_id', '=', 'payment_processor_type.id'])
->addWhere('payment_processor_type.name', 'IN', $separateMembershipPaymentNotSupportedProcessors)
->execute()
->column('id');
if (!empty($contributionPagesToCheck)) {
$contributionPages = civicrm_api3('ContributionPage', 'get', [
'return' => ['payment_processor'],
'id' => ['IN' => $contributionPagesToCheck],
'is_active' => 1,
]);
foreach ($contributionPages['values'] as $contributionPage) {
$enabledPaymentProcessors = is_array($contributionPage['payment_processor'])
? $contributionPage['payment_processor'] : explode(CRM_Core_DAO::VALUE_SEPARATOR, $contributionPage['payment_processor']);
foreach ($enabledPaymentProcessors as $enabledID) {
if (in_array($enabledID, $paymentProcessorIDs)) {
$message = new CRM_Utils_Check_Message(
__FUNCTION__ . 'mjwshared_requirements',
E::ts('You need to disable "Separate Membership Payment" or disable the payment processors: %2 on contribution page %1 because it is not supported and will not work.
See <a href="https://lab.civicrm.org/extensions/stripe/-/issues/134">Stripe#134</a>.',
[
1 => $contributionPage['id'],
2 => implode(', ', $separateMembershipPaymentNotSupportedProcessors),
]),
E::ts('Payments: Invalid configuration'),
\Psr\Log\LogLevel::ERROR,
'fa-money'
);
$this->messages[] = $message;
return;
}
}
}
}
}
}
/**
* @throws \CRM_Core_Exception
*/
private function checkExtensionSweetalert() {
// sweetalert: recommended. If installed requires min version
$extensionName = 'sweetalert';
$extensions = civicrm_api3('Extension', 'get', [
'full_name' => $extensionName,
]);
if (empty($extensions['count']) || ($extensions['values'][$extensions['id']]['status'] !== 'installed')) {
$message = new CRM_Utils_Check_Message(
__FUNCTION__ . 'mjwshared_recommended',
E::ts('It is recommended that you install the <strong><a href="https://civicrm.org/extensions/sweetalert">sweetalert</a></strong> extension.
This allows extensions such as Stripe to show useful messages to the user when processing payment.
If this is not installed it will fallback to the browser "alert" message but you will
not see some messages (such as <em>we are pre-authorizing your card</em> and <em>please wait</em>) and the feedback to the user will not be as helpful.'),
E::ts('Recommended Extension: sweetalert'),
\Psr\Log\LogLevel::NOTICE,
'fa-lightbulb-o'
);
$message->addAction(
E::ts('Install now'),
NULL,
'href',
['path' => 'civicrm/admin/extensions', 'query' => ['action' => 'update', 'id' => $extensionName, 'key' => $extensionName]]
);
$this->messages[] = $message;
return;
}
if (isset($extensions['id']) && $extensions['values'][$extensions['id']]['status'] === 'installed') {
$this->requireExtensionMinVersion($extensionName, self::MIN_VERSION_SWEETALERT, $extensions['values'][$extensions['id']]['version']);
}
}
/**
* @throws \CRM_Core_Exception
* @throws \Civi\API\Exception\UnauthorizedException
*/
private function checkMultidomainJobs() {
$domains = \Civi\Api4\Domain::get(FALSE)
->execute();
if ($domains->count() <= 1) {
return;
}
$jobs = civicrm_api3('Job', 'get', [
'api_action' => "process_paymentprocessor_webhooks",
'api_entity' => "job",
])['values'];
$domainMissingJob = [];
foreach ($domains as $domain) {
foreach ($jobs as $job) {
if ((int) $job['domain_id'] === $domain['id']) {
// We found a job for this domain.
continue 2;
}
}
$domainMissingJob[$domain['id']] = "{$domain['id']}: {$domain['name']}";
}
if (!empty($domainMissingJob)) {
$domainMessage = '<ul><li>' . implode('</li><li>', $domainMissingJob) . '</li></ul>';
$message = new CRM_Utils_Check_Message(
__FUNCTION__ . 'mjwshared_multidomain',
E::ts('You have multiple domains configured and some domains are missing the scheduled job "Job.process_paymentprocessor_webhooks": %1',
[1 => $domainMessage]
),
E::ts('Payments: Multidomain scheduled jobs'),
\Psr\Log\LogLevel::WARNING,
'fa-code'
);
$this->messages[] = $message;
}
}
/**
* @throws \CRM_Core_Exception
* @throws \Civi\API\Exception\UnauthorizedException
*/
private function checkPaymentprocessorWebhooks() {
$paymentprocessorWebhooksProcessingCount = \Civi\Api4\PaymentprocessorWebhook::get(FALSE)
->addSelect('row_count')
->addWhere('status', '=', 'processing')
->addWhere('created_date', '<', 'now-1hour')
->addOrderBy('created_date', 'DESC')
->execute()
->countMatched();
if ($paymentprocessorWebhooksProcessingCount > 0) {
$paymentprocessorWebhookProcessors = \Civi\Api4\PaymentprocessorWebhook::get(FALSE)
->addSelect('id', 'payment_processor_id:label')
->addWhere('status', '=', 'processing')
->addWhere('created_date', '<', 'now-1hour')
->addOrderBy('created_date', 'DESC')
->addGroupBy('payment_processor_id')
->execute()
->indexBy('payment_processor_id:label')
->getArrayCopy();
$message = new CRM_Utils_Check_Message(
__FUNCTION__ . 'mjwshared_paymentprocessorwebhooks',
E::ts('You have %1 payment processor webhooks in "processing" status for %2 payment processors.
This means that the system started processing them but something went wrong that can\'t be fixed automatically.
Please check and identify the problem. Then you can mark them for retry.',
[1 => $paymentprocessorWebhooksProcessingCount, 2 => implode(',', array_keys($paymentprocessorWebhookProcessors))]
),
E::ts('Payment Processor Webhooks: Processing failed'),
\Psr\Log\LogLevel::ERROR,
'fa-code'
);
$message->addAction('Check now', FALSE, 'href', ['path' => 'civicrm/a#/paymentprocessorWebhook']);
$this->messages[] = $message;
}
}
}
<?php
/**
* DAOs provide an OOP-style facade for reading and writing database records.
*
* DAOs are a primary source for metadata in older versions of CiviCRM (<5.74)
* and are required for some subsystems (such as APIv3).
*
* This stub provides compatibility. It is not intended to be modified in a
* substantive way. Property annotations may be added, but are not required.
* @property string $id
* @property string $payment_processor_id
* @property string $event_id
* @property string $trigger
* @property string $created_date
* @property string $processed_date
* @property string $status
* @property string $identifier
* @property string $message
* @property string $data
*/
class CRM_Mjwshared_DAO_PaymentprocessorWebhook extends CRM_Mjwshared_DAO_Base {
/**
* Required by older versions of CiviCRM (<5.74).
* @var string
*/
public static $_tableName = 'civicrm_paymentprocessor_webhook';
}
<?php
use Civi\Api4\LineItem;
use Civi\Api4\Membership;
use Civi\Api4\Participant;
use Civi\Api4\PaymentProcessor;
use Civi\Payment\Exception\PaymentProcessorException;
use CRM_Mjwshared_ExtensionUtil as E;
use Brick\Money\Money;
use Brick\Money\Context\DefaultContext;
use Brick\Math\RoundingMode;
/**
* Form controller class
*
* @see https://docs.civicrm.org/dev/en/latest/framework/quickform/
*/
class CRM_Mjwshared_Form_PaymentRefund extends CRM_Core_Form {
/**
* @var int $paymentID
*/
private $paymentID;
/**
* @var int $contributionID
*/
private $contributionID;
/**
* @var array $financialTrxn
*/
private $financialTrxn;
public function buildQuickForm() {
if (!CRM_Core_Permission::check('edit contributions')) {
CRM_Core_Error::statusBounce(ts('You do not have permission to access this page.'));
}
$this->addFormRule(['CRM_Mjwshared_Form_PaymentRefund', 'formRule'], $this);
$this->setTitle('Refund payment');
$this->paymentID = CRM_Utils_Request::retrieveValue('payment_id', 'Positive', NULL, FALSE, 'REQUEST');
if (!$this->paymentID) {
CRM_Core_Error::statusBounce('Payment not found!');
}
$this->contributionID = CRM_Utils_Request::retrieveValue('contribution_id', 'Positive', NULL, FALSE, 'REQUEST');
if (!$this->contributionID) {
CRM_Core_Error::statusBounce('Contribution not found!');
}
$financialTrxn = reset(civicrm_api3('Mjwpayment', 'get_payment', [
'financial_trxn_id' => $this->paymentID,
])['values']);
if ((int)$financialTrxn['contribution_id'] !== $this->contributionID) {
CRM_Core_Error::statusBounce('Contribution / Payment does not match');
}
$financialTrxn['order_reference'] = $financialTrxn['order_reference'] ?? NULL;
$paymentProcessor = PaymentProcessor::get(FALSE)
->addWhere('id', '=', $financialTrxn['payment_processor_id'])
->execute()
->first();
$financialTrxn['payment_processor_title'] = $paymentProcessor['title'] ?? $paymentProcessor['name'];
$this->assign('paymentInfo', $financialTrxn);
$this->financialTrxn = $financialTrxn;
$this->add('hidden', 'payment_id');
$this->add('hidden', 'contribution_id');
$participantIDs = $membershipIDs = [];
$lineItems = LineItem::get(FALSE)
->addWhere('contribution_id', '=', $this->contributionID)
->execute();
foreach ($lineItems as $lineItemDetails) {
switch ($lineItemDetails['entity_table']) {
case 'civicrm_participant':
$participantIDs[] = $lineItemDetails['entity_id'];
break;
case 'civicrm_membership':
$membershipIDs[] = $lineItemDetails['entity_id'];
break;
}
}
if (!empty($participantIDs)) {
$participantsForAssign = [];
$this->set('participant_ids', $participantIDs);
$participants = Participant::get()
->addSelect('*', 'event_id.title', 'status_id:label', 'contact_id.display_name')
->addWhere('id', 'IN', $participantIDs)
->execute();
foreach ($participants->getArrayCopy() as $participant) {
$participant['status'] = $participant['status_id:label'];
$participant['event_title'] = $participant['event_id.title'];
$participant['display_name'] = $participant['contact_id.display_name'];
$participantsForAssign[] = $participant;
}
$this->addYesNo('cancel_participants', E::ts('Do you want to cancel these registrations when you refund the payment?'), NULL, TRUE);
}
$this->assign('participants', $participantsForAssign ?? NULL);
if (!empty($membershipIDs)) {
$membershipsForAssign = [];
$this->set('membership_ids', $membershipIDs);
$memberships = Membership::get(FALSE)
->addSelect('*', 'membership_type_id:label', 'status_id:label', 'contact_id.display_name')
->addWhere('id', 'IN', $membershipIDs)
->execute();
foreach ($memberships->getArrayCopy() as $membership) {
$membership['status'] = $membership['status_id:label'];
$membership['type'] = $membership['membership_type_id:label'];
$membership['display_name'] = $membership['contact_id.display_name'];
$membershipsForAssign[] = $membership;
}
$this->addYesNo('cancel_memberships', E::ts('Do you want to cancel these memberships when you refund the payment?'), NULL, TRUE);
}
$this->assign('memberships', $membershipsForAssign ?? NULL);
$this->addMoney('refund_amount',
ts('Refund Amount'),
TRUE,
[],
TRUE, 'currency', $financialTrxn['currency'], TRUE
);
$this->addButtons([
[
'type' => 'submit',
'name' => ts('Refund'),
'isDefault' => TRUE,
],
[
'type' => 'cancel',
'name' => ts('Cancel'),
],
]);
}
public function setDefaultValues() {
if ($this->paymentID) {
$this->_defaults['payment_id'] = $this->paymentID;
$this->set('payment_id', $this->paymentID);
$this->_defaults['contribution_id'] = $this->contributionID;
$this->set('contribution_id', $this->contributionID);
$this->_defaults['refund_amount'] = $this->financialTrxn['total_amount'];
}
return $this->_defaults;
}
/**
* Global form rule.
*
* @param array $fields
* The input form values.
* @param array $files
* The uploaded files if any.
* @param CRM_Core_Form $form
*
* @return bool|array
* true if no errors, else array of errors
*/
public static function formRule($fields, $files, $form) {
$errors = [];
$formValues = $form->getSubmitValues();
$paymentID = $form->get('payment_id');
$payment = reset(civicrm_api3('Mjwpayment', 'get_payment', ['id' => $paymentID])['values']);
// Check refund amount
$refundAmount = Money::of($formValues['refund_amount'], $payment['currency'], new DefaultContext(), RoundingMode::CEILING);
$paymentAmount = Money::of($payment['total_amount'], $payment['currency'], new DefaultContext(), RoundingMode::CEILING);
if ($refundAmount->isGreaterThan($paymentAmount)) {
$errors['refund_amount'] = 'Cannot refund more than the original amount';
}
if ($refundAmount->isNegativeOrZero()) {
$errors['refund_amount'] = 'Cannot refund zero or negative amount';
}
return $errors;
}
public function postProcess() {
$formValues = $this->getSubmitValues();
$paymentID = $this->get('payment_id');
$participantIDs = $this->get('participant_ids');
$cancelParticipants = $formValues['cancel_participants'] ?? FALSE;
$membershipIDs = $this->get('membership_ids');
$cancelMemberships = $formValues['cancel_memberships'] ?? FALSE;
try {
$payment = reset(civicrm_api3('Mjwpayment', 'get_payment', ['id' => $paymentID])['values']);
// Check refund amount
$refundAmount = Money::of($formValues['refund_amount'], $payment['currency'], new DefaultContext(), RoundingMode::CEILING);
$paymentAmount = Money::of($payment['total_amount'], $payment['currency'], new DefaultContext(), RoundingMode::CEILING);
if ($refundAmount->isGreaterThan($paymentAmount)) {
throw new PaymentProcessorException('Cannot refund more than the original amount');
}
if ($refundAmount->isNegativeOrZero()) {
throw new PaymentProcessorException('Cannot refund zero or negative amount');
}
$refundParams = [
'payment_processor_id' => $payment['payment_processor_id'],
'amount' => $refundAmount->getAmount()->toFloat(),
'currency' => $payment['currency'],
'trxn_id' => $payment['trxn_id'],
];
$refund = reset(civicrm_api3('PaymentProcessor', 'Refund', $refundParams)['values']);
if ($refund['refund_status'] === 'Completed') {
$refundPaymentParams = [
'contribution_id' => $payment['contribution_id'],
'trxn_id' => $refund['refund_trxn_id'],
'order_reference' => $payment['order_reference'] ?? NULL,
'total_amount' => 0 - abs($refundAmount->getAmount()->toFloat()),
'fee_amount' => 0 - abs($refund['fee_amount']),
'payment_processor_id' => $payment['payment_processor_id'],
];
$lock = Civi::lockManager()->acquire('data.contribute.contribution.' . $refundPaymentParams['contribution_id']);
if (!$lock->isAcquired()) {
throw new PaymentProcessorException('Could not acquire lock to record refund for contribution: ' . $refundPaymentParams['contribution_id']);
}
$refundPayment = civicrm_api3('Payment', 'get', [
'contribution_id' => $refundPaymentParams['contribution_id'],
'total_amount' => $refundPaymentParams['total_amount'],
'trxn_id' => $refundPaymentParams['trxn_id'],
]);
if (empty($refundPayment['count'])) {
// Record the refund in CiviCRM
civicrm_api3('Mjwpayment', 'create_payment', $refundPaymentParams);
}
$lock->release();
$message = E::ts('Refund was processed successfully.');
if ($cancelParticipants && !empty($participantIDs)) {
foreach ($participantIDs as $participantID) {
civicrm_api3('Participant', 'create', [
'id' => $participantID,
'status_id' => 'Cancelled',
]);
}
$message .= ' ' . E::ts('Cancelled %1 participant registration(s).', [1 => count($participantIDs)]);
}
if ($cancelMemberships && !empty($membershipIDs)) {
Membership::update(FALSE)
->addValue('status_id.name', 'Cancelled')
->addWhere('id', 'IN', $membershipIDs)
->execute();
$message .= ' ' . E::ts('Cancelled %1 membership(s).', [1 => count($membershipIDs)]);
}
CRM_Core_Session::setStatus($message, 'Refund processed', 'success');
}
else {
CRM_Core_Error::statusBounce("Refund status '{$refund['refund_status']}'is not supported at this time and was not recorded in CiviCRM.");
}
} catch (Exception $e) {
CRM_Core_Error::statusBounce($e->getMessage(), NULL, 'Refund failed');
}
}
}
<?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 |
+--------------------------------------------------------------------+
*/
/**
* This class implements hooks for Mjwshared
*/
class CRM_Mjwshared_Hook {
/**
* This hook allows modifying recurring contribution parameters
*
* @param string $type The type of webhook - eg. 'stripe'
* @param object $object The object (eg. CRM_Core_Payment_StripeIPN)
* @param string $code "Code" to identify what was not matched (eg. customer_not_found)
* @param array $result Results returned by hook processing. Depends on the type/code. Eg. for stripe.contribution_not_found return $result['contribution'] = "contribution array from API"
*
* @return mixed
*/
public static function webhookEventNotMatched(string $type, $object, string $code = '', array &$result = []) {
// Wrap in a try/catch to guard against coding errors in extensions.
try {
return CRM_Utils_Hook::singleton()
->invoke([
'type',
'object',
'code',
'result'
], $type, $object, $code, $result, CRM_Utils_Hook::$_nullObject, CRM_Utils_Hook::$_nullObject,
'civicrm_webhook_eventNotMatched'
);
}
catch (Exception $e) {
\Civi::log()->error("webhookEventNotMatched triggered exception. Type: {$type}; Code: {$code}; Message: " . $e->getMessage());
return FALSE;
}
}
}
<?php
use CRM_Mjwshared_ExtensionUtil as E;
/**
* Collection of upgrade steps.
*/
class CRM_Mjwshared_Upgrader extends CRM_Extension_Upgrader_Base {
/**
* @return TRUE on success
* @throws Exception
*/
public function upgrade_1000() {
$this->ctx->log->info('Applying update 1000 - Add civicrm_paymentprocessor_webhook table');
if (!CRM_Core_DAO::checkTableExists('civicrm_paymentprocessor_webhook')) {
// Note: this SQL installs an old version of this table which will then
// be updated by upgrade_1001 It only exists for the sake of people
// upgrading from old versions.
$this->executeSqlFile('sql/upgrade_1000.sql');
}
return TRUE;
}
/**
* @return TRUE on success
* @throws Exception
*/
public function upgrade_1001() {
$this->ctx->log->info('Applying update 1001 - alter civicrm_paymentprocessor_webhook table');
$this->executeSqlFile('sql/upgrade_1001.sql');
return TRUE;
}
public function upgrade_1002() {
$this->ctx->log->info('Add indexes to civicrm_paymentprocessor_webhook table');
if (!CRM_Core_BAO_SchemaHandler::checkIfIndexExists('civicrm_paymentprocessor_webhook', 'index_processed_date')) {
CRM_Core_DAO::executeQuery('ALTER TABLE `civicrm_paymentprocessor_webhook` ADD INDEX `index_processed_date` (`processed_date`)');
}
if (!CRM_Core_BAO_SchemaHandler::checkIfIndexExists('civicrm_paymentprocessor_webhook', 'index_identifier')) {
CRM_Core_DAO::executeQuery('ALTER TABLE `civicrm_paymentprocessor_webhook` ADD INDEX `index_identifier` (`identifier`)');
}
return TRUE;
}
public function upgrade_1003() {
$this->ctx->log->info('Make "message" field not required');
CRM_Core_DAO::executeQuery("ALTER TABLE civicrm_paymentprocessor_webhook MODIFY COLUMN `message` varchar(1024) DEFAULT '' COMMENT 'Stores data sent that is needed for processing. JSON suggested.'");
return TRUE;
}
}
......@@ -17,12 +17,12 @@ class CRM_Mjwshared_Utils {
/**
* Return the field ID for $fieldName custom field
*
* @param $fieldName
* @param $fieldGroup
* @param bool $fullString
* @param string $fieldName
* @param string $fieldGroup
* @param bool $fullString If TRUE return "custom_25", If FALSE return "25"
*
* @return mixed
* @throws \CiviCRM_API3_Exception
* @return int|string
* @throws \CRM_Core_Exception
*/
public static function getCustomByName($fieldName, $fieldGroup, $fullString = TRUE) {
if (!isset(Civi::$statics[__CLASS__][$fieldGroup][$fieldName])) {
......
<?php
namespace Civi\Api4\Action\ContributionRecur;
use Brick\Money\Money;
/**
* This API Action updates the contributionRecur and related entities (templatecontribution/lineitems)
* when a subscription is changed.
*
*/
class UpdateAmountOnRecurMJW extends \Civi\Api4\Generic\AbstractUpdateAction {
/**
*
* Note that the result class is that of the annotation below, not the hint
* in the method (which must match the parent class)
*
* @var \Civi\Api4\Generic\Result $result
*/
public function _run(\Civi\Api4\Generic\Result $result) {
if (!array_key_exists('amount', $this->values)) {
throw new \CRM_Core_Exception('Must specify amount');
}
foreach ($this->values as $key => $value) {
if ($key !== 'amount') {
throw new \CRM_Core_Exception('Only amount can be specified');
}
}
// Load the recurs.
$recurs = \Civi\Api4\ContributionRecur::get(FALSE)
->setWhere($this->where)
->execute()->indexBy('id');
foreach ($recurs as $recur) {
$subscription = new \Civi\MJW\Payment\Subscription();
$subscription->setRecur($recur);
if (empty($subscription->getRecur())) {
throw new \CRM_Core_Exception('RecurID is not valid!');
}
$newAmount = Money::of($this->values['amount'], $recur['currency']);
$recurResults[] = $subscription->updateRecurAndTemplateContributionAmount($newAmount);
}
$result->exchangeArray($recurResults ?? []);
return $result;
}
protected function updateRecords(array $items): array {
return $items;
}
}
<?php
namespace Civi\Api4\Action\Membership;
/**
* This API Action updates the contributionRecur and related entities (templatecontribution/lineitems)
* when a subscription is changed so they are not linked to a Membership.
*
*/
class LinkToRecurMJW extends \Civi\Api4\Generic\AbstractUpdateAction {
/**
* @var array field => REQUIRED
*/
private $whereFields = [
'id' => TRUE,
'contribution_recur_id' => FALSE,
'contribution_id' => FALSE,
];
/**
*
* Note that the result class is that of the annotation below, not the hint
* in the method (which must match the parent class)
*
* @var \Civi\Api4\Generic\Result $result
*/
public function _run(\Civi\Api4\Generic\Result $result) {
if (empty($this->values['id'])) {
throw new \CRM_Core_Exception('Must specify Membership ID (id)');
}
// One of contribution_recur_id or contribution_id is required.
if (empty($this->values['contribution_recur_id']) && (empty($this->values['contribution_id']))) {
throw new \CRM_Core_Exception('One of contribution_id or contribution_recur_id is required');
}
$membershipProcessor = new \Civi\MJW\Payment\Membership();
$entityIDs = $membershipProcessor->linkMembershipToRecur($this->values['id'], $this->values['contribution_id'] ?? NULL, $this->values['contribution_recur_id'] ?? NULL);
$result->exchangeArray($entityIDs);
return $result;
}
protected function updateRecords(array $items): array {
return $items;
}
}
<?php
namespace Civi\Api4\Action\Membership;
/**
* This API Action updates the contributionRecur and related entities (templatecontribution/lineitems)
* when a subscription is changed so they are not linked to a Membership
*
*/
class UnlinkFromRecurMJW extends \Civi\Api4\Generic\AbstractUpdateAction {
/**
* @var array field => REQUIRED
*/
private $whereFields = [
'id' => TRUE,
'contribution_recur_id' => FALSE,
'contribution_id' => FALSE,
];
/**
*
* Note that the result class is that of the annotation below, not the hint
* in the method (which must match the parent class)
*
* @var \Civi\Api4\Generic\Result $result
*/
public function _run(\Civi\Api4\Generic\Result $result) {
if (empty($this->values['id'])) {
throw new \CRM_Core_Exception('Must specify Membership ID (id)');
}
$membershipProcessor = new \Civi\MJW\Payment\Membership();
$entityIDs = $membershipProcessor->unlinkMembershipFromRecur($this->values['id'], $this->values['contribution_id'] ?? NULL, $this->values['contribution_recur_id'] ?? NULL);
$result->exchangeArray($entityIDs);
return $result;
}
protected function updateRecords(array $items): array {
return $items;
}
}
<?php
namespace Civi\Api4\Action\PaymentMJW;
use CRM_Mjwshared_ExtensionUtil as E;
use Civi\Api4\CustomField;
/**
* This API Action creates a payment. It is based on API3 Payment.create and API3 MJWPayment.create
*
*/
class Create extends \Civi\Api4\Generic\AbstractCreateAction {
public static function getCreateFields() {
// Basically a copy of _civicrm_api3_payment_create_spec;
$fields = [
[
'name' => 'contribution_id',
'required' => TRUE,
'description' => E::ts('Contribution ID'),
'data_type' => 'Integer',
'fk_entity' => 'Contribution',
'input_type' => 'EntityRef',
],
[
'name' => 'total_amount',
'required' => TRUE,
'description' => E::ts('Total Payment Amount'),
'data_type' => 'Float',
],
[
'name' => 'fee_amount',
'description' => E::ts('Fee Amount'),
'data_type' => 'Float',
],
[
'name' => 'payment_processor_id',
'data_type' => 'Integer',
'description' => E::ts('Payment Processor for this payment'),
'fk_entity' => 'PaymentProcessor',
'input_type' => 'EntityRef',
],
[
'name' => 'trxn_date',
'description' => E::ts('Payment Date'),
'data_type' => 'Datetime',
'default' => 'now',
'required' => TRUE,
],
[
'name' => 'is_send_contribution_notification',
'description' => E::ts('Send out notifications based on contribution status change?'),
'data_type' => 'Boolean',
'default' => TRUE,
],
[
'name' => 'payment_instrument_id',
'data_type' => 'Integer',
'description' => E::ts('Payment Method (FK to payment_instrument option group values)'),
'pseudoconstant' => [
'optionGroupName' => 'payment_instrument',
'optionEditPath' => 'civicrm/admin/options/payment_instrument',
],
],
[
'name' => 'card_type_id',
'data_type' => 'Integer',
'description' => E::ts('Card Type ID (FK to accept_creditcard option group values)'),
'pseudoconstant' => [
'optionGroupName' => 'accept_creditcard',
'optionEditPath' => 'civicrm/admin/options/accept_creditcard',
],
],
[
'name' => 'trxn_result_code',
'data_type' => 'String',
'description' => E::ts('Transaction Result Code'),
],
[
'name' => 'trxn_id',
'data_type' => 'String',
'description' => E::ts('Transaction ID supplied by external processor. This may not be unique.'),
],
[
'name' => 'order_reference',
'data_type' => 'String',
'description' => E::ts('Payment Processor external order reference'),
],
[
'name' => 'check_number',
'data_type' => 'String',
'description' => E::ts('Check Number'),
],
[
'name' => 'pan_truncation',
'type' => 'String',
'description' => E::ts('PAN Truncation (Last 4 digits of credit card)'),
],
];
$customFields = CustomField::get(FALSE)
->addSelect('custom_group_id:name', 'name', 'label', 'data_type')
->addWhere('custom_group_id.extends', '=', 'FinancialTrxn')
->execute();
foreach ($customFields as $customField) {
$customField['name'] = $customField['custom_group_id:name'] . '.' . $customField['name'];
unset($customField['id'], $customField['custom_group_id:name']);
$customField['description'] = $customField['label'];
$fields[] = $customField;
}
return $fields;
}
public function fields(): array {
return self::getCreateFields();
}
/**
*
* Note that the result class is that of the annotation below, not the h
* in the method (which must match the parent class)
*
* @var \Civi\Api4\Generic\Result $result
*/
public function _run(\Civi\Api4\Generic\Result $result) {
$trxn = \CRM_Financial_BAO_Payment::create($this->values);
$customFields = CustomField::get(FALSE)
->addSelect('id', 'custom_group_id:name', 'name', 'label', 'data_type')
->addWhere('custom_group_id.extends', '=', 'FinancialTrxn')
->execute();
foreach ($customFields as $customField) {
$key = $customField['custom_group_id:name'] . '.' . $customField['name'];
if (isset($this->values[$key])) {
$customParams['custom_' . $customField['id']] = $this->values[$key];
}
}
if (!empty($customParams)) {
$customParams['entity_id'] = $trxn->id;
civicrm_api3('CustomValue', 'create', $customParams);
}
$result->exchangeArray($trxn);
return $result;
}
}
<?php
namespace Civi\Api4\Action\PriceFieldValue;
use Civi\Api4\PriceField;
use Civi\Api4\PriceFieldValue;
/**
* This API Action gets the default price_field_value_id for a contribution
*
*/
class GetDefaultPriceFieldValueForContributionMJW extends \Civi\Api4\Generic\AbstractQueryAction {
/**
*
* Get the default price_field_id and price_field_value_id for a contribution
*
* $result = ['price_field_id' = X, 'price_field_value_id' = Y, 'label' = price_field_value.label]
*
* Note that the result class is that of the annotation below, not the hint
* in the method (which must match the parent class)
*
* @var \Civi\Api4\Generic\Result $result
*/
public function _run(\Civi\Api4\Generic\Result $result) {
$priceSetName = 'default_contribution_amount';
$priceField = PriceField::get(FALSE)
->addWhere('price_set_id:name', '=', $priceSetName)
->addOrderBy('id', 'ASC')
->execute()
->first();
// Now get the relevant PriceFieldValue for the MembershipType.
$priceFieldValue = PriceFieldValue::get(FALSE)
->addWhere('price_field_id', '=', $priceField['id'])
->execute()
->first();
$results = [
'price_field_id' => $priceField['id'],
'price_field_value_id' => $priceFieldValue['id'],
'label' => $priceFieldValue['label'],
];
$result->exchangeArray($results);
return $result;
}
}
<?php
namespace Civi\Api4\Action\PriceFieldValue;
use Civi\Api4\PriceField;
use Civi\Api4\PriceFieldValue;
/**
* This API Action gets the default price_field_value_id for the specified membership
*
* @property int $membershipID
*/
class GetDefaultPriceFieldValueForMembershipMJW extends \Civi\Api4\Generic\AbstractAction {
/**
* The Membership ID
*
* @var int
* @required
*/
protected int $membershipID = 0;
/**
*
* Get the default price_field_id and price_field_value_id for the membership
*
* $result = ['price_field_id' = X, 'price_field_value_id' = Y, 'label' = price_field_value.label]
*
* Note that the result class is that of the annotation below, not the hint
* in the method (which must match the parent class)
*
* @var \Civi\Api4\Generic\Result $result
*/
public function _run(\Civi\Api4\Generic\Result $result) {
if (empty($this->membershipID)) {
throw new \CRM_Core_Exception('Membership ID is required');
}
// First we need membership_type_id.member_of_contact_id and membership_type_id to find the PriceFieldValue.
$membership = \Civi\Api4\Membership::get(FALSE)
->addSelect('membership_type_id.member_of_contact_id', 'membership_type_id')
->addWhere('id', '=', $this->membershipID)
->execute()
->first();
// PriceFields and Membership Types...
// There is a default PriceSet for contributions with name="default_contribution_amount"
// There is a default PriceSet for memberships with name="default_membership_type_amount"
// Each MembershipType has a "member_of_contact_id" which gets used as a FK reference in PriceField.name
// Each MembershipType has a PriceFieldValue with PriceFieldValue.membership_type_id = Membership Type
// Why all this? Well we could (and probably do) have multiple PriceSets that include the same MembershipType but
// different prices. But only one of them will be linked to the default PriceSet.
// Confused yet?
// Get the default price field ID for memberships from the default membership PriceSet and the member_of_contact_id
$priceField = PriceField::get(FALSE)
->addWhere('price_set_id:name', '=', 'default_membership_type_amount')
->addWhere('name', '=', $membership['membership_type_id.member_of_contact_id'])
->addOrderBy('id', 'ASC')
->execute()
->first();
// Now get the relevant PriceFieldValue for the MembershipType.
$priceFieldValue = PriceFieldValue::get(FALSE)
->addWhere('price_field_id', '=', $priceField['id'])
->addWhere('membership_type_id', '=', $membership['membership_type_id'])
->execute()
->first();
$results = [
'price_field_id' => $priceField['id'],
'price_field_value_id' => $priceFieldValue['id'],
'label' => $priceFieldValue['label'],
];
$result->exchangeArray($results);
return $result;
}
}
<?php
namespace Civi\Api4;
/**
* A collection of system maintenance/diagnostic utilities.
*
* @searchable none
* @since 5.19
* @package Civi\Api4
*/
class PaymentMJW extends Generic\AbstractEntity {
/**
* @param bool $checkPermissions
* @return Generic\BasicGetFieldsAction
*/
public static function getFields($checkPermissions = TRUE) {
return (new Generic\BasicGetFieldsAction(__CLASS__, __FUNCTION__, function() {
return [];
}))->setCheckPermissions($checkPermissions);
}
}
<?php
namespace Civi\Api4;
/**
* PaymentprocessorWebhook entity.
*
* Provided by the Payment Shared extension.
*
* @package Civi\Api4
*/
class PaymentprocessorWebhook extends Generic\DAOEntity {
}
<?php
namespace Civi\MJW;
/**
* Abstracts the Civi::log class so that we:
* - Don't have to specify channel each time we log.
* - Automatically log the entityID/name if provided.
*/
class Logger {
/**
* The Log channel
*
* @var string
*/
private string $logChannel = '';
/**
* The entityID/Name
*
* @var string
*/
private string $logEntity = '';
public function __construct(string $logChannel, string $logEntity) {
$this->logChannel = $logChannel;
$this->logEntity = $logEntity;
}
/**
* Log an info message with payment processor prefix
* @param string $message
*
* @return void
*/
public function logInfo(string $message) {
$this->log('info', $message);
}
/**
* Log an error message with payment processor prefix
*
* @param string $message
*
* @return void
*/
public function logError(string $message) {
$this->log('error', $message);
}
/**
* Log a debug message with payment processor prefix
*
* @param string $message
*
* @return void
*/
public function logDebug(string $message) {
$this->log('debug', $message);
}
/**
* @param string $level
* @param string $message
*
* @return void
*/
private function log(string $level, string $message) {
$channel = $this->logChannel;
$prefix = $channel . '(' . $this->logEntity . '): ';
\Civi::log($channel)->$level($prefix . $message);
}
}
\ No newline at end of file
<?php
namespace Civi\MJW\Payment;
use Civi\Api4\Contribution;
use Civi\Api4\ContributionRecur;
use Civi\Api4\LineItem;
use Civi\Core\Service\AutoService;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
/**
* @internal
* @service
*/
class Membership extends AutoService implements EventSubscriberInterface {
/**
* @return array
*/
public static function getSubscribedEvents() {
return [
'hook_civicrm_pre' => ['on_hook_civicrm_pre', 150],
];
}
/**
* Force setting entity_table / entity_id in LineItem.create (for update)
*
* @param \Civi\Core\Event\PreEvent $event
*
* @return void
*/
public function on_hook_civicrm_pre(\Civi\Core\Event\PreEvent $event) {
if ($event->entity !== 'LineItem' || $event->action !== 'edit') {
return;
}
// API3 LineItem.create removes entity_table/entity_id before saving.
// We add them after LineItem.create removed them but before save!
if (isset($event->params['entity_table_force'])) {
$event->params['entity_table'] = $event->params['entity_table_force'];
unset($event->params['entity_table_force']);
}
if (isset($event->params['entity_id_force'])) {
$event->params['entity_id'] = $event->params['entity_id_force'];
unset($event->params['entity_id_force']);
}
}
/**
* Update and Link Membership to Contribution LineItem and Recur
*
* @param int $membershipID
* @param int|null $contributionID
* @param int|null $contributionRecurID
*
* @return array The various entity IDs
* @throws \CRM_Core_Exception
* @throws \Civi\API\Exception\UnauthorizedException
*/
public function linkMembershipToRecur(int $membershipID, $contributionID = NULL, $contributionRecurID = NULL): array {
// Get contribution ID from the recur if possible
if (empty($contributionID) && !empty($contributionRecurID)) {
$contributionID = \CRM_Contribute_BAO_ContributionRecur::ensureTemplateContributionExists($contributionRecurID);
}
// First the simple bit! Link the membership to the contributionRecur record if we have one
if ($contributionRecurID) {
\Civi\Api4\Membership::update(FALSE)
->addWhere('id', '=', $membershipID)
->addValue('contribution_recur_id', $contributionRecurID)
->execute()
->first();
}
// Now we need to update the LineItems on the contribution so that renewals etc. work properly.
$priceFieldValues = \Civi\Api4\PriceFieldValue::getDefaultPriceFieldValueForMembershipMJW(FALSE)
->setMembershipID($membershipID)
->execute();
// Now get the LineItems for the contribution
$lineItems = LineItem::get(FALSE)
->addWhere('contribution_id', '=', $contributionID)
->execute();
if ($lineItems->count() > 1) {
throw new \CRM_Core_Exception('ContributionID: ' . $contributionID . ' has more than one lineitem. Not linking membership as I don\'t know what to do');
// @todo: Maybe search them for a LineItem matching this membership type?
}
// Get the membership FinancialType so we can update the Contribution,Recur and LineItem.
$membership = \Civi\Api4\Membership::get(FALSE)
->addSelect('membership_type_id.financial_type_id')
->addWhere('id', '=', $membershipID)
->execute()
->first();
// Update the contributionRecur with the new FinancialType
if (!empty($contributionRecurID)) {
ContributionRecur::update(FALSE)
->addWhere('id', '=', $contributionRecurID)
->addValue('financial_type_id', $membership['membership_type_id.financial_type_id'])
->execute();
}
// Update the contribution with the new FinancialType
if (!empty($contributionID)) {
Contribution::update(FALSE)
->addWhere('id', '=', $contributionID)
->addValue('financial_type_id', $membership['membership_type_id.financial_type_id'])
->execute();
}
// Finally, Update the LineItem to map to a membership
civicrm_api3('LineItem', 'create', [
'entity_id' => $membershipID,
'id' => $lineItems->first()['id'],
'entity_table' => 'civicrm_membership',
'price_field_id' => $priceFieldValues['price_field_id'],
'price_field_value_id' => $priceFieldValues['price_field_value_id'],
'label' => $priceFieldValues['label'],
'financial_type_id' => $membership['membership_type_id.financial_type_id'],
// API3 LineItem.create removes entity_table/entity_id before saving.
// We add them here so we can add them back in using hook_civicrm_pre (implemented in on_hook_civicrm_pre).
'entity_table_force' => 'civicrm_membership',
'entity_id_force' => $membershipID,
]);
return [
'lineItemID' => $lineItems->first()['id'],
'contributionRecurID' => $contributionRecurID ?? NULL,
'contributionID' => $contributionID,
'membershipID' => $membershipID,
'action' => 'link',
];
}
/**
* Update and Link Membership to Contribution LineItem and Recur
*
* @param int $membershipID
* @param int|null $contributionID
* @param int|null $contributionRecurID
*
* @return array The various entity IDs
* @throws \CRM_Core_Exception
* @throws \Civi\API\Exception\UnauthorizedException
*/
public function unlinkMembershipFromRecur(int $membershipID, $contributionID = NULL, $contributionRecurID = NULL): array {
if (empty($contributionID) && empty($contributionRecurID)) {
$contributionRecurID = \Civi\Api4\Membership::get(FALSE)
->addSelect('contribution_recur_id')
->addWhere('id', '=', $membershipID)
->execute()
->first()['contribution_recur_id'];
if (empty($contributionRecurID)) {
throw new \CRM_Core_Exception('Membership ' . $membershipID . ' does not have a contribution_recur_id. Cannot unlink!');
}
}
// Get contribution ID from the recur if possible
if (empty($contributionID) && !empty($contributionRecurID)) {
$contributionID = \CRM_Contribute_BAO_ContributionRecur::ensureTemplateContributionExists($contributionRecurID);
}
// First the simple bit! Remove the contribution_recur_id from the membership
if ($contributionRecurID) {
\Civi\Api4\Membership::update(FALSE)
->addWhere('id', '=', $membershipID)
->addValue('contribution_recur_id', NULL)
->execute()
->first();
}
// Now we need to update the LineItems on the contribution so that renewals etc. work properly.
$priceFieldValues = \Civi\Api4\PriceFieldValue::getDefaultPriceFieldValueForContributionMJW(FALSE)->execute();
// Now get the LineItems for the contribution
$lineItems = LineItem::get(FALSE)
->addWhere('contribution_id', '=', $contributionID)
->execute();
if ($lineItems->count() > 1) {
throw new \CRM_Core_Exception('ContributionID: ' . $contributionID . ' has more than one lineitem. Not unlinking membership as I don\'t know what to do');
// @todo: Maybe search them for a LineItem matching this membership type?
}
// @todo Maybe change the financial type on contribution/recur to default / "Donation".
// But not sure what would be best as we don't have an obvious default (when linking we use Membership financialType).
// Finally, Update the LineItem to map to a contribution
$newLineItem = civicrm_api3('LineItem', 'create', [
'entity_id' => $contributionID,
'id' => $lineItems->first()['id'],
'entity_table' => 'civicrm_contribution',
'price_field_id' => $priceFieldValues['price_field_id'],
'price_field_value_id' => $priceFieldValues['price_field_value_id'],
'label' => $priceFieldValues['label'],
// 'financial_type_id' => $membership['membership_type_id.financial_type_id'],
// API3 LineItem.create removes entity_table/entity_id before saving.
// We add them here so we can add them back in using hook_civicrm_pre (implemented in on_hook_civicrm_pre).
'entity_table_force' => 'civicrm_contribution',
'entity_id_force' => $contributionID,
]);
return [
'lineItemID' => $lineItems->first()['id'],
'contributionRecurID' => $contributionRecurID ?? NULL,
'contributionID' => $contributionID,
'membershipID' => $membershipID,
'action' => 'unlink',
];
}
}
<?php
namespace Civi\MJW\Payment;
use Brick\Money\Money;
use Civi\Api4\Contribution;
use Civi\Api4\ContributionRecur;
class Subscription {
/**
* The ContributionRecur as retrieved by API4.
*
* @var array
*/
protected array $recur;
public function setRecur(array $recur) {
$this->recur = $recur;
}
public function getRecur(): array {
return $this->recur;
}
/**
* Updates the ContributionRecur entity, and if a template contribution
* exists, update that along with its single line item.
*
* @param \Brick\Money\Money $newAmount
*
* @return array
* @throws \Brick\Money\Exception\MoneyMismatchException
* @throws \Brick\Money\Exception\UnknownCurrencyException
* @throws \CRM_Core_Exception
* @throws \Civi\API\Exception\UnauthorizedException
* @throws \Civi\Core\Exception\DBQueryException
*/
public function updateRecurAndTemplateContributionAmount(Money $newAmount): array {
$recur = $this->getRecur();
if (empty($recur)) {
throw new \CRM_Core_Exception('Must set recur first');
}
// Check if amount is the same
if (Money::of($recur['amount'], $recur['currency'])->compareTo($newAmount) === 0) {
\Civi::log()->debug('nothing to do. Amount is already the same');
return $recur;
}
// Get the template contribution
// Calling ensureTemplateContributionExists will *always* return a template contribution
// Either it will have created one or will return the one that already exists.
$templateContributionID = \CRM_Contribute_BAO_ContributionRecur::ensureTemplateContributionExists($recur['id']);
// Now we update the template contribution with the new details
// This will automatically update the Contribution LineItems as well.
Contribution::update(FALSE)
->addValue('id', $templateContributionID)
->addValue('total_amount', $newAmount->getAmount()->toFloat())
->addWhere('id', '=', $templateContributionID)
->execute()
->first();
// Update the recur
// If we update a template contribution the recur will automatically be updated
// (see CRM_Contribute_BAO_Contribution::self_hook_civicrm_post)
// We need to make sure we updated the template contribution first because
// CRM_Contribute_BAO_ContributionRecur::self_hook_civicrm_post will also try to update it.
$this->setRecur(ContributionRecur::get(FALSE)
->addWhere('id', '=', $recur['id'])
->execute()
->first());
return $this->getRecur();
}
}