This article explains how Salesforce Loyalty Management handles a new implementation guardrail introduced in Spring '25 that prevents transaction processing for certain loyalty program members. Administrators and developers managing loyalty programs should review this article before the Spring '25 upgrade.
After the Spring '25 upgrade, Salesforce Loyalty Management will not process new transactions for a member who meets ALL of the following conditions simultaneously:
Members who do not meet both conditions are unaffected. To prevent service disruptions, impacted member records must be migrated before or shortly after the upgrade.
If your org has a loyalty program that has members with a negative point balance and have more than 2,000 processed redemption transactions , you must manually migrate the member’s negative point balances data to the specific fields in Loyalty Management objects.
Migrate the negative point balances manually for each impacted member. Check the Redeemed Points Expiration Information field of the member’s transaction journals to find the details about their negative point balance.
If the member has negative points available in their transactions, move those points over to the Loyalty Ledger Traceability objects as new records.
This step can be skipped for members who don’t have negative points in the transaction journals.
In the Loyalty Member Currency record of the member, update the Redemption Settlement Pending From Date Time field value to the EPOCH date and time value: 01/01/1970 00:00:00.
After you complete these steps, the transactions should process as expected.
Developers in your organization can create a custom batch Apex job to avoid the failures for members with a negative point balance and more than 2000 transactions. The job ensures that the negative point balance data for your loyalty program members is migrated without any issues, preventing any service disruptions in Spring ’25.
Chunk Processing: Each chunk of the batch job processes a single non-migrated member currency with a negative balance.
Migrate Negative Point Balance: Migrates the negative point balance from the Redeemed Points Expiration Information field of the TransactionJournal object to the LoyaltyLedgerTraceability object.
Update Member Currency Information: Updates the Redemption Settlement Pending From Date field in the Loyalty Member Currency object with the EPOCH datetime value (01/01/1970 00:00:00). This is helpful in preventing future migration since migration is already complete.
Run the batch job during a period when there are minimum member activities to avoid conflicts.
Ensure no other requests are executed when the batch job is running because the batch job does not lock records in any object. Some of the other requests include member points expiration and bulk upload of partner transactions.
Test the script in your sandbox before running it in your production environment.
Run the batch job in the context of the user who is assigned the System Administrator profile.
public class MigrateNegBalFromTxnEntToTraceEnt implements Database.Batchable<sObject>, Database.Stateful {
public class JsonApexWrapper {
public Map<String, Map<String, Double>> Info;
public Integer recordsProcessed = 0;
public static String NPB = 'NPB';
public Boolean isSuccessful = false;
public String errorMessage = '';
}
public Database.QueryLocator start(Database.BatchableContext bc) {
String query = 'SELECT Id, LoyaltyMemberId, LoyaltyProgramCurrencyId, RedemStlPendFromDateTime ' +
'FROM LoyaltyMemberCurrency ' +
'WHERE PointsBalance < 0 AND RedemStlPendFromDateTime != 1970-01-01T00:00:00.000+0000';
return Database.getQueryLocator(query);
}
public void execute(Database.BatchableContext bc, List<LoyaltyMemberCurrency> memCurrs) {
if (memCurrs.size() > 1) {
isSuccessful = false;
errorMessage += 'Please make sure batch size=1';
System.debug(errorMessage);
throw new IllegalArgumentException(errorMessage);
}
Savepoint sp = Database.setSavepoint();
try {
// We are taking LOCK here on Member Currency to avoid any concurrency issues
LoyaltyMemberCurrency memCurr = [SELECT Id, LoyaltyMemberId, LoyaltyProgramCurrencyId, RedemStlPendFromDateTime
FROM LoyaltyMemberCurrency
WHERE Id = :memCurrs.get(0).Id
LIMIT 1 FOR UPDATE];
String pgmCrcyId = memCurr.LoyaltyProgramCurrencyId;
// We need ordering and locking isn't allowed simultaneously, so just ordered
List<TransactionJournal> txns = [SELECT Id, MemberId, RedeemedPointsExpirationInformation, CreatedDate
FROM TransactionJournal
WHERE CreatedDate >= :memCurr.RedemStlPendFromDateTime
AND MemberId = :memCurr.LoyaltyMemberId
AND JournalType.Name = 'Redemption'
AND Status = 'Processed'
ORDER BY MemberId, CreatedDate];
List<LoyaltyLedgerTraceability> traces = new List<LoyaltyLedgerTraceability>();
List<TransactionJournal> txnsWhichMightGetUpdated = new List<TransactionJournal>();
List<TransactionJournal> txnsToBeUpdated = new List<TransactionJournal>();
Set<String> txnIds = new Set<String>();
for (TransactionJournal txnJournal : txns) {
String txnId = txnJournal.Id;
String memberId = txnJournal.MemberId;
String jsonData = txnJournal.RedeemedPointsExpirationInformation;
if (!String.isBlank(jsonData) && jsonData.contains(JsonApexWrapper.NPB)) {
txnsWhichMightGetUpdated.add(txnJournal);
txnIds.add(txnId);
}
}
List<LoyaltyLedger> ledgers = [SELECT Id, TransactionJournalId, Points
FROM LoyaltyLedger
WHERE EventType = 'Debit'
AND LoyaltyProgramCurrencyId = :pgmCrcyId
AND TransactionJournalId IN :txnIds];
Map<String, List<LoyaltyLedger>> txnIdToLedgers = new Map<String, List<LoyaltyLedger>>();
for (LoyaltyLedger ledger : ledgers) {
String txnId = ledger.TransactionJournalId;
if (!txnIdToLedgers.containsKey(txnId)) {
txnIdToLedgers.put(txnId, new List<LoyaltyLedger>());
}
txnIdToLedgers.get(txnId).add(ledger);
}
for (TransactionJournal txnJournal : txnsWhichMightGetUpdated) {
String txnId = txnJournal.Id;
String memberId = txnJournal.MemberId;
String jsonData = txnJournal.RedeemedPointsExpirationInformation;
if (!String.isBlank(jsonData) && jsonData.contains(JsonApexWrapper.NPB)) {
String modifiedJson = '{"Info" : ' + jsonData + ' }';
JsonApexWrapper redemptionData = (JsonApexWrapper)JSON.deserialize(modifiedJson, JsonApexWrapper.class);
Map<String, Map<String, Double>> redemptionDataMap = redemptionData.Info;
if (redemptionDataMap.containsKey(pgmCrcyId)) {
Map<String, Double> npbOrExpiryDateToPoints = redemptionDataMap.get(pgmCrcyId);
if (npbOrExpiryDateToPoints.containsKey(JsonApexWrapper.NPB)) {
Double negativePoints = npbOrExpiryDateToPoints.get(JsonApexWrapper.NPB);
npbOrExpiryDateToPoints.remove(JsonApexWrapper.NPB);
if (negativePoints > 0) {
for (LoyaltyLedger ledger : txnIdToLedgers.get(txnId)) {
if (negativePoints <= 0) {
break;
}
Double pointsAdjusted = Math.min(ledger.Points, negativePoints);
negativePoints -= pointsAdjusted;
// Create Trace Ledger
LoyaltyLedgerTraceability trace = new LoyaltyLedgerTraceability(
Id = null,
DebitLoyaltyLedgerId = ledger.Id,
Points = pointsAdjusted,
ActionType = 'DebitWithArrears',
LoyaltyProgramCurrencyId = pgmCrcyId,
LoyaltyProgramMemberId = memberId
);
traces.add(trace);
}
}
recordsProcessed += 1;
txnsToBeUpdated.add(txnJournal);
if (redemptionDataMap.get(pgmCrcyId).size() == 0) {
redemptionDataMap.remove(pgmCrcyId);
}
if (redemptionDataMap.size() == 0) {
txnJournal.RedeemedPointsExpirationInformation = null;
} else {
txnJournal.RedeemedPointsExpirationInformation = JSON.serialize(redemptionDataMap);
}
}
}
}
}
if (txnsToBeUpdated.size() > 0) {
update txnsToBeUpdated;
}
memCurr.RedemStlPendFromDateTime = DateTime.newInstance(1970, 1, 1, 0, 0, 0);
update memCurr;
insert traces;
isSuccessful = true;
System.debug('Number of transaction records migrated [in this batch]: ' + recordsProcessed);
} catch (Exception e) {
isSuccessful = false;
errorMessage += '\n\n' + e.getMessage();
Database.rollback(sp);
System.debug('Failed to process the chunk due to error: ' + e.getMessage());
}
}
public void finish(Database.BatchableContext bc) {
String message;
if (isSuccessful) {
message = 'The Batch Job for migrating the negative balances has migrated [' + recordsProcessed + '] transaction records!';
} else {
message = 'The Batch Job for migrating the negative balances is failed!\n\nKindly check error email in your mailbox or logs for more details \n\n\n\n\n\n\n' + errorMessage;
}
System.debug(message);
AsyncApexJob job = [SELECT Id, Status, NumberOfErrors, JobItemsProcessed, TotalJobItems, CreatedBy.Email
FROM AsyncApexJob
WHERE Id = :bc.getJobId()
LIMIT 1];
Messaging.SingleEmailMessage mail = new Messaging.SingleEmailMessage();
mail.setToAddresses(new String[] { job.CreatedBy.Email });
mail.setSubject('Execution of "MigrateNegBalFromTxnEntToTraceEnt" Batch Job has completed.');
mail.setPlainTextBody(message);
Messaging.sendEmail(new Messaging.SingleEmailMessage[] { mail });
}
004461737

We use three kinds of cookies on our websites: required, functional, and advertising. You can choose whether functional and advertising cookies apply. Click on the different cookie categories to find out more about each category and to change the default settings.
Privacy Statement
Required cookies are necessary for basic website functionality. Some examples include: session cookies needed to transmit the website, authentication cookies, and security cookies.
Functional cookies enhance functions, performance, and services on the website. Some examples include: cookies used to analyze site traffic, cookies used for market research, and cookies used to display advertising that is not directed to a particular individual.
Advertising cookies track activity across websites in order to understand a viewer’s interests, and direct them specific marketing. Some examples include: cookies used for remarketing, or interest-based advertising.