diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index ae51017..d2d5829 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -4,8 +4,10 @@ on: branches: - main # or the name of your main branch - release** + - feature** pull_request: types: [opened, synchronize, reopened] + workflow_dispatch: jobs: sonarqube: name: SonarQubeAnalysis @@ -22,9 +24,9 @@ jobs: env: PMD_JAVA_OPTS: -Xmx2g - name: updatePmd - run: awk '{gsub("/home/.*salesforce-apex-example/", "/github/workspace/");print}' $GITHUB_WORKSPACE/pmd.xml > $GITHUB_WORKSPACE/pmdnew.xml + run: awk '{gsub("/home/.*salesforce-apex-example/", "/github/workspace/");print}' $GITHUB_WORKSPACE/pmd.xml > $GITHUB_WORKSPACE/pmdfixedpaths.xml - name: check-pmd-new - run: cat $GITHUB_WORKSPACE/pmdnew.xml + run: cat $GITHUB_WORKSPACE/pmdfixedpaths.xml - uses: sonarsource/sonarqube-scan-action@master env: SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} @@ -34,4 +36,5 @@ jobs: -Dsonar.projectName=Salesforce-Apex-Example -Dsonar.projectKey=SonarSource-Demos_salesforce-apex-example -Dsonar.verbose=true - -Dsonar.apex.pmd.reportPaths=pmdnew.xml + -Dsonar.apex.pmd.reportPaths=pmdfixedpaths.xml + -Dsonar.exclusions=pmd*.xml diff --git a/README.md b/README.md index fa6c59a..29234f0 100644 --- a/README.md +++ b/README.md @@ -4,10 +4,10 @@ This project is a copy of the [Milestones-PM](https://github.com/SalesforceLabs/ Please don't use this code, or any bytecode hosted here, for anything. Please refer to the original Force.com LAB repository for any use of the library. -[![Quality Gate Status](https://nautilus.sonarqube.org/api/project_badges/measure?project=SonarSource-Demos_salesforce-apex-example&metric=alert_status&token=01f5a714d878252a848df0651cea66135a0f2837)](https://nautilus.sonarqube.org/dashboard?id=SonarSource-Demos_salesforce-apex-example) +Main branch SonarQube status +============== +[![Quality Gate Status](https://nautilus.sonarqube.org/api/project_badges/measure?project=SonarSource-Demos_salesforce-apex-example&metric=alert_status&token=01f5a714d878252a848df0651cea66135a0f2837)](https://nautilus.sonarqube.org/dashboard?id=SonarSource-Demos_salesforce-apex-example)[![Security Rating](https://nautilus.sonarqube.org/api/project_badges/measure?project=SonarSource-Demos_salesforce-apex-example&metric=security_rating&token=01f5a714d878252a848df0651cea66135a0f2837)](https://nautilus.sonarqube.org/dashboard?id=SonarSource-Demos_salesforce-apex-example)[![Reliability Rating](https://nautilus.sonarqube.org/api/project_badges/measure?project=SonarSource-Demos_salesforce-apex-example&metric=reliability_rating&token=01f5a714d878252a848df0651cea66135a0f2837)](https://nautilus.sonarqube.org/dashboard?id=SonarSource-Demos_salesforce-apex-example) -[![Security Rating](https://nautilus.sonarqube.org/api/project_badges/measure?project=SonarSource-Demos_salesforce-apex-example&metric=security_rating&token=01f5a714d878252a848df0651cea66135a0f2837)](https://nautilus.sonarqube.org/dashboard?id=SonarSource-Demos_salesforce-apex-example) -[![Reliability Rating](https://nautilus.sonarqube.org/api/project_badges/measure?project=SonarSource-Demos_salesforce-apex-example&metric=reliability_rating&token=01f5a714d878252a848df0651cea66135a0f2837)](https://nautilus.sonarqube.org/dashboard?id=SonarSource-Demos_salesforce-apex-example) diff --git a/src/force-app/AddressService.cls b/src/force-app/AddressService.cls new file mode 100644 index 0000000..375c6f8 --- /dev/null +++ b/src/force-app/AddressService.cls @@ -0,0 +1,205 @@ +/* + Copyright (c) 2021 Salesforce.org + All rights reserved. + + Redistribution and use in source and binary forms, with or without + modification, are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + * Neither the name of Salesforce.org nor the names of + its contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + + THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS + FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE + COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, + INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, + BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT + LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN + ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + POSSIBILITY OF SUCH DAMAGE. +*/ +/** +* @author Salesforce.org +* @date 2021 +* @description Address Service class in NPSP. +*/ +public inherited sharing class AddressService { + + @TestVisible + private OrgConfig orgConfig { + get { + if (orgConfig == null) { + orgConfig = new OrgConfig(); + } + return orgConfig; + } + set; + } + + /******************************************************************************************************* + * Doesn't do anything special when State and Country picklists are enabled (the + * platform will fill out State and Country Code values using the values from the + * picklist fields), and multiline street addresses. + * @param sobjSrc the source Contact or Account + * @param strFieldPrefixSrc the address fields to copy from, ie., Mailing, Other, Shipping, Billing + * @param sobjDst the destination Contact or Account + * @param strFieldPrefixDst the address fields to copy to, ie., Mailing, Other, Shipping, Billing + */ + public void copyAddressStdSObj(SObject sobjSrc, String strFieldPrefixSrc, SObject sobjDst, String strFieldPrefixDst) { + sobjDst.put(strFieldPrefixDst + 'Street', sobjSrc.get(strFieldPrefixSrc + 'Street')); + sobjDst.put(strFieldPrefixDst + 'City', sobjSrc.get(strFieldPrefixSrc + 'City')); + sobjDst.put(strFieldPrefixDst + 'PostalCode', sobjSrc.get(strFieldPrefixSrc + 'PostalCode')); + sobjDst.put(strFieldPrefixDst + 'State', sobjSrc.get(strFieldPrefixSrc + 'State')); + sobjDst.put(strFieldPrefixDst + 'Country', sobjSrc.get(strFieldPrefixSrc + 'Country')); + sobjDst.put(strFieldPrefixDst + 'Latitude', sobjSrc.get(strFieldPrefixSrc + 'Latitude')); + sobjDst.put(strFieldPrefixDst + 'Longitude', sobjSrc.get(strFieldPrefixSrc + 'Longitude')); + if (orgConfig.isStateCountryPicklistsEnabled()) { + sobjDst.put(strFieldPrefixDst + 'StateCode', sobjSrc.get(strFieldPrefixSrc + 'StateCode')); + sobjDst.put(strFieldPrefixDst + 'CountryCode', sobjSrc.get(strFieldPrefixSrc + 'CountryCode')); + } + } + + /******************************************************************************************************* + * @description utility to compare a Contact or Account address to the Address record + * @param sObj Account or Contact + * @param addr Address + * @return boolean. true if any of the Address fields on the Contact are different from this Address record + */ + public Boolean isSObjectAddressDifferent(SObject sObj, IAddress other) { + Address__c addr = (Address__c) other.getRecord(); + if (sObj == null || addr == null) { + return false; + } + String prefix = ((sObj.getSObjectType() == Contact.SObjectType) ? 'Mailing' : 'Billing'); + + Boolean isDifferent = isDifferentIncludingLatLong(sObj, prefix, other); + return isDifferent; + } + + private Boolean isDifferentIncludingLatLong(SObject sObj, String prefix, IAddress other) { + return ( + !equalsCaseSensitive((String) sObj.get(prefix + 'Street'), other.multilineStreet()) || + !equalsCaseSensitive((String) sObj.get(prefix + 'City'), other.city()) || + !equalsCaseSensitive((String) sObj.get(prefix + 'State'), other.state()) || + !equalsCaseSensitive((String) sObj.get(prefix + 'PostalCode'), other.postalCode()) || + !equalsCaseSensitive((String) sObj.get(prefix + 'Country'), other.country()) || + (Decimal) sObj.get(prefix + 'Latitude') != other.latitude() || + (Decimal) sObj.get(prefix + 'Longitude') != other.longitude() + ); + } + + public static Boolean isAddressManagementEnabled() { + if (!UTIL_CustomSettingsFacade.getContactsSettings().Household_Account_Addresses_Disabled__c) { + return true; + } + return false; + } + + public static Boolean isOrgAccountAddressesEnabled() { + if (UTIL_CustomSettingsFacade.getContactsSettings().Organizational_Account_Addresses_Enabled__c) { + return true; + } + return false; + } + + /******************************************************************************************************* + * @description Returns whether two strings are equal, using a case sensitve comparison + * @param str1 The first string + * @param str2 The second string + * @return boolean + ********************************************************************************************************/ + public Boolean equalsCaseSensitive(String str1, String str2) { + if (str1 == null) { + return str2 == null; + } + if (str2 == null) { + return false; + } + return str1.equals(str2); + } + + /******************************************************************************************************* + * @description Utility to copy Address fields from an Address object to a Contact or Account. + * Handles instances where State and Country picklists are enabled, and multiline street addresses. + * @param anAddress the Address object to copy from + * @param sobjDst the destination Contact or Account + * @param strFieldPrefix the address fields to copy to, ie., Mailing, Other, Shipping, Billing + * @param strFieldAddrType an optional Address Type field on sobjDst to copy to + */ + public void copyOntoSObject(IAddress anAddress, SObject sobjDst, + String strFieldPrefix, String strFieldAddrType) { + Address__c addr = (Address__c) anAddress.getRecord(); + + String undeliverableField = UTIL_Namespace.StrAllNSPrefix('Undeliverable__c'); + Set populatedFieldsAsMapKeySet = + anAddress.getRecord().getPopulatedFieldsAsMap().keySet(); + if (populatedFieldsAsMapKeySet.contains(undeliverableField)) { + String undeliverableAddressField = + UTIL_Namespace.StrAllNSPrefix('Undeliverable_Address__c'); + sobjDst.put(undeliverableAddressField, anAddress.isUndeliverable()); + } + + sobjDst.put(strFieldPrefix + 'Street', anAddress.multilineStreet()); + sobjDst.put(strFieldPrefix + 'City', addr.MailingCity__c); + sobjDst.put(strFieldPrefix + 'PostalCode', addr.MailingPostalCode__c); + sobjDst.put(strFieldPrefix + 'Latitude', addr.Geolocation__Latitude__s); + sobjDst.put(strFieldPrefix + 'Longitude', addr.Geolocation__Longitude__s); + + if (!orgConfig.isStateCountryPicklistsEnabled()) { + sobjDst.put(strFieldPrefix + 'State', addr.MailingState__c); + sobjDst.put(strFieldPrefix + 'Country', addr.MailingCountry__c); + } else { + if (addr.MailingCountry__c != null) { + if (orgConfig.validCountriesByLabel().containsKey(addr.MailingCountry__c + .toUpperCase() + )) { + sobjDst.put(strFieldPrefix + 'Country', addr.MailingCountry__c); + sobjDst.put(strFieldPrefix + 'CountryCode', + orgConfig.validCountriesByLabel().get( + addr.MailingCountry__c.toUpperCase())); + } else if (orgConfig.validCountriesByCode().containsKey(addr.MailingCountry__c + .toUpperCase())) { + sobjDst.put(strFieldPrefix + 'CountryCode', addr.MailingCountry__c.toUpperCase()); + sobjDst.put(strFieldPrefix + 'Country', + orgConfig.validCountriesByCode().get( + addr.MailingCountry__c.toUpperCase())); + } else { + // allow the invalid country to be placed in the country field, so Salesforce will generate the error. + sobjDst.put(strFieldPrefix + 'Country', addr.MailingCountry__c); + } + } else { // MailingCountry = null + sobjDst.put(strFieldPrefix + 'CountryCode', null); + sobjDst.put(strFieldPrefix + 'Country', null); + } + if (addr.MailingState__c != null) { + if (orgConfig.validStatesByLabel().containsKey(addr.MailingState__c + .toUpperCase())) { + sobjDst.put(strFieldPrefix + 'State', addr.MailingState__c); + sobjDst.put(strFieldPrefix + 'StateCode', orgConfig.validStatesByLabel() + .get(addr + .MailingState__c.toUpperCase())); + } else { + // too expensive for us to create the map of CountryCode|StateCode to StateLabel + // so we will just try to save any state that isn't a label as a code. + sobjDst.put(strFieldPrefix + 'StateCode', addr.MailingState__c.toUpperCase()); + } + } else { // MailingState = null + sobjDst.put(strFieldPrefix + 'StateCode', null); + sobjDst.put(strFieldPrefix + 'State', null); + } + } + + if (strFieldAddrType != null) { + sobjDst.put(strFieldAddrType, addr.Address_Type__c); + } + } +} \ No newline at end of file diff --git a/src/force-app/AddressService.cls-meta.xml b/src/force-app/AddressService.cls-meta.xml new file mode 100644 index 0000000..f928c8e --- /dev/null +++ b/src/force-app/AddressService.cls-meta.xml @@ -0,0 +1,5 @@ + + + 53.0 + Active + diff --git a/src/force-app/AddressServiceTests_TEST.cls b/src/force-app/AddressServiceTests_TEST.cls new file mode 100644 index 0000000..29602c1 --- /dev/null +++ b/src/force-app/AddressServiceTests_TEST.cls @@ -0,0 +1,101 @@ +/* + Copyright (c) 2022 Salesforce.org + All rights reserved. + + Redistribution and use in source and binary forms, with or without + modification, are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + * Neither the name of Salesforce.org nor the names of + its contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + + THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS + FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE + COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, + INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, + BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT + LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN + ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + POSSIBILITY OF SUCH DAMAGE. +*/ +/** +* @author Salesforce.org +* @date 2022 +* @description Tests for the Address Service class in NPSP. +*/ +@IsTest +private class AddressServiceTests_TEST { + + private static OrgConfig orgConfig = new OrgConfig(); + + /** + * @description in order to be effective this test must be run in an Org that has State + * & Country picklists enabled. + * NPSP's in-memory map, validStatesByLabel(), has no way of knowing which State is + * applicable for which Country. The platform does and will automatically populate the + * state code depending on the value in the Country (or CountryCode) value. + */ + @IsTest + static void shouldDirectlySetStateCodeValue() { + if (!orgConfig.isStateCountryPicklistsEnabled()) { + return; + } + + // Arrange + String stateName = 'Minnesota'; + String stateCode = 'MN'; + Contact aContact = new Contact(); + IAddress anAddress = new NPSP_Address( + new Address__c( + MailingState__c = stateName, + MailingCountry__c = 'United States' + ) + ); + AddressService addressService = new AddressService(); + + // Act + addressService.copyOntoSObject( + anAddress, + aContact, + 'Mailing', + null + ); + + // Assert + System.assertEquals(stateCode, aContact.get('MailingStateCode'), + 'The copyOntoSObject method should be setting State Code values.'); + } + + @IsTest + static void shouldCopyUndeliverableStatus(){ + // Arrange + Contact aContact = new Contact(); + IAddress anUndeliverableAddress = new NPSP_Address( + new Address__c( + Undeliverable__c = true + ) + ); + AddressService addressService = new AddressService(); + + // Act + addressService.copyOntoSObject( + anUndeliverableAddress, + aContact, + 'Mailing', + null + ); + + // Assert + System.assertEquals(true, aContact.Undeliverable_Address__c, + 'The copyOntoSObject method should map the Addresses undeliverable status.'); + } +} \ No newline at end of file diff --git a/src/force-app/AddressServiceTests_TEST.cls-meta.xml b/src/force-app/AddressServiceTests_TEST.cls-meta.xml new file mode 100644 index 0000000..f928c8e --- /dev/null +++ b/src/force-app/AddressServiceTests_TEST.cls-meta.xml @@ -0,0 +1,5 @@ + + + 53.0 + Active + diff --git a/src/force-app/ContactService.cls b/src/force-app/ContactService.cls new file mode 100644 index 0000000..89a4d6d --- /dev/null +++ b/src/force-app/ContactService.cls @@ -0,0 +1,135 @@ +/* + Copyright (c) 2021 Salesforce.org + All rights reserved. + + Redistribution and use in source and binary forms, with or without + modification, are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + * Neither the name of Salesforce.org nor the names of + its contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + + THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS + FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE + COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, + INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, + BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT + LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN + ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + POSSIBILITY OF SUCH DAMAGE. +*/ +/** +* @author Salesforce.org +* @date 2021 +* @description Contact Service class in NPSP. +*/ +public with sharing class ContactService { + + @TestVisible + private AddressService addressServiceInstance { + get { + if (addressServiceInstance == null) { + addressServiceInstance = new AddressService(); + } + return addressServiceInstance; + } + set; + } + + + @TestVisible + private HouseholdService householdService { + get { + if (householdService == null) { + householdService = new HouseholdService(); + } + return householdService; + } + set; + } + + public void moveOppsToNewContactAccount(Map newAcctIdByContactId, Map oldAcctIdByContactId) { + moveOppsToContactAccount(newAcctIdByContactId, oldAcctIdByContactId); + } + + /******************************************************************************************************* + * @description Moves all opps for the Primary OCR provided contacts to their new account. Note that it + * is the caller's responsibility to decide whether moving opps is appropriate for the involved accounts. + * @param newHouseholdIdByContactId a map of Contact Id to Account Id, for the Accounts to move opps to + * @param oldHouseholdIdByContactId a map of Contact Id to Account Id, for the Accounts to move opps from + */ + private static void moveOppsToContactAccount(Map newHouseholdIdByContactId, Map + oldHouseholdIdByContactId) { + if (newHouseholdIdByContactId.isEmpty()) { + return; + } + + List opportunities = new List(); + Set oppIds = new Set(); + + for (OpportunityContactRole role : [ + SELECT ContactId, OpportunityId, Opportunity.AccountId + FROM OpportunityContactRole + WHERE IsPrimary = TRUE + AND Opportunity.AccountId IN :oldHouseholdIdByContactId.values() + AND ContactId IN :newHouseholdIdByContactId.keySet() + ]) { + Boolean shouldMoveOpp = oldHouseholdIdByContactId.get(role.ContactId) == role.Opportunity.AccountId + && oppIds.add(role.OpportunityId); + + if (shouldMoveOpp) { + opportunities.add(new Opportunity( + Id = role.OpportunityId, + AccountId = newHouseholdIdByContactId.get(role.ContactId))); + } + } + + if (!opportunities.isEmpty()) { + UTIL_DMLService.updateRecords(opportunities); + } + } + + public List moveOppsForContactsSwitchingAccounts(Contacts contacts) { + Map oldHouseholdIdByContactId = new Map(); + Map newHouseholdIdByContactId = new Map(); + + List householdIdsToUpdate = new List(); + for (Contact contactRecord : (List) contacts.getRecords()) { + Contact oldContact = contacts.oldVersionOf(contactRecord); + + if (contactRecord.AccountId != oldContact.AccountId) { + addHouseholdIds(householdIdsToUpdate, newHouseholdIdByContactId, contactRecord); + addHouseholdIds(householdIdsToUpdate, oldHouseholdIdByContactId, oldContact); + + } + } + + moveOppsToContactAccount(newHouseholdIdByContactId, + oldHouseholdIdByContactId); + return householdIdsToUpdate; + } + + /** + * addHouseholdIds Adds the account ids from all of the contacts belonging to a household + * @param householdIds The collection of householdIds to add the account ids to + * @param householdIdByContactId The map of household by their id. + * @param contactRecord The contact to extract the id from. + */ + private static void addHouseholdIds(List householdIds, Map + householdIdByContactId, Contact contactRecord) { + if (contactRecord.npe01__Organization_Type__c == CAO_Constants.HH_ACCOUNT_TYPE) { + householdIds.add(contactRecord.AccountId); + householdIdByContactId.put(contactRecord.Id, contactRecord.AccountId); + } + } + +} \ No newline at end of file diff --git a/src/force-app/ContactService.cls-meta.xml b/src/force-app/ContactService.cls-meta.xml new file mode 100644 index 0000000..f928c8e --- /dev/null +++ b/src/force-app/ContactService.cls-meta.xml @@ -0,0 +1,5 @@ + + + 53.0 + Active + diff --git a/src/force-app/DonationHistoryService.cls b/src/force-app/DonationHistoryService.cls new file mode 100644 index 0000000..923fe11 --- /dev/null +++ b/src/force-app/DonationHistoryService.cls @@ -0,0 +1,154 @@ +/* + Copyright (c) 2021 Salesforce.org + All rights reserved. + + Redistribution and use in source and binary forms, with or without + modification, are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + * Neither the name of Salesforce.org nor the names of + its contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + + THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS + FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE + COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, + INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, + BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT + LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN + ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + POSSIBILITY OF SUCH DAMAGE. +*/ +/** +* @author Salesforce.org +* @date 2021 +* @description Donation History Service class in NPSP. +*/ +public with sharing class DonationHistoryService { + + @TestVisible + private UTIL_Permissions permissions { + get { + if(permissions == null) { + permissions = new UTIL_Permissions(); + } + return permissions; + } + set; + } + + @TestVisible + private DonationHistorySelector selector { + get { + if(selector == null) { + selector = new DonationHistorySelector(); + } + return selector; + } + set { + this.selector = value; + } + } + + private Boolean hasAccessToDonationHistory() { + return permissions.canRead(Opportunity.SObjectType, new Set{ + Opportunity.Amount, + Opportunity.CloseDate + }) && permissions.canRead(Contact.SObjectType, new Set{ + Contact.Email, + Contact.Name + }) && permissions.canRead(User.SObjectType, new Set{ + User.ContactId + }) && permissions.canRead(npe01__OppPayment__c.SObjectType, new Set{ + npe01__OppPayment__c.npe01__Payment_Method__c, + npe01__OppPayment__c.npe01__Opportunity__c + }); + } + + public Boolean checkIfPaymentsAreEnabled() { + return npe01__Contacts_And_Orgs_Settings__c.getOrgDefaults().npe01__Payments_Enabled__c; + } + + public String getPaymentMethodLabel() { + return Schema.DataImport__c.Payment_Method__c.getDescribe().getLabel(); + } + + /** + * @author Salesforce.org + * @date 2021 + * @description method to return Donation History list. + * @return List returns Opportunities and Payment Method for them. + */ + public List getDonationHistory(Id contactId, Integer offset, String filter) { + if (hasAccessToDonationHistory()) { + List credits = null; + if (shouldApplyFilterByYear(filter)) { + credits = selector.getWonOpportunitiesByContactIdAndYear(contactId, offset, Integer.valueOf(filter)); + } else { + credits = selector.getWonOpportunitiesByContactId(contactId, offset); + } + return credits; + } else { + throw new UTIL_Permissions.InsufficientPermissionException(Label.commonInsufficientPermissions); + } + } + + /** + * @description method to return Payment Method associated to Donations + * @param paymentListForOpportunity List for current opportunity + * @return String with payment method for current opportunity. This returns the first Paid Payment Method unless + * none are paid, in which case it returns the first Payment Method + */ + public String getRequiredPaymentMethodForOpportunities(List paymentListForOpportunity){ + String paymentMethodToReturn = ''; + for (npe01__OppPayment__c p : paymentListForOpportunity) { + if (p.npe01__Paid__c) { + paymentMethodToReturn = p.npe01__Payment_Method__c; + break; + } + if (paymentMethodToReturn == '') { + paymentMethodToReturn = p.npe01__Payment_Method__c; + continue; + } + } + return paymentMethodToReturn; + } + + private boolean shouldApplyFilterByYear(String filter) { + return filter != null && filter.isNumeric(); + } + + /** + * @descritpion returns the years where the contact made at least 1 donation. + * @param contactId the contact id. + * @return List + */ + public List getYearsWithDonationsForContact(Id contactId) { + + if (!hasAccessToDonationHistory()) { + throw new UTIL_Permissions.InsufficientPermissionException(Label.commonInsufficientPermissions); + } + return selector.getYearsWithDonationForContact(contactId); + } + + /** + * @author Salesforce.org + * @date 2021 + * @description method to return Donation History Number of Records for infinite scroll. + * @param contactId contact id which is going to be requested. + */ + public Integer getTotalNumberOfRecords(Id contactId, String filter) { + if (shouldApplyFilterByYear(filter)) { + return selector.getTotalNumberOfRecordsWithYear(contactId, Integer.valueOf(filter)); + } + return selector.getTotalNumberOfRecords(contactId); + } +} \ No newline at end of file diff --git a/src/force-app/DonationHistoryService.cls-meta.xml b/src/force-app/DonationHistoryService.cls-meta.xml new file mode 100644 index 0000000..f928c8e --- /dev/null +++ b/src/force-app/DonationHistoryService.cls-meta.xml @@ -0,0 +1,5 @@ + + + 53.0 + Active + diff --git a/src/force-app/HouseholdNamingService.cls b/src/force-app/HouseholdNamingService.cls new file mode 100644 index 0000000..a225a9c --- /dev/null +++ b/src/force-app/HouseholdNamingService.cls @@ -0,0 +1,549 @@ +/* + Copyright (c) 2011, Salesforce.org + All rights reserved. + + Redistribution and use in source and binary forms, with or without + modification, are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + * Neither the name of Salesforce.org nor the names of + its contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + + THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS + FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE + COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, + INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, + BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT + LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN + ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + POSSIBILITY OF SUCH DAMAGE. +*/ +/** +* @author Salesforce.org +* @date 2011 +* @group Households +* @description Controls generation of household names for lists of households +* NOTE: This class handles both household naming for the Household__c object +* as well as for HH Account objects. +*/ +public without sharing class HouseholdNamingService { + + private HouseholdSettings settings = new HouseholdSettings(); + + @TestVisible + private ContactSelector contactSelector { + get { + if (contactSelector == null) { + contactSelector = new ContactSelector(); + } + return contactSelector; + } + set; + } + + @TestVisible + private UnitOfWork unitOfWork { + get { + if (unitOfWork == null) { + unitOfWork = new UnitOfWork(); + } + return unitOfWork; + } + set; + } + + @TestVisible + private AddressService addressService { + get { + if (addressService == null) { + addressService = new AddressService(); + } + return addressService; + } + set; + } + + /******************************************************************************************************* + * @description turns off household naming in the current execution context. provided for other + * components, like Batch Data Import, to momentarily turn off the household naming logic. there is no + * direct way to do that by disabling TDTM trigger handlers, or using existing static flags. + * @param disable True to disable, False to enable + * @return void + */ + private static Boolean isHouseholdNamingDisabled = false; + public static void disableHouseholdNaming(Boolean disable) { + isHouseholdNamingDisabled = disable; + } + + public void updateHouseholdNameAndMemberCountAsync(List hhids) { + updateHouseholdNameAndMemberCountAsynchronously(hhids, AccountAdapter.isAllMembersDeceasedUpdateEnabled); + } + + public static void updateHouseholdNameAndMemberCountAsynchronously(List hhids) { + updateHouseholdNameAndMemberCountAsynchronously(hhids, AccountAdapter.isAllMembersDeceasedUpdateEnabled); + } + + /******************************************************************************************************* + * @description future method to update the household names for the specified households + * @param hhids the list of household Id's (either Household__c or Account Id's) + * @return void + */ + @future + public static void updateHouseholdNameAndMemberCountAsynchronously(List hhids, Boolean isDeceasedUpdateEnabled) { + //set our process control to avoid recursive updating on household records + AccountAdapter.enableHouseholdDeceasedUpdate(isDeceasedUpdateEnabled); + TDTM_ProcessControl.setRecursionFlag(TDTM_ProcessControl.flag.HH, true); + HouseholdNamingService householdNamingService = new HouseholdNamingService(); + householdNamingService.updateHouseholdNameAndMemberCount(hhids); + TDTM_ProcessControl.setRecursionFlag(TDTM_ProcessControl.flag.HH, false); + } + + /******************************************************************************************************* + * @description Sets Number_Of_Household_Members__c, and + * it will be responsible for only updating names if the advanced household naming is true. + * So any code that used to check for advanced household naming should no longer do so, + * (unless it is only for a naming scenario that would possibly change number of household members.) + * @param householdOrAccountIds the list of household Id's (either Household__c or Account Id's) + * @return void + */ + public void updateHouseholdNameAndMemberCount(List householdOrAccountIds) { + if (isHouseholdNamingDisabled) { + return; + } + //we need this turned on to prevent recursive triggering on household creation + TDTM_ProcessControl.setRecursionFlag(TDTM_ProcessControl.flag.HH, true); + + List householdsOrAccounts = householdsOrAccountsFor(householdOrAccountIds); + Map> membersByHouseholdId = + getHouseholdMembersByHouseholdId(householdOrAccountIds); + if (settings.isAdvancedHouseholdNaming()) { + setHouseholdNameFieldValues(householdsOrAccounts, membersByHouseholdId); + } + setNumberOfHouseholdMembers(householdsOrAccounts, membersByHouseholdId); + + if (isListOfAccountIds(householdsOrAccounts)) { + setAllMembersDeceasedFlag(householdsOrAccounts, membersByHouseholdId); + } + + unitOfWork.save(); + + TDTM_ProcessControl.setRecursionFlag(TDTM_ProcessControl.flag.HH, false); + } + + private Boolean isListOfAccountIds(List householdsOrAccounts) { + if (householdsOrAccounts.isEmpty()) { + return false; + } + return householdsOrAccounts?.get(0).getSObjectType() == Account.SObjectType; + } + + private void save(List householdsOrAccounts) { + if (!householdsOrAccounts.isEmpty()) { + UTIL_DMLService.updateRecords(householdsOrAccounts); + } + } + + private void setNumberOfHouseholdMembers(List householdsOrAccounts, Map> membersByHouseholdId) { + for (SObject household : householdsOrAccounts) { + List members = membersByHouseholdId.get(idFor(household)); + if (numberOfHouseholdMembersIsIncorrect(household, members)) { + refreshNumberOfHouseholdMembers(household, members); + unitOfWork.registerDirty(new List{household}); + } + } + } + + private void refreshNumberOfHouseholdMembers(SObject household, List members) { + household.put(UTIL_Namespace.StrTokenNSPrefix('Number_of_Household_Members__c'), + members != null ? members.size() : 0); + } + + private Boolean numberOfHouseholdMembersIsIncorrect(SObject household, List householdMembers) { + Object currentNumberOfHouseholdMembersValue = household.get('Number_of_Household_Members__c'); + if (householdMembers == null) { + if (currentNumberOfHouseholdMembersValue != null + && currentNumberOfHouseholdMembersValue != 0) { + return true; + } else { + return false; + } + } else { + if (currentNumberOfHouseholdMembersValue != null) { + return currentNumberOfHouseholdMembersValue != householdMembers.size(); + } else { + return true; + } + } + } + + public void setAllMembersDeceasedFlag(List accounts, Map> membersByHouseholdId) { + for (Account household : accounts) { + Boolean originalDeceased = household.All_Members_Deceased__c; + Boolean allMembersDeceased; + + List members = membersByHouseholdId?.get(household.Id); + if (members == null) { + allMembersDeceased = false; + + } else { + allMembersDeceased = true; + for (Contact member : members) { + if (member.Deceased__c == false) { + allMembersDeceased = false; + break; + } + } + } + + if (originalDeceased != allMembersDeceased) { + household.All_Members_Deceased__c = allMembersDeceased; + unitOfWork.registerDirty(new List{household}); + } + } + } + + private void setHouseholdNameFieldValues(List householdsOrAccounts, + Map> membersByHouseholdId) { + for (SObject household : householdsOrAccounts) { + List householdMembers = getHouseholdMembers(membersByHouseholdId, household); + HouseholdName householdName = new HouseholdName( + new HouseholdMembers(householdMembers), + householdNamingImpl); + String formalGreeting = (String) household.get('npo02__Formal_Greeting__c'); + String informalGreeting = (String) household.get('npo02__Informal_Greeting__c'); + if (qualifiesForHouseholdRenaming(household, householdName, formalGreeting, informalGreeting)) { + setNameFieldValuesOnHousehold( + household, + householdName + ); + } + } + } + + private Boolean qualifiesForHouseholdRenaming(SObject household, HouseholdName householdName, String formalGreeting, String informalGreeting) { + return !household.get('Name').equals(householdName.asName()) + || formalGreeting != null + && !formalGreeting.equals(String.valueOf( + householdName.asFormalGreeting())) + || informalGreeting != null + && !informalGreeting.equals(String.valueOf( + householdName.asInformalGreeting())); + } + + private List getHouseholdMembers(Map> membersByHouseholdId, SObject household) { + List householdMembers = membersByHouseholdId.get(idFor(household)); + if (householdMembers == null) { + householdMembers = new List(); + } + return householdMembers; + } + + private Map> getHouseholdMembersByHouseholdId(List householdOrAccountIds) { + return householdMembersByHouseholdId( + contactSelector.householdMembersFor(householdOrAccountIds)); + } + + private List householdsOrAccountsFor(List householdOrAccountIds) { + List hhupdatelist = new List(); + hhupdatelist.addAll(getHouseholdsFor(householdOrAccountIds)); + hhupdatelist.addAll(getAccountsFor(householdOrAccountIds)); + return hhupdatelist; + } + + private List getAccountsFor(List hhids) { + return [ + SELECT Id, Name, npo02__SYSTEM_CUSTOM_NAMING__c, npo02__Formal_Greeting__c, + npo02__Informal_Greeting__c, Number_of_Household_Members__c, + All_Members_Deceased__c + FROM Account + WHERE Id IN :hhids + ]; + } + + private List getHouseholdsFor(List hhids) { + return [ + SELECT Id, Name, npo02__SYSTEM_CUSTOM_NAMING__c, npo02__Formal_Greeting__c, + npo02__Informal_Greeting__c, Number_of_Household_Members__c + FROM Npo02__Household__c + WHERE Id IN :hhids + ]; + } + + private Id idFor(SObject household) { + return String.valueOf(household.get('Id')); + } + + private Map> householdMembersByHouseholdId(List contacts) { + Map> hhIDContactMap = new Map>(); + for (Contact con : contacts) { + if (!hhIDContactMap.containskey(con.hhId__c)) { + hhIDContactMap.put(con.hhId__c, new List{ + con + }); + } else { + List clist = hhIDContactMap.get(con.hhId__c); + clist.add(con); + } + } + return hhIDContactMap; + } + + + /******************************************************************************************************* + * @description Returns Contact fields specified in the Household Naming Settings format fields. + * @return Set If the Automatic Household Naming is enabled, return set of Contact field API names; + * otherwise, an empty set + */ + //Todo: move to Household Settings? + public Set getHouseholdNamingContactFields() { + if (!settings.isAdvancedHouseholdNaming() + || householdNamingImpl.setHouseholdNameFieldsOnContact() == null) { + return new Set(); + } else { + return householdNamingImpl.setHouseholdNameFieldsOnContact(); + } + } + + /******************************************************************************************************* + * @description the class object that supports the HH_INaming interface + */ + HH_INaming householdNamingImpl { + get { + if (householdNamingImpl == null) { + String implementingClass = new HouseholdSettings().getImplementingClass(); + + Type classType = Type.forName(implementingClass); + if (classType != null) { + Object classInstance = classType.newInstance(); + if (classInstance instanceof HH_INaming) { + householdNamingImpl = (HH_INaming) classInstance; + } + } + } + return householdNamingImpl; + } + set; + } + + /******************************************************************************************************* + * @description executes the batch job to update all household names + * @param isActivation whether this is being called when npo02__Advanced_Household_Naming__c is being turned on + * @return void + */ + public static void refreshAllHouseholdNaming(Boolean isActivation) { + // the household batch expects a list of Contact's with just Id, LastName, HHId__c available. + String strSoql = 'SELECT Id, LastName, ' + UTIL_Namespace.StrTokenNSPrefix('HHId__c') + ' FROM Contact WHERE ' + + UTIL_Namespace.StrTokenNSPrefix('HHId__c') + ' != NULL '; + HH_HouseholdNaming_BATCH batch = new HH_HouseholdNaming_BATCH(strSoql, isActivation); + Id batchProcessId = database.executeBatch(batch, getBatchSize()); + } + + private static Integer getBatchSize() { + return 200; + } + + private Boolean needsNamingFieldsUpdated(SObject household, SObject oldRecord) { + if (needsNameReplaced(household)) { + return true; + } else if (needsInformalGreetingReplaced(household)) { + return true; + } else if (needsFormalGreetingReplaced(household)) { + return true; + } else if (isSystemCustomNamingChanged(household, oldRecord)) { + return true; + } else if (oneToOneAccountChanged(household, oldRecord)) { + return true; + } + return false; + } + + private Boolean oneToOneAccountChanged(SObject household, SObject oldRecord) { + return isAccount(household) && isOneToOneContactChanged(household, oldRecord); + } + + private Boolean isAccount(SObject household) { + return household.getSObjectType() == Account.sObjectType; + } + + private Boolean needsFormalGreetingReplaced(SObject household) { + return formalGreetingFor(household) == nameReplacementText(); + } + + private Boolean needsInformalGreetingReplaced(SObject household) { + return informalGreetingFor(household) == nameReplacementText(); + } + + private Boolean needsNameReplaced(SObject household) { + return nameFor(household) == nameReplacementText(); + } + + private Object formalGreetingFor(SObject household) { + return household.get('npo02__Formal_Greeting__c'); + } + + private Object informalGreetingFor(SObject household) { + return household.get('npo02__Informal_Greeting__c'); + } + + private Object nameFor(SObject household) { + return household.get('Name'); + } + + private Boolean isOneToOneContactChanged(SObject household, SObject oldRecord) { + return household.get('npe01__One2OneContact__c') != oldRecord.get('npe01__One2OneContact__c'); + } + + private Boolean isSystemCustomNamingChanged(SObject household, SObject oldRecord) { + return household.get('npo02__SYSTEM_CUSTOM_NAMING__c') != + oldRecord.get('npo02__SYSTEM_CUSTOM_NAMING__c'); + } + + public List getHouseholdsNeedingNameUpdates(List records, + Map oldMap) { + List hhList = new List(); + for (SObject household : records) { + if (needsNamingFieldsUpdated(household, oldMap.get(household.Id))) { + hhList.add(household.Id); + } + } + return hhList; + } + + public void setCustomNamingField(List records, Map oldMap) { + for (SObject household : records) { + SObject oldRecord = oldMap.get(household.Id); + setCustomNamingStringValue(household, oldRecord); + } + } + + public void setNameAndGreetingsToReplacementText(List records) { + for (SObject household : records) { + setNameAndGreetingsToReplacementText(household); + } + } + + public void setNameFieldValuesOnHousehold(SObject household, HouseholdName householdName) { + HouseholdNamingUserControlledFields namingOverrides = + new HouseholdNamingUserControlledFields(namingOverridesFor(household)); + if (!namingOverrides.isNameControlledByUser() && + nameNeedsUpdate(household, householdName)) { + setName(household, householdName); + unitOfWork.registerDirty(new List{ + household + }); + } + + if (!namingOverrides.isFormalGreetingControlledByUser() && + formalGreetingNeedsUpdate(household, householdName)) { + setFormalGreeting(household, householdName); + unitOfWork.registerDirty(new List{ + household + }); + } + if (!namingOverrides.isInformalGreetingControlledByUser() && + informalGreetingNeedsUpdate(household, householdName)) { + setInformalGreeting(household, householdName); + unitOfWork.registerDirty(new List{ + household + }); + } + } + + private Boolean informalGreetingNeedsUpdate(SObject household, HouseholdName householdName) { + return !addressService.equalsCaseSensitive( + (String) household.get('npo02__Informal_Greeting__c'), + householdName.asInformalGreeting()); + } + + private Boolean formalGreetingNeedsUpdate(SObject household, HouseholdName householdName) { + return !addressService.equalsCaseSensitive( + (String) household.get('npo02__Formal_Greeting__c'), + householdName.asFormalGreeting()); + } + + private Boolean nameNeedsUpdate(SObject household, HouseholdName householdName) { + return !addressService.equalsCaseSensitive( + (String) household.get('Name'), + householdName.asName()); + } + + private void setInformalGreeting(SObject household, HouseholdName householdName) { + household.put('npo02__Informal_Greeting__c', householdName.asInformalGreeting()); + } + + private void setFormalGreeting(SObject household, HouseholdName householdName) { + household.put('npo02__Formal_Greeting__c', householdName.asFormalGreeting()); + } + + private void setName(SObject household, HouseholdName householdName) { + household.put('Name', householdName.asName()); + } + + private void replaceHouseholdFormalGreeting(SObject household) { + household.put('npo02__Formal_Greeting__c', nameReplacementText()); + } + + private void replaceHouseholdInformalGreeting(SObject household) { + household.put('npo02__Informal_Greeting__c', nameReplacementText()); + } + + private void replaceHouseholdName(SObject household) { + household.put('Name', nameReplacementText()); + } + + private void setNameAndGreetingsToReplacementText(SObject household) { + if (isNameReplaceable(household)) { + replaceHouseholdName(household); + } + + if (isInformalGreetingReplaceable(household)) { + replaceHouseholdInformalGreeting(household); + } + + if (isFormalGreetingReplaceable(household)) { + replaceHouseholdFormalGreeting(household); + } + } + + private Boolean isNameReplaceable(SObject household) { + return isReplaceable(household, 'Name'); + } + + private Boolean isFormalGreetingReplaceable(SObject household) { + return isReplaceable(household, 'npo02__Formal_Greeting__c'); + } + + private Boolean isInformalGreetingReplaceable(SObject household) { + return isReplaceable(household, 'npo02__Informal_Greeting__c'); + } + + private Boolean isReplaceable(SObject household, String fieldApiName) { + String fieldValue = (String) household.get(fieldApiName); + return fieldValue == null || fieldValue == '' || fieldValue == nameReplacementText(); + } + + private void setCustomNamingStringValue(SObject household, SObject oldRecord) { + HouseholdNamingUserControlledFields userControlledNamingFields = + new HouseholdNamingUserControlledFields(household, oldRecord); + household.put('npo02__SYSTEM_CUSTOM_NAMING__c', + userControlledNamingFields.asConcatenatedString()); + } + + private String namingOverridesFor(SObject household) { + return (String) household.get + ('npo02__SYSTEM_CUSTOM_NAMING__c'); + } + + private String nameReplacementText() { + return System.Label.npo02.NameReplacementText; + } +} \ No newline at end of file diff --git a/src/force-app/HouseholdNamingService.cls-meta.xml b/src/force-app/HouseholdNamingService.cls-meta.xml new file mode 100644 index 0000000..e14e09d --- /dev/null +++ b/src/force-app/HouseholdNamingService.cls-meta.xml @@ -0,0 +1,10 @@ + + + 53.0 + + 3 + 11 + npo02 + + Active + diff --git a/src/force-app/HouseholdService.cls b/src/force-app/HouseholdService.cls new file mode 100644 index 0000000..69ba9f0 --- /dev/null +++ b/src/force-app/HouseholdService.cls @@ -0,0 +1,551 @@ +/* + Copyright (c) 2021 Salesforce.org + All rights reserved. + + Redistribution and use in source and binary forms, with or without + modification, are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + * Neither the name of Salesforce.org nor the names of + its contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + + THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS + FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE + COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, + INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, + BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT + LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN + ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + POSSIBILITY OF SUCH DAMAGE. +*/ +/** +* @author Salesforce.org +* @date 2021 +* @description Domain class for Contacts in NPSP. +*/ +public inherited sharing class HouseholdService { + + @TestVisible + private HouseholdSettings settings { + get { + if (settings == null) { + settings = new HouseholdSettings(); + } + return settings; + } + set; + } + + @TestVisible + private HouseholdNamingService householdNamingServiceInst { + get { + if (householdNamingServiceInst == null) { + householdNamingServiceInst = new HouseholdNamingService(); + } + return householdNamingServiceInst; + } + set; + } + + @TestVisible + private AddressService addressServiceInstance { + get { + if (addressServiceInstance == null) { + addressServiceInstance = new AddressService(); + } + return addressServiceInstance; + } + set; + } + + @TestVisible + private HouseholdSettings householdSettings { + get { + if (householdSettings == null) { + householdSettings = new HouseholdSettings(); + } + return householdSettings; + } + set; + } + + private static final String RECORD_TYPE_ID = 'RecordTypeId'; + + @TestVisible + private UnitOfWork unitOfWorkInst { + get { + if (unitOfWorkInst == null) { + unitOfWorkInst = new UnitOfWork(); + } + return unitOfWorkInst; + } + set; + } + + public Set fieldsUsedForHouseholdNaming() { + return householdNamingServiceInst.getHouseholdNamingContactFields(); + } + + public void createHouseholdsFor( + LegacyHouseholdMembers legacyHouseholdMembers) { + if (allContactsGetHouseholds()) { + insertHouseholdsForContacts( + legacyHouseholdMembers.contactsWithoutHouseholds()); + } + if (allIndividualContactsGetHouseholds()) { + insertHouseholdsForContacts( + legacyHouseholdMembers.contactsInIndividualAccountsWithoutHouseholds()); + } + } + + public void deleteEmptyHouseholdsFor(LegacyHouseholdMembers legacyHouseholdMembers) { + LegacyHouseholds oldHouseholds = new LegacyHouseholds( + new Set(legacyHouseholdMembers.oldHouseholdIds()), + new LegacyHouseholdSelector()); + oldHouseholds.deleteEmptyHouseholds(); + } + + public void updateHouseholdNamesFor(LegacyHouseholdMembers legacyHouseholdMembers) { + if (legacyHouseholdMembers.householdsWithMembershipOrNamingFieldChanges().size() > 0) { + if (isFutureEligible()) { + HouseholdNamingService.updateHouseholdNameAndMemberCountAsynchronously( + legacyHouseholdMembers + .householdsWithMembershipOrNamingFieldChanges()); + } else { + HouseholdNamingService householdNamingService = new HouseholdNamingService(); + householdNamingService.updateHouseholdNameAndMemberCount( + legacyHouseholdMembers + .householdsWithMembershipOrNamingFieldChanges()); + } + } + } + + private Boolean isFutureEligible() { + return settings.isAsyncEnabled() + && !System.isFuture() && !System.isBatch(); + } + + public void fireHouseholdRollupsFor(LegacyHouseholdMembers legacyHouseholdMembers) { + if (legacyHouseholdMembers.householdsWithMembershipChanges().isEmpty()) { + return; //No need to fire rollups if no Contacts changed Households + } + LegacyHouseholds householdsWithMembershipChanges = + new LegacyHouseholds( + legacyHouseholdMembers.householdsWithMembershipChanges(), + new LegacyHouseholdSelector()); + householdsWithMembershipChanges.calculateLegacyHouseholdRollups(); + } + + public void processOldHouseholdsAfterDeletingContacts( + LegacyHouseholdMembers legacyHouseholdMembers) { + LegacyHouseholds oldHouseholds = + new LegacyHouseholds(legacyHouseholdMembers.householdIds(), + new LegacyHouseholdSelector()); + oldHouseholds.deleteEmptyHouseholds(); + oldHouseholds.updateHouseholdNameAndMemberCount(); + } + + private Boolean allIndividualContactsGetHouseholds() { + return settings.isAllIndividualsProcessor(); + } + + private Boolean allContactsGetHouseholds() { + return settings.isAllProcessor(); + } + + /******************************************************************************************************* + * @description Creates a new Household Object for each contact, unless excluded by recordType + * @param contacts List of Contacts + * @param isInsertTrigger Whether called from the afterInsert trigger (vs. afterUpdate trigger) on Contacts + ********************************************************************************************************/ + private void insertHouseholdsForContacts(List contacts) { + List households = getHouseholdsForInsert(contacts); + if (households.size() > 0) { + unitOfWorkInst.registerNew((List) households); + Database.SaveResult[] householdSaveResults = unitOfWorkInst.save(); + if (householdSaveResults != null && !householdSaveResults.isEmpty()) { + putHouseholdIdOntoContacts(contacts, householdSaveResults); + updateNamesAfterHouseholdInsert(householdSaveResults); + } + } + } + + private void putHouseholdIdOntoContacts(List contacts, + Database.SaveResult[] householdSaveResults) { + List contactsToBeUpdatedWithHouseholdId = + getContactsToBeUpdatedWithNewHouseholdId(contacts, householdSaveResults); + update contactsToBeUpdatedWithHouseholdId; + } + + private void updateNamesAfterHouseholdInsert(Database.SaveResult[] lsr) { + List householdIdsNeedingNamesUpdated = + getHouseholdIdsNeedingNamesUpdatedFromSaveResult(lsr); + if (!householdIdsNeedingNamesUpdated.isEmpty()) { + if (isFutureEligible()) { + HouseholdNamingService.updateHouseholdNameAndMemberCountAsynchronously( + householdIdsNeedingNamesUpdated + ); + } else { + HouseholdNamingService householdNamingService = new HouseholdNamingService(); + householdNamingService.updateHouseholdNameAndMemberCount( + householdIdsNeedingNamesUpdated + ); + } + } + } + + private List getHouseholdIdsNeedingNamesUpdatedFromSaveResult( + Database.SaveResult[] lsr) { + List householdIdsNeedingNamesUpdated = new List(); + for (Database.SaveResult sr : lsr) { + if (sr.isSuccess()) { + householdIdsNeedingNamesUpdated.add(sr.getID()); + } + } + return householdIdsNeedingNamesUpdated; + } + + private List getContactsToBeUpdatedWithNewHouseholdId(List contacts, + Database.SaveResult[] householdSaveResults) { + List contactsToBeUpdatedWithHouseholdId = new List(); + Integer i = 0; + for (Contact con : getContactsThatAreNotExcludedByRecordType(contacts)) { + Database.SaveResult saveResult = householdSaveResults[i]; + if (saveResult.isSuccess() == true) { + //write the new Household Ids to the Contacts + Contact cloneContact = con.Clone(true, false); + cloneContact.npo02__household__c = saveResult.getId(); + contactsToBeUpdatedWithHouseholdId.add(cloneContact); + } else { + Database.Error err = saveResult.getErrors()[0]; + con.addError(err.getMessage()); + } + i += 1; + } + return contactsToBeUpdatedWithHouseholdId; + } + + private List getHouseholdsForInsert(List contacts) { + List households = new List(); + for (Contact con : getContactsThatAreNotExcludedByRecordType(contacts)) { + households.add(getHouseholdForInsertFromContact(con)); + } + return households; + } + + private List getContactsThatAreNotExcludedByRecordType(List contacts) { + List contactsThatAreNotExcludedByRecordType = new List(); + for (Contact con : contacts) { + if (isNotExcludedByRecordType(con)) { + contactsThatAreNotExcludedByRecordType.add(con); + } + } + return contactsThatAreNotExcludedByRecordType; + } + + private npo02__Household__c getHouseholdForInsertFromContact(Contact con) { + npo02__household__c household = + new npo02__household__c(Name = getDefaultHouseholdName(con)); + syncPrimaryAddressBlock(household, con); + return household; + } + + private void syncPrimaryAddressBlock(npo02__Household__c household, Contact con) { + household.npo02__MailingStreet__c = con.MailingStreet; + household.npo02__MailingCity__c = con.MailingCity; + household.npo02__MailingState__c = con.MailingState; + household.npo02__MailingPostalCode__c = con.MailingPostalCode; + household.npo02__MailingCountry__c = con.MailingCountry; + household.npo02__HouseholdPhone__c = con.HomePhone; + household.npo02__HouseholdEmail__c = con.Email; + } + + private String getDefaultHouseholdName(Contact con) { + String hName = con.LastName; + return hName += ' ' + System.Label.npo02.DefaultHouseholdName; + } + + private Boolean isNotExcludedByRecordType(Contact con) { + return !isRecordTypeInUseOnContacts + || !settings.contactRecordTypesToExclude().contains(recordTypeIdFor(con)); + } + + private Id recordTypeIdFor(Contact con) { + return (Id) con.get(RECORD_TYPE_ID); + } + + private static Boolean isRecordTypeInUseOnContacts { + get { + if (isRecordTypeInUseOnContacts == NULL) { + isRecordTypeInUseOnContacts = + Schema.sObjectType.Contact.fields.getMap().get(RECORD_TYPE_ID) != null; + } + return isRecordTypeInUseOnContacts; + } + set; + } + + public void updateHouseholds(List ids, TDTM_Runnable.DmlWrapper dmlWrapper) { + if (ids.isEmpty()) { + return; + } + + Households households = new Households(new Set(ids)); + households.updateHouseholds(dmlWrapper); + } + + /******************************************************************************************************* + * @description Inserts a new 1:1 or HH Account for an Individual Contact + * @param contacts the list of Contacts that need updating. + */ + public void createOneToOneAndHouseholdAccountsFor(List contacts){ + if (contacts.isEmpty()) { + return; + } + + List contactRecords = new List(); + + //track which contacts are being inserted vs. updated + List existingContactIds = getContactIds(contacts); + + // get all the Accounts that are connected to the existing Contacts + Map accountByPrimaryContactId = + getAccountByPrimaryContactId(existingContactIds); + + List accountsToInsert = new List(); + List accountsToUpdate = new List(); + List contactsNeedingAddressesCreated = new List(); + for (Contact contactRecord : contacts) { + // if we found an Account already connected to this Contact, connect the Contact to that Account if it + // is the correct type for the current account processor + Account accountRecord = accountByPrimaryContactId.get(contactRecord.Id); + if (accountRecord != null && CAO_Constants.isHHAccountModel() && isHousehold(accountRecord) + || accountRecord != null && CAO_Constants.isOneToOne() && isOneToOne(accountRecord)) { + // if a user has blanked out the Account for a Contact, this will put it right back + contactRecord.AccountId = accountRecord.Id; + if (CAO_Constants.isHHAccountModel() && AddressService.isAddressManagementEnabled()) { + ContactAddress contactAddress = new ContactAddress(contactRecord); + if (contactAddress.hasAddress()) { // todo: should check for changes on + // before update + contactsNeedingAddressesCreated.add(contactRecord); + } + } + if (accountRecord.Number_of_Household_Members__c == null) { + accountRecord.Number_of_Household_Members__c = 1; + } else { + accountRecord.Number_of_Household_Members__c = + accountRecord.Contacts.size(); + } + accountsToUpdate.add(accountRecord); + } else { + // Construct the Household (Account), or the Individual/One2One Account when + // in the Legacy Household model. + accountRecord = getBaseOneToOneOrHouseholdAccountFor(contactRecord); + + // *** + // This method seems to be called from both before and after insert. + // When called after insert, the accounts One2One (aka Primary Contact) + // field is set. + // *** + // connect the Account to the Contact for 1:1 and HHAccount + if (contactRecord.Id != null) { + accountRecord.npe01__One2OneContact__c = contactRecord.Id; + } + + if (CAO_Constants.isHHAccountModel()) { + configureHouseholdAccount(contactRecord, accountRecord); + if (AddressService.isAddressManagementEnabled()) { + contactsNeedingAddressesCreated.add(contactRecord); + } + } else { + configureOneToOneAccount(accountRecord, contactRecord); + } + + accountsToInsert.add(accountRecord); + contactRecords.add(contactRecord); + } + } + + if (!accountsToUpdate.isEmpty()) { + update accountsToUpdate; // Update member count + Households.updateNameAndMemberCount( // Update name and greetings + new List(new Map(accountsToUpdate).keySet())); + } + + if (!accountsToInsert.isEmpty()) { + insertNewHouseholds(accountsToInsert, contactRecords); + } + + if (!contactsNeedingAddressesCreated.isEmpty()) { + Contacts contactsDomain = new Contacts(contactsNeedingAddressesCreated); + List contactsWithNonNullAccountIdsRecords = + contactsDomain.contactsWithNonNullAccountIds().getRecords(); + if (!contactsWithNonNullAccountIdsRecords.isEmpty()) { + Households.insertHouseholdAddressesFor( + contactsWithNonNullAccountIdsRecords + ); + } + } + } + + private void insertNewHouseholds(List accountsToInsert, List contactRecords) { + // Set the HH flag to true to prevent the trigger handlers from running again + // to update the name and member count now that they are already set correctly here. + TDTM_ProcessControl.setRecursionFlag(TDTM_ProcessControl.flag.HH, true); + // Suppress the trigger handler that creates Addresses from Accounts, as + // the Addresses will be inserted using the Contact info as a next step. + TDTM_ProcessControl.toggleTriggerState('Account', 'ADDR_Account_TDTM', false); + List insertResults = UTIL_DMLService.insertRecords(accountsToInsert, false); + TDTM_ProcessControl.toggleTriggerState('Account', 'ADDR_Account_TDTM', true); + TDTM_ProcessControl.setRecursionFlag(TDTM_ProcessControl.flag.HH, false); + + applyErrorsToContactRecords(insertResults, accountsToInsert, contactRecords); + + // now update each contact's accountId + applyAccountIdsToContactRecords(contactRecords, accountsToInsert); + } + + private void applyAccountIdsToContactRecords(List contactRecords, List accountsToInsert) { + Integer index = 0; + for (Contact contactRecord : contactRecords) { + contactRecord.AccountId = accountsToInsert[index].Id; + index++; + } + } + + private void applyErrorsToContactRecords(List insertResults, List accountsToInsert, List contactRecords) { + for (Integer index = 0; index < insertResults.size(); index++) { + Database.SaveResult insertResult = insertResults[index]; + if (!insertResult.isSuccess()) { + for (Database.Error err : insertResult.getErrors()) { + accountsToInsert[index].addError(err.getMessage()); + Contact contactRecord = contactRecords[index]; + contactRecord.addError(String.format(System.Label.conFailedAccountCreate, new String[]{ + contactRecord.FirstName, contactRecord.LastName, err.getMessage() + })); + } + } + } + } + + private Map getAccountByPrimaryContactId(List existingContactIds) { + Map accountByPrimaryContactId = new Map(); + for (Account accountRecord : getAccounts(existingContactIds)) { + accountByPrimaryContactId.put( + accountRecord.npe01__One2OneContact__c, accountRecord); + } + return accountByPrimaryContactId; + } + + private Account getBaseOneToOneOrHouseholdAccountFor(Contact contactRecord) { + Account accountRecord = new Account(); + if (UserInfo.isMultiCurrencyOrganization()) { + accountRecord.put('CurrencyIsoCode', + (String) contactRecord.get('CurrencyIsoCode')); + } + + // Always copy the Contact's Mailing & Others address to the new Account's + // Billing & Shipping address. + // NOTE: This does NOT map the Contact's Primary Address Type, since there + // is no equivalent field on the Household/Account. This results in + // Addresses that are created during the new Contact flow not getting the + // correct Address Type. + if (contactRecord.is_Address_Override__c != true) { + addressServiceInstance.copyAddressStdSObj(contactRecord, 'Mailing', + accountRecord, 'Billing'); + addressServiceInstance.copyAddressStdSObj(contactRecord, 'Other', + accountRecord, 'Shipping'); + } + + accountRecord.Phone = contactRecord.Phone; + accountRecord.Fax = contactRecord.Fax; + accountRecord.OwnerId = contactRecord.OwnerId; + accountRecord.npe01__SYSTEMIsIndividual__c = true; + return accountRecord; + } + + private void configureOneToOneAccount(Account accountRecord, Contact contactRecord) { + accountRecord.Name = + Households.strNameOne2OneAccountForContact(contactRecord); + accountRecord.npe01__SYSTEM_AccountType__c = + CAO_Constants.ONE_TO_ONE_ORGANIZATION_TYPE; + accountRecord.Type = ''; + + if (ContactAndOrgSettings.rtIdForAccountModel(false) != null) { + accountRecord.put('RecordTypeID', + ContactAndOrgSettings.rtIdForAccountModel(false)); + } + } + + private void configureHouseholdAccount(Contact contactRecord, Account accountRecord) { + if (householdSettings.isAdvancedHouseholdNaming()) { + // Generate and set the Name + Greetings + HouseholdName householdName = + new HouseholdName(new HouseholdMembers(new List{ + contactRecord + })); + accountRecord.Name = householdName.asName(); + accountRecord.npo02__Formal_Greeting__c = + householdName.asFormalGreeting(); + accountRecord.npo02__Informal_Greeting__c = + householdName.asInformalGreeting(); + } else { + accountRecord.Name = + Households.strNameHHAccountForContact(contactRecord); + } + + // Set the member count + accountRecord.Number_of_Household_Members__c = 1; + + // Set the account Type fields + accountRecord.npe01__SYSTEM_AccountType__c = + CAO_Constants.HH_ACCOUNT_TYPE; + accountRecord.Type = CAO_Constants.HH_TYPE; + + // Set the Record Type + if (ContactAndOrgSettings.rtIdForAccountModel(true) != null) { + accountRecord.put('RecordTypeID', + ContactAndOrgSettings.rtIdForAccountModel(true)); + } + } + + private Boolean isOneToOne(Account accountRecord) { + return accountRecord.npe01__SYSTEM_AccountType__c + == CAO_Constants.ONE_TO_ONE_ORGANIZATION_TYPE; + } + + private Boolean isHousehold(Account accountRecord) { + return accountRecord.npe01__SYSTEM_AccountType__c + == CAO_Constants.HH_ACCOUNT_TYPE; + } + + private List getContactIds(List contactsWithoutAccounts) { + List contactIds = new List(); + for (Contact contactRecord : contactsWithoutAccounts) { + if (contactRecord.Id != null) { + contactIds.add(contactRecord.Id); + } + } + return contactIds; + } + + private List getAccounts(List contactIds) { + return [ + SELECT Id, npe01__One2OneContact__c, + npe01__SYSTEM_AccountType__c, + Number_of_Household_Members__c, + (SELECT Id FROM Contacts) + FROM Account + WHERE npe01__One2OneContact__c IN :contactIds + ]; + } +} \ No newline at end of file diff --git a/src/force-app/HouseholdService.cls-meta.xml b/src/force-app/HouseholdService.cls-meta.xml new file mode 100644 index 0000000..f928c8e --- /dev/null +++ b/src/force-app/HouseholdService.cls-meta.xml @@ -0,0 +1,5 @@ + + + 53.0 + Active +