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¶
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()¶
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)
- Query Criteria (lines 23-36):
- Branch 1: Active memberships past end date (except Corporate Council)
Status__c = 'Active'End_Date__c < todayMember_Type__c != 'Corporate Council Member'
-
Branch 2: Grace period memberships past grace cutoff
Status__c = 'Within Grace period'End_Date__c < graceCutoff
-
Test Mode Variation (lines 21-36):
- Production: Enforces
Status__c = 'Active'(line 29) - 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()¶
Purpose: Processes each batch chunk of memberships, updating statuses and related subscriptions.
Parameters:
- bc (Database.BatchableContext) - Standard batch context
- scope (List
Returns: void
Business Logic:
- Date Recalculation (lines 47-51):
- Recalculates
todayandgraceCutofffor each chunk -
Ensures accuracy if batch runs across midnight
-
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'
- Related Subscription Expiration (lines 65-68):
- If membership has
Subcription__clookup populated - Creates minimal Subscription update:
{Id: subId, Status__c: 'Expired'} -
Separate list for bulk DML
-
Bulk Updates (lines 72-79):
- Updates all modified memberships via
DailyMembershipUtil.safeUpdate() - Updates all related subscriptions via
DailyMembershipUtil.safeUpdate() -
Uses partial-success DML pattern
-
Debug Logging (lines 80-81):
- 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()¶
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¶
- Batch Apex Pattern: Processes large datasets in chunks
- Stateful Batch: Preserves
graceDaysacross chunks viaDatabase.Stateful - Batch Chaining: Sequences dependent batch jobs in
finish() - Partial Success DML: Uses
safeUpdate()for resilient updates - 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__cpopulated) - 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¶
- Partial Success: Individual record failures don't stop batch
- Error Logging: Failed records logged to
Flow_Error_Log__cviasafeUpdate() - Batch Continuation: Batch proceeds to next chunk after failures
- Account Sync: Chained batch always executes (even if errors occurred)
Error Handling Gaps¶
- No Finish Error Handling: Batch chaining failure not caught
- Silent Test Mode Logic: Test vs production behavior differs without logging
- 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
@TestVisibletest 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.Statefulto 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
AsyncApexJobevery morning to verify completion - Error Log Volume: Monitor
Flow_Error_Log__cfor 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¶
- Platform Event Notifications: Publish event when member expires for downstream systems
- Configurable Corporate Council: Move exception logic to custom metadata
- Batch Metrics: Log summary statistics (records processed, status transitions) to custom object
- 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
🔗 Related Components¶
- DailyMembershipAccountsBatch: Chained after this batch to sync Account fields
- DailyMembershipUtil: Provides
getGraceDays()andsafeUpdate()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