/*jshint esversion: 6 */ (function($, ts) { var payment = { name: 'CRM.payment', form: null, submitButtons: null, scripts: {}, /** * Get the total amount on the form * @returns {number} */ getTotalAmount: function() { var totalAmount = 0.0; if (this.getIsDrupalWebform()) { // This is how webform civicrm calculates the amount in webform_civicrm_payment.js $('.line-item:visible', '#wf-crm-billing-items').each(function() { totalAmount += parseFloat($(this).data('amount')); }); } else if (typeof calculateTotalFee == 'function') { // This is ONLY triggered in the following circumstances on a CiviCRM contribution page: // - With a priceset that allows a 0 amount to be selected. // - When we are the ONLY payment processor configured on the page. // It is ALSO replaced by percentagepricesetfield and extrafee extensions totalAmount = parseFloat(calculateTotalFee()); } else if (document.getElementById('totalTaxAmount') !== null) { totalAmount = this.calculateTaxAmount(); this.debugging(this.name, 'Calculated amount using internal calculateTaxAmount()'); } else if ($("#priceset [price]").length > 0) { // This is ONLY triggered in the following circumstances on a CiviCRM contribution page: // - With a priceset that allows a 0 amount to be selected. // - When we are the ONLY payment processor configured on the page. $("#priceset [price]").each(function () { totalAmount = totalAmount + $(this).data('line_raw_total'); }); } else if (document.getElementById('total_amount')) { // The input#total_amount field exists on backend contribution forms totalAmount = parseFloat(document.getElementById('total_amount').value); } if (this.isEventAdditionalParticipants()) { // The amount shown on the initial page is the total amount for 1 registration. // It is "impossible" to calculate the total amount because we don't know what will be selected // for each additional participant. // Set totalAmount = null to force the use of a setupIntent if (CRM.vars.cividiscount !== undefined && CRM.vars.cividiscount.discountApplied && CRM.vars.cividiscount.totalAmountZero) { // Special case for CiviDiscount and 100% discount. We know that total will be zero for all participants // Setting this to 0.0 means the stripe element will not be loaded or validated. totalAmount = 0.0; } else { // Force a setupIntent because we don't know the total amount totalAmount = null; } } this.debugging(this.name, 'getTotalAmount: ' + totalAmount); return totalAmount; }, /** * This is calculated in CRM/Contribute/Form/Contribution.tpl and is used to calculate the total * amount with tax on backend submit contribution forms. * The only way we can get the amount is by parsing the text field and extracting the final bit after the space. * eg. "Amount including Tax: $ 4.50" gives us 4.50. * The PHP side is responsible for converting money formats (we just parse to cents and remove any ,. chars). * * @returns {float} */ calculateTaxAmount: function() { var totalTaxAmount = 0; if (document.getElementById('totalTaxAmount') === null) { return totalTaxAmount; } // If tax and invoicing is disabled totalTaxAmount div exists but is empty if (document.getElementById('totalTaxAmount').textContent.length === 0) { totalTaxAmount = document.getElementById('total_amount').value; } else { // Otherwise totalTaxAmount div contains a textual amount including currency symbol // Use the "separator" variable declared in Contribution.tpl or default to . for decimal point var dPoint = (typeof separator !== 'undefined') && separator || '.'; // Regular expression to comb for numeric parts of the totalTaxAmount div. var matcher = new RegExp('\\d{1,3}(' + dPoint.replace(/\W/g, '\\$&') + '\\d{0,2})?', 'g'); // Join all parts and ensure the decimal point is per javascript format. totalTaxAmount = document.getElementById('totalTaxAmount').textContent.match(matcher).join('').replace(dPoint, '.'); } totalTaxAmount = parseFloat(totalTaxAmount); if (isNaN(totalTaxAmount)) { totalTaxAmount = 0.0; } return totalTaxAmount; }, /** * Get currency on the form * @param defaultCurrency * @returns {string} */ getCurrency: function(defaultCurrency) { var currency = defaultCurrency; if (this.form.querySelector('#currency')) { currency = this.form.querySelector('#currency').value; } this.debugging(this.name, 'Currency is: ' + currency); return currency; }, /** * Is the event registering additional participants (this means we do not know the full amount) * * @returns {boolean} */ isEventAdditionalParticipants: function() { if ((document.getElementById('additional_participants') !== null) && (document.getElementById('additional_participants').value.length !== 0)) { this.debugging(this.name, 'Event has additional participants'); return true; } return false; }, /** * Are we currently loaded on a drupal webform? * * @returns {boolean} */ getIsDrupalWebform: function() { // form class for drupal webform: webform-client-form (drupal 7); webform-submission-form (drupal 8) if (this.form !== null) { return this.form.classList.contains('webform-client-form') || this.form.classList.contains('webform-submission-form'); } return false; }, /** * Get the Billing Form as a DOM/HTMLElement. * Also set the "form" property on CRM.payment. * * @returns {HTMLElement} */ getBillingForm: function() { // If we have a billing form on the page with our processor var billingFormID = $('div#crm-payment-js-billing-form-container').closest('form').attr('id'); if ((typeof billingFormID === 'undefined') || (!billingFormID.length)) { // If we have multiple payment processors to select and we are not currently loaded billingFormID = $('input[name=hidden_processor]').closest('form').prop('id'); } if (typeof billingFormID === 'undefined' || (!billingFormID.length)) { billingFormID = $('div#billing-payment-block').closest('form').prop('id'); } if (typeof billingFormID === 'undefined' || (!billingFormID.length)) { this.debugging(this.name, 'no billing form'); this.form = null; return this.form; } // We have to use document.getElementById here so we have the right elementtype for appendChild() this.form = document.getElementById(billingFormID); return this.form; }, /** * Get all the billing submit buttons on the form as DOM elements * Also set the "submitButtons" property on CRM.payment. * * @returns {NodeList} */ getBillingSubmit: function() { if (CRM.payment.getIsDrupalWebform()) { this.submitButtons = this.form.querySelectorAll('[type="submit"].webform-submit'); if (this.submitButtons.length === 0) { // drupal 8 webform this.submitButtons = this.form.querySelectorAll('[type="submit"].webform-button--submit'); } } else { this.submitButtons = this.form.querySelectorAll('[type="submit"].validate'); } if (this.submitButtons.length === 0) { this.debugging(this.name, 'No submit button found!'); } return this.submitButtons; }, /** * Are we creating a recurring contribution? * @returns {boolean} */ getIsRecur: function() { if (!this.supportsRecur()) { return false; } var isRecur = false; // Auto-renew contributions for CiviCRM Webforms. if (this.getIsDrupalWebform()) { if (($('input[data-civicrm-field-key$="contribution_installments"]').length !== 0 && $('input[data-civicrm-field-key$="contribution_installments"]').val() != 1) || ($('input[data-civicrm-field-key$="contribution_frequency_interval"]').length !== 0 && $('input[data-civicrm-field-key$="contribution_frequency_interval"]').val() > 0) ) { isRecur = true; } } // Auto-renew contributions if (document.getElementById('is_recur') !== null) { if (document.getElementById('is_recur').type == 'hidden') { isRecur = (document.getElementById('is_recur').value == 1); } else { isRecur = Boolean(document.getElementById('is_recur').checked); } } // Auto-renew memberships // This gets messy quickly! // input[name="auto_renew"] : set to 1 when there is a force-renew membership with no priceset. else if ($('input[name="auto_renew"]').length !== 0) { if ($('input[name="auto_renew"]').prop('checked')) { isRecur = true; } else if ($('input[name="auto_renew"]').attr('type') == 'hidden') { // If the auto_renew field exists as a hidden field, then we force a // recurring contribution (the value isn't useful since it depends on // the locale - e.g. "Please renew my membership") isRecur = true; } else { isRecur = Boolean($('input[name="auto_renew"]').checked); } } if (!isRecur) { // multi-installment pledges are also recurring.... var is_pledge = $('input[name="is_pledge"]:checked'); isRecur = is_pledge.length === 1 && parseInt(is_pledge.val()) !== 0 && parseInt($('#pledge_installments').val()) > 1; } this.debugging(this.name, 'isRecur is ' + isRecur); return isRecur; }, /** * Does the form support recurring contributions? * @returns {boolean} */ supportsRecur: function() { var supportsRecur = false; // Auto-renew contributions for CiviCRM Webforms. if (this.getIsDrupalWebform()) { if (($('input[data-civicrm-field-key$="contribution_installments"]').length !== 0) || ($('input[data-civicrm-field-key$="contribution_frequency_interval"]').length !== 0) ) { supportsRecur = true; } } // Auto-renew contributions if (document.getElementById('is_recur') !== null) { supportsRecur = true; } // Auto-renew memberships // input[name="auto_renew"] : set to 1 when there is a force-renew membership with no priceset. else if ($('input[name="auto_renew"]').length !== 0) { supportsRecur = true; } else if ($('input[name=\'is_pledge\']').length !== 0) { // Pledge payments supportsRecur = true; } this.debugging(this.name, 'supportsRecur is ' + supportsRecur); return supportsRecur; }, /** * Try and get the billing email(s) from the form * @returns {string} separated by ; */ getBillingEmail: function() { var billingEmail = ''; $(this.form).find('input[id^=email]').filter(':visible').each(function() { billingEmail += $(this).val() + ';'; }); return billingEmail; }, /** * Try and get the billing contact name from the form * @returns {string} separated by ; */ getBillingName: function() { var billingName = ''; $(this.form).find('input#first_name,input#last_name').each(function() { billingName += $(this).val() + ';'; }); return billingName; }, /** * Get the selected payment processor on the form * @returns {null|number} */ getPaymentProcessorSelectorValue: function() { // Frontend radio selector var paymentProcessorSelected = this.form.querySelector('input[name="payment_processor_id"]:checked'); if (paymentProcessorSelected !== null) { return parseInt(paymentProcessorSelected.value); } else { // Backend select dropdown paymentProcessorSelected = this.form.querySelector('select[name="payment_processor_id"]'); if (paymentProcessorSelected !== null) { return parseInt(paymentProcessorSelected.value); } } return null; }, /** * Is the AJAX request a payment form? * @param {string} url * @returns {bool} */ isAJAXPaymentForm: function(url) { // /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 var patterns = [ "(\/|%2F)payment(\/|%2F)form", "(\/|\%2F)contact(\/|\%2F)view(\/|\%2F)participant", "(\/|\%2F)contact(\/|\%2F)view(\/|\%2F)membership", "(\/|\%2F)contact(\/|\%2F)view(\/|\%2F)contribution" ]; if (CRM.config.isFrontend && CRM.vars.payment && CRM.vars.payment.basePage !== 'civicrm') { for (const pattern of patterns) { if (url.match(CRM.vars.payment.basePage + pattern) !== null) { return true; } } } for (const pattern of patterns) { if (url.match('civicrm' + pattern) !== null) { return true; } } }, /** * Call this function before submitting the form to CiviCRM (if you ran setBillingFieldsRequiredForJQueryValidate()). * The "name" parameter on a group of checkboxes where at least one must be checked must be the same or validation will require all of them! * Reset the name of the checkboxes before submitting otherwise CiviCRM will not get the checkbox values. */ resetBillingFieldsRequiredForJQueryValidate: function() { $('div#priceset input[type="checkbox"], fieldset.crm-profile input[type="checkbox"], #on-behalf-block input[type="checkbox"]').each(function() { if ($(this).attr('data-name') !== undefined) { $(this).attr('name', $(this).attr('data-name')); } }); }, /** * Call this function before running jQuery validation * * CustomField checkboxes in profiles do not get the "required" class. * This should be fixed in CRM_Core_BAO_CustomField::addQuickFormElement but requires that the "name" is fixed as well. */ setBillingFieldsRequiredForJQueryValidate: function() { $('div.label span.crm-marker').each(function() { $(this).closest('div').next('div').find('input[type="checkbox"]').addClass('required'); }); // The "name" parameter on a set of checkboxes where at least one must be checked must be the same or validation will require all of them! // Checkboxes for custom fields are added as quickform "advcheckbox" which seems to require a unique name for each checkbox. But that breaks // jQuery validation because each checkbox in a required group must have the same name. // We store the original name and then change it. resetBillingFieldsRequiredForJQueryValidate() must be called before submit. // Most checkboxes get names like: "custom_63[1]" but "onbehalf" checkboxes get "onbehalf[custom_63][1]". We change them to "custom_63" and "onbehalf[custom_63]". $('div#priceset input[type="checkbox"], fieldset.crm-profile input[type="checkbox"], #on-behalf-block input[type="checkbox"]').each(function() { var name = $(this).attr('name'); $(this).attr('data-name', name); $(this).attr('name', name.replace('[' + name.split('[').pop(), '')); }); // Default email validator accepts test@example but on test@example.org is valid (https://jqueryvalidation.org/jQuery.validator.methods/) $.validator.methods.email = function(value, element) { // Regex from https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address return this.optional(element) || /^[a-zA-Z0-9.!#$%&'*+\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/.test(value); }; }, /** * Drupal webform requires a custom element to determine how it should process the submission * or if it should do another action. Because we trigger submit via javascript we have to add this manually. * @param submitAction */ addDrupalWebformActionElement: function(submitAction) { var hiddenInput = null; if (document.getElementById('action') !== null) { hiddenInput = document.getElementById('action'); } else { hiddenInput = document.createElement('input'); } hiddenInput.setAttribute('type', 'hidden'); hiddenInput.setAttribute('name', 'op'); hiddenInput.setAttribute('id', 'action'); hiddenInput.setAttribute('value', submitAction); this.form.appendChild(hiddenInput); }, /** * Delegate to standard form submission (we do not submit via our javascript processing). * @returns {*} */ doStandardFormSubmit: function() { // Disable the submit button to prevent repeated clicks for (i = 0; i < this.submitButtons.length; ++i) { this.submitButtons[i].setAttribute('disabled', true); } this.resetBillingFieldsRequiredForJQueryValidate(); return this.form.submit(); }, /** * Validate a reCaptcha if it exists on the form. * Ideally we would use grecaptcha.getResponse() but the reCaptcha is already * render()ed by CiviCRM so we don't have clientID and can't be sure we are * checking the reCaptcha that is on our form. * * @returns {boolean} */ validateReCaptcha: function() { if (typeof grecaptcha === 'undefined') { // No reCaptcha library loaded this.debugging(this.name, 'reCaptcha library not loaded'); return true; } if ($(this.form).find('[name=g-recaptcha-response]').length === 0) { // no reCaptcha on form - we check this first because there could be reCaptcha on another form on the same page that we don't want to validate this.debugging(this.name, 'no reCaptcha on form'); return true; } if ($(this.form).find('[name=g-recaptcha-response]').val().length > 0) { // We can't use grecaptcha.getResponse because there might be multiple reCaptchas on the page and we might not be the first one. this.debugging(this.name, 'recaptcha is valid'); return true; } this.debugging(this.name, 'recaptcha active and not valid'); $('div#card-errors').hide(); this.swalFire({ icon: 'warning', text: '', title: ts('Please complete the reCaptcha') }, '.recaptcha-section', true); this.triggerEvent('crmBillingFormNotValid'); this.form.dataset.submitted = 'false'; return false; }, /** * Validate if the discount code has been applied or the text-field is empty * * @returns {boolean} */ validateCiviDiscount: function() { // cividiscount: when a code is applied it stays in the text-field // If it is valid discount-applied attribute is 1, otherwise it's undefined. // Logic: If we have a discountcode field, text in the discountcode field and the discount-applied attribute is not set do not submit form if ($('input#discountcode').length && ($('input#discountcode').val().length > 0) && ($('input#discountcode').attr('discount-applied') != 1)) { this.debugging(this.name,'Discount Code Entered but not applied'); this.swalFire({ icon: 'error', text: ts('Please apply the Discount Code or clear the Discount Code text-field'), title: '' }, '#crm-container', true); this.triggerEvent('crmBillingFormNotValid'); this.form.dataset.submitted = 'false'; return false; } return true; }, /** * Check if form is valid (required fields filled in etc). * @returns {boolean} */ validateForm: function() { if (($(this.form).valid() === false) || $(this.form).data('crmBillingFormValid') === false) { this.debugging(this.name, 'Form not valid'); $('div#card-errors').hide(); CRM.payment.swalFire({ icon: 'error', text: ts('Please check and fill in all required fields!'), title: '' }, '#crm-container', true); CRM.payment.triggerEvent('crmBillingFormNotValid'); CRM.payment.form.dataset.submitted = 'false'; return false; } return true; }, /** * Find submit buttons which should not submit payment */ addHandlerNonPaymentSubmitButtons: function() { // Find submit buttons which should not submit payment var nonPaymentSubmitButtons = this.form.querySelectorAll('[type="submit"][formnovalidate="1"], ' + '[type="submit"][formnovalidate="formnovalidate"], ' + '[type="submit"].cancel, ' + '[type="submit"].webform-previous'), i; for (i = 0; i < nonPaymentSubmitButtons.length; ++i) { nonPaymentSubmitButtons[i].addEventListener('click', submitDontProcess(nonPaymentSubmitButtons[i])); } function submitDontProcess(element) { CRM.payment.debugging(CRM.payment.scriptName, 'adding submitdontprocess: ' + element.id); CRM.payment.form.dataset.submitdontprocess = 'true'; } }, /** * This adds handling for the CiviDiscount extension "apply" button. * A better way should really be found. */ addSupportForCiviDiscount: function() { // Add a keypress handler to set flag if enter is pressed var cividiscountElements = this.form.querySelectorAll('input#discountcode'); var cividiscountHandleKeydown = function(event) { if (event.code === 'Enter') { event.preventDefault(); CRM.payment.debugging(this.name, 'adding submitdontprocess'); CRM.payment.form.dataset.submitdontprocess = 'true'; } }; for (i = 0; i < cividiscountElements.length; ++i) { cividiscountElements[i].addEventListener('keydown', cividiscountHandleKeydown); } }, /** * Display an error for the payment element * * @param {string} errorMessage - the error string * @param {boolean} notify - whether to popup a notification as well as * display on the form. */ displayError: function(errorMessage, notify) { // Display error.message in your UI. this.debugging(this.name, 'error: ' + errorMessage); // Inform the user if there was an error var errorElement = document.getElementById('card-errors'); errorElement.style.display = 'block'; errorElement.textContent = errorMessage; this.form.dataset.submitted = 'false'; if (this.submitButtons !== null) { for (i = 0; i < this.submitButtons.length; ++i) { this.submitButtons[i].removeAttribute('disabled'); } } this.triggerEvent('crmBillingFormNotValid'); if (notify) { this.swalClose(); CRM.payment.swalFire({ icon: 'error', text: errorMessage, title: '' }, '#card-element', true); } }, /** * Wrapper around Swal.fire() * @param {array} parameters * @param {string} scrollToElement * @param {boolean} fallBackToAlert */ swalFire: function(parameters, scrollToElement, fallBackToAlert) { if (typeof Swal === 'function') { if (scrollToElement.length > 0) { parameters.didClose = function() { window.scrollTo($(scrollToElement).position()); }; } Swal.fire(parameters); } else if (fallBackToAlert) { window.alert(parameters.title + ' ' + parameters.text); } }, /** * Wrapper around Swal.close() */ swalClose: function() { if (typeof Swal === 'function') { Swal.close(); } }, /** * Trigger a jQuery event * @param {string} event */ triggerEvent: function(event, scriptName) { var triggerNow = true; if (typeof scriptName !== 'undefined') { if (event === 'crmBillingFormReloadComplete') { this.scripts[scriptName].reloadComplete = true; $.each(CRM.payment.scripts, function(scriptName,scriptObject) { if (scriptObject.reloadComplete !== true) { triggerNow = false; return; } }); } } if (triggerNow) { this.debugging((typeof scriptName !== 'undefined') ? scriptName : this.name, 'Firing Event: ' + event); $(this.form).trigger(event); } else { this.debugging((typeof scriptName !== 'undefined') ? scriptName : this.name, 'Waiting for other scripts (' + event + ')'); } }, /** * This should be called as soon as a script is executed so CRM.payment can handle multiple scripts on the DOM * * @param scriptName */ registerScript: function(scriptName) { this.scripts[scriptName] = { reloadComplete: false }; }, /** * Output debug information * @param {string} scriptName * @param {string} errorCode */ debugging: function(scriptName, errorCode) { if ((typeof(CRM.vars.payment) !== 'undefined') && (Boolean(CRM.vars.payment.jsDebug) === true)) { console.log(new Date().toISOString() + ' ' + scriptName + ': ' + errorCode); } } }; if (typeof CRM.payment === 'undefined') { CRM.payment = payment; } else { if (CRM.payment.hasOwnProperty('scriptName') && (CRM.payment.scriptName === 'CRM.payment')) { return; } if (CRM.payment.hasOwnProperty('getTotalAmount')) { delete(payment.getTotalAmount); payment.debugging(payment.name, 'Deferring to client getTotalAmount function'); } $.extend(CRM.payment, payment); } document.addEventListener('DOMContentLoaded', function() { CRM.payment.debugging('CRM.payment', 'loaded via DOMContentLoaded'); CRM.payment.getBillingForm(); }); // Re-prep form when we've loaded a new payproc via ajax or via webform $(document).ajaxComplete(function(event, xhr, settings) { // /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 (CRM.payment.isAJAXPaymentForm(settings.url)) { CRM.payment.debugging('CRM.payment', 'triggered via ajax'); CRM.payment.getBillingForm(); // This resets the reload status of all scripts when CRM.payment is reloaded $.each(CRM.payment.scripts, function(scriptName,scriptObject) { CRM.payment.scripts[scriptName].reloadComplete = false; }); } }); }(CRM.$, CRM.ts('mjwshared')));