Skip to content

Class Name: AccountTriggerHandler

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

API Name: AccountTriggerHandler Type: Trigger Handler Test Coverage: AccountTriggerHandlerTest.cls Author: Unknown Created: Unknown

Business Purpose

This trigger handler manages Account record automation for AANP's member and business account lifecycle. It generates unique PersonId and BusinessId values for account identification, maintains phone field defaults, synchronizes account data with related User records, and triggers auto-renewal processing workflows. The handler ensures data consistency between accounts and users while managing the complex relationships required for member portal authentication and membership management.

Class Overview

Scope and Sharing

  • Sharing Model: WITHOUT SHARING - Bypasses all record-level security
  • Access Modifier: public
  • Interfaces Implemented: None

CRITICAL SECURITY NOTE: Uses 'without sharing' which bypasses record-level security. All user updates execute with system privileges.

Key Responsibilities

  • Generate sequential PersonId for person accounts (3000000+)
  • Generate sequential BusinessId for business accounts (6000000+)
  • Set default phone number and type for accounts without phones
  • Synchronize account fields to related User records
  • Queue asynchronous user updates via AccountUpdateUserQueueable
  • Detect auto-renewal flag changes and trigger processing
  • Handle test execution with random IDs

Public Methods

accountBeforeInsert

public static void accountBeforeInsert(List<Account> newAccounts)

Purpose: Before insert trigger handler that assigns unique PersonId/BusinessId and ensures all accounts have phone numbers.

Parameters: - newAccounts (List): Trigger.new from Account trigger

Returns: void (modifies accounts in place)

Business Logic:

  1. Generate Random Test ID:
    Integer randomSevenDigit = Math.round(Math.random() * 6000000) + 3000000;
    
  2. Creates 7-digit random number between 3000000-9000000
  3. Used only in tests to avoid query conflicts

  4. Get Last PersonId:

    List<Account> personAccounts = [
        SELECT Id, Name, IsPersonAccount, PersonId__c, BusinessId__c, Phone 
        FROM Account 
        WHERE IsPersonAccount = true 
          AND PersonId__c != NULL 
          AND PersonId__c != 'NULL' 
        ORDER BY PersonId__c DESC 
        LIMIT 1
    ];
    
    Integer lastPersonId = 3000000;
    if(Test.isRunningTest()){
        lastPersonId = randomSevenDigit;
    } else {
        if (!personAccounts.isEmpty() && String.isNotBlank(personAccounts[0].PersonId__c)) {
            lastPersonId = Integer.valueOf(personAccounts[0].PersonId__c);
        }
    }
    

  5. Queries highest existing PersonId
  6. Defaults to 3000000 if none exist
  7. Uses random in tests to avoid conflicts

  8. Get Last BusinessId:

    List<Account> businessAccounts = [
        SELECT Id, Name, IsPersonAccount, PersonId__c, BusinessId__c, Phone 
        FROM Account 
        WHERE IsPersonAccount != true 
          AND BusinessId__c != NULL 
          AND BusinessId__c != 'NULL' 
        ORDER BY BusinessId__c DESC 
        LIMIT 1
    ];
    Integer lastBusinessId = 6000000;
    if(Test.isRunningTest()){
        lastBusinessId = randomSevenDigit;
    } else {
        if (!businessAccounts.isEmpty() && String.isNotBlank(businessAccounts[0].BusinessId__c)) {
            lastBusinessId = Integer.valueOf(businessAccounts[0].BusinessId__c);
        }
    }
    

  9. Queries highest existing BusinessId
  10. Defaults to 6000000 if none exist

  11. Assign IDs and Phone Defaults:

    for (Account acc : newAccList) {
        if (acc.IsPersonAccount == true) {
            lastPersonId++;
            acc.PersonId__c = String.valueOf(lastPersonId);
        } else {
            lastBusinessId++;
            acc.BusinessId__c = String.valueOf(lastBusinessId);
        }
        if(acc.Phone == null || acc.Phone == ''){
            acc.Phone_Type__c = 'Home';
            acc.Phone = '+10000000000';
        }
    }
    

  12. Increments and assigns PersonId or BusinessId
  13. Sets default phone '+10000000000' with type 'Home' if empty

Issues: - CONCURRENCY: Sequential ID assignment not safe for concurrent inserts - Test Logic: Test.isRunningTest() in production code - Default Phone: Fake number '+10000000000' added to all accounts without phones - String Comparison: PersonId__c != 'NULL' compares to string 'NULL' (unusual)


accounAfterUpdate

public static void accounAfterUpdate(Map<Id, Account> oldAccountMap, Map<Id, Account> newAccountMap)

Purpose: After update trigger handler that synchronizes account changes to related Users and triggers auto-renewal processing.

Parameters: - oldAccountMap (Map): Trigger.oldMap - newAccountMap (Map): Trigger.newMap

Returns: void

Business Logic:

  1. Check Auto-Renewal Changes:
    autoRenewalCheck(oldAccountMap, newAccountMap);
    
  2. Calls helper to queue auto-renewal processing

  3. Query Related Users:

    List<User> allUserList = [SELECT Id,AccountId, ContactId,PersonAccountId__c,Dues_Paid_Thru__c,
                                Is_Member__c, MemberType__c, Fellow__c,CompanyAdmin__c, BoardMember__c,Staterep__c,Staff__c
                                FROM User 
                                WHERE AccountId IN: newAccountMap.keySet()];
    if(allUserList.isEmpty()){ return; }
    

  4. Queries all users related to updated accounts
  5. Returns early if no users found

  6. Check Queueable Limit:

    if(Limits.getQueueableJobs() >= Limits.getLimitQueueableJobs()){
        System.debug('Queuable job already executed from this trigger');
        return;
    }
    

  7. Prevents exceeding queueable job limit
  8. Silently skips user updates if limit reached (RISK)

  9. Detect Changes and Build Update List:

    List<User> usersToUpdate = new List<User>();
    for(User user: allUserList){
        Account currentAccount = newAccountMap.get(user.AccountId);
        if(user.Dues_Paid_Thru__c != (String.valueOf(currentAccount.Membership_End_Date__c) != '' ? String.valueOf(currentAccount.Membership_End_Date__c) : '') ||
            user.MemberType__c != (currentAccount.Is_Member__c ? currentAccount.Member_Type__c : 'None') ||
            user.Fellow__c != String.valueOf(currentAccount.Is_a_Fellow__c).toUpperCase() ||
            user.Is_Member__c != String.valueOf(currentAccount.Is_Member__c).toUpperCase() ||
            user.PersonAccountId__c != currentAccount.PersonId__c ||
            user.CompanyAdmin__c != String.valueOf(currentAccount.Is_Company_Admin__c).toUpperCase() ||
            user.BoardMember__c != String.valueOf(currentAccount.Is_BoardMember__c).toUpperCase() ||
            user.Staterep__c != String.valueOf(currentAccount.Is_Staterep__c).toUpperCase() ||
            user.Staff__c != String.valueOf(currentAccount.Member_Type__c == 'Staff').toUpperCase()
        ){
            user.PersonAccountId__c = currentAccount.PersonId__c;
            user.Dues_Paid_Thru__c = String.valueOf(currentAccount.Membership_End_Date__c);
            user.Is_Member__c = String.valueOf(currentAccount.Is_Member__c).toUpperCase();
            user.MemberType__c = currentAccount.Is_Member__c ? currentAccount.Member_Type__c : 'None';
            user.Fellow__c = String.valueOf(currentAccount.Is_a_Fellow__c).toUpperCase();
            user.CompanyAdmin__c = String.valueOf(currentAccount.Is_Company_Admin__c).toUpperCase();
            user.BoardMember__c = String.valueOf(currentAccount.Is_BoardMember__c).toUpperCase();
            user.Staterep__c = String.valueOf(currentAccount.Is_Staterep__c).toUpperCase();
            user.Staff__c = String.valueOf(currentAccount.Member_Type__c == 'Staff').toUpperCase();
            usersToUpdate.add(user);
        }
    }
    

  10. Compares 9 fields between User and Account
  11. Only adds to update list if any field changed

  12. Queue User Updates:

    if (usersToUpdate.isEmpty()) { return ;}
    System.AsyncOptions queueableOpts = new System.AsyncOptions();
    queueableOpts.DuplicateSignature =  QueueableDuplicateSignature.Builder()
                                        .addString('AccountUpdateUserQueueable')
                                        .build();
    System.enqueueJob(new AccountUpdateUserQueueable(usersToUpdate),queueableOpts);
    

  13. Enqueues AccountUpdateUserQueueable with duplicate signature
  14. Prevents duplicate jobs for same operation

Issues: - LONG CONDITION: Line 86-94 has 9-condition OR statement (hard to read/maintain) - SILENT FAILURE: Returns silently if queueable limit reached (users not updated) - NO ERROR HANDLING: No try-catch, errors bubble up - STRING CONVERSIONS: Multiple String.valueOf().toUpperCase() conversions (inefficient)


autoRenewalCheck

public static void autoRenewalCheck(Map<Id, Account> oldAccountMap, Map<Id, Account> newAccountMap)

Purpose: Detects Auto_Renew__c flag changes and queues auto-renewal processing.

Parameters: - oldAccountMap (Map): Old account values - newAccountMap (Map): New account values

Returns: void

Business Logic:

for(Id accId : newAccountMap.keySet()){
    Account act = newAccountMap.get(accId);
    Account priorAct = oldAccountMap.get(accId);
    if(act.Auto_Renew__c && act.Auto_Renew__c != priorAct.Auto_Renew__c){
        System.enqueueJob(new I2C_QueueableAutoRenewChanges(accId));
    }
}

  • Loops through updated accounts
  • Checks if Auto_Renew__c changed to true
  • Queues I2C_QueueableAutoRenewChanges for processing

Issues: - Only triggers when Auto_Renew__c changes to true (not false) - No queueable limit check - Could queue many jobs if bulk account update


Private/Helper Methods

None - All logic in public methods.

Could Be Enhanced With: - getNextPersonId() - Extract ID generation logic - getNextBusinessId() - Extract ID generation logic - buildUserUpdateList(users, accounts) - Extract comparison logic - shouldUpdateUser(user, account) - Extract condition check


Dependencies

Apex Classes

AccountUpdateUserQueueable - Purpose: Performs asynchronous user updates - Called from: accounAfterUpdate() - Criticality: HIGH - User sync depends on this

I2C_QueueableAutoRenewChanges - Purpose: Processes auto-renewal changes - Called from: autoRenewalCheck() - Criticality: HIGH - Auto-renewal depends on this

Salesforce Objects

Account (Standard) - Fields read/written: - PersonId__c, BusinessId__c (custom) - IsPersonAccount - Phone, Phone_Type__c - Membership_End_Date__c, Member_Type__c - Is_Member__c, Is_a_Fellow__c - Is_Company_Admin__c, Is_BoardMember__c, Is_Staterep__c - Auto_Renew__c - Purpose: Core object being triggered

User (Standard) - Fields updated: - PersonAccountId__c, Dues_Paid_Thru__c - Is_Member__c, MemberType__c, Fellow__c - CompanyAdmin__c, BoardMember__c, Staterep__c, Staff__c - Purpose: Keep user data synchronized with account


Design Patterns

  • Handler Pattern: Trigger handler separates logic from trigger
  • Sequential ID Pattern: Generates unique IDs sequentially
  • Queueable Pattern: Async user updates via queueable
  • Change Detection Pattern: Only processes changed fields
  • Duplicate Signature Pattern: Prevents duplicate queueable jobs

Why These Patterns: - Handler pattern enables testability and organization - Sequential ID provides human-readable identifiers - Queueable prevents mixed DML errors (Account + User) - Change detection optimizes performance - Duplicate signature prevents redundant processing


Governor Limits Considerations

SOQL Queries: 3 (PersonId query, BusinessId query, User query) DML Operations: 0 (modifies Trigger.new, queues async updates) Queueable Jobs: Up to 2 per execution (user update + auto-renewal) CPU Time: Low Heap Size: Low (small queries)

Bulkification: Partial - accountBeforeInsert: Processes all accounts in loop - accounAfterUpdate: Queues single queueable job for all users - autoRenewalCheck: Queues separate job PER account

Async Processing: Yes (queueable jobs)

Governor Limit Risks: - HIGH: autoRenewalCheck queues job per account (bulk update of 100 accounts = 100 jobs) - MEDIUM: Queueable limit check prevents job queuing but silently fails - MEDIUM: Sequential ID queries could have concurrency issues - LOW: User query could return many users for large account updates

Performance Considerations: - Queries for highest ID on every insert (could cache) - User query fetches ALL users for updated accounts - Queueable jobs execute asynchronously

Recommendations: 1. CRITICAL: Batch auto-renewal changes (don't queue per account) 2. HIGH: Add error handling for queueable limit 3. MEDIUM: Cache ID queries within transaction 4. MEDIUM: Add LIMIT to user query


Error Handling

Strategy: Try-catch only in accountBeforeInsert, logs to debug

Logging: - System.debug for errors in accountBeforeInsert - No logging in other methods - Debug logs not visible in production

User Notifications: - None - all errors logged only - User updates fail silently if queueable limit reached

Validation: - None - assumes valid data

Rollback Behavior: - accountBeforeInsert: Error prevents all accounts from inserting - accounAfterUpdate: Account updates succeed, user updates queued separately

Recommended Improvements: 1. HIGH: Add try-catch to all methods 2. HIGH: Log queueable limit failures 3. MEDIUM: Throw exception if critical updates fail 4. MEDIUM: Add validation for PersonId/BusinessId format 5. LOW: Persistent error logging


Security Considerations

Sharing Rules: BYPASSED - Uses 'without sharing' - System-level access to all accounts - Appropriate for trigger handler

Field-Level Security: BYPASSED - Can update any User fields regardless of FLS - Appropriate for system automation

CRUD Permissions: BYPASSED - System context allows all operations

Input Validation: NONE - No validation of account data - Trusts trigger context

Security Risks: - LOW: WITHOUT SHARING appropriate for trigger handler - LOW: No user input (trigger context only)


Test Class

Test Class: AccountTriggerHandlerTest.cls

Test Scenarios That Should Be Covered:

accountBeforeInsert: - Insert person account (PersonId assigned) - Insert business account (BusinessId assigned) - Insert account with phone (unchanged) - Insert account without phone (defaults added) - Bulk insert 200 accounts - Test.isRunningTest() behavior

accounAfterUpdate: - Update account, verify user updated - Update multiple fields, verify all sync - No field changes (no user update) - Queueable limit reached (verify skip) - Account with no users - Bulk update 200 accounts

autoRenewalCheck: - Auto_Renew__c changes from false to true - Auto_Renew__c changes from true to false - Auto_Renew__c stays true - Bulk update with auto-renewal changes

ID Generation: - Sequential PersonId (3000000, 3000001, 3000002) - Sequential BusinessId (6000000, 6000001, 6000002) - Starting from existing highest ID - Concurrent inserts (may have gaps)


Changes & History

  • Created: Unknown
  • Author: Unknown
  • Purpose: Account trigger automation
  • Evolution: Added user sync, auto-renewal, ID generation over time

⚠️ Pre-Go-Live Concerns

CRITICAL - Fix Before Go-Live

  • CONCURRENCY IN ID GENERATION: Sequential ID assignment not safe for concurrent transactions. Two simultaneous inserts could get same ID. Use custom auto-number or lock records.
  • SILENT QUEUEABLE LIMIT FAILURE: Line 77 returns silently if queueable limit reached. Users never updated. Log error, throw exception, or implement retry.
  • AUTO-RENEWAL JOB EXPLOSION: autoRenewalCheck() queues job PER account. Bulk update of 100 accounts = 100 queueable jobs. Batch into single job.
  • TEST LOGIC IN PRODUCTION: Lines 22, 41 use Test.isRunningTest(). Causes test vs production discrepancy. Remove or use dependency injection.

HIGH - Address Soon After Go-Live

  • NO ERROR HANDLING: accounAfterUpdate and autoRenewalCheck have no try-catch. Add error handling.
  • FAKE DEFAULT PHONE: accountBeforeInsert sets Phone = '+10000000000' for all accounts without phones. Use proper default or allow blank.
  • MASSIVE CONDITION: Lines 86-94 have 9-condition OR. Extract to helper method for readability.
  • NO QUEUEABLE LIMIT CHECK IN AUTORENEWAL: autoRenewalCheck doesn't check queueable limits before enqueuing. Add check.

MEDIUM - Future Enhancement

  • USER SYNC EFFICIENCY: Queries ALL users for ALL updated accounts. Only query users for accounts with relevant field changes.
  • ID CACHING: Queries highest ID on every insert. Cache within transaction for bulk inserts.
  • STRING CONVERSIONS: Multiple String.valueOf().toUpperCase() calls. Create helper or reduce conversions.
  • NO FIELD VALIDATION: No validation that PersonId/BusinessId are numeric. Add validation.

LOW - Monitor

  • CODE ORGANIZATION: 135 lines could be split into separate classes (ID generator, user sync, auto-renewal).
  • HARDCODED VALUES: '3000000', '6000000', 'Home', '+10000000000'. Use custom metadata.
  • UNUSED QUERY FIELDS: businessAccounts query selects Phone but doesn't use it.

Maintenance Notes

Complexity: Medium (ID generation, user sync, async processing) Recommended Review Schedule: Quarterly

Key Maintainer Notes:

🚨 CRITICAL CONCURRENCY BUG:

Integer lastPersonId = Integer.valueOf(personAccounts[0].PersonId__c);
for (Account acc : newAccList) {
    lastPersonId++;
    acc.PersonId__c = String.valueOf(lastPersonId);
}
- Scenario: Two users insert accounts simultaneously - Impact: Both query same highest ID, assign duplicates - Fix: Use custom auto-number field or SELECT FOR UPDATE

📋 Usage Patterns: - Called from AccountTrigger on before insert, after update - Processes all person and business account changes - Queues async jobs for user updates and auto-renewal

🧪 Testing Requirements: - Test sequential ID assignment - Test concurrency (difficult) - Test user synchronization - Mock queueable jobs - Verify auto-renewal triggering

🔧 Configuration Dependencies: - PersonId__c, BusinessId__c custom fields - User custom fields (PersonAccountId__c, etc.) - AccountUpdateUserQueueable class - I2C_QueueableAutoRenewChanges class

⚠️ Gotchas and Warnings: - ID assignment not thread-safe - Silent failure if queueable limit reached - Auto-renewal queues job PER account - Default phone '+10000000000' added if blank - Test.isRunningTest() causes test/production difference - Only triggers auto-renewal when changing to true (not false)

📅 When to Review This Class: - IMMEDIATELY: Fix concurrency in ID generation - When ID ranges need adjustment (3M, 6M) - If user sync failures reported - During trigger performance issues - When adding new account/user fields to sync


Business Owner

Primary: IT Operations / Data Management Secondary: Member Operations / User Management Stakeholders: Development, Member Services, Data Quality