diff --git a/force-app/main/default/classes/RD2_OpportunityEvaluationService_TEST.cls b/force-app/main/default/classes/RD2_OpportunityEvaluationService_TEST.cls index 41b8262c64..5a7d5b3fa2 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 + * month rather than skipping to the next month. + * Regression test for W-19306508. + */ + @isTest + 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, 30); + + RD2_ScheduleService.currentDate = today; + RD2_EnablementService_TEST.setRecurringDonations2Enabled(); + + npe03__Recurring_Donation__c rd = getRecurringDonationBuilder(getContact()) + .withStartDate(startDate) + .withDayOfMonth('30') + .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 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_OpportunityEvaluation_TEST.cls b/force-app/main/default/classes/RD2_OpportunityEvaluation_TEST.cls index 954b96474b..2ba97a2b53 100644 --- a/force-app/main/default/classes/RD2_OpportunityEvaluation_TEST.cls +++ b/force-app/main/default/classes/RD2_OpportunityEvaluation_TEST.cls @@ -1355,6 +1355,51 @@ 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=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 shouldNotSkipMonthForNextOppWhenBatchRunsBeforeDayOfMonthWithFebStart() { + 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('30') + .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 (day 30 clamped to end of Feb)'); + + 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)); + + 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(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.cls b/force-app/main/default/classes/RD2_ScheduleService.cls index d71d09c325..e40be1187c 100644 --- a/force-app/main/default/classes/RD2_ScheduleService.cls +++ b/force-app/main/default/classes/RD2_ScheduleService.cls @@ -524,45 +524,69 @@ 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); - } - 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); } + // 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() && @@ -620,6 +644,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 diff --git a/force-app/main/default/classes/RD2_ScheduleService_TEST.cls b/force-app/main/default/classes/RD2_ScheduleService_TEST.cls index 2af0b82a2f..55786430d9 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. */ @@ -1587,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=30, + * StartDate is in February, and visualization starts from a late day in March. + * The first installment shown should be Mar 30, not skip to April. + * 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('30') + .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, 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, 30), installments[2].nextDonationDate, + 'Third installment should be May 30'); + } + /*** * @description Verifies visualization works as expected across short month with day of month > days in short month */