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'));