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
Showing
with 775 additions and 113 deletions
<?php
namespace Civi\PaymentprocessorWebhook;
use Civi\Core\Service\AutoSubscriber;
use CRM_Mjwshared_ExtensionUtil as E;
class Tasks extends AutoSubscriber {
public static function getSubscribedEvents() {
return [
'&hook_civicrm_searchKitTasks' => 'onSearchKitTasks',
];
}
public function onSearchKitTasks(array &$tasks, bool $checkPermissions, ?int $userID): void {
$tasks['PaymentprocessorWebhook']['retry'] = [
'title' => E::ts('Retry Paymentprocessor Webhooks'),
'icon' => 'fa-rectangle-refresh',
'apiBatch' => [
'action' => 'update',
'params' => ['values' => ['status' => 'new', 'processed_date' => NULL]],
'confirmMsg' => E::ts('Schedule retry for %1 %2.'),
'runMsg' => E::ts('Scheduling retry for %1 %2...'),
'successMsg' => E::ts('%1 %2 have been scheduled for retry (will retry next time scheduled jobs are run).'),
'errorMsg' => E::ts('An error occurred while attempting to schedule retry for %1 %2.'),
],
];
}
}
Package: mjwshared
Copyright (C) 2021, Matthew Wire (MJW Consulting) <mjw@mjwconsult.co.uk>
Copyright (C) 2025, Matthew Wire (MJW Consulting) <mjw@mjwconsult.co.uk>
Licensed under the GNU Affero Public License 3.0 (below).
-------------------------------------------------------------------------------
......
# mjwshared
# Payment Shared library
This extension does nothing by itself but is required by a number of other extensions developed by MJW Consulting.
This library is used by all payment processors by MJW Consulting and other extensions.
It provides multiple functions such as APIs, refund UI, shared code and a compatibility layer to support multiple versions of CiviCRM without requiring explicit support in the payment processor.
The extension is licensed under [AGPL-3.0](LICENSE.txt).
**Always read the [Release Notes](https://lab.civicrm.org/extensions/mjwshared/blob/master/docs/release/release_notes.md) carefully before upgrading!**
**Always read the [Release Notes](https://lab.civicrm.org/extensions/mjwshared/blob/master/docs/releasenotes.md) carefully before upgrading!**
## Installation
......
<div af-fieldset="">
<crm-search-display-list search-name="Paymentprocessor_Webhook_Detail" display-name="Paymentprocessor_Webhook_Detail" filters="{id: routeParams.id}"></crm-search-display-list>
</div>
<?php
use CRM_Mjwshared_ExtensionUtil as E;
return [
'type' => 'search',
'title' => E::ts('Payment Processor Webhook Detail'),
'icon' => 'fa-list-alt',
'server_route' => 'civicrm/paymentprocessorwebhooks/detail',
'permission' => [
'edit contributions',
],
];
<div af-fieldset="">
<div class="af-container af-layout-inline">
<af-field name="status" />
<af-field name="payment_processor_id" defn="{input_attrs: {multiple: true}}" />
<af-field name="event_id" />
<af-field name="identifier" />
<af-field name="message" />
<af-field name="data" />
<af-field name="created_date" defn="{input_type: 'Select', search_range: true, input_attrs: {}}" />
<af-field name="processed_date" defn="{input_type: 'Select', search_range: true, input_attrs: {}}" />
</div>
<crm-search-display-table search-name="Paymentprocessor_Webhook_Search" display-name="Paymentprocessor_Webhook_Search"></crm-search-display-table>
</div>
<?php
use CRM_Mjwshared_ExtensionUtil as E;
return [
'type' => 'search',
'title' => E::ts('Payment Processor Webhooks'),
'icon' => 'fa-list-alt',
'server_route' => 'civicrm/paymentprocessorwebhooks',
'permission' => [
'edit contributions',
],
];
<?php
// This file declares an Angular module which can be autoloaded
// in CiviCRM. See also:
// \https://docs.civicrm.org/dev/en/latest/hooks/hook_civicrm_angularModules/n
return [
'js' => [
'ang/mjwshared.js',
'ang/mjwshared/*.js',
'ang/mjwshared/*/*.js',
],
'css' => [
'ang/mjwshared.css',
],
'partials' => [
'ang/mjwshared',
],
'requires' => [
'crmUi',
'crmUtil',
'ngRoute',
],
'settings' => [],
];
/* Add any CSS rules for Angular module "mjwshared" */
#paymentprocessor-webhooks-table td.success { color: #080; }
#paymentprocessor-webhooks-table td.error { color: #800; }
#paymentprocessor-webhooks-table td.processing { color: #048; }
#paymentprocessor-webhooks-table td.new { color: #08a; }
#paymentprocessor-webhooks-table tbody>tr { position: relative; }
#paymentprocessor-webhooks-table tbody>tr.selected { position: relative; background-color: white; }
/* Hack for Greenwich theme which sets color white */
#paymentprocessor-webhooks-table tbody>tr.selected td { color: inherit; background-color: inherit; }
/*
* the following fixes Greenwich but will break other themes...
#paymentprocessor-webhooks-table tbody>tr.selected td a:link,
#paymentprocessor-webhooks-table tbody>tr.selected td a:visited,
#paymentprocessor-webhooks-table tbody>tr.selected td a:active { color: inherit; background-color: inherit; }
*/
#paymentprocessor-webhooks-table div.details { position: absolute; left: 0; right: 0; top: 100%; background: white; padding: 1.6rem; z-index: 1; box-shadow: 0 1rem 2rem rgba(0,0,0,0.4); }
#paymentprocessor-webhooks-table div.details .message { padding: 1.6rem; background: #fafafa; white-space: pre-wrap; font-size: 0.875em;}
#paymentprocessor-webhooks-table div.details .raw { padding: 1.6rem; background: #fafafa; white-space: pre-wrap; font-size: 0.875em; color: #444; line-height: 1.3; overflow: auto; font-family: monospace, monospace; }
#paymentprocessor-webhooks div.pager {
display: flex;
justify-content: space-between;
}
#paymentprocessor-webhooks div.pager-info {
flex: 0 1 auto;
}
#paymentprocessor-webhooks div.pager-buttons {
flex: 1 1 13em;
text-align: right;
}
#paymentprocessor-webhooks div.pager-buttons button + button {
margin-left: 2rem;
display: inline-block;
}
(function(angular, $, _) {
// Declare a list of dependencies.
angular.module('mjwshared', CRM.angRequires('mjwshared'));
})(angular, CRM.$, CRM._);
<div class="crm-container" id="paymentprocessor-webhooks">
<h1 crm-page-title>{{ts('Payment Processor Webhooks')}}</h1>
<form name="myForm" crm-ui-id-scope>
<div crm-ui-accordion="{title: ts('Filters')}">
<div class="crm-block">
<div class="crm-group">
<div crm-ui-field="{name: 'myForm.statuses', title: ts('Status')}">
<select type="text" ng-model="$ctrl.statusFilter" >
<option value="">{{ts('- Any -')}}</option>
<option value="new">{{ts('New')}}</option>
<option value="processing">{{ts('Processing')}}</option>
<option value="success">{{ts('Success')}}</option>
<option value="error">{{ts('Error')}}</option>
</select>
</div>
<div crm-ui-field="{name: 'myForm.processor', title: ts('Processor')}">
<select type="text" ng-model="$ctrl.processorFilter" >
<option value="">{{ts('- Any -')}}</option>
<option ng-repeat="processor in $ctrl.processors"
value="{{processor.id}}"
>{{processor.processorType}}:
{{processor.processorName}}:
{{:: processor.isTest ? ts('Test') : ts('Live') }}
({{ts('%count webhooks', {count:processor.webhooksCount}) }})
</option>
</select>
</div>
<div crm-ui-field="{name: 'myForm.event', title: ts('Event ID')}">
<input
crm-ui-id="myForm.event_id"
name="event_id"
ng-model="$ctrl.eventFilter"
class="crm-form-text"
/>
</div>
<div crm-ui-field="{name: 'myForm.identifier', title: ts('Search identifier')}">
<input
crm-ui-id="myForm.identifier"
name="identifier"
ng-model="$ctrl.identifierFilter"
class="crm-form-text"
/>
</div>
<div crm-ui-field="{name: 'myForm.raw', title: ts('Search raw data')}">
<input
crm-ui-id="myForm.raw"
name="raw"
ng-model="$ctrl.rawFilter"
class="crm-form-text"
/>
</div>
</div>
</div>
<div class="pager">
<div class="pager-info">Showing {{$ctrl.offset+1}} &ndash; {{$ctrl.offset + $ctrl.events.length}} of {{$ctrl.resultsCount}} </div>
<div class="pager-buttons">
<button
ng-if="$ctrl.offset > 0"
ng-click="$ctrl.changePage(-1)"
>{{ts('Previous page')}}</button>
<button
ng-if="$ctrl.offset + $ctrl.limit < $ctrl.resultsCount"
ng-click="$ctrl.changePage(1)"
>{{ts('Next page')}}</button>
<button ng-click="$ctrl.load()">{{ts('Search / Reload')}}</button>
</div>
</div>
</div>
</form>
<table id="paymentprocessor-webhooks-table" >
<thead>
<tr>
<th>Status</th>
<th>Time</th>
<th>Processor</th>
<th>Event ID</th>
<th>Message</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr ng-repeat="(idx, row) in $ctrl.events" ng-class="{selected: (row.id == $ctrl.selectedRow)}">
<td ng-class="row['status'] + ' text-center'" title="{{ $ctrl.statusMap[row['status']].title }}">{{ $ctrl.statusMap[row['status']].content }}</td>
<td><span title="Date received">{{row['created_date']}}</span> <span ng-if="row['processed_date']" title="Date processed">| {{row['processed_date']}}</span></td>
<td><span title="Test processor" ng-if="row['payment_processor.is_test'] == 1" >🧪 </span>{{row['payment_processor.name']}}</td>
<td>{{row['event_id']}}</td>
<td>{{$ctrl.abbreviate(row['message'])}}</td>
<td>
<a href ng-click="$ctrl.selectedRow = row.id" ng-if="$ctrl.selectedRow != row.id">Details</a>
<a href ng-click="$ctrl.selectedRow = null" ng-if="$ctrl.selectedRow == row.id">Hide details</a> &nbsp;
<a href ng-click="$ctrl.delete(row.id)">Delete</a> &nbsp;
<a href ng-click="$ctrl.retry(row.id)">Retry</a>
<div class="details" ng-if="$ctrl.selectedRow == row.id">
<p>
Payment Processor:
<em>{{row['payment_processor.name']}}</em>
(<strong ng-if="row['payment_processor.is_test'] == 1" >Test</strong><strong ng-if="row['payment_processor.is_test'] != 1" >Live</strong>
ID: <code>{{row['payment_processor_id']}}</code>)
</p>
<p>Status: {{ $ctrl.statusMap[row['status']].title }}</p>
<p>
Identifier: <code>{{row.identifier}}</code>
Type: <code>{{row.trigger}}</code>
</p>
<p>Full message:</p>
<div class="message">{{row.message || '(none)'}}</div>
<p>
Raw data:
</p>
<div class="raw">{{row.data}}</div>
</div>
</td>
</tr>
</tbody>
</table>
<br />
<!-- this is repeated code, @todo tidy into separate directive? -->
<div class="pager">
<div class="pager-info">Showing {{$ctrl.offset+1}} &ndash; {{$ctrl.offset + $ctrl.events.length}} of {{$ctrl.resultsCount}} </div>
<div class="pager-buttons">
<button
ng-if="$ctrl.offset > 0"
ng-click="$ctrl.changePage(-1)"
>{{ts('Previous page')}}</button>
<button
ng-if="$ctrl.offset + $ctrl.limit < $ctrl.resultsCount"
ng-click="$ctrl.changePage(1)"
>{{ts('Next page')}}</button>
<button ng-click="$ctrl.load()">{{ts('Search / Reload')}}</button>
</div>
</div>
</div>
(function(angular, $, _) {
angular.module("mjwshared").config(function($routeProvider) {
$routeProvider.when("/paymentprocessorWebhook", {
controller: "MjwsharedPaymentprocessorWebhook",
controllerAs: "$ctrl",
templateUrl: "~/mjwshared/PaymentprocessorWebhook.html",
// If you need to look up data when opening the page, list it out
// under "resolve".
resolve: {
processors: function(crmApi4) {
return crmApi4("PaymentProcessor", "get", {
select: [
"id",
"MIN(name) AS processorName",
"MIN(payment_processor_type_id:label) AS processorType",
"MIN(is_test) AS isTest",
"COUNT(paymentprocessor_webhook.id) AS webhooksCount"
],
join: [
[
"PaymentprocessorWebhook AS paymentprocessor_webhook",
"LEFT",
["id", "=", "paymentprocessor_webhook.payment_processor_id"]
]
],
groupBy: ["id"],
where: [["is_test", "IS NOT EMPTY"]],
orderBy: {
"payment_processor_type_id:label": "ASC",
is_test: "ASC"
}
});
}
}
});
});
// The controller uses *injection*. This default injects a few things:
// $scope -- This is the set of variables shared between JS and HTML.
// crmApi, crmStatus, crmUiHelp -- These are services provided by civicrm-core.
// myContact -- The current contact, defined above in config().
angular
.module("mjwshared")
.controller("MjwsharedPaymentprocessorWebhook", function(
$scope,
crmApi4,
crmStatus,
crmUiHelp,
processors
) {
// The ts() and hs() functions help load strings for this module.
var ts = ($scope.ts = CRM.ts("mjwshared"));
var hs = ($scope.hs = crmUiHelp({
file: "CRM/mjwshared/PaymentprocessorWebhook"
})); // See: templates/CRM/mjwshared/PaymentprocessorWebhook.hlp
// Local variable for this controller (needed when inside a callback fn where `this` is not available).
var ctrl = this;
this.processors = processors;
this.events = [];
this.statusFilter = "";
this.processorFilter = "";
this.eventFilter = "";
this.rawFilter = "";
this.identifierFilter = "";
this.paymentProcessor = [];
this.offset = 0;
this.limit = 25;
this.selectedRow = null;
this.resultsCount = 0;
this.lastQuery = "";
this.statusMap = {
new: {
content: "🔵 " + ts("New"),
title: ts("This event is awaiting processing")
},
processing: {
content: "🟡 " + ts("Processing"),
title: ts(
"This event is currently being processed by the Scheduled Job"
)
},
success: {
content: "🟢 " + ts("Success"),
title: ts("This event was successfully processed.")
},
error: {
content: "🔴 " + ts("Error"),
title: ts("There was an error processing this event.")
}
};
this.abbreviate = function(text) {
if (text === null) return "";
return text.replace(/^(.{80}).+$/s, "$1 ...");
};
this.load = function() {
const params = {
select: [
"*",
"row_count",
"payment_processor.name",
"payment_processor.is_test"
],
join: [
[
"PaymentProcessor AS payment_processor",
true,
null,
["payment_processor_id", "=", "payment_processor.id"]
]
],
where: [],
orderBy: { id: "DESC" }
};
if (ctrl.statusFilter) {
params.where.push(["status", "=", ctrl.statusFilter]);
}
if (ctrl.processorFilter) {
params.where.push([
"payment_processor_id",
"=",
ctrl.processorFilter
]);
}
if (ctrl.eventFilter) {
params.where.push(["event_id", "LIKE", "%" + ctrl.eventFilter + "%"]);
}
if (ctrl.identifierFilter) {
params.where.push([
"identifier",
"LIKE",
"%" + ctrl.identifierFilter + "%"
]);
}
if (ctrl.rawFilter) {
params.where.push(["data", "LIKE", "%" + ctrl.rawFilter + "%"]);
}
// If we've changed the query, then start from the top again.
if (JSON.stringify(params) !== ctrl.lastQuery) {
ctrl.offset = 0;
ctrl.lastQuery = JSON.stringify(params);
}
// Handle paging.
Object.assign(params, {
offset: ctrl.offset,
limit: ctrl.limit
});
return crmStatus(
// Status messages. For defaults, just use "{}"
{ start: ts("Loading..."), success: ts("Loaded") },
crmApi4("PaymentprocessorWebhook", "get", params).then(r => {
ctrl.resultsCount = r.count;
ctrl.events = r.map(row => {
// See if we can pretty up the raw data.
if (row.data) {
try {
const parsed = JSON.parse(row.data);
if (parsed) {
row.data = JSON.stringify(parsed, null, 2);
}
} catch (e) {}
}
return row;
});
})
);
};
this.changePage = function(dir) {
let newOffset = Math.min(
Math.max(ctrl.offset + dir * ctrl.limit, 0),
ctrl.resultsCount - 1
);
// console.log({newOffset, o: ctrl.offset, dir});
if (newOffset != ctrl.offset) {
ctrl.offset = newOffset;
ctrl.load();
}
};
this.delete = function(id) {
if (!(parseInt(id) > 0)) return;
if (
!confirm(
ts(
"Deleting a received webhook event is not un-do-able, and you may not be able to generate it again. Are you sure?"
)
)
) {
return;
}
return crmStatus(
{ start: ts("Deleting..."), success: ts("Gone") },
crmApi4("PaymentprocessorWebhook", "delete", {
where: [["id", "=", id]],
limit: 1
})
).then(r => {
// Reload the page.
return ctrl.load();
});
};
this.retry = function(id) {
if (!(parseInt(id) > 0)) return;
if (
!confirm(
ts(
"Retrying an event could cause bad things to happen, depending on the event and the processor, so please be confident in your understanding of both. Schedule retry of this event?"
)
)
) {
return;
}
return crmStatus(
// Status messages. For defaults, just use "{}"
{ start: ts("Updating..."), success: ts("Updated") },
crmApi4("PaymentprocessorWebhook", "update", {
where: [["id", "=", id]],
values: {
status: "new",
processed_date: null,
message: ts("Scheduled for retry")
},
limit: 1
})
).then(r => {
// Reload the page.
return ctrl.load();
});
};
this.load();
});
})(angular, CRM.$, CRM._);
......@@ -6,7 +6,7 @@
*
* @return array
* API result array.
* @throws CiviCRM_API3_Exception
* @throws CRM_Core_Exception
*/
function civicrm_api3_contribution_getbalance($params) {
$result['id'] = $params['id'];
......
......@@ -18,60 +18,100 @@ use Civi\Api4\PaymentprocessorWebhook;
*
* @return array
* API result array.
* @throws CiviCRM_API3_Exception
* @throws CRM_Core_Exception
*/
function civicrm_api3_job_process_paymentprocessor_webhooks($params) {
// @fixme: remove when minversion = 5.38
// API4 changed autoJoinFK format in 5.38
// See https://github.com/civicrm/civicrm-core/pull/20130
$joinKey = 'payment_processor_id';
if (version_compare(\CRM_Utils_System::version(), '5.38', '<')) {
$joinKey = 'payment_processor';
}
if ($params['delete_old'] !== 0 && !empty($params['delete_old'])) {
// Delete all locally recorded webhooks that are older than 3 months
PaymentprocessorWebhook::delete()
->setCheckPermissions(FALSE) // Replace with ::update(FALSE) when minversion = 5.29
$oldWebhooksCount = PaymentprocessorWebhook::get(FALSE)
->selectRowCount()
->addWhere("{$joinKey}.domain_id", '=', CRM_Core_Config::domainID())
->addWhere('created_date', '<', $params['delete_old'])
->execute();
->execute()
->count();
if (!empty($oldWebhooksCount)) {
PaymentprocessorWebhook::delete(FALSE)
->addWhere("{$joinKey}.domain_id", '=', CRM_Core_Config::domainID())
->addWhere('created_date', '<', $params['delete_old'])
->execute();
}
}
// Get the Webhook Events to process
// This is domain specific (as entities such as membershipType are domain-specific we must process per-domain).
$paymentProcessorWebhooks = PaymentprocessorWebhook::get(FALSE)
->addWhere("{$joinKey}.domain_id", '=', CRM_Core_Config::domainID());
if (!empty($params['id'])) {
// Allow to force processing of a single record
$paymentProcessorWebhooks = PaymentprocessorWebhook::get()
->setCheckPermissions(FALSE) // Replace with ::update(FALSE) when minversion = 5.29
->addWhere('id', '=', $params['id'])
->execute();
$paymentProcessorWebhooks->addWhere('id', '=', $params['id']);
}
elseif (!empty($params['event_id'])) {
$paymentProcessorWebhooks->addWhere('event_id', '=', $params['event_id']);
}
else {
$paymentProcessorWebhooks = PaymentprocessorWebhook::get()
->setCheckPermissions(FALSE) // Replace with ::update(FALSE) when minversion = 5.29
$paymentProcessorWebhooks
->addWhere('processed_date', 'IS NULL')
->addWhere('status', 'IS NULL')
->execute();
->addWhere('status', '=', 'new')
->setLimit($params['queue_limit']);
}
$paymentProcessorWebhooksResult = $paymentProcessorWebhooks->execute();
$results = [];
if (!empty($paymentProcessorWebhooks->rowCount)) {
PaymentprocessorWebhook::update()
->setCheckPermissions(FALSE) // Replace with ::update(FALSE) when minversion = 5.29
->addWhere('id', 'IN', $paymentProcessorWebhooks->column('id'))
$results = [
'queue_count' => $paymentProcessorWebhooksResult->count(),
'deleted' => $oldWebhooksCount ?? 0,
'processed' => 0,
'successes' => 0,
'errors' => 0,
];
$eventsToProcess = [];
if ($results['queue_count'] > 0) {
$eventsToProcess = $paymentProcessorWebhooksResult->column('id');
PaymentprocessorWebhook::update(FALSE)
->addWhere('id', 'IN', $eventsToProcess)
->addValue('status', 'processing')
->execute();
}
foreach ($paymentProcessorWebhooks as $webhook) {
// When should we stop processing?
$timeLimit = $params['time_limit'] + microtime(TRUE);
foreach ($paymentProcessorWebhooksResult as $webhookEvent) {
$paymentProcessor = \Civi\Payment\System::singleton()
->getById($webhook['payment_processor_id']);
switch ($paymentProcessor->getPaymentProcessor()['class_name']) {
case 'Payment_Stripe':
try {
$results[$webhook['id']] = civicrm_api3('Stripe', 'Ipn', [
'evtid' => $webhook['event_id'],
'ppid' => $webhook['payment_processor_id']
])['values'];
}
catch (Exception $e) {
\Civi::log()->error('Error processing webhook (ID: ' . $webhook['id'] . '): ' . $e->getMessage());
PaymentprocessorWebhook::update()
->setCheckPermissions(FALSE) // Replace with ::update(FALSE) when minversion = 5.29
->addWhere('id', '=', $webhook['id'])
->addValue('status', 'error')
->execute();
}
break;
->getById($webhookEvent['payment_processor_id']);
if (method_exists($paymentProcessor, 'processWebhookEvent')) {
// Payment Processor extensions implementing processWebhookEvent() have responsibility to:
//
// - attempt to process the event.
// - catch expected and not expected exceptions, and handle appropriately
// - update the stored $webhookEvent to error|success, optionally providing a message.
// - return TRUE for success, FALSE for error
$eventResult = $paymentProcessor->processWebhookEvent($webhookEvent);
$results[$eventResult ? 'successes' : 'errors']++;
}
else {
\Civi::log('mjwshared')->warning('Not processing webhook event because payment processor does not implement processWebhookEvent. Details: ' . print_r($webhookEvent, TRUE));
}
$results['processed']++;
if ($results['processed'] < $results['queue_count'] && microtime(TRUE) > $timeLimit) {
$results['note'] = "Stopped processing as time limit exceeded.";
// Release the 'processing' status for any that we did not complete.
PaymentprocessorWebhook::update(FALSE)
->addWhere('id', 'IN', $eventsToProcess)
->addWhere('status', '=', 'processing')
->addValue('status', 'new')
->execute();
break;
}
}
......@@ -91,4 +131,21 @@ function _civicrm_api3_job_process_paymentprocessor_webhooks_spec(&$params) {
$params['id']['title'] = 'ID of PaymentprocessorWebhook record (for debugging)';
$params['id']['description'] = 'Specify an ID to FORCE processing and ignore the state of the status/processed_date fields';
$params['id']['type'] = CRM_Utils_TYPE::T_INT;
$params['event_id'] = [
'type' => CRM_Utils_Type::T_STRING,
'title' => 'Event ID of PaymentprocessorWebhook record (for debugging)',
'description' => 'Specify an Event ID to force processing of only that event (and ignore status/processed_date fields)'
];
$params['time_limit'] = [
'type' => CRM_Utils_TYPE::T_INT,
'title' => 'Time limit (seconds)',
'description' => 'After each event has been processed, we stop to see whether the time limit is exceeded, and stop if so. Useful if your cron is http initiated. Default 1 hour',
'api.default' => 60*60,
];
$params['queue_limit'] = [
'type' => CRM_Utils_Type::T_INT,
'title' => 'Queue limit (count)',
'description' => 'Maximum number of webhook events to process each time this job runs. Too many events can cause memory issues and lock the database for too long. Default 1000',
'api.default' => 1000,
];
}
......@@ -35,6 +35,10 @@ function _civicrm_api3_mjwpayment_get_contribution_spec(&$params) {
'title' => 'Get Test Contributions?',
'api.aliases' => ['is_test'],
],
'contribution_id' => [
'title' => ts('Contribution ID'),
'type' => CRM_Utils_Type::T_INT,
],
'trxn_id' => [
'name' => 'trxn_id',
'type' => CRM_Utils_Type::T_STRING,
......@@ -87,17 +91,18 @@ function civicrm_api3_mjwpayment_get_contribution($params) {
// We may have more than one payment (eg. A payment + a refund payment)
// Return the contribution of the FIRST payment (all found payments SHOULD reference the same contribution)
$contributionID = reset($payments['values'])['contribution_id'];
$contribution = civicrm_api3('Contribution', 'getsingle', [
'id' => $contributionID,
'contribution_test' => $params['contribution_test'],
]);
$contribution = \Civi\Api4\Contribution::get(FALSE)
->addWhere('id', '=', $contributionID)
->addWhere('is_test', 'IN', [TRUE, FALSE])
->execute()
->first();
$contribution['payments'] = $payments['values'];
}
else {
$contributionParams = [
'options' => ['limit' => 1, 'sort' => 'id DESC'],
'contribution_test' => $params['contribution_test'],
];
$contributionApi4 = \Civi\Api4\Contribution::get(FALSE)
->addWhere('is_test', 'IN', [TRUE, FALSE])
->addOrderBy('id', 'DESC');
if (isset($params['order_reference'])) {
$contributionParams['trxn_id'][] = $params['order_reference'];
}
......@@ -105,10 +110,12 @@ function civicrm_api3_mjwpayment_get_contribution($params) {
$contributionParams['trxn_id'][] = $params['trxn_id'];
}
if (isset($contributionParams['trxn_id'])) {
$contributionParams['trxn_id'] = ['IN' => $contributionParams['trxn_id']];
$contributionApi4->addWhere('trxn_id', 'IN', $contributionParams['trxn_id']);
}
$contribution = civicrm_api3('Contribution', 'get', $contributionParams)['values'];
$contribution = reset($contribution);
if (isset($params['contribution_id'])) {
$contributionApi4->addWhere('id', '=', $params['contribution_id']);
}
$contribution = $contributionApi4->execute()->first();
}
$result = [];
if ($contribution) {
......@@ -121,44 +128,13 @@ function civicrm_api3_mjwpayment_get_contribution($params) {
* Adjust Metadata for Get action.
*
* The metadata is used for setting defaults, documentation & validation.
* @fixme replace with call to _civicrm_api3_payment_get_spec once https://github.com/civicrm/civicrm-core/pull/19449
* is merged - 5.35?
*
* @param array $params
* Array of parameters determined by getfields.
*/
function _civicrm_api3_mjwpayment_get_payment_spec(&$params) {
$params = [
'contribution_id' => [
'title' => ts('Contribution ID'),
'type' => CRM_Utils_Type::T_INT,
],
'entity_id' => [
'title' => ts('Entity ID'),
'type' => CRM_Utils_Type::T_INT,
'api.aliases' => ['contribution_id'],
],
'trxn_id' => [
'title' => ts('Transaction ID'),
'description' => ts('Transaction id supplied by external processor. This may not be unique.'),
'type' => CRM_Utils_Type::T_STRING,
],
'order_reference' => [
'title' => 'Order Reference',
'description' => 'Payment Processor external order reference',
'type' => CRM_Utils_Type::T_STRING,
],
'trxn_date' => [
'title' => ts('Payment Date'),
'type' => CRM_Utils_Type::T_TIMESTAMP,
],
'financial_trxn_id' => [
'title' => ts('Payment ID'),
'description' => ts('The ID of the record in civicrm_financial_trxn'),
'type' => CRM_Utils_Type::T_INT,
'api.aliases' => ['payment_id', 'id'],
],
];
_civicrm_api3_payment_get_spec($params);
}
/**
......@@ -169,7 +145,7 @@ function _civicrm_api3_mjwpayment_get_payment_spec(&$params) {
*
* @return array
* Array of financial transactions which are payments, if error an array with an error id and error message
* @throws \CiviCRM_API3_Exception
* @throws \CRM_Core_Exception
*/
function civicrm_api3_mjwpayment_get_payment($params) {
return civicrm_api3_payment_get($params);
......@@ -185,6 +161,15 @@ function civicrm_api3_mjwpayment_get_payment($params) {
*/
function _civicrm_api3_mjwpayment_create_payment_spec(&$params) {
_civicrm_api3_payment_create_spec($params);
$customFields = \Civi\Api4\CustomField::get(FALSE)
->addSelect('name', 'label', 'data_type')
->addWhere('custom_group_id:name', '=', 'Payment_details')
->execute();
foreach ($customFields as $customField) {
unset($customField['id']);
$customField['description'] = $customField['label'];
$params[$customField['name']] = $customField;
}
}
/**
......@@ -198,7 +183,7 @@ function _civicrm_api3_mjwpayment_create_payment_spec(&$params) {
* Api result array
*
* @throws \CRM_Core_Exception
* @throws \CiviCRM_API3_Exception
* @throws \CRM_Core_Exception
*/
function civicrm_api3_mjwpayment_create_payment($params) {
if (empty($params['skipCleanMoney'])) {
......@@ -208,36 +193,26 @@ function civicrm_api3_mjwpayment_create_payment($params) {
}
}
}
if (!empty($params['payment_processor'])) {
// I can't find evidence this is passed in - I was gonna just remove it but decided to deprecate as I see getToFinancialAccount
// also anticipates it.
CRM_Core_Error::deprecatedFunctionWarning('passing payment_processor is deprecated - use payment_processor_id');
$params['payment_processor_id'] = $params['payment_processor'];
}
// Check if it is an update
if (!empty($params['id'])) {
$amount = $params['total_amount'];
civicrm_api3('Payment', 'cancel', $params);
$params['total_amount'] = $amount;
}
// @todo #17777 - we store receive_date so we can fix it later
if (version_compare(CRM_Utils_System::version(), '5.29', '<')) {
$contributionReceiveDate = (string) civicrm_api3('Contribution', 'getvalue', [
'id' => $params['contribution_id'],
'return' => 'receive_date'
]);
}
$trxn = CRM_Financial_BAO_Payment::create($params);
if (version_compare(CRM_Utils_System::version(), '5.29', '<')) {
// @todo Fix contribution receive date as it should not be updated by Payment.create https://github.com/civicrm/civicrm-core/pull/17777
$sql = 'UPDATE civicrm_contribution SET receive_date="%2" WHERE id=%1';
$sqlParams = [
1 => [$params['contribution_id'], 'Positive'],
2 => [CRM_Utils_Date::isoToMysql($contributionReceiveDate), 'Date']
];
CRM_Core_DAO::executeQuery($sql, $sqlParams);
$customFields = \Civi\Api4\CustomField::get(FALSE)
->addWhere('custom_group_id:name', '=', 'Payment_details')
->execute()
->indexBy('name');
foreach ($customFields as $key => $value) {
if (isset($params[$key])) {
$customParams['custom_' . $value['id']] = $params[$key];
}
}
if (!empty($customParams)) {
$customParams['entity_id'] = $trxn->id;
civicrm_api3('CustomValue', 'create', $customParams);
}
$values = [];
......
......@@ -22,7 +22,7 @@ function _civicrm_api3_mjwpayment_notificationretry_spec(&$params) {
*/
function civicrm_api3_mjwpayment_notificationretry($params) {
if (!empty($params['system_log_id'])) {
// lets replace params with this rather than allow altering
// let's replace params with this rather than allow altering
$logEntry = civicrm_api3('system_log', 'getsingle', ['id' => $params['system_log_id'], 'return' => ['context', 'message']]);
}
$dataRaw = $logEntry['context'];
......@@ -32,12 +32,12 @@ function civicrm_api3_mjwpayment_notificationretry($params) {
$paymentProcessorType = civicrm_api3('PaymentProcessor', 'getsingle', ['id' => $paymentProcessorId]);
}
else {
throw new API_Exception('Unsupported payment processor');
throw new CRM_Core_Exception('Unsupported payment processor');
}
$processorClassName = "CRM_Core_{$paymentProcessorType['class_name']}";
if (!method_exists($processorClassName, 'processPaymentNotification')) {
throw new API_Exception('Unsupported payment processor');
throw new CRM_Core_Exception('Unsupported payment processor');
}
$result = FALSE;
......
{
"name": "civicrm/mjwshared",
"type": "civicrm-ext",
"description": "Extension containing shared code used by payment processors and other extensions by MJW"
}
# API (v3)
# API4
## ContributionRecur.updateAmountOnRecurMJW
Accepts `amount` parameter.
Accepts standard `where` clause to select recurring contributions.
You can update multiple recurring contributions at the same time
if multiple are returned by the `where` clause.
This updates a recurring contribution, associated contribution and lineitems with a new amount.
Should be called eg. after calling changeSubscriptionAmount on a paymentprocessor to
reflect the changes in CiviCRM.
Logic:
- Find or create a template contribution for the recur.
- Update the template contribution with the new amount.
- CiviCRM core automatically updates LineItems and Recur amounts.
Notes:
Will fail if (template) contribution has more than one LineItem.
## Membership.LinkToRecurMJW
Accepts `membershipID` parameter.
This links a membership to a recurring contribution and takes care of updating
related entities (contribution, template contribution, lineitem) so that the
membership will automatically update/renew.
## Membership.UnlinkFromRecurMJW
This unlinks a membership from a recurring contribution and takes care of updating
related entities (contribution, template contribution, lineitem) so that history is
preserved but future payments will not be linked to or renew the membership.
## PriceFieldValue.GetDefaultPriceFieldValueForContributionMJW
No parameters. This returns an array containing the defaul contribution price_field_value_id:
`$result = ['price_field_id' = X, 'price_field_value_id' = Y, 'label' = price_field_value.label]`
## PriceFieldValue.GetDefaultPriceFieldValueForMembershipMJW
One parameter: `membershipID`.
You must specify a membership ID from which the membership type and default price_field_value_id for
that type will be returned as an array:
`$result = ['price_field_id' = X, 'price_field_value_id' = Y, 'label' = price_field_value.label]`
## PaymentMJW.create
Use like API3 Payment.create. This should not yet be used in production code as it is still subject to change
and does not yet have test coverage.
# API3 (Deprecated)
This extension comes with several APIs to help you troubleshoot problems. These can be run via /civicrm/api or via drush if you are using Drupal (drush cvapi Mjwpayment.XXX).
......
# Hooks
## webhookEventNotMatched
This allows you to implement custom handling for unrecognised/unknown webhook events.
Example implementation: https://github.com/mjwconsult/civicrm-stripewebhookrules
For example if you use the same Stripe account to take payments through multiple systems
(eg. online shop, CiviCRM) you will receive webhooks for payments to both systems.
But only the payments that were created using CiviCRM will be matched.
By implementing this hook you can choose to do something with those payments from external
systems - eg. add them into CiviCRM. Once they are in CiviCRM they will be handled like any
other payment in future and subscriptions will continue to be updated automatically in CiviCRM.
```php
/**
* @param string $type The type of webhook - eg. 'stripeipn'
* @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
*/
function myextension_civicrm_webhookEventNotMatched(string $type, $object, string $code = '', array &$result) {
if ($type !== 'stripe') {
return;
}
if (!($object instanceof CRM_Core_Payment_StripeIPN) && !($object instanceof \Civi\Stripe\Webhook\Events)) {
return;
}
switch ($code) {
case 'customer_not_found':
createStripeCustomerInCiviCRM($object);
break;
case 'contribution_not_found':
// If you have a rule to find/create a matching contribution put it in the result array:
$result['contribution'] = Contribution::get(...);
break;
}
}
```
docs/images/refundui-events.png

42.6 KiB