Automating Member License Sorting¶
Status: Live — Active bug under remediation (see fix plan)
Created: 2025-12-08
Author: Jason Adams
Related Issue: #42
Problem Statement¶
The Spokane Mountaineers manage two types of Salesforce Community licenses:
- Customer Community Plus (Premium): ~$9–$10 per member per year, unlimited logins
- Customer Community Plus Login (Login-Based): ~$1.50 per member per year, pooled 5,000 total logins per year
The system automatically optimizes license assignments to address:
- Inefficient license usage (Premium licenses assigned to low-usage members)
- Cost optimization opportunities
- Manual administrative overhead
- Risk of exceeding license limits
System Constraints¶
- 505 Premium licenses available (maximum: 475 assigned)
- Chairs must remain Premium (Profile:
SM Community Plus Chair) - New users (<90 days old) must remain Premium (to allow for initial usage patterns)
- 200 Plus Login licenses included in contract
Usage Indicators¶
- Plus Login → Plus: More than 5 logins per year
- Plus → Plus Login: 5 or fewer logins per year
- Maximum Premium licenses: 475 (if more than 475 users qualify, top 475 by login count get Premium)
Solution¶
Automate license optimization through a scheduled batch process that:
- Runs daily to optimize license assignments
- Assigns Premium licenses to users with >5 logins, up to a maximum of 475 Premium licenses
- Protects critical users (Chairs and new users <90 days) - they always get Premium
- Prioritizes by login count - if more than 475 users qualify, top 475 by login count get Premium
- Tracks full fiscal year login history via custom object (LoginHistory API only provides 6 months)
- Logs all changes for auditing and compliance
Architecture¶
Components¶
-
Fiscal_Year_Login_History__c (Custom Object)
- Mirrors LoginHistory records to track full fiscal year (Feb 1 - Jan 31) login activity
- Required because LoginHistory API only provides 6 months of data
- Fields mirror LoginHistory structure with external ID on
Login_History_Id__cto prevent duplicates - Located in:
force-app/main/default/objects/Fiscal_Year_Login_History__c/
-
LoginHistorySyncBatch (
Database.Batchable,Database.Stateful)- Runs daily to sync LoginHistory records to Fiscal_Year_Login_History__c
- Queries LoginHistory from last 2 days and upserts to custom object
- Triggers LicenseShuffleBatch in finish() method to ensure sync completes before shuffling
- Located in:
force-app/main/default/classes/LoginHistorySyncBatch.cls
-
LoginHistorySyncScheduler (
Schedulable)- Schedules LoginHistorySyncBatch to run daily (e.g., 2:00 AM)
- Located in:
force-app/main/default/classes/LoginHistorySyncScheduler.cls
-
LoginHistoryCleanupBatch (
Database.Batchable)- Runs annually on May 1st to delete records before February 1st of previous fiscal year
- Keeps exactly one full fiscal year of data (Feb 1 - Jan 31)
- Located in:
force-app/main/default/classes/LoginHistoryCleanupBatch.cls
-
LoginHistoryCleanupScheduler (
Schedulable)- Schedules LoginHistoryCleanupBatch to run annually on May 1st
- Located in:
force-app/main/default/classes/LoginHistoryCleanupScheduler.cls
-
LicenseShuffleBatch (
Database.Batchable,Database.Stateful)- Queries all active Community users
- Counts logins from
Fiscal_Year_Login_History__c(fiscal year: Feb 1 - Jan 31) - Assigns Premium licenses to users with >5 logins, up to maximum of 475
- Updates licenses and profiles
- Accumulates audit logs for later insertion
- Located in:
force-app/main/default/classes/LicenseShuffleBatch.cls
-
LicenseChangeLogQueueable (
Queueable)- Handles insertion of
License_Change_Log__crecords in a separate transaction - Resolves
MIXED_DML_OPERATIONerror (cannot mix User updates with custom object inserts) - Enqueued from
LicenseShuffleBatch.finish()method - Located in:
force-app/main/default/classes/LicenseChangeLogQueueable.cls
- Handles insertion of
-
License_Change_Log__c (Custom Object)
- Tracks all license changes
- Stores: User, old/new license, old/new profile, login count, reason, timestamp, batch job ID
- Located in:
force-app/main/default/objects/License_Change_Log__c/
-
LoginHistoryMigrationBatch (
Database.Batchable,Database.Stateful)- One-time batch class for initial data migration
- Backfills
Fiscal_Year_Login_History__cwith existing LoginHistory records (last 6 months) - Used for initial setup before daily sync process begins
- Located in:
force-app/main/default/classes/LoginHistoryMigrationBatch.cls
-
Field-Level Security Script (
grant_fls_fiscal_year_login_history.apex)- Anonymous Apex script to grant FLS access to System Administrator profile
- Programmatically sets read/edit permissions for all Fiscal_Year_Login_History__c fields
- Must be run after deploying the custom object (foundation step)
- Located in:
scripts/apex/license-sorting/grant_fls_fiscal_year_login_history.apex
Flow Diagram¶
Implementation Details¶
License Shuffling Algorithm¶
-
Identify Protected Users:
- Profile =
SM Community Plus Chair - CreatedDate >= LAST_N_DAYS:90
- Profile =
-
Calculate Login Counts:
- Query
Fiscal_Year_Login_History__cfor each user (not LoginHistory - API only provides 6 months) - Fiscal Year Logic (Fiscal Year: Feb 1 - Jan 31):
- If current month in {2,3,4} (Feb-Apr): Use
LAST_N_DAYS:365(last 365 days - first 3 months of FY) - If current month >= 5 (May-Dec): Use current fiscal year (Feb 1 of current year to now)
- If current month == 1 (Jan): Use fiscal year (Feb 1 of previous year to now)
- If current month in {2,3,4} (Feb-Apr): Use
- Count total logins per user in the relevant period
- Query
-
Identify Qualifying Users:
- Protected users: Always get Premium (Chairs and new users <90 days)
- Qualifying users: Users with >5 logins qualify for Premium
-
Calculate Premium License Assignments:
- Always include all protected users (they must be Premium)
- Include all users with >5 logins
- If total exceeds 475, sort all qualifying users by login count (descending) and take top 475
- Maximum: 475 Premium licenses total
-
Execute Changes:
- Assign Premium to users in the keep list (protected + top qualifying users up to 475)
- Assign Login to users not in the keep list (users with ≤5 logins, or qualifying users beyond the 475 limit)
Profile Mapping¶
- Customer Community Plus → Profile:
SM Community Plus Member - Customer Community Plus Login → Profile:
SM Community Plus Login - Chairs → Profile:
SM Community Plus Chair(always Premium, never changed)
Logging¶
All license changes are logged to License_Change_Log__c with:
- User reference
- Old and new license types
- Old and new profile names
- Login count at time of change
- Reason for change ("Low usage" or "High usage")
- Timestamp (
Changed_At__c) - Batch job ID
Important: Due to Salesforce's MIXED_DML_OPERATION restriction, log records are inserted via a Queueable job that runs after the batch completes. This means:
- User license changes happen immediately
- Log records are created shortly after (typically within seconds)
- If the Queueable job fails, check debug logs for error details
Expected Outcomes¶
Benefits¶
- Automated Management: Eliminates manual license assignment overhead
- Proactive Optimization: Automatically rebalances when usage patterns change
- Audit Trail: Provides complete logging of all license changes for compliance
- Full Fiscal Year Tracking: Maintains complete fiscal year login history (not limited to 6 months like LoginHistory API)
Metrics¶
- Premium licenses assigned: Up to 475 (based on users with >5 logins)
- Manual license management time: Near zero (automated daily)
- License changes logged: 100% of all changes
- Login history tracking: Full fiscal year (Feb 1 - Jan 31)
Risks and Considerations¶
Technical Risks¶
- Governor Limits: LoginHistory queries are expensive; batch size is set to 50 users per batch to stay within limits
- Query Performance: Large
Fiscal_Year_Login_History__ctables may impact batch execution time - LoginHistory API Limitation: LoginHistory API only provides 6 months of data
- Mitigation: Custom object
Fiscal_Year_Login_History__ctracks full fiscal year via daily sync
- Mitigation: Custom object
- Error Handling: Users with validation rules or required fields may fail to update (errors are logged but don't stop the batch)
- MIXED_DML_OPERATION: Cannot update User (setup object) and insert
License_Change_Log__c(non-setup object) in the same transaction- Mitigation: Log records are inserted via
LicenseChangeLogQueueablein a separate transaction
- Mitigation: Log records are inserted via
- Data Sync Timing:
LoginHistorySyncBatchmust complete beforeLicenseShuffleBatchruns- Mitigation:
LicenseShuffleBatchis triggered fromLoginHistorySyncBatch.finish()method
- Mitigation:
- Field-Level Security: System Administrators require FLS permissions for
Fiscal_Year_Login_History__cfields- Mitigation: FLS grant script (
scripts/apex/license-sorting/grant_fls_fiscal_year_login_history.apex) programmatically sets permissions
- Mitigation: FLS grant script (
Business Risks¶
- User Experience: License changes may affect user access (mitigated by protecting Chairs and new users <90 days)
- Timing: Batch runs daily; changes may lag behind actual usage patterns by up to 24 hours
- Edge Cases: Users with exactly 5 logins may oscillate between licenses depending on other qualifying users and the 475 Premium license limit
Mitigation Strategies¶
- Comprehensive test coverage (>75% for all classes)
- Idempotent batch design (safe to run multiple times)
- Detailed error logging to
License_Change_Log__cand debug logs - Protected users (Chairs and new members) always retain Premium licenses
- Manual batch execution available for immediate optimization when needed
Implementation Plan¶
Phase 1: Foundation ✅¶
- Create
License_Change_Log__ccustom object and fields - Create
Fiscal_Year_Login_History__ccustom object and fields - Create
LicenseShuffleBatchclass skeleton - Create FLS grant script for field-level security setup
Phase 2: Core Logic ✅¶
- Implement fiscal year login counting logic using
Fiscal_Year_Login_History__c - Implement license shuffling algorithm (>5 logins, max 475 Premium)
- Implement logging functionality via Queueable class
- Create
LoginHistorySyncBatchfor daily data sync - Create
LoginHistoryCleanupBatchfor annual data cleanup
Phase 3: Testing ✅¶
- Create comprehensive test classes for all components
- Test all scenarios (upgrade, downgrade, protected users, edge cases)
- Verify governor limit handling and batch processing
- Test job chaining (sync batch triggers shuffle batch)
Phase 4: Deployment & Setup¶
- Deploy all components to staging environment
- Create Fiscal_Year_Login_History__c custom object and fields
- Create LoginHistorySyncBatch and scheduler
- Create LoginHistoryCleanupBatch and scheduler
- Update LicenseShuffleBatch to use custom object and new logic
- Create LoginHistoryMigrationBatch for initial data backfill
- Create FLS grant script for field-level security setup
- Run FLS grant script to set permissions
- Run initial data migration (LoginHistoryMigrationBatch) to backfill last 6 months
- Schedule
LoginHistorySyncSchedulerto run daily (running since ~Dec 2025, 120+ triggers) - Deploy to production
Phase 5: Documentation¶
- Document system architecture and components
- Document Queueable class and MIXED_DML workaround
- Document setup scripts and deployment steps
Phase 6: Bug Fix (active)¶
See fix plan.
- Emergency script: downgrade bottom 30 Premium users to relieve license cap
- Rewrite
LicenseShuffleBatchwith two-pass design (collect in execute, act in finish) - Fix
LoginHistoryCleanupBatch: addDatabase.Stateful - Update test classes for new batch structure
- Deploy fixes and verify steady state
Implementation Notes¶
Testing Configuration¶
- Max Premium Licenses: 475
- Login Threshold: >5 logins qualify for Premium
- Fiscal Year: Feb 1 - Jan 31
- Manual Execution: Can trigger LoginHistorySyncBatch or LicenseShuffleBatch directly
Key Implementation Details¶
- Batch Size: 50 users per chunk for LicenseShuffleBatch, 200 for LoginHistorySyncBatch and LoginHistoryMigrationBatch
- Stateful Processing: Uses
Database.Statefulto track Premium users across chunks - Log Insertion: Logs are accumulated during batch execution and inserted via Queueable after completion
- Protected Users: Chairs and users <90 days old always get Premium licenses
- Daily Sync: LoginHistorySyncBatch runs daily to sync LoginHistory to custom object
- Annual Cleanup: LoginHistoryCleanupBatch runs May 1st to delete records before previous fiscal year
- Job Chaining: LoginHistorySyncBatch triggers LicenseShuffleBatch in finish() method
- Field-Level Security: System Administrator profile requires FLS permissions for Fiscal_Year_Login_History__c fields (granted via script)
- Initial Migration: LoginHistoryMigrationBatch provides one-time backfill of last 6 months of LoginHistory data
Test Classes¶
LicenseShuffleBatchTest.cls: Comprehensive test coverage for batch logicLicenseChangeLogQueueableTest.cls: Tests Queueable log insertionLoginHistorySyncBatchTest.cls: Tests sync batch logicLoginHistorySyncSchedulerTest.cls: Tests sync schedulerLoginHistoryCleanupBatchTest.cls: Tests cleanup batch logicLoginHistoryCleanupSchedulerTest.cls: Tests cleanup schedulerLoginHistoryMigrationBatch: Includes test methods for initial migration logic
Setup Scripts¶
scripts/apex/license-sorting/grant_fls_fiscal_year_login_history.apex: Grants FLS permissions to System Administrator profilescripts/apex/license-sorting/migrate_login_history_initial.apex: Executes LoginHistoryMigrationBatch for initial data backfillscripts/apex/license-sorting/README.md: Documentation for all license sorting scripts
Deployment Steps¶
Initial Setup (One-Time)¶
- Deploy Components: Deploy all Apex classes, custom objects, and fields to target org
- Grant Field-Level Security: Run
scripts/apex/license-sorting/grant_fls_fiscal_year_login_history.apexin Anonymous Apex to grant FLS permissions - Initial Data Migration: Run
scripts/apex/license-sorting/migrate_login_history_initial.apexto backfill last 6 months of LoginHistory data - Schedule Daily Sync: Schedule
LoginHistorySyncSchedulerto run daily (recommended: 2:00 AM) - Schedule Annual Cleanup: Schedule
LoginHistoryCleanupSchedulerto run annually on May 1st
Ongoing Operations¶
- Daily sync automatically maintains
Fiscal_Year_Login_History__crecords - License shuffling runs automatically after each daily sync completes
- Annual cleanup removes records older than one full fiscal year
Future Enhancements¶
- Custom Metadata: Configurable thresholds (475 max Premium, 5 login threshold)
- Dashboard/Reports: Visualize license usage trends and fiscal year login activity
- Manual Trigger: Option to run optimization immediately (available via direct batch execution)
- Predictive Analytics: Forecast license needs based on usage trends
- Data Retention Policy: Configurable retention period for Fiscal_Year_Login_History__c records
References¶
- Salesforce LoginHistory Object Documentation
- Salesforce LoginHistory API Limitations - Only 6 months of data available via API
- Salesforce Batch Apex Best Practices
- Internal notes from conversation with Matt Jeffries
Status¶
System is live in production. As of 2026-04-12:
Login History Sync - Dailyscheduler has been running since ~Dec 2025 (120+ triggers)Fiscal_Year_Login_History__chas 25,069 records- Active issue:
LicenseShuffleBatchhas a two-pass correctness bug causing it to make license decisions per-chunk before all users are visible. As a result, 171 non-protected Premium users with ≤5 logins remain on Premium licenses and the hard cap of 505 has been reached. An emergency downgrade script and batch rewrite are underway — see fix plan.