diff --git a/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalDelinquencyPause.feature b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalDelinquencyPause.feature index c939db30b2d..34621c50ed9 100644 --- a/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalDelinquencyPause.feature +++ b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalDelinquencyPause.feature @@ -28,6 +28,7 @@ Feature: Working Capital Delinquency Pause And Working Capital loan delinquency range schedule has the following data: | periodNumber | fromDate | toDate | expectedAmount | paidAmount | outstandingAmount | minPaymentCriteriaMet | delinquentAmount | delinquentDays | | 1 | 2026-01-01 | 2026-02-14 | 270.0 | 0.0 | 270.0 | null | null | null | + Then Admin closes the Working Capital loan with all obligations met with a full repayment on "01 January 2026" @TestRailId:C74481 Scenario: Verify working capital loan delinquency pause - UC2: delinquency pause in the middle of first period @@ -60,6 +61,7 @@ Feature: Working Capital Delinquency Pause And Working Capital loan delinquency range schedule has the following data: | periodNumber | fromDate | toDate | expectedAmount | paidAmount | outstandingAmount | minPaymentCriteriaMet | delinquentAmount | delinquentDays | | 1 | 2026-01-01 | 2026-02-10 | 270.0 | 0.0 | 270.0 | null | null | null | + Then Admin closes the Working Capital loan with all obligations met with a full repayment on "15 January 2026" @TestRailId:C74482 Scenario: Verify working capital loan delinquency pause - UC3: backdated delinquency pause, whole pause period before actual business date @@ -92,6 +94,7 @@ Feature: Working Capital Delinquency Pause And Working Capital loan delinquency range schedule has the following data: | periodNumber | fromDate | toDate | expectedAmount | paidAmount | outstandingAmount | minPaymentCriteriaMet | delinquentAmount | delinquentDays | | 1 | 2026-01-01 | 2026-02-14 | 270.0 | 0.0 | 270.0 | null | null | null | + Then Admin closes the Working Capital loan with all obligations met with a full repayment on "15 January 2026" @TestRailId:C74483 Scenario: Verify working capital loan delinquency pause - UC4: backdated delinquency pause, pause period is overlapping actual business date @@ -124,6 +127,7 @@ Feature: Working Capital Delinquency Pause And Working Capital loan delinquency range schedule has the following data: | periodNumber | fromDate | toDate | expectedAmount | paidAmount | outstandingAmount | minPaymentCriteriaMet | delinquentAmount | delinquentDays | | 1 | 2026-01-01 | 2026-02-24 | 270.0 | 0.0 | 270.0 | null | null | null | + Then Admin closes the Working Capital loan with all obligations met with a full repayment on "15 January 2026" @TestRailId:C74484 Scenario: Verify working capital loan delinquency pause - UC5: delinquency pause in the middle of second period @@ -157,6 +161,7 @@ Feature: Working Capital Delinquency Pause | periodNumber | fromDate | toDate | expectedAmount | paidAmount | outstandingAmount | minPaymentCriteriaMet | delinquentAmount | delinquentDays | | 1 | 2026-01-01 | 2026-01-30 | 270.0 | 0.0 | 270.0 | false | 270.0 | 16 | | 2 | 2026-01-31 | 2026-03-12 | 270.0 | 0.0 | 270.0 | null | null | null | + Then Admin closes the Working Capital loan with all obligations met with a full repayment on "15 February 2026" @TestRailId:C74485 Scenario: Verify working capital loan delinquency pause - UC6: delinquency pause in the middle of first period with pause overlapping to second period @@ -200,6 +205,90 @@ Feature: Working Capital Delinquency Pause | periodNumber | fromDate | toDate | expectedAmount | paidAmount | outstandingAmount | minPaymentCriteriaMet | delinquentAmount | delinquentDays | | 1 | 2026-01-01 | 2026-03-13 | 270.0 | 0.0 | 270.0 | false | 270.0 | 2 | | 2 | 2026-03-14 | 2026-04-12 | 270.0 | 0.0 | 270.0 | null | null | null | + Then Admin closes the Working Capital loan with all obligations met with a full repayment on "25 February 2026" + + @TestRailId:C98197 + Scenario: Verify working capital loan delinquency pause - UC6.3: second future-dated pause overlapping an existing future-dated pause results an error (Negative) + When Admin sets the business date to "01 January 2026" + And Admin creates a client with random data + And Admin creates a working capital loan with the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPaymentVolume | periodPaymentRate | discount | + | WCLP | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | 0 | + And Admin successfully approves the working capital loan on "01 January 2026" with "9000" amount and expected disbursement date on "01 January 2026" + Then Working capital loan approval was successful + When Admin successfully disburse the Working Capital loan on "01 January 2026" with "9000" EUR transaction amount + Then Working Capital loan status will be "ACTIVE" + When Admin runs inline COB job for Working Capital Loan by loanId + Then Working Capital loan delinquency range schedule has the following data: + | periodNumber | fromDate | toDate | expectedAmount | paidAmount | outstandingAmount | minPaymentCriteriaMet | delinquentAmount | delinquentDays | + | 1 | 2026-01-01 | 2026-01-30 | 270.0 | 0.0 | 270.0 | null | null | null | + When Admin sets the business date to "15 January 2026" + And Admin runs inline COB job for Working Capital Loan by loanId + And Admin initiate a Working Capital loan delinquency pause with startDate "15 January 2026" and endDate "25 February 2026" + Then Working Capital loan delinquency action has the following data: + | action | startDate | endDate | + | PAUSE | 2026-01-15 | 2026-02-25 | + And Working Capital loan delinquency range schedule has the following data: + | periodNumber | fromDate | toDate | expectedAmount | paidAmount | outstandingAmount | minPaymentCriteriaMet | delinquentAmount | delinquentDays | + | 1 | 2026-01-01 | 2026-03-13 | 270.0 | 0.0 | 270.0 | null | null | null | + When Admin initiate a Working Capital loan delinquency pause with startDate "17 March 2026" and endDate "24 March 2026" + Then Working Capital loan delinquency action has the following data: + | action | startDate | endDate | + | PAUSE | 2026-01-15 | 2026-02-25 | + | PAUSE | 2026-03-17 | 2026-03-24 | + Then Initiating a Working Capital loan delinquency pause with startDate "17 March 2026" and endDate "30 March 2026" results an error with the following data: + | httpCode | errorMessage | + | 400 | Delinquency pause period cannot overlap with another pause period | + And Working Capital loan delinquency action has the following data: + | action | startDate | endDate | + | PAUSE | 2026-01-15 | 2026-02-25 | + | PAUSE | 2026-03-17 | 2026-03-24 | + Then Admin closes the Working Capital loan with all obligations met with a full repayment on "15 January 2026" + + @TestRailId:C98198 + Scenario: Verify working capital loan delinquency pause - UC6.4: backdated delinquency pause overlaps to an existing pause results an error (Negative) + When Admin sets the business date to "01 January 2026" + And Admin creates a client with random data + And Admin creates a working capital loan with the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPaymentVolume | periodPaymentRate | discount | + | WCLP | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | 0 | + And Admin successfully approves the working capital loan on "01 January 2026" with "9000" amount and expected disbursement date on "01 January 2026" + Then Working capital loan approval was successful + When Admin successfully disburse the Working Capital loan on "01 January 2026" with "9000" EUR transaction amount + Then Working Capital loan status will be "ACTIVE" + When Admin runs inline COB job for Working Capital Loan by loanId + Then Working Capital loan delinquency range schedule has the following data: + | periodNumber | fromDate | toDate | expectedAmount | paidAmount | outstandingAmount | minPaymentCriteriaMet | delinquentAmount | delinquentDays | + | 1 | 2026-01-01 | 2026-01-30 | 270.0 | 0.0 | 270.0 | null | null | null | + When Admin sets the business date to "15 January 2026" + And Admin runs inline COB job for Working Capital Loan by loanId + And Admin initiate a Working Capital loan delinquency pause with startDate "15 January 2026" and endDate "25 February 2026" + Then Working Capital loan delinquency action has the following data: + | action | startDate | endDate | + | PAUSE | 2026-01-15 | 2026-02-25 | + And Working Capital loan delinquency range schedule has the following data: + | periodNumber | fromDate | toDate | expectedAmount | paidAmount | outstandingAmount | minPaymentCriteriaMet | delinquentAmount | delinquentDays | + | 1 | 2026-01-01 | 2026-03-13 | 270.0 | 0.0 | 270.0 | null | null | null | + And Admin initiate a Working Capital loan delinquency pause with startDate "05 January 2026" and endDate "10 January 2026" + Then Working Capital loan delinquency action has the following data: + | action | startDate | endDate | + | PAUSE | 2026-01-15 | 2026-02-25 | + | PAUSE | 2026-01-05 | 2026-01-10 | + Then Initiating a Working Capital loan delinquency pause with startDate "05 January 2026" and endDate "14 January 2026" results an error with the following data: + | httpCode | errorMessage | + | 400 | Delinquency pause period cannot overlap with another pause period | + Then Working Capital loan delinquency action has the following data: + | action | startDate | endDate | + | PAUSE | 2026-01-15 | 2026-02-25 | + | PAUSE | 2026-01-05 | 2026-01-10 | + Then Initiating a Working Capital loan delinquency pause with startDate "02 January 2026" and endDate "14 January 2026" results an error with the following data: + | httpCode | errorMessage | + | 400 | Delinquency pause period cannot overlap with another pause period | + Then Working Capital loan delinquency action has the following data: + | action | startDate | endDate | + | PAUSE | 2026-01-15 | 2026-02-25 | + | PAUSE | 2026-01-05 | 2026-01-10 | + Then Admin closes the Working Capital loan with all obligations met with a full repayment on "15 January 2026" @TestRailId:C78810 Scenario: Verify working capital loan delinquency pause - UC6.1: delinquency pause in the middle of first period with future pauses overlapping to a few periods @@ -263,6 +352,7 @@ Feature: Working Capital Delinquency Pause | 1 | 2026-01-01 | 2026-03-13 | 270.0 | 0.0 | 270.0 | false | 270.0 | 49 | | 2 | 2026-03-14 | 2026-04-17 | 270.0 | 0.0 | 270.0 | false | 270.0 | 14 | | 3 | 2026-04-18 | 2026-05-25 | 270.0 | 0.0 | 270.0 | null | null | null | + Then Admin closes the Working Capital loan with all obligations met with a full repayment on "01 May 2026" @TestRailId:C78811 Scenario: Verify working capital loan delinquency pause - UC6.2: delinquency pause in the middle of first period with pauses overlapping to a few periods @@ -341,6 +431,7 @@ Feature: Working Capital Delinquency Pause | 1 | 2026-01-01 | 2026-03-13 | 270.0 | 0.0 | 270.0 | false | 270.0 | 49 | | 2 | 2026-03-14 | 2026-04-17 | 270.0 | 0.0 | 270.0 | false | 270.0 | 14 | | 3 | 2026-04-18 | 2026-05-25 | 270.0 | 0.0 | 270.0 | null | null | null | + Then Admin closes the Working Capital loan with all obligations met with a full repayment on "01 May 2026" @TestRailId:C74486 Scenario: Verify working capital loan delinquency pause - UC7: backdated delinquency pause into an already evaluated period shifts and re-evaluates the schedule @@ -384,6 +475,7 @@ Feature: Working Capital Delinquency Pause | periodNumber | fromDate | toDate | expectedAmount | paidAmount | outstandingAmount | minPaymentCriteriaMet | delinquentAmount | delinquentDays | | 1 | 2026-01-01 | 2026-02-26 | 270.0 | 0.0 | 270.0 | false | 270.0 | 1 | | 2 | 2026-02-27 | 2026-03-28 | 270.0 | 0.0 | 270.0 | null | null | null | + Then Admin closes the Working Capital loan with all obligations met with a full repayment on "27 February 2026" @TestRailId:C89782 Scenario: Verify working capital loan delinquency pause - UC11: backdated pause into an evaluated period shifts subsequent generated periods and reopens them until re-evaluated @@ -420,6 +512,7 @@ Feature: Working Capital Delinquency Pause | periodNumber | fromDate | toDate | expectedAmount | paidAmount | outstandingAmount | minPaymentCriteriaMet | delinquentAmount | delinquentDays | | 1 | 2026-01-01 | 2026-02-27 | 270.0 | 0.0 | 270.0 | false | 270.0 | 1 | | 2 | 2026-02-28 | 2026-03-29 | 270.0 | 0.0 | 270.0 | null | null | null | + Then Admin closes the Working Capital loan with all obligations met with a full repayment on "28 February 2026" @TestRailId:C89783 Scenario: Verify working capital loan delinquency pause - UC12: backdated pause into a delinquent period lifts the loan's delinquency classification until re-evaluation @@ -462,6 +555,7 @@ Feature: Working Capital Delinquency Pause And Working capital loan account has the correct data: | delinquencyStartDate | | 2026-01-01 | + Then Admin closes the Working Capital loan with all obligations met with a full repayment on "28 February 2026" @TestRailId:C74487 Scenario: Verify working capital loan delinquency pause - UC8: multiple delinquency pauses in the same period @@ -504,6 +598,7 @@ Feature: Working Capital Delinquency Pause And Working Capital loan delinquency range schedule has the following data: | periodNumber | fromDate | toDate | expectedAmount | paidAmount | outstandingAmount | minPaymentCriteriaMet | delinquentAmount | delinquentDays | | 1 | 2026-01-01 | 2026-02-11 | 270.0 | 0.0 | 270.0 | null | null | null | + Then Admin closes the Working Capital loan with all obligations met with a full repayment on "25 January 2026" @TestRailId:C74488 Scenario: Verify working capital loan delinquency pause - UC9: second delinquency pause overlaps with first pause period results an error (Negative) @@ -541,6 +636,7 @@ Feature: Working Capital Delinquency Pause Then Initiating a Working Capital loan delinquency pause with startDate "20 January 2026" and endDate "30 January 2026" results an error with the following data: | httpCode | errorMessage | | 400 | Delinquency pause period cannot overlap with another pause period | + Then Admin closes the Working Capital loan with all obligations met with a full repayment on "20 January 2026" @TestRailId:C74489 Scenario: Verify working capital loan delinquency pause - UC10: delinquency pause by external ID in the middle of first period @@ -573,6 +669,7 @@ Feature: Working Capital Delinquency Pause And Working Capital loan delinquency range schedule has the following data: | periodNumber | fromDate | toDate | expectedAmount | paidAmount | outstandingAmount | minPaymentCriteriaMet | delinquentAmount | delinquentDays | | 1 | 2026-01-01 | 2026-02-10 | 270.0 | 0.0 | 270.0 | null | null | null | + Then Admin closes the Working Capital loan with all obligations met with a full repayment on "15 January 2026" @TestRailId:C85313 Scenario: Verify working capital loan delinquency pause - touching consecutive pauses are rejected (Negative) @@ -595,3 +692,4 @@ Feature: Working Capital Delinquency Pause Then Initiating a Working Capital loan delinquency pause with startDate "25 January 2026" and endDate "05 February 2026" results an error with the following data: | httpCode | errorMessage | | 400 | Delinquency pause period cannot overlap with another pause period | + Then Admin closes the Working Capital loan with all obligations met with a full repayment on "25 January 2026" diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/WorkingCapitalLoanDelinquencyActionIntegrationTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/WorkingCapitalLoanDelinquencyActionIntegrationTest.java index 8ab92be3f5e..1140e965263 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/WorkingCapitalLoanDelinquencyActionIntegrationTest.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/WorkingCapitalLoanDelinquencyActionIntegrationTest.java @@ -57,6 +57,7 @@ public class WorkingCapitalLoanDelinquencyActionIntegrationTest { private static final int PERIOD_FREQUENCY_DAYS = 30; + private static final String OVERLAP_ERROR_MESSAGE = "Delinquency pause period cannot overlap with another pause period"; private final WorkingCapitalLoanHelper applicationHelper = new WorkingCapitalLoanHelper(); private final WorkingCapitalLoanProductHelper productHelper = new WorkingCapitalLoanProductHelper(); @@ -481,8 +482,316 @@ public void testDelinquencyActionWithNonExistentExternalIdReturns404() { assertEquals(404, getException.getStatus()); } + /** + * With an existing future-dated pause, a second future-dated pause sharing the same startDate but a longer endDate + * must be rejected with 400 and the overlap message. + */ + @Test + public void testFutureDatedOverlappingPauseSameStartLongerEndIsRejected() { + final Long[] loanIdHolder = new Long[1]; + final LocalDate businessDate = LocalDate.of(2026, 7, 1); + + BusinessDateHelper.runAt("17 June 2026", () -> loanIdHolder[0] = createActiveLoan(businessDate.minusDays(14))); + + BusinessDateHelper.runAt("01 July 2026", () -> { + // given - an existing pause entirely in the future relative to the business date + final LocalDate pause1Start = businessDate.plusDays(61); + final LocalDate pause1End = businessDate.plusDays(68); + WorkingCapitalLoanDelinquencyActionHelper.createDelinquencyAction(loanIdHolder[0], "pause", pause1Start, pause1End); + + // when - a second future-dated pause with the same start but a longer end + // then - rejected with 400 + overlap message + assertPauseRejectedAsOverlapping(loanIdHolder[0], pause1Start, businessDate.plusDays(74)); + + // and - only the original pause is persisted, with its dates unchanged + final List actions = WorkingCapitalLoanDelinquencyActionHelper + .retrieveDelinquencyActions(loanIdHolder[0]); + assertEquals(1, actions.size(), "The rejected overlapping pause must not be persisted"); + assertEquals(WorkingCapitalLoanDelinquencyActionData.ActionEnum.PAUSE, actions.getFirst().getAction()); + assertEquals(pause1Start, actions.getFirst().getStartDate(), "Existing pause startDate must be unchanged"); + assertEquals(pause1End, actions.getFirst().getEndDate(), "Existing pause endDate must be unchanged"); + }); + } + + /** + * A future-dated pause identical to an existing one (same startDate and endDate) must be rejected with 400 and the + * overlap message. + */ + @Test + public void testFutureDatedIdenticalDuplicatePauseIsRejected() { + final Long[] loanIdHolder = new Long[1]; + final LocalDate businessDate = LocalDate.of(2026, 7, 1); + + BusinessDateHelper.runAt("17 June 2026", () -> loanIdHolder[0] = createActiveLoan(businessDate.minusDays(14))); + + BusinessDateHelper.runAt("01 July 2026", () -> { + // given + final LocalDate pause1Start = businessDate.plusDays(61); + final LocalDate pause1End = businessDate.plusDays(68); + WorkingCapitalLoanDelinquencyActionHelper.createDelinquencyAction(loanIdHolder[0], "pause", pause1Start, pause1End); + + // when / then - an identical duplicate pause is rejected + assertPauseRejectedAsOverlapping(loanIdHolder[0], pause1Start, pause1End); + + final List actions = WorkingCapitalLoanDelinquencyActionHelper + .retrieveDelinquencyActions(loanIdHolder[0]); + assertEquals(1, actions.size(), "The rejected duplicate pause must not be persisted"); + assertEquals(pause1Start, actions.getFirst().getStartDate(), "Existing pause startDate must be unchanged"); + assertEquals(pause1End, actions.getFirst().getEndDate(), "Existing pause endDate must be unchanged"); + }); + } + + /** + * With an existing backdated pause, a second backdated pause sharing the same startDate but a different endDate + * must be rejected with 400 and the overlap message. + */ + @Test + public void testBackdatedOverlappingPauseSameStartDifferentEndIsRejected() { + final Long[] loanIdHolder = new Long[1]; + final LocalDate businessDate = LocalDate.of(2026, 7, 1); + + BusinessDateHelper.runAt("17 June 2026", () -> loanIdHolder[0] = createActiveLoan(businessDate.minusDays(14))); + + BusinessDateHelper.runAt("01 July 2026", () -> { + // given - an existing pause entirely in the past relative to the business date + final LocalDate pause1Start = businessDate.minusDays(10); + final LocalDate pause1End = businessDate.minusDays(5); + WorkingCapitalLoanDelinquencyActionHelper.createDelinquencyAction(loanIdHolder[0], "pause", pause1Start, pause1End); + + // when / then - a backdated pause with the same start but a different end is rejected + assertPauseRejectedAsOverlapping(loanIdHolder[0], pause1Start, businessDate.minusDays(1)); + + final List actions = WorkingCapitalLoanDelinquencyActionHelper + .retrieveDelinquencyActions(loanIdHolder[0]); + assertEquals(1, actions.size(), "The rejected overlapping backdated pause must not be persisted"); + assertEquals(pause1Start, actions.getFirst().getStartDate(), "Existing pause startDate must be unchanged"); + assertEquals(pause1End, actions.getFirst().getEndDate(), "Existing pause endDate must be unchanged"); + }); + } + + /** + * A new pause starting exactly on an existing pause's endDate (inclusive boundary) must be rejected with 400 and + * the overlap message. + */ + @Test + public void testBackdatedPauseStartingOnExistingPauseEndIsRejected() { + final Long[] loanIdHolder = new Long[1]; + final LocalDate businessDate = LocalDate.of(2026, 7, 1); + + BusinessDateHelper.runAt("17 June 2026", () -> loanIdHolder[0] = createActiveLoan(businessDate.minusDays(14))); + + BusinessDateHelper.runAt("01 July 2026", () -> { + // given + final LocalDate pause1Start = businessDate.minusDays(10); + final LocalDate pause1End = businessDate.minusDays(5); + WorkingCapitalLoanDelinquencyActionHelper.createDelinquencyAction(loanIdHolder[0], "pause", pause1Start, pause1End); + + // when / then - a new pause starting on the existing pause's endDate is rejected (inclusive intervals) + assertPauseRejectedAsOverlapping(loanIdHolder[0], pause1End, businessDate.plusDays(3)); + + final List actions = WorkingCapitalLoanDelinquencyActionHelper + .retrieveDelinquencyActions(loanIdHolder[0]); + assertEquals(1, actions.size(), "The rejected boundary-overlapping pause must not be persisted"); + assertEquals(pause1Start, actions.getFirst().getStartDate(), "Existing pause startDate must be unchanged"); + assertEquals(pause1End, actions.getFirst().getEndDate(), "Existing pause endDate must be unchanged"); + }); + } + + /** + * A new pause ending exactly on an existing pause's startDate (inclusive boundary) must be rejected with 400 and + * the overlap message. + */ + @Test + public void testPauseEndingOnExistingPauseStartIsRejected() { + final Long[] loanIdHolder = new Long[1]; + final LocalDate businessDate = LocalDate.of(2026, 7, 1); + + BusinessDateHelper.runAt("17 June 2026", () -> loanIdHolder[0] = createActiveLoan(businessDate.minusDays(14))); + + BusinessDateHelper.runAt("01 July 2026", () -> { + // given + final LocalDate pause1Start = businessDate.plusDays(10); + final LocalDate pause1End = businessDate.plusDays(15); + WorkingCapitalLoanDelinquencyActionHelper.createDelinquencyAction(loanIdHolder[0], "pause", pause1Start, pause1End); + + // when / then - a new pause ending on the existing pause's startDate is rejected (inclusive intervals) + assertPauseRejectedAsOverlapping(loanIdHolder[0], businessDate.plusDays(5), pause1Start); + + final List actions = WorkingCapitalLoanDelinquencyActionHelper + .retrieveDelinquencyActions(loanIdHolder[0]); + assertEquals(1, actions.size(), "The rejected boundary-overlapping pause must not be persisted"); + assertEquals(pause1Start, actions.getFirst().getStartDate(), "Existing pause startDate must be unchanged"); + assertEquals(pause1End, actions.getFirst().getEndDate(), "Existing pause endDate must be unchanged"); + }); + } + + /** + * A new pause fully containing an existing pause must be rejected with 400 and the overlap message. + */ + @Test + public void testPauseFullyContainingExistingPauseIsRejected() { + final Long[] loanIdHolder = new Long[1]; + final LocalDate businessDate = LocalDate.of(2026, 7, 1); + + BusinessDateHelper.runAt("17 June 2026", () -> loanIdHolder[0] = createActiveLoan(businessDate.minusDays(14))); + + BusinessDateHelper.runAt("01 July 2026", () -> { + // given + final LocalDate pause1Start = businessDate.plusDays(10); + final LocalDate pause1End = businessDate.plusDays(15); + WorkingCapitalLoanDelinquencyActionHelper.createDelinquencyAction(loanIdHolder[0], "pause", pause1Start, pause1End); + + // when / then - a new pause that fully contains the existing one is rejected + assertPauseRejectedAsOverlapping(loanIdHolder[0], businessDate.plusDays(5), businessDate.plusDays(20)); + + final List actions = WorkingCapitalLoanDelinquencyActionHelper + .retrieveDelinquencyActions(loanIdHolder[0]); + assertEquals(1, actions.size(), "The rejected containing pause must not be persisted"); + assertEquals(pause1Start, actions.getFirst().getStartDate(), "Existing pause startDate must be unchanged"); + assertEquals(pause1End, actions.getFirst().getEndDate(), "Existing pause endDate must be unchanged"); + }); + } + + /** + * A new pause fully inside an existing pause must be rejected with 400 and the overlap message. + */ + @Test + public void testPauseFullyInsideExistingPauseIsRejected() { + final Long[] loanIdHolder = new Long[1]; + final LocalDate businessDate = LocalDate.of(2026, 7, 1); + + BusinessDateHelper.runAt("17 June 2026", () -> loanIdHolder[0] = createActiveLoan(businessDate.minusDays(14))); + + BusinessDateHelper.runAt("01 July 2026", () -> { + // given + final LocalDate pause1Start = businessDate.plusDays(5); + final LocalDate pause1End = businessDate.plusDays(20); + WorkingCapitalLoanDelinquencyActionHelper.createDelinquencyAction(loanIdHolder[0], "pause", pause1Start, pause1End); + + // when / then - a new pause entirely inside the existing one is rejected + assertPauseRejectedAsOverlapping(loanIdHolder[0], businessDate.plusDays(10), businessDate.plusDays(15)); + + final List actions = WorkingCapitalLoanDelinquencyActionHelper + .retrieveDelinquencyActions(loanIdHolder[0]); + assertEquals(1, actions.size(), "The rejected contained pause must not be persisted"); + assertEquals(pause1Start, actions.getFirst().getStartDate(), "Existing pause startDate must be unchanged"); + assertEquals(pause1End, actions.getFirst().getEndDate(), "Existing pause endDate must be unchanged"); + }); + } + + /** + * Two future-dated pauses with a 1-day gap between the inclusive periods must both be accepted and persisted with + * their exact dates. + */ + @Test + public void testAdjacentFutureDatedPausesAreAccepted() { + final Long[] loanIdHolder = new Long[1]; + final LocalDate businessDate = LocalDate.of(2026, 7, 1); + + BusinessDateHelper.runAt("17 June 2026", () -> loanIdHolder[0] = createActiveLoan(businessDate.minusDays(14))); + + BusinessDateHelper.runAt("01 July 2026", () -> { + // given + final LocalDate pause1Start = businessDate.plusDays(10); + final LocalDate pause1End = businessDate.plusDays(15); + WorkingCapitalLoanDelinquencyActionHelper.createDelinquencyAction(loanIdHolder[0], "pause", pause1Start, pause1End); + + // when - the second pause starts the day after the first pause ends + final LocalDate pause2Start = businessDate.plusDays(16); + final LocalDate pause2End = businessDate.plusDays(20); + final PostWorkingCapitalLoansDelinquencyActionResponse result = WorkingCapitalLoanDelinquencyActionHelper + .createDelinquencyAction(loanIdHolder[0], "pause", pause2Start, pause2End); + assertNotNull(result, "Adjacent non-overlapping future-dated pause must be accepted"); + + // then - both pauses persisted with their exact dates + final List actions = WorkingCapitalLoanDelinquencyActionHelper + .retrieveDelinquencyActions(loanIdHolder[0]); + assertEquals(2, actions.size(), "Both adjacent future-dated pauses must be persisted"); + assertEquals(WorkingCapitalLoanDelinquencyActionData.ActionEnum.PAUSE, actions.get(0).getAction()); + assertEquals(pause1Start, actions.get(0).getStartDate()); + assertEquals(pause1End, actions.get(0).getEndDate()); + assertEquals(WorkingCapitalLoanDelinquencyActionData.ActionEnum.PAUSE, actions.get(1).getAction()); + assertEquals(pause2Start, actions.get(1).getStartDate()); + assertEquals(pause2End, actions.get(1).getEndDate()); + }); + } + + /** + * Once a pause is resumed, its blocked window ends at the RESUME-aware effective end date. A new pause starting the + * day after the effective end must be accepted (loan A); a new pause starting exactly on the effective end must be + * rejected as overlapping (loan B). + */ + @Test + public void testPauseAfterResumeRespectsEffectiveEndBoundary() { + final Long[] loanAHolder = new Long[1]; + final Long[] loanBHolder = new Long[1]; + final LocalDate businessDate = LocalDate.of(2026, 7, 1); + final LocalDate pause1Start = businessDate.minusDays(5); + final LocalDate pause1End = businessDate.plusDays(10); + + // given - two loans, each with an active pause [D-5, D+10] created before the resume date + BusinessDateHelper.runAt("26 June 2026", () -> { + loanAHolder[0] = createActiveLoan(businessDate.minusDays(14)); + loanBHolder[0] = createActiveLoan(businessDate.minusDays(14)); + WorkingCapitalLoanDelinquencyActionHelper.createDelinquencyAction(loanAHolder[0], "pause", pause1Start, pause1End); + WorkingCapitalLoanDelinquencyActionHelper.createDelinquencyAction(loanBHolder[0], "pause", pause1Start, pause1End); + }); + + BusinessDateHelper.runAt("01 July 2026", () -> { + // and - both pauses are resumed on the business date D, shortening the blocked window to [D-5, D] + WorkingCapitalLoanDelinquencyActionHelper.createDelinquencyAction(loanAHolder[0], "resume", businessDate, null); + WorkingCapitalLoanDelinquencyActionHelper.createDelinquencyAction(loanBHolder[0], "resume", businessDate, null); + + // when (a) - loan A: a new pause starting the day after the effective end (D+1) is accepted + final LocalDate pause2Start = businessDate.plusDays(1); + final LocalDate pause2End = businessDate.plusDays(8); + final PostWorkingCapitalLoansDelinquencyActionResponse result = WorkingCapitalLoanDelinquencyActionHelper + .createDelinquencyAction(loanAHolder[0], "pause", pause2Start, pause2End); + assertNotNull(result, "Pause starting the day after the resumed pause's effective end must be accepted"); + + // then (a) - loan A has 2 PAUSE rows with exact dates, first one shortened via effectiveEndDate = D + final List loanAActions = WorkingCapitalLoanDelinquencyActionHelper + .retrieveDelinquencyActions(loanAHolder[0]); + final List loanAPauses = loanAActions.stream() + .filter(a -> WorkingCapitalLoanDelinquencyActionData.ActionEnum.PAUSE == a.getAction()).toList(); + assertEquals(2, loanAPauses.size(), "Loan A must have exactly 2 persisted PAUSE rows"); + assertEquals(pause1Start, loanAPauses.get(0).getStartDate()); + assertEquals(pause1End, loanAPauses.get(0).getEndDate(), "Original endDate of the resumed pause must be unchanged"); + assertEquals(businessDate, loanAPauses.get(0).getEffectiveEndDate(), + "Resumed pause's effectiveEndDate must equal the resume date D"); + assertEquals(pause2Start, loanAPauses.get(1).getStartDate()); + assertEquals(pause2End, loanAPauses.get(1).getEndDate()); + + // when/then (b) - loan B: a new pause starting exactly on the effective end (D) is rejected as overlap + assertPauseRejectedAsOverlapping(loanBHolder[0], businessDate, businessDate.plusDays(8)); + + final List loanBPauses = WorkingCapitalLoanDelinquencyActionHelper + .retrieveDelinquencyActions(loanBHolder[0]).stream() + .filter(a -> WorkingCapitalLoanDelinquencyActionData.ActionEnum.PAUSE == a.getAction()).toList(); + assertEquals(1, loanBPauses.size(), "Loan B must still have only the original PAUSE row"); + }); + } + // ===================== Helper Methods ===================== + private void assertPauseRejectedAsOverlapping(final Long loanId, final LocalDate startDate, final LocalDate endDate) { + final CallFailedRuntimeException exception = assertThrows(CallFailedRuntimeException.class, + () -> WorkingCapitalLoanDelinquencyActionHelper.createDelinquencyAction(loanId, "pause", startDate, endDate)); + assertEquals(400, exception.getStatus(), "Overlapping pause must be rejected with HTTP 400"); + assertNotNull(exception.getResponseBody(), "Overlap rejection must carry an error body"); + assertTrue(exception.getResponseBody().contains(OVERLAP_ERROR_MESSAGE), + "Error body must contain '" + OVERLAP_ERROR_MESSAGE + "' but was: " + exception.getResponseBody()); + } + + private Long createActiveLoan(final LocalDate disbursementDate) { + final Long bucketId = createWorkingCapitalLoanDelinquencyBucket(PERIOD_FREQUENCY_DAYS); + final Long productId = createProduct(bucketId); + final Long clientId = createClient(); + final Long loanId = submitAndApproveLoan(clientId, productId); + WorkingCapitalLoanDelinquencyActionHelper.activateLoan(loanId, disbursementDate); + return loanId; + } + private Long createWorkingCapitalLoanDelinquencyBucket(final int frequencyDays) { final PostDelinquencyRangeResponse range1 = DelinquencyRangesHelper.createRange(new DelinquencyRangeRequest() .classification(Utils.randomStringGenerator("DLQ_R_", 10)).minimumAgeDays(1).maximumAgeDays(30).locale("en"));