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 844 additions and 287 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) 2020, 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.
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!**
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.
## Requirements
The extension is licensed under [AGPL-3.0](LICENSE.txt).
* PHP v7.2+
* CiviCRM 5.24+
**Always read the [Release Notes](https://lab.civicrm.org/extensions/mjwshared/blob/master/docs/releasenotes.md) carefully before upgrading!**
## Installation
See: https://docs.civicrm.org/sysadmin/en/latest/customize/extensions/#installing-a-new-extension
## Support and Maintenance
This extension is supported and maintained by:
[![MJW Consulting](docs/images/mjwconsulting.jpg)](https://www.mjwconsult.co.uk)
We offer paid [support and development](https://mjw.pt/support) as well as a [troubleshooting/investigation service](https://mjw.pt/investigation).
<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'];
......
<?php
/*
+--------------------------------------------------------------------+
| Copyright CiviCRM LLC. All rights reserved. |
| |
| This work is published under the GNU AGPLv3 license with some |
| permitted exceptions and without any warranty. For full license |
| and copyright information, see https://civicrm.org/licensing |
+--------------------------------------------------------------------+
*/
use Civi\Api4\PaymentprocessorWebhook;
/**
* This job performs various housekeeping actions related to the Stripe payment processor
*
* @param array $params
*
* @return array
* API result array.
* @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
$oldWebhooksCount = PaymentprocessorWebhook::get(FALSE)
->selectRowCount()
->addWhere("{$joinKey}.domain_id", '=', CRM_Core_Config::domainID())
->addWhere('created_date', '<', $params['delete_old'])
->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->addWhere('id', '=', $params['id']);
}
elseif (!empty($params['event_id'])) {
$paymentProcessorWebhooks->addWhere('event_id', '=', $params['event_id']);
}
else {
$paymentProcessorWebhooks
->addWhere('processed_date', 'IS NULL')
->addWhere('status', '=', 'new')
->setLimit($params['queue_limit']);
}
$paymentProcessorWebhooksResult = $paymentProcessorWebhooks->execute();
$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();
}
// When should we stop processing?
$timeLimit = $params['time_limit'] + microtime(TRUE);
foreach ($paymentProcessorWebhooksResult as $webhookEvent) {
$paymentProcessor = \Civi\Payment\System::singleton()
->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;
}
}
return civicrm_api3_create_success($results, $params);
}
/**
* Action Payment.
*
* @param array $params
*/
function _civicrm_api3_job_process_paymentprocessor_webhooks_spec(&$params) {
$params['delete_old']['api.default'] = '-3 month';
$params['delete_old']['title'] = 'Delete old records after (default: -3 month)';
$params['delete_old']['description'] = 'Delete old records from database. Specify 0 to disable. Default is "-3 month"';
$params['delete_old']['type'] = CRM_Utils_Type::T_STRING;
$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,
];
}
......@@ -9,6 +9,8 @@
+--------------------------------------------------------------------+
*/
require_once('api/v3/Payment.php');
/**
* @todo mjwpayment.get_contribution is a replacement for Contribution.get
* which support querying by contribution/payment trxn_id per https://github.com/civicrm/civicrm-core/pull/14748
......@@ -33,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,
......@@ -85,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'];
}
......@@ -103,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']);
}
if (isset($params['contribution_id'])) {
$contributionApi4->addWhere('id', '=', $params['contribution_id']);
}
$contribution = civicrm_api3('Contribution', 'get', $contributionParams)['values'];
$contribution = reset($contribution);
$contribution = $contributionApi4->execute()->first();
}
$result = [];
if ($contribution) {
......@@ -124,90 +133,26 @@ function civicrm_api3_mjwpayment_get_contribution($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);
}
/**
* Retrieve a set of financial transactions which are payments.
* @todo This matches Payment.Get following https://github.com/civicrm/civicrm-core/pull/17071 which will be in CiviCRM 5.26
*
* @param array $params
* Input parameters.
*
* @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) {
$params['is_payment'] = TRUE;
$contributionID = $params['entity_id'] ?? NULL;
// In order to support contribution id we need to do an extra lookup.
if ($contributionID) {
$eftParams = [
'entity_id' => $contributionID,
'entity_table' => 'civicrm_contribution',
'options' => ['limit' => 0],
'financial_trxn_id.is_payment' => 1,
];
$eft = civicrm_api3('EntityFinancialTrxn', 'get', $eftParams)['values'];
if (empty($eft)) {
return civicrm_api3_create_success([], $params, 'Payment', 'get');
}
foreach ($eft as $entityFinancialTrxn) {
$params['financial_trxn_id']['IN'][] = $entityFinancialTrxn['financial_trxn_id'];
}
}
$financialTrxn = civicrm_api3('FinancialTrxn', 'get', array_merge($params, ['sequential' => FALSE]))['values'];
if ($contributionID) {
foreach ($financialTrxn as &$values) {
$values['contribution_id'] = $contributionID;
}
}
elseif (!empty($financialTrxn)) {
$entityFinancialTrxns = civicrm_api3('EntityFinancialTrxn', 'get', ['financial_trxn_id' => ['IN' => array_keys($financialTrxn)], 'entity_table' => 'civicrm_contribution', 'options' => ['limit' => 0]])['values'];
foreach ($entityFinancialTrxns as $entityFinancialTrxn) {
$financialTrxn[$entityFinancialTrxn['financial_trxn_id']]['contribution_id'] = $entityFinancialTrxn['entity_id'];
}
}
return civicrm_api3_create_success($financialTrxn, $params, 'Mjwpayment', 'get_payment');
return civicrm_api3_payment_get($params);
}
/**
* Adjust Metadata for Create action.
* @fixme Spec per Payment.create in CiviCRM 5.27
*
* The metadata is used for setting defaults, documentation & validation.
*
......@@ -215,174 +160,21 @@ function civicrm_api3_mjwpayment_get_payment($params) {
* Array of parameters.
*/
function _civicrm_api3_mjwpayment_create_payment_spec(&$params) {
$params = [
'contribution_id' => [
'api.required' => 1,
'title' => ts('Contribution ID'),
'type' => CRM_Utils_Type::T_INT,
// We accept order_id as an alias so that we can chain like
// civicrm_api3('Order', 'create', ['blah' => 'blah', 'contribution_status_id' => 'Pending', 'api.Payment.create => ['total_amount' => 5]]
'api.aliases' => ['order_id'],
],
'total_amount' => [
'api.required' => 1,
'title' => ts('Total Payment Amount'),
'type' => CRM_Utils_Type::T_FLOAT,
],
'fee_amount' => [
'title' => ts('Fee Amount'),
'type' => CRM_Utils_Type::T_FLOAT,
],
'payment_processor_id' => [
'name' => 'payment_processor_id',
'type' => CRM_Utils_Type::T_INT,
'title' => ts('Payment Processor'),
'description' => ts('Payment Processor for this payment'),
'where' => 'civicrm_financial_trxn.payment_processor_id',
'table_name' => 'civicrm_financial_trxn',
'entity' => 'FinancialTrxn',
'bao' => 'CRM_Financial_DAO_FinancialTrxn',
'localizable' => 0,
'FKClassName' => 'CRM_Financial_DAO_PaymentProcessor',
],
'id' => [
'title' => ts('Payment ID'),
'type' => CRM_Utils_Type::T_INT,
'api.aliases' => ['payment_id'],
],
'trxn_date' => [
'title' => ts('Payment Date'),
'type' => CRM_Utils_Type::T_DATE + CRM_Utils_Type::T_TIME,
'api.default' => 'now',
'api.required' => TRUE,
],
'is_send_contribution_notification' => [
'title' => ts('Send out notifications based on contribution status change?'),
'description' => ts('Most commonly this equates to emails relating to the contribution, event, etcwhen a payment completes a contribution'),
'type' => CRM_Utils_Type::T_BOOLEAN,
'api.default' => TRUE,
],
'payment_instrument_id' => [
'name' => 'payment_instrument_id',
'type' => CRM_Utils_Type::T_INT,
'title' => ts('Payment Method'),
'description' => ts('FK to payment_instrument option group values'),
'where' => 'civicrm_financial_trxn.payment_instrument_id',
'table_name' => 'civicrm_financial_trxn',
'entity' => 'FinancialTrxn',
'bao' => 'CRM_Financial_DAO_FinancialTrxn',
'localizable' => 0,
'html' => [
'type' => 'Select',
],
'pseudoconstant' => [
'optionGroupName' => 'payment_instrument',
'optionEditPath' => 'civicrm/admin/options/payment_instrument',
],
],
'card_type_id' => [
'name' => 'card_type_id',
'type' => CRM_Utils_Type::T_INT,
'title' => ts('Card Type ID'),
'description' => ts('FK to accept_creditcard option group values'),
'where' => 'civicrm_financial_trxn.card_type_id',
'table_name' => 'civicrm_financial_trxn',
'entity' => 'FinancialTrxn',
'bao' => 'CRM_Financial_DAO_FinancialTrxn',
'localizable' => 0,
'html' => [
'type' => 'Select',
],
'pseudoconstant' => [
'optionGroupName' => 'accept_creditcard',
'optionEditPath' => 'civicrm/admin/options/accept_creditcard',
],
],
'trxn_result_code' => [
'name' => 'trxn_result_code',
'type' => CRM_Utils_Type::T_STRING,
'title' => ts('Transaction Result Code'),
'description' => ts('processor result code'),
'maxlength' => 255,
'size' => CRM_Utils_Type::HUGE,
'where' => 'civicrm_financial_trxn.trxn_result_code',
'table_name' => 'civicrm_financial_trxn',
'entity' => 'FinancialTrxn',
'bao' => 'CRM_Financial_DAO_FinancialTrxn',
'localizable' => 0,
],
'trxn_id' => [
'name' => 'trxn_id',
'type' => CRM_Utils_Type::T_STRING,
'title' => ts('Transaction ID'),
'description' => ts('Transaction id supplied by external processor. This may not be unique.'),
'maxlength' => 255,
'size' => 10,
'where' => 'civicrm_financial_trxn.trxn_id',
'table_name' => 'civicrm_financial_trxn',
'entity' => 'FinancialTrxn',
'bao' => 'CRM_Financial_DAO_FinancialTrxn',
'localizable' => 0,
'html' => [
'type' => 'Text',
],
],
'order_reference' => [
'name' => 'order_reference',
'type' => CRM_Utils_Type::T_STRING,
'title' => 'Order Reference',
'description' => 'Payment Processor external order reference',
'maxlength' => 255,
'size' => 25,
'where' => 'civicrm_financial_trxn.order_reference',
'table_name' => 'civicrm_financial_trxn',
'entity' => 'FinancialTrxn',
'bao' => 'CRM_Financial_DAO_FinancialTrxn',
'localizable' => 0,
'html' => [
'type' => 'Text',
],
],
'check_number' => [
'name' => 'check_number',
'type' => CRM_Utils_Type::T_STRING,
'title' => ts('Check Number'),
'description' => ts('Check number'),
'maxlength' => 255,
'size' => 6,
'where' => 'civicrm_financial_trxn.check_number',
'table_name' => 'civicrm_financial_trxn',
'entity' => 'FinancialTrxn',
'bao' => 'CRM_Financial_DAO_FinancialTrxn',
'localizable' => 0,
'html' => [
'type' => 'Text',
],
],
'pan_truncation' => [
'name' => 'pan_truncation',
'type' => CRM_Utils_Type::T_STRING,
'title' => ts('PAN Truncation'),
'description' => ts('Last 4 digits of credit card'),
'maxlength' => 4,
'size' => 4,
'where' => 'civicrm_financial_trxn.pan_truncation',
'table_name' => 'civicrm_financial_trxn',
'entity' => 'FinancialTrxn',
'bao' => 'CRM_Financial_DAO_FinancialTrxn',
'localizable' => 0,
'html' => [
'type' => 'Text',
],
],
];
_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;
}
}
/**
* Add a payment for a Contribution.
* @fixme Payment.create didn't support order_reference param until CiviCRM version 5.27
* (https://github.com/civicrm/civicrm-core/pull/17278)
* This provides compatibility by copying APIv3 Payment.create + FinancialTrxn.create
* @fixme This is a copy of API3 Payment.create including some handling for bugfixes in certain versions.
*
* @param array $params
* Input parameters.
......@@ -391,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'])) {
......@@ -401,44 +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.27', '<')) {
// @todo Payment.create didn't support order_reference param until CiviCRM version 5.27 (https://github.com/civicrm/civicrm-core/pull/17278)
civicrm_api3('FinancialTrxn', 'create', [
'id' => $trxn->id,
'order_reference' => $params['order_reference'],
]);
$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 (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);
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/mjwconsulting.jpg

8.12 KiB