Loading

Loyalty Management Spring '25 Negative Balance Transaction Guardrail

Дата публикации: Jun 25, 2026
Описание

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:

• The member has a negative point balance for a non-qualifying currency
• The member has 2,000 or more transactions in Processed status for that same currency

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.

  1. 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.

  2. 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.

  3. 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.

Batch Apex Job

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.

What the Batch Job Does

  • 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.

  • Remove Negative Point Balance Data: Removes the negative point balance data from the RedeemedPointsExpirationInformation field of the TransactionJournal 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.


      Points to Consider Before Running the Batch Apex Job

      • 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.

      Sample Apex Batch Job Definition:

      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

     
    Загрузка
    Salesforce Help | Article