text:
import { LightningElement, api, track, wire } from 'lwc';

import sendPurchaseRequest from '@salesforce/apex/GE_GiftEntryController.sendPurchaseRequest';
import upsertDataImport from '@salesforce/apex/GE_GiftEntryController.upsertDataImport';
import submitDataImportToBDI from '@salesforce/apex/GE_GiftEntryController.submitDataImportToBDI';
import validateAuthorizedGiftEdit from '@salesforce/apex/GE_GiftEntryController.validateAuthorizedGiftEdit';
import getPaymentTransactionStatusValues from '@salesforce/apex/GE_PaymentServices.getPaymentTransactionStatusValues';
import { getCurrencyLowestCommonDenominator } from 'c/utilNumberFormatter';
import PAYMENT_AUTHORIZE_TOKEN from '@salesforce/schema/DataImport__c.Payment_Authorization_Token__c';
import PAYMENT_ELEVATE_ID from '@salesforce/schema/DataImport__c.Payment_Elevate_ID__c';
import PAYMENT_ELEVATE_ELEVATE_BATCH_ID from '@salesforce/schema/DataImport__c.Payment_Elevate_Batch_Id__c';
import PAYMENT_CARD_NETWORK from '@salesforce/schema/DataImport__c.Payment_Card_Network__c';
import PAYMENT_EXPIRATION_YEAR from '@salesforce/schema/DataImport__c.Payment_Card_Expiration_Year__c';
import PAYMENT_EXPIRATION_MONTH from '@salesforce/schema/DataImport__c.Payment_Card_Expiration_Month__c';
import PAYMENT_GATEWAY_ID from '@salesforce/schema/DataImport__c.Payment_Gateway_ID__c';
import PAYMENT_GATEWAY_TRANSACTION_ID from '@salesforce/schema/DataImport__c.Payment_Gateway_Payment_ID__c';
import PAYMENT_AUTHORIZED_AT from '@salesforce/schema/DataImport__c.Payment_Authorized_UTC_Timestamp__c';
import PAYMENT_CREATED_AT from '@salesforce/schema/DataImport__c.Payment_Elevate_Created_UTC_Timestamp__c';
import PAYMENT_LAST_4 from '@salesforce/schema/DataImport__c.Payment_Card_Last_4__c';
import PAYMENT_STATUS from '@salesforce/schema/DataImport__c.Payment_Status__c';
import PAYMENT_DECLINED_REASON from '@salesforce/schema/DataImport__c.Payment_Declined_Reason__c';
import DONATION_CAMPAIGN_NAME from '@salesforce/schema/DataImport__c.Donation_Campaign_Name__c';
import PAYMENT_ACH_CODE from '@salesforce/schema/DataImport__c.Payment_ACH_Code__c';
import PAYMENT_ACH_LAST_4 from '@salesforce/schema/DataImport__c.Payment_ACH_Last_4__c';
import PAYMENT_METHOD from '@salesforce/schema/DataImport__c.Payment_Method__c';
import PAYMENT_ELEVATE_ORIGINAL_PAYMENT_ID
    from '@salesforce/schema/DataImport__c.Payment_Elevate_Original_Payment_ID__c';
import PAYMENT_TYPE from '@salesforce/schema/DataImport__c.Payment_Type__c';
import PAYMENT_ACH_CONSENT from '@salesforce/schema/DataImport__c.ACH_Consent__c';

import { getObjectInfo } from 'lightning/uiObjectInfoApi';
import { convertBDIToWidgetJson } from './geFormRendererHelper';
import GeFormElementHelper from './geFormElementHelper'
import GeFormService from 'c/geFormService';
import GeLabelService from 'c/geLabelService';
import messageLoading from '@salesforce/label/c.labelMessageLoading';
import { getNumberAsLocalizedCurrency } from 'c/utilNumberFormatter';
import {
    buildErrorMessage,
    DONATION_DONOR_FIELDS,
    DONATION_DONOR,
    handleError,
    getRecordFieldNames,
    setRecordValuesOnTemplate,
    checkPermissionErrors,
    isTrueFalsePicklist,
    trueFalsePicklistOptions,
    CHECKBOX_TRUE,
    CHECKBOX_FALSE,
    PICKLIST_TRUE,
    PICKLIST_FALSE, CONTACT_FIRST_NAME_INFO, CONTACT_LAST_NAME_INFO
} from 'c/utilTemplateBuilder';
import { registerListener, fireEvent } from 'c/pubsubNoPageRef';
import {
    getQueryParameters,
    isEmpty,
    isObject,
    isNotEmpty,
    format,
    isUndefined,
    hasNestedProperty,
    deepClone,
    getNamespace,
    validateJSONString,
    relatedRecordFieldNameFor,
    apiNameFor,
    isString,
    showToast
} from 'c/utilCommon';
import ExceptionDataError from './exceptionDataError';
import ElevateBatch from './elevateBatch';
import ElevateTokenizeableGift from './elevateTokenizeableGift';
import { getRecord, getFieldValue } from 'lightning/uiRecordApi';
import FORM_TEMPLATE_FIELD from '@salesforce/schema/DataImportBatch__c.Form_Template__c';
import BATCH_DEFAULTS_FIELD from '@salesforce/schema/DataImportBatch__c.Batch_Defaults__c';
import STATUS_FIELD from '@salesforce/schema/DataImport__c.Status__c';
import NPSP_DATA_IMPORT_BATCH_FIELD from '@salesforce/schema/DataImport__c.NPSP_Data_Import_Batch__c';

import DATA_IMPORT_ADDITIONAL_OBJECT_FIELD from '@salesforce/schema/DataImport__c.Additional_Object_JSON__c'
import DATA_IMPORT_ACCOUNT1_IMPORTED_FIELD from '@salesforce/schema/DataImport__c.Account1Imported__c';
import DATA_IMPORT_CONTACT1_IMPORTED_FIELD from '@salesforce/schema/DataImport__c.Contact1Imported__c';
import DATA_IMPORT_CONTACT1_FIRSTNAME_FIELD from '@salesforce/schema/DataImport__c.Contact1_Firstname__c';
import DATA_IMPORT_CONTACT1_LASTNAME_FIELD from '@salesforce/schema/DataImport__c.Contact1_Lastname__c';
import DATA_IMPORT_DONATION_IMPORTED_FIELD from '@salesforce/schema/DataImport__c.DonationImported__c';
import DATA_IMPORT_PAYMENT_IMPORTED_FIELD from '@salesforce/schema/DataImport__c.PaymentImported__c';
import DATA_IMPORT_DONATION_IMPORT_STATUS_FIELD from '@salesforce/schema/DataImport__c.DonationImportStatus__c';
import DATA_IMPORT_PAYMENT_IMPORT_STATUS_FIELD from '@salesforce/schema/DataImport__c.PaymentImportStatus__c';
import DATA_IMPORT_DONATION_DONOR_FIELD
    from '@salesforce/schema/DataImport__c.Donation_Donor__c';
import DONATION_AMOUNT from '@salesforce/schema/DataImport__c.Donation_Amount__c';
import DONATION_DATE from '@salesforce/schema/DataImport__c.Donation_Date__c';
import DONATION_RECORD_TYPE_NAME
    from '@salesforce/schema/DataImport__c.Donation_Record_Type_Name__c';
import OPP_PAYMENT_AMOUNT
    from '@salesforce/schema/npe01__OppPayment__c.npe01__Payment_Amount__c';
import SCHEDULED_DATE from '@salesforce/schema/npe01__OppPayment__c.npe01__Scheduled_Date__c';
import {
    DISABLE_TOKENIZE_WIDGET_EVENT_NAME,
    LABEL_NEW_LINE,
    ACCOUNT_HOLDER_BANK_TYPES,
    ACCOUNT_HOLDER_TYPES,
    PAYMENT_METHODS, ACH_CODE,
    PAYMENT_METHOD_CREDIT_CARD,
    PAYMENT_UNKNOWN_ERROR_STATUS,
    FAILED
} from 'c/geConstants';

import ACCOUNT_OBJECT from '@salesforce/schema/Account';
import ACCOUNT_NAME_FIELD from '@salesforce/schema/Account.Name';
import CONTACT_OBJECT from '@salesforce/schema/Contact';
import CONTACT_NAME_FIELD from '@salesforce/schema/Contact.Name';
import OPP_PAYMENT_OBJECT from '@salesforce/schema/npe01__OppPayment__c';
import OPPORTUNITY_OBJECT from '@salesforce/schema/Opportunity';
import PARENT_OPPORTUNITY_FIELD
    from '@salesforce/schema/npe01__OppPayment__c.npe01__Opportunity__c';
import ELEVATE_PAYMENT_STATUS_FIELD
    from '@salesforce/schema/npe01__OppPayment__c.Elevate_Payment_API_Status__c';
import DATA_IMPORT_OBJECT from '@salesforce/schema/DataImport__c';
import DATA_IMPORT_ACCOUNT1_NAME
    from '@salesforce/schema/DataImport__c.Account1_Name__c';

// Labels are used in BDI_MatchDonations class
import userSelectedMatch from '@salesforce/label/c.bdiMatchedByUser';
import userSelectedNewOpp from '@salesforce/label/c.bdiMatchedByUserNewOpp';
import applyNewPayment from '@salesforce/label/c.bdiMatchedApplyNewPayment';
import CURRENCY from '@salesforce/i18n/currency';

const mode = {
    CREATE: 'create',
    UPDATE: 'update'
};
const DONATION_DONOR_TYPE_ENUM = Object.freeze({
    ACCOUNT1: 'Account1',
    CONTACT1: 'Contact1'
});

const ACH_CONSENT_MESSAGE = 'true';
const EXPANDABLE_SECTION_CONTAINER = 'expandableSectionContainer';

export default class GeFormRenderer extends LightningElement{
    // these three fields are used to query the donor record
    // when opened from an Account or Contact
    @api donorRecordId;
    @api donorRecord;
    @api loadingText;

    fieldNames = [ ACCOUNT_NAME_FIELD, CONTACT_NAME_FIELD ];
    PAYMENT_TRANSACTION_STATUS_ENUM;
    @api sections = [];
    @api showSpinner = false;
    @api batchId;
    @api submissions = [];
    @api hasPageLevelError = false;
    @api pageLevelErrorMessageList = [];
    @api batchCurrencyIsoCode;

    @track isPermissionError = false;
    @track permissionErrorTitle;
    @track permissionErrorMessage;
    @track formTemplate;
    @track fieldMappings;
    @track ready = false;
    @track name = '';
    @track description = '';
    @track mappingSet = '';
    @track version = '';
    @track formTemplateId;
    _batchDefaults;
    _isElevateWidgetInDisabledState = false;
    _hasPaymentWidget = false;
    latestElevateBatchId = null;
    cardholderNamesNotInTemplate = {};
    _openedGiftId;

    erroredFields = [];
    CUSTOM_LABELS = {...GeLabelService.CUSTOM_LABELS, messageLoading};

    @track widgetConfig = {
        hasPaymentMethodFieldInForm: undefined,
        paymentTransactionStatusValues: undefined
    }
    @track isAccessible = true;

    _isFormCollapsed = false;
    _shouldInformParent = true;

    set selectedDonationOrPaymentRecord(record) {
        if (record.new === true) {
            this.setCreateNewOpportunityInFormState();
        } else if (this.isAPaymentId(record.Id)) {
            this.setSelectedPaymentInFormState(record);
            this.loadPaymentAndParentDonationFieldValues(record);
        } else if (this.isAnOpportunityId(record.Id)) {
            this.setSelectedDonationInFormState(record, record.applyPayment);
            this.loadSelectedDonationFieldValues(record);
        } else {
            throw 'Unsupported selected donation type!';
        }
    }

    setSelectedPaymentInFormState(record) {
        const updatedData = {
            [apiNameFor(DATA_IMPORT_PAYMENT_IMPORTED_FIELD)]: record.Id,
            [apiNameFor(DATA_IMPORT_DONATION_IMPORTED_FIELD)]:
                record[apiNameFor(PARENT_OPPORTUNITY_FIELD)],
            [apiNameFor(DATA_IMPORT_PAYMENT_IMPORT_STATUS_FIELD)]: userSelectedMatch,
            [apiNameFor(DATA_IMPORT_DONATION_IMPORT_STATUS_FIELD)]: userSelectedMatch,
            [apiNameFor(PAYMENT_STATUS)]:
                record[apiNameFor(ELEVATE_PAYMENT_STATUS_FIELD)],
        };
        this.updateFormState(updatedData);
    }

    loadPaymentAndParentDonationFieldValues(record) {
        this.loadSelectedRecordFieldValues(
            apiNameFor(DATA_IMPORT_DONATION_IMPORTED_FIELD),
            record[apiNameFor(PARENT_OPPORTUNITY_FIELD)]);

        this.loadSelectedRecordFieldValues(
            apiNameFor(DATA_IMPORT_PAYMENT_IMPORTED_FIELD), record.Id);
    }

    setSelectedDonationInFormState(record, isApplyNewPayment = false) {
        this.resetDonationAndPaymentImportedFields();

        this.updateFormState({
            [apiNameFor(DATA_IMPORT_DONATION_IMPORTED_FIELD)]: record.Id,
            [apiNameFor(DATA_IMPORT_DONATION_IMPORT_STATUS_FIELD)]:
                isApplyNewPayment ? applyNewPayment : userSelectedMatch
        });
    }

    loadSelectedDonationFieldValues(record) {
        this.loadSelectedRecordFieldValues(
            apiNameFor(DATA_IMPORT_DONATION_IMPORTED_FIELD), record.Id);
    }

    @track hasPurchaseCallTimedout = false;

    /*******************************************************************************
     * @description Object used to hold current values for all fields on the form.
     */
    @track
    _formState = {}

    /** Determines when we show payment related text above the cancel and save buttons */
    get showPaymentSaveNotice() {
        return this._hasPaymentWidget && this._isElevateWidgetInDisabledState === false;
    }

    get title() {
        return hasNestedProperty(this.donorRecord, 'fields', 'Name', 'value') ?
            GeLabelService.format(
                this.CUSTOM_LABELS.geHeaderMatchingGiftBy,
                [this.donorRecord.fields.Name.value]) :
            this.CUSTOM_LABELS.commonNewGift;
    }

    get isSingleGiftEntry() {
        return !this.batchId;
    }

    get cancelButtonText() {
        return this.isSingleGiftEntry ?
            this.CUSTOM_LABELS.commonCancel :
            this.CUSTOM_LABELS.geButtonCancelAndClear;
    }

    @wire(getRecord, {recordId: '$donorRecordId', optionalFields: '$fieldNames'})
    wiredGetRecordMethod({error, data}) {
        if (data) {
            this.donorRecord = data;
            this.initializeForm(this.formTemplate, this.fieldMappings);
        } else if (error) {
            console.error(JSON.stringify(error));
        }
    }

    connectedCallback() {
        getPaymentTransactionStatusValues()
            .then(response => {
                this.PAYMENT_TRANSACTION_STATUS_ENUM = Object.freeze(JSON.parse(response));
            });
        registerListener('paymentError', this.handleAsyncWidgetError, this);
        registerListener('doNotChargeState', this.handleDisableElevateWidgetState, this);
        registerListener('geModalCloseEvent', this.handleChangeSelectedDonation, this);
        registerListener('nullPaymentFieldsInFormState', this.handleNullPaymentFieldsInFormState, this);
        registerListener('formRendererReset', this.reset, this);
        registerListener('widgetStateChange', this.handleWidgetStateChange, this);

        GeFormService.getFormTemplate().then(response => {
            if (this.batchId) {
                // When the form is being used for Batch Gift Entry, the Form Template JSON
                // uses the @wire service below to retrieve the Template using the Template Id
                // stored on the Batch.
                return;
            }

            // check if there is a record id in the url
            this.donorRecordId = getQueryParameters().c__donorRecordId;
            const donorApiName = getQueryParameters().c__apiName;
            if (donorApiName) {
                this.initializeDonationDonorTypeInFormState(donorApiName);
            }

            // read the template header info
            if (response !== null && typeof response !== 'undefined') {
                this.formTemplate = response.formTemplate;
                this.fieldMappings = response.fieldMappingSetWrapper.fieldMappingByDevName;

                let errorObject = checkPermissionErrors(this.formTemplate);
                if (errorObject) {
                    this.setPermissionsError(errorObject);

                    return;
                }

                // get the target field names to be used by getRecord
                let fieldNamesFromTemplate =
                    getRecordFieldNames(this.formTemplate, this.fieldMappings, donorApiName);
                this.fieldNames = [...this.fieldNames, ...fieldNamesFromTemplate];
                if (isEmpty(this.donorRecordId)) {
                    // if we don't have a donor record, it's ok to initialize the form now
                    // otherwise the form will be initialized after wiredGetRecordMethod completes
                    this.initializeForm(this.formTemplate);
                }
            }
        });
    }

    handleWidgetStateChange(changeEvent) {
        if (changeEvent.state === 'readOnly') {
            this._isElevateWidgetInDisabledState = true;
        }
    }

    handleNullPaymentFieldsInFormState() {
        if (this.shouldNullPaymentRelatedFields()) {
            this.nullPaymentFieldsInFormState([
                apiNameFor(PAYMENT_AUTHORIZE_TOKEN),
                apiNameFor(PAYMENT_DECLINED_REASON),
                apiNameFor(PAYMENT_STATUS),
                apiNameFor(PAYMENT_ELEVATE_ELEVATE_BATCH_ID),
                apiNameFor(PAYMENT_ELEVATE_ID),
                apiNameFor(PAYMENT_ELEVATE_ORIGINAL_PAYMENT_ID),
                apiNameFor(PAYMENT_LAST_4),
                apiNameFor(PAYMENT_CARD_NETWORK),
                apiNameFor(PAYMENT_EXPIRATION_MONTH),
                apiNameFor(PAYMENT_EXPIRATION_YEAR),
                apiNameFor(PAYMENT_AUTHORIZED_AT),
                apiNameFor(PAYMENT_GATEWAY_ID),
                apiNameFor(PAYMENT_GATEWAY_TRANSACTION_ID),
            ]);
        }
    }

    initializeDonationDonorTypeInFormState(donorApiName) {
        const updates = new Map();
        updates.set(DATA_IMPORT_DONATION_DONOR_FIELD, donorApiName);
        this.updateFormState(updates);
    }

    getDonationDonorTypeFor(donorApiName) {
        switch (donorApiName) {
            case 'Account':
                return DONATION_DONOR_TYPE_ENUM.ACCOUNT1;
            case 'Contact':
                return DONATION_DONOR_TYPE_ENUM.CONTACT1;
            default:
                throw `Unsupported donorApiName of: ${donorApiName}`;
        }
    }

    initializeForm(formTemplate, fieldMappings) {
        // read the template header info
        this.ready = true;
        this.name = formTemplate.name;
        this.description = formTemplate.description;
        this.version = formTemplate.layout.version;

        if (typeof formTemplate.layout !== 'undefined'
            && Array.isArray(formTemplate.layout.sections)) {
            // add record data to the template fields

            if (isNotEmpty(fieldMappings) && isNotEmpty(this.donorRecord)) {
                this.sections = setRecordValuesOnTemplate(formTemplate.layout.sections,
                    fieldMappings, this.donorRecord);
            } else {
                this.sections = formTemplate.layout.sections;
            }

            if (!this.isSingleGiftEntry) {
                this.sections = this.prepareFormForBatchMode(formTemplate.layout.sections);
                this.dispatchEvent(new CustomEvent('sectionsretrieved'));
            }
        }

        this.sections = this.appendRecordTypeLocationInfoToPicklistElements();
        this.initializeFormState();
        this.initializeWidgetConfig();
    }

    initializeWidgetConfig() {
        this.widgetConfig.hasPaymentMethodFieldInForm = this.sourceFieldsUsedInTemplate().includes(apiNameFor(PAYMENT_METHOD));
        this.widgetConfig.paymentTransactionStatusValues = this.PAYMENT_TRANSACTION_STATUS_ENUM;
    }

    appendRecordTypeLocationInfoToPicklistElements() {

        this.sections
            .forEach(section => {
                section.elements
                    .forEach(element => {
                        this.enrichElement(element);
                    });
            });

        return [...this.sections];
    }

    appendRecordTypeLocationInfo(element) {
        const fieldMappingDevName =
            element.dataImportFieldMappingDevNames &&
            element.dataImportFieldMappingDevNames[0];

        if (fieldMappingDevName) {
            element.siblingRecordTypeField =
                this.siblingRecordTypeFieldFor(fieldMappingDevName);
            element.parentRecordField =
                this.parentRecordFieldFor(fieldMappingDevName);
        }
    }

    enrichElement(element) {
        this.appendRecordTypeLocationInfo(element);
        this.appendElementHelperData(element);
    }

    appendElementHelperData(element) {
        const helper = new GeFormElementHelper(element);
        element.isRenderable = helper.isRenderable();
        if (helper.isTrueFalsePicklist()) {
            element.picklistOptionsOverride = trueFalsePicklistOptions();
        }
    }

    setPermissionsError(errorObject) {
        if (errorObject) {
            this.isPermissionError = true;
            this.permissionErrorTitle = errorObject.errorTitle;
            this.permissionErrorMessage = errorObject.errorMessage;
        }
    }

    @wire(getRecord, {
        recordId: '$batchId',
        fields: [FORM_TEMPLATE_FIELD, BATCH_DEFAULTS_FIELD]
    })
    wiredBatch({data, error}) {
        if (data) {
            this.formTemplateId = data.fields[apiNameFor(FORM_TEMPLATE_FIELD)].value;
            this._batchDefaults = data.fields[apiNameFor(BATCH_DEFAULTS_FIELD)].value;
            GeFormService.getFormTemplateById(this.formTemplateId)
                .then(formTemplate => {
                    this.formTemplate = formTemplate;

                    let errorObject = checkPermissionErrors(formTemplate);
                    if (errorObject) {
                        this.dispatchEvent(new CustomEvent('permissionerror'));
                        this.setPermissionsError(errorObject)
                    }
                    this.initializeForm(formTemplate, GeFormService.fieldMappings);
                })
                .catch(err => {
                    handleError(err);
                });
        } else if (error) {
            handleError(error);
        }
    }

    handleCancel() {
        this.reset();
        this.initializeFormState();

        if (this.isSingleGiftEntry) {
            const originatedFromRecordDetailPage = getQueryParameters().c__donorRecordId;
            if (originatedFromRecordDetailPage) {
                this.goToRecordDetailPage(originatedFromRecordDetailPage);
            } else {
                this.goToLandingPage();
            }
        }
    }

    goToRecordDetailPage(recordId) {
        if (!recordId) return;
        const navigateEvent = new CustomEvent('navigate', {
            detail: {
                to: 'recordPage',
                recordId: recordId
            }
        });
        this.dispatchEvent(navigateEvent);
    }

    goToLandingPage() {
        const navigateEvent = new CustomEvent('navigate', {
            detail: {
                to: 'landingPage'
            }
        });
        this.dispatchEvent(navigateEvent);
    }

    /*******************************************************************************
    * @description Dispatches an event to the geFormWidgetTokenizeCard component
    * to disable itself and display the provided message.
    *
    * @param {string} message: Message to display in the UI
    */
    dispatchDisablePaymentServicesWidgetEvent(message) {
        fireEvent(this, DISABLE_TOKENIZE_WIDGET_EVENT_NAME,
            { detail: { message: message } });
    }

    /*******************************************************************************
    * @description Dispatches an event and notifies the parent component to display
    * an aura overlay library modal with a lightning web component in its body.
    *
    * @param {string} modalBodyComponentName: Name of the LWC to render in the
    * overlay library modal's body.
    */
    toggleModalByComponentName(modalBodyComponentName, componentProperties) {
        const detail = {
            modalProperties: {
                componentName: modalBodyComponentName,
                showCloseButton: false
            },
            componentProperties
        };
        this.dispatchEvent(new CustomEvent('togglemodal', { detail }));
    }

    continueBatchGiftEntrySave(dataImportRecord, formControls, isCreditCardAuth) {
        // reset function for callback
        const reset = () => this.reset();
        // handle error on callback from promise
        const handleCatchError = (err) => this.handleCatchOnSave(err);

        this.dispatchEvent(new CustomEvent('submit', {
            detail: {
                dataImportRecord,
                success: () => {
                    formControls.enableSaveButton();
                    formControls.toggleSpinner();
                    reset();

                    if (isCreditCardAuth) {
                        showToast(
                            this.CUSTOM_LABELS.PageMessagesConfirm,
                            this.CUSTOM_LABELS.geAuthorizedCreditCardSuccess,
                            'success',
                            'dismissible',
                            null
                        );
                    }
                },
                error: (error) => {
                    formControls.enableSaveButton();
                    formControls.toggleSpinner();
                    handleCatchError(error);
                }
            }
        }));
    }

    handleCatchOnSave( error ) {
        // var inits
        const sectionsList = this.template.querySelectorAll('c-ge-form-section');
        const exceptionWrapper = new ExceptionDataError(error);
        const allDisplayedFields = this.getDisplayedFieldsMappedByAPIName(sectionsList);
        this.hasPageLevelError = true;

        if (isNotEmpty(exceptionWrapper.exceptionType)) {

            // Check to see if there are any field level errors
            if (Object.entries(exceptionWrapper.DMLErrorFieldNameMapping).length === undefined ||
                Object.entries(exceptionWrapper.DMLErrorFieldNameMapping).length === 0) {

                // validation rules on Target Objects shows up here
                // unfortunately currently it doesnt bring field info yet
                if (isNotEmpty(exceptionWrapper.errorMessage)) {
                    let errorMessage = exceptionWrapper.errorMessage;

                    const errorMessageObject = validateJSONString(exceptionWrapper.errorMessage);
                    if (errorMessageObject) {
                        errorMessage = errorMessageObject.errorMessage;
                    }

                    this.addPageLevelErrorMessage({ errorMessage, index: this.pageLevelErrorMessageList.length });
                }

                // If there are no specific fields the error has to go to,
                // put it on the page level error message.
                for (const dmlIndex in exceptionWrapper.DMLErrorMessageMapping) {
                    const errorMessage = exceptionWrapper.DMLErrorMessageMapping[dmlIndex];
                    const index = dmlIndex + 1;
                    this.addPageLevelErrorMessage({ errorMessage, index });
                }

            } else {
                // If there is a specific field that each error is supposed to go to,
                // show it on the field on the page.
                // If it is not on the page to show, display it on the page level.
                for (const key in exceptionWrapper.DMLErrorFieldNameMapping) {

                    // List of fields with this error
                    let fieldList = exceptionWrapper.DMLErrorFieldNameMapping[key];
                    // Error message for the field.
                    let errorMessage = exceptionWrapper.DMLErrorMessageMapping[key];
                    // Errored fields that are not displayed
                    let hiddenFieldList = [];

                    fieldList.forEach(fieldWithError => {

                        // Go to the field and set the error message using setCustomValidity
                        if (fieldWithError in allDisplayedFields) {
                            let fieldInput = allDisplayedFields[fieldWithError];
                            this.erroredFields.push(fieldInput);
                            fieldInput.setCustomValidity(errorMessage);
                        } else {
                            // Keep track of errored fields that are not displayed.
                            hiddenFieldList.push(fieldWithError);
                        }

                    });

                    // If there are hidden fields, display the error message at the page level.
                    // With the fields noted.
                    if (hiddenFieldList.length > 0) {
                        let combinedFields = hiddenFieldList.join(', ');
                        this.addPageLevelErrorMessage({
                            errorMessage: `${errorMessage} [${combinedFields}]`,
                            index: key
                        });
                    }
                }
            }
        } else {
            this.addPageLevelErrorMessage({ errorMessage: exceptionWrapper.errorMessage, index: 0 });
        }

        // focus either the page level or field level error message somehow
        window.scrollTo(0, 0);
    }

    /*******************************************************************************
     * @description Add an error message to the overall page level error messages
     * array.
     *
     * @param errorObject
     */
    addPageLevelErrorMessage(errorObject) {
        errorObject.index = errorObject.index ? errorObject.index : 0;
        this.pageLevelErrorMessageList = [
            ...this.pageLevelErrorMessageList,
            { ...errorObject }
        ];
        this.hasPageLevelError = true;
    }

    /*******************************************************************************
    * @description Handles the form save action. Builds a data import record and
    * calls handlers for Batch Gift and Single Gift depending on the form's mode.
    *
    * @param {object} event: Onclick event from the form save button
    */
    async handleSave(event) {
        const sectionsList = this.template.querySelectorAll('c-ge-form-section');
        const isFormReadyToSave = this.prepareFormForSave(sectionsList);

        if (isFormReadyToSave) {
            // Disable save button
            this.loadingText = '';
            event.target.disable = true;
            const formControls = this.getFormControls(event);
            formControls.toggleSpinner();

            let tokenizedGift = null;
            try {
                if (this.shouldTokenizeCard()) {
                    tokenizedGift = new ElevateTokenizeableGift(
                        this.cardholderNames,
                        getCurrencyLowestCommonDenominator(
                            this.getFieldValueFromFormState(DONATION_AMOUNT)
                        )
                    );

                    this.updateFormState(await tokenizedGift.tokenize(sectionsList));
                }
            } catch(ex) {
                // exceptions that we expect here are all async widget-related
                this.handleAsyncWidgetError(ex);
                return;
            }

            if (this.batchId) {
                await this.submitBatch(formControls, tokenizedGift);
            } else {
                await this.submitSingleGift();
            }
        }
    }

    async submitBatch(formControls, tokenizedGift) {
        this.handleNullPaymentFieldsInFormState();

        if (this.isGiftAuthorized() && !tokenizedGift && this._openedGiftId) {
            const canUpdate = await this.canAuthorizedGiftBeUpdated(
                this.saveableFormState(), formControls);
            if (!canUpdate) { return; }
        }

        const hasSaved = await this.saveDataImport(this.saveableFormState());
        if (!hasSaved) {
            this.disabled = false;
            this.toggleSpinner();
            return;
        }

        await this.prepareForBatchGiftSave(this.saveableFormState(), formControls, tokenizedGift);
    }

    shouldNullPaymentRelatedFields() {
        return (this.isGiftAuthorized() || this.isGiftExpired())
            && this.selectedPaymentMethod() !== PAYMENT_METHOD_CREDIT_CARD;
    }

    async prepareForBatchGiftSave(dataImportFromFormState, formControls, tokenizedGift) {
        if (tokenizedGift) {
            try {
                this.loadingText = this.CUSTOM_LABELS.geAuthorizingCreditCard;

                const currentElevateBatch = new ElevateBatch(this.latestElevateBatchId);
                const authorizedGift = await currentElevateBatch.add(tokenizedGift);
                const isAuthorized = authorizedGift.status === this.PAYMENT_TRANSACTION_STATUS_ENUM.AUTHORIZED
                    || authorizedGift.status === this.PAYMENT_TRANSACTION_STATUS_ENUM.PENDING;

                if (isAuthorized) {
                    this.updateFormState({
                        [apiNameFor(PAYMENT_ELEVATE_ELEVATE_BATCH_ID)]: currentElevateBatch.elevateBatchId,
                        [apiNameFor(PAYMENT_ELEVATE_ID)]: authorizedGift.paymentId,
                        [apiNameFor(PAYMENT_STATUS)]: this.PAYMENT_TRANSACTION_STATUS_ENUM.AUTHORIZED,
                        [apiNameFor(PAYMENT_ELEVATE_ORIGINAL_PAYMENT_ID)]: authorizedGift.originalTransactionId,
                        [apiNameFor(PAYMENT_DECLINED_REASON)]: authorizedGift.declineReason,
                        [apiNameFor(PAYMENT_LAST_4)]: authorizedGift.cardLast4,
                        [apiNameFor(PAYMENT_CARD_NETWORK)]: authorizedGift.cardNetwork,
                        [apiNameFor(PAYMENT_EXPIRATION_MONTH)]: authorizedGift.cardExpirationMonth,
                        [apiNameFor(PAYMENT_EXPIRATION_YEAR)]: authorizedGift.cardExpirationYear,
                        [apiNameFor(PAYMENT_AUTHORIZED_AT)]: authorizedGift.authorizedAt,
                        [apiNameFor(PAYMENT_GATEWAY_ID)]: authorizedGift.gatewayId,
                        [apiNameFor(PAYMENT_GATEWAY_TRANSACTION_ID)]: authorizedGift.gatewayTransactionId,
                        [apiNameFor(PAYMENT_DECLINED_REASON)]: null,
                        [apiNameFor(STATUS_FIELD)]: null
                    });

                    this.deleteFieldFromFormState(apiNameFor(PAYMENT_AUTHORIZE_TOKEN));
                    dataImportFromFormState = this.saveableFormState();
                } else {
                    await this.handleAuthorizationFailure(authorizedGift.declineReason);
                    return;
                }
            } catch (ex) {
                await this.handleAuthorizationFailure(buildErrorMessage(ex));
                return;
            }
        }

        this.continueBatchGiftEntrySave(dataImportFromFormState, formControls, !!tokenizedGift);
    }

    async handleAuthorizationFailure(declineReason) {
        this.updateFormState({
            [apiNameFor(PAYMENT_DECLINED_REASON)]: declineReason,
            [apiNameFor(PAYMENT_STATUS)]: this.PAYMENT_TRANSACTION_STATUS_ENUM.DECLINED,
            [apiNameFor(STATUS_FIELD)]: FAILED
        });
        await this.saveDataImport(this.saveableFormState());

        const errors = [{ message: declineReason }];
        this.handleElevateAPIErrors(errors);

        fireEvent(this, 'refreshtable', {});
    }

    /*******************************************************************************
    * @description On an authorized data import record where re-tokenization is not
    * required, validate that certain payment-related fields have not been modified
    *
    * @param {object} dataImportFromFormState: Representing data import record
    * @param {object} formControls: Used to drive UI elements like spinner and button
    * visibility
    *
    * @return {boolean}: True if the gift can be saved in its current state
    */
    async canAuthorizedGiftBeUpdated(dataImportFromFormState, formControls) {
        try {
            await validateAuthorizedGiftEdit({dataImport: dataImportFromFormState});
        } catch (ex) {
            this.handleCatchOnSave(ex.body.message);
            formControls.enableSaveButton();
            formControls.toggleSpinner();
            return false;
        }

        return true;
    }

    isGiftAuthorized() {
        return this.formState[apiNameFor(PAYMENT_STATUS)] === this.PAYMENT_TRANSACTION_STATUS_ENUM.AUTHORIZED;
    }

    isGiftExpired() {
        return this.formState[apiNameFor(PAYMENT_STATUS)] === this.PAYMENT_TRANSACTION_STATUS_ENUM.EXPIRED;
    }

    shouldNotNullPaymentFields() {
        return (this.isGiftAuthorized() || this.isGiftExpired());
    }

    shouldTokenizeCard() {
        return !!(this.showPaymentSaveNotice) && this.hasChargeableTransactionStatus();
    }

    /*******************************************************************************
    * @description Clears existing errors from the form and re-validates all form
    * sections.
    *
    * @param {list} sectionsList: List of all the form sections
    *
    * @return {boolean}: True if the form is ready for a save attempt.
    */
    prepareFormForSave(sectionsList) {
        // clean errors present on form
        this.clearErrors();
        // apply custom and standard field validation
        return this.isFormValid(sectionsList);

    }

    /*******************************************************************************
    * @description Collects form controls for toggling the spinner and enabling
    * the form save button in one object.
    *
    * @param {object} event: Onclick event from the form save button
    *
    * @return {object}: An object with methods that toggle the form lightning
    * spinner and enables the form save button.
    */
    getFormControls(event) {
        const toggleSpinner = () => this.toggleSpinner();
        const enableSaveButton = function () {
            this.disabled = false;
        }.bind(event.target);

        return { toggleSpinner, enableSaveButton };
    }

    isFormValid(sectionsList) {

        // custom donor type validation
        if (this.isDonorTypeInvalid(sectionsList)) {
            return false;
        }

        // field validations
        let invalidFields = [];
        sectionsList.forEach(section => {
            const fields = section.getInvalidFields();
            invalidFields.push(...fields);
        });

        if (invalidFields.length > 0) {
            let fieldListAsString = invalidFields.join(', ');
            this.hasPageLevelError = true;
            this.pageLevelErrorMessageList = [ {
                index: 0,
                errorMessage: `The following fields are required: ${fieldListAsString}`
            } ];
        }

        return invalidFields.length === 0;
    }

    /**
     * validates donation donor type on sectionsList
     * @param sectionsList, list of sections
     * @returns {boolean|*} - true if form invalid, false otherwise
     */
    isDonorTypeInvalid(sectionsList) {

        const DONATION_VALUES = [
            DONATION_DONOR_FIELDS.donationDonorField,
            DONATION_DONOR_FIELDS.account1ImportedField, DONATION_DONOR_FIELDS.account1NameField,
            DONATION_DONOR_FIELDS.contact1ImportedField, DONATION_DONOR_FIELDS.contact1LastNameField
        ];
        // get label and value using apiName as key from fields for each section
        let miniFieldWrapper = {};
        sectionsList.forEach(section => {
            miniFieldWrapper = { ...miniFieldWrapper, ...(section.getFieldValueAndLabel(DONATION_VALUES))};
        });

        // if no donation donor selection, nothing to validate here yet
        if ( isEmpty(miniFieldWrapper[DONATION_DONOR_FIELDS.donationDonorField].value) ) {
            return false;
        }

        // returns true when error message was generated
        return this.getDonorTypeValidationError( miniFieldWrapper, sectionsList );
    }

    /**
     * helper class for isDonorTypeInvalid, contains majority of logic
     * @param fieldWrapper - Array, field ui-label and value using field-api-name as key
     * @param sectionsList - Array, all sections
     * @returns {boolean} - true if error message was generated, false if otherwise
     */
    getDonorTypeValidationError(fieldWrapper, sectionsList) {

        // get data import record helper
        const di_record = this.getDataImportHelper(fieldWrapper);

        // donation donor validation depending on selection and field presence
        let isError = (di_record.donationDonorValue === DONATION_DONOR.isAccount1) ?
            di_record.isAccount1ImportedEmpty && di_record.isAccount1NameEmpty :
            di_record.donationDonorValue === DONATION_DONOR.isContact1 &&
            di_record.isContact1ImportedEmpty && di_record.isContact1LastNameEmpty;

        // process error notification when error
        if (isError) {
            // highlight validation fields
            this.highlightValidationErrorFields(di_record, sectionsList, ' ');
            // set page error
            this.hasPageLevelError = true;
            this.pageLevelErrorMessageList = [ {
                index: 0,
                errorMessage: this.getDonationDonorErrorLabel(di_record, fieldWrapper)
            } ];
        }

        return isError;
    }

    /**
     * Set donation donor error message using custom label depending on field presence
     * @param diRecord, Object - helper obj
     * @param fieldWrapper, Array of fields with Values and Labels
     * @returns {String}, formatted error message for donation donor validation
     */
    getDonationDonorErrorLabel(diRecord, fieldWrapper) {

        // init array replacement for custom label
        let validationErrorLabelReplacements = [diRecord.donationDonorValue, diRecord.donationDonorLabel];

        if (diRecord.donationDonorValue === DONATION_DONOR.isAccount1) {
            if (diRecord.isAccount1ImportedPresent)
                validationErrorLabelReplacements.push(fieldWrapper[DONATION_DONOR_FIELDS.account1ImportedField].label);
            if (diRecord.isAccount1NamePresent)
                validationErrorLabelReplacements.push(fieldWrapper[DONATION_DONOR_FIELDS.account1NameField].label);
        } else {
            if (diRecord.isContact1ImportedPresent)
                validationErrorLabelReplacements.push(fieldWrapper[DONATION_DONOR_FIELDS.contact1ImportedField].label);
            if (diRecord.isContact1LastNamePresent)
                validationErrorLabelReplacements.push(fieldWrapper[DONATION_DONOR_FIELDS.contact1LastNameField].label);
        }

        // set label depending fields present on template
        let label;
        switch (validationErrorLabelReplacements.length) {
            case 2:
                label = this.CUSTOM_LABELS.geErrorDonorTypeInvalid;
                break;
            case 3:
                label = this.CUSTOM_LABELS.geErrorDonorTypeValidationSingle;
                break;
            case 4:
                label = this.CUSTOM_LABELS.geErrorDonorTypeValidation;
                break;
            default:
                label = this.CUSTOM_LABELS.geErrorDonorTypeInvalid;
        }

        // set message using replacement array
        return format(label, validationErrorLabelReplacements);
    }

    /**
     * highlight geForm fields on lSections using sError as message
     * @param diRecord, Object - helper obj
     * @param lSections, Array of geFormSection
     * @param sError, String to set on setCustomValidity
     */
    highlightValidationErrorFields(diRecord, lSections, sError) {

        // prepare array to highlight fields that require attention depending on Donation_Donor
        const highlightFields = [DONATION_DONOR_FIELDS.donationDonorField,
            diRecord.donationDonorValue === DONATION_DONOR.isAccount1 ? DONATION_DONOR_FIELDS.account1ImportedField :
                DONATION_DONOR_FIELDS.contact1ImportedField,
            diRecord.donationDonorValue === DONATION_DONOR.isAccount1 ? DONATION_DONOR_FIELDS.account1NameField :
                DONATION_DONOR_FIELDS.contact1LastNameField
        ];
        lSections.forEach(section => {
            section.setCustomValidityOnFields(highlightFields, sError);
        });

    }

    /**
     * helper object to minimize length of if statements and improve code legibility
     * @param fieldWrapper, Array of fields with Values and Labels
     * @returns Object, helper object to minimize length of if statements and improve code legibility
     */
    getDataImportHelper(fieldWrapper) {
        return {
            // donation donor
            donationDonorValue: fieldWrapper[DONATION_DONOR_FIELDS.donationDonorField].value,
            donationDonorLabel: fieldWrapper[DONATION_DONOR_FIELDS.donationDonorField].label,
            // empty val checks
            isAccount1ImportedEmpty: isEmpty(fieldWrapper[DONATION_DONOR_FIELDS.account1ImportedField]) ||
                isEmpty(fieldWrapper[DONATION_DONOR_FIELDS.account1ImportedField].value),
            isContact1ImportedEmpty: isEmpty(fieldWrapper[DONATION_DONOR_FIELDS.contact1ImportedField]) ||
                isEmpty(fieldWrapper[DONATION_DONOR_FIELDS.contact1ImportedField].value),
            isContact1LastNameEmpty: isEmpty(fieldWrapper[DONATION_DONOR_FIELDS.contact1LastNameField]) ||
                isEmpty(fieldWrapper[DONATION_DONOR_FIELDS.contact1LastNameField].value),
            isAccount1NameEmpty: isEmpty(fieldWrapper[DONATION_DONOR_FIELDS.account1NameField]) ||
                isEmpty(fieldWrapper[DONATION_DONOR_FIELDS.account1NameField].value),
            // field presence
            isAccount1ImportedPresent: isNotEmpty(fieldWrapper[DONATION_DONOR_FIELDS.account1ImportedField]),
            isAccount1NamePresent: isNotEmpty(fieldWrapper[DONATION_DONOR_FIELDS.account1NameField]),
            isContact1ImportedPresent: isNotEmpty(fieldWrapper[DONATION_DONOR_FIELDS.contact1ImportedField]),
            isContact1LastNamePresent: isNotEmpty(fieldWrapper[DONATION_DONOR_FIELDS.contact1LastNameField])
        };
    }

    // change showSpinner to the opposite of its current value
    toggleSpinner() {
        this.showSpinner = !this.showSpinner;
    }

    getDisplayedFieldsMappedByAPIName(sectionsList) {
        let allFields = {};
        sectionsList.forEach(section => {
            const fields = section.getAllFieldsByAPIName();

            allFields = Object.assign(allFields, fields);
        });

        return allFields;
    }

    clearErrors() {

        // Clear the page level error
        this.hasPageLevelError = false;
        this.pageLevelErrorMessageList = [];

        // Clear the field level errors
        if (this.erroredFields.length > 0) {
            this.erroredFields.forEach(fieldToReset => {
                fieldToReset.setCustomValidity('');
            });
        }

        this.erroredFields = [];
    }

    @api
    loadDataImportRecord(dataImport) {
        const isAlreadyOpen = dataImport.Id === this._openedGiftId;
        if (isAlreadyOpen) {
            return;
        }

        this.expandForm();
        const dataImportWithNullValuesAppended =
            this.appendNullValuesForMissingFields(dataImport);

        dataImportWithNullValuesAppended[apiNameFor(DATA_IMPORT_ADDITIONAL_OBJECT_FIELD)] =
            convertBDIToWidgetJson(dataImportWithNullValuesAppended[apiNameFor(DATA_IMPORT_ADDITIONAL_OBJECT_FIELD)]);
        this.reset();
        this.updateFormState(dataImportWithNullValuesAppended);
        this._openedGiftId = dataImport.Id;
    }

    reset() {
        this.clearErrors();
        this.resetFormState();
        this._openedGiftId = null;
    }

    resetFormState() {
        fireEvent(this, 'resetReviewDonationsEvent', {});
        this.initializeFormState();
        this.resetElevateWidget();
        this._isElevateWidgetInDisabledState = false;
    }

    resetFieldsForObjMappingApplyDefaults(objectMappingDeveloperName) {
        this.setFormStateToInitialFieldValuesForObjMapping(objectMappingDeveloperName);
    }

    fieldMappingDevNamesFor(objectMappingDeveloperName) {
        return Object.values(GeFormService.fieldMappings)
            .filter(
                ({Target_Object_Mapping_Dev_Name}) =>
                    Target_Object_Mapping_Dev_Name === objectMappingDeveloperName)
            .map(({DeveloperName}) => DeveloperName);
    }

    get mode() {
        return this.getFieldValueFromFormState('Id') ?
            mode.UPDATE :
            mode.CREATE;
    }

    get saveActionLabel() {
        return this.isSingleGiftEntry ?
            this.CUSTOM_LABELS.commonSave :
            this.mode === mode.UPDATE ?
                this.CUSTOM_LABELS.commonUpdate :
                this.CUSTOM_LABELS.geButtonSaveNewGift;
    }

    get isUpdateActionDisabled() {
        return this.getFieldValueFromFormState(STATUS_FIELD) === 'Imported';
    }

    get cardholderNames() {
        const names = this.donorNames;
        const firstName = isNotEmpty(names.firstName) ? names.firstName : '';
        const lastName = isNotEmpty(names.lastName) ? names.lastName : '';
        const accountName = isNotEmpty(names.accountName) ? names.accountName : '';
        if (this.donorType === DONATION_DONOR_TYPE_ENUM.ACCOUNT1) {
            return {
                firstName: accountName,
                lastName: accountName
            }
        } else {
            return { firstName, lastName };
        }
    }

    get donorNames() {
        let donorNames = {};

        const nameFields = {
            firstName: DATA_IMPORT_CONTACT1_FIRSTNAME_FIELD,
            lastName: DATA_IMPORT_CONTACT1_LASTNAME_FIELD,
            accountName: DATA_IMPORT_ACCOUNT1_NAME
        };

        Object.entries(nameFields).forEach(([k, v]) => {
            const formStateValue = this.getFieldValueFromFormState(v);
            const notInTemplateValue = this.getFieldValueFromCardholderState(v);
            donorNames[k] = formStateValue || notInTemplateValue;
        });

        return donorNames;
    }

    /**
     * Handle payment errors at the form level
     * @param event The paymentError event object
     */
    handleAsyncWidgetError(event) {
        let errorMessage = this.CUSTOM_LABELS.commonUnknownError;
        let errorResponse;

        if (event.error && event.error.message) {
            errorMessage = event.error.message[0];

            if (event.error.message.length > 1) {
                errorResponse = event.error.message[1];
            }
        }

        let errorObjects = [];
        if (event.error && event.error.isObject) {
            // Represents the error response returned from payment services
            let errorObject = JSON.parse(errorResponse);
            errorObject.forEach((message, index) => {
                errorObjects.push({
                    message: message,
                    index: index
                });
            });

        } else if (errorResponse) {
            let errorObject = errorResponse.message
                ? errorResponse
                : {
                    message: errorResponse,
                    index: 0
                };
            errorObjects.push(errorObject);
        }

        this.pageLevelErrorMessageList = [{
            index: 0,
            errorMessage: errorMessage,
            multilineMessages: errorObjects
        }];
        this.showSpinner = false;
        this.hasPageLevelError = true;
    }

    /**
     * @description Set variable that informs the form renderer when the widget is in a disabled state
     * @param event
     */
    handleDisableElevateWidgetState (event) {
        this._isElevateWidgetInDisabledState = event.isElevateWidgetDisabled;
        if (this._isElevateWidgetInDisabledState) {
            this.nullPaymentFieldsInFormState([
                apiNameFor(PAYMENT_AUTHORIZE_TOKEN),
                apiNameFor(PAYMENT_DECLINED_REASON),
                apiNameFor(PAYMENT_STATUS)
            ]);
        }
    }

    handleFormWidgetChange = (event) => {
        this.updateFormState(event.detail);
    }

    /*******************************************************************************
     * @description Pass through method that receives an event from geReviewDonations
     * to notify the parent component to construct a modal for reviewing donations.
     *
     * @param {object} event: Event object containing a payload for the modal.
     */
    toggleModal(event) {
        this.dispatchEvent(new CustomEvent('togglemodal', { detail: event.detail }));
    }

    getSiblingFieldsForSourceField(sourceFieldApiName) {
        const objectMapping = Object.values(GeFormService.objectMappings)
            .find(({Imported_Record_Field_Name}) =>
                Imported_Record_Field_Name === sourceFieldApiName);
        return this.getSiblingFields(objectMapping.DeveloperName);
    }

    getSiblingFields(objectMappingDeveloperName) {
        // For a given field, get the full list of fields related to its object mapping

        // 1. Get this field's object mapping
        // 2. Get the other field mappings that have the same Target_Object_Mapping_Dev_Name
        // 3. Return the list of fields from those mappings

        const objectMapping =
            GeFormService.getObjectMapping(objectMappingDeveloperName);

        const relevantFieldMappings =
            Object.values(GeFormService.fieldMappings)
                .filter(({Target_Object_Mapping_Dev_Name}) =>
                    Target_Object_Mapping_Dev_Name === objectMapping.DeveloperName);

        // Return the sibling fields used by Advanced Mapping
        const relevantFields = relevantFieldMappings.map(
            ({Target_Field_API_Name}) =>
                `${objectMapping.Object_API_Name}.${Target_Field_API_Name}`);

        if (this.isPaymentImportedField(objectMapping.Imported_Record_Field_Name)) {
            const pmtAmountField =
                `${objectMapping.Object_API_Name}.${apiNameFor(OPP_PAYMENT_AMOUNT)}`;
            const pmtScheduledDate =
                `${objectMapping.Object_API_Name}.${apiNameFor(SCHEDULED_DATE)}`;
            relevantFields.push(pmtAmountField, pmtScheduledDate);
        }
        return relevantFields;
    }

    objectMappingDeveloperNameFor(fieldApiName) {
        const objectMapping = this.getObjectMapping(fieldApiName);
        return objectMapping && objectMapping.DeveloperName;
    }

    @track selectedDonationCopyForReviewDonationsModal;
    handleChangeSelectedDonation(event) {
        this.selectedDonationCopyForReviewDonationsModal =
            event.detail.payment || event.detail.opportunity;

        this.selectedDonationOrPaymentRecord =
            this.selectedDonationCopyForReviewDonationsModal;
        if (!this.isSingleGiftEntry) {
            this.expandForm();
        }

        this.resetElevateWidget();
    }

    resetElevateWidget() {
        fireEvent(this, 'resetElevateWidget', {});
    }

    hasSelectedDonationOrPayment() {
        return !!this.selectedDonationOrPaymentRecordId();
    }

    setCreateNewOpportunityInFormState() {
        this.resetDonationAndPaymentImportedFields();
        this.updateFormState({
            [apiNameFor(DATA_IMPORT_DONATION_IMPORT_STATUS_FIELD)]: userSelectedNewOpp
        });
    }

    resetDonationAndPaymentImportedFields() {
        this.resetSelectedPaymentFieldsInFormState();
        this.resetSelectedDonationFieldsInFormState();
    }

    /**
     * @description Function that prepares (sets batch defaults, remove credit card widget)
     * the gift entry form in Batch Mode
     * @param templateSections
     * @returns {sections}
     */
    prepareFormForBatchMode (templateSections) {
        let sections = deepClone(templateSections);
        if (isNotEmpty(this._batchDefaults)) {
            let batchDefaultsObject;
            try {
                batchDefaultsObject = JSON.parse(this._batchDefaults);
                sections.forEach(section => {
                    section.elements.forEach(element => {
                        for (let key in batchDefaultsObject) {
                            if (batchDefaultsObject.hasOwnProperty(key)) {
                                const batchDefault = batchDefaultsObject[key];
                                if (batchDefault.objectApiName === element.objectApiName &&
                                    batchDefault.fieldApiName === element.fieldApiName) {
                                    if (!isUndefined(batchDefault.value)) {
                                        element.defaultValue = batchDefault.value;
                                    }
                                }
                            }
                        }
                    });
                });
            } catch (err) {
                handleError(err);
            }
        }
        return sections;
    }

    /**
     * @description Retrieves a records mapped target field values and
     *              loads them into the appropriate source fields in use
     *              on the Gift Entry form.
     * @param lookupFieldApiName Api name of the lookup field.
     * @param selectedRecordId Id of the selected record.
     */
    loadSelectedRecordFieldValues(lookupFieldApiName, selectedRecordId) {
        let selectedRecordFields =
            this.getSiblingFieldsForSourceField(lookupFieldApiName);

        this.storeSelectedRecordIdByObjectMappingName(
            this.getObjectMapping(lookupFieldApiName).DeveloperName,
            selectedRecordId
        );

        this.lookupFieldApiNameBySelectedRecordId[selectedRecordId] = lookupFieldApiName;
        this.queueSelectedRecordForRetrieval(selectedRecordId, selectedRecordFields);
    }

    lookupFieldApiNameBySelectedRecordId = {};

    getQualifiedFieldName(objectInfo, fieldInfo) {
        return `${objectInfo.objectApiName}.${fieldInfo.fieldApiName}`;
    }

    get oppPaymentKeyPrefix() {
        return this.oppPaymentObjectInfo.data.keyPrefix;
    }

    isAnOpportunityId(id) {
        if (!id || typeof id !== 'string') {
            return false;
        }
        return id.startsWith(this.opportunityKeyPrefix);
    }

    isAPaymentId(id) {
        if (!id || typeof id !== 'string') {
            return false;
        }
        return id.startsWith(this.oppPaymentKeyPrefix);
    }

    get opportunityKeyPrefix() {
        return this.opportunityObjectInfo.data.keyPrefix;
    }

    get accountKeyPrefix() {
        return this.accountObjectInfo.data.keyPrefix;
    }

    get contactKeyPrefix() {
        return this.contactObjectInfo.data.keyPrefix;
    }

    getObjectMapping(fieldApiName) {
        return Object.values(GeFormService.objectMappings)
            .find(({Imported_Record_Field_Name}) =>
                Imported_Record_Field_Name === fieldApiName);
    }

    // Properties used to manage retrieval of fields for selected records
    selectedRecordIdByObjectMappingDevName = {};
    selectedRecordId;
    selectedRecordFields;
    getSelectedRecordStatus = 'ready';
    selectedRecordsQueue = [];

    @wire(getRecord, {recordId: '$selectedRecordId', optionalFields: '$selectedRecordFields'})
    getSelectedRecord({error, data}) {
        if (error) {
            handleError(error);
        } else if (data) {
            const updates = this.getFormStateUpdatesFromSelectedRecord(data);
            this.updateFormState(updates);
        }
        this.loadNextSelectedRecordFromQueue();
    }

    loadParentOpportunityForSelectedPayment(oppId) {
        this.loadSelectedRecordFieldValues(apiNameFor(DATA_IMPORT_DONATION_IMPORTED_FIELD), oppId);
    }

    loadNextSelectedRecordFromQueue() {
        if (this.selectedRecordsQueue.length > 0) {
            const nextSelectedRecord = this.selectedRecordsQueue.pop();
            this.selectedRecordId = nextSelectedRecord.selectedRecordId;
            this.selectedRecordFields = nextSelectedRecord.selectedRecordFields;
        } else {
            // If there are no records in the queue, set status back to 'ready'
            this.getSelectedRecordStatus = 'ready';
        }
    }

    parentOpportunityIdFor(oppPaymentRecord) {
        return getFieldValue(oppPaymentRecord, PARENT_OPPORTUNITY_FIELD);
    }

    @wire(getObjectInfo, { objectApiName: OPP_PAYMENT_OBJECT })
    oppPaymentObjectInfo;

    @wire(getObjectInfo, { objectApiName: OPPORTUNITY_OBJECT })
    opportunityObjectInfo;

    @wire(getObjectInfo, { objectApiName: ACCOUNT_OBJECT })
    accountObjectInfo;

    @wire(getObjectInfo, { objectApiName: CONTACT_OBJECT })
    contactObjectInfo;

    @wire(getObjectInfo, { objectApiName: DATA_IMPORT_OBJECT })
    dataImportObjectInfo;

    mapRecordValuesToDataImportFields(record) {
        //reverse map to create an object with relevant source field api names to values
        let dataImport = {};
        let objectMappingDevNames = this.getObjectMappingDevNamesForSelectedRecord(record);

        objectMappingDevNames.forEach(objectMappingName => {
            this.fieldMappingsFor(objectMappingName).forEach(fieldMapping => {
                const valueObjectFromRecord =
                    record.fields[fieldMapping.Target_Field_API_Name];
                const sourceField = fieldMapping.Source_Field_API_Name;

                if (this.isFieldMappingUsedInTemplate(fieldMapping)) {
                    dataImport[sourceField] =
                        this.getFieldValueForFormState(
                            valueObjectFromRecord, fieldMapping);
                } else if(this.isFieldUsedForCardholderName(fieldMapping)) {
                    this.updateCardholderState(valueObjectFromRecord, fieldMapping);
                }
            });

            const objectMapping = GeFormService.getObjectMapping(objectMappingName);
            if (this.isPaymentImportedObjectMapping(objectMapping)) {
                dataImport =
                    this.overwriteDonationAmountAndDateWithPaymentInfo(dataImport, record);
            }

        });

        return dataImport;
    }

    isFieldMappingUsedInTemplate(fieldMapping) {
        return this.fieldMappingDevNamesUsedInTemplate().includes(fieldMapping.DeveloperName);
    }

    getFieldValueForFormState(valueObject, fieldMapping) {
        const { value } = valueObject;

        if (value === null) {
            return this.defaultValueFor(fieldMapping.DeveloperName);
        } else if (isTrueFalsePicklist(fieldMapping)) {
            return this.transformForTrueFalsePicklist(value);
        }

        return value;
    }

    isPaymentImportedObjectMapping(objectMapping) {
        return objectMapping &&
            objectMapping.Imported_Record_Field_Name ===
            apiNameFor(DATA_IMPORT_PAYMENT_IMPORTED_FIELD);
    }

    overwriteDonationAmountAndDateWithPaymentInfo(dataImport, record) {
        dataImport[apiNameFor(DONATION_AMOUNT)] =
            record.fields[apiNameFor(OPP_PAYMENT_AMOUNT)].value;
        dataImport[apiNameFor(DONATION_DATE)] =
            record.fields[apiNameFor(SCHEDULED_DATE)].value;
        return dataImport;
    }

    getObjectMappingDevNamesForSelectedRecord(record) {
        let objectMappingDevNames = [];
        for (let [key, value] of Object.entries(
            this.selectedRecordIdByObjectMappingDevName)) {
            if (value === record.id) {
                objectMappingDevNames.push(key);
            }
        }
        return objectMappingDevNames;
    }

    storeSelectedRecordIdByObjectMappingName(objectMappingName, recordId) {
        this.selectedRecordIdByObjectMappingDevName[objectMappingName] = recordId;
    }

    set donorId(id) {
        this.updateFormStateFromMap(new Map([
            [this.lookupFieldForDonorId(id), id]
        ]));
    }

    lookupFieldForDonorId(id) {
        return this.isAnAccountId(id) ?
            DATA_IMPORT_ACCOUNT1_IMPORTED_FIELD :
            this.isAContactId(id) ?
                DATA_IMPORT_CONTACT1_IMPORTED_FIELD :
                null;
    }

    get donorId() {
        switch (this.donorType) {
            case DONATION_DONOR_TYPE_ENUM.ACCOUNT1:
                return this.getFieldValueFromFormState(DATA_IMPORT_ACCOUNT1_IMPORTED_FIELD);
            case DONATION_DONOR_TYPE_ENUM.CONTACT1:
                return this.getFieldValueFromFormState(DATA_IMPORT_CONTACT1_IMPORTED_FIELD);
            default:
                return null;
        }
    }

    handleDonationDonorChange() {
        if (this.hasSelectedDonationOrPayment()) {
            this.resetDonationAndPaymentImportedFields();
            fireEvent(this, 'resetReviewDonationsEvent', {});
        }
    }

    getObjectMappingsForSourceField(fieldApiName) {
        return Object.values(GeFormService.fieldMappings)
            .filter(({Source_Field_API_Name}) => Source_Field_API_Name === fieldApiName)
            .map(({Target_Object_Mapping_Dev_Name}) => Target_Object_Mapping_Dev_Name);
    }

    /**
     * @description Queues selected record Ids (and fields) when getRecord is
     *              in the progress of retrieving another record's related fields.
     *              Prevents one lookup from overwriting the reactive selectedRecordId
     *              and selectedRecordFields properties before getRecord has returned
     *              with data.
     * @param selectedRecordId Id of record to be retrieved.
     * @param selectedRecordFields Fields list to be retrieved.
     */
    queueSelectedRecordForRetrieval(selectedRecordId, selectedRecordFields) {
        if (this.getSelectedRecordStatus === 'ready') {
            this.getSelectedRecordStatus = 'pending';
            this.selectedRecordId = selectedRecordId;
            this.selectedRecordFields = selectedRecordFields;
        } else {
            this.selectedRecordsQueue.push({selectedRecordId, selectedRecordFields});
        }
    }

    handleRegisterPaymentWidget() {
       this._hasPaymentWidget = true;
    }

    /*******************************************************************************
     * @description Method formats custom labels for the purchase call timeout error
     * scenario.
     */
    formatTimeoutErrorMessage() {
        const donorName = this.getDonorName();
        const donationAmountFormField = this.getFormFieldBySourceName(apiNameFor(DONATION_AMOUNT));
        const formattedDonationAmount = getNumberAsLocalizedCurrency(donationAmountFormField.value);

        this.CUSTOM_LABELS.geErrorUncertainCardChargePart1 = GeLabelService.format(
            this.CUSTOM_LABELS.geErrorUncertainCardChargePart1,
            [formattedDonationAmount, donorName, this.CUSTOM_LABELS.commonPaymentServices]);

        this.CUSTOM_LABELS.geErrorUncertainCardChargePart3 = GeLabelService.format(
            this.CUSTOM_LABELS.geErrorUncertainCardChargePart3,
            [this.CUSTOM_LABELS.commonPaymentServices]);

        this.CUSTOM_LABELS.geErrorUncertainCardChargePart4 = GeLabelService.format(
            this.CUSTOM_LABELS.geErrorUncertainCardChargePart4,
            [this.CUSTOM_LABELS.commonPaymentServices]);
    }

    getDonorName() {
        const names = this.donorNames;
        if (names.firstName && names.lastName) {
            return `${names.firstName} ${names.lastName}`;
        } else {
            return names.accountName;
        }
    }

    /*******************************************************************************
    * @description Get a form field's value and label properties by the source
    * field api name.
    *
    * @param {string} sourceFieldApiName: A field api name from the DataImport__c
    * custom object.
    */
    getFormFieldBySourceName(sourceFieldApiName) {
        const sectionsList = this.template.querySelectorAll('c-ge-form-section');
        for (let i = 0; i < sectionsList.length; i++) {
            const matchingFormField = sectionsList[i].getFieldValueAndLabel([sourceFieldApiName]);
            if (isObject(matchingFormField) && matchingFormField.hasOwnProperty(sourceFieldApiName)) {
                return matchingFormField[sourceFieldApiName];
            }
        }
    }

    get namespace() {
        return getNamespace(apiNameFor(FORM_TEMPLATE_FIELD));
    }

    // ================================================================================
    // AUTOMATION LOCATOR GETTERS
    // ================================================================================

    get qaLocatorCancelButton() {
        return `button ${this.cancelButtonText}`;
    }

    get qaLocatorSaveButton() {
        return `button ${this.saveActionLabel}`;
    }

    get formState() {
        return this._formState;
    }

    set formState(formState) {
        this._formState = formState;
    }

    lookupFieldApiNameFor(recordId) {
        const valueForKeyByStartsWith =
            this.getValueForKeyByStartsWith(recordId,
                this.lookupFieldApiNameBySelectedRecordId);

        return this.lookupFieldApiNameBySelectedRecordId[recordId] || valueForKeyByStartsWith;
    }

    /*******************************************************************************
     * @description Updates the formState object that holds the current value
     * of all fields on the form.
     * @param fields An object with key-value pairs.
     */
    updateFormState(fields) {
        Object.assign(this.formState, fields);
        if (fields.hasOwnProperty(apiNameFor(DONATION_RECORD_TYPE_NAME))) {
            this.updateFormStateForDonationRecordType(fields);
        }

        if (this.hasImportedRecordFieldsBeingSetToNull(fields)) {
            this.deleteRelationshipFieldsFromStateFor(fields);
        }

        // Shallow-copy to a new object to prompt reactivity
        this.formState = Object.assign({}, this.formState);
    }

    updateFormStateFromMap(fieldReferenceToValueMap) {
        let updates = {};
        for (const [key, value] of fieldReferenceToValueMap.entries()) {
            if (typeof key === 'string' || key instanceof String) {
                updates[key] = value;
            } else {
                updates[apiNameFor(key)] = value;
            }
        }
        this.updateFormState(updates);
    }

    updateFormStateForDonationRecordType(fields) {
        const opportunityRecordTypeValue = fields[apiNameFor(DONATION_RECORD_TYPE_NAME)];

        if (opportunityRecordTypeValue) {
            const isId = opportunityRecordTypeValue.startsWith('012');
            const val = isId ?
                opportunityRecordTypeValue :
                this.opportunityRecordTypeIdFor(opportunityRecordTypeValue);

            this.formState[apiNameFor(DONATION_RECORD_TYPE_NAME)] =
                this.opportunityRecordTypeNameFor(val);

            this.setDonationRecordTypeIdInFormState(val);
        }
    }

    opportunityRecordTypeNameFor(id) {
        const found = this.opportunityRecordTypeInfos &&
            this.opportunityRecordTypeInfos
                .find(recordTypeInfo => recordTypeInfo.recordTypeId === id);

        return found && found.name;
    }

    hasImportedRecordFieldsBeingSetToNull(fields) {
        return Object.keys(fields)
            .filter(field =>
                GeFormService.importedRecordFieldNames.includes(field) &&
                fields[field] === null
            ).length > 0;
    }

    deleteRelationshipFieldsFromStateFor(fields) {
        const needsRelationshipFieldDeleted = (field) =>
            this.hasRelatedRecordFieldInFormState(field) &&
            fields[field] === null;

        Object.keys(fields)
            .filter(needsRelationshipFieldDeleted)
            .forEach(field => {
                this.deleteFieldFromFormState(relatedRecordFieldNameFor(field));
            });
    }

    deleteFieldFromFormState(field) {
        delete this.formState[field];
    }

    hasRelatedRecordFieldInFormState(field) {
        return this.formState.hasOwnProperty(relatedRecordFieldNameFor(field));
    }

    handleFormFieldChange(event) {
        const value = event.detail.value,
            label = event.detail.label,
            sourceField = this.sourceFieldFor(event.detail.fieldMappingDevName),
            isDonationRecordTypeName = this.isDonationRecordTypeName(sourceField),
            isDonationDonor = this.isDonationDonor(sourceField),
            isImportedRecordField = this.isImportedRecordField(sourceField);

        this.updateFormState({
            [sourceField]: isDonationRecordTypeName ? label : value
        });

        if (isDonationRecordTypeName) {
            this.setDonationRecordTypeIdInFormState(value);
        }

        if (isDonationDonor) {
            this.handleDonationDonorChange(value)
        }

        if (isImportedRecordField) {
            this.handleImportedRecordFieldChange(sourceField, value);
        }
    }

    isDonationDonor(fieldApiName) {
        return fieldApiName === apiNameFor(DATA_IMPORT_DONATION_DONOR_FIELD);
    }

    isDonationRecordTypeName(fieldApiName) {
        return fieldApiName === apiNameFor(DONATION_RECORD_TYPE_NAME);
    }

    handleImportedRecordFieldChange(sourceField, value) {
        this.populateRelatedFieldsForSelectedLookupRecord(sourceField, value);
    }

    populateRelatedFieldsForSelectedLookupRecord(sourceField, value) {
        if (value) {
            this.loadSelectedRecordFieldValues(sourceField, value);
        } else {
            this.resetFieldsForObjMappingApplyDefaults(
                this.objectMappingDeveloperNameFor(sourceField));
        }
    }

    sourceFieldFor(fieldMappingDevName) {
        return GeFormService.getFieldMappingWrapper(fieldMappingDevName).Source_Field_API_Name;
    }

    isTrueFalsePicklist(fieldMappingDevName) {
        const fieldMapping = GeFormService.getFieldMappingWrapper(fieldMappingDevName);
        return isTrueFalsePicklist(fieldMapping);
    }

    /*******************************************************************************
     * @description Analyzes the sections property to get initial values and set them
     * in the formState property.
     */
    initializeFormState() {
        this.formState = {};
        if (this.sections) {
            this.sections
                .forEach(section => {
                    if (section.elements) {
                        section.elements
                            .forEach(element => {
                                this.setInitialValueInFormStateForElement(element);
                            });
                    }
                });
        }

        if (this.batchId) {
            this.updateFormState({
                [apiNameFor(NPSP_DATA_IMPORT_BATCH_FIELD)]: this.batchId
            });
        }

        this.setFormStateForURLQueryParameters();
    }

    setFormStateForURLQueryParameters() {
        const donorApiName = getQueryParameters().c__apiName;
        const donorId = getQueryParameters().c__donorRecordId;

        if (donorApiName) {
            this.initializeDonationDonorTypeInFormState(donorApiName);
        }
        if (donorId) {
            this.donorId = donorId;
        }
    }

    setInitialValueInFormStateForElement(element) {
        const sourceField = this.getSourceFieldApiNameFor(element);
        const value = this.getValueFrom(element);
        const isLookupWithDefaultValue = value &&
            GeFormService.importedRecordFieldNames.includes(sourceField);

        this.updateFormState({[sourceField]: value});

        if (isLookupWithDefaultValue) {
            this.loadSelectedRecordFieldValues(sourceField, value);
        }
    }

    setInitialValueInFormStateForFieldMappings(fieldMappingDevNames) {
        this.resetFieldsToNullInFormStateIfPresent(fieldMappingDevNames);

        this.elementsFor(fieldMappingDevNames)
            .forEach(el => this.setInitialValueInFormStateForElement(el));
    }

    resetFieldsToNullInFormStateIfPresent(fieldMappingDevNames) {
        const updates = {};
        fieldMappingDevNames.forEach(fieldMappingDevName => {
            const sourceField = this.sourceFieldFor(fieldMappingDevName);
            if (this.formState.hasOwnProperty(sourceField)) {
                updates[sourceField] = null;
            }
        });
        this.updateFormState(updates);
    }

    elementsFor(fieldMappingDevNames) {
        const foundInFieldMappingDevNames =
            (fieldMappingDevName) => fieldMappingDevNames
                .includes(fieldMappingDevName);

        return this.sections
            .map(s => s.elements)
            .flat()
            .filter(element =>
                element.dataImportFieldMappingDevNames
                    .some(foundInFieldMappingDevNames));
    }

    setFormStateToInitialFieldValuesForObjMapping(objectMappingDeveloperName) {
        this.setInitialValueInFormStateForFieldMappings(
            this.fieldMappingsFor(objectMappingDeveloperName)
                .map(({DeveloperName}) => DeveloperName));
    }

    fieldMappingsFor(objectMappingDeveloperName) {
        return GeFormService.fieldMappingsForObjectMappingDevName(objectMappingDeveloperName);
    }

    getSourceFieldApiNameFor(element) {
        return this.fieldMappingWrapperFor(element) &&
            this.fieldMappingWrapperFor(element).Source_Field_API_Name;
    }

    fieldMappingWrapperFor(element) {
        return element.dataImportFieldMappingDevNames &&
            GeFormService.getFieldMappingWrapper(element.dataImportFieldMappingDevNames[0]);
    }

    getValueFrom(element) {
        return element && element.recordValue !== undefined ?
            element.recordValue :
            element.defaultValue;
    }

    opportunityRecordTypeIdFor(opportunityRecordTypeName) {
        const recordTypeInfo = Object.values(this.opportunityRecordTypeInfos)
            .find(recordTypeInfo =>
                recordTypeInfo.name === opportunityRecordTypeName);

        if (!recordTypeInfo) return null;
        return recordTypeInfo.recordTypeId;
    }

    get opportunityRecordTypeInfos() {
        return this.opportunityObjectInfo &&
            Object.values(this.opportunityObjectInfo.data.recordTypeInfos);
    }

    transformForTrueFalsePicklist(value) {
        if (value === true || value === CHECKBOX_TRUE) {
            return PICKLIST_TRUE;
        } else if (value === false || value === CHECKBOX_FALSE) {
            return PICKLIST_FALSE;
        }
        return ''; // blank values are valid for picklist/checkbox mappings
    }

    setDonationRecordTypeIdInFormState(opportunityRecordTypeId) {
        const donationImportedRelatedRecordField =
            relatedRecordFieldNameFor(apiNameFor(DATA_IMPORT_DONATION_IMPORTED_FIELD));
        const relatedRecord =
            this.getFieldValueFromFormState(donationImportedRelatedRecordField);

        let updatedRecord;
        if (relatedRecord) {
            updatedRecord = Object.assign(
                relatedRecord,
                {recordTypeId: opportunityRecordTypeId});
        } else {
            updatedRecord = {recordTypeId: opportunityRecordTypeId};
        }

        this.updateFormState({
            [donationImportedRelatedRecordField]: updatedRecord
        })
    }

    siblingRecordTypeFieldFor(fieldMappingDevName) {
        const fieldMappingToRecordTypeId =
            Object.values(GeFormService.fieldMappings)
                .filter(f =>
                    f.Target_Object_Mapping_Dev_Name ===
                    this.objectMappingDevNameFor(fieldMappingDevName))
                .find(f => f.Target_Field_API_Name === 'RecordTypeId')

        return fieldMappingToRecordTypeId &&
            fieldMappingToRecordTypeId.Source_Field_API_Name;
    }

    objectMappingDevNameFor(fieldMappingDevName) {
        const fieldMapping = GeFormService.getFieldMappingWrapper(fieldMappingDevName);
        return fieldMapping && fieldMapping.Target_Object_Mapping_Dev_Name;
    }

    parentRecordFieldFor(fieldMappingDevName) {
        const objMapping =
            GeFormService.getObjectMapping(
                this.objectMappingDevNameFor(fieldMappingDevName));

        return objMapping && objMapping.Imported_Record_Field_Name;
    }

    get donorType() {
        return this.getFieldValueFromFormState(
            apiNameFor(DATA_IMPORT_DONATION_DONOR_FIELD));
    }

    /**
     * @description Pass in a DataImport field's api to get that field's current
     * value from the formState object.
     * @param fieldApiNameOrFieldReference: field name or an imported field reference object
     * @returns {*} Current value stored in formState for the passed-in field.
     */
    getFieldValueFromFormState(fieldApiNameOrFieldReference) {
        const fieldApiName =
            typeof fieldApiNameOrFieldReference === 'string' ?
                fieldApiNameOrFieldReference :
                apiNameFor(fieldApiNameOrFieldReference);

        return this.formState[fieldApiName];
    }

    defaultValueFor(fieldMappingDevName) {
        const element = this.formElements()
            .find(element =>
                element.dataImportFieldMappingDevNames[0] === fieldMappingDevName);

        return element && element.defaultValue;
    }

    appendNullValuesForMissingFields(dataImport) {
        this.applyNullValuesForMissingFields(this.sourceFieldsUsedInTemplate(), dataImport);
        return dataImport;
    }

    applyNullValuesForMissingFields(sourceFieldsOnForm, dataImport) {
        sourceFieldsOnForm.forEach(sourceFieldOnForm => {
            if (!dataImport.hasOwnProperty(sourceFieldOnForm)) {
                dataImport[sourceFieldOnForm] = null;
            }
        });
    }

    sourceFieldsUsedInTemplate() {
        return Object.values(GeFormService.fieldMappings)
            .filter(fieldMapping =>
                this.fieldMappingDevNamesUsedInTemplate()
                    .includes(fieldMapping.DeveloperName))
            .map(fieldMapping => fieldMapping.Source_Field_API_Name);
    }

    fieldMappingDevNamesUsedInTemplate() {
        return this.formElements()
            .filter(element => element.elementType === 'field')
            .map(element => element.dataImportFieldMappingDevNames[0]);
    }

    formElements() {
        return this.sections.flat()
            .map(({elements}) => elements)
            .flat();
    }

    isImportedRecordField(fieldApiName) {
        return GeFormService.importedRecordFieldNames.includes(fieldApiName);
    }

    isDonorLookupField(fieldApiName) {
        return this.isDonorAccountField(fieldApiName) ||
            this.isDonorContactField(fieldApiName);
    }

    isDonorAccountField(fieldApiName) {
        return fieldApiName === apiNameFor(DATA_IMPORT_ACCOUNT1_IMPORTED_FIELD);
    }

    isDonorContactField(fieldApiName) {
        return fieldApiName === apiNameFor(DATA_IMPORT_CONTACT1_IMPORTED_FIELD);
    }

    selectedDonorId() {
        if (this.isDonorTypeContact()) {
            return this.donorContactId();
        } else if (this.isDonorTypeAccount()) {
            return this.donorAccountId();
        } else {
            return null;
        }
    }

    isDonorTypeContact() {
        return this.donorType === DONATION_DONOR_TYPE_ENUM.CONTACT1;
    }

    isDonorTypeAccount() {
        return this.donorType === DONATION_DONOR_TYPE_ENUM.ACCOUNT1;
    }

    donorContactId() {
        return this.getFieldValueFromFormState(
            apiNameFor(DATA_IMPORT_CONTACT1_IMPORTED_FIELD)
        );
    }

    donorAccountId() {
        return this.getFieldValueFromFormState(
            apiNameFor(DATA_IMPORT_ACCOUNT1_IMPORTED_FIELD)
        );
    }

    selectedDonationOrPaymentRecordId() {
        return this.getFieldValueFromFormState(DATA_IMPORT_PAYMENT_IMPORTED_FIELD) ||
            this.getFieldValueFromFormState(DATA_IMPORT_DONATION_IMPORTED_FIELD);
    }

    resetSelectedPaymentFieldsInFormState() {
        this.updateFormState({
            [apiNameFor(DATA_IMPORT_PAYMENT_IMPORTED_FIELD)]: null,
            [apiNameFor(DATA_IMPORT_PAYMENT_IMPORT_STATUS_FIELD)]: null,
            [relatedRecordFieldNameFor(apiNameFor(DATA_IMPORT_PAYMENT_IMPORTED_FIELD))]: null
        });

        this.resetFieldsForObjMappingApplyDefaults(
            this.objectMappingDeveloperNameForFieldReference(
                DATA_IMPORT_PAYMENT_IMPORTED_FIELD
            )
        );
    }

    resetSelectedDonationFieldsInFormState() {
        const updates = new Map();
        updates.set(DATA_IMPORT_DONATION_IMPORTED_FIELD, null);
        updates.set(DATA_IMPORT_DONATION_IMPORT_STATUS_FIELD, null);
        updates.set(relatedRecordFieldNameFor(DATA_IMPORT_DONATION_IMPORTED_FIELD), null);
        this.updateFormStateFromMap(updates);

        this.resetFieldsForObjMappingApplyDefaults(
            this.objectMappingDeveloperNameForFieldReference(
                DATA_IMPORT_DONATION_IMPORTED_FIELD
            )
        );
    }

    objectMappingDeveloperNameForFieldReference(fieldReference) {
        const objectMapping =
            this.getObjectMapping(apiNameFor(fieldReference));
        return objectMapping && objectMapping.DeveloperName;
    }

    saveableFormState() {
        let dataImportRecord = { ...this.formState };
        dataImportRecord = this.removeFieldsNotInObjectInfo(dataImportRecord);

        return dataImportRecord;
    }

    removeFieldsNotInObjectInfo(dataImportRecord) {
        const diFields = Object.keys(this.dataImportObjectInfo.data.fields);
        for (const key of Object.keys(dataImportRecord)) {
            if (!diFields.includes(key)) {
                delete dataImportRecord[key];
            }
        }
        return dataImportRecord;
    }

    get hasDataImportId() {
        return !!this.getFieldValueFromFormState('id');
    }

    hasProcessableDataImport() {
        return !this.hasFailedPurchaseRequest ||
            this._isElevateWidgetInDisabledState;
    }

    hasAuthorizationToken() {
        return !!this.getFieldValueFromFormState(
            apiNameFor(PAYMENT_AUTHORIZE_TOKEN));
    }

    shouldMakePurchaseRequest() {
        return this.hasAuthorizationToken() &&
            this.hasChargeableTransactionStatus() &&
            !this._isElevateWidgetInDisabledState;
    }

    hasChargeableTransactionStatus = () => {
        const nonChargeableForSingleGift = this.selectedPaymentMethod() !== PAYMENT_METHODS.ACH
            && this.selectedPaymentMethod() !== PAYMENT_METHOD_CREDIT_CARD;
        if (this.isSingleGiftEntry && nonChargeableForSingleGift) {
            return false;
        }

        const nonChargeableForBatchGift = this.selectedPaymentMethod() !== PAYMENT_METHOD_CREDIT_CARD;
        if (!this.isSingleGiftEntry && nonChargeableForBatchGift) {
            return false;
        }

        const paymentStatus = this.convertPaymentStatusToUpperCase(
            this.getFieldValueFromFormState(PAYMENT_STATUS));

        switch (paymentStatus) {
            case '':
            case undefined:
            case null:
            case this.PAYMENT_TRANSACTION_STATUS_ENUM.PENDING:
            case this.PAYMENT_TRANSACTION_STATUS_ENUM.AUTHORIZED:
            case this.PAYMENT_TRANSACTION_STATUS_ENUM.DECLINED:
            case this.PAYMENT_TRANSACTION_STATUS_ENUM.RETRYABLEERROR:
            case this.PAYMENT_TRANSACTION_STATUS_ENUM.EXPIRED:
            case PAYMENT_UNKNOWN_ERROR_STATUS:
                return true;

            default:
                return false;
        }
    }

    convertPaymentStatusToUpperCase(paymentStatus) {
        if (isString(paymentStatus)) {
            return paymentStatus.toUpperCase();
        }
        return paymentStatus;
    }

    /*******************************************************************************
     * @description Saves a Data Import record, makes an elevate payment if needed,
     * and processes the Data Import through BDI.
     *
     * @param dataImportFromFormState
     */
    submitSingleGift = async () => {
        const gift = this.saveableFormState();
        try {
            await this.saveDataImport(gift);

            if (this.shouldMakePurchaseRequest()) {
                await this.makePurchaseRequest();
            }

            if (this.hasProcessableDataImport()) {
                await this.processDataImport();
            }
        } catch (error) {
            this.disabled = false;
            this.toggleSpinner();
            handleError(error);
        }
    }

    saveDataImport = async (dataImportFromFormState) => {
        this.loadingText = this.hasDataImportId
            ? this.CUSTOM_LABELS.geTextUpdating
            : this.CUSTOM_LABELS.geTextSaving;

        try {
            delete dataImportFromFormState[apiNameFor(PAYMENT_AUTHORIZE_TOKEN)];
            const upsertResponse = await upsertDataImport({
                dataImport: dataImportFromFormState
            });
            this.updateFormState(upsertResponse);
            return true;
        } catch (err) {
            this.handleCatchOnSave(err);
            return false;
        }
    };

    makePurchaseRequest = async () => {
        this.loadingText = this.CUSTOM_LABELS.geTextChargingCard;
        const responseBodyString = await sendPurchaseRequest({
            requestBodyParameters: this.buildPurchaseRequestBodyParameters(),
            dataImportRecordId: this.getFieldValueFromFormState('id'),
        });

        const responseBody = JSON.parse(responseBodyString);
        await this.processPurchaseResponse(responseBody);
    }

    buildPurchaseRequestBodyParameters() {
        if (this.selectedPaymentMethod() === PAYMENT_METHODS.ACH) {
            return this.buildACHPurchaseRequestBodyParameters();
        } else {
            return this.buildCreditCardPurchaseRequestBodyParameters();
        }
    }

    buildACHPurchaseRequestBodyParameters() {
        const metadata = {
            campaignCode: this.getFieldValueFromFormState(DONATION_CAMPAIGN_NAME)
        };
        const achData = {
            achCode: ACH_CODE,
            consent: ACH_CONSENT_MESSAGE,
            type: this.accountHolderType(),
            bankType: ACCOUNT_HOLDER_BANK_TYPES.CHECKING,
        }
        const amount = getCurrencyLowestCommonDenominator(
            this.getFieldValueFromFormState(DONATION_AMOUNT));
        const paymentMethodToken =
            this.getFieldValueFromFormState(PAYMENT_AUTHORIZE_TOKEN);

        return JSON.stringify({
            ...this.buildACHPurchaseBodyNameParameter(),
            paymentMethodType : PAYMENT_METHODS.ACH,
            amount: amount,
            paymentMethodToken: paymentMethodToken,
            achData : achData,
            metadata: metadata
        });

    }

    buildACHPurchaseBodyNameParameter() {
        let names;
        if (this.isDonorTypeContact()) {
            names = {
                firstName: this.donorNames.firstName,
                lastName: this.donorNames.lastName
            };
        } else {
            names = {
                accountName: this.donorNames.accountName
            }
        }
        return names;
    }

    buildCreditCardPurchaseRequestBodyParameters() {
        const {firstName, lastName} = this.cardholderNames;
        const metadata = {
            campaignCode: this.getFieldValueFromFormState(
                apiNameFor(DONATION_CAMPAIGN_NAME)),
        };
        return JSON.stringify({
            firstName: firstName,
            lastName: lastName,
            metadata: metadata,
            amount: getCurrencyLowestCommonDenominator(
                this.getFieldValueFromFormState(apiNameFor(DONATION_AMOUNT)),
            ),
            paymentMethodToken:
                this.getFieldValueFromFormState(
                    apiNameFor(PAYMENT_AUTHORIZE_TOKEN)),
            paymentMethodType: PAYMENT_METHODS.CREDIT_CARD,
        });
    }

    accountHolderType() {
        return this.isDonorTypeContact()
            ? ACCOUNT_HOLDER_TYPES.INDIVIDUAL
            : ACCOUNT_HOLDER_TYPES.BUSINESS;
    }

    selectedPaymentMethod () {
        return this.getFieldValueFromFormState(PAYMENT_METHOD);
    }

    processPurchaseResponse = async (responseBody) => {
        if (responseBody.errors) {
            this.updateFormStateWithFailedPurchaseCall(responseBody.errors);
            this.handleElevateAPIErrors(responseBody.errors);
            this.hasFailedPurchaseRequest = true;
        }

        if (this.hasPurchaseTimedOut(responseBody)) {
            this.updateFormStateWithTimedoutPurchaseCall(responseBody);
            this.hasFailedPurchaseRequest = true;
            this.hasPurchaseCallTimedout = true;
            this.formatTimeoutErrorMessage();
        }

        if (this.isPurchaseCreated(responseBody)) {
            this.updateFormStateWithSuccessfulPurchaseCall(responseBody);
            this.hasFailedPurchaseRequest = false;
        }

        await this.saveDataImport(this.saveableFormState());
    }

    isPurchaseCreated(responseBody) {
        return responseBody.id
            && this.isCreditCardTransactionSuccessResponse(responseBody)
            || this.isACHTransactionSuccessResponse(responseBody);
    }

    isCreditCardTransactionSuccessResponse(responseBody) {
        return responseBody.status ===
            this.PAYMENT_TRANSACTION_STATUS_ENUM.CAPTURED;
    }

    isACHTransactionSuccessResponse(responseBody) {
        return responseBody.status ===
            this.PAYMENT_TRANSACTION_STATUS_ENUM.SUBMITTED;
    }

    hasPurchaseTimedOut (responseBody) {
        return !responseBody.id && responseBody.message && responseBody.status;
    }

    updateFormStateWithFailedPurchaseCall(errors) {
        if (errors && errors[0]) {
            const primaryError = errors[0];
            const paymentStatus =
                hasNestedProperty(primaryError, 'errorDetail', 'code', 'name') ?
                primaryError.errorDetail.code.name :
                this.CUSTOM_LABELS.commonUnknownError;

            this.updateFormState({
                [apiNameFor(PAYMENT_DECLINED_REASON)]: primaryError.message,
                [apiNameFor(PAYMENT_STATUS)]: paymentStatus,
            });
        }
    }

    handleElevateAPIErrors(errors) {
        const errorMessage = JSON.stringify(
            errors.map(error => error.message))
            || this.CUSTOM_LABELS.commonUnknownError;
        let labelReplacements = [
            this.CUSTOM_LABELS.commonPaymentServices, errorMessage];
        let formattedErrorResponse = format(
            this.CUSTOM_LABELS.gePaymentProcessError, labelReplacements);

        const error = {
            message: formattedErrorResponse.split(LABEL_NEW_LINE),
            isObject: true
        };
        this.handleAsyncWidgetError({ error });
    }

    updateFormStateWithTimedoutPurchaseCall(responseBody) {
        this.updateFormState({
            [apiNameFor(PAYMENT_DECLINED_REASON)]: responseBody.message,
            [apiNameFor(PAYMENT_STATUS)]: responseBody.status,
        });
    }

    updateFormStateWithSuccessfulPurchaseCall(responseBody) {
        const baseDataImportPaymentFields = {
            [apiNameFor(PAYMENT_ELEVATE_ID)]: responseBody.id,
            [apiNameFor(PAYMENT_STATUS)]: responseBody.status,
            [apiNameFor(PAYMENT_DECLINED_REASON)]: '',
            [apiNameFor(PAYMENT_GATEWAY_ID)]: responseBody.gatewayId,
            [apiNameFor(PAYMENT_GATEWAY_TRANSACTION_ID)]: responseBody.gatewayTransactionId,
            [apiNameFor(PAYMENT_AUTHORIZED_AT)]: responseBody.authorizedAt,
            [apiNameFor(PAYMENT_CREATED_AT)]: responseBody.createdAt
        };

        if (this.isCreditCardTransaction()) {
            this.updateFormStateWithCreditCardTransactionResponse(
                baseDataImportPaymentFields, responseBody);
        } else {
            this.updateFormStateWithACHTransactionResponse(
                baseDataImportPaymentFields, responseBody);
        }
    }

    updateFormStateWithCreditCardTransactionResponse(
        baseDataImportPaymentFields, responseBody) {
        this.updateFormState({
            ...baseDataImportPaymentFields,
            [apiNameFor(PAYMENT_CARD_NETWORK)]: responseBody.cardData.brand,
            [apiNameFor(PAYMENT_LAST_4)]: responseBody.cardData.last4,
            [apiNameFor(
                PAYMENT_EXPIRATION_MONTH)]: responseBody.cardData.expirationMonth,
            [apiNameFor(
                PAYMENT_EXPIRATION_YEAR)]: responseBody.cardData.expirationYear
        });
    }

    updateFormStateWithACHTransactionResponse(
        baseDataImportPaymentFields, responseBody) {
        this.updateFormState({
            ...baseDataImportPaymentFields,
            [apiNameFor(PAYMENT_ACH_LAST_4)]: responseBody.achData.last4,
            [apiNameFor(PAYMENT_ACH_CODE)]: responseBody.achData.achCode,
            [apiNameFor(PAYMENT_METHOD)]: responseBody.paymentType,
            [apiNameFor(
                PAYMENT_ACH_CONSENT)]: responseBody.achData.consentMessage,
            [apiNameFor(PAYMENT_TYPE)]: responseBody.paymentType,
            [apiNameFor(
                PAYMENT_ELEVATE_ORIGINAL_PAYMENT_ID)]: responseBody.originalTransactionId,
        });
    }

    isCreditCardTransaction() {
        return this.selectedPaymentMethod() === PAYMENT_METHOD_CREDIT_CARD;
    }

    processDataImport = async () => {
        this.loadingText = this.CUSTOM_LABELS.geTextProcessing;
        this.deleteFieldFromFormState(apiNameFor(PAYMENT_AUTHORIZE_TOKEN));
        const dataImportRecord = this.saveableFormState();

        submitDataImportToBDI({
            dataImport: dataImportRecord,
            updateGift: this.hasSelectedDonationOrPayment()
        })
            .then(opportunityId => {
                this.loadingText = this.CUSTOM_LABELS.geTextNavigateToOpportunity;
                this.goToRecordDetailPage(opportunityId);
            })
            .catch(error => {
                this.handleBdiProcessingError(error);
            });
    }

    handleBdiProcessingError(error) {
        if (this.isProcessableElevateTransaction) {
            const exceptionDataError = new ExceptionDataError(error);
            this.handleElevateTransactionBDIError(exceptionDataError);
        } else {
            this.handleCatchOnSave(error);
            this.toggleSpinner();
        }
    }

    get isProcessableElevateTransaction() {
        const paymentStatus = this.getFieldValueFromFormState(PAYMENT_STATUS);
        return paymentStatus
            && (paymentStatus === this.PAYMENT_TRANSACTION_STATUS_ENUM.CAPTURED
                || paymentStatus === this.PAYMENT_TRANSACTION_STATUS_ENUM.SUBMITTED);

    }

    get elevateTransactionWarning() {
        return format(this.CUSTOM_LABELS.gePaymentProcessedWarning, [this.CUSTOM_LABELS.commonPaymentServices]);
    }

    get showElevateTransactionWarning() {
        const paymentStatus = this.getFieldValueFromFormState(PAYMENT_STATUS);
        return paymentStatus &&
            (paymentStatus === this.PAYMENT_TRANSACTION_STATUS_ENUM.CAPTURED
                || paymentStatus === this.PAYMENT_TRANSACTION_STATUS_ENUM.SUBMITTED
                || paymentStatus === this.PAYMENT_TRANSACTION_STATUS_ENUM.SETTLED
            );
    }

    handleElevateTransactionBDIError(exceptionDataError) {
        this.dispatchDisablePaymentServicesWidgetEvent(this.CUSTOM_LABELS.geErrorCardChargedBDIFailed);
        this.toggleModalByComponentName('geModalPrompt',
            {
                'variant': 'error',
                'title': this.CUSTOM_LABELS.commonCriticalError,
                'message': this.CUSTOM_LABELS.geErrorCardChargedBDIFailed,
                'buttons':
                    [{
                        label: this.CUSTOM_LABELS.commonReviewForm,
                        action: () => { fireEvent(this.pageRef, 'geModalCloseEvent', {}) }
                    }]
            });

        const pageLevelError = this.buildElevateTransactionBDIError(exceptionDataError);
        this.addPageLevelErrorMessage(pageLevelError);

        this.disabled = false;
        this.toggleSpinner();
    }

    buildElevateTransactionBDIError(exceptionDataError) {
        return {
            index: 0,
            errorMessage: this.CUSTOM_LABELS.geErrorCardChargedBDIFailed,
            multilineMessages: [
                {
                    message: exceptionDataError.errorMessage ||
                        this.CUSTOM_LABELS.commonUnknownError,
                    index: 0
                }]
        };
    }

    isAnAccountId(id) {
        return id && typeof id === 'string' &&
            id.startsWith(this.accountKeyPrefix);
    }

    isAContactId(id) {
        return id && typeof id === 'string' &&
            id.startsWith(this.contactKeyPrefix);
    }

    getValueForKeyByStartsWith(longKey, keyValueMap) {
        for (const [key, value] of Object.entries(keyValueMap)) {
            if (longKey.startsWith(key)) {
                return value;
            }
        }
    }

    isPaymentImportedField(sourceField) {
        return sourceField === apiNameFor(DATA_IMPORT_PAYMENT_IMPORTED_FIELD);
    }

    nullPaymentFieldsInFormState(paymentFields) {
        paymentFields.forEach(field => {
            this.updateFormState({ [field]: null });
        });
    }

    getFormStateUpdatesFromSelectedRecord(record) {
        const formStateUpdates = this.mapRecordValuesToDataImportFields(record);
        const relatedRecordFieldName =
            relatedRecordFieldNameFor(this.lookupFieldApiNameFor(record.id));
        formStateUpdates[relatedRecordFieldName] = record;
        return formStateUpdates;
    }

    get isFormCollapsed() {
        return this._isFormCollapsed;
    }

    get altTextLabel() {
        return 'Toggle '
            + this.CUSTOM_LABELS.geHeaderFormFieldsDefaultSectionName;
    }

    expandForm() {
        if (this.isFormCollapsed) {
            this._isFormCollapsed = false;
        }
    }

    @api
    collapse() {
        this._isFormCollapsed = true;
    }

    get expandableContainerId() {
        return EXPANDABLE_SECTION_CONTAINER;
    }

    handleCollapse(event) {
        this._isFormCollapsed = event.detail.isCollapsed;
    }

    get showMismatchedCurrencyWarning() {
        if (isEmpty(this.batchCurrencyIsoCode)) {
            return false;
        }
        return this.batchCurrencyIsoCode !== CURRENCY;
    }

    get mismatchedCurrencyWarning() {
        if (this.showMismatchedCurrencyWarning) {
            return GeLabelService.format(
                this.CUSTOM_LABELS.geWarningBatchGiftEntryCurrencyMismatch,
                [this.batchCurrencyIsoCode]);
        }
    }

    isFieldUsedForCardholderName(fieldMapping) {
        const cardholderNameFields = [CONTACT_LAST_NAME_INFO, CONTACT_FIRST_NAME_INFO, ACCOUNT_NAME_FIELD];
        return cardholderNameFields.find( f => apiNameFor(f) === fieldMapping.Target_Field_API_Name);
    }

    updateCardholderState(valueObjectFromRecord, fieldMapping) {
        const value = this.getFieldValueForFormState(valueObjectFromRecord, fieldMapping);
        this.cardholderNamesNotInTemplate[fieldMapping.Source_Field_API_Name] = value;
    }

    getFieldValueFromCardholderState(fieldApiNameOrFieldReference) {
        const fieldApiName =
            typeof fieldApiNameOrFieldReference === 'string' ?
                fieldApiNameOrFieldReference :
                apiNameFor(fieldApiNameOrFieldReference);

        return this.cardholderNamesNotInTemplate[fieldApiName];
    }
}