Skip to content

Commit

Permalink
Introduce Account-level matching & mapping to allow supporting Accoun…
Browse files Browse the repository at this point in the history
…t-level fields on PersonAccounts
  • Loading branch information
jbachelet-osf committed Apr 29, 2024
1 parent 788a4e2 commit 3509b6c
Show file tree
Hide file tree
Showing 55 changed files with 1,547 additions and 395 deletions.
30 changes: 28 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ Salesforce B2C Commerce / CRM Sync is an enablement solution designed by Salesfo

b2c-crm-sync includes a framework for integrating these clouds (ex. B2C Commerce and Service Cloud) -- leveraging REST APIs and the declarative capabilities of the Salesforce Platform. This approach powers frictionless customer experiences across B2C Commerce, Service, and Marketing Clouds by resolving and synchronizing customer profiles across these Salesforce products.

> :100:  This repository is currently in it's **v3.0.0** release. The MVP feature-set is complete, and you can now deploy b2c-crm-sync to scratchOrgs and sandboxes via its CLI tooling. Solution trustworthiness is critical for our success. Please use the tagged release, but also feel free to deploy from master if you want to work with the latest updates.  :100:
> :100:  This repository is currently in it's **v3.0.0** release. You can now deploy b2c-crm-sync to scratchOrgs and sandboxes via its CLI tooling. Solution trustworthiness is critical for our success. Please use the tagged release, but also feel free to deploy from master if you want to work with the latest updates.  :100:
Please visit our [issues-list](https://github.com/SalesforceCommerceCloud/b2c-crm-sync/issues) to see outstanding issues and features, and visit our [discussions](https://github.com/SalesforceCommerceCloud/b2c-crm-sync/discussions) to ask questions.

Expand All @@ -24,6 +24,12 @@ b2c-crm-sync enables the resolution, synchronization, viewing, and management of
b2c-crm-sync leverages Salesforce B2C Commerce Open Commerce REST APIs to interact with B2C Customer Profiles -- and a Salesforce Platform REST API to 'announce' when shoppers register or modify B2C Commerce Customer Profiles. Through these announcements, the Salesforce Platform requests the identified data objects (ex. customers) via REST APIs -- and then ingests elements of those data objects to create Account / Contact or PersonAccount representations of B2C Commerce Customer Profiles.

Please find hereafter diagrams that shows how b2c-crm-sync is synching customer profile profiles in a bidirectional way:

![B2C To Core Diagram](docs/imgs/B2CtoCore.png "B2C To Core Diagram")

![Core To B2C Diagram](docs/imgs/CoretoB2C.png "Core To B2C Diagram")

### License
This project, its source code, and sample assets are all licensed under the [BSD 3-Clause](License.md) License.

Expand Down Expand Up @@ -56,6 +62,20 @@ b2c-crm-sync supports the following extensible features (yes, you can customize

> We leverage [Salesforce SFDX for Deployment](https://trailhead.salesforce.com/content/learn/modules/sfdx_app_dev), [Flow for Automation](https://trailhead.salesforce.com/en/content/learn/modules/flow-builder), [Platform Events for Messaging](https://trailhead.salesforce.com/en/content/learn/modules/platform_events_basics), [Salesforce Connect for Data Federation](https://trailhead.salesforce.com/en/content/learn/projects/quickstart-lightning-connect), and [Apex Invocable Actions](https://trailhead.salesforce.com/en/content/learn/projects/quick-start-explore-the-automation-comps-sample-app) to support these features. If you're a B2C Commerce Architect interested in learning how to integrate with the Salesforce Platform -- this is the project for you :)
### Account-level attribute matching & mapping

Starting since v4.0.0, we introduced a new level of data matching and data mapping between B2C Commerce and the Salesforce Core Platform.
As some business requirements make fields declared at the Account level within the Core platform, and some other fields declared at the Contact level, we introduced a new level of data mapping that allows you to map fields from B2C Commerce to either the Account or the Contact level within the Core Platform.
Of course, this new feature makes way more sense when you are using PersonAccounts within the Salesforce Core Platform, as both the Account & Contact records are merged under the hood.

As a schema is always easier to understand than a thousand words, please have a look at the following schema to understand how the B2C Commerce Customer Profile data is matched into the Core Platform:

![B2C To Core - Account Level Mapping Diagram](docs/imgs/B2CtoCore-AccountLevelMapping.png "B2C To Core - Account Level Mapping Diagram")

In order to configure account-level mapping attributes, please create a new `B2C_Integration_Field_Mappings` custom metadata with the value `Account` into the `Service_Cloud_Object__c` field.

> Please note that due to this new feature: the Contact duplicate rule is not not used anymore if you enable the `PersonAccount` model. It is only used by the unit tests, and thus is still required to be enabled. This happens because, if you are enabling the `PersonAccount` and have at least one account-level mapping, then b2c-crm-sync is using a person account record instead of a contact to resolve the customer profile and map data to it.
## Setup Guidance

### Deployment Considerations
Expand Down Expand Up @@ -969,6 +989,12 @@ npm run crm-sync:sf:connectedapps
```
This command creates a connectedApp for each of the B2C Commerce storefronts configured in your .env file. The B2C Commerce service definitions used to connect with your Salesforce Org use these connectedApps to connect securely.

> b2c-crm-sync use Username-password flows, which are blocked by default in orgs created in Summer ‘23 or later. make sure to activate it if it's not activated already
- Go to settings
- in the search, look for OAuth
- select OAuth and OpenID Connect Settings under identity
- Turn on `Allow OAuth Username-Password Flows`

#### Create and Deploy Your Duplicate Rules
16. Duplicate rules can be configured and deployed via a CLI command that retrieves the duplicateRules configuration in the Salesforce Org, identifies which b2c-crm-sync rules already exist, and creates the rule templates to deploy. Please execute this CLI command to create and deploy duplicateRules:

Expand Down Expand Up @@ -1440,7 +1466,7 @@ As the B2C Commerce customer profile and its addresses are fetched by the core p
3. In the Quick Find box at the top left, search for `Custom Metadata` and click on this menu
4. Click on the `Manage records` on the row `B2C Integration Field Mapping`
5. You'll find all the data mapping here, that you can modify, remove, or add new attributes as part of the data mapping.

6. Please note that the data mapping provided here is based on standard fields. It is up to you to add/remove/update mappings based on the business requirements.

| Label | Core Object | Core ID | Core Alt ID | B2C Object | OCAPI ID |
|:-------------------:|:----------------------:|:--------------------------:|:---------------------------:|:---------------:|:-------------------:|
Expand Down
Binary file added docs/imgs/B2CtoCore-AccountLevelMapping.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/imgs/B2CtoCore.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/imgs/CoretoB2C.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,9 @@ Customer.prototype = {
getRequestBody: function (profileDetails) {
return JSON.stringify({
inputs: [{
sourceContact: profileDetails || this.profileRequestObjectRepresentation
sourceInput: {
jsonRepresentation: JSON.stringify(profileDetails || this.profileRequestObjectRepresentation)
}
}]
});
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ public abstract with sharing class B2CBaseAttributeAssignment {
if (sourceObject.isSet(objectFieldName) && sourceObject.get(objectFieldName) != null) {

// If so, then evaluate if the targetObject is missing this field -- or it has it, and the value is null
if (!targetObjectFields.contains(objectFieldName) || (targetObjectFields.contains(objectFieldName) && targetObject.get(objectFieldName) == null)) {
if (doesFieldExist(getSchemaMap(targetObject), objectFieldName) && (!targetObjectFields.contains(objectFieldName) || (targetObjectFields.contains(objectFieldName) && targetObject.get(objectFieldName) == null))) {

// If the field exists, see if it has been set in the target object
if (targetObject.get(objectFieldName) == null) {
Expand Down Expand Up @@ -205,6 +205,42 @@ public abstract with sharing class B2CBaseAttributeAssignment {

}

/**
* @description This method translates a Contact into a Person Account. It does this by leveraging the
* field mappings for the contact and account objects.
*
* @param contact {SObject} Represents the contact to translate
* @param contactFieldMappings {List<B2C_Integration_Field_Mappings__mdt>} Represents the collection of field mappings to leverage for the contact
* @param accountFieldMappings {List<B2C_Integration_Field_Mappings__mdt>} Represents the collection of field mappings to leverage for the account
* @return {Account} Returns the translated contact into a Person Account
*/
public static Account translateContactToPersonAccount(Contact contact, List<B2C_Integration_Field_Mappings__mdt> contactFieldMappings, List<B2C_Integration_Field_Mappings__mdt> accountFieldMappings) {
// Person Account Record Type
RecordType rt = [SELECT Id, DeveloperName FROM RecordType WHERE DeveloperName = :B2CConfigurationManager.getPersonAccountRecordTypeDeveloperName() WITH SECURITY_ENFORCED];
Account a = new Account(
RecordTypeId = rt.Id
);
Map<String, Object> populatedFields = contact.getPopulatedFieldsAsMap();

// Loop over the contact fieldMappings and evaluate each field
for (B2C_Integration_Field_Mappings__mdt thisFieldMapping : contactFieldMappings) {
// Compare the attribute values for the original and processed objects
if (doesFieldExist(getSchemaMap(a), thisFieldMapping.Service_Cloud_Attribute_Alt__c) && doesFieldExist(getSchemaMap(contact), thisFieldMapping.Service_Cloud_Attribute__c) && populatedFields.containsKey(thisFieldMapping.Service_Cloud_Attribute__c)) {
a.put(thisFieldMapping.Service_Cloud_Attribute_Alt__c, contact.get(thisFieldMapping.Service_Cloud_Attribute__c));
}
}

// Loop over the account fieldMappings and evaluate each field
for (B2C_Integration_Field_Mappings__mdt thisFieldMapping : accountFieldMappings) {
// Compare the attribute values for the original and processed objects
if (doesFieldExist(getSchemaMap(a), thisFieldMapping.Service_Cloud_Attribute__c) && doesFieldExist(getSchemaMap(contact), thisFieldMapping.Service_Cloud_Attribute__c) && populatedFields.containsKey(thisFieldMapping.Service_Cloud_Attribute__c)) {
a.put(thisFieldMapping.Service_Cloud_Attribute__c, contact.get(thisFieldMapping.Service_Cloud_Attribute__c));
}
}

return a;
}

/**
* @description This method compares the "before" and "after" version of a processed sObject and evaluates
* if any updates were made to the record. It does this by iterating over the collection of field mappings
Expand Down
2 changes: 2 additions & 0 deletions src/sfdc/base/main/default/classes/B2CConstant.cls
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ public with sharing class B2CConstant {
'[{0}]; please verify that this storefront is defined and active.',
ERRORS_META_CONTACTNOTFOUND = '--> B2C MetaData --> No Contact found mapped to Id [{0}]; please verify that ' +
'this Contact record is defined.',
ERRORS_META_ACCOUNTNOTFOUND = '--> B2C MetaData --> No Account found mapped to Id [{0}]; please verify that ' +
'this Account record is defined.',

// Define the account / contact short-hand model names and mapping objects
ACCOUNTCONTACTMODEL_STANDARD = 'Standard',
Expand Down
149 changes: 149 additions & 0 deletions src/sfdc/base/main/default/classes/B2CContactAccountManager.cls
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
/**
* @author Abraham David Lloyd
* @date February 11th, 2021
*
* @description This class is used to retrieve B2C Commerce customer data and details
* from custom object definitions. Each customer should also have an associated
* default customerList.
*/
public with sharing class B2CContactAccountManager extends B2CBaseMeta {

/**
* @description Attempts to retrieve a Contact configured via custom objects.
*
* @param contactId {String} Describes the Contact identifier used to retrieve a given definition
* @param returnEmptyObject {Boolean} Describes if an empty sObject should be returned if no results are found
* @param fieldMappings {List<B2C_Integration_Field_Mappings__mdt>} Represents the fieldMappings
* @return {Account} Returns an instance of a Contact
*/
public static Account getAccountById(
String accountId, Boolean returnEmptyObject, List<B2C_Integration_Field_Mappings__mdt> fieldMappings
) {

// Initialize local variables
List<Account> accounts;
String errorMsg;
Query accountQuery;
Account output;

// Default the error message
errorMsg = B2CConstant.buildErrorMessage(B2CConstant.ERRORS_META_ACCOUNTNOTFOUND, accountId);

// Seed the default query structure to leverage
accountQuery = getDefaultQuery(fieldMappings);

// Define the record limit for the query
accountQuery.setLimit(1);

// Define the default where-clause for the query
accountQuery.addConditionEq('Id', accountId);

// Execute the query and evaluate the results
accounts = accountQuery.run();

// Process the return results in a consistent manner
output = (Account)processReturnResult('Account', returnEmptyObject, accounts, errorMsg);

// Return the customerList result
return output;

}

/**
* @description Helper function that takes an existing contact, and fieldMappings -- and creates an
* object representation only containing mapped B2C Commerce properties that can be updated via the
* OCAPI Data REST API.
*
* @param customerProfile {Account} Represents the account being processed for B2C Commerce updates
* @param fieldMappings {List<B2C_Integration_Field_Mappings__mdt>} Represents the collection of
* fieldMappings being evaluated
* @return {Map<String, Object>} Returns an object representation of the properties to update
*/
public static Map<String, Object> getPublishProfile(
Account customerProfile, List<B2C_Integration_Field_Mappings__mdt> fieldMappings, Map<String, Object> contactBasedMap
) {

// Initialize local variables
Map<String, Object> output;
List<String> deleteNode;
Object accountPropertyValue;
String oCAPISubKey;

// Initialize the output map
output = contactBasedMap.clone();
deleteNode = new List<String>();

// Attach the contact and account Ids to the profile
if (!contactBasedMap.containsKey('c_b2ccrm_accountId')) {
output.put('c_b2ccrm_accountId', customerProfile.Id);
}

// Loop over the collection of field mappings
for (B2C_Integration_Field_Mappings__mdt thisFieldMapping: fieldMappings) {
// Ensure contact-based mapping has priority on account-based fields
if (output.containsKey(thisFieldMapping.B2C_Commerce_OCAPI_Attribute__c)) {
continue;
}

// Create a reference to the property value for this contact
accountPropertyValue = customerProfile.get(thisFieldMapping.Service_Cloud_Attribute__c);

// Is this property empty and is this not a child node? If so, then add it to the delete node
if (accountPropertyValue == null && !thisFieldMapping.B2C_Commerce_OCAPI_Attribute__c.contains('.')) {

// If so, then add it to the delete node (fields to clear out)
deleteNode.add(thisFieldMapping.B2C_Commerce_OCAPI_Attribute__c);

} else {

// Otherwise, attach the OCAPI property value to the object root
output.put(thisFieldMapping.B2C_Commerce_OCAPI_Attribute__c, accountPropertyValue);

}

}

// Do we have properties to delete? If so, then include it in the output
if (deleteNode.size() > 0) {
if (!contactBasedMap.containsKey('_delete')) {
contactBasedMap.put('_delete', new List<String>());
}

((List<String>)contactBasedMap.get('_delete')).addAll(deleteNode);
}

// Returns the output collection
return output;

}

/**
* @description Helper method that provides a consistent set of columns to leverage
* when selecting sObject data via SOQL
*
* @param fieldMappings {List<B2C_Integration_Field_Mappings__mdt>} Represents the fieldMappings
* @return {Query} Returns the query template to leverage for customerLists
*/
private static Query getDefaultQuery(List<B2C_Integration_Field_Mappings__mdt> fieldMappings) {

// Initialize local variables
Query accountQuery;

// Create the profile query that will be used to drive resolution
accountQuery = new Query('Account');

// Add the base fields to retrieve (identifiers first)
accountQuery.selectField('Id');

// Iterate over the field mappings and attach the mapped fields to the query
for (B2C_Integration_Field_Mappings__mdt thisFieldMapping: fieldMappings) {

// Add the Salesforce Platform attribute to the query
accountQuery.selectField(thisFieldMapping.Service_Cloud_Attribute__c);

}

// Return the default query structure
return accountQuery;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<ApexClass xmlns="http://soap.sforce.com/2006/04/metadata">
<apiVersion>54.0</apiVersion>
<status>Active</status>
</ApexClass>
Loading

0 comments on commit 3509b6c

Please sign in to comment.