diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/account/api/StandingInstructionApiConstants.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/account/api/StandingInstructionApiConstants.java index 50fce06b097..2c6606fbdf0 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/account/api/StandingInstructionApiConstants.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/account/api/StandingInstructionApiConstants.java @@ -38,5 +38,6 @@ private StandingInstructionApiConstants() { public static final String recurrenceIntervalParamName = "recurrenceInterval"; public static final String recurrenceOnMonthDayParamName = "recurrenceOnMonthDay"; public static final String monthDayFormatParamName = "monthDayFormat"; + public static final String allowPartialTransferParamName = "allowPartialTransfer"; } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/account/api/StandingInstructionApiResourceSwagger.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/account/api/StandingInstructionApiResourceSwagger.java index aecdd54ac81..a9493c817fc 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/account/api/StandingInstructionApiResourceSwagger.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/account/api/StandingInstructionApiResourceSwagger.java @@ -433,6 +433,8 @@ private GetRecurrenceFrequencyStandingInstructionSwagger() {} public Integer recurrenceInterval; @Schema(example = "[4, 3]") public LocalDate recurrenceOnMonthDay; + @Schema(example = "false") + public Boolean allowPartialTransfer; } @Schema(example = "2") diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/account/data/StandingInstructionData.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/account/data/StandingInstructionData.java index 9ceaad0dc24..5872b8fe31a 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/account/data/StandingInstructionData.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/account/data/StandingInstructionData.java @@ -89,6 +89,8 @@ public final class StandingInstructionData { @Getter private final Integer recurrenceInterval; private final MonthDay recurrenceOnMonthDay; + @Getter + private final boolean allowPartialTransfer; private final Page transactions; private final Collection fromOfficeOptions; @@ -137,14 +139,15 @@ public static StandingInstructionData template(final Collection from final EnumOptionData recurrenceFrequency = null; final Integer recurrenceInterval = null; final MonthDay recurrenceOnMonthDay = null; + final boolean allowPartialTransfer = false; final Page transactions = null; return new StandingInstructionData(id, accountDetailId, name, fromOffice, fromClient, fromAccountType, fromAccount, toOffice, toClient, toAccountType, toAccount, transferType, priority, instructionType, status, amount, validFrom, validTill, - recurrenceType, recurrenceFrequency, recurrenceInterval, recurrenceOnMonthDay, transactions, fromOfficeOptions, - fromClientOptions, fromAccountTypeOptions, fromAccountOptions, toOfficeOptions, toClientOptions, toAccountTypeOptions, - toAccountOptions, transferTypeOptions, statusOptions, instructionTypeOptions, priorityOptions, recurrenceTypeOptions, - recurrenceFrequencyOptions); + recurrenceType, recurrenceFrequency, recurrenceInterval, recurrenceOnMonthDay, allowPartialTransfer, transactions, + fromOfficeOptions, fromClientOptions, fromAccountTypeOptions, fromAccountOptions, toOfficeOptions, toClientOptions, + toAccountTypeOptions, toAccountOptions, transferTypeOptions, statusOptions, instructionTypeOptions, priorityOptions, + recurrenceTypeOptions, recurrenceFrequencyOptions); } public static StandingInstructionData instance(final Long id, final Long accountDetailId, final String name, @@ -153,7 +156,7 @@ public static StandingInstructionData instance(final Long id, final Long account final PortfolioAccountData toAccount, final EnumOptionData transferType, final EnumOptionData priority, final EnumOptionData instructionType, final EnumOptionData status, final BigDecimal amount, final LocalDate validFrom, final LocalDate validTill, final EnumOptionData recurrenceType, final EnumOptionData recurrenceFrequency, - final Integer recurrenceInterval, final MonthDay recurrenceOnMonthDay) { + final Integer recurrenceInterval, final MonthDay recurrenceOnMonthDay, final boolean allowPartialTransfer) { final Page transactions = null; final Collection fromOfficeOptions = null; final Collection fromClientOptions = null; @@ -172,10 +175,10 @@ public static StandingInstructionData instance(final Long id, final Long account return new StandingInstructionData(id, accountDetailId, name, fromOffice, fromClient, fromAccountType, fromAccount, toOffice, toClient, toAccountType, toAccount, transferType, priority, instructionType, status, amount, validFrom, validTill, - recurrenceType, recurrenceFrequency, recurrenceInterval, recurrenceOnMonthDay, transactions, fromOfficeOptions, - fromClientOptions, fromAccountTypeOptions, fromAccountOptions, toOfficeOptions, toClientOptions, toAccountTypeOptions, - toAccountOptions, transferTypeOptions, statusOptions, instructionTypeOptions, priorityOptions, recurrenceTypeOptions, - recurrenceFrequencyOptions); + recurrenceType, recurrenceFrequency, recurrenceInterval, recurrenceOnMonthDay, allowPartialTransfer, transactions, + fromOfficeOptions, fromClientOptions, fromAccountTypeOptions, fromAccountOptions, toOfficeOptions, toClientOptions, + toAccountTypeOptions, toAccountOptions, transferTypeOptions, statusOptions, instructionTypeOptions, priorityOptions, + recurrenceTypeOptions, recurrenceFrequencyOptions); } public static StandingInstructionData withTemplateData(StandingInstructionData instructionData, StandingInstructionData templateData) { @@ -185,11 +188,12 @@ public static StandingInstructionData withTemplateData(StandingInstructionData i instructionData.transferType, instructionData.priority, instructionData.instructionType, instructionData.status, instructionData.amount, instructionData.validFrom, instructionData.validTill, instructionData.recurrenceType, instructionData.recurrenceFrequency, instructionData.recurrenceInterval, instructionData.recurrenceOnMonthDay, - instructionData.transactions, templateData.fromOfficeOptions, templateData.fromClientOptions, - templateData.fromAccountTypeOptions, templateData.fromAccountOptions, templateData.toOfficeOptions, - templateData.toClientOptions, templateData.toAccountTypeOptions, templateData.toAccountOptions, - templateData.transferTypeOptions, templateData.statusOptions, templateData.instructionTypeOptions, - templateData.priorityOptions, templateData.recurrenceTypeOptions, templateData.recurrenceFrequencyOptions); + instructionData.allowPartialTransfer, instructionData.transactions, templateData.fromOfficeOptions, + templateData.fromClientOptions, templateData.fromAccountTypeOptions, templateData.fromAccountOptions, + templateData.toOfficeOptions, templateData.toClientOptions, templateData.toAccountTypeOptions, + templateData.toAccountOptions, templateData.transferTypeOptions, templateData.statusOptions, + templateData.instructionTypeOptions, templateData.priorityOptions, templateData.recurrenceTypeOptions, + templateData.recurrenceFrequencyOptions); } private StandingInstructionData(final Long id, final Long accountDetailId, final String name, final OfficeData fromOffice, @@ -198,7 +202,7 @@ private StandingInstructionData(final Long id, final Long accountDetailId, final final EnumOptionData transferType, final EnumOptionData priority, final EnumOptionData instructionType, final EnumOptionData status, final BigDecimal amount, final LocalDate validFrom, LocalDate validTill, final EnumOptionData recurrenceType, final EnumOptionData recurrenceFrequency, final Integer recurrenceInterval, - final MonthDay recurrenceOnMonthDay, final Page transactions, + final MonthDay recurrenceOnMonthDay, final boolean allowPartialTransfer, final Page transactions, final Collection fromOfficeOptions, final Collection fromClientOptions, final Collection fromAccountTypeOptions, final Collection fromAccountOptions, final Collection toOfficeOptions, final Collection toClientOptions, @@ -229,6 +233,7 @@ private StandingInstructionData(final Long id, final Long accountDetailId, final this.recurrenceFrequency = recurrenceFrequency; this.recurrenceInterval = recurrenceInterval; this.recurrenceOnMonthDay = recurrenceOnMonthDay; + this.allowPartialTransfer = allowPartialTransfer; this.fromOfficeOptions = fromOfficeOptions; this.fromClientOptions = fromClientOptions; @@ -271,14 +276,15 @@ public static StandingInstructionData template(OfficeData fromOffice, ClientData final EnumOptionData recurrenceFrequency = null; final Integer recurrenceInterval = null; final MonthDay recurrenceOnMonthDay = null; + final boolean allowPartialTransfer = false; final Page transactions = null; return new StandingInstructionData(id, accountDetailId, name, fromOffice, fromClient, fromAccountType, fromAccount, toOffice, toClient, toAccountType, toAccount, transferType, priority, instructionType, status, amount, validFrom, validTill, - recurrenceType, recurrenceFrequency, recurrenceInterval, recurrenceOnMonthDay, transactions, fromOfficeOptions, - fromClientOptions, fromAccountTypeOptions, fromAccountOptions, toOfficeOptions, toClientOptions, toAccountTypeOptions, - toAccountOptions, transferTypeOptions, statusOptions, instructionTypeOptions, priorityOptions, recurrenceTypeOptions, - recurrenceFrequencyOptions); + recurrenceType, recurrenceFrequency, recurrenceInterval, recurrenceOnMonthDay, allowPartialTransfer, transactions, + fromOfficeOptions, fromClientOptions, fromAccountTypeOptions, fromAccountOptions, toOfficeOptions, toClientOptions, + toAccountTypeOptions, toAccountOptions, transferTypeOptions, statusOptions, instructionTypeOptions, priorityOptions, + recurrenceTypeOptions, recurrenceFrequencyOptions); } public static StandingInstructionData withTransferData(StandingInstructionData instructionData, @@ -288,12 +294,12 @@ public static StandingInstructionData withTransferData(StandingInstructionData i instructionData.toOffice, instructionData.toClient, instructionData.toAccountType, instructionData.toAccount, instructionData.transferType, instructionData.priority, instructionData.instructionType, instructionData.status, instructionData.amount, instructionData.validFrom, instructionData.validTill, instructionData.recurrenceType, - instructionData.recurrenceFrequency, instructionData.recurrenceInterval, instructionData.recurrenceOnMonthDay, transactions, - instructionData.fromOfficeOptions, instructionData.fromClientOptions, instructionData.fromAccountTypeOptions, - instructionData.fromAccountOptions, instructionData.toOfficeOptions, instructionData.toClientOptions, - instructionData.toAccountTypeOptions, instructionData.toAccountOptions, instructionData.transferTypeOptions, - instructionData.statusOptions, instructionData.instructionTypeOptions, instructionData.priorityOptions, - instructionData.recurrenceTypeOptions, instructionData.recurrenceFrequencyOptions); + instructionData.recurrenceFrequency, instructionData.recurrenceInterval, instructionData.recurrenceOnMonthDay, + instructionData.allowPartialTransfer, transactions, instructionData.fromOfficeOptions, instructionData.fromClientOptions, + instructionData.fromAccountTypeOptions, instructionData.fromAccountOptions, instructionData.toOfficeOptions, + instructionData.toClientOptions, instructionData.toAccountTypeOptions, instructionData.toAccountOptions, + instructionData.transferTypeOptions, instructionData.statusOptions, instructionData.instructionTypeOptions, + instructionData.priorityOptions, instructionData.recurrenceTypeOptions, instructionData.recurrenceFrequencyOptions); } // Domain enum helper for internal logic - renamed to avoid Jackson property conflict diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/account/data/StandingInstructionDataValidator.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/account/data/StandingInstructionDataValidator.java index 0169c4fb760..15f98062bc2 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/account/data/StandingInstructionDataValidator.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/account/data/StandingInstructionDataValidator.java @@ -68,7 +68,8 @@ public class StandingInstructionDataValidator { StandingInstructionApiConstants.validFromParamName, StandingInstructionApiConstants.validTillParamName, StandingInstructionApiConstants.recurrenceTypeParamName, StandingInstructionApiConstants.recurrenceFrequencyParamName, StandingInstructionApiConstants.recurrenceIntervalParamName, StandingInstructionApiConstants.recurrenceOnMonthDayParamName, - StandingInstructionApiConstants.nameParamName, StandingInstructionApiConstants.monthDayFormatParamName)); + StandingInstructionApiConstants.nameParamName, StandingInstructionApiConstants.monthDayFormatParamName, + StandingInstructionApiConstants.allowPartialTransferParamName)); private static final Set UPDATE_REQUEST_DATA_PARAMETERS = new HashSet<>(Arrays.asList(AccountDetailConstants.localeParamName, AccountDetailConstants.dateFormatParamName, StandingInstructionApiConstants.priorityParamName, @@ -76,7 +77,8 @@ public class StandingInstructionDataValidator { StandingInstructionApiConstants.amountParamName, StandingInstructionApiConstants.validFromParamName, StandingInstructionApiConstants.validTillParamName, StandingInstructionApiConstants.recurrenceTypeParamName, StandingInstructionApiConstants.recurrenceFrequencyParamName, StandingInstructionApiConstants.recurrenceIntervalParamName, - StandingInstructionApiConstants.recurrenceOnMonthDayParamName, StandingInstructionApiConstants.monthDayFormatParamName)); + StandingInstructionApiConstants.recurrenceOnMonthDayParamName, StandingInstructionApiConstants.monthDayFormatParamName, + StandingInstructionApiConstants.allowPartialTransferParamName)); @Autowired public StandingInstructionDataValidator(final FromJsonHelper fromApiJsonHelper, @@ -119,6 +121,11 @@ public void validateForCreate(final JsonCommand command) { .extractBigDecimalWithLocaleNamed(StandingInstructionApiConstants.amountParamName, element); baseDataValidator.reset().parameter(StandingInstructionApiConstants.amountParamName).value(transferAmount).positiveAmount(); + final Boolean allowPartialTransfer = this.fromApiJsonHelper + .extractBooleanNamed(StandingInstructionApiConstants.allowPartialTransferParamName, element); + baseDataValidator.reset().parameter(StandingInstructionApiConstants.allowPartialTransferParamName).value(allowPartialTransfer) + .ignoreIfNull().validateForBooleanValue(); + final Integer transferType = this.fromApiJsonHelper.extractIntegerNamed(transferTypeParamName, element, Locale.getDefault()); baseDataValidator.reset().parameter(transferTypeParamName).value(transferType).notNull().inMinMaxRange(1, 3); @@ -283,6 +290,13 @@ public void validateForUpdate(final JsonCommand command) { baseDataValidator.reset().parameter(StandingInstructionApiConstants.nameParamName).value(name).notNull(); } + if (this.fromApiJsonHelper.parameterExists(StandingInstructionApiConstants.allowPartialTransferParamName, element)) { + final Boolean allowPartialTransfer = this.fromApiJsonHelper + .extractBooleanNamed(StandingInstructionApiConstants.allowPartialTransferParamName, element); + baseDataValidator.reset().parameter(StandingInstructionApiConstants.allowPartialTransferParamName).value(allowPartialTransfer) + .notNull().validateForBooleanValue(); + } + throwExceptionIfValidationWarningsExist(dataValidationErrors); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/account/data/request/StandingInstructionCreationRequest.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/account/data/request/StandingInstructionCreationRequest.java index b917a8f890a..f94da399efa 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/account/data/request/StandingInstructionCreationRequest.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/account/data/request/StandingInstructionCreationRequest.java @@ -53,4 +53,5 @@ public class StandingInstructionCreationRequest implements Serializable { private String name; private String transferType; private String status; + private Boolean allowPartialTransfer; } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/account/data/request/StandingInstructionUpdatesRequest.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/account/data/request/StandingInstructionUpdatesRequest.java index 26d78ac7ead..e2b10ef6220 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/account/data/request/StandingInstructionUpdatesRequest.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/account/data/request/StandingInstructionUpdatesRequest.java @@ -44,4 +44,5 @@ public class StandingInstructionUpdatesRequest implements Serializable { private String name; private String monthDayFormat; private String status; + private Boolean allowPartialTransfer; } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/account/domain/AccountTransferStandingInstruction.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/account/domain/AccountTransferStandingInstruction.java index fb437308707..2625a94a36a 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/account/domain/AccountTransferStandingInstruction.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/account/domain/AccountTransferStandingInstruction.java @@ -20,6 +20,7 @@ import static org.apache.fineract.portfolio.account.AccountDetailConstants.transferTypeParamName; import static org.apache.fineract.portfolio.account.api.StandingInstructionApiConstants.STANDING_INSTRUCTION_RESOURCE_NAME; +import static org.apache.fineract.portfolio.account.api.StandingInstructionApiConstants.allowPartialTransferParamName; import static org.apache.fineract.portfolio.account.api.StandingInstructionApiConstants.amountParamName; import static org.apache.fineract.portfolio.account.api.StandingInstructionApiConstants.instructionTypeParamName; import static org.apache.fineract.portfolio.account.api.StandingInstructionApiConstants.priorityParamName; @@ -99,6 +100,9 @@ public class AccountTransferStandingInstruction extends AbstractPersistableCusto @Column(name = "last_run_date") private LocalDate latsRunDate; + @Column(name = "allow_partial_transfer", nullable = false) + private boolean allowPartialTransfer; + protected AccountTransferStandingInstruction() { } @@ -106,7 +110,7 @@ protected AccountTransferStandingInstruction() { public static AccountTransferStandingInstruction create(final AccountTransferDetails accountTransferDetails, final String name, final Integer priority, final Integer instructionType, final Integer status, final BigDecimal amount, final LocalDate validFrom, final LocalDate validTill, final Integer recurrenceType, final Integer recurrenceFrequency, final Integer recurrenceInterval, - final MonthDay recurrenceOnMonthDay) { + final MonthDay recurrenceOnMonthDay, final boolean allowPartialTransfer) { Integer recurrenceOnDay = null; Integer recurrenceOnMonth = null; if (recurrenceOnMonthDay != null) { @@ -114,13 +118,14 @@ public static AccountTransferStandingInstruction create(final AccountTransferDet recurrenceOnMonth = recurrenceOnMonthDay.getMonthValue(); } return new AccountTransferStandingInstruction(accountTransferDetails, name, priority, instructionType, status, amount, validFrom, - validTill, recurrenceType, recurrenceFrequency, recurrenceInterval, recurrenceOnDay, recurrenceOnMonth); + validTill, recurrenceType, recurrenceFrequency, recurrenceInterval, recurrenceOnDay, recurrenceOnMonth, + allowPartialTransfer); } private AccountTransferStandingInstruction(final AccountTransferDetails accountTransferDetails, final String name, final Integer priority, final Integer instructionType, final Integer status, final BigDecimal amount, final LocalDate validFrom, final LocalDate validTill, final Integer recurrenceType, final Integer recurrenceFrequency, final Integer recurrenceInterval, - final Integer recurrenceOnDay, final Integer recurrenceOnMonth) { + final Integer recurrenceOnDay, final Integer recurrenceOnMonth, final boolean allowPartialTransfer) { this.accountTransferDetails = accountTransferDetails; this.name = name; this.priority = priority; @@ -134,6 +139,7 @@ private AccountTransferStandingInstruction(final AccountTransferDetails accountT this.recurrenceInterval = recurrenceInterval; this.recurrenceOnDay = recurrenceOnDay; this.recurrenceOnMonth = recurrenceOnMonth; + this.allowPartialTransfer = allowPartialTransfer; final List dataValidationErrors = new ArrayList<>(); final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors) .resource(STANDING_INSTRUCTION_RESOURCE_NAME); @@ -222,6 +228,12 @@ public Map update(JsonCommand command) { actualChanges.put(recurrenceIntervalParamName, newValue); this.recurrenceInterval = newValue; } + + if (command.isChangeInBooleanParameterNamed(allowPartialTransferParamName, this.allowPartialTransfer)) { + final boolean newValue = command.booleanPrimitiveValueOfParameterNamed(allowPartialTransferParamName); + actualChanges.put(allowPartialTransferParamName, newValue); + this.allowPartialTransfer = newValue; + } validateDependencies(baseDataValidator); if (!dataValidationErrors.isEmpty()) { throw new PlatformApiDataValidationException(dataValidationErrors); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/account/domain/StandingInstructionAssembler.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/account/domain/StandingInstructionAssembler.java index 079bda7387b..57d98d2c5cb 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/account/domain/StandingInstructionAssembler.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/account/domain/StandingInstructionAssembler.java @@ -18,6 +18,7 @@ */ package org.apache.fineract.portfolio.account.domain; +import static org.apache.fineract.portfolio.account.api.StandingInstructionApiConstants.allowPartialTransferParamName; import static org.apache.fineract.portfolio.account.api.StandingInstructionApiConstants.amountParamName; import static org.apache.fineract.portfolio.account.api.StandingInstructionApiConstants.instructionTypeParamName; import static org.apache.fineract.portfolio.account.api.StandingInstructionApiConstants.nameParamName; @@ -72,9 +73,10 @@ public void assembleStandingInstruction(final JsonCommand command, final Account final MonthDay recurrenceOnMonthDay = command.extractMonthDayNamed(recurrenceOnMonthDayParamName); final Integer recurrenceInterval = command.integerValueOfParameterNamed(recurrenceIntervalParamName); final String name = command.stringValueOfParameterNamed(nameParamName); + final boolean allowPartialTransfer = command.booleanPrimitiveValueOfParameterNamed(allowPartialTransferParamName); AccountTransferStandingInstruction accountTransferStandingInstruction = AccountTransferStandingInstruction.create( accountTransferDetails, name, priority, standingInstructionType, status, amount, validFrom, validTill, recurrenceType, - recurrenceFrequency, recurrenceInterval, recurrenceOnMonthDay); + recurrenceFrequency, recurrenceInterval, recurrenceOnMonthDay, allowPartialTransfer); accountTransferDetails.updateAccountTransferStandingInstruction(accountTransferStandingInstruction); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/account/jobs/executestandinginstructions/ExecuteStandingInstructionsConfig.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/account/jobs/executestandinginstructions/ExecuteStandingInstructionsConfig.java index 69fecbdb4c4..b22e27c197d 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/account/jobs/executestandinginstructions/ExecuteStandingInstructionsConfig.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/account/jobs/executestandinginstructions/ExecuteStandingInstructionsConfig.java @@ -22,6 +22,7 @@ import org.apache.fineract.infrastructure.jobs.service.JobName; import org.apache.fineract.portfolio.account.service.AccountTransfersWritePlatformService; import org.apache.fineract.portfolio.account.service.StandingInstructionReadPlatformService; +import org.apache.fineract.portfolio.savings.domain.SavingsAccountAssembler; import org.springframework.batch.core.Job; import org.springframework.batch.core.Step; import org.springframework.batch.core.job.builder.JobBuilder; @@ -49,6 +50,8 @@ public class ExecuteStandingInstructionsConfig { private DatabaseSpecificSQLGenerator sqlGenerator; @Autowired private AccountTransfersWritePlatformService accountTransfersWritePlatformService; + @Autowired + private SavingsAccountAssembler savingsAccountAssembler; @Bean protected Step executeStandingInstructionsStep() { @@ -65,6 +68,6 @@ public Job executeStandingInstructionsJob() { @Bean public ExecuteStandingInstructionsTasklet executeStandingInstructionsTasklet() { return new ExecuteStandingInstructionsTasklet(standingInstructionReadPlatformService, jdbcTemplate, sqlGenerator, - accountTransfersWritePlatformService); + accountTransfersWritePlatformService, savingsAccountAssembler); } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/account/jobs/executestandinginstructions/ExecuteStandingInstructionsTasklet.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/account/jobs/executestandinginstructions/ExecuteStandingInstructionsTasklet.java index 357dc35c7b3..df971aee0f1 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/account/jobs/executestandinginstructions/ExecuteStandingInstructionsTasklet.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/account/jobs/executestandinginstructions/ExecuteStandingInstructionsTasklet.java @@ -29,6 +29,7 @@ import org.apache.fineract.infrastructure.core.exception.AbstractPlatformServiceUnavailableException; import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException; import org.apache.fineract.infrastructure.core.service.DateUtils; +import org.apache.fineract.infrastructure.core.service.MathUtil; import org.apache.fineract.infrastructure.core.service.database.DatabaseSpecificSQLGenerator; import org.apache.fineract.infrastructure.jobs.exception.JobExecutionException; import org.apache.fineract.portfolio.account.PortfolioAccountType; @@ -44,6 +45,7 @@ import org.apache.fineract.portfolio.loanaccount.loanschedule.domain.DefaultScheduledDateGenerator; import org.apache.fineract.portfolio.loanaccount.loanschedule.domain.ScheduledDateGenerator; import org.apache.fineract.portfolio.savings.domain.SavingsAccount; +import org.apache.fineract.portfolio.savings.domain.SavingsAccountAssembler; import org.apache.fineract.portfolio.savings.exception.InsufficientAccountBalanceException; import org.springframework.batch.core.StepContribution; import org.springframework.batch.core.scope.context.ChunkContext; @@ -59,6 +61,7 @@ public class ExecuteStandingInstructionsTasklet implements Tasklet { private final JdbcTemplate jdbcTemplate; private final DatabaseSpecificSQLGenerator sqlGenerator; private final AccountTransfersWritePlatformService accountTransfersWritePlatformService; + private final SavingsAccountAssembler savingsAccountAssembler; @Override public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) throws Exception { @@ -103,7 +106,27 @@ public RepeatStatus execute(StepContribution contribution, ChunkContext chunkCon } if (isDueForTransfer && transactionAmount != null && transactionAmount.compareTo(BigDecimal.ZERO) > 0) { - final SavingsAccount fromSavingsAccount = null; + SavingsAccount fromSavingsAccount = null; + boolean partialTransfer = false; + if (data.isAllowPartialTransfer() && PortfolioAccountType.SAVINGS.equals(data.getFromAccountTypeEnum())) { + // The account is loaded here rather than inside transferFunds, which would otherwise assemble it + // itself, so reading the available balance costs no additional fetch. + fromSavingsAccount = savingsAccountAssembler.assembleFrom(data.getFromAccount().getId(), false); + final BigDecimal withdrawableBalance = fromSavingsAccount.getWithdrawableBalance(); + if (!MathUtil.isGreaterThanZero(withdrawableBalance)) { + log.info("Skipping standing instruction {}: nothing available to transfer from savings account {}", data.getId(), + data.getFromAccount().getId()); + continue; + } + partialTransfer = MathUtil.isLessThan(withdrawableBalance, transactionAmount); + if (partialTransfer) { + log.info( + "Standing instruction {} transferring {} of the {} due, limited by the balance available on savings " + + "account {}", + data.getId(), withdrawableBalance, transactionAmount, data.getFromAccount().getId()); + transactionAmount = withdrawableBalance; + } + } final boolean isRegularTransaction = true; final boolean isExceptionForBalanceCheck = false; AccountTransferDTO accountTransferDTO = new AccountTransferDTO(transactionDate, transactionAmount, @@ -111,7 +134,7 @@ public RepeatStatus execute(StepContribution contribution, ChunkContext chunkCon data.getToAccount().getId(), data.getName() + " Standing instruction trasfer ", null, null, null, null, data.toTransferType(), null, null, data.getTransferTypeEnum().getValue(), null, null, ExternalId.empty(), null, null, fromSavingsAccount, isRegularTransaction, isExceptionForBalanceCheck); - final boolean transferCompleted = transferAmount(errors, accountTransferDTO, data.getId()); + final boolean transferCompleted = transferAmount(errors, accountTransferDTO, data.getId(), partialTransfer); if (transferCompleted) { final String updateQuery = "UPDATE m_account_transfer_standing_instructions SET last_run_date = ? where id = ?"; @@ -126,7 +149,8 @@ public RepeatStatus execute(StepContribution contribution, ChunkContext chunkCon return RepeatStatus.FINISHED; } - private boolean transferAmount(final List errors, final AccountTransferDTO accountTransferDTO, final Long instructionId) { + private boolean transferAmount(final List errors, final AccountTransferDTO accountTransferDTO, final Long instructionId, + final boolean partialTransfer) { boolean transferCompleted = true; StringBuilder errorLog = new StringBuilder(); StringBuilder updateQuery = new StringBuilder( @@ -156,6 +180,8 @@ private boolean transferAmount(final List errors, final AccountTransf if (errorLog.length() > 0) { transferCompleted = false; updateQuery.append("'failed'").append(","); + } else if (partialTransfer) { + updateQuery.append("'partial'").append(","); } else { updateQuery.append("'success'").append(","); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/account/service/StandingInstructionReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/account/service/StandingInstructionReadPlatformServiceImpl.java index 9a947c029ca..2864c34897c 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/account/service/StandingInstructionReadPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/account/service/StandingInstructionReadPlatformServiceImpl.java @@ -375,6 +375,7 @@ private static final class StandingInstructionMapper implements RowMapper + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0254_add_allow_partial_transfer_to_standing_instruction.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0254_add_allow_partial_transfer_to_standing_instruction.xml new file mode 100644 index 00000000000..744f9406b0e --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0254_add_allow_partial_transfer_to_standing_instruction.xml @@ -0,0 +1,33 @@ + + + + + Allow a standing instruction to transfer the available balance when it cannot cover the full amount due. + + + + + + + diff --git a/fineract-provider/src/main/resources/static/legacy-docs/apiLive.htm b/fineract-provider/src/main/resources/static/legacy-docs/apiLive.htm index 014777dc54e..1b97dd50b36 100644 --- a/fineract-provider/src/main/resources/static/legacy-docs/apiLive.htm +++ b/fineract-provider/src/main/resources/static/legacy-docs/apiLive.htm @@ -35754,7 +35754,8 @@

Create new Standing Instruction

"locale":"en", "dateFormat":"dd MMMM yyyy", "recurrenceOnMonthDay":"02 April", - "monthDayFormat":"dd MMMM" + "monthDayFormat":"dd MMMM", + "allowPartialTransfer":false } @@ -35779,14 +35780,16 @@

Update Standing Instruction

Content-Type: application/json No Request Body: { - "recurrenceInterval":"2" + "recurrenceInterval":"2", + "allowPartialTransfer":true }
{ "resourceId":20, "changes":{ - "recurrenceInterval":2 + "recurrenceInterval":2, + "allowPartialTransfer":true } } diff --git a/fineract-provider/src/test/java/org/apache/fineract/portfolio/account/data/StandingInstructionDataSerializationTest.java b/fineract-provider/src/test/java/org/apache/fineract/portfolio/account/data/StandingInstructionDataSerializationTest.java index 4d3cf14e9e6..68a618732dc 100644 --- a/fineract-provider/src/test/java/org/apache/fineract/portfolio/account/data/StandingInstructionDataSerializationTest.java +++ b/fineract-provider/src/test/java/org/apache/fineract/portfolio/account/data/StandingInstructionDataSerializationTest.java @@ -81,7 +81,8 @@ public void testStatusFieldSerialization() throws Exception { null, // recurrenceType null, // recurrenceFrequency null, // recurrenceInterval - null // recurrenceOnMonthDay + null, // recurrenceOnMonthDay + false // allowPartialTransfer ); // Serialize to JSON @@ -179,7 +180,8 @@ private StandingInstructionData createStandingInstructionWithStatus(EnumOptionDa null, // recurrenceType null, // recurrenceFrequency null, // recurrenceInterval - null // recurrenceOnMonthDay + null, // recurrenceOnMonthDay + false // allowPartialTransfer ); } } diff --git a/fineract-provider/src/test/java/org/apache/fineract/portfolio/account/jobs/executestandinginstructions/ExecuteOverdueAndCurrentStandingInstructionsTest.java b/fineract-provider/src/test/java/org/apache/fineract/portfolio/account/jobs/executestandinginstructions/ExecuteOverdueAndCurrentStandingInstructionsTest.java index 8b730538ae4..cdd870a25d8 100644 --- a/fineract-provider/src/test/java/org/apache/fineract/portfolio/account/jobs/executestandinginstructions/ExecuteOverdueAndCurrentStandingInstructionsTest.java +++ b/fineract-provider/src/test/java/org/apache/fineract/portfolio/account/jobs/executestandinginstructions/ExecuteOverdueAndCurrentStandingInstructionsTest.java @@ -19,27 +19,76 @@ package org.apache.fineract.portfolio.account.jobs.executestandinginstructions; import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; import java.math.BigDecimal; import java.time.Clock; import java.time.LocalDate; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.apache.fineract.infrastructure.businessdate.domain.BusinessDateType; +import org.apache.fineract.infrastructure.core.data.EnumOptionData; import org.apache.fineract.infrastructure.core.domain.ActionContext; import org.apache.fineract.infrastructure.core.domain.FineractPlatformTenant; import org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil; +import org.apache.fineract.infrastructure.core.service.database.DatabaseSpecificSQLGenerator; +import org.apache.fineract.infrastructure.jobs.exception.JobExecutionException; +import org.apache.fineract.portfolio.account.PortfolioAccountType; +import org.apache.fineract.portfolio.account.data.AccountTransferDTO; +import org.apache.fineract.portfolio.account.data.PortfolioAccountData; +import org.apache.fineract.portfolio.account.data.StandingInstructionData; import org.apache.fineract.portfolio.account.data.StandingInstructionDuesData; +import org.apache.fineract.portfolio.account.domain.AccountTransferRecurrenceType; +import org.apache.fineract.portfolio.account.domain.AccountTransferType; +import org.apache.fineract.portfolio.account.domain.StandingInstructionStatus; +import org.apache.fineract.portfolio.account.domain.StandingInstructionType; +import org.apache.fineract.portfolio.account.service.AccountTransfersWritePlatformService; +import org.apache.fineract.portfolio.account.service.StandingInstructionReadPlatformService; +import org.apache.fineract.portfolio.savings.domain.SavingsAccount; +import org.apache.fineract.portfolio.savings.domain.SavingsAccountAssembler; +import org.apache.fineract.portfolio.savings.exception.InsufficientAccountBalanceException; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.batch.core.StepContribution; +import org.springframework.batch.core.scope.context.ChunkContext; +import org.springframework.jdbc.core.JdbcTemplate; public class ExecuteOverdueAndCurrentStandingInstructionsTest { + private static final Long INSTRUCTION_ID = 1L; + private static final Long FROM_SAVINGS_ACCOUNT_ID = 11L; + private static final Long TO_LOAN_ACCOUNT_ID = 22L; + private static final BigDecimal INSTALLMENT_DUE = BigDecimal.valueOf(40000); + private static final BigDecimal JUST_UNDER_THE_INSTALLMENT = BigDecimal.valueOf(39900); + private static final BigDecimal MORE_THAN_THE_INSTALLMENT = BigDecimal.valueOf(50000); + private final LocalDate currentDate = LocalDate.now(Clock.systemUTC()); private final LocalDate previousDate = currentDate.minusDays(2); + private final StandingInstructionReadPlatformService standingInstructionReadPlatformService = mock( + StandingInstructionReadPlatformService.class); + private final JdbcTemplate jdbcTemplate = mock(JdbcTemplate.class); + private final DatabaseSpecificSQLGenerator sqlGenerator = mock(DatabaseSpecificSQLGenerator.class); + private final AccountTransfersWritePlatformService accountTransfersWritePlatformService = mock( + AccountTransfersWritePlatformService.class); + private final SavingsAccountAssembler savingsAccountAssembler = mock(SavingsAccountAssembler.class); + @BeforeEach public void setUp() { ThreadLocalContextUtil.setTenant(new FineractPlatformTenant(1L, "default", "Default", "Africa/Kampala", null)); ThreadLocalContextUtil.setActionContext(ActionContext.DEFAULT); + ThreadLocalContextUtil.setBusinessDates(new HashMap<>(Map.of(BusinessDateType.BUSINESS_DATE, currentDate))); } @AfterEach @@ -49,15 +98,119 @@ public void tearDown() { @Test public void testAcceptPreviousDateAsDue() { - ExecuteStandingInstructionsTasklet tasklet = new ExecuteStandingInstructionsTasklet(null, null, null, null); - boolean isDueForTransfer = tasklet.isDueForTransfer(new StandingInstructionDuesData(previousDate, BigDecimal.ONE)); + boolean isDueForTransfer = tasklet().isDueForTransfer(new StandingInstructionDuesData(previousDate, BigDecimal.ONE)); assertThat(isDueForTransfer).isTrue().describedAs("Earlier instructions are accepted as due"); } @Test public void testAcceptCurrentDateAsDue() { - ExecuteStandingInstructionsTasklet tasklet = new ExecuteStandingInstructionsTasklet(null, null, null, null); - boolean isDueForTransfer = tasklet.isDueForTransfer(new StandingInstructionDuesData(currentDate, BigDecimal.ONE)); + boolean isDueForTransfer = tasklet().isDueForTransfer(new StandingInstructionDuesData(currentDate, BigDecimal.ONE)); assertThat(isDueForTransfer).isTrue().describedAs("Current day instructions are accepted as due"); } + + /** + * Without the opt-in, an instruction that cannot be covered in full still attempts the whole amount, is rejected + * outright, and collects nothing. This is the behaviour FINERACT-2400 leaves untouched by default. + */ + @Test + public void insufficientBalanceAttemptsFullAmountAndCollectsNothing() { + givenAnInstructionDueToday(false); + doThrow(new InsufficientAccountBalanceException("transactionAmount", JUST_UNDER_THE_INSTALLMENT, null, INSTALLMENT_DUE)) + .when(accountTransfersWritePlatformService).transferFunds(any(AccountTransferDTO.class)); + + assertThrows(JobExecutionException.class, this::runTheJob); + + assertThat(transferredAmount()).isEqualByComparingTo(INSTALLMENT_DUE) + .describedAs("the whole installment is attempted even though only part of it is available"); + assertThat(historyRow()).contains("'failed'"); + verifyNoInteractions(savingsAccountAssembler); + } + + @Test + public void partialTransferIsLimitedToTheAvailableBalance() throws Exception { + givenAnInstructionDueToday(true); + givenAWithdrawableBalanceOf(JUST_UNDER_THE_INSTALLMENT); + + runTheJob(); + + assertThat(transferredAmount()).isEqualByComparingTo(JUST_UNDER_THE_INSTALLMENT) + .describedAs("only what the savings account can release is transferred"); + assertThat(historyRow()).contains("'partial'").describedAs("a partly covered installment is distinguishable from a full one"); + } + + @Test + public void partialTransferIsSkippedWhenNothingIsAvailable() throws Exception { + givenAnInstructionDueToday(true); + givenAWithdrawableBalanceOf(BigDecimal.ZERO); + + runTheJob(); + + verifyNoInteractions(accountTransfersWritePlatformService); + verifyNoInteractions(jdbcTemplate); + } + + @Test + public void fullAmountIsTransferredWhenTheBalanceCoversIt() throws Exception { + givenAnInstructionDueToday(true); + givenAWithdrawableBalanceOf(MORE_THAN_THE_INSTALLMENT); + + runTheJob(); + + assertThat(transferredAmount()).isEqualByComparingTo(INSTALLMENT_DUE); + assertThat(historyRow()).contains("'success'").describedAs("opting in does not turn a fully covered run into a partial one"); + } + + private void runTheJob() throws Exception { + tasklet().execute(mock(StepContribution.class), mock(ChunkContext.class)); + } + + private ExecuteStandingInstructionsTasklet tasklet() { + return new ExecuteStandingInstructionsTasklet(standingInstructionReadPlatformService, jdbcTemplate, sqlGenerator, + accountTransfersWritePlatformService, savingsAccountAssembler); + } + + private void givenAnInstructionDueToday(final boolean allowPartialTransfer) { + when(standingInstructionReadPlatformService.retrieveAll(StandingInstructionStatus.ACTIVE.getValue())) + .thenReturn(List.of(duesBasedLoanRepayment(allowPartialTransfer))); + when(standingInstructionReadPlatformService.retriveLoanDuesData(TO_LOAN_ACCOUNT_ID)) + .thenReturn(new StandingInstructionDuesData(currentDate, INSTALLMENT_DUE)); + when(sqlGenerator.escape(anyString())).thenAnswer(invocation -> invocation.getArgument(0)); + } + + private void givenAWithdrawableBalanceOf(final BigDecimal withdrawableBalance) { + final SavingsAccount fromSavingsAccount = mock(SavingsAccount.class); + when(fromSavingsAccount.getWithdrawableBalance()).thenReturn(withdrawableBalance); + when(savingsAccountAssembler.assembleFrom(eq(FROM_SAVINGS_ACCOUNT_ID), eq(false))).thenReturn(fromSavingsAccount); + } + + private BigDecimal transferredAmount() { + ArgumentCaptor transfer = ArgumentCaptor.forClass(AccountTransferDTO.class); + verify(accountTransfersWritePlatformService).transferFunds(transfer.capture()); + return transfer.getValue().getTransactionAmount(); + } + + private String historyRow() { + ArgumentCaptor insert = ArgumentCaptor.forClass(String.class); + verify(jdbcTemplate).update(insert.capture()); + return insert.getValue(); + } + + /** A dues-based standing instruction repaying a loan from a savings account, active and in date. */ + private StandingInstructionData duesBasedLoanRepayment(final boolean allowPartialTransfer) { + final PortfolioAccountData fromAccount = new PortfolioAccountData(FROM_SAVINGS_ACCOUNT_ID, "000000011", null, null, null, null, + null, null, null, null, null, null); + final PortfolioAccountData toAccount = new PortfolioAccountData(TO_LOAN_ACCOUNT_ID, "000000022", null, null, null, null, null, null, + null, null, null, null); + final BigDecimal noFixedAmount = null; + + return StandingInstructionData.instance(INSTRUCTION_ID, 1L, "Loan repayment from savings", null, null, null, null, + enumOf(PortfolioAccountType.SAVINGS.getValue()), fromAccount, enumOf(PortfolioAccountType.LOAN.getValue()), toAccount, + enumOf(AccountTransferType.LOAN_REPAYMENT.getValue()), null, enumOf(StandingInstructionType.DUES.getValue()), + enumOf(StandingInstructionStatus.ACTIVE.getValue()), noFixedAmount, currentDate.minusMonths(1), null, + enumOf(AccountTransferRecurrenceType.AS_PER_DUES.getValue()), null, null, null, allowPartialTransfer); + } + + private EnumOptionData enumOf(final Integer value) { + return new EnumOptionData(value.longValue(), null, null); + } } diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/SchedulerJobsTestResults.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/SchedulerJobsTestResults.java index 75255f766c2..b4566812732 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/SchedulerJobsTestResults.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/SchedulerJobsTestResults.java @@ -764,6 +764,77 @@ public void testExecuteStandingInstructionsJobOutcome() throws InterruptedExcept "Verifying transferred amount and logged transaction amounts"); } + @Test + public void testExecuteStandingInstructionsJobTransfersAvailableBalanceWhenPartialTransferIsAllowed() throws InterruptedException { + savingsAccountHelper = new SavingsAccountHelper(requestSpec, responseSpec); + StandingInstructionsHelper standingInstructionsHelper = new StandingInstructionsHelper(requestSpec, responseSpec); + + final DateTimeFormatter dateFormat = DateTimeFormatter.ofPattern("dd MMMM yyyy", Locale.US); + final DateTimeFormatter monthDayFormat = DateTimeFormatter.ofPattern("dd MMMM", Locale.US); + + final LocalDate localDate = LocalDate.now(this.systemTimeZone.toZoneId()); + ZonedDateTime currentDate = ZonedDateTime.of(localDate, LocalTime.MIDNIGHT, this.systemTimeZone.toZoneId()); + final String MONTH_DAY = monthDayFormat.format(currentDate.toLocalDate()); + currentDate = currentDate.minus(Duration.ofDays(7)); + final String VALID_FROM = dateFormat.format(currentDate); + currentDate = currentDate.plus(1, ChronoUnit.YEARS); + final String VALID_TO = dateFormat.format(currentDate); + + final Integer clientID = ClientHelper.createClient(requestSpec, responseSpec); + Assertions.assertNotNull(clientID); + + final Integer savingsProductID = createSavingsProduct(requestSpec, responseSpec, + ClientSavingsIntegrationTest.MINIMUM_OPENING_BALANCE); + Assertions.assertNotNull(savingsProductID); + + final Integer fromSavingsId = createActiveSavingsAccount(clientID, savingsProductID); + final Integer toSavingsId = createActiveSavingsAccount(clientID, savingsProductID); + + HashMap fromSavingsSummaryBefore = this.savingsAccountHelper.getSavingsSummary(fromSavingsId); + Float fromSavingsBalanceBefore = (Float) fromSavingsSummaryBefore.get("accountBalance"); + HashMap toSavingsSummaryBefore = this.savingsAccountHelper.getSavingsSummary(toSavingsId); + Float toSavingsBalanceBefore = (Float) toSavingsSummaryBefore.get("accountBalance"); + + // ask for more than the account holds, so the transfer can only be covered in part + final String amountBeyondTheBalance = String.valueOf(fromSavingsBalanceBefore.intValue() * 2); + Integer standingInstructionId = standingInstructionsHelper.createStandingInstruction(clientID.toString(), fromSavingsId.toString(), + toSavingsId.toString(), FROM_ACCOUNT_TYPE_SAVINGS, TO_ACCOUNT_TYPE_SAVINGS, VALID_FROM, VALID_TO, MONTH_DAY, + amountBeyondTheBalance, true); + Assertions.assertNotNull(standingInstructionId); + + SchedulerJobHelper.executeAndAwaitJob("Execute Standing Instruction"); + + HashMap fromSavingsSummaryAfter = this.savingsAccountHelper.getSavingsSummary(fromSavingsId); + Float fromSavingsBalanceAfter = (Float) fromSavingsSummaryAfter.get("accountBalance"); + HashMap toSavingsSummaryAfter = this.savingsAccountHelper.getSavingsSummary(toSavingsId); + Float toSavingsBalanceAfter = (Float) toSavingsSummaryAfter.get("accountBalance"); + + Assertions.assertEquals(0.0f, fromSavingsBalanceAfter, "Verifying the whole available balance was transferred"); + Assertions.assertEquals(toSavingsBalanceBefore + fromSavingsBalanceBefore, toSavingsBalanceAfter, + "Verifying the receiving account was credited with the available balance"); + + Set standingInstructionHistoryData = standingInstructionsHelper + .getStandingInstructionHistory(fromSavingsId, PortfolioAccountType.SAVINGS.getValue(), clientID, + AccountTransferType.ACCOUNT_TRANSFER.getValue()); + Assertions.assertEquals(1, standingInstructionHistoryData.size(), + "Verifying the no of standing instruction transactions logged for the client"); + GetStandingInstructionHistoryPageItemsResponse loggedTransaction = standingInstructionHistoryData.iterator().next(); + Assertions.assertEquals("partial", loggedTransaction.getStatus(), + "Verifying a partly covered transfer is distinguishable from a fully covered one"); + Assertions.assertEquals(fromSavingsBalanceBefore, loggedTransaction.getAmount(), + "Verifying the logged amount is what was actually transferred, not what was due"); + } + + private Integer createActiveSavingsAccount(final Integer clientID, final Integer savingsProductID) { + final Integer savingsId = this.savingsAccountHelper.applyForSavingsApplication(clientID, savingsProductID, + ClientSavingsIntegrationTest.ACCOUNT_TYPE_INDIVIDUAL); + Assertions.assertNotNull(savingsId); + SavingsStatusChecker.verifySavingsIsPending(SavingsStatusChecker.getStatusOfSavings(requestSpec, responseSpec, savingsId)); + SavingsStatusChecker.verifySavingsIsApproved(this.savingsAccountHelper.approveSavings(savingsId)); + SavingsStatusChecker.verifySavingsIsActive(this.savingsAccountHelper.activateSavings(savingsId)); + return savingsId; + } + @Test public void testApplyPenaltyForOverdueLoansJobOutcome() throws InterruptedException { this.savingsAccountHelper = new SavingsAccountHelper(requestSpec, responseSpec); diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/StandingInstructionsHelper.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/StandingInstructionsHelper.java index 4571641c3d7..76e946a590d 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/StandingInstructionsHelper.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/StandingInstructionsHelper.java @@ -64,6 +64,20 @@ public Integer createStandingInstruction(final String clientId, final String fro return response.getResourceId(); } + /** + * Creates a standing instruction for a fixed amount that may exceed the balance available on the from account, + * optionally letting the job transfer whatever is available instead of nothing. + */ + public Integer createStandingInstruction(final String clientId, final String fromAccountId, final String toAccountId, + final String fromAccountType, final String toAccountType, final String validFrom, final String validTo, final String monthDay, + final String amount, final Boolean allowPartialTransfer) { + PostStandingInstructionsResponse response = ok( + () -> FineractFeignClientHelper.getFineractFeignClient().standingInstructions().createStandingInstruction( + build(clientId, fromAccountId, toAccountId, fromAccountType, toAccountType, validFrom, validTo, monthDay) + .amount(amount).allowPartialTransfer(allowPartialTransfer))); + return response.getResourceId(); + } + public GetStandingInstructionsStandingInstructionIdResponse getStandingInstructionById(final Long standingInstructionId) { return ok(() -> FineractFeignClientHelper.getFineractFeignClient().standingInstructions() .retrieveOneStandingInstruction(standingInstructionId, null, null, null, null, null));