| /* |
| * Copyright 2017 The Mifos Initiative. |
| * |
| * Licensed under the Apache License, Version 2.0 (the "License"); |
| * you may not use this file except in compliance with the License. |
| * You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, software |
| * distributed under the License is distributed on an "AS IS" BASIS, |
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| * See the License for the specific language governing permissions and |
| * limitations under the License. |
| */ |
| package io.mifos.individuallending.internal.service; |
| |
| import io.mifos.individuallending.api.v1.domain.caseinstance.CaseParameters; |
| import io.mifos.individuallending.api.v1.domain.workflow.Action; |
| import io.mifos.portfolio.api.v1.domain.PaymentCycle; |
| |
| import javax.annotation.Nonnull; |
| import java.time.*; |
| import java.time.temporal.ChronoUnit; |
| import java.util.*; |
| import java.util.stream.Collectors; |
| import java.util.stream.Stream; |
| |
| /** |
| * @author Myrle Krantz |
| */ |
| @SuppressWarnings("WeakerAccess") |
| public class ScheduledActionHelpers { |
| public static boolean actionHasNoActionPeriod(final Action action) { |
| return preTermActions().anyMatch(x -> action == x) || postTermActions().anyMatch(x -> action == x); |
| } |
| |
| private static Stream<Action> preTermActions() { |
| return Stream.of(Action.OPEN, Action.APPROVE, Action.DISBURSE); |
| } |
| |
| private static Stream<Action> postTermActions() { |
| return Stream.of(Action.CLOSE); |
| } |
| |
| public static List<ScheduledAction> getHypotheticalScheduledActions(final @Nonnull LocalDate startOfTerm, |
| final @Nonnull CaseParameters caseParameters) |
| { |
| final LocalDate endOfTerm = getRoughEndDate(startOfTerm, caseParameters); |
| return Stream.concat( Stream.concat( |
| preTermActions().map(action -> new ScheduledAction(action, startOfTerm)), |
| getHypotheticalScheduledActionsForDisbursedLoan(startOfTerm, endOfTerm, caseParameters)), |
| postTermActions().map(action -> new ScheduledAction(action, endOfTerm))) |
| .collect(Collectors.toList()); |
| } |
| |
| public static ScheduledAction getNextScheduledPayment(final @Nonnull LocalDate startOfTerm, |
| final @Nonnull LocalDate endOfTerm, |
| final @Nonnull CaseParameters caseParameters) { |
| final LocalDate now = LocalDate.now(Clock.systemUTC()); |
| final LocalDate effectiveEndOfTerm = now.isAfter(endOfTerm) ? now : endOfTerm; |
| |
| return getHypotheticalScheduledActionsForDisbursedLoan(startOfTerm, effectiveEndOfTerm, caseParameters) |
| .filter(x -> x.action.equals(Action.ACCEPT_PAYMENT)) |
| .filter(x -> x.actionIsOnOrAfter(now)) |
| .findFirst() |
| .orElseGet(() -> new ScheduledAction(Action.ACCEPT_PAYMENT, now)); |
| } |
| |
| private static Stream<ScheduledAction> getHypotheticalScheduledActionsForDisbursedLoan( |
| final @Nonnull LocalDate startOfTerm, |
| final @Nonnull LocalDate endOfTerm, |
| final @Nonnull CaseParameters caseParameters) |
| { |
| return generateRepaymentPeriods(startOfTerm, endOfTerm, caseParameters) |
| .flatMap(ScheduledActionHelpers::generateScheduledActionsForRepaymentPeriod); |
| } |
| |
| /** 'Rough' end date, because if the repayment period takes the last period after that end date, then the repayment |
| period will 'win'.*/ |
| |
| public static LocalDate getRoughEndDate(final @Nonnull LocalDate startOfTerm, |
| final @Nonnull CaseParameters caseParameters) { |
| final Integer maximumTermSize = caseParameters.getTermRange().getMaximum(); |
| final ChronoUnit termUnit = caseParameters.getTermRange().getTemporalUnit(); |
| |
| return startOfTerm.plus( |
| maximumTermSize, |
| termUnit); |
| } |
| |
| private static Stream<ScheduledAction> generateScheduledActionsForRepaymentPeriod(final @Nonnull Period repaymentPeriod) { |
| return Stream.concat(generateScheduledInterestPaymentsForRepaymentPeriod(repaymentPeriod), |
| Stream.of(new ScheduledAction(Action.ACCEPT_PAYMENT, repaymentPeriod.getEndDate(), repaymentPeriod, repaymentPeriod))); |
| } |
| |
| private static Stream<ScheduledAction> generateScheduledInterestPaymentsForRepaymentPeriod(final @Nonnull Period repaymentPeriod) { |
| return getInterestDayInRepaymentPeriod(repaymentPeriod).map(x -> |
| new ScheduledAction(Action.APPLY_INTEREST, x, new Period(x.minus(1, ChronoUnit.DAYS), x), repaymentPeriod)); |
| } |
| |
| private static Stream<LocalDate> getInterestDayInRepaymentPeriod(final @Nonnull Period repaymentPeriod) { |
| return Stream.iterate(repaymentPeriod.getBeginDate().plusDays(1), date -> date.plusDays(1)) |
| .limit(ChronoUnit.DAYS.between(repaymentPeriod.getBeginDate(), repaymentPeriod.getEndDate())); |
| } |
| |
| private static Stream<Period> generateRepaymentPeriods( |
| final LocalDate startOfTerm, |
| final LocalDate endOfTerm, |
| final CaseParameters caseParameters) { |
| |
| final List<Period> ret = new ArrayList<>(); |
| LocalDate lastPaymentDate = startOfTerm; |
| LocalDate nextPaymentDate = generateNextPaymentDate(caseParameters, lastPaymentDate); |
| while (nextPaymentDate.isBefore(endOfTerm)) |
| { |
| final Period period = new Period(lastPaymentDate, nextPaymentDate); |
| ret.add(period); |
| lastPaymentDate = nextPaymentDate; |
| nextPaymentDate = generateNextPaymentDate(caseParameters, lastPaymentDate); |
| } |
| ret.add(new Period(lastPaymentDate, nextPaymentDate)); |
| |
| return ret.stream(); |
| } |
| |
| private static LocalDate generateNextPaymentDate(final CaseParameters caseParameters, final LocalDate lastPaymentDate) { |
| final PaymentCycle paymentCycle = caseParameters.getPaymentCycle(); |
| |
| final ChronoUnit maximumSpecifiedAlignmentChronoUnit = |
| paymentCycle.getAlignmentMonth() != null ? ChronoUnit.MONTHS : |
| paymentCycle.getAlignmentWeek() != null ? ChronoUnit.WEEKS : |
| paymentCycle.getAlignmentDay() != null ? ChronoUnit.DAYS : |
| ChronoUnit.HOURS; |
| |
| final ChronoUnit maximumPossibleAlignmentChronoUnit = |
| paymentCycle.getTemporalUnit().equals(ChronoUnit.YEARS) ? ChronoUnit.MONTHS : |
| paymentCycle.getTemporalUnit().equals(ChronoUnit.MONTHS) ? ChronoUnit.WEEKS : |
| paymentCycle.getTemporalUnit().equals(ChronoUnit.WEEKS) ? ChronoUnit.DAYS : |
| ChronoUnit.HOURS; //Hours as a placeholder. |
| |
| final ChronoUnit maximumAlignmentChronoUnit = min(maximumSpecifiedAlignmentChronoUnit, maximumPossibleAlignmentChronoUnit); |
| |
| |
| final LocalDate incrementedPaymentDate = incrementPaymentDate(lastPaymentDate, paymentCycle); |
| final LocalDate orientedPaymentDate = orientPaymentDate(incrementedPaymentDate, maximumSpecifiedAlignmentChronoUnit, paymentCycle); |
| return alignPaymentDate(orientedPaymentDate, maximumAlignmentChronoUnit, paymentCycle); |
| } |
| |
| private static LocalDate incrementPaymentDate(final LocalDate paymentDate, final PaymentCycle paymentCycle) { |
| return paymentDate.plus( |
| paymentCycle.getPeriod(), |
| paymentCycle.getTemporalUnit()); |
| } |
| |
| private static LocalDate orientPaymentDate(final LocalDate paymentDate, final ChronoUnit maximumSpecifiedAlignmentChronoUnit, PaymentCycle paymentCycle) { |
| if (maximumSpecifiedAlignmentChronoUnit == ChronoUnit.HOURS) |
| return paymentDate; //No need to orient at all since no alignment is specified. |
| |
| switch (paymentCycle.getTemporalUnit()) |
| { |
| case YEARS: |
| return orientInYear(paymentDate); |
| case MONTHS: |
| return orientInMonth(paymentDate); |
| case WEEKS: |
| return orientInWeek(paymentDate); |
| default: |
| case DAYS: |
| return paymentDate; |
| } |
| } |
| |
| private static @Nonnull ChronoUnit min(@Nonnull final ChronoUnit a, @Nonnull final ChronoUnit b) { |
| if (a.getDuration().compareTo(b.getDuration()) < 0) |
| return a; |
| else |
| return b; |
| } |
| |
| private static LocalDate orientInYear(final LocalDate paymentDate) { |
| return LocalDate.of(paymentDate.getYear(), 1, 1); |
| } |
| |
| private static LocalDate orientInMonth(final LocalDate paymentDate) { |
| return LocalDate.of(paymentDate.getYear(), paymentDate.getMonth(), 1); |
| } |
| |
| private static LocalDate orientInWeek(final LocalDate paymentDate) { |
| final DayOfWeek dayOfWeek = paymentDate.getDayOfWeek(); |
| final int dayOfWeekIndex = dayOfWeek.getValue() - 1; |
| return paymentDate.minusDays(dayOfWeekIndex); |
| } |
| |
| private static LocalDate alignPaymentDate(final LocalDate paymentDate, final ChronoUnit maximumAlignmentChronoUnit, final PaymentCycle paymentCycle) { |
| LocalDate ret = paymentDate; |
| switch (maximumAlignmentChronoUnit) |
| { |
| case MONTHS: |
| ret = alignInMonths(ret, paymentCycle); |
| case WEEKS: |
| ret = alignInWeeks(ret, paymentCycle); |
| case DAYS: |
| ret = alignInDays(ret, paymentCycle); |
| default: |
| case HOURS: |
| return ret; |
| } |
| } |
| |
| private static LocalDate alignInMonths(final LocalDate paymentDate, final PaymentCycle paymentCycle) { |
| final Integer alignmentMonth = paymentCycle.getAlignmentMonth(); |
| if (alignmentMonth == null) |
| return paymentDate; |
| |
| return paymentDate.plusMonths(alignmentMonth); |
| } |
| |
| private static LocalDate alignInWeeks(final LocalDate paymentDate, final PaymentCycle paymentCycle) { |
| final Integer alignmentWeek = paymentCycle.getAlignmentWeek(); |
| if (alignmentWeek == null) |
| return paymentDate; |
| if ((alignmentWeek == 0) || (alignmentWeek == 1) || (alignmentWeek == 2)) |
| return paymentDate.plusWeeks(alignmentWeek); |
| if (alignmentWeek == -1) |
| { |
| final LocalDate lastDayOfMonth = YearMonth.of(paymentDate.getYear(), paymentDate.getMonth()).atEndOfMonth(); |
| int dayOfWeek = lastDayOfMonth.getDayOfWeek().getValue() - 1; |
| if (paymentCycle.getAlignmentDay() == null || dayOfWeek == paymentCycle.getAlignmentDay()) { |
| return lastDayOfMonth; |
| } |
| else |
| return lastDayOfMonth.minus(7, ChronoUnit.DAYS); //Will align days in next step. |
| } |
| |
| throw new IllegalStateException("PaymentCycle.alignmentWeek should only ever be 0, 1, 2, or -1."); |
| } |
| |
| static private LocalDate alignInDays(final LocalDate paymentDate, final PaymentCycle paymentCycle) { |
| final Integer alignmentDay = paymentCycle.getAlignmentDay(); |
| if (alignmentDay == null) |
| return paymentDate; |
| |
| if ((paymentCycle.getAlignmentWeek() != null) || (paymentCycle.getTemporalUnit() == ChronoUnit.WEEKS)) |
| return alignInDaysOfWeek(paymentDate, alignmentDay); |
| else |
| return alignInDaysOfMonth(paymentDate, alignmentDay); |
| } |
| |
| static private LocalDate alignInDaysOfWeek(final LocalDate paymentDate, final Integer alignmentDay) { |
| final int dayOfWeek = paymentDate.getDayOfWeek().getValue()-1; |
| |
| if (dayOfWeek < alignmentDay) |
| return paymentDate.plusDays(alignmentDay - dayOfWeek); |
| else if (dayOfWeek > alignmentDay) |
| return paymentDate.plusDays(7 - (dayOfWeek - alignmentDay)); |
| else |
| return paymentDate; |
| } |
| |
| private static LocalDate alignInDaysOfMonth(final LocalDate paymentDate, final Integer alignmentDay) { |
| final int maxDay = YearMonth.of(paymentDate.getYear(), paymentDate.getMonth()).lengthOfMonth()-1; |
| return paymentDate.plusDays(Math.min(maxDay, alignmentDay)); |
| } |
| |
| public static Optional<Duration> getAccrualPeriodDurationForAction(final Action action) { |
| if (action == Action.APPLY_INTEREST) |
| return Optional.of(ChronoUnit.DAYS.getDuration()); |
| else |
| return Optional.empty(); |
| } |
| } |