diff --git a/CRM/Core/Payment/GoCardless.php b/CRM/Core/Payment/GoCardless.php
index d38a9b7..0549c68 100644
--- a/CRM/Core/Payment/GoCardless.php
+++ b/CRM/Core/Payment/GoCardless.php
@@ -299,6 +299,12 @@ public function doPayment(&$params, $component = 'contribute') {
];
}
+ // Added via javascript so "removed" by quickform after submission
+ $dayOfMonth = CRM_Utils_Request::retrieveValue('day_of_month', 'Integer', NULL, FALSE, 'POST');
+ if ($dayOfMonth) {
+ $params['day_of_month'] = $dayOfMonth;
+ }
+
$url = $this->createRedirectFlow($params, $component);
CRM_Utils_System::redirect($url);
}
@@ -338,7 +344,7 @@ public function createRedirectFlow(&$params, $component) {
'payment_processor_id' => $this->_paymentProcessor['id'],
"description" => $params['description'],
];
- foreach (['contributionID', 'contributionRecurID', 'contactID', 'membershipID'] as $_) {
+ foreach (['contributionID', 'contributionRecurID', 'contactID', 'membershipID', 'day_of_month'] as $_) {
if (!empty($params[$_])) {
$sesh_store[$redirect_flow->id][$_] = $params[$_];
}
diff --git a/CRM/GoCardlessUtils.php b/CRM/GoCardlessUtils.php
index 23a1ef4..14ce498 100644
--- a/CRM/GoCardlessUtils.php
+++ b/CRM/GoCardlessUtils.php
@@ -222,6 +222,9 @@ public static function completeRedirectFlowWithGoCardless($deets) {
'links' => ['mandate' => $redirect_flow->links->mandate],
'metadata' => ['civicrm' => $metadata],
];
+ if ($deets['day_of_month']) {
+ $params['day_of_month'] = $deets['day_of_month'];
+ }
if (isset($installments)) {
$params['count'] = $installments;
@@ -483,9 +486,77 @@ public static function handleContributeFormHacks() {
->addWhere('is_test', 'IS NOT NULL')
->execute()
->column('id');
+
$js = file_get_contents(E::path('js/gcform.js'));
- $js = str_replace('var goCardlessProcessorIDs = [];', 'var goCardlessProcessorIDs = ' . json_encode($paymentProcessorsIDs) . ';', $js);
+ $js = str_replace([
+ 'var goCardlessProcessorIDs = [];',
+ 'var dayOfMonthOptions = {};'
+ ],
+ [
+ 'var goCardlessProcessorIDs = ' . json_encode($paymentProcessorsIDs) . ';',
+ 'var dayOfMonthOptions = ' . json_encode(self::getDayOfMonthOptions()) . ';'
+ ],
+ $js
+ );
CRM_Core_Region::instance('page-body')->add(['markup' => ""]);
}
}
+
+ /**
+ * Get the formatted options for daysOfMonth eg. [1 => '1st']
+ * This is used for frontend presentation and backend settings
+ *
+ * @param bool $all
+ *
+ * @return array
+ */
+ public static function getDayOfMonthOptions($all = FALSE) {
+ if ($all) {
+ $options = [0];
+ for ($i = 1; $i <= 28; $i++) {
+ // Add days 1 to 28 (29-31 are excluded because don't exist for some months)
+ $options[] = $i;
+ }
+ $options[] = -1;
+ }
+ else {
+ $options = static::getSettings()['daysOfMonth'];
+ }
+
+ foreach ($options as $option) {
+ switch ($option) {
+ case 0:
+ $dayOfMonthOptions[$option] = 'auto';
+ break;
+
+ case -1:
+ $dayOfMonthOptions[$option] = 'last';
+ break;
+
+ default:
+ $dayOfMonthOptions[$option] = self::formatPreferredCollectionDay($option);
+ }
+ }
+ return $dayOfMonthOptions;
+ }
+
+ /**
+ * Format collection day like 1st, 2nd, 3rd, 4th etc.
+ *
+ * @param $collectionDay
+ *
+ * @return string
+ */
+ public static function formatPreferredCollectionDay($collectionDay) {
+ $ends = ['th', 'st', 'nd', 'rd', 'th', 'th', 'th', 'th', 'th', 'th'];
+ if ((($collectionDay%100) >= 11) && (($collectionDay%100) <= 13)) {
+ $abbreviation = $collectionDay . 'th';
+ }
+ else {
+ $abbreviation = $collectionDay . $ends[$collectionDay % 10];
+ }
+
+ return $abbreviation;
+ }
+
}
diff --git a/ang/gocardless/GoCardlessSettings.html b/ang/gocardless/GoCardlessSettings.html
index ebea208..b663d4f 100644
--- a/ang/gocardless/GoCardlessSettings.html
+++ b/ang/gocardless/GoCardlessSettings.html
@@ -40,6 +40,24 @@
{{ts('GoCardless settings')}}
+
+
+
+
The allowed days of the month. Set to "auto" for the next available date or "last" for the last day of the month.
+ This only works if "Force Recurring" is enabled and "Monthly" or "Yearly" is selected.
+
+
+
+
diff --git a/ang/gocardless/GoCardlessSettings.js b/ang/gocardless/GoCardlessSettings.js
index 8cf598b..78b82e0 100644
--- a/ang/gocardless/GoCardlessSettings.js
+++ b/ang/gocardless/GoCardlessSettings.js
@@ -52,7 +52,8 @@
// Annoyingly this is duplicated from CRM_GoCardlessUtils::getSettings()
const defaults = {
forceRecurring: false,
- sendReceiptsForCustomPayments: 'never'
+ sendReceiptsForCustomPayments: 'never',
+ daysOfMonth: ["0"]
};
Object.keys(defaults).forEach(k => {
if (!(k in gcSettings)) {
@@ -61,6 +62,12 @@
});
$scope.gcSettings = gcSettings;
+ const m={1:'1st', 2:'2nd', 3:'3rd', 21:'21st', 22:'22nd', 23:'23rd'};
+ const daysOfMonthOpts = [{key: '0', value: 'any day (earliest possible)'}];
+ for (var i=1;i<29;i++) daysOfMonthOpts.push({key: i.toString(), value: m[i] || (i + 'th')});
+ daysOfMonthOpts.push({key: '-1', value: 'last day of month'});
+ $scope.gcDaysOfMonth = daysOfMonthOpts;
+
// Make pay processors accessible
var ppTable = [];
var ppNames = {};
diff --git a/docs/gc-settings.jpg b/docs/gc-settings.jpg
deleted file mode 100644
index cbd41e8..0000000
Binary files a/docs/gc-settings.jpg and /dev/null differ
diff --git a/docs/gc-settings.png b/docs/gc-settings.png
new file mode 100644
index 0000000..a690521
Binary files /dev/null and b/docs/gc-settings.png differ
diff --git a/docs/reference/settings.md b/docs/reference/settings.md
index 4867cf5..b9631f3 100644
--- a/docs/reference/settings.md
+++ b/docs/reference/settings.md
@@ -1,6 +1,6 @@
# Settings Page
-![Screnshot of settings page](../gc-settings.jpg)
+![Screnshot of settings page](../gc-settings.png)
## Force Recurring
@@ -60,3 +60,16 @@ in some custom code, you might not have set this `is_email_receipt` value.
and will pass 0 or 1 into the payment APIs.
- `defer` will pass in the value from the recurring contribution record.
+
+## Allowed days of the month
+
+The allowed days of the month.
+Set to "auto" for the next available date or "last" for the last day of the month.
+You can specify multiple options and it will give the user a choice when setting up the payment.
+
+This only works if "Force Recurring" is enabled and "Monthly" or "Yearly" is selected.
+
+#### Developers notes
+* The GoCardless API also supports specifying "month" for "Yearly subscriptions" but that is not currently implemented.
+* The "Force recurring" setting is required because we are sharing the `gcform.js` code and it was simpler to implement that way.
+
diff --git a/info.xml b/info.xml
index ed32ab7..8c47a9c 100644
--- a/info.xml
+++ b/info.xml
@@ -22,6 +22,9 @@
5.35
+
+
+ CRM/GoCardless
diff --git a/js/gcform.js b/js/gcform.js
index 571c12b..b5a734f 100644
--- a/js/gcform.js
+++ b/js/gcform.js
@@ -7,152 +7,273 @@
//
// - Contribution forms with a choice of PPs use radio buttons.
//
-document.addEventListener('DOMContentLoaded', function () {
- // var debug = console.log;
- var debug = function() {};
- debug("GoCardless loaded");
-
- // This next line gets swapped out by PHP
- var goCardlessProcessorIDs = [];
-
- // CiviCRM uses isRecur for non-membership payments, and autoRenew for memberships.
- // Nb. autoRenew may not be there e.g. if autoRenew is not allowed, or is not optional.
- var isRecurInput = document.getElementById('is_recur');
- // Note: the auto renew input might be a checkbox OR a hidden element.
- var autoRenewInput = document.getElementById('auto_renew');
- // If Civi offers a choice of payment processors by radio, they'll be found like this:
- var ppRadios = document.querySelectorAll('input[type="radio"][name="payment_processor_id"]');
- // Boolean: whether the currently selected payment processor is a GoCardless one.
- var goCardlessProcessorSelected = false;
- // The name of the selected GoCardless processor, or is empty.
- var selectedProcessorName = null;
-
- // Note: templates/CRM/common/paymentBlock.tpl includes JS which
- // un-checks the .checked property on all payment processor radios when that
- // block of form is hidden, and when it is shown, it sets the .checked
- // property where the checked attribute exists (i.e. the default).
- // There are no events to hook into here, so we need to resort to polling to
- // find out when it's changed.
- //var paymentOptionsGroup = document.querySelector('div.payment_options-group');
- //var paymentProcessorSection = document.querySelector('div.payment_processor-section');
- var gcWasPreviouslySelected = false;
-
- // Listen for when the user changes/tries to change the isRecurInput
- if (isRecurInput) {
- isRecurInput.addEventListener('change', function(e) {
- if (!isRecurInput.checked && goCardlessProcessorSelected) {
- // They tried to un-check it, but GoCardless is selected.
- e.preventDefault();
- e.stopPropagation();
- forceRecurring(true);
- }
- });
- debug("Added event listener to isRecurInput", isRecurInput);
- }
- // Listen for when the user changes/tries to change the autoRenewInput checkbox
- if (autoRenewInput && autoRenewInput.getAttribute('type') === 'checkbox') {
- autoRenewInput.addEventListener('change', function(e) {
- if (!autoRenewInput.checked && goCardlessProcessorSelected) {
- e.preventDefault();
- e.stopPropagation();
- forceRecurring(true);
- }
- });
- debug("Added event listener to autoRenew", autoRenewInput);
- }
+(function($, ts) {
+
+ $(document).ajaxComplete(function(event, xhr, settings) {
+ function isAJAXPaymentForm(url) {
+ return (url.match("civicrm(\/|%2F)payment(\/|%2F)form") !== null) ||
+ (url.match("civicrm(\/|\%2F)contact(\/|\%2F)view(\/|\%2F)participant") !== null) ||
+ (url.match("civicrm(\/|\%2F)contact(\/|\%2F)view(\/|\%2F)membership") !== null) ||
+ (url.match("civicrm(\/|\%2F)contact(\/|\%2F)view(\/|\%2F)contribution") !== null);
+ }
+ // /civicrm/payment/form? occurs when a payproc is selected on page
+ // /civicrm/contact/view/participant occurs when payproc is first loaded on event credit card payment
+ // On wordpress these are urlencoded
+ if (isAJAXPaymentForm(settings.url)) {
+ load();
+ }
+ });
+
+ document.addEventListener('DOMContentLoaded', function () {
+ load();
+ });
+
+ function load() {
+ // var debug = console.log;
+ var debug = function() {};
+ debug("GoCardless loaded");
+
+ // This next line gets swapped out by PHP
+ var goCardlessProcessorIDs = [];
+
+ // CiviCRM uses isRecur for non-membership payments, and autoRenew for memberships.
+ // Nb. autoRenew may not be there e.g. if autoRenew is not allowed, or is not optional.
+ var isRecurInput = document.getElementById('is_recur');
+ // Note: the auto renew input might be a checkbox OR a hidden element.
+ var autoRenewInput = document.getElementById('auto_renew');
+ // If Civi offers a choice of payment processors by radio, they'll be found like this:
+ var ppRadios = document.querySelectorAll('input[type="radio"][name="payment_processor_id"]');
+ // Boolean: whether the currently selected payment processor is a GoCardless one.
+ var goCardlessProcessorSelected = false;
+ // The name of the selected GoCardless processor, or is empty.
+ var selectedProcessorName = null;
+
+ // Note: templates/CRM/common/paymentBlock.tpl includes JS which
+ // un-checks the .checked property on all payment processor radios when that
+ // block of form is hidden, and when it is shown, it sets the .checked
+ // property where the checked attribute exists (i.e. the default).
+ // There are no events to hook into here, so we need to resort to polling to
+ // find out when it's changed.
+ //var paymentOptionsGroup = document.querySelector('div.payment_options-group');
+ //var paymentProcessorSection = document.querySelector('div.payment_processor-section');
+ var gcWasPreviouslySelected = false;
+
+ // Listen for when the user changes/tries to change the isRecurInput
+ if (isRecurInput) {
+ isRecurInput.addEventListener('change', function (e) {
+ if (!isRecurInput.checked && goCardlessProcessorSelected) {
+ // They tried to un-check it, but GoCardless is selected.
+ e.preventDefault();
+ e.stopPropagation();
+ forceRecurring(true);
+ }
+ });
+ debug("Added event listener to isRecurInput", isRecurInput);
+ }
- // This forces whichever of autoRenewInput and isRecurInput is on.
- function forceRecurring(withAlert) {
+ // Listen for when the user changes/tries to change the autoRenewInput checkbox
+ if (autoRenewInput && autoRenewInput.getAttribute('type') === 'checkbox') {
+ autoRenewInput.addEventListener('change', function (e) {
+ if (!autoRenewInput.checked && goCardlessProcessorSelected) {
+ e.preventDefault();
+ e.stopPropagation();
+ forceRecurring(true);
+ }
+ });
+ debug("Added event listener to autoRenew", autoRenewInput);
+ }
+ else if (document.getElementById('force_renew')) {
+ forceRecurring(false);
+ }
+
+ // This forces whichever of autoRenewInput and isRecurInput is on.
+ function forceRecurring(withAlert) {
- if (withAlert) {
- if (selectedProcessorName) {
- alert("Contributions made with " + selectedProcessorName + " must be recurring/auto renewing.");
+ if (withAlert) {
+ if (selectedProcessorName) {
+ alert("Contributions made with " + selectedProcessorName + " must be recurring/auto renewing.");
+ }
+ else {
+ alert("Direct Debit contributions must be recurring/auto renewing.");
+ }
}
- else {
- alert("Direct Debit contributions must be recurring/auto renewing.");
+
+ // As we've changed this we need to trigger a 'change' event so that the other UI can respond.
+ var fakeEvent = new Event('change');
+
+ if (isRecurInput) {
+ debug("Forcing isRecurInput");
+ isRecurInput.checked = true;
+ isRecurInput.dispatchEvent(fakeEvent);
}
- }
- // As we've changed this we need to trigger a 'change' event so that the other UI can respond.
- var fakeEvent = new Event('change');
+ if (autoRenewInput) {
+ debug("Forcing autoRenew");
+ autoRenewInput.checked = true;
+ autoRenewInput.dispatchEvent(fakeEvent);
+ }
- if (isRecurInput) {
- debug("Forcing isRecurInput");
- isRecurInput.checked = true;
- isRecurInput.dispatchEvent(fakeEvent);
+ setRecurStartDate();
}
- if (autoRenewInput) {
- debug("Forcing autoRenew");
- autoRenewInput.checked = true;
- autoRenewInput.dispatchEvent(fakeEvent);
+ function hideStartDate() {
+ $('#gc-recurring-start-date').hide();
+ $("#gc-recurring-start-date option:selected").prop("selected", false);
+ $("#gc-recurring-start-date option:first").prop("selected", "selected");
+ $('#recur-start-date-description').remove();
}
- }
- // This function looks through payment processor selector radios,
- // set goCardlessProcessorSelected and if found, forceRecurring
- function gcFixRecurFromRadios() {
- var ppID;
- selectedProcessorName = null;
- [].forEach.call(ppRadios, function(r) {
- if (r.checked) {
- ppID = parseInt(r.value);
- var label = document.querySelector('label[for="' + r.id + '"]');
- selectedProcessorName = label ? label.textContent : 'Direct Debit';
+ function setRecurStartDate() {
+ var recurSection = '.is_recur-section';
+ if (!$(recurSection).length) {
+ recurSection = '#allow_auto_renew';
}
- });
- goCardlessProcessorSelected = (typeof ppID === 'number') && (goCardlessProcessorIDs.indexOf(ppID) > -1);
- if (goCardlessProcessorSelected && !gcWasPreviouslySelected) {
- forceRecurring();
+ if ($('select#frequency_unit,input[name=is_recur_radio]').length > 0) {
+ // core select element
+ var selectedFrequencyUnit = $('select#frequency_unit').val();
+ if (!selectedFrequencyUnit) {
+ // recurringbuttons extension radio buttons
+ selectedFrequencyUnit = $('input[name=is_recur_radio]:checked').val();
+ }
+ if ($.inArray(selectedFrequencyUnit, ['month', 'year']) < 0) {
+ hideStartDate();
+ return;
+ }
+ }
+
+ var recurStartDateDiv = document.getElementById('gc-recurring-start-date');
+ if (recurStartDateDiv) {
+ recurStartDateDiv.remove();
+ }
+
+ var dayOfMonthSelect = document.createElement('select');
+ dayOfMonthSelect.setAttribute('id', 'day_of_month');
+ dayOfMonthSelect.setAttribute('name', 'day_of_month');
+ dayOfMonthSelect.classList.add('crm-form-select');
+
+ var dayOfMonthOptions = {};
+
+ // Build the "day_of_month" select element and add to form
+ var options = '';
+ for (var key in dayOfMonthOptions) {
+ if (dayOfMonthOptions.hasOwnProperty(key)) {
+ options += '';
+ }
+ }
+ dayOfMonthSelect.innerHTML = options;
+
+ recurStartDateDiv = document.createElement('div');
+ recurStartDateDiv.setAttribute('id', 'gc-recurring-start-date');
+ var recurStartDateElement = document.createElement('div');
+ recurStartDateElement.classList.add('crm-section', 'recurring-start-date');
+ var recurStartDateLabel = document.createElement('div');
+ recurStartDateLabel.classList.add('label');
+ recurStartDateLabel.innerText = ts('Day of month');
+ recurStartDateElement.appendChild(recurStartDateLabel);
+ var recurStartDateContent = document.createElement('div');
+ recurStartDateContent.classList.add('content');
+ recurStartDateContent.appendChild(dayOfMonthSelect);
+ recurStartDateElement.appendChild(recurStartDateLabel);
+ recurStartDateElement.appendChild(recurStartDateContent);
+ recurStartDateDiv.appendChild(recurStartDateElement);
+ // Remove/insert the recur start date element just below the recur selections
+ $(recurSection + ' #gc-recurring-start-date').remove();
+ $(recurSection).after(recurStartDateDiv);
+
+ if (Object.keys(dayOfMonthOptions).length === 1) {
+ // We only have one option. No need to offer selection - just show the date
+ $(dayOfMonthSelect).parent('div.content').prev('div.label').hide();
+ $(dayOfMonthSelect).next('div.description').hide();
+ $(dayOfMonthSelect).hide();
+ $('#recur-start-date-description').remove();
+ if ($(dayOfMonthSelect).val() !== '0') {
+ var recurStartMessage = ts('Your direct debit will be collected on the %1 day of the month', {
+ 1: $(dayOfMonthSelect).text()
+ });
+ $(dayOfMonthSelect).after(
+ '
' + recurStartMessage + '
'
+ );
+ }
+ }
+ $('#gc-recurring-start-date').show().val('');
}
- gcWasPreviouslySelected = goCardlessProcessorSelected;
- debug("Determined whether GoCardless is the processor selected from radios:", goCardlessProcessorSelected);
- }
- // If the user has a choice about any type of auto renew...
- if (isRecurInput || autoRenewInput) {
- if (ppRadios.length > 1) {
- debug("Found a choice of payment processors as radios");
- // We have radio inputs to select the processor.
- [].forEach.call(ppRadios, function(r) {
- r.addEventListener('click', gcFixRecurFromRadios);
+ // This function looks through payment processor selector radios,
+ // set goCardlessProcessorSelected and if found, forceRecurring
+ function gcFixRecurFromRadios() {
+ var ppID;
+ selectedProcessorName = null;
+ [].forEach.call(ppRadios, function (r) {
+ if (r.checked) {
+ ppID = parseInt(r.value);
+ var label = document.querySelector('label[for="' + r.id + '"]');
+ selectedProcessorName = label ? label.textContent : 'Direct Debit';
+ }
});
+ goCardlessProcessorSelected = (typeof ppID === 'number') && (goCardlessProcessorIDs.indexOf(ppID) > -1);
+
+ if (goCardlessProcessorSelected && !gcWasPreviouslySelected) {
+ forceRecurring();
+ }
+ gcWasPreviouslySelected = goCardlessProcessorSelected;
+ debug("Determined whether GoCardless is the processor selected from radios:", goCardlessProcessorSelected);
+ }
- gcFixRecurFromRadios();
- // Look out for when it changes in a way we can't detect.
- window.setInterval(gcFixRecurFromRadios, 300);
+ // If the user has a choice about any type of auto renew...
+ if (isRecurInput || autoRenewInput) {
+ if (ppRadios.length > 1) {
+ debug("Found a choice of payment processors as radios");
+ // We have radio inputs to select the processor.
+ [].forEach.call(ppRadios, function (r) {
+ r.addEventListener('click', gcFixRecurFromRadios);
+ });
+
+ gcFixRecurFromRadios();
+ // Look out for when it changes in a way we can't detect.
+ window.setInterval(gcFixRecurFromRadios, 300);
+ }
+ else {
+ // The processor type is fixed.
+ var ppInput = document.querySelectorAll('input[type="hidden"][name="payment_processor_id"]');
+ if (ppInput.length === 1) {
+ // We have a single payment processor involved that won't be changing.
+ var ppID = parseInt(ppInput[0].value);
+ goCardlessProcessorSelected = (goCardlessProcessorIDs.indexOf(ppID) > -1);
+ if (goCardlessProcessorSelected) {
+ forceRecurring();
+ }
+ debug("Determined whether GoCardless is the processor:", goCardlessProcessorSelected);
+ }
+ // else: no idea, let's do nothing.
+ }
}
- else {
- // The processor type is fixed.
- var ppInput = document.querySelectorAll('input[type="hidden"][name="payment_processor_id"]');
- if (ppInput.length === 1) {
- // We have a single payment processor involved that won't be changing.
- var ppID = parseInt(ppInput[0].value);
- goCardlessProcessorSelected = (goCardlessProcessorIDs.indexOf(ppID) > -1);
+
+ if ('showHideAutoRenew' in window) {
+ // This function is defined in templates/CRM/Contribute/Form/Contribution/MembershipBlock.tpl
+ // and called by Civi in various places, including in onclick HTML attributes, defined in:
+ // - CRM/Contribute/Form/ContributionBase.php
+ // - CRM/Price/BAO/PriceField.php
+ // Wrap this function to ensure we still forceRecurring if GC is selected.
+ var origShowHideAutoRenew = window.showHideAutoRenew;
+ window.showHideAutoRenew = function (memTypeId) {
+ origShowHideAutoRenew(memTypeId);
if (goCardlessProcessorSelected) {
forceRecurring();
}
- debug("Determined whether GoCardless is the processor:", goCardlessProcessorSelected);
- }
- // else: no idea, let's do nothing.
+ };
}
- }
- if ('showHideAutoRenew' in window) {
- // This function is defined in templates/CRM/Contribute/Form/Contribution/MembershipBlock.tpl
- // and called by Civi in various places, including in onclick HTML attributes, defined in:
- // - CRM/Contribute/Form/ContributionBase.php
- // - CRM/Price/BAO/PriceField.php
- // Wrap this function to ensure we still forceRecurring if GC is selected.
- var origShowHideAutoRenew = window.showHideAutoRenew;
- window.showHideAutoRenew = function(memTypeId) {
- origShowHideAutoRenew(memTypeId);
- if (goCardlessProcessorSelected) {
- forceRecurring();
- }
- };
+ // Trigger when we change the frequency unit selector (eg. month, year) on recur
+ CRM.$('select#frequency_unit').on('change', function() {
+ setRecurStartDate();
+ });
+
+ CRM.$('input[name=is_recur_radio]').on('change', function() {
+ setRecurStartDate();
+ });
+
}
-});
+})(CRM.$, CRM.ts('uk.artfulrobot.civicrm.gocardless'));