From cd6f3d27d79e3c0910cc551dbbfeafc0b27e6934 Mon Sep 17 00:00:00 2001 From: Andrew Yu Date: Wed, 6 May 2026 14:45:39 -0700 Subject: [PATCH 1/5] Add regression tests for W-19306508 monthly RD next donation date skip bug (TDD) Tests verify that monthly RDs with DayOfMonth=31/30/Last_Day starting in short months (Feb/Apr/Jun) do not skip the current 31-day month when calculating next donation date. Covers unit, integration, and batch paths. --- .../RD2_OpportunityEvaluationService_TEST.cls | 35 ++++ .../RD2_OpportunityEvaluation_TEST.cls | 44 +++++ .../classes/RD2_ScheduleService_TEST.cls | 167 ++++++++++++++++++ 3 files changed, 246 insertions(+) diff --git a/force-app/main/default/classes/RD2_OpportunityEvaluationService_TEST.cls b/force-app/main/default/classes/RD2_OpportunityEvaluationService_TEST.cls index 41b8262c642..8ff92fb2f74 100644 --- a/force-app/main/default/classes/RD2_OpportunityEvaluationService_TEST.cls +++ b/force-app/main/default/classes/RD2_OpportunityEvaluationService_TEST.cls @@ -2350,6 +2350,41 @@ private class RD2_OpportunityEvaluationService_TEST { 'The non matching installment opportunity should stay open'); } + /*** + * @description Verifies that when a monthly RD with DayOfMonth=31 starts in a short month + * (February), the created Installment Opportunity has the correct CloseDate in the current + * 31-day month rather than skipping to the next month. + * Regression test for W-19306508. + */ + @isTest + private static void shouldCreateOppWithCorrectCloseDateWhenDayOfMonth31AndStartInFeb() { + final Date startDate = Date.newInstance(2021, 2, 1); + final Date today = Date.newInstance(2021, 7, 29); + final Date expectedCloseDate = Date.newInstance(2021, 7, 31); + + RD2_ScheduleService.currentDate = today; + RD2_EnablementService_TEST.setRecurringDonations2Enabled(); + + npe03__Recurring_Donation__c rd = getRecurringDonationBuilder(getContact()) + .withStartDate(startDate) + .withDayOfMonth('31') + .withCalculateNextDonationDate() + .build(); + + Test.startTest(); + insert rd; + Test.stopTest(); + + rd = rdGateway.getRecord(rd.Id); + List opps = oppGateway.getRecords(rd); + + System.assertEquals(1, opps.size(), 'One Installment Opp should be created'); + System.assertEquals(expectedCloseDate, opps[0].CloseDate, + 'Opp CloseDate should be July 31, not skip to August'); + System.assertEquals(expectedCloseDate, rd.npe03__Next_Payment_Date__c, + 'Next Payment Date should be July 31'); + } + // Helpers /////////////////// diff --git a/force-app/main/default/classes/RD2_OpportunityEvaluation_TEST.cls b/force-app/main/default/classes/RD2_OpportunityEvaluation_TEST.cls index 954b96474bd..0a5ac7935ec 100644 --- a/force-app/main/default/classes/RD2_OpportunityEvaluation_TEST.cls +++ b/force-app/main/default/classes/RD2_OpportunityEvaluation_TEST.cls @@ -1355,6 +1355,50 @@ public with sharing class RD2_OpportunityEvaluation_TEST { 'The batch should be set to Custom Setting Recurring_Donation_Batch_Size__c value'); } + /*** + * @description Verifies the batch job creates an Opp with correct CloseDate when + * DayOfMonth=31 (converted to Last_Day), StartDate is in February, and the batch + * runs on day 31 of a subsequent month. The next donation date should not skip a month. + * Regression test for W-19306508. + */ + @IsTest + private static void shouldNotSkipMonthForNextOppWhenBatchRunsOnDay31WithFebStart() { + RD2_EnablementService_TEST.setRecurringDonations2Enabled(); + + final Date startDate = Date.newInstance(2021, 2, 1); + RD2_ScheduleService.currentDate = startDate; + + npe03__Recurring_Donation__c rd = getRecurringDonationBuilder() + .withStartDate(startDate) + .withDayOfMonth('31') + .withCalculateNextDonationDate() + .build(); + insert rd; + + List opps = oppGateway.getRecords(rd); + System.assertEquals(1, opps.size(), 'An Opp should be created on insert'); + System.assertEquals(Date.newInstance(2021, 2, 28), opps[0].CloseDate, + 'First Opp CloseDate should be Feb 28 (last day of Feb)'); + + final Date batchRunDate = Date.newInstance(2021, 7, 31); + RD2_ScheduleService.currentDate = batchRunDate; + + runBatch(new RD2_OpportunityEvaluation_BATCH(batchRunDate)); + + assertBatchJobIteration(1); + + rd = rdGateway.getRecord(rd.Id); + opps = oppGateway.getRecords(rd); + + System.assertEquals(2, opps.size(), 'A second Opp should be created by the batch'); + + Opportunity newOpp = opps[0].CloseDate > opps[1].CloseDate ? opps[0] : opps[1]; + System.assertEquals(batchRunDate, newOpp.CloseDate, + 'New Opp CloseDate should be July 31, not skip to August 31'); + System.assertEquals(batchRunDate, rd.npe03__Next_Payment_Date__c, + 'Next Payment Date should be July 31'); + } + // Helpers ////////////// diff --git a/force-app/main/default/classes/RD2_ScheduleService_TEST.cls b/force-app/main/default/classes/RD2_ScheduleService_TEST.cls index 2af0b82a2fb..004c8b81327 100644 --- a/force-app/main/default/classes/RD2_ScheduleService_TEST.cls +++ b/force-app/main/default/classes/RD2_ScheduleService_TEST.cls @@ -353,6 +353,173 @@ public with sharing class RD2_ScheduleService_TEST { System.assertEquals(nextDonationDate, service.getNextDonationDate(rd), 'Next Donation Date should match'); } + /*** + * @description Verifies next donation date is not skipped when DayOfMonth=31, + * StartDate is in February (28 days), and referenceDate is before end of target month. + * Regression test for W-19306508. + */ + @isTest + private static void shouldNotSkipMonthWhenStartInFebAndDayOfMonth31() { + final Date startDate = Date.newInstance(2021, 2, 1); + Date today = Date.newInstance(2021, 7, 29); + Date nextDonationDate = Date.newInstance(2021, 7, 31); + + npe03__Recurring_Donation__c rd = getRecurringDonationMonthlyBuilder() + .withStartDate(startDate) + .withDayOfMonth('31') + .build(); + + RD2_ScheduleService service = getScheduleServiceForCurrentDate(today); + System.assertEquals(nextDonationDate, service.getNextDonationDate(rd), + 'Next Donation Date should be July 31, not skip to August'); + } + + /*** + * @description Verifies next donation date when referenceDate equals the expected + * donation date (July 31) for a DayOfMonth=31 RD starting in February. + * Regression test for W-19306508. + */ + @isTest + private static void shouldReturnSameDateWhenRefDateEqualsDayOfMonth31() { + final Date startDate = Date.newInstance(2021, 2, 1); + Date today = Date.newInstance(2021, 7, 31); + Date nextDonationDate = Date.newInstance(2021, 7, 31); + + npe03__Recurring_Donation__c rd = getRecurringDonationMonthlyBuilder() + .withStartDate(startDate) + .withDayOfMonth('31') + .build(); + + RD2_ScheduleService service = getScheduleServiceForCurrentDate(today); + System.assertEquals(nextDonationDate, service.getNextDonationDate(rd), + 'Next Donation Date should be July 31 when referenceDate is July 31'); + } + + /*** + * @description Verifies next donation date advances to next month when referenceDate + * is past the current month's donation date. + * Regression test for W-19306508. + */ + @isTest + private static void shouldAdvanceToNextMonthWhenRefDatePastDayOfMonth31() { + final Date startDate = Date.newInstance(2021, 2, 1); + Date today = Date.newInstance(2021, 8, 1); + Date nextDonationDate = Date.newInstance(2021, 8, 31); + + npe03__Recurring_Donation__c rd = getRecurringDonationMonthlyBuilder() + .withStartDate(startDate) + .withDayOfMonth('31') + .build(); + + RD2_ScheduleService service = getScheduleServiceForCurrentDate(today); + System.assertEquals(nextDonationDate, service.getNextDonationDate(rd), + 'Next Donation Date should be Aug 31 when referenceDate is Aug 1'); + } + + /*** + * @description Verifies next donation date is not skipped when DayOfMonth=31 and + * StartDate is in April (30 days), and batch runs on last day of a 31-day month. + * Regression test for W-19306508. + */ + @isTest + private static void shouldNotSkipMonthWhenStartInAprilAndDayOfMonth31() { + final Date startDate = Date.newInstance(2021, 4, 1); + Date today = Date.newInstance(2021, 7, 31); + Date nextDonationDate = Date.newInstance(2021, 7, 31); + + npe03__Recurring_Donation__c rd = getRecurringDonationMonthlyBuilder() + .withStartDate(startDate) + .withDayOfMonth('31') + .build(); + + RD2_ScheduleService service = getScheduleServiceForCurrentDate(today); + System.assertEquals(nextDonationDate, service.getNextDonationDate(rd), + 'Next Donation Date should be July 31 when starting in April'); + } + + /*** + * @description Verifies next donation date is not skipped when DayOfMonth=Last_Day and + * StartDate is in June (30 days), and batch runs on last day of a 31-day month. + * Regression test for W-19306508. + */ + @isTest + private static void shouldNotSkipMonthWhenStartInJuneAndDayOfMonthLastDay() { + final Date startDate = Date.newInstance(2021, 6, 1); + Date today = Date.newInstance(2021, 7, 31); + Date nextDonationDate = Date.newInstance(2021, 7, 31); + + npe03__Recurring_Donation__c rd = getRecurringDonationMonthlyBuilder() + .withStartDate(startDate) + .withDayOfMonth(RD2_Constants.DAY_OF_MONTH_LAST_DAY) + .build(); + + RD2_ScheduleService service = getScheduleServiceForCurrentDate(today); + System.assertEquals(nextDonationDate, service.getNextDonationDate(rd), + 'Next Donation Date should be July 31 when using Last_Day starting in June'); + } + + /*** + * @description Verifies next donation date is not skipped when DayOfMonth=30 and + * StartDate is in February (28 days). + * Regression test for W-19306508. + */ + @isTest + private static void shouldNotSkipMonthWhenStartInFebAndDayOfMonth30() { + final Date startDate = Date.newInstance(2021, 2, 1); + Date today = Date.newInstance(2021, 7, 29); + Date nextDonationDate = Date.newInstance(2021, 7, 30); + + npe03__Recurring_Donation__c rd = getRecurringDonationMonthlyBuilder() + .withStartDate(startDate) + .withDayOfMonth('30') + .build(); + + RD2_ScheduleService service = getScheduleServiceForCurrentDate(today); + System.assertEquals(nextDonationDate, service.getNextDonationDate(rd), + 'Next Donation Date should be July 30 when starting in Feb with DayOfMonth 30'); + } + + /*** + * @description Verifies next donation date is not skipped when DayOfMonth=Last_Day and + * StartDate is in February. + * Regression test for W-19306508. + */ + @isTest + private static void shouldNotSkipMonthWhenStartInFebAndDayOfMonthLastDay() { + final Date startDate = Date.newInstance(2021, 2, 1); + Date today = Date.newInstance(2021, 7, 29); + Date nextDonationDate = Date.newInstance(2021, 7, 31); + + npe03__Recurring_Donation__c rd = getRecurringDonationMonthlyBuilder() + .withStartDate(startDate) + .withDayOfMonth(RD2_Constants.DAY_OF_MONTH_LAST_DAY) + .build(); + + RD2_ScheduleService service = getScheduleServiceForCurrentDate(today); + System.assertEquals(nextDonationDate, service.getNextDonationDate(rd), + 'Next Donation Date should be July 31 when using Last_Day starting in Feb'); + } + + /*** + * @description Non-regression: Verifies normal DayOfMonth (15) is not affected by + * the fix for W-19306508. Day 15 never exceeds any month's length. + */ + @isTest + private static void shouldNotAffectNormalDayOfMonth15WithFebStart() { + final Date startDate = Date.newInstance(2021, 2, 1); + Date today = Date.newInstance(2021, 7, 20); + Date nextDonationDate = Date.newInstance(2021, 8, 15); + + npe03__Recurring_Donation__c rd = getRecurringDonationMonthlyBuilder() + .withStartDate(startDate) + .withDayOfMonth('15') + .build(); + + RD2_ScheduleService service = getScheduleServiceForCurrentDate(today); + System.assertEquals(nextDonationDate, service.getNextDonationDate(rd), + 'Next Donation Date should be Aug 15 for normal DayOfMonth'); + } + /*** * @description Verifies next donation date for monthly donation when donation falls in current month. */ From 7ab8ffca7ed80f8c7b0fa5106d9982ad757533f3 Mon Sep 17 00:00:00 2001 From: Andrew Yu Date: Wed, 6 May 2026 15:09:57 -0700 Subject: [PATCH 2/5] Add visualization and batch regression tests for W-19306508 Adds test for getVisualizedInstallments showing skipped installment when viewed from a late day in the month (e.g., Mar 29 with Feb start DayOfMonth=31). Also fixes April/June tests to use referenceDate=Jul 31 to actually trigger the bug. --- .../classes/RD2_ScheduleService_TEST.cls | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/force-app/main/default/classes/RD2_ScheduleService_TEST.cls b/force-app/main/default/classes/RD2_ScheduleService_TEST.cls index 004c8b81327..0814bcfc52f 100644 --- a/force-app/main/default/classes/RD2_ScheduleService_TEST.cls +++ b/force-app/main/default/classes/RD2_ScheduleService_TEST.cls @@ -1754,6 +1754,44 @@ public with sharing class RD2_ScheduleService_TEST { testAndAssertVisualizedInstallments(RD2_Constants.INSTALLMENT_PERIOD_MONTHLY, startDate, dayOfMonth, nextDonationDateByIndex); } + /*** + * @description Verifies visualized installments are not skipped when DayOfMonth=31, + * StartDate is in February, and visualization starts from a late day in March. + * The first installment shown should be Mar 31, not skip to Apr 30. + * Regression test for W-19306508. + */ + @isTest + private static void shouldNotSkipInstallmentInVisualizationWhenStartInFebAndViewedFromDay29() { + final Date startDate = Date.newInstance(2021, 2, 1); + final Date viewDate = Date.newInstance(2021, 3, 29); + + RD2_EnablementService_TEST.setRecurringDonations2Enabled(); + RD2_ScheduleService service = getScheduleServiceForCurrentDate(viewDate); + + npe03__Recurring_Donation__c rd = getRecurringDonationMonthlyBuilder() + .withStartDate(startDate) + .withDayOfMonth('31') + .build(); + + Test.startTest(); + insert rd; + Test.stopTest(); + + List schedules = new RD2_QueryService() + .getRecurringDonationsWithRelatedRecords(new Set{ rd.Id }, startDate)[0].RecurringDonationSchedules__r; + + List installments = service.getVisualizedInstallments( + viewDate, 3, schedules); + + System.assertEquals(3, installments.size(), 'Should return 3 installments'); + System.assertEquals(Date.newInstance(2021, 3, 31), installments[0].nextDonationDate, + 'First visualized installment should be March 31, not skip to April'); + System.assertEquals(Date.newInstance(2021, 4, 30), installments[1].nextDonationDate, + 'Second installment should be April 30'); + System.assertEquals(Date.newInstance(2021, 5, 31), installments[2].nextDonationDate, + 'Third installment should be May 31'); + } + /*** * @description Verifies visualization works as expected across short month with day of month > days in short month */ From f22538d4849186d91cf093a1fe7e1cdbb19ee0f9 Mon Sep 17 00:00:00 2001 From: Andrew Yu Date: Wed, 6 May 2026 15:53:35 -0700 Subject: [PATCH 3/5] Fix monthly RD next donation date skipping month for short-month starts Apply day-of-month correction before comparing against referenceDate so the comparison uses the actual donation date (e.g., Jul 31) rather than the clamped addMonths result (e.g., Jul 28). Extract adjustToTargetDayOfMonth helper to avoid duplicating the correction logic. W-19306508 --- .../default/classes/RD2_ScheduleService.cls | 38 ++++++++++++------- 1 file changed, 24 insertions(+), 14 deletions(-) diff --git a/force-app/main/default/classes/RD2_ScheduleService.cls b/force-app/main/default/classes/RD2_ScheduleService.cls index d71d09c325e..a6bf3cb0c08 100644 --- a/force-app/main/default/classes/RD2_ScheduleService.cls +++ b/force-app/main/default/classes/RD2_ScheduleService.cls @@ -543,24 +543,16 @@ public without sharing class RD2_ScheduleService { Date adjusted = addDateUnits(firstValid, (unitsBetween / adjustedFrequency) * adjustedFrequency, schedule.InstallmentPeriod__c); + if (schedule.InstallmentPeriod__c == RD2_Constants.INSTALLMENT_PERIOD_MONTHLY && schedule.DayOfMonth__c != null) { + adjusted = adjustToTargetDayOfMonth(adjusted, schedule); + } + if (adjusted < referenceDate) { adjusted = addDateUnits(adjusted, adjustedFrequency, schedule.InstallmentPeriod__c); - } - if (schedule.InstallmentPeriod__c == RD2_Constants.INSTALLMENT_PERIOD_MONTHLY) { - if (schedule.DayOfMonth__c == null) { - return adjusted; - } - Integer nextDayOfMonth; - if (schedule.DayOfMonth__c == RD2_Constants.DAY_OF_MONTH_LAST_DAY || - Integer.valueOf(schedule.DayOfMonth__c) > Date.daysInMonth(adjusted.year(), adjusted.month())) - { - nextDayOfMonth = Date.daysInMonth(adjusted.year(),adjusted.month()); + if (schedule.InstallmentPeriod__c == RD2_Constants.INSTALLMENT_PERIOD_MONTHLY && schedule.DayOfMonth__c != null) { + adjusted = adjustToTargetDayOfMonth(adjusted, schedule); } - else { - nextDayOfMonth = Integer.valueOf(schedule.DayOfMonth__c); - } - adjusted = Date.newInstance(adjusted.year(), adjusted.month(), nextDayOfMonth); } if ( @@ -620,6 +612,24 @@ public without sharing class RD2_ScheduleService { return adjustedDate; } + /*** + * @description Adjusts a date to the target day of month for the schedule. + * @param adjusted Date to adjust + * @param schedule Recurring Donation Schedule record + * @return Date + */ + private Date adjustToTargetDayOfMonth(Date adjusted, RecurringDonationSchedule__c schedule) { + Integer nextDayOfMonth; + if (schedule.DayOfMonth__c == RD2_Constants.DAY_OF_MONTH_LAST_DAY || + Integer.valueOf(schedule.DayOfMonth__c) > Date.daysInMonth(adjusted.year(), adjusted.month())) + { + nextDayOfMonth = Date.daysInMonth(adjusted.year(), adjusted.month()); + } else { + nextDayOfMonth = Integer.valueOf(schedule.DayOfMonth__c); + } + return Date.newInstance(adjusted.year(), adjusted.month(), nextDayOfMonth); + } + /*** * @description Calculates the earliest valid donation date based on start date. * @param schedule Recurring Donation Schedule record From 98b0deda5aaadc5f4a2614cc2c5a22c0e56f52a5 Mon Sep 17 00:00:00 2001 From: Andrew Yu Date: Mon, 11 May 2026 10:17:25 -0700 Subject: [PATCH 4/5] Use DayOfMonth 30 instead of 31 in regression tests to avoid Last_Day restricted picklist error in scratch orgs --- .../RD2_OpportunityEvaluationService_TEST.cls | 12 +++++------ .../RD2_OpportunityEvaluation_TEST.cls | 21 ++++++++++--------- .../classes/RD2_ScheduleService_TEST.cls | 14 ++++++------- 3 files changed, 24 insertions(+), 23 deletions(-) diff --git a/force-app/main/default/classes/RD2_OpportunityEvaluationService_TEST.cls b/force-app/main/default/classes/RD2_OpportunityEvaluationService_TEST.cls index 8ff92fb2f74..5a7d5b3fa25 100644 --- a/force-app/main/default/classes/RD2_OpportunityEvaluationService_TEST.cls +++ b/force-app/main/default/classes/RD2_OpportunityEvaluationService_TEST.cls @@ -2353,21 +2353,21 @@ private class RD2_OpportunityEvaluationService_TEST { /*** * @description Verifies that when a monthly RD with DayOfMonth=31 starts in a short month * (February), the created Installment Opportunity has the correct CloseDate in the current - * 31-day month rather than skipping to the next month. + * month rather than skipping to the next month. * Regression test for W-19306508. */ @isTest - private static void shouldCreateOppWithCorrectCloseDateWhenDayOfMonth31AndStartInFeb() { + private static void shouldCreateOppWithCorrectCloseDateWhenDayOfMonth30AndStartInFeb() { final Date startDate = Date.newInstance(2021, 2, 1); final Date today = Date.newInstance(2021, 7, 29); - final Date expectedCloseDate = Date.newInstance(2021, 7, 31); + final Date expectedCloseDate = Date.newInstance(2021, 7, 30); RD2_ScheduleService.currentDate = today; RD2_EnablementService_TEST.setRecurringDonations2Enabled(); npe03__Recurring_Donation__c rd = getRecurringDonationBuilder(getContact()) .withStartDate(startDate) - .withDayOfMonth('31') + .withDayOfMonth('30') .withCalculateNextDonationDate() .build(); @@ -2380,9 +2380,9 @@ private class RD2_OpportunityEvaluationService_TEST { System.assertEquals(1, opps.size(), 'One Installment Opp should be created'); System.assertEquals(expectedCloseDate, opps[0].CloseDate, - 'Opp CloseDate should be July 31, not skip to August'); + 'Opp CloseDate should be July 30, not skip to August'); System.assertEquals(expectedCloseDate, rd.npe03__Next_Payment_Date__c, - 'Next Payment Date should be July 31'); + 'Next Payment Date should be July 30'); } // Helpers diff --git a/force-app/main/default/classes/RD2_OpportunityEvaluation_TEST.cls b/force-app/main/default/classes/RD2_OpportunityEvaluation_TEST.cls index 0a5ac7935ec..2ba97a2b53d 100644 --- a/force-app/main/default/classes/RD2_OpportunityEvaluation_TEST.cls +++ b/force-app/main/default/classes/RD2_OpportunityEvaluation_TEST.cls @@ -1357,12 +1357,12 @@ public with sharing class RD2_OpportunityEvaluation_TEST { /*** * @description Verifies the batch job creates an Opp with correct CloseDate when - * DayOfMonth=31 (converted to Last_Day), StartDate is in February, and the batch - * runs on day 31 of a subsequent month. The next donation date should not skip a month. + * DayOfMonth=30, StartDate is in February, and the batch runs before the donation + * date in a subsequent month. The next donation date should not skip a month. * Regression test for W-19306508. */ @IsTest - private static void shouldNotSkipMonthForNextOppWhenBatchRunsOnDay31WithFebStart() { + private static void shouldNotSkipMonthForNextOppWhenBatchRunsBeforeDayOfMonthWithFebStart() { RD2_EnablementService_TEST.setRecurringDonations2Enabled(); final Date startDate = Date.newInstance(2021, 2, 1); @@ -1370,7 +1370,7 @@ public with sharing class RD2_OpportunityEvaluation_TEST { npe03__Recurring_Donation__c rd = getRecurringDonationBuilder() .withStartDate(startDate) - .withDayOfMonth('31') + .withDayOfMonth('30') .withCalculateNextDonationDate() .build(); insert rd; @@ -1378,9 +1378,10 @@ public with sharing class RD2_OpportunityEvaluation_TEST { List opps = oppGateway.getRecords(rd); System.assertEquals(1, opps.size(), 'An Opp should be created on insert'); System.assertEquals(Date.newInstance(2021, 2, 28), opps[0].CloseDate, - 'First Opp CloseDate should be Feb 28 (last day of Feb)'); + 'First Opp CloseDate should be Feb 28 (day 30 clamped to end of Feb)'); - final Date batchRunDate = Date.newInstance(2021, 7, 31); + final Date batchRunDate = Date.newInstance(2021, 7, 29); + final Date expectedCloseDate = Date.newInstance(2021, 7, 30); RD2_ScheduleService.currentDate = batchRunDate; runBatch(new RD2_OpportunityEvaluation_BATCH(batchRunDate)); @@ -1393,10 +1394,10 @@ public with sharing class RD2_OpportunityEvaluation_TEST { System.assertEquals(2, opps.size(), 'A second Opp should be created by the batch'); Opportunity newOpp = opps[0].CloseDate > opps[1].CloseDate ? opps[0] : opps[1]; - System.assertEquals(batchRunDate, newOpp.CloseDate, - 'New Opp CloseDate should be July 31, not skip to August 31'); - System.assertEquals(batchRunDate, rd.npe03__Next_Payment_Date__c, - 'Next Payment Date should be July 31'); + System.assertEquals(expectedCloseDate, newOpp.CloseDate, + 'New Opp CloseDate should be July 30, not skip to August'); + System.assertEquals(expectedCloseDate, rd.npe03__Next_Payment_Date__c, + 'Next Payment Date should be July 30'); } // Helpers diff --git a/force-app/main/default/classes/RD2_ScheduleService_TEST.cls b/force-app/main/default/classes/RD2_ScheduleService_TEST.cls index 0814bcfc52f..55786430d93 100644 --- a/force-app/main/default/classes/RD2_ScheduleService_TEST.cls +++ b/force-app/main/default/classes/RD2_ScheduleService_TEST.cls @@ -1755,9 +1755,9 @@ public with sharing class RD2_ScheduleService_TEST { } /*** - * @description Verifies visualized installments are not skipped when DayOfMonth=31, + * @description Verifies visualized installments are not skipped when DayOfMonth=30, * StartDate is in February, and visualization starts from a late day in March. - * The first installment shown should be Mar 31, not skip to Apr 30. + * The first installment shown should be Mar 30, not skip to April. * Regression test for W-19306508. */ @isTest @@ -1770,7 +1770,7 @@ public with sharing class RD2_ScheduleService_TEST { npe03__Recurring_Donation__c rd = getRecurringDonationMonthlyBuilder() .withStartDate(startDate) - .withDayOfMonth('31') + .withDayOfMonth('30') .build(); Test.startTest(); @@ -1784,12 +1784,12 @@ public with sharing class RD2_ScheduleService_TEST { viewDate, 3, schedules); System.assertEquals(3, installments.size(), 'Should return 3 installments'); - System.assertEquals(Date.newInstance(2021, 3, 31), installments[0].nextDonationDate, - 'First visualized installment should be March 31, not skip to April'); + System.assertEquals(Date.newInstance(2021, 3, 30), installments[0].nextDonationDate, + 'First visualized installment should be March 30, not skip to April'); System.assertEquals(Date.newInstance(2021, 4, 30), installments[1].nextDonationDate, 'Second installment should be April 30'); - System.assertEquals(Date.newInstance(2021, 5, 31), installments[2].nextDonationDate, - 'Third installment should be May 31'); + System.assertEquals(Date.newInstance(2021, 5, 30), installments[2].nextDonationDate, + 'Third installment should be May 30'); } /*** From 7801843be8e03e4fce287affdf1561b8f3e859d7 Mon Sep 17 00:00:00 2001 From: Andrew Yu Date: Tue, 12 May 2026 09:41:43 -0700 Subject: [PATCH 5/5] Add detailed comments to getNextDonationDateGreaterThan documenting the 6-step algorithm Co-Authored-By: Claude Opus 4.6 (1M context) --- .../default/classes/RD2_ScheduleService.cls | 36 +++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/force-app/main/default/classes/RD2_ScheduleService.cls b/force-app/main/default/classes/RD2_ScheduleService.cls index a6bf3cb0c08..e40be1187c0 100644 --- a/force-app/main/default/classes/RD2_ScheduleService.cls +++ b/force-app/main/default/classes/RD2_ScheduleService.cls @@ -524,29 +524,57 @@ public without sharing class RD2_ScheduleService { } /*** - * @description Calculates the next donation date >= referenced date + * @description Calculates the next donation date that is on or after the given reference date. + * + * Algorithm: + * 1. Compute the first valid donation date from the schedule's start date (honoring DayOfMonth). + * 2. If that date already meets or exceeds the reference date, return it immediately. + * 3. Otherwise, use arithmetic to fast-forward close to the reference date in one step: + * compute how many period-units (months/days/years) lie between the first valid date and + * the reference date, then jump forward by the largest multiple of the installment frequency + * that fits (integer division, so the result is <= referenceDate). + * 4. For monthly schedules, snap the result to the correct DayOfMonth (handles short months + * and "Last_Day"). + * 5. If the jump landed before the reference date (due to integer division truncation), + * advance by one more installment period and re-snap for monthly. + * 6. For yearly schedules that started on the last day of a short month (e.g. Feb 29), + * ensure the result also lands on the last day of its month. + * * @param schedule Recurring Donation Schedule record * @param referenceDate Reference date used to calculate next donation date. It can be today or a future projected date. - * @return Date The Next Donation Date + * @return Date The Next Donation Date on or after referenceDate */ public Date getNextDonationDateGreaterThan(RecurringDonationSchedule__c schedule, Date referenceDate) { + // Step 1: Determine the earliest possible donation date from the schedule start Date firstValid = getFirstPossibleDonationDateFromStart(schedule); + // Step 2: If the first valid date already satisfies >= referenceDate, no calculation needed if (firstValid >= referenceDate) { return firstValid; } + // Step 3: Fast-forward using arithmetic instead of iterating period by period. + // For weekly schedules, frequency is measured in days (e.g. every 2 weeks = 14 days). + // For monthly/yearly, frequency is in those native units (e.g. every 3 months = 3). Integer adjustedFrequency = (schedule.InstallmentPeriod__c == RD2_Constants.INSTALLMENT_PERIOD_WEEKLY ? DAYS_IN_WEEK : 1) * Integer.valueOf(schedule.InstallmentFrequency__c); + // Compute how many period-units (months, days, or years) separate firstValid from referenceDate Integer unitsBetween = getDateUnitsBetweenDates(firstValid, schedule.InstallmentPeriod__c, referenceDate); + // Jump forward by the largest whole number of installment cycles that fits. + // Integer division truncates, so this lands on or just before the referenceDate. Date adjusted = addDateUnits(firstValid, (unitsBetween / adjustedFrequency) * adjustedFrequency, schedule.InstallmentPeriod__c); + // Step 4: For monthly schedules, addMonths may land on the wrong day of month + // (e.g. Jan 31 + 1 month = Feb 28, but DayOfMonth is "31" aka last day). + // Snap to the correct target day within the landed month. if (schedule.InstallmentPeriod__c == RD2_Constants.INSTALLMENT_PERIOD_MONTHLY && schedule.DayOfMonth__c != null) { adjusted = adjustToTargetDayOfMonth(adjusted, schedule); } + // Step 5: If integer division truncation caused us to land before the referenceDate, + // advance exactly one more installment period and re-snap for monthly. if (adjusted < referenceDate) { adjusted = addDateUnits(adjusted, adjustedFrequency, schedule.InstallmentPeriod__c); @@ -555,6 +583,10 @@ public without sharing class RD2_ScheduleService { } } + // Step 6: Yearly edge case — if the schedule started on the last day of a short month + // (e.g. Feb 29 in a leap year), addYears may land on a different day (Feb 28). + // Detect this by checking if the start day equals the last day of the adjusted month, + // and if so, correct to the actual last day of the target month. if ( schedule.InstallmentPeriod__c == RD2_Constants.INSTALLMENT_PERIOD_YEARLY && schedule.StartDate__c.day() != adjusted.day() &&