Skip to content

Class Name: DailyMembershipStatusBatch

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

API Name: DailyMembershipStatusBatch Type: Batch (Database.Batchable) Test Coverage: Target 90%+

Business Purpose

The DailyMembershipStatusBatch batch job manages the automated lifecycle transitions of membership records through status stages: Active → Within Grace Period → Expired. This scheduled automation ensures membership status accurately reflects end dates and grace periods without requiring manual intervention.

When memberships expire, this batch: 1. Moves Active memberships past their end date to "Within Grace period" status 2. Moves Grace Period memberships past the grace cutoff to "Expired" status 3. Expires related Subscription__c records when membership expires 4. Chains DailyMembershipAccountsBatch to sync Account fields after status changes

Class Overview

  • Scope/Sharing: with sharing - Respects record-level security
  • Implements: Database.Batchable<SObject>, Database.Stateful
  • Batch Size: Not specified in class (default 200 or configured by caller)
  • Key Responsibilities:
  • Transition Active memberships to Grace Period when End_Date__c < TODAY()
  • Transition Grace Period memberships to Expired when End_Date__c < (TODAY() - graceDays)
  • Expire related Subscription records when membership expires
  • Chain Account refresh batch after completion

Why Database.Stateful?

The Database.Stateful interface preserves the graceDays instance variable across batch chunks. Without this, each chunk would need to re-query the grace period configuration, wasting SOQL queries.

Batch Lifecycle Methods

Constructor

public DailyMembershipStatusBatch(Integer graceDays)

Purpose: Initializes the batch with grace period configuration.

Parameters: - graceDays (Integer) - Number of days after end date before moving to Expired status

Business Logic: - Stores grace period in instance variable (line 8) - Value persists across batch chunks via Database.Stateful - Typically called with: DailyMembershipUtil.getGraceDays()

Example Usage:

Integer grace = DailyMembershipUtil.getGraceDays(); // 30 days
Database.executeBatch(new DailyMembershipStatusBatch(grace), 200);

start()

public Database.QueryLocator start(Database.BatchableContext bc)

Purpose: Builds dynamic SOQL query to find memberships requiring status transitions.

Parameters: - bc (Database.BatchableContext) - Standard batch context

Returns: Database.QueryLocator - Query locator for memberships to process

Business Logic: 1. Date Calculations: - today = Date.today() (line 12) - graceCutoff = today.addDays(-graceDays) (line 13)

  1. Query Criteria (lines 23-36):
  2. Branch 1: Active memberships past end date (except Corporate Council)
    • Status__c = 'Active'
    • End_Date__c < today
    • Member_Type__c != 'Corporate Council Member'
  3. Branch 2: Grace period memberships past grace cutoff

    • Status__c = 'Within Grace period'
    • End_Date__c < graceCutoff
  4. Test Mode Variation (lines 21-36):

  5. Production: Enforces Status__c = 'Active' (line 29)
  6. Test: Omits Active check to allow test data setup flexibility (line 33)

Issues/Concerns: - ⚠️ Test.isRunningTest() in Production Code (line 21): Anti-pattern coupling test logic to production - ⚠️ Corporate Council Exception: These members never transition to Grace/Expired (business rule hardcoded) - ⚠️ No LIMIT Clause: Could process millions of records if memberships expire in bulk - ⚠️ Dynamic SOQL: String concatenation increases complexity (lines 23-36) - ✅ Covers All Scenarios: Queries both Active→Grace and Grace→Expired transitions

Query Examples:

-- Branch 1: Active memberships ending yesterday
-- Status: Active → Within Grace period
SELECT Id, Status__c, End_Date__c, Start_Date__c, Member_Type__c, Subcription__c
FROM Membership__c
WHERE Status__c = 'Active'
  AND End_Date__c < 2025-10-22  -- today
  AND Member_Type__c != 'Corporate Council Member'

-- Branch 2: Grace period memberships past cutoff
-- Status: Within Grace period → Expired
SELECT Id, Status__c, End_Date__c, Start_Date__c, Member_Type__c, Subcription__c
FROM Membership__c
WHERE Status__c = 'Within Grace period'
  AND End_Date__c < 2025-09-22  -- today minus 30 grace days

execute()

public void execute(Database.BatchableContext bc, List<Membership__c> scope)

Purpose: Processes each batch chunk of memberships, updating statuses and related subscriptions.

Parameters: - bc (Database.BatchableContext) - Standard batch context - scope (List) - Memberships in current batch chunk (up to batch size)

Returns: void

Business Logic:

  1. Date Recalculation (lines 47-51):
  2. Recalculates today and graceCutoff for each chunk
  3. Ensures accuracy if batch runs across midnight

  4. Status Transition Logic (lines 53-70):

Transition 1: Active → Within Grace Period (lines 54-56)

IF Status = 'Active'
   AND End_Date < today
   AND Member_Type != 'Corporate Council Member'
THEN
   Status = 'Within Grace period'

Transition 2: Within Grace Period → Expired (lines 57-68)

IF Status = 'Within Grace period'
   AND End_Date < (today - graceDays)
THEN
   Status = 'Expired'
   ALSO: Set related Subscription.Status = 'Expired'

  1. Related Subscription Expiration (lines 65-68):
  2. If membership has Subcription__c lookup populated
  3. Creates minimal Subscription update: {Id: subId, Status__c: 'Expired'}
  4. Separate list for bulk DML

  5. Bulk Updates (lines 72-79):

  6. Updates all modified memberships via DailyMembershipUtil.safeUpdate()
  7. Updates all related subscriptions via DailyMembershipUtil.safeUpdate()
  8. Uses partial-success DML pattern

  9. Debug Logging (lines 80-81):

  10. Logs count of memberships and subscriptions updated

Issues/Concerns: - ⚠️ Duplicate Date Calculation: Recalculates today and graceCutoff every chunk (lines 47-51) - Could use instance variables with Database.Stateful - ⚠️ Typo in Field Name: Subcription__c (missing 'i') - lines 25, 65, 66 - Indicates either typo in object or code - ⚠️ Redundant Logic: Lines 54-56 replicate query WHERE clause logic - Query already filters these records - ⚠️ No Subscription Query: Creates update records without validation subscription exists (line 66) - If lookup is incorrect, update will fail silently in safeUpdate - ⚠️ Corporate Council Members Never Expire: Business rule prevents standard expiration flow - ✅ Bulk DML Pattern: Collects all updates before single DML operation - ✅ Partial Success: Uses safeUpdate() to continue processing after individual failures

Data Flow Example:

// Input scope (2 memberships)
[
  {Id: '001', Status: 'Active', End_Date: '2025-10-20', Subcription__c: null},
  {Id: '002', Status: 'Within Grace period', End_Date: '2025-09-15', Subcription__c: 'sub123'}
]

// Today: 2025-10-22, Grace Days: 30, Grace Cutoff: 2025-09-22

// Processing:
// 001: Active, End_Date (Oct 20) < Today (Oct 22) → 'Within Grace period'
// 002: Within Grace, End_Date (Sep 15) < Grace Cutoff (Sep 22) → 'Expired'

// toUpdateMs = [
//   {Id: '001', Status__c: 'Within Grace period'},
//   {Id: '002', Status__c: 'Expired'}
// ]

// toUpdateSubs = [
//   {Id: 'sub123', Status__c: 'Expired'}
// ]

finish()

public void finish(Database.BatchableContext bc)

Purpose: Chains the DailyMembershipAccountsBatch to refresh Account membership fields after status updates.

Parameters: - bc (Database.BatchableContext) - Standard batch context

Returns: void

Business Logic: 1. Batch Chaining (lines 86-89): - Executes DailyMembershipAccountsBatch with batch size 200 - Ensures Account fields sync with new membership statuses - No delay between batches (immediate execution)

Issues/Concerns: - ⚠️ No Error Handling: If batch fails to queue, no fallback or notification - ⚠️ Hardcoded Batch Size: Uses 200 (could be configurable) - ⚠️ No Conditional Logic: Always chains batch even if no memberships updated - ✅ Proper Sequencing: Ensures Account updates happen after Membership updates

Recommended Improvements:

public void finish(Database.BatchableContext bc) {
    AsyncApexJob job = [
        SELECT Status, NumberOfErrors, JobItemsProcessed
        FROM AsyncApexJob
        WHERE Id = :bc.getJobId()
    ];

    if (job.NumberOfErrors > 0) {
        System.debug(LoggingLevel.WARN, 'DailyMembershipStatusBatch completed with ' + job.NumberOfErrors + ' errors');
    }

    // Chain account refresh
    try {
        Database.executeBatch(new DailyMembershipAccountsBatch(), 200);
    } catch (Exception e) {
        System.debug(LoggingLevel.ERROR, 'Failed to chain DailyMembershipAccountsBatch: ' + e.getMessage());
    }
}

Dependencies

Salesforce Objects

  • Membership__c (Custom Object)
  • Fields: Id, Status__c, End_Date__c, Start_Date__c, Member_Type__c, Subcription__c
  • Access: Read (start query), Write (status updates)
  • Transitions: Active → Within Grace period → Expired
  • Subscription__c (Custom Object)
  • Fields: Id, Status__c
  • Access: Write (status set to Expired)
  • Relationship: Looked up from Membership__c.Subcription__c

Custom Settings/Metadata

  • Environment_Settings__mdt: Grace period configuration (accessed via DailyMembershipUtil.getGraceDays())

Other Classes

  • DailyMembershipUtil:
  • safeUpdate() - Partial-success DML with error logging
  • DailyMembershipAccountsBatch:
  • Chained in finish() to sync Account fields

External Services

  • None

Design Patterns

  1. Batch Apex Pattern: Processes large datasets in chunks
  2. Stateful Batch: Preserves graceDays across chunks via Database.Stateful
  3. Batch Chaining: Sequences dependent batch jobs in finish()
  4. Partial Success DML: Uses safeUpdate() for resilient updates
  5. Separation of Concerns: Status updates separated from Account field sync

Governor Limits Considerations

Current Impact (Per Chunk)

  • SOQL Queries: 0 (query in start method)
  • DML Statements: 2 (memberships + subscriptions via safeUpdate)
  • DML Rows:
  • Memberships: Up to batch size (default 200)
  • Subscriptions: Variable (only memberships with Subcription__c populated)
  • Heap Size: Minimal (small object updates)

Scalability Analysis

  • QueryLocator Pattern: Handles up to 50 million records
  • Bulk DML: Processes all scope records in single DML operation
  • ⚠️ No Governor Limit Checks: Assumes batch size keeps within limits
  • Stateful Optimization: Avoids re-querying grace days per chunk

Batch Size Recommendations

  • Standard: 200 (current implementation)
  • Large Volumes: 100 if many subscriptions need updating (doubles DML rows)
  • Small Volumes: 500 if most memberships have no subscriptions

Scheduling Recommendations

// Schedule daily at 1 AM
String cronExp = '0 0 1 * * ?';
System.schedule(
    'Daily Membership Status Update',
    cronExp,
    new DailyMembershipScheduler()
);

Error Handling

Exception Types Thrown

  • None - Uses safeUpdate() for resilient DML

Exception Types Caught

  • Implicitly handled by DailyMembershipUtil.safeUpdate():
  • DML exceptions (validation rules, triggers, required fields)
  • List exceptions (duplicate IDs)

Error Handling Strategy

  1. Partial Success: Individual record failures don't stop batch
  2. Error Logging: Failed records logged to Flow_Error_Log__c via safeUpdate()
  3. Batch Continuation: Batch proceeds to next chunk after failures
  4. Account Sync: Chained batch always executes (even if errors occurred)

Error Handling Gaps

  1. No Finish Error Handling: Batch chaining failure not caught
  2. Silent Test Mode Logic: Test vs production behavior differs without logging
  3. No Job Status Check: Finish method doesn't inspect job results before chaining

Monitoring Recommendations

// Query batch job status
SELECT Id, Status, NumberOfErrors, JobItemsProcessed, TotalJobItems
FROM AsyncApexJob
WHERE ApexClass.Name = 'DailyMembershipStatusBatch'
  AND CreatedDate = TODAY
ORDER BY CreatedDate DESC
LIMIT 1

// Query error logs
SELECT Id, ErrorMessage__c, MembershipId__c, FlowRunDateTime__c
FROM Flow_Error_Log__c
WHERE FlowName__c = 'DailyMembershipStatusBatch apexClass'
  AND FlowRunDateTime__c = TODAY

Security Considerations

Sharing Model

  • WITH SHARING: Respects record-level security
  • Implication: User executing batch must have access to all memberships requiring updates
  • Recommendation: Schedule via System context or user with "View All" on Membership__c

FLS Considerations

  • ⚠️ No FLS Checks: Doesn't validate field-level security before updates
  • Fields Updated: Membership__c.Status__c, Subscription__c.Status__c
  • Risk: Low - batch typically runs as system user
  • Mitigation: Grant profile access to Status fields for scheduler user

Data Access Patterns

  • Read Access: All Active and Within Grace memberships
  • Write Access: Updates membership and subscription statuses
  • Mass Updates: Could affect thousands of records in single run

Test Class Requirements

Required Test Coverage

@IsTest
public class DailyMembershipStatusBatchTest {

    @TestSetup
    static void setup() {
        // Create test accounts
        Account acc = new Account(
            FirstName = 'Test',
            LastName = 'Member',
            RecordTypeId = [SELECT Id FROM RecordType WHERE DeveloperName = 'PersonAccount' LIMIT 1].Id
        );
        insert acc;

        // Create subscription
        Subscription__c sub = new Subscription__c(
            Name = 'Test Subscription',
            Status__c = 'Active',
            Account__c = acc.Id
        );
        insert sub;

        // Create memberships with different scenarios
        List<Membership__c> memberships = new List<Membership__c>();

        // 1. Active membership ending yesterday (should → Grace)
        memberships.add(new Membership__c(
            Account_Name__c = acc.Id,
            Status__c = 'Active',
            Start_Date__c = Date.today().addDays(-365),
            End_Date__c = Date.today().addDays(-1),
            Member_Type__c = 'Individual Member',
            Product_Family__c = 'Membership',
            Subcription__c = null
        ));

        // 2. Grace period membership past cutoff (should → Expired)
        memberships.add(new Membership__c(
            Account_Name__c = acc.Id,
            Status__c = 'Within Grace period',
            Start_Date__c = Date.today().addDays(-400),
            End_Date__c = Date.today().addDays(-35), // 35 days ago (> 30 grace)
            Member_Type__c = 'Individual Member',
            Product_Family__c = 'Membership',
            Subcription__c = sub.Id
        ));

        // 3. Corporate Council member (should NOT transition)
        memberships.add(new Membership__c(
            Account_Name__c = acc.Id,
            Status__c = 'Active',
            Start_Date__c = Date.today().addDays(-365),
            End_Date__c = Date.today().addDays(-10),
            Member_Type__c = 'Corporate Council Member',
            Product_Family__c = 'Membership'
        ));

        // 4. Active membership still valid (should NOT change)
        memberships.add(new Membership__c(
            Account_Name__c = acc.Id,
            Status__c = 'Active',
            Start_Date__c = Date.today().addDays(-30),
            End_Date__c = Date.today().addDays(335),
            Member_Type__c = 'Individual Member',
            Product_Family__c = 'Membership'
        ));

        insert memberships;
    }

    @IsTest
    static void testActiveToGracePeriod() {
        List<Membership__c> memberships = [
            SELECT Id, Status__c
            FROM Membership__c
            WHERE End_Date__c = :Date.today().addDays(-1)
            AND Member_Type__c = 'Individual Member'
        ];

        Assert.areEqual('Active', memberships[0].Status__c, 'Should start as Active');

        Test.startTest();
        Database.executeBatch(new DailyMembershipStatusBatch(30), 200);
        Test.stopTest();

        Membership__c updated = [
            SELECT Status__c
            FROM Membership__c
            WHERE Id = :memberships[0].Id
        ];

        Assert.areEqual('Within Grace period', updated.Status__c, 'Should transition to Grace period');
    }

    @IsTest
    static void testGracePeriodToExpired() {
        List<Membership__c> memberships = [
            SELECT Id, Status__c, Subcription__c
            FROM Membership__c
            WHERE Status__c = 'Within Grace period'
        ];

        Assert.areEqual('Within Grace period', memberships[0].Status__c, 'Should start in Grace period');
        Id subscriptionId = memberships[0].Subcription__c;

        Test.startTest();
        Database.executeBatch(new DailyMembershipStatusBatch(30), 200);
        Test.stopTest();

        Membership__c updated = [
            SELECT Status__c
            FROM Membership__c
            WHERE Id = :memberships[0].Id
        ];

        Assert.areEqual('Expired', updated.Status__c, 'Should transition to Expired');

        // Verify subscription also expired
        Subscription__c updatedSub = [
            SELECT Status__c
            FROM Subscription__c
            WHERE Id = :subscriptionId
        ];

        Assert.areEqual('Expired', updatedSub.Status__c, 'Related subscription should be expired');
    }

    @IsTest
    static void testCorporateCouncilException() {
        List<Membership__c> memberships = [
            SELECT Id, Status__c
            FROM Membership__c
            WHERE Member_Type__c = 'Corporate Council Member'
        ];

        String originalStatus = memberships[0].Status__c;

        Test.startTest();
        Database.executeBatch(new DailyMembershipStatusBatch(30), 200);
        Test.stopTest();

        Membership__c unchanged = [
            SELECT Status__c
            FROM Membership__c
            WHERE Id = :memberships[0].Id
        ];

        Assert.areEqual(originalStatus, unchanged.Status__c, 'Corporate Council status should not change');
    }

    @IsTest
    static void testNoChangesForValidMemberships() {
        List<Membership__c> memberships = [
            SELECT Id, Status__c
            FROM Membership__c
            WHERE End_Date__c = :Date.today().addDays(335)
        ];

        Assert.areEqual('Active', memberships[0].Status__c, 'Should start as Active');

        Test.startTest();
        Database.executeBatch(new DailyMembershipStatusBatch(30), 200);
        Test.stopTest();

        Membership__c unchanged = [
            SELECT Status__c
            FROM Membership__c
            WHERE Id = :memberships[0].Id
        ];

        Assert.areEqual('Active', unchanged.Status__c, 'Valid membership should remain Active');
    }

    @IsTest
    static void testBatchChaining() {
        Test.startTest();
        Id batchId = Database.executeBatch(new DailyMembershipStatusBatch(30), 200);
        Test.stopTest();

        // Verify status batch completed
        AsyncApexJob statusJob = [
            SELECT Status, NumberOfErrors
            FROM AsyncApexJob
            WHERE Id = :batchId
        ];

        Assert.areEqual('Completed', statusJob.Status, 'Status batch should complete');
        Assert.areEqual(0, statusJob.NumberOfErrors, 'Should have no errors');

        // Verify account batch was queued (will be in test context)
        List<AsyncApexJob> accountBatches = [
            SELECT Id, ApexClass.Name
            FROM AsyncApexJob
            WHERE ApexClass.Name = 'DailyMembershipAccountsBatch'
        ];

        Assert.isFalse(accountBatches.isEmpty(), 'Account batch should be queued');
    }

    @IsTest
    static void testCustomGracePeriod() {
        // Test with custom grace period (45 days)
        List<Membership__c> memberships = [
            SELECT Id, Status__c
            FROM Membership__c
            WHERE Status__c = 'Within Grace period'
        ];

        Test.startTest();
        Database.executeBatch(new DailyMembershipStatusBatch(45), 200);
        Test.stopTest();

        Membership__c updated = [
            SELECT Status__c
            FROM Membership__c
            WHERE Id = :memberships[0].Id
        ];

        // With 45-day grace, membership ending 35 days ago should NOT expire yet
        Assert.areEqual('Within Grace period', updated.Status__c, 'Should remain in grace period with 45-day grace');
    }

    @IsTest
    static void testEmptyScope() {
        // Delete all test memberships
        delete [SELECT Id FROM Membership__c];

        Test.startTest();
        Database.executeBatch(new DailyMembershipStatusBatch(30), 200);
        Test.stopTest();

        // Should complete without errors
        List<Flow_Error_Log__c> logs = [
            SELECT Id
            FROM Flow_Error_Log__c
            WHERE FlowName__c = 'DailyMembershipStatusBatch apexClass'
        ];

        Assert.isTrue(logs.isEmpty(), 'No errors should be logged for empty scope');
    }
}

Test Data Requirements

  • Account: Person Account for membership association
  • Membership__c: Records with various statuses and end dates
  • Subscription__c: Record linked to grace period membership
  • RecordType: Person Account record type

Changes & History

Date Author Description
Unknown Original Developer Initial implementation
(Current) - Documentation added

Pre-Go-Live Concerns

CRITICAL

  • Typo in Field Name: Subcription__c (missing 'i') appears throughout code (lines 25, 65, 66)
  • Verify this is correct custom field API name
  • If typo, could cause null subscription updates
  • Test.isRunningTest() in Production: Line 21 couples test logic to production code
  • Remove test-specific query logic
  • Use @TestVisible test data instead

HIGH

  • Corporate Council Never Expires: Hardcoded business rule prevents expiration
  • Document this exception prominently in user training
  • Consider separate "Lifetime Member" status instead
  • No Batch Chaining Error Handling: finish() has no try-catch around executeBatch()
  • If chaining fails, Account fields won't sync
  • Add error logging or notification

MEDIUM

  • Redundant Query Logic in Execute: Lines 54-60 replicate query WHERE conditions
  • Query already filters these records
  • Remove redundant IF checks or add comment explaining defensive programming
  • Date Recalculation Every Chunk: Lines 47-51 recalculate dates for each batch chunk
  • Use instance variables with Database.Stateful to calculate once

LOW

  • Hardcoded Batch Size: finish() uses hardcoded 200 batch size
  • Extract to custom metadata or constant
  • No Finish Job Inspection: Doesn't check job status before chaining
  • Add AsyncApexJob query to log completion metrics

Maintenance Notes

📋 Monitoring Recommendations

  • Daily Batch Success: Query AsyncApexJob every morning to verify completion
  • Error Log Volume: Monitor Flow_Error_Log__c for DML failures
  • Status Distribution: Track counts of Active/Grace/Expired memberships over time
  • Subscription Sync: Verify memberships and subscriptions have matching statuses

🔧 Future Enhancement Opportunities

  1. Platform Event Notifications: Publish event when member expires for downstream systems
  2. Configurable Corporate Council: Move exception logic to custom metadata
  3. Batch Metrics: Log summary statistics (records processed, status transitions) to custom object
  4. Conditional Chaining: Only chain Account batch if memberships were actually updated

⚠️ Breaking Change Risks

  • Changing grace period default will affect transition timing for existing grace period members
  • Removing Corporate Council exception will cause those memberships to expire
  • Modifying status values ('Active', 'Within Grace period', 'Expired') requires data migration
  • DailyMembershipAccountsBatch: Chained after this batch to sync Account fields
  • DailyMembershipUtil: Provides getGraceDays() and safeUpdate() utilities
  • DailyMembershipScheduler: Schedules this batch to run daily
  • Environment_Settings__mdt: Stores grace period configuration
  • Flow_Error_Log__c: Error logging target

Business Owner

Primary Contact: Membership Operations Team Technical Owner: Salesforce Development Team Last Reviewed: [Date]


Documentation Status: ✅ Complete Code Review Status: ⚠️ Requires review (Test.isRunningTest, typo in field name) Test Coverage: Target 90%+ with provided test class