diff --git a/js/crm.payment.js b/js/crm.payment.js
index 062fff0b852f836d930a5098261bd7a94301595d..2e00225a5c3ba91565ce31ec8f865405f296c022 100644
--- a/js/crm.payment.js
+++ b/js/crm.payment.js
@@ -1,8 +1,9 @@
 (function($) {
 
   var payment = {
-    form: null,
     scriptName: 'CRM.payment',
+    form: null,
+    submitButtons: null,
 
     /**
      * Get the total amount on the form
@@ -11,7 +12,7 @@
     getTotalAmount: function() {
       var totalAmount = 0.0;
       if (CRM.payment.isEventAdditionalParticipants()) {
-        // We MUST return null because 0.0 is treated as a non-stripe submit.
+        // We MUST return null because 0.0 is treated as a non-processor submit.
         // In this case the amount is not 0, we just don't know what it is yet.
         totalAmount = null;
       }
@@ -22,7 +23,7 @@
       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 Stripe is the ONLY payment processor configured on the page.
+          // - When we are the ONLY payment processor configured on the page.
           totalAmount = parseFloat(calculateTotalFee());
         }
         else if (this.getIsDrupalWebform()) {
@@ -88,6 +89,8 @@
     },
 
     /**
+     * Is the event registering additional participants (this means we do not know the full amount)
+     *
      * @returns {boolean}
      */
     isEventAdditionalParticipants: function() {
@@ -99,6 +102,11 @@
       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) {
@@ -107,33 +115,45 @@
       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 stripe billing form on the page
-      var billingFormID = $('div#card-element').closest('form').prop('id');
+      // If we have a billing form on the page with our processor
+      var billingFormID = $('div#crm-payment-js-billing-form-container').closest('form').prop('id');
       if ((typeof billingFormID === 'undefined') || (!billingFormID.length)) {
-        // If we have multiple payment processors to select and stripe is not currently loaded
+        // If we have multiple payment processors to select and we are not currently loaded
         billingFormID = $('input[name=hidden_processor]').closest('form').prop('id');
       }
       // We have to use document.getElementById here so we have the right elementtype for appendChild()
-      return document.getElementById(billingFormID);
+      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() {
-      var submit = null;
       if (CRM.payment.getIsDrupalWebform()) {
-        submit = this.form.querySelectorAll('[type="submit"].webform-submit');
-        if (submit.length === 0) {
+        this.submitButtons = this.form.querySelectorAll('[type="submit"].webform-submit');
+        if (this.submitButtons.length === 0) {
           // drupal 8 webform
-          submit = this.form.querySelectorAll('[type="submit"].webform-button--submit');
+          this.submitButtons = this.form.querySelectorAll('[type="submit"].webform-button--submit');
         }
       }
       else {
-        submit = this.form.querySelectorAll('[type="submit"].validate');
+        this.submitButtons = this.form.querySelectorAll('[type="submit"].validate');
       }
-      if (submit.length === 0) {
+      if (this.submitButtons.length === 0) {
         this.debugging(this.scriptName, 'No submit button found!');
       }
-      return submit;
+      return this.submitButtons;
     },
 
     /**
@@ -253,12 +273,213 @@
      * @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
       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);
     },
 
+    /**
+     * 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.scriptName, '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.scriptName, '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.scriptName, 'recaptcha is valid');
+        return true;
+      }
+      this.debugging(this.scriptName, '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;
+    },
+
+    /**
+     * 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
+      cividiscountElements = this.form.querySelectorAll('input#discountcode');
+      var cividiscountHandleKeydown = function(event) {
+        if (event.code === 'Enter') {
+          event.preventDefault();
+          this.debugging(this.scriptName, 'adding submitdontprocess');
+          this.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.scriptName, '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.onAfterClose = 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) {
+      this.debugging(this.scriptName, 'Firing Event: ' + event);
+      $(this.form).trigger(event);
+    },
+
     /**
      * Output debug information
      * @param {string} scriptName
@@ -288,7 +509,7 @@
 
   document.addEventListener('DOMContentLoaded', function() {
     CRM.payment.debugging(CRM.payment.scriptName, 'loaded via DOMContentLoaded');
-    CRM.payment.form = CRM.payment.getBillingForm();
+    CRM.payment.getBillingForm();
   });
 
   // Re-prep form when we've loaded a new payproc via ajax or via webform
@@ -298,7 +519,7 @@
     // On wordpress these are urlencoded
     if (CRM.payment.isAJAXPaymentForm(settings.url)) {
       CRM.payment.debugging(CRM.payment.scriptName, 'triggered via ajax');
-      CRM.payment.form = CRM.payment.getBillingForm();
+      CRM.payment.getBillingForm();
     }
   });