Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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<Opportunity> 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
///////////////////

Expand Down
45 changes: 45 additions & 0 deletions force-app/main/default/classes/RD2_OpportunityEvaluation_TEST.cls
Original file line number Diff line number Diff line change
Expand Up @@ -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<Opportunity> 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
//////////////

Expand Down
74 changes: 58 additions & 16 deletions force-app/main/default/classes/RD2_ScheduleService.cls
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Comment thread
force2b marked this conversation as resolved.
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() &&
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading