Skip to content

Class Name: SubscriptionTriggerHandler

Last Updated: 2025-10-22 Source Code: https://github.com/AANP-IT/I2C.Salesforce.Metadata/blob/STAGING/force-app/main/default/classes/SubscriptionTriggerHandler.cls

API Name: SubscriptionTriggerHandler Type: Trigger Handler Test Coverage: To be determined

Business Purpose

The SubscriptionTriggerHandler class manages subscription-related trigger logic, specifically synchronizing Higher Logic community access based on active subscriptions. This supports: - Automatic community product access when subscriptions are created/updated - Syncing Account.Higher_Logic_Communities__c field with community product subscriptions - Queueable async processing to avoid trigger governor limits - Recursion control via static flag

This ensures that customers automatically gain access to community products they've subscribed to.

Class Overview

  • Author: Not specified
  • Created: Unknown
  • Test Class: SubscriptionTriggerHandlerTest (likely)
  • Scope/Sharing: without sharing - Bypasses record-level security
  • Key Responsibilities:
  • Queue async community sync jobs
  • Update Account.Higher_Logic_Communities__c with community product names
  • Set HL__c checkbox flag when communities exist
  • Prevent recursion with static flag

Security Warning

public without sharing class SubscriptionTriggerHandler

🚨 CRITICAL CONCERN: Uses without sharing which bypasses all record-level security.

Implications: - Can read/write ANY subscription records regardless of user access - Can update ANY account records - Violates principle of least privilege - Should use with sharing unless specific business requirement

Justification Needed: - Document why without sharing is required - Consider if system context is truly necessary - If needed for system automation, add comments explaining why

Static Variables

public static Boolean alreadyRan = false;

Purpose: Prevents recursion when queueing jobs (line 3).

Pattern: Common recursion control pattern in triggers.

Issues/Concerns: - ✅ Recursion Prevention: Prevents infinite loops - ⚠️ Name: alreadyRan is vague - syncCommunitiesExecuted would be clearer - ⚠️ Scope: public allows external code to manipulate flag - Should be private or @TestVisible private

Public Methods

syncCommunities

public static void syncCommunities(Map<Id, Subscription__c> subscriptionNewMap)

Purpose: Queues asynchronous job to sync communities for subscription changes.

Parameters: - subscriptionNewMap (Map) - New/updated subscriptions from trigger

Business Logic (lines 5-12):

if(!alreadyRan){
    alreadyRan = true;
    I2C_QueueableCommunitiesSync q = new I2C_QueueableCommunitiesSync(subscriptionNewMap);
    System.enqueueJob(q);
}

Flow: 1. Check if already executed in this transaction 2. Set flag to true 3. Create queueable job with subscription map 4. Enqueue for async execution

Caller: Likely called from Subscription__c trigger (SubscriptionTrigger).

Issues/Concerns: - ✅ Recursion Control: Prevents multiple queue jobs in same transaction - ✅ Async Pattern: Uses queueable to avoid trigger governor limits - ⚠️ No Error Handling: enqueueJob could fail if queue limit reached - Should wrap in try-catch - ⚠️ No Null Check: Assumes subscriptionNewMap is not null - ⚠️ Flag Never Resets: Once true, stays true for entire transaction - This is correct for trigger context but could cause issues if called multiple times

updateAccountCommunities

public static void updateAccountCommunities(Map<Id, List<Id>> accIdToListProdIdMap, Set<Id> productIdSet)

Purpose: Updates Account.Higher_Logic_Communities__c with community product names from subscriptions.

Parameters: - accIdToListProdIdMap (Map>) - Map of Account Id to List of Product Ids - productIdSet (Set) - Set of all Product Ids to query

Business Logic:

  1. Query Products and Accounts (lines 15-16):
    Map<Id, Product2> productQuery = new Map<Id, Product2>([SELECT Id, Name FROM Product2 WHERE Id IN: productIdSet AND Family = 'Communities']);
    Map<Id, Account> accountQuery = new Map<Id, Account>([SELECT Id, Higher_Logic_Communities__c FROM Account WHERE Id IN: accIdToListProdIdMap.keySet()]);
    
  2. Filters products by Family = 'Communities'
  3. Queries accounts with existing Higher_Logic_Communities__c values

  4. Build Community String (lines 20-38):

    for(Id accId: accIdToListProdIdMap.keySet()){
        Account acc = accountQuery.get(accId);
        Boolean updateCommunities = false;
        acc.Higher_Logic_Communities__c = '';  // ⚠️ Clears existing value!
        for(Id productId: accIdToListProdIdMap.get(accId)){
            Boolean notACommunityProduct = !productQuery.containsKey(productId);
            if(notACommunityProduct){continue;}
    
            String productName = productQuery.get(productId).Name;
            Boolean isCommunitiesEmpty = String.isBlank(acc.Higher_Logic_Communities__c);
            Boolean productNotFoundInString =  isCommunitiesEmpty || !acc.Higher_Logic_Communities__c?.contains(productName);
            if(productNotFoundInString){
                if(isCommunitiesEmpty){
                    acc.Higher_Logic_Communities__c = productName;
                } else {
                    acc.Higher_Logic_Communities__c += ';' + productName;
                }
            }
        }
        acc.HL__c = !String.isBlank(acc.Higher_Logic_Communities__c);
        accToUpdate.add(acc);
    }
    

Key Operations: - Line 23: Clears existing Higher_Logic_Communities__c - Loops through each account's product subscriptions - Skips products not in 'Communities' family - Builds semicolon-separated list of product names - Uses .contains() to check for duplicates - Sets HL__c checkbox flag if communities exist

  1. Update Accounts (lines 43-45):
    if(!accToUpdate.isEmpty()){
        update accToUpdate;
    }
    

Issues/Concerns:

  • 🚨 CRITICAL - Line 23: acc.Higher_Logic_Communities__c = ''; CLEARS EXISTING VALUE
  • This removes all previous communities before rebuilding
  • If accIdToListProdIdMap only contains partial data, existing communities are lost
  • Impact: User could lose access to communities they should have
  • Fix: Only clear if rebuilding complete community list

  • ⚠️ Substring Matching Issue (line 30):

    !acc.Higher_Logic_Communities__c?.contains(productName)
    

  • If productName = "Women" and field contains "Women's Health", contains() returns true
  • Could cause false positive for duplicate detection
  • Fix: Split field by ';' and check for exact matches in list

  • ⚠️ Unused Variable (line 22): Boolean updateCommunities = false; - Never used

  • ⚠️ No Error Handling: Update could fail silently in WITHOUT SHARING context

  • ⚠️ No Transaction Safety: All-or-nothing update, no partial success

  • ⚠️ Field Name: Higher_Logic_Communities__c is multi-select picklist or text field?

  • If picklist: Semicolon-separated is correct
  • If text: Should validate against picklist values

  • Null Safety: Uses safe navigation operator ?. (line 30)

  • Bulk Pattern: Queries and updates in bulk

Caller: I2C_QueueableCommunitiesSync

Based on line 8, this handler is called by I2C_QueueableCommunitiesSync:

I2C_QueueableCommunitiesSync q = new I2C_QueueableCommunitiesSync(subscriptionNewMap);
System.enqueueJob(q);

Expected Flow: 1. Subscription trigger fires (insert/update) 2. Trigger calls SubscriptionTriggerHandler.syncCommunities(Trigger.newMap) 3. Handler queues I2C_QueueableCommunitiesSync job 4. Queueable executes async: - Queries active subscriptions per account - Builds account → product list map - Calls updateAccountCommunities() to sync

Dependencies

Salesforce Objects

  • Subscription__c (Custom Object)
  • Access: Read (from queueable)
  • Used to determine active subscriptions

  • Account (Standard Object)

  • Fields: Id, Higher_Logic_Communities__c, HL__c
  • Access: Read, Update (WITHOUT SHARING)

  • Product2 (Standard Object)

  • Fields: Id, Name, Family
  • Access: Read
  • Filter: Family = 'Communities'

Other Classes

  • I2C_QueueableCommunitiesSync: Queueable job for async processing
  • Constructor: Takes Map
  • Calls: updateAccountCommunities()

Custom Fields

  • Account.Higher_Logic_Communities__c - Semicolon-separated list of community product names
  • Account.HL__c - Checkbox indicating if account has communities

Design Patterns

  1. Static Trigger Handler: Static methods called from trigger
  2. Queueable Pattern: Async processing to avoid trigger limits
  3. Recursion Control: Static boolean flag
  4. Bulk Processing: Handles multiple accounts/products at once
  5. Map-Based Queries: Efficient lookups via Map

Governor Limits Considerations

syncCommunities Method

  • Queueable Jobs: 1 per invocation (limit: 50 per transaction)
  • SOQL Queries: 0 (queries happen in queueable)

updateAccountCommunities Method

  • SOQL Queries: 2 (Product2, Account)
  • DML Statements: 1 (Account update)
  • DML Rows: Number of accounts in map

Scalability

  • Bulk Pattern: Handles multiple accounts at once
  • Queueable: Avoids trigger timeout
  • ⚠️ String Concatenation: Higher_Logic_Communities__c could exceed field length
  • ⚠️ No Chunking: Large account sets could hit heap size limits

Test Class Requirements

@IsTest
public class SubscriptionTriggerHandlerTest {

    @TestSetup
    static void setup() {
        // Create account
        Account acc = TestDataFactory.getAccountRecord(true);

        // Create community products
        Product2 communityProd1 = TestDataFactory.getProduct(true);
        communityProd1.Name = 'Pediatrics Community';
        communityProd1.Family = 'Communities';
        update communityProd1;

        Product2 communityProd2 = TestDataFactory.getProduct(true);
        communityProd2.Name = 'Women\'s Health Community';
        communityProd2.Family = 'Communities';
        update communityProd2;

        // Create subscriptions
        Subscription__c sub1 = TestDataFactory.createSubscription(acc.Id, communityProd1.Id);
        sub1.Status__c = 'Active';
        insert sub1;

        Subscription__c sub2 = TestDataFactory.createSubscription(acc.Id, communityProd2.Id);
        sub2.Status__c = 'Active';
        insert sub2;
    }

    @IsTest
    static void testSyncCommunities() {
        Subscription__c sub = [SELECT Id FROM Subscription__c LIMIT 1];
        Map<Id, Subscription__c> subMap = new Map<Id, Subscription__c>{sub.Id => sub};

        // Reset flag for test
        SubscriptionTriggerHandler.alreadyRan = false;

        Test.startTest();
        SubscriptionTriggerHandler.syncCommunities(subMap);
        Test.stopTest();

        // Verify job was queued
        Assert.isTrue(SubscriptionTriggerHandler.alreadyRan, 'Flag should be set');

        // Check AsyncApexJob
        List<AsyncApexJob> jobs = [
            SELECT Id, Status, ApexClass.Name
            FROM AsyncApexJob
            WHERE ApexClass.Name = 'I2C_QueueableCommunitiesSync'
        ];
        Assert.areEqual(1, jobs.size(), 'Should queue one job');
    }

    @IsTest
    static void testUpdateAccountCommunities() {
        Account acc = [SELECT Id, Higher_Logic_Communities__c, HL__c FROM Account LIMIT 1];
        List<Product2> products = [SELECT Id FROM Product2 WHERE Family = 'Communities'];

        Set<Id> productIdSet = new Set<Id>();
        List<Id> productIdList = new List<Id>();
        for (Product2 p : products) {
            productIdSet.add(p.Id);
            productIdList.add(p.Id);
        }

        Map<Id, List<Id>> accToProductMap = new Map<Id, List<Id>>{
            acc.Id => productIdList
        };

        Test.startTest();
        SubscriptionTriggerHandler.updateAccountCommunities(accToProductMap, productIdSet);
        Test.stopTest();

        Account updatedAcc = [SELECT Id, Higher_Logic_Communities__c, HL__c FROM Account WHERE Id = :acc.Id];

        Assert.isTrue(updatedAcc.HL__c, 'HL checkbox should be checked');
        Assert.isTrue(updatedAcc.Higher_Logic_Communities__c.contains('Pediatrics Community'), 'Should contain first community');
        Assert.isTrue(updatedAcc.Higher_Logic_Communities__c.contains('Women\'s Health Community'), 'Should contain second community');

        // Verify semicolon separator
        List<String> communities = updatedAcc.Higher_Logic_Communities__c.split(';');
        Assert.areEqual(2, communities.size(), 'Should have 2 communities');
    }

    @IsTest
    static void testRecursionPrevention() {
        Subscription__c sub = [SELECT Id FROM Subscription__c LIMIT 1];
        Map<Id, Subscription__c> subMap = new Map<Id, Subscription__c>{sub.Id => sub};

        // Reset flag
        SubscriptionTriggerHandler.alreadyRan = false;

        Test.startTest();
        SubscriptionTriggerHandler.syncCommunities(subMap);
        SubscriptionTriggerHandler.syncCommunities(subMap); // Second call should do nothing
        Test.stopTest();

        // Only one job should be queued
        List<AsyncApexJob> jobs = [
            SELECT Id, Status, ApexClass.Name
            FROM AsyncApexJob
            WHERE ApexClass.Name = 'I2C_QueueableCommunitiesSync'
        ];
        Assert.areEqual(1, jobs.size(), 'Should only queue one job despite two calls');
    }
}

Pre-Go-Live Concerns

🚨 CRITICAL

  • WITHOUT SHARING (line 1): Bypasses all record-level security
  • Document business justification
  • Consider if with sharing is possible
  • If required, add extensive comments explaining why

  • Clears Existing Communities (line 23): acc.Higher_Logic_Communities__c = ''

  • Loses existing communities if map is partial
  • Fix: Only clear if rebuilding complete list, or append/remove specific communities
  • Risk: Data loss, user access revoked incorrectly

HIGH

  • Substring Matching Bug (line 30): .contains() for duplicate check
  • False positives possible (e.g., "Women" matches "Women's Health")
  • Fix: Split by ';' and check for exact matches in list

  • No Error Handling: Updates could fail silently

  • Add try-catch and logging
  • Consider partial success with Database.update(records, false)

MEDIUM

  • Static Flag Scope (line 3): public static Boolean alreadyRan
  • Should be private or @TestVisible private
  • Prevents external manipulation

  • Unused Variable (line 22): updateCommunities never used

  • Remove dead code

  • No Null Checks: Assumes parameters are valid

  • Validate subscriptionNewMap, accIdToListProdIdMap, productIdSet

LOW

  • Variable Naming: alreadyRan is vague
  • Rename to syncCommunitiesExecuted or isQueuedForCommunitySync

Fix 1: Additive Community Sync (Don't Clear Existing)

public static void updateAccountCommunities(Map<Id, List<Id>> accIdToListProdIdMap, Set<Id> productIdSet){
    Map<Id, Product2> productQuery = new Map<Id, Product2>([SELECT Id, Name FROM Product2 WHERE Id IN: productIdSet AND Family = 'Communities']);
    Map<Id, Account> accountQuery = new Map<Id, Account>([SELECT Id, Higher_Logic_Communities__c FROM Account WHERE Id IN: accIdToListProdIdMap.keySet()]);
    List<Account> accToUpdate = new List<Account>();

    for(Id accId: accIdToListProdIdMap.keySet()){
        Account acc = accountQuery.get(accId);

        // Parse existing communities
        Set<String> existingCommunities = new Set<String>();
        if (String.isNotBlank(acc.Higher_Logic_Communities__c)) {
            existingCommunities.addAll(acc.Higher_Logic_Communities__c.split(';'));
        }

        // Add new communities
        for(Id productId: accIdToListProdIdMap.get(accId)){
            if(!productQuery.containsKey(productId)){continue;}
            String productName = productQuery.get(productId).Name;
            existingCommunities.add(productName); // Set automatically handles duplicates
        }

        // Rebuild field
        acc.Higher_Logic_Communities__c = String.join(new List<String>(existingCommunities), ';');
        acc.HL__c = !existingCommunities.isEmpty();
        accToUpdate.add(acc);
    }

    if(!accToUpdate.isEmpty()){
        try {
            update accToUpdate;
        } catch (Exception e) {
            System.debug(LoggingLevel.ERROR, 'Failed to update account communities: ' + e.getMessage());
            // Consider logging to custom object
        }
    }
}

Fix 2: Use WITH SHARING

public with sharing class SubscriptionTriggerHandler {
    @TestVisible
    private static Boolean syncCommunitiesExecuted = false;

    public static void syncCommunities(Map<Id, Subscription__c> subscriptionNewMap){
        if(subscriptionNewMap == null || subscriptionNewMap.isEmpty()) {
            return;
        }

        if(!syncCommunitiesExecuted){
            syncCommunitiesExecuted = true;
            try {
                I2C_QueueableCommunitiesSync q = new I2C_QueueableCommunitiesSync(subscriptionNewMap);
                System.enqueueJob(q);
            } catch (Exception e) {
                System.debug(LoggingLevel.ERROR, 'Failed to enqueue community sync: ' + e.getMessage());
            }
        }
    }
}

Changes & History

Date Author Description
Unknown Original Developer Initial implementation for Higher Logic community sync

Documentation Status: ✅ Complete Code Review Status: 🚨 CRITICAL - Fix data loss bug (line 23), justify WITHOUT SHARING Test Coverage: Test class needed Trigger Integration: Called from Subscription__c trigger Queueable Dependency: I2C_QueueableCommunitiesSync Security Risk: WITHOUT SHARING bypasses all record security